Repositories openvibers openvibe.run official
openvibers/openvibe.run
Isolated job execution: accepts jobs, places each on a worker node, follows its frames and meters it (plan T14, ADR-034).
Get it
Get this repository
A Git token is made for this copy and shown once. It can read and write the repositories you own, and expires in an hour.
git clone https://git.openvibe.codes/openvibers/openvibe.run.git
OpenVibe Link asks you on your computer. The folder is inside the root you chose in Link, and the request ends after two minutes.
Clone to my computerA Codes session clones from our disk: no network, no GitHub quota. When this repository has a GitHub twin, Codes pushes there too.
Open in CodesREADME.md
View fileOpenVibe.Run
The job service of the network (openvibe.run). Design: OpenVibe.Contracts docs/adr/ADR-034-platform-north-star.md;
plan track T14.
Live site: https://openvibe.run · API docs: https://openvibe.services · For AI: https://openvibe.run/llms.txt
Run it: npm ci && npm run dev (Node 22 or newer) · Tests: npm test
Purpose
OpenVibe.Run accepts jobs, places each one on a node that advertises the runtime class it needs, and follows it to
its end. A job belongs to a project, is billed per wall-clock second it runs, and is visible only to the token
that project presents. Run owns the API, the project scope and the placement; OpenVibe.Bot owns the node link and the
per-second metering of the jobs it carries (plan T14 L1). The one exception is the external managed-browser provider
(plan T14 R4a): Run meters its jobs itself and posts their readings with a Billing token of its own, used for nothing
else.
This repository is at plan T14 steps 5 (R1b), 6 (R1c) and 7 (R3): the API is complete, and a queued job is
placed from the Fabric's offers and actually runs — the poller in server/jobs/poller.js (started by
server/index.js) sends it to OpenVibe.Bot, mirrors Bot's state and the node's stdout back, and settles it. Without
OV_OAUTH_CLIENT_SECRET Run cannot mint the Network token Bot requires, so the bridge is off and every job stays
queued.
How a job runs
- Submit —
POST /api/v1/jobs(capabilityrun.job.submit). Run validates the request against
run.job-create-request@1, mints thejob_<ULID>id, stores the rowqueuedand writesrun.job.queuedin the
same transaction. Nothing leaves the process here: a submit never waits on Network or Bot. - Place — the poller (
RUN_POLL_MS, default 1000 ms) reads the Fabric's offers for the job's class
(GET {OV_NETWORK_URL}/api/v1/offers?kind=node), andopenvibe-sdk/placement'splan()chooses the node the
job's ownrequirementsallow. Run records the node, provider and region on its row (placed_atincluded).
No eligible offer: the job waits queued (a node may be briefly offline) and every tick tries again; if its
ttl_msruns out first it endsexpiredwithrun.job.unplaceable(ADR-036 section 2). - Send — Run hands the
platform.job@1body to Bot (POST {RUN_BOT_URL}/api/v1/jobs, capability
bot.job.dispatch, audienceopenvibe.bot) and Bot gives it to the node over its device link. Bot's dispatch is
idempotent by job id, so a retry after a crash or a timeout is safe. - Mirror — every tick mirrors each
placedorrunningjob fromGET {RUN_BOT_URL}/api/v1/jobs/{id}: state,
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
change and itsrun.job.started|succeeded|failed|cancelled|expiredevent are one transaction, and the job's
stdout is carried into its SSE stream asoutputevents. A job Bot answers 404 for after Run sent it fails
run.job.worker_failedafterRUN_DISPATCH_UNKNOWN_POLLSpolls — it is never mirrored forever. - End —
succeeded,failed,cancelledorexpired, from Bot'sjob_exit(or from the cancel request and
the ttl sweep). An end state never changes. Bot's answer is never trusted over Run's own row: it can neither
rename the node nor the project of a job Run placed.
Owns
- The public site at openvibe.run (
server/web/home.js,server/app.js): the front page in the OpenVibe Frame (what a
job is and how it runs, the API in brief), the legal pages (openvibe-shared/legal),robots.txt, the sitemap,
/llms.txtand/llms-full.txt, IndexNow (only withINDEXNOW_KEY: the key file and one ping at boot), the icon and the web manifest. It says only what works today;test/site.test.jschecks it. - The job API (
server/api/v1.js, one capability per route — the table below) and the job's lifecycle record
(run_jobs,migrations/0001_jobs.sql): state, placement, timings, exit, result and the metered wall clock. - The project scope: a job's
project_idandrequestercome from the caller's Network token
(claims.project_id,claims.sub) and never from a request body; another project's job is404, never403. - Project delegation (ADR-003 amendment 2026-10-12): a first-party service acting for a person (
X-OV-Subject) may
name a project withX-OV-Project. Run asks Network whether the person is an admin or owner of the unarchived
project (network.project.member.read, a yes cached 60 s), then the person is the requester and the service is
stored asvia(migrations/0002_job_via.sql). The service submits, reads, cancels and gets a stream ticket for only
the jobs it started for that person (another person's or another service's job is404); list and a delegated Bearer
stream are403 project.not_delegated, and so is any other caller naming a project. Network unreachable is
503 project.check_unavailable. Watch'srun-jobaction and OpenVibe.Codes' coding jobs use it. - The
job_<ULID>id (minted here, passed unchanged to Bot asplatform.job@1id, so every metering reading of
the job isrun:<id>:<n>). - Idempotent submit: one row per
(project_id, idempotency_key), a repeat answering the first job, a different body
with the same key refused409 run.idempotency.conflict. - The
run.job.*events (server/jobs/events.js,run_events_outbox), written in the same transaction as the
state change they describe. - The job stream (
server/jobs/stream.js): one ring buffer per open job, the SSEidbeing the job's own monotonic
seq, plus the two-minute single-use stream ticket (server/jobs/ticket.js). - The
platform.workload-requirements@1default ({ kind: run.<class>, mobility: job, latency_class: background, objective: balanced }, the contract's own default) and the project caps on a job's limits. - Placement (plan T14 R3,
server/dispatch/placement.js): the job's requirements are stated by Run and the choice
among offers isopenvibe-sdk/placementplan()— Run writes no placer of its own (ADR-036). The job's
worker:<class>capability is what a candidate must advertise. A user-owned offer is a person's own machine
(contracts 0.161.0): it is eligible only for that person's own job (requesteruser:usr_…equal to the offer's
owner) whose requirements nameuser-owned. Network keeps such offers off the public list, so Run reads them,
owners included, fromGET /internal/resources?kind=node&trust=user-ownedwith its own client
(network.registry.read); a user-owned offer on the public list is never used. - The poller (
server/jobs/poller.js): the ttl sweep, the send, the mirror, the cancel-on-Bot and the job's stdout
into the stream.
Does not own
- The node link, the device credentials and the per-second metering: OpenVibe.Bot (
bot.job.dispatch), which gives
the job to a node advertisingworker:<class>and meters every wall-clock second intoplatform.usage-sample@1
readings keyedrun:<job id>:<n>. - Identity, tokens and projects (OpenVibe.Network); Run only verifies the tokens it is the audience of.
- The artifacts a job runs and the media its inputs come from (OpenVibe.Media; Run never fetches a caller-chosen
URL). - Money for the jobs Bot carries: Bot writes their usage records. Run writes only the external provider's
(billing.usage.record, clamped to Run's own observed duration + 5 s).
Depends on
- PostgreSQL 18:
run_jobsand therun_events_outbox;migrations/applies on boot. - Valkey (
VALKEY_URL, optional): shared state between processes; unset, nothing is shared. - OpenVibe.Network: the RS256 signing key (JWKS) every token is verified against; the offers Run places from
(GET /api/v1/offers, public); Run's clientrunmints the events relay's service token, its Bot token and the
token for project memberships (GET /internal/projects/:project/members/:subject, project delegation). - OpenVibe.Events:
run.job.*events go toevents.event.publish; unset, they wait inrun_events_outbox. - OpenVibe.Bench (
RUN_BENCH_URL, defaulthttps://bench.openvibe.services): a race's judgement is recorded as a
bench.match@1onPOST /api/v1/matches(bench.judgement.cast, audienceopenvibe.bench). Bench has no
matchmaker endpoint yet;matchmaker: truefalls back toRUN_RACE_ENTRANTS. - OpenVibe.Bot (
RUN_BOT_URL,RUN_POLL_MS): the node link and the meter. Run sends each job to it
(bot.job.dispatch, audienceopenvibe.bot, minted fromOV_OAUTH_CLIENT_ID/OV_OAUTH_CLIENT_SECRETagainst
OV_NETWORK_INTERNAL_URLand cached until 60 s before expiry) and mirrors its answer. Unset the client secret and
the bridge is off:/api/readyskips thebotcheck and every job staysqueued. openvibe-contractsv0.106.0,openvibe-sdkv0.74.0 andopenvibe-sharedv4.179.0 (package.json).
Capabilities and routes
Every route checks exactly the capability its manifest names (contracts/manifests/capabilities/run.job.*.json),
from the caller's service token. A Network user token names no project and holds no capability here, so it is
refused like any other token without the grant.
| Method and path | Capability | Request → answer |
|---------------------------------|------------------|--------------------------------------------------------|
| 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) |
| GET /api/v1/jobs/:id | run.job.read | common.no-body@1 → run.job-read-result@1 |
| GET /api/v1/jobs?state=&limit=&cursor= | run.job.list | run.job-list-query@1 → run.job-list-result@1 |
| POST /api/v1/jobs/:id/cancel | run.job.cancel | common.no-body@1 → run.job-read-result@1 |
| POST /api/v1/jobs/:id/stream/ticket | run.job.stream | common.no-body@1 → run.job-stream-ticket-result@1 |
| 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 |
| GET /api/v1/admin/jobs | run.job.admin | run.job-list-query@1 (with project_id) → run.job-list-result@1 |
run.job.admin additionally reads and cancels any project's job and is the only way to pass project_id to a list
(the manifest says so); it is internal, so only a first-party svc: token may hold it — an app, mod or agent token
with the grant is refused. Every request body and query string is validated against its released contract, and so is
every answer before it is sent; errors are RFC 9457 application/problem+json.
Checks
The checks scheduler (server/checks/, [docs/checks.md](docs/checks.md)) turns a matching GitHub workflow job into a
Run code job on a node and reports a GitHub check run. The owner creates the App once at /admin/checks (the
manifest flow); GitHub calls POST /webhooks/github; GET /api/v1/checks/agreement?repo= gives the owner the node vs
Actions agreement. Its secrets (the App key, webhook secret, client secret and installation tokens) are sealed with
RUN_SECRETS_KEY; RUN_CHECKS_PROJECT is the project a check job belongs to.
Sessions
A session (contracts 0.223.0, the Desks plan's D6) is a browser desk Run places for one person and OpenVibe.Bot leases
to it (bot.desk.session, server/sessions/). Unlike jobs, a session is a person's own: their Network token, or a
first-party svc: token holding the capability and naming them in X-OV-Subject (recorded as via).
| Method and path | Capability | Request → answer |
|-----------------------------------------|-----------------------|------------------|
| POST /api/v1/sessions | run.session.create | run.session-create-request@1 → run.session@1 (201; 200 on an idempotent repeat) |
| GET /api/v1/sessions?state=&limit=&cursor= | run.session.read | → run.session-list-result@1 (running ones by default) |
| GET /api/v1/sessions/:id | run.session.read | → run.session@1 |
| POST /api/v1/sessions/:id/commands | run.session.control | run.session-control-request@1 (screen, input, browser) → run.session-control-result@1 |
| POST /api/v1/sessions/:id/end | run.session.control | → run.session@1, ended |
- Placement. The request's own
machine, or nowhere. Otherwise the hosts Bot lists for the person: online with
room, in the trust classes the requirements allow (first-party, then community, by default; user-owned only when
the requirements name it), most free desks first. A full host is skipped; with none, the session waitsplacing
until itsttl_s, then endsunplaceable. - Its life.
ttl_s(default 3600) bounds it.idle_s(default 900) without a command ends it. Every
RUN_SESSION_LEASE_CHECK_MS(30 s) Run asks Bot whether the desk is still leased: a host that dropped it ends the
sessionhost_lost. A person holdsRUN_SESSIONS_PER_PERSON(3) at once. - Metering. One
platform.usage-sample@1per minute of desk time (run:<session>:<minute>, operation
browser.desk, the last one the remainder) inrun_usage_outbox, posted to Billing by the usage relay. - Events.
run.session.startedandrun.session.ended, in the transaction that records each.
Jobs
- Submit.
Idempotency-Key(orbody.idempotency_key) makes a retry safe; with neither, Run mints its own key
and returns it in the read result (auto:<ulid>). The row is unique per(project_id, idempotency_key)and that
index is permanent, so a key is spent for good: a repeat always answers the first job, and the same key with
different work is409— the contract promising that "within 24 h" is answered the same way, forever, rather than
reused.limitsare capped by the project's (RUN_LIMITS_*,422 run.limits.exceeded) — the lifetime too, whether
named or defaulted —argsover 512 KiB are refused413 run.args_too_large, andttl_msdefaults to
limits.wall_ms + 600000exactly as the contract says. - States.
queued → placed → running → succeeded | failed | cancelled | expired. Run storesqueuedand, once
the job is on a node, recordsplaceditself;runningand the end states arrive from Bot. An end state never
changes, so a replay of a cancel is a no-op rather than a second event. - Events.
run.job.queued|started|succeeded|failed|cancelled|expired, payloads being the
run.job-read-result@1projection their contracts describe. The outbox row is keyedrun:<job id>:<state>(one
row per job and state, so a replay is one row); the published envelope carries a contract-validevt_<ULID>
document id, becauseevents.event-envelope@1pins that pattern and OpenVibe.Events validates it on publish. - Stream.
POST /jobs/:id/stream/ticketmints a two-minute single-use RS256 ticket for that one job (audience
openvibe.run,typ run-stream, its own key — never Network's signing key, so it is never a session anywhere).
GET /jobs/:id/stream?ticket=…replays what is kept (the last 1 MiB) and follows; a service that can send a header
opens the same stream with a Bearer token holdingrun.job.streamfor the job's project (the manifest allows
both).Last-Event-IDresumes after the lastseqseen; an ended job replays what is kept and closes with
type: 'end'. Reconnecting never re-runs a job. Theoutputevents are the node'sjob_stdoutchunks as Bot
holds them (best effort: a chunk lost with the node's link is never resent, and a gap Bot dropped is dropped here
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
a stream opens again), soseqis this process's counter: a resume across a restart gets the current state rather
than a gap, and output is best effort either way. - The
jobrecord.run_jobs.jobholds theplatform.job@1body Run will send (id, class, artifact, args,
ttl_ms, limits, net, inputs) plus the fieldsplatform.job@1has no place for and0001_jobs.sqlhas no column
for:requirements(required byrun.job-read-result@1and by placement),placed_at(timings.placed_at) and
cancel_requested(a cancel of a placed or running job).store.frameOf(record)builds the frame body from the
platform.job@1keys alone, and the dispatcher bridge validates it before anything is sent. - Cancelling. A queued job was never sent anywhere: cancelled at once (
exitstays null, usage stays 0). A
placed or running job may be on a node this instant, so it is markedcancel_requested, Bot is asked for its
job_cancel(once per job — Bot repeats it on every reconnect until the job ends — and the poller retries the
request until it lands), and the job reachescancelledwhen Bot says so. Run never answerscancelledfor work a
node may still be doing. Cancelling an ended job answers it as it is. - Usage.
usage.wall_msandusage.secondsare Bot's metered wall clock (Bot writes the
run:<job id>:<n>readings; Run never posts those). A running job therefore shows 0
until itsjob_exitcarries the wall clock: Run does not estimate a number it cannot back. A job the managed-browser
provider ran is metered by Run: the provider's claimedwall_ms, never above Run's own observed duration (its send
to the answer that showed the end) + 5 s — a larger claim is clamped and logged — queued asrun:<job id>:<n>
readings inrun_usage_outboxwith the end state and relayed to Billing (RUN_BILLING_URL; unset, they wait). - Placement. The node is chosen per tick from the offers Network publishes
(GET {OV_NETWORK_URL}/api/v1/offers?kind=node) byopenvibe-sdk/placementplan(), on the job's own
requirements;node_id,providerandregionare recorded on Run's row. A class no node offers keeps the job
queued until an offer appears or itsttl_msends itexpiredwithrun.job.unplaceable. Offers are cached forRUN_OFFERS_TTL_MS; a Network that does not answer
is retried, never a reason to fail a job.
Races (the Arena on the one engine)
A race is the Arena's round (compare-arena-ecosystem.md §3): the same prompt, two to four entrants, one child job per
entrant on the same machine class (linux), the same share of the parent budget each, and no entrant named until a
person judges. The play is data — agents.play@1, id race, one role per side, one parallel phase, blind: true,
stop.when: person — listed by GET /api/v1/plays. Contracts 0.422.0 has no run.race@1, so the request and answer
are validated in server/races/service.js; the bench.match@1 the judgement records is validated before it is sent.
| Method and path | Capability | Request → answer |
|-----------------------------------|------------------|------------------|
| GET /api/v1/plays | run.job.list | → { plays: agents.play@1[] } |
| 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 |
| 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 |
| POST /api/v1/races/:id/judgement | run.job.submit | { winner: a\|b\|tie\|both_bad } → 201 race with the names revealed |
- Who may start and judge. A person, through a first-party service (
X-OV-Subject+X-OV-Project): Run records
the person as the requester and the service asvia. Only that person, through that service and project, may read
or judge it; anyone else gets the same404 run.race_not_founda missing race does. - The split.
budgetis the round's total and each side getsfloor(total / sides)of wall, CPU and memory
(memory floored to bytes);usdis split and recorded, not metered by Run. A total too small for every side is
422 run.limits.exceeded; the per-side limits then pass the project caps like any job. - The child.
class: linux,args: { play, role, race, kind, prompt, entrant, budget }, with
job.parent = { type: 'race', id }(dropped byframeOf, so Bot never receives it). Placement, metering, events
and the stream are the ordinary job path. - The judgement. One
bench.match@1, sourcearena, the shown order (a, b, …, randomised when the race is
created),blind: true, each ended side's receipts,play: { id: 'race', run_id }, and onehuman_pairwise
judgement whoseby.id_hashis an HMAC of the person (RUN_RACE_HASH_KEY, falling back to the Network client
secret). Bench records it before Run writes the judgement and reveals the names; a Bench that cannot answer leaves
the race blind and retryable with the same match id. - The matchmaker. Bench has no matchmaker endpoint yet (checked 2026-11-13).
matchmaker: truepicks two entrants
of different model families at random fromRUN_RACE_ENTRANTS; unset, it answers422 run.race_matchmaker_unavailableand names the setting.
Tests
npm test # every file in test/, PGlite (in-process PostgreSQL)
npm run test:pg # the same on the production-shaped containers (openvibe-sdk scripts/test-services.sh up)
node test/jobs.test.js # the job API against the released contracts
node test/dispatch.test.js # the dispatcher bridge: offers → placement → Bot → mirror → stream, cancel, ttl
node test/app.test.js # the skeleton: health, readiness, release.json, /metrics, the bridge's off state
test/helpers/app.js boots Run against an in-process OpenVibe.Network stub (its JWKS, its tokens, the token
endpoint Run mints from and the offers registry), so nothing needs the network, OpenVibe.Bot or a running service.
test/dispatch.test.js runs a fake OpenVibe.Bot as a real HTTP server that verifies the Network service token Run
mints for it (audience openvibe.bot, capability bot.job.dispatch), so the bridge is exercised over the wire
rather than through a fetch stub.
Security
- Every route checks one capability; the project and the requester come from the token, never the body.
- Another project's job is
404 run.job_not_found, never403: an id's existence is never confirmed. - The stream route accepts a valid, unexpired, unused ticket for that job, or a Bearer service token holding
run.job.streamfor the job's project — a token in the query string is refused like any other non-ticket. - Bot is a trusted peer but never an authority: its answer cannot rename the node or the project of a job Run
placed, cannot move a job's state backwards, and cannot make Run carry out work the caller did not ask for. Run's
own row, its offers and the job's requirements decide. - Run's Bot token is minted from OV_OAUTH_CLIENT_ID/SECRET, cached until 60 s before expiry, never logged and never
put in an error; the job body is validated againstplatform.job@1before it is sent. - Limits are bounded by the contract and the project's caps; nothing is silently lowered.
- No secret is logged (no
Authorizationheader, no ticket, no job output), and nothing starts at module load. - See SECURITY.md to report a problem.
Deploy
On openvibe-ovh since 2026-10-07: unit openvibe-run on loopback :4920, release layout under /opt/openvibe.run,
env /etc/openvibe/run.env, database ov_run; deploy with sudo ovhost deploy run. First-party callers reach the job
API on loopback, and jobs are placed on people's own Bot nodes. openvibe.run is parked (a 302 to openvibe.network)
until Run has a product surface; the vhost below is installed then.
deploy/nginx/openvibe.run.conf— the vhost (TLS, www → apex, rate limits, client IP from$remote_addronly,
/internal/and/metricsnever proxied).deploy/systemd/openvibe-run.service— unitopenvibe-run,WorkingDirectory=/opt/openvibe.run,
EnvironmentFile=/etc/openvibe/run.env,PORT=4920./api/healthand/api/readyare for the loopback caller;/metricsrefuses anything that arrived through a
proxy.
Deploy files
| File | What it is |
|------|------------|
| deploy/nginx/openvibe.run.conf | The nginx vhost for openvibe.run (reference; deployed to /etc/nginx/sites-available/) |
| deploy/systemd/openvibe-run.service | The systemd unit (release layout /opt/openvibe.run) |
| .github/workflows/ci.yml | CI: the shared test job (syntax, tests, openvibe-contracts-check --service run) and the shared security job |
| migrations/0001_jobs.sql | run_jobs and run_events_outbox |
| 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) |
| STATUS.json | What this repository is, what it does today and where it runs |
<!-- versions:start -->
- openvibe-contracts: v0.422.0
- openvibe-sdk: v0.161.0
- openvibe-shared: v4.237.0
<!-- versions:end -->
Compute placement, pool autoscaling, rental caps and the operator view are documented in
[docs/compute-broker.md](docs/compute-broker.md). Rentals default to quote-only; keys and caps
are never inferred from available credentials.