openvibers/openvibe.network Commits

Frame Runs: every run on OpenVibe in one shape under /api/frame/runs (#872)

OP OpenVibers committed 22 h ago · 92d687f2b2 · 4 files, +578 −0

The agent engine's front door (agent-engine.md section 3; Contracts 0.414.0 docs/agents-runs.md).
One list across Chat's Vibe conversations, Codes' tasks and Actor's tasks (needs-you first, then
newest; a product that does not answer is named in partial), one run, its thread, start by kind or
target, say, stop, approvals, and a stream for a product without one (Chat: steps read every 1.5 s,
Last-Event-ID resumes). A chat is a run waiting for a message; a persona is the agent's face. The
person's Bearer goes upstream unchanged. The older product relays stay until the menu has moved.

Changes

ddiff --git a/server/frame/runs.js b/server/frame/runs.js
nnew file mode 100644
iindex 00000000..dcbf0857
---- /dev/null
++++ b/server/frame/runs.js
@@@ -0,0 +1,384 @@
++'use strict';
++/**
++ * Frame Runs: one front door for every run on OpenVibe (agent-engine.md §3; OpenVibe.Contracts docs/agents-runs.md).
++ *
++ * Everything a person asks an AI to do is a run with a thread of steps: a chat with Vibe, a coding task, an agent's
++ * task. Each product keeps its own runs; this router lists, reads, starts, continues and follows them in one shape
++ * (`agents.run@1` 1.1.0, `agents.step@1`, `agents.event@1`) under one address, the run's ref `<product>:<id>`:
++ *
++ * GET /api/frame/runs?kind=&status=&q=&limit= every product's runs, newest first → { runs, next, partial }
++ * POST /api/frame/runs start (agents.run-start@1; Idempotency-Key) → 201 the run
++ * GET /api/frame/runs/:ref one run
++ * GET /api/frame/runs/:ref/steps?after=&limit= its thread → { steps, next }
++ * GET /api/frame/runs/:ref/events its stream (agents.event@1 as server-sent events)
++ * POST /api/frame/runs/:ref/messages say something to it { text, attachments } → 201 the step
++ * POST /api/frame/runs/:ref/stop stop it
++ * POST /api/frame/runs/:ref/approvals/:approval answer an approval { answer }
++ *
++ * Products behind it (an adapter each; a product that serves the Run API natively needs only a pass-through):
++ * chat OpenVibe.Chat's Vibe conversations (kind chat; a conversation is a run waiting for a message);
++ * codes OpenVibe.Codes' tasks (kind code);
++ * actor OpenVibe.Actor's tasks, already served as agents.run@1 (kind task).
++ * The person's own bearer goes upstream unchanged; every product checks it. A product that does not answer within its
++ * deadline is left out of a list and named in `partial`; it never blocks the others.
++ */
++const crypto = require('crypto');
++const express = require('express');
++
++const REF = /^([a-z][a-z0-9-]{0,30}):([A-Za-z0-9._~-]{1,160})$/;
++const BEARER = /^Bearer [A-Za-z0-9._~+/=-]{10,4096}$/;
++const LIST_DEADLINE_MS = 4000;
++const CALL_TIMEOUT_MS = 15000;
++const POLL_MS = 1500;
++const STREAM_MS = 55000;
++const KINDS = new Set(['chat', 'ask', 'task', 'code', 'machine', 'goal']);
++const STATUSES = new Set(['planning', 'running', 'waiting', 'done', 'failed', 'stopped']);
++
++const iso = (t) => {
++ if (t == null || t === '') return null;
++ const v = String(t);
++ const d = new Date(/^\d{4}-\d\d-\d\d[ T]\d\d:\d\d(:\d\d(\.\d+)?)?$/.test(v) ? `${v.replace(' ', 'T')}Z` : v);
++ return Number.isNaN(d.getTime()) ? null : d.toISOString();
++};
++const clip = (v, n) => String(v == null ? '' : v).replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '').trim().slice(0, n);
++const httpsUrl = (v) => { try { const u = new URL(String(v)); return u.protocol === 'https:' ? u.toString() : null; } catch { return null; } };
++const fail = (status, code, message) => Object.assign(new Error(message), { status, code });
++
++/** Codes' task status (codes.task-summary@1) → the run's. */
++const CODES_STATUS = { queued: 'planning', running: 'running', 'needs-you': 'waiting', done: 'done', failed: 'failed', stopped: 'stopped' };
++/** An AG-UI run's status (agent.run@1) → the run's. */
++const AGUI_STATUS = { queued: 'planning', running: 'running', waiting: 'waiting', needs_you: 'waiting', succeeded: 'done', failed: 'failed', stopped: 'stopped', cancelled: 'stopped' };
++const CODES_TARGET = { create: 'new', openvibe: 'openvibe' };
++
++function createFrameRuns({ svcUrl = (name, fallback) => fallback, personOf = async () => null, fetchImpl = fetch, now = Date.now, log = console } = {}) {
++ const base = {
++ chat: svcUrl('chat', 'http://127.0.0.1:4400'),
++ codes: svcUrl('codes', 'http://127.0.0.1:4900'),
++ actor: svcUrl('actor', 'http://127.0.0.1:4950'),
++ };
++ async function call(product, path, { auth, method = 'GET', body, accept = 'application/json', timeoutMs = CALL_TIMEOUT_MS, idempotencyKey } = {}) {
++ const headers = { Accept: accept, Authorization: auth };
++ if (body !== undefined) headers['Content-Type'] = 'application/json';
++ if (idempotencyKey) headers['Idempotency-Key'] = idempotencyKey;
++ let r;
++ try {
++ r = await fetchImpl(new URL(path, base[product]).href, { method, headers, body: body === undefined ? undefined : JSON.stringify(body), redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) });
++ } catch { throw fail(502, 'runs.product_unavailable', `${product} did not answer`); }
++ const text = await r.text();
++ let json = null; try { json = text ? JSON.parse(text) : null; } catch { json = null; }
++ if (!r.ok) throw fail(r.status === 404 ? 404 : r.status >= 500 ? 502 : r.status, (json && json.code) || `runs.${product}_${r.status}`, (json && (json.error || json.detail || json.message)) || `${product} answered ${r.status}`);
++ return json;
++ }
++
++ // ── chat: Vibe's conversations ──
++ const chat = {
++ product: 'chat',
++ kinds: ['chat'],
++ toRun(c, owner, last) {
++ const persona = c.persona && typeof c.persona === 'object' ? c.persona : null;
++ const at = iso(c.last_message_at || c.updated_at || c.created_at) || new Date(now()).toISOString();
++ const run = {
++ id: String(c.slug), ref: `chat:${c.slug}`, kind: 'chat', title: clip((persona && persona.name) || c.name || 'Vibe', 200) || 'Vibe',
++ owner, agent: { id: 'vibe', name: clip((persona && persona.name) || 'Vibe', 160) || 'Vibe', product: 'chat', harness: 'openvibe' },
++ status: 'waiting', waiting: { for: 'message' },
++ url: httpsUrl(c.url) || `https://openvibe.chat/vibe/${encodeURIComponent(c.slug)}`,
++ created_at: iso(c.created_at) || at, updated_at: at,
++ };
++ if (persona && /^[a-z][a-z0-9-]{0,40}$/.test(String(persona.glyph || ''))) run.agent.glyph = persona.glyph;
++ if (persona && Number.isInteger(persona.hue) && persona.hue >= 0 && persona.hue <= 359) run.agent.hue = persona.hue;
++ if (persona && httpsUrl(persona.image_url)) run.agent.avatar_url = httpsUrl(persona.image_url);
++ const line = last ? last.text : c.last_line;
++ if (line) run.preview = { text: clip(line, 500), from: last ? (last.from === 'you' ? 'person' : 'agent') : 'agent', at };
++ if (last && last.from === 'you') { run.status = 'running'; run.waiting = null; run.doing = { text: 'Writing an answer', glyph: 'spark' }; }
++ const site = String(c.site || '').replace(/^https?:\/\//, '').replace(/\/.*$/, '');
++ if (/^[a-z0-9.-]{3,80}$/.test(site)) run.context = { site };
++ return run;
++ },
++ toStep(m, runId) {
++ const step = { run_id: runId, seq: Number(m.id), kind: 'message', from: m.from === 'you' ? 'person' : 'agent', status: 'done', text: String(m.text || '').slice(0, 65536), started_at: iso(m.at) || new Date(now()).toISOString() };
++ step.ended_at = step.started_at;
++ if (Array.isArray(m.results) && m.results.length) step.ui = { type: 'results', data: { results: m.results.slice(0, 20) } };
++ return step;
++ },
++ async list(auth, owner, { limit }) {
++ const out = await call('chat', `/api/vibe/conversations?limit=${limit}`, { auth, timeoutMs: LIST_DEADLINE_MS });
++ return (out && Array.isArray(out.conversations) ? out.conversations : []).filter((c) => c && c.slug).map((c) => chat.toRun(c, owner));
++ },
++ async get(auth, owner, id) {
++ const out = await call('chat', `/api/vibe/chat?slug=${encodeURIComponent(id)}`, { auth });
++ if (!out || !out.chat) throw fail(404, 'runs.not_found', 'No such run');
++ const msgs = Array.isArray(out.messages) ? out.messages : [];
++ const last = msgs[msgs.length - 1] || null;
++ return chat.toRun({ ...out.chat, last_message_at: last && last.at }, owner, last);
++ },
++ async steps(auth, owner, id, { after, limit }) {
++ const out = await call('chat', `/api/vibe/chat?slug=${encodeURIComponent(id)}${after ? `&after=${after}` : ''}`, { auth });
++ if (!out || !out.chat) throw fail(404, 'runs.not_found', 'No such run');
++ const steps = (Array.isArray(out.messages) ? out.messages : []).map((m) => chat.toStep(m, id)).filter((s) => Number.isInteger(s.seq) && s.seq > 0).slice(0, limit);
++ return { steps, next: null };
++ },
++ async start(auth, owner, body) {
++ const out = await call('chat', '/api/vibe/ask', { auth, method: 'POST', body: { message: body.text, fresh: true } });
++ if (!out || !out.chat) throw fail(502, 'runs.chat_no_chat', 'Chat did not start a conversation');
++ return chat.toRun({ ...out.chat, last_message_at: out.message && out.message.at }, owner, out.message ? { from: 'you', text: out.message.text || body.text, at: out.message.at } : null);
++ },
++ async say(auth, owner, id, msg) {
++ const out = await call('chat', '/api/vibe/ask', { auth, method: 'POST', body: { message: msg.text, slug: id } });
++ const m = out && out.message;
++ if (!m) throw fail(502, 'runs.chat_no_message', 'Chat did not keep the message');
++ return chat.toStep({ id: m.id, from: 'you', text: m.text || msg.text, at: m.at }, id);
++ },
++ };
++
++ // ── codes: coding tasks ──
++ const codes = {
++ product: 'codes',
++ kinds: ['code'],
++ fromSummary(t, owner) {
++ const status = CODES_STATUS[t.status] || 'running';
++ const run = {
++ id: t.id, ref: `codes:${t.id}`, kind: 'code', title: clip(t.title, 200) || 'A coding task', owner,
++ agent: { id: 'codes', name: 'Codes', product: 'codes', glyph: 'code' },
++ status, url: httpsUrl(t.url) || `https://openvibe.codes/tasks/${t.id}`,
++ created_at: iso(t.started_at) || new Date(now()).toISOString(), updated_at: iso(t.updated_at) || iso(t.started_at) || new Date(now()).toISOString(),
++ counts: { steps: Number.isInteger(t.steps) ? t.steps : 0 },
++ };
++ run.target = CODES_TARGET[t.mode] || (t.repo ? { repo: clip(t.repo, 300) } : 'new');
++ if (status === 'waiting') run.waiting = { for: 'approval', what: 'Codes asks before it goes on' };
++ if (status === 'running') run.doing = { text: 'Working on the code', glyph: 'code' };
++ const outputs = [];
++ if (httpsUrl(t.pr_url)) outputs.push({ kind: 'pr', title: 'Pull request', url: httpsUrl(t.pr_url) });
++ if (httpsUrl(t.preview_url)) outputs.push({ kind: 'page', title: 'Preview', url: httpsUrl(t.preview_url) });
++ if (outputs.length) run.outputs = outputs;
++ if (['done', 'failed', 'stopped'].includes(status)) run.ended_at = run.updated_at;
++ return run;
++ },
++ fromAgui(r, owner) {
++ const status = AGUI_STATUS[r.status] || 'running';
++ const run = {
++ id: r.id, ref: `codes:${r.id}`, kind: 'code', title: clip(r.title, 200) || 'A coding task', owner,
++ agent: { id: 'codes', name: 'Codes', product: 'codes', glyph: 'code' }, status,
++ url: `https://openvibe.codes/tasks/${encodeURIComponent(r.id)}`,
++ created_at: iso(r.started_at) || new Date(now()).toISOString(), updated_at: iso(r.ended_at || r.started_at) || new Date(now()).toISOString(),
++ counts: { steps: Array.isArray(r.steps) ? r.steps.length : 0 },
++ };
++ if (r.result && typeof r.result === 'object' && typeof r.result.summary === 'string') run.result_summary = r.result.summary.slice(0, 8000);
++ if (typeof r.cost_usd === 'number') run.cost = { usd: r.cost_usd };
++ if (status === 'waiting') run.waiting = { for: 'approval', what: 'Codes asks before it goes on' };
++ if (['done', 'failed', 'stopped'].includes(status)) run.ended_at = iso(r.ended_at) || run.updated_at;
++ return run;
++ },
++ async list(auth, owner, { limit }) {
++ const out = await call('codes', `/api/v1/me/tasks?limit=${Math.min(limit, 50)}`, { auth, timeoutMs: LIST_DEADLINE_MS });
++ return (out && Array.isArray(out.tasks) ? out.tasks : []).filter((t) => t && t.id).map((t) => codes.fromSummary(t, owner));
++ },
++ async get(auth, owner, id) {
++ return codes.fromAgui(await call('codes', `/api/v1/runs/${encodeURIComponent(id)}`, { auth }), owner);
++ },
++ async steps(auth, owner, id, { after, limit }) {
++ const r = await call('codes', `/api/v1/runs/${encodeURIComponent(id)}`, { auth });
++ const started = iso(r && r.started_at) || new Date(now()).toISOString();
++ const steps = (Array.isArray(r && r.steps) ? r.steps : []).map((s, i) => {
++ const title = clip(s && (s.title || s.name || s.label), 200);
++ const text = clip(s && (s.text || s.summary || s.detail || s.title || s.name), 65536);
++ const step = { run_id: id, seq: i + 1, kind: /tool|command|exec|edit/i.test(String(s && (s.kind || s.type) || '')) ? 'tool' : 'think', from: 'agent', text: text || null, started_at: iso(s && (s.started_at || s.at)) || started };
++ if (title) step.title = title;
++ const st = String(s && s.status || '');
++ step.status = /run|progress|pending|active/.test(st) ? 'running' : /fail|error/.test(st) ? 'failed' : 'done';
++ return step;
++ }).filter((s) => !after || s.seq > after).slice(0, limit);
++ return { steps, next: null };
++ },
++ async start(auth, owner, body, { idempotencyKey } = {}) {
++ const target = body.target;
++ if (!target || typeof target !== 'object' || !target.repo) throw fail(422, 'runs.codes_needs_a_repository', 'Codes starts a task on a repository for now ({ target: { repo } }); Create and OpenVibe without one are coming');
++ const out = await call('codes', '/api/v1/tasks', { auth, method: 'POST', body: { repo: target.repo, ref: target.branch || null, prompt: body.text }, idempotencyKey });
++ const id = out && (out.id || (out.task && out.task.id));
++ if (!id) throw fail(502, 'runs.codes_no_task', 'Codes did not start a task');
++ return codes.get(auth, owner, id).catch(() => ({ id, ref: `codes:${id}`, kind: 'code', title: clip(body.text, 200), owner, agent: { id: 'codes', name: 'Codes', product: 'codes', glyph: 'code' }, status: 'planning', target: { repo: target.repo }, created_at: new Date(now()).toISOString(), updated_at: new Date(now()).toISOString() }));
++ },
++ async stop(auth, owner, id, { reason }) {
++ await call('codes', `/api/v1/tasks/${encodeURIComponent(id)}/stop`, { auth, method: 'POST', body: reason ? { reason } : {} });
++ return codes.get(auth, owner, id);
++ },
++ };
++
++ // ── actor: agents' tasks, served as agents.run@1 ──
++ const RUN_TYPE = 'application/vnd.openvibe.agents.run+json, application/json';
++ const actorRun = (r) => (r && r.id ? { ...r, ref: `actor:${r.id}`, kind: r.kind || 'task' } : r);
++ const actor = {
++ product: 'actor',
++ kinds: ['task', 'goal', 'machine'],
++ async list(auth, owner, { limit, status }) {
++ const out = await call('actor', `/api/v1/runs?limit=${limit}${status ? `&status=${status}` : ''}`, { auth, accept: RUN_TYPE, timeoutMs: LIST_DEADLINE_MS });
++ return (out && Array.isArray(out.runs) ? out.runs : []).filter((r) => r && r.id).map(actorRun);
++ },
++ async get(auth, owner, id) { return actorRun(await call('actor', `/api/v1/runs/${encodeURIComponent(id)}?shape=agents.run@1`, { auth, accept: RUN_TYPE })); },
++ async steps(auth, owner, id, { after, limit }) {
++ const out = await call('actor', `/api/v1/runs/${encodeURIComponent(id)}/steps?limit=${limit}${after ? `&after=${after}` : ''}`, { auth });
++ return { steps: out && Array.isArray(out.steps) ? out.steps : [], next: out && out.next != null ? out.next : null };
++ },
++ async start(auth, owner, body, { idempotencyKey } = {}) {
++ return actorRun(await call('actor', '/api/v1/runs', { auth, method: 'POST', body, accept: RUN_TYPE, idempotencyKey }));
++ },
++ async say(auth, owner, id, msg) { return call('actor', `/api/v1/runs/${encodeURIComponent(id)}/messages`, { auth, method: 'POST', body: msg }); },
++ async stop(auth, owner, id, { reason }) { return actorRun(await call('actor', `/api/v1/runs/${encodeURIComponent(id)}/stop`, { auth, method: 'POST', body: reason ? { reason } : {}, accept: RUN_TYPE })); },
++ async answer(auth, owner, id, approval, answer) { return call('actor', `/api/v1/runs/${encodeURIComponent(id)}/approvals/${encodeURIComponent(approval)}`, { auth, method: 'POST', body: { answer } }); },
++ };
++
++ const PRODUCTS = { chat, codes, actor };
++ const productFor = (ref) => {
++ const m = REF.exec(String(ref || ''));
++ if (!m || !PRODUCTS[m[1]]) throw fail(404, 'runs.not_found', 'No such run');
++ return { a: PRODUCTS[m[1]], id: m[2] };
++ };
++ /** Which product starts a run: its kind, else a target (code), else Vibe (it answers, or hands the work on). */
++ const starterFor = (body) => {
++ if (body.kind === 'code' || body.target) return codes;
++ if (body.kind && ['task', 'goal', 'machine'].includes(body.kind)) return actor;
++ return chat;
++ };
++
++ const router = express.Router();
++ const json = express.json({ limit: '64kb' });
++ router.use((req, res, next) => {
++ res.set({ 'Access-Control-Allow-Origin': '*', 'Access-Control-Allow-Headers': 'Authorization, Content-Type, Last-Event-ID, Idempotency-Key', 'Access-Control-Allow-Methods': 'GET, POST, OPTIONS', 'Access-Control-Max-Age': '600', 'Cache-Control': 'private, no-store', Vary: 'Authorization' });
++ if (req.method === 'OPTIONS') return res.status(204).end();
++ next();
++ });
++ /** The person: their bearer for the products, their subject for the owner field. */
++ async function who(req) {
++ const auth = String(req.get('authorization') || '');
++ if (!BEARER.test(auth)) throw fail(401, 'auth.required', 'Sign in to see your runs');
++ let person = null;
++ try { person = await personOf(req); } catch { person = null; }
++ const subject = person && (person.subject_id || person.subject || (person.id && /^usr_/.test(String(person.id)) ? person.id : null));
++ return { auth, owner: { type: 'user', id: subject || 'usr_unknown' } };
++ }
++ const send = (res, err) => {
++ const status = err && Number.isInteger(err.status) ? err.status : 500;
++ if (status >= 500 && status !== 502) log.warn && log.warn(`[frame runs] ${err && err.message}`);
++ return res.status(status).json({ error: err && err.status ? err.message : 'Runs are unavailable right now', code: (err && err.code) || 'runs.error' });
++ };
++ const handle = (fn) => async (req, res) => { try { await fn(req, res); } catch (err) { send(res, err); } };
++
++ router.get('/', handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 30, 1), 100);
++ const kind = KINDS.has(String(req.query.kind || '')) ? String(req.query.kind) : null;
++ const status = STATUSES.has(String(req.query.status || '')) ? String(req.query.status) : null;
++ const q = clip(req.query.q, 200).toLowerCase();
++ const asked = Object.values(PRODUCTS).filter((a) => !kind || a.kinds.includes(kind));
++ const partial = [];
++ const lists = await Promise.all(asked.map((a) => a.list(auth, owner, { limit, status }).catch((err) => {
++ if (!(err && err.status === 401)) partial.push(a.product);
++ return [];
++ })));
++ let runs = lists.flat();
++ if (kind) runs = runs.filter((r) => (r.kind || 'task') === kind);
++ if (status) runs = runs.filter((r) => r.status === status);
++ if (q) runs = runs.filter((r) => `${r.title || ''} ${(r.preview && r.preview.text) || ''}`.toLowerCase().includes(q));
++ // Needs you first, then newest.
++ const needs = (r) => (r.status === 'waiting' && r.waiting && r.waiting.for !== 'message' ? 1 : 0);
++ runs.sort((x, y) => needs(y) - needs(x) || String(y.updated_at).localeCompare(String(x.updated_at)));
++ res.json({ runs: runs.slice(0, limit), next: null, partial });
++ }));
++
++ router.post('/', json, handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const b = req.body && typeof req.body === 'object' && !Array.isArray(req.body) ? req.body : {};
++ const text = clip(b.text, 16000);
++ if (!text) throw fail(422, 'runs.start_needs_words', 'Say what the run is for ({ text })');
++ const key = String(req.get('idempotency-key') || '');
++ const idempotencyKey = /^[A-Za-z0-9._~-]{1,200}$/.test(key) ? key : (key ? null : crypto.randomUUID());
++ const a = starterFor(b);
++ const run = await a.start(auth, owner, { ...b, text }, { idempotencyKey });
++ res.status(201).json(run);
++ }));
++
++ router.get('/:ref', handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const { a, id } = productFor(req.params.ref);
++ res.json(await a.get(auth, owner, id));
++ }));
++
++ router.get('/:ref/steps', handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const { a, id } = productFor(req.params.ref);
++ const after = parseInt(req.query.after, 10) > 0 ? parseInt(req.query.after, 10) : 0;
++ const limit = Math.min(Math.max(parseInt(req.query.limit, 10) || 100, 1), 500);
++ res.json(await a.steps(auth, owner, id, { after, limit }));
++ }));
++
++ router.post('/:ref/messages', json, handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const { a, id } = productFor(req.params.ref);
++ const b = req.body && typeof req.body === 'object' ? req.body : {};
++ const text = clip(b.text, 16000);
++ const attachments = Array.isArray(b.attachments) ? b.attachments.slice(0, 20) : [];
++ if (!text && !attachments.length) throw fail(422, 'runs.say_needs_words', 'Say something ({ text })');
++ if (!a.say) throw fail(409, 'runs.cannot_say', `A ${a.product} run does not take messages yet`);
++ res.status(201).json(await a.say(auth, owner, id, { text, attachments }));
++ }));
++
++ router.post('/:ref/stop', json, handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const { a, id } = productFor(req.params.ref);
++ if (!a.stop) throw fail(409, 'runs.cannot_stop', a.product === 'chat' ? 'A conversation does not stop: it waits for you' : `A ${a.product} run cannot be stopped from here yet`);
++ res.json(await a.stop(auth, owner, id, { reason: clip(req.body && req.body.reason, 500) || undefined }));
++ }));
++
++ router.post('/:ref/approvals/:approval', json, handle(async (req, res) => {
++ const { auth, owner } = await who(req);
++ const { a, id } = productFor(req.params.ref);
++ const approval = String(req.params.approval || '');
++ if (!/^[A-Za-z0-9._~-]{1,160}$/.test(approval)) throw fail(404, 'runs.not_found', 'No such approval');
++ const answer = req.body && req.body.answer;
++ if (answer === undefined || answer === null || answer === '') throw fail(422, 'runs.answer_needed', 'An answer is required ({ answer })');
++ if (!a.answer) throw fail(409, 'runs.cannot_answer', `Answer a ${a.product} run on its own page for now`);
++ res.json(await a.answer(auth, owner, id, approval, answer));
++ }));
++
++ /**
++ * The stream. A product without a stream of its own (Chat today) is followed by reading its steps every 1.5 s;
++ * the connection closes after 55 s and the client resumes with Last-Event-ID (a step's seq), so a proxy never
++ * holds it open forever. Events: step (a new step), run (the snapshot when a new step arrived), end.
++ */
++ router.get('/:ref/events', async (req, res) => {
++ let ctx;
++ try { ctx = await who(req); } catch (err) { return send(res, err); }
++ let target;
++ try { target = productFor(req.params.ref); } catch (err) { return send(res, err); }
++ const last = String(req.get('last-event-id') || req.query.after || '0');
++ if (!/^\d{1,16}$/.test(last)) return res.status(400).json({ error: 'Last-Event-ID is a step seq', code: 'runs.bad_event_id' });
++ let after = Number(last);
++ let open = true;
++ req.on('close', () => { open = false; });
++ res.status(200).set({ 'Content-Type': 'text/event-stream', 'X-Accel-Buffering': 'no' });
++ res.flushHeaders();
++ const write = (event, id, data) => { if (open) res.write(`${id != null ? `id: ${id}\n` : ''}event: ${event}\ndata: ${JSON.stringify({ type: event, ...data })}\n\n`); };
++ const until = now() + STREAM_MS;
++ try {
++ while (open && now() < until) {
++ const { steps } = await target.a.steps(ctx.auth, ctx.owner, target.id, { after, limit: 100 });
++ const fresh = steps.filter((s) => s.seq > after);
++ for (const s of fresh) { write('step', s.seq, { step: s }); after = Math.max(after, s.seq); }
++ if (fresh.length) {
++ const run = await target.a.get(ctx.auth, ctx.owner, target.id).catch(() => null);
++ if (run) {
++ write('run', after, { run });
++ if (['done', 'failed', 'stopped'].includes(run.status)) { write('end', after, { run_id: run.ref || req.params.ref, status: run.status }); break; }
++ }
++ }
++ await new Promise((resolve) => setTimeout(resolve, POLL_MS));
++ }
++ } catch (err) {
++ write('error', null, { error: err && err.status ? err.message : 'The run could not be read', code: (err && err.code) || 'runs.error' });
++ }
++ if (open) res.end();
++ });
++
++ return { router, adapters: PRODUCTS };
++}
++
++module.exports = { createFrameRuns, REF };
ddiff --git a/server/frame/service.js b/server/frame/service.js
iindex f79991eb..5045c0ef 100644
---- a/server/frame/service.js
++++ b/server/frame/service.js
@@@ -36,6 +36,7 @@ const { createFrameChoices } = require('./choices');
const { createFrameAsk } = require('./ask');
const { read: readCommands } = require('./commands');
const { createFrameMentions } = require('./mentions');
++const { createFrameRuns } = require('./runs');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
@@@ -961,6 +962,12 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
router.get('/codes-runs/:id/events', runEvents('codes'));
router.get('/agent-runs/actor/:id/events', runEvents('actor'));
++ // Frame Runs (agent-engine.md §3): every run on OpenVibe (Vibe's chats, Codes' tasks, Actor's tasks) in one shape
++ // under one address, /api/frame/runs/<product>:<id>. The product-specific relays above stay until the menu has
++ // moved to these routes, then go in one cut.
++ const frameRuns = createFrameRuns({ svcUrl, personOf, now });
++ router.use('/runs', frameRuns.router);
++
// GET /api/frame/suggest?q=&owner=&limit= — the navbar's magnifier (openvibe-shared search.js): OpenVibe.Search's
// suggestions, asked through Network because every site's connect-src already lets the navbar reach
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
ddiff --git a/test/frame-runs.test.js b/test/frame-runs.test.js
nnew file mode 100644
iindex 00000000..43f9661b
---- /dev/null
++++ b/test/frame-runs.test.js
@@@ -0,0 +1,186 @@
++'use strict';
++// Frame Runs (agent-engine.md §3): every run on OpenVibe in one shape under /api/frame/runs/<product>:<id>. Stub Chat,
++// Codes and Actor servers stand in for the products: the list merges them (needs-you first, then newest) and names a
++// product that did not answer in `partial`; a chat is a run waiting for a message, its messages are steps; a coding task
++// maps Codes' statuses, targets and pull requests; Actor's runs pass through with a ref; start goes to the product its
++// kind or target names; say, stop and approvals reach the right product or say plainly why not; the stream follows a
++// product without one by reading its steps, and resumes after Last-Event-ID; the person's bearer is required and goes
++// upstream unchanged.
++const assert = require('assert');
++const http = require('http');
++const express = require('express');
++const { createFrameRuns } = require('../server/frame/runs');
++
++const TOKEN = 'Bearer tok_person_1234567890';
++const listen = (handler) => new Promise((resolve) => { const s = http.createServer(handler); s.listen(0, '127.0.0.1', () => resolve(s)); });
++const readBody = (req) => new Promise((resolve) => { let b = ''; req.on('data', (c) => { b += c; }); req.on('end', () => resolve(b ? JSON.parse(b) : null)); });
++const sendJson = (res, status, body) => { res.statusCode = status; res.setHeader('content-type', 'application/json'); res.end(JSON.stringify(body)); };
++
++(async () => {
++ const seen = [];
++ const messages = [
++ { id: 101, from: 'you', text: 'Find me a mechanical keyboard', at: '2026-11-12 10:00:00' },
++ { id: 102, from: 'vibe', text: 'Three picks under $90.', at: '2026-11-12 10:00:20', results: [{ kind: 'timer', label: '10 min' }] },
++ ];
++ let actorDown = false;
++ const chat = await listen(async (req, res) => {
++ seen.push({ svc: 'chat', method: req.method, url: req.url, auth: req.headers.authorization });
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/vibe/conversations') return sendJson(res, 200, { conversations: [{ slug: 'c_keys', name: 'Keyboard Scout', last_line: 'Three picks under $90.', last_message_at: '2026-11-12 10:00:20', site: 'https://openvibe.deals', persona: { name: 'Keyboard Scout', glyph: 'search', hue: 212 } }], count: 1 });
++ if (u.pathname === '/api/vibe/chat') {
++ if (u.searchParams.get('slug') !== 'c_keys') return sendJson(res, 200, { chat: null, messages: [] });
++ const after = Number(u.searchParams.get('after')) || 0;
++ return sendJson(res, 200, { chat: { slug: 'c_keys', name: 'Keyboard Scout', url: 'https://openvibe.chat/vibe/c_keys' }, messages: messages.filter((m) => m.id > after) });
++ }
++ if (u.pathname === '/api/vibe/ask' && req.method === 'POST') {
++ const b = await readBody(req);
++ const m = { id: 103 + (b.fresh ? 100 : 0), from: 'you', text: b.message, at: '2026-11-12 10:01:00' };
++ if (!b.fresh) messages.push(m);
++ return sendJson(res, 200, { chat: { slug: b.fresh ? 'c_new' : b.slug, name: 'Vibe', url: `https://openvibe.chat/vibe/${b.fresh ? 'c_new' : b.slug}` }, message: m });
++ }
++ return sendJson(res, 404, { error: 'not found' });
++ });
++ const codes = await listen(async (req, res) => {
++ seen.push({ svc: 'codes', method: req.method, url: req.url, auth: req.headers.authorization, idem: req.headers['idempotency-key'] });
++ const u = new URL(req.url, 'http://x');
++ if (u.pathname === '/api/v1/me/tasks') return sendJson(res, 200, { tasks: [
++ { id: 'tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH', title: 'A goal bar for my sub goal', status: 'needs-you', mode: 'openvibe', repo: 'OpenVibers/OpenVibe.Live', started_at: '2026-11-12T09:00:00Z', updated_at: '2026-11-12T09:30:00Z', steps: 4, url: 'https://openvibe.codes/tasks/tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH', preview_url: null, pr_url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Live/pulls/871' },
++ { id: 'tsk_01K9R2V6M8Q4T0B7C3D5E9F2GJ', title: 'Old task', status: 'done', mode: 'repo', repo: 'me/site', started_at: '2026-11-01T09:00:00Z', updated_at: '2026-11-01T09:30:00Z', steps: 1, url: 'https://openvibe.codes/tasks/tsk_01K9R2V6M8Q4T0B7C3D5E9F2GJ', preview_url: 'https://preview.openvibe.codes/x', pr_url: null },
++ ] });
++ if (u.pathname === '/api/v1/runs/tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH') return sendJson(res, 200, { id: 'tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH', product: 'codes', title: 'A goal bar for my sub goal', status: 'needs_you', plan: [], steps: [{ title: 'Read the overlay code', kind: 'tool', status: 'done' }, { title: 'Write the bar', status: 'running' }], sources: [], result: null, started_at: '2026-11-12T09:00:00Z', ended_at: null, cost_usd: 0.42 });
++ if (u.pathname === '/api/v1/tasks' && req.method === 'POST') { const b = await readBody(req); seen.at(-1).body = b; return sendJson(res, 201, { id: 'tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH' }); }
++ if (/\/api\/v1\/tasks\/tsk_[0-9A-Z]+\/stop$/.test(u.pathname)) return sendJson(res, 200, { ok: true });
++ return sendJson(res, 404, { error: 'not found' });
++ });
++ const actor = await listen(async (req, res) => {
++ seen.push({ svc: 'actor', method: req.method, url: req.url, auth: req.headers.authorization, accept: req.headers.accept });
++ if (actorDown) return sendJson(res, 503, { error: 'down' });
++ const u = new URL(req.url, 'http://x');
++ const run = { id: 'tsk_ACTOR1', owner: { type: 'user', id: 'usr_1' }, agent: { id: 'agt_1', name: 'Juniper', product: 'actor' }, status: 'running', created_at: '2026-11-12T08:00:00Z', updated_at: '2026-11-12T10:05:00Z' };
++ if (u.pathname === '/api/v1/runs' && req.method === 'GET') return sendJson(res, 200, { runs: [run], next: null });
++ if (u.pathname === '/api/v1/runs/tsk_ACTOR1') return sendJson(res, 200, run);
++ if (u.pathname === '/api/v1/runs/tsk_ACTOR1/approvals/apr_1') { const b = await readBody(req); return sendJson(res, 200, { run_id: 'tsk_ACTOR1', step_seq: 3, what: 'post', options: [{ id: 'approve', label: 'Approve' }], answer: b.answer }); }
++ return sendJson(res, 404, { error: 'not found' });
++ });
++
++ const at = (s) => `http://127.0.0.1:${s.address().port}`;
++ const urls = { chat: at(chat), codes: at(codes), actor: at(actor) };
++ const runs = createFrameRuns({ svcUrl: (name, fallback) => urls[name] || fallback, personOf: async () => ({ subject_id: 'usr_01K9R2V6M8Q4T0B7C3D5E9F2GH' }), log: { warn() {} } });
++ const app = express();
++ app.use('/api/frame/runs', runs.router);
++ const server = app.listen(0, '127.0.0.1');
++ await new Promise((r) => server.once('listening', r));
++ const base = `http://127.0.0.1:${server.address().port}/api/frame/runs`;
++ const go = async (path, { method = 'GET', body, headers = {} } = {}) => {
++ const r = await fetch(`${base}${path}`, { method, headers: { Authorization: TOKEN, ...(body ? { 'Content-Type': 'application/json' } : {}), ...headers }, body: body ? JSON.stringify(body) : undefined });
++ const text = await r.text();
++ let d = null; try { d = JSON.parse(text); } catch { d = text; }
++ return { r, d };
++ };
++
++ try {
++ // Signed out: no runs.
++ let res = await fetch(base);
++ assert.strictEqual(res.status, 401, 'a bearer is required');
++
++ // The list: every product, needs-you first, then newest; the bearer goes upstream unchanged.
++ let { r, d } = await go('/?limit=10');
++ assert.strictEqual(r.status, 200);
++ assert.deepStrictEqual(d.partial, []);
++ assert.deepStrictEqual(d.runs.map((x) => x.ref), ['codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH', 'actor:tsk_ACTOR1', 'chat:c_keys', 'codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GJ'], 'needs you first, then newest');
++ assert.ok(seen.every((x) => x.auth === TOKEN), 'the person\'s own bearer reaches every product');
++ const vibeRun = d.runs.find((x) => x.ref === 'chat:c_keys');
++ assert.strictEqual(vibeRun.kind, 'chat');
++ assert.strictEqual(vibeRun.status, 'waiting');
++ assert.deepStrictEqual(vibeRun.waiting, { for: 'message' }, 'a conversation is a run waiting for a message');
++ assert.deepStrictEqual(vibeRun.agent, { id: 'vibe', name: 'Keyboard Scout', product: 'chat', harness: 'openvibe', glyph: 'search', hue: 212 }, 'the persona is the agent\'s face');
++ assert.strictEqual(vibeRun.updated_at, '2026-11-12T10:00:20.000Z', 'a time without a zone is UTC');
++ assert.deepStrictEqual(vibeRun.context, { site: 'openvibe.deals' });
++ assert.deepStrictEqual(vibeRun.owner, { type: 'user', id: 'usr_01K9R2V6M8Q4T0B7C3D5E9F2GH' });
++ const codeRun = d.runs[0];
++ assert.strictEqual(codeRun.status, 'waiting');
++ assert.strictEqual(codeRun.waiting.for, 'approval');
++ assert.strictEqual(codeRun.target, 'openvibe');
++ assert.deepStrictEqual(codeRun.outputs, [{ kind: 'pr', title: 'Pull request', url: 'https://git.openvibe.codes/OpenVibers/OpenVibe.Live/pulls/871' }]);
++ assert.deepStrictEqual(d.runs[3].target, { repo: 'me/site' });
++ assert.strictEqual(d.runs[3].ended_at, '2026-11-01T09:30:00.000Z');
++ assert.match(seen.find((x) => x.svc === 'actor').accept, /agents\.run\+json/, 'Actor is asked for agents.run@1');
++
++ // Filters and a product that does not answer.
++ ({ d } = await go('/?kind=chat'));
++ assert.deepStrictEqual(d.runs.map((x) => x.ref), ['chat:c_keys'], 'kind=chat asks Chat only');
++ ({ d } = await go('/?q=goal+bar'));
++ assert.deepStrictEqual(d.runs.map((x) => x.ref), ['codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH']);
++ actorDown = true;
++ ({ d } = await go('/'));
++ assert.deepStrictEqual(d.partial, ['actor'], 'a product that does not answer is named, not fatal');
++ assert.strictEqual(d.runs.length, 3);
++ actorDown = false;
++
++ // One run and its thread.
++ ({ r, d } = await go('/chat:c_keys'));
++ assert.strictEqual(d.ref, 'chat:c_keys');
++ assert.strictEqual(d.preview.text, 'Three picks under $90.');
++ ({ d } = await go('/chat:c_keys/steps'));
++ assert.deepStrictEqual(d.steps.map((x) => [x.seq, x.from, x.kind]), [[101, 'person', 'message'], [102, 'agent', 'message']]);
++ assert.deepStrictEqual(d.steps[1].ui, { type: 'results', data: { results: [{ kind: 'timer', label: '10 min' }] } }, 'what Vibe did is a card');
++ ({ d } = await go('/chat:c_keys/steps?after=101'));
++ assert.deepStrictEqual(d.steps.map((x) => x.seq), [102], 'after= reads only what came later');
++ ({ r } = await go('/chat:c_missing'));
++ assert.strictEqual(r.status, 404);
++ ({ r } = await go('/nothing'));
++ assert.strictEqual(r.status, 404, 'an address that is not a ref is not a run');
++ ({ d } = await go('/codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH'));
++ assert.strictEqual(d.status, 'waiting'); assert.deepStrictEqual(d.cost, { usd: 0.42 });
++ ({ d } = await go('/codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH/steps'));
++ assert.deepStrictEqual(d.steps.map((x) => [x.seq, x.kind, x.title, x.status]), [[1, 'tool', 'Read the overlay code', 'done'], [2, 'think', 'Write the bar', 'running']]);
++ ({ d } = await go('/actor:tsk_ACTOR1'));
++ assert.strictEqual(d.ref, 'actor:tsk_ACTOR1'); assert.strictEqual(d.kind, 'task');
++
++ // Start: by kind or target; words are required.
++ ({ r, d } = await go('/', { method: 'POST', body: { kind: 'chat' } }));
++ assert.strictEqual(r.status, 422);
++ ({ r, d } = await go('/', { method: 'POST', body: { text: 'What happened in tech news today?' } }));
++ assert.strictEqual(r.status, 201); assert.strictEqual(d.ref, 'chat:c_new'); assert.strictEqual(d.status, 'running', 'Vibe is writing its answer');
++ ({ r, d } = await go('/', { method: 'POST', body: { text: 'Add a goal bar', kind: 'code', target: 'openvibe' } }));
++ assert.strictEqual(r.status, 422, 'Codes needs a repository until Create and OpenVibe ship');
++ assert.match(d.error, /repository/);
++ ({ r, d } = await go('/', { method: 'POST', body: { text: 'Fix the login redirect', target: { repo: 'me/site' } }, headers: { 'Idempotency-Key': 'k-123' } }));
++ assert.strictEqual(r.status, 201); assert.strictEqual(d.kind, 'code');
++ const started = seen.filter((x) => x.svc === 'codes' && x.method === 'POST' && x.url === '/api/v1/tasks').at(-1);
++ assert.deepStrictEqual(started.body, { repo: 'me/site', ref: null, prompt: 'Fix the login redirect' });
++ assert.strictEqual(started.idem, 'k-123', 'the idempotency key goes to the product');
++
++ // Say, stop, approvals.
++ ({ r, d } = await go('/chat:c_keys/messages', { method: 'POST', body: { text: 'Under $70?' } }));
++ assert.strictEqual(r.status, 201); assert.strictEqual(d.from, 'person'); assert.strictEqual(d.text, 'Under $70?');
++ ({ r } = await go('/chat:c_keys/messages', { method: 'POST', body: {} }));
++ assert.strictEqual(r.status, 422);
++ ({ r, d } = await go('/chat:c_keys/stop', { method: 'POST', body: {} }));
++ assert.strictEqual(r.status, 409); assert.match(d.error, /waits for you/);
++ ({ r, d } = await go('/codes:tsk_01K9R2V6M8Q4T0B7C3D5E9F2GH/stop', { method: 'POST', body: { reason: 'not now' } }));
++ assert.strictEqual(r.status, 200);
++ ({ r, d } = await go('/actor:tsk_ACTOR1/approvals/apr_1', { method: 'POST', body: { answer: 'approve' } }));
++ assert.strictEqual(r.status, 200); assert.strictEqual(d.answer, 'approve');
++ ({ r } = await go('/actor:tsk_ACTOR1/approvals/apr_1', { method: 'POST', body: {} }));
++ assert.strictEqual(r.status, 422);
++
++ // The stream: Chat has none, so the Frame reads its steps; Last-Event-ID resumes after a seq.
++ const ctl = new AbortController();
++ const sr = await fetch(`${base}/chat:c_keys/events`, { headers: { Authorization: TOKEN, 'Last-Event-ID': '102' }, signal: ctl.signal });
++ assert.strictEqual(sr.status, 200);
++ assert.match(sr.headers.get('content-type'), /text\/event-stream/);
++ const reader = sr.body.getReader();
++ let text = '';
++ const deadline = Date.now() + 5000;
++ while (!/event: run/.test(text) && Date.now() < deadline) { const { value, done } = await reader.read(); if (done) break; text += Buffer.from(value).toString(); }
++ ctl.abort();
++ assert.match(text, /id: 103\nevent: step\n/, 'the message said above arrives as a step');
++ assert.ok(!/id: 10[12]\n/.test(text), 'nothing at or before Last-Event-ID is sent again');
++ assert.match(text, /event: run\n/, 'and the run\'s new snapshot follows');
++
++ console.log('frame-runs: one list across Chat, Codes and Actor (needs you first, partial named), a chat as a run waiting for a message, start by kind or target, say, stop, approvals, and a stream for a product without one');
++ } finally {
++ server.close(); chat.close(); codes.close(); actor.close();
++ }
++})().catch((err) => { console.error(err); process.exit(1); });
ddiff --git a/test/security-ssrf.test.js b/test/security-ssrf.test.js
iindex de29dd4b..acd04bcf 100644
---- a/test/security-ssrf.test.js
++++ b/test/security-ssrf.test.js
@@@ -119,6 +119,7 @@ const REVIEWED = {
'server/composer/routes.js fetch': [2, 'mention search: the operator-configured OpenVibe.Git internal URL (config.services.git) with a fixed /api/v1/repos path and the query percent-encoded; no caller-chosen host, 2.5 s timeout, and no token or body is sent with it'],
'server/frame/today.js fetch': [1, 'Report internal URL from operator config, fixed /api/v1/stats?days=30 path, five-second timeout; no request-selected URL'],
'server/frame/choices.js fetch': [1, 'the composer\'s Model sheet: AI (OV_AI_INTERNAL_URL), Exchange (OV_EXCHANGE_INTERNAL_URL / config.services.exchange) and Report (OV_REPORT_INTERNAL_URL) internal URLs from operator config, fixed /api/v1/choices, /api/v1/models and /api/v1/ai/leaderboard paths, the task percent-encoded and a checked person Bearer only; no request value names a host, redirects refused, 3-8 s timeouts'],
++ 'server/frame/runs.js fetch': [1, 'Frame Runs: only the operator-configured internal URLs of Chat, Codes and Actor (svcUrl), fixed /api/vibe and /api/v1 paths with encoded ids from a checked ref, the person\'s own Bearer, redirects refused, 4-15 s timeouts; never a request-chosen host'],
'server/frame/ask.js fetch': [2, 'the composer\'s Ask: Search (OV_SEARCH_INTERNAL_URL) fixed /api/v1/query path with checked scopes and the person\'s Bearer, and AI (OV_AI_INTERNAL_URL) fixed /api/v1/runs?stream=1 path with the person\'s Bearer; both operator-configured internal URLs, no request value names a host, redirects refused, 30-150 s timeouts'],
'server/admin/analytics-routes.js fetch': [2, 'staff analytics: the services\' internal URLs from the registry (owner/admin set), and Insights\' Prometheus (PROMETHEUS_URL, loopback by default) with fixed queries; never a request value'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],