openvibers/openvibe.actor Commits
One agent system: the person's runs and steps in agents.run@1 and agents.step@1 (#451)
Changes
@@@ -2,6 +2,21 @@What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.++## Unreleased — the same run shape as Codes and Run (`agents.run@1`, `agents.step@1`)++++- **`GET /api/v1/runs`** lists the person's own runs newest first (`limit`, `before`, `status`, `agent`) in the one++ agent system's `agents.run@1` shape: owner, agent and product, parent run, budget, status, timestamps, result++ summary and cost. A task is the run; nothing new is stored.++- **`GET /api/v1/runs/:id/steps?after=`** reads the task's own event log as `agents.step@1`, one entry per output++ event, with the task event's seq as the cursor. `GET /api/v1/runs/:id` answers the same run shape when the reader++ asks for it with `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` sends) or++ `?shape=agents.run@1`; the existing `agent.run@1` snapshot stays the default for its callers.++- Read-only and behind the existing `actor.task.read`/`actor.task.list`: only the person's own Network token or++ session, and another person's run is the same 404 as one that does not exist. Actor's tables are unchanged.++- The field map and what does not fit (per-step cost, Actor's `needs_you`, a tool call and its result as two log++ entries) are in OpenVibe.Contracts `docs/agents-runs.md`; the routes are in [docs/agent-runs.md](docs/agent-runs.md).++ Tests: `test/runs-api.test.js`.++## Unreleased — See it work (W1): the desk, the viewer and the replay- **The desk tile** in a running task's card: the desk's private Live stream (started with the task's desk through Link's@@@ -20,3 +20,25 @@ with titles. Historical clipped outputs cannot be recovered. Approvals emit ov.aGrouping, one-line summaries and step status are carried in `metadata["ov.step"]`; the reserved `metadata.ov`contains the contract's title, kind, sources, attachments, cost, needs_you, product and ref fields.++++## The one agent system's shape (`agents.run@1`, `agents.step@1`)++++The same run also answers in the Contracts shape every agent product uses (OpenVibe.Contracts `docs/agents-runs.md`++maps Actor's `tasks`/`task_events` field by field):++++- `GET /api/v1/runs` — the person's own runs, newest first (`limit`, `before`, `status`, `agent`), `{ runs, next }`.++ A run is `agents.run@1`: `id`, `owner`, `agent { id, name?, product: "actor" }`, `parent_run`, `budget` (USD and++ the timeout in seconds), `status` (`planning|running|waiting|done|failed|stopped`), `created_at`, `updated_at`,++ `result_summary` and `cost`.++- `GET /api/v1/runs/:id` — the older `agent.run@1` snapshot stays the default. A reader asks for the new shape with++ `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` `runs.get` sends) or++ `?shape=agents.run@1`.++- `GET /api/v1/runs/:id/steps?after=N` — the task's own event log as `agents.step@1`, one entry per output event++ (`run_id`, `seq`, `kind think|tool|message|child|file`, `text`, `tool_call`, `result`, `started_at`, `ended_at`),++ `{ run_id, steps, next }`. `seq` is the task event's own and is never renumbered, so `after` is the same cursor a++ reconnect uses.++++All three are read-only and use the task routes' `actor.task.read`/`actor.task.list`; only the person's own Network++token (or this site's session) is accepted, and anyone else — including an app's own token — gets the same 404 as a++run that does not exist. Actor's tables are unchanged. The event log splits a tool call and its result into two++entries; both become `kind: tool`, the call's entry has `tool_call`, the result's has `result`.@@@ -28,6 +28,7 @@const express = require('express');const contracts = require('openvibe-contracts');const { asyncRouter } = require('./router');++const runsApi = require('./runs-api');const store = require('../tasks/store');const tickets = require('../task-view/tickets');const catalog = require('../agents/catalog');@@@ -58,6 +59,11 @@ function createApi(ctx) {const { config, s, engine, stream, principal, limits, webhooks, triggers } = ctx;const r = asyncRouter();const problem = (req, res, status, code, detail, extra) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });++ // The one agent system's run shape (agents.run@1) and the older run card's agent.run@1 share GET /runs/:id:++ // a reader asks for the new shape with this media type (the SDK's runs.get does) or ?shape=agents.run@1, and++ // every existing caller keeps the snapshot it knows.++ const wantsAgentsRun = (req) => String(req.query.shape || req.query.contract || '') === 'agents.run@1'++ || /application\/vnd\.openvibe\.agents\.run\+json/i.test(String(req.get('accept') || ''));// A trigger belongs to the person in the request, and its Watch calls use that person's own token: the// Bearer the API was called with, or this site's session token. A service acting for a person is not it.const watchToken = (req) => {@@@ -116,6 +122,8 @@ function createApi(ctx) {});r.use('/me/actor', require('../day/cors').cors(ctx.config));r.use(principal.middleware);++ // GET /runs and /runs/:id/steps: the one agent system's read-only view (agents.run@1, agents.step@1).++ r.use('/runs', runsApi.createRunsRoutes(ctx));r.use('/me/actor', require('../day/routes').router(ctx));r.use('/chat-apps', require('../chat-apps/routes').router(ctx));@@@ -973,6 +981,12 @@ function createApi(ctx) {if (match) req.params.id = match[1];const task = await load(req, res);if (!task) return;++ if (wantsAgentsRun(req)) {++ if (!req.principal || req.principal.kind !== 'user') return problem(req, res, 404, 'agents.run.not_found', 'No such run.');++ const run = await runsApi.loadRunRow(s, task.id, req.principal.requester);++ if (!run) return problem(req, res, 404, 'agents.run.not_found', 'No such run.');++ return res.json(await runsApi.viewOf(s, run, s.now()));++ }const row = await agentRuns.resolve(task, start);if (!row) return problem(req, res, 404, 'actor.task.not_found', 'No such run.');if (!(config.agentUiSetting ? config.agentUiSetting() : config.agentUi)) return problem(req, res, 404, 'actor.runs_disabled', 'Run streams are disabled.');nnew file mode 100644@@@ -0,0 +1,213 @@++'use strict';++++/**++ * GET /api/v1/runs — the one agent system's read-only view of Actor's runs, in the Contracts `agents.run@1` and++ * `agents.step@1` shapes (docs/agents-runs.md in OpenVibe.Contracts). Actor's task is the run: the person's own++ * tasks are the runs they may read, and a run's task events are its steps.++ *++ * GET /api/v1/runs actor.task.list the person's runs, newest first (limit, before, status, agent)++ * GET /api/v1/runs/:id actor.task.read one run++ * GET /api/v1/runs/:id/steps actor.task.read its steps after ?after= (an append-only log, never renumbered)++ *++ * A person's Network token (or this site's session) only: an app's token gets the same 404 as another person's run,++ * so nothing confirms that a run exists. Read-only: creating, stopping and answering an approval are the engine's++ * later routes and are not here. No table changes.++ */++const contracts = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const store = require('../tasks/store');++const catalog = require('../agents/catalog');++++const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const STATUS = { queued: 'planning', running: 'running', verifying: 'running', needs_you: 'waiting', succeeded: 'done', failed: 'failed', cancelled: 'stopped' };++const BY_STATUS = {++ planning: ['queued'],++ running: ['running', 'verifying'],++ waiting: ['needs_you'],++ done: ['succeeded'],++ failed: ['failed'],++ stopped: ['cancelled'],++};++const STEP_KIND = {++ plan: 'think',++ check: 'think',++ text: 'message',++ tool_call: 'tool',++ tool_result: 'tool',++ handoff: 'message',++ trigger: 'message',++ 'trigger-run': 'message',++};++++const parse = (v) => {++ if (v == null) return null;++ if (typeof v !== 'string') return v;++ try { return JSON.parse(v); } catch { return v; }++};++const iso = (v) => {++ if (v == null || v === '') return null;++ const t = typeof v === 'number' ? v : Date.parse(String(v));++ return Number.isFinite(t) ? new Date(t).toISOString() : String(v);++};++const clip = (v, n) => (typeof v === 'string' ? (v.length > n ? `${v.slice(0, n - 1)}…` : v) : null);++const num = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));++const round = (v, places = 6) => (v == null ? null : Math.round(Number(v) * 10 ** places) / 10 ** places);++++/** What a run card reads for a result: the answer, the summary, or why it did not finish. */++function summaryOf(row) {++ const result = parse(row.result);++ if (row.state === 'succeeded' && result) {++ if (typeof result === 'string') return clip(result.trim(), 8000);++ if (result.answer != null) return clip(typeof result.answer === 'string' ? result.answer.trim() : JSON.stringify(result.answer), 8000);++ if (result.summary != null) return clip(typeof result.summary === 'string' ? result.summary.trim() : JSON.stringify(result.summary), 8000);++ if (result.text != null) return clip(String(result.text).trim(), 8000);++ }++ if (row.error_detail) return clip(String(row.error_detail).trim(), 8000);++ if (result && result.error) return clip(String(result.error).trim(), 8000);++ return null;++}++++function elapsedSeconds(row, now) {++ const start = Date.parse(String(row.created_at || ''));++ if (!Number.isFinite(start)) return null;++ const end = row.finished_at ? Date.parse(String(row.finished_at)) : now;++ if (!Number.isFinite(end)) return null;++ return Math.max(0, Math.round(((end - start) / 1000) * 1000) / 1000);++}++++/** A task row → agents.run@1. `names` maps a contact id to its name; `now` is the store's clock. */++function runOf(row, names, now) {++ const agentId = row.agent_id || row.agent || 'openvibe-runtime';++ const listed = !row.agent_id && catalog.byId(row.agent || '');++ const agent = { id: String(agentId), product: 'actor' };++ const name = (row.agent_id && names.get(row.agent_id)) || (listed && listed.name);++ if (name) agent.name = String(name);++ const budget = { usd: num(row.budget_task) };++ if (row.timeout_ms != null) budget.seconds = Number(row.timeout_ms) / 1000;++ const cost = {};++ const usd = num(row.cost_usd);++ if (usd != null) cost.usd = round(usd);++ if (row.step_count != null) cost.steps = Number(row.step_count);++ const seconds = elapsedSeconds(row, now);++ if (seconds != null) cost.seconds = seconds;++ const out = {++ id: row.id,++ owner: store.subjectRef(row.requester),++ agent,++ parent_run: row.parent_id || null,++ budget,++ status: STATUS[row.state] || 'running',++ created_at: iso(row.created_at),++ updated_at: iso(row.last_at || row.finished_at || row.created_at),++ result_summary: summaryOf(row),++ cost: Object.keys(cost).length ? cost : null,++ };++ return out;++}++++/** A task event → agents.step@1. One event is one step: Actor writes a tool call and its result as two entries. */++function stepOf(runId, seq, body) {++ const b = body && typeof body === 'object' ? body : {};++ const step = {++ run_id: b.task_id || runId,++ seq: Number(b.seq == null ? seq : b.seq),++ kind: STEP_KIND[b.step] || 'message',++ text: b.text == null ? null : clip(String(b.text), 65536),++ started_at: iso(b.at) || iso(new Date().toISOString()),++ ended_at: null,++ cost: null,++ child_run: null,++ };++ if (b.step === 'tool_call' && b.tool) step.tool_call = { name: String(b.tool).slice(0, 160), input: b.input == null ? null : b.input };++ if (b.step === 'tool_result') {++ step.result = b.text == null ? null : b.text;++ step.ended_at = step.started_at;++ } else if (b.step !== 'tool_call') {++ // A point event: it happened at `at`, so the step is complete when it is written.++ step.ended_at = step.started_at;++ }++ if (b.step === 'trigger' || b.step === 'trigger-run') {++ step.result = b.trigger || b.run || null;++ }++ return step;++}++++const RUN_ROW_SELECT = `SELECT t.*,++ (SELECT e.body->>'at' FROM task_events e WHERE e.task_id = t.id ORDER BY e.seq DESC LIMIT 1) AS last_at,++ (SELECT count(*) FROM task_events e WHERE e.task_id = t.id AND e.body->>'kind' = 'output') AS step_count++ FROM tasks t`;++++async function namesFor(s, requester, rows) {++ const ids = [...new Set(rows.map((row) => row.agent_id).filter((id) => id && /^agt_/.test(String(id))))];++ const names = new Map();++ if (!ids.length) return names;++ const found = await s.db.many('SELECT id, name FROM actor_agents WHERE owner = $1 AND id = ANY($2::text[])', [requester, ids]);++ for (const row of found) names.set(row.id, row.name);++ return names;++}++++/** One run row with the newest event's time and its step count, for the one run's read. */++const loadRunRow = (s, id, requester) => s.db.maybe(`${RUN_ROW_SELECT} WHERE t.id = $1 AND t.requester = $2`, [id, requester]);++++/** A row from loadRunRow → the agents.run@1 body, with the contact's name when the run names one. */++async function viewOf(s, row, now) {++ return runOf(row, await namesFor(s, row.requester, [row]), now);++}++++function createRunsRoutes(ctx) {++ const { s, principal, limits } = ctx;++ const r = asyncRouter();++ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });++ const notFound = (req, res) => problem(req, res, 404, 'agents.run.not_found', 'No such run.');++ // The one agent system's run view is the person's own: a service acting for them, an app's own token and another++ // person all get the same 404, never a 401 that would confirm a run.++ const own = (req, res, next) => (req.principal && req.principal.kind === 'user' && /^user:usr_/.test(String(req.principal.requester)) ? next() : notFound(req, res));++++ const RUN_SELECT = RUN_ROW_SELECT;++++ r.get('/', principal.requireCapability('actor.task.list'), own, limits.reads('actor.task.list'), async (req, res) => {++ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));++ const before = TASK_ID.test(String(req.query.before || '')) ? String(req.query.before) : null;++ let states = null;++ if (req.query.status != null && req.query.status !== '') {++ states = BY_STATUS[String(req.query.status)];++ if (!states) return problem(req, res, 400, 'agents.run.status', 'status must be one of planning, running, waiting, done, failed, stopped.');++ }++ const agent = typeof req.query.agent === 'string' && req.query.agent.trim() ? req.query.agent.trim().slice(0, 160) : null;++ const where = ['t.requester = $1'];++ const args = [req.principal.requester];++ if (before) { args.push(before); where.push(`t.id < $${args.length}`); }++ if (states) { args.push(states); where.push(`t.state = ANY($${args.length}::text[])`); }++ if (agent) { args.push(agent); where.push(`(t.agent_id = $${args.length} OR t.agent = $${args.length})`); }++ args.push(limit + 1);++ const rows = await s.db.many(`${RUN_SELECT} WHERE ${where.join(' AND ')} ORDER BY t.id DESC LIMIT $${args.length}`, args);++ const page = rows.slice(0, limit);++ const names = await namesFor(s, req.principal.requester, page);++ const now = s.now();++ res.json({ runs: page.map((row) => runOf(row, names, now)), next: rows.length > limit ? page[page.length - 1].id : null });++ });++++ async function load(req, res) {++ if (!TASK_ID.test(String(req.params.id || ''))) { notFound(req, res); return null; }++ const row = await loadRunRow(s, req.params.id, req.principal.requester);++ if (!row) { notFound(req, res); return null; }++ return row;++ }++++ r.get('/:id/steps', principal.requireCapability('actor.task.read'), own, limits.reads('actor.task.read'), async (req, res) => {++ const row = await load(req, res);++ if (!row) return;++ const after = Math.max(0, parseInt(req.query.after, 10) || 0);++ const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 200));++ const rows = await s.db.many(`SELECT e.seq, e.body FROM task_events e++ WHERE e.task_id = $1 AND e.seq > $2 AND e.body->>'kind' = 'output'++ ORDER BY e.seq LIMIT $3`, [row.id, after, limit + 1]);++ const page = rows.slice(0, limit);++ const steps = page.map((entry) => stepOf(row.id, entry.seq, parse(entry.body)));++ res.json({ run_id: row.id, steps, next: rows.length > limit ? Number(page[page.length - 1].seq) : null });++ });++++ return r;++}++++module.exports = { createRunsRoutes, runOf, stepOf, viewOf, loadRunRow, STATUS, STEP_KIND };@@@ -20,7 +20,9 @@ const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];// answer names and aggregate numbers only (never a person or a task), so they need no grant; a later release may add// actor.stats.read.const ROUTE_EXTENSIONS = new Map([++ ['GET /api/v1/runs', 'actor.task.list'],['GET /api/v1/runs/:id', 'actor.task.read'],++ ['GET /api/v1/runs/:id/steps', 'actor.task.read'],['GET /api/v1/runs/:id/events', 'actor.task.read'],['GET /api/v1/tasks/:id/trace', 'actor.task.read'],['GET /api/v1/stats/tools', null],@@@ -72,6 +74,7 @@ const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'apconst A2A_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'a2a-pages.js'), 'utf8');const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-control.js'), 'utf8');++const RUNS_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'runs-api.js'), 'utf8');/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */function codeRoutes() {@@@ -83,6 +86,12 @@ function codeRoutes() {out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });}}++ // The runs router is mounted at /api/v1/runs (the one agent system's read-only shape).++ for (const m of RUNS_SRC.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {++ const guard = m[3].match(/requireCapability\('([a-z0-9._]+)'\)/);++ const read = m[3].match(/limits\.reads\('([a-z0-9._]+)'\)/);++ out.set(`${m[1].toUpperCase()} /api/v1/runs${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });++ }return out;}nnew file mode 100644@@@ -0,0 +1,102 @@++'use strict';++/**++ * The one agent system's read-only view of Actor's runs (agents.run@1, agents.step@1; OpenVibe.Contracts++ * docs/agents-runs.md). Actor's task is the run: the person's own tasks are listed and read, their task events are++ * the steps, and nobody else sees them. The older agent.run@1 snapshot stays at GET /runs/:id unless the reader asks++ * for the new shape with the media type or ?shape=agents.run@1.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++++const AGENTS = 'application/vnd.openvibe.agents.run+json';++const STATUS = new Set(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);++const KIND = new Set(['think', 'tool', 'message', 'approval', 'child', 'file']);++++(async () => {++ const t = await boot();++ const kim = t.network.addUser('kim');++ const lee = t.network.addUser('lee');++ const create = async (user, task) => {++ t.providers.reset();++ t.providers.state.script = [{ content: 'The answer is 42.' }];++ const r = await t.get('/api/v1/tasks', { as: user, json: { task }, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(r.status, 201, r.text);++ const id = r.json().id;++ await t.waitFor(id);++ return id;++ };++++ try {++ const kimTask = await create(kim, 'What is the answer?');++ const leeTask = await create(lee, 'What is my answer?');++++ await check('GET /api/v1/runs lists only the person\'s own runs in the agents.run@1 shape', async () => {++ const r = await t.get('/api/v1/runs', { as: kim });++ assert.strictEqual(r.status, 200, r.text);++ const body = r.json();++ assert.ok(Array.isArray(body.runs) && body.runs.length === 1, JSON.stringify(body));++ const run = body.runs[0];++ assert.strictEqual(run.id, kimTask);++ assert.strictEqual(run.owner.type, 'user');++ assert.strictEqual(run.owner.id, kim.subject);++ assert.strictEqual(run.agent.product, 'actor');++ assert.ok(run.agent.id);++ assert.ok(STATUS.has(run.status), run.status);++ assert.ok(run.created_at && run.updated_at);++ assert.ok(run.budget && Number.isFinite(run.budget.usd));++ assert.ok(run.cost && Number.isFinite(run.cost.usd));++ assert.ok(!body.runs.some((x) => x.id === leeTask), 'another person\'s run is not listed');++ assert.strictEqual(body.next, null);++ });++++ await check('GET /api/v1/runs/:id returns agents.run@1 when asked, scoped to its owner', async () => {++ const next = await t.get(`/api/v1/runs/${kimTask}`, { as: kim, headers: { accept: AGENTS } });++ assert.strictEqual(next.status, 200, next.text);++ const run = next.json();++ assert.strictEqual(run.id, kimTask);++ assert.strictEqual(run.owner.id, kim.subject);++ assert.ok(STATUS.has(run.status));++ const query = await t.get(`/api/v1/runs/${kimTask}?shape=agents.run@1`, { as: kim });++ assert.strictEqual(query.status, 200, query.text);++ assert.strictEqual(query.json().id, kimTask);++ const elsewhere = await t.get(`/api/v1/runs/${leeTask}`, { as: kim, headers: { accept: AGENTS } });++ assert.strictEqual(elsewhere.status, 404);++ const stranger = await t.get(`/api/v1/runs/${kimTask}`, { as: lee, headers: { accept: AGENTS } });++ assert.strictEqual(stranger.status, 404);++ });++++ await check('GET /api/v1/runs/:id/steps reads the task event log as agents.step@1', async () => {++ const r = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: kim });++ assert.strictEqual(r.status, 200, r.text);++ const body = r.json();++ assert.strictEqual(body.run_id, kimTask);++ assert.ok(Array.isArray(body.steps) && body.steps.length > 0, JSON.stringify(body));++ let previous = 0;++ for (const step of body.steps) {++ assert.strictEqual(step.run_id, kimTask);++ assert.ok(Number.isInteger(step.seq) && step.seq > previous, `seq ${step.seq} after ${previous}`);++ assert.ok(KIND.has(step.kind), step.kind);++ assert.ok(step.started_at);++ previous = step.seq;++ }++ assert.ok(body.steps.some((s) => s.kind === 'message'), 'the answer is a message step');++ const after = await t.get(`/api/v1/runs/${kimTask}/steps?after=${previous}`, { as: kim });++ assert.deepStrictEqual(after.json().steps, []);++ const stranger = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: lee });++ assert.strictEqual(stranger.status, 404);++ });++++ await check('the run read is behind the task grants and refuses a bad status filter', async () => {++ assert.strictEqual((await t.get('/api/v1/runs')).status, 401);++ const bad = await t.get('/api/v1/runs?status=almost', { as: kim });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.json().code, 'agents.run.status');++ const doneRuns = await t.get('/api/v1/runs?status=done', { as: kim });++ assert.strictEqual(doneRuns.status, 200);++ assert.ok(doneRuns.json().runs.some((x) => x.id === kimTask));++ });++ } finally {++ await t.close();++ }++ done();++})();
@@ -2,6 +2,21 @@
@@ -2,6 +2,21 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
+## Unreleased — the same run shape as Codes and Run (`agents.run@1`, `agents.step@1`)
+
+- **`GET /api/v1/runs`** lists the person's own runs newest first (`limit`, `before`, `status`, `agent`) in the one
+ agent system's `agents.run@1` shape: owner, agent and product, parent run, budget, status, timestamps, result
+ summary and cost. A task is the run; nothing new is stored.
+- **`GET /api/v1/runs/:id/steps?after=`** reads the task's own event log as `agents.step@1`, one entry per output
+ event, with the task event's seq as the cursor. `GET /api/v1/runs/:id` answers the same run shape when the reader
+ asks for it with `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` sends) or
+ `?shape=agents.run@1`; the existing `agent.run@1` snapshot stays the default for its callers.
+- Read-only and behind the existing `actor.task.read`/`actor.task.list`: only the person's own Network token or
+ session, and another person's run is the same 404 as one that does not exist. Actor's tables are unchanged.
+- The field map and what does not fit (per-step cost, Actor's `needs_you`, a tool call and its result as two log
+ entries) are in OpenVibe.Contracts `docs/agents-runs.md`; the routes are in [docs/agent-runs.md](docs/agent-runs.md).
+ Tests: `test/runs-api.test.js`.
+
## Unreleased — See it work (W1): the desk, the viewer and the replay
## Unreleased — See it work (W1): the desk, the viewer and the replay
- **The desk tile** in a running task's card: the desk's private Live stream (started with the task's desk through Link's
- **The desk tile** in a running task's card: the desk's private Live stream (started with the task's desk through Link's
@@ -20,3 +20,25 @@ with titles. Historical clipped outputs cannot be recovered. Approvals emit ov.a
@@ -20,3 +20,25 @@ with titles. Historical clipped outputs cannot be recovered. Approvals emit ov.a
Grouping, one-line summaries and step status are carried in `metadata["ov.step"]`; the reserved `metadata.ov`
Grouping, one-line summaries and step status are carried in `metadata["ov.step"]`; the reserved `metadata.ov`
contains the contract's title, kind, sources, attachments, cost, needs_you, product and ref fields.
contains the contract's title, kind, sources, attachments, cost, needs_you, product and ref fields.
+
+## The one agent system's shape (`agents.run@1`, `agents.step@1`)
+
+The same run also answers in the Contracts shape every agent product uses (OpenVibe.Contracts `docs/agents-runs.md`
+maps Actor's `tasks`/`task_events` field by field):
+
+- `GET /api/v1/runs` — the person's own runs, newest first (`limit`, `before`, `status`, `agent`), `{ runs, next }`.
+ A run is `agents.run@1`: `id`, `owner`, `agent { id, name?, product: "actor" }`, `parent_run`, `budget` (USD and
+ the timeout in seconds), `status` (`planning|running|waiting|done|failed|stopped`), `created_at`, `updated_at`,
+ `result_summary` and `cost`.
+- `GET /api/v1/runs/:id` — the older `agent.run@1` snapshot stays the default. A reader asks for the new shape with
+ `Accept: application/vnd.openvibe.agents.run+json` (what `openvibe-sdk/agents` `runs.get` sends) or
+ `?shape=agents.run@1`.
+- `GET /api/v1/runs/:id/steps?after=N` — the task's own event log as `agents.step@1`, one entry per output event
+ (`run_id`, `seq`, `kind think|tool|message|child|file`, `text`, `tool_call`, `result`, `started_at`, `ended_at`),
+ `{ run_id, steps, next }`. `seq` is the task event's own and is never renumbered, so `after` is the same cursor a
+ reconnect uses.
+
+All three are read-only and use the task routes' `actor.task.read`/`actor.task.list`; only the person's own Network
+token (or this site's session) is accepted, and anyone else — including an app's own token — gets the same 404 as a
+run that does not exist. Actor's tables are unchanged. The event log splits a tool call and its result into two
+entries; both become `kind: tool`, the call's entry has `tool_call`, the result's has `result`.
@@ -28,6 +28,7 @@
@@ -28,6 +28,7 @@
const express = require('express');
const express = require('express');
const contracts = require('openvibe-contracts');
const contracts = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
+const runsApi = require('./runs-api');
const store = require('../tasks/store');
const store = require('../tasks/store');
const tickets = require('../task-view/tickets');
const tickets = require('../task-view/tickets');
const catalog = require('../agents/catalog');
const catalog = require('../agents/catalog');
@@ -58,6 +59,11 @@ function createApi(ctx) {
@@ -58,6 +59,11 @@ function createApi(ctx) {
const { config, s, engine, stream, principal, limits, webhooks, triggers } = ctx;
const { config, s, engine, stream, principal, limits, webhooks, triggers } = ctx;
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail, extra) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
const problem = (req, res, status, code, detail, extra) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov, extra });
+ // The one agent system's run shape (agents.run@1) and the older run card's agent.run@1 share GET /runs/:id:
+ // a reader asks for the new shape with this media type (the SDK's runs.get does) or ?shape=agents.run@1, and
+ // every existing caller keeps the snapshot it knows.
+ const wantsAgentsRun = (req) => String(req.query.shape || req.query.contract || '') === 'agents.run@1'
+ || /application\/vnd\.openvibe\.agents\.run\+json/i.test(String(req.get('accept') || ''));
// A trigger belongs to the person in the request, and its Watch calls use that person's own token: the
// A trigger belongs to the person in the request, and its Watch calls use that person's own token: the
// Bearer the API was called with, or this site's session token. A service acting for a person is not it.
// Bearer the API was called with, or this site's session token. A service acting for a person is not it.
const watchToken = (req) => {
const watchToken = (req) => {
@@ -116,6 +122,8 @@ function createApi(ctx) {
@@ -116,6 +122,8 @@ function createApi(ctx) {
});
});
r.use('/me/actor', require('../day/cors').cors(ctx.config));
r.use('/me/actor', require('../day/cors').cors(ctx.config));
r.use(principal.middleware);
r.use(principal.middleware);
+ // GET /runs and /runs/:id/steps: the one agent system's read-only view (agents.run@1, agents.step@1).
+ r.use('/runs', runsApi.createRunsRoutes(ctx));
r.use('/me/actor', require('../day/routes').router(ctx));
r.use('/me/actor', require('../day/routes').router(ctx));
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
r.use('/chat-apps', require('../chat-apps/routes').router(ctx));
@@ -973,6 +981,12 @@ function createApi(ctx) {
@@ -973,6 +981,12 @@ function createApi(ctx) {
if (match) req.params.id = match[1];
if (match) req.params.id = match[1];
const task = await load(req, res);
const task = await load(req, res);
if (!task) return;
if (!task) return;
+ if (wantsAgentsRun(req)) {
+ if (!req.principal || req.principal.kind !== 'user') return problem(req, res, 404, 'agents.run.not_found', 'No such run.');
+ const run = await runsApi.loadRunRow(s, task.id, req.principal.requester);
+ if (!run) return problem(req, res, 404, 'agents.run.not_found', 'No such run.');
+ return res.json(await runsApi.viewOf(s, run, s.now()));
+ }
const row = await agentRuns.resolve(task, start);
const row = await agentRuns.resolve(task, start);
if (!row) return problem(req, res, 404, 'actor.task.not_found', 'No such run.');
if (!row) return problem(req, res, 404, 'actor.task.not_found', 'No such run.');
if (!(config.agentUiSetting ? config.agentUiSetting() : config.agentUi)) return problem(req, res, 404, 'actor.runs_disabled', 'Run streams are disabled.');
if (!(config.agentUiSetting ? config.agentUiSetting() : config.agentUi)) return problem(req, res, 404, 'actor.runs_disabled', 'Run streams are disabled.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,213 @@
@@ -0,0 +1,213 @@
+'use strict';
+
+/**
+ * GET /api/v1/runs — the one agent system's read-only view of Actor's runs, in the Contracts `agents.run@1` and
+ * `agents.step@1` shapes (docs/agents-runs.md in OpenVibe.Contracts). Actor's task is the run: the person's own
+ * tasks are the runs they may read, and a run's task events are its steps.
+ *
+ * GET /api/v1/runs actor.task.list the person's runs, newest first (limit, before, status, agent)
+ * GET /api/v1/runs/:id actor.task.read one run
+ * GET /api/v1/runs/:id/steps actor.task.read its steps after ?after= (an append-only log, never renumbered)
+ *
+ * A person's Network token (or this site's session) only: an app's token gets the same 404 as another person's run,
+ * so nothing confirms that a run exists. Read-only: creating, stopping and answering an approval are the engine's
+ * later routes and are not here. No table changes.
+ */
+const contracts = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const store = require('../tasks/store');
+const catalog = require('../agents/catalog');
+
+const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const STATUS = { queued: 'planning', running: 'running', verifying: 'running', needs_you: 'waiting', succeeded: 'done', failed: 'failed', cancelled: 'stopped' };
+const BY_STATUS = {
+ planning: ['queued'],
+ running: ['running', 'verifying'],
+ waiting: ['needs_you'],
+ done: ['succeeded'],
+ failed: ['failed'],
+ stopped: ['cancelled'],
+};
+const STEP_KIND = {
+ plan: 'think',
+ check: 'think',
+ text: 'message',
+ tool_call: 'tool',
+ tool_result: 'tool',
+ handoff: 'message',
+ trigger: 'message',
+ 'trigger-run': 'message',
+};
+
+const parse = (v) => {
+ if (v == null) return null;
+ if (typeof v !== 'string') return v;
+ try { return JSON.parse(v); } catch { return v; }
+};
+const iso = (v) => {
+ if (v == null || v === '') return null;
+ const t = typeof v === 'number' ? v : Date.parse(String(v));
+ return Number.isFinite(t) ? new Date(t).toISOString() : String(v);
+};
+const clip = (v, n) => (typeof v === 'string' ? (v.length > n ? `${v.slice(0, n - 1)}…` : v) : null);
+const num = (v) => (v == null || !Number.isFinite(Number(v)) ? null : Number(v));
+const round = (v, places = 6) => (v == null ? null : Math.round(Number(v) * 10 ** places) / 10 ** places);
+
+/** What a run card reads for a result: the answer, the summary, or why it did not finish. */
+function summaryOf(row) {
+ const result = parse(row.result);
+ if (row.state === 'succeeded' && result) {
+ if (typeof result === 'string') return clip(result.trim(), 8000);
+ if (result.answer != null) return clip(typeof result.answer === 'string' ? result.answer.trim() : JSON.stringify(result.answer), 8000);
+ if (result.summary != null) return clip(typeof result.summary === 'string' ? result.summary.trim() : JSON.stringify(result.summary), 8000);
+ if (result.text != null) return clip(String(result.text).trim(), 8000);
+ }
+ if (row.error_detail) return clip(String(row.error_detail).trim(), 8000);
+ if (result && result.error) return clip(String(result.error).trim(), 8000);
+ return null;
+}
+
+function elapsedSeconds(row, now) {
+ const start = Date.parse(String(row.created_at || ''));
+ if (!Number.isFinite(start)) return null;
+ const end = row.finished_at ? Date.parse(String(row.finished_at)) : now;
+ if (!Number.isFinite(end)) return null;
+ return Math.max(0, Math.round(((end - start) / 1000) * 1000) / 1000);
+}
+
+/** A task row → agents.run@1. `names` maps a contact id to its name; `now` is the store's clock. */
+function runOf(row, names, now) {
+ const agentId = row.agent_id || row.agent || 'openvibe-runtime';
+ const listed = !row.agent_id && catalog.byId(row.agent || '');
+ const agent = { id: String(agentId), product: 'actor' };
+ const name = (row.agent_id && names.get(row.agent_id)) || (listed && listed.name);
+ if (name) agent.name = String(name);
+ const budget = { usd: num(row.budget_task) };
+ if (row.timeout_ms != null) budget.seconds = Number(row.timeout_ms) / 1000;
+ const cost = {};
+ const usd = num(row.cost_usd);
+ if (usd != null) cost.usd = round(usd);
+ if (row.step_count != null) cost.steps = Number(row.step_count);
+ const seconds = elapsedSeconds(row, now);
+ if (seconds != null) cost.seconds = seconds;
+ const out = {
+ id: row.id,
+ owner: store.subjectRef(row.requester),
+ agent,
+ parent_run: row.parent_id || null,
+ budget,
+ status: STATUS[row.state] || 'running',
+ created_at: iso(row.created_at),
+ updated_at: iso(row.last_at || row.finished_at || row.created_at),
+ result_summary: summaryOf(row),
+ cost: Object.keys(cost).length ? cost : null,
+ };
+ return out;
+}
+
+/** A task event → agents.step@1. One event is one step: Actor writes a tool call and its result as two entries. */
+function stepOf(runId, seq, body) {
+ const b = body && typeof body === 'object' ? body : {};
+ const step = {
+ run_id: b.task_id || runId,
+ seq: Number(b.seq == null ? seq : b.seq),
+ kind: STEP_KIND[b.step] || 'message',
+ text: b.text == null ? null : clip(String(b.text), 65536),
+ started_at: iso(b.at) || iso(new Date().toISOString()),
+ ended_at: null,
+ cost: null,
+ child_run: null,
+ };
+ if (b.step === 'tool_call' && b.tool) step.tool_call = { name: String(b.tool).slice(0, 160), input: b.input == null ? null : b.input };
+ if (b.step === 'tool_result') {
+ step.result = b.text == null ? null : b.text;
+ step.ended_at = step.started_at;
+ } else if (b.step !== 'tool_call') {
+ // A point event: it happened at `at`, so the step is complete when it is written.
+ step.ended_at = step.started_at;
+ }
+ if (b.step === 'trigger' || b.step === 'trigger-run') {
+ step.result = b.trigger || b.run || null;
+ }
+ return step;
+}
+
+const RUN_ROW_SELECT = `SELECT t.*,
+ (SELECT e.body->>'at' FROM task_events e WHERE e.task_id = t.id ORDER BY e.seq DESC LIMIT 1) AS last_at,
+ (SELECT count(*) FROM task_events e WHERE e.task_id = t.id AND e.body->>'kind' = 'output') AS step_count
+ FROM tasks t`;
+
+async function namesFor(s, requester, rows) {
+ const ids = [...new Set(rows.map((row) => row.agent_id).filter((id) => id && /^agt_/.test(String(id))))];
+ const names = new Map();
+ if (!ids.length) return names;
+ const found = await s.db.many('SELECT id, name FROM actor_agents WHERE owner = $1 AND id = ANY($2::text[])', [requester, ids]);
+ for (const row of found) names.set(row.id, row.name);
+ return names;
+}
+
+/** One run row with the newest event's time and its step count, for the one run's read. */
+const loadRunRow = (s, id, requester) => s.db.maybe(`${RUN_ROW_SELECT} WHERE t.id = $1 AND t.requester = $2`, [id, requester]);
+
+/** A row from loadRunRow → the agents.run@1 body, with the contact's name when the run names one. */
+async function viewOf(s, row, now) {
+ return runOf(row, await namesFor(s, row.requester, [row]), now);
+}
+
+function createRunsRoutes(ctx) {
+ const { s, principal, limits } = ctx;
+ const r = asyncRouter();
+ const problem = (req, res, status, code, detail) => contracts.http.sendProblem(res, status, code, { detail, ctx: req.ov });
+ const notFound = (req, res) => problem(req, res, 404, 'agents.run.not_found', 'No such run.');
+ // The one agent system's run view is the person's own: a service acting for them, an app's own token and another
+ // person all get the same 404, never a 401 that would confirm a run.
+ const own = (req, res, next) => (req.principal && req.principal.kind === 'user' && /^user:usr_/.test(String(req.principal.requester)) ? next() : notFound(req, res));
+
+ const RUN_SELECT = RUN_ROW_SELECT;
+
+ r.get('/', principal.requireCapability('actor.task.list'), own, limits.reads('actor.task.list'), async (req, res) => {
+ const limit = Math.min(100, Math.max(1, parseInt(req.query.limit, 10) || 20));
+ const before = TASK_ID.test(String(req.query.before || '')) ? String(req.query.before) : null;
+ let states = null;
+ if (req.query.status != null && req.query.status !== '') {
+ states = BY_STATUS[String(req.query.status)];
+ if (!states) return problem(req, res, 400, 'agents.run.status', 'status must be one of planning, running, waiting, done, failed, stopped.');
+ }
+ const agent = typeof req.query.agent === 'string' && req.query.agent.trim() ? req.query.agent.trim().slice(0, 160) : null;
+ const where = ['t.requester = $1'];
+ const args = [req.principal.requester];
+ if (before) { args.push(before); where.push(`t.id < $${args.length}`); }
+ if (states) { args.push(states); where.push(`t.state = ANY($${args.length}::text[])`); }
+ if (agent) { args.push(agent); where.push(`(t.agent_id = $${args.length} OR t.agent = $${args.length})`); }
+ args.push(limit + 1);
+ const rows = await s.db.many(`${RUN_SELECT} WHERE ${where.join(' AND ')} ORDER BY t.id DESC LIMIT $${args.length}`, args);
+ const page = rows.slice(0, limit);
+ const names = await namesFor(s, req.principal.requester, page);
+ const now = s.now();
+ res.json({ runs: page.map((row) => runOf(row, names, now)), next: rows.length > limit ? page[page.length - 1].id : null });
+ });
+
+ async function load(req, res) {
+ if (!TASK_ID.test(String(req.params.id || ''))) { notFound(req, res); return null; }
+ const row = await loadRunRow(s, req.params.id, req.principal.requester);
+ if (!row) { notFound(req, res); return null; }
+ return row;
+ }
+
+ r.get('/:id/steps', principal.requireCapability('actor.task.read'), own, limits.reads('actor.task.read'), async (req, res) => {
+ const row = await load(req, res);
+ if (!row) return;
+ const after = Math.max(0, parseInt(req.query.after, 10) || 0);
+ const limit = Math.min(500, Math.max(1, parseInt(req.query.limit, 10) || 200));
+ const rows = await s.db.many(`SELECT e.seq, e.body FROM task_events e
+ WHERE e.task_id = $1 AND e.seq > $2 AND e.body->>'kind' = 'output'
+ ORDER BY e.seq LIMIT $3`, [row.id, after, limit + 1]);
+ const page = rows.slice(0, limit);
+ const steps = page.map((entry) => stepOf(row.id, entry.seq, parse(entry.body)));
+ res.json({ run_id: row.id, steps, next: rows.length > limit ? Number(page[page.length - 1].seq) : null });
+ });
+
+ return r;
+}
+
+module.exports = { createRunsRoutes, runOf, stepOf, viewOf, loadRunRow, STATUS, STEP_KIND };
@@ -20,7 +20,9 @@ const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
@@ -20,7 +20,9 @@ const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// answer names and aggregate numbers only (never a person or a task), so they need no grant; a later release may add
// answer names and aggregate numbers only (never a person or a task), so they need no grant; a later release may add
// actor.stats.read.
// actor.stats.read.
const ROUTE_EXTENSIONS = new Map([
const ROUTE_EXTENSIONS = new Map([
+ ['GET /api/v1/runs', 'actor.task.list'],
['GET /api/v1/runs/:id', 'actor.task.read'],
['GET /api/v1/runs/:id', 'actor.task.read'],
+ ['GET /api/v1/runs/:id/steps', 'actor.task.read'],
['GET /api/v1/runs/:id/events', 'actor.task.read'],
['GET /api/v1/runs/:id/events', 'actor.task.read'],
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/stats/tools', null],
['GET /api/v1/stats/tools', null],
@@ -72,6 +74,7 @@ const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'ap
@@ -72,6 +74,7 @@ const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'ap
const A2A_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'a2a-pages.js'), 'utf8');
const A2A_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'a2a-pages.js'), 'utf8');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-control.js'), 'utf8');
const CONTROL_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-control.js'), 'utf8');
+const RUNS_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'runs-api.js'), 'utf8');
/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
/** `METHOD /api/v1/path` for every API and resource index route, with its capability guard (null: public). */
function codeRoutes() {
function codeRoutes() {
@@ -83,6 +86,12 @@ function codeRoutes() {
@@ -83,6 +86,12 @@ function codeRoutes() {
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
out.set(`${m[1].toUpperCase()} ${prefix}${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
}
}
}
}
+ // The runs router is mounted at /api/v1/runs (the one agent system's read-only shape).
+ for (const m of RUNS_SRC.matchAll(/^\s*r\.(get|post|put|patch|delete)\('([^']+)'(.*)$/gm)) {
+ const guard = m[3].match(/requireCapability\('([a-z0-9._]+)'\)/);
+ const read = m[3].match(/limits\.reads\('([a-z0-9._]+)'\)/);
+ out.set(`${m[1].toUpperCase()} /api/v1/runs${m[2] === '/' ? '' : m[2]}`, { guard: guard ? guard[1] : null, limit: read ? read[1] : null });
+ }
return out;
return out;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
+'use strict';
+/**
+ * The one agent system's read-only view of Actor's runs (agents.run@1, agents.step@1; OpenVibe.Contracts
+ * docs/agents-runs.md). Actor's task is the run: the person's own tasks are listed and read, their task events are
+ * the steps, and nobody else sees them. The older agent.run@1 snapshot stays at GET /runs/:id unless the reader asks
+ * for the new shape with the media type or ?shape=agents.run@1.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+
+const AGENTS = 'application/vnd.openvibe.agents.run+json';
+const STATUS = new Set(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);
+const KIND = new Set(['think', 'tool', 'message', 'approval', 'child', 'file']);
+
+(async () => {
+ const t = await boot();
+ const kim = t.network.addUser('kim');
+ const lee = t.network.addUser('lee');
+ const create = async (user, task) => {
+ t.providers.reset();
+ t.providers.state.script = [{ content: 'The answer is 42.' }];
+ const r = await t.get('/api/v1/tasks', { as: user, json: { task }, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const id = r.json().id;
+ await t.waitFor(id);
+ return id;
+ };
+
+ try {
+ const kimTask = await create(kim, 'What is the answer?');
+ const leeTask = await create(lee, 'What is my answer?');
+
+ await check('GET /api/v1/runs lists only the person\'s own runs in the agents.run@1 shape', async () => {
+ const r = await t.get('/api/v1/runs', { as: kim });
+ assert.strictEqual(r.status, 200, r.text);
+ const body = r.json();
+ assert.ok(Array.isArray(body.runs) && body.runs.length === 1, JSON.stringify(body));
+ const run = body.runs[0];
+ assert.strictEqual(run.id, kimTask);
+ assert.strictEqual(run.owner.type, 'user');
+ assert.strictEqual(run.owner.id, kim.subject);
+ assert.strictEqual(run.agent.product, 'actor');
+ assert.ok(run.agent.id);
+ assert.ok(STATUS.has(run.status), run.status);
+ assert.ok(run.created_at && run.updated_at);
+ assert.ok(run.budget && Number.isFinite(run.budget.usd));
+ assert.ok(run.cost && Number.isFinite(run.cost.usd));
+ assert.ok(!body.runs.some((x) => x.id === leeTask), 'another person\'s run is not listed');
+ assert.strictEqual(body.next, null);
+ });
+
+ await check('GET /api/v1/runs/:id returns agents.run@1 when asked, scoped to its owner', async () => {
+ const next = await t.get(`/api/v1/runs/${kimTask}`, { as: kim, headers: { accept: AGENTS } });
+ assert.strictEqual(next.status, 200, next.text);
+ const run = next.json();
+ assert.strictEqual(run.id, kimTask);
+ assert.strictEqual(run.owner.id, kim.subject);
+ assert.ok(STATUS.has(run.status));
+ const query = await t.get(`/api/v1/runs/${kimTask}?shape=agents.run@1`, { as: kim });
+ assert.strictEqual(query.status, 200, query.text);
+ assert.strictEqual(query.json().id, kimTask);
+ const elsewhere = await t.get(`/api/v1/runs/${leeTask}`, { as: kim, headers: { accept: AGENTS } });
+ assert.strictEqual(elsewhere.status, 404);
+ const stranger = await t.get(`/api/v1/runs/${kimTask}`, { as: lee, headers: { accept: AGENTS } });
+ assert.strictEqual(stranger.status, 404);
+ });
+
+ await check('GET /api/v1/runs/:id/steps reads the task event log as agents.step@1', async () => {
+ const r = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: kim });
+ assert.strictEqual(r.status, 200, r.text);
+ const body = r.json();
+ assert.strictEqual(body.run_id, kimTask);
+ assert.ok(Array.isArray(body.steps) && body.steps.length > 0, JSON.stringify(body));
+ let previous = 0;
+ for (const step of body.steps) {
+ assert.strictEqual(step.run_id, kimTask);
+ assert.ok(Number.isInteger(step.seq) && step.seq > previous, `seq ${step.seq} after ${previous}`);
+ assert.ok(KIND.has(step.kind), step.kind);
+ assert.ok(step.started_at);
+ previous = step.seq;
+ }
+ assert.ok(body.steps.some((s) => s.kind === 'message'), 'the answer is a message step');
+ const after = await t.get(`/api/v1/runs/${kimTask}/steps?after=${previous}`, { as: kim });
+ assert.deepStrictEqual(after.json().steps, []);
+ const stranger = await t.get(`/api/v1/runs/${kimTask}/steps`, { as: lee });
+ assert.strictEqual(stranger.status, 404);
+ });
+
+ await check('the run read is behind the task grants and refuses a bad status filter', async () => {
+ assert.strictEqual((await t.get('/api/v1/runs')).status, 401);
+ const bad = await t.get('/api/v1/runs?status=almost', { as: kim });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.json().code, 'agents.run.status');
+ const doneRuns = await t.get('/api/v1/runs?status=done', { as: kim });
+ assert.strictEqual(doneRuns.status, 200);
+ assert.ok(doneRuns.json().runs.some((x) => x.id === kimTask));
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})();