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)

OP OpenVibers committed 16 days ago · debd0160cc · 8 files, +274 −9

Changes

ddiff --git a/package-lock.json b/package-lock.json
iindex edf7234..789b2e7 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"
@@@ -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",
ddiff --git a/package.json b/package.json
iindex 9cbafbc..3f2446e 100644
---- a/package.json
++++ b/package.json
@@@ -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"
ddiff --git a/server/actions/items.js b/server/actions/items.js
nnew file mode 100644
iindex 0000000..3ebd92f
---- /dev/null
++++ b/server/actions/items.js
@@@ -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 };
ddiff --git a/server/app.js b/server/app.js
iindex dfcb547..bacb6cd 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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 ───────────────────────────────────────────────
ddiff --git a/server/http/actions-api.js b/server/http/actions-api.js
nnew file mode 100644
iindex 0000000..6272ec2
---- /dev/null
++++ b/server/http/actions-api.js
@@@ -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 };
ddiff --git a/server/http/moved.js b/server/http/moved.js
iindex 062f092..b8bcc58 100644
---- a/server/http/moved.js
++++ b/server/http/moved.js
@@@ -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}/`);
ddiff --git a/test/actions.test.js b/test/actions.test.js
nnew file mode 100644
iindex 0000000..2d5fc74
---- /dev/null
++++ b/test/actions.test.js
@@@ -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) &amp; 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** &amp; 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); });
ddiff --git a/test/contracts.test.js b/test/contracts.test.js
iindex 76f1c03..d821b63 100644
---- a/test/contracts.test.js
++++ b/test/contracts.test.js
@@@ -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'];