openvibers/openvibe.run Commits
Run sessions (contracts 0.223.0, Desks D6): a browser desk placed for one person on their own machine or a desk host, leased through Bot; create/read/commands/end; ttl, idle and lost hosts; desk time metered a minute at a time; run.session.started/ended (#93)
Changes
@@@ -122,6 +122,31 @@ refused like any other token without the grant.with the grant is refused. Every request body and query string is validated against its released contract, and so isevery answer before it is sent; errors are RFC 9457 `application/problem+json`.++## 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 waits `placing`++ until its `ttl_s`, then ends `unplaceable`.++- **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++ session `host_lost`. A person holds `RUN_SESSIONS_PER_PERSON` (3) at once.++- **Metering.** One `platform.usage-sample@1` per minute of desk time (`run:<session>:<minute>`, operation++ `browser.desk`, the last one the remainder) in `run_usage_outbox`, posted to Billing by the usage relay.++- **Events.** `run.session.started` and `run.session.ended`, in the transaction that records each.++## Jobs- **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key@@@ -227,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.| `STATUS.json` | What this repository is, what it does today and where it runs |<!-- versions:start -->--- openvibe-contracts: v0.222.0++- openvibe-contracts: v0.223.0- openvibe-sdk: v0.67.0- openvibe-shared: v4.19.0<!-- versions:end -->@@@ -7,11 +7,11 @@"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db) + Valkey 9","port": 4920,"domain": "openvibe.run",-- "contracts": "openvibe-contracts v0.222.0",++ "contracts": "openvibe-contracts v0.223.0","sdk": "openvibe-sdk v0.67.0","shared": "openvibe-shared v4.19.0","packages": [-- "openvibe-contracts v0.222.0",++ "openvibe-contracts v0.223.0","openvibe-sdk v0.67.0","openvibe-shared v4.19.0"],@@@ -21,7 +21,10 @@"run.job.list","run.job.cancel","run.job.stream",-- "run.job.admin"++ "run.job.admin",++ "run.session.create",++ "run.session.read",++ "run.session.control"],"events": ["run.job.queued",@@@ -29,7 +32,9 @@"run.job.succeeded","run.job.failed","run.job.cancelled",-- "run.job.expired"++ "run.job.expired",++ "run.session.started",++ "run.session.ended"],"deployed": true,"deployment": "openvibe-ovh: unit openvibe-run.service on 127.0.0.1:4920 since 2026-10-07 (release layout /opt/openvibe.run, env /etc/openvibe/run.env, database ov_run), deployed with `sudo ovhost deploy run`. The job API is reached by first-party callers on loopback; jobs are placed on people's own Bot nodes through Bot's internal jobs API (bot.job.dispatch). openvibe.run itself is parked (a 302 to openvibe.network) until Run has a product surface; the vhost in deploy/nginx/openvibe.run.conf is installed then. Its rules stay: /api/health, /api/ready, /release.json and the job API proxied, the client address from $remote_addr only, /internal/ and /metrics refused at the edge.",nnew file mode 100644@@@ -0,0 +1,37 @@++-- phase: expand++-- Run sessions (contracts 0.223.0 run.session@1, the Desks plan's D6): a browser desk Run places for one person, on++-- one of their own machines or on a desk host that serves other people, and leases through OpenVibe.Bot++-- (bot.desk.session). One row per session; its desk time is metered a minute at a time into run_usage_outbox++-- (run:<session>:<minute>) and its start and end are run.session.* events in run_events_outbox.++CREATE TABLE IF NOT EXISTS run_sessions (++ id text PRIMARY KEY, -- ses_<ULID>++ owner text NOT NULL, -- user:usr_…++ via text, -- service:actor, or null++ kind text NOT NULL DEFAULT 'browser' CHECK (kind IN ('browser')),++ state text NOT NULL CHECK (state IN ('placing', 'starting', 'running', 'ended')),++ width integer NOT NULL,++ height integer NOT NULL,++ ttl_s integer NOT NULL,++ idle_s integer NOT NULL,++ machine_asked text, -- the person's own machine the request named++ requirements jsonb,++ idempotency_key text,++ request_digest text NOT NULL,++ machine text, -- where it runs (placement)++ desk text,++ trust text,++ offer_id text,++ reasons jsonb NOT NULL DEFAULT '[]',++ created_at bigint NOT NULL,++ started_at bigint,++ last_used_at bigint,++ ended_at bigint,++ end_reason text CHECK (end_reason IS NULL OR end_reason IN ('ended', 'ttl', 'idle', 'host_lost', 'unplaceable', 'failed')),++ end_detail text,++ metered_minutes integer NOT NULL DEFAULT 0, -- whole minutes of desk time already queued for Billing++ lease_checked_at bigint,++ updated_at bigint NOT NULL++);++CREATE UNIQUE INDEX IF NOT EXISTS run_sessions_idem ON run_sessions (owner, idempotency_key) WHERE idempotency_key IS NOT NULL;++CREATE INDEX IF NOT EXISTS run_sessions_owner ON run_sessions (owner, created_at DESC);++CREATE INDEX IF NOT EXISTS run_sessions_open ON run_sessions (state) WHERE state <> 'ended';@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0","pg": "^8.23.0"@@@ -872,9 +872,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.223.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",++ "integrity": "sha512-FWcPL1951IeYcelp3dUiDRJEiFkTbsLMLth9RVUFfuLTuHGQKoQXA5HIu6H2e2BupPTyBH+AZD00LqmA6jDmdg==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -26,7 +26,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,85 @@++'use strict';++++/**++ * Run sessions' API (contracts 0.223.0, the Desks plan's D6), under /api/v1 behind api/auth.js:++ *++ * POST /api/v1/sessions run.session.create run.session-create-request@1 → 201 run.session@1 (200 for++ * an idempotent repeat)++ * GET /api/v1/sessions run.session.read ?state=running|ended|all&limit=&cursor= → run.session-list-result@1++ * 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 → run.session-control-result@1++ * POST /api/v1/sessions/:id/end run.session.control → run.session@1, ended++ *++ * A session is its person's: their own Network token, or a first-party service (svc:) holding the capability and++ * naming the person in X-OV-Subject (recorded as via). Anyone else's session is 404, never confirmed to exist. An app,++ * mod or agent token never opens a session for a person here.++ */++const express = require('express');++const { capabilities, http, ids } = require('openvibe-contracts');++++const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const SESSION_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;++++function sessionsRouter({ sessions }) {++ const r = express.Router();++ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ /** The person a call acts for, and the service it came through. → { owner: 'user:usr_…', via } or a sent problem (null). */++ function who(req, res, cap) {++ const p = req.principal || { kind: 'anonymous' };++ if (p.kind === 'anonymous') { problem(res, req, 401, 'token.required', `${cap} needs your OpenVibe sign-in, or a first-party service acting for you`); return null; }++ if (p.kind === 'user') return { owner: `user:${p.subject}`, via: null };++ const sub = String(p.sub || '');++ if (!sub.startsWith('svc:')) { problem(res, req, 403, 'capability.denied', 'a session is opened by its person, or by a first-party service acting for them'); return null; }++ const decision = capabilities.check(p.claims, cap);++ if (!decision.allowed) { problem(res, req, 403, decision.code || 'capability.denied', decision.reason || `${cap} not granted`); return null; }++ const subject = String(req.get('X-OV-Subject') || '').trim();++ if (!USER_SUBJECT.test(subject) || !ids.isSubjectId('user', subject)) { problem(res, req, 422, 'run.session.subject_required', 'a service opens a session for a person: X-OV-Subject usr_…'); return null; }++ return { owner: `user:${subject}`, via: `service:${sub.slice(4)}` };++ }++ async function mine(req, res, cap) {++ const w = who(req, res, cap);++ if (!w) return null;++ const id = String(req.params.id || '');++ const row = SESSION_RE.test(id) ? await sessions.get(id) : null;++ if (!row || row.owner !== w.owner) { problem(res, req, 404, 'run.session.not_found', 'no such session of yours'); return null; }++ return row;++ }++ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {++ if (err && err.status && err.code) return problem(res, req, err.status, err.code, err.detail || err.message);++ return next(err);++ });++++ r.post('/sessions', wrap(async (req, res) => {++ const w = who(req, res, 'run.session.create');++ if (!w) return;++ const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {});++ res.status(created ? 201 : 200).json(sessions.present(row));++ }));++ r.get('/sessions', wrap(async (req, res) => {++ const w = who(req, res, 'run.session.read');++ if (!w) return;++ const state = ['running', 'ended', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'running';++ const cursor = SESSION_RE.test(String(req.query.cursor || '')) ? String(req.query.cursor) : null;++ const page = await sessions.list(w.owner, { state, limit: req.query.limit, cursor });++ res.json({ sessions: page.rows.map(sessions.present), next_cursor: page.next });++ }));++ r.get('/sessions/:id', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.read');++ if (row) res.json(sessions.present(row));++ }));++ r.post('/sessions/:id/commands', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.control');++ if (!row) return;++ res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}));++ }));++ r.post('/sessions/:id/end', wrap(async (req, res) => {++ const row = await mine(req, res, 'run.session.control');++ if (!row) return;++ const ended = await sessions.finish(row, 'ended', 'ended by its person');++ res.json(sessions.present(ended || row));++ }));++ return r;++}++++module.exports = { sessionsRouter };@@@ -30,6 +30,9 @@ const { createRunEvents } = require('./jobs/events');const { createDispatcher } = require('./dispatch');const { createApiAuth } = require('./api/auth');const { v1Router } = require('./api/v1');++const { sessionsRouter } = require('./api/sessions');++const { createDeskClient } = require('./sessions/desks');++const { createSessions } = require('./sessions/service');const { inputError } = require('./util');const { createRunReadiness, registerRunGauges } = require('./observability');@@@ -57,6 +60,9 @@ function createApp(opts = {}) {const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });++ // Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.++ const desks = opts.desks || createDeskClient({ config, fetchImpl, now });++ const sessions = opts.sessions || createSessions({ config, db, desks, outbox, now, log });const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });const app = express();@@@ -94,6 +100,7 @@ function createApp(opts = {}) {express.json({ limit: JSON_LIMIT }),(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },apiAuth.middleware,++ sessionsRouter({ sessions }),v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));// ── The public site (openvibe.run): the front page, the legal pages and the Frame's shared assets. ──@@@ -152,7 +159,7 @@ function createApp(opts = {}) {return res.status(500).type('text/plain').send('Something went wrong\n');});-- Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });++ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions });return app;}@@@ -111,6 +111,14 @@ function loadConfig(env = process.env) {// The dispatcher bridge to Bot (plan T14 step 6, R1c): Bot holds the node link and the per-second// metering; Run calls it as the service principal `svc:run` for audience openvibe.bot. Without// OV_OAUTH_CLIENT_SECRET Run cannot mint that token and nothing is sent (jobs stay queued).++ // Run sessions (contracts 0.223.0, server/sessions/): one pass over the open ones every tickMs; a person holds at++ // most maxOpen at once; a running one's lease is checked on Bot every leaseCheckMs.++ sessions: {++ tickMs: Math.max(200, int(env.RUN_SESSION_TICK_MS, 2000)),++ maxOpen: Math.max(1, int(env.RUN_SESSIONS_PER_PERSON, 3)),++ leaseCheckMs: Math.max(1000, int(env.RUN_SESSION_LEASE_CHECK_MS, 30000)),++ },++dispatch: {botUrl: trim(env.RUN_BOT_URL || 'http://127.0.0.1:4630'),botAudience: env.RUN_BOT_AUDIENCE || 'openvibe.bot',@@@ -9,6 +9,8 @@* run.job.failed job_exit with another code, or reason limit, stopped or failed* run.job.cancelled POST /api/v1/jobs/:id/cancel, or job_exit reason cancelled* run.job.expired ttl_ms ran out (run.job.unplaceable or run.job.ttl)++ * run.session.started a session's desk answered (server/sessions/service.js)++ * run.session.ended a session ended (ended, ttl, idle, host_lost, unplaceable or failed)** A row is written inside the transaction that makes the change (jobs/events.js: the row is keyed by* `run:<job id>:<state>`, and it validates its envelope against events.event-envelope@1 and its payload@@@ -21,7 +23,8 @@ const { createServiceOutbox } = require('openvibe-sdk/events');const TABLE = 'run_events_outbox'; // migrations/0001_jobs.sqlconst ACTOR = { type: 'service', id: 'run' };--const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired'];++const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired',++ 'run.session.started', 'run.session.ended']; // server/sessions/service.js (contracts 0.223.0)function createRunOutbox({ db, config, fetchImpl, now, log = console }) {return createServiceOutbox({@@@ -34,7 +34,7 @@ async function main() {const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });const app = createApp({ config, db, valkey, registry });-- const { keys, outbox, stream, tickets, dispatch, indexnow } = app.locals;++ const { keys, outbox, stream, tickets, dispatch, indexnow, sessions } = app.locals;keys.start();const ticketKey = tickets.start();@@@ -44,11 +44,13 @@ async function main() {// credentials: start() then does nothing and every job stays queued.outbox.start();dispatch.start();++ // Run sessions (server/sessions/): place, watch and meter the open ones. Off without the Bot token.++ sessions.start();}const server = app.listen(config.port, config.host, () => {console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);-- console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);++ console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}; ${sessions.status().note}`);});server.keepAliveTimeout = 65_000;// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status@@@ -72,6 +74,7 @@ async function main() {() => keys.stop(),() => tickets.stop(),() => dispatch.stop(),++ () => sessions.stop(),() => stream.close(),() => outbox.stop(),],nnew file mode 100644@@@ -0,0 +1,77 @@++'use strict';++++/**++ * The desk client: Run → OpenVibe.Bot for its sessions (contracts 0.223.0, capability bot.desk.session). Bot owns the++ * machines and their desks; Run asks it, as the session's person (X-OV-Subject), where a desk may go, leases one,++ * acts on it and ends it:++ *++ * hosts(subject) GET /api/v1/desk-hosts → [{ machine_id, trust, … }]++ * lease(machine, subject, body) POST /api/v1/machines/:id/desk-leases → bot.desk-lease@1++ * get(machine, desk, subject) GET /api/v1/machines/:id/desk-leases/:desk → the lease | null (404)++ * act(machine, desk, subject, { id, kind, value }) POST …/desk-leases/:desk/commands → bot.command-result@1 fields++ * end(machine, desk, subject) DELETE /api/v1/machines/:id/desk-leases/:desk → { ended, stopped } | null++ *++ * The token is Run's Network client-credentials token for audience openvibe.bot with scope bot.desk.session (a 401++ * drops it and the call is tried once more). Off without OV_OAUTH_CLIENT_SECRET: every call fails retryable and++ * sessions wait placing. A DeskError carries Bot's own code (409 bot.machine_busy, 404 bot.device_not_found …).++ */++const { createServiceTokenClient } = require('openvibe-sdk/auth');++++const SCOPE = 'bot.desk.session';++++class DeskError extends Error {++ constructor(status, code, detail, { retryable = false } = {}) {++ super(detail || code);++ this.name = 'DeskError';++ this.status = status;++ this.code = code;++ this.detail = detail;++ this.retryable = retryable;++ }++}++++function createDeskClient({ config, fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {++ const { botUrl, botAudience, timeoutMs } = config.dispatch;++ const { clientId, clientSecret } = config.oauth;++ const tokens = clientSecret++ ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: botAudience, scope: SCOPE, fetch: fetchImpl, now })++ : null;++ const enabled = Boolean(tokens && botUrl);++ const note = enabled ? `sessions lease desks on Bot: ${botUrl}, capability ${SCOPE}` : 'sessions are off: OV_OAUTH_CLIENT_SECRET is unset, so Run cannot mint its Bot token';++++ async function call(method, path, subject, body) {++ if (!enabled) throw new DeskError(503, 'run.session.disabled', note, { retryable: true });++ for (let attempt = 0; ; attempt++) {++ const headers = { Accept: 'application/json', 'X-OV-Subject': subject, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) };++ try { Object.assign(headers, await tokens.authHeaders()); }++ catch { throw new DeskError(503, 'run.session.no_token', 'Run could not mint its Bot token from Network', { retryable: true }); }++ let res;++ try {++ res = await fetchImpl(`${botUrl}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(Math.max(timeoutMs, 35000)) });++ } catch (e) {++ const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'no answer in time' : 'unreachable';++ throw new DeskError(503, 'run.session.bot_unavailable', `Bot did not answer (${why})`, { retryable: true });++ }++ if (res.status === 401 && attempt === 0) { tokens.invalidate(); continue; }++ const doc = await res.json().catch(() => null);++ if (res.ok) return doc || {};++ const code = doc && typeof doc.code === 'string' ? doc.code : null;++ const detail = doc && typeof doc.detail === 'string' ? doc.detail.slice(0, 300) : null;++ if (res.status === 404) return null;++ throw new DeskError(res.status, code || 'run.session.bot_refused', detail || `Bot answered HTTP ${res.status}`, { retryable: res.status >= 500 || res.status === 429 });++ }++ }++ const m = (id) => encodeURIComponent(id);++ return {++ enabled,++ note,++ async hosts(subject) { const out = await call('GET', '/api/v1/desk-hosts', subject); return out && Array.isArray(out.hosts) ? out.hosts : []; },++ lease: (machine, subject, body) => call('POST', `/api/v1/machines/${m(machine)}/desk-leases`, subject, body),++ get: (machine, desk, subject) => call('GET', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}`, subject),++ act: (machine, desk, subject, cmd) => call('POST', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}/commands`, subject, cmd),++ end: (machine, desk, subject) => call('DELETE', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}`, subject),++ status: () => ({ enabled, note, bot_url: botUrl, capability: SCOPE }),++ };++}++++module.exports = { createDeskClient, DeskError, SCOPE };nnew file mode 100644@@@ -0,0 +1,252 @@++'use strict';++++/**++ * Run sessions (contracts 0.223.0, the Desks plan's D6): a browser desk Run places for one person and OpenVibe.Bot++ * leases to it (server/sessions/desks.js).++ *++ * create(owner, via, body) a validated run.session-create-request@1 → { row, created } (state placing)++ * act(row, cmd) one run.session-control-request@1 on its desk → run.session-control-result@1++ * finish(row, reason, detail) end it: the desk stopped, the lease given back, the last desk time metered++ * tick() one pass over the open sessions: place the ones placing, end the ones past their++ * ttl_s or idle_s or whose desk went away, meter running desk time a minute at a time++ * start() / stop() the tick on its interval (server/index.js; nothing runs at module load)++ *++ * PLACEMENT. The request's own machine (one of the person's, where they let agents work) or nowhere. Otherwise the++ * hosts Bot lists for the person that are online with room, in the trust classes the requirements allow (default++ * first-party then community; user-owned only when the requirements name it): user-owned first, then first-party,++ * partner, community, and the most free desks first within a class. A host that turns out to be full is skipped and++ * the next one tried; with none the session waits placing until its ttl_s, then ends unplaceable.++ *++ * METERING. One platform.usage-sample@1 per minute of desk time (run:<session>:<minute>, quantity 60; the last one++ * the remainder), service run, operation browser.desk, unit s, written in the transaction that records it, into++ * run_usage_outbox (Run's usage relay posts it to Billing, server/jobs/usage.js). EVENTS: run.session.started when++ * the desk answered, run.session.ended when it ended, in run_events_outbox, in the same transaction.++ */++const contracts = require('openvibe-contracts');++const store = require('./store');++const usage = require('../jobs/usage');++const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');++++const TRUST_ORDER = ['user-owned', 'first-party', 'partner', 'community'];++const DEFAULT_TRUST = ['first-party', 'community'];++const STARTING_STALE_MS = 60_000;++const OPERATION = 'browser.desk';++const SOURCE = 'openvibe-run.session';++++class SessionError extends Error {++ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }++}++++function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console }) {++ const cfg = config.sessions || {};++ const tickMs = cfg.tickMs || 2000;++ const leaseCheckMs = cfg.leaseCheckMs || 30_000;++ const maxOpen = cfg.maxOpen || 3;++ let timer = null;++ let running = null;++ const hostCache = new Map(); // subject → { at, hosts }++++ const bare = (owner) => String(owner).replace(/^user:/, '');++++ // ── events and usage, inside the caller's transaction ─────────────────────────────────────────────────────++ async function emit(t, row, event) {++ const payload = store.present(row, now());++ contracts.assertValid(`${event}@1`, payload);++ const prepared = outbox.events.prepare({++ event_type: event, version: 1, source: 'run', actor: ACTOR, timestamp: new Date(now()).toISOString(),++ subject: { type: 'session', id: row.id }, visibility: 'internal', priority: 'low', payload,++ }, { now: now() });++ contracts.assertValid('events.event-envelope@1', prepared);++ await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,++ [`run:${row.id}:${event}`, JSON.stringify(prepared), now()]);++ }++ /** Queue the desk time not yet metered: whole minutes, and with `final` the remainder too. → minutes now metered */++ async function meter(t, row, { final = false } = {}) {++ if (row.started_at == null || !row.machine) return row.metered_minutes || 0;++ const secs = store.deskSeconds(row, now());++ const whole = Math.floor(secs / 60);++ const readings = [];++ const one = (n, quantity) => {++ const key = `run:${row.id}:${n}`;++ return { id: key, idempotency_key: key, service: usage.SERVICE, operation: OPERATION, unit: 's', quantity,++ at: new Date(row.started_at + n * 60_000).toISOString(), source: SOURCE, subject: row.owner, node: row.machine };++ };++ for (let n = row.metered_minutes || 0; n < whole; n++) readings.push(one(n, 60));++ let metered = Math.max(row.metered_minutes || 0, whole);++ const rest = Math.round((secs - whole * 60) * 10) / 10;++ if (final && rest > 0 && metered === whole) { readings.push(one(whole, rest)); metered = whole + 1; }++ if (readings.length) await usage.enqueue(t, readings, now());++ return metered;++ }++++ // ── create, act, finish ───────────────────────────────────────────────────────────────────────────────────++ async function create(owner, via, body) {++ const v = contracts.validate('run.session-create-request@1', body);++ if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));++ const digest = store.digestOf(body);++ if (body.idempotency_key) {++ const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);++ if (earlier) {++ if (earlier.request_digest !== digest) throw new SessionError(409, 'run.idempotency.conflict', 'that idempotency key was used with another request');++ return { row: await store.get(db, earlier.id), created: false };++ }++ }++ if ((await store.openCount(db, owner)) >= maxOpen) throw new SessionError(429, 'run.session.limit', `at most ${maxOpen} sessions at once: end one first`);++ const at = now();++ const row = {++ id: store.newSessionId(), owner, via: via || null, kind: 'browser', state: 'placing',++ width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,++ machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,++ request_digest: digest, reasons: [], created_at: at, updated_at: at,++ };++ const out = await store.insert(db, row);++ if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });++ return out;++ }++++ async function finish(row, reason, detail = null, { release = true } = {}) {++ if (!row || row.state === 'ended') return row;++ if (release && row.machine && row.desk && desks.enabled) {++ try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }++ }++ return db.tx(async (t) => {++ const fresh = await store.get(t, row.id);++ if (!fresh || fresh.state === 'ended') return fresh;++ const ended = { ...fresh, state: 'ended', ended_at: now(), end_reason: reason, end_detail: detail ? String(detail).slice(0, 500) : null };++ const metered = await meter(t, ended, { final: true });++ const saved = await store.update(t, row.id, { state: 'ended', ended_at: ended.ended_at, end_reason: reason, end_detail: ended.end_detail, metered_minutes: metered, updated_at: now() });++ await emit(t, saved, 'run.session.ended');++ return saved;++ });++ }++++ const RESULTS = new Set(['ack', 'nack', 'refused', 'expired']);++ async function act(row, cmd) {++ const v = contracts.validate('run.session-control-request@1', cmd);++ if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid command: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));++ if (row.state !== 'running') throw new SessionError(409, 'run.session.not_running', row.state === 'ended' ? 'the session has ended' : 'the session\'s desk is not running yet');++ const id = cmd.id || `c-${now().toString(36)}`;++ const r = await desks.act(row.machine, row.desk, bare(row.owner), { id, kind: cmd.kind, value: cmd.value });++ if (r === null) {++ const ended = await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });++ return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended' };++ }++ await store.update(db, row.id, { last_used_at: now(), updated_at: now() });++ const out = { session_id: row.id, id: String(r.id || id).slice(0, 64), result: RESULTS.has(r.result) ? r.result : 'expired', state: 'running' };++ if (r.code != null) out.code = String(r.code).slice(0, 80);++ if (r.reason != null) out.reason = String(r.reason).slice(0, 1000);++ if (r.data && typeof r.data === 'object' && out.result === 'ack') out.data = r.data;++ return out;++ }++++ // ── placement ─────────────────────────────────────────────────────────────────────────────────────────────++ async function hostsFor(subject) {++ const c = hostCache.get(subject);++ if (c && now() - c.at < 5000) return c.hosts;++ const hosts = await desks.hosts(subject);++ hostCache.set(subject, { at: now(), hosts });++ if (hostCache.size > 500) hostCache.clear();++ return hosts;++ }++ function candidates(row, hosts) {++ const live = hosts.filter((h) => h && h.online && Number(h.free) > 0 && typeof h.machine_id === 'string');++ if (row.machine_asked) return live.filter((h) => h.machine_id === row.machine_asked && h.trust === 'user-owned');++ const allowed = row.requirements && Array.isArray(row.requirements.trust) ? row.requirements.trust : DEFAULT_TRUST;++ return live.filter((h) => allowed.includes(h.trust))++ .sort((a, b) => (TRUST_ORDER.indexOf(a.trust) - TRUST_ORDER.indexOf(b.trust)) || (Number(b.free) - Number(a.free)));++ }++ const reasonsOf = (row, h) => [++ row.machine_asked ? 'your own machine, as asked' : h.trust === 'user-owned' ? 'your own machine, with room'++ : h.trust === 'first-party' ? 'an OpenVibe desk host with room' : 'a community desk host with room',++ `${Number(h.free)} of ${Number(h.max_browser_desks) || Number(h.free)} desks free there`,++ ...(h.shared ? ['each desk there runs in its own sandbox'] : []),++ ];++++ async function place(row) {++ if (now() - row.created_at >= row.ttl_s * 1000) return finish(row, 'unplaceable', 'no machine had room before the session\'s time ran out', { release: false });++ let hosts;++ try { hosts = await hostsFor(bare(row.owner)); } catch (e) { if (!e.retryable) log.warn(`[Run] session ${row.id}: desk hosts: ${e.code}`); return row; }++ const list = candidates(row, hosts);++ if (!list.length) {++ if (row.machine_asked && !hosts.some((h) => h.machine_id === row.machine_asked)) return finish(row, 'unplaceable', 'that machine is not open to your agent (or it runs no browser desks)', { release: false });++ return row;++ }++ const desk = store.deskOf(row.id);++ for (const h of list) {++ await store.update(db, row.id, { state: 'starting', machine: h.machine_id, desk, trust: h.trust, offer_id: h.shared ? `desk:${h.machine_id}` : null, reasons: reasonsOf(row, h), updated_at: now() });++ let lease;++ try {++ lease = await desks.lease(h.machine_id, bare(row.owner), { session: row.id, desk, kind: 'browser', width: row.width, height: row.height });++ } catch (e) {++ await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });++ hostCache.delete(bare(row.owner));++ if (e.code === 'bot.machine_busy' || e.code === 'bot.desk_busy' || e.retryable) continue;++ if (e.code === 'bot.desk_failed') continue;++ log.warn(`[Run] session ${row.id}: lease on ${h.machine_id} refused: ${e.code}`);++ continue;++ }++ if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }++ return db.tx(async (t) => {++ const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });++ await emit(t, saved, 'run.session.started');++ return saved;++ });++ }++ return store.get(db, row.id);++ }++++ // ── the tick ──────────────────────────────────────────────────────────────────────────────────────────────++ async function tickOnce() {++ for (const row of await store.open(db)) {++ try {++ if (row.state === 'placing') { await place(row); continue; }++ if (row.state === 'starting') {++ if (now() - row.updated_at < STARTING_STALE_MS) continue;++ const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;++ if (lease === undefined) continue;++ if (lease) await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });++ else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });++ continue;++ }++ // running++ if (now() - row.created_at >= row.ttl_s * 1000) { await finish(row, 'ttl', `its ${row.ttl_s} s ran out`); continue; }++ if (now() - (row.last_used_at || row.started_at) >= row.idle_s * 1000) { await finish(row, 'idle', `no command for ${row.idle_s} s`); continue; }++ if (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {++ const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);++ if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }++ if (lease) await store.update(db, row.id, { lease_checked_at: now() });++ }++ await db.tx(async (t) => {++ const fresh = await store.get(t, row.id);++ if (!fresh || fresh.state !== 'running') return;++ const metered = await meter(t, fresh);++ if (metered !== fresh.metered_minutes) await store.update(t, row.id, { metered_minutes: metered });++ });++ } catch (e) {++ log.warn(`[Run] session ${row.id}: ${e.code || e.message}`);++ }++ }++ }++ /** One pass at a time: a pass asked for while one runs waits for it. */++ function tick() {++ if (running) return running;++ running = tickOnce().finally(() => { running = null; });++ return running;++ }++++ return {++ create, act, finish, tick, SessionError,++ get: (id) => store.get(db, id),++ list: (owner, opts) => store.listOf(db, owner, opts),++ present: (row) => store.present(row, now()),++ start() {++ if (timer || !desks.enabled) return;++ timer = setInterval(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); }, tickMs);++ if (timer.unref) timer.unref();++ },++ async stop() { if (timer) clearInterval(timer); timer = null; if (running) await running.catch(() => {}); },++ status: () => ({ enabled: desks.enabled, note: desks.note, tick_ms: tickMs, max_open: maxOpen }),++ };++}++++module.exports = { createSessions, SessionError, OPERATION, SOURCE };nnew file mode 100644@@@ -0,0 +1,115 @@++'use strict';++++/**++ * Run sessions' rows (migrations/0005_sessions.sql) and their run.session@1 shape (contracts 0.223.0).++ *++ * present(row, now) → run.session@1 (desk_seconds counted to now while running)++ * insert(db, row) → the row, or the earlier one with the same (owner, idempotency_key)++ * get(db, id) / listOf(db, owner, { state, limit, cursor })++ * open(db, limit) → sessions not ended, oldest first (the tick's work)++ * update(t, id, fields) → the row after the change++ */++const crypto = require('crypto');++const { ids } = require('openvibe-contracts');++++const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',++ 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',++ 'end_detail', 'metered_minutes', 'lease_checked_at', 'updated_at'];++const JSON_COLUMNS = new Set(['requirements', 'reasons']);++++const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());++const json = (v, dflt) => { if (v == null) return dflt; if (typeof v === 'object') return v; try { return JSON.parse(v); } catch { return dflt; } };++++/** A new session id: ses_<ULID>. */++const newSessionId = () => `ses_${ids.ulid()}`;++/** The desk a session holds on its machine: desk-s + the id's last 16 characters (unique, within the desk pattern). */++const deskOf = (id) => `desk-s${String(id).slice(-16).toLowerCase()}`;++++/** The digest of a create request, for its idempotency key: a repeat with another body is a conflict. */++function digestOf(body) {++ const canonical = (v) => (Array.isArray(v) ? `[${v.map(canonical).join(',')}]`++ : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined && k !== 'idempotency_key').map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`++ : JSON.stringify(v));++ return crypto.createHash('sha256').update(canonical(body)).digest('hex');++}++++/** Seconds the desk ran: from its start to its end, or to now while it runs. */++function deskSeconds(row, now) {++ if (row.started_at == null) return 0;++ const end = row.ended_at != null ? Number(row.ended_at) : now;++ return Math.max(0, Math.round((end - Number(row.started_at)) / 100) / 10);++}++++/** The row as run.session@1. */++function present(row, now = Date.now()) {++ const placed = row.machine && row.desk;++ return {++ id: row.id,++ owner: ids.parseSubject(row.owner),++ via: row.via || null,++ kind: row.kind,++ state: row.state,++ size: { width: Number(row.width), height: Number(row.height) },++ ttl_s: Number(row.ttl_s),++ idle_s: Number(row.idle_s),++ placement: placed ? {++ machine: row.machine, desk: row.desk, trust: row.trust, offer_id: row.offer_id || null, region: null,++ reasons: json(row.reasons, []).slice(0, 8).map((r) => String(r).slice(0, 200)),++ } : null,++ timings: { created_at: iso(row.created_at), started_at: iso(row.started_at), last_used_at: iso(row.last_used_at), ended_at: iso(row.ended_at) },++ end: row.state === 'ended' ? { reason: row.end_reason || 'ended', detail: row.end_detail ? String(row.end_detail).slice(0, 500) : null } : null,++ usage: { desk_seconds: deskSeconds(row, now) },++ idempotency_key: row.idempotency_key || null,++ };++}++++const fromDb = (row) => {++ if (!row) return null;++ const out = { ...row };++ for (const k of JSON_COLUMNS) out[k] = json(row[k], k === 'reasons' ? [] : null);++ for (const k of ['created_at', 'started_at', 'last_used_at', 'ended_at', 'lease_checked_at', 'updated_at']) if (out[k] != null) out[k] = Number(out[k]);++ return out;++};++++async function insert(db, row) {++ const cols = COLUMNS.filter((c) => row[c] !== undefined);++ const vals = cols.map((c) => (JSON_COLUMNS.has(c) && row[c] != null ? JSON.stringify(row[c]) : row[c]));++ const made = await db.maybe(++ `INSERT INTO run_sessions (${cols.join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})++ ON CONFLICT DO NOTHING RETURNING *`, vals);++ if (made) return { row: fromDb(made), created: true };++ const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2', [row.owner, row.idempotency_key]);++ return { row: fromDb(earlier), created: false };++}++++const get = async (db, id) => fromDb(await db.maybe('SELECT * FROM run_sessions WHERE id = $1', [id]));++++/** The owner's sessions, newest first: state running (placing, starting or running; the default), ended, or all. */++async function listOf(db, owner, { state = 'running', limit = 20, cursor = null } = {}) {++ const n = Math.min(100, Math.max(1, Number(limit) || 20));++ const where = ['owner = $1'];++ const args = [owner];++ if (state === 'ended') where.push("state = 'ended'");++ else if (state !== 'all') where.push("state <> 'ended'");++ if (cursor) { args.push(String(cursor)); where.push(`id < $${args.length}`); }++ args.push(n + 1);++ const rows = (await db.many(`SELECT * FROM run_sessions WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT $${args.length}`, args)).map(fromDb);++ const more = rows.length > n;++ const page = rows.slice(0, n);++ return { rows: page, next: more ? page[page.length - 1].id : null };++}++++const open = async (db, limit = 200) => (await db.many("SELECT * FROM run_sessions WHERE state <> 'ended' ORDER BY created_at LIMIT $1", [limit])).map(fromDb);++++/** How many sessions a person holds open now (the per-person cap). */++const openCount = async (db, owner) => Number((await db.one("SELECT count(*)::int AS n FROM run_sessions WHERE owner = $1 AND state <> 'ended'", [owner])).n);++++async function update(t, id, fields) {++ const cols = Object.keys(fields).filter((k) => COLUMNS.includes(k) && k !== 'id');++ if (!cols.length) return get(t, id);++ const vals = cols.map((c) => (JSON_COLUMNS.has(c) && fields[c] != null ? JSON.stringify(fields[c]) : fields[c]));++ const row = await t.maybe(`UPDATE run_sessions SET ${cols.map((c, i) => `${c} = $${i + 2}`).join(', ')} WHERE id = $1 RETURNING *`, [id, ...vals]);++ return fromDb(row);++}++++module.exports = { present, insert, get, listOf, open, openCount, update, newSessionId, deskOf, digestOf, deskSeconds };nnew file mode 100644@@@ -0,0 +1,209 @@++'use strict';++/**++ * Run sessions (contracts 0.223.0, the Desks plan's D6): a browser desk placed for one person and leased through++ * OpenVibe.Bot. The desk client is a fake Bot here (the hosts, leases and commands it is asked for are recorded); the++ * last check runs the real client against a small HTTP Bot to see its token, scope and X-OV-Subject on the wire.++ *++ * node test/sessions.test.js++ */++const assert = require('assert');++const http = require('http');++const contracts = require('openvibe-contracts');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/app');++++const OWN = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';++const SHARED = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2B';++const OPENVIBE = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2C';++++function fakeDesks() {++ const calls = [];++ const leases = new Map(); // `${machine} ${desk}` → lease++ const state = {++ hosts: [++ { machine_id: OWN, trust: 'user-owned', shared: false, online: true, free: 2, max_browser_desks: 2 },++ { machine_id: SHARED, trust: 'community', shared: true, online: true, free: 3, max_browser_desks: 4 },++ { machine_id: OPENVIBE, trust: 'first-party', shared: true, online: true, free: 1, max_browser_desks: 4 },++ ],++ busy: new Set(),++ };++ return {++ calls, leases, state, enabled: true, note: 'a fake Bot',++ async hosts(subject) { calls.push(['hosts', subject]); return state.hosts.map((h) => ({ ...h })); },++ async lease(machine, subject, body) {++ calls.push(['lease', machine, subject, body]);++ if (state.busy.has(machine)) { const e = new Error('busy'); e.code = 'bot.machine_busy'; e.status = 409; throw e; }++ const lease = { machine_id: machine, desk: body.desk, session: body.session, owner: `user:${subject}`, kind: 'browser', width: body.width, height: body.height, shared: machine !== OWN, created_at: new Date().toISOString() };++ leases.set(`${machine} ${body.desk}`, lease);++ return lease;++ },++ async get(machine, desk) { calls.push(['get', machine, desk]); return leases.get(`${machine} ${desk}`) || null; },++ async act(machine, desk, subject, cmd) {++ calls.push(['act', machine, desk, subject, cmd]);++ if (!leases.has(`${machine} ${desk}`)) return null;++ return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: cmd.kind === 'screen' ? { format: 'jpeg', width: 1280, height: 800, content_b64: '/9j/' } : { url: 'https://example.org/', title: 'Example' } };++ },++ async end(machine, desk) { calls.push(['end', machine, desk]); const had = leases.delete(`${machine} ${desk}`); return had ? { ended: true, stopped: true } : null; },++ status: () => ({ enabled: true }),++ };++}++++(async () => {++ const desks = fakeDesks();++ const t = await boot({ appOpts: { desks }, env: { RUN_SESSIONS_PER_PERSON: '3', RUN_SESSION_LEASE_CHECK_MS: '1000' } });++ const { sessions } = t.app.locals;++ const ada = ids.newId('user');++ const bea = ids.newId('user');++ const as = (subject) => ({ token: t.network.signUser({ subject }) });++ const valid = (doc, id) => { const v = contracts.validate(id, doc); assert.ok(v.valid, `${id}: ${JSON.stringify(v.errors)}\n${JSON.stringify(doc)}`); };++ const usageOf = async (id) => (await t.db.many('SELECT envelope FROM run_usage_outbox ORDER BY id')).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((u) => u.id.startsWith(`run:${id}:`));++++ let ses;++ await check('a person opens a session: placed on a shared host (first-party first), its desk leased and running', async () => {++ const r = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', width: 1024, height: 700 } });++ assert.strictEqual(r.status, 201, r.text);++ valid(r.json, 'run.session@1');++ assert.deepStrictEqual([r.json.state, r.json.owner, r.json.via], ['placing', { type: 'user', id: ada }, null]);++ await sessions.tick();++ ses = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(ada))).json;++ valid(ses, 'run.session@1');++ assert.strictEqual(ses.state, 'running');++ assert.deepStrictEqual([ses.placement.machine, ses.placement.trust, ses.placement.offer_id], [OPENVIBE, 'first-party', `desk:${OPENVIBE}`], 'first-party before community; the own machine only when asked');++ assert.match(ses.placement.desk, /^desk-s[0-9a-z]{16}$/);++ assert.ok(ses.placement.reasons.includes('an OpenVibe desk host with room'));++ const lease = desks.calls.find((c) => c[0] === 'lease');++ assert.deepStrictEqual(lease[3], { session: ses.id, desk: ses.placement.desk, kind: 'browser', width: 1024, height: 700 });++ assert.strictEqual(lease[2], ada, 'Bot is asked as the person');++ const started = (await t.outboxRows('run.session.started')).map((e) => e.payload || e);++ assert.ok(started.some((p) => p.id === ses.id && p.state === 'running'), 'run.session.started');++ });++++ await check('commands reach its desk; anyone else\'s call is 404', async () => {++ const r = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });++ assert.strictEqual(r.status, 200, r.text);++ valid(r.json, 'run.session-control-result@1');++ assert.deepStrictEqual([r.json.result, r.json.state, r.json.data.title], ['ack', 'running', 'Example']);++ const act = desks.calls.filter((c) => c[0] === 'act').at(-1);++ assert.deepStrictEqual([act[1], act[2], act[4].kind], [OPENVIBE, ses.placement.desk, 'browser']);++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { kind: 'browser', value: { action: 'read', desk: 'desk-x' } } })).status, 422, 'a value naming a desk');++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { kind: 'exec', value: { argv: ['id'] } } })).status, 422);++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}`, as(bea))).status, 404);++ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/end`, as(bea))).status, 404);++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}`, { token: null })).status, 401);++ const list = (await t.call('GET', '/api/v1/sessions', as(ada))).json;++ valid(list, 'run.session-list-result@1');++ assert.deepStrictEqual(list.sessions.map((s) => s.id), [ses.id]);++ assert.deepStrictEqual((await t.call('GET', '/api/v1/sessions', as(bea))).json.sessions, []);++ });++++ await check('a first-party service opens one for a person, with the capability; an app never does', async () => {++ const svc = (cap, sub = 'svc:actor') => ({ token: t.network.signService({ sub, cap }), headers: { 'X-OV-Subject': bea } });++ const ok = await t.call('POST', '/api/v1/sessions', { ...svc(['run.session.create']), body: { kind: 'browser', requirements: { kind: 'run.browser', mobility: 'session', latency_class: 'interactive', objective: 'cheapest', trust: ['user-owned'] } } });++ assert.strictEqual(ok.status, 201, ok.text);++ assert.deepStrictEqual([ok.json.owner.id, ok.json.via], [bea, 'service:actor']);++ await sessions.tick();++ const placed = (await t.call('GET', `/api/v1/sessions/${ok.json.id}`, svc(['run.session.read']))).json;++ assert.deepStrictEqual([placed.placement.machine, placed.placement.trust], [OWN, 'user-owned'], 'requirements naming user-owned: the person\'s own machine');++ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { ...svc(['run.job.submit']), body: { kind: 'browser' } })).status, 403, 'needs run.session.create');++ const app = await t.call('POST', '/api/v1/sessions', { token: t.network.signService({ sub: 'app:widget', actor_type: 'app', cap: ['run.session.create'] }), headers: { 'X-OV-Subject': bea }, body: { kind: 'browser' } });++ assert.ok([401, 403].includes(app.status), `an app token never opens a session for a person (${app.status})`);++ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.create'] }), body: { kind: 'browser' } })).status, 422, 'for a person');++ await t.call('POST', `/api/v1/sessions/${ok.json.id}/end`, svc(['run.session.control']));++ });++++ await check('an idempotency key answers the first session; another body is a conflict; a person holds three at most', async () => {++ const body = { kind: 'browser', ttl_s: 600, idempotency_key: 'actor:tsk_1' };++ const a = await t.call('POST', '/api/v1/sessions', { ...as(bea), body });++ const b = await t.call('POST', '/api/v1/sessions', { ...as(bea), body });++ assert.deepStrictEqual([a.status, b.status, b.json.id], [201, 200, a.json.id]);++ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { ...body, ttl_s: 700 } })).json.code, 'run.idempotency.conflict');++ await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });++ await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });++ const over = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });++ assert.deepStrictEqual([over.status, over.json.code], [429, 'run.session.limit']);++ const mine = (await t.call('GET', '/api/v1/sessions', as(bea))).json.sessions;++ await Promise.all(mine.map((s) => t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea))));++ });++++ await check('a full host is skipped; its own machine named and not open ends unplaceable', async () => {++ desks.state.busy.add(OPENVIBE);++ const r = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });++ await sessions.tick();++ const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;++ assert.deepStrictEqual([s.state, s.placement.machine, s.placement.trust], ['running', SHARED, 'community']);++ desks.state.busy.delete(OPENVIBE);++ await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));++ const named = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2Z' } });++ await sessions.tick();++ const gone = (await t.call('GET', `/api/v1/sessions/${named.json.id}`, as(bea))).json;++ valid(gone, 'run.session@1');++ assert.deepStrictEqual([gone.state, gone.end.reason], ['ended', 'unplaceable']);++ });++++ await check('idle, ttl and a lost host end it; desk time is metered a minute at a time; end gives the desk back', async () => {++ // Ada's first session has run a while: 2 minutes 30 seconds of desk time, then it idles out.++ t.clock.offset += 150_000;++ const before = (await t.call('GET', `/api/v1/sessions/${ses.id}`, as(ada))).json;++ assert.ok(before.usage.desk_seconds >= 150, `desk time counts while running (${before.usage.desk_seconds})`);++ t.clock.offset += 900_000; // idle_s 900 passed since the last command++ await sessions.tick();++ const idle = (await t.call('GET', `/api/v1/sessions/${ses.id}`, as(ada))).json;++ valid(idle, 'run.session@1');++ assert.deepStrictEqual([idle.state, idle.end.reason], ['ended', 'idle']);++ assert.ok(desks.calls.some((c) => c[0] === 'end' && c[2] === ses.placement.desk), 'the desk was stopped and the lease given back');++ const readings = await usageOf(ses.id);++ readings.forEach((u) => valid(u, 'platform.usage-sample@1'));++ const total = readings.reduce((n, u) => n + u.quantity, 0);++ assert.ok(Math.abs(total - idle.usage.desk_seconds) < 0.2, `the readings add up to the desk time (${total} vs ${idle.usage.desk_seconds})`);++ assert.ok(readings.slice(0, -1).every((u) => u.quantity === 60) && readings.every((u) => u.operation === 'browser.desk' && u.subject === `user:${ada}` && u.node === OPENVIBE));++ const ended = (await t.outboxRows('run.session.ended')).map((e) => e.payload || e);++ assert.ok(ended.some((p) => p.id === ses.id && p.end.reason === 'idle'), 'run.session.ended');++ // A host that drops the desk: the next lease check ends the session host_lost, without stopping anything.++ const r = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', ttl_s: 120 } });++ await sessions.tick();++ const live = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(ada))).json;++ desks.leases.clear();++ t.clock.offset += 5_000;++ await sessions.tick();++ const lost = (await t.call('GET', `/api/v1/sessions/${live.id}`, as(ada))).json;++ assert.deepStrictEqual([lost.state, lost.end.reason], ['ended', 'host_lost']);++ // ttl: a session's whole life is bounded.++ const s3 = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', ttl_s: 60, idle_s: 3600 } });++ await sessions.tick();++ t.clock.offset += 61_000;++ await sessions.tick();++ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s3.json.id}`, as(ada))).json.end.reason, 'ttl');++ // An ended session takes no command.++ const late = await t.call('POST', `/api/v1/sessions/${s3.json.id}/commands`, { ...as(ada), body: { kind: 'screen', value: {} } });++ assert.deepStrictEqual([late.status, late.json.code], [409, 'run.session.not_running']);++ });++++ await check('the real desk client: Run\'s token for Bot with bot.desk.session, the person in X-OV-Subject', async () => {++ const seen = [];++ const bot = http.createServer((req, res) => {++ let body = '';++ req.on('data', (c) => { body += c; });++ req.on('end', () => {++ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body });++ if (req.url === '/api/v1/desk-hosts') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ hosts: [{ machine_id: SHARED, trust: 'community', online: true, free: 1 }] })); }++ if (req.method === 'POST' && /\/desk-leases$/.test(req.url)) { res.writeHead(409, { 'Content-Type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'bot.machine_busy', detail: 'full' })); }++ res.writeHead(404, { 'Content-Type': 'application/problem+json' });++ return res.end(JSON.stringify({ code: 'bot.lease_not_found' }));++ });++ });++ await new Promise((r) => bot.listen(0, '127.0.0.1', r));++ const { createDeskClient } = require('../server/sessions/desks');++ const config = { ...t.config, oauth: { clientId: 'run', clientSecret: 'secret' }, dispatch: { ...t.config.dispatch, botUrl: `http://127.0.0.1:${bot.address().port}` } };++ const client = createDeskClient({ config });++ assert.strictEqual(client.enabled, true);++ assert.deepStrictEqual((await client.hosts(ada)).map((h) => h.machine_id), [SHARED]);++ await assert.rejects(() => client.lease(SHARED, ada, { session: 'ses_x', desk: 'desk-x', kind: 'browser' }), (e) => e.code === 'bot.machine_busy' && e.status === 409);++ assert.strictEqual(await client.get(SHARED, 'desk-x', ada), null, '404: no such lease');++ assert.ok(seen.every((c) => c.subject === ada && /^Bearer /.test(c.auth)));++ assert.ok(t.network.tokenRequests.scopes.includes('bot.desk.session'), 'its token is scoped bot.desk.session');++ bot.close();++ });++++ await t.close();++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -122,6 +122,31 @@ refused like any other token without the grant.
@@ -122,6 +122,31 @@ refused like any other token without the grant.
with the grant is refused. Every request body and query string is validated against its released contract, and so is
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`.
every answer before it is sent; errors are RFC 9457 `application/problem+json`.
+## 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 waits `placing`
+ until its `ttl_s`, then ends `unplaceable`.
+- **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
+ session `host_lost`. A person holds `RUN_SESSIONS_PER_PERSON` (3) at once.
+- **Metering.** One `platform.usage-sample@1` per minute of desk time (`run:<session>:<minute>`, operation
+ `browser.desk`, the last one the remainder) in `run_usage_outbox`, posted to Billing by the usage relay.
+- **Events.** `run.session.started` and `run.session.ended`, in the transaction that records each.
+
## Jobs
## Jobs
- **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key
- **Submit.** `Idempotency-Key` (or `body.idempotency_key`) makes a retry safe; with neither, Run mints its own key
@@ -227,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.
@@ -227,7 +252,7 @@ until Run has a product surface; the vhost below is installed then.
| `STATUS.json` | What this repository is, what it does today and where it runs |
| `STATUS.json` | What this repository is, what it does today and where it runs |
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.222.0
+- openvibe-contracts: v0.223.0
- openvibe-sdk: v0.67.0
- openvibe-sdk: v0.67.0
- openvibe-shared: v4.19.0
- openvibe-shared: v4.19.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db) + Valkey 9",
"port": 4920,
"port": 4920,
"domain": "openvibe.run",
"domain": "openvibe.run",
- "contracts": "openvibe-contracts v0.222.0",
+ "contracts": "openvibe-contracts v0.223.0",
"sdk": "openvibe-sdk v0.67.0",
"sdk": "openvibe-sdk v0.67.0",
"shared": "openvibe-shared v4.19.0",
"shared": "openvibe-shared v4.19.0",
"packages": [
"packages": [
- "openvibe-contracts v0.222.0",
+ "openvibe-contracts v0.223.0",
"openvibe-sdk v0.67.0",
"openvibe-sdk v0.67.0",
"openvibe-shared v4.19.0"
"openvibe-shared v4.19.0"
],
],
@@ -21,7 +21,10 @@
@@ -21,7 +21,10 @@
"run.job.list",
"run.job.list",
"run.job.cancel",
"run.job.cancel",
"run.job.stream",
"run.job.stream",
- "run.job.admin"
+ "run.job.admin",
+ "run.session.create",
+ "run.session.read",
+ "run.session.control"
],
],
"events": [
"events": [
"run.job.queued",
"run.job.queued",
@@ -29,7 +32,9 @@
@@ -29,7 +32,9 @@
"run.job.succeeded",
"run.job.succeeded",
"run.job.failed",
"run.job.failed",
"run.job.cancelled",
"run.job.cancelled",
- "run.job.expired"
+ "run.job.expired",
+ "run.session.started",
+ "run.session.ended"
],
],
"deployed": true,
"deployed": true,
"deployment": "openvibe-ovh: unit openvibe-run.service on 127.0.0.1:4920 since 2026-10-07 (release layout /opt/openvibe.run, env /etc/openvibe/run.env, database ov_run), deployed with `sudo ovhost deploy run`. The job API is reached by first-party callers on loopback; jobs are placed on people's own Bot nodes through Bot's internal jobs API (bot.job.dispatch). openvibe.run itself is parked (a 302 to openvibe.network) until Run has a product surface; the vhost in deploy/nginx/openvibe.run.conf is installed then. Its rules stay: /api/health, /api/ready, /release.json and the job API proxied, the client address from $remote_addr only, /internal/ and /metrics refused at the edge.",
"deployment": "openvibe-ovh: unit openvibe-run.service on 127.0.0.1:4920 since 2026-10-07 (release layout /opt/openvibe.run, env /etc/openvibe/run.env, database ov_run), deployed with `sudo ovhost deploy run`. The job API is reached by first-party callers on loopback; jobs are placed on people's own Bot nodes through Bot's internal jobs API (bot.job.dispatch). openvibe.run itself is parked (a 302 to openvibe.network) until Run has a product surface; the vhost in deploy/nginx/openvibe.run.conf is installed then. Its rules stay: /api/health, /api/ready, /release.json and the job API proxied, the client address from $remote_addr only, /internal/ and /metrics refused at the edge.",
new file mode 100644
new file mode 100644
@@ -0,0 +1,37 @@
@@ -0,0 +1,37 @@
+-- phase: expand
+-- Run sessions (contracts 0.223.0 run.session@1, the Desks plan's D6): a browser desk Run places for one person, on
+-- one of their own machines or on a desk host that serves other people, and leases through OpenVibe.Bot
+-- (bot.desk.session). One row per session; its desk time is metered a minute at a time into run_usage_outbox
+-- (run:<session>:<minute>) and its start and end are run.session.* events in run_events_outbox.
+CREATE TABLE IF NOT EXISTS run_sessions (
+ id text PRIMARY KEY, -- ses_<ULID>
+ owner text NOT NULL, -- user:usr_…
+ via text, -- service:actor, or null
+ kind text NOT NULL DEFAULT 'browser' CHECK (kind IN ('browser')),
+ state text NOT NULL CHECK (state IN ('placing', 'starting', 'running', 'ended')),
+ width integer NOT NULL,
+ height integer NOT NULL,
+ ttl_s integer NOT NULL,
+ idle_s integer NOT NULL,
+ machine_asked text, -- the person's own machine the request named
+ requirements jsonb,
+ idempotency_key text,
+ request_digest text NOT NULL,
+ machine text, -- where it runs (placement)
+ desk text,
+ trust text,
+ offer_id text,
+ reasons jsonb NOT NULL DEFAULT '[]',
+ created_at bigint NOT NULL,
+ started_at bigint,
+ last_used_at bigint,
+ ended_at bigint,
+ end_reason text CHECK (end_reason IS NULL OR end_reason IN ('ended', 'ttl', 'idle', 'host_lost', 'unplaceable', 'failed')),
+ end_detail text,
+ metered_minutes integer NOT NULL DEFAULT 0, -- whole minutes of desk time already queued for Billing
+ lease_checked_at bigint,
+ updated_at bigint NOT NULL
+);
+CREATE UNIQUE INDEX IF NOT EXISTS run_sessions_idem ON run_sessions (owner, idempotency_key) WHERE idempotency_key IS NOT NULL;
+CREATE INDEX IF NOT EXISTS run_sessions_owner ON run_sessions (owner, created_at DESC);
+CREATE INDEX IF NOT EXISTS run_sessions_open ON run_sessions (state) WHERE state <> 'ended';
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -872,9 +872,9 @@
@@ -872,9 +872,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.223.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
+ "integrity": "sha512-FWcPL1951IeYcelp3dUiDRJEiFkTbsLMLth9RVUFfuLTuHGQKoQXA5HIu6H2e2BupPTyBH+AZD00LqmA6jDmdg==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -26,7 +26,7 @@
@@ -26,7 +26,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.223.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.19.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
+'use strict';
+
+/**
+ * Run sessions' API (contracts 0.223.0, the Desks plan's D6), under /api/v1 behind api/auth.js:
+ *
+ * POST /api/v1/sessions run.session.create run.session-create-request@1 → 201 run.session@1 (200 for
+ * an idempotent repeat)
+ * GET /api/v1/sessions run.session.read ?state=running|ended|all&limit=&cursor= → run.session-list-result@1
+ * 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 → run.session-control-result@1
+ * POST /api/v1/sessions/:id/end run.session.control → run.session@1, ended
+ *
+ * A session is its person's: their own Network token, or a first-party service (svc:) holding the capability and
+ * naming the person in X-OV-Subject (recorded as via). Anyone else's session is 404, never confirmed to exist. An app,
+ * mod or agent token never opens a session for a person here.
+ */
+const express = require('express');
+const { capabilities, http, ids } = require('openvibe-contracts');
+
+const USER_SUBJECT = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const SESSION_RE = /^ses_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function sessionsRouter({ sessions }) {
+ const r = express.Router();
+ const problem = (res, req, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ /** The person a call acts for, and the service it came through. → { owner: 'user:usr_…', via } or a sent problem (null). */
+ function who(req, res, cap) {
+ const p = req.principal || { kind: 'anonymous' };
+ if (p.kind === 'anonymous') { problem(res, req, 401, 'token.required', `${cap} needs your OpenVibe sign-in, or a first-party service acting for you`); return null; }
+ if (p.kind === 'user') return { owner: `user:${p.subject}`, via: null };
+ const sub = String(p.sub || '');
+ if (!sub.startsWith('svc:')) { problem(res, req, 403, 'capability.denied', 'a session is opened by its person, or by a first-party service acting for them'); return null; }
+ const decision = capabilities.check(p.claims, cap);
+ if (!decision.allowed) { problem(res, req, 403, decision.code || 'capability.denied', decision.reason || `${cap} not granted`); return null; }
+ const subject = String(req.get('X-OV-Subject') || '').trim();
+ if (!USER_SUBJECT.test(subject) || !ids.isSubjectId('user', subject)) { problem(res, req, 422, 'run.session.subject_required', 'a service opens a session for a person: X-OV-Subject usr_…'); return null; }
+ return { owner: `user:${subject}`, via: `service:${sub.slice(4)}` };
+ }
+ async function mine(req, res, cap) {
+ const w = who(req, res, cap);
+ if (!w) return null;
+ const id = String(req.params.id || '');
+ const row = SESSION_RE.test(id) ? await sessions.get(id) : null;
+ if (!row || row.owner !== w.owner) { problem(res, req, 404, 'run.session.not_found', 'no such session of yours'); return null; }
+ return row;
+ }
+ const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch((err) => {
+ if (err && err.status && err.code) return problem(res, req, err.status, err.code, err.detail || err.message);
+ return next(err);
+ });
+
+ r.post('/sessions', wrap(async (req, res) => {
+ const w = who(req, res, 'run.session.create');
+ if (!w) return;
+ const { row, created } = await sessions.create(w.owner, w.via, req.body && typeof req.body === 'object' ? req.body : {});
+ res.status(created ? 201 : 200).json(sessions.present(row));
+ }));
+ r.get('/sessions', wrap(async (req, res) => {
+ const w = who(req, res, 'run.session.read');
+ if (!w) return;
+ const state = ['running', 'ended', 'all'].includes(String(req.query.state)) ? String(req.query.state) : 'running';
+ const cursor = SESSION_RE.test(String(req.query.cursor || '')) ? String(req.query.cursor) : null;
+ const page = await sessions.list(w.owner, { state, limit: req.query.limit, cursor });
+ res.json({ sessions: page.rows.map(sessions.present), next_cursor: page.next });
+ }));
+ r.get('/sessions/:id', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.read');
+ if (row) res.json(sessions.present(row));
+ }));
+ r.post('/sessions/:id/commands', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.control');
+ if (!row) return;
+ res.json(await sessions.act(row, req.body && typeof req.body === 'object' ? req.body : {}));
+ }));
+ r.post('/sessions/:id/end', wrap(async (req, res) => {
+ const row = await mine(req, res, 'run.session.control');
+ if (!row) return;
+ const ended = await sessions.finish(row, 'ended', 'ended by its person');
+ res.json(sessions.present(ended || row));
+ }));
+ return r;
+}
+
+module.exports = { sessionsRouter };
@@ -30,6 +30,9 @@ const { createRunEvents } = require('./jobs/events');
@@ -30,6 +30,9 @@ const { createRunEvents } = require('./jobs/events');
const { createDispatcher } = require('./dispatch');
const { createDispatcher } = require('./dispatch');
const { createApiAuth } = require('./api/auth');
const { createApiAuth } = require('./api/auth');
const { v1Router } = require('./api/v1');
const { v1Router } = require('./api/v1');
+const { sessionsRouter } = require('./api/sessions');
+const { createDeskClient } = require('./sessions/desks');
+const { createSessions } = require('./sessions/service');
const { inputError } = require('./util');
const { inputError } = require('./util');
const { createRunReadiness, registerRunGauges } = require('./observability');
const { createRunReadiness, registerRunGauges } = require('./observability');
@@ -57,6 +60,9 @@ function createApp(opts = {}) {
@@ -57,6 +60,9 @@ function createApp(opts = {}) {
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
const dispatch = opts.dispatch || createDispatcher({ config, db, store: require('./jobs/store'), events, stream, log, now, fetchImpl });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
const membership = opts.membership || createProjectMembership(config, { fetchImpl, now });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
const apiAuth = createApiAuth({ config, keys, userAuth, membership, log });
+ // Run sessions (contracts 0.223.0): browser desks Bot leases per session; the tick starts in server/index.js.
+ const desks = opts.desks || createDeskClient({ config, fetchImpl, now });
+ const sessions = opts.sessions || createSessions({ config, db, desks, outbox, now, log });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const release = require('openvibe-shared/release').createRelease({ service: 'run', root: path.join(__dirname, '..') });
const app = express();
const app = express();
@@ -94,6 +100,7 @@ function createApp(opts = {}) {
@@ -94,6 +100,7 @@ function createApp(opts = {}) {
express.json({ limit: JSON_LIMIT }),
express.json({ limit: JSON_LIMIT }),
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
(req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); },
apiAuth.middleware,
apiAuth.middleware,
+ sessionsRouter({ sessions }),
v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));
v1Router({ config, db, apiAuth, stream, tickets, events, dispatch, now, log }));
// ── The public site (openvibe.run): the front page, the legal pages and the Frame's shared assets. ──
// ── The public site (openvibe.run): the front page, the legal pages and the Frame's shared assets. ──
@@ -152,7 +159,7 @@ function createApp(opts = {}) {
@@ -152,7 +159,7 @@ function createApp(opts = {}) {
return res.status(500).type('text/plain').send('Something went wrong\n');
return res.status(500).type('text/plain').send('Something went wrong\n');
});
});
- Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics });
+ Object.assign(app.locals, { config, db, indexnow, valkey, keys, outbox, events, stream, tickets, dispatch, apiAuth, metrics, desks, sessions });
return app;
return app;
}
}
@@ -111,6 +111,14 @@ function loadConfig(env = process.env) {
@@ -111,6 +111,14 @@ function loadConfig(env = process.env) {
// The dispatcher bridge to Bot (plan T14 step 6, R1c): Bot holds the node link and the per-second
// The dispatcher bridge to Bot (plan T14 step 6, R1c): Bot holds the node link and the per-second
// metering; Run calls it as the service principal `svc:run` for audience openvibe.bot. Without
// metering; Run calls it as the service principal `svc:run` for audience openvibe.bot. Without
// OV_OAUTH_CLIENT_SECRET Run cannot mint that token and nothing is sent (jobs stay queued).
// OV_OAUTH_CLIENT_SECRET Run cannot mint that token and nothing is sent (jobs stay queued).
+ // Run sessions (contracts 0.223.0, server/sessions/): one pass over the open ones every tickMs; a person holds at
+ // most maxOpen at once; a running one's lease is checked on Bot every leaseCheckMs.
+ sessions: {
+ tickMs: Math.max(200, int(env.RUN_SESSION_TICK_MS, 2000)),
+ maxOpen: Math.max(1, int(env.RUN_SESSIONS_PER_PERSON, 3)),
+ leaseCheckMs: Math.max(1000, int(env.RUN_SESSION_LEASE_CHECK_MS, 30000)),
+ },
+
dispatch: {
dispatch: {
botUrl: trim(env.RUN_BOT_URL || 'http://127.0.0.1:4630'),
botUrl: trim(env.RUN_BOT_URL || 'http://127.0.0.1:4630'),
botAudience: env.RUN_BOT_AUDIENCE || 'openvibe.bot',
botAudience: env.RUN_BOT_AUDIENCE || 'openvibe.bot',
@@ -9,6 +9,8 @@
@@ -9,6 +9,8 @@
* run.job.failed job_exit with another code, or reason limit, stopped or failed
* run.job.failed job_exit with another code, or reason limit, stopped or failed
* run.job.cancelled POST /api/v1/jobs/:id/cancel, or job_exit reason cancelled
* run.job.cancelled POST /api/v1/jobs/:id/cancel, or job_exit reason cancelled
* run.job.expired ttl_ms ran out (run.job.unplaceable or run.job.ttl)
* run.job.expired ttl_ms ran out (run.job.unplaceable or run.job.ttl)
+ * run.session.started a session's desk answered (server/sessions/service.js)
+ * run.session.ended a session ended (ended, ttl, idle, host_lost, unplaceable or failed)
*
*
* A row is written inside the transaction that makes the change (jobs/events.js: the row is keyed by
* A row is written inside the transaction that makes the change (jobs/events.js: the row is keyed by
* `run:<job id>:<state>`, and it validates its envelope against events.event-envelope@1 and its payload
* `run:<job id>:<state>`, and it validates its envelope against events.event-envelope@1 and its payload
@@ -21,7 +23,8 @@ const { createServiceOutbox } = require('openvibe-sdk/events');
@@ -21,7 +23,8 @@ const { createServiceOutbox } = require('openvibe-sdk/events');
const TABLE = 'run_events_outbox'; // migrations/0001_jobs.sql
const TABLE = 'run_events_outbox'; // migrations/0001_jobs.sql
const ACTOR = { type: 'service', id: 'run' };
const ACTOR = { type: 'service', id: 'run' };
-const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired'];
+const EVENT_TYPES = ['run.job.queued', 'run.job.started', 'run.job.succeeded', 'run.job.failed', 'run.job.cancelled', 'run.job.expired',
+ 'run.session.started', 'run.session.ended']; // server/sessions/service.js (contracts 0.223.0)
function createRunOutbox({ db, config, fetchImpl, now, log = console }) {
function createRunOutbox({ db, config, fetchImpl, now, log = console }) {
return createServiceOutbox({
return createServiceOutbox({
@@ -34,7 +34,7 @@ async function main() {
@@ -34,7 +34,7 @@ async function main() {
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const valkey = createValkey({ url: config.valkey.url, prefix: config.valkey.prefix });
const app = createApp({ config, db, valkey, registry });
const app = createApp({ config, db, valkey, registry });
- const { keys, outbox, stream, tickets, dispatch, indexnow } = app.locals;
+ const { keys, outbox, stream, tickets, dispatch, indexnow, sessions } = app.locals;
keys.start();
keys.start();
const ticketKey = tickets.start();
const ticketKey = tickets.start();
@@ -44,11 +44,13 @@ async function main() {
@@ -44,11 +44,13 @@ async function main() {
// credentials: start() then does nothing and every job stays queued.
// credentials: start() then does nothing and every job stays queued.
outbox.start();
outbox.start();
dispatch.start();
dispatch.start();
+ // Run sessions (server/sessions/): place, watch and meter the open ones. Off without the Bot token.
+ sessions.start();
}
}
const server = app.listen(config.port, config.host, () => {
const server = app.listen(config.port, config.host, () => {
console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);
console.log(`[Run] ${config.nodeEnv} on http://${config.host}:${config.port} → ${config.baseUrl} (store ${db.store}; valkey ${valkey ? 'on' : 'off: this process only'})`);
- console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}`);
+ console.log(`[Run] jobs ${config.jobs.enabled ? 'on' : 'off (RUN_JOBS=off)'}; events relay ${outbox.enabled ? `→ ${config.events.url}` : 'off (run.job.* events wait in the outbox)'}; stream tickets ${ticketKey.enabled ? `on (${config.stream.keyId}${ticketKey.ephemeral ? ', ephemeral key' : ''})` : 'off (no stream key)'}; dispatcher ${dispatch.enabled ? `on (poll ${config.dispatch.pollMs} ms): ${dispatch.note}` : `off: ${dispatch.note}`}; ${dispatch.status().usage_relay.note}; ${sessions.status().note}`);
});
});
server.keepAliveTimeout = 65_000;
server.keepAliveTimeout = 65_000;
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
// Request telemetry (openvibe-sdk/service, plan T1): one aggregated sample per route template, method and status
@@ -72,6 +74,7 @@ async function main() {
@@ -72,6 +74,7 @@ async function main() {
() => keys.stop(),
() => keys.stop(),
() => tickets.stop(),
() => tickets.stop(),
() => dispatch.stop(),
() => dispatch.stop(),
+ () => sessions.stop(),
() => stream.close(),
() => stream.close(),
() => outbox.stop(),
() => outbox.stop(),
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,77 @@
@@ -0,0 +1,77 @@
+'use strict';
+
+/**
+ * The desk client: Run → OpenVibe.Bot for its sessions (contracts 0.223.0, capability bot.desk.session). Bot owns the
+ * machines and their desks; Run asks it, as the session's person (X-OV-Subject), where a desk may go, leases one,
+ * acts on it and ends it:
+ *
+ * hosts(subject) GET /api/v1/desk-hosts → [{ machine_id, trust, … }]
+ * lease(machine, subject, body) POST /api/v1/machines/:id/desk-leases → bot.desk-lease@1
+ * get(machine, desk, subject) GET /api/v1/machines/:id/desk-leases/:desk → the lease | null (404)
+ * act(machine, desk, subject, { id, kind, value }) POST …/desk-leases/:desk/commands → bot.command-result@1 fields
+ * end(machine, desk, subject) DELETE /api/v1/machines/:id/desk-leases/:desk → { ended, stopped } | null
+ *
+ * The token is Run's Network client-credentials token for audience openvibe.bot with scope bot.desk.session (a 401
+ * drops it and the call is tried once more). Off without OV_OAUTH_CLIENT_SECRET: every call fails retryable and
+ * sessions wait placing. A DeskError carries Bot's own code (409 bot.machine_busy, 404 bot.device_not_found …).
+ */
+const { createServiceTokenClient } = require('openvibe-sdk/auth');
+
+const SCOPE = 'bot.desk.session';
+
+class DeskError extends Error {
+ constructor(status, code, detail, { retryable = false } = {}) {
+ super(detail || code);
+ this.name = 'DeskError';
+ this.status = status;
+ this.code = code;
+ this.detail = detail;
+ this.retryable = retryable;
+ }
+}
+
+function createDeskClient({ config, fetchImpl = globalThis.fetch, now = () => Date.now() } = {}) {
+ const { botUrl, botAudience, timeoutMs } = config.dispatch;
+ const { clientId, clientSecret } = config.oauth;
+ const tokens = clientSecret
+ ? createServiceTokenClient({ tokenUrl: `${config.network.internalUrl}/oauth/token`, clientId, clientSecret, audience: botAudience, scope: SCOPE, fetch: fetchImpl, now })
+ : null;
+ const enabled = Boolean(tokens && botUrl);
+ const note = enabled ? `sessions lease desks on Bot: ${botUrl}, capability ${SCOPE}` : 'sessions are off: OV_OAUTH_CLIENT_SECRET is unset, so Run cannot mint its Bot token';
+
+ async function call(method, path, subject, body) {
+ if (!enabled) throw new DeskError(503, 'run.session.disabled', note, { retryable: true });
+ for (let attempt = 0; ; attempt++) {
+ const headers = { Accept: 'application/json', 'X-OV-Subject': subject, ...(body !== undefined ? { 'Content-Type': 'application/json' } : {}) };
+ try { Object.assign(headers, await tokens.authHeaders()); }
+ catch { throw new DeskError(503, 'run.session.no_token', 'Run could not mint its Bot token from Network', { retryable: true }); }
+ let res;
+ try {
+ res = await fetchImpl(`${botUrl}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), signal: AbortSignal.timeout(Math.max(timeoutMs, 35000)) });
+ } catch (e) {
+ const why = e && (e.name === 'TimeoutError' || e.name === 'AbortError') ? 'no answer in time' : 'unreachable';
+ throw new DeskError(503, 'run.session.bot_unavailable', `Bot did not answer (${why})`, { retryable: true });
+ }
+ if (res.status === 401 && attempt === 0) { tokens.invalidate(); continue; }
+ const doc = await res.json().catch(() => null);
+ if (res.ok) return doc || {};
+ const code = doc && typeof doc.code === 'string' ? doc.code : null;
+ const detail = doc && typeof doc.detail === 'string' ? doc.detail.slice(0, 300) : null;
+ if (res.status === 404) return null;
+ throw new DeskError(res.status, code || 'run.session.bot_refused', detail || `Bot answered HTTP ${res.status}`, { retryable: res.status >= 500 || res.status === 429 });
+ }
+ }
+ const m = (id) => encodeURIComponent(id);
+ return {
+ enabled,
+ note,
+ async hosts(subject) { const out = await call('GET', '/api/v1/desk-hosts', subject); return out && Array.isArray(out.hosts) ? out.hosts : []; },
+ lease: (machine, subject, body) => call('POST', `/api/v1/machines/${m(machine)}/desk-leases`, subject, body),
+ get: (machine, desk, subject) => call('GET', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}`, subject),
+ act: (machine, desk, subject, cmd) => call('POST', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}/commands`, subject, cmd),
+ end: (machine, desk, subject) => call('DELETE', `/api/v1/machines/${m(machine)}/desk-leases/${m(desk)}`, subject),
+ status: () => ({ enabled, note, bot_url: botUrl, capability: SCOPE }),
+ };
+}
+
+module.exports = { createDeskClient, DeskError, SCOPE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,252 @@
@@ -0,0 +1,252 @@
+'use strict';
+
+/**
+ * Run sessions (contracts 0.223.0, the Desks plan's D6): a browser desk Run places for one person and OpenVibe.Bot
+ * leases to it (server/sessions/desks.js).
+ *
+ * create(owner, via, body) a validated run.session-create-request@1 → { row, created } (state placing)
+ * act(row, cmd) one run.session-control-request@1 on its desk → run.session-control-result@1
+ * finish(row, reason, detail) end it: the desk stopped, the lease given back, the last desk time metered
+ * tick() one pass over the open sessions: place the ones placing, end the ones past their
+ * ttl_s or idle_s or whose desk went away, meter running desk time a minute at a time
+ * start() / stop() the tick on its interval (server/index.js; nothing runs at module load)
+ *
+ * PLACEMENT. The request's own machine (one of the person's, where they let agents work) or nowhere. Otherwise the
+ * hosts Bot lists for the person that are online with room, in the trust classes the requirements allow (default
+ * first-party then community; user-owned only when the requirements name it): user-owned first, then first-party,
+ * partner, community, and the most free desks first within a class. A host that turns out to be full is skipped and
+ * the next one tried; with none the session waits placing until its ttl_s, then ends unplaceable.
+ *
+ * METERING. One platform.usage-sample@1 per minute of desk time (run:<session>:<minute>, quantity 60; the last one
+ * the remainder), service run, operation browser.desk, unit s, written in the transaction that records it, into
+ * run_usage_outbox (Run's usage relay posts it to Billing, server/jobs/usage.js). EVENTS: run.session.started when
+ * the desk answered, run.session.ended when it ended, in run_events_outbox, in the same transaction.
+ */
+const contracts = require('openvibe-contracts');
+const store = require('./store');
+const usage = require('../jobs/usage');
+const { TABLE: EVENTS_TABLE, ACTOR } = require('../events/outbox');
+
+const TRUST_ORDER = ['user-owned', 'first-party', 'partner', 'community'];
+const DEFAULT_TRUST = ['first-party', 'community'];
+const STARTING_STALE_MS = 60_000;
+const OPERATION = 'browser.desk';
+const SOURCE = 'openvibe-run.session';
+
+class SessionError extends Error {
+ constructor(status, code, detail) { super(detail || code); this.status = status; this.code = code; this.detail = detail; }
+}
+
+function createSessions({ config, db, desks, outbox, now = () => Date.now(), log = console }) {
+ const cfg = config.sessions || {};
+ const tickMs = cfg.tickMs || 2000;
+ const leaseCheckMs = cfg.leaseCheckMs || 30_000;
+ const maxOpen = cfg.maxOpen || 3;
+ let timer = null;
+ let running = null;
+ const hostCache = new Map(); // subject → { at, hosts }
+
+ const bare = (owner) => String(owner).replace(/^user:/, '');
+
+ // ── events and usage, inside the caller's transaction ─────────────────────────────────────────────────────
+ async function emit(t, row, event) {
+ const payload = store.present(row, now());
+ contracts.assertValid(`${event}@1`, payload);
+ const prepared = outbox.events.prepare({
+ event_type: event, version: 1, source: 'run', actor: ACTOR, timestamp: new Date(now()).toISOString(),
+ subject: { type: 'session', id: row.id }, visibility: 'internal', priority: 'low', payload,
+ }, { now: now() });
+ contracts.assertValid('events.event-envelope@1', prepared);
+ await t.maybe(`INSERT INTO ${EVENTS_TABLE} (event_id, envelope, created_at) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING RETURNING id`,
+ [`run:${row.id}:${event}`, JSON.stringify(prepared), now()]);
+ }
+ /** Queue the desk time not yet metered: whole minutes, and with `final` the remainder too. → minutes now metered */
+ async function meter(t, row, { final = false } = {}) {
+ if (row.started_at == null || !row.machine) return row.metered_minutes || 0;
+ const secs = store.deskSeconds(row, now());
+ const whole = Math.floor(secs / 60);
+ const readings = [];
+ const one = (n, quantity) => {
+ const key = `run:${row.id}:${n}`;
+ return { id: key, idempotency_key: key, service: usage.SERVICE, operation: OPERATION, unit: 's', quantity,
+ at: new Date(row.started_at + n * 60_000).toISOString(), source: SOURCE, subject: row.owner, node: row.machine };
+ };
+ for (let n = row.metered_minutes || 0; n < whole; n++) readings.push(one(n, 60));
+ let metered = Math.max(row.metered_minutes || 0, whole);
+ const rest = Math.round((secs - whole * 60) * 10) / 10;
+ if (final && rest > 0 && metered === whole) { readings.push(one(whole, rest)); metered = whole + 1; }
+ if (readings.length) await usage.enqueue(t, readings, now());
+ return metered;
+ }
+
+ // ── create, act, finish ───────────────────────────────────────────────────────────────────────────────────
+ async function create(owner, via, body) {
+ const v = contracts.validate('run.session-create-request@1', body);
+ if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid session request: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
+ const digest = store.digestOf(body);
+ if (body.idempotency_key) {
+ const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2 AND created_at > $3', [owner, body.idempotency_key, now() - 86_400_000]);
+ if (earlier) {
+ if (earlier.request_digest !== digest) throw new SessionError(409, 'run.idempotency.conflict', 'that idempotency key was used with another request');
+ return { row: await store.get(db, earlier.id), created: false };
+ }
+ }
+ if ((await store.openCount(db, owner)) >= maxOpen) throw new SessionError(429, 'run.session.limit', `at most ${maxOpen} sessions at once: end one first`);
+ const at = now();
+ const row = {
+ id: store.newSessionId(), owner, via: via || null, kind: 'browser', state: 'placing',
+ width: body.width || 1280, height: body.height || 800, ttl_s: body.ttl_s || 3600, idle_s: body.idle_s || 900,
+ machine_asked: body.machine || null, requirements: body.requirements || null, idempotency_key: body.idempotency_key || null,
+ request_digest: digest, reasons: [], created_at: at, updated_at: at,
+ };
+ const out = await store.insert(db, row);
+ if (out.created) setImmediate(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); });
+ return out;
+ }
+
+ async function finish(row, reason, detail = null, { release = true } = {}) {
+ if (!row || row.state === 'ended') return row;
+ if (release && row.machine && row.desk && desks.enabled) {
+ try { await desks.end(row.machine, row.desk, bare(row.owner)); } catch (e) { log.warn(`[Run] session ${row.id}: the desk did not end cleanly (${e.code || e.message})`); }
+ }
+ return db.tx(async (t) => {
+ const fresh = await store.get(t, row.id);
+ if (!fresh || fresh.state === 'ended') return fresh;
+ const ended = { ...fresh, state: 'ended', ended_at: now(), end_reason: reason, end_detail: detail ? String(detail).slice(0, 500) : null };
+ const metered = await meter(t, ended, { final: true });
+ const saved = await store.update(t, row.id, { state: 'ended', ended_at: ended.ended_at, end_reason: reason, end_detail: ended.end_detail, metered_minutes: metered, updated_at: now() });
+ await emit(t, saved, 'run.session.ended');
+ return saved;
+ });
+ }
+
+ const RESULTS = new Set(['ack', 'nack', 'refused', 'expired']);
+ async function act(row, cmd) {
+ const v = contracts.validate('run.session-control-request@1', cmd);
+ if (!v.valid) throw new SessionError(422, 'run.session.invalid', `invalid command: ${(v.errors && v.errors[0] && v.errors[0].message) || 'bad value'}`.slice(0, 300));
+ if (row.state !== 'running') throw new SessionError(409, 'run.session.not_running', row.state === 'ended' ? 'the session has ended' : 'the session\'s desk is not running yet');
+ const id = cmd.id || `c-${now().toString(36)}`;
+ const r = await desks.act(row.machine, row.desk, bare(row.owner), { id, kind: cmd.kind, value: cmd.value });
+ if (r === null) {
+ const ended = await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false });
+ return { session_id: row.id, id, result: 'refused', code: 'run.session.host_lost', reason: 'the session\'s machine went away', state: ended ? ended.state : 'ended' };
+ }
+ await store.update(db, row.id, { last_used_at: now(), updated_at: now() });
+ const out = { session_id: row.id, id: String(r.id || id).slice(0, 64), result: RESULTS.has(r.result) ? r.result : 'expired', state: 'running' };
+ if (r.code != null) out.code = String(r.code).slice(0, 80);
+ if (r.reason != null) out.reason = String(r.reason).slice(0, 1000);
+ if (r.data && typeof r.data === 'object' && out.result === 'ack') out.data = r.data;
+ return out;
+ }
+
+ // ── placement ─────────────────────────────────────────────────────────────────────────────────────────────
+ async function hostsFor(subject) {
+ const c = hostCache.get(subject);
+ if (c && now() - c.at < 5000) return c.hosts;
+ const hosts = await desks.hosts(subject);
+ hostCache.set(subject, { at: now(), hosts });
+ if (hostCache.size > 500) hostCache.clear();
+ return hosts;
+ }
+ function candidates(row, hosts) {
+ const live = hosts.filter((h) => h && h.online && Number(h.free) > 0 && typeof h.machine_id === 'string');
+ if (row.machine_asked) return live.filter((h) => h.machine_id === row.machine_asked && h.trust === 'user-owned');
+ const allowed = row.requirements && Array.isArray(row.requirements.trust) ? row.requirements.trust : DEFAULT_TRUST;
+ return live.filter((h) => allowed.includes(h.trust))
+ .sort((a, b) => (TRUST_ORDER.indexOf(a.trust) - TRUST_ORDER.indexOf(b.trust)) || (Number(b.free) - Number(a.free)));
+ }
+ const reasonsOf = (row, h) => [
+ row.machine_asked ? 'your own machine, as asked' : h.trust === 'user-owned' ? 'your own machine, with room'
+ : h.trust === 'first-party' ? 'an OpenVibe desk host with room' : 'a community desk host with room',
+ `${Number(h.free)} of ${Number(h.max_browser_desks) || Number(h.free)} desks free there`,
+ ...(h.shared ? ['each desk there runs in its own sandbox'] : []),
+ ];
+
+ async function place(row) {
+ if (now() - row.created_at >= row.ttl_s * 1000) return finish(row, 'unplaceable', 'no machine had room before the session\'s time ran out', { release: false });
+ let hosts;
+ try { hosts = await hostsFor(bare(row.owner)); } catch (e) { if (!e.retryable) log.warn(`[Run] session ${row.id}: desk hosts: ${e.code}`); return row; }
+ const list = candidates(row, hosts);
+ if (!list.length) {
+ if (row.machine_asked && !hosts.some((h) => h.machine_id === row.machine_asked)) return finish(row, 'unplaceable', 'that machine is not open to your agent (or it runs no browser desks)', { release: false });
+ return row;
+ }
+ const desk = store.deskOf(row.id);
+ for (const h of list) {
+ await store.update(db, row.id, { state: 'starting', machine: h.machine_id, desk, trust: h.trust, offer_id: h.shared ? `desk:${h.machine_id}` : null, reasons: reasonsOf(row, h), updated_at: now() });
+ let lease;
+ try {
+ lease = await desks.lease(h.machine_id, bare(row.owner), { session: row.id, desk, kind: 'browser', width: row.width, height: row.height });
+ } catch (e) {
+ await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
+ hostCache.delete(bare(row.owner));
+ if (e.code === 'bot.machine_busy' || e.code === 'bot.desk_busy' || e.retryable) continue;
+ if (e.code === 'bot.desk_failed') continue;
+ log.warn(`[Run] session ${row.id}: lease on ${h.machine_id} refused: ${e.code}`);
+ continue;
+ }
+ if (!lease) { await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() }); continue; }
+ return db.tx(async (t) => {
+ const saved = await store.update(t, row.id, { state: 'running', started_at: now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() });
+ await emit(t, saved, 'run.session.started');
+ return saved;
+ });
+ }
+ return store.get(db, row.id);
+ }
+
+ // ── the tick ──────────────────────────────────────────────────────────────────────────────────────────────
+ async function tickOnce() {
+ for (const row of await store.open(db)) {
+ try {
+ if (row.state === 'placing') { await place(row); continue; }
+ if (row.state === 'starting') {
+ if (now() - row.updated_at < STARTING_STALE_MS) continue;
+ const lease = row.machine && row.desk ? await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined) : null;
+ if (lease === undefined) continue;
+ if (lease) await db.tx(async (t) => { const s = await store.update(t, row.id, { state: 'running', started_at: row.started_at || now(), last_used_at: now(), lease_checked_at: now(), updated_at: now() }); await emit(t, s, 'run.session.started'); });
+ else await store.update(db, row.id, { state: 'placing', machine: null, desk: null, trust: null, offer_id: null, reasons: [], updated_at: now() });
+ continue;
+ }
+ // running
+ if (now() - row.created_at >= row.ttl_s * 1000) { await finish(row, 'ttl', `its ${row.ttl_s} s ran out`); continue; }
+ if (now() - (row.last_used_at || row.started_at) >= row.idle_s * 1000) { await finish(row, 'idle', `no command for ${row.idle_s} s`); continue; }
+ if (now() - (row.lease_checked_at || 0) >= leaseCheckMs) {
+ const lease = await desks.get(row.machine, row.desk, bare(row.owner)).catch(() => undefined);
+ if (lease === null) { await finish(row, 'host_lost', 'its machine no longer holds the desk', { release: false }); continue; }
+ if (lease) await store.update(db, row.id, { lease_checked_at: now() });
+ }
+ await db.tx(async (t) => {
+ const fresh = await store.get(t, row.id);
+ if (!fresh || fresh.state !== 'running') return;
+ const metered = await meter(t, fresh);
+ if (metered !== fresh.metered_minutes) await store.update(t, row.id, { metered_minutes: metered });
+ });
+ } catch (e) {
+ log.warn(`[Run] session ${row.id}: ${e.code || e.message}`);
+ }
+ }
+ }
+ /** One pass at a time: a pass asked for while one runs waits for it. */
+ function tick() {
+ if (running) return running;
+ running = tickOnce().finally(() => { running = null; });
+ return running;
+ }
+
+ return {
+ create, act, finish, tick, SessionError,
+ get: (id) => store.get(db, id),
+ list: (owner, opts) => store.listOf(db, owner, opts),
+ present: (row) => store.present(row, now()),
+ start() {
+ if (timer || !desks.enabled) return;
+ timer = setInterval(() => { tick().catch((e) => log.warn(`[Run] session tick: ${e.message}`)); }, tickMs);
+ if (timer.unref) timer.unref();
+ },
+ async stop() { if (timer) clearInterval(timer); timer = null; if (running) await running.catch(() => {}); },
+ status: () => ({ enabled: desks.enabled, note: desks.note, tick_ms: tickMs, max_open: maxOpen }),
+ };
+}
+
+module.exports = { createSessions, SessionError, OPERATION, SOURCE };
new file mode 100644
new file mode 100644
@@ -0,0 +1,115 @@
@@ -0,0 +1,115 @@
+'use strict';
+
+/**
+ * Run sessions' rows (migrations/0005_sessions.sql) and their run.session@1 shape (contracts 0.223.0).
+ *
+ * present(row, now) → run.session@1 (desk_seconds counted to now while running)
+ * insert(db, row) → the row, or the earlier one with the same (owner, idempotency_key)
+ * get(db, id) / listOf(db, owner, { state, limit, cursor })
+ * open(db, limit) → sessions not ended, oldest first (the tick's work)
+ * update(t, id, fields) → the row after the change
+ */
+const crypto = require('crypto');
+const { ids } = require('openvibe-contracts');
+
+const COLUMNS = ['id', 'owner', 'via', 'kind', 'state', 'width', 'height', 'ttl_s', 'idle_s', 'machine_asked', 'requirements', 'idempotency_key',
+ 'request_digest', 'machine', 'desk', 'trust', 'offer_id', 'reasons', 'created_at', 'started_at', 'last_used_at', 'ended_at', 'end_reason',
+ 'end_detail', 'metered_minutes', 'lease_checked_at', 'updated_at'];
+const JSON_COLUMNS = new Set(['requirements', 'reasons']);
+
+const iso = (ms) => (ms == null ? null : new Date(Number(ms)).toISOString());
+const json = (v, dflt) => { if (v == null) return dflt; if (typeof v === 'object') return v; try { return JSON.parse(v); } catch { return dflt; } };
+
+/** A new session id: ses_<ULID>. */
+const newSessionId = () => `ses_${ids.ulid()}`;
+/** The desk a session holds on its machine: desk-s + the id's last 16 characters (unique, within the desk pattern). */
+const deskOf = (id) => `desk-s${String(id).slice(-16).toLowerCase()}`;
+
+/** The digest of a create request, for its idempotency key: a repeat with another body is a conflict. */
+function digestOf(body) {
+ const canonical = (v) => (Array.isArray(v) ? `[${v.map(canonical).join(',')}]`
+ : v && typeof v === 'object' ? `{${Object.keys(v).sort().filter((k) => v[k] !== undefined && k !== 'idempotency_key').map((k) => `${JSON.stringify(k)}:${canonical(v[k])}`).join(',')}}`
+ : JSON.stringify(v));
+ return crypto.createHash('sha256').update(canonical(body)).digest('hex');
+}
+
+/** Seconds the desk ran: from its start to its end, or to now while it runs. */
+function deskSeconds(row, now) {
+ if (row.started_at == null) return 0;
+ const end = row.ended_at != null ? Number(row.ended_at) : now;
+ return Math.max(0, Math.round((end - Number(row.started_at)) / 100) / 10);
+}
+
+/** The row as run.session@1. */
+function present(row, now = Date.now()) {
+ const placed = row.machine && row.desk;
+ return {
+ id: row.id,
+ owner: ids.parseSubject(row.owner),
+ via: row.via || null,
+ kind: row.kind,
+ state: row.state,
+ size: { width: Number(row.width), height: Number(row.height) },
+ ttl_s: Number(row.ttl_s),
+ idle_s: Number(row.idle_s),
+ placement: placed ? {
+ machine: row.machine, desk: row.desk, trust: row.trust, offer_id: row.offer_id || null, region: null,
+ reasons: json(row.reasons, []).slice(0, 8).map((r) => String(r).slice(0, 200)),
+ } : null,
+ timings: { created_at: iso(row.created_at), started_at: iso(row.started_at), last_used_at: iso(row.last_used_at), ended_at: iso(row.ended_at) },
+ end: row.state === 'ended' ? { reason: row.end_reason || 'ended', detail: row.end_detail ? String(row.end_detail).slice(0, 500) : null } : null,
+ usage: { desk_seconds: deskSeconds(row, now) },
+ idempotency_key: row.idempotency_key || null,
+ };
+}
+
+const fromDb = (row) => {
+ if (!row) return null;
+ const out = { ...row };
+ for (const k of JSON_COLUMNS) out[k] = json(row[k], k === 'reasons' ? [] : null);
+ for (const k of ['created_at', 'started_at', 'last_used_at', 'ended_at', 'lease_checked_at', 'updated_at']) if (out[k] != null) out[k] = Number(out[k]);
+ return out;
+};
+
+async function insert(db, row) {
+ const cols = COLUMNS.filter((c) => row[c] !== undefined);
+ const vals = cols.map((c) => (JSON_COLUMNS.has(c) && row[c] != null ? JSON.stringify(row[c]) : row[c]));
+ const made = await db.maybe(
+ `INSERT INTO run_sessions (${cols.join(', ')}) VALUES (${cols.map((_, i) => `$${i + 1}`).join(', ')})
+ ON CONFLICT DO NOTHING RETURNING *`, vals);
+ if (made) return { row: fromDb(made), created: true };
+ const earlier = await db.maybe('SELECT * FROM run_sessions WHERE owner = $1 AND idempotency_key = $2', [row.owner, row.idempotency_key]);
+ return { row: fromDb(earlier), created: false };
+}
+
+const get = async (db, id) => fromDb(await db.maybe('SELECT * FROM run_sessions WHERE id = $1', [id]));
+
+/** The owner's sessions, newest first: state running (placing, starting or running; the default), ended, or all. */
+async function listOf(db, owner, { state = 'running', limit = 20, cursor = null } = {}) {
+ const n = Math.min(100, Math.max(1, Number(limit) || 20));
+ const where = ['owner = $1'];
+ const args = [owner];
+ if (state === 'ended') where.push("state = 'ended'");
+ else if (state !== 'all') where.push("state <> 'ended'");
+ if (cursor) { args.push(String(cursor)); where.push(`id < $${args.length}`); }
+ args.push(n + 1);
+ const rows = (await db.many(`SELECT * FROM run_sessions WHERE ${where.join(' AND ')} ORDER BY id DESC LIMIT $${args.length}`, args)).map(fromDb);
+ const more = rows.length > n;
+ const page = rows.slice(0, n);
+ return { rows: page, next: more ? page[page.length - 1].id : null };
+}
+
+const open = async (db, limit = 200) => (await db.many("SELECT * FROM run_sessions WHERE state <> 'ended' ORDER BY created_at LIMIT $1", [limit])).map(fromDb);
+
+/** How many sessions a person holds open now (the per-person cap). */
+const openCount = async (db, owner) => Number((await db.one("SELECT count(*)::int AS n FROM run_sessions WHERE owner = $1 AND state <> 'ended'", [owner])).n);
+
+async function update(t, id, fields) {
+ const cols = Object.keys(fields).filter((k) => COLUMNS.includes(k) && k !== 'id');
+ if (!cols.length) return get(t, id);
+ const vals = cols.map((c) => (JSON_COLUMNS.has(c) && fields[c] != null ? JSON.stringify(fields[c]) : fields[c]));
+ const row = await t.maybe(`UPDATE run_sessions SET ${cols.map((c, i) => `${c} = $${i + 2}`).join(', ')} WHERE id = $1 RETURNING *`, [id, ...vals]);
+ return fromDb(row);
+}
+
+module.exports = { present, insert, get, listOf, open, openCount, update, newSessionId, deskOf, digestOf, deskSeconds };
new file mode 100644
new file mode 100644
@@ -0,0 +1,209 @@
@@ -0,0 +1,209 @@
+'use strict';
+/**
+ * Run sessions (contracts 0.223.0, the Desks plan's D6): a browser desk placed for one person and leased through
+ * OpenVibe.Bot. The desk client is a fake Bot here (the hosts, leases and commands it is asked for are recorded); the
+ * last check runs the real client against a small HTTP Bot to see its token, scope and X-OV-Subject on the wire.
+ *
+ * node test/sessions.test.js
+ */
+const assert = require('assert');
+const http = require('http');
+const contracts = require('openvibe-contracts');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/app');
+
+const OWN = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2A';
+const SHARED = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2B';
+const OPENVIBE = 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2C';
+
+function fakeDesks() {
+ const calls = [];
+ const leases = new Map(); // `${machine} ${desk}` → lease
+ const state = {
+ hosts: [
+ { machine_id: OWN, trust: 'user-owned', shared: false, online: true, free: 2, max_browser_desks: 2 },
+ { machine_id: SHARED, trust: 'community', shared: true, online: true, free: 3, max_browser_desks: 4 },
+ { machine_id: OPENVIBE, trust: 'first-party', shared: true, online: true, free: 1, max_browser_desks: 4 },
+ ],
+ busy: new Set(),
+ };
+ return {
+ calls, leases, state, enabled: true, note: 'a fake Bot',
+ async hosts(subject) { calls.push(['hosts', subject]); return state.hosts.map((h) => ({ ...h })); },
+ async lease(machine, subject, body) {
+ calls.push(['lease', machine, subject, body]);
+ if (state.busy.has(machine)) { const e = new Error('busy'); e.code = 'bot.machine_busy'; e.status = 409; throw e; }
+ const lease = { machine_id: machine, desk: body.desk, session: body.session, owner: `user:${subject}`, kind: 'browser', width: body.width, height: body.height, shared: machine !== OWN, created_at: new Date().toISOString() };
+ leases.set(`${machine} ${body.desk}`, lease);
+ return lease;
+ },
+ async get(machine, desk) { calls.push(['get', machine, desk]); return leases.get(`${machine} ${desk}`) || null; },
+ async act(machine, desk, subject, cmd) {
+ calls.push(['act', machine, desk, subject, cmd]);
+ if (!leases.has(`${machine} ${desk}`)) return null;
+ return { machine_id: machine, desk, id: cmd.id, result: 'ack', data: cmd.kind === 'screen' ? { format: 'jpeg', width: 1280, height: 800, content_b64: '/9j/' } : { url: 'https://example.org/', title: 'Example' } };
+ },
+ async end(machine, desk) { calls.push(['end', machine, desk]); const had = leases.delete(`${machine} ${desk}`); return had ? { ended: true, stopped: true } : null; },
+ status: () => ({ enabled: true }),
+ };
+}
+
+(async () => {
+ const desks = fakeDesks();
+ const t = await boot({ appOpts: { desks }, env: { RUN_SESSIONS_PER_PERSON: '3', RUN_SESSION_LEASE_CHECK_MS: '1000' } });
+ const { sessions } = t.app.locals;
+ const ada = ids.newId('user');
+ const bea = ids.newId('user');
+ const as = (subject) => ({ token: t.network.signUser({ subject }) });
+ const valid = (doc, id) => { const v = contracts.validate(id, doc); assert.ok(v.valid, `${id}: ${JSON.stringify(v.errors)}\n${JSON.stringify(doc)}`); };
+ const usageOf = async (id) => (await t.db.many('SELECT envelope FROM run_usage_outbox ORDER BY id')).map((r) => (typeof r.envelope === 'string' ? JSON.parse(r.envelope) : r.envelope)).filter((u) => u.id.startsWith(`run:${id}:`));
+
+ let ses;
+ await check('a person opens a session: placed on a shared host (first-party first), its desk leased and running', async () => {
+ const r = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', width: 1024, height: 700 } });
+ assert.strictEqual(r.status, 201, r.text);
+ valid(r.json, 'run.session@1');
+ assert.deepStrictEqual([r.json.state, r.json.owner, r.json.via], ['placing', { type: 'user', id: ada }, null]);
+ await sessions.tick();
+ ses = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(ada))).json;
+ valid(ses, 'run.session@1');
+ assert.strictEqual(ses.state, 'running');
+ assert.deepStrictEqual([ses.placement.machine, ses.placement.trust, ses.placement.offer_id], [OPENVIBE, 'first-party', `desk:${OPENVIBE}`], 'first-party before community; the own machine only when asked');
+ assert.match(ses.placement.desk, /^desk-s[0-9a-z]{16}$/);
+ assert.ok(ses.placement.reasons.includes('an OpenVibe desk host with room'));
+ const lease = desks.calls.find((c) => c[0] === 'lease');
+ assert.deepStrictEqual(lease[3], { session: ses.id, desk: ses.placement.desk, kind: 'browser', width: 1024, height: 700 });
+ assert.strictEqual(lease[2], ada, 'Bot is asked as the person');
+ const started = (await t.outboxRows('run.session.started')).map((e) => e.payload || e);
+ assert.ok(started.some((p) => p.id === ses.id && p.state === 'running'), 'run.session.started');
+ });
+
+ await check('commands reach its desk; anyone else\'s call is 404', async () => {
+ const r = await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { id: 'c1', kind: 'browser', value: { action: 'open', url: 'https://example.org/' } } });
+ assert.strictEqual(r.status, 200, r.text);
+ valid(r.json, 'run.session-control-result@1');
+ assert.deepStrictEqual([r.json.result, r.json.state, r.json.data.title], ['ack', 'running', 'Example']);
+ const act = desks.calls.filter((c) => c[0] === 'act').at(-1);
+ assert.deepStrictEqual([act[1], act[2], act[4].kind], [OPENVIBE, ses.placement.desk, 'browser']);
+ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { kind: 'browser', value: { action: 'read', desk: 'desk-x' } } })).status, 422, 'a value naming a desk');
+ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/commands`, { ...as(ada), body: { kind: 'exec', value: { argv: ['id'] } } })).status, 422);
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}`, as(bea))).status, 404);
+ assert.strictEqual((await t.call('POST', `/api/v1/sessions/${ses.id}/end`, as(bea))).status, 404);
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${ses.id}`, { token: null })).status, 401);
+ const list = (await t.call('GET', '/api/v1/sessions', as(ada))).json;
+ valid(list, 'run.session-list-result@1');
+ assert.deepStrictEqual(list.sessions.map((s) => s.id), [ses.id]);
+ assert.deepStrictEqual((await t.call('GET', '/api/v1/sessions', as(bea))).json.sessions, []);
+ });
+
+ await check('a first-party service opens one for a person, with the capability; an app never does', async () => {
+ const svc = (cap, sub = 'svc:actor') => ({ token: t.network.signService({ sub, cap }), headers: { 'X-OV-Subject': bea } });
+ const ok = await t.call('POST', '/api/v1/sessions', { ...svc(['run.session.create']), body: { kind: 'browser', requirements: { kind: 'run.browser', mobility: 'session', latency_class: 'interactive', objective: 'cheapest', trust: ['user-owned'] } } });
+ assert.strictEqual(ok.status, 201, ok.text);
+ assert.deepStrictEqual([ok.json.owner.id, ok.json.via], [bea, 'service:actor']);
+ await sessions.tick();
+ const placed = (await t.call('GET', `/api/v1/sessions/${ok.json.id}`, svc(['run.session.read']))).json;
+ assert.deepStrictEqual([placed.placement.machine, placed.placement.trust], [OWN, 'user-owned'], 'requirements naming user-owned: the person\'s own machine');
+ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { ...svc(['run.job.submit']), body: { kind: 'browser' } })).status, 403, 'needs run.session.create');
+ const app = await t.call('POST', '/api/v1/sessions', { token: t.network.signService({ sub: 'app:widget', actor_type: 'app', cap: ['run.session.create'] }), headers: { 'X-OV-Subject': bea }, body: { kind: 'browser' } });
+ assert.ok([401, 403].includes(app.status), `an app token never opens a session for a person (${app.status})`);
+ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { token: t.network.signService({ sub: 'svc:actor', cap: ['run.session.create'] }), body: { kind: 'browser' } })).status, 422, 'for a person');
+ await t.call('POST', `/api/v1/sessions/${ok.json.id}/end`, svc(['run.session.control']));
+ });
+
+ await check('an idempotency key answers the first session; another body is a conflict; a person holds three at most', async () => {
+ const body = { kind: 'browser', ttl_s: 600, idempotency_key: 'actor:tsk_1' };
+ const a = await t.call('POST', '/api/v1/sessions', { ...as(bea), body });
+ const b = await t.call('POST', '/api/v1/sessions', { ...as(bea), body });
+ assert.deepStrictEqual([a.status, b.status, b.json.id], [201, 200, a.json.id]);
+ assert.strictEqual((await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { ...body, ttl_s: 700 } })).json.code, 'run.idempotency.conflict');
+ await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });
+ await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });
+ const over = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });
+ assert.deepStrictEqual([over.status, over.json.code], [429, 'run.session.limit']);
+ const mine = (await t.call('GET', '/api/v1/sessions', as(bea))).json.sessions;
+ await Promise.all(mine.map((s) => t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea))));
+ });
+
+ await check('a full host is skipped; its own machine named and not open ends unplaceable', async () => {
+ desks.state.busy.add(OPENVIBE);
+ const r = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser' } });
+ await sessions.tick();
+ const s = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(bea))).json;
+ assert.deepStrictEqual([s.state, s.placement.machine, s.placement.trust], ['running', SHARED, 'community']);
+ desks.state.busy.delete(OPENVIBE);
+ await t.call('POST', `/api/v1/sessions/${s.id}/end`, as(bea));
+ const named = await t.call('POST', '/api/v1/sessions', { ...as(bea), body: { kind: 'browser', machine: 'dev_01J8Z4M2Q0R7T9YV3K6N8P1W2Z' } });
+ await sessions.tick();
+ const gone = (await t.call('GET', `/api/v1/sessions/${named.json.id}`, as(bea))).json;
+ valid(gone, 'run.session@1');
+ assert.deepStrictEqual([gone.state, gone.end.reason], ['ended', 'unplaceable']);
+ });
+
+ await check('idle, ttl and a lost host end it; desk time is metered a minute at a time; end gives the desk back', async () => {
+ // Ada's first session has run a while: 2 minutes 30 seconds of desk time, then it idles out.
+ t.clock.offset += 150_000;
+ const before = (await t.call('GET', `/api/v1/sessions/${ses.id}`, as(ada))).json;
+ assert.ok(before.usage.desk_seconds >= 150, `desk time counts while running (${before.usage.desk_seconds})`);
+ t.clock.offset += 900_000; // idle_s 900 passed since the last command
+ await sessions.tick();
+ const idle = (await t.call('GET', `/api/v1/sessions/${ses.id}`, as(ada))).json;
+ valid(idle, 'run.session@1');
+ assert.deepStrictEqual([idle.state, idle.end.reason], ['ended', 'idle']);
+ assert.ok(desks.calls.some((c) => c[0] === 'end' && c[2] === ses.placement.desk), 'the desk was stopped and the lease given back');
+ const readings = await usageOf(ses.id);
+ readings.forEach((u) => valid(u, 'platform.usage-sample@1'));
+ const total = readings.reduce((n, u) => n + u.quantity, 0);
+ assert.ok(Math.abs(total - idle.usage.desk_seconds) < 0.2, `the readings add up to the desk time (${total} vs ${idle.usage.desk_seconds})`);
+ assert.ok(readings.slice(0, -1).every((u) => u.quantity === 60) && readings.every((u) => u.operation === 'browser.desk' && u.subject === `user:${ada}` && u.node === OPENVIBE));
+ const ended = (await t.outboxRows('run.session.ended')).map((e) => e.payload || e);
+ assert.ok(ended.some((p) => p.id === ses.id && p.end.reason === 'idle'), 'run.session.ended');
+ // A host that drops the desk: the next lease check ends the session host_lost, without stopping anything.
+ const r = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', ttl_s: 120 } });
+ await sessions.tick();
+ const live = (await t.call('GET', `/api/v1/sessions/${r.json.id}`, as(ada))).json;
+ desks.leases.clear();
+ t.clock.offset += 5_000;
+ await sessions.tick();
+ const lost = (await t.call('GET', `/api/v1/sessions/${live.id}`, as(ada))).json;
+ assert.deepStrictEqual([lost.state, lost.end.reason], ['ended', 'host_lost']);
+ // ttl: a session's whole life is bounded.
+ const s3 = await t.call('POST', '/api/v1/sessions', { ...as(ada), body: { kind: 'browser', ttl_s: 60, idle_s: 3600 } });
+ await sessions.tick();
+ t.clock.offset += 61_000;
+ await sessions.tick();
+ assert.strictEqual((await t.call('GET', `/api/v1/sessions/${s3.json.id}`, as(ada))).json.end.reason, 'ttl');
+ // An ended session takes no command.
+ const late = await t.call('POST', `/api/v1/sessions/${s3.json.id}/commands`, { ...as(ada), body: { kind: 'screen', value: {} } });
+ assert.deepStrictEqual([late.status, late.json.code], [409, 'run.session.not_running']);
+ });
+
+ await check('the real desk client: Run\'s token for Bot with bot.desk.session, the person in X-OV-Subject', async () => {
+ const seen = [];
+ const bot = http.createServer((req, res) => {
+ let body = '';
+ req.on('data', (c) => { body += c; });
+ req.on('end', () => {
+ seen.push({ method: req.method, url: req.url, auth: req.headers.authorization, subject: req.headers['x-ov-subject'], body });
+ if (req.url === '/api/v1/desk-hosts') { res.writeHead(200, { 'Content-Type': 'application/json' }); return res.end(JSON.stringify({ hosts: [{ machine_id: SHARED, trust: 'community', online: true, free: 1 }] })); }
+ if (req.method === 'POST' && /\/desk-leases$/.test(req.url)) { res.writeHead(409, { 'Content-Type': 'application/problem+json' }); return res.end(JSON.stringify({ code: 'bot.machine_busy', detail: 'full' })); }
+ res.writeHead(404, { 'Content-Type': 'application/problem+json' });
+ return res.end(JSON.stringify({ code: 'bot.lease_not_found' }));
+ });
+ });
+ await new Promise((r) => bot.listen(0, '127.0.0.1', r));
+ const { createDeskClient } = require('../server/sessions/desks');
+ const config = { ...t.config, oauth: { clientId: 'run', clientSecret: 'secret' }, dispatch: { ...t.config.dispatch, botUrl: `http://127.0.0.1:${bot.address().port}` } };
+ const client = createDeskClient({ config });
+ assert.strictEqual(client.enabled, true);
+ assert.deepStrictEqual((await client.hosts(ada)).map((h) => h.machine_id), [SHARED]);
+ await assert.rejects(() => client.lease(SHARED, ada, { session: 'ses_x', desk: 'desk-x', kind: 'browser' }), (e) => e.code === 'bot.machine_busy' && e.status === 409);
+ assert.strictEqual(await client.get(SHARED, 'desk-x', ada), null, '404: no such lease');
+ assert.ok(seen.every((c) => c.subject === ada && /^Bearer /.test(c.auth)));
+ assert.ok(t.network.tokenRequests.scopes.includes('bot.desk.session'), 'its token is scoped bot.desk.session');
+ bot.close();
+ });
+
+ await t.close();
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });