openvibers/openvibe.codes Commits

Codes shows the agents' session: every step's command, change and output (live while it runs), helpers nested, hand-offs and the plan, on the task page and after the end (job lines 1.1, the result keeps the log); the API loop streams command output; openvibe-shared v4.24.0; harness 0.4.0 (#132)

OP OpenVibers committed 16 days ago · 85414dd818 · 14 files, +241 −41

Changes

ddiff --git a/README.md b/README.md
iindex a3f9b9c..59c187e 100644
---- a/README.md
++++ b/README.md
@@@ -20,7 +20,7 @@ One place to send a coding task to the agent that fits it — by what the task n
| The harness | `openvibe-codes` ([bin/openvibe-codes.js](bin/openvibe-codes.js), [harness/](harness/)) | Runs on a person's own machine with their CLIs and keys; nothing goes through OpenVibe. See **Use the harness** below. |
| Home | `/` | What the harness is, the agents in the catalog, how a task finds its agent, Improve OpenVibe, and an honest table of what works today and what comes next. |
| Get started | `/start` | Install, the agents and what each needs, the commands, permission levels, hand-offs, the event format and using it from code. |
--| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). |
++| Run on your machine | `/run`, `/run/tasks/:id` ([server/http/run.js](server/http/run.js)) | A signed-in person starts a coding task in any browser and their own machine runs it: an OpenVibe Run job (class `code`, artifact `[email protected]`, `trust: user-owned`) that Codes starts for them in their project (project delegation: `X-OV-Subject`, `X-OV-Project`; [server/domain/run-client.js](server/domain/run-client.js)). Run places it only on a machine of theirs (added on openvibe.bot/machines), whose OpenVibe Node runs `openvibe-codes job` ([harness/job.js](harness/job.js)) in its job sandbox: clone, run the agents with hand-offs, hand back the summary, the changed files and the patch, and, when asked and the machine holds `GITHUB_TOKEN`, push a new branch (never the one it started from) and open a pull request. The page streams the job's lines (Run's job stream through Codes) and keeps the result (`run_tasks`, migration 0003). Each task is its owner's; account export and deletion cover them (ADR-033, [server/account-data.js](server/account-data.js)). The task page shows the agents' **session** with openvibe-shared/agent-session.js: steps grouped, each step's command, change and output (live while it runs), helpers nested, hand-offs and the plan; after the end, the session the result kept (job lines 1.1, `log`). |
| 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`). |
@@@ -35,7 +35,7 @@ Everything is server-rendered and usable without JavaScript.
## Use the harness
```bash
--npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.3.0 # Node.js 22+
++npm install -g https://codeload.github.com/OpenVibers/OpenVibe.Codes/tar.gz/refs/tags/v0.4.0 # Node.js 22+
openvibe-codes agents # what this machine can run, and why not the rest
openvibe-codes run "rename getUser to findUser" # routed, handed off on failure, kept
openvibe-codes run --agent codex --permission read "where is the session cookie set?"
ddiff --git a/STATUS.json b/STATUS.json
iindex 00a6db9..8aef7b0 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -9,11 +9,11 @@
"domain": "openvibe.codes",
"contracts": "openvibe-contracts v0.222.0",
"sdk": "openvibe-sdk v0.67.0",
-- "shared": "openvibe-shared v4.23.0",
++ "shared": "openvibe-shared v4.24.0",
"packages": [
"openvibe-contracts v0.222.0",
"openvibe-sdk v0.67.0",
-- "openvibe-shared v4.23.0"
++ "openvibe-shared v4.24.0"
],
"summary": "Public at https://openvibe.codes (sudo ovhost deploy codes; unit openvibe-codes on 127.0.0.1:4900). Since 2026-10-08 Codes is the open coding-agent harness: openvibe-codes (a command-line tool and Node.js module, v0.2.0) runs a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or a person's own OpenAI-compatible model on their machine, hands it to the next agent when one fails or goes quiet, and keeps every run; the site carries the agent catalog with a public routing API, getting started, Improve OpenVibe and the community documents. The developer console that used to live here moved to OpenVibe.Services (https://openvibe.services) and every one of its addresses answers a permanent redirect there.",
"works": [
ddiff --git a/harness/adapters/api.js b/harness/adapters/api.js
iindex 772d254..5b5899e 100644
---- a/harness/adapters/api.js
++++ b/harness/adapters/api.js
@@@ -125,12 +125,12 @@ function runTool(name, args, cwd) {
return [`unknown tool ${name}`, true];
}
--/** run_command, asynchronously (the only tool that waits on something else). */
--function runCommand(command, cwd, signal) {
++/** run_command, asynchronously (the only tool that waits on something else); onChunk(text) hears its output as it comes. */
++function runCommand(command, cwd, signal, onChunk) {
return new Promise((resolve) => {
const child = spawn('bash', ['-lc', String(command)], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
let out = '';
-- const add = (d) => { out = (out + d.toString()).slice(-16_000); };
++ const add = (d) => { out = (out + d.toString()).slice(-16_000); if (onChunk) { try { onChunk(d.toString()); } catch { /* a listener never stops the command */ } } };
child.stdout.on('data', add); child.stderr.on('data', add);
const timer = setTimeout(() => child.kill('SIGKILL'), 120_000);
const abort = () => child.kill('SIGKILL');
@@@ -140,6 +140,16 @@ function runCommand(command, cwd, signal) {
});
}
++/** What a step shows of its input: an edit its change and a new file its start (clipped, secrets scrubbed), so a
++ * reader sees the diff; everything else as the agent gave it. */
++function shownInput(name, args) {
++ const cut = (s, n) => ev.clip(ev.scrub(s == null ? '' : s), n);
++ if (name === 'edit_file') return { file_path: args.path, old_string: cut(args.old_string, 4000), new_string: cut(args.new_string, 4000) };
++ if (name === 'write_file') return { file_path: args.path, content: cut(args.content, 4000) };
++ if (name === 'run_command') return { command: cut(args.command, 2000) };
++ return args;
++}
++
/**
* apiAgent({ id, name, baseUrl, keyEnv, model, priceIn, priceOut, token }) → an adapter.
* extra(env) → fields added to every request body (OpenVibe.AI's x_openvibe.provider: the project's own provider).
@@@ -206,12 +216,25 @@ function apiAgent(spec) {
const name = call.function && call.function.name;
let args = {};
try { args = JSON.parse((call.function && call.function.arguments) || '{}'); } catch { /* answered as an error below */ }
-- yield ev.toolUse(call.id, SHOWN_AS[name] || name, name === 'edit_file' || name === 'write_file' ? { file_path: args.path } : args, model);
++ yield ev.toolUse(call.id, SHOWN_AS[name] || name, shownInput(name, args), model);
let out;
const def = TOOL_DEFS[name];
if (!def) out = [`unknown tool ${name}`, true];
else if (LEVELS[def.level] > level) out = [`${name} needs the ${def.level} permission level`, true];
-- else if (name === 'run_command') out = await runCommand(args.command, t.cwd, controller.signal);
++ else if (name === 'run_command') {
++ // The command's output reaches the stream as it comes (batched every 150 ms), so a
++ // reader sees a long test run move instead of waiting for its end.
++ const chunks = []; let wake = null; let finished = null;
++ const nudge = () => { if (wake) { const w = wake; wake = null; w(); } };
++ runCommand(args.command, t.cwd, controller.signal, (d) => { chunks.push(d); nudge(); }).then((r) => { finished = r; nudge(); });
++ while (!finished) {
++ await new Promise((r) => { wake = r; if (finished || chunks.length) nudge(); });
++ if (!finished) await new Promise((r) => setTimeout(r, 150));
++ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
++ }
++ if (chunks.length) yield ev.toolProgress(call.id, chunks.splice(0).join(''));
++ out = finished;
++ }
else { try { out = runTool(name, args, t.cwd); } catch (err) { out = [err.message, true]; } }
yield ev.toolResult(call.id, out[0], out[1]);
messages.push({ role: 'tool', tool_call_id: call.id, content: String(out[0]).slice(0, 16_000) });
ddiff --git a/harness/events.js b/harness/events.js
iindex b55dd58..513abaf 100644
---- a/harness/events.js
++++ b/harness/events.js
@@@ -10,6 +10,8 @@
* usage? } } what the agent said or called
* { type: 'user', message: { content: [ { type: 'tool_result', tool_use_id, content, is_error } ] } } a tool's answer
* { type: 'system', subtype: 'usage' | 'warning' | 'error' | 'stderr' | 'handoff', ... } along the way
++ * { type: 'system', subtype: 'tool_progress', tool_use_id, text } a step's output while it runs (the agents whose
++ * steps the harness runs itself: the API loop)
* { type: 'result', subtype: 'success' | 'error', is_error, result, session_id, model, duration_ms, num_turns,
* total_cost_usd, usage: { input_tokens, output_tokens, cache_read_input_tokens, cache_creation_input_tokens } }
* the end: exactly one per agent run
@@@ -39,6 +41,7 @@ function scrub(s) {
const init = (sessionId, agent, model) => ({ type: 'system', subtype: 'init', session_id: sessionId || null, agent, model: model || null });
const text = (t, model, usage) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'text', text: scrub(t) }], ...(usage ? { usage } : {}) } });
const toolUse = (id, name, input, model) => ({ type: 'assistant', message: { model: model || null, content: [{ type: 'tool_use', id: String(id), name, input: input || {} }] } });
++const toolProgress = (id, chunk) => ({ type: 'system', subtype: 'tool_progress', tool_use_id: String(id), text: clip(scrub(chunk), 4000) });
const toolResult = (id, content, isError) => ({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: String(id), content: clip(scrub(content), 4000), is_error: !!isError }] } });
const usageEvent = (agent, usage) => ({ type: 'system', subtype: 'usage', agent, usage });
const warning = (t) => ({ type: 'system', subtype: 'warning', text: clip(scrub(t), 500) });
@@@ -85,4 +88,4 @@ function createTally() {
return { add, get: () => t };
}
--module.exports = { init, text, toolUse, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };
++module.exports = { init, text, toolUse, toolProgress, toolResult, usageEvent, warning, error, stderr, usage, result, createTally, clip, scrub };
ddiff --git a/harness/job.js b/harness/job.js
iindex 5ea0014..2385d51 100644
---- a/harness/job.js
++++ b/harness/job.js
@@@ -18,10 +18,13 @@ const os = require('os');
const path = require('path');
const { execFile } = require('child_process');
--const JOB_PROTOCOL = '1.0.0';
++const JOB_PROTOCOL = '1.0.0'; // the job's contract with the Node (stdin, stdout lines, fd 3): unchanged by 1.1's lines
++const LINES = '1.1'; // 1.1: lines carry step ids, inputs, results, progress, helpers; the result keeps `log`
const MAX_PROMPT = 16_000;
const MAX_PATCH = 180_000;
const MAX_TEXT = 2_000;
++const MAX_OUTPUT = 4_000; // a step's output on a line (live)
++const MAX_LOG = 240_000; // the session log kept in the result (its outputs cut shorter when it would not fit)
const BRANCH = /^[A-Za-z0-9._/-]{1,100}$/;
/** git with no prompt and no credential helper; rejects with the command's stderr (tokens scrubbed). */
@@@ -88,30 +91,95 @@ function parseArgs(raw, env = process.env) {
};
}
--/** One harness event as a short line the page can show (long text cut, tool inputs reduced to what they touch). */
++/** A step's input as a reader needs it: what it ran or touched, an edit's change, the plan; every string cut and
++ * scrubbed of credentials, everything else left out. */
++function stepInput(input) {
++ const i = input && typeof input === 'object' ? input : {};
++ const str = (v, n) => (typeof v === 'string' ? clean(cutTo(v, n)) : undefined);
++ const out = {};
++ const put = (k, v) => { if (v !== undefined && v !== '') out[k] = v; };
++ put('command', str(i.command || i.cmd, 2000));
++ put('description', str(i.description, 300));
++ put('file_path', str(i.file_path || i.path || i.notebook_path, 300));
++ put('pattern', str(i.pattern, 300));
++ put('glob', str(i.glob, 200));
++ put('query', str(i.query, 300));
++ put('url', str(i.url, 500));
++ put('old_string', str(i.old_string, MAX_OUTPUT));
++ put('new_string', str(i.new_string, MAX_OUTPUT));
++ put('content', str(i.content, MAX_OUTPUT));
++ put('prompt', str(i.prompt, 1600));
++ put('subagent_type', str(i.subagent_type, 60));
++ if (Array.isArray(i.edits)) out.edits = i.edits.slice(0, 20).map((e) => ({ old_string: str(e && e.old_string, 2000) || '', new_string: str(e && e.new_string, 2000) || '' }));
++ if (Array.isArray(i.todos)) out.todos = i.todos.slice(0, 30).map((t) => ({ content: str(t && t.content, 200) || '', status: str(t && t.status, 20) || 'pending', activeForm: str(t && t.activeForm, 200) }));
++ return out;
++}
++// Every string a line carries goes through both scrubbers: the events one (provider keys, tokens, JWTs, private keys,
++// key=… pairs) and this file's (credentials in URLs, GitHub tokens): CLI agents' output reaches here unscrubbed.
++const clean = (s) => scrub(require('./events').scrub(s));
++const cutTo = (s, n) => { const t = String(s == null ? '' : s); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
++const resultText = (c) => (typeof c === 'string' ? c : Array.isArray(c) ? c.map((x) => (x && typeof x.text === 'string' ? x.text : '')).join('\n') : c == null ? '' : String(c));
++
++/**
++ * One harness event as the lines a reader shows (job protocol 1.1, openvibe-shared/agent-session.js reads them):
++ * agent, text, tool (with its id and input), tool_progress, tool_result (with its output), handoff, note, usage, done.
++ * A line from a helper agent carries `parent`, the id of the Task step that started it. 1.0 readers keep working: the
++ * fields they read are where they were.
++ */
function compact(e) {
-- const cut = (s) => { const t = String(s || ''); return t.length > MAX_TEXT ? `${t.slice(0, MAX_TEXT)}…` : t; };
++ const cut = (s) => cutTo(s, MAX_TEXT);
++ const parent = e.parent_tool_use_id ? { parent: String(e.parent_tool_use_id) } : {};
if (e.type === 'system' && e.subtype === 'init') return { t: 'agent', agent: e.agent, model: e.model || null };
-- if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, reason: cut(e.reason) };
-- if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', text: cut(e.text) };
++ if (e.type === 'system' && e.subtype === 'handoff') return { t: 'handoff', from: e.from, ...(e.to ? { to: e.to } : {}), reason: cut(e.reason) };
++ if (e.type === 'system' && (e.subtype === 'error' || e.subtype === 'warning')) return { t: 'note', level: e.subtype, text: cut(e.text) };
++ if (e.type === 'system' && e.subtype === 'tool_progress') return { t: 'tool_progress', id: String(e.tool_use_id || ''), chunk: clean(cutTo(e.text, MAX_OUTPUT)) };
++ if (e.type === 'system' && e.subtype === 'usage' && e.usage) return { t: 'usage', input_tokens: e.usage.input_tokens || 0, output_tokens: e.usage.output_tokens || 0 };
if (e.type === 'assistant') {
const out = [];
for (const c of (e.message && e.message.content) || []) {
-- if (c.type === 'text' && c.text) out.push({ t: 'text', text: cut(c.text) });
++ if (c.type === 'text' && c.text) out.push({ t: 'text', text: clean(cut(c.text)), ...parent });
if (c.type === 'tool_use') {
const i = c.input || {};
const what = i.command || i.file_path || i.path || i.pattern || i.query || i.description || '';
-- out.push({ t: 'tool', name: String(c.name || ''), what: cut(String(what).split('\n')[0]).slice(0, 200) });
++ out.push({ t: 'tool', id: String(c.id || ''), name: String(c.name || ''), what: clean(cut(String(what).split('\n')[0]).slice(0, 200)), input: stepInput(i), ...parent });
}
}
return out;
}
++ if (e.type === 'user' && e.message && Array.isArray(e.message.content)) {
++ return e.message.content.filter((c) => c && c.type === 'tool_result')
++ .map((c) => ({ t: 'tool_result', id: String(c.tool_use_id || ''), ok: !c.is_error, output: clean(cutTo(resultText(c.content), MAX_OUTPUT)), ...parent }));
++ }
if (e.type === 'result') {
-- return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0 };
++ return { t: 'done', ok: !e.is_error, text: cut(e.result), duration_ms: e.duration_ms || 0, cost_usd: e.total_cost_usd || 0, turns: e.num_turns || 0 };
}
return null;
}
++/**
++ * The session log a result keeps: every line, in order, up to MAX_LOG bytes. When it would not fit, outputs and
++ * progress are cut shorter first (progress is dropped where its step's result has the output), so the steps and what
++ * the agents said survive whole.
++ */
++function sessionLog(lines) {
++ const size = (l) => JSON.stringify(l).length + 1;
++ let list = lines.slice();
++ let total = list.reduce((n, l) => n + size(l), 0);
++ if (total <= MAX_LOG) return list;
++ const ended = new Set(list.filter((l) => l.t === 'tool_result').map((l) => l.id));
++ list = list.filter((l) => !(l.t === 'tool_progress' && ended.has(l.id)));
++ for (const n of [1500, 600, 200]) {
++ total = list.reduce((m, l) => m + size(l), 0);
++ if (total <= MAX_LOG) return list;
++ list = list.map((l) => (l.t === 'tool_result' || l.t === 'tool_progress') ? { ...l, [l.t === 'tool_result' ? 'output' : 'chunk']: cutTo(l.output || l.chunk, n) } : l.t === 'tool' && l.input && (l.input.content || l.input.old_string) ? { ...l, input: { ...l.input, content: l.input.content && cutTo(l.input.content, n), old_string: l.input.old_string && cutTo(l.input.old_string, n), new_string: l.input.new_string && cutTo(l.input.new_string, n) } } : l);
++ }
++ const kept = [];
++ total = 0;
++ for (const l of list) { total += size(l); if (total > MAX_LOG) break; kept.push(l); }
++ kept.push({ t: 'note', level: 'warning', text: 'The rest of this session was too long to keep.' });
++ return kept;
++}
++
/** What changed: per-file counts and the patch (new files included), cut to fit the result channel. */
async function changes(dir) {
await git(['add', '-A'], { cwd: dir });
@@@ -175,7 +243,8 @@ async function pushBranch(dir, job, { env, fetchImpl, base }) {
*/
async function runJob({ input, harness, emit, writeResult, env = process.env, tmp = os.tmpdir(), fetchImpl = globalThis.fetch }) {
const started = Date.now();
-- const say = (doc) => emit(JSON.stringify(doc));
++ const log = [];
++ const say = (doc) => { if (doc && doc.t && doc.t !== 'result_doc') log.push(doc); emit(JSON.stringify(doc)); };
let job;
try {
job = parseArgs(input, env);
@@@ -226,6 +295,8 @@ async function runJob({ input, harness, emit, writeResult, env = process.env, tm
result.duration_ms = Date.now() - started;
fs.rmSync(dir, { recursive: true, force: true });
}
++ result.lines = LINES;
++ result.log = sessionLog(log);
writeResult(result);
return result;
}
@@@ -242,4 +313,4 @@ function resultWriter(emit) {
};
}
--module.exports = { runJob, parseArgs, compact, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };
++module.exports = { runJob, parseArgs, compact, sessionLog, stepInput, resultWriter, scrub, githubRepo, pushTarget, JOB_PROTOCOL };
ddiff --git a/package-lock.json b/package-lock.json
iindex 6d12cb5..cdaec8e 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -17,7 +17,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
"pg": "^8.23.0"
},
"bin": {
@@@ -918,9 +918,9 @@
}
},
"node_modules/openvibe-shared": {
-- "version": "4.23.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
-- "integrity": "sha512-oLcUXmHOOC2CCpAuwCN95TIoadukLG5lC6YHPhPbYKcS/rZh0iwLbEA0aAzYHjalKGrqu9mw896LcFc3cd5xWg==",
++ "version": "4.24.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
++ "integrity": "sha512-EJyEPg21pRGmHyMmIlpDb2tvgHwF2eAuEtKTj/eOPj0/LvZLvdQAVS5ge3wBsJgt/Ttz1rhobsTBY0ByTEi8zA==",
"license": "MIT",
"dependencies": {
"jsonwebtoken": "^9.0.0"
ddiff --git a/package.json b/package.json
iindex fa60c73..1b9bf8a 100644
---- a/package.json
++++ b/package.json
@@@ -1,6 +1,6 @@
{
"name": "openvibe-codes",
-- "version": "0.3.1",
++ "version": "0.4.0",
"private": true,
"description": "OpenVibe.Codes: the open coding-agent harness. Run a coding task on Claude Code, Codex, OpenCode, Command Code, Aider, the DeepSeek API or your own model, with routing by capability and cost and hand-offs between agents; plus the site with the agent catalog, a public routing API and the way to improve OpenVibe itself.",
"main": "harness/index.js",
@@@ -32,7 +32,7 @@
"iovalkey": "^0.4.0",
"openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
-- "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.23.0",
++ "openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.24.0",
"pg": "^8.23.0"
},
"engines": {
ddiff --git a/public/js/run.js b/public/js/run.js
iindex ba2755b..628406b 100644
---- a/public/js/run.js
++++ b/public/js/run.js
@@@ -1,5 +1,8 @@
/*
* Run on your machine (server/http/run.js): the live lines of a running task and the colours of a finished patch.
++ * With openvibe-shared/agent-session.js on the page (the task page loads it), the lines become the agents' session:
++ * steps grouped, each step's command, change and output (live), helpers, hand-offs and the plan; a finished task's
++ * page shows the session its result kept. Without it, the plain list below.
* The lines come from the task's own event stream (/run/tasks/:id/events, Run's job stream through Codes): `output`
* events carry the job's stdout, one JSON object per line (harness/job.js), `state` the job's state, `end` its end.
* Everything is put on the page as text, never as HTML.
@@@ -37,6 +40,13 @@
var pill = document.querySelector('[data-state]');
var buffer = '';
if (!log || !window.EventSource) return;
++ var view = null;
++ if (window.OpenVibeAgentSession) {
++ var host = document.createElement('div');
++ log.parentNode.replaceChild(host, log);
++ view = window.OpenVibeAgentSession.mount(host, { title: section.getAttribute('data-title') || 'Task', status: 'queued', empty: 'Waiting for your machine to take the task…' });
++ log = null;
++ }
var source = new EventSource(section.getAttribute('data-events'));
source.addEventListener('output', function (e) {
var doc;
@@@ -46,13 +56,17 @@
buffer = parts.pop();
parts.forEach(function (raw) {
if (!raw.trim()) return;
-- try { show(log, JSON.parse(raw)); } catch (err) { line(log, 'tool', raw); }
++ var doc;
++ try { doc = JSON.parse(raw); } catch (err) { doc = null; }
++ if (view) { if (doc) view.push(doc); return; }
++ if (doc) show(log, doc); else line(log, 'tool', raw);
});
});
source.addEventListener('state', function (e) {
try {
var s = JSON.parse(e.data).state;
if (pill && LABEL[s]) { pill.textContent = LABEL[s]; pill.className = 'run-pill run-pill-' + s; }
++ if (view) view.status(s, LABEL[s]);
} catch (err) { /* ignore */ }
});
source.addEventListener('end', function () {
@@@ -79,6 +93,20 @@
});
}
++ /** A finished task: the session its result kept (lines 1.1), read whole. */
++ function replay(section) {
++ var data = section.querySelector('[data-session-log]');
++ var into = section.querySelector('[data-session-view]');
++ if (!data || !into || !window.OpenVibeAgentSession) { section.hidden = true; return; }
++ var lines;
++ try { lines = JSON.parse(data.textContent); } catch (err) { section.hidden = true; return; }
++ var state = section.getAttribute('data-state');
++ var view = window.OpenVibeAgentSession.mount(into, { title: section.getAttribute('data-title') || 'Task', status: 'running', follow: false });
++ view.pushAll(lines);
++ view.status(state, LABEL[state]);
++ }
++
document.querySelectorAll('[data-events]').forEach(follow);
++ document.querySelectorAll('[data-session]').forEach(replay);
document.querySelectorAll('[data-patch]').forEach(colour);
})();
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex c590149..3c78f28 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -55,6 +55,20 @@ function presentResult(d) {
});
}
++// The job lines a session log may hold (harness/job.js, lines 1.1; openvibe-shared/agent-session.js reads them).
++const LINE_KINDS = new Set(['agent', 'text', 'tool', 'tool_progress', 'tool_result', 'handoff', 'note', 'usage', 'done', 'step']);
++/**
++ * The session a finished task's result keeps (`log`), for its page: the lines whose kind is known, at most 4000, each a
++ * plain object no larger than 20 KB. Not part of codes.task@1 (the API passes the result's contract fields only).
++ */
++function sessionLogOf(row) {
++ let d = row && row.result;
++ if (typeof d === 'string') { try { d = JSON.parse(d); } catch { return null; } }
++ if (!d || !Array.isArray(d.log)) return null;
++ const lines = d.log.filter((l) => l && typeof l === 'object' && !Array.isArray(l) && LINE_KINDS.has(l.t) && JSON.stringify(l).length <= 20000).slice(0, 4000);
++ return lines.length ? lines : null;
++}
++
function createRunTasks({ store, run, config, log = console }) {
const db = store.db;
const who = (row) => ({ subject: row.subject, project: row.project_id });
@@@ -103,4 +117,4 @@ function createRunTasks({ store, run, config, log = console }) {
return { start, refresh, latestProject, present, who };
}
--module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL };
++module.exports = { createRunTasks, presentResult, TERMINAL, STATE_LABEL, sessionLogOf };
ddiff --git a/server/http/run.js b/server/http/run.js
iindex 8460602..cb4e3d5 100644
---- a/server/http/run.js
++++ b/server/http/run.js
@@@ -18,7 +18,7 @@ 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 { createRunTasks, TERMINAL, STATE_LABEL, sessionLogOf } = require('../domain/run-tasks');
const DELEGABLE = new Set(['owner', 'admin']);
const MACHINES_URL = 'https://openvibe.bot/machines';
@@@ -158,7 +158,9 @@ ${tasksHtml(recent)}`;
return res.redirect(303, `/run/tasks/${row.id}`);
});
-- function resultHtml(row) {
++ /** The end of a task: the verdict, what the agents said, the files and the patch. With the session on the page (`session`),
++ * its closing card already says how it ended and the agents' last words, so only a failure keeps its notice. */
++ function resultHtml(row, { session = false } = {}) {
const d = row.result || {};
const files = Array.isArray(d.files) ? d.files : [];
const push = d.push || null;
@@@ -166,8 +168,9 @@ ${tasksHtml(recent)}`;
? html`<p class="run-notice run-notice-error">${row.state === 'expired' ? 'No machine of yours took the task in time. Is it online on openvibe.bot/machines, with the Codes job runner?' : row.error || STATE_LABEL[row.state]}</p>`
: d.ok ? html`<p class="run-notice run-notice-ok">The agents finished the task${d.agents && d.agents.length ? ` (${d.agents.join(' → ')})` : ''}.</p>`
: html`<p class="run-notice run-notice-error">The task ended without success${d.error ? `: ${d.error}` : ''}.</p>`;
-- return html`<section class="run-result">${verdict}
--${d.summary ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}
++ const ended = !(session && row.state === 'succeeded' && d.ok);
++ return html`<section class="run-result">${ended ? verdict : ''}
++${d.summary && !session ? html`<h2>What the agent said</h2><pre tabindex="0" class="run-summary">${d.summary}</pre>` : ''}
${push ? (push.pushed ? html`<p>Pushed <a href="${push.branch_url}">${push.branch}</a>${push.pull_request ? html` · <a href="${push.pull_request}">pull request</a>` : ''}${push.pull_request_error ? html` · the pull request was not opened (${push.pull_request_error})` : ''}</p>` : html`<p class="muted">Not pushed: ${push.reason}</p>`) : ''}
${files.length ? html`<h2>Changed files</h2><table class="run-files"><tbody>${files.map((f) => html`<tr><td><code>${f.path}</code></td><td class="add">+${f.added == null ? '·' : f.added}</td><td class="del">−${f.removed == null ? '·' : f.removed}</td></tr>`)}</tbody></table>` : (row.state === 'succeeded' ? html`<p class="muted">No files changed.</p>` : '')}
${d.patch ? html`<h2>The patch${d.patch_truncated ? ' (cut)' : ''}</h2><pre tabindex="0" class="run-patch" data-patch>${d.patch}</pre>` : ''}
@@@ -181,13 +184,18 @@ ${typeof d.duration_ms === 'number' ? html`<p class="muted">${Math.round(d.durat
if (!row) return page(req, res, { title: 'Not found', body: html`<h1>Not found</h1><p>No such task of yours. <a href="/run">Your tasks</a></p>` }, 404);
row = await refresh(row);
const live = !TERMINAL.has(row.state);
++ // The agents' session (openvibe-shared/agent-session.js, js/run.js mounts it): live from the job's stream, and
++ // after the end from the log the result keeps (a machine on an older Codes keeps none: the summary stands).
++ const lines = live ? null : sessionLogOf(row);
++ const title = String(row.prompt || '').split('\n')[0].slice(0, 90);
++ const session = lines ? html`<section class="run-session" data-session data-title="${title}" data-state="${row.state}"><h2>The session</h2><div data-session-view></div><script type="application/json" data-session-log>${raw(JSON.stringify(lines).replace(/</g, '\\u003c'))}</script></section>` : '';
const body = html`<p class="crumbs"><a href="/run">Run on your machine</a></p>
<h1 class="run-repo">${repoName(row.repo)}${row.ref ? html` <small>${row.ref}</small>` : ''}</h1>
<p><span class="run-pill run-pill-${row.state}" data-state>${STATE_LABEL[row.state] || row.state}</span>${row.agent ? html` <span class="run-pill">${row.agent}</span>` : ''}</p>
<pre tabindex="0" class="run-prompt">${row.prompt}</pre>
--${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
--<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : resultHtml(row)}`;
-- return page(req, res, { title: 'Task', body });
++${live ? html`<section class="run-live" data-events="/run/tasks/${row.id}/events" data-title="${title}" aria-live="polite"><h2>Live</h2><ol class="run-log" data-log></ol></section>
++<form method="post" action="/run/tasks/${row.id}/cancel"><button class="run-btn" type="submit">Cancel</button></form>` : html`${session}${resultHtml(row, { session: !!lines })}`}`;
++ return page(req, res, { title: 'Task', body, sharedScripts: ['agent-session.js'] });
});
r.get('/run/tasks/:id/events', async (req, res) => {
ddiff --git a/server/render/layout.js b/server/render/layout.js
iindex ee76219..d03ec90 100644
---- a/server/render/layout.js
++++ b/server/render/layout.js
@@@ -45,7 +45,7 @@ let RELEASE = 'dev';
function setRelease(id) { if (id) RELEASE = String(id); }
/**
-- * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel],
++ * o: title, description, body (html), viewer, config, path, index (default false), scripts [rel], sharedScripts [openvibe-shared file names],
* crumbs [{ label, href }], jsonLd [nodes] (serialised by openvibe-shared/seo), styles [openvibe-shared stylesheet names],
* css [this site's own extra stylesheets, rel to public/, for the pages that need them]
*/
@@@ -77,7 +77,7 @@ function renderPage(o) {
const crumbs = (o.crumbs || []).length
? html`<nav class="crumbs" aria-label="Breadcrumbs">${o.crumbs.map((c, i) => html`${i ? ' / ' : ''}${c.href ? html`<a href="${c.href}">${c.label}</a>` : c.label}`)}</nav>`
: '';
-- const scripts = (o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`);
++ const scripts = (o.sharedScripts || []).map((name) => html`<script src="${ovServe.url(name)}" defer></script>`).concat((o.scripts || []).map((rel) => html`<script src="${asset(rel)}" defer></script>`));
return shell.page({
name: SITE_NAME,
lang: 'en',
ddiff --git a/test/harness-api.test.js b/test/harness-api.test.js
iindex 923dbd2..8520d9a 100644
---- a/test/harness-api.test.js
++++ b/test/harness-api.test.js
@@@ -161,6 +161,21 @@ const toolResults = (events) => events.filter((e) => e.type === 'user').flatMap(
assert.ok(toolResults(events).some((c) => c.content.trim() === fs.realpathSync(work)));
});
++ // A command's output reaches the stream while it runs (tool_progress), before its result: a long test run is seen
++ // moving (the owner, 2026-10-28: the Claude app said "Output unavailable" for a running command).
++ await check('a running command\'s output streams before its result; edits carry their change', async () => {
++ api.set([{ calls: [['run_command', { command: 'echo first; sleep 0.4; echo second' }], ['write_file', { path: 'note.txt', content: 'hello' }]] }, { content: 'ok' }]);
++ const events = await collect(h.run({ prompt: 'run it', agent: 'openai-compatible', cwd: work, permission: 'full', handoff: false }));
++ const progress = events.filter((e) => e.type === 'system' && e.subtype === 'tool_progress');
++ const resultAt = events.findIndex((e) => e.type === 'user' && e.message.content.some((c) => /second/.test(c.content)));
++ assert.ok(progress.length >= 1 && /first/.test(progress[0].text), JSON.stringify(progress));
++ assert.ok(events.indexOf(progress[0]) < resultAt, 'the first output came before the command ended');
++ assert.ok(progress.every((p) => p.tool_use_id === progress[0].tool_use_id), 'every chunk names its step');
++ const write = events.find((e) => e.type === 'assistant' && e.message.content.some((c) => c.type === 'tool_use' && c.name !== 'Bash' && /note\.txt/.test(JSON.stringify(c.input))));
++ const input = write.message.content.find((c) => c.type === 'tool_use').input;
++ assert.deepStrictEqual([input.file_path, input.content], ['note.txt', 'hello'], 'a new file shows what it holds');
++ });
++
await check('an HTTP error ends the run with the server\'s message', async () => {
api.set([{ status: 429, error: 'rate limited, slow down' }]);
const events = await collect(h.run({ prompt: 'x', agent: 'openai-compatible', cwd: work, handoff: false }));
ddiff --git a/test/job.test.js b/test/job.test.js
iindex 987bdcd..b43a728 100644
---- a/test/job.test.js
++++ b/test/job.test.js
@@@ -11,7 +11,7 @@ const fs = require('fs');
const os = require('os');
const path = require('path');
const { execFileSync, spawnSync } = require('child_process');
--const { runJob, parseArgs, compact, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');
++const { runJob, parseArgs, compact, sessionLog, scrub, githubRepo, JOB_PROTOCOL } = require('../harness/job');
const { check, done } = require('./helpers/boot');
const sh = (cwd, ...args) => execFileSync('git', args, { cwd, env: { ...process.env, GIT_AUTHOR_NAME: 't', GIT_AUTHOR_EMAIL: 't@t', GIT_COMMITTER_NAME: 't', GIT_COMMITTER_EMAIL: 't@t' } });
@@@ -125,7 +125,35 @@ function fakeHarness({ fail = false } = {}) {
assert.strictEqual(scrub('token ghs_abcdefghijklmnopqrstuvwxyz0123 leaked'), 'token *** leaked');
assert.deepStrictEqual(githubRepo('https://github.com/OpenVibers/OpenVibe.Codes.git'), { owner: 'OpenVibers', name: 'OpenVibe.Codes' });
assert.strictEqual(githubRepo('https://gitlab.com/a/b'), null);
-- assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0 });
++ assert.deepStrictEqual(compact({ type: 'result', is_error: false, result: 'ok', duration_ms: 5 }), { t: 'done', ok: true, text: 'ok', duration_ms: 5, cost_usd: 0, turns: 0 });
++ });
++
++ // Lines 1.1 (openvibe-shared/agent-session.js reads them): a step keeps its id and what a reader needs of its input,
++ // a result its output, a helper's line its parent; secrets never pass, in any string.
++ await check('lines 1.1: steps with their input, results with their output, helpers nested, secrets scrubbed', () => {
++ const [said, step] = compact({ type: 'assistant', parent_tool_use_id: 'T1', message: { content: [{ type: 'text', text: 'key sk-abcdefghijklmnopqrstuv' }, { type: 'tool_use', id: 'b1', name: 'Bash', input: { command: 'curl -H "Authorization: Bearer ghp_abcdefghijklmnopqrstuvwxyz0123" https://x', description: 'Call the API', timeout: 5 } }] } });
++ assert.deepStrictEqual(said, { t: 'text', text: 'key [redacted]', parent: 'T1' });
++ assert.deepStrictEqual([step.t, step.id, step.name, step.parent, Object.keys(step.input).sort()], ['tool', 'b1', 'Bash', 'T1', ['command', 'description']], 'only what a reader needs of the input');
++ assert.ok(!/ghp_/.test(JSON.stringify(step)), 'no token in the command');
++ const [edit] = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'e1', name: 'Edit', input: { file_path: 'a.js', old_string: 'x', new_string: 'y' } }] } });
++ assert.deepStrictEqual(edit.input, { file_path: 'a.js', old_string: 'x', new_string: 'y' }, 'an edit carries its change');
++ assert.deepStrictEqual(compact({ type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'b1', is_error: true, content: [{ type: 'text', text: 'boom ghs_abcdefghijklmnopqrstuvwxyz0123' }] }] } }),
++ [{ t: 'tool_result', id: 'b1', ok: false, output: 'boom [redacted]' }], 'a result with its output, scrubbed');
++ assert.deepStrictEqual(compact({ type: 'system', subtype: 'tool_progress', tool_use_id: 'b1', text: 'line\n' }), { t: 'tool_progress', id: 'b1', chunk: 'line\n' });
++ assert.deepStrictEqual(compact({ type: 'system', subtype: 'handoff', from: 'claude', to: 'codex', reason: 'limit' }), { t: 'handoff', from: 'claude', to: 'codex', reason: 'limit' });
++ const todos = compact({ type: 'assistant', message: { content: [{ type: 'tool_use', id: 'p', name: 'TodoWrite', input: { todos: [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }] } }] } })[0];
++ assert.deepStrictEqual(todos.input.todos, [{ content: 'A', status: 'in_progress', activeForm: 'Doing A' }], 'the plan travels');
++ });
++
++ await check('the session log a result keeps fits: outputs are cut shorter first, the steps survive', () => {
++ const lines = [];
++ for (let i = 0; i < 120; i++) { lines.push({ t: 'tool', id: `s${i}`, name: 'Bash', what: 'npm test', input: { command: 'npm test' } }); lines.push({ t: 'tool_progress', id: `s${i}`, chunk: 'p'.repeat(3000) }); lines.push({ t: 'tool_result', id: `s${i}`, ok: true, output: 'o'.repeat(4000) }); }
++ const kept = sessionLog(lines);
++ assert.ok(JSON.stringify(kept).length <= 250000, 'within the result\'s room');
++ assert.strictEqual(kept.filter((l) => l.t === 'tool').length, 120, 'every step is still there');
++ assert.ok(!kept.some((l) => l.t === 'tool_progress'), 'progress whose step ended goes first');
++ assert.ok(kept.every((l) => l.t !== 'tool_result' || l.output.length <= 1500), 'outputs cut shorter');
++ assert.deepStrictEqual(sessionLog([{ t: 'text', text: 'hi' }]), [{ t: 'text', text: 'hi' }], 'a short session is kept as it is');
});
await check('the command reads its input on stdin and prints its result when fd 3 is not open', () => {
ddiff --git a/test/run-pages.test.js b/test/run-pages.test.js
iindex 4e9fb7e..2dc2652 100644
---- a/test/run-pages.test.js
++++ b/test/run-pages.test.js
@@@ -84,10 +84,20 @@ const { boot, check, done } = require('./helpers/boot');
const stream = calls.find((c) => c[0] === 'stream');
assert.deepStrictEqual(stream[2], { subject: ada.subject, project });
state = 'succeeded';
-- result = { protocol: '1.0.0', ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };
++ result = { protocol: '1.0.0', lines: '1.1', log: [{ t: 'agent', agent: 'claude-code' }, { t: 'text', text: 'I fix <b>it</b></script>' }, { t: 'tool', id: 'e1', name: 'Edit', input: { file_path: 'README.md', old_string: 'teh', new_string: 'the' } }, { t: 'tool_result', id: 'e1', ok: true, output: 'ok' }, { t: 'mystery', x: 1 }, { t: 'done', ok: true, text: 'Fixed', duration_ms: 900 }], ok: true, agents: ['claude-code'], summary: 'Fixed <the> typo', files: [{ path: 'README.md', added: 1, removed: 1 }], patch: '@@ -1 +1 @@\n-teh\n+the\n', patch_truncated: false, push: { pushed: true, branch: 'openvibe-codes/x', branch_url: 'https://github.com/OpenVibers/OpenVibe.Codes/tree/openvibe-codes%2Fx', pull_request: 'https://github.com/OpenVibers/OpenVibe.Codes/pull/9' }, duration_ms: 42000 };
const done_ = await t.get(`/run/tasks/${taskId}`, { as: ada });
-- assert.ok(done_.text.includes('The agents finished the task') && done_.text.includes('Fixed &lt;the&gt; typo') && done_.text.includes('README.md'));
++ // With the session on the page, its closing card says how it ended and what the agents said: the page does not repeat it.
++ assert.ok(!done_.text.includes('The agents finished the task') && !done_.text.includes('What the agent said') && done_.text.includes('README.md'));
assert.ok(done_.text.includes('pull/9') && done_.text.includes('data-patch') && !done_.text.includes('data-events'));
++ // The session the result kept, for openvibe-shared/agent-session.js: known lines only, and nothing in them can end
++ // the page's script early.
++ assert.ok(done_.text.includes('data-session') && /<script src="[^"]*agent-session\.js[^"]*" defer>/.test(done_.text), 'the session and the component');
++ const kept = JSON.parse(/<script type="application\/json" data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]);
++ assert.deepStrictEqual(kept.map((l) => l.t), ['agent', 'text', 'tool', 'tool_result', 'done'], 'lines of an unknown kind are left out');
++ assert.ok(!/<\/script>I|<b>it/.test(/data-session-log>([\s\S]*?)<\/script>/.exec(done_.text)[1]), 'no markup inside the data');
++ assert.strictEqual(kept[1].text, 'I fix <b>it</b></script>', 'and the text reads back as it was');
++ const api = await t.get(`/api/v1/tasks/${taskId}`, { as: ada });
++ if (api.status === 200) assert.ok(!('log' in ((api.json && api.json.result) || {})), 'the task API passes codes.task@1\'s fields only');
const row = await t.ctx.store.db.one('SELECT state, result FROM run_tasks WHERE id = $1', [taskId]);
assert.strictEqual(row.state, 'succeeded');
assert.strictEqual(row.result.summary, 'Fixed <the> typo');