openvibers/openvibe.codes Commits
The Action Center's read: GET /api/v1/actions?subject= (codes.actions.read) lists a person's coding tasks as common.action-item@1; contracts v0.229.0 (#141)
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"@@@ -874,9 +874,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",@@@ -30,7 +30,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,81 @@++'use strict';++++/**++ * Codes' Action Center items (plan 2026-10-28 §3.10, common.action-item@1): one person's coding tasks as++ * codes.task@1 already presents them, mapped to the five Action Center buckets. bucketFor() is exported on its own++ * so its state table can be tested.++ */++++const PUBLIC_ORIGIN = 'https://openvibe.codes';++++const STATE_BUCKET = Object.freeze({++ queued: 'running',++ placed: 'running',++ running: 'running',++ succeeded: 'done',++ failed: 'failed',++ cancelled: 'failed',++ expired: 'failed',++});++++function bucketFor(state) {++ return STATE_BUCKET[state] || 'failed';++}++++/** What the person may do from the list ([email protected]). */++function actionsFor(bucket) {++ if (bucket === 'running') return ['open', 'cancel'];++ return ['open']; // no retry route on Codes++}++++/** 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) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' })[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; }++}++++/** Tool calls in the session log the result keeps, or null when it kept none. */++function stepsOf(row) {++ const result = parse(row.result);++ if (!result || !Array.isArray(result.log)) return null;++ return result.log.filter((line) => line && typeof line === 'object' && line.t === 'tool').length;++}++++function toItem(row, presented) {++ const result = presented.result || {};++ const bucket = bucketFor(presented.state);++ const agent = Array.isArray(result.agents) && result.agents.length ? result.agents.join(', ') : presented.agent;++ const summary = result.summary || presented.error || '';++ return {++ service: 'codes',++ id: presented.id,++ kind: 'code_task',++ title: plain(row.prompt || presented.id, 160) || presented.id,++ summary: summary ? plain(summary, 400) : null,++ bucket,++ state: presented.state,++ agent: agent ? plain(agent, 80) : null,++ cost_usd: typeof result.cost_usd === 'number' && Number.isFinite(result.cost_usd) ? result.cost_usd : null,++ steps: stepsOf(row),++ error: presented.error ? plain(presented.error, 400) : null,++ created_at: presented.created_at,++ updated_at: presented.updated_at,++ href: `${PUBLIC_ORIGIN}/run/tasks/${presented.id}`,++ actions: actionsFor(bucket),++ };++}++++module.exports = { PUBLIC_ORIGIN, bucketFor, actionsFor, plain, stepsOf, toItem };@@@ -29,6 +29,7 @@ const { createHarnesses } = require('./domain/harnesses');const { createPageRoutes } = require('./http/pages');const { createRunRoutes } = require('./http/run');const { createTasksApi } = require('./http/tasks-api');++const { createActionsApi } = require('./http/actions-api');const { createRunClient } = require('./domain/run-client');const { createApi } = require('./http/api');const { createMovedRoutes } = require('./http/moved');@@@ -151,6 +152,7 @@ async function createApp(opts = {}) {ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });app.use('/api/v1', createApi(ctx));app.use('/api/v1', createTasksApi({ config, store, keys, run: ctx.run, harnesses, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createActionsApi({ config, store, keys, run: ctx.run, actorLimits: ctx.actorLimits, log }));app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));// ── Pages ───────────────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,55 @@++'use strict';++++/**++ * The Action Center read (plan 2026-10-28 §3.10): GET /api/v1/actions?subject=usr_…++ *++ * A Network service token for audience openvibe.codes holding codes.actions.read names one person and gets that++ * person's coding tasks as common.action-list-result@1: everything queued/placed/running, then done and failed tasks++ * from the last 30 days, newest updated_at first, at most 50. Never another person's tasks; a person's own token is++ * refused, as is a token without the capability or a subject that is not a usr_… id.++ */++const { serviceAuth, capabilities, http } = require('openvibe-contracts');++const { asyncRouter } = require('./router');++const { createRunTasks } = require('../domain/run-tasks');++const items = require('../actions/items');++++const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;++const RUNNING = ['queued', 'placed', 'running'];++++function createActionsApi({ config, store, keys, run, actorLimits, log = console }) {++ const r = asyncRouter();++ const tasks = createRunTasks({ store, run, config, log });++ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });++++ const guard = async (req, res, next) => {++ const header = String(req.headers.authorization || '');++ if (!header.startsWith('Bearer ')) return problem(req, res, 401, 'token.required', `codes.actions.read needs a Network service token for ${config.serviceAudience}`);++ if (!keys.loaded()) await keys.ensure();++ const publicKey = keys.get();++ if (!publicKey) return problem(req, res, 503, 'identity.unavailable', 'the Network signing key is not loaded yet');++ const v = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.networkIssuer, audience: config.serviceAudience });++ if (!v.ok) return problem(req, res, 401, v.code || 'token.invalid', v.reason || 'the service token is invalid');++ const sub = String(v.claims.sub || '');++ if (!sub.startsWith('svc:')) return problem(req, res, 403, 'capability.denied', 'codes.actions.read is for first-party services');++ const decision = capabilities.check(v.claims, 'codes.actions.read');++ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || 'codes.actions.read not granted');++ const subject = String(req.query.subject || '').trim();++ if (!USER.test(subject)) return problem(req, res, 400, 'subject.invalid', 'subject must be a usr_… subject id');++ req.principal = { sub: `user:${subject}`, subject, via: `service:${sub.slice(4)}` };++ return next();++ };++++ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ r.get('/actions', guard, actorLimits.reads('codes.actions.read'), async (req, res) => {++ const cutoff = store.now() - 30 * 86_400_000;++ const rows = await store.db.many(`SELECT * FROM run_tasks++ WHERE subject = $1 AND (state = ANY($2) OR updated_at >= $3)++ ORDER BY updated_at DESC++ LIMIT 50`, [req.principal.subject, RUNNING, cutoff]);++ res.json({ items: rows.map((row) => items.toItem(row, tasks.present(row))) });++ });++++ return r;++}++++module.exports = { createActionsApi };@@@ -17,8 +17,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';// The console's paths (and everything under them), and the platform policy pages that moved with it.const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];--// The console's API: everything under /api/v1 but the agent catalog and the coding tasks (codes.task.*).--const API_STAYS = /^\/api\/v1\/(harnesses|tasks)(\/|$)/;++// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*) and the Action++// Center read (codes.actions.read).++const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions)(\/|$)/;const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);nnew file mode 100644@@@ -0,0 +1,125 @@++'use strict';++/**++ * Codes' Action Center read (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with++ * codes.actions.read names one person and gets their coding tasks, never another person's — running first-class,++ * done and failed from the last 30 days only, newest first, at most 50, plain text, https links and the right++ * actions. A person's token, a service without the capability and a bad subject are refused.++ */++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 run = { enabled: true, async submit() { throw new Error('not used'); }, async read() { throw new Error('not used'); }, async cancel() { throw new Error('not used'); }, async streamUrl() { throw new Error('not used'); } };++ const t = await boot({ run });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const now = Date.now();++ const token = (cap = ['codes.actions.read'], sub = 'svc:network') => {++ const at = Math.floor(now / 1000);++ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.codes'], cap, iat: at, exp: at + 300, jti: crypto.randomUUID() }, t.network.privatePem);++ };++ const reader = token();++ const db = t.ctx.store.db;++ const project = ids.newId('project');++++ async function addTask({ subject = ada.subject, state = 'queued', created = now, updated = created, prompt = 'Fix the bug', result = null, error = null, id = ids.newId('task') }) {++ await db.query(`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, state, result, error, created_at, updated_at)++ VALUES ($1,$2,$3,'https://github.com/ada/site',$4,'deepseek',$5,$6::jsonb,$7,$8,$9)`,++ [id, subject, project, prompt, state, result == null ? null : JSON.stringify(result), error, created, updated]);++ return id;++ }++++ await check('the state → bucket table and the actions each bucket allows', async () => {++ const table = [['queued', 'running'], ['placed', 'running'], ['running', 'running'], ['succeeded', 'done'], ['failed', 'failed'], ['cancelled', 'failed'], ['expired', 'failed']];++ for (const [state, bucket] of table) assert.strictEqual(items.bucketFor(state), bucket, state);++ assert.deepStrictEqual(items.actionsFor('running'), ['open', 'cancel']);++ for (const bucket of ['done', 'failed', 'waiting', 'scheduled']) assert.deepStrictEqual(items.actionsFor(bucket), ['open']);++ assert.strictEqual(items.plain('<b>Fix</b> **the** [bug](https://x) & ship', 160), 'Fix the bug & ship');++ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);++ });++++ const queuedOld = ids.newId('task');++ const running = ids.newId('task');++ const doneId = ids.newId('task');++ const failedId = ids.newId('task');++ const cancelledId = ids.newId('task');++ const expiredId = ids.newId('task');++ const oldId = ids.newId('task');++ const theirsId = ids.newId('task');++++ await check('only that person\'s tasks, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {++ await addTask({ id: queuedOld, state: 'queued', created: now - 40 * DAY, prompt: 'Still waiting' });++ await addTask({ id: running, state: 'running', created: now - 2 * 3600_000, prompt: '# Running **now**' });++ await addTask({ id: doneId, state: 'succeeded', created: now - 2 * DAY, updated: now - DAY, prompt: '<b>Fix</b> **the** [bug](https://example.com)', result: { ok: true, agents: ['deepseek'], summary: '<p>All **done** & shipped.</p>', cost_usd: 0.0011, files: [{ path: 'a.js', added: 1, removed: 0 }], log: [{ t: 'tool' }, { t: 'text' }, { t: 'tool' }] } });++ await addTask({ id: failedId, state: 'failed', created: now - 4 * DAY, updated: now - 3 * DAY, prompt: 'Broken task', error: '<script>it broke</script>', result: { ok: false, error: 'the machine ran out of room', log: [{ t: 'tool' }] } });++ await addTask({ id: cancelledId, state: 'cancelled', created: now - 5 * DAY, updated: now - 4 * DAY, prompt: 'Stopped task' });++ await addTask({ id: expiredId, state: 'expired', created: now - 6 * DAY, updated: now - 5 * DAY, prompt: 'Expired task' });++ await addTask({ id: oldId, state: 'succeeded', created: now - 45 * DAY, updated: now - 40 * DAY, prompt: 'Ancient' });++ await addTask({ id: theirsId, subject: bo.subject, state: 'running', created: now - 60_000, prompt: 'Bo\'s task' });++++ const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `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.deepStrictEqual([byId.get(queuedOld).bucket, byId.get(queuedOld).actions], ['running', ['open', 'cancel']], 'an old queued task still runs');++ assert.deepStrictEqual([byId.get(running).bucket, byId.get(running).actions], ['running', ['open', 'cancel']]);++ assert.deepStrictEqual([byId.get(doneId).bucket, byId.get(doneId).cost_usd, byId.get(doneId).steps, byId.get(doneId).actions], ['done', 0.0011, 2, ['open']]);++ assert.strictEqual(byId.get(doneId).title, 'Fix the bug');++ assert.strictEqual(byId.get(doneId).summary, 'All done & shipped.');++ assert.strictEqual(byId.get(doneId).agent, 'deepseek');++ assert.strictEqual(byId.get(failedId).bucket, 'failed');++ assert.strictEqual(byId.get(failedId).error, 'it broke');++ assert.strictEqual(byId.get(failedId).steps, 1);++ assert.strictEqual(byId.get(failedId).actions[0], 'open');++ assert.ok(!byId.get(failedId).actions.includes('retry'), 'Codes has no retry route');++ assert.deepStrictEqual([byId.get(cancelledId).bucket, byId.get(expiredId).bucket], ['failed', 'failed']);++ assert.ok(!byId.has(oldId), 'done and failed older than 30 days are gone');++ assert.ok(!byId.has(theirsId), 'never another person\'s task');++ for (const it of body.items) {++ assert.match(it.href, /^https:\/\//);++ assert.strictEqual(it.href, `https://openvibe.codes/run/tasks/${it.id}`);++ 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');++ });++++ await check('at most 50 items, newest first', async () => {++ const mara = t.network.addUser('mara');++ const newest = ids.newId('task');++ for (let i = 0; i < 55; i++) {++ await addTask({ id: i === 0 ? newest : ids.newId('task'), subject: mara.subject, state: 'running', prompt: `Task ${i}`, created: now - i * 60_000, updated: now - i * 60_000 });++ }++ const res = await t.get(`/api/v1/actions?subject=${mara.subject}`, { headers: { Authorization: `Bearer ${reader}` } });++ assert.strictEqual(res.status, 200, res.text);++ assert.strictEqual(res.json().items.length, 50);++ assert.strictEqual(res.json().items[0].id, newest);++ });++++ await check('a person\'s token, a service without the capability and a bad subject are refused', async () => {++ const person = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `Bearer ${t.network.userToken(ada)}` } });++ assert.strictEqual(person.status, 401);++ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `Bearer ${token(['codes.task.read'])}` } });++ 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', { headers: { Authorization: `Bearer ${reader}` } });++ assert.strictEqual(bad.status, 400);++ assert.strictEqual(bad.json().code, 'subject.invalid');++ const missing = await t.get('/api/v1/actions', { headers: { Authorization: `Bearer ${reader}` } });++ assert.strictEqual(missing.status, 400);++ });++ } finally {++ await t.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -19,8 +19,9 @@ const { check, done } = require('./helpers/boot');assert.strictEqual(manifest.status, 'alpha');assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);assert.strictEqual(manifest.exposure.state, 'live');-- // Contracts 0.162: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js).-- assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel']);++ // Contracts 0.229.0: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js)++ // and reads their Action Center items (http/actions-api.js).++ assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel', 'codes.actions.read']);for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);assert.deepStrictEqual(manifest.eventsProduced, []);const range = manifest.contractRanges['openvibe-contracts'];
@@ -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"
@@ -874,9 +874,9 @@
@@ -874,9 +874,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",
@@ -30,7 +30,7 @@
@@ -30,7 +30,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,81 @@
@@ -0,0 +1,81 @@
+'use strict';
+
+/**
+ * Codes' Action Center items (plan 2026-10-28 §3.10, common.action-item@1): one person's coding tasks as
+ * codes.task@1 already presents them, mapped to the five Action Center buckets. bucketFor() is exported on its own
+ * so its state table can be tested.
+ */
+
+const PUBLIC_ORIGIN = 'https://openvibe.codes';
+
+const STATE_BUCKET = Object.freeze({
+ queued: 'running',
+ placed: 'running',
+ running: 'running',
+ succeeded: 'done',
+ failed: 'failed',
+ cancelled: 'failed',
+ expired: 'failed',
+});
+
+function bucketFor(state) {
+ return STATE_BUCKET[state] || 'failed';
+}
+
+/** What the person may do from the list ([email protected]). */
+function actionsFor(bucket) {
+ if (bucket === 'running') return ['open', 'cancel'];
+ return ['open']; // no retry route on Codes
+}
+
+/** 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) => ({ amp: '&', lt: '<', gt: '>', quot: '"', '#39': "'", nbsp: ' ' })[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; }
+}
+
+/** Tool calls in the session log the result keeps, or null when it kept none. */
+function stepsOf(row) {
+ const result = parse(row.result);
+ if (!result || !Array.isArray(result.log)) return null;
+ return result.log.filter((line) => line && typeof line === 'object' && line.t === 'tool').length;
+}
+
+function toItem(row, presented) {
+ const result = presented.result || {};
+ const bucket = bucketFor(presented.state);
+ const agent = Array.isArray(result.agents) && result.agents.length ? result.agents.join(', ') : presented.agent;
+ const summary = result.summary || presented.error || '';
+ return {
+ service: 'codes',
+ id: presented.id,
+ kind: 'code_task',
+ title: plain(row.prompt || presented.id, 160) || presented.id,
+ summary: summary ? plain(summary, 400) : null,
+ bucket,
+ state: presented.state,
+ agent: agent ? plain(agent, 80) : null,
+ cost_usd: typeof result.cost_usd === 'number' && Number.isFinite(result.cost_usd) ? result.cost_usd : null,
+ steps: stepsOf(row),
+ error: presented.error ? plain(presented.error, 400) : null,
+ created_at: presented.created_at,
+ updated_at: presented.updated_at,
+ href: `${PUBLIC_ORIGIN}/run/tasks/${presented.id}`,
+ actions: actionsFor(bucket),
+ };
+}
+
+module.exports = { PUBLIC_ORIGIN, bucketFor, actionsFor, plain, stepsOf, toItem };
@@ -29,6 +29,7 @@ const { createHarnesses } = require('./domain/harnesses');
@@ -29,6 +29,7 @@ const { createHarnesses } = require('./domain/harnesses');
const { createPageRoutes } = require('./http/pages');
const { createPageRoutes } = require('./http/pages');
const { createRunRoutes } = require('./http/run');
const { createRunRoutes } = require('./http/run');
const { createTasksApi } = require('./http/tasks-api');
const { createTasksApi } = require('./http/tasks-api');
+const { createActionsApi } = require('./http/actions-api');
const { createRunClient } = require('./domain/run-client');
const { createRunClient } = require('./domain/run-client');
const { createApi } = require('./http/api');
const { createApi } = require('./http/api');
const { createMovedRoutes } = require('./http/moved');
const { createMovedRoutes } = require('./http/moved');
@@ -151,6 +152,7 @@ async function createApp(opts = {}) {
@@ -151,6 +152,7 @@ async function createApp(opts = {}) {
ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
ctx.run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createTasksApi({ config, store, keys, run: ctx.run, harnesses, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createTasksApi({ config, store, keys, run: ctx.run, harnesses, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createActionsApi({ config, store, keys, run: ctx.run, actorLimits: ctx.actorLimits, log }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
app.use('/api', (req, res) => contracts.http.sendProblem(res, 404, 'route.not_found', { detail: 'No such API route', ctx: req.ov }));
// ── Pages ───────────────────────────────────────────────
// ── Pages ───────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+'use strict';
+
+/**
+ * The Action Center read (plan 2026-10-28 §3.10): GET /api/v1/actions?subject=usr_…
+ *
+ * A Network service token for audience openvibe.codes holding codes.actions.read names one person and gets that
+ * person's coding tasks as common.action-list-result@1: everything queued/placed/running, then done and failed tasks
+ * from the last 30 days, newest updated_at first, at most 50. Never another person's tasks; a person's own token is
+ * refused, as is a token without the capability or a subject that is not a usr_… id.
+ */
+const { serviceAuth, capabilities, http } = require('openvibe-contracts');
+const { asyncRouter } = require('./router');
+const { createRunTasks } = require('../domain/run-tasks');
+const items = require('../actions/items');
+
+const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
+const RUNNING = ['queued', 'placed', 'running'];
+
+function createActionsApi({ config, store, keys, run, actorLimits, log = console }) {
+ const r = asyncRouter();
+ const tasks = createRunTasks({ store, run, config, log });
+ const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
+
+ const guard = async (req, res, next) => {
+ const header = String(req.headers.authorization || '');
+ if (!header.startsWith('Bearer ')) return problem(req, res, 401, 'token.required', `codes.actions.read needs a Network service token for ${config.serviceAudience}`);
+ if (!keys.loaded()) await keys.ensure();
+ const publicKey = keys.get();
+ if (!publicKey) return problem(req, res, 503, 'identity.unavailable', 'the Network signing key is not loaded yet');
+ const v = serviceAuth.verifyServiceToken(header.slice(7).trim(), { publicKey, issuer: config.networkIssuer, audience: config.serviceAudience });
+ if (!v.ok) return problem(req, res, 401, v.code || 'token.invalid', v.reason || 'the service token is invalid');
+ const sub = String(v.claims.sub || '');
+ if (!sub.startsWith('svc:')) return problem(req, res, 403, 'capability.denied', 'codes.actions.read is for first-party services');
+ const decision = capabilities.check(v.claims, 'codes.actions.read');
+ if (!decision.allowed) return problem(req, res, 403, decision.code || 'capability.denied', decision.reason || 'codes.actions.read not granted');
+ const subject = String(req.query.subject || '').trim();
+ if (!USER.test(subject)) return problem(req, res, 400, 'subject.invalid', 'subject must be a usr_… subject id');
+ req.principal = { sub: `user:${subject}`, subject, via: `service:${sub.slice(4)}` };
+ return next();
+ };
+
+ r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ r.get('/actions', guard, actorLimits.reads('codes.actions.read'), async (req, res) => {
+ const cutoff = store.now() - 30 * 86_400_000;
+ const rows = await store.db.many(`SELECT * FROM run_tasks
+ WHERE subject = $1 AND (state = ANY($2) OR updated_at >= $3)
+ ORDER BY updated_at DESC
+ LIMIT 50`, [req.principal.subject, RUNNING, cutoff]);
+ res.json({ items: rows.map((row) => items.toItem(row, tasks.present(row))) });
+ });
+
+ return r;
+}
+
+module.exports = { createActionsApi };
@@ -17,8 +17,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
@@ -17,8 +17,9 @@ const SERVICES_ORIGIN = 'https://openvibe.services';
// The console's paths (and everything under them), and the platform policy pages that moved with it.
// The console's paths (and everything under them), and the platform policy pages that moved with it.
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_PREFIXES = ['/docs', '/projects', '/releases', '/apps', '/oauth', '/tools', '/manifests', '/staff'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
const MOVED_EXACT = ['/policy/rfc', '/policy/compatibility', '/policy/licensing', '/policy/transparency'];
-// The console's API: everything under /api/v1 but the agent catalog and the coding tasks (codes.task.*).
-const API_STAYS = /^\/api\/v1\/(harnesses|tasks)(\/|$)/;
+// The console's API: everything under /api/v1 but the agent catalog, the coding tasks (codes.task.*) and the Action
+// Center read (codes.actions.read).
+const API_STAYS = /^\/api\/v1\/(harnesses|tasks|actions)(\/|$)/;
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
const under = (p, prefix) => p === prefix || p.startsWith(`${prefix}/`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+'use strict';
+/**
+ * Codes' Action Center read (plan 2026-10-28 §3.10, common.action-list-result@1): a first-party service with
+ * codes.actions.read names one person and gets their coding tasks, never another person's — running first-class,
+ * done and failed from the last 30 days only, newest first, at most 50, plain text, https links and the right
+ * actions. A person's token, a service without the capability and a bad subject are refused.
+ */
+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 run = { enabled: true, async submit() { throw new Error('not used'); }, async read() { throw new Error('not used'); }, async cancel() { throw new Error('not used'); }, async streamUrl() { throw new Error('not used'); } };
+ const t = await boot({ run });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const now = Date.now();
+ const token = (cap = ['codes.actions.read'], sub = 'svc:network') => {
+ const at = Math.floor(now / 1000);
+ return serviceAuth.signServiceToken({ iss: t.network.url, sub, actor_type: 'service', aud: ['openvibe.codes'], cap, iat: at, exp: at + 300, jti: crypto.randomUUID() }, t.network.privatePem);
+ };
+ const reader = token();
+ const db = t.ctx.store.db;
+ const project = ids.newId('project');
+
+ async function addTask({ subject = ada.subject, state = 'queued', created = now, updated = created, prompt = 'Fix the bug', result = null, error = null, id = ids.newId('task') }) {
+ await db.query(`INSERT INTO run_tasks (id, subject, project_id, repo, prompt, agent, state, result, error, created_at, updated_at)
+ VALUES ($1,$2,$3,'https://github.com/ada/site',$4,'deepseek',$5,$6::jsonb,$7,$8,$9)`,
+ [id, subject, project, prompt, state, result == null ? null : JSON.stringify(result), error, created, updated]);
+ return id;
+ }
+
+ await check('the state → bucket table and the actions each bucket allows', async () => {
+ const table = [['queued', 'running'], ['placed', 'running'], ['running', 'running'], ['succeeded', 'done'], ['failed', 'failed'], ['cancelled', 'failed'], ['expired', 'failed']];
+ for (const [state, bucket] of table) assert.strictEqual(items.bucketFor(state), bucket, state);
+ assert.deepStrictEqual(items.actionsFor('running'), ['open', 'cancel']);
+ for (const bucket of ['done', 'failed', 'waiting', 'scheduled']) assert.deepStrictEqual(items.actionsFor(bucket), ['open']);
+ assert.strictEqual(items.plain('<b>Fix</b> **the** [bug](https://x) & ship', 160), 'Fix the bug & ship');
+ assert.ok(items.plain('x'.repeat(500), 400).length <= 400);
+ });
+
+ const queuedOld = ids.newId('task');
+ const running = ids.newId('task');
+ const doneId = ids.newId('task');
+ const failedId = ids.newId('task');
+ const cancelledId = ids.newId('task');
+ const expiredId = ids.newId('task');
+ const oldId = ids.newId('task');
+ const theirsId = ids.newId('task');
+
+ await check('only that person\'s tasks, newest first, with the buckets, actions, text and links common.action-item@1 allows', async () => {
+ await addTask({ id: queuedOld, state: 'queued', created: now - 40 * DAY, prompt: 'Still waiting' });
+ await addTask({ id: running, state: 'running', created: now - 2 * 3600_000, prompt: '# Running **now**' });
+ await addTask({ id: doneId, state: 'succeeded', created: now - 2 * DAY, updated: now - DAY, prompt: '<b>Fix</b> **the** [bug](https://example.com)', result: { ok: true, agents: ['deepseek'], summary: '<p>All **done** & shipped.</p>', cost_usd: 0.0011, files: [{ path: 'a.js', added: 1, removed: 0 }], log: [{ t: 'tool' }, { t: 'text' }, { t: 'tool' }] } });
+ await addTask({ id: failedId, state: 'failed', created: now - 4 * DAY, updated: now - 3 * DAY, prompt: 'Broken task', error: '<script>it broke</script>', result: { ok: false, error: 'the machine ran out of room', log: [{ t: 'tool' }] } });
+ await addTask({ id: cancelledId, state: 'cancelled', created: now - 5 * DAY, updated: now - 4 * DAY, prompt: 'Stopped task' });
+ await addTask({ id: expiredId, state: 'expired', created: now - 6 * DAY, updated: now - 5 * DAY, prompt: 'Expired task' });
+ await addTask({ id: oldId, state: 'succeeded', created: now - 45 * DAY, updated: now - 40 * DAY, prompt: 'Ancient' });
+ await addTask({ id: theirsId, subject: bo.subject, state: 'running', created: now - 60_000, prompt: 'Bo\'s task' });
+
+ const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `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.deepStrictEqual([byId.get(queuedOld).bucket, byId.get(queuedOld).actions], ['running', ['open', 'cancel']], 'an old queued task still runs');
+ assert.deepStrictEqual([byId.get(running).bucket, byId.get(running).actions], ['running', ['open', 'cancel']]);
+ assert.deepStrictEqual([byId.get(doneId).bucket, byId.get(doneId).cost_usd, byId.get(doneId).steps, byId.get(doneId).actions], ['done', 0.0011, 2, ['open']]);
+ assert.strictEqual(byId.get(doneId).title, 'Fix the bug');
+ assert.strictEqual(byId.get(doneId).summary, 'All done & shipped.');
+ assert.strictEqual(byId.get(doneId).agent, 'deepseek');
+ assert.strictEqual(byId.get(failedId).bucket, 'failed');
+ assert.strictEqual(byId.get(failedId).error, 'it broke');
+ assert.strictEqual(byId.get(failedId).steps, 1);
+ assert.strictEqual(byId.get(failedId).actions[0], 'open');
+ assert.ok(!byId.get(failedId).actions.includes('retry'), 'Codes has no retry route');
+ assert.deepStrictEqual([byId.get(cancelledId).bucket, byId.get(expiredId).bucket], ['failed', 'failed']);
+ assert.ok(!byId.has(oldId), 'done and failed older than 30 days are gone');
+ assert.ok(!byId.has(theirsId), 'never another person\'s task');
+ for (const it of body.items) {
+ assert.match(it.href, /^https:\/\//);
+ assert.strictEqual(it.href, `https://openvibe.codes/run/tasks/${it.id}`);
+ 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');
+ });
+
+ await check('at most 50 items, newest first', async () => {
+ const mara = t.network.addUser('mara');
+ const newest = ids.newId('task');
+ for (let i = 0; i < 55; i++) {
+ await addTask({ id: i === 0 ? newest : ids.newId('task'), subject: mara.subject, state: 'running', prompt: `Task ${i}`, created: now - i * 60_000, updated: now - i * 60_000 });
+ }
+ const res = await t.get(`/api/v1/actions?subject=${mara.subject}`, { headers: { Authorization: `Bearer ${reader}` } });
+ assert.strictEqual(res.status, 200, res.text);
+ assert.strictEqual(res.json().items.length, 50);
+ assert.strictEqual(res.json().items[0].id, newest);
+ });
+
+ await check('a person\'s token, a service without the capability and a bad subject are refused', async () => {
+ const person = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `Bearer ${t.network.userToken(ada)}` } });
+ assert.strictEqual(person.status, 401);
+ const noCap = await t.get(`/api/v1/actions?subject=${ada.subject}`, { headers: { Authorization: `Bearer ${token(['codes.task.read'])}` } });
+ 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', { headers: { Authorization: `Bearer ${reader}` } });
+ assert.strictEqual(bad.status, 400);
+ assert.strictEqual(bad.json().code, 'subject.invalid');
+ const missing = await t.get('/api/v1/actions', { headers: { Authorization: `Bearer ${reader}` } });
+ assert.strictEqual(missing.status, 400);
+ });
+ } finally {
+ await t.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -19,8 +19,9 @@ const { check, done } = require('./helpers/boot');
@@ -19,8 +19,9 @@ const { check, done } = require('./helpers/boot');
assert.strictEqual(manifest.status, 'alpha');
assert.strictEqual(manifest.status, 'alpha');
assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
assert.deepStrictEqual(manifest.domains, ['openvibe.codes']);
assert.strictEqual(manifest.exposure.state, 'live');
assert.strictEqual(manifest.exposure.state, 'live');
- // Contracts 0.162: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js).
- assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel']);
+ // Contracts 0.229.0: a first-party service starts, reads and cancels a person's coding task (http/tasks-api.js)
+ // and reads their Action Center items (http/actions-api.js).
+ assert.deepStrictEqual(manifest.capabilities, ['codes.task.start', 'codes.task.read', 'codes.task.cancel', 'codes.actions.read']);
for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
for (const id of manifest.capabilities) assert.strictEqual(contracts.capabilities.get(id).status, 'active', id);
assert.deepStrictEqual(manifest.eventsProduced, []);
assert.deepStrictEqual(manifest.eventsProduced, []);
const range = manifest.contractRanges['openvibe-contracts'];
const range = manifest.contractRanges['openvibe-contracts'];