openvibers/openvibe.codes Commits

Codes: a service starts, reads and cancels a person's coding task (codes.task.*, for Actor) (#65)

OP OpenVibers committed 30 days ago · 07b75710fc · 14 files, +398 −49

Plan T16/T17: a first-party service (OpenVibe.Actor first) starts a coding task for a person on that person's own machine. This implements Contracts 0.162 `codes.task.start`, `codes.task.read` and `codes.task.cancel`.

**API** (`server/http/tasks-api.js`, under `/api/v1`)
- `POST /tasks` (`codes.task-start-request@1` → `codes.task-start-result@1`):
  - runs in the project of the person's latest `/run` task (none → 409 `codes.no_project`);
  - `idempotency_key` answers a retry with the first task, including two requests at once (unique index);
  - an unknown agent → 422.
- `GET /tasks/:id` follows Run.
- `POST /tasks/:id/cancel` cancels the Run job. An ended task is answered as it is.
- **Auth:** a Network service token for `openvibe.codes` (`CODES_AUDIENCE`), from a `svc:` principal holding the route's capability, plus `X-OV-Subject`. A service sees only the tasks it started for that person; anything else is 404.
- **Limits:** calls count against the person's own budget (starts and cancels 6/min, 60/h).
- **Untrusted results:** the result comes from the person's machine. `present()` passes on only `codes.task@1`'s fields, each type-checked and cut to its limit; https URLs only for push links.

**Shared code**
- `server/domain/run-tasks.js` holds start, refresh, latest project and present. The `/run` pages use it too, so both paths behave the same, and a service's task shows on the person's `/run` page.

**Other changes**
- Migration 0005 (expand): `run_tasks.via`, `run_tasks.idempotency_key`, and a unique index on (subject, via, key).
- `moved.js` keeps `/api/v1/tasks` here (it redirected everything under `/api/v1` but the agent API to Services).
- Contracts pin 0.143 → 0.162.
- README.

**Tests**
- `tasks-api` (7 checks): tokens refused, no project, the start, idempotency, a result cut to the contract, another person or service or hand-made task 404, cancel, the `/run` listing.
- `contracts` now expects the three capabilities.
- Suite: 28/28.

Changes

ddiff --git a/README.md b/README.md
iindex 581849a..a8fb132 100644
---- a/README.md
++++ b/README.md
@@@ -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.
ddiff --git a/migrations/0005_run_tasks_service.sql b/migrations/0005_run_tasks_service.sql
nnew file mode 100644
iindex 0000000..52b8505
---- /dev/null
++++ b/migrations/0005_run_tasks_service.sql
@@@ -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;
ddiff --git a/package-lock.json b/package-lock.json
iindex e26a311..00c3692 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -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",
ddiff --git a/package.json b/package.json
iindex c859f0f..e4b1c94 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/app.js b/server/app.js
iindex 7cbbe94..f53cd6e 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 it
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((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>` }));
ddiff --git a/server/config.js b/server/config.js
iindex db0e1ea..41372ba 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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 || '',
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
nnew file mode 100644
iindex 0000000..c590149
---- /dev/null
++++ b/server/domain/run-tasks.js
@@@ -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 };
ddiff --git a/server/http/actor-limits.js b/server/http/actor-limits.js
iindex 2cd3c46..2efb177 100644
---- a/server/http/actor-limits.js
++++ b/server/http/actor-limits.js
@@@ -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 },
};
/**
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 1ea4218..062f092 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -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}/`);
ddiff --git a/server/http/run.js b/server/http/run.js
iindex 847c62e..d3e4ad7 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -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 = globa
const 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) {
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
nnew file mode 100644
iindex 0000000..52f37bd
---- /dev/null
++++ b/server/http/tasks-api.js
@@@ -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 };
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex d54cc78..76f1c03 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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));
ddiff --git a/test/helpers/mocks.js b/test/helpers/mocks.js
iindex 289fe7b..d0afcfb 100644
---- a/test/helpers/mocks.js
++++ b/test/helpers/mocks.js
@@@ -322,6 +322,8 @@ async function startNetwork({ sandboxAudiences = ['openvibe.events', 'openvibe.m
setCatalog(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,
};
}
ddiff --git a/test/tasks-api.test.js b/test/tasks-api.test.js
nnew file mode 100644
iindex 0000000..deddc0a
---- /dev/null
++++ b/test/tasks-api.test.js
@@@ -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); });