openvibers/openvibe.run README.md
README.md
| 1 | # OpenVibe.Run |
| 2 | |
| 3 | The job service of the network (openvibe.run). Design: OpenVibe.Contracts docs/adr/ADR-034-platform-north-star.md; |
| 4 | plan track T14. |
| 5 | |
| 6 | **Live site:** https://openvibe.run · **API docs:** https://openvibe.services · **For AI:** https://openvibe.run/llms.txt |
| 7 | **Run it:** `npm ci && npm run dev` (Node 22 or newer) · **Tests:** `npm test` |
| 8 | |
| 9 | ## Purpose |
| 10 | |
| 11 | OpenVibe.Run accepts jobs, places each one on a node that advertises the runtime class it needs, and follows it to |
| 12 | its end. A job belongs to a **project**, is billed per wall-clock second it runs, and is visible only to the token |
| 13 | that project presents. Run owns the API, the project scope and the placement; OpenVibe.Bot owns the node link and the |
| 14 | per-second metering of the jobs it carries (plan T14 L1). The one exception is the external managed-browser provider |
| 15 | (plan T14 R4a): Run meters its jobs itself and posts their readings with a Billing token of its own, used for nothing |
| 16 | else. |
| 17 | |
| 18 | This repository is at plan **T14 steps 5 (R1b), 6 (R1c) and 7 (R3)**: the API is complete, and a queued job is |
| 19 | placed from the Fabric's offers and actually runs — the poller in `server/jobs/poller.js` (started by |
| 20 | `server/index.js`) sends it to OpenVibe.Bot, mirrors Bot's state and the node's stdout back, and settles it. Without |
| 21 | `OV_OAUTH_CLIENT_SECRET` Run cannot mint the Network token Bot requires, so the bridge is off and every job stays |
| 22 | `queued`. |
| 23 | |
| 24 | ## How a job runs |
| 25 | |
| 26 | 1. **Submit** — `POST /api/v1/jobs` (capability `run.job.submit`). Run validates the request against |
| 27 | `run.job-create-request@1`, mints the `job_<ULID>` id, stores the row `queued` and writes `run.job.queued` in the |
| 28 | same transaction. Nothing leaves the process here: a submit never waits on Network or Bot. |
| 29 | 2. **Place** — the poller (`RUN_POLL_MS`, default 1000 ms) reads the Fabric's offers for the job's class |
| 30 | (`GET {OV_NETWORK_URL}/api/v1/offers?kind=node`), and `openvibe-sdk/placement`'s `plan()` chooses the node the |
| 31 | job's own `requirements` allow. Run records the node, provider and region on its row (`placed_at` included). |
| 32 | No eligible offer: the job waits queued (a node may be briefly offline) and every tick tries again; if its |
| 33 | `ttl_ms` runs out first it ends `expired` with `run.job.unplaceable` (ADR-036 section 2). |
| 34 | 3. **Send** — Run hands the `platform.job@1` body to Bot (`POST {RUN_BOT_URL}/api/v1/jobs`, capability |
| 35 | `bot.job.dispatch`, audience `openvibe.bot`) and Bot gives it to the node over its device link. Bot's dispatch is |
| 36 | idempotent by job id, so a retry after a crash or a timeout is safe. |
| 37 | 4. **Mirror** — every tick mirrors each `placed` or `running` job from `GET {RUN_BOT_URL}/api/v1/jobs/{id}`: state, |
| 38 | timings, exit, result and the metered wall clock (Bot is the meter; Run never posts a usage record for a job Bot carries). The state |
| 39 | change and its `run.job.started|succeeded|failed|cancelled|expired` event are one transaction, and the job's |
| 40 | stdout is carried into its SSE stream as `output` events. A job Bot answers 404 for after Run sent it fails |
| 41 | `run.job.worker_failed` after `RUN_DISPATCH_UNKNOWN_POLLS` polls — it is never mirrored forever. |
| 42 | 5. **End** — `succeeded`, `failed`, `cancelled` or `expired`, from Bot's `job_exit` (or from the cancel request and |
| 43 | the ttl sweep). An end state never changes. Bot's answer is never trusted over Run's own row: it can neither |
| 44 | rename the node nor the project of a job Run placed. |
| 45 | |
| 46 | ## Owns |
| 47 | |
| 48 | - The public site at openvibe.run (`server/web/home.js`, `server/app.js`): the front page in the OpenVibe Frame (what a |
| 49 | job is and how it runs, the API in brief), the legal pages (`openvibe-shared/legal`), `robots.txt`, the sitemap, |
| 50 | `/llms.txt` and `/llms-full.txt`, IndexNow (only with `INDEXNOW_KEY`: the key file and one ping at boot), the icon and the web manifest. It says only what works today; `test/site.test.js` checks it. |
| 51 | - The job API (`server/api/v1.js`, one capability per route — the table below) and the job's lifecycle record |
| 52 | (`run_jobs`, `migrations/0001_jobs.sql`): state, placement, timings, exit, result and the metered wall clock. |
| 53 | - The project scope: a job's `project_id` and `requester` come from the caller's Network token |
| 54 | (`claims.project_id`, `claims.sub`) and never from a request body; another project's job is `404`, never `403`. |
| 55 | - Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (`X-OV-Subject`) may |
| 56 | name a project with `X-OV-Project`. Run asks Network whether the person is an admin or owner of the unarchived |
| 57 | project (`network.project.member.read`, a yes cached 60 s), then the person is the requester and the service is |
| 58 | stored as `via` (`migrations/0002_job_via.sql`). The service submits, reads, cancels and gets a stream ticket for only |
| 59 | the jobs it started for that person (another person's or another service's job is `404`); list and a delegated Bearer |
| 60 | stream are `403 project.not_delegated`, and so is any other caller naming a project. Network unreachable is |
| 61 | `503 project.check_unavailable`. Watch's `run-job` action and OpenVibe.Codes' coding jobs use it. |
| 62 | - The `job_<ULID>` id (minted here, passed unchanged to Bot as `platform.job@1` `id`, so every metering reading of |
| 63 | the job is `run:<id>:<n>`). |
| 64 | - Idempotent submit: one row per `(project_id, idempotency_key)`, a repeat answering the first job, a different body |
| 65 | with the same key refused `409 run.idempotency.conflict`. |
| 66 | - The `run.job.*` events (`server/jobs/events.js`, `run_events_outbox`), written in the same transaction as the |
| 67 | state change they describe. |
| 68 | - The job stream (`server/jobs/stream.js`): one ring buffer per open job, the SSE `id` being the job's own monotonic |
| 69 | `seq`, plus the two-minute single-use stream ticket (`server/jobs/ticket.js`). |
| 70 | - The `platform.workload-requirements@1` default (`{ kind: run.<class>, mobility: job, latency_class: background, |
| 71 | objective: balanced }`, the contract's own default) and the project caps on a job's limits. |
| 72 | - Placement (plan T14 R3, `server/dispatch/placement.js`): the job's requirements are stated by Run and the choice |
| 73 | among offers is `openvibe-sdk/placement` `plan()` — Run writes no placer of its own (ADR-036). The job's |
| 74 | `worker:<class>` capability is what a candidate must advertise. A user-owned offer is a person's own machine |
| 75 | (contracts 0.161.0): it is eligible only for that person's own job (requester `user:usr_…` equal to the offer's |
| 76 | `owner`) whose requirements name `user-owned`. Network keeps such offers off the public list, so Run reads them, |
| 77 | owners included, from `GET /internal/resources?kind=node&trust=user-owned` with its own client |
| 78 | (`network.registry.read`); a user-owned offer on the public list is never used. |
| 79 | - The poller (`server/jobs/poller.js`): the ttl sweep, the send, the mirror, the cancel-on-Bot and the job's stdout |
| 80 | into the stream. |
| 81 | |
| 82 | ## Does not own |
| 83 | |
| 84 | - The node link, the device credentials and the per-second metering: OpenVibe.Bot (`bot.job.dispatch`), which gives |
| 85 | the job to a node advertising `worker:<class>` and meters every wall-clock second into `platform.usage-sample@1` |
| 86 | readings keyed `run:<job id>:<n>`. |
| 87 | - Identity, tokens and projects (OpenVibe.Network); Run only verifies the tokens it is the audience of. |
| 88 | - The artifacts a job runs and the media its inputs come from (OpenVibe.Media; Run never fetches a caller-chosen |
| 89 | URL). |
| 90 | - Money for the jobs Bot carries: Bot writes their usage records. Run writes only the external provider's |
| 91 | (`billing.usage.record`, clamped to Run's own observed duration + 5 s). |
| 92 | |
| 93 | ## Depends on |
| 94 | |
| 95 | - PostgreSQL 18: `run_jobs` and the `run_events_outbox`; `migrations/` applies on boot. |
| 96 | - Valkey (`VALKEY_URL`, optional): shared state between processes; unset, nothing is shared. |
| 97 | - OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from |
| 98 | (`GET /api/v1/offers`, public); Run's client `run` mints the events relay's service token, its Bot token and the |
| 99 | token for project memberships (`GET /internal/projects/:project/members/:subject`, project delegation). |
| 100 | - OpenVibe.Events: `run.job.*` events go to `events.event.publish`; unset, they wait in `run_events_outbox`. |
| 101 | - OpenVibe.Bench (`RUN_BENCH_URL`, default `https://bench.openvibe.services`): a race's judgement is recorded as a |
| 102 | `bench.match@1` on `POST /api/v1/matches` (`bench.judgement.cast`, audience `openvibe.bench`). Bench has no |
| 103 | matchmaker endpoint yet; `matchmaker: true` falls back to `RUN_RACE_ENTRANTS`. |
| 104 | - OpenVibe.Bot (`RUN_BOT_URL`, `RUN_POLL_MS`): the node link and the meter. Run sends each job to it |
| 105 | (`bot.job.dispatch`, audience `openvibe.bot`, minted from `OV_OAUTH_CLIENT_ID`/`OV_OAUTH_CLIENT_SECRET` against |
| 106 | `OV_NETWORK_INTERNAL_URL` and cached until 60 s before expiry) and mirrors its answer. Unset the client secret and |
| 107 | the bridge is off: `/api/ready` skips the `bot` check and every job stays `queued`. |
| 108 | - `openvibe-contracts` v0.106.0, `openvibe-sdk` v0.74.0 and `openvibe-shared` v4.179.0 (package.json). |
| 109 | |
| 110 | ## Capabilities and routes |
| 111 | |
| 112 | Every route checks exactly the capability its manifest names (`contracts/manifests/capabilities/run.job.*.json`), |
| 113 | from the caller's service token. A Network user token names no project and holds no capability here, so it is |
| 114 | refused like any other token without the grant. |
| 115 | |
| 116 | | Method and path | Capability | Request → answer | |
| 117 | |---------------------------------|------------------|--------------------------------------------------------| |
| 118 | | `POST /api/v1/jobs` | `run.job.submit` | `run.job-create-request@1` → `run.job-create-result@1` (201 created; 200, `created:false` on an idempotent repeat) | |
| 119 | | `GET /api/v1/jobs/:id` | `run.job.read` | `common.no-body@1` → `run.job-read-result@1` | |
| 120 | | `GET /api/v1/jobs?state=&limit=&cursor=` | `run.job.list` | `run.job-list-query@1` → `run.job-list-result@1` | |
| 121 | | `POST /api/v1/jobs/:id/cancel` | `run.job.cancel` | `common.no-body@1` → `run.job-read-result@1` | |
| 122 | | `POST /api/v1/jobs/:id/stream/ticket` | `run.job.stream` | `common.no-body@1` → `run.job-stream-ticket-result@1` | |
| 123 | | `GET /api/v1/jobs/:id/stream?ticket=` | `run.job.stream` | a ticket in the query, **or** a Bearer service token with the grant → `text/event-stream` of `run.job-stream-event@1` | |
| 124 | | `GET /api/v1/admin/jobs` | `run.job.admin` | `run.job-list-query@1` (with `project_id`) → `run.job-list-result@1` | |
| 125 | |
| 126 | `run.job.admin` additionally reads and cancels any project's job and is the only way to pass `project_id` to a list |
| 127 | (the manifest says so); it is internal, so only a first-party `svc:` token may hold it — an app, mod or agent token |
| 128 | with the grant is refused. Every request body and query string is validated against its released contract, and so is |
| 129 | every answer before it is sent; errors are RFC 9457 `application/problem+json`. |
| 130 | |
| 131 | ## Checks |
| 132 | |
| 133 | The checks scheduler (`server/checks/`, [docs/checks.md](docs/checks.md)) turns a matching GitHub workflow job into a |
| 134 | Run `code` job on a node and reports a GitHub check run. The owner creates the App once at `/admin/checks` (the |
| 135 | manifest flow); GitHub calls `POST /webhooks/github`; `GET /api/v1/checks/agreement?repo=` gives the owner the node vs |
| 136 | Actions agreement. Its secrets (the App key, webhook secret, client secret and installation tokens) are sealed with |
| 137 | `RUN_SECRETS_KEY`; `RUN_CHECKS_PROJECT` is the project a check job belongs to. |
| 138 | |
| 139 | ## Sessions |
| 140 | |
| 141 | A session (contracts 0.223.0, the Desks plan's D6) is a browser desk Run places for one person and OpenVibe.Bot leases |
| 142 | to it (`bot.desk.session`, `server/sessions/`). Unlike jobs, a session is a person's own: their Network token, or a |
| 143 | first-party `svc:` token holding the capability and naming them in `X-OV-Subject` (recorded as `via`). |
| 144 | |
| 145 | | Method and path | Capability | Request → answer | |
| 146 | |-----------------------------------------|-----------------------|------------------| |
| 147 | | `POST /api/v1/sessions` | `run.session.create` | `run.session-create-request@1` → `run.session@1` (201; 200 on an idempotent repeat) | |
| 148 | | `GET /api/v1/sessions?state=&limit=&cursor=` | `run.session.read` | → `run.session-list-result@1` (running ones by default) | |
| 149 | | `GET /api/v1/sessions/:id` | `run.session.read` | → `run.session@1` | |
| 150 | | `POST /api/v1/sessions/:id/commands` | `run.session.control` | `run.session-control-request@1` (screen, input, browser) → `run.session-control-result@1` | |
| 151 | | `POST /api/v1/sessions/:id/end` | `run.session.control` | → `run.session@1`, ended | |
| 152 | |
| 153 | - **Placement.** The request's own `machine`, or nowhere. Otherwise the hosts Bot lists for the person: online with |
| 154 | room, in the trust classes the requirements allow (first-party, then community, by default; user-owned only when |
| 155 | the requirements name it), most free desks first. A full host is skipped; with none, the session waits `placing` |
| 156 | until its `ttl_s`, then ends `unplaceable`. |
| 157 | - **Its life.** `ttl_s` (default 3600) bounds it. `idle_s` (default 900) without a command ends it. Every |
| 158 | `RUN_SESSION_LEASE_CHECK_MS` (30 s) Run asks Bot whether the desk is still leased: a host that dropped it ends the |
| 159 | session `host_lost`. A person holds `RUN_SESSIONS_PER_PERSON` (3) at once. |
| 160 | - **Metering.** One `platform.usage-sample@1` per minute of desk time (`run:<session>:<minute>`, operation |
| 161 | `browser.desk`, the last one the remainder) in `run_usage_outbox`, posted to Billing by the usage relay. |
| 162 | - **Events.** `run.session.started` and `run.session.ended`, in the transaction that records each. |
| 163 | |
| 164 | ## Jobs |
| 165 | |
| 166 | - **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key |
| 167 | and returns it in the read result (`auto:<ulid>`). The row is unique per `(project_id, idempotency_key)` and that |
| 168 | index is permanent, so a key is spent for good: a repeat always answers the first job, and the same key with |
| 169 | different work is `409` — the contract promising that "within 24 h" is answered the same way, forever, rather than |
| 170 | reused. `limits` are capped by the project's (`RUN_LIMITS_*`, `422 run.limits.exceeded`) — the lifetime too, whether |
| 171 | named or defaulted — `args` over 512 KiB are refused `413 run.args_too_large`, and `ttl_ms` defaults to |
| 172 | `limits.wall_ms + 600000` exactly as the contract says. |
| 173 | - **States.** `queued → placed → running → succeeded | failed | cancelled | expired`. Run stores `queued` and, once |
| 174 | the job is on a node, records `placed` itself; `running` and the end states arrive from Bot. An end state never |
| 175 | changes, so a replay of a cancel is a no-op rather than a second event. |
| 176 | - **Events.** `run.job.queued|started|succeeded|failed|cancelled|expired`, payloads being the |
| 177 | `run.job-read-result@1` projection their contracts describe. The outbox row is keyed `run:<job id>:<state>` (one |
| 178 | row per job and state, so a replay is one row); the published envelope carries a contract-valid `evt_<ULID>` |
| 179 | document id, because `events.event-envelope@1` pins that pattern and OpenVibe.Events validates it on publish. |
| 180 | - **Stream.** `POST /jobs/:id/stream/ticket` mints a two-minute single-use RS256 ticket for that one job (audience |
| 181 | `openvibe.run`, `typ run-stream`, its own key — never Network's signing key, so it is never a session anywhere). |
| 182 | `GET /jobs/:id/stream?ticket=…` replays what is kept (the last 1 MiB) and follows; a service that can send a header |
| 183 | opens the same stream with a Bearer token holding `run.job.stream` for the job's project (the manifest allows |
| 184 | both). `Last-Event-ID` resumes after the last `seq` seen; an ended job replays what is kept and closes with |
| 185 | `type: 'end'`. Reconnecting never re-runs a job. The `output` events are the node's `job_stdout` chunks as Bot |
| 186 | holds them (best effort: a chunk lost with the node's link is never resent, and a gap Bot dropped is dropped here |
| 187 | rather than streamed twice). The ring is this process's memory (reclaimed once it ended or went idle, and rebuilt from the job's row when |
| 188 | a stream opens again), so `seq` is this process's counter: a resume across a restart gets the current state rather |
| 189 | than a gap, and output is best effort either way. |
| 190 | - **The `job` record.** `run_jobs.job` holds the `platform.job@1` body Run will send (id, class, artifact, args, |
| 191 | ttl_ms, limits, net, inputs) plus the fields `platform.job@1` has no place for and `0001_jobs.sql` has no column |
| 192 | for: `requirements` (required by `run.job-read-result@1` and by placement), `placed_at` (`timings.placed_at`) and |
| 193 | `cancel_requested` (a cancel of a placed or running job). `store.frameOf(record)` builds the frame body from the |
| 194 | `platform.job@1` keys alone, and the dispatcher bridge validates it before anything is sent. |
| 195 | - **Cancelling.** A **queued** job was never sent anywhere: cancelled at once (`exit` stays null, usage stays 0). A |
| 196 | **placed or running** job may be on a node this instant, so it is marked `cancel_requested`, Bot is asked for its |
| 197 | `job_cancel` (once per job — Bot repeats it on every reconnect until the job ends — and the poller retries the |
| 198 | request until it lands), and the job reaches `cancelled` when Bot says so. Run never answers `cancelled` for work a |
| 199 | node may still be doing. Cancelling an ended job answers it as it is. |
| 200 | - **Usage.** `usage.wall_ms` and `usage.seconds` are Bot's metered wall clock (Bot writes the |
| 201 | `run:<job id>:<n>` readings; Run never posts those). A running job therefore shows 0 |
| 202 | until its `job_exit` carries the wall clock: Run does not estimate a number it cannot back. A job the managed-browser |
| 203 | provider ran is metered by Run: the provider's claimed `wall_ms`, never above Run's own observed duration (its send |
| 204 | to the answer that showed the end) + 5 s — a larger claim is clamped and logged — queued as `run:<job id>:<n>` |
| 205 | readings in `run_usage_outbox` with the end state and relayed to Billing (`RUN_BILLING_URL`; unset, they wait). |
| 206 | - **Placement.** The node is chosen per tick from the offers Network publishes |
| 207 | (`GET {OV_NETWORK_URL}/api/v1/offers?kind=node`) by `openvibe-sdk/placement` `plan()`, on the job's own |
| 208 | `requirements`; `node_id`, `provider` and `region` are recorded on Run's row. A class no node offers keeps the job |
| 209 | queued until an offer appears or its `ttl_ms` ends it `expired` with `run.job.unplaceable`. Offers are cached for `RUN_OFFERS_TTL_MS`; a Network that does not answer |
| 210 | is retried, never a reason to fail a job. |
| 211 | |
| 212 | ## Races (the Arena on the one engine) |
| 213 | |
| 214 | A race is the Arena's round (compare-arena-ecosystem.md §3): the same prompt, two to four entrants, one child job per |
| 215 | entrant on the same machine class (`linux`), the same share of the parent budget each, and no entrant named until a |
| 216 | person judges. The play is data — `agents.play@1`, id `race`, one role per side, one parallel phase, `blind: true`, |
| 217 | `stop.when: person` — listed by `GET /api/v1/plays`. Contracts 0.422.0 has no `run.race@1`, so the request and answer |
| 218 | are validated in `server/races/service.js`; the `bench.match@1` the judgement records is validated before it is sent. |
| 219 | |
| 220 | | Method and path | Capability | Request → answer | |
| 221 | |-----------------------------------|------------------|------------------| |
| 222 | | `GET /api/v1/plays` | `run.job.list` | → `{ plays: agents.play@1[] }` | |
| 223 | | `POST /api/v1/races` | `run.job.submit` | `{ prompt, kind: chat\|code\|design, entrants: [2–4 bench.entrant ids] \| matchmaker: true, budget: { usd?, wall_ms?, cpu_ms?, mem_bytes? } }` → 201 race | |
| 224 | | `GET /api/v1/races/:id` | `run.job.read` | each side's output so far (the job stream's ring), its state and, once ended, its receipts; names only after a judgement | |
| 225 | | `POST /api/v1/races/:id/judgement` | `run.job.submit` | `{ winner: a\|b\|tie\|both_bad }` → 201 race with the names revealed | |
| 226 | |
| 227 | - **Who may start and judge.** A person, through a first-party service (`X-OV-Subject` + `X-OV-Project`): Run records |
| 228 | the person as the requester and the service as `via`. Only that person, through that service and project, may read |
| 229 | or judge it; anyone else gets the same `404 run.race_not_found` a missing race does. |
| 230 | - **The split.** `budget` is the round's total and each side gets `floor(total / sides)` of wall, CPU and memory |
| 231 | (memory floored to bytes); `usd` is split and recorded, not metered by Run. A total too small for every side is |
| 232 | `422 run.limits.exceeded`; the per-side limits then pass the project caps like any job. |
| 233 | - **The child.** `class: linux`, `args: { play, role, race, kind, prompt, entrant, budget }`, with |
| 234 | `job.parent = { type: 'race', id }` (dropped by `frameOf`, so Bot never receives it). Placement, metering, events |
| 235 | and the stream are the ordinary job path. |
| 236 | - **The judgement.** One `bench.match@1`, source `arena`, the shown order (`a, b, …`, randomised when the race is |
| 237 | created), `blind: true`, each ended side's receipts, `play: { id: 'race', run_id }`, and one `human_pairwise` |
| 238 | judgement whose `by.id_hash` is an HMAC of the person (`RUN_RACE_HASH_KEY`, falling back to the Network client |
| 239 | secret). Bench records it before Run writes the judgement and reveals the names; a Bench that cannot answer leaves |
| 240 | the race blind and retryable with the same match id. |
| 241 | - **The matchmaker.** Bench has no matchmaker endpoint yet (checked 2026-11-13). `matchmaker: true` picks two entrants |
| 242 | of different model families at random from `RUN_RACE_ENTRANTS`; unset, it answers `422 |
| 243 | run.race_matchmaker_unavailable` and names the setting. |
| 244 | |
| 245 | ## Tests |
| 246 | |
| 247 | ``` |
| 248 | npm test # every file in test/, PGlite (in-process PostgreSQL) |
| 249 | npm run test:pg # the same on the production-shaped containers (openvibe-sdk scripts/test-services.sh up) |
| 250 | node test/jobs.test.js # the job API against the released contracts |
| 251 | node test/dispatch.test.js # the dispatcher bridge: offers → placement → Bot → mirror → stream, cancel, ttl |
| 252 | node test/app.test.js # the skeleton: health, readiness, release.json, /metrics, the bridge's off state |
| 253 | ``` |
| 254 | |
| 255 | `test/helpers/app.js` boots Run against an in-process OpenVibe.Network stub (its JWKS, its tokens, the token |
| 256 | endpoint Run mints from and the offers registry), so nothing needs the network, OpenVibe.Bot or a running service. |
| 257 | `test/dispatch.test.js` runs a fake OpenVibe.Bot as a real HTTP server that verifies the Network service token Run |
| 258 | mints for it (audience `openvibe.bot`, capability `bot.job.dispatch`), so the bridge is exercised over the wire |
| 259 | rather than through a fetch stub. |
| 260 | |
| 261 | ## Security |
| 262 | |
| 263 | - Every route checks one capability; the project and the requester come from the token, never the body. |
| 264 | - Another project's job is `404 run.job_not_found`, never `403`: an id's existence is never confirmed. |
| 265 | - The stream route accepts a valid, unexpired, unused ticket for that job, or a Bearer service token holding |
| 266 | `run.job.stream` for the job's project — a token in the query string is refused like any other non-ticket. |
| 267 | - Bot is a trusted peer but never an authority: its answer cannot rename the node or the project of a job Run |
| 268 | placed, cannot move a job's state backwards, and cannot make Run carry out work the caller did not ask for. Run's |
| 269 | own row, its offers and the job's requirements decide. |
| 270 | - Run's Bot token is minted from OV_OAUTH_CLIENT_ID/SECRET, cached until 60 s before expiry, never logged and never |
| 271 | put in an error; the job body is validated against `platform.job@1` before it is sent. |
| 272 | - Limits are bounded by the contract and the project's caps; nothing is silently lowered. |
| 273 | - No secret is logged (no `Authorization` header, no ticket, no job output), and nothing starts at module load. |
| 274 | - See SECURITY.md to report a problem. |
| 275 | |
| 276 | ## Deploy |
| 277 | |
| 278 | On openvibe-ovh since 2026-10-07: unit `openvibe-run` on loopback `:4920`, release layout under `/opt/openvibe.run`, |
| 279 | env `/etc/openvibe/run.env`, database `ov_run`; deploy with `sudo ovhost deploy run`. First-party callers reach the job |
| 280 | API on loopback, and jobs are placed on people's own Bot nodes. `openvibe.run` is parked (a 302 to openvibe.network) |
| 281 | until Run has a product surface; the vhost below is installed then. |
| 282 | |
| 283 | - `deploy/nginx/openvibe.run.conf` — the vhost (TLS, www → apex, rate limits, client IP from `$remote_addr` only, |
| 284 | `/internal/` and `/metrics` never proxied). |
| 285 | - `deploy/systemd/openvibe-run.service` — unit `openvibe-run`, `WorkingDirectory=/opt/openvibe.run`, |
| 286 | `EnvironmentFile=/etc/openvibe/run.env`, `PORT=4920`. |
| 287 | - `/api/health` and `/api/ready` are for the loopback caller; `/metrics` refuses anything that arrived through a |
| 288 | proxy. |
| 289 | |
| 290 | ## Deploy files |
| 291 | |
| 292 | | File | What it is | |
| 293 | |------|------------| |
| 294 | | `deploy/nginx/openvibe.run.conf` | The nginx vhost for openvibe.run (reference; deployed to `/etc/nginx/sites-available/`) | |
| 295 | | `deploy/systemd/openvibe-run.service` | The systemd unit (release layout `/opt/openvibe.run`) | |
| 296 | | `.github/workflows/ci.yml` | CI: the shared test job (syntax, tests, `openvibe-contracts-check --service run`) and the shared security job | |
| 297 | | `migrations/0001_jobs.sql` | `run_jobs` and `run_events_outbox` | |
| 298 | | `migrations/0004_telemetry.sql` | `telemetry_samples`: request telemetry (openvibe-sdk 0.46, `platform.telemetry-sample@1`) — each HTTP request aggregated by route template, method and status class, never the raw URL, with `/shared` files, static assets, health, readiness and `/metrics` not counted; once a minute one `http.request` sample per key, plus the active-requests, p95 latency and event-loop-lag gauges; rows older than 14 days deleted daily; wired in `server/app.js` (`telemetryMiddleware`, a no-op until init) and `server/index.js` (`telemetry.init()` at production start, retention, flush on stop) | |
| 299 | | `STATUS.json` | What this repository is, what it does today and where it runs | |
| 300 | |
| 301 | <!-- versions:start --> |
| 302 | - openvibe-contracts: v0.422.0 |
| 303 | - openvibe-sdk: v0.161.0 |
| 304 | - openvibe-shared: v4.241.0 |
| 305 | <!-- versions:end --> |
| 306 | |
| 307 | Compute placement, pool autoscaling, rental caps and the operator view are documented in |
| 308 | [docs/compute-broker.md](docs/compute-broker.md). Rentals default to quote-only; keys and caps |
| 309 | are never inferred from available credentials. |
| 310 |