- JavaScript 100%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
|
|
||
| .github/workflows | ||
| deploy | ||
| docs | ||
| migrations | ||
| server | ||
| test | ||
| .env.example | ||
| .gitignore | ||
| .gitleaks.toml | ||
| CHANGELOG.md | ||
| package-lock.json | ||
| package.json | ||
| README.md | ||
| SECURITY.md | ||
| STATUS.json | ||
OpenVibe.Run
The job service of the network (openvibe.run). Design: OpenVibe.Contracts docs/adr/ADR-034-platform-north-star.md; plan track T14.
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 againstrun.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 itsttl_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, capabilitybot.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 failsrun.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 is503 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 monotonicseq, 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'sworker:<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'sowner) 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@1readings 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.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_SECRETagainstOV_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.65.0 andopenvibe-sharedv4.15.2 (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.
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 tolimits.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 therun.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 (audienceopenvibe.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 withtype: '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) andcancel_requested(a cancel of a placed or running job).store.frameOf(record)builds the frame body from theplatform.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 itsjob_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 therun:<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 ownrequirements;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.
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 against
platform.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 |
- openvibe-contracts: v0.216.0
- openvibe-sdk: v0.65.0
- openvibe-shared: v4.15.2