openvibers/openvibe.codes Commits
Codes: the Dock's Code tab API — cross-site tasks list, start and stop (#390)
Changes
@@@ -206,6 +206,8 @@ async function stopTask(args) {const id = String(args.id || '');if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');const t = await call(auth(), 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);++ // The owner's shape is { ok: true }: the stop was accepted, so the attempt stops and the outcome is stopped.++ if (t && t.ok === true) return text({ id, state: 'stopped', outcome: 'stopped' });return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });}nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- The Dock's Code tab (codes.task-summary@1 mode): where the request started — openvibe (one of OpenVibe's own++-- projects) or repo (the person's own repository). Rows from before it read back by their repository instead.++ALTER TABLE run_tasks++ ADD COLUMN IF NOT EXISTS mode text COLLATE "C";@@@ -70,9 +70,6 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}.ch-pick-repo input{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}.ch-pick-repo input:focus{outline:none}--.ch-pick-file{cursor:pointer;padding:0 9px}--.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}--.ch-pick-file.has-files{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}/* Files waiting in the composer: one row that wraps, a chip per file that never outgrows a phone; the upload is a2px line along the chip's foot, and the remove keeps a 44px target inside a 32px chip. */.ch-files{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px;min-width:0}@@@ -125,8 +122,9 @@ details[open]>summary .ch-caret{rotate:180deg}.ch-dim{height:32px;padding:0 11px;font-size:12.5px;font-weight:750;letter-spacing:.03em}.ch-kinds:has([data-ch-kind][value="game"]:checked) .ch-dims{display:inline-flex}@media (pointer:coarse){.ch-kind{height:40px}.ch-dim{height:40px}}--/* Each picker and line shows for the way it belongs to. */--.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents),++/* Each picker and line shows for the way it belongs to (the kit's attach menu with them: files ride with a++ task on your code or an OpenVibe site, never with Something new). */++.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents,.ovt-attach-menu,.ovt-attach),.ch:has([data-ch-mode][value="openvibe"]:checked) :is(.ch-for-new,.ch-for-code),.ch:has([data-ch-mode][value="code"]:checked) :is(.ch-for-new,.ch-for-openvibe){display:none}/* The repositories used last, one tap each: a sideways row that never wraps the composer taller. */@@@ -195,7 +195,10 @@}if (form) {// ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──-- const picker = $('[data-ch-files]', form);++ // The kit's attach menu renders one input per row (Files, Photos and videos, the camera): every one of++ // them feeds the same queue, and the no-JavaScript post sends them all as multipart.++ const pickers = Array.from(form.querySelectorAll('input[data-ovt-files], input[data-ch-files]'));++ const picker = pickers[0] || null;const chips = $('[data-ch-files-list]', form);const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;@@@ -277,8 +280,8 @@}if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);};-- if (picker) {-- picker.addEventListener('change', () => { addFiles(picker.files); picker.value = ''; });++ for (const one of pickers) {++ one.addEventListener('change', () => { addFiles(one.files); one.value = ''; });}// Paste an image from the clipboard, or drop files on the composer.if (text) text.addEventListener('paste', (e) => {@@@ -330,7 +333,7 @@}// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).form.enctype = 'application/x-www-form-urlencoded';-- if (picker) picker.value = '';++ for (const one of pickers) one.value = '';for (const id of ids) {const hidden = document.createElement('input');hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;@@@ -107,7 +107,7 @@ async function createApp(opts = {}) {const harnesses = createHarnesses();// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.const sessions = createSessionLines({ store, config, log, media: require('./media').createMedia({ config, log }) });-- const outbox = createOutbox({ store, log });++ const outbox = createOutbox({ store, log, baseUrl: config.baseUrl });const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a// short-lived run token that names them.@@@ -309,10 +309,10 @@ async function createApp(opts = {}) {}app.use('/api/v1', createApi(ctx));app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));-- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));++ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));-- app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, actorLimits: ctx.actorLimits, log }));++ app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, actorLimits: ctx.actorLimits, log }));// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).@@@ -159,7 +159,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst who = (row) => ({ subject: row.subject, project: row.project_id });// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });-- const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });++ const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log, baseUrl: config && config.baseUrl });// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.const taskProof = proof || require('./proof').createProof({ store, config, log });// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.@@@ -577,8 +577,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, oureturn `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;}-- async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null }) {++ async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null, mode = null }) {if (pin && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(pin)) throw new Error('the Link machine id is invalid');++ // Where the request started (the Dock's Code tab, codes.task-summary@1 mode): openvibe or repo. Rows from++ // before it, and tasks other callers start without naming one, read back by their repository.++ const startMode = mode === 'openvibe' || mode === 'repo' ? mode : null;const id = store.newId('tsk');const at = store.now();const claims = await claimFiles(subject, attachmentIds);@@@ -644,9 +647,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ouconst placementKind = placed ? `harness.${taskKind}` : null;let inserted;await store.tx(async () => {-- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, created_at, updated_at)-- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $18) RETURNING *`,-- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, at]);++ inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, mode, created_at, updated_at)++ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $19, $19) RETURNING *`,++ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, startMode, at]);if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });await taskOutbox.emitTask(inserted, null);return inserted;nnew file mode 100644@@@ -0,0 +1,69 @@++'use strict';++++/**++ * The Dock's Code tab list (codes.task-summary@1): a task in the Dock's words. The person's latest, running first.++ *++ * summarize(rows, { stepsByTask, waitingIds, openvibeUrls }) → [codes.task-summary@1, …]++ *++ * STATUS maps the task's state: queued or placed waits for a machine, running runs, a live task with an approval++ * waiting in its inbox needs the person, succeeded is done, failed or expired failed, cancelled stopped. MODE is the++ * stored mode (openvibe | repo), read back by repository for older rows: an OpenVibe project's own URL is openvibe,++ * anything else repo. STEPS counts the attempts a coding task ran, or a conductor's messages. PREVIEW_URL stays++ * null: Codes tasks have no live preview; a Space build's preview lives behind the create handover, not a task.++ */++const LIVE = new Set(['queued', 'placed', 'running']);++++const iso = (ms) => { const t = new Date(Number(ms)); return Number.isFinite(t.getTime()) ? t.toISOString() : new Date(0).toISOString(); };++++/** The task's first line, as its title: markdown markers stripped, 200 characters at most, never empty. */++function titleOf(row) {++ if (row && row.kind === 'conductor' && row.folder) {++ const folder = String(row.folder).replace(/\/+$/, '').split('/').pop() || 'a folder';++ return `Conductor · ${folder}`.slice(0, 200);++ }++ const line = String((row && row.prompt) || '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean) || '';++ return (line.length > 200 ? `${line.slice(0, 199)}…` : line) || 'New task';++}++++function statusOf(row, waiting) {++ if (LIVE.has(row.state) && waiting) return 'needs-you';++ if (row.state === 'queued' || row.state === 'placed') return 'queued';++ if (row.state === 'running') return 'running';++ if (row.state === 'succeeded') return 'done';++ if (row.state === 'failed' || row.state === 'expired') return 'failed';++ return 'stopped';++}++++function modeOf(row, openvibeUrls) {++ if (row.mode === 'openvibe' || row.mode === 'repo') return row.mode;++ if (row.kind === 'conductor') return 'repo';++ if (openvibeUrls && openvibeUrls.has(String(row.repo || ''))) return 'openvibe';++ return 'repo';++}++++function prUrlOf(row) {++ let result = row && row.result;++ if (typeof result === 'string') { try { result = JSON.parse(result); } catch { return null; } }++ const push = result && typeof result === 'object' ? result.push : null;++ const url = push && typeof push === 'object' ? push.pull_request : null;++ return typeof url === 'string' && /^https:\/\//.test(url) ? url.slice(0, 2000) : null;++}++++function summarize(rows, { stepsByTask = {}, waitingIds = new Set(), openvibeUrls = new Set(), baseUrl = 'https://openvibe.codes' } = {}) {++ const base = String(baseUrl).replace(/\/+$/, '');++ return (Array.isArray(rows) ? rows : []).map((row) => ({++ id: row.id,++ title: titleOf(row),++ status: statusOf(row, waitingIds.has(row.id)),++ mode: modeOf(row, openvibeUrls),++ repo: String(row.repo || '').slice(0, 300),++ started_at: iso(row.created_at),++ updated_at: iso(row.updated_at),++ steps: Math.max(0, Number(stepsByTask[row.id]) || 0),++ url: `${base}/tasks/${encodeURIComponent(row.id)}`,++ preview_url: null,++ pr_url: prUrlOf(row),++ }));++}++++module.exports = { summarize, titleOf, statusOf, modeOf, LIVE };@@@ -21,6 +21,12 @@ const TOPICS = Object.freeze(['codes.task.queued', 'codes.task.running', 'codes.task.succeeded', 'codes.task.failed', 'codes.task.cancelled',]);const TOPIC_SET = new Set(TOPICS);++/**++ * The one event the Dock's Code tab follows instead of polling (codes.task.updated@1): written on every task state++ * change, alongside the per-state codes.task.* event above. Its payload is the per-state payload with the task's++ * page URL added.++ */++const UPDATED_TOPIC = 'codes.task.updated';/** A ticket follows its pull request (AL5): bound, follow-up started, ended. Payloads are plain JSON; the* service manifest's eventsProduced stays the codes.task.* five (contracts.test.js), so these are the* portal's view, not a new contract. */@@@ -123,21 +129,40 @@ function agentStepOf(row, status) {};}--function createOutbox({ store, log = console }) {++function createOutbox({ store, log = console, baseUrl = null } = {}) {const db = store.db;++ const urlOf = (row) => {++ const base = String(baseUrl || 'https://openvibe.codes').replace(/\/+$/, '');++ return `${base}/tasks/${encodeURIComponent(row.id)}`;++ };-- /** Write the state change's event in the caller's (ambient) transaction. → the evt_ id, or null. */++ /**++ * Write the state change's events in the caller's (ambient) transaction. → the first evt_ id, or null.++ * A per-state codes.task.* row goes out when the announced state changed (placed → running is the same++ * announced state, so only the updated row below is written); the codes.task.updated row goes out on every++ * raw state change, so the Dock's Code tab follows one event instead of polling.++ */async function emitTask(row, previousState) {if (!row || !row.id || !row.subject) return null;++ const changed = row.state !== previousState;const topic = topicFor(row.state, previousState);-- if (!topic || !TOPIC_SET.has(topic)) return null;++ if (!changed && (!topic || !TOPIC_SET.has(topic))) return null;const eventId = ids.newId('event', store.now());const at = store.iso();-- await db.exec(-- `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)-- VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,-- [eventId, topic, row.subject, JSON.stringify(payloadOf(row)), at],-- );++ if (topic && TOPIC_SET.has(topic)) {++ await db.exec(++ `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,++ [eventId, topic, row.subject, JSON.stringify(payloadOf(row)), at],++ );++ }++ if (changed) {++ await db.exec(++ `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)++ VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,++ [ids.newId('event', store.now()), UPDATED_TOPIC, row.subject, JSON.stringify({ ...payloadOf(row), url: urlOf(row) }), at],++ );++ }await emitAgentStep(row);return eventId;}@@@ -354,4 +379,4 @@ function createPublisher({ config, store, fetchImpl = globalThis.fetch, log = co};}--module.exports = { createOutbox, createPublisher, TOPICS, TOPIC_SET, PR_TOPICS, AGENT_TOPICS, agentStepOf, topicFor, groupOf, payloadOf, repoSlug, MAX_BACKOFF_MS, DEAD_AFTER_REFUSALS, DEAD_AFTER_ATTEMPTS };++module.exports = { createOutbox, createPublisher, TOPICS, TOPIC_SET, UPDATED_TOPIC, PR_TOPICS, AGENT_TOPICS, agentStepOf, topicFor, groupOf, payloadOf, repoSlug, MAX_BACKOFF_MS, DEAD_AFTER_REFUSALS, DEAD_AFTER_ATTEMPTS };@@@ -16,6 +16,7 @@const { http } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { readMultipart, readBody, isMultipart } = require('./upload');++const { corsOpenVibe } = require('./openvibe-origins');const FILE = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;const INLINE = /^(image\/(png|jpeg|gif|webp|avif|heic)|application\/pdf)$/;@@@ -24,9 +25,8 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, lconst r = asyncRouter();const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;-- /** The person: the session cookie, or their own Network access token as a Bearer (what a job on their machine has). */-- async function subjectOf(req) {-- if (signedIn(req)) return req.viewer.subject;++ /** A write from another OpenVibe site is the person's own bearer token — never the session cookie. */++ const bearerSubject = async (req) => {const header = String(req.headers.authorization || '');if (!header.startsWith('Bearer ') || !keys) return null;try {@@@ -36,18 +36,28 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, lconst subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '') ? subject : null;} catch { return null; }-- }++ };const sameOrigin = (req) => {const origin = req.get('origin');if (!origin) return true;try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }};++ /** The person: the session cookie, or their own Network access token as a Bearer (what a job on their machine has).++ * Across sites the session cookie stays out of it: only the person's own bearer token names them. */++ async function subjectOf(req) {++ if (sameOrigin(req) && signedIn(req)) return req.viewer.subject;++ return bearerSubject(req);++ };++ const writeAllowed = async (req) => sameOrigin(req) || (await bearerSubject(req)) != null;++ // The Dock uploads from another OpenVibe site with the person's bearer token: an allowed origin is reflected++ // so the page may read the answer (server/http/openvibe-origins.js). Only this router's own routes.++ r.use(corsOpenVibe({ methods: 'GET, POST, DELETE, OPTIONS', paths: ['/attachments'] }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });r.post('/attachments', actorLimits.budget('codes.attachment.upload'), async (req, res) => {const subject = await subjectOf(req);if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to attach a file');-- if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site uploads are refused');++ if (!(await writeAllowed(req))) return problem(req, res, 403, 'cross_site', 'cross-site uploads are refused');try {let file;if (isMultipart(req)) {@@@ -85,7 +95,7 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, lr.delete('/attachments/:id', actorLimits.budget('codes.attachment.upload'), async (req, res) => {const subject = await subjectOf(req);if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to remove an attachment');-- if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site changes are refused');++ if (!(await writeAllowed(req))) return problem(req, res, 403, 'cross_site', 'cross-site changes are refused');if (!FILE.test(String(req.params.id))) return problem(req, res, 404, 'attachment.not_found', 'no such attachment');try {return res.json(await attachments.remove(subject, req.params.id));@@@ -430,16 +430,20 @@ function createChatRoutes(ctx) {function effortPicker(selected = '', cls = '') {return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort" data-ovt-default="${esc(selected)}">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;}-- /** Any file (not a program) up to 10 at once: the script adds chips and progress, the plain form posts multipart. */-- function filePicker() {-- return `<label class="ch-pick ch-pick-file ch-for-agents" title="Attach files">${ico('attach')}<span class="ch-sr">Attach files</span><input type="file" name="files" multiple data-ch-files></label>`;-- }++ // The composer's files (the kit's own attach menu, thread.composer): images, text and code, PDF and zip —++ // the owner's words for the limits the server holds (domain/attachments.js). The person's OpenVibe files live++ // on OpenVibe.Media, which names the library row until the shared files picker lands (composer-plumbing).++ const ATTACH_ACCEPT = 'image/*,text/*,application/pdf,application/zip,.pdf,.zip,.md,.markdown,.json,.js,.ts,.jsx,.tsx,.py,.css,.html,.xml,.yml,.yaml,.toml,.sql,.csv,.log,.txt';++ const MEDIA_LIBRARY = (() => { try { const m = require('openvibe-contracts').services.get('media'); return (m && m.publicOrigin) || null; } catch { return null; } })();++ const ATTACH_NOTE = 'Files: up to 10, 20 MB each, 50 MB a message. Programs can\u2019t be attached.';/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */-- function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false }) {++ function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false, value = '' }) {return thread.composer({-- action, id, placeholder, label: placeholder, tools: (files ? filePicker() : '') + pickers,-- options: options ? { html: options, label: 'Options' } : null, examples,-- foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`, note,++ action, id, placeholder, label: placeholder, tools: pickers,++ attach: files ? { menu: true, accept: ATTACH_ACCEPT, library: MEDIA_LIBRARY, name: 'files' } : null,++ options: options ? { html: options, label: 'Options' } : null, examples, value,++ foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`,++ note: files ? (note ? `${note} ${ATTACH_NOTE}` : ATTACH_NOTE) : note,attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,});@@@ -548,7 +552,11 @@ function createChatRoutes(ctx) {const asked = String(req.query.repo || '');// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.const repo = asked || (repos[0] && (repos[0].full_name || repos[0].url)) || '';-- const mode = start.MODES.includes(req.query.mode) ? req.query.mode : asked ? 'code' : repos.length ? 'code' : 'new';++ // The Dock's Code tab names the ways create|openvibe|repo; the site's own are new|openvibe|code.++ const askedMode = { create: 'new', openvibe: 'openvibe', repo: 'code', new: 'new', code: 'code' }[String(req.query.mode || '')];++ const mode = askedMode || (asked ? 'code' : repos.length ? 'code' : 'new');++ // The Dock's fallback when the tasks API is unreachable: its link carries the words here.++ const prompt = String(req.query.prompt || '').slice(0, 4000);const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';const error = String(req.query.error || '').slice(0, 200);@@@ -611,7 +619,7 @@ function createChatRoutes(ctx) {const hello = `<div class="ch-hello"><span class="ov-mark" data-size="40" aria-hidden="true" data-ovt-fold></span><h1>${ask}</h1><p data-ovt-fold>Say it the way you would to a friend.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;return frame(req, res, {title: 'New chat', heading: 'New chat', start: true, empty: hello,-- composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, options, examples: (sets[mode] || []).slice(0, 12).map((i) => i.text), where, files: true }) + nudge + ideas,++ composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, options, examples: (sets[mode] || []).slice(0, 12).map((i) => i.text), where, files: true, value: prompt }) + nudge + ideas,});});@@@ -694,12 +702,12 @@ function createChatRoutes(ctx) {for (const [i, x] of targets.entries()) {const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin });++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin, mode: 'openvibe' });if (!first) first = row;}return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);}-- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin });++ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin, mode: mode === 'openvibe' ? 'openvibe' : 'repo' });return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);});@@@ -18,12 +18,18 @@ const { http, validate } = require('openvibe-contracts');const { asyncRouter } = require('./router');const { parseArgs } = require('../../harness/job');const { createRunTasks, TERMINAL } = require('../domain/run-tasks');++const { summarize } = require('../domain/task-summary');++const { corsOpenVibe } = require('./openvibe-origins');++const start = require('./start');++++// The person's latest, running first; the Dock asks with ?limit=8.++const LIST_LIMIT = 50;const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;const DELEGABLE = new Set(['owner', 'admin']);--function createMeApi({ config, store, sso, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, log = console }) {++function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });@@@ -31,6 +37,26 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,const json = express.json({ limit: '32kb' });const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;++ /**++ * The person a Network access token belongs to, or null (the token openvibe.codes signs a person in with,++ * and the one `openvibe-codes task` reads from OPENVIBE_TOKEN). A person token carries no capability claim:++ * being that person is what authorizes their own tasks.++ */++ async function personOf(token) {++ if (!keys) return null;++ if (!keys.loaded()) await keys.ensure();++ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: [config.oauth.sessionAudience, config.serviceAudience] });++ if (!v.ok) return null;++ const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;++ return USER.test(subject || '') ? subject : null;++ }++ /** The signed-in person, or their own token as a Bearer (what the Dock on another OpenVibe site sends). */++ async function subjectOf(req) {++ if (signedIn(req)) return req.viewer.subject;++ const header = String(req.headers.authorization || '');++ if (header.startsWith('Bearer ')) return personOf(header.slice(7).trim());++ return null;++ }const sameOrigin = (req) => {const origin = req.get('origin');if (!origin) return true;@@@ -69,12 +95,48 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,return row && row.subject === req.viewer.subject ? row : null;};++ // The Dock's Code tab reads this from another OpenVibe site with the person's bearer token: an allowed origin++ // is reflected so the page may read the answer (server/http/openvibe-origins.js). Writes below stay same-site.++ // Only this router's own routes: another router's preflight must fall through to its own answer.++ r.use(corsOpenVibe({ methods: 'GET, POST, OPTIONS', paths: ['/me/tasks'] }));r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });++ /**++ * The person's latest tasks, running first → { tasks: [codes.task-summary@1] }. ?limit= caps the list++ * (the Dock asks with 8). A session cookie or the person's own Network bearer token; another person's tasks++ * are never in the answer.++ */r.get('/me/tasks', actorLimits.reads('codes.read'), async (req, res) => {-- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');-- const rows = await db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY updated_at DESC LIMIT 50', [req.viewer.subject]);-- return res.json({ tasks: rows.map((row) => taskStore.present(row)) });++ const subject = await subjectOf(req);++ if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');++ const limit = Math.min(Math.max(Number(req.query.limit) || LIST_LIMIT, 1), LIST_LIMIT);++ const rows = await db.many(++ `SELECT * FROM run_tasks WHERE subject = $1++ ORDER BY (state IN ('queued', 'placed', 'running')) DESC, updated_at DESC LIMIT $2`,++ [subject, limit],++ );++ const ids = rows.map((row) => row.id);++ const stepsByTask = {};++ if (ids.length) {++ const [attempts, messages] = await Promise.all([++ db.many('SELECT task_id, COUNT(*)::int AS n FROM codes_attempts WHERE task_id = ANY($1) GROUP BY task_id', [ids]),++ db.many('SELECT task_id, COUNT(*)::int AS n FROM codes_conductor_messages WHERE task_id = ANY($1) GROUP BY task_id', [ids]),++ ]);++ for (const a of attempts) stepsByTask[a.task_id] = a.n;++ for (const m of messages) stepsByTask[m.task_id] = Math.max(stepsByTask[m.task_id] || 0, m.n);++ }++ const waiting = new Set();++ if (approvals && ids.length) {++ try {++ for (const a of await approvals.inbox(subject)) if (ids.includes(a.task_id)) waiting.add(a.task_id);++ } catch { /* approvals down: no task reads as waiting for the person */ }++ }++ return res.json({++ tasks: summarize(rows, {++ stepsByTask, waitingIds: waiting,++ openvibeUrls: new Set(start.PROJECTS.map((p) => p.url)), baseUrl: config.baseUrl,++ }),++ });});r.post('/me/tasks', actorLimits.budget('codes.task.start'), json, async (req, res) => {nnew file mode 100644@@@ -0,0 +1,54 @@++'use strict';++++/**++ * Cross-site reads and writes for the person's own token (the Dock's Code tab, on any OpenVibe site): the exact++ * https origins the service manifests declare (`domains` of every manifest in the pinned openvibe-contracts), the++ * same list OpenVibe.Network's own CORS allow-list is built from. Only an origin on the list is reflected, never `*`,++ * and credentials stay out of it: the person's Network bearer token rides the Authorization header, never a cookie.++ *++ * openVibeOrigins() → the cached Set of allowed origins++ * corsOpenVibe({ methods }) → Express middleware: reflect an allowed Origin, answer preflights++ */++const contracts = require('openvibe-contracts');++++const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++let cached = null;++++function openVibeOrigins() {++ if (cached) return cached;++ const out = new Set();++ for (const m of (contracts.services && contracts.services.manifests) || []) {++ for (const d of m.domains || []) {++ const host = String(d || '').trim().toLowerCase();++ if (HOST_RE.test(host)) out.add(`https://${host}`);++ }++ }++ cached = out;++ return out;++}++++/**++ * Reflect an allowed cross-site Origin so its page may read the answer; answer preflights outright. `paths`++ * scopes the middleware to the router's own routes (a string matches exactly or as a path segment prefix,++ * a RegExp is tested as is): another router's preflight must fall through to its own answer, not end here.++ */++function corsOpenVibe({ methods = 'GET, POST, OPTIONS', headers = 'authorization, content-type', paths = null } = {}) {++ const own = (p) => !paths || paths.some((w) => (w instanceof RegExp ? w.test(p) : p === w || p.startsWith(`${w}/`)));++ return (req, res, next) => {++ if (!own(String(req.path || ''))) return next();++ const origin = String(req.get('origin') || '');++ if (origin && openVibeOrigins().has(origin)) {++ res.set('Access-Control-Allow-Origin', origin);++ res.set('Vary', 'Origin');++ }++ if (req.method === 'OPTIONS') {++ res.set('Access-Control-Allow-Methods', methods);++ res.set('Access-Control-Allow-Headers', headers);++ res.set('Access-Control-Max-Age', '600');++ return res.status(204).end();++ }++ return next();++ };++}++++module.exports = { openVibeOrigins, corsOpenVibe };@@@ -36,17 +36,27 @@ const { createTaskEvents } = require('./task-events');const { createServiceTokenClient } = require('openvibe-sdk/auth');const { expandMatrix } = require('../domain/bench');const childReport = require('../domain/child-report');++const { readMultipart, isMultipart } = require('./upload');++const { corsOpenVibe } = require('./openvibe-origins');++const start = require('./start');const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const FIL = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;++// The Dock's Code tab shape (the home composer's three ways, as JSON): POST /tasks dispatches on it.++const DOCK_MODES = ['create', 'openvibe', 'repo'];--function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, log = console }) {++function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, conductor = null, attachments = null, log = console }) {const r = asyncRouter();const db = store.db;const taskStore = tasks || createRunTasks({ store, run, config, log });const taskEvents = createTaskEvents({ store, config, log, attempts: taskStore.attempts });const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });const json = express.json({ limit: '32kb' });++ // The Dock's Code tab calls these from another OpenVibe site with the person's bearer token: an allowed origin++ // is reflected so the page may read the answer (server/http/openvibe-origins.js). Only the Dock's own two++ // routes: another router's preflight must fall through to its own answer.++ r.use(corsOpenVibe({ methods: 'GET, POST, OPTIONS', paths: ['/tasks', /^\/tasks\/[^/]+\/stop$/] }));const benchTokens = config.oauth.clientSecret ? createServiceTokenClient({tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,clientSecret: config.oauth.clientSecret, audience: 'openvibe.bench', scope: 'bench.run.publish',@@@ -182,8 +192,121 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskreturn res.json({ ok: out.ok !== false, machines: latest.machines, agents, at: latest.at, ...(out.problem ? { problem: out.problem } : {}) });});++ /**++ * The Dock's Code tab start (the home composer's three ways, as JSON): { text, mode, repo?, attachments? }.++ * Only the owner starts one this way (a session, or their own Network token); a service keeps the++ * codes.task-start-request@1 shape below. The checks, limits and quota are the site's own (POST /chat): the++ * words, the repository, the project, and the files — all refused before any task is started.++ */++ async function dockCreate(req, res) {++ const p = req.principal;++ if (!p.owner) return problem(req, res, 422, 'codes.task_invalid', 'services start tasks with codes.task-start-request@1 (a prompt and a repo)');++ let b = req.body || {};++ let uploaded = [];++ if (isMultipart(req)) {++ if (!attachments) return problem(req, res, 503, 'codes.attachments_unavailable', 'file uploads are not configured here');++ try {++ const parsed = await readMultipart(req, { maxBytes: attachments.MAX_MESSAGE_BYTES });++ b = parsed.fields || {};++ uploaded = parsed.files || [];++ } catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.task_invalid', String((e && e.message) || 'the form could not be read').slice(0, 300)); }++ }++ const first = (v) => (Array.isArray(v) ? v[0] : v);++ const text = String(first(b.text) || '').trim().slice(0, 16000);++ const mode = String(first(b.mode) || '').trim();++ if (!DOCK_MODES.includes(mode)) return problem(req, res, 422, 'codes.task_invalid', 'mode is create, openvibe or repo');++ if (!text) return problem(req, res, 422, 'codes.task_invalid', 'write what you want made or changed first');++ const repoIn = String(first(b.repo) || '').trim().slice(0, 500);++ const named = [++ ...(Array.isArray(b.attachments) ? b.attachments : b.attachments ? [b.attachments] : []),++ ...(Array.isArray(b.attachment_ids) ? b.attachment_ids : b.attachment_ids ? [b.attachment_ids] : []),++ ].map(String).filter(Boolean);++ // Something new: OpenVibe AI builds it on openvibe.space, like the site's own handover. No Codes task is++ // started: the answer is where to press Build. Files ride with a task, never with a handover.++ if (mode === 'create') {++ if (repoIn || named.length || uploaded.length) {++ return problem(req, res, 422, 'codes.create_invalid', 'Something new starts in OpenVibe AI, where you can add the repository and the files there.');++ }++ return res.json({ id: null, url: start.spaceHref({ text }) });++ }++ // The files this task carries: uploaded before send by their ids, or posted with the form now (no++ // JavaScript). A limit or a program is refused before any task is started; an id from outside Codes names++ // where it resolves (composer.attachment@1, once OpenVibe.Media serves it).++ let ids = [];++ if (named.length || uploaded.length) {++ if (!attachments) return problem(req, res, 503, 'codes.attachments_unavailable', 'file uploads are not configured here');++ const foreign = named.find((id) => !FIL.test(id));++ if (foreign) return problem(req, res, 422, 'codes.attachment_unresolved', `attach ${foreign.slice(0, 40)} on openvibe.codes first: shared library files arrive with composer.attachment@1`);++ try {++ const saved = uploaded.length ? await attachments.saveMany(p.subject, uploaded) : [];++ const pending = await attachments.pending(p.subject, [...saved.map((f) => f.id), ...named]);++ ids = pending.map((f) => f.id);++ } catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.task_invalid', String((e && e.message) || 'the files could not be attached').slice(0, 300)); }++ }++ // Which repository the task works on: one of OpenVibe's own projects, or the person's own address.++ let targets = null;++ let repoUrl = '';++ if (mode === 'openvibe') {++ if (repoIn) {++ const hit = start.PROJECTS.find((x) => x.repository === repoIn || x.url === start.normalizeRepo(repoIn));++ if (!hit) return problem(req, res, 422, 'codes.repo_unknown', 'pick one of OpenVibe\u2019s own projects, or name yours with mode repo');++ repoUrl = hit.url;++ } else {++ const found = start.matchProjects(text);++ if (!found.length) {++ return problem(req, res, 422, 'codes.project_unknown', 'say which OpenVibe site this is for, in your own words (like Live, Chat or Games, or \u201cthe navbar\u201d), or pick it from the list');++ }++ targets = found;++ repoUrl = found[0].url;++ }++ } else {++ repoUrl = start.normalizeRepo(repoIn);++ if (!repoUrl) {++ return problem(req, res, 422, 'codes.task_invalid', repoIn++ ? 'That is not a Git address Codes can clone: use https://\u2026, git@host:owner/name or owner/name, with no password in it.'++ : 'Pick a repository (owner/name or any Git URL), choose an OpenVibe site, or start something new.');++ }++ }++ let args;++ try {++ args = parseArgs(JSON.stringify({ repo: repoUrl, prompt: text, permission: 'edit' }));++ } catch (e) {++ return problem(req, res, 422, 'codes.task_invalid', e.message);++ }++ const header = String(req.headers.authorization || '');++ const token = req.token || (req.viewer && req.viewer.token) || (header.startsWith('Bearer ') ? header.slice(7).trim() : null);++ let project = null;++ try {++ project = await taskStore.latestProject(p.subject)++ || (conductor ? await conductor.projectFor(token, p.subject) : null);++ } catch { project = null; }++ if (!project) return problem(req, res, 409, 'codes.no_project', 'Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');++ const jobInput = (repo, prompt) => JSON.stringify({ repo, prompt, permission: 'edit' });++ let firstRow = null;++ try {++ if (targets && targets.length > 1) {++ // One request across several projects: a task for each, each told what the others are, like the site.++ const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));++ for (const [i, x] of targets.entries()) {++ const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;++ const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));++ const row = await taskStore.start({ subject: p.subject, project, args: each, attachmentIds: i === 0 ? ids : [], mode: 'openvibe' });++ if (!firstRow) firstRow = row;++ }++ } else {++ firstRow = await taskStore.start({ subject: p.subject, project, args, attachmentIds: ids, mode: mode === 'openvibe' ? 'openvibe' : 'repo' });++ }++ } catch (e) {++ return problem(req, res, e.status || 502, e.code || 'codes.task_failed', String((e && e.message) || 'the task could not start').slice(0, 300));++ }++ log.info(`[Codes] task ${firstRow.id} started from the Dock for ${p.subject} (${mode})`);++ return res.status(201).json({ id: firstRow.id, url: `${config.baseUrl}/tasks/${firstRow.id}` });++ }++r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {const b = req.body;++ // The Dock's shape takes its own path; services and the site shape keep the contract below.++ if ((b && b.mode != null) || isMultipart(req)) return dockCreate(req, res);// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored// separately. When the contract is amended, pass them through instead.const actorRef = {};@@@ -371,13 +494,18 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, taskreturn res.json(await timeline(out.row));});-- /** The running attempt stops: the outcome is "stopped" and nothing carries it on. */++ /**++ * The running attempt stops: the outcome is "stopped" and nothing carries it on. The owner (the Dock's shape)++ * gets { ok }; a service keeps the timeline its capability was built on. Another person's task is 404, never++ * a refusal that says it exists.++ */r.post('/tasks/:id/stop', guardAny('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {const row = await mine(req);if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');const out = await taskStore.stopTask(row);if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);log.info(`[Codes] task ${row.id} stopped by ${req.principal.via || req.principal.subject}`);++ if (req.principal.owner) return res.json({ ok: true });return res.json(await timeline(out.row));});@@@ -164,15 +164,18 @@ function multipart(fields, files) {assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);});-- await check('the composers offer files without JavaScript too: a multipart form and a file input', async () => {++ await check('the composers offer files without JavaScript too: a multipart form and the kit\u2019s attach menu', async () => {const home = await t.get('/', { as: ada });assert.strictEqual(home.status, 200);assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');-- assert.match(home.text, /<input type="file" name="files" multiple data-ch-files>/, 'and has the file input');++ assert.match(home.text, /data-ovt-attach/, 'the kit\u2019s attach menu');++ assert.match(home.text, /<input type="file" name="files"[^>]*multiple data-ovt-files/, 'the Files row takes files');++ assert.match(home.text, /accept="[^"]*image\/\*/, 'images, text, code, PDF and zip');++ assert.match(home.text, /From your OpenVibe files/, 'the library row');assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');const task = await t.get(`/tasks/${taskId}`, { as: ada });assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');-- assert.match(task.text, /data-ch-files/);++ assert.match(task.text, /data-ovt-files/);});await check('a multipart reply carries its own files on the next turn', async () => {nnew file mode 100644@@@ -0,0 +1,268 @@++'use strict';++/**++ * The Dock's Code tab API (the home composer's three ways, cross-site): the person's tasks as++ * codes.task-summary@1 (GET /api/v1/me/tasks, running first), starting one (POST /api/v1/tasks++ * { text, mode, repo?, attachments? }) and stopping one (POST /api/v1/tasks/:id/stop → { ok }).++ *++ * A session cookie or the person's own Network bearer token; another person's task is 404, never shown.++ * Cross-site reads and uploads from the OpenVibe origins are reflected; anything else is not.++ */++const assert = require('assert');++const { boot, check, done } = require('./helpers/boot');++const { summarize } = require('../server/domain/task-summary');++++const SUMMARY_KEYS = ['id', 'title', 'status', 'mode', 'repo', 'started_at', 'updated_at', 'steps', 'url', 'preview_url', 'pr_url'];++// TODO (contracts 0.401.0): validate the answers with contracts.validate('codes.task-summary@1', …)++// once the pin carries it, instead of this structural mirror of its required keys and enums.++function assertSummary(s) {++ assert.deepStrictEqual(Object.keys(s).sort(), SUMMARY_KEYS.slice().sort());++ assert.match(s.id, /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/);++ assert.ok(typeof s.title === 'string' && s.title.length >= 1 && s.title.length <= 200, 'a title');++ assert.ok(['queued', 'running', 'needs-you', 'done', 'failed', 'stopped'].includes(s.status), `status ${s.status}`);++ assert.ok(['create', 'openvibe', 'repo'].includes(s.mode), `mode ${s.mode}`);++ assert.ok(!Number.isNaN(Date.parse(s.started_at)) && !Number.isNaN(Date.parse(s.updated_at)), 'timestamps');++ assert.ok(Number.isInteger(s.steps) && s.steps >= 0, 'steps');++ assert.ok(s.url.startsWith('https://openvibe.codes/tasks/'), 'the task page');++ assert.ok(s.preview_url === null, 'no preview yet');++ assert.ok(s.pr_url === null || typeof s.pr_url === 'string', 'the pull request or null');++}++++(async () => {++ let readState = 'queued';++ let jobs = 0;++ const calls = [];++ const run = {++ enabled: true,++ async submit(o) { calls.push(['submit', o]); jobs += 1; return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${jobs}`, state: 'queued' }; },++ async read(jobId) { return { id: jobId, state: readState, result: null, error: null }; },++ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); return { id: jobId, state: 'cancelled' }; },++ async streamUrl() { throw new Error('not used'); },++ };++ const t = await boot({ run });++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const adaToken = () => t.network.userToken(ada);++++ const api = (method, path, { as = null, bearer = null, body = undefined, origin = undefined } = {}) => {++ const headers = {};++ if (origin) headers.origin = origin;++ if (bearer) headers.authorization = `Bearer ${bearer}`;++ return t.get(`/api/v1${path}`, { as, method, ...(body !== undefined ? { json: body } : {}), headers });++ };++ const startRepo = (over = {}, opts = {}) => api('POST', '/tasks', {++ body: { text: 'Add a test for the date parser.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World', ...over },++ ...opts,++ });++++ try {++ await check('a guest is refused everywhere', async () => {++ assert.strictEqual((await api('GET', '/me/tasks')).status, 401);++ assert.strictEqual((await startRepo()).status, 401);++ assert.strictEqual((await api('POST', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3/stop')).status, 401);++ });++++ await check('CORS: an OpenVibe origin is reflected, anything else is not; preflights end here', async () => {++ const yes = await api('GET', '/me/tasks?limit=1', { as: ada, origin: 'https://openvibe.chat' });++ assert.strictEqual(yes.status, 200);++ assert.strictEqual(yes.headers.get('access-control-allow-origin'), 'https://openvibe.chat');++ const no = await api('GET', '/me/tasks?limit=1', { as: ada, origin: 'https://evil.example' });++ assert.strictEqual(no.status, 200);++ assert.strictEqual(no.headers.get('access-control-allow-origin'), null);++ const pre = await t.get('/api/v1/me/tasks', { method: 'OPTIONS', headers: { origin: 'https://openvibe.chat' } });++ assert.strictEqual(pre.status, 204);++ assert.ok((pre.headers.get('access-control-allow-methods') || '').includes('GET'));++ });++++ let firstId;++ await check('a repo task starts from the Dock shape, by session and by bearer', async () => {++ const r = await startRepo({}, { as: ada });++ assert.strictEqual(r.status, 201, r.text);++ assert.match(r.json().id, /^tsk_/);++ assert.strictEqual(r.json().url, `https://openvibe.codes/tasks/${r.json().id}`);++ firstId = r.json().id;++ const row = await t.ctx.store.db.maybe('SELECT mode, subject FROM run_tasks WHERE id = $1', [firstId]);++ assert.strictEqual(row.mode, 'repo');++ assert.strictEqual(row.subject, ada.subject);++ const byBearer = await startRepo({ text: 'Fix the off-by-one in the pager.' }, { bearer: adaToken() });++ assert.strictEqual(byBearer.status, 201, byBearer.text);++ });++++ await check('the start is validated like the site: words, mode and repository', async () => {++ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: '', mode: 'repo', repo: 'https://github.com/o/r' } })).status, 422);++ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'teleport' } })).status, 422);++ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'repo' } })).status, 422);++ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'repo', repo: 'not a repo' } })).status, 422);++ const before = calls.filter((c) => c[0] === 'submit').length;++ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'create', repo: 'https://github.com/o/r' } })).status, 422);++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before, 'a refused start reaches no machine');++ });++++ await check('create hands over to OpenVibe AI: no task, the Space address to press Build in', async () => {++ const r = await api('POST', '/tasks', { as: ada, body: { text: 'A bakery site with today\u2019s hours', mode: 'create' } });++ assert.strictEqual(r.status, 200, r.text);++ assert.strictEqual(r.json().id, null);++ assert.ok(String(r.json().url).startsWith('https://openvibe.space'), r.json().url);++ });++++ await check('openvibe mode works out the project from the words, like the site', async () => {++ const r = await api('POST', '/tasks', { as: ada, body: { text: 'Make Live faster on phones', mode: 'openvibe' } });++ assert.strictEqual(r.status, 201, r.text);++ const row = await t.ctx.store.db.maybe('SELECT repo, mode FROM run_tasks WHERE id = $1', [r.json().id]);++ assert.strictEqual(row.repo, 'https://github.com/OpenVibers/OpenVibe.Live');++ assert.strictEqual(row.mode, 'openvibe');++ const vague = await api('POST', '/tasks', { as: ada, body: { text: 'Make it faster on phones', mode: 'openvibe' } });++ assert.strictEqual(vague.status, 422, 'no project named, no task');++ });++++ await check('a service keeps the contract shape: the Dock shape is the owner\u2019s', async () => {++ const svc = t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.start']);++ const r = await api('POST', '/tasks', { body: { text: 'Hi', mode: 'repo', repo: 'https://github.com/o/r' }, bearer: svc });++ assert.strictEqual(r.status, 422);++ });++++ let attachedId;++ await check('composer attachment ids ride the task: named in the first message, on disk for the runner', async () => {++ const data = new FormData();++ data.append('file', new Blob(['hello'], { type: 'text/plain' }), 'notes.txt');++ const up = await fetch(`${t.base}/api/v1/attachments`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: data });++ const upText = await up.text();++ assert.strictEqual(up.status, 201, upText);++ const saved = JSON.parse(upText).attachment;++ const r = await api('POST', '/tasks', { as: bo, body: { text: 'Read the notes.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World', attachments: [saved.id] } });++ assert.strictEqual(r.status, 201, r.text);++ attachedId = r.json().id;++ const linked = await t.ctx.store.db.many('SELECT id, task_id FROM codes_attachments WHERE task_id = $1', [attachedId]);++ assert.strictEqual(linked.length, 1);++ // The task's first turn carries the workspace path (the row keeps the person's own words).++ const submitted = calls.filter((c) => c[0] === 'submit').pop()[1];++ assert.ok(submitted.args.prompt.includes('.openvibe/attachments/') && submitted.args.prompt.includes('notes.txt'), 'the first message names the workspace path');++ assert.ok(Array.isArray(submitted.args.attachments) && submitted.args.attachments.length === 1, 'the file rides the job');++ const foreign = await api('POST', '/tasks', { as: bo, body: { text: 'Read it.', mode: 'repo', repo: 'https://github.com/o/r', attachments: ['att_123'] } });++ assert.strictEqual(foreign.status, 422, 'a foreign id names composer.attachment@1, not this task');++ });++++ await check('multipart posts work too, and a program is refused before any task starts', async () => {++ const data = new FormData();++ data.append('text', 'Read the notes.');++ data.append('mode', 'repo');++ data.append('repo', 'https://github.com/octocat/Hello-World');++ data.append('files', new Blob(['hello'], { type: 'text/plain' }), 'notes.txt');++ const before = calls.filter((c) => c[0] === 'submit').length;++ const r = await fetch(`${t.base}/api/v1/tasks`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: data });++ assert.strictEqual(r.status, 201, await r.text());++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before + 1);++ const bad = new FormData();++ bad.append('text', 'Run it.');++ bad.append('mode', 'repo');++ bad.append('repo', 'https://github.com/octocat/Hello-World');++ bad.append('files', new Blob(['MZ' + 'x'.repeat(10)], { type: 'application/x-msdownload' }), 'run.exe');++ const refused = await fetch(`${t.base}/api/v1/tasks`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: bad });++ assert.strictEqual(refused.status, 422);++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before + 1, 'a refused start reaches no machine');++ });++++ await check('the list is the person\u2019s latest, running first, as codes.task-summary@1', async () => {++ readState = 'running';++ const row = await t.ctx.store.db.maybe('SELECT * FROM run_tasks WHERE id = $1', [firstId]);++ await t.ctx.tasks.refresh(row, { force: true });++ readState = 'queued';++ const list = await api('GET', '/me/tasks?limit=8', { as: ada });++ assert.strictEqual(list.status, 200);++ const tasks = list.json().tasks;++ assert.ok(tasks.length >= 2 && tasks.length <= 8, 'limit respected');++ assert.strictEqual(tasks[0].id, firstId, 'the running task lists first');++ assert.strictEqual(tasks[0].status, 'running');++ assert.ok(tasks.every((x) => x.mode === 'repo' || x.mode === 'openvibe'), 'every task names its mode');++ for (const s of tasks) assertSummary(s);++ const one = await api('GET', '/me/tasks?limit=1', { as: ada });++ assert.strictEqual(one.json().tasks.length, 1, '?limit= caps the list');++ });++++ await check('statuses read in the Dock\u2019s words: done, failed, stopped', async () => {++ const r = await startRepo({ text: 'Another one.' }, { as: bo });++ const id = r.json().id;++ readState = 'succeeded';++ await t.ctx.tasks.refresh(await t.ctx.store.db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]), { force: true });++ readState = 'queued';++ const doneRow = (await api('GET', '/me/tasks?limit=50', { as: bo })).json().tasks.find((x) => x.id === id);++ assert.strictEqual(doneRow.status, 'done');++ const stop = await api('POST', `/tasks/${firstId}/stop`, { as: ada });++ assert.strictEqual(stop.status, 200, stop.text);++ assert.deepStrictEqual(stop.json(), { ok: true });++ const stopped = (await api('GET', '/me/tasks?limit=50', { as: ada })).json().tasks.find((x) => x.id === firstId);++ assert.strictEqual(stopped.status, 'stopped');++ });++++ await check('a person cannot see or stop another\u2019s task', async () => {++ const list = await api('GET', '/me/tasks?limit=50', { as: bo });++ assert.ok(!list.json().tasks.some((x) => x.id === firstId), 'no foreign task in the list');++ assert.strictEqual((await api('GET', `/me/tasks/${firstId}`, { as: bo })).status, 404);++ assert.strictEqual((await api('POST', `/tasks/${firstId}/stop`, { as: bo })).status, 404);++ assert.strictEqual((await api('POST', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T9/stop', { as: ada })).status, 404);++ });++++ await check('the home reads ?prompt=, ?mode= and ?repo= and prefills its composer', async () => {++ const plain = await t.get('/', { as: ada });++ assert.strictEqual(plain.status, 200);++ assert.ok(plain.text.includes('data-ovt-attach'), 'the kit\u2019s attach menu');++ assert.ok(plain.text.includes('image/*'), 'images, text, code, PDF and zip');++ assert.ok(plain.text.includes('https://openvibe.media'), 'the person\u2019s OpenVibe files');++ assert.ok(plain.text.includes('Programs can\u2019t be attached'), 'the limits in the note');++ const pre = await t.get('/?prompt=' + encodeURIComponent('Make it faster') + '&mode=repo&repo=' + encodeURIComponent('octocat/Hello-World'), { as: ada });++ assert.ok(pre.text.includes('>Make it faster</textarea>'), 'the words are in the box');++ assert.ok(pre.text.includes('value="octocat/Hello-World"'), 'the repository is picked');++ assert.ok(/value="code"[^>]*checked|checked[^>]*value="code"/.test(pre.text), 'repo reads as My code');++ const create = await t.get('/?mode=create', { as: ada });++ assert.ok(/value="new"[^>]*checked|checked[^>]*value="new"/.test(create.text), 'create reads as Create');++ });++++ await check('another router\u2019s preflight still ends at its own answer', async () => {++ const pre = await fetch(`${t.base}/api/v1/me/agent-view`, {++ method: 'OPTIONS', headers: { origin: 'https://openvibe.live' },++ });++ assert.strictEqual(pre.status, 204);++ assert.strictEqual(pre.headers.get('access-control-allow-origin'), '*', 'agent-view keeps its own wildcard');++ });++++ await check('summarize maps rows to the contract: needs-you, old rows, titles', async () => {++ const base = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', prompt: 'Fix it\n\nMore words.', repo: 'https://github.com/octocat/Hello-World', created_at: 1, updated_at: 2 };++ const [waiting] = summarize([{ ...base, state: 'running' }], { waitingIds: new Set([base.id]) });++ assert.strictEqual(waiting.status, 'needs-you');++ assert.strictEqual(waiting.steps, 0);++ const [live] = summarize([{ ...base, state: 'running' }], {});++ assert.strictEqual(live.status, 'running');++ const [old] = summarize([{ ...base, state: 'queued', mode: null }], { openvibeUrls: new Set(['https://github.com/OpenVibers/OpenVibe.Live']) });++ assert.strictEqual(old.mode, 'repo');++ assert.strictEqual(old.title, 'Fix it');++ const [ov] = summarize([{ ...base, state: 'queued', mode: null, repo: 'https://github.com/OpenVibers/OpenVibe.Live' }], { openvibeUrls: new Set(['https://github.com/OpenVibers/OpenVibe.Live']) });++ assert.strictEqual(ov.mode, 'openvibe');++ const [cond] = summarize([{ ...base, state: 'queued', kind: 'conductor', prompt: '', folder: '/home/you/work' }], {});++ assert.strictEqual(cond.title, 'Conductor \u00b7 work');++ const [pr] = summarize([{ ...base, state: 'succeeded', result: JSON.stringify({ ok: true, push: { pushed: true, pull_request: 'https://github.com/o/r/pull/1' } }) }], {});++ assert.strictEqual(pr.status, 'done');++ assert.strictEqual(pr.pr_url, 'https://github.com/o/r/pull/1');++ });++ } finally {++ await t.close();++ }++++ // The quota: its own boot with the limiter counting, so the other checks stay uncounted.++ const q = await boot({ run, actorLimits: true });++ try {++ const cara = q.network.addUser('cara');++ await check('the start quota refuses the seventh task in a minute, before any work', async () => {++ const before = calls.filter((c) => c[0] === 'submit').length;++ for (let i = 0; i < 6; i++) {++ const r = await q.get('/api/v1/tasks', { as: cara, json: { text: `Task ${i}.`, mode: 'repo', repo: 'https://github.com/octocat/Hello-World' } });++ assert.strictEqual(r.status, 201, r.text);++ }++ const refused = await q.get('/api/v1/tasks', { as: cara, json: { text: 'One too many.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World' } });++ assert.strictEqual(refused.status, 429);++ assert.strictEqual(refused.json().code, 'rate_limited');++ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length - before, 6, 'the refused start reaches no machine');++ });++ } finally {++ await q.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });@@@ -86,8 +86,9 @@ function eventOf(id, type, jobId, subject, project, state, timings = {}) {const line = await db.maybe("SELECT * FROM codes_session_lines WHERE task_id = $1 AND kind = 'done'", [task.id]);assert.ok(line, 'the result log was back-filled');const outbox = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1 ORDER BY created_at', [task.id]);-- assert.deepStrictEqual(outbox.map((x) => x.topic), ['codes.task.queued', 'codes.task.running', 'codes.task.succeeded']);-- const payload = outbox[2].payload;++ assert.deepStrictEqual(outbox.map((x) => x.topic).sort(),++ ['codes.task.queued', 'codes.task.running', 'codes.task.succeeded', 'codes.task.updated', 'codes.task.updated', 'codes.task.updated'].sort());++ const payload = outbox.find((x) => x.topic === 'codes.task.succeeded').payload;assert.strictEqual(payload.task_id, task.id);assert.strictEqual(payload.state, 'succeeded');assert.strictEqual(payload.repo, 'Octocat/Hello-World');@@@ -56,14 +56,18 @@ const { createPublisher } = require('../server/events/outbox');await check('a task start writes its queued row in the change transaction, with the fixed evt_ id', async () => {const rows = await db.many('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [task.id]);-- assert.strictEqual(rows.length, 1);-- const row = rows[0];++ assert.strictEqual(rows.length, 2, 'the per-state row and the Dock\u2019s codes.task.updated row');++ const row = rows.find((x) => x.topic === 'codes.task.queued');assert.match(row.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);-- assert.strictEqual(row.topic, 'codes.task.queued');assert.strictEqual(row.subject, ada.subject);assert.deepStrictEqual(Object.keys(row.payload).sort(), ['agent', 'cost_usd', 'error', 'files_changed', 'finished_at', 'repo', 'started_at', 'state', 'task_id']);assert.strictEqual(row.payload.repo, 'Octocat/Hello-World');assert.strictEqual(row.payload.state, 'queued');++ const updated = rows.find((x) => x.topic === 'codes.task.updated');++ assert.ok(updated, 'codes.task.updated is written on the state change');++ assert.strictEqual(updated.subject, ada.subject);++ assert.strictEqual(updated.payload.state, 'queued');++ assert.strictEqual(updated.payload.url, `https://openvibe.codes/tasks/${task.id}`);const before = await db.value('SELECT COUNT(*)::int FROM codes_outbox');await assert.rejects(t.ctx.store.tx(async () => {await t.ctx.outbox.emitTask({ ...task, state: 'succeeded', result: { ok: true } }, 'running');@@@ -72,17 +76,21 @@ const { createPublisher } = require('../server/events/outbox');assert.strictEqual(await db.value('SELECT COUNT(*)::int FROM codes_outbox'), before, 'the event is not written without the change');});-- await check('a Run state change writes one row in the same transaction', async () => {++ await check('a Run state change writes its rows in the same transaction', async () => {readState = 'succeeded';readResult = { ok: true, cost_usd: 0.02, files: [{ path: 'README', added: 1, removed: 1 }], log: [{ t: 'done', ok: true }] };const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);const done_ = await t.ctx.tasks.refresh(row, { force: true });assert.strictEqual(done_.state, 'succeeded');-- const rows = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1 ORDER BY created_at, event_id', [task.id]);-- assert.deepStrictEqual(rows.map((x) => x.topic), ['codes.task.queued', 'codes.task.succeeded']);-- assert.strictEqual(rows[1].payload.state, 'succeeded');-- assert.strictEqual(rows[1].payload.cost_usd, 0.02);-- assert.strictEqual(rows[1].payload.files_changed, 1);++ const rows = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1', [task.id]);++ assert.deepStrictEqual(rows.map((x) => x.topic).sort(), ['codes.task.queued', 'codes.task.succeeded', 'codes.task.updated', 'codes.task.updated'].sort());++ const ended = rows.find((x) => x.topic === 'codes.task.succeeded');++ assert.strictEqual(ended.payload.state, 'succeeded');++ assert.strictEqual(ended.payload.cost_usd, 0.02);++ assert.strictEqual(ended.payload.files_changed, 1);++ const updates = rows.filter((x) => x.topic === 'codes.task.updated');++ assert.deepStrictEqual(updates.map((x) => x.payload.state).sort(), ['queued', 'succeeded']);++ assert.ok(updates.every((x) => x.payload.url === `https://openvibe.codes/tasks/${task.id}`), 'every updated row links the task');});await check('publishing sends each row once, with the fixed id and the proposal envelope', async () => {@@@ -115,7 +123,9 @@ const { createPublisher } = require('../server/events/outbox');await check('a failure records attempts/last_error, backs off, then retries the same id', async () => {const second = await t.ctx.tasks.start({ subject: ada.subject, project, args });-- const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [second.id]);++ // Only the per-state row is due: the updated row waits its turn, so the failure below is deterministic.++ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() + interval '1 hour' WHERE payload->>'task_id' = $1 AND topic = 'codes.task.updated'", [second.id]);++ const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1 AND topic = \'codes.task.queued\'', [second.id]);failNext = 1;const failed = await t.ctx.publisher.runOnce();assert.strictEqual(failed.failed, 1);@@@ -132,17 +142,19 @@ const { createPublisher } = require('../server/events/outbox');assert.ok(after.published_at);assert.strictEqual(after.attempts, 1, 'the failed attempt stays recorded');assert.strictEqual(received.filter((x) => x.event_id === row.event_id).length, 1, 'one event under the fixed id');++ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE payload->>'task_id' = $1 AND topic = 'codes.task.updated'", [second.id]);++ assert.strictEqual((await t.ctx.publisher.runOnce()).published, 1, 'the updated row follows');});await check('an event OpenVibe.Events refuses is dead after a fixed number of tries, not retried forever', async () => {const third = await t.ctx.tasks.start({ subject: ada.subject, project, args });-- const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [third.id]);-- refuseNext = 3;++ const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1 AND topic = \'codes.task.queued\'', [third.id]);++ refuseNext = 6;for (let tries = 0; tries < 3; tries++) {-- await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);++ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE payload->>'task_id' = $1", [third.id]);const r = await t.ctx.publisher.runOnce();-- assert.strictEqual(r.claimed, 1, 'the row is claimed');-- assert.strictEqual(r.failed, 1, 'the refusal is a failure');++ assert.strictEqual(r.claimed, 2, 'both rows are claimed');++ assert.strictEqual(r.failed, 2, 'the refusals are failures');}const dead = await db.one('SELECT * FROM codes_outbox WHERE event_id = $1', [row.event_id]);assert.ok(dead.dead_at, 'the event is dead');@@@ -151,7 +163,7 @@ const { createPublisher } = require('../server/events/outbox');assert.strictEqual(dead.attempts, 3, 'it got the tries it was given');assert.match(dead.last_error, /events 422/);assert.strictEqual(await t.ctx.publisher.pending(), 0, 'a dead event is not pending');-- assert.strictEqual(await t.ctx.publisher.dead(), 1);++ assert.strictEqual(await t.ctx.publisher.dead(), 2, 'the per-state row and the updated row are both dead');await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);assert.strictEqual((await t.ctx.publisher.runOnce()).claimed, 0, 'a dead event is never sent again');refuseNext = 0;@@@ -201,7 +201,9 @@ const { boot, check, done } = require('./helpers/boot');assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');const stopped = await asAda('POST', `/tasks/${own.id}/stop`);assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));-- assert.strictEqual(stopped.json.outcome.state, 'stopped');++ assert.deepStrictEqual(stopped.json, { ok: true }, 'the owner\u2019s shape (the Dock); a service keeps the timeline');++ const ended = await asAda('GET', `/tasks/${own.id}`);++ assert.strictEqual(ended.json.outcome.state, 'stopped', 'the task did stop');});await check('list filters: needs is the task the engine left unfinished, done is the rest', async () => {
@@ -206,6 +206,8 @@ async function stopTask(args) {
@@ -206,6 +206,8 @@ async function stopTask(args) {
const id = String(args.id || '');
const id = String(args.id || '');
if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');
if (!TASK.test(id)) return failed('that is not a task id (tsk_…)');
const t = await call(auth(), 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);
const t = await call(auth(), 'POST', `/api/v1/tasks/${encodeURIComponent(id)}/stop`);
+ // The owner's shape is { ok: true }: the stop was accepted, so the attempt stops and the outcome is stopped.
+ if (t && t.ok === true) return text({ id, state: 'stopped', outcome: 'stopped' });
return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
return text({ id: t.id, state: t.state, outcome: t.outcome ? t.outcome.state : null });
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- The Dock's Code tab (codes.task-summary@1 mode): where the request started — openvibe (one of OpenVibe's own
+-- projects) or repo (the person's own repository). Rows from before it read back by their repository instead.
+ALTER TABLE run_tasks
+ ADD COLUMN IF NOT EXISTS mode text COLLATE "C";
@@ -70,9 +70,6 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -70,9 +70,6 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}
.ch-pick .ch-i:not(.ch-caret){width:15px;height:15px}
.ch-pick-repo input{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
.ch-pick-repo input{width:min(240px,48vw);border:0;background:transparent;color:inherit;font:inherit;font-size:13px}
.ch-pick-repo input:focus{outline:none}
.ch-pick-repo input:focus{outline:none}
-.ch-pick-file{cursor:pointer;padding:0 9px}
-.ch-pick-file input{position:absolute;inset:0;opacity:0;cursor:pointer}
-.ch-pick-file.has-files{color:var(--ch-accent);border-color:color-mix(in srgb,var(--ch-accent) 50%,var(--ch-line))}
/* Files waiting in the composer: one row that wraps, a chip per file that never outgrows a phone; the upload is a
/* Files waiting in the composer: one row that wraps, a chip per file that never outgrows a phone; the upload is a
2px line along the chip's foot, and the remove keeps a 44px target inside a 32px chip. */
2px line along the chip's foot, and the remove keeps a 44px target inside a 32px chip. */
.ch-files{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px;min-width:0}
.ch-files{display:flex;flex-wrap:wrap;gap:6px;margin:0 0 8px;min-width:0}
@@ -125,8 +122,9 @@ details[open]>summary .ch-caret{rotate:180deg}
@@ -125,8 +122,9 @@ details[open]>summary .ch-caret{rotate:180deg}
.ch-dim{height:32px;padding:0 11px;font-size:12.5px;font-weight:750;letter-spacing:.03em}
.ch-dim{height:32px;padding:0 11px;font-size:12.5px;font-weight:750;letter-spacing:.03em}
.ch-kinds:has([data-ch-kind][value="game"]:checked) .ch-dims{display:inline-flex}
.ch-kinds:has([data-ch-kind][value="game"]:checked) .ch-dims{display:inline-flex}
@media (pointer:coarse){.ch-kind{height:40px}.ch-dim{height:40px}}
@media (pointer:coarse){.ch-kind{height:40px}.ch-dim{height:40px}}
-/* Each picker and line shows for the way it belongs to. */
-.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents),
+/* Each picker and line shows for the way it belongs to (the kit's attach menu with them: files ride with a
+ task on your code or an OpenVibe site, never with Something new). */
+.ch:has([data-ch-mode][value="new"]:checked) :is(.ch-for-openvibe,.ch-for-code,.ch-for-agents,.ovt-attach-menu,.ovt-attach),
.ch:has([data-ch-mode][value="openvibe"]:checked) :is(.ch-for-new,.ch-for-code),
.ch:has([data-ch-mode][value="openvibe"]:checked) :is(.ch-for-new,.ch-for-code),
.ch:has([data-ch-mode][value="code"]:checked) :is(.ch-for-new,.ch-for-openvibe){display:none}
.ch:has([data-ch-mode][value="code"]:checked) :is(.ch-for-new,.ch-for-openvibe){display:none}
/* The repositories used last, one tap each: a sideways row that never wraps the composer taller. */
/* The repositories used last, one tap each: a sideways row that never wraps the composer taller. */
@@ -195,7 +195,10 @@
@@ -195,7 +195,10 @@
}
}
if (form) {
if (form) {
// ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──
// ── The composer's files: upload before send, with a chip, a thumbnail, a progress bar and a × ──
- const picker = $('[data-ch-files]', form);
+ // The kit's attach menu renders one input per row (Files, Photos and videos, the camera): every one of
+ // them feeds the same queue, and the no-JavaScript post sends them all as multipart.
+ const pickers = Array.from(form.querySelectorAll('input[data-ovt-files], input[data-ch-files]'));
+ const picker = pickers[0] || null;
const chips = $('[data-ch-files-list]', form);
const chips = $('[data-ch-files-list]', form);
const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
const BLOCKED = /\.(exe|dll|com|bat|cmd|msi|scr|ps1|sh|so|dylib|app|apk|deb|rpm|jar|bin|elf|vbs|wsf|hta)$/i;
const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;
const MAX_FILES = 10, MAX_FILE = 20 * 1024 * 1024, MAX_TOTAL = 50 * 1024 * 1024;
@@ -277,8 +280,8 @@
@@ -277,8 +280,8 @@
}
}
if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);
if (entries.length) say(`${entries.length} file${entries.length === 1 ? '' : 's'} ready to send`);
};
};
- if (picker) {
- picker.addEventListener('change', () => { addFiles(picker.files); picker.value = ''; });
+ for (const one of pickers) {
+ one.addEventListener('change', () => { addFiles(one.files); one.value = ''; });
}
}
// Paste an image from the clipboard, or drop files on the composer.
// Paste an image from the clipboard, or drop files on the composer.
if (text) text.addEventListener('paste', (e) => {
if (text) text.addEventListener('paste', (e) => {
@@ -330,7 +333,7 @@
@@ -330,7 +333,7 @@
}
}
// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).
// The files are up: a plain form post names their ids (and sends the words and the pickers as usual).
form.enctype = 'application/x-www-form-urlencoded';
form.enctype = 'application/x-www-form-urlencoded';
- if (picker) picker.value = '';
+ for (const one of pickers) one.value = '';
for (const id of ids) {
for (const id of ids) {
const hidden = document.createElement('input');
const hidden = document.createElement('input');
hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;
hidden.type = 'hidden'; hidden.name = 'attachment_ids'; hidden.value = id;
@@ -107,7 +107,7 @@ async function createApp(opts = {}) {
@@ -107,7 +107,7 @@ async function createApp(opts = {}) {
const harnesses = createHarnesses();
const harnesses = createHarnesses();
// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.
// Sessions, the run.job.* consumer and the codes.task.* outbox share one store and one clock.
const sessions = createSessionLines({ store, config, log, media: require('./media').createMedia({ config, log }) });
const sessions = createSessionLines({ store, config, log, media: require('./media').createMedia({ config, log }) });
- const outbox = createOutbox({ store, log });
+ const outbox = createOutbox({ store, log, baseUrl: config.baseUrl });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
const run = opts.run || createRunClient({ config, fetchImpl: fetchImpl || globalThis.fetch });
// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
// A person's own connections (Codes C3): their provider keys live in OpenVibe.AI, and their harnesses run with a
// short-lived run token that names them.
// short-lived run token that names them.
@@ -309,10 +309,10 @@ async function createApp(opts = {}) {
@@ -309,10 +309,10 @@ async function createApp(opts = {}) {
}
}
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createApi(ctx));
app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createSkillsApi({ config, skills: ctx.skills, actorLimits: ctx.actorLimits, log }));
- app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, actorLimits: ctx.actorLimits, sharing, log }));
+ app.use('/api/v1', createTasksApi({ config, store, keys, run, harnesses, tasks, fleet: ctx.fleet, conductor: ctx.conductor, attachments: ctx.attachments, actorLimits: ctx.actorLimits, sharing, log }));
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
// The composer's files: upload before send (with progress), or as the multipart form without JavaScript.
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
app.use('/api/v1', createAttachmentsApi({ attachments: ctx.attachments, actorLimits: ctx.actorLimits, config, keys, log }));
- app.use('/api/v1', createMeApi({ config, store, sso, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, actorLimits: ctx.actorLimits, log }));
+ app.use('/api/v1', createMeApi({ config, store, sso, keys, run, harnesses, tasks, userSettings: ctx.userSettings, github: ctx.github, approvals: ctx.approvals, actorLimits: ctx.actorLimits, log }));
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
// The Dock's agent view (Dock K-D3, Agent Streams S1): the person's own desk stream with a view ticket minted here.
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
app.use('/api/v1', createAgentView({ config, sso, actorLimits: ctx.actorLimits, log }));
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
// Know K5: the person's own agents read and add workspace knowledge with their token (the ov-know MCP server).
@@ -159,7 +159,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -159,7 +159,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const who = (row) => ({ subject: row.subject, project: row.project_id });
const who = (row) => ({ subject: row.subject, project: row.project_id });
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
// Lazily, so a caller that only reads tasks does not need the session/outbox modules (and there is no cycle).
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
const sessionLines = sessions || require('./session-lines').createSessionLines({ store, config, log, media: require('../media').createMedia({ config, log }) });
- const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log });
+ const taskOutbox = outbox || require('../events/outbox').createOutbox({ store, log, baseUrl: config && config.baseUrl });
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
// The proof of a finished task (stage C8): the tests, the pages, the diff and the second model's opinion.
const taskProof = proof || require('./proof').createProof({ store, config, log });
const taskProof = proof || require('./proof').createProof({ store, config, log });
// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.
// The session report of a finished task (Agent Streams S3): Codes writes it to OpenVibe.Report when the task ends.
@@ -577,8 +577,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -577,8 +577,11 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
return `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;
return `${base.slice(0, Math.max(0, 16000 - section.length))}${section}`;
}
}
- async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null }) {
+ async function start({ subject, project, args, via = null, key = null, actorRef = null, attachmentIds = [], messageId = null, pin = null, mode = null }) {
if (pin && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(pin)) throw new Error('the Link machine id is invalid');
if (pin && !/^dev_[0-9A-HJKMNP-TV-Z]{26}$/.test(pin)) throw new Error('the Link machine id is invalid');
+ // Where the request started (the Dock's Code tab, codes.task-summary@1 mode): openvibe or repo. Rows from
+ // before it, and tasks other callers start without naming one, read back by their repository.
+ const startMode = mode === 'openvibe' || mode === 'repo' ? mode : null;
const id = store.newId('tsk');
const id = store.newId('tsk');
const at = store.now();
const at = store.now();
const claims = await claimFiles(subject, attachmentIds);
const claims = await claimFiles(subject, attachmentIds);
@@ -644,9 +647,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
@@ -644,9 +647,9 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
const placementKind = placed ? `harness.${taskKind}` : null;
const placementKind = placed ? `harness.${taskKind}` : null;
let inserted;
let inserted;
await store.tx(async () => {
await store.tx(async () => {
- inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $18) RETURNING *`,
- [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, at]);
+ inserted = await db.one(`INSERT INTO run_tasks (id, subject, project_id, repo, ref, prompt, agent, push, pull_request, state, via, idempotency_key, actor_ref, placement_line, placement_option_key, placement_step_kind, pin, goal, mode, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'queued', $10, $11, $12::jsonb, $13, $14, $15, $16, $17::jsonb, $18, $19, $19) RETURNING *`,
+ [id, subject, project, runArgs.repo || 'bench://', runArgs.ref, runArgs.prompt, agent, !!runArgs.push, !!(runArgs.push && runArgs.push.pull_request), via, key, actorRefValue, placementLine, placementKey, placementKind, pin, runArgs.goal ? JSON.stringify(runArgs.goal) : null, startMode, at]);
if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
if (claims.length) await filesStore.link(subject, claims, { taskId: id, attemptN: 1, messageId: turnId });
await taskOutbox.emitTask(inserted, null);
await taskOutbox.emitTask(inserted, null);
return inserted;
return inserted;
new file mode 100644
new file mode 100644
@@ -0,0 +1,69 @@
@@ -0,0 +1,69 @@
+'use strict';
+
+/**
+ * The Dock's Code tab list (codes.task-summary@1): a task in the Dock's words. The person's latest, running first.
+ *
+ * summarize(rows, { stepsByTask, waitingIds, openvibeUrls }) → [codes.task-summary@1, …]
+ *
+ * STATUS maps the task's state: queued or placed waits for a machine, running runs, a live task with an approval
+ * waiting in its inbox needs the person, succeeded is done, failed or expired failed, cancelled stopped. MODE is the
+ * stored mode (openvibe | repo), read back by repository for older rows: an OpenVibe project's own URL is openvibe,
+ * anything else repo. STEPS counts the attempts a coding task ran, or a conductor's messages. PREVIEW_URL stays
+ * null: Codes tasks have no live preview; a Space build's preview lives behind the create handover, not a task.
+ */
+const LIVE = new Set(['queued', 'placed', 'running']);
+
+const iso = (ms) => { const t = new Date(Number(ms)); return Number.isFinite(t.getTime()) ? t.toISOString() : new Date(0).toISOString(); };
+
+/** The task's first line, as its title: markdown markers stripped, 200 characters at most, never empty. */
+function titleOf(row) {
+ if (row && row.kind === 'conductor' && row.folder) {
+ const folder = String(row.folder).replace(/\/+$/, '').split('/').pop() || 'a folder';
+ return `Conductor · ${folder}`.slice(0, 200);
+ }
+ const line = String((row && row.prompt) || '').split('\n').map((l) => l.replace(/^#+\s*/, '').trim()).find(Boolean) || '';
+ return (line.length > 200 ? `${line.slice(0, 199)}…` : line) || 'New task';
+}
+
+function statusOf(row, waiting) {
+ if (LIVE.has(row.state) && waiting) return 'needs-you';
+ if (row.state === 'queued' || row.state === 'placed') return 'queued';
+ if (row.state === 'running') return 'running';
+ if (row.state === 'succeeded') return 'done';
+ if (row.state === 'failed' || row.state === 'expired') return 'failed';
+ return 'stopped';
+}
+
+function modeOf(row, openvibeUrls) {
+ if (row.mode === 'openvibe' || row.mode === 'repo') return row.mode;
+ if (row.kind === 'conductor') return 'repo';
+ if (openvibeUrls && openvibeUrls.has(String(row.repo || ''))) return 'openvibe';
+ return 'repo';
+}
+
+function prUrlOf(row) {
+ let result = row && row.result;
+ if (typeof result === 'string') { try { result = JSON.parse(result); } catch { return null; } }
+ const push = result && typeof result === 'object' ? result.push : null;
+ const url = push && typeof push === 'object' ? push.pull_request : null;
+ return typeof url === 'string' && /^https:\/\//.test(url) ? url.slice(0, 2000) : null;
+}
+
+function summarize(rows, { stepsByTask = {}, waitingIds = new Set(), openvibeUrls = new Set(), baseUrl = 'https://openvibe.codes' } = {}) {
+ const base = String(baseUrl).replace(/\/+$/, '');
+ return (Array.isArray(rows) ? rows : []).map((row) => ({
+ id: row.id,
+ title: titleOf(row),
+ status: statusOf(row, waitingIds.has(row.id)),
+ mode: modeOf(row, openvibeUrls),
+ repo: String(row.repo || '').slice(0, 300),
+ started_at: iso(row.created_at),
+ updated_at: iso(row.updated_at),
+ steps: Math.max(0, Number(stepsByTask[row.id]) || 0),
+ url: `${base}/tasks/${encodeURIComponent(row.id)}`,
+ preview_url: null,
+ pr_url: prUrlOf(row),
+ }));
+}
+
+module.exports = { summarize, titleOf, statusOf, modeOf, LIVE };
@@ -21,6 +21,12 @@ const TOPICS = Object.freeze([
@@ -21,6 +21,12 @@ const TOPICS = Object.freeze([
'codes.task.queued', 'codes.task.running', 'codes.task.succeeded', 'codes.task.failed', 'codes.task.cancelled',
'codes.task.queued', 'codes.task.running', 'codes.task.succeeded', 'codes.task.failed', 'codes.task.cancelled',
]);
]);
const TOPIC_SET = new Set(TOPICS);
const TOPIC_SET = new Set(TOPICS);
+/**
+ * The one event the Dock's Code tab follows instead of polling (codes.task.updated@1): written on every task state
+ * change, alongside the per-state codes.task.* event above. Its payload is the per-state payload with the task's
+ * page URL added.
+ */
+const UPDATED_TOPIC = 'codes.task.updated';
/** A ticket follows its pull request (AL5): bound, follow-up started, ended. Payloads are plain JSON; the
/** A ticket follows its pull request (AL5): bound, follow-up started, ended. Payloads are plain JSON; the
* service manifest's eventsProduced stays the codes.task.* five (contracts.test.js), so these are the
* service manifest's eventsProduced stays the codes.task.* five (contracts.test.js), so these are the
* portal's view, not a new contract. */
* portal's view, not a new contract. */
@@ -123,21 +129,40 @@ function agentStepOf(row, status) {
@@ -123,21 +129,40 @@ function agentStepOf(row, status) {
};
};
}
}
-function createOutbox({ store, log = console }) {
+function createOutbox({ store, log = console, baseUrl = null } = {}) {
const db = store.db;
const db = store.db;
+ const urlOf = (row) => {
+ const base = String(baseUrl || 'https://openvibe.codes').replace(/\/+$/, '');
+ return `${base}/tasks/${encodeURIComponent(row.id)}`;
+ };
- /** Write the state change's event in the caller's (ambient) transaction. → the evt_ id, or null. */
+ /**
+ * Write the state change's events in the caller's (ambient) transaction. → the first evt_ id, or null.
+ * A per-state codes.task.* row goes out when the announced state changed (placed → running is the same
+ * announced state, so only the updated row below is written); the codes.task.updated row goes out on every
+ * raw state change, so the Dock's Code tab follows one event instead of polling.
+ */
async function emitTask(row, previousState) {
async function emitTask(row, previousState) {
if (!row || !row.id || !row.subject) return null;
if (!row || !row.id || !row.subject) return null;
+ const changed = row.state !== previousState;
const topic = topicFor(row.state, previousState);
const topic = topicFor(row.state, previousState);
- if (!topic || !TOPIC_SET.has(topic)) return null;
+ if (!changed && (!topic || !TOPIC_SET.has(topic))) return null;
const eventId = ids.newId('event', store.now());
const eventId = ids.newId('event', store.now());
const at = store.iso();
const at = store.iso();
- await db.exec(
- `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)
- VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,
- [eventId, topic, row.subject, JSON.stringify(payloadOf(row)), at],
- );
+ if (topic && TOPIC_SET.has(topic)) {
+ await db.exec(
+ `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,
+ [eventId, topic, row.subject, JSON.stringify(payloadOf(row)), at],
+ );
+ }
+ if (changed) {
+ await db.exec(
+ `INSERT INTO codes_outbox (event_id, topic, subject, payload, created_at, next_attempt_at)
+ VALUES ($1, $2, $3, $4::jsonb, $5::timestamptz, $5::timestamptz)`,
+ [ids.newId('event', store.now()), UPDATED_TOPIC, row.subject, JSON.stringify({ ...payloadOf(row), url: urlOf(row) }), at],
+ );
+ }
await emitAgentStep(row);
await emitAgentStep(row);
return eventId;
return eventId;
}
}
@@ -354,4 +379,4 @@ function createPublisher({ config, store, fetchImpl = globalThis.fetch, log = co
@@ -354,4 +379,4 @@ function createPublisher({ config, store, fetchImpl = globalThis.fetch, log = co
};
};
}
}
-module.exports = { createOutbox, createPublisher, TOPICS, TOPIC_SET, PR_TOPICS, AGENT_TOPICS, agentStepOf, topicFor, groupOf, payloadOf, repoSlug, MAX_BACKOFF_MS, DEAD_AFTER_REFUSALS, DEAD_AFTER_ATTEMPTS };
+module.exports = { createOutbox, createPublisher, TOPICS, TOPIC_SET, UPDATED_TOPIC, PR_TOPICS, AGENT_TOPICS, agentStepOf, topicFor, groupOf, payloadOf, repoSlug, MAX_BACKOFF_MS, DEAD_AFTER_REFUSALS, DEAD_AFTER_ATTEMPTS };
@@ -16,6 +16,7 @@
@@ -16,6 +16,7 @@
const { http } = require('openvibe-contracts');
const { http } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { readMultipart, readBody, isMultipart } = require('./upload');
const { readMultipart, readBody, isMultipart } = require('./upload');
+const { corsOpenVibe } = require('./openvibe-origins');
const FILE = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
const FILE = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
const INLINE = /^(image\/(png|jpeg|gif|webp|avif|heic)|application\/pdf)$/;
const INLINE = /^(image\/(png|jpeg|gif|webp|avif|heic)|application\/pdf)$/;
@@ -24,9 +25,8 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
@@ -24,9 +25,8 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
const r = asyncRouter();
const r = asyncRouter();
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
- /** The person: the session cookie, or their own Network access token as a Bearer (what a job on their machine has). */
- async function subjectOf(req) {
- if (signedIn(req)) return req.viewer.subject;
+ /** A write from another OpenVibe site is the person's own bearer token — never the session cookie. */
+ const bearerSubject = async (req) => {
const header = String(req.headers.authorization || '');
const header = String(req.headers.authorization || '');
if (!header.startsWith('Bearer ') || !keys) return null;
if (!header.startsWith('Bearer ') || !keys) return null;
try {
try {
@@ -36,18 +36,28 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
@@ -36,18 +36,28 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;
const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '') ? subject : null;
return /^usr_[0-9A-HJKMNP-TV-Z]{26}$/.test(subject || '') ? subject : null;
} catch { return null; }
} catch { return null; }
- }
+ };
const sameOrigin = (req) => {
const sameOrigin = (req) => {
const origin = req.get('origin');
const origin = req.get('origin');
if (!origin) return true;
if (!origin) return true;
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
try { return new URL(origin).origin === new URL(config.baseUrl).origin; } catch { return false; }
};
};
+ /** The person: the session cookie, or their own Network access token as a Bearer (what a job on their machine has).
+ * Across sites the session cookie stays out of it: only the person's own bearer token names them. */
+ async function subjectOf(req) {
+ if (sameOrigin(req) && signedIn(req)) return req.viewer.subject;
+ return bearerSubject(req);
+ };
+ const writeAllowed = async (req) => sameOrigin(req) || (await bearerSubject(req)) != null;
+ // The Dock uploads from another OpenVibe site with the person's bearer token: an allowed origin is reflected
+ // so the page may read the answer (server/http/openvibe-origins.js). Only this router's own routes.
+ r.use(corsOpenVibe({ methods: 'GET, POST, DELETE, OPTIONS', paths: ['/attachments'] }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.post('/attachments', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
r.post('/attachments', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
const subject = await subjectOf(req);
const subject = await subjectOf(req);
if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to attach a file');
if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to attach a file');
- if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site uploads are refused');
+ if (!(await writeAllowed(req))) return problem(req, res, 403, 'cross_site', 'cross-site uploads are refused');
try {
try {
let file;
let file;
if (isMultipart(req)) {
if (isMultipart(req)) {
@@ -85,7 +95,7 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
@@ -85,7 +95,7 @@ function createAttachmentsApi({ attachments, actorLimits, config, keys = null, l
r.delete('/attachments/:id', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
r.delete('/attachments/:id', actorLimits.budget('codes.attachment.upload'), async (req, res) => {
const subject = await subjectOf(req);
const subject = await subjectOf(req);
if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to remove an attachment');
if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to remove an attachment');
- if (!sameOrigin(req)) return problem(req, res, 403, 'cross_site', 'cross-site changes are refused');
+ if (!(await writeAllowed(req))) return problem(req, res, 403, 'cross_site', 'cross-site changes are refused');
if (!FILE.test(String(req.params.id))) return problem(req, res, 404, 'attachment.not_found', 'no such attachment');
if (!FILE.test(String(req.params.id))) return problem(req, res, 404, 'attachment.not_found', 'no such attachment');
try {
try {
return res.json(await attachments.remove(subject, req.params.id));
return res.json(await attachments.remove(subject, req.params.id));
@@ -430,16 +430,20 @@ function createChatRoutes(ctx) {
@@ -430,16 +430,20 @@ function createChatRoutes(ctx) {
function effortPicker(selected = '', cls = '') {
function effortPicker(selected = '', cls = '') {
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort" data-ovt-default="${esc(selected)}">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
return `<label class="ch-pick${cls ? ` ${cls}` : ''}" title="How hard it thinks"><span class="ch-sr">Effort</span>${ico('sub')}<select name="effort" data-ch-effort data-ov-select="bare" data-ov-title="Effort" data-ovt-default="${esc(selected)}">${EFFORT.map(([v, l]) => `<option value="${v}"${selected === v ? ' selected' : ''}>${l}</option>`).join('')}</select>${ico('chevron', 'ch-caret')}</label>`;
}
}
- /** Any file (not a program) up to 10 at once: the script adds chips and progress, the plain form posts multipart. */
- function filePicker() {
- return `<label class="ch-pick ch-pick-file ch-for-agents" title="Attach files">${ico('attach')}<span class="ch-sr">Attach files</span><input type="file" name="files" multiple data-ch-files></label>`;
- }
+ // The composer's files (the kit's own attach menu, thread.composer): images, text and code, PDF and zip —
+ // the owner's words for the limits the server holds (domain/attachments.js). The person's OpenVibe files live
+ // on OpenVibe.Media, which names the library row until the shared files picker lands (composer-plumbing).
+ const ATTACH_ACCEPT = 'image/*,text/*,application/pdf,application/zip,.pdf,.zip,.md,.markdown,.json,.js,.ts,.jsx,.tsx,.py,.css,.html,.xml,.yml,.yaml,.toml,.sql,.csv,.log,.txt';
+ const MEDIA_LIBRARY = (() => { try { const m = require('openvibe-contracts').services.get('media'); return (m && m.publicOrigin) || null; } catch { return null; } })();
+ const ATTACH_NOTE = 'Files: up to 10, 20 MB each, 50 MB a message. Programs can\u2019t be attached.';
/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
/** The kit's composer with Codes' pickers in its tools, where the work runs under the bar, and its JSON route. */
- function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false }) {
+ function composer({ action, api = '', placeholder, pickers = '', options = '', examples = [], live = false, stopAction = '', note = '', id = null, where = '', files = false, value = '' }) {
return thread.composer({
return thread.composer({
- action, id, placeholder, label: placeholder, tools: (files ? filePicker() : '') + pickers,
- options: options ? { html: options, label: 'Options' } : null, examples,
- foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`, note,
+ action, id, placeholder, label: placeholder, tools: pickers,
+ attach: files ? { menu: true, accept: ATTACH_ACCEPT, library: MEDIA_LIBRARY, name: 'files' } : null,
+ options: options ? { html: options, label: 'Options' } : null, examples, value,
+ foot: `${files ? '<div class="ch-files" data-ch-files-list role="list" aria-live="polite"></div>' : ''}${where}`,
+ note: files ? (note ? `${note} ${ATTACH_NOTE}` : ATTACH_NOTE) : note,
attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },
attrs: { ...(api ? { 'data-ch-api': api } : {}), ...(files ? { enctype: 'multipart/form-data' } : {}) },
busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
busy: live, stopAction: live && stopAction ? stopAction : null, maxlength: 16000,
});
});
@@ -548,7 +552,11 @@ function createChatRoutes(ctx) {
@@ -548,7 +552,11 @@ function createChatRoutes(ctx) {
const asked = String(req.query.repo || '');
const asked = String(req.query.repo || '');
// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.
// A repository shows by its name (owner/name), not a cut-off URL; the server reads either.
const repo = asked || (repos[0] && (repos[0].full_name || repos[0].url)) || '';
const repo = asked || (repos[0] && (repos[0].full_name || repos[0].url)) || '';
- const mode = start.MODES.includes(req.query.mode) ? req.query.mode : asked ? 'code' : repos.length ? 'code' : 'new';
+ // The Dock's Code tab names the ways create|openvibe|repo; the site's own are new|openvibe|code.
+ const askedMode = { create: 'new', openvibe: 'openvibe', repo: 'code', new: 'new', code: 'code' }[String(req.query.mode || '')];
+ const mode = askedMode || (asked ? 'code' : repos.length ? 'code' : 'new');
+ // The Dock's fallback when the tasks API is unreachable: its link carries the words here.
+ const prompt = String(req.query.prompt || '').slice(0, 4000);
const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
const kind = start.KINDS[req.query.kind] ? req.query.kind : 'website';
const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
const chosenProject = start.project(req.query.project) ? String(req.query.project) : 'auto';
const error = String(req.query.error || '').slice(0, 200);
const error = String(req.query.error || '').slice(0, 200);
@@ -611,7 +619,7 @@ function createChatRoutes(ctx) {
@@ -611,7 +619,7 @@ function createChatRoutes(ctx) {
const hello = `<div class="ch-hello"><span class="ov-mark" data-size="40" aria-hidden="true" data-ovt-fold></span><h1>${ask}</h1><p data-ovt-fold>Say it the way you would to a friend.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
const hello = `<div class="ch-hello"><span class="ov-mark" data-size="40" aria-hidden="true" data-ovt-fold></span><h1>${ask}</h1><p data-ovt-fold>Say it the way you would to a friend.</p>${error ? `<p class="ch-error" role="alert">${esc(error)}</p>` : ''}${modes}</div>`;
return frame(req, res, {
return frame(req, res, {
title: 'New chat', heading: 'New chat', start: true, empty: hello,
title: 'New chat', heading: 'New chat', start: true, empty: hello,
- composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, options, examples: (sets[mode] || []).slice(0, 12).map((i) => i.text), where, files: true }) + nudge + ideas,
+ composerHtml: composer({ action: '/chat', id: 'ch-start', placeholder: mode === 'new' ? 'Describe your website, game or app' : 'What should change?', pickers, options, examples: (sets[mode] || []).slice(0, 12).map((i) => i.text), where, files: true, value: prompt }) + nudge + ideas,
});
});
});
});
@@ -694,12 +702,12 @@ function createChatRoutes(ctx) {
@@ -694,12 +702,12 @@ function createChatRoutes(ctx) {
for (const [i, x] of targets.entries()) {
for (const [i, x] of targets.entries()) {
const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin });
+ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args: each, attachmentIds: i === 0 ? ids : [], pin, mode: 'openvibe' });
if (!first) first = row;
if (!first) first = row;
}
}
return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
return res.redirect(303, `/tasks/${encodeURIComponent(first.id)}`);
}
}
- const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin });
+ const row = await tasks.start({ subject: req.viewer.subject, project: project.id || project, args, attachmentIds: ids, pin, mode: mode === 'openvibe' ? 'openvibe' : 'repo' });
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
return res.redirect(303, `/tasks/${encodeURIComponent(row.id)}`);
});
});
@@ -18,12 +18,18 @@ const { http, validate } = require('openvibe-contracts');
@@ -18,12 +18,18 @@ const { http, validate } = require('openvibe-contracts');
const { asyncRouter } = require('./router');
const { asyncRouter } = require('./router');
const { parseArgs } = require('../../harness/job');
const { parseArgs } = require('../../harness/job');
const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
const { createRunTasks, TERMINAL } = require('../domain/run-tasks');
+const { summarize } = require('../domain/task-summary');
+const { corsOpenVibe } = require('./openvibe-origins');
+const start = require('./start');
+
+// The person's latest, running first; the Dock asks with ?limit=8.
+const LIST_LIMIT = 50;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const DELEGABLE = new Set(['owner', 'admin']);
const DELEGABLE = new Set(['owner', 'admin']);
-function createMeApi({ config, store, sso, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, log = console }) {
+function createMeApi({ config, store, sso, keys, run, harnesses, userSettings = null, actorLimits, tasks = null, github = null, approvals = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
@@ -31,6 +37,26 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
@@ -31,6 +37,26 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
const json = express.json({ limit: '32kb' });
const json = express.json({ limit: '32kb' });
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
const signedIn = (req) => req.viewer && req.viewer.kind === 'user' && req.viewer.subject;
+ /**
+ * The person a Network access token belongs to, or null (the token openvibe.codes signs a person in with,
+ * and the one `openvibe-codes task` reads from OPENVIBE_TOKEN). A person token carries no capability claim:
+ * being that person is what authorizes their own tasks.
+ */
+ async function personOf(token) {
+ if (!keys) return null;
+ if (!keys.loaded()) await keys.ensure();
+ const v = await keys.verifyUser(token, { issuer: config.networkIssuer, audience: [config.oauth.sessionAudience, config.serviceAudience] });
+ if (!v.ok) return null;
+ const subject = typeof v.claims.subject_id === 'string' ? v.claims.subject_id : null;
+ return USER.test(subject || '') ? subject : null;
+ }
+ /** The signed-in person, or their own token as a Bearer (what the Dock on another OpenVibe site sends). */
+ async function subjectOf(req) {
+ if (signedIn(req)) return req.viewer.subject;
+ const header = String(req.headers.authorization || '');
+ if (header.startsWith('Bearer ')) return personOf(header.slice(7).trim());
+ return null;
+ }
const sameOrigin = (req) => {
const sameOrigin = (req) => {
const origin = req.get('origin');
const origin = req.get('origin');
if (!origin) return true;
if (!origin) return true;
@@ -69,12 +95,48 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
@@ -69,12 +95,48 @@ function createMeApi({ config, store, sso, run, harnesses, userSettings = null,
return row && row.subject === req.viewer.subject ? row : null;
return row && row.subject === req.viewer.subject ? row : null;
};
};
+ // The Dock's Code tab reads this from another OpenVibe site with the person's bearer token: an allowed origin
+ // is reflected so the page may read the answer (server/http/openvibe-origins.js). Writes below stay same-site.
+ // Only this router's own routes: another router's preflight must fall through to its own answer.
+ r.use(corsOpenVibe({ methods: 'GET, POST, OPTIONS', paths: ['/me/tasks'] }));
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
r.use((req, res, next) => { res.set('Cache-Control', 'no-store'); next(); });
+ /**
+ * The person's latest tasks, running first → { tasks: [codes.task-summary@1] }. ?limit= caps the list
+ * (the Dock asks with 8). A session cookie or the person's own Network bearer token; another person's tasks
+ * are never in the answer.
+ */
r.get('/me/tasks', actorLimits.reads('codes.read'), async (req, res) => {
r.get('/me/tasks', actorLimits.reads('codes.read'), async (req, res) => {
- if (!signedIn(req)) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
- const rows = await db.many('SELECT * FROM run_tasks WHERE subject = $1 ORDER BY updated_at DESC LIMIT 50', [req.viewer.subject]);
- return res.json({ tasks: rows.map((row) => taskStore.present(row)) });
+ const subject = await subjectOf(req);
+ if (!subject) return problem(req, res, 401, 'auth.required', 'sign in to see your tasks');
+ const limit = Math.min(Math.max(Number(req.query.limit) || LIST_LIMIT, 1), LIST_LIMIT);
+ const rows = await db.many(
+ `SELECT * FROM run_tasks WHERE subject = $1
+ ORDER BY (state IN ('queued', 'placed', 'running')) DESC, updated_at DESC LIMIT $2`,
+ [subject, limit],
+ );
+ const ids = rows.map((row) => row.id);
+ const stepsByTask = {};
+ if (ids.length) {
+ const [attempts, messages] = await Promise.all([
+ db.many('SELECT task_id, COUNT(*)::int AS n FROM codes_attempts WHERE task_id = ANY($1) GROUP BY task_id', [ids]),
+ db.many('SELECT task_id, COUNT(*)::int AS n FROM codes_conductor_messages WHERE task_id = ANY($1) GROUP BY task_id', [ids]),
+ ]);
+ for (const a of attempts) stepsByTask[a.task_id] = a.n;
+ for (const m of messages) stepsByTask[m.task_id] = Math.max(stepsByTask[m.task_id] || 0, m.n);
+ }
+ const waiting = new Set();
+ if (approvals && ids.length) {
+ try {
+ for (const a of await approvals.inbox(subject)) if (ids.includes(a.task_id)) waiting.add(a.task_id);
+ } catch { /* approvals down: no task reads as waiting for the person */ }
+ }
+ return res.json({
+ tasks: summarize(rows, {
+ stepsByTask, waitingIds: waiting,
+ openvibeUrls: new Set(start.PROJECTS.map((p) => p.url)), baseUrl: config.baseUrl,
+ }),
+ });
});
});
r.post('/me/tasks', actorLimits.budget('codes.task.start'), json, async (req, res) => {
r.post('/me/tasks', actorLimits.budget('codes.task.start'), json, async (req, res) => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
+'use strict';
+
+/**
+ * Cross-site reads and writes for the person's own token (the Dock's Code tab, on any OpenVibe site): the exact
+ * https origins the service manifests declare (`domains` of every manifest in the pinned openvibe-contracts), the
+ * same list OpenVibe.Network's own CORS allow-list is built from. Only an origin on the list is reflected, never `*`,
+ * and credentials stay out of it: the person's Network bearer token rides the Authorization header, never a cookie.
+ *
+ * openVibeOrigins() → the cached Set of allowed origins
+ * corsOpenVibe({ methods }) → Express middleware: reflect an allowed Origin, answer preflights
+ */
+const contracts = require('openvibe-contracts');
+
+const HOST_RE = /^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+let cached = null;
+
+function openVibeOrigins() {
+ if (cached) return cached;
+ const out = new Set();
+ for (const m of (contracts.services && contracts.services.manifests) || []) {
+ for (const d of m.domains || []) {
+ const host = String(d || '').trim().toLowerCase();
+ if (HOST_RE.test(host)) out.add(`https://${host}`);
+ }
+ }
+ cached = out;
+ return out;
+}
+
+/**
+ * Reflect an allowed cross-site Origin so its page may read the answer; answer preflights outright. `paths`
+ * scopes the middleware to the router's own routes (a string matches exactly or as a path segment prefix,
+ * a RegExp is tested as is): another router's preflight must fall through to its own answer, not end here.
+ */
+function corsOpenVibe({ methods = 'GET, POST, OPTIONS', headers = 'authorization, content-type', paths = null } = {}) {
+ const own = (p) => !paths || paths.some((w) => (w instanceof RegExp ? w.test(p) : p === w || p.startsWith(`${w}/`)));
+ return (req, res, next) => {
+ if (!own(String(req.path || ''))) return next();
+ const origin = String(req.get('origin') || '');
+ if (origin && openVibeOrigins().has(origin)) {
+ res.set('Access-Control-Allow-Origin', origin);
+ res.set('Vary', 'Origin');
+ }
+ if (req.method === 'OPTIONS') {
+ res.set('Access-Control-Allow-Methods', methods);
+ res.set('Access-Control-Allow-Headers', headers);
+ res.set('Access-Control-Max-Age', '600');
+ return res.status(204).end();
+ }
+ return next();
+ };
+}
+
+module.exports = { openVibeOrigins, corsOpenVibe };
@@ -36,17 +36,27 @@ const { createTaskEvents } = require('./task-events');
@@ -36,17 +36,27 @@ const { createTaskEvents } = require('./task-events');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { createServiceTokenClient } = require('openvibe-sdk/auth');
const { expandMatrix } = require('../domain/bench');
const { expandMatrix } = require('../domain/bench');
const childReport = require('../domain/child-report');
const childReport = require('../domain/child-report');
+const { readMultipart, isMultipart } = require('./upload');
+const { corsOpenVibe } = require('./openvibe-origins');
+const start = require('./start');
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const USER = /^usr_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const FIL = /^fil_[0-9A-HJKMNP-TV-Z]{26}$/;
+// The Dock's Code tab shape (the home composer's three ways, as JSON): POST /tasks dispatches on it.
+const DOCK_MODES = ['create', 'openvibe', 'repo'];
-function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, log = console }) {
+function createTasksApi({ config, store, keys, run, harnesses, actorLimits, tasks = null, fleet = null, sharing = null, conductor = null, attachments = null, log = console }) {
const r = asyncRouter();
const r = asyncRouter();
const db = store.db;
const db = store.db;
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskStore = tasks || createRunTasks({ store, run, config, log });
const taskEvents = createTaskEvents({ store, config, log, attempts: taskStore.attempts });
const taskEvents = createTaskEvents({ store, config, log, attempts: taskStore.attempts });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const problem = (req, res, status, code, detail) => http.sendProblem(res, status, code, { detail, ctx: req.ov });
const json = express.json({ limit: '32kb' });
const json = express.json({ limit: '32kb' });
+ // The Dock's Code tab calls these from another OpenVibe site with the person's bearer token: an allowed origin
+ // is reflected so the page may read the answer (server/http/openvibe-origins.js). Only the Dock's own two
+ // routes: another router's preflight must fall through to its own answer.
+ r.use(corsOpenVibe({ methods: 'GET, POST, OPTIONS', paths: ['/tasks', /^\/tasks\/[^/]+\/stop$/] }));
const benchTokens = config.oauth.clientSecret ? createServiceTokenClient({
const benchTokens = config.oauth.clientSecret ? createServiceTokenClient({
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
tokenUrl: `${config.networkInternalUrl}/oauth/token`, clientId: config.oauth.clientId,
clientSecret: config.oauth.clientSecret, audience: 'openvibe.bench', scope: 'bench.run.publish',
clientSecret: config.oauth.clientSecret, audience: 'openvibe.bench', scope: 'bench.run.publish',
@@ -182,8 +192,121 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -182,8 +192,121 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return res.json({ ok: out.ok !== false, machines: latest.machines, agents, at: latest.at, ...(out.problem ? { problem: out.problem } : {}) });
return res.json({ ok: out.ok !== false, machines: latest.machines, agents, at: latest.at, ...(out.problem ? { problem: out.problem } : {}) });
});
});
+ /**
+ * The Dock's Code tab start (the home composer's three ways, as JSON): { text, mode, repo?, attachments? }.
+ * Only the owner starts one this way (a session, or their own Network token); a service keeps the
+ * codes.task-start-request@1 shape below. The checks, limits and quota are the site's own (POST /chat): the
+ * words, the repository, the project, and the files — all refused before any task is started.
+ */
+ async function dockCreate(req, res) {
+ const p = req.principal;
+ if (!p.owner) return problem(req, res, 422, 'codes.task_invalid', 'services start tasks with codes.task-start-request@1 (a prompt and a repo)');
+ let b = req.body || {};
+ let uploaded = [];
+ if (isMultipart(req)) {
+ if (!attachments) return problem(req, res, 503, 'codes.attachments_unavailable', 'file uploads are not configured here');
+ try {
+ const parsed = await readMultipart(req, { maxBytes: attachments.MAX_MESSAGE_BYTES });
+ b = parsed.fields || {};
+ uploaded = parsed.files || [];
+ } catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.task_invalid', String((e && e.message) || 'the form could not be read').slice(0, 300)); }
+ }
+ const first = (v) => (Array.isArray(v) ? v[0] : v);
+ const text = String(first(b.text) || '').trim().slice(0, 16000);
+ const mode = String(first(b.mode) || '').trim();
+ if (!DOCK_MODES.includes(mode)) return problem(req, res, 422, 'codes.task_invalid', 'mode is create, openvibe or repo');
+ if (!text) return problem(req, res, 422, 'codes.task_invalid', 'write what you want made or changed first');
+ const repoIn = String(first(b.repo) || '').trim().slice(0, 500);
+ const named = [
+ ...(Array.isArray(b.attachments) ? b.attachments : b.attachments ? [b.attachments] : []),
+ ...(Array.isArray(b.attachment_ids) ? b.attachment_ids : b.attachment_ids ? [b.attachment_ids] : []),
+ ].map(String).filter(Boolean);
+ // Something new: OpenVibe AI builds it on openvibe.space, like the site's own handover. No Codes task is
+ // started: the answer is where to press Build. Files ride with a task, never with a handover.
+ if (mode === 'create') {
+ if (repoIn || named.length || uploaded.length) {
+ return problem(req, res, 422, 'codes.create_invalid', 'Something new starts in OpenVibe AI, where you can add the repository and the files there.');
+ }
+ return res.json({ id: null, url: start.spaceHref({ text }) });
+ }
+ // The files this task carries: uploaded before send by their ids, or posted with the form now (no
+ // JavaScript). A limit or a program is refused before any task is started; an id from outside Codes names
+ // where it resolves (composer.attachment@1, once OpenVibe.Media serves it).
+ let ids = [];
+ if (named.length || uploaded.length) {
+ if (!attachments) return problem(req, res, 503, 'codes.attachments_unavailable', 'file uploads are not configured here');
+ const foreign = named.find((id) => !FIL.test(id));
+ if (foreign) return problem(req, res, 422, 'codes.attachment_unresolved', `attach ${foreign.slice(0, 40)} on openvibe.codes first: shared library files arrive with composer.attachment@1`);
+ try {
+ const saved = uploaded.length ? await attachments.saveMany(p.subject, uploaded) : [];
+ const pending = await attachments.pending(p.subject, [...saved.map((f) => f.id), ...named]);
+ ids = pending.map((f) => f.id);
+ } catch (e) { return problem(req, res, e.status || 500, e.code || 'codes.task_invalid', String((e && e.message) || 'the files could not be attached').slice(0, 300)); }
+ }
+ // Which repository the task works on: one of OpenVibe's own projects, or the person's own address.
+ let targets = null;
+ let repoUrl = '';
+ if (mode === 'openvibe') {
+ if (repoIn) {
+ const hit = start.PROJECTS.find((x) => x.repository === repoIn || x.url === start.normalizeRepo(repoIn));
+ if (!hit) return problem(req, res, 422, 'codes.repo_unknown', 'pick one of OpenVibe\u2019s own projects, or name yours with mode repo');
+ repoUrl = hit.url;
+ } else {
+ const found = start.matchProjects(text);
+ if (!found.length) {
+ return problem(req, res, 422, 'codes.project_unknown', 'say which OpenVibe site this is for, in your own words (like Live, Chat or Games, or \u201cthe navbar\u201d), or pick it from the list');
+ }
+ targets = found;
+ repoUrl = found[0].url;
+ }
+ } else {
+ repoUrl = start.normalizeRepo(repoIn);
+ if (!repoUrl) {
+ return problem(req, res, 422, 'codes.task_invalid', repoIn
+ ? 'That is not a Git address Codes can clone: use https://\u2026, git@host:owner/name or owner/name, with no password in it.'
+ : 'Pick a repository (owner/name or any Git URL), choose an OpenVibe site, or start something new.');
+ }
+ }
+ let args;
+ try {
+ args = parseArgs(JSON.stringify({ repo: repoUrl, prompt: text, permission: 'edit' }));
+ } catch (e) {
+ return problem(req, res, 422, 'codes.task_invalid', e.message);
+ }
+ const header = String(req.headers.authorization || '');
+ const token = req.token || (req.viewer && req.viewer.token) || (header.startsWith('Bearer ') ? header.slice(7).trim() : null);
+ let project = null;
+ try {
+ project = await taskStore.latestProject(p.subject)
+ || (conductor ? await conductor.projectFor(token, p.subject) : null);
+ } catch { project = null; }
+ if (!project) return problem(req, res, 409, 'codes.no_project', 'Codes could not find or make a project for you on OpenVibe.Services: sign in again and retry.');
+ const jobInput = (repo, prompt) => JSON.stringify({ repo, prompt, permission: 'edit' });
+ let firstRow = null;
+ try {
+ if (targets && targets.length > 1) {
+ // One request across several projects: a task for each, each told what the others are, like the site.
+ const names = targets.map((x) => x.repository.replace(/^OpenVibers\//, ''));
+ for (const [i, x] of targets.entries()) {
+ const note = `\n\n(This request spans ${names.join(', ')}. This task is the ${names[i]} part; the others are separate tasks. Change only what this repository needs, and keep the names and behaviour consistent with the other parts.)`;
+ const each = parseArgs(jobInput(x.url, `${text}${note}`.slice(0, 16000)));
+ const row = await taskStore.start({ subject: p.subject, project, args: each, attachmentIds: i === 0 ? ids : [], mode: 'openvibe' });
+ if (!firstRow) firstRow = row;
+ }
+ } else {
+ firstRow = await taskStore.start({ subject: p.subject, project, args, attachmentIds: ids, mode: mode === 'openvibe' ? 'openvibe' : 'repo' });
+ }
+ } catch (e) {
+ return problem(req, res, e.status || 502, e.code || 'codes.task_failed', String((e && e.message) || 'the task could not start').slice(0, 300));
+ }
+ log.info(`[Codes] task ${firstRow.id} started from the Dock for ${p.subject} (${mode})`);
+ return res.status(201).json({ id: firstRow.id, url: `${config.baseUrl}/tasks/${firstRow.id}` });
+ }
+
r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
r.post('/tasks', guardAny('codes.task.start'), actorLimits.budget('codes.task.start'), json, async (req, res) => {
const b = req.body;
const b = req.body;
+ // The Dock's shape takes its own path; services and the site shape keep the contract below.
+ if ((b && b.mode != null) || isMultipart(req)) return dockCreate(req, res);
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
// Actor back-reference: the contract does not carry these yet, so they are stripped before validation and stored
// separately. When the contract is amended, pass them through instead.
// separately. When the contract is amended, pass them through instead.
const actorRef = {};
const actorRef = {};
@@ -371,13 +494,18 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
@@ -371,13 +494,18 @@ function createTasksApi({ config, store, keys, run, harnesses, actorLimits, task
return res.json(await timeline(out.row));
return res.json(await timeline(out.row));
});
});
- /** The running attempt stops: the outcome is "stopped" and nothing carries it on. */
+ /**
+ * The running attempt stops: the outcome is "stopped" and nothing carries it on. The owner (the Dock's shape)
+ * gets { ok }; a service keeps the timeline its capability was built on. Another person's task is 404, never
+ * a refusal that says it exists.
+ */
r.post('/tasks/:id/stop', guardAny('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
r.post('/tasks/:id/stop', guardAny('codes.task.cancel'), actorLimits.budget('codes.task.start'), async (req, res) => {
const row = await mine(req);
const row = await mine(req);
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
if (!row) return problem(req, res, 404, 'codes.task_not_found', 'no such task of this service for this person');
const out = await taskStore.stopTask(row);
const out = await taskStore.stopTask(row);
if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
if (out.error) return problem(req, res, 409, 'codes.task_conflict', out.error);
log.info(`[Codes] task ${row.id} stopped by ${req.principal.via || req.principal.subject}`);
log.info(`[Codes] task ${row.id} stopped by ${req.principal.via || req.principal.subject}`);
+ if (req.principal.owner) return res.json({ ok: true });
return res.json(await timeline(out.row));
return res.json(await timeline(out.row));
});
});
@@ -164,15 +164,18 @@ function multipart(fields, files) {
@@ -164,15 +164,18 @@ function multipart(fields, files) {
assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);
assert.strictEqual((await t.get(`/api/v1/attachments/${rows[0].id}`, { as: ada, method: 'DELETE' })).status, 409);
});
});
- await check('the composers offer files without JavaScript too: a multipart form and a file input', async () => {
+ await check('the composers offer files without JavaScript too: a multipart form and the kit\u2019s attach menu', async () => {
const home = await t.get('/', { as: ada });
const home = await t.get('/', { as: ada });
assert.strictEqual(home.status, 200);
assert.strictEqual(home.status, 200);
assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');
assert.match(home.text, /<form class="ovt-composer" id="ch-start"[^>]*enctype="multipart\/form-data"/, 'the start form posts multipart');
- assert.match(home.text, /<input type="file" name="files" multiple data-ch-files>/, 'and has the file input');
+ assert.match(home.text, /data-ovt-attach/, 'the kit\u2019s attach menu');
+ assert.match(home.text, /<input type="file" name="files"[^>]*multiple data-ovt-files/, 'the Files row takes files');
+ assert.match(home.text, /accept="[^"]*image\/\*/, 'images, text, code, PDF and zip');
+ assert.match(home.text, /From your OpenVibe files/, 'the library row');
assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');
assert.match(home.text, /data-ch-files-list/, 'with the chips list the script fills');
const task = await t.get(`/tasks/${taskId}`, { as: ada });
const task = await t.get(`/tasks/${taskId}`, { as: ada });
assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');
assert.match(task.text, /enctype="multipart\/form-data"/, 'the reply composer too');
- assert.match(task.text, /data-ch-files/);
+ assert.match(task.text, /data-ovt-files/);
});
});
await check('a multipart reply carries its own files on the next turn', async () => {
await check('a multipart reply carries its own files on the next turn', async () => {
new file mode 100644
new file mode 100644
@@ -0,0 +1,268 @@
@@ -0,0 +1,268 @@
+'use strict';
+/**
+ * The Dock's Code tab API (the home composer's three ways, cross-site): the person's tasks as
+ * codes.task-summary@1 (GET /api/v1/me/tasks, running first), starting one (POST /api/v1/tasks
+ * { text, mode, repo?, attachments? }) and stopping one (POST /api/v1/tasks/:id/stop → { ok }).
+ *
+ * A session cookie or the person's own Network bearer token; another person's task is 404, never shown.
+ * Cross-site reads and uploads from the OpenVibe origins are reflected; anything else is not.
+ */
+const assert = require('assert');
+const { boot, check, done } = require('./helpers/boot');
+const { summarize } = require('../server/domain/task-summary');
+
+const SUMMARY_KEYS = ['id', 'title', 'status', 'mode', 'repo', 'started_at', 'updated_at', 'steps', 'url', 'preview_url', 'pr_url'];
+// TODO (contracts 0.401.0): validate the answers with contracts.validate('codes.task-summary@1', …)
+// once the pin carries it, instead of this structural mirror of its required keys and enums.
+function assertSummary(s) {
+ assert.deepStrictEqual(Object.keys(s).sort(), SUMMARY_KEYS.slice().sort());
+ assert.match(s.id, /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/);
+ assert.ok(typeof s.title === 'string' && s.title.length >= 1 && s.title.length <= 200, 'a title');
+ assert.ok(['queued', 'running', 'needs-you', 'done', 'failed', 'stopped'].includes(s.status), `status ${s.status}`);
+ assert.ok(['create', 'openvibe', 'repo'].includes(s.mode), `mode ${s.mode}`);
+ assert.ok(!Number.isNaN(Date.parse(s.started_at)) && !Number.isNaN(Date.parse(s.updated_at)), 'timestamps');
+ assert.ok(Number.isInteger(s.steps) && s.steps >= 0, 'steps');
+ assert.ok(s.url.startsWith('https://openvibe.codes/tasks/'), 'the task page');
+ assert.ok(s.preview_url === null, 'no preview yet');
+ assert.ok(s.pr_url === null || typeof s.pr_url === 'string', 'the pull request or null');
+}
+
+(async () => {
+ let readState = 'queued';
+ let jobs = 0;
+ const calls = [];
+ const run = {
+ enabled: true,
+ async submit(o) { calls.push(['submit', o]); jobs += 1; return { id: `job_01J8Z3Q4R5S6T7V8W9X0Y1Z2J${jobs}`, state: 'queued' }; },
+ async read(jobId) { return { id: jobId, state: readState, result: null, error: null }; },
+ async cancel(jobId, who) { calls.push(['cancel', jobId, who]); return { id: jobId, state: 'cancelled' }; },
+ async streamUrl() { throw new Error('not used'); },
+ };
+ const t = await boot({ run });
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const adaToken = () => t.network.userToken(ada);
+
+ const api = (method, path, { as = null, bearer = null, body = undefined, origin = undefined } = {}) => {
+ const headers = {};
+ if (origin) headers.origin = origin;
+ if (bearer) headers.authorization = `Bearer ${bearer}`;
+ return t.get(`/api/v1${path}`, { as, method, ...(body !== undefined ? { json: body } : {}), headers });
+ };
+ const startRepo = (over = {}, opts = {}) => api('POST', '/tasks', {
+ body: { text: 'Add a test for the date parser.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World', ...over },
+ ...opts,
+ });
+
+ try {
+ await check('a guest is refused everywhere', async () => {
+ assert.strictEqual((await api('GET', '/me/tasks')).status, 401);
+ assert.strictEqual((await startRepo()).status, 401);
+ assert.strictEqual((await api('POST', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3/stop')).status, 401);
+ });
+
+ await check('CORS: an OpenVibe origin is reflected, anything else is not; preflights end here', async () => {
+ const yes = await api('GET', '/me/tasks?limit=1', { as: ada, origin: 'https://openvibe.chat' });
+ assert.strictEqual(yes.status, 200);
+ assert.strictEqual(yes.headers.get('access-control-allow-origin'), 'https://openvibe.chat');
+ const no = await api('GET', '/me/tasks?limit=1', { as: ada, origin: 'https://evil.example' });
+ assert.strictEqual(no.status, 200);
+ assert.strictEqual(no.headers.get('access-control-allow-origin'), null);
+ const pre = await t.get('/api/v1/me/tasks', { method: 'OPTIONS', headers: { origin: 'https://openvibe.chat' } });
+ assert.strictEqual(pre.status, 204);
+ assert.ok((pre.headers.get('access-control-allow-methods') || '').includes('GET'));
+ });
+
+ let firstId;
+ await check('a repo task starts from the Dock shape, by session and by bearer', async () => {
+ const r = await startRepo({}, { as: ada });
+ assert.strictEqual(r.status, 201, r.text);
+ assert.match(r.json().id, /^tsk_/);
+ assert.strictEqual(r.json().url, `https://openvibe.codes/tasks/${r.json().id}`);
+ firstId = r.json().id;
+ const row = await t.ctx.store.db.maybe('SELECT mode, subject FROM run_tasks WHERE id = $1', [firstId]);
+ assert.strictEqual(row.mode, 'repo');
+ assert.strictEqual(row.subject, ada.subject);
+ const byBearer = await startRepo({ text: 'Fix the off-by-one in the pager.' }, { bearer: adaToken() });
+ assert.strictEqual(byBearer.status, 201, byBearer.text);
+ });
+
+ await check('the start is validated like the site: words, mode and repository', async () => {
+ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: '', mode: 'repo', repo: 'https://github.com/o/r' } })).status, 422);
+ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'teleport' } })).status, 422);
+ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'repo' } })).status, 422);
+ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'repo', repo: 'not a repo' } })).status, 422);
+ const before = calls.filter((c) => c[0] === 'submit').length;
+ assert.strictEqual((await api('POST', '/tasks', { as: ada, body: { text: 'Hi', mode: 'create', repo: 'https://github.com/o/r' } })).status, 422);
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before, 'a refused start reaches no machine');
+ });
+
+ await check('create hands over to OpenVibe AI: no task, the Space address to press Build in', async () => {
+ const r = await api('POST', '/tasks', { as: ada, body: { text: 'A bakery site with today\u2019s hours', mode: 'create' } });
+ assert.strictEqual(r.status, 200, r.text);
+ assert.strictEqual(r.json().id, null);
+ assert.ok(String(r.json().url).startsWith('https://openvibe.space'), r.json().url);
+ });
+
+ await check('openvibe mode works out the project from the words, like the site', async () => {
+ const r = await api('POST', '/tasks', { as: ada, body: { text: 'Make Live faster on phones', mode: 'openvibe' } });
+ assert.strictEqual(r.status, 201, r.text);
+ const row = await t.ctx.store.db.maybe('SELECT repo, mode FROM run_tasks WHERE id = $1', [r.json().id]);
+ assert.strictEqual(row.repo, 'https://github.com/OpenVibers/OpenVibe.Live');
+ assert.strictEqual(row.mode, 'openvibe');
+ const vague = await api('POST', '/tasks', { as: ada, body: { text: 'Make it faster on phones', mode: 'openvibe' } });
+ assert.strictEqual(vague.status, 422, 'no project named, no task');
+ });
+
+ await check('a service keeps the contract shape: the Dock shape is the owner\u2019s', async () => {
+ const svc = t.network.mintService('svc:actor', 'openvibe.codes', ['codes.task.start']);
+ const r = await api('POST', '/tasks', { body: { text: 'Hi', mode: 'repo', repo: 'https://github.com/o/r' }, bearer: svc });
+ assert.strictEqual(r.status, 422);
+ });
+
+ let attachedId;
+ await check('composer attachment ids ride the task: named in the first message, on disk for the runner', async () => {
+ const data = new FormData();
+ data.append('file', new Blob(['hello'], { type: 'text/plain' }), 'notes.txt');
+ const up = await fetch(`${t.base}/api/v1/attachments`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: data });
+ const upText = await up.text();
+ assert.strictEqual(up.status, 201, upText);
+ const saved = JSON.parse(upText).attachment;
+ const r = await api('POST', '/tasks', { as: bo, body: { text: 'Read the notes.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World', attachments: [saved.id] } });
+ assert.strictEqual(r.status, 201, r.text);
+ attachedId = r.json().id;
+ const linked = await t.ctx.store.db.many('SELECT id, task_id FROM codes_attachments WHERE task_id = $1', [attachedId]);
+ assert.strictEqual(linked.length, 1);
+ // The task's first turn carries the workspace path (the row keeps the person's own words).
+ const submitted = calls.filter((c) => c[0] === 'submit').pop()[1];
+ assert.ok(submitted.args.prompt.includes('.openvibe/attachments/') && submitted.args.prompt.includes('notes.txt'), 'the first message names the workspace path');
+ assert.ok(Array.isArray(submitted.args.attachments) && submitted.args.attachments.length === 1, 'the file rides the job');
+ const foreign = await api('POST', '/tasks', { as: bo, body: { text: 'Read it.', mode: 'repo', repo: 'https://github.com/o/r', attachments: ['att_123'] } });
+ assert.strictEqual(foreign.status, 422, 'a foreign id names composer.attachment@1, not this task');
+ });
+
+ await check('multipart posts work too, and a program is refused before any task starts', async () => {
+ const data = new FormData();
+ data.append('text', 'Read the notes.');
+ data.append('mode', 'repo');
+ data.append('repo', 'https://github.com/octocat/Hello-World');
+ data.append('files', new Blob(['hello'], { type: 'text/plain' }), 'notes.txt');
+ const before = calls.filter((c) => c[0] === 'submit').length;
+ const r = await fetch(`${t.base}/api/v1/tasks`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: data });
+ assert.strictEqual(r.status, 201, await r.text());
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before + 1);
+ const bad = new FormData();
+ bad.append('text', 'Run it.');
+ bad.append('mode', 'repo');
+ bad.append('repo', 'https://github.com/octocat/Hello-World');
+ bad.append('files', new Blob(['MZ' + 'x'.repeat(10)], { type: 'application/x-msdownload' }), 'run.exe');
+ const refused = await fetch(`${t.base}/api/v1/tasks`, { method: 'POST', headers: { cookie: t.signIn(bo) }, body: bad });
+ assert.strictEqual(refused.status, 422);
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length, before + 1, 'a refused start reaches no machine');
+ });
+
+ await check('the list is the person\u2019s latest, running first, as codes.task-summary@1', async () => {
+ readState = 'running';
+ const row = await t.ctx.store.db.maybe('SELECT * FROM run_tasks WHERE id = $1', [firstId]);
+ await t.ctx.tasks.refresh(row, { force: true });
+ readState = 'queued';
+ const list = await api('GET', '/me/tasks?limit=8', { as: ada });
+ assert.strictEqual(list.status, 200);
+ const tasks = list.json().tasks;
+ assert.ok(tasks.length >= 2 && tasks.length <= 8, 'limit respected');
+ assert.strictEqual(tasks[0].id, firstId, 'the running task lists first');
+ assert.strictEqual(tasks[0].status, 'running');
+ assert.ok(tasks.every((x) => x.mode === 'repo' || x.mode === 'openvibe'), 'every task names its mode');
+ for (const s of tasks) assertSummary(s);
+ const one = await api('GET', '/me/tasks?limit=1', { as: ada });
+ assert.strictEqual(one.json().tasks.length, 1, '?limit= caps the list');
+ });
+
+ await check('statuses read in the Dock\u2019s words: done, failed, stopped', async () => {
+ const r = await startRepo({ text: 'Another one.' }, { as: bo });
+ const id = r.json().id;
+ readState = 'succeeded';
+ await t.ctx.tasks.refresh(await t.ctx.store.db.maybe('SELECT * FROM run_tasks WHERE id = $1', [id]), { force: true });
+ readState = 'queued';
+ const doneRow = (await api('GET', '/me/tasks?limit=50', { as: bo })).json().tasks.find((x) => x.id === id);
+ assert.strictEqual(doneRow.status, 'done');
+ const stop = await api('POST', `/tasks/${firstId}/stop`, { as: ada });
+ assert.strictEqual(stop.status, 200, stop.text);
+ assert.deepStrictEqual(stop.json(), { ok: true });
+ const stopped = (await api('GET', '/me/tasks?limit=50', { as: ada })).json().tasks.find((x) => x.id === firstId);
+ assert.strictEqual(stopped.status, 'stopped');
+ });
+
+ await check('a person cannot see or stop another\u2019s task', async () => {
+ const list = await api('GET', '/me/tasks?limit=50', { as: bo });
+ assert.ok(!list.json().tasks.some((x) => x.id === firstId), 'no foreign task in the list');
+ assert.strictEqual((await api('GET', `/me/tasks/${firstId}`, { as: bo })).status, 404);
+ assert.strictEqual((await api('POST', `/tasks/${firstId}/stop`, { as: bo })).status, 404);
+ assert.strictEqual((await api('POST', '/tasks/tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2T9/stop', { as: ada })).status, 404);
+ });
+
+ await check('the home reads ?prompt=, ?mode= and ?repo= and prefills its composer', async () => {
+ const plain = await t.get('/', { as: ada });
+ assert.strictEqual(plain.status, 200);
+ assert.ok(plain.text.includes('data-ovt-attach'), 'the kit\u2019s attach menu');
+ assert.ok(plain.text.includes('image/*'), 'images, text, code, PDF and zip');
+ assert.ok(plain.text.includes('https://openvibe.media'), 'the person\u2019s OpenVibe files');
+ assert.ok(plain.text.includes('Programs can\u2019t be attached'), 'the limits in the note');
+ const pre = await t.get('/?prompt=' + encodeURIComponent('Make it faster') + '&mode=repo&repo=' + encodeURIComponent('octocat/Hello-World'), { as: ada });
+ assert.ok(pre.text.includes('>Make it faster</textarea>'), 'the words are in the box');
+ assert.ok(pre.text.includes('value="octocat/Hello-World"'), 'the repository is picked');
+ assert.ok(/value="code"[^>]*checked|checked[^>]*value="code"/.test(pre.text), 'repo reads as My code');
+ const create = await t.get('/?mode=create', { as: ada });
+ assert.ok(/value="new"[^>]*checked|checked[^>]*value="new"/.test(create.text), 'create reads as Create');
+ });
+
+ await check('another router\u2019s preflight still ends at its own answer', async () => {
+ const pre = await fetch(`${t.base}/api/v1/me/agent-view`, {
+ method: 'OPTIONS', headers: { origin: 'https://openvibe.live' },
+ });
+ assert.strictEqual(pre.status, 204);
+ assert.strictEqual(pre.headers.get('access-control-allow-origin'), '*', 'agent-view keeps its own wildcard');
+ });
+
+ await check('summarize maps rows to the contract: needs-you, old rows, titles', async () => {
+ const base = { id: 'tsk_01J8Z3Q4R5S6T7V8W9X0Y1Z2A3', prompt: 'Fix it\n\nMore words.', repo: 'https://github.com/octocat/Hello-World', created_at: 1, updated_at: 2 };
+ const [waiting] = summarize([{ ...base, state: 'running' }], { waitingIds: new Set([base.id]) });
+ assert.strictEqual(waiting.status, 'needs-you');
+ assert.strictEqual(waiting.steps, 0);
+ const [live] = summarize([{ ...base, state: 'running' }], {});
+ assert.strictEqual(live.status, 'running');
+ const [old] = summarize([{ ...base, state: 'queued', mode: null }], { openvibeUrls: new Set(['https://github.com/OpenVibers/OpenVibe.Live']) });
+ assert.strictEqual(old.mode, 'repo');
+ assert.strictEqual(old.title, 'Fix it');
+ const [ov] = summarize([{ ...base, state: 'queued', mode: null, repo: 'https://github.com/OpenVibers/OpenVibe.Live' }], { openvibeUrls: new Set(['https://github.com/OpenVibers/OpenVibe.Live']) });
+ assert.strictEqual(ov.mode, 'openvibe');
+ const [cond] = summarize([{ ...base, state: 'queued', kind: 'conductor', prompt: '', folder: '/home/you/work' }], {});
+ assert.strictEqual(cond.title, 'Conductor \u00b7 work');
+ const [pr] = summarize([{ ...base, state: 'succeeded', result: JSON.stringify({ ok: true, push: { pushed: true, pull_request: 'https://github.com/o/r/pull/1' } }) }], {});
+ assert.strictEqual(pr.status, 'done');
+ assert.strictEqual(pr.pr_url, 'https://github.com/o/r/pull/1');
+ });
+ } finally {
+ await t.close();
+ }
+
+ // The quota: its own boot with the limiter counting, so the other checks stay uncounted.
+ const q = await boot({ run, actorLimits: true });
+ try {
+ const cara = q.network.addUser('cara');
+ await check('the start quota refuses the seventh task in a minute, before any work', async () => {
+ const before = calls.filter((c) => c[0] === 'submit').length;
+ for (let i = 0; i < 6; i++) {
+ const r = await q.get('/api/v1/tasks', { as: cara, json: { text: `Task ${i}.`, mode: 'repo', repo: 'https://github.com/octocat/Hello-World' } });
+ assert.strictEqual(r.status, 201, r.text);
+ }
+ const refused = await q.get('/api/v1/tasks', { as: cara, json: { text: 'One too many.', mode: 'repo', repo: 'https://github.com/octocat/Hello-World' } });
+ assert.strictEqual(refused.status, 429);
+ assert.strictEqual(refused.json().code, 'rate_limited');
+ assert.strictEqual(calls.filter((c) => c[0] === 'submit').length - before, 6, 'the refused start reaches no machine');
+ });
+ } finally {
+ await q.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });
@@ -86,8 +86,9 @@ function eventOf(id, type, jobId, subject, project, state, timings = {}) {
@@ -86,8 +86,9 @@ function eventOf(id, type, jobId, subject, project, state, timings = {}) {
const line = await db.maybe("SELECT * FROM codes_session_lines WHERE task_id = $1 AND kind = 'done'", [task.id]);
const line = await db.maybe("SELECT * FROM codes_session_lines WHERE task_id = $1 AND kind = 'done'", [task.id]);
assert.ok(line, 'the result log was back-filled');
assert.ok(line, 'the result log was back-filled');
const outbox = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1 ORDER BY created_at', [task.id]);
const outbox = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1 ORDER BY created_at', [task.id]);
- assert.deepStrictEqual(outbox.map((x) => x.topic), ['codes.task.queued', 'codes.task.running', 'codes.task.succeeded']);
- const payload = outbox[2].payload;
+ assert.deepStrictEqual(outbox.map((x) => x.topic).sort(),
+ ['codes.task.queued', 'codes.task.running', 'codes.task.succeeded', 'codes.task.updated', 'codes.task.updated', 'codes.task.updated'].sort());
+ const payload = outbox.find((x) => x.topic === 'codes.task.succeeded').payload;
assert.strictEqual(payload.task_id, task.id);
assert.strictEqual(payload.task_id, task.id);
assert.strictEqual(payload.state, 'succeeded');
assert.strictEqual(payload.state, 'succeeded');
assert.strictEqual(payload.repo, 'Octocat/Hello-World');
assert.strictEqual(payload.repo, 'Octocat/Hello-World');
@@ -56,14 +56,18 @@ const { createPublisher } = require('../server/events/outbox');
@@ -56,14 +56,18 @@ const { createPublisher } = require('../server/events/outbox');
await check('a task start writes its queued row in the change transaction, with the fixed evt_ id', async () => {
await check('a task start writes its queued row in the change transaction, with the fixed evt_ id', async () => {
const rows = await db.many('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [task.id]);
const rows = await db.many('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [task.id]);
- assert.strictEqual(rows.length, 1);
- const row = rows[0];
+ assert.strictEqual(rows.length, 2, 'the per-state row and the Dock\u2019s codes.task.updated row');
+ const row = rows.find((x) => x.topic === 'codes.task.queued');
assert.match(row.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
assert.match(row.event_id, /^evt_[0-9A-HJKMNP-TV-Z]{26}$/);
- assert.strictEqual(row.topic, 'codes.task.queued');
assert.strictEqual(row.subject, ada.subject);
assert.strictEqual(row.subject, ada.subject);
assert.deepStrictEqual(Object.keys(row.payload).sort(), ['agent', 'cost_usd', 'error', 'files_changed', 'finished_at', 'repo', 'started_at', 'state', 'task_id']);
assert.deepStrictEqual(Object.keys(row.payload).sort(), ['agent', 'cost_usd', 'error', 'files_changed', 'finished_at', 'repo', 'started_at', 'state', 'task_id']);
assert.strictEqual(row.payload.repo, 'Octocat/Hello-World');
assert.strictEqual(row.payload.repo, 'Octocat/Hello-World');
assert.strictEqual(row.payload.state, 'queued');
assert.strictEqual(row.payload.state, 'queued');
+ const updated = rows.find((x) => x.topic === 'codes.task.updated');
+ assert.ok(updated, 'codes.task.updated is written on the state change');
+ assert.strictEqual(updated.subject, ada.subject);
+ assert.strictEqual(updated.payload.state, 'queued');
+ assert.strictEqual(updated.payload.url, `https://openvibe.codes/tasks/${task.id}`);
const before = await db.value('SELECT COUNT(*)::int FROM codes_outbox');
const before = await db.value('SELECT COUNT(*)::int FROM codes_outbox');
await assert.rejects(t.ctx.store.tx(async () => {
await assert.rejects(t.ctx.store.tx(async () => {
await t.ctx.outbox.emitTask({ ...task, state: 'succeeded', result: { ok: true } }, 'running');
await t.ctx.outbox.emitTask({ ...task, state: 'succeeded', result: { ok: true } }, 'running');
@@ -72,17 +76,21 @@ const { createPublisher } = require('../server/events/outbox');
@@ -72,17 +76,21 @@ const { createPublisher } = require('../server/events/outbox');
assert.strictEqual(await db.value('SELECT COUNT(*)::int FROM codes_outbox'), before, 'the event is not written without the change');
assert.strictEqual(await db.value('SELECT COUNT(*)::int FROM codes_outbox'), before, 'the event is not written without the change');
});
});
- await check('a Run state change writes one row in the same transaction', async () => {
+ await check('a Run state change writes its rows in the same transaction', async () => {
readState = 'succeeded';
readState = 'succeeded';
readResult = { ok: true, cost_usd: 0.02, files: [{ path: 'README', added: 1, removed: 1 }], log: [{ t: 'done', ok: true }] };
readResult = { ok: true, cost_usd: 0.02, files: [{ path: 'README', added: 1, removed: 1 }], log: [{ t: 'done', ok: true }] };
const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);
const row = await db.one('SELECT * FROM run_tasks WHERE id = $1', [task.id]);
const done_ = await t.ctx.tasks.refresh(row, { force: true });
const done_ = await t.ctx.tasks.refresh(row, { force: true });
assert.strictEqual(done_.state, 'succeeded');
assert.strictEqual(done_.state, 'succeeded');
- const rows = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1 ORDER BY created_at, event_id', [task.id]);
- assert.deepStrictEqual(rows.map((x) => x.topic), ['codes.task.queued', 'codes.task.succeeded']);
- assert.strictEqual(rows[1].payload.state, 'succeeded');
- assert.strictEqual(rows[1].payload.cost_usd, 0.02);
- assert.strictEqual(rows[1].payload.files_changed, 1);
+ const rows = await db.many('SELECT topic, payload FROM codes_outbox WHERE payload->>\'task_id\' = $1', [task.id]);
+ assert.deepStrictEqual(rows.map((x) => x.topic).sort(), ['codes.task.queued', 'codes.task.succeeded', 'codes.task.updated', 'codes.task.updated'].sort());
+ const ended = rows.find((x) => x.topic === 'codes.task.succeeded');
+ assert.strictEqual(ended.payload.state, 'succeeded');
+ assert.strictEqual(ended.payload.cost_usd, 0.02);
+ assert.strictEqual(ended.payload.files_changed, 1);
+ const updates = rows.filter((x) => x.topic === 'codes.task.updated');
+ assert.deepStrictEqual(updates.map((x) => x.payload.state).sort(), ['queued', 'succeeded']);
+ assert.ok(updates.every((x) => x.payload.url === `https://openvibe.codes/tasks/${task.id}`), 'every updated row links the task');
});
});
await check('publishing sends each row once, with the fixed id and the proposal envelope', async () => {
await check('publishing sends each row once, with the fixed id and the proposal envelope', async () => {
@@ -115,7 +123,9 @@ const { createPublisher } = require('../server/events/outbox');
@@ -115,7 +123,9 @@ const { createPublisher } = require('../server/events/outbox');
await check('a failure records attempts/last_error, backs off, then retries the same id', async () => {
await check('a failure records attempts/last_error, backs off, then retries the same id', async () => {
const second = await t.ctx.tasks.start({ subject: ada.subject, project, args });
const second = await t.ctx.tasks.start({ subject: ada.subject, project, args });
- const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [second.id]);
+ // Only the per-state row is due: the updated row waits its turn, so the failure below is deterministic.
+ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() + interval '1 hour' WHERE payload->>'task_id' = $1 AND topic = 'codes.task.updated'", [second.id]);
+ const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1 AND topic = \'codes.task.queued\'', [second.id]);
failNext = 1;
failNext = 1;
const failed = await t.ctx.publisher.runOnce();
const failed = await t.ctx.publisher.runOnce();
assert.strictEqual(failed.failed, 1);
assert.strictEqual(failed.failed, 1);
@@ -132,17 +142,19 @@ const { createPublisher } = require('../server/events/outbox');
@@ -132,17 +142,19 @@ const { createPublisher } = require('../server/events/outbox');
assert.ok(after.published_at);
assert.ok(after.published_at);
assert.strictEqual(after.attempts, 1, 'the failed attempt stays recorded');
assert.strictEqual(after.attempts, 1, 'the failed attempt stays recorded');
assert.strictEqual(received.filter((x) => x.event_id === row.event_id).length, 1, 'one event under the fixed id');
assert.strictEqual(received.filter((x) => x.event_id === row.event_id).length, 1, 'one event under the fixed id');
+ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE payload->>'task_id' = $1 AND topic = 'codes.task.updated'", [second.id]);
+ assert.strictEqual((await t.ctx.publisher.runOnce()).published, 1, 'the updated row follows');
});
});
await check('an event OpenVibe.Events refuses is dead after a fixed number of tries, not retried forever', async () => {
await check('an event OpenVibe.Events refuses is dead after a fixed number of tries, not retried forever', async () => {
const third = await t.ctx.tasks.start({ subject: ada.subject, project, args });
const third = await t.ctx.tasks.start({ subject: ada.subject, project, args });
- const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1', [third.id]);
- refuseNext = 3;
+ const row = await db.one('SELECT * FROM codes_outbox WHERE payload->>\'task_id\' = $1 AND topic = \'codes.task.queued\'', [third.id]);
+ refuseNext = 6;
for (let tries = 0; tries < 3; tries++) {
for (let tries = 0; tries < 3; tries++) {
- await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);
+ await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE payload->>'task_id' = $1", [third.id]);
const r = await t.ctx.publisher.runOnce();
const r = await t.ctx.publisher.runOnce();
- assert.strictEqual(r.claimed, 1, 'the row is claimed');
- assert.strictEqual(r.failed, 1, 'the refusal is a failure');
+ assert.strictEqual(r.claimed, 2, 'both rows are claimed');
+ assert.strictEqual(r.failed, 2, 'the refusals are failures');
}
}
const dead = await db.one('SELECT * FROM codes_outbox WHERE event_id = $1', [row.event_id]);
const dead = await db.one('SELECT * FROM codes_outbox WHERE event_id = $1', [row.event_id]);
assert.ok(dead.dead_at, 'the event is dead');
assert.ok(dead.dead_at, 'the event is dead');
@@ -151,7 +163,7 @@ const { createPublisher } = require('../server/events/outbox');
@@ -151,7 +163,7 @@ const { createPublisher } = require('../server/events/outbox');
assert.strictEqual(dead.attempts, 3, 'it got the tries it was given');
assert.strictEqual(dead.attempts, 3, 'it got the tries it was given');
assert.match(dead.last_error, /events 422/);
assert.match(dead.last_error, /events 422/);
assert.strictEqual(await t.ctx.publisher.pending(), 0, 'a dead event is not pending');
assert.strictEqual(await t.ctx.publisher.pending(), 0, 'a dead event is not pending');
- assert.strictEqual(await t.ctx.publisher.dead(), 1);
+ assert.strictEqual(await t.ctx.publisher.dead(), 2, 'the per-state row and the updated row are both dead');
await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);
await db.exec("UPDATE codes_outbox SET next_attempt_at = now() - interval '1 second' WHERE event_id = $1", [row.event_id]);
assert.strictEqual((await t.ctx.publisher.runOnce()).claimed, 0, 'a dead event is never sent again');
assert.strictEqual((await t.ctx.publisher.runOnce()).claimed, 0, 'a dead event is never sent again');
refuseNext = 0;
refuseNext = 0;
@@ -201,7 +201,9 @@ const { boot, check, done } = require('./helpers/boot');
@@ -201,7 +201,9 @@ const { boot, check, done } = require('./helpers/boot');
assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');
assert.ok(carried.json.attempts.length >= 2, 'the continue is the next attempt');
const stopped = await asAda('POST', `/tasks/${own.id}/stop`);
const stopped = await asAda('POST', `/tasks/${own.id}/stop`);
assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));
assert.strictEqual(stopped.status, 200, JSON.stringify(stopped.json));
- assert.strictEqual(stopped.json.outcome.state, 'stopped');
+ assert.deepStrictEqual(stopped.json, { ok: true }, 'the owner\u2019s shape (the Dock); a service keeps the timeline');
+ const ended = await asAda('GET', `/tasks/${own.id}`);
+ assert.strictEqual(ended.json.outcome.state, 'stopped', 'the task did stop');
});
});
await check('list filters: needs is the task the engine left unfinished, done is the rest', async () => {
await check('list filters: needs is the task the engine left unfinished, done is the rest', async () => {