openvibers/openvibe.network Commits
The action feed speaks in words, and the Dock keeps the choice you made (#712)
Changes
@@@ -34,6 +34,10 @@ const DECIDE = {const CANCEL = {actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },};++const RETRY = {++ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.Actor' },++ ai: { cap: 'ai.run.create', path: (id) => '/api/v1/runs/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.AI' },++};const ACTIONS_TIMEOUT_MS = 2500;const CACHE_MS = 10_000;const CACHE_MAX = 500;@@@ -41,6 +45,28 @@ const PER_BUCKET = 50;const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };++// The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; this++// copy is what Network answers with until the Contracts release that carries the manifest is pinned here.++const ERROR_MAP = {++ 'route.unavailable': ['No model could take this task.', 'Pick a model or add an AI key, then retry.', 'pick_model'],++ 'provider.unavailable': ['The model provider is unavailable.', 'Retry when it is back.', 'retry'],++ 'provider.rate_limited': ['The model provider reached its limit.', 'Retry after the limit resets.', 'retry_later'],++ 'credentials.unavailable': ['Your AI key is unavailable.', 'Add or check your AI key.', 'add_key'],++ 'quota.refused': ['Your AI allowance is used up.', 'Pick another model or retry when it resets.', 'pick_model'],++ 'queue.full': ['AI is busy right now.', 'Retry in a moment.', 'retry_later'],++ 'run.interrupted': ['This run stopped during a service restart.', 'Retry the task.', 'retry'],++ 'task.failed': ['This task could not finish.', 'Open the task to review it, then retry.', 'open'],++};++function safeItem(it) {++ const detail = String(it.error || it.summary || '');++ const code = it.run_error && it.run_error.code || (detail.includes('no provider on this route') ? 'route.unavailable'++ : detail.includes('AI is resting') ? 'provider.rate_limited' : it.service === 'actor' ? 'task.failed' : 'unknown');++ const [words, next, action] = ERROR_MAP[code] || ['This run could not finish.', 'Open its details or retry.', 'open'];++ const run_error = it.bucket === 'failed' ? { code, words, next, action } : null;++ return { ...it, error: run_error ? words : it.error, summary: run_error ? words : it.summary, run_error,++ skill: it.skill || null, subject: it.subject || null, model: it.model || null, maker: it.maker || null,++ duration_ms: it.duration_ms == null ? null : it.duration_ms, step: it.step || null };++}/** A pending Network confirmation as an Action Center item (waiting). */function confirmationItem(c, agent) {@@@ -98,11 +124,12 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...['network', ...REMOTE].forEach((svc, i) => {sources[svc] = results[i].status;for (const it of results[i].items) {-- if (!validate('common.action-item@1', it).valid) {++ const { run_error, skill, subject, model, maker, duration_ms, step, ...legacy } = it;++ if (!validate('common.action-item@1', legacy).valid) {if (warned++ < 20) log.warn(`[Actions] ${svc} answered an item that is not common.action-item@1; dropped`);continue;}-- buckets[it.bucket].push(it);++ buckets[it.bucket].push(safeItem(it));}});for (const b of BUCKETS) {@@@ -121,8 +148,9 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (.../** Approve, decline or cancel one item through its authority. → common.action-item@1 */async function act(actor, service, id, action, ctx) {if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });-- if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });++ if (action !== 'approve' && action !== 'decline' && action !== 'cancel' && action !== 'retry') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });if (action === 'cancel') return await cancel(actor, service, id, ctx);++ if (action === 'retry') return await retry(actor, service, id);cache.delete(actor.subject);if (service === 'network') {const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });@@@ -169,7 +197,58 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...};}-- return { forPerson, act, _cache: cache };++ async function retry(actor, service, id) {++ const route = RETRY[service];++ if (!route) throw Object.assign(new Error('this run cannot retry here'), { status: 422, code: 'actions.unsupported' });++ const token = urls[service] ? selfToken('openvibe.' + service, [route.cap]) : null;++ if (!token) throw Object.assign(new Error(route.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });++ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + route.path(id), {++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },++ body: '{}', signal: AbortSignal.timeout(timeoutMs * 4),++ });++ const body = await res.json().catch(() => ({}));++ if (!res.ok) throw Object.assign(new Error(body.detail || 'This run could not restart.'), { status: res.status, code: body.code || 'actions.retry_failed' });++ cache.delete(actor.subject);++ return body;++ }++++ /**++ * The skills the Dock offers in one tap ("Things agents can do for you"): Actor's public catalogue, read here because++ * every site's connect-src reaches openvibe.network and refuses openvibe.actor. Only passed, active listings, only++ * the fields the Dock draws; kept five minutes; Actor not answering is an empty list.++ */++ let skillsKept = null; let skillsAt = 0;++ async function skillCatalogue() {++ if (skillsKept && now() - skillsAt < 5 * 60_000) return skillsKept;++ if (!urls.actor) return { listings: [] };++ try {++ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/skills`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });++ const body = res.ok ? await res.json().catch(() => ({})) : {};++ const listings = (Array.isArray(body.listings) ? body.listings : [])++ .filter((x) => x && x.skill && ID_RE.test(String(x.id || '')) && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active')++ .slice(0, 12)++ .map((x) => ({ id: String(x.id), skill: { name: String(x.skill.name || '').slice(0, 80), slug: String(x.skill.slug || '').slice(0, 80) }, scan: { state: 'passed' }, curator: { state: 'active' } }));++ skillsKept = { listings }; skillsAt = now();++ } catch { skillsKept = skillsKept || { listings: [] }; skillsAt = now(); }++ return skillsKept;++ }++++ /** A one-tap catalogue skill starts on Actor as this person; Network never executes the skill itself. */++ async function runSkill(actor, id, body) {++ if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such skill'), { status: 404, code: 'actions.skill_not_found' });++ const token = urls.actor ? selfToken('openvibe.actor', ['actor.skill.run']) : null;++ if (!token) throw Object.assign(new Error('Actor is not connected'), { status: 503, code: 'actions.not_connected' });++ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/runs`, {++ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': actor.subject },++ body: JSON.stringify({ skill: id, input: body.input || {}, choice: body.choice || { mode: 'balanced' } }), signal: AbortSignal.timeout(timeoutMs * 4),++ });++ const result = await res.json().catch(() => ({}));++ if (!res.ok) throw Object.assign(new Error(result.detail || 'This skill could not start.'), { status: res.status, code: result.code || 'actions.skill_failed' });++ cache.delete(actor.subject);++ return result;++ }++++ return { forPerson, act, runSkill, skillCatalogue, _cache: cache };}/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */@@@ -187,6 +266,14 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {http.sendProblem(res, 500, 'actions.error', { detail: 'could not read your actions' });}});++ r.get('/skills', requireAuth, async (req, res) => { res.json(await actions.skillCatalogue()); });++ r.post('/skills/:id/run', requireAuth, async (req, res) => {++ try {++ const actor = await store.actorOf(req.app.locals.db, req.user);++ const result = await actions.runSkill(actor, req.params.id, req.body || {});++ return res.status(201).json(result);++ } catch (err) { return http.sendProblem(res, err.status || 502, err.code || 'actions.skill_failed', { detail: err.message }); }++ });r.post('/:service/:id/:action', requireAuth, async (req, res) => {try {const actor = await store.actorOf(req.app.locals.db, req.user);@@@ -44,6 +44,19 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);++// Keep the new cross-site view fields usable while Network still runs the previous Contracts release. The base++// contract checks every established field; these two fields have the same bounds as its additive schema update.++function checkState(state) {++ const { vibe, agent_filter, ...base } = state;++ const check = validate(CONTRACT, base);++ if (!check.valid) return check;++ if (vibe !== undefined && vibe !== null && (!isObject(vibe) || Object.keys(vibe).some((k) => !['slug', 'at'].includes(k))++ || !/^[A-Za-z0-9_-]{1,80}$/.test(String(vibe.slug || '')) || !Number.isFinite(Date.parse(vibe.at)) || typeof vibe.at !== 'string'))++ return { valid: false, errors: [{ instancePath: '/vibe', message: 'must be a Vibe conversation and a date' }] };++ if (agent_filter !== undefined && !['all', 'running', 'waiting', 'scheduled', 'done', 'failed'].includes(agent_filter))++ return { valid: false, errors: [{ instancePath: '/agent_filter', message: 'must be an Agents filter' }] };++ return check;++}/** The default state, for a person who has never opened the Dock. */function defaultState(at) {@@@ -138,7 +151,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Dateif (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }// A row written by an older release (or by hand) is still shown, but only if it is a state the contract// accepts: a broken row falls back to the default rather than breaking the page that reads it.-- return state && validate(CONTRACT, state).valid ? state : defaultState(at);++ return state && checkState(state).valid ? state : defaultState(at);}/** The counts the pill's status dot is drawn from. */@@@ -202,7 +215,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date}const current = await stateOf(actor, at);const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };-- const check = validate(CONTRACT, merged);++ const check = checkState(merged);if (!check.valid) {throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });}@@@ -227,7 +240,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Dateif (bad) throw Object.assign(new Error(bad.error), { status: 422, code: 'dock.invalid', errors: bad.errors });const current = await stateOf(actor, at);const merged = { ...current, timer: timerOf(body, at), updated_at: new Date(at).toISOString() };-- const check = validate(CONTRACT, merged);++ const check = checkState(merged);if (!check.valid) {throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });}@@@ -133,6 +133,14 @@ try {assert.strictEqual(r.body.state.agent, null, 'null clears the agent');assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');++ // The Vibe conversation and the Agents filter: the choice a person made follows them across sites.++ r = await call('PUT', { vibe: { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' }, agent_filter: 'failed' });++ assert.deepStrictEqual(r.body.state.vibe, { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' });++ assert.strictEqual(r.body.state.agent_filter, 'failed');++ r = await call('PUT', { vibe: null });++ assert.strictEqual(r.body.state.vibe, null, 'null clears the conversation');++ assert.strictEqual(r.body.state.agent_filter, 'failed', 'the filter was not in the body and stayed');++// An invalid body is refused with the contract's problem shape.for (const [body, why] of [[{ tab: 'profile' }, 'an unknown tab'],@@@ -141,6 +149,9 @@ try {[{ theme: 'neon-tokyo' }, 'a key the contract does not know'],[{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],[{ chat: '' }, 'an empty chat id'],++ [{ vibe: { slug: 'not a slug', at: '2026-11-09T08:00:00.000Z' } }, 'a conversation slug with spaces'],++ [{ vibe: { slug: 'v-abc', at: 'yesterday' } }, 'a conversation with no time'],++ [{ agent_filter: 'everything' }, 'an Agents filter the Dock does not know'],]) {const before = (await call('GET')).body.state;r = await call('PUT', body);@@@ -31,6 +31,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_vconst calls = [];let aiDelay = 0;let cancelled = false;++let skillReads = 0;const svc = http.createServer((req, res) => {let body = '';req.on('data', (c) => { body += c; });@@@ -38,6 +39,12 @@ const svc = http.createServer((req, res) => {const u = new URL(req.url, 'http://x');calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };++ if (u.pathname === '/actor/api/v1/skills') { skillReads++; return json(200, { listings: [++ { id: 'skl_ok', skill: { name: 'Research brief', slug: 'research-brief', instructions: 'secret prompt' }, scan: { state: 'passed' }, curator: { state: 'active' } },++ { id: 'skl_flagged', skill: { name: 'Flagged', slug: 'flagged' }, scan: { state: 'failed' }, curator: { state: 'active' } },++ { id: '../x', skill: { name: 'Odd id', slug: 'odd' }, scan: { state: 'passed' }, curator: { state: 'active' } },++ ] }); }++ if (u.pathname === '/actor/api/v1/runs' && req.method === 'POST') return json(201, { run: { id: 'tsk_new', skill: 'research-brief', subject: 'Research brief' } });if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),@@@ -54,6 +61,7 @@ const svc = http.createServer((req, res) => {if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }++ if (u.pathname === '/actor/api/v1/tasks/tsk_old/retry') return json(201, { run: { id: 'tsk_retry', state: 'queued' } });if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));@@@ -88,7 +96,7 @@ app.use('/api/v1/me/actions', actionsMod.router({ actions, requireAuth }));const server = http.createServer(app);await new Promise((r) => server.listen(0, '127.0.0.1', r));const api = `http://127.0.0.1:${server.address().port}/api/v1/me/actions`;--const call = (method, path = '', auth = true) => fetch(api + path, { method, headers: auth ? { authorization: `Bearer ${token}` } : {} }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));++const call = (method, path = '', auth = true, body = null) => fetch(api + path, { method, headers: { ...(auth ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));try {assert.strictEqual((await call('GET', '', false)).status, 401, 'a session is needed');@@@ -97,7 +105,8 @@ try {let r = await call('GET');assert.strictEqual(r.status, 200);const doc = r.body;-- assert.ok(validate('network.me-actions-result@1', doc).valid, JSON.stringify(validate('network.me-actions-result@1', doc).errors));++ const legacyDoc = { ...doc, buckets: Object.fromEntries(Object.entries(doc.buckets).map(([name, items]) => [name, items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy)])) };++ assert.ok(validate('network.me-actions-result@1', legacyDoc).valid, JSON.stringify(validate('network.me-actions-result@1', legacyDoc).errors));assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout', domains: 'ok' });assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');assert.deepStrictEqual(doc.counts, { running: 1, waiting: 5, scheduled: 1, done: 0, failed: 1 });@@@ -155,6 +164,21 @@ try {const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');++ r = await call('POST', '/actor/tsk_old/retry');++ assert.strictEqual(r.body.item.run.id, 'tsk_retry');++ const retried = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_old/retry');++ assert.deepStrictEqual([retried.auth, retried.subject.startsWith('usr_')], ['Bearer tok:openvibe.actor:actor.task.create', true]);++ r = await call('POST', '/skills/skl_example/run', true, { input: {}, choice: { mode: 'balanced' } });++ assert.strictEqual(r.status, 201, JSON.stringify(r.body));++ const started = calls.find((c) => c.path === '/actor/api/v1/runs');++ assert.deepStrictEqual([started.auth, started.subject.startsWith('usr_'), started.body.skill], ['Bearer tok:openvibe.actor:actor.skill.run', true, 'skl_example']);++ // The Dock's one-tap skills come through Network (every site's connect-src refuses openvibe.actor): passed, active,++ // plain ids, the fields the Dock draws only (never a skill's instructions), kept five minutes.++ r = await call('GET', '/skills');++ assert.deepStrictEqual(r.body.listings.map((x) => x.id), ['skl_ok']);++ assert.ok(!JSON.stringify(r.body).includes('secret prompt'), 'a skill\'s instructions never leave Actor here');++ await call('GET', '/skills');++ assert.strictEqual(skillReads, 1, 'the catalogue is kept');assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');// Codes has no cancel route, and Codes is not connected for it either.assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');@@@ -135,7 +135,7 @@ const REVIEWED = {'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],-- 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],++ 'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
@@ -34,6 +34,10 @@ const DECIDE = {
@@ -34,6 +34,10 @@ const DECIDE = {
const CANCEL = {
const CANCEL = {
actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },
actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/cancel', name: 'OpenVibe.Actor', what: 'task' },
};
};
+const RETRY = {
+ actor: { cap: 'actor.task.create', path: (id) => '/api/v1/tasks/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.Actor' },
+ ai: { cap: 'ai.run.create', path: (id) => '/api/v1/runs/' + encodeURIComponent(id) + '/retry', name: 'OpenVibe.AI' },
+};
const ACTIONS_TIMEOUT_MS = 2500;
const ACTIONS_TIMEOUT_MS = 2500;
const CACHE_MS = 10_000;
const CACHE_MS = 10_000;
const CACHE_MAX = 500;
const CACHE_MAX = 500;
@@ -41,6 +45,28 @@ const PER_BUCKET = 50;
@@ -41,6 +45,28 @@ const PER_BUCKET = 50;
const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
const ID_RE = /^[A-Za-z0-9_-]{1,80}$/;
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
+// The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; this
+// copy is what Network answers with until the Contracts release that carries the manifest is pinned here.
+const ERROR_MAP = {
+ 'route.unavailable': ['No model could take this task.', 'Pick a model or add an AI key, then retry.', 'pick_model'],
+ 'provider.unavailable': ['The model provider is unavailable.', 'Retry when it is back.', 'retry'],
+ 'provider.rate_limited': ['The model provider reached its limit.', 'Retry after the limit resets.', 'retry_later'],
+ 'credentials.unavailable': ['Your AI key is unavailable.', 'Add or check your AI key.', 'add_key'],
+ 'quota.refused': ['Your AI allowance is used up.', 'Pick another model or retry when it resets.', 'pick_model'],
+ 'queue.full': ['AI is busy right now.', 'Retry in a moment.', 'retry_later'],
+ 'run.interrupted': ['This run stopped during a service restart.', 'Retry the task.', 'retry'],
+ 'task.failed': ['This task could not finish.', 'Open the task to review it, then retry.', 'open'],
+};
+function safeItem(it) {
+ const detail = String(it.error || it.summary || '');
+ const code = it.run_error && it.run_error.code || (detail.includes('no provider on this route') ? 'route.unavailable'
+ : detail.includes('AI is resting') ? 'provider.rate_limited' : it.service === 'actor' ? 'task.failed' : 'unknown');
+ const [words, next, action] = ERROR_MAP[code] || ['This run could not finish.', 'Open its details or retry.', 'open'];
+ const run_error = it.bucket === 'failed' ? { code, words, next, action } : null;
+ return { ...it, error: run_error ? words : it.error, summary: run_error ? words : it.summary, run_error,
+ skill: it.skill || null, subject: it.subject || null, model: it.model || null, maker: it.maker || null,
+ duration_ms: it.duration_ms == null ? null : it.duration_ms, step: it.step || null };
+}
/** A pending Network confirmation as an Action Center item (waiting). */
/** A pending Network confirmation as an Action Center item (waiting). */
function confirmationItem(c, agent) {
function confirmationItem(c, agent) {
@@ -98,11 +124,12 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -98,11 +124,12 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
['network', ...REMOTE].forEach((svc, i) => {
['network', ...REMOTE].forEach((svc, i) => {
sources[svc] = results[i].status;
sources[svc] = results[i].status;
for (const it of results[i].items) {
for (const it of results[i].items) {
- if (!validate('common.action-item@1', it).valid) {
+ const { run_error, skill, subject, model, maker, duration_ms, step, ...legacy } = it;
+ if (!validate('common.action-item@1', legacy).valid) {
if (warned++ < 20) log.warn(`[Actions] ${svc} answered an item that is not common.action-item@1; dropped`);
if (warned++ < 20) log.warn(`[Actions] ${svc} answered an item that is not common.action-item@1; dropped`);
continue;
continue;
}
}
- buckets[it.bucket].push(it);
+ buckets[it.bucket].push(safeItem(it));
}
}
});
});
for (const b of BUCKETS) {
for (const b of BUCKETS) {
@@ -121,8 +148,9 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -121,8 +148,9 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
/** Approve, decline or cancel one item through its authority. → common.action-item@1 */
/** Approve, decline or cancel one item through its authority. → common.action-item@1 */
async function act(actor, service, id, action, ctx) {
async function act(actor, service, id, action, ctx) {
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such item'), { status: 404, code: 'actions.not_found' });
- if (action !== 'approve' && action !== 'decline' && action !== 'cancel') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
+ if (action !== 'approve' && action !== 'decline' && action !== 'cancel' && action !== 'retry') throw Object.assign(new Error('this item cannot do that here: open it instead'), { status: 422, code: 'actions.unsupported' });
if (action === 'cancel') return await cancel(actor, service, id, ctx);
if (action === 'cancel') return await cancel(actor, service, id, ctx);
+ if (action === 'retry') return await retry(actor, service, id);
cache.delete(actor.subject);
cache.delete(actor.subject);
if (service === 'network') {
if (service === 'network') {
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
const out = await confirmationsImpl.decide(db, actor, id, action === 'approve' ? 'approve' : 'deny', {}, { ctx });
@@ -169,7 +197,58 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
@@ -169,7 +197,58 @@ function createActions({ db, selfToken = () => null, urls = {}, fetchImpl = (...
};
};
}
}
- return { forPerson, act, _cache: cache };
+ async function retry(actor, service, id) {
+ const route = RETRY[service];
+ if (!route) throw Object.assign(new Error('this run cannot retry here'), { status: 422, code: 'actions.unsupported' });
+ const token = urls[service] ? selfToken('openvibe.' + service, [route.cap]) : null;
+ if (!token) throw Object.assign(new Error(route.name + ' is not connected'), { status: 503, code: 'actions.not_connected' });
+ const res = await fetchImpl(String(urls[service]).replace(/\/+$/, '') + route.path(id), {
+ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: 'Bearer ' + token, 'x-ov-subject': actor.subject },
+ body: '{}', signal: AbortSignal.timeout(timeoutMs * 4),
+ });
+ const body = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(body.detail || 'This run could not restart.'), { status: res.status, code: body.code || 'actions.retry_failed' });
+ cache.delete(actor.subject);
+ return body;
+ }
+
+ /**
+ * The skills the Dock offers in one tap ("Things agents can do for you"): Actor's public catalogue, read here because
+ * every site's connect-src reaches openvibe.network and refuses openvibe.actor. Only passed, active listings, only
+ * the fields the Dock draws; kept five minutes; Actor not answering is an empty list.
+ */
+ let skillsKept = null; let skillsAt = 0;
+ async function skillCatalogue() {
+ if (skillsKept && now() - skillsAt < 5 * 60_000) return skillsKept;
+ if (!urls.actor) return { listings: [] };
+ try {
+ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/skills`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
+ const body = res.ok ? await res.json().catch(() => ({})) : {};
+ const listings = (Array.isArray(body.listings) ? body.listings : [])
+ .filter((x) => x && x.skill && ID_RE.test(String(x.id || '')) && x.scan && x.scan.state === 'passed' && x.curator && x.curator.state === 'active')
+ .slice(0, 12)
+ .map((x) => ({ id: String(x.id), skill: { name: String(x.skill.name || '').slice(0, 80), slug: String(x.skill.slug || '').slice(0, 80) }, scan: { state: 'passed' }, curator: { state: 'active' } }));
+ skillsKept = { listings }; skillsAt = now();
+ } catch { skillsKept = skillsKept || { listings: [] }; skillsAt = now(); }
+ return skillsKept;
+ }
+
+ /** A one-tap catalogue skill starts on Actor as this person; Network never executes the skill itself. */
+ async function runSkill(actor, id, body) {
+ if (!ID_RE.test(String(id))) throw Object.assign(new Error('no such skill'), { status: 404, code: 'actions.skill_not_found' });
+ const token = urls.actor ? selfToken('openvibe.actor', ['actor.skill.run']) : null;
+ if (!token) throw Object.assign(new Error('Actor is not connected'), { status: 503, code: 'actions.not_connected' });
+ const res = await fetchImpl(`${String(urls.actor).replace(/\/+$/, '')}/api/v1/runs`, {
+ method: 'POST', headers: { accept: 'application/json', 'content-type': 'application/json', authorization: `Bearer ${token}`, 'x-ov-subject': actor.subject },
+ body: JSON.stringify({ skill: id, input: body.input || {}, choice: body.choice || { mode: 'balanced' } }), signal: AbortSignal.timeout(timeoutMs * 4),
+ });
+ const result = await res.json().catch(() => ({}));
+ if (!res.ok) throw Object.assign(new Error(result.detail || 'This skill could not start.'), { status: res.status, code: result.code || 'actions.skill_failed' });
+ cache.delete(actor.subject);
+ return result;
+ }
+
+ return { forPerson, act, runSkill, skillCatalogue, _cache: cache };
}
}
/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */
/** The routes, behind Network's session check (cookie on openvibe.network, Bearer elsewhere), like the bell's. */
@@ -187,6 +266,14 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
@@ -187,6 +266,14 @@ function router({ actions, requireAuth, requireDock = () => requireAuth }) {
http.sendProblem(res, 500, 'actions.error', { detail: 'could not read your actions' });
http.sendProblem(res, 500, 'actions.error', { detail: 'could not read your actions' });
}
}
});
});
+ r.get('/skills', requireAuth, async (req, res) => { res.json(await actions.skillCatalogue()); });
+ r.post('/skills/:id/run', requireAuth, async (req, res) => {
+ try {
+ const actor = await store.actorOf(req.app.locals.db, req.user);
+ const result = await actions.runSkill(actor, req.params.id, req.body || {});
+ return res.status(201).json(result);
+ } catch (err) { return http.sendProblem(res, err.status || 502, err.code || 'actions.skill_failed', { detail: err.message }); }
+ });
r.post('/:service/:id/:action', requireAuth, async (req, res) => {
r.post('/:service/:id/:action', requireAuth, async (req, res) => {
try {
try {
const actor = await store.actorOf(req.app.locals.db, req.user);
const actor = await store.actorOf(req.app.locals.db, req.user);
@@ -44,6 +44,19 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
@@ -44,6 +44,19 @@ const SUBJECT_RE = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const CORNERS = ['bottom-right', 'bottom-left', 'top-right', 'top-left'];
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
const isObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
+// Keep the new cross-site view fields usable while Network still runs the previous Contracts release. The base
+// contract checks every established field; these two fields have the same bounds as its additive schema update.
+function checkState(state) {
+ const { vibe, agent_filter, ...base } = state;
+ const check = validate(CONTRACT, base);
+ if (!check.valid) return check;
+ if (vibe !== undefined && vibe !== null && (!isObject(vibe) || Object.keys(vibe).some((k) => !['slug', 'at'].includes(k))
+ || !/^[A-Za-z0-9_-]{1,80}$/.test(String(vibe.slug || '')) || !Number.isFinite(Date.parse(vibe.at)) || typeof vibe.at !== 'string'))
+ return { valid: false, errors: [{ instancePath: '/vibe', message: 'must be a Vibe conversation and a date' }] };
+ if (agent_filter !== undefined && !['all', 'running', 'waiting', 'scheduled', 'done', 'failed'].includes(agent_filter))
+ return { valid: false, errors: [{ instancePath: '/agent_filter', message: 'must be an Agents filter' }] };
+ return check;
+}
/** The default state, for a person who has never opened the Dock. */
/** The default state, for a person who has never opened the Dock. */
function defaultState(at) {
function defaultState(at) {
@@ -138,7 +151,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
@@ -138,7 +151,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
if (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }
if (typeof state === 'string') { try { state = JSON.parse(state); } catch { state = null; } }
// A row written by an older release (or by hand) is still shown, but only if it is a state the contract
// A row written by an older release (or by hand) is still shown, but only if it is a state the contract
// accepts: a broken row falls back to the default rather than breaking the page that reads it.
// accepts: a broken row falls back to the default rather than breaking the page that reads it.
- return state && validate(CONTRACT, state).valid ? state : defaultState(at);
+ return state && checkState(state).valid ? state : defaultState(at);
}
}
/** The counts the pill's status dot is drawn from. */
/** The counts the pill's status dot is drawn from. */
@@ -202,7 +215,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
@@ -202,7 +215,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
}
}
const current = await stateOf(actor, at);
const current = await stateOf(actor, at);
const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };
const merged = { ...mergeState(current, body), updated_at: new Date(at).toISOString() };
- const check = validate(CONTRACT, merged);
+ const check = checkState(merged);
if (!check.valid) {
if (!check.valid) {
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
}
}
@@ -227,7 +240,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
@@ -227,7 +240,7 @@ function createDock({ db, actions = null, notifications = null, now = () => Date
if (bad) throw Object.assign(new Error(bad.error), { status: 422, code: 'dock.invalid', errors: bad.errors });
if (bad) throw Object.assign(new Error(bad.error), { status: 422, code: 'dock.invalid', errors: bad.errors });
const current = await stateOf(actor, at);
const current = await stateOf(actor, at);
const merged = { ...current, timer: timerOf(body, at), updated_at: new Date(at).toISOString() };
const merged = { ...current, timer: timerOf(body, at), updated_at: new Date(at).toISOString() };
- const check = validate(CONTRACT, merged);
+ const check = checkState(merged);
if (!check.valid) {
if (!check.valid) {
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
throw Object.assign(new Error('that is not a Dock state'), { status: 422, code: 'dock.invalid', errors: check.errors });
}
}
@@ -133,6 +133,14 @@ try {
@@ -133,6 +133,14 @@ try {
assert.strictEqual(r.body.state.agent, null, 'null clears the agent');
assert.strictEqual(r.body.state.agent, null, 'null clears the agent');
assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');
assert.strictEqual(r.body.state.chat, 'cnv_1', 'the chat was not in the body and stayed');
+ // The Vibe conversation and the Agents filter: the choice a person made follows them across sites.
+ r = await call('PUT', { vibe: { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' }, agent_filter: 'failed' });
+ assert.deepStrictEqual(r.body.state.vibe, { slug: 'v-abc', at: '2026-11-09T08:00:00.000Z' });
+ assert.strictEqual(r.body.state.agent_filter, 'failed');
+ r = await call('PUT', { vibe: null });
+ assert.strictEqual(r.body.state.vibe, null, 'null clears the conversation');
+ assert.strictEqual(r.body.state.agent_filter, 'failed', 'the filter was not in the body and stayed');
+
// An invalid body is refused with the contract's problem shape.
// An invalid body is refused with the contract's problem shape.
for (const [body, why] of [
for (const [body, why] of [
[{ tab: 'profile' }, 'an unknown tab'],
[{ tab: 'profile' }, 'an unknown tab'],
@@ -141,6 +149,9 @@ try {
@@ -141,6 +149,9 @@ try {
[{ theme: 'neon-tokyo' }, 'a key the contract does not know'],
[{ theme: 'neon-tokyo' }, 'a key the contract does not know'],
[{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],
[{ place: { tablet: { corner: 'top-left' } } }, 'a device class the contract does not know'],
[{ chat: '' }, 'an empty chat id'],
[{ chat: '' }, 'an empty chat id'],
+ [{ vibe: { slug: 'not a slug', at: '2026-11-09T08:00:00.000Z' } }, 'a conversation slug with spaces'],
+ [{ vibe: { slug: 'v-abc', at: 'yesterday' } }, 'a conversation with no time'],
+ [{ agent_filter: 'everything' }, 'an Agents filter the Dock does not know'],
]) {
]) {
const before = (await call('GET')).body.state;
const before = (await call('GET')).body.state;
r = await call('PUT', body);
r = await call('PUT', body);
@@ -31,6 +31,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
@@ -31,6 +31,7 @@ await db.prepare("INSERT INTO users (id, username, email, password_hash, email_v
const calls = [];
const calls = [];
let aiDelay = 0;
let aiDelay = 0;
let cancelled = false;
let cancelled = false;
+let skillReads = 0;
const svc = http.createServer((req, res) => {
const svc = http.createServer((req, res) => {
let body = '';
let body = '';
req.on('data', (c) => { body += c; });
req.on('data', (c) => { body += c; });
@@ -38,6 +39,12 @@ const svc = http.createServer((req, res) => {
@@ -38,6 +39,12 @@ const svc = http.createServer((req, res) => {
const u = new URL(req.url, 'http://x');
const u = new URL(req.url, 'http://x');
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
calls.push({ method: req.method, path: u.pathname, subject: u.searchParams.get('subject') || req.headers['x-ov-subject'] || null, auth: req.headers.authorization, body: body ? JSON.parse(body) : null });
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
const json = (s, b) => { res.writeHead(s, { 'content-type': 'application/json' }); res.end(JSON.stringify(b)); };
+ if (u.pathname === '/actor/api/v1/skills') { skillReads++; return json(200, { listings: [
+ { id: 'skl_ok', skill: { name: 'Research brief', slug: 'research-brief', instructions: 'secret prompt' }, scan: { state: 'passed' }, curator: { state: 'active' } },
+ { id: 'skl_flagged', skill: { name: 'Flagged', slug: 'flagged' }, scan: { state: 'failed' }, curator: { state: 'active' } },
+ { id: '../x', skill: { name: 'Odd id', slug: 'odd' }, scan: { state: 'passed' }, curator: { state: 'active' } },
+ ] }); }
+ if (u.pathname === '/actor/api/v1/runs' && req.method === 'POST') return json(201, { run: { id: 'tsk_new', skill: 'research-brief', subject: 'Research brief' } });
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
if (u.pathname === '/actor/api/v1/actions') return json(200, { items: [
item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),
item(cancelled ? { state: 'cancelled', bucket: 'failed', actions: ['open'], updated_at: iso(1) } : {}),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
item({ id: 'apr_1', kind: 'approval', title: 'Spend $0.40 on a desk hour', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(5), href: 'https://openvibe.actor/me/approvals' }),
@@ -54,6 +61,7 @@ const svc = http.createServer((req, res) => {
@@ -54,6 +61,7 @@ const svc = http.createServer((req, res) => {
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_1/decide') return json(200, item({ id: 'apr_1', kind: 'approval', bucket: 'done', state: 'approved', actions: ['open'] }));
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
if (u.pathname === '/actor/api/v1/approvals/apr_404/decide') return json(404, { code: 'not_found' });
if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }
if (u.pathname === '/actor/api/v1/tasks/tsk_1/cancel') { cancelled = true; return json(200, { id: 'tsk_1', state: 'cancelled' }); }
+ if (u.pathname === '/actor/api/v1/tasks/tsk_old/retry') return json(201, { run: { id: 'tsk_retry', state: 'queued' } });
if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });
if (u.pathname === '/actor/api/v1/tasks/tsk_404/cancel') return json(404, { code: 'not_found' });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/actions') return json(200, { items: [item({ service: 'domains', id: 'dpr_1', kind: 'approval', title: 'Add a TXT record to example.com', bucket: 'waiting', state: 'pending', actions: ['approve', 'decline', 'open'], updated_at: iso(20), href: 'https://openvibe.domains/panel/p/dpr_1' })] });
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
if (u.pathname === '/domains/api/v1/proposals/dpr_1/decide') return json(200, item({ service: 'domains', id: 'dpr_1', kind: 'approval', bucket: 'done', state: 'applied', actions: ['open'], href: 'https://openvibe.domains/panel/p/dpr_1' }));
@@ -88,7 +96,7 @@ app.use('/api/v1/me/actions', actionsMod.router({ actions, requireAuth }));
@@ -88,7 +96,7 @@ app.use('/api/v1/me/actions', actionsMod.router({ actions, requireAuth }));
const server = http.createServer(app);
const server = http.createServer(app);
await new Promise((r) => server.listen(0, '127.0.0.1', r));
await new Promise((r) => server.listen(0, '127.0.0.1', r));
const api = `http://127.0.0.1:${server.address().port}/api/v1/me/actions`;
const api = `http://127.0.0.1:${server.address().port}/api/v1/me/actions`;
-const call = (method, path = '', auth = true) => fetch(api + path, { method, headers: auth ? { authorization: `Bearer ${token}` } : {} }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
+const call = (method, path = '', auth = true, body = null) => fetch(api + path, { method, headers: { ...(auth ? { authorization: `Bearer ${token}` } : {}), ...(body ? { 'content-type': 'application/json' } : {}) }, ...(body ? { body: JSON.stringify(body) } : {}) }).then(async (r) => ({ status: r.status, body: await r.json().catch(() => ({})) }));
try {
try {
assert.strictEqual((await call('GET', '', false)).status, 401, 'a session is needed');
assert.strictEqual((await call('GET', '', false)).status, 401, 'a session is needed');
@@ -97,7 +105,8 @@ try {
@@ -97,7 +105,8 @@ try {
let r = await call('GET');
let r = await call('GET');
assert.strictEqual(r.status, 200);
assert.strictEqual(r.status, 200);
const doc = r.body;
const doc = r.body;
- assert.ok(validate('network.me-actions-result@1', doc).valid, JSON.stringify(validate('network.me-actions-result@1', doc).errors));
+ const legacyDoc = { ...doc, buckets: Object.fromEntries(Object.entries(doc.buckets).map(([name, items]) => [name, items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy)])) };
+ assert.ok(validate('network.me-actions-result@1', legacyDoc).valid, JSON.stringify(validate('network.me-actions-result@1', legacyDoc).errors));
assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout', domains: 'ok' });
assert.deepStrictEqual(doc.sources, { network: 'ok', actor: 'ok', codes: 'not_connected', ai: 'timeout', domains: 'ok' });
assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');
assert.strictEqual(doc.partial, true, 'a timeout makes the answer partial');
assert.deepStrictEqual(doc.counts, { running: 1, waiting: 5, scheduled: 1, done: 0, failed: 1 });
assert.deepStrictEqual(doc.counts, { running: 1, waiting: 5, scheduled: 1, done: 0, failed: 1 });
@@ -155,6 +164,21 @@ try {
@@ -155,6 +164,21 @@ try {
const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');
const dc = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_1/cancel');
assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);
assert.deepStrictEqual([dc.method, dc.auth, dc.subject.startsWith('usr_')], ['POST', 'Bearer tok:openvibe.actor:actor.task.create', true]);
assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');
assert.deepStrictEqual([r.body.item.state, r.body.item.bucket], ['cancelled', 'failed'], 'the item comes back as it is now');
+ r = await call('POST', '/actor/tsk_old/retry');
+ assert.strictEqual(r.body.item.run.id, 'tsk_retry');
+ const retried = calls.find((c) => c.path === '/actor/api/v1/tasks/tsk_old/retry');
+ assert.deepStrictEqual([retried.auth, retried.subject.startsWith('usr_')], ['Bearer tok:openvibe.actor:actor.task.create', true]);
+ r = await call('POST', '/skills/skl_example/run', true, { input: {}, choice: { mode: 'balanced' } });
+ assert.strictEqual(r.status, 201, JSON.stringify(r.body));
+ const started = calls.find((c) => c.path === '/actor/api/v1/runs');
+ assert.deepStrictEqual([started.auth, started.subject.startsWith('usr_'), started.body.skill], ['Bearer tok:openvibe.actor:actor.skill.run', true, 'skl_example']);
+ // The Dock's one-tap skills come through Network (every site's connect-src refuses openvibe.actor): passed, active,
+ // plain ids, the fields the Dock draws only (never a skill's instructions), kept five minutes.
+ r = await call('GET', '/skills');
+ assert.deepStrictEqual(r.body.listings.map((x) => x.id), ['skl_ok']);
+ assert.ok(!JSON.stringify(r.body).includes('secret prompt'), 'a skill\'s instructions never leave Actor here');
+ await call('GET', '/skills');
+ assert.strictEqual(skillReads, 1, 'the catalogue is kept');
assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');
assert.strictEqual((await call('POST', '/actor/tsk_404/cancel')).status, 404, 'no such task');
// Codes has no cancel route, and Codes is not connected for it either.
// Codes has no cancel route, and Codes is not connected for it either.
assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');
assert.strictEqual((await call('POST', '/codes/tsk_1/approve')).body.code, 'actions.unsupported');
@@ -135,7 +135,7 @@ const REVIEWED = {
@@ -135,7 +135,7 @@ const REVIEWED = {
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/integrations/github.js fetch': [1, 'fixed host api.github.com (asserted below)'],
'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
'server/node/send.js fetch': [3, 'browser-only Link Send requests to fixed same-origin machine-key and transfer routes; page input supplies file bytes, never a URL or destination host'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
'server/notifications/email-service.js http.request': [1, 'fixed host api.resend.com'],
- 'server/actions/index.js fetch': [4, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route and the cancel route), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
+ 'server/actions/index.js fetch': [7, 'The Action Center: OpenVibe.Actor, OpenVibe.Codes and OpenVibe.AI only (OV_ACTOR_INTERNAL_URL, OV_CODES_INTERNAL_URL, OV_AI_INTERNAL_URL, loopback); the paths are fixed (the decide route, the cancel route, the retry route of Actor or AI, Actor\'s public GET /api/v1/skills catalogue, and Actor\'s POST /api/v1/runs for a catalogue skill whose id must match the skill id pattern), the subject is the signed-in person\'s own usr_ id, and an item id must match [A-Za-z0-9_-]{1,80}'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/public.js fetch': [6, 'OpenVibe.Inventory, Quest and Play use operator-configured internal URLs and fixed paths with a checked usr_ id. Public place reads use only the configured Live, Community and Space service URLs, fixed public paths, encoded canonical usernames, 2 s timeouts and no user-supplied host or token; Space results are restricted to the exact owner and Published cards'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/profile/avatar.js fetch': [4, 'Live (push) and Media (ingest, release) internal URLs from config; a picture is never fetched here — Media\'s ingest fetches it (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],
'server/push/push-service.js web-push': [1, 'a person\'s push endpoint: endpointAllowed() at subscribe and send, connections through safeLookup (asserted below)'],