openvibers/openvibe.codes Commits
Codes: a service starts, reads and cancels a person's coding task (codes.task.*, for Actor) (#65)
Changes
@@@ -20,10 +20,11 @@ One place to send a coding task to the agent that fits it — by what the task n| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. || Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). || The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |++| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. || Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). || Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. || Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |--| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent API | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |++| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent and task APIs | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). || Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. || Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |@@@ -99,6 +100,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.++- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel.- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.nnew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- Contracts 0.162 codes.task.start: a first-party service (OpenVibe.Actor) starts a coding task for a person. VIA is++-- that service (service:<id>; null for a task the person started on /run), and only it reads or cancels the task++-- through the API; IDEMPOTENCY_KEY makes its retry answer the first task.++ALTER TABLE run_tasks ADD COLUMN via text COLLATE "C";++ALTER TABLE run_tasks ADD COLUMN idempotency_key text COLLATE "C";++CREATE UNIQUE INDEX run_tasks_service_key ON run_tasks (subject, via, idempotency_key) WHERE idempotency_key IS NOT NULL;@@@ -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.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -874,9 +874,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.143.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",-- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",++ "version": "0.162.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0",++ "integrity": "sha512-uSJBsK1mFEnbS/SRoNx6jzSQE7P+ihJ1VkHXbaOKwbEc7wJdeFkeEVaJIMwUjq6f/u+StbyDepJR9CT8LkxNRA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -30,7 +30,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.143.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3","pg": "^8.23.0"@@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');const { createHarnesses } = require('./domain/harnesses');const { createPageRoutes } = require('./http/pages');const { createRunRoutes } = require('./http/run');++const { createTasksApi } = require('./http/tasks-api');const { createRunClient } = require('./domain/run-client');const { createApi } = require('./http/api');const { createMovedRoutes } = require('./http/moved');@@@ -144,14 +145,16 @@ async function createApp(opts = {}) {}));// ── API ─────────────────────────────────────────────────++ // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run++ // pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).++ ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });app.use('/api/v1', createApi(ctx));++ app.use('/api/v1', createTasksApi({ config, store, keys, run: ctx.run, harnesses, actorLimits: ctx.actorLimits, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Pages ───────────────────────────────────────────────app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise itapp.use(createPageRoutes(ctx));-- // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run.-- ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });app.use(createRunRoutes({ config, store, sso, harnesses, run: ctx.run, fetchImpl: fetchImpl || globalThis.fetch, log }));app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));@@@ -43,6 +43,8 @@ function load(env = process.env) {networkUrl,networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),++ // The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).++ serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',oauth: {clientId: env.OV_OAUTH_CLIENT_ID || 'codes',clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++++/**++ * Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the++ * service API (server/http/tasks-api.js, codes.task.*) share.++ *++ * createRunTasks({ store, run, config, log })++ * start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)++ * refresh(row) → the row, updated from Run while its job has not ended++ * latestProject(subject) → the project of the person's latest task, or null++ * present(row) → codes.task@1++ * TERMINAL, STATE_LABEL++ *++ * A task's result comes from the person's machine: present() passes on only the contract's fields, each checked and++ * cut to its limit.++ */++const { RunError } = require('./run-client');++++const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);++const STATE_LABEL = {++ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',++ failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',++};++const STATES = new Set(Object.keys(STATE_LABEL));++++const iso = (ms) => new Date(Number(ms)).toISOString();++const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : undefined);++const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);++const httpsUrl = (v) => {++ if (typeof v !== 'string' || v.length > 2000) return undefined;++ try { return new URL(v).protocol === 'https:' ? v : undefined; } catch { return undefined; }++};++const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));++++/** The job's result document (harness/job.js) as codes.task@1's `result`, or undefined. */++function presentResult(d) {++ if (!d || typeof d !== 'object' || Array.isArray(d)) return undefined;++ const files = Array.isArray(d.files) ? d.files.slice(0, 2000).filter((f) => f && typeof f.path === 'string' && f.path)++ .map((f) => ({ path: f.path.slice(0, 500), added: count(f.added), removed: count(f.removed) })) : undefined;++ const p = d.push && typeof d.push === 'object' ? drop({++ pushed: d.push.pushed === true, branch: str(d.push.branch, 200), branch_url: httpsUrl(d.push.branch_url),++ pull_request: httpsUrl(d.push.pull_request), pull_request_error: str(d.push.pull_request_error, 500), reason: str(d.push.reason, 500),++ }) : undefined;++ return drop({++ ok: d.ok === true,++ agents: Array.isArray(d.agents) ? d.agents.filter((a) => typeof a === 'string').slice(0, 20).map((a) => a.slice(0, 41)) : undefined,++ summary: str(d.summary, 4000),++ files,++ patch: str(d.patch, 262144),++ patch_truncated: d.patch_truncated === true || (typeof d.patch === 'string' && d.patch.length > 262144) ? true : undefined,++ push: p,++ cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0 ? d.cost_usd : undefined,++ duration_ms: Number.isInteger(d.duration_ms) && d.duration_ms >= 0 ? d.duration_ms : undefined,++ error: str(d.error, 1000),++ });++}++++function createRunTasks({ store, run, config, log = console }) {++ const db = store.db;++ const who = (row) => ({ subject: row.subject, project: row.project_id });++++ async function start({ subject, project, args, via = null, key = null }) {++ const id = store.newId('tsk');++ const at = store.now();++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12, $12)`,++ [id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, at]);++ try {++ const job = await run.submit({ subject, project, args, key: id });++ await db.exec('UPDATE run_tasks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1', [id, job.id, job.state, store.now()]);++ } catch (e) {++ const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;++ await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1", [id, why, store.now()]);++ }++ return db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);++ }++++ async function refresh(row) {++ if (!row.job_id || TERMINAL.has(row.state)) return row;++ let job;++ try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }++ if (job.state === row.state && !TERMINAL.has(job.state)) return row;++ const error = job.error ? `${job.error.code}${job.error.detail ? `: ${job.error.detail}` : ''}` : null;++ const result = job.result && typeof job.result === 'object' ? job.result : null;++ return db.one('UPDATE run_tasks SET state = $2, result = $3, error = $4, updated_at = $5 WHERE id = $1 RETURNING *',++ [row.id, job.state, result ? JSON.stringify(result) : null, error, store.now()]);++ }++++ async function latestProject(subject) {++ const row = await db.maybe('SELECT project_id FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 1', [subject]);++ return row ? row.project_id : null;++ }++++ function present(row) {++ const result = TERMINAL.has(row.state) ? presentResult(typeof row.result === 'string' ? JSON.parse(row.result) : row.result) : undefined;++ return drop({++ id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,++ agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),++ error: row.error ? String(row.error).slice(0, 1000) : null, result,++ });++ }++++ return { start, refresh, latestProject, present, who };++}++++module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL };@@@ -33,6 +33,8 @@ function actor(req) {const BUDGETS = {// Routing a coding task over the harness catalog (no I/O, but a POST: the reads' defaults skip it).'codes.harness.route': { minute: 30, hour: 600 },++ // A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).++ 'codes.task.start': { minute: 6, hour: 60 },};/**@@@ -17,8 +17,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';// The console's paths (and everything under them), and the platform policy pages that moved with it.const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];--// The console's API: everything under /api/v1 but the agent catalog.--const API_STAYS = /^\/api\/v1\/harnesses(\/|$)/;++// The console's API: everything under /api/v1 but the agent catalog and the coding tasks (codes.task.*).++const API_STAYS = /^\/api\/v1\/(harnesses|tasks)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);@@@ -17,22 +17,18 @@ const { asyncRouter } = require('./router');const { send } = require('../render/layout');const { html, raw, esc } = require('../render/html');++const { parseArgs } = require('../../harness/job');++const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks');++++const DELEGABLE = new Set(['owner', 'admin']);++const MACHINES_URL = 'https://openvibe.bot/machines';++/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */function repoName(url) {const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');if (!path.length) return host;return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;}--const { parseArgs } = require('../../harness/job');--const { RunError } = require('../domain/run-client');----const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);--const STATE_LABEL = {-- queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',-- failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',--};--const DELEGABLE = new Set(['owner', 'admin']);--const MACHINES_URL = 'https://openvibe.bot/machines';function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console }) {const r = asyncRouter();@@@ -62,19 +58,9 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globaconst row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);return row && row.subject === req.viewer.subject ? row : null;};-- const who = (row) => ({ subject: row.subject, project: row.project_id });---- /** A task not yet ended asks Run where its job is; an ended job's result is kept on the row. */-- async function refresh(row) {-- if (!row.job_id || TERMINAL.has(row.state)) return row;-- let job;-- try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }-- if (job.state === row.state && !TERMINAL.has(job.state)) return row;-- const error = job.error ? `${job.error.code}${job.error.detail ? `: ${job.error.detail}` : ''}` : null;-- const result = job.result && typeof job.result === 'object' ? job.result : null;-- return db.one('UPDATE run_tasks SET state = $2, result = $3, error = $4, updated_at = $5 WHERE id = $1 RETURNING *',-- [row.id, job.state, result ? JSON.stringify(result) : null, error, store.now()]);-- }++ // A task not yet ended asks Run where its job is; an ended job's result is kept on the row (domain/run-tasks).++ const tasks = createRunTasks({ store, run, config, log });++ const { who, refresh } = tasks;function formHtml({ projects, values = {}, error = null }) {const agents = harnesses.list();@@@ -168,19 +154,8 @@ ${tasksHtml(recent)}`;}const projects = await projectsOf(req, res);if (!projects.some((p) => p.id === values.project)) return runPage(req, res, { values, error: 'Choose one of your projects.', status: 422 });-- const id = store.newId('tsk');-- const at = store.now();-- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $10)`,-- [id, req.viewer.subject, values.project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), at]);-- try {-- const job = await run.submit({ subject: req.viewer.subject, project: values.project, args, key: id });-- await db.exec('UPDATE run_tasks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1', [id, job.id, job.state, store.now()]);-- } catch (e) {-- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;-- await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1", [id, why, store.now()]);-- }-- return res.redirect(303, `/run/tasks/${id}`);++ const row = await tasks.start({ subject: req.viewer.subject, project: values.project, args });++ return res.redirect(303, `/run/tasks/${row.id}`);});function resultHtml(row) {nnew file mode 100644@@@ -0,0 +1,116 @@++'use strict';++++/**++ * The service API for coding tasks (Contracts 0.162 codes.task.*, plan T16/T17), under /api/v1: a first-party++ * service (OpenVibe.Actor) starts a coding task for a person, who is named in X-OV-Subject, reads it and cancels it.++ * The task is the same one openvibe.codes/run starts (domain/run-tasks): an OpenVibe.Run job on the person's own++ * machine, in the project of their latest /run task.++ *++ * POST /tasks codes.task.start codes.task-start-request@1 → codes.task-start-result@1 (201, or 200 again)++ * GET /tasks/:id codes.task.read → codes.task@1++ * POST /tasks/:id/cancel codes.task.cancel → codes.task@1++ *++ * A Network service token for audience openvibe.codes (CODES_AUDIENCE), from a svc: principal holding the route's++ * capability. A service sees only the tasks it started, for that person; anything else is 404. The person's task++ * page (the codes.task@1 url) shows every task they own, whoever started it. Calls count against the person's own++ * per-actor budget (actor-limits.js), not the service's.++ */++const express = require('express');++const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { parseArgs } = require('../../harness/job');++const { createRunTasks, TERMINAL } = require('../domain/run-tasks');++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, log = console }) {++ const r = asyncRouter();++ const db = store.db;++ const tasks = createRunTasks({ store, run, config, log });++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const json = express.json({ limit: '32kb' });++++ /** The route's guard: the service token, its capability and the person it acts for → req.principal. */++ const guard = (cap) => async (req, res, next) => {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return problem(req, res, 401, 'token.required', `${cap} needs a Network service token for ${config.serviceAudience}`);++ if (!keys.loaded()) await keys.ensure();++ const publicKey = keys.get();++ if (!publicKey) return problem(req, res, 503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const v = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.networkIssuer, audience: config.serviceAudience });++ if (!v.ok) return problem(req, res, 401, v.code || 'token.invalid', v.reason || 'the service token is invalid');++ const sub = String(v.claims.sub || '');++ if (!sub.startsWith('svc:')) return problem(req, res, 403, 'capability.denied', `${cap} is for first-party services`);++ const decision = capabilities.check(v.claims, cap);++ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || `${cap} not granted`);++ const subject = String(req.get('X-OV-Subject') || '').trim();++ if (!USER.test(subject)) return problem(req, res, 422, 'subject.required', 'name the person the task is for in X-OV-Subject (usr_…)');++ req.principal = { sub: `user:${subject}`, subject, via: `service:${sub.slice(4)}` };++ return next();++ };++++ /** The task this service started for this person, or null. */++ const mine = async (req) => {++ if (!TASK.test(String(req.params.id))) return null;++ return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND via = $3', [req.params.id, req.principal.subject, req.principal.via]);++ };++ const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via = $2 AND idempotency_key = $3', [p.subject, p.via, key]);++++ r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {++ const b = req.body;++ const check = validate('codes.task-start-request@1', b);++ if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));++ if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');++ const p = req.principal;++ const key = b.idempotency_key || null;++ if (key) {++ const seen = await byKey(p, key);++ if (seen) return res.status(200).json({ task: tasks.present(await tasks.refresh(seen)), created: false });++ }++ let args;++ try {++ args = parseArgs(JSON.stringify({++ repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit',++ ...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),++ }));++ } catch (e) {++ return problem(req, res, 422, 'codes.task_invalid', e.message);++ }++ const project = await tasks.latestProject(p.subject);++ if (!project) {++ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.bot/machines)`);++ }++ let row;++ try {++ row = await tasks.start({ subject: p.subject, project, args, via: p.via, key });++ } catch (e) {++ // Two requests with one key at once: the unique index lets one in; the other answers with it.++ const seen = key && e && e.code === '23505' ? await byKey(p, key) : null;++ if (!seen) throw e;++ return res.status(200).json({ task: tasks.present(seen), created: false });++ }++ log.info(`[Codes] task ${row.id} started by ${p.via} for ${p.subject}: ${row.state}`);++ return res.status(201).json({ task: tasks.present(row), created: true });++ });++++ r.get('/tasks/:id', guard('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {++ const row = await mine(req);++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');++ return res.json(tasks.present(await tasks.refresh(row)));++ });++++ r.post('/tasks/:id/cancel', guard('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {++ let row = await mine(req);++ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');++ if (row.job_id && !TERMINAL.has(row.state)) {++ try { await run.cancel(row.job_id, tasks.who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }++ row = await tasks.refresh(row);++ }++ return res.json(tasks.present(row));++ });++++ return r;++}++++module.exports = { createTasksApi };@@@ -1,7 +1,7 @@'use strict';/*** The released contracts (the pinned openvibe-contracts tag) describe what the code does. Since 0.113.0 the codes-- * service manifest declares no capability and no event: the developer console moved to OpenVibe.Services, which owns++ * service manifest declares the coding-task capabilities (codes.task.*, Contracts 0.162) and no event: the developer console moved to OpenVibe.Services, which owns* services.release.* and services.app.*, and the codes.* forms are retired. The harness catalog's rows are the* contracts' platform.harness-offer@1 and platform.agent-offer@1 (checked at boot by server/domain/harnesses.js).*/@@@ -14,12 +14,14 @@ const { check, done } = require('./helpers/boot');(async () => {const manifest = contracts.services.get('codes');-- await check('the codes service manifest is released as alpha, live, with no capability and no event', async () => {++ await check('the codes service manifest is released as alpha, live, with the coding-task capabilities and no event', async () => {assert.ok(manifest);assert.strictEqual(manifest.status, 'alpha');assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);assert.strictEqual(manifest.exposure.state, 'live');-- assert.deepStrictEqual(manifest.capabilities, []);++ // Contracts 0.162: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js).++ assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel']);++ for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);assert.deepStrictEqual(manifest.eventsProduced, []);const range = manifest.contractRanges['openvibe-contracts'];assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));@@@ -322,6 +322,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.msetCatalog(list) { st.catalog = list; },setDown(v) { st.down = v; },mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },++ /** A Network client-credentials token: a first-party service (svc:<id>) or an app (app:<id>), for audience, holding cap. */++ mintService: (sub, audience, cap, extra = {}) => { const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },close: srv.close,};}nnew file mode 100644@@@ -0,0 +1,132 @@++'use strict';++/**++ * The service API for coding tasks (server/http/tasks-api.js, Contracts 0.162 codes.task.*): a first-party service++ * holding the capability starts a task for a person named in X-OV-Subject, in the project of that person's latest++ * /run task, on a stand-in OpenVibe.Run; a repeated idempotency key answers the first task; the service reads and++ * cancels only the tasks it started for that person; what the person's machine returned is passed on only as the++ * contract allows. Tokens: none, an app's, a service's without the capability or for another audience are refused.++ */++const assert = require('assert');++const { validate } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++++(async () => {++ const calls = [];++ let state = 'queued';++ let result = null;++ const run = {++ enabled: true,++ async submit(o) { calls.push(['submit', o]); return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },++ async read(jobId, who) { calls.push(['read', jobId, who]); return { id: jobId, state, result, error: null }; },++ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); state = 'cancelled'; return { id: jobId, state }; },++ async streamUrl() { throw new Error('not used'); },++ };++ const t = await boot({ run });++ const db = t.ctx.store.db;++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];++ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);++ const call = async (method, path, { token = actor, subject = ada.subject, body } = {}) => {++ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };++ if (body !== undefined) headers['Content-Type'] = 'application/json';++ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });++ return { status: res.status, json: await res.json().catch(() => null) };++ };++ const ask = { repo: 'https://github.com/octocat/Hello-World', prompt: 'Add a line to the README.', agent: 'deepseek', idempotency_key: 'act_task_1:step-1' };++++ // Ada ran a task on /run before (so Codes knows her project); Bo never did.++ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';++ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)++ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, $2, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z200', 'https://github.com/a/b', 'by hand', 'succeeded', 1, 2)`, [ada.subject, project]);++++ try {++ await check('no token, an app token, a service without the capability or for another audience, no person: refused', async () => {++ assert.strictEqual((await call('POST', '/tasks', { token: null, body: ask })).status, 401);++ assert.strictEqual((await call('POST', '/tasks', { token: t.network.mintService('app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', 'openvibe.codes', CAPS, { ns: ['prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P9'], project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P9', env: 'production' }), body: ask })).status, 403);++ const noCap = await call('POST', '/tasks', { token: t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.read']), body: ask });++ assert.strictEqual(noCap.status, 403, JSON.stringify(noCap.json));++ assert.strictEqual((await call('POST', '/tasks', { token: t.network.mintService('svc:actor', 'openvibe.run', CAPS), body: ask })).status, 401);++ assert.strictEqual((await call('POST', '/tasks', { subject: null, body: ask })).status, 422);++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 0);++ });++++ await check('a bad body or an unknown agent is 422; a person who never ran a task on /run is 409 codes.no_project', async () => {++ assert.strictEqual((await call('POST', '/tasks', { body: { ...ask, repo: 'http://github.com/a/b' } })).status, 422);++ assert.strictEqual((await call('POST', '/tasks', { body: { ...ask, agent: 'nobody' } })).status, 422);++ const none = await call('POST', '/tasks', { subject: bo.subject, body: ask });++ assert.strictEqual(none.status, 409);++ assert.strictEqual(none.json.code, 'codes.no_project');++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 0);++ });++++ let task;++ await check('the task starts as a Run job for the person in their project; the same key answers it again', async () => {++ const r = await call('POST', '/tasks', { body: ask });++ assert.strictEqual(r.status, 201, JSON.stringify(r.json));++ assert.ok(validate('codes.task-start-result@1', r.json).valid, JSON.stringify(validate('codes.task-start-result@1', r.json).errors));++ task = r.json.task;++ assert.strictEqual(task.state, 'queued');++ assert.strictEqual(task.url, `https://openvibe.codes/run/tasks/${task.id}`);++ const submit = calls.find((c) => c[0] === 'submit')[1];++ assert.strictEqual(submit.subject, ada.subject);++ assert.strictEqual(submit.project, project);++ assert.strictEqual(submit.args.agent, 'deepseek');++ assert.strictEqual(submit.key, task.id);++ const again = await call('POST', '/tasks', { body: ask });++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json.created, false);++ assert.strictEqual(again.json.task.id, task.id);++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 1, 'one job');++ });++++ await check('reading follows Run; the machine’s result is passed on only as codes.task@1 allows', async () => {++ state = 'succeeded';++ result = {++ protocol: '[email protected]', ok: true, repo: 'https://github.com/octocat/Hello-World', base: 'master', agents: ['deepseek'],++ summary: 'x'.repeat(5000), files: [{ path: 'README', added: 1, removed: 0, extra: 'dropped' }, { nope: 1 }],++ patch: '+line\n', cost_usd: 0.0011, duration_ms: 4200, push: { pushed: false, reason: 'no token', smuggled: '<script>' },++ branch_url: 'javascript:alert(1)',++ };++ const r = await call('GET', `/tasks/${task.id}`);++ assert.strictEqual(r.status, 200, JSON.stringify(r.json));++ const v = validate('codes.task@1', r.json);++ assert.ok(v.valid, JSON.stringify(v.errors));++ assert.strictEqual(r.json.state, 'succeeded');++ assert.strictEqual(r.json.result.summary.length, 4000);++ assert.deepStrictEqual(r.json.result.files, [{ path: 'README', added: 1, removed: 0 }]);++ assert.deepStrictEqual(r.json.result.push, { pushed: false, reason: 'no token' });++ });++++ await check('another person, another service, or a task the person started by hand: 404', async () => {++ assert.strictEqual((await call('GET', `/tasks/${task.id}`, { subject: bo.subject })).status, 404);++ const watch = t.network.mintService('svc:watch', 'openvibe.codes', CAPS);++ assert.strictEqual((await call('GET', `/tasks/${task.id}`, { token: watch })).status, 404);++ assert.strictEqual((await call('GET', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1')).status, 404);++ assert.strictEqual((await call('GET', '/tasks/nope')).status, 404);++ });++++ await check('cancel reaches Run for a task not ended; an ended one is answered as it is', async () => {++ state = 'queued';++ result = null;++ const r2 = await call('POST', '/tasks', { body: { ...ask, idempotency_key: 'act_task_1:step-2' } });++ assert.strictEqual(r2.status, 201);++ const c = await call('POST', `/tasks/${r2.json.task.id}/cancel`);++ assert.strictEqual(c.status, 200, JSON.stringify(c.json));++ assert.strictEqual(c.json.state, 'cancelled');++ assert.ok(calls.some((x) => x[0] === 'cancel'));++ const before = calls.filter((x) => x[0] === 'cancel').length;++ const ended = await call('POST', `/tasks/${task.id}/cancel`);++ assert.strictEqual(ended.json.state, 'succeeded');++ assert.strictEqual(calls.filter((x) => x[0] === 'cancel').length, before);++ });++++ await check('the person’s own /run page lists the task a service started for them', async () => {++ const page = await t.get('/run', { as: ada });++ assert.ok(page.text.includes(`/run/tasks/${task.id}`));++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -20,10 +20,11 @@ One place to send a coding task to the agent that fits it — by what the task n
@@ -20,10 +20,11 @@ One place to send a coding task to the agent that fits it — by what the task n
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |
| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
| The agents | `/harnesses` | Every harness in [`server/data/harness-offers.json`](server/data/harness-offers.json) (validated at boot as `platform.harness-offer@1`, each model as `platform.agent-offer@1`) with its provider, address kind, what it can do, price and limits, and its models with context limits and token prices. No local path is ever shown. |
+| Task API (services) | `POST /api/v1/tasks`, `GET /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/cancel` ([server/http/tasks-api.js](server/http/tasks-api.js)) | Contracts 0.162 `codes.task.start` / `read` / `cancel`. A first-party service (OpenVibe.Actor) holding the capability starts a coding task for a person named in `X-OV-Subject`, with a Network service token for audience `openvibe.codes` (`CODES_AUDIENCE`). The task is the same one `/run` starts ([server/domain/run-tasks.js](server/domain/run-tasks.js)): it runs on that person's own machine, in the project of their latest `/run` task (none yet → 409 `codes.no_project`). It shows on their `/run` page. `idempotency_key` answers a retry with the first task (200, `created: false`). A service reads and cancels only the tasks it started for that person (otherwise 404). The result the machine returned is passed on only as `codes.task@1` allows, each field checked and cut to its limit (migration 0005: `via`, `idempotency_key`). Calls count against the person's budget: starts and cancels 6 a minute, 60 an hour. |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Agent API | `GET /api/v1/harnesses`, `POST /api/v1/harnesses/route` | Public, no key. The router takes `{ task, requirements? }` (`task` is `edit`, `review`, `long` or `resume`; `requirements.capabilities` adds hard constraints) and answers `{ selected, reasons, candidates }` from `openvibe-sdk/placement` — hard constraints first (a missing capability excludes an agent, and says so), then cost. RFC 9457 problems (`harness.invalid`, `harness.task_unknown`). |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
| Improve OpenVibe | `/improve` | Every OpenVibe repository, from the pinned contracts' service manifests (the services that run, then the shared libraries), and the path from a change to a reviewed pull request. |
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
| Community | `/policy`, `/policy/<document>` | The code of conduct ([CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md)), the contribution guide ([CONTRIBUTING.md](CONTRIBUTING.md)), the [contributor ladder](docs/governance/contributor-ladder.md) and the [moderation policy](docs/governance/moderation.md), rendered from those files. All four are **drafts pending owner review**: marked at the top, shown with a notice, not indexed; deleting the draft line publishes one. They govern the repositories and the apps and releases on OpenVibe.Services alike. |
-| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent API | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |
+| Moved | `/projects`, `/docs`, `/oauth`, `/tools`, `/manifests`, `/releases`, `/apps`, `/staff`, `/policy/{rfc,compatibility,licensing,transparency}`, `/api/v1/*` but the agent and task APIs | Permanent redirects to the same path and query on `https://openvibe.services`: 301 for GET and HEAD, 308 for anything else (the method and body are kept). `/docs/harnesses` goes to `/harnesses` here. [`server/http/moved.js`](server/http/moved.js). |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. |
| Machine | `/api/health`, `/api/ready`, `/release.json`, `/metrics` | Readiness is truthful (the database and the catalog required; JWKS, the OAuth client and Valkey reported as optional checks). `/metrics` answers loopback callers only. |
| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |
| Crawl artifacts | `/robots.txt`, `/sitemap.xml`, `/llms.txt`, `/llms-full.txt` | Built with openvibe-shared/seo. robots.txt disallows sign-in and the API and leaves the moved addresses crawlable, so engines follow their 301s to openvibe.services; the sitemap lists the public pages with STATUS.json's content date (never "today"). |
@@ -99,6 +100,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
@@ -99,6 +100,7 @@ No path in Codes sends or accepts a shared loopback key (tested by grep and at r
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harness-placement.test.js](test/harness-placement.test.js) — placement over the catalog: hard capability constraints first, then cost, with the reasons; an unknown task is refused.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses.test.js](test/harnesses.test.js) — the catalog validated at boot as `platform.harness-offer@1` / `platform.agent-offer@1`.
- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.
- [test/harnesses-api.test.js](test/harnesses-api.test.js) — `GET /api/v1/harnesses` and `POST /api/v1/harnesses/route`, their 422 problems and their per-actor limits.
+- [test/tasks-api.test.js](test/tasks-api.test.js) — `codes.task.*`: tokens refused, `codes.no_project`, a start in the person's project, idempotency, reading through Run with the machine's result cut to the contract, another person's or service's task 404, cancel.
- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.
- [test/harness-cli.test.js](test/harness-cli.test.js) — the `openvibe-codes` command (`agents`, `route`, `run`, `runs`, `show`, `resume`) and its exit code.
- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.
- [test/moved.test.js](test/moved.test.js) — every former console address answers 301 or 308 to the same path on openvibe.services, and what stays here is untouched.
- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.
- [test/auth-jwks.test.js](test/auth-jwks.test.js) — the Network signing key is fetched and verified: the last good keys through an outage, backoff, readiness reporting the cache.
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- Contracts 0.162 codes.task.start: a first-party service (OpenVibe.Actor) starts a coding task for a person. VIA is
+-- that service (service:<id>; null for a task the person started on /run), and only it reads or cancels the task
+-- through the API; IDEMPOTENCY_KEY makes its retry answer the first task.
+ALTER TABLE run_tasks ADD COLUMN via text COLLATE "C";
+ALTER TABLE run_tasks ADD COLUMN idempotency_key text COLLATE "C";
+CREATE UNIQUE INDEX run_tasks_service_key ON run_tasks (subject, via, idempotency_key) WHERE idempotency_key IS NOT NULL;
@@ -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.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -874,9 +874,9 @@
@@ -874,9 +874,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.143.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.143.0",
- "integrity": "sha512-Cdbf+9RoPAUBibpM2zXQvtsiKMugb55dKubTt8pFZvAmxsLtGvX+zaC++yL1vOSub8FX3+GgqcGtoJS6tklrUg==",
+ "version": "0.162.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0",
+ "integrity": "sha512-uSJBsK1mFEnbS/SRoNx6jzSQE7P+ihJ1VkHXbaOKwbEc7wJdeFkeEVaJIMwUjq6f/u+StbyDepJR9CT8LkxNRA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -30,7 +30,7 @@
@@ -30,7 +30,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.143.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.162.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.44.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.1.3",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
@@ -28,6 +28,7 @@ const { createSso } = require('./auth/sso');
const { createHarnesses } = require('./domain/harnesses');
const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createRunRoutes } = require('./http/run');
const { createRunRoutes } = require('./http/run');
+const { createTasksApi } = require('./http/tasks-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createMovedRoutes } = require('./http/moved');
const { createMovedRoutes } = require('./http/moved');
@@ -144,14 +145,16 @@ async function createApp(opts = {}) {
@@ -144,14 +145,16 @@ async function createApp(opts = {}) {
}));
}));
// ── API ─────────────────────────────────────────────────
// ── API ─────────────────────────────────────────────────
+ // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run, from the /run
+ // pages below or, for a first-party service acting for the person, from /api/v1/tasks (codes.task.*).
+ ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
+ app.use('/api/v1', createTasksApi({ config, store, keys, run: ctx.run, harnesses, actorLimits: ctx.actorLimits, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
app.use(rateLimit({ windowMs: 60_000, limit: Number(process.env.CODES_RATE_LIMIT_PER_MIN) || 300, standardHeaders: true, legacyHeaders: false })); // per address; tests raise it
app.use(createPageRoutes(ctx));
app.use(createPageRoutes(ctx));
- // Run on your machine (plan T16): coding tasks on the person's own machine, through OpenVibe Run.
- ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
app.use(createRunRoutes({ config, store, sso, harnesses, run: ctx.run, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use(createRunRoutes({ config, store, sso, harnesses, run: ctx.run, fetchImpl: fetchImpl || globalThis.fetch, log }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
app.use((req, res) => send(res, 404, { viewer: req.viewer, config, path: req.originalUrl, title: 'Not found', body: html`<h1>Not found</h1><p>No page here. Try <a href="/harnesses">the agents</a>, <a href="/improve">Improve OpenVibe</a>, or the developer platform at <a href="https://openvibe.services">openvibe.services</a>.</p>` }));
@@ -43,6 +43,8 @@ function load(env = process.env) {
@@ -43,6 +43,8 @@ function load(env = process.env) {
networkUrl,
networkUrl,
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkInternalUrl: trim(env.OV_NETWORK_INTERNAL_URL || 'http://127.0.0.1:4000'),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
networkIssuer: trim(env.OV_NETWORK_ISSUER || networkUrl),
+ // The audience of the Network service tokens a first-party service calls /api/v1/tasks with (codes.task.*).
+ serviceAudience: env.CODES_AUDIENCE || 'openvibe.codes',
oauth: {
oauth: {
clientId: env.OV_OAUTH_CLIENT_ID || 'codes',
clientId: env.OV_OAUTH_CLIENT_ID || 'codes',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
clientSecret: env.OV_OAUTH_CLIENT_SECRET || '',
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+
+/**
+ * Coding tasks on a person's own machine (run_tasks, plan T16): what the /run pages (server/http/run.js) and the
+ * service API (server/http/tasks-api.js, codes.task.*) share.
+ *
+ * createRunTasks({ store, run, config, log })
+ * start({ subject, project, args, via, key }) → the row: queued (Run took the job) or failed (Run's reason)
+ * refresh(row) → the row, updated from Run while its job has not ended
+ * latestProject(subject) → the project of the person's latest task, or null
+ * present(row) → codes.task@1
+ * TERMINAL, STATE_LABEL
+ *
+ * A task's result comes from the person's machine: present() passes on only the contract's fields, each checked and
+ * cut to its limit.
+ */
+const { RunError } = require('./run-client');
+
+const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
+const STATE_LABEL = {
+ queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',
+ failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',
+};
+const STATES = new Set(Object.keys(STATE_LABEL));
+
+const iso = (ms) => new Date(Number(ms)).toISOString();
+const str = (v, max) => (typeof v === 'string' ? v.slice(0, max) : undefined);
+const count = (v) => (Number.isInteger(v) && v >= 0 ? v : null);
+const httpsUrl = (v) => {
+ if (typeof v !== 'string' || v.length > 2000) return undefined;
+ try { return new URL(v).protocol === 'https:' ? v : undefined; } catch { return undefined; }
+};
+const drop = (o) => Object.fromEntries(Object.entries(o).filter(([, v]) => v !== undefined));
+
+/** The job's result document (harness/job.js) as codes.task@1's `result`, or undefined. */
+function presentResult(d) {
+ if (!d || typeof d !== 'object' || Array.isArray(d)) return undefined;
+ const files = Array.isArray(d.files) ? d.files.slice(0, 2000).filter((f) => f && typeof f.path === 'string' && f.path)
+ .map((f) => ({ path: f.path.slice(0, 500), added: count(f.added), removed: count(f.removed) })) : undefined;
+ const p = d.push && typeof d.push === 'object' ? drop({
+ pushed: d.push.pushed === true, branch: str(d.push.branch, 200), branch_url: httpsUrl(d.push.branch_url),
+ pull_request: httpsUrl(d.push.pull_request), pull_request_error: str(d.push.pull_request_error, 500), reason: str(d.push.reason, 500),
+ }) : undefined;
+ return drop({
+ ok: d.ok === true,
+ agents: Array.isArray(d.agents) ? d.agents.filter((a) => typeof a === 'string').slice(0, 20).map((a) => a.slice(0, 41)) : undefined,
+ summary: str(d.summary, 4000),
+ files,
+ patch: str(d.patch, 262144),
+ patch_truncated: d.patch_truncated === true || (typeof d.patch === 'string' && d.patch.length > 262144) ? true : undefined,
+ push: p,
+ cost_usd: typeof d.cost_usd === 'number' && Number.isFinite(d.cost_usd) && d.cost_usd >= 0 ? d.cost_usd : undefined,
+ duration_ms: Number.isInteger(d.duration_ms) && d.duration_ms >= 0 ? d.duration_ms : undefined,
+ error: str(d.error, 1000),
+ });
+}
+
+function createRunTasks({ store, run, config, log = console }) {
+ const db = store.db;
+ const who = (row) => ({ subject: row.subject, project: row.project_id });
+
+ async function start({ subject, project, args, via = null, key = null }) {
+ const id = store.newId('tsk');
+ const at = store.now();
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12, $12)`,
+ [id, subject, project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), via, key, at]);
+ try {
+ const job = await run.submit({ subject, project, args, key: id });
+ await db.exec('UPDATE run_tasks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1', [id, job.id, job.state, store.now()]);
+ } catch (e) {
+ const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
+ await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1", [id, why, store.now()]);
+ }
+ return db.one('SELECT * FROM run_tasks WHERE id = $1', [id]);
+ }
+
+ async function refresh(row) {
+ if (!row.job_id || TERMINAL.has(row.state)) return row;
+ let job;
+ try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }
+ if (job.state === row.state && !TERMINAL.has(job.state)) return row;
+ const error = job.error ? `${job.error.code}${job.error.detail ? `: ${job.error.detail}` : ''}` : null;
+ const result = job.result && typeof job.result === 'object' ? job.result : null;
+ return db.one('UPDATE run_tasks SET state = $2, result = $3, error = $4, updated_at = $5 WHERE id = $1 RETURNING *',
+ [row.id, job.state, result ? JSON.stringify(result) : null, error, store.now()]);
+ }
+
+ async function latestProject(subject) {
+ const row = await db.maybe('SELECT project_id FROM run_tasks WHERE subject = $1 ORDER BY created_at DESC LIMIT 1', [subject]);
+ return row ? row.project_id : null;
+ }
+
+ function present(row) {
+ const result = TERMINAL.has(row.state) ? presentResult(typeof row.result === 'string' ? JSON.parse(row.result) : row.result) : undefined;
+ return drop({
+ id: row.id, state: STATES.has(row.state) ? row.state : 'failed', repo: String(row.repo).slice(0, 300), ref: row.ref || null,
+ agent: row.agent || null, url: `${config.baseUrl}/run/tasks/${row.id}`, created_at: iso(row.created_at), updated_at: iso(row.updated_at),
+ error: row.error ? String(row.error).slice(0, 1000) : null, result,
+ });
+ }
+
+ return { start, refresh, latestProject, present, who };
+}
+
+module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL };
@@ -33,6 +33,8 @@ function actor(req) {
@@ -33,6 +33,8 @@ function actor(req) {
const BUDGETS = {
const BUDGETS = {
// Routing a coding task over the harness catalog (no I/O, but a POST: the reads' defaults skip it).
// Routing a coding task over the harness catalog (no I/O, but a POST: the reads' defaults skip it).
'codes.harness.route': { minute: 30, hour: 600 },
'codes.harness.route': { minute: 30, hour: 600 },
+ // A service starting (or cancelling) a coding task for a person counts against that person (tasks-api.js).
+ 'codes.task.start': { minute: 6, hour: 60 },
};
};
/**
/**
@@ -17,8 +17,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -17,8 +17,8 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
// The console's paths (and everything under them), and the platform policy pages that moved with it.
// The console's paths (and everything under them), and the platform policy pages that moved with it.
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
-// The console's API: everything under /api/v1 but the agent catalog.
-const API_STAYS = /^\/api\/v1\/harnesses(\/|$)/;
+// The console's API: everything under /api/v1 but the agent catalog and the coding tasks (codes.task.*).
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
@@ -17,22 +17,18 @@ const { asyncRouter } = require('./router');
@@ -17,22 +17,18 @@ const { asyncRouter } = require('./router');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const { html, raw, esc } = require('../render/html');
const { html, raw, esc } = require('../render/html');
+const { parseArgs } = require('../../harness/job');
+const { createRunTasks, TERMINAL, STATE_LABEL } = require('../domain/run-tasks');
+
+const DELEGABLE = new Set(['owner', 'admin']);
+const MACHINES_URL = 'https://openvibe.bot/machines';
+
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
/** A repository URL as a heading: the host small, then owner/name, which may break after a slash but never mid-word. */
function repoName(url) {
function repoName(url) {
const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');
const [host, ...path] = String(url).replace(/^https:\/\//, '').split('/');
if (!path.length) return host;
if (!path.length) return host;
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
return html`<small class="run-host">${host}/</small>${path.map((part, i) => html`${i ? raw('/<wbr>') : ''}${part}`)}`;
}
}
-const { parseArgs } = require('../../harness/job');
-const { RunError } = require('../domain/run-client');
-
-const TERMINAL = new Set(['succeeded', 'failed', 'cancelled', 'expired']);
-const STATE_LABEL = {
- queued: 'Waiting for your machine', placed: 'Sent to your machine', running: 'Running', succeeded: 'Finished',
- failed: 'Failed', cancelled: 'Cancelled', expired: 'Expired (no machine took it in time)',
-};
-const DELEGABLE = new Set(['owner', 'admin']);
-const MACHINES_URL = 'https://openvibe.bot/machines';
function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console }) {
function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globalThis.fetch, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
@@ -62,19 +58,9 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
@@ -62,19 +58,9 @@ function createRunRoutes({ config, store, sso, harnesses, run, fetchImpl = globa
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
const row = await db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]);
return row && row.subject === req.viewer.subject ? row : null;
return row && row.subject === req.viewer.subject ? row : null;
};
};
- const who = (row) => ({ subject: row.subject, project: row.project_id });
-
- /** A task not yet ended asks Run where its job is; an ended job's result is kept on the row. */
- async function refresh(row) {
- if (!row.job_id || TERMINAL.has(row.state)) return row;
- let job;
- try { job = await run.read(row.job_id, who(row)); } catch (e) { log.warn(`[Codes] task ${row.id}: ${e.message}`); return row; }
- if (job.state === row.state && !TERMINAL.has(job.state)) return row;
- const error = job.error ? `${job.error.code}${job.error.detail ? `: ${job.error.detail}` : ''}` : null;
- const result = job.result && typeof job.result === 'object' ? job.result : null;
- return db.one('UPDATE run_tasks SET state = $2, result = $3, error = $4, updated_at = $5 WHERE id = $1 RETURNING *',
- [row.id, job.state, result ? JSON.stringify(result) : null, error, store.now()]);
- }
+ // A task not yet ended asks Run where its job is; an ended job's result is kept on the row (domain/run-tasks).
+ const tasks = createRunTasks({ store, run, config, log });
+ const { who, refresh } = tasks;
function formHtml({ projects, values = {}, error = null }) {
function formHtml({ projects, values = {}, error = null }) {
const agents = harnesses.list();
const agents = harnesses.list();
@@ -168,19 +154,8 @@ ${tasksHtml(recent)}`;
@@ -168,19 +154,8 @@ ${tasksHtml(recent)}`;
}
}
const projects = await projectsOf(req, res);
const projects = await projectsOf(req, res);
if (!projects.some((p) => p.id === values.project)) return runPage(req, res, { values, error: 'Choose one of your projects.', status: 422 });
if (!projects.some((p) => p.id === values.project)) return runPage(req, res, { values, error: 'Choose one of your projects.', status: 422 });
- const id = store.newId('tsk');
- const at = store.now();
- await db.exec(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $10)`,
- [id, req.viewer.subject, values.project, args.repo, args.ref, args.prompt, args.agent, !!args.push, !!(args.push && args.push.pull_request), at]);
- try {
- const job = await run.submit({ subject: req.viewer.subject, project: values.project, args, key: id });
- await db.exec('UPDATE run_tasks SET job_id = $2, state = $3, updated_at = $4 WHERE id = $1', [id, job.id, job.state, store.now()]);
- } catch (e) {
- const why = e instanceof RunError ? `${e.code}: ${e.message}` : e.message;
- await db.exec("UPDATE run_tasks SET state = 'failed', error = $2, updated_at = $3 WHERE id = $1", [id, why, store.now()]);
- }
- return res.redirect(303, `/run/tasks/${id}`);
+ const row = await tasks.start({ subject: req.viewer.subject, project: values.project, args });
+ return res.redirect(303, `/run/tasks/${row.id}`);
});
});
function resultHtml(row) {
function resultHtml(row) {
new file mode 100644
new file mode 100644
@@ -0,0 +1,116 @@
@@ -0,0 +1,116 @@
+'use strict';
+
+/**
+ * The service API for coding tasks (Contracts 0.162 codes.task.*, plan T16/T17), under /api/v1: a first-party
+ * service (OpenVibe.Actor) starts a coding task for a person, who is named in X-OV-Subject, reads it and cancels it.
+ * The task is the same one openvibe.codes/run starts (domain/run-tasks): an OpenVibe.Run job on the person's own
+ * machine, in the project of their latest /run task.
+ *
+ * POST /tasks codes.task.start codes.task-start-request@1 → codes.task-start-result@1 (201, or 200 again)
+ * GET /tasks/:id codes.task.read → codes.task@1
+ * POST /tasks/:id/cancel codes.task.cancel → codes.task@1
+ *
+ * A Network service token for audience openvibe.codes (CODES_AUDIENCE), from a svc: principal holding the route's
+ * capability. A service sees only the tasks it started, for that person; anything else is 404. The person's task
+ * page (the codes.task@1 url) shows every task they own, whoever started it. Calls count against the person's own
+ * per-actor budget (actor-limits.js), not the service's.
+ */
+const express = require('express');
+const { serviceAuth, capabilities, http, validate } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { parseArgs } = require('../../harness/job');
+const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+function createTasksApi({ config, store, keys, run, harnesses, actorLimits, log = console }) {
+ const r = asyncRouter();
+ const db = store.db;
+ const tasks = createRunTasks({ store, run, config, log });
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const json = express.json({ limit: '32kb' });
+
+ /** The route's guard: the service token, its capability and the person it acts for → req.principal. */
+ const guard = (cap) => async (req, res, next) => {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return problem(req, res, 401, 'token.required', `${cap} needs a Network service token for ${config.serviceAudience}`);
+ if (!keys.loaded()) await keys.ensure();
+ const publicKey = keys.get();
+ if (!publicKey) return problem(req, res, 503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const v = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.networkIssuer, audience: config.serviceAudience });
+ if (!v.ok) return problem(req, res, 401, v.code || 'token.invalid', v.reason || 'the service token is invalid');
+ const sub = String(v.claims.sub || '');
+ if (!sub.startsWith('svc:')) return problem(req, res, 403, 'capability.denied', `${cap} is for first-party services`);
+ const decision = capabilities.check(v.claims, cap);
+ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || `${cap} not granted`);
+ const subject = String(req.get('X-OV-Subject') || '').trim();
+ if (!USER.test(subject)) return problem(req, res, 422, 'subject.required', 'name the person the task is for in X-OV-Subject (usr_…)');
+ req.principal = { sub: `user:${subject}`, subject, via: `service:${sub.slice(4)}` };
+ return next();
+ };
+
+ /** The task this service started for this person, or null. */
+ const mine = async (req) => {
+ if (!TASK.test(String(req.params.id))) return null;
+ return db.maybe('SELECT * FROM run_tasks WHERE id = $1 AND subject = $2 AND via = $3', [req.params.id, req.principal.subject, req.principal.via]);
+ };
+ const byKey = (p, key) => db.maybe('SELECT * FROM run_tasks WHERE subject = $1 AND via = $2 AND idempotency_key = $3', [p.subject, p.via, key]);
+
+ r.post('/tasks', guard('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
+ const b = req.body;
+ const check = validate('codes.task-start-request@1', b);
+ if (!check.valid) return problem(req, res, 422, 'codes.task_invalid', check.errors.map((e) => `${e.instancePath || 'body'} ${e.message}`).join('; ').slice(0, 500));
+ if (b.agent && !harnesses.list().some((a) => a.id === b.agent)) return problem(req, res, 422, 'codes.agent_unknown', 'agent is one of GET /api/v1/harnesses, or absent for the router to pick');
+ const p = req.principal;
+ const key = b.idempotency_key || null;
+ if (key) {
+ const seen = await byKey(p, key);
+ if (seen) return res.status(200).json({ task: tasks.present(await tasks.refresh(seen)), created: false });
+ }
+ let args;
+ try {
+ args = parseArgs(JSON.stringify({
+ repo: b.repo, ref: b.ref || null, prompt: b.prompt, agent: b.agent || null, permission: b.permission || 'edit',
+ ...(b.push ? { push: { pull_request: b.pull_request === true } } : {}),
+ }));
+ } catch (e) {
+ return problem(req, res, 422, 'codes.task_invalid', e.message);
+ }
+ const project = await tasks.latestProject(p.subject);
+ if (!project) {
+ return problem(req, res, 409, 'codes.no_project', `this person has not run a task on ${config.baseUrl}/run yet: a service's task runs in the project of their latest one there, on a machine of theirs (openvibe.bot/machines)`);
+ }
+ let row;
+ try {
+ row = await tasks.start({ subject: p.subject, project, args, via: p.via, key });
+ } catch (e) {
+ // Two requests with one key at once: the unique index lets one in; the other answers with it.
+ const seen = key && e && e.code === '23505' ? await byKey(p, key) : null;
+ if (!seen) throw e;
+ return res.status(200).json({ task: tasks.present(seen), created: false });
+ }
+ log.info(`[Codes] task ${row.id} started by ${p.via} for ${p.subject}: ${row.state}`);
+ return res.status(201).json({ task: tasks.present(row), created: true });
+ });
+
+ r.get('/tasks/:id', guard('codes.task.read'), actorLimits.reads('codes.read'), async (req, res) => {
+ const row = await mine(req);
+ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
+ return res.json(tasks.present(await tasks.refresh(row)));
+ });
+
+ r.post('/tasks/:id/cancel', guard('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
+ let row = await mine(req);
+ if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
+ if (row.job_id && !TERMINAL.has(row.state)) {
+ try { await run.cancel(row.job_id, tasks.who(row)); } catch (e) { log.warn(`[Codes] cancel ${row.id}: ${e.message}`); }
+ row = await tasks.refresh(row);
+ }
+ return res.json(tasks.present(row));
+ });
+
+ return r;
+}
+
+module.exports = { createTasksApi };
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
'use strict';
'use strict';
/**
/**
* The released contracts (the pinned openvibe-contracts tag) describe what the code does. Since 0.113.0 the codes
* The released contracts (the pinned openvibe-contracts tag) describe what the code does. Since 0.113.0 the codes
- * service manifest declares no capability and no event: the developer console moved to OpenVibe.Services, which owns
+ * service manifest declares the coding-task capabilities (codes.task.*, Contracts 0.162) and no event: the developer console moved to OpenVibe.Services, which owns
* services.release.* and services.app.*, and the codes.* forms are retired. The harness catalog's rows are the
* services.release.* and services.app.*, and the codes.* forms are retired. The harness catalog's rows are the
* contracts' platform.harness-offer@1 and platform.agent-offer@1 (checked at boot by server/domain/harnesses.js).
* contracts' platform.harness-offer@1 and platform.agent-offer@1 (checked at boot by server/domain/harnesses.js).
*/
*/
@@ -14,12 +14,14 @@ const { check, done } = require('./helpers/boot');
@@ -14,12 +14,14 @@ const { check, done } = require('./helpers/boot');
(async () => {
(async () => {
const manifest = contracts.services.get('codes');
const manifest = contracts.services.get('codes');
- await check('the codes service manifest is released as alpha, live, with no capability and no event', async () => {
+ await check('the codes service manifest is released as alpha, live, with the coding-task capabilities and no event', async () => {
assert.ok(manifest);
assert.ok(manifest);
assert.strictEqual(manifest.status, 'alpha');
assert.strictEqual(manifest.status, 'alpha');
assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
assert.strictEqual(manifest.exposure.state, 'live');
assert.strictEqual(manifest.exposure.state, 'live');
- assert.deepStrictEqual(manifest.capabilities, []);
+ // Contracts 0.162: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js).
+ assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel']);
+ for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
assert.deepStrictEqual(manifest.eventsProduced, []);
assert.deepStrictEqual(manifest.eventsProduced, []);
const range = manifest.contractRanges['openvibe-contracts'];
const range = manifest.contractRanges['openvibe-contracts'];
assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
assert.ok(require('openvibe-sdk/core').satisfiesRange(require('openvibe-contracts/package.json').version, range));
@@ -322,6 +322,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
@@ -322,6 +322,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
setCatalog(list) { st.catalog = list; },
setCatalog(list) { st.catalog = list; },
setDown(v) { st.down = v; },
setDown(v) { st.down = v; },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
mintApp: (appId, audience, cap, extra = {}) => { const a = st.apps.get(appId); const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub: `app:${a.id}`, actor_type: 'app', aud: [audience], cap, ns: [a.project_id], project_id: a.project_id, env: a.environment, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
+ /** A Network client-credentials token: a first-party service (svc:<id>) or an app (app:<id>), for audience, holding cap. */
+ mintService: (sub, audience, cap, extra = {}) => { const now = Math.floor(Date.now() / 1000); return serviceAuth.signServiceToken({ iss: issuer, sub, actor_type: sub.startsWith('svc:') ? 'service' : 'app', aud: [audience], cap, iat: now, exp: now + 300, jti: `tok_${crypto.randomBytes(8).toString('hex')}`, ...extra }, privatePem); },
close: srv.close,
close: srv.close,
};
};
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,132 @@
@@ -0,0 +1,132 @@
+'use strict';
+/**
+ * The service API for coding tasks (server/http/tasks-api.js, Contracts 0.162 codes.task.*): a first-party service
+ * holding the capability starts a task for a person named in X-OV-Subject, in the project of that person's latest
+ * /run task, on a stand-in OpenVibe.Run; a repeated idempotency key answers the first task; the service reads and
+ * cancels only the tasks it started for that person; what the person's machine returned is passed on only as the
+ * contract allows. Tokens: none, an app's, a service's without the capability or for another audience are refused.
+ */
+const assert = require('assert');
+const { validate } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+
+(async () => {
+ const calls = [];
+ let state = 'queued';
+ let result = null;
+ const run = {
+ enabled: true,
+ async submit(o) { calls.push(['submit', o]); return { id: 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J1', state: 'queued' }; },
+ async read(jobId, who) { calls.push(['read', jobId, who]); return { id: jobId, state, result, error: null }; },
+ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); state = 'cancelled'; return { id: jobId, state }; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ const t = await boot({ run });
+ const db = t.ctx.store.db;
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const CAPS = ['codes.task.start', 'codes.task.read', 'codes.task.cancel'];
+ const actor = t.network.mintService('svc:actor', 'openvibe.codes', CAPS);
+ const call = async (method, path, { token = actor, subject = ada.subject, body } = {}) => {
+ const headers = { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(subject ? { 'X-OV-Subject': subject } : {}) };
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
+ const res = await fetch(`${t.base}/api/v1${path}`, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual' });
+ return { status: res.status, json: await res.json().catch(() => null) };
+ };
+ const ask = { repo: 'https://github.com/octocat/Hello-World', prompt: 'Add a line to the README.', agent: 'deepseek', idempotency_key: 'act_task_1:step-1' };
+
+ // Ada ran a task on /run before (so Codes knows her project); Bo never did.
+ const project = 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P1';
+ await db.exec(`INSERT INTO run_tasks (id, subject, project_id, job_id, repo, prompt, state, created_at, updated_at)
+ VALUES ('tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1', $1, $2, 'job_01J8Z3Q4R5S6T7V8W9X0Y1Z200', 'https://github.com/a/b', 'by hand', 'succeeded', 1, 2)`, [ada.subject, project]);
+
+ try {
+ await check('no token, an app token, a service without the capability or for another audience, no person: refused', async () => {
+ assert.strictEqual((await call('POST', '/tasks', { token: null, body: ask })).status, 401);
+ assert.strictEqual((await call('POST', '/tasks', { token: t.network.mintService('app:app_01J8Z3Q4R5S6T7V8W9X0Y1Z2A1', 'openvibe.codes', CAPS, { ns: ['prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P9'], project_id: 'prj_01J8Z3Q4R5S6T7V8W9X0Y1Z2P9', env: 'production' }), body: ask })).status, 403);
+ const noCap = await call('POST', '/tasks', { token: t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.read']), body: ask });
+ assert.strictEqual(noCap.status, 403, JSON.stringify(noCap.json));
+ assert.strictEqual((await call('POST', '/tasks', { token: t.network.mintService('svc:actor', 'openvibe.run', CAPS), body: ask })).status, 401);
+ assert.strictEqual((await call('POST', '/tasks', { subject: null, body: ask })).status, 422);
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 0);
+ });
+
+ await check('a bad body or an unknown agent is 422; a person who never ran a task on /run is 409 codes.no_project', async () => {
+ assert.strictEqual((await call('POST', '/tasks', { body: { ...ask, repo: 'http://github.com/a/b' } })).status, 422);
+ assert.strictEqual((await call('POST', '/tasks', { body: { ...ask, agent: 'nobody' } })).status, 422);
+ const none = await call('POST', '/tasks', { subject: bo.subject, body: ask });
+ assert.strictEqual(none.status, 409);
+ assert.strictEqual(none.json.code, 'codes.no_project');
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 0);
+ });
+
+ let task;
+ await check('the task starts as a Run job for the person in their project; the same key answers it again', async () => {
+ const r = await call('POST', '/tasks', { body: ask });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.json));
+ assert.ok(validate('codes.task-start-result@1', r.json).valid, JSON.stringify(validate('codes.task-start-result@1', r.json).errors));
+ task = r.json.task;
+ assert.strictEqual(task.state, 'queued');
+ assert.strictEqual(task.url, `https://openvibe.codes/run/tasks/${task.id}`);
+ const submit = calls.find((c) => c[0] === 'submit')[1];
+ assert.strictEqual(submit.subject, ada.subject);
+ assert.strictEqual(submit.project, project);
+ assert.strictEqual(submit.args.agent, 'deepseek');
+ assert.strictEqual(submit.key, task.id);
+ const again = await call('POST', '/tasks', { body: ask });
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json.created, false);
+ assert.strictEqual(again.json.task.id, task.id);
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, 1, 'one job');
+ });
+
+ await check('reading follows Run; the machine’s result is passed on only as codes.task@1 allows', async () => {
+ state = 'succeeded';
+ result = {
+ protocol: '[email protected]', ok: true, repo: 'https://github.com/octocat/Hello-World', base: 'master', agents: ['deepseek'],
+ summary: 'x'.repeat(5000), files: [{ path: 'README', added: 1, removed: 0, extra: 'dropped' }, { nope: 1 }],
+ patch: '+line\n', cost_usd: 0.0011, duration_ms: 4200, push: { pushed: false, reason: 'no token', smuggled: '<script>' },
+ branch_url: 'javascript:alert(1)',
+ };
+ const r = await call('GET', `/tasks/${task.id}`);
+ assert.strictEqual(r.status, 200, JSON.stringify(r.json));
+ const v = validate('codes.task@1', r.json);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ assert.strictEqual(r.json.state, 'succeeded');
+ assert.strictEqual(r.json.result.summary.length, 4000);
+ assert.deepStrictEqual(r.json.result.files, [{ path: 'README', added: 1, removed: 0 }]);
+ assert.deepStrictEqual(r.json.result.push, { pushed: false, reason: 'no token' });
+ });
+
+ await check('another person, another service, or a task the person started by hand: 404', async () => {
+ assert.strictEqual((await call('GET', `/tasks/${task.id}`, { subject: bo.subject })).status, 404);
+ const watch = t.network.mintService('svc:watch', 'openvibe.codes', CAPS);
+ assert.strictEqual((await call('GET', `/tasks/${task.id}`, { token: watch })).status, 404);
+ assert.strictEqual((await call('GET', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1')).status, 404);
+ assert.strictEqual((await call('GET', '/tasks/nope')).status, 404);
+ });
+
+ await check('cancel reaches Run for a task not ended; an ended one is answered as it is', async () => {
+ state = 'queued';
+ result = null;
+ const r2 = await call('POST', '/tasks', { body: { ...ask, idempotency_key: 'act_task_1:step-2' } });
+ assert.strictEqual(r2.status, 201);
+ const c = await call('POST', `/tasks/${r2.json.task.id}/cancel`);
+ assert.strictEqual(c.status, 200, JSON.stringify(c.json));
+ assert.strictEqual(c.json.state, 'cancelled');
+ assert.ok(calls.some((x) => x[0] === 'cancel'));
+ const before = calls.filter((x) => x[0] === 'cancel').length;
+ const ended = await call('POST', `/tasks/${task.id}/cancel`);
+ assert.strictEqual(ended.json.state, 'succeeded');
+ assert.strictEqual(calls.filter((x) => x[0] === 'cancel').length, before);
+ });
+
+ await check('the person’s own /run page lists the task a service started for them', async () => {
+ const page = await t.get('/run', { as: ada });
+ assert.ok(page.text.includes(`/run/tasks/${task.id}`));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });