openvibers/openvibe.actor Commits
Actor: an approval inbox — a person's agent asks before it starts a coding task on their machine (#51)
Changes
@@@ -49,6 +49,7 @@ Rate cards are typed in by a person from each provider's price page, with the pa| `GET /api/v1/me/agents` | public | `actor.agent-list-result@1` || `GET/POST /api/v1/me/agents` (signed in) | `actor.agent.manage` for a token | list or create your personal agents; signed-out GET keeps the public catalog || `GET/PATCH/DELETE /api/v1/me/agents/:id` | `actor.agent.manage` for a token | read, edit or delete one personal agent |++| `GET /me/approvals`, `GET/POST /me/approvals/:id` (signed in) | the person | the approval inbox ([server/agents/approvals.js](server/agents/approvals.js)): when one of the person's own agents runs while they are away and would start a coding task on their machine (the `codes` agent), the run asks instead and ends with a link; nothing reaches Codes until the person approves, which starts it as their own task (declining runs nothing; a request expires after 7 days). In the account export (`approvals.json`) and deletion. || `GET/POST /api/v1/me/agents/:id/memory`, `PATCH/DELETE /api/v1/me/agents/:id/memory/:mem`, `DELETE /api/v1/me/agents/:id/memory` | `actor.agent.manage` for a token | control its memory || `POST /api/v1/me/agents/:id/run`, `GET /api/v1/me/agents/:id/runs` | `actor.agent.run` for a token | run now or list its tasks || `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |nnew file mode 100644@@@ -0,0 +1,19 @@++-- phase: expand++-- Plan T17 "an approval inbox for consequential actions": what a person's own agent asked to do while they were away++-- (a coding task on their machine), waiting for their yes or no. Nothing runs until they approve; approving starts it++-- as the person's own task. Kept with the person's data (account export and deletion).++CREATE TABLE actor_approvals (++ id text COLLATE "C" PRIMARY KEY, -- apr_<ULID>++ owner text COLLATE "C" NOT NULL, -- user:usr_…++ agent_id text COLLATE "C", -- agt_… that asked++ task_id text COLLATE "C", -- the agent run that asked++ kind text COLLATE "C" NOT NULL CHECK (kind IN ('codes.task')),++ summary text NOT NULL CHECK (char_length(summary) BETWEEN 1 AND 500),++ payload jsonb NOT NULL,++ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'declined', 'expired')),++ result_task_id text COLLATE "C", -- the task approving started++ created_at text COLLATE "C" NOT NULL,++ expires_at text COLLATE "C" NOT NULL,++ decided_at text COLLATE "C"++);++CREATE INDEX actor_approvals_owner ON actor_approvals (owner, created_at DESC);@@@ -31,3 +31,5 @@.pa-btn:hover { border-color: var(--accent-light, #60a5fa); }.pa-btn.pa-primary { border: 0; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }.pa-btn.pa-primary:hover { filter: brightness(1.08); }++.pa-pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22rem; overflow: auto; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,.15)); font-size: 13px; }++.pa-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }nnew file mode 100644@@@ -0,0 +1,70 @@++'use strict';++++/**++ * The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they++ * are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their++ * own machine through OpenVibe.Codes — the run asks instead of acting. The request waits on /me/approvals; approving++ * starts that work as the person's own task (they are present now), declining or letting it expire (7 days) runs++ * nothing. A person's direct task never asks: they are the one asking.++ *++ * request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)++ * list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired++ * decide(ctx, owner, id, 'approve' | 'decline') → { row, task? } | { error, status }++ * KINDS, EXPIRES_DAYS++ */++const { createTask } = require('../tasks/create');++++const KINDS = ['codes.task'];++const EXPIRES_DAYS = 7;++const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;++++async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload }) {++ if (!KINDS.includes(kind)) throw new Error(`unknown approval kind ${kind}`);++ const id = s.newId('apr');++ const now = s.iso();++ const expires = new Date(Date.parse(now) + EXPIRES_DAYS * 86400_000).toISOString();++ return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'pending', $8, $9) RETURNING *`,++ [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), now, expires]);++}++++/** Pending requests past their expiry become expired as they are read. */++async function expire(s, owner) {++ await s.db.query("UPDATE actor_approvals SET state = 'expired', decided_at = $2 WHERE owner = $1 AND state = 'pending' AND expires_at < $2", [owner, s.iso()]);++}++++async function list(s, owner) {++ await expire(s, owner);++ return await s.db.many("SELECT * FROM actor_approvals WHERE owner = $1 ORDER BY (state = 'pending') DESC, created_at DESC LIMIT 100", [owner]);++}++++async function get(s, owner, id) {++ if (!ID.test(String(id))) return null;++ await expire(s, owner);++ return await s.db.maybe('SELECT * FROM actor_approvals WHERE id = $1 AND owner = $2', [id, owner]);++}++++async function pendingCount(s, owner) {++ await expire(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) {++ 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 (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 };++ }++ if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };++ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;++ const out = await createTask({ s, config, engine, webhooks, principal: { requester: owner, via: null, project: null }, body: { task: payload.task, agent: 'codes', mode: 'balanced' } });++ if (!out.task) return { error: out.detail || 'The task could not start.', status: out.status || 422 };++ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, result_task_id = $3 WHERE id = $1 RETURNING *", [id, s.iso(), out.task.id]);++ return { row: done, task: out.task };++}++++module.exports = { request, list, get, pendingCount, decide, KINDS, EXPIRES_DAYS };@@@ -9,7 +9,7 @@ const path = require('path');const { createDb } = require('openvibe-sdk/db');const { ids } = require('openvibe-contracts');--const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory'];++const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_approvals'];const MIGRATIONS = path.join(__dirname, '..', 'migrations');const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');@@@ -6,6 +6,7 @@ const { sameOrigin } = require('./principal');const { html, raw, time } = require('../render/html');const { send } = require('../render/layout');const personal = require('../agents/personal');++const approvals = require('../agents/approvals');function createAgentPages(ctx) {const { config, s } = ctx;@@@ -53,8 +54,10 @@ function createAgentPages(ctx) {r.get('/me/agents', async (req, res) => {if (!signed(req)) return login(req, res);const agents = await personal.list(s, owner(req));++ const waiting = await approvals.pendingCount(s, owner(req));page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>--<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p></header>++<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p>++<p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away.</p></header>${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a><span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span><span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`@@@ -67,6 +70,37 @@ ${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);res.redirect(303, `/me/agents/${out.row.id}`);});++ // The approval inbox (agents/approvals.js): what an agent asked to do while its owner was away.++ const APPROVAL_STATE = { pending: ['Waiting for you', 'pill-running'], approved: ['Approved', 'pill-succeeded'], declined: ['Declined', 'pill-failed'], expired: ['Expired', 'pill-failed'] };++ const approvalPill = (a) => html`<span class="pill ${APPROVAL_STATE[a.state][1]}">${APPROVAL_STATE[a.state][0]}</span>`;++ r.get('/me/approvals', async (req, res) => {++ if (!signed(req)) return login(req, res);++ const rows = await approvals.list(s, owner(req));++ page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>++<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine), it asks here first. Nothing runs until you approve. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>++${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`++ : html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}++<p><a href="/me/agents">Your agents</a></p>`);++ });++ r.get('/me/approvals/:id', async (req, res) => {++ if (!signed(req)) return login(req, res);++ const a = await approvals.get(s, owner(req), req.params.id);++ if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);++ const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;++ page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>++<p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>++<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>++<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>++${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">Approve and start it</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`++ : a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);++ });++ r.post('/me/approvals/:id', async (req, res) => {++ if (!write(req, res)) return;++ const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''));++ if (out.error) return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);++ res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);++ });++r.get('/me/agents/:id', async (req, res) => {if (!signed(req)) return login(req, res);const a = await find(req, res); if (!a) return;@@@ -37,6 +37,8 @@ const TABLES = [{ table: 'spend_daily', subject: 'requester', value: (usr) => `user:${usr}`, file: 'spend.json' },{ table: 'actor_agent_memory', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-memory.json' },{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },++ // What the person's agents asked them to approve (agents/approvals.js), and what they decided.++ { table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },];/** The account-data handle for Actor's store (server/db.js createStore). */@@@ -19,6 +19,8 @@ const catalog = require('../agents/catalog');const { classify, heuristic } = require('../agents/classify');const { route, whyNone } = require('../agents/router');const { check } = require('../agents/check');++const approvals = require('../agents/approvals');++const { repoIn: codesRepoIn } = require('../agents/codes');const MAX_ATTEMPTS = 2;@@@ -110,6 +112,22 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =}const agentId = placement.selected;const agent = catalog.byId(agentId);++ // The approval inbox (agents/approvals.js): a person's own agent, running while they are away, asks++ // before it starts a coding task on their machine. Nothing runs until they approve.++ if (agentId === 'codes' && /^agent:agt_/.test(String(row.via || '')) && codesRepoIn(row.task)) {++ const ask = await approvals.request(s, {++ owner: row.requester, agentId: row.via.slice(6), taskId: id, kind: 'codes.task',++ summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`, payload: { task: row.task },++ });++ const url = `${config.baseUrl}/me/approvals/${ask.id}`;++ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: `Your agent wants to start a coding task on your own machine. Nothing runs until you approve it: ${url}` });++ const result = {++ answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${codesRepoIn(row.task)}. Nothing has run: approve or decline it at ${url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,++ sources: [url], agent: agentId, model: null, class: cls, approval: { id: ask.id, url },++ checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },++ };++ return await setState(id, 'succeeded', { result, finished_at: iso() });++ }await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });let out;try {nnew file mode 100644@@@ -0,0 +1,106 @@++'use strict';++/**++ * The approval inbox (agents/approvals.js, plan T17): a person's own agent, running while they are away, asks before it++ * starts a coding task on their machine — the run ends with a link and nothing reaches Codes; the request is on++ * /me/approvals for its owner only; approving starts the work as the person's own task routed to the codes agent;++ * declining runs nothing; a request decides once; a request left 7 days expires; a person's direct coding task never++ * asks; the approvals are in the person's account export.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const personal = require('../server/agents/personal');++const approvals = require('../server/agents/approvals');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++(async () => {++ const codesCalls = [];++ const t = await boot({++ env: { ACTOR_CODES_URL: 'http://127.0.0.1:9' },++ adapters: {++ // No deepseek checker: classification is the heuristic, which reads "repository … bug" as code.++ checkers: { openai: async () => ({ text: '{"ok":true,"reason":"reports the change"}', cost_usd: 0 }) },++ run: {++ codes: async ({ task, requester, taskId }) => {++ codesCalls.push({ task, requester, taskId });++ return { text: '**Done on your machine** (deepseek).\n\nFixed the bug.', sources: ['https://openvibe.codes/run/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'], model: 'deepseek' };++ },++ },++ },++ });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const agent = (await personal.create(t.ctx.s, t.config, owner, { name: 'Fixer', instructions: 'In https://github.com/ada/site fix the failing bug in the repository.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 5, schedule: 'off', status: 'active' })).row;++++ let askId;++ await check('an agent run that would start a coding task asks instead, and nothing reaches Codes', async () => {++ const out = await personal.run(t.ctx, agent, 'The build broke.');++ assert.strictEqual(out.status, 201);++ const row = await t.waitFor(out.task.id);++ assert.strictEqual(row.state, 'succeeded');++ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result;++ assert.match(result.answer, /Nothing has run: approve or decline it at https:\/\/openvibe\.actor\/me\/approvals\/apr_/);++ askId = result.approval.id;++ assert.strictEqual(codesCalls.length, 0);++ const a = await approvals.get(t.ctx.s, owner, askId);++ assert.deepStrictEqual([a.state, a.kind, a.agent_id, a.task_id], ['pending', 'codes.task', agent.id, out.task.id]);++ assert.match(a.summary, /https:\/\/github\.com\/ada\/site/);++ });++++ await check('the inbox is its owner\'s: listed with a count, shown, and refused to anyone else', async () => {++ const list = await t.get('/me/approvals', { as: ada });++ assert.ok(list.text.includes(`/me/approvals/${askId}`) && list.text.includes('Waiting for you'));++ const agents = await t.get('/me/agents', { as: ada });++ assert.match(agents.text, /1 waiting/);++ const one = await t.get(`/me/approvals/${askId}`, { as: ada });++ assert.ok(one.text.includes('Approve and start it') && one.text.includes('fix the failing bug'));++ assert.strictEqual((await t.get(`/me/approvals/${askId}`, { as: bo })).status, 404);++ const theirs = await t.get(`/me/approvals/${askId}`, { as: bo, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(theirs.status, 404);++ const cross = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' } });++ assert.strictEqual(cross.status, 403);++ });++++ await check('approving starts it as the person\'s own task on the codes agent, once', async () => {++ const res = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(res.status, 303);++ const taskId = /\/tasks\/(tsk_[0-9A-Z]{26})/.exec(res.headers.get('location'))[1];++ const row = await t.waitFor(taskId);++ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));++ assert.strictEqual(row.requester, owner);++ assert.strictEqual(row.via, null);++ assert.strictEqual(codesCalls.length, 1);++ assert.strictEqual(codesCalls[0].taskId, taskId);++ const a = await approvals.get(t.ctx.s, owner, askId);++ assert.deepStrictEqual([a.state, a.result_task_id], ['approved', taskId]);++ const again = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: SAME });++ assert.strictEqual(again.status, 409);++ assert.strictEqual(codesCalls.length, 1);++ });++++ await check('declining runs nothing; a request left seven days expires', async () => {++ const d = await approvals.request(t.ctx.s, { owner, agentId: agent.id, kind: 'codes.task', summary: 'x', payload: { task: 'In https://github.com/a/b fix the bug.' } });++ const res = await t.get(`/me/approvals/${d.id}`, { as: ada, form: { decision: 'decline' }, headers: SAME });++ assert.strictEqual(res.status, 303);++ assert.strictEqual((await approvals.get(t.ctx.s, owner, d.id)).state, 'declined');++ const old = await approvals.request(t.ctx.s, { owner, agentId: agent.id, kind: 'codes.task', summary: 'y', payload: { task: 'z' } });++ await t.ctx.s.db.query('UPDATE actor_approvals SET expires_at = $2 WHERE id = $1', [old.id, '2000-01-01T00:00:00.000Z']);++ assert.strictEqual((await approvals.get(t.ctx.s, owner, old.id)).state, 'expired');++ assert.strictEqual(codesCalls.length, 1);++ });++++ await check('a person\'s own coding task never asks', async () => {++ const before = codesCalls.length;++ const res = await t.get('/api/v1/tasks', { as: ada, json: { task: 'In https://github.com/ada/site fix the bug in the repository.' }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const row = await t.waitFor(res.json().id);++ assert.strictEqual(row.state, 'succeeded');++ assert.strictEqual(codesCalls.length, before + 1);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -49,6 +49,7 @@ Rate cards are typed in by a person from each provider's price page, with the pa
@@ -49,6 +49,7 @@ Rate cards are typed in by a person from each provider's price page, with the pa
| `GET /api/v1/me/agents` | public | `actor.agent-list-result@1` |
| `GET /api/v1/me/agents` | public | `actor.agent-list-result@1` |
| `GET/POST /api/v1/me/agents` (signed in) | `actor.agent.manage` for a token | list or create your personal agents; signed-out GET keeps the public catalog |
| `GET/POST /api/v1/me/agents` (signed in) | `actor.agent.manage` for a token | list or create your personal agents; signed-out GET keeps the public catalog |
| `GET/PATCH/DELETE /api/v1/me/agents/:id` | `actor.agent.manage` for a token | read, edit or delete one personal agent |
| `GET/PATCH/DELETE /api/v1/me/agents/:id` | `actor.agent.manage` for a token | read, edit or delete one personal agent |
+| `GET /me/approvals`, `GET/POST /me/approvals/:id` (signed in) | the person | the approval inbox ([server/agents/approvals.js](server/agents/approvals.js)): when one of the person's own agents runs while they are away and would start a coding task on their machine (the `codes` agent), the run asks instead and ends with a link; nothing reaches Codes until the person approves, which starts it as their own task (declining runs nothing; a request expires after 7 days). In the account export (`approvals.json`) and deletion. |
| `GET/POST /api/v1/me/agents/:id/memory`, `PATCH/DELETE /api/v1/me/agents/:id/memory/:mem`, `DELETE /api/v1/me/agents/:id/memory` | `actor.agent.manage` for a token | control its memory |
| `GET/POST /api/v1/me/agents/:id/memory`, `PATCH/DELETE /api/v1/me/agents/:id/memory/:mem`, `DELETE /api/v1/me/agents/:id/memory` | `actor.agent.manage` for a token | control its memory |
| `POST /api/v1/me/agents/:id/run`, `GET /api/v1/me/agents/:id/runs` | `actor.agent.run` for a token | run now or list its tasks |
| `POST /api/v1/me/agents/:id/run`, `GET /api/v1/me/agents/:id/runs` | `actor.agent.run` for a token | run now or list its tasks |
| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
| `POST /api/v1/route` | public | dry run: which agent and why (heuristic class; nothing runs or is charged) |
new file mode 100644
new file mode 100644
@@ -0,0 +1,19 @@
@@ -0,0 +1,19 @@
+-- phase: expand
+-- Plan T17 "an approval inbox for consequential actions": what a person's own agent asked to do while they were away
+-- (a coding task on their machine), waiting for their yes or no. Nothing runs until they approve; approving starts it
+-- as the person's own task. Kept with the person's data (account export and deletion).
+CREATE TABLE actor_approvals (
+ id text COLLATE "C" PRIMARY KEY, -- apr_<ULID>
+ owner text COLLATE "C" NOT NULL, -- user:usr_…
+ agent_id text COLLATE "C", -- agt_… that asked
+ task_id text COLLATE "C", -- the agent run that asked
+ kind text COLLATE "C" NOT NULL CHECK (kind IN ('codes.task')),
+ summary text NOT NULL CHECK (char_length(summary) BETWEEN 1 AND 500),
+ payload jsonb NOT NULL,
+ state text COLLATE "C" NOT NULL CHECK (state IN ('pending', 'approved', 'declined', 'expired')),
+ result_task_id text COLLATE "C", -- the task approving started
+ created_at text COLLATE "C" NOT NULL,
+ expires_at text COLLATE "C" NOT NULL,
+ decided_at text COLLATE "C"
+);
+CREATE INDEX actor_approvals_owner ON actor_approvals (owner, created_at DESC);
@@ -31,3 +31,5 @@
@@ -31,3 +31,5 @@
.pa-btn:hover { border-color: var(--accent-light, #60a5fa); }
.pa-btn:hover { border-color: var(--accent-light, #60a5fa); }
.pa-btn.pa-primary { border: 0; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
.pa-btn.pa-primary { border: 0; background: var(--accent-strong, #2563eb); color: var(--on-accent-strong, #fff); }
.pa-btn.pa-primary:hover { filter: brightness(1.08); }
.pa-btn.pa-primary:hover { filter: brightness(1.08); }
+.pa-pre { white-space: pre-wrap; overflow-wrap: anywhere; max-height: 22rem; overflow: auto; padding: 10px 12px; border-radius: 8px; border: 1px solid var(--border-color, rgba(255,255,255,.15)); font-size: 13px; }
+.pa-actions { display: flex; flex-wrap: wrap; gap: 10px; margin: 16px 0; }
new file mode 100644
new file mode 100644
@@ -0,0 +1,70 @@
@@ -0,0 +1,70 @@
+'use strict';
+
+/**
+ * The approval inbox (plan T17: "an approval inbox for consequential actions"). A person's own agent runs while they
+ * are away (a schedule, a watch): when its work would act outside Actor on their behalf — today, a coding task on their
+ * own machine through OpenVibe.Codes — the run asks instead of acting. The request waits on /me/approvals; approving
+ * starts that work as the person's own task (they are present now), declining or letting it expire (7 days) runs
+ * nothing. A person's direct task never asks: they are the one asking.
+ *
+ * request(s, { owner, agentId, taskId, kind, summary, payload }) → the row (pending)
+ * list(s, owner) / get(s, owner, id) → rows, pending past their expiry marked expired
+ * decide(ctx, owner, id, 'approve' | 'decline') → { row, task? } | { error, status }
+ * KINDS, EXPIRES_DAYS
+ */
+const { createTask } = require('../tasks/create');
+
+const KINDS = ['codes.task'];
+const EXPIRES_DAYS = 7;
+const ID = /^apr_[0-9A-HJKMNP-TV-Z]{26}$/;
+
+async function request(s, { owner, agentId = null, taskId = null, kind, summary, payload }) {
+ if (!KINDS.includes(kind)) throw new Error(`unknown approval kind ${kind}`);
+ const id = s.newId('apr');
+ const now = s.iso();
+ const expires = new Date(Date.parse(now) + EXPIRES_DAYS * 86400_000).toISOString();
+ return await s.db.one(`INSERT INTO actor_approvals (id, owner, agent_id, task_id, kind, summary, payload, state, created_at, expires_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, 'pending', $8, $9) RETURNING *`,
+ [id, owner, agentId, taskId, kind, String(summary).slice(0, 500), JSON.stringify(payload), now, expires]);
+}
+
+/** Pending requests past their expiry become expired as they are read. */
+async function expire(s, owner) {
+ await s.db.query("UPDATE actor_approvals SET state = 'expired', decided_at = $2 WHERE owner = $1 AND state = 'pending' AND expires_at < $2", [owner, s.iso()]);
+}
+
+async function list(s, owner) {
+ await expire(s, owner);
+ return await s.db.many("SELECT * FROM actor_approvals WHERE owner = $1 ORDER BY (state = 'pending') DESC, created_at DESC LIMIT 100", [owner]);
+}
+
+async function get(s, owner, id) {
+ if (!ID.test(String(id))) return null;
+ await expire(s, owner);
+ return await s.db.maybe('SELECT * FROM actor_approvals WHERE id = $1 AND owner = $2', [id, owner]);
+}
+
+async function pendingCount(s, owner) {
+ await expire(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) {
+ 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 (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 };
+ }
+ if (decision !== 'approve') return { error: 'Approve or decline.', status: 422 };
+ const payload = typeof row.payload === 'string' ? JSON.parse(row.payload) : row.payload;
+ const out = await createTask({ s, config, engine, webhooks, principal: { requester: owner, via: null, project: null }, body: { task: payload.task, agent: 'codes', mode: 'balanced' } });
+ if (!out.task) return { error: out.detail || 'The task could not start.', status: out.status || 422 };
+ const done = await s.db.one("UPDATE actor_approvals SET state = 'approved', decided_at = $2, result_task_id = $3 WHERE id = $1 RETURNING *", [id, s.iso(), out.task.id]);
+ return { row: done, task: out.task };
+}
+
+module.exports = { request, list, get, pendingCount, decide, KINDS, EXPIRES_DAYS };
@@ -9,7 +9,7 @@ const path = require('path');
@@ -9,7 +9,7 @@ const path = require('path');
const { createDb } = require('openvibe-sdk/db');
const { createDb } = require('openvibe-sdk/db');
const { ids } = require('openvibe-contracts');
const { ids } = require('openvibe-contracts');
-const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory'];
+const TABLES = ['tasks', 'task_events', 'spend_daily', 'actor_agents', 'actor_agent_memory', 'actor_approvals'];
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const MIGRATIONS = path.join(__dirname, '..', 'migrations');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
const DEV_PGLITE = path.join(__dirname, '..', 'data', 'pglite');
@@ -6,6 +6,7 @@ const { sameOrigin } = require('./principal');
@@ -6,6 +6,7 @@ const { sameOrigin } = require('./principal');
const { html, raw, time } = require('../render/html');
const { html, raw, time } = require('../render/html');
const { send } = require('../render/layout');
const { send } = require('../render/layout');
const personal = require('../agents/personal');
const personal = require('../agents/personal');
+const approvals = require('../agents/approvals');
function createAgentPages(ctx) {
function createAgentPages(ctx) {
const { config, s } = ctx;
const { config, s } = ctx;
@@ -53,8 +54,10 @@ function createAgentPages(ctx) {
@@ -53,8 +54,10 @@ function createAgentPages(ctx) {
r.get('/me/agents', async (req, res) => {
r.get('/me/agents', async (req, res) => {
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const agents = await personal.list(s, owner(req));
const agents = await personal.list(s, owner(req));
+ const waiting = await approvals.pendingCount(s, owner(req));
page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>
page(req, res, 'Your agents', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · personal agents · alpha</p><h1>Your agents</h1>
-<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p></header>
+<p class="pa-lede">An agent is a task you set up once: standing instructions, a memory you can read and edit, and a schedule. Every run is an ordinary Actor task, with the same routing, checks, costs and limits.</p>
+<p><a href="/me/approvals">Approvals${waiting ? html` <span class="pill pill-running">${String(waiting)} waiting</span>` : ''}</a> · what your agents asked to do while you were away.</p></header>
${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a>
${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="pa-card"><a href="/me/agents/${a.id}"><strong>${a.name}</strong></a>
<span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span>
<span class="pill ${a.status === 'paused' ? 'pill-failed' : 'pill-succeeded'}">${a.status === 'paused' ? 'Paused' : 'Active'}</span>
<span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`
<span class="pa-when">${when(a)}</span>${a.last_run_at ? html`<span class="pa-when">Last run ${time(a.last_run_at)}</span>` : ''}</li>`)}</ul>`
@@ -67,6 +70,37 @@ ${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="
@@ -67,6 +70,37 @@ ${agents.length ? html`<ul class="pa-cards">${agents.map((a) => html`<li class="
if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);
if (!out.row) return page(req, res, 'Create agent', html`<h1>Create agent</h1><p>${out.error || 'Agent limit reached.'}</p><form method="post" action="/me/agents">${form(req.body)}<button type="submit">Create</button></form>`, out.limit ? 429 : 422);
res.redirect(303, `/me/agents/${out.row.id}`);
res.redirect(303, `/me/agents/${out.row.id}`);
});
});
+ // The approval inbox (agents/approvals.js): what an agent asked to do while its owner was away.
+ const APPROVAL_STATE = { pending: ['Waiting for you', 'pill-running'], approved: ['Approved', 'pill-succeeded'], declined: ['Declined', 'pill-failed'], expired: ['Expired', 'pill-failed'] };
+ const approvalPill = (a) => html`<span class="pill ${APPROVAL_STATE[a.state][1]}">${APPROVAL_STATE[a.state][0]}</span>`;
+ r.get('/me/approvals', async (req, res) => {
+ if (!signed(req)) return login(req, res);
+ const rows = await approvals.list(s, owner(req));
+ page(req, res, 'Approvals', html`<header class="pa-head"><p class="pa-eyebrow">OpenVibe.Actor · approvals</p><h1>Approvals</h1>
+<p class="pa-lede">When one of your agents runs while you are away and its work would act for you outside Actor (a coding task on your own machine), it asks here first. Nothing runs until you approve. A request you leave expires after ${String(approvals.EXPIRES_DAYS)} days.</p></header>
+${rows.length ? html`<ul class="pa-cards">${rows.map((a) => html`<li class="pa-card"><a href="/me/approvals/${a.id}"><strong>${a.summary}</strong></a> ${approvalPill(a)}<span class="pa-when">Asked ${time(a.created_at)}</span></li>`)}</ul>`
+ : html`<p class="pa-empty">Nothing to approve. Your agents have not asked for anything yet.</p>`}
+<p><a href="/me/agents">Your agents</a></p>`);
+ });
+ r.get('/me/approvals/:id', async (req, res) => {
+ if (!signed(req)) return login(req, res);
+ const a = await approvals.get(s, owner(req), req.params.id);
+ if (!a) return page(req, res, 'Request not found', html`<h1>Request not found</h1><p><a href="/me/approvals">Approvals</a></p>`, 404);
+ const p = typeof a.payload === 'string' ? JSON.parse(a.payload) : a.payload;
+ page(req, res, 'Approval', html`<p class="pa-eyebrow"><a href="/me/approvals">Approvals</a></p><h1>${a.summary}</h1>
+<p>${approvalPill(a)} <span class="pa-when">Asked ${time(a.created_at)}${a.agent_id ? html` by <a href="/me/agents/${a.agent_id}">your agent</a>` : ''}${a.task_id ? html` in <a href="/tasks/${a.task_id}">this run</a>` : ''}</span></p>
+<section class="pa-panel"><h2>What it would do</h2><p>Start a coding task on your own machine through <a href="https://openvibe.codes/run">OpenVibe.Codes</a> with this request. You get the summary, the changed files and the patch; it pushes nowhere unless the request asks for a branch and your machine holds a GitHub token.</p>
+<pre class="pa-pre">${String(p.task || '').slice(0, 8000)}</pre></section>
+${a.state === 'pending' ? html`<form method="post" action="/me/approvals/${a.id}" class="pa-actions"><button type="submit" name="decision" value="approve" class="pa-btn pa-primary">Approve and start it</button> <button type="submit" name="decision" value="decline" class="pa-btn">Decline</button></form>`
+ : a.result_task_id ? html`<p>Started as <a href="/tasks/${a.result_task_id}">this task</a>.</p>` : ''}`);
+ });
+ r.post('/me/approvals/:id', async (req, res) => {
+ if (!write(req, res)) return;
+ const out = await approvals.decide(ctx, owner(req), req.params.id, String((req.body || {}).decision || ''));
+ if (out.error) return page(req, res, 'Not done', html`<h1>Not done</h1><p>${out.error}</p><p><a href="/me/approvals">Approvals</a></p>`, out.status);
+ res.redirect(303, out.task ? `/tasks/${out.task.id}` : `/me/approvals/${req.params.id}`);
+ });
+
r.get('/me/agents/:id', async (req, res) => {
r.get('/me/agents/:id', async (req, res) => {
if (!signed(req)) return login(req, res);
if (!signed(req)) return login(req, res);
const a = await find(req, res); if (!a) return;
const a = await find(req, res); if (!a) return;
@@ -37,6 +37,8 @@ const TABLES = [
@@ -37,6 +37,8 @@ const TABLES = [
{ table: 'spend_daily', subject: 'requester', value: (usr) => `user:${usr}`, file: 'spend.json' },
{ table: 'spend_daily', subject: 'requester', value: (usr) => `user:${usr}`, file: 'spend.json' },
{ table: 'actor_agent_memory', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-memory.json' },
{ table: 'actor_agent_memory', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agent-memory.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
{ table: 'actor_agents', subject: 'owner', value: (usr) => `user:${usr}`, file: 'agents.json' },
+ // What the person's agents asked them to approve (agents/approvals.js), and what they decided.
+ { table: 'actor_approvals', subject: 'owner', value: (usr) => `user:${usr}`, file: 'approvals.json' },
];
];
/** The account-data handle for Actor's store (server/db.js createStore). */
/** The account-data handle for Actor's store (server/db.js createStore). */
@@ -19,6 +19,8 @@ const catalog = require('../agents/catalog');
@@ -19,6 +19,8 @@ const catalog = require('../agents/catalog');
const { classify, heuristic } = require('../agents/classify');
const { classify, heuristic } = require('../agents/classify');
const { route, whyNone } = require('../agents/router');
const { route, whyNone } = require('../agents/router');
const { check } = require('../agents/check');
const { check } = require('../agents/check');
+const approvals = require('../agents/approvals');
+const { repoIn: codesRepoIn } = require('../agents/codes');
const MAX_ATTEMPTS = 2;
const MAX_ATTEMPTS = 2;
@@ -110,6 +112,22 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
@@ -110,6 +112,22 @@ function createEngine({ config, s, adapters, stream, webhooks = null, now = () =
}
}
const agentId = placement.selected;
const agentId = placement.selected;
const agent = catalog.byId(agentId);
const agent = catalog.byId(agentId);
+ // The approval inbox (agents/approvals.js): a person's own agent, running while they are away, asks
+ // before it starts a coding task on their machine. Nothing runs until they approve.
+ if (agentId === 'codes' && /^agent:agt_/.test(String(row.via || '')) && codesRepoIn(row.task)) {
+ const ask = await approvals.request(s, {
+ owner: row.requester, agentId: row.via.slice(6), taskId: id, kind: 'codes.task',
+ summary: `Start a coding task on your machine in ${codesRepoIn(row.task)}`, payload: { task: row.task },
+ });
+ const url = `${config.baseUrl}/me/approvals/${ask.id}`;
+ await emit(id, { kind: 'output', step: 'text', agent: agentId, text: `Your agent wants to start a coding task on your own machine. Nothing runs until you approve it: ${url}` });
+ const result = {
+ answer: `Your agent asked to start a coding task on your own machine (OpenVibe.Codes) in ${codesRepoIn(row.task)}. Nothing has run: approve or decline it at ${url}. The request expires in ${approvals.EXPIRES_DAYS} days.`,
+ sources: [url], agent: agentId, model: null, class: cls, approval: { id: ask.id, url },
+ checked: { by: 'none', cross_family: false, reason: 'Nothing was run: its owner decides first.' },
+ };
+ return await setState(id, 'succeeded', { result, finished_at: iso() });
+ }
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
await emit(id, { kind: 'output', step: attempt > 1 ? 'handoff' : 'plan', agent: agentId, text: `${agent.name} takes it${placement.reasons && placement.reasons[0] ? `: ${placement.reasons[0]}` : ''}.` });
let out;
let out;
try {
try {
new file mode 100644
new file mode 100644
@@ -0,0 +1,106 @@
@@ -0,0 +1,106 @@
+'use strict';
+/**
+ * The approval inbox (agents/approvals.js, plan T17): a person's own agent, running while they are away, asks before it
+ * starts a coding task on their machine — the run ends with a link and nothing reaches Codes; the request is on
+ * /me/approvals for its owner only; approving starts the work as the person's own task routed to the codes agent;
+ * declining runs nothing; a request decides once; a request left 7 days expires; a person's direct coding task never
+ * asks; the approvals are in the person's account export.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const personal = require('../server/agents/personal');
+const approvals = require('../server/agents/approvals');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+(async () => {
+ const codesCalls = [];
+ const t = await boot({
+ env: { ACTOR_CODES_URL: 'http://127.0.0.1:9' },
+ adapters: {
+ // No deepseek checker: classification is the heuristic, which reads "repository … bug" as code.
+ checkers: { openai: async () => ({ text: '{"ok":true,"reason":"reports the change"}', cost_usd: 0 }) },
+ run: {
+ codes: async ({ task, requester, taskId }) => {
+ codesCalls.push({ task, requester, taskId });
+ return { text: '**Done on your machine** (deepseek).\n\nFixed the bug.', sources: ['https://openvibe.codes/run/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T1'], model: 'deepseek' };
+ },
+ },
+ },
+ });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const agent = (await personal.create(t.ctx.s, t.config, owner, { name: 'Fixer', instructions: 'In https://github.com/ada/site fix the failing bug in the repository.', mode: 'balanced', budget_per_run_usd: 0.02, daily_runs_max: 5, schedule: 'off', status: 'active' })).row;
+
+ let askId;
+ await check('an agent run that would start a coding task asks instead, and nothing reaches Codes', async () => {
+ const out = await personal.run(t.ctx, agent, 'The build broke.');
+ assert.strictEqual(out.status, 201);
+ const row = await t.waitFor(out.task.id);
+ assert.strictEqual(row.state, 'succeeded');
+ const result = typeof row.result === 'string' ? JSON.parse(row.result) : row.result;
+ assert.match(result.answer, /Nothing has run: approve or decline it at https:\/\/openvibe\.actor\/me\/approvals\/apr_/);
+ askId = result.approval.id;
+ assert.strictEqual(codesCalls.length, 0);
+ const a = await approvals.get(t.ctx.s, owner, askId);
+ assert.deepStrictEqual([a.state, a.kind, a.agent_id, a.task_id], ['pending', 'codes.task', agent.id, out.task.id]);
+ assert.match(a.summary, /https:\/\/github\.com\/ada\/site/);
+ });
+
+ await check('the inbox is its owner\'s: listed with a count, shown, and refused to anyone else', async () => {
+ const list = await t.get('/me/approvals', { as: ada });
+ assert.ok(list.text.includes(`/me/approvals/${askId}`) && list.text.includes('Waiting for you'));
+ const agents = await t.get('/me/agents', { as: ada });
+ assert.match(agents.text, /1 waiting/);
+ const one = await t.get(`/me/approvals/${askId}`, { as: ada });
+ assert.ok(one.text.includes('Approve and start it') && one.text.includes('fix the failing bug'));
+ assert.strictEqual((await t.get(`/me/approvals/${askId}`, { as: bo })).status, 404);
+ const theirs = await t.get(`/me/approvals/${askId}`, { as: bo, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(theirs.status, 404);
+ const cross = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' } });
+ assert.strictEqual(cross.status, 403);
+ });
+
+ await check('approving starts it as the person\'s own task on the codes agent, once', async () => {
+ const res = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(res.status, 303);
+ const taskId = /\/tasks\/(tsk_[0-9A-Z]{26})/.exec(res.headers.get('location'))[1];
+ const row = await t.waitFor(taskId);
+ assert.strictEqual(row.state, 'succeeded', JSON.stringify(row.error_detail));
+ assert.strictEqual(row.requester, owner);
+ assert.strictEqual(row.via, null);
+ assert.strictEqual(codesCalls.length, 1);
+ assert.strictEqual(codesCalls[0].taskId, taskId);
+ const a = await approvals.get(t.ctx.s, owner, askId);
+ assert.deepStrictEqual([a.state, a.result_task_id], ['approved', taskId]);
+ const again = await t.get(`/me/approvals/${askId}`, { as: ada, form: { decision: 'approve' }, headers: SAME });
+ assert.strictEqual(again.status, 409);
+ assert.strictEqual(codesCalls.length, 1);
+ });
+
+ await check('declining runs nothing; a request left seven days expires', async () => {
+ const d = await approvals.request(t.ctx.s, { owner, agentId: agent.id, kind: 'codes.task', summary: 'x', payload: { task: 'In https://github.com/a/b fix the bug.' } });
+ const res = await t.get(`/me/approvals/${d.id}`, { as: ada, form: { decision: 'decline' }, headers: SAME });
+ assert.strictEqual(res.status, 303);
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, d.id)).state, 'declined');
+ const old = await approvals.request(t.ctx.s, { owner, agentId: agent.id, kind: 'codes.task', summary: 'y', payload: { task: 'z' } });
+ await t.ctx.s.db.query('UPDATE actor_approvals SET expires_at = $2 WHERE id = $1', [old.id, '2000-01-01T00:00:00.000Z']);
+ assert.strictEqual((await approvals.get(t.ctx.s, owner, old.id)).state, 'expired');
+ assert.strictEqual(codesCalls.length, 1);
+ });
+
+ await check('a person\'s own coding task never asks', async () => {
+ const before = codesCalls.length;
+ const res = await t.get('/api/v1/tasks', { as: ada, json: { task: 'In https://github.com/ada/site fix the bug in the repository.' }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const row = await t.waitFor(res.json().id);
+ assert.strictEqual(row.state, 'succeeded');
+ assert.strictEqual(codesCalls.length, before + 1);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });