openvibers/openvibe.network Commits
Frame Runs: every run on OpenVibe in one shape under /api/frame/runs (#872)
Changes
nnew file mode 100644@@@ -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 };@@@ -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, isrouter.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 callnnew file mode 100644@@@ -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); });@@@ -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'],
new file mode 100644
new file mode 100644
@@ -0,0 +1,384 @@
@@ -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 };
@@ -36,6 +36,7 @@ const { createFrameChoices } = require('./choices');
@@ -36,6 +36,7 @@ const { createFrameChoices } = require('./choices');
const { createFrameAsk } = require('./ask');
const { createFrameAsk } = require('./ask');
const { read: readCommands } = require('./commands');
const { read: readCommands } = require('./commands');
const { createFrameMentions } = require('./mentions');
const { createFrameMentions } = require('./mentions');
+const { createFrameRuns } = require('./runs');
const seen = require('./seen');
const seen = require('./seen');
const toolsCatalog = require('../domains/catalog');
const toolsCatalog = require('../domains/catalog');
@@ -961,6 +962,12 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
@@ -961,6 +962,12 @@ async function createFrameService(db, config, analytics, { privateKey = null, is
router.get('/codes-runs/:id/events', runEvents('codes'));
router.get('/codes-runs/:id/events', runEvents('codes'));
router.get('/agent-runs/actor/:id/events', runEvents('actor'));
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
// 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
// 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
// openvibe.network, and 33 of them refused search.openvibe.network (2026-10-28). Anonymous, as the browser's own call
new file mode 100644
new file mode 100644
@@ -0,0 +1,186 @@
@@ -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); });
@@ -119,6 +119,7 @@ const REVIEWED = {
@@ -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/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/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/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/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/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'],
'server/admin/events-ops.js fetch': [1, 'OV_EVENTS_INTERNAL_URL (operator config) for staff DLQ views'],