openvibers/openvibe.actor Commits
A6: the one timeline behind the Dock (store, subscription, API, stream, approvals proxy, kill switch) (#304)
Changes
@@@ -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.@@@ -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 -->@@@ -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"],nnew file mode 100644@@@ -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++);@@@ -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",@@@ -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"nnew file mode 100644@@@ -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 };@@@ -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());@@@ -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');@@@ -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.urconst 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 || ''}`);@@@ -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.');@@@ -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 }) {@@@ -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('.');@@@ -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@@@ -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 },});});@@@ -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);});nnew file mode 100644@@@ -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)."++}@@@ -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]);@@@ -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)',nnew file mode 100644@@@ -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); });
@@ -2,6 +2,12 @@
@@ -2,6 +2,12 @@
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
What changed in OpenVibe.Actor, newest first. Each site also publishes its patch notes at /updates.
+## 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
## 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.
- **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.
@@ -257,7 +257,7 @@ Reporting a vulnerability: [SECURITY.md](SECURITY.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).
Part of the [OpenVibe network](https://openvibe.network). Built in the open by [OpenVibers](https://github.com/OpenVibers).
<!-- versions:start -->
<!-- versions:start -->
-- openvibe-contracts: v0.312.0
+- openvibe-contracts: v0.316.0
- openvibe-sdk: v0.94.0
- openvibe-sdk: v0.94.0
- openvibe-shared: v4.126.0
- openvibe-shared: v4.126.0
<!-- versions:end -->
<!-- versions:end -->
@@ -7,11 +7,11 @@
@@ -7,11 +7,11 @@
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"runtime": "node 22, express 4, PostgreSQL 18 (openvibe-sdk/db, async) + Valkey 9",
"port": 4950,
"port": 4950,
"domain": "openvibe.actor",
"domain": "openvibe.actor",
- "contracts": "openvibe-contracts v0.312.0",
+ "contracts": "openvibe-contracts v0.316.0",
"sdk": "openvibe-sdk v0.94.0",
"sdk": "openvibe-sdk v0.94.0",
"shared": "openvibe-shared v4.126.0",
"shared": "openvibe-shared v4.126.0",
"packages": [
"packages": [
- "openvibe-contracts v0.312.0",
+ "openvibe-contracts v0.316.0",
"openvibe-sdk v0.94.0",
"openvibe-sdk v0.94.0",
"openvibe-shared v4.126.0"
"openvibe-shared v4.126.0"
],
],
new file mode 100644
new file mode 100644
@@ -0,0 +1,39 @@
@@ -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
+);
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -1146,9 +1146,9 @@
@@ -1146,9 +1146,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"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",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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-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",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.126.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,255 @@
@@ -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 };
@@ -45,6 +45,7 @@ function checkSelfHosted(config) {
@@ -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)`);
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 { createChatAsks } = require('./chat/asks');
+const timeline = require('./agents/timeline');
const { createTask } = require('./tasks/create');
const { createTask } = require('./tasks/create');
const { createWebhookPoster } = require('./net/webhook-post');
const { createWebhookPoster } = require('./net/webhook-post');
const { createTaskStream } = require('./tasks/stream');
const { createTaskStream } = require('./tasks/stream');
@@ -244,7 +245,10 @@ async function createApp(opts = {}) {
@@ -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.
// 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,
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).
// 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) ──────
// ── Static assets (content-hashed ?v= → immutable) ──────
app.use('/shared', require('openvibe-shared/serve').handler());
app.use('/shared', require('openvibe-shared/serve').handler());
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
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 MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -14,6 +14,9 @@
@@ -14,6 +14,9 @@
const { serviceAuth } = require('openvibe-contracts');
const { serviceAuth } = require('openvibe-contracts');
const { TOPICS } = require('./identity/account-data');
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). */
/** 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 = [] }) {
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;
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
@@ -29,8 +32,10 @@ function startSubscriptions({ config, port, secret, eventsUrl = config.events.ur
const listed = await call('GET', '/api/v1/subscriptions');
const listed = await call('GET', '/api/v1/subscriptions');
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
if (!listed.ok) throw new Error(`listing subscriptions: ${listed.status}`);
const mine = (listed.body.subscriptions || []).filter((s) => s.endpoint === endpoint);
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;
if (mine.some((s) => s.topic_pattern === topic)) continue;
const r = await call('POST', '/api/v1/subscriptions', { topic_pattern: topic, endpoint, secret });
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 || ''}`);
if (!r.ok && r.status !== 409) throw new Error(`subscribing to ${topic}: ${r.status} ${r.body.code || ''}`);
@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
@@ -30,6 +30,7 @@ const personal = require('../agents/personal');
const memoryStore = require('../memory');
const memoryStore = require('../memory');
const approvals = require('../agents/approvals');
const approvals = require('../agents/approvals');
const items = require('../actions/items');
const items = require('../actions/items');
+const timeline = require('../agents/timeline');
const modelPrefs = require('../runtime/model-prefs');
const modelPrefs = require('../runtime/model-prefs');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -110,6 +111,65 @@ function createApi(ctx) {
@@ -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.');
if (!req.principal.requester || !req.principal.requester.startsWith('user:')) return problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
return next();
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 loadAgent = async (req, res) => {
const row = await personal.get(s, req.principal.requester, req.params.id);
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.');
if (!row) problem(req, res, 404, 'actor.agent.not_found', 'No such agent.');
@@ -35,6 +35,8 @@ const BUDGETS = {
@@ -35,6 +35,8 @@ const BUDGETS = {
'actor.route': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { 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 }) {
function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {
@@ -30,7 +30,7 @@ const subject = require('./subject');
@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
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) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -63,6 +63,11 @@ const TABLES = [
@@ -63,6 +63,11 @@ const TABLES = [
// What the person's agents asked them to approve (agents/approvals.js), and what they decided.
// 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_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' },
{ 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.
// 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' },
{ 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
// The model they chose for their tasks (plan L55 C3): which provider their own key is for, and the model id
@@ -29,6 +29,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -29,6 +29,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.route': { minute: 30, hour: 600 },
'actor.route': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.task.stream': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
'actor.approval.decide': { minute: 30, hour: 600 },
+ 'actor.resource.control': { minute: 30, hour: 600 },
});
});
});
});
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
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'];
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
// 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
// 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([
@@ -23,6 +23,11 @@ const ROUTE_EXTENSIONS = new Map([
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/tasks/:id/trace', 'actor.task.read'],
['GET /api/v1/stats/tools', null],
['GET /api/v1/stats/tools', null],
['GET /api/v1/stats/models', 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');
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.
// 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() {
@@ -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);
for (const id of CAPS) assert.deepStrictEqual(contracts.capabilities.get(id).events, [], id);
});
});
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -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)."
+}
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
@@ -43,7 +43,7 @@ const owner = 'user:usr_01JZ0000000000000000000AAA';
// 0017 on top of the running schema.
// 0017 on top of the running schema.
const second = await db.migrate({ dir: MIGRATIONS });
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.
// 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 () => {
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]);
const old = await db.maybe('SELECT id, text, origin, status, kind, uses FROM actor_agent_memory WHERE id = $1', [agentMemoryId]);
@@ -108,6 +108,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -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/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/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/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/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/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)',
'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)',
new file mode 100644
new file mode 100644
@@ -0,0 +1,138 @@
@@ -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); });