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)

OP OpenVibers committed 16 days ago · fdb40d97d7 · 10 files, +417 −11

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex 65811a7..4ce4899 100644
---- a/package-lock.json
++++ b/package-lock.json
@@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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",
ddiff --git a/package.json b/package.json
iindex c94a6ab..5ad814e 100644
---- a/package.json
++++ b/package.json
@@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.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"
ddiff --git a/server/actions/items.js b/server/actions/items.js
nnew file mode 100644
iindex 0000000..a4f0c05
---- /dev/null
++++ b/server/actions/items.js
@@@ -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 };
ddiff --git a/server/agents/approvals.js b/server/agents/approvals.js
iindex 1ce811e..ffd2b06 100644
---- a/server/agents/approvals.js
++++ b/server/agents/approvals.js
@@@ -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 };
ddiff --git a/server/http/api.js b/server/http/api.js
iindex 6e2e2df..b6d072a 100644
---- a/server/http/api.js
++++ b/server/http/api.js
@@@ -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) }));
ddiff --git a/server/http/caller-limits.js b/server/http/caller-limits.js
iindex 5db0ee5..ef6a7ca 100644
---- a/server/http/caller-limits.js
++++ b/server/http/caller-limits.js
@@@ -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 }) {
ddiff --git a/server/http/principal.js b/server/http/principal.js
iindex 85ba9e2..8c111e4 100644
---- a/server/http/principal.js
++++ b/server/http/principal.js
@@@ -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('.');
ddiff --git a/test/actions.test.js b/test/actions.test.js
nnew file mode 100644
iindex 0000000..3b04c47
---- /dev/null
++++ b/test/actions.test.js
@@@ -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) &amp; 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** &amp; 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); });
ddiff --git a/test/caller-limits.test.js b/test/caller-limits.test.js
iindex 42ecba7..71e6b3b 100644
---- a/test/caller-limits.test.js
++++ b/test/caller-limits.test.js
@@@ -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 },
});
});
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 716d4a5..3554b3c 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run'];
++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');