openvibers/openvibe.actor Commits
The Action Center's read: GET /api/v1/actions?subject= (actor.actions.read) lists a person's tasks, pending approvals and scheduled agents as common.action-item@1, and POST /api/v1/approvals/:id/decide (actor.approval.decide) decides one for them; contracts v0.229.0 (#169)
Changes
@@@ -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.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0","pg": "^8.23.0"@@@ -871,9 +871,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.222.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",-- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",++ "version": "0.229.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",++ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==","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.222.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0","pg": "^8.23.0"nnew file mode 100644@@@ -0,0 +1,148 @@++'use strict';++++/**++ * Actor's Action Center items (plan 2026-10-28 §3.10, common.action-item@1): the person's tasks, the approvals++ * waiting on them, and their personal agents' next scheduled runs. The mapping from Actor's own states to the five++ * Action Center buckets lives in bucketFor(), so the table can be tested on its own.++ */++++const PUBLIC_ORIGIN = 'https://openvibe.actor';++const OPEN = ['queued', 'running', 'verifying'];++++// Actor's own word → the Action Center bucket. A task whose newest event is a "Needs you:" line is waiting, though++// its state is still running; the route passes needsYou for that.++const STATE_BUCKET = Object.freeze({++ queued: 'running',++ running: 'running',++ verifying: 'running',++ needs_you: 'waiting',++ pending: 'waiting',++ scheduled: 'scheduled',++ succeeded: 'done',++ approved: 'done',++ done: 'done',++ failed: 'failed',++ declined: 'failed',++ cancelled: 'failed',++ expired: 'failed',++});++++function bucketFor(state, { needsYou = false } = {}) {++ if (needsYou && OPEN.includes(state)) return 'waiting';++ return STATE_BUCKET[state] || 'failed';++}++++/** What the person may do from the list ([email protected]). */++function actionsFor(bucket, { approval = false, canCancel = false, canRetry = false } = {}) {++ if (bucket === 'waiting' && approval) return ['approve', 'decline', 'open'];++ if (bucket === 'running') return canCancel ? ['open', 'cancel'] : ['open'];++ if (bucket === 'failed') return canRetry ? ['open', 'retry'] : ['open'];++ return ['open'];++}++++const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' };++++/** Plain text (no HTML or Markdown), single line, at most `max` characters. */++function plain(value, max = 400) {++ let s = value == null ? '' : String(value);++ s = s.replace(/```[\s\S]*?```/g, ' ').replace(/<\/?[^>]+>/g, ' ')++ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')++ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')++ .replace(/&([a-zA-Z#0-9]+);/g, (m, k) => (Object.hasOwn(ENTITIES, k) ? ENTITIES[k] : m))++ .replace(/[`*_~]+/g, '').replace(/^[#>\s-]+/gm, '')++ .replace(/\s+/g, ' ').trim();++ if (s.length > max) s = `${s.slice(0, Math.max(0, max - 1)).trimEnd()}…`;++ return s;++}++++function parse(value) {++ if (value == null) return null;++ if (typeof value !== 'string') return value;++ try { return JSON.parse(value); } catch { return null; }++}++++/** The "Needs you:" hand-off in a task's newest event, if it is one (tasks/store.js needsYou). */++function needsYou(event) {++ if (!event || event.step !== 'text' || !/^Needs you: /.test(String(event.text || ''))) return null;++ const text = String(event.text).replace(/^Needs you: /, '');++ const url = (text.match(/https:\/\/\S+$/) || [])[0] || null;++ return { seq: Number(event.seq), what: text.replace(/\s*Open your agent's desk.*$/s, '').trim().slice(0, 300), url };++}++++function answerOf(result) {++ const r = parse(result);++ if (!r || typeof r !== 'object') return '';++ for (const key of ['answer', 'text', 'summary']) if (typeof r[key] === 'string') return r[key];++ return '';++}++++function taskItem(row, { lastEvent = null, steps = null } = {}) {++ const needs = needsYou(lastEvent);++ const state = needs ? 'needs_you' : row.state;++ const bucket = bucketFor(row.state, { needsYou: Boolean(needs) });++ const updated = row.finished_at || (lastEvent && lastEvent.at) || row.created_at;++ const last = lastEvent && (lastEvent.text || (lastEvent.tool ? `Using ${lastEvent.tool}` : ''));++ const summary = needs ? needs.what : (row.state === 'failed' ? row.error_detail : (answerOf(row.result) || last));++ return {++ service: 'actor',++ id: row.id,++ kind: 'agent_task',++ title: plain(row.task || row.id, 160) || row.id,++ summary: summary ? plain(summary, 400) : null,++ bucket,++ state,++ agent: row.agent ? plain(row.agent, 80) : (lastEvent && lastEvent.agent ? plain(lastEvent.agent, 80) : null),++ cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),++ steps: steps == null ? null : Number(steps),++ error: row.error_detail ? plain(row.error_detail, 400) : null,++ created_at: row.created_at,++ updated_at: updated,++ href: `${PUBLIC_ORIGIN}/tasks/${row.id}`,++ actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state) }),++ };++}++++function approvalItem(row) {++ const bucket = bucketFor(row.state);++ const payload = parse(row.payload) || {};++ return {++ service: 'actor',++ id: row.id,++ kind: 'approval',++ title: plain(row.summary || row.id, 160) || row.id,++ summary: payload.task ? plain(payload.task, 400) : null,++ bucket,++ state: row.state,++ agent: row.agent_id ? plain(row.agent_id, 80) : null,++ cost_usd: null,++ steps: null,++ error: row.state === 'expired' ? 'This approval expired before it was decided.' : null,++ created_at: row.created_at,++ updated_at: row.decided_at || row.created_at,++ href: `${PUBLIC_ORIGIN}/me/approvals/${row.id}`,++ actions: actionsFor(bucket, { approval: true }),++ };++}++++function scheduledItem(agent) {++ return {++ service: 'actor',++ id: agent.id,++ kind: 'agent_task',++ title: plain(agent.name || agent.id, 160) || agent.id,++ summary: agent.instructions ? plain(agent.instructions, 400) : null,++ bucket: 'scheduled',++ state: 'scheduled',++ agent: plain(agent.name || agent.id, 80),++ cost_usd: null,++ steps: null,++ error: null,++ created_at: agent.created_at,++ updated_at: agent.updated_at || agent.created_at,++ next_run_at: agent.next_run_at,++ href: `${PUBLIC_ORIGIN}/me/agents/${agent.id}`,++ actions: ['open'],++ };++}++++module.exports = { PUBLIC_ORIGIN, OPEN, bucketFor, actionsFor, plain, needsYou, taskItem, approvalItem, scheduledItem };@@@ -49,12 +49,17 @@ async function pendingCount(s, owner) {return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));}--/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent. */--async function decide(ctx, owner, id, decision) {++/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.++ * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON++ * Action Center route); the HTML page keeps its 409. */++async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {const { s, config, engine, webhooks } = ctx;const row = await get(s, owner, id);if (!row) return { error: 'No such request of yours.', status: 404 };-- if (row.state !== 'pending') return { error: `This request is already ${row.state}.`, status: 409 };++ if (row.state !== 'pending') {++ if (idempotent) return { row };++ return { error: `This request is already ${row.state}.`, status: 409 };++ }if (decision === 'decline') {const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);return { row: done };@@@ -24,8 +24,11 @@ const { heuristic } = require('../agents/classify');const { route, MODES } = require('../agents/router');const { createTask } = require('../tasks/create');const personal = require('../agents/personal');++const approvals = require('../agents/approvals');++const items = require('../actions/items');const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;function createApi(ctx) {const { config, s, engine, stream, principal, limits, webhooks } = ctx;@@@ -36,6 +39,48 @@ function createApi(ctx) {r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });r.use(principal.middleware);++ // An Action Center read is a first-party service acting for one person, named in the query. A person's own token++ // never reaches another person's items through it.++ const isService = (p) => Boolean(p && p.kind === 'app' && p.claims && String(p.claims.sub || '').startsWith('svc:') && p.claims.actor_type === 'service');++++ // ── The Action Center (plan 2026-10-28 §3.10) ────────────++ r.get('/actions', principal.requireCapability('actor.actions.read'), limits.reads('actor.actions.read'), async (req, res) => {++ if (!isService(req.principal)) return problem(req, res, 403, 'capability.denied', 'actor.actions.read is for first-party services');++ const subject = String(req.query.subject || '').trim();++ if (!USER_ID.test(subject)) return problem(req, res, 400, 'subject.invalid', 'subject must be a usr_… subject id');++ const requester = `user:${subject}`;++ const cutoff = new Date(s.now() - 30 * 86_400_000).toISOString();++ const rows = await s.db.many(`SELECT t.*, le.at AS last_at, le.body AS last_event,++ (SELECT count(*) FROM task_events e WHERE e.task_id = t.id AND e.body->>'step' = 'tool_call') AS tool_steps++ FROM tasks t++ LEFT JOIN LATERAL (SELECT body, body->>'at' AS at FROM task_events e WHERE e.task_id = t.id ORDER BY e.seq DESC LIMIT 1) le ON true++ WHERE t.requester = $1 AND (t.state = ANY($2) OR COALESCE(t.finished_at, t.created_at) >= $3)++ ORDER BY COALESCE(t.finished_at, le.at, t.created_at) DESC++ LIMIT 50`, [requester, store.OPEN, cutoff]);++ const tasks = rows.map((row) => items.taskItem(row, { lastEvent: store.parseJson(row.last_event), steps: Number(row.tool_steps) }));++ const asks = (await approvals.list(s, requester))++ .filter((a) => a.state === 'pending')++ .map(items.approvalItem);++ const scheduled = (await personal.list(s, requester))++ .filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)++ .map(items.scheduledItem);++ const all = [...tasks, ...asks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));++ res.json({ items: all.slice(0, 50) });++ });++++ // The same decision the person makes on /me/approvals, for the service acting from the Action Center. Deciding an++ // already-decided request answers its current state (idempotent), never a conflict.++ r.post('/approvals/:id/decide', principal.requireCapability('actor.approval.decide'), limits.budget('actor.approval.decide'), async (req, res) => {++ if (!isService(req.principal)) return problem(req, res, 403, 'capability.denied', 'actor.approval.decide is for first-party services');++ const subject = String(req.get('x-ov-subject') || '').trim();++ if (!USER_ID.test(subject)) return problem(req, res, 400, 'subject.invalid', 'X-OV-Subject must be the approval owner\'s usr_… subject id');++ 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 out = await approvals.decide(ctx, `user:${subject}`, req.params.id, decision, { idempotent: true });++ if (out.error) return problem(req, res, out.status, out.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', out.error);++ res.json(items.approvalItem(out.row));++ });++// ── The catalog and the dry run (public) ─────────────────r.get('/agents', limits.reads('actor.agent.read'), (req, res) => res.json({ agents: catalog.list(config) }));@@@ -32,6 +32,7 @@ const BUDGETS = {'actor.agent.manage': { minute: 30, hour: 600 },'actor.route': { minute: 30, hour: 600 },'actor.task.stream': { minute: 30, hour: 600 },++ 'actor.approval.decide': { minute: 30, hour: 600 },};function createCallerLimits({ config, now = () => Date.now(), registry = null, log = console, enabled = true, valkey = null }) {@@@ -29,7 +29,7 @@ const { sameSecret } = require('../auth/local');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'];++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'];function decodePayload(token) {const parts = String(token || '').split('.');nnew file mode 100644@@@ -0,0 +1,206 @@++'use strict';++/**++ * The Action Center reads on Actor (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with++ * actor.actions.read names one person and gets their tasks, approvals and next scheduled agent runs — never another++ * person's — mapped bucket by bucket, plain text, https links, at most 50, newest first. A person's token, a token++ * without the capability and a bad subject are refused. Actor's approval decide route (actor.approval.decide) makes++ * the same decision as /me/approvals, for its owner only, and answers the current state when decided twice.++ */++const assert = require('assert');++const crypto = require('crypto');++const { ids, serviceAuth, validate } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const items = require('../server/actions/items');++++const DAY = 86_400_000;++++(async () => {++ const t = await boot({ adapters: { checkers: { openai: async () => ({ text: '{"ok":true,"reason":"fine"}', cost_usd: 0 }) } } });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const token = (cap, sub = 'svc:network') => {++ const now = Math.floor(Date.now() / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.actor'], cap, iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);++ };++ const reader = token(['actor.actions.read']);++ const decider = token(['actor.approval.decide']);++ const db = t.ctx.s.db;++ const now = Date.now();++ const iso = (ms) => new Date(ms).toISOString();++++ async function addTask({ requester = `user:${ada.subject}`, id = ids.newId('task'), state = 'running', text = 'Do the thing', created = now, finished = null, cost = null, error = null, result = null, agent = null, events = [] }) {++ await db.query(`INSERT INTO tasks (id, requester, task, mode, agent, budget_task, budget_day, state, cost_usd, error_detail, result, created_at, finished_at)++ VALUES ($1, $2, $3, 'balanced', $4, 0.001, 1, $5, $6, $7, $8::jsonb, $9, $10)`,++ [id, requester, text, agent, state, cost, error, result == null ? null : JSON.stringify(result), iso(created), finished ? iso(finished) : null]);++ let seq = 0;++ for (const e of events) {++ seq++;++ await db.query('INSERT INTO task_events (task_id, seq, body) VALUES ($1, $2, $3::jsonb)', [id, seq, JSON.stringify({ task_id: id, seq, kind: 'output', at: iso(created), ...e })]);++ }++ return id;++ }++++ await check('the state → bucket table and the actions each bucket allows', async () => {++ const table = [++ ['queued', 'running', {}], ['running', 'running', {}], ['verifying', 'running', {}],++ ['running', 'waiting', { needsYou: true }], ['pending', 'waiting', {}],++ ['scheduled', 'scheduled', {}], ['succeeded', 'done', {}], ['approved', 'done', {}],++ ['failed', 'failed', {}], ['declined', 'failed', {}], ['cancelled', 'failed', {}], ['expired', 'failed', {}],++ ];++ for (const [state, bucket, opts] of table) assert.strictEqual(items.bucketFor(state, opts), bucket, state);++ assert.deepStrictEqual(items.actionsFor('running', { canCancel: true }), ['open', 'cancel']);++ assert.deepStrictEqual(items.actionsFor('running'), ['open']);++ assert.deepStrictEqual(items.actionsFor('waiting', { approval: true }), ['approve', 'decline', 'open']);++ assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);++ assert.deepStrictEqual(items.actionsFor('failed', { canRetry: true }), ['open', 'retry']);++ assert.deepStrictEqual(items.actionsFor('failed'), ['open']);++ assert.deepStrictEqual(items.actionsFor('done'), ['open']);++ assert.strictEqual(items.plain('<b>Fix</b> **the** [bug](https://x) & ship\tnow', 160), 'Fix the bug & ship now');++ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);++ assert.ok(items.plain('y'.repeat(500), 160).length <= 160);++ });++++ const runningOld = ids.newId('task');++ const needs = ids.newId('task');++ const succeeded = ids.newId('task');++ const failed = ids.newId('task');++ const cancelled = ids.newId('task');++ const old = ids.newId('task');++ const theirs = ids.newId('task');++ const approval = `apr_${ids.ulid()}`;++ const declined = `apr_${ids.ulid()}`;++ const oldApproval = `apr_${ids.ulid()}`;++ const theirsApproval = `apr_${ids.ulid()}`;++ const agent = ids.newId('agent');++ const paused = ids.newId('agent');++ const off = ids.newId('agent');++++ await check('only that person\'s items, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {++ await addTask({ id: runningOld, state: 'running', created: now - 40 * DAY, text: '<b>Still</b> working' });++ await addTask({ id: needs, state: 'running', text: 'Sign in for me', created: now - 3 * 3600_000, events: [{ at: iso(now - 2 * 3600_000), step: 'text', text: 'Needs you: sign in to GitHub' }] });++ await addTask({ id: succeeded, state: 'succeeded', text: '# Fixed **the** [bug](https://example.com)', created: now - 2 * DAY, finished: now - DAY, cost: 0.12, result: { answer: '<p>All **done** & shipped.</p>' }, events: [{ step: 'tool_call', tool: 'read', text: 'read' }, { step: 'tool_call', tool: 'write', text: 'write' }] });++ await addTask({ id: failed, state: 'failed', text: 'Broken task', created: now - 4 * DAY, finished: now - 3 * DAY, error: '<script>it broke</script>' });++ await addTask({ id: cancelled, state: 'cancelled', text: 'Stopped task', created: now - 5 * DAY, finished: now - 4 * DAY });++ await addTask({ id: old, state: 'succeeded', text: 'Ancient', created: now - 45 * DAY, finished: now - 40 * DAY });++ await addTask({ id: theirs, requester: `user:${bo.subject}`, state: 'running', text: 'Bo\'s task' });++++ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)++ VALUES ($1,$2,$3,'codes.task','Approve the fix', $4::jsonb,'pending',$5,$6,NULL)`, [approval, `user:${ada.subject}`, agent, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);++ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)++ VALUES ($1,$2,$3,'codes.task','Already declined', '{}'::jsonb,'declined',$4,$5,$4)`, [declined, `user:${ada.subject}`, agent, iso(now - 2 * DAY), iso(now + 5 * DAY)]);++ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)++ VALUES ($1,$2,$3,'codes.task','Old approval', '{}'::jsonb,'approved',$4,$5,$4)`, [oldApproval, `user:${ada.subject}`, agent, iso(now - 40 * DAY), iso(now - 33 * DAY)]);++ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)++ VALUES ($1,$2,$3,'codes.task','Bo approval', '{}'::jsonb,'pending',$4,$5,NULL)`, [theirsApproval, `user:${bo.subject}`, agent, iso(now - 3600_000), iso(now + 6 * DAY)]);++++ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)++ VALUES ($1,$2,'Morning report','Summarize my day','balanced',1,1,'daily','07:00',$3,'active',$4,$4)`, [agent, `user:${ada.subject}`, iso(now + 5 * 3600_000), iso(now - 6 * DAY)]);++ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)++ VALUES ($1,$2,'Paused one','Never runs','balanced',1,1,'daily','08:00',$3,'paused',$4,$4)`, [paused, `user:${ada.subject}`, iso(now + 6 * 3600_000), iso(now - 6 * DAY)]);++ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)++ VALUES ($1,$2,'Off one','Never runs','balanced',1,1,'off',NULL,NULL,'active',$3,$3)`, [off, `user:${ada.subject}`, iso(now - 6 * DAY)]);++++ const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });++ assert.strictEqual(res.status, 200, res.text);++ const body = res.json();++ const v = validate('common.action-list-result@1', body);++ assert.ok(v.valid, JSON.stringify(v.errors));++ const byId = new Map(body.items.map((i) => [i.id, i]));++ assert.ok(byId.has(runningOld) && byId.get(runningOld).bucket === 'running', 'an old running task is still running');++ assert.deepStrictEqual([byId.get(needs).state, byId.get(needs).bucket, byId.get(needs).actions], ['needs_you', 'waiting', ['open']]);++ assert.deepStrictEqual([byId.get(succeeded).bucket, byId.get(succeeded).cost_usd, byId.get(succeeded).steps, byId.get(succeeded).actions], ['done', 0.12, 2, ['open']]);++ assert.strictEqual(byId.get(succeeded).title, 'Fixed the bug');++ assert.strictEqual(byId.get(succeeded).summary, 'All done & shipped.');++ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);++ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);++ assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');++ assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);++ assert.strictEqual(byId.get(approval).summary, 'Fix the bug in the repository');++ assert.ok(!byId.has(declined), 'a decided approval is no longer an action waiting on the person');++ assert.deepStrictEqual([byId.get(agent).bucket, byId.get(agent).state, byId.get(agent).next_run_at], ['scheduled', 'scheduled', iso(now + 5 * 3600_000)]);++ assert.ok(!byId.has(paused) && !byId.has(off), 'paused and off agents are not scheduled');++ assert.ok(!byId.has(old) && !byId.has(oldApproval), 'done and failed tasks older than 30 days are gone');++ assert.ok(!byId.has(theirs) && !byId.has(theirsApproval), 'never another person\'s items');++ for (const it of body.items) {++ assert.match(it.href, /^https:\/\//);++ assert.ok(it.title.length <= 160 && (!it.summary || it.summary.length <= 400) && (!it.error || it.error.length <= 400));++ assert.ok(!/[<>*_`]/.test(it.title) && !/[<>*_`]/.test(it.summary || '') && !/[<>*_`]/.test(it.error || ''));++ }++ const times = body.items.map((i) => i.updated_at);++ assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest updated_at first');++ assert.strictEqual(byId.get(runningOld).href, `https://openvibe.actor/tasks/${runningOld}`);++ assert.strictEqual(byId.get(approval).href, `https://openvibe.actor/me/approvals/${approval}`);++ assert.strictEqual(byId.get(agent).href, `https://openvibe.actor/me/agents/${agent}`);++ });++++ await check('at most 50 items, newest first', async () => {++ const mara = t.network.addUser('mara');++ const first = ids.newId('task');++ for (let i = 0; i < 55; i++) {++ await addTask({ id: i === 0 ? first : ids.newId('task'), requester: `user:${mara.subject}`, state: 'running', text: `Task ${i}`, created: now - i * 60_000 });++ }++ const res = await t.get(`/api/v1/actions?subject=${mara.subject}`, { bearer: reader });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().items.length, 50);++ assert.strictEqual(res.json().items[0].id, first, 'the newest of the 55');++ });++++ await check('a person\'s token, a service without the capability and a bad subject are refused', async () => {++ const user = await t.get(`/api/v1/actions?subject=${ada.subject}`, { as: ada });++ assert.strictEqual(user.status, 403);++ const userBearer = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: t.network.userToken(ada) });++ assert.strictEqual(userBearer.status, 403);++ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: token(['actor.task.list']) });++ assert.strictEqual(noCap.status, 403);++ const none = await t.get(`/api/v1/actions?subject=${ada.subject}`);++ assert.strictEqual(none.status, 401);++ const bad = await t.get('/api/v1/actions?subject=usr_nope', { bearer: reader });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.json().code, 'subject.invalid');++ const missing = await t.get('/api/v1/actions', { bearer: reader });++ assert.strictEqual(missing.status, 400);++ });++++ await check('decide approves and declines, answers current state when decided twice, and another subject is 404', async () => {++ const decide = (id, decision, subject = ada.subject) => t.get(`/api/v1/approvals/${id}/decide`, {++ method: 'POST', bearer: decider, json: { decision }, headers: { 'X-OV-Subject': subject },++ });++ const noHeader = await t.get(`/api/v1/approvals/${approval}/decide`, { method: 'POST', bearer: decider, json: { decision: 'approve' } });++ assert.strictEqual(noHeader.status, 400);++ const badDecision = await decide(approval, 'maybe');++ assert.strictEqual(badDecision.status, 422);++ const wrongOwner = await decide(approval, 'approve', bo.subject);++ assert.strictEqual(wrongOwner.status, 404);++++ const approved = await decide(approval, 'approve');++ assert.strictEqual(approved.status, 200, approved.text);++ assert.strictEqual(approved.json().state, 'approved');++ assert.strictEqual(approved.json().bucket, 'done');++ assert.deepStrictEqual(approved.json().actions, ['open']);++ assert.ok(validate('common.action-item@1', approved.json()).valid);++ const row = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE id = $1', [approval]);++ assert.strictEqual(row.state, 'approved');++ assert.match(row.result_task_id, /^tsk_/);++ const tasksBefore = Number(await t.ctx.s.db.value('SELECT count(*) FROM tasks WHERE requester = $1', [`user:${ada.subject}`]));++ const again = await decide(approval, 'decline');++ assert.strictEqual(again.status, 200);++ assert.strictEqual(again.json().state, 'approved', 'a second decision answers the current state');++ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM tasks WHERE requester = $1', [`user:${ada.subject}`])), tasksBefore, 'and starts nothing');++++ const decidedDecline = await decide(declined, 'decline');++ assert.strictEqual(decidedDecline.status, 200);++ assert.strictEqual(decidedDecline.json().state, 'declined');++ assert.strictEqual(decidedDecline.json().bucket, 'failed');++ assert.strictEqual((await decide(declined, 'approve')).json().state, 'declined');++++ const userToken = await t.get(`/api/v1/approvals/${declined}/decide`, { as: ada, method: 'POST', json: { decision: 'approve' }, headers: { 'sec-fetch-site': 'same-origin' } });++ assert.strictEqual(userToken.status, 403);++ const noCap = await t.get(`/api/v1/approvals/${declined}/decide`, { method: 'POST', bearer: reader, json: { decision: 'approve' }, headers: { 'X-OV-Subject': ada.subject } });++ assert.strictEqual(noCap.status, 403);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -4,7 +4,7 @@* gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while* another caller still passes; the window reopens on the clock. A person counts as themselves, anyone* else by address. Actor's own budgets are pinned: actor.task.create and actor.agent.run 6 a minute, actor.agent.manage and actor.route 30,-- * actor.task.stream 30; reads take ACTOR_LIMITS_MINUTE/ACTOR_LIMITS_HOUR. Health, ready, release.json++ * actor.task.stream 30; an approval decision 30; reads take ACTOR_LIMITS_MINUTE/ACTOR_LIMITS_HOUR. Health, ready, release.json* and metrics are never limited; refusals are logged (no token) and counted in actor_rate_limited_total.*/const assert = require('assert');@@@ -27,6 +27,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };'actor.agent.manage': { minute: 30, hour: 600 },'actor.route': { minute: 30, hour: 600 },'actor.task.stream': { minute: 30, hour: 600 },++ 'actor.approval.decide': { 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'];++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'];const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];const API_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'http', 'api.js'), 'utf8');const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
@@ -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.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
@@ -871,9 +871,9 @@
@@ -871,9 +871,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.222.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.222.0",
- "integrity": "sha512-wdUQCilq9glRMwfxEswbIj3l0QQqKDXZAi2+CR5Mlh34rZjI1Q+7GlAoR0UtNwZAgtLwXhPxFyJ/aPk8lcDG1Q==",
+ "version": "0.229.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
+ "integrity": "sha512-BYazi/dna8bgdM6+Go8t+D7lTyI9UaWYnJIx0hYEdD0v2ps87iTPONkjnCBadk5RUrBDtvcNoSONS3R+bwFG0w==",
"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.222.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.229.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.67.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.30.0",
"pg": "^8.23.0"
"pg": "^8.23.0"
new file mode 100644
new file mode 100644
@@ -0,0 +1,148 @@
@@ -0,0 +1,148 @@
+'use strict';
+
+/**
+ * Actor's Action Center items (plan 2026-10-28 §3.10, common.action-item@1): the person's tasks, the approvals
+ * waiting on them, and their personal agents' next scheduled runs. The mapping from Actor's own states to the five
+ * Action Center buckets lives in bucketFor(), so the table can be tested on its own.
+ */
+
+const PUBLIC_ORIGIN = 'https://openvibe.actor';
+const OPEN = ['queued', 'running', 'verifying'];
+
+// Actor's own word → the Action Center bucket. A task whose newest event is a "Needs you:" line is waiting, though
+// its state is still running; the route passes needsYou for that.
+const STATE_BUCKET = Object.freeze({
+ queued: 'running',
+ running: 'running',
+ verifying: 'running',
+ needs_you: 'waiting',
+ pending: 'waiting',
+ scheduled: 'scheduled',
+ succeeded: 'done',
+ approved: 'done',
+ done: 'done',
+ failed: 'failed',
+ declined: 'failed',
+ cancelled: 'failed',
+ expired: 'failed',
+});
+
+function bucketFor(state, { needsYou = false } = {}) {
+ if (needsYou && OPEN.includes(state)) return 'waiting';
+ return STATE_BUCKET[state] || 'failed';
+}
+
+/** What the person may do from the list ([email protected]). */
+function actionsFor(bucket, { approval = false, canCancel = false, canRetry = false } = {}) {
+ if (bucket === 'waiting' && approval) return ['approve', 'decline', 'open'];
+ if (bucket === 'running') return canCancel ? ['open', 'cancel'] : ['open'];
+ if (bucket === 'failed') return canRetry ? ['open', 'retry'] : ['open'];
+ return ['open'];
+}
+
+const ENTITIES = { amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' };
+
+/** Plain text (no HTML or Markdown), single line, at most `max` characters. */
+function plain(value, max = 400) {
+ let s = value == null ? '' : String(value);
+ s = s.replace(/```[\s\S]*?```/g, ' ').replace(/<\/?[^>]+>/g, ' ')
+ .replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
+ .replace(/&([a-zA-Z#0-9]+);/g, (m, k) => (Object.hasOwn(ENTITIES, k) ? ENTITIES[k] : m))
+ .replace(/[`*_~]+/g, '').replace(/^[#>\s-]+/gm, '')
+ .replace(/\s+/g, ' ').trim();
+ if (s.length > max) s = `${s.slice(0, Math.max(0, max - 1)).trimEnd()}…`;
+ return s;
+}
+
+function parse(value) {
+ if (value == null) return null;
+ if (typeof value !== 'string') return value;
+ try { return JSON.parse(value); } catch { return null; }
+}
+
+/** The "Needs you:" hand-off in a task's newest event, if it is one (tasks/store.js needsYou). */
+function needsYou(event) {
+ if (!event || event.step !== 'text' || !/^Needs you: /.test(String(event.text || ''))) return null;
+ const text = String(event.text).replace(/^Needs you: /, '');
+ const url = (text.match(/https:\/\/\S+$/) || [])[0] || null;
+ return { seq: Number(event.seq), what: text.replace(/\s*Open your agent's desk.*$/s, '').trim().slice(0, 300), url };
+}
+
+function answerOf(result) {
+ const r = parse(result);
+ if (!r || typeof r !== 'object') return '';
+ for (const key of ['answer', 'text', 'summary']) if (typeof r[key] === 'string') return r[key];
+ return '';
+}
+
+function taskItem(row, { lastEvent = null, steps = null } = {}) {
+ const needs = needsYou(lastEvent);
+ const state = needs ? 'needs_you' : row.state;
+ const bucket = bucketFor(row.state, { needsYou: Boolean(needs) });
+ const updated = row.finished_at || (lastEvent && lastEvent.at) || row.created_at;
+ const last = lastEvent && (lastEvent.text || (lastEvent.tool ? `Using ${lastEvent.tool}` : ''));
+ const summary = needs ? needs.what : (row.state === 'failed' ? row.error_detail : (answerOf(row.result) || last));
+ return {
+ service: 'actor',
+ id: row.id,
+ kind: 'agent_task',
+ title: plain(row.task || row.id, 160) || row.id,
+ summary: summary ? plain(summary, 400) : null,
+ bucket,
+ state,
+ agent: row.agent ? plain(row.agent, 80) : (lastEvent && lastEvent.agent ? plain(lastEvent.agent, 80) : null),
+ cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),
+ steps: steps == null ? null : Number(steps),
+ error: row.error_detail ? plain(row.error_detail, 400) : null,
+ created_at: row.created_at,
+ updated_at: updated,
+ href: `${PUBLIC_ORIGIN}/tasks/${row.id}`,
+ actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state) }),
+ };
+}
+
+function approvalItem(row) {
+ const bucket = bucketFor(row.state);
+ const payload = parse(row.payload) || {};
+ return {
+ service: 'actor',
+ id: row.id,
+ kind: 'approval',
+ title: plain(row.summary || row.id, 160) || row.id,
+ summary: payload.task ? plain(payload.task, 400) : null,
+ bucket,
+ state: row.state,
+ agent: row.agent_id ? plain(row.agent_id, 80) : null,
+ cost_usd: null,
+ steps: null,
+ error: row.state === 'expired' ? 'This approval expired before it was decided.' : null,
+ created_at: row.created_at,
+ updated_at: row.decided_at || row.created_at,
+ href: `${PUBLIC_ORIGIN}/me/approvals/${row.id}`,
+ actions: actionsFor(bucket, { approval: true }),
+ };
+}
+
+function scheduledItem(agent) {
+ return {
+ service: 'actor',
+ id: agent.id,
+ kind: 'agent_task',
+ title: plain(agent.name || agent.id, 160) || agent.id,
+ summary: agent.instructions ? plain(agent.instructions, 400) : null,
+ bucket: 'scheduled',
+ state: 'scheduled',
+ agent: plain(agent.name || agent.id, 80),
+ cost_usd: null,
+ steps: null,
+ error: null,
+ created_at: agent.created_at,
+ updated_at: agent.updated_at || agent.created_at,
+ next_run_at: agent.next_run_at,
+ href: `${PUBLIC_ORIGIN}/me/agents/${agent.id}`,
+ actions: ['open'],
+ };
+}
+
+module.exports = { PUBLIC_ORIGIN, OPEN, bucketFor, actionsFor, plain, needsYou, taskItem, approvalItem, scheduledItem };
@@ -49,12 +49,17 @@ async function pendingCount(s, owner) {
@@ -49,12 +49,17 @@ async function pendingCount(s, owner) {
return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));
return Number(await s.db.value("SELECT count(*) FROM actor_approvals WHERE owner = $1 AND state = 'pending'", [owner]));
}
}
-/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent. */
-async function decide(ctx, owner, id, decision) {
+/** The person decides. Approving a codes.task starts it as their own task, routed to the codes agent.
+ * With `idempotent`, a request already decided answers its current state instead of a conflict (the JSON
+ * Action Center route); the HTML page keeps its 409. */
+async function decide(ctx, owner, id, decision, { idempotent = false } = {}) {
const { s, config, engine, webhooks } = ctx;
const { s, config, engine, webhooks } = ctx;
const row = await get(s, owner, id);
const row = await get(s, owner, id);
if (!row) return { error: 'No such request of yours.', status: 404 };
if (!row) return { error: 'No such request of yours.', status: 404 };
- if (row.state !== 'pending') return { error: `This request is already ${row.state}.`, status: 409 };
+ if (row.state !== 'pending') {
+ if (idempotent) return { row };
+ return { error: `This request is already ${row.state}.`, status: 409 };
+ }
if (decision === 'decline') {
if (decision === 'decline') {
const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);
const done = await s.db.one("UPDATE actor_approvals SET state = 'declined', decided_at = $2 WHERE id = $1 AND state = 'pending' RETURNING *", [id, s.iso()]);
return { row: done };
return { row: done };
@@ -24,8 +24,11 @@ const { heuristic } = require('../agents/classify');
@@ -24,8 +24,11 @@ const { heuristic } = require('../agents/classify');
const { route, MODES } = require('../agents/router');
const { route, MODES } = require('../agents/router');
const { createTask } = require('../tasks/create');
const { createTask } = require('../tasks/create');
const personal = require('../agents/personal');
const personal = require('../agents/personal');
+const approvals = require('../agents/approvals');
+const items = require('../actions/items');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const USER_ID = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
function createApi(ctx) {
function createApi(ctx) {
const { config, s, engine, stream, principal, limits, webhooks } = ctx;
const { config, s, engine, stream, principal, limits, webhooks } = ctx;
@@ -36,6 +39,48 @@ function createApi(ctx) {
@@ -36,6 +39,48 @@ function createApi(ctx) {
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use(principal.middleware);
r.use(principal.middleware);
+ // An Action Center read is a first-party service acting for one person, named in the query. A person's own token
+ // never reaches another person's items through it.
+ const isService = (p) => Boolean(p && p.kind === 'app' && p.claims && String(p.claims.sub || '').startsWith('svc:') && p.claims.actor_type === 'service');
+
+ // ── The Action Center (plan 2026-10-28 §3.10) ────────────
+ r.get('/actions', principal.requireCapability('actor.actions.read'), limits.reads('actor.actions.read'), async (req, res) => {
+ if (!isService(req.principal)) return problem(req, res, 403, 'capability.denied', 'actor.actions.read is for first-party services');
+ const subject = String(req.query.subject || '').trim();
+ if (!USER_ID.test(subject)) return problem(req, res, 400, 'subject.invalid', 'subject must be a usr_… subject id');
+ const requester = `user:${subject}`;
+ const cutoff = new Date(s.now() - 30 * 86_400_000).toISOString();
+ const rows = await s.db.many(`SELECT t.*, le.at AS last_at, le.body AS last_event,
+ (SELECT count(*) FROM task_events e WHERE e.task_id = t.id AND e.body->>'step' = 'tool_call') AS tool_steps
+ FROM tasks t
+ LEFT JOIN LATERAL (SELECT body, body->>'at' AS at FROM task_events e WHERE e.task_id = t.id ORDER BY e.seq DESC LIMIT 1) le ON true
+ WHERE t.requester = $1 AND (t.state = ANY($2) OR COALESCE(t.finished_at, t.created_at) >= $3)
+ ORDER BY COALESCE(t.finished_at, le.at, t.created_at) DESC
+ LIMIT 50`, [requester, store.OPEN, cutoff]);
+ const tasks = rows.map((row) => items.taskItem(row, { lastEvent: store.parseJson(row.last_event), steps: Number(row.tool_steps) }));
+ const asks = (await approvals.list(s, requester))
+ .filter((a) => a.state === 'pending')
+ .map(items.approvalItem);
+ const scheduled = (await personal.list(s, requester))
+ .filter((a) => a.status === 'active' && a.schedule !== 'off' && a.next_run_at)
+ .map(items.scheduledItem);
+ const all = [...tasks, ...asks, ...scheduled].sort((a, b) => (a.updated_at < b.updated_at ? 1 : a.updated_at > b.updated_at ? -1 : a.id < b.id ? 1 : -1));
+ res.json({ items: all.slice(0, 50) });
+ });
+
+ // The same decision the person makes on /me/approvals, for the service acting from the Action Center. Deciding an
+ // already-decided request answers its current state (idempotent), never a conflict.
+ r.post('/approvals/:id/decide', principal.requireCapability('actor.approval.decide'), limits.budget('actor.approval.decide'), async (req, res) => {
+ if (!isService(req.principal)) return problem(req, res, 403, 'capability.denied', 'actor.approval.decide is for first-party services');
+ const subject = String(req.get('x-ov-subject') || '').trim();
+ if (!USER_ID.test(subject)) return problem(req, res, 400, 'subject.invalid', 'X-OV-Subject must be the approval owner\'s usr_… subject id');
+ 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 out = await approvals.decide(ctx, `user:${subject}`, req.params.id, decision, { idempotent: true });
+ if (out.error) return problem(req, res, out.status, out.status === 404 ? 'actor.approval.not_found' : 'actor.approval.invalid', out.error);
+ res.json(items.approvalItem(out.row));
+ });
+
// ── The catalog and the dry run (public) ─────────────────
// ── The catalog and the dry run (public) ─────────────────
r.get('/agents', limits.reads('actor.agent.read'), (req, res) => res.json({ agents: catalog.list(config) }));
r.get('/agents', limits.reads('actor.agent.read'), (req, res) => res.json({ agents: catalog.list(config) }));
@@ -32,6 +32,7 @@ const BUDGETS = {
@@ -32,6 +32,7 @@ const BUDGETS = {
'actor.agent.manage': { minute: 30, hour: 600 },
'actor.agent.manage': { minute: 30, hour: 600 },
'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 },
};
};
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 }) {
@@ -29,7 +29,7 @@ const { sameSecret } = require('../auth/local');
@@ -29,7 +29,7 @@ const { sameSecret } = require('../auth/local');
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'];
+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'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
new file mode 100644
new file mode 100644
@@ -0,0 +1,206 @@
@@ -0,0 +1,206 @@
+'use strict';
+/**
+ * The Action Center reads on Actor (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with
+ * actor.actions.read names one person and gets their tasks, approvals and next scheduled agent runs — never another
+ * person's — mapped bucket by bucket, plain text, https links, at most 50, newest first. A person's token, a token
+ * without the capability and a bad subject are refused. Actor's approval decide route (actor.approval.decide) makes
+ * the same decision as /me/approvals, for its owner only, and answers the current state when decided twice.
+ */
+const assert = require('assert');
+const crypto = require('crypto');
+const { ids, serviceAuth, validate } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const items = require('../server/actions/items');
+
+const DAY = 86_400_000;
+
+(async () => {
+ const t = await boot({ adapters: { checkers: { openai: async () => ({ text: '{"ok":true,"reason":"fine"}', cost_usd: 0 }) } } });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const token = (cap, sub = 'svc:network') => {
+ const now = Math.floor(Date.now() / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.actor'], cap, iat: now, exp: now + 300, jti: crypto.randomUUID() }, t.network.privatePem);
+ };
+ const reader = token(['actor.actions.read']);
+ const decider = token(['actor.approval.decide']);
+ const db = t.ctx.s.db;
+ const now = Date.now();
+ const iso = (ms) => new Date(ms).toISOString();
+
+ async function addTask({ requester = `user:${ada.subject}`, id = ids.newId('task'), state = 'running', text = 'Do the thing', created = now, finished = null, cost = null, error = null, result = null, agent = null, events = [] }) {
+ await db.query(`INSERT INTO tasks (id, requester, task, mode, agent, budget_task, budget_day, state, cost_usd, error_detail, result, created_at, finished_at)
+ VALUES ($1, $2, $3, 'balanced', $4, 0.001, 1, $5, $6, $7, $8::jsonb, $9, $10)`,
+ [id, requester, text, agent, state, cost, error, result == null ? null : JSON.stringify(result), iso(created), finished ? iso(finished) : null]);
+ let seq = 0;
+ for (const e of events) {
+ seq++;
+ await db.query('INSERT INTO task_events (task_id, seq, body) VALUES ($1, $2, $3::jsonb)', [id, seq, JSON.stringify({ task_id: id, seq, kind: 'output', at: iso(created), ...e })]);
+ }
+ return id;
+ }
+
+ await check('the state → bucket table and the actions each bucket allows', async () => {
+ const table = [
+ ['queued', 'running', {}], ['running', 'running', {}], ['verifying', 'running', {}],
+ ['running', 'waiting', { needsYou: true }], ['pending', 'waiting', {}],
+ ['scheduled', 'scheduled', {}], ['succeeded', 'done', {}], ['approved', 'done', {}],
+ ['failed', 'failed', {}], ['declined', 'failed', {}], ['cancelled', 'failed', {}], ['expired', 'failed', {}],
+ ];
+ for (const [state, bucket, opts] of table) assert.strictEqual(items.bucketFor(state, opts), bucket, state);
+ assert.deepStrictEqual(items.actionsFor('running', { canCancel: true }), ['open', 'cancel']);
+ assert.deepStrictEqual(items.actionsFor('running'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('waiting', { approval: true }), ['approve', 'decline', 'open']);
+ assert.deepStrictEqual(items.actionsFor('waiting'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('failed', { canRetry: true }), ['open', 'retry']);
+ assert.deepStrictEqual(items.actionsFor('failed'), ['open']);
+ assert.deepStrictEqual(items.actionsFor('done'), ['open']);
+ assert.strictEqual(items.plain('<b>Fix</b> **the** [bug](https://x) & ship\tnow', 160), 'Fix the bug & ship now');
+ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);
+ assert.ok(items.plain('y'.repeat(500), 160).length <= 160);
+ });
+
+ const runningOld = ids.newId('task');
+ const needs = ids.newId('task');
+ const succeeded = ids.newId('task');
+ const failed = ids.newId('task');
+ const cancelled = ids.newId('task');
+ const old = ids.newId('task');
+ const theirs = ids.newId('task');
+ const approval = `apr_${ids.ulid()}`;
+ const declined = `apr_${ids.ulid()}`;
+ const oldApproval = `apr_${ids.ulid()}`;
+ const theirsApproval = `apr_${ids.ulid()}`;
+ const agent = ids.newId('agent');
+ const paused = ids.newId('agent');
+ const off = ids.newId('agent');
+
+ await check('only that person\'s items, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {
+ await addTask({ id: runningOld, state: 'running', created: now - 40 * DAY, text: '<b>Still</b> working' });
+ await addTask({ id: needs, state: 'running', text: 'Sign in for me', created: now - 3 * 3600_000, events: [{ at: iso(now - 2 * 3600_000), step: 'text', text: 'Needs you: sign in to GitHub' }] });
+ await addTask({ id: succeeded, state: 'succeeded', text: '# Fixed **the** [bug](https://example.com)', created: now - 2 * DAY, finished: now - DAY, cost: 0.12, result: { answer: '<p>All **done** & shipped.</p>' }, events: [{ step: 'tool_call', tool: 'read', text: 'read' }, { step: 'tool_call', tool: 'write', text: 'write' }] });
+ await addTask({ id: failed, state: 'failed', text: 'Broken task', created: now - 4 * DAY, finished: now - 3 * DAY, error: '<script>it broke</script>' });
+ await addTask({ id: cancelled, state: 'cancelled', text: 'Stopped task', created: now - 5 * DAY, finished: now - 4 * DAY });
+ await addTask({ id: old, state: 'succeeded', text: 'Ancient', created: now - 45 * DAY, finished: now - 40 * DAY });
+ await addTask({ id: theirs, requester: `user:${bo.subject}`, state: 'running', text: 'Bo\'s task' });
+
+ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
+ VALUES ($1,$2,$3,'codes.task','Approve the fix', $4::jsonb,'pending',$5,$6,NULL)`, [approval, `user:${ada.subject}`, agent, JSON.stringify({ task: 'Fix the **bug** in the repository' }), iso(now - 3600_000), iso(now + 6 * DAY)]);
+ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
+ VALUES ($1,$2,$3,'codes.task','Already declined', '{}'::jsonb,'declined',$4,$5,$4)`, [declined, `user:${ada.subject}`, agent, iso(now - 2 * DAY), iso(now + 5 * DAY)]);
+ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
+ VALUES ($1,$2,$3,'codes.task','Old approval', '{}'::jsonb,'approved',$4,$5,$4)`, [oldApproval, `user:${ada.subject}`, agent, iso(now - 40 * DAY), iso(now - 33 * DAY)]);
+ await db.query(`INSERT INTO actor_approvals (id, owner, agent_id, kind, summary, payload, state, created_at, expires_at, decided_at)
+ VALUES ($1,$2,$3,'codes.task','Bo approval', '{}'::jsonb,'pending',$4,$5,NULL)`, [theirsApproval, `user:${bo.subject}`, agent, iso(now - 3600_000), iso(now + 6 * DAY)]);
+
+ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)
+ VALUES ($1,$2,'Morning report','Summarize my day','balanced',1,1,'daily','07:00',$3,'active',$4,$4)`, [agent, `user:${ada.subject}`, iso(now + 5 * 3600_000), iso(now - 6 * DAY)]);
+ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)
+ VALUES ($1,$2,'Paused one','Never runs','balanced',1,1,'daily','08:00',$3,'paused',$4,$4)`, [paused, `user:${ada.subject}`, iso(now + 6 * 3600_000), iso(now - 6 * DAY)]);
+ await db.query(`INSERT INTO actor_agents (id, owner, name, instructions, mode, budget_per_run_usd, daily_runs_max, schedule, schedule_at, next_run_at, status, created_at, updated_at)
+ VALUES ($1,$2,'Off one','Never runs','balanced',1,1,'off',NULL,NULL,'active',$3,$3)`, [off, `user:${ada.subject}`, iso(now - 6 * DAY)]);
+
+ const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });
+ assert.strictEqual(res.status, 200, res.text);
+ const body = res.json();
+ const v = validate('common.action-list-result@1', body);
+ assert.ok(v.valid, JSON.stringify(v.errors));
+ const byId = new Map(body.items.map((i) => [i.id, i]));
+ assert.ok(byId.has(runningOld) && byId.get(runningOld).bucket === 'running', 'an old running task is still running');
+ assert.deepStrictEqual([byId.get(needs).state, byId.get(needs).bucket, byId.get(needs).actions], ['needs_you', 'waiting', ['open']]);
+ assert.deepStrictEqual([byId.get(succeeded).bucket, byId.get(succeeded).cost_usd, byId.get(succeeded).steps, byId.get(succeeded).actions], ['done', 0.12, 2, ['open']]);
+ assert.strictEqual(byId.get(succeeded).title, 'Fixed the bug');
+ assert.strictEqual(byId.get(succeeded).summary, 'All done & shipped.');
+ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);
+ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
+ assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');
+ assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);
+ assert.strictEqual(byId.get(approval).summary, 'Fix the bug in the repository');
+ assert.ok(!byId.has(declined), 'a decided approval is no longer an action waiting on the person');
+ assert.deepStrictEqual([byId.get(agent).bucket, byId.get(agent).state, byId.get(agent).next_run_at], ['scheduled', 'scheduled', iso(now + 5 * 3600_000)]);
+ assert.ok(!byId.has(paused) && !byId.has(off), 'paused and off agents are not scheduled');
+ assert.ok(!byId.has(old) && !byId.has(oldApproval), 'done and failed tasks older than 30 days are gone');
+ assert.ok(!byId.has(theirs) && !byId.has(theirsApproval), 'never another person\'s items');
+ for (const it of body.items) {
+ assert.match(it.href, /^https:\/\//);
+ assert.ok(it.title.length <= 160 && (!it.summary || it.summary.length <= 400) && (!it.error || it.error.length <= 400));
+ assert.ok(!/[<>*_`]/.test(it.title) && !/[<>*_`]/.test(it.summary || '') && !/[<>*_`]/.test(it.error || ''));
+ }
+ const times = body.items.map((i) => i.updated_at);
+ assert.deepStrictEqual(times, [...times].sort().reverse(), 'newest updated_at first');
+ assert.strictEqual(byId.get(runningOld).href, `https://openvibe.actor/tasks/${runningOld}`);
+ assert.strictEqual(byId.get(approval).href, `https://openvibe.actor/me/approvals/${approval}`);
+ assert.strictEqual(byId.get(agent).href, `https://openvibe.actor/me/agents/${agent}`);
+ });
+
+ await check('at most 50 items, newest first', async () => {
+ const mara = t.network.addUser('mara');
+ const first = ids.newId('task');
+ for (let i = 0; i < 55; i++) {
+ await addTask({ id: i === 0 ? first : ids.newId('task'), requester: `user:${mara.subject}`, state: 'running', text: `Task ${i}`, created: now - i * 60_000 });
+ }
+ const res = await t.get(`/api/v1/actions?subject=${mara.subject}`, { bearer: reader });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().items.length, 50);
+ assert.strictEqual(res.json().items[0].id, first, 'the newest of the 55');
+ });
+
+ await check('a person\'s token, a service without the capability and a bad subject are refused', async () => {
+ const user = await t.get(`/api/v1/actions?subject=${ada.subject}`, { as: ada });
+ assert.strictEqual(user.status, 403);
+ const userBearer = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: t.network.userToken(ada) });
+ assert.strictEqual(userBearer.status, 403);
+ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: token(['actor.task.list']) });
+ assert.strictEqual(noCap.status, 403);
+ const none = await t.get(`/api/v1/actions?subject=${ada.subject}`);
+ assert.strictEqual(none.status, 401);
+ const bad = await t.get('/api/v1/actions?subject=usr_nope', { bearer: reader });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.json().code, 'subject.invalid');
+ const missing = await t.get('/api/v1/actions', { bearer: reader });
+ assert.strictEqual(missing.status, 400);
+ });
+
+ await check('decide approves and declines, answers current state when decided twice, and another subject is 404', async () => {
+ const decide = (id, decision, subject = ada.subject) => t.get(`/api/v1/approvals/${id}/decide`, {
+ method: 'POST', bearer: decider, json: { decision }, headers: { 'X-OV-Subject': subject },
+ });
+ const noHeader = await t.get(`/api/v1/approvals/${approval}/decide`, { method: 'POST', bearer: decider, json: { decision: 'approve' } });
+ assert.strictEqual(noHeader.status, 400);
+ const badDecision = await decide(approval, 'maybe');
+ assert.strictEqual(badDecision.status, 422);
+ const wrongOwner = await decide(approval, 'approve', bo.subject);
+ assert.strictEqual(wrongOwner.status, 404);
+
+ const approved = await decide(approval, 'approve');
+ assert.strictEqual(approved.status, 200, approved.text);
+ assert.strictEqual(approved.json().state, 'approved');
+ assert.strictEqual(approved.json().bucket, 'done');
+ assert.deepStrictEqual(approved.json().actions, ['open']);
+ assert.ok(validate('common.action-item@1', approved.json()).valid);
+ const row = await t.ctx.s.db.maybe('SELECT * FROM actor_approvals WHERE id = $1', [approval]);
+ assert.strictEqual(row.state, 'approved');
+ assert.match(row.result_task_id, /^tsk_/);
+ const tasksBefore = Number(await t.ctx.s.db.value('SELECT count(*) FROM tasks WHERE requester = $1', [`user:${ada.subject}`]));
+ const again = await decide(approval, 'decline');
+ assert.strictEqual(again.status, 200);
+ assert.strictEqual(again.json().state, 'approved', 'a second decision answers the current state');
+ assert.strictEqual(Number(await t.ctx.s.db.value('SELECT count(*) FROM tasks WHERE requester = $1', [`user:${ada.subject}`])), tasksBefore, 'and starts nothing');
+
+ const decidedDecline = await decide(declined, 'decline');
+ assert.strictEqual(decidedDecline.status, 200);
+ assert.strictEqual(decidedDecline.json().state, 'declined');
+ assert.strictEqual(decidedDecline.json().bucket, 'failed');
+ assert.strictEqual((await decide(declined, 'approve')).json().state, 'declined');
+
+ const userToken = await t.get(`/api/v1/approvals/${declined}/decide`, { as: ada, method: 'POST', json: { decision: 'approve' }, headers: { 'sec-fetch-site': 'same-origin' } });
+ assert.strictEqual(userToken.status, 403);
+ const noCap = await t.get(`/api/v1/approvals/${declined}/decide`, { method: 'POST', bearer: reader, json: { decision: 'approve' }, headers: { 'X-OV-Subject': ada.subject } });
+ assert.strictEqual(noCap.status, 403);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
* gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
* gets 429 problem+json `rate_limited` with Retry-After, before the route does any work, while
* another caller still passes; the window reopens on the clock. A person counts as themselves, anyone
* another caller still passes; the window reopens on the clock. A person counts as themselves, anyone
* else by address. Actor's own budgets are pinned: actor.task.create and actor.agent.run 6 a minute, actor.agent.manage and actor.route 30,
* else by address. Actor's own budgets are pinned: actor.task.create and actor.agent.run 6 a minute, actor.agent.manage and actor.route 30,
- * actor.task.stream 30; reads take ACTOR_LIMITS_MINUTE/ACTOR_LIMITS_HOUR. Health, ready, release.json
+ * actor.task.stream 30; an approval decision 30; reads take ACTOR_LIMITS_MINUTE/ACTOR_LIMITS_HOUR. Health, ready, release.json
* and metrics are never limited; refusals are logged (no token) and counted in actor_rate_limited_total.
* and metrics are never limited; refusals are logged (no token) and counted in actor_rate_limited_total.
*/
*/
const assert = require('assert');
const assert = require('assert');
@@ -27,6 +27,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
@@ -27,6 +27,7 @@ const SAME = { 'sec-fetch-site': 'same-origin' };
'actor.agent.manage': { minute: 30, hour: 600 },
'actor.agent.manage': { minute: 30, hour: 600 },
'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 },
});
});
});
});
@@ -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'];
+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'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
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');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');
const INDEX_SRC = fs.readFileSync(path.join(__dirname, '..', 'server', 'registry', 'resource-index.js'), 'utf8');