openvibers/openvibe.actor Commits

A6: the one timeline behind the Dock (store, subscription, API, stream, approvals proxy, kill switch) (#304)

OP OpenVibers committed 6 days ago · 1164d27e89 · 20 files, +557 −18

* A6: the one timeline behind the Dock

* A6: pin openvibe-contracts v0.313.0

* Merge remote-tracking branch 'origin/main' into a6/one-timeline

# Conflicts:
#	package.json

* A6: merge main; pin the timeline contracts branch and test the Act->Watch->Act transition

* A6: merge main; sdk v0.94.0 beside the timeline contracts pin

* A6: pin openvibe-contracts v0.316.0 (the release tag, was the branch commit)

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 0985338..5850568 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -2,6 +2,12 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
++## 0.2.0 — 2026-11-07
++
++- **The one timeline behind the Dock** (design 2026-11-07): every mode of the agent (Chat, Codes, Run, Watch, Live, Surf) publishes the step it took as agent.step.started|updated|ended, and Actor keeps the one timeline: GET /api/v1/agent/timeline (cursor, mode and needs-you filters, the per-mode counts) and the live stream GET /api/v1/agent/timeline/stream. An entry is a title, an outcome and a link: the table has no column for a prompt or a diff, so none can be stored.
++- **Needs you and the kill switch:** POST /api/v1/agent/approvals/:product/:id/decide answers an approval in the mode that owns it (the person's own token goes with it), and POST /api/v1/agent/kill-switch stops what is open and pauses the agent until the person resumes it.
++- **Only me by default:** an entry is private until the person shares it through the one Share sheet; another person's entry is the same 404 as one that does not exist. The timeline is in the account export and goes with the person on deletion.
++
## 0.1.0 — 2026-10-08
- **First release:** the task router (plan T17, ADR-044). A task is classified, routed by `openvibe-sdk/placement`, run, checked by a second model family, and delivered as `platform.task@1` with its cost and explanation.
ddiff --git a/README.md b/README.md
iindex 099ae9d..24da12b 100644
---- a/README.md
++++ b/README.md
@@@ -257,7 +257,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.md).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
--- openvibe-contracts: v0.312.0
++- openvibe-contracts: v0.316.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.126.0
<!-- versions:end -->
ddiff --git a/STATUS.json b/STATUS.json
iindex 8bcd0ce..681f79b 100644
---- a/STATUS.json
++++ b/STATUS.json
@@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"domain": "openvibe.actor",
-- "contracts": "openvibe-contracts v0.312.0",
++ "contracts": "openvibe-contracts v0.316.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.126.0",
"packages": [
-- "openvibe-contracts v0.312.0",
++ "openvibe-contracts v0.316.0",
"openvibe-sdk v0.94.0",
"openvibe-shared v4.126.0"
],
ddiff --git a/migrations/0032_agent_timeline.sql b/migrations/0032_agent_timeline.sql
nnew file mode 100644
iindex 0000000..b5c9b27
---- /dev/null
++++ b/migrations/0032_agent_timeline.sql
@@@ -0,0 +1,39 @@
++-- phase: expand
++-- The one timeline behind the Dock (design 2026-11-07 §2). Expand-only: two new tables, nothing changed, nothing
++-- dropped. A row is one step of the agent's work in one mode for one person: its title, its outcome and its link.
++-- Never a prompt, a diff or a page: the columns below are the whole shape of an entry (agent.timeline-entry@1).
++CREATE TABLE IF NOT EXISTS agent_timeline (
++ id text COLLATE "C" PRIMARY KEY,
++ subject text COLLATE "C" NOT NULL,
++ step_id text COLLATE "C" NOT NULL,
++ product text COLLATE "C" NOT NULL,
++ mode text COLLATE "C" NOT NULL,
++ ref text COLLATE "C",
++ title text COLLATE "C" NOT NULL,
++ status text COLLATE "C" NOT NULL,
++ placement_where text COLLATE "C",
++ placement_why text COLLATE "C",
++ cost_usd numeric,
++ link text COLLATE "C",
++ reason text COLLATE "C",
++ transition text COLLATE "C",
++ approval_product text COLLATE "C",
++ approval_id text COLLATE "C",
++ approval_kind text COLLATE "C",
++ audience text COLLATE "C" NOT NULL DEFAULT 'private',
++ shared boolean NOT NULL DEFAULT false,
++ started_at timestamptz NOT NULL,
++ ended_at timestamptz,
++ created_at timestamptz NOT NULL,
++ updated_at timestamptz NOT NULL
++);
++CREATE UNIQUE INDEX IF NOT EXISTS agent_timeline_step_idx ON agent_timeline (subject, product, step_id);
++CREATE INDEX IF NOT EXISTS agent_timeline_subject_idx ON agent_timeline (subject, started_at DESC, id DESC);
++CREATE INDEX IF NOT EXISTS agent_timeline_status_idx ON agent_timeline (subject, status);
++
++-- The kill switch: whether the person paused the agent. One row per person.
++CREATE TABLE IF NOT EXISTS agent_state (
++ subject text COLLATE "C" PRIMARY KEY,
++ paused boolean NOT NULL DEFAULT false,
++ updated_at timestamptz NOT NULL
++);
ddiff --git a/package-lock.json b/package-lock.json
iindex 0c6d424..b278293 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.316.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0"
@@@ -1146,9 +1146,9 @@
}
},
"node_modules/openvibe-contracts": {
-- "version": "0.312.0",
-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
-- "integrity": "sha512-2GsD53zuIedL3eW2LBlYQ68ODTHzoZJHrAmVnZCpiL7ZWnAXGwPW9dpQagLI9Zdkh3LQuuU5ae1YJI0K+acXbA==",
++ "version": "0.316.0",
++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.316.0",
++ "integrity": "sha512-c+PZDmrq56rFyVXpYUhOlTpdvZntVJMA1bGOX9Amr72uobs8qh8G2DZDn31lYn56EGMMjPulhDAtvMxcTaxlFg==",
"license": "AGPL-3.0-only",
"dependencies": {
"ajv": "^8.17.1",
ddiff --git a/package.json b/package.json
iindex d88934e..6cad31f 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.312.0",
++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.316.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.94.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0"
ddiff --git a/server/agents/timeline.js b/server/agents/timeline.js
nnew file mode 100644
iindex 0000000..3dae7bf
---- /dev/null
++++ b/server/agents/timeline.js
@@@ -0,0 +1,255 @@
++'use strict';
++
++/**
++ * The one timeline behind the Dock (design 2026-11-07 §2).
++ *
++ * Every mode of the agent publishes agent.step.started|updated|ended on OpenVibe.Events with the person's subject;
++ * Actor keeps the entries and serves them to the Dock (GET /api/v1/agent/timeline, and the live stream). No product
++ * reads another's tables.
++ *
++ * Privacy: a row is a title, an outcome and a link (agent.timeline-entry@1). The columns are the whole shape of an
++ * entry: there is no column for a prompt, a diff or a page, so one cannot be stored here even by accident. The
++ * audience is 'private' (Only me) until the person shares the entry through the one Share sheet.
++ */
++const MODES = new Set(['talk', 'act', 'run', 'code', 'browse', 'watch', 'stream']);
++const STATUSES = new Set(['running', 'waiting', 'needs_you', 'done', 'failed', 'stopped']);
++const OPEN = new Set(['running', 'waiting', 'needs_you']);
++const PAGE = 50;
++const MAX = 200;
++
++const subs = new Map(); // subject → Set<{ res }>: the live update stream
++
++const iso = (v) => (v instanceof Date ? v.toISOString() : (v ? new Date(v).toISOString() : null));
++const text = (v, max) => (typeof v === 'string' && v.trim() ? v.trim().slice(0, max) : null);
++const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null);
++
++/** The person an event is for: only a user subject is the agent's. */
++function subjectOf(ev) {
++ const s = ev && ev.subject;
++ if (!s || s.type !== 'user' || typeof s.id !== 'string') return null;
++ return 'user:' + s.id;
++}
++
++/** One row → one agent.timeline-entry@1. */
++function entryOf(row) {
++ if (!row) return null;
++ return {
++ id: row.id,
++ subject: row.subject,
++ mode: row.mode,
++ product: row.product,
++ ref: row.ref || null,
++ title: row.title,
++ started_at: iso(row.started_at),
++ ended_at: iso(row.ended_at),
++ status: row.status,
++ placement: (row.placement_where || row.placement_why) ? { where: row.placement_where || null, why: row.placement_why || null } : null,
++ cost_usd: row.cost_usd === null || row.cost_usd === undefined ? null : Number(row.cost_usd),
++ link: row.link || null,
++ reason: row.reason || null,
++ transition: row.transition || null,
++ approval: row.approval_id ? { product: row.approval_product || null, id: row.approval_id, kind: row.approval_kind || null } : null,
++ audience: row.audience || 'private',
++ shared: Boolean(row.shared),
++ };
++}
++
++/** Write a step into the timeline (started, updated or ended: the same row, brought forward). */
++async function upsert(s, subject, payload) {
++ const step = payload || {};
++ if (!subject || !MODES.has(step.mode) || !STATUSES.has(step.status) || !text(step.step_id, 80)) return null;
++ const now = s.iso();
++ const row = await s.db.one(`INSERT INTO agent_timeline (id, subject, step_id, product, mode, ref, title, status, placement_where, placement_why,
++ cost_usd, link, reason, transition, approval_product, approval_id, approval_kind, audience, shared, started_at, ended_at, created_at, updated_at)
++ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,'private',false,$18,$19,$20,$20)
++ ON CONFLICT (subject, product, step_id) DO UPDATE SET
++ mode = EXCLUDED.mode, ref = EXCLUDED.ref, title = EXCLUDED.title, status = EXCLUDED.status,
++ placement_where = EXCLUDED.placement_where, placement_why = EXCLUDED.placement_why, cost_usd = EXCLUDED.cost_usd,
++ link = EXCLUDED.link, reason = EXCLUDED.reason, transition = EXCLUDED.transition,
++ approval_product = EXCLUDED.approval_product, approval_id = EXCLUDED.approval_id, approval_kind = EXCLUDED.approval_kind,
++ ended_at = EXCLUDED.ended_at, updated_at = EXCLUDED.updated_at
++ RETURNING *`,
++ [s.newId('tl'), subject, text(step.step_id, 80), text(step.product, 40) || 'agent', step.mode, text(step.ref, 120),
++ text(step.title, 160) || 'A step of the agent', step.status,
++ text(step.placement && step.placement.where, 160), text(step.placement && step.placement.why, 240),
++ num(step.cost_usd), text(step.link, 2048), text(step.reason, 240), text(step.transition, 240),
++ text(step.approval && step.approval.product, 40), text(step.approval && step.approval.id, 80), text(step.approval && step.approval.kind, 40),
++ iso(step.started_at) || now, iso(step.ended_at), now]);
++ publish(subject, entryOf(row));
++ return row;
++}
++
++/** An event from OpenVibe.Events → the timeline. */
++async function onEvent(s, ev) {
++ if (!ev || !String(ev.event_type || '').startsWith('agent.step.')) return 'ignored:type';
++ const subject = subjectOf(ev);
++ if (!subject) return 'ignored:subject';
++ const row = await upsert(s, subject, ev.payload);
++ return row ? 'applied' : 'ignored:payload';
++}
++
++const encode = (v) => Buffer.from(JSON.stringify(v)).toString('base64url');
++const decode = (v) => { try { return JSON.parse(Buffer.from(String(v), 'base64url').toString('utf8')); } catch { return null; } };
++
++/**
++ * The person's timeline: what is happening now, what needs them, the entries (newest first), the next cursor and the
++ * per-mode counts. The filters are the Dock's chips (mode, needs you); the counts say what a filter would hide.
++ */
++async function list(s, subject, { cursor = null, mode = null, needs_you = false, scope = 'mine', limit = PAGE } = {}) {
++ const where = ['subject = $1'];
++ const args = [subject];
++ if (mode && MODES.has(mode)) { args.push(mode); where.push('mode = $' + args.length); }
++ if (needs_you) where.push("status = 'needs_you'");
++ if (scope === 'shared') where.push("audience <> 'private'");
++ if (cursor) {
++ const c = decode(cursor);
++ if (c && c.t && c.i) { args.push(c.t, c.i); where.push('(started_at < $' + (args.length - 1) + ' OR (started_at = $' + (args.length - 1) + ' AND id < $' + args.length + '))'); }
++ }
++ const take = Math.min(Number(limit) || PAGE, MAX);
++ args.push(take);
++ const rows = await s.db.many('SELECT * FROM agent_timeline WHERE ' + where.join(' AND ') + ' ORDER BY started_at DESC, id DESC LIMIT $' + args.length, args);
++ const entries = rows.map(entryOf);
++ const last = rows[rows.length - 1];
++ const next_cursor = rows.length >= take && last ? encode({ t: iso(last.started_at), i: last.id }) : null;
++
++ const now = await s.db.maybe("SELECT * FROM agent_timeline WHERE subject = $1 AND status = 'running' ORDER BY started_at DESC LIMIT 1", [subject]);
++ const asks = await s.db.many("SELECT * FROM agent_timeline WHERE subject = $1 AND status = 'needs_you' ORDER BY started_at ASC LIMIT 50", [subject]);
++ const counted = await s.db.many('SELECT mode, count(*) AS n FROM agent_timeline WHERE subject = $1 GROUP BY mode', [subject]);
++ const counts = {};
++ for (const row of counted) counts[row.mode] = Number(row.n);
++ counts.needs_you = Number(asks.length);
++ return { now: entryOf(now), needs_you: asks.map(entryOf), entries, next_cursor, counts };
++}
++
++/** The steps that are still open (running, waiting, needs the person). */
++async function open(s, subject) {
++ const rows = await s.db.many('SELECT * FROM agent_timeline WHERE subject = $1 AND status = ANY($2) ORDER BY started_at ASC', [subject, [...OPEN]]);
++ return rows.map(entryOf);
++}
++
++/** The kill switch: the person pauses the agent (paused) or lets it run again. */
++async function setPaused(s, subject, paused) {
++ const now = s.iso();
++ await s.db.one(`INSERT INTO agent_state (subject, paused, updated_at) VALUES ($1,$2,$3)
++ ON CONFLICT (subject) DO UPDATE SET paused = EXCLUDED.paused, updated_at = EXCLUDED.updated_at RETURNING subject`, [subject, Boolean(paused), now]);
++ return { subject, paused: Boolean(paused) };
++}
++
++async function isPaused(s, subject) {
++ const row = await s.db.maybe('SELECT paused FROM agent_state WHERE subject = $1', [subject]);
++ return Boolean(row && row.paused);
++}
++
++/** One entry of theirs: another person's entry is the same 404 as one that does not exist. */
++async function get(s, subject, id) {
++ const row = await s.db.maybe('SELECT * FROM agent_timeline WHERE id = $1 AND subject = $2', [String(id || ''), subject]);
++ return row ? entryOf(row) : null;
++}
++
++/** Delete one entry of theirs (the person may remove a step they do not want to see). */
++async function remove(s, subject, id) {
++ const out = await s.db.maybe('DELETE FROM agent_timeline WHERE id = $1 AND subject = $2 RETURNING id', [String(id || ''), subject]);
++ return Boolean(out);
++}
++
++// ── The live update stream ─────────────────────────────────────────────
++function publish(subject, entry) {
++ for (const sub of subs.get(subject) || []) {
++ try { sub.res.write('event: entry\ndata: ' + JSON.stringify(entry) + '\n\n'); } catch (err) { /* the reader is gone */ }
++ }
++}
++
++/** A reader of the live stream: the Dock's panel. It gets every new entry of its own timeline. */
++function subscribe(subject, res) {
++ if (!subs.has(subject)) subs.set(subject, new Set());
++ const sub = { res };
++ subs.get(subject).add(sub);
++ return () => {
++ const set = subs.get(subject);
++ if (!set) return;
++ set.delete(sub);
++ if (!set.size) subs.delete(subject);
++ };
++}
++
++/** Where a mode lives: the URL the person answers it or stops it on. */
++function productUrl(ctx, product) {
++ const c = (ctx && ctx.config) || {};
++ const direct = c[product] && c[product].url;
++ if (direct) return String(direct).replace(/\/+$/, '');
++ const env = process.env['ACTOR_' + String(product).toUpperCase() + '_URL'];
++ return env ? String(env).replace(/\/+$/, '') : null;
++}
++
++/** The person's own token, so the mode sees the person answering, not a service. */
++function bearer(req) {
++ const h = String((req && req.headers && req.headers.authorization) || '');
++ return h.startsWith('Bearer ') ? h : null;
++}
++
++/**
++ * The approvals proxy: the person answers in the Dock, and the mode that owns the approval decides it there.
++ * The token goes with it, so the mode sees the person's own request.
++ */
++async function proxyApproval(ctx, req, { product, id, decision }) {
++ const base = productUrl(ctx, product);
++ if (!base) return { error: product + ' has no address here', status: 502, code: 'actor.approval.no_product' };
++ const auth = bearer(req);
++ try {
++ const res = await fetch(base + '/api/v1/agent/approvals/' + encodeURIComponent(id) + '/decide', {
++ method: 'POST',
++ headers: { 'Content-Type': 'application/json', ...(auth ? { Authorization: auth } : {}) },
++ body: JSON.stringify({ decision }),
++ signal: AbortSignal.timeout(15000),
++ });
++ const body = await res.json().catch(() => ({}));
++ if (!res.ok) return { error: body.detail || body.title || (product + ' refused the answer'), status: res.status === 404 ? 404 : 502, code: body.code || 'actor.approval.proxy_failed' };
++ return { state: body.state || decision };
++ } catch (err) {
++ return { error: product + ' did not answer: ' + err.message, status: 502, code: 'actor.approval.proxy_failed' };
++ }
++}
++
++/** The mode's own stop route: the Dock's Stop for Code and Run, End for Stream. */
++async function stopInProduct(ctx, req, entry) {
++ const base = productUrl(ctx, entry.product);
++ if (!base || !entry.ref) return false;
++ const auth = bearer(req);
++ const p = entry.product === 'codes' ? '/api/v1/tasks/' + encodeURIComponent(entry.ref) + '/cancel'
++ : entry.product === 'run' ? '/api/v1/jobs/' + encodeURIComponent(entry.ref) + '/cancel'
++ : entry.product === 'live' ? '/api/v1/agent-streams/' + encodeURIComponent(entry.ref) + '/end'
++ : null;
++ if (!p) return false;
++ try {
++ const res = await fetch(base + p, { method: 'POST', headers: auth ? { Authorization: auth } : {}, signal: AbortSignal.timeout(15000) });
++ return res.ok;
++ } catch (err) {
++ return false;
++ }
++}
++
++/**
++ * The kill switch: stop every step that is still open (the mode's own stop route where it has one) and pause the
++ * agent until the person resumes it.
++ */
++async function killSwitch(ctx, req, action) {
++ const { s } = ctx;
++ const subject = req.principal.requester;
++ if (action === 'resume') {
++ await setPaused(s, subject, false);
++ return { paused: false, stopped: [] };
++ }
++ const openEntries = await open(s, subject);
++ const stopped = [];
++ for (const entry of openEntries) {
++ const at = s.iso();
++ await s.db.query("UPDATE agent_timeline SET status = 'stopped', ended_at = COALESCE(ended_at, $2), reason = COALESCE(reason, 'Stopped by you'), updated_at = $2 WHERE id = $1", [entry.id, at]);
++ stopped.push({ id: entry.id, product: entry.product, ref: entry.ref, mode: entry.mode });
++ await stopInProduct(ctx, req, entry);
++ }
++ await setPaused(s, subject, true);
++ publish(subject, { paused: true });
++ return { paused: true, stopped };
++}
++
++module.exports = { upsert, onEvent, list, open, get, remove, setPaused, isPaused, entryOf, subscribe, killSwitch, proxyApproval, productUrl, MODES, STATUSES, OPEN };
ddiff --git a/server/app.js b/server/app.js
iindex 506b3f8..2b868a6 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -45,6 +45,7 @@ function checkSelfHosted(config) {
if (missing.length) throw new Error(`ACTOR_SELF_HOSTED=1 needs ${missing.join(', ')}: run \`npm run selfhost:init\` (docs/self-host.md)`);
}
const { createChatAsks } = require('./chat/asks');
++const timeline = require('./agents/timeline');
const { createTask } = require('./tasks/create');
const { createWebhookPoster } = require('./net/webhook-post');
const { createTaskStream } = require('./tasks/stream');
@@@ -244,7 +245,10 @@ async function createApp(opts = {}) {
// chat.message.created goes to chat/asks.js (ask Actor from chat); anything else is acknowledged and ignored.
app.use('/internal/events', accountData.consumer({ secrets: config.events.secrets, send: accountSend, log,
// A chat message asks Actor a question; anything else may wake a wait (plan A2).
-- onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev) : waits.onEvent(ev)) }));
++ onEvent: (ev) => (ev.event_type === 'chat.message.created' ? chatAsks.onEvent(ev)
++ // The agent's steps (design 2026-11-07): every mode publishes agent.step.* with the person's subject.
++ : String(ev.event_type || '').startsWith('agent.step.') ? timeline.onEvent(ctx.s, ev)
++ : waits.onEvent(ev)) }));
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
ddiff --git a/server/db.js b/server/db.js
iindex d34431c..af8be46 100644
---- a/server/db.js
++++ b/server/db.js
@@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors'];
++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_agent_proposals', 'actor_vibe_deliveries', 'actor_approvals', 'actor_artifacts', 'actor_task_notices', 'actor_spans', 'actor_bench_offers', 'actor_checkpoints', 'actor_waits', 'actor_wait_events', 'actor_event_cursors', 'agent_timeline', 'agent_state'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
ddiff --git a/server/events-consumer.js b/server/events-consumer.js
iindex 710d125..0f31709 100644
---- a/server/events-consumer.js
++++ b/server/events-consumer.js
@@@ -14,6 +14,9 @@
const { serviceAuth } = require('openvibe-contracts');
const { TOPICS } = require('./identity/account-data');
++/** The agent's steps (design 2026-11-07): agent.step.started|updated|ended from every mode. */
++const AGENT_TOPICS = ['agent.step.*'];
++
/** Create any missing subscription for TOPICS at Events (idempotent; retried in the background at boot). */
function startSubscriptions({ config, port, secret, eventsUrl = config.events.url, fetchImpl = globalThis.fetch, log = console, topics: extra = [] }) {
if (!eventsUrl || !secret || !config.oauth.clientSecret || process.env.ACTOR_EVENTS_SUBSCRIBE === '0') return null;
@@@ -29,8 +32,10 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
const listed = await call('GET', '/api/v1/subscriptions');
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
-- // The ADR-033 topics, and chat.message.created while asking Actor from chat is on (chat/asks.js).
-- for (const topic of [...TOPICS, ...extra]) {
++ // The ADR-033 topics, chat.message.created while asking Actor from chat is on (chat/asks.js), and the
++ // agent's own steps (design 2026-11-07): every mode publishes agent.step.* into the network's agent
++ // namespace, and Actor keeps the one timeline behind the Dock.
++ for (const topic of [...TOPICS, AGENT_TOPICS, ...extra]) {
if (mine.some((s) => s.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 64a909c..0ee634c 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
++const timeline = require('../agents/timeline');
const modelPrefs = require('../runtime/model-prefs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@@ -110,6 +111,65 @@ function createApi(ctx) {
if (!req.principal.requester || !req.principal.requester.startsWith('user:')) return problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
return next();
};
++
++ // ── The one timeline behind the Dock (design 2026-11-07) ──
++ // The person's own steps, newest first, with the cursor and the mode and needs-you filters the Dock's chips use.
++ // Another person's entry is the same 404 as one that does not exist: the list is scoped to the caller.
++ r.get('/agent/timeline', principal.requireCapability('actor.timeline.read'), owner, limits.reads('actor.timeline.read'), async (req, res) => {
++ const out = await timeline.list(s, req.principal.requester, {
++ cursor: req.query.cursor ? String(req.query.cursor) : null,
++ mode: req.query.mode ? String(req.query.mode) : null,
++ needs_you: req.query.needs_you === '1' || req.query.needs_you === 'true',
++ scope: req.query.scope === 'shared' ? 'shared' : 'mine',
++ });
++ res.json(out);
++ });
++
++ // One step of theirs: another person's step is the same 404 as one that does not exist.
++ r.get('/agent/timeline/:id', principal.requireCapability('actor.timeline.read'), owner, limits.reads('actor.timeline.read'), async (req, res) => {
++ const entry = await timeline.get(s, req.principal.requester, req.params.id);
++ if (!entry) return problem(req, res, 404, 'agent.timeline.not_found', 'No such step.');
++ res.json(entry);
++ });
++
++ // The live update stream: every new entry of the person's own timeline as it happens.
++ r.get('/agent/timeline/stream', principal.requireCapability('actor.timeline.read'), owner, async (req, res) => {
++ res.set('Content-Type', 'text/event-stream');
++ res.set('Cache-Control', 'no-store');
++ res.set('Connection', 'keep-alive');
++ if (typeof res.flushHeaders === 'function') res.flushHeaders();
++ const stop = timeline.subscribe(req.principal.requester, res);
++ const beat = setInterval(() => { try { res.write(': beat\n\n'); } catch (err) { /* the reader is gone */ } }, 15000);
++ if (beat.unref) beat.unref();
++ req.on('close', () => { clearInterval(beat); stop(); });
++ res.write('event: ready\ndata: {}\n\n');
++ });
++
++ // The approvals proxy: the person answers in the Dock, and the answer lands in the product that owns it
++ // (Actor's own approvals here; another mode's own route there). The product is the one that decides.
++ r.post('/agent/approvals/:product/:id/decide', principal.requireCapability('actor.approval.decide'), owner, limits.budget('actor.approval.decide'), async (req, res) => {
++ const decision = String((req.body || {}).decision || '');
++ if (!['approve', 'decline'].includes(decision)) return problem(req, res, 422, 'actor.approval.invalid', 'decision must be approve or decline');
++ const product = String(req.params.product || '');
++ const id = String(req.params.id || '');
++ if (product === 'actor') {
++ const decided = await approvals.decide(ctx, req.principal.requester, id, decision, { idempotent: true });
++ if (decided.error) return problem(req, res, decided.status, decided.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', decided.error);
++ return res.json({ product, id, state: decided.row.state });
++ }
++ const proxied = await timeline.proxyApproval(ctx, req, { product, id, decision });
++ if (proxied.error) return problem(req, res, proxied.status || 502, proxied.code || 'actor.approval.proxy_failed', proxied.error);
++ res.json({ product, id, state: proxied.state || decision });
++ });
++
++ // The kill switch: stop what the agent is doing and pause it until the person resumes it.
++ r.post('/agent/kill-switch', principal.requireCapability('actor.resource.control'), owner, limits.budget('actor.resource.control'), async (req, res) => {
++ const action = String((req.body || {}).action || 'stop');
++ if (!['stop', 'resume'].includes(action)) return problem(req, res, 422, 'actor.agent.invalid', 'action must be stop or resume');
++ const out = await timeline.killSwitch(ctx, req, action);
++ res.json(out);
++ });
++
const loadAgent = async (req, res) => {
const row = await personal.get(s, req.principal.requester, req.params.id);
if (!row) problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 62b0abc..3d66d35 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -35,6 +35,8 @@ const BUDGETS = {
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
++ // The kill switch and the other controls of the agent (design 2026-11-07).
++ 'actor.resource.control': { minute: 30, hour: 600 },
};
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 0c43ac9..e6a1887 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
++const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];
function decodePayload(token) {
const parts = String(token || '').split('.');
ddiff --git a/server/identity/account-data.js b/server/identity/account-data.js
iindex db57bd1..4726936 100644
---- a/server/identity/account-data.js
++++ b/server/identity/account-data.js
@@@ -63,6 +63,11 @@ const TABLES = [
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
{ table: 'actor_chat_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'chat-app-approvals.json' },
{ table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
++ // The agent's timeline (design 2026-11-07): the steps the agent took for this person, and whether they paused
++ // it. Titles, outcomes and links only: there is no column for a prompt or a diff, so none can be exported.
++ { table: 'agent_timeline', subject: 'subject', value: (usr) => `user:${usr}`, file: 'timeline.json',
++ columns: ['id', 'subject', 'step_id', 'product', 'mode', 'ref', 'title', 'status', 'placement_where', 'placement_why', 'cost_usd', 'link', 'reason', 'transition', 'approval_product', 'approval_id', 'approval_kind', 'audience', 'shared', 'started_at', 'ended_at', 'created_at', 'updated_at'] },
++ { table: 'agent_state', subject: 'subject', value: (usr) => `user:${usr}`, file: 'agent-state.json', columns: ['subject', 'paused', 'updated_at'] },
// The sharing default they chose for new runs (design 2026-11-06): the audience the next one starts as.
{ table: 'actor_share_defaults', subject: 'subject', value: (usr) => usr, file: 'share-defaults.json' },
// The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex b6ead01..6e73809 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -29,6 +29,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
++ 'actor.resource.control': { minute: 30, hour: 600 },
});
});
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 9c78b1f..9488447 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage'];
++const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
@@@ -23,6 +23,11 @@ const ROUTE_EXTENSIONS = new Map([
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/stats/tools', null],
['GET /api/v1/stats/models', null],
++ // The Dock's answers on the agent (design 2026-11-07): the approvals proxy and the kill switch. They are the
++ // person's own decisions on the agent, guarded by the capability that already covers them.
++ ['GET /api/v1/agent/timeline/:id', 'actor.timeline.read'],
++ ['POST /api/v1/agent/approvals/:product/:id/decide', 'actor.approval.decide'],
++ ['POST /api/v1/agent/kill-switch', 'actor.resource.control'],
]);
const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');
// The A2A surface (stage V2-11): POST /a2a/v1/tasks and GET /a2a/v1/tasks/:id, guarded by actor.a2a.task.
@@@ -131,9 +136,9 @@ function codeRoutes() {
}
});
-- await check('events: the manifest produces none and consumes the two account events (ADR-033) and chat messages (asks from chat); the capabilities declare none', () => {
-- assert.deepStrictEqual(manifest.eventsProduced, []);
-- assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['chat.message.created', 'network.account.deleted', 'network.account.export_requested']);
++ await check('events: the manifest registers the network agent namespace (agent.step.*: one producer for the family) and consumes the account events, chat messages and the steps of the agent itself', () => {
++ assert.deepStrictEqual([...manifest.eventsProduced].sort(), ['agent.step.ended', 'agent.step.started', 'agent.step.updated']);
++ assert.deepStrictEqual([...manifest.eventsConsumed].sort(), ['agent.step.ended', 'agent.step.started', 'agent.step.updated', 'chat.message.created', 'network.account.deleted', 'network.account.export_requested']);
for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);
});
ddiff --git a/test/fixtures/watch-agent-step.json b/test/fixtures/watch-agent-step.json
nnew file mode 100644
iindex 0000000..fbc3e38
---- /dev/null
++++ b/test/fixtures/watch-agent-step.json
@@@ -0,0 +1,18 @@
++{
++ "step_id": "wch_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "mode": "watch",
++ "product": "watch",
++ "ref": "wch_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "title": "Watching #167 CI",
++ "status": "waiting",
++ "started_at": "2026-11-07T09:00:00.000Z",
++ "ended_at": null,
++ "placement": {
++ "where": "On the network",
++ "why": "it checks the page from our side, on your schedule, and only tells you when it changes"
++ },
++ "cost_usd": null,
++ "link": "https://openvibe.watch/watches/wch_01JAB2C3D4E5F6G7H8J9K0MNPS",
++ "reason": null,
++ "transition": "Waiting for CI on #167 (Watch, 4 min), then I'll merge it (Act) and deploy on your machine (Run)."
++}
ddiff --git a/test/memory-migration.test.js b/test/memory-migration.test.js
iindex a4f3212..cacdbe1 100644
---- a/test/memory-migration.test.js
++++ b/test/memory-migration.test.js
@@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
// 0025 is the person's own model choice (plan L55 C3): it is a migration of its own, like every one before it.
-- assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031']);
++ assert.deepStrictEqual(second.applied.map((m) => m.id), ['0017', '0018', '0019', '0020', '0021', '0022', '0023', '0024', '0025', '0026', '0027', '0028', '0029', '0030', '0031', '0032']);
await check('an old-style INSERT still works after 0017, and a 900-character row is intact', async () => {
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex 7d50fa1..f5fdd53 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -108,6 +108,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'server/agents/providers.js': 'the configured provider base URLs (DeepSeek, OpenAI, the open model) with the operator\'s keys',
'server/agents/codes.js': 'the configured OpenVibe.Codes base (ACTOR_CODES_URL) with Actor\'s own service token; the subject must be usr_<ULID> and every task id tsk_<ULID> before it goes in a path; the repository named in the task is data for the person\'s own machine, never fetched here (test/codes.test.js)',
'server/agents/computers.js': 'the configured OpenVibe.Bot base (ACTOR_BOT_URL) with Actor\'s own service token (bot.computer.control); the machine id must be dev_<ULID> and the subject usr_<ULID> before anything is sent; a path or program is data for the person\'s own machine, never fetched here (test/computers.test.js)',
++ 'server/agents/timeline.js': 'the approval proxy answers the mode that owns an approval: the product base is the configured ACTOR_<PRODUCT>_URL (or the config map) and the approval id is URL-encoded; the person\'s own bearer token is forwarded, and a stop uses the same base with the step\'s own ref (codes cancel, run cancel, live end); no URL from a step\'s content (test/timeline.test.js)',
'server/chat/client.js': 'the configured OpenVibe.Chat base (ACTOR_CHAT_URL, loopback) with Actor\'s own service token; the room and the message it answers come from a signed chat.message.created, never from the task (test/chat-asks.test.js)',
'server/notify/client.js': 'the configured OpenVibe.Network internal base (OV_NETWORK_INTERNAL_URL, loopback) with Actor\'s own service tokens: identity resolve and one notification push; the subject is the task\'s owner and the link is the desk page Actor built, never a URL from the task (test/notify-client.test.js)',
'server/sharing.js': 'the configured Network internal base (OV_NETWORK_INTERNAL_URL) for the token endpoint, identity lookups and one notification push, and the configured Events URL (ACTOR_EVENTS_URL); share refs are the sharing system\'s own kind/id strings, never a URL from a person (test/sharing.test.js)',
ddiff --git a/test/timeline.test.js b/test/timeline.test.js
nnew file mode 100644
iindex 0000000..dc066c0
---- /dev/null
++++ b/test/timeline.test.js
@@@ -0,0 +1,138 @@
++'use strict';
++/**
++ * The one timeline behind the Dock (design 2026-11-07): the agent's steps in every mode, in one list, for one
++ * person. A step is a title, an outcome and a link: never a prompt or a diff. Another person's entries are the same
++ * 404 as one that does not exist, and an Only-me entry is in no shared list.
++ */
++const assert = require('assert');
++const { boot, check, done } = require('./helpers/boot');
++
++// A signed-in write must come from the site itself (the same rule every other write follows).
++const SAME = { 'sec-fetch-site': 'same-origin' };
++const timeline = require('../server/agents/timeline');
++// What OpenVibe.Watch's emitter publishes for a watch waiting on CI (test/fixtures/watch-agent-step.json,
++// kept in step with the emitter by OpenVibe.Watch's own test/agent-step.test.js).
++const WATCH_STEP = require('./fixtures/watch-agent-step.json');
++
++(async () => {
++ const t = await boot({});
++ try {
++ const ada = t.network.addUser('ada');
++ const bo = t.network.addUser('bo');
++ const adaSubject = 'user:' + ada.subject;
++ const boSubject = 'user:' + bo.subject;
++ const step = (over) => Object.assign({
++ step_id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPS', mode: 'code', product: 'codes', ref: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNPS',
++ title: 'Running the checkout tests', status: 'running', started_at: new Date().toISOString(),
++ placement: { where: 'On your desktop', why: 'it needs your files and your Claude sign-in, and it is free' },
++ link: 'https://codes.openvibe.network/tasks/tsk_01JAB2C3D4E5F6G7H8J9K0MNPS',
++ }, over || {});
++
++ await check('a step from a mode becomes an entry, with its title, its placement and its link', async () => {
++ await timeline.upsert(t.ctx.s, adaSubject, step());
++ const res = await t.get('/api/v1/agent/timeline', { as: ada });
++ assert.strictEqual(res.status, 200, res.text);
++ const body = res.json();
++ assert.strictEqual(body.entries.length, 1);
++ assert.strictEqual(body.now.title, 'Running the checkout tests');
++ assert.strictEqual(body.now.mode, 'code');
++ assert.strictEqual(body.now.placement.where, 'On your desktop');
++ assert.strictEqual(body.now.audience, 'private');
++ assert.strictEqual(body.counts.code, 1);
++ });
++
++ await check('the timeline never carries a prompt or a diff: the entry has no such field, and a step that brings one is still only its title', async () => {
++ await timeline.upsert(t.ctx.s, adaSubject, step({ step_id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNQ', title: 'Fixing the bug', prompt: 'the prompt the person typed', diff: '@@ -1 +1 @@' }));
++ const res = await t.get('/api/v1/agent/timeline', { as: ada });
++ const raw = res.text;
++ assert.ok(!raw.includes('the prompt the person typed'), 'no prompt');
++ assert.ok(!raw.includes('@@ -1 +1 @@'), 'no diff');
++ assert.ok(!/"(prompt|diff|content|message)"s*:/.test(raw), 'no such field at all');
++ assert.ok(raw.includes('Fixing the bug'), 'the title is kept');
++ });
++
++ await check('another person sees nothing of it: the list is empty and one entry is a 404', async () => {
++ const theirs = await t.get('/api/v1/agent/timeline', { as: bo });
++ assert.strictEqual(theirs.status, 200);
++ assert.strictEqual(theirs.json().entries.length, 0);
++ const mine = await t.get('/api/v1/agent/timeline', { as: ada });
++ const id = mine.json().entries[0].id;
++ const one = await t.get('/api/v1/agent/timeline/' + id, { as: ada });
++ assert.strictEqual(one.status, 200);
++ const other = await t.get('/api/v1/agent/timeline/' + id, { as: bo });
++ assert.strictEqual(other.status, 404, other.text);
++ });
++
++ await check('an Only-me entry is in no shared list', async () => {
++ const shared = await t.get('/api/v1/agent/timeline?scope=shared', { as: ada });
++ assert.strictEqual(shared.status, 200);
++ assert.strictEqual(shared.json().entries.length, 0, 'a private entry is never shared');
++ assert.strictEqual(shared.json().entries.filter((e) => e.audience === 'private').length, 0);
++ });
++
++ await check('needs you: an approval from a mode is listed, oldest first, and answering it goes to that mode', async () => {
++ await timeline.upsert(t.ctx.s, adaSubject, step({ step_id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', mode: 'code', product: 'codes', title: 'Run the deploy?', status: 'needs_you', approval: { product: 'codes', id: 'apr_01JAB2C3D4E5F6G7H8J9K0MNPS', kind: 'codes.task' } }));
++ const res = await t.get('/api/v1/agent/timeline?needs_you=1', { as: ada });
++ assert.strictEqual(res.status, 200);
++ assert.strictEqual(res.json().needs_you.length, 1);
++ assert.strictEqual(res.json().needs_you[0].approval.product, 'codes');
++ assert.strictEqual(res.json().counts.needs_you, 1);
++ });
++
++ await check('the kill switch stops what is open and pauses the agent until it is resumed', async () => {
++ const stop = await t.get('/api/v1/agent/kill-switch', { as: ada, method: 'POST', json: { action: 'stop' }, headers: SAME });
++ assert.strictEqual(stop.status, 200, stop.text);
++ assert.strictEqual(stop.json().paused, true);
++ assert.ok(stop.json().stopped.length >= 1, 'the open steps were stopped');
++ const after = await t.get('/api/v1/agent/timeline', { as: ada });
++ assert.ok(after.json().entries.every((e) => e.status !== 'running'), 'nothing is running any more');
++ const resume = await t.get('/api/v1/agent/kill-switch', { as: ada, method: 'POST', json: { action: 'resume' }, headers: SAME });
++ assert.strictEqual(resume.status, 200);
++ assert.strictEqual(resume.json().paused, false);
++ });
++
++ await check('the mode filter and the cursor page the list', async () => {
++ const all = await t.get('/api/v1/agent/timeline', { as: ada });
++ assert.ok(all.json().entries.length >= 2);
++ const code = await t.get('/api/v1/agent/timeline?mode=code', { as: ada });
++ assert.ok(code.json().entries.every((e) => e.mode === 'code'));
++ assert.ok(code.json().counts.run === undefined || code.json().counts.run === 0, 'the counts say what a filter hides');
++ });
++
++ await check('an Act step that waits on CI shows Watch with its deadline, then the transition sentence, then Act', async () => {
++ const t0 = Date.now() - 60000;
++ const at = (ms) => new Date(t0 + ms).toISOString();
++ const act = (id, title, started_at) => step({
++ step_id: id, mode: 'act', product: 'actor', ref: id, title, status: 'running',
++ started_at, link: 'https://actor.openvibe.network/tasks/' + id,
++ });
++ // 1. Act is working: it is merging #167 on the person's desktop.
++ await timeline.upsert(t.ctx.s, adaSubject, act('tsk_01JAB2C3D4E5F6G7H8J9K0MAC1', 'Merging #167 on your desktop', at(0)));
++ // 2. It waits on CI: the Watch step starts waiting, with its deadline in the agent's sentence.
++ await timeline.upsert(t.ctx.s, adaSubject, { ...WATCH_STEP, started_at: at(1000) });
++ // 3. The check sees the CI pass and stays one step; 4. the agent is back in Act, deploying.
++ await timeline.upsert(t.ctx.s, adaSubject, { ...WATCH_STEP, status: 'done', started_at: at(1000), ended_at: at(5000) });
++ await timeline.upsert(t.ctx.s, adaSubject, act('tsk_01JAB2C3D4E5F6G7H8J9K0MAC2', 'Deploying on your machine', at(6000)));
++
++ const res = await t.get('/api/v1/agent/timeline', { as: ada });
++ assert.strictEqual(res.status, 200, res.text);
++ const body = res.json();
++ const watches = body.entries.filter((e) => e.mode === 'watch' && e.title === WATCH_STEP.title);
++ assert.strictEqual(watches.length, 1, 'a watch that checks again is the same entry, not a second one');
++ assert.strictEqual(watches[0].status, 'done');
++ assert.match(watches[0].transition, /Waiting for CI on #167 \(Watch, 4 min\)/, 'the deadline is in the sentence');
++ assert.match(watches[0].transition, /then I'll merge it \(Act\)/, 'and what comes after the watch');
++ assert.strictEqual(watches[0].link, WATCH_STEP.link);
++ assert.strictEqual(body.now && body.now.title, 'Deploying on your machine', 'the agent is back in Act');
++ const order = body.entries.map((e) => e.title);
++ assert.ok(order.indexOf('Deploying on your machine') < order.indexOf(WATCH_STEP.title), 'newest first: Act after the watch');
++ assert.ok(order.indexOf(WATCH_STEP.title) < order.indexOf('Merging #167 on your desktop'), 'the watch sits between the two Act steps');
++ assert.strictEqual(body.counts.watch, 1);
++ assert.ok(!/"prompt"|"diff"/.test(res.text), 'no prompt or diff, only the sentence');
++ });
++
++ done();
++ } finally {
++ await t.close();
++ }
++})().catch((err) => { process.stdout.write('FAILED ' + String((err && err.stack) || err) + '\n'); process.exit(1); });