openvibers/openvibe.codes Commits

Codes: the Dock's Code tab API — cross-site tasks list, start and stop (#390)

OP OpenVibers committed 2 days ago · 507ab0225c · 18 files, +725 −72

* Codes: the Dock's Code tab API — cross-site tasks list, start and stop

* Codes: the owner's stop answers { ok }; the timeline stays a service shape

* Codes: scope the Dock CORS to its own routes; the MCP stop follows { ok }

Changes

ddiff --git a/bin/mcp.js b/bin/mcp.js
iindex 57f1bb6..52cad52 100644
---- a/bin/mcp.js
++++ b/bin/mcp.js
@@@ -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 });
}
ddiff --git a/migrations/0034_task_mode.sql b/migrations/0034_task_mode.sql
nnew file mode 100644
iindex 0000000..61b55e8
---- /dev/null
++++ b/migrations/0034_task_mode.sql
@@@ -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";
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 95dcf96..2322de2 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -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 a
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}
@@@ -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. */
ddiff --git a/public/js/chat.js b/public/js/chat.js
iindex 948e905..c757aa0 100644
---- a/public/js/chat.js
++++ b/public/js/chat.js
@@@ -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;
ddiff --git a/server/app.js b/server/app.js
iindex 2dc4fa0..8d631d1 100644
---- a/server/app.js
++++ b/server/app.js
@@@ -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).
ddiff --git a/server/domain/run-tasks.js b/server/domain/run-tasks.js
iindex a66a834..8294c46 100644
---- a/server/domain/run-tasks.js
++++ b/server/domain/run-tasks.js
@@@ -159,7 +159,7 @@ function createRunTasks({ store, run, config, log = console, sessions = null, ou
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).
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, ou
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');
++ // 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, ou
const 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;
ddiff --git a/server/domain/task-summary.js b/server/domain/task-summary.js
nnew file mode 100644
iindex 0000000..f2746a4
---- /dev/null
++++ b/server/domain/task-summary.js
@@@ -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 };
ddiff --git a/server/events/outbox.js b/server/events/outbox.js
iindex 32ddcf3..9481bff 100644
---- a/server/events/outbox.js
++++ b/server/events/outbox.js
@@@ -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 };
ddiff --git a/server/http/attachments-api.js b/server/http/attachments-api.js
iindex ecc02a8..6704adf 100644
---- a/server/http/attachments-api.js
++++ b/server/http/attachments-api.js
@@@ -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, l
const 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, l
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;
} 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, l
r.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));
ddiff --git a/server/http/chat.js b/server/http/chat.js
iindex 54401e0..205663b 100644
---- a/server/http/chat.js
++++ b/server/http/chat.js
@@@ -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)}`);
});
ddiff --git a/server/http/me-api.js b/server/http/me-api.js
iindex d9dc3ad..c64a1a2 100644
---- a/server/http/me-api.js
++++ b/server/http/me-api.js
@@@ -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) => {
ddiff --git a/server/http/openvibe-origins.js b/server/http/openvibe-origins.js
nnew file mode 100644
iindex 0000000..3aa6fa8
---- /dev/null
++++ b/server/http/openvibe-origins.js
@@@ -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 };
ddiff --git a/server/http/tasks-api.js b/server/http/tasks-api.js
iindex 31d0b4e..2386035 100644
---- a/server/http/tasks-api.js
++++ b/server/http/tasks-api.js
@@@ -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, task
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) => {
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, task
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) => {
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));
});
ddiff --git a/test/attachments.test.js b/test/attachments.test.js
iindex 85a4337..f84b7df 100644
---- a/test/attachments.test.js
++++ b/test/attachments.test.js
@@@ -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 () => {
ddiff --git a/test/dock-tasks.test.js b/test/dock-tasks.test.js
nnew file mode 100644
iindex 0000000..7778f92
---- /dev/null
++++ b/test/dock-tasks.test.js
@@@ -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); });
ddiff --git a/test/events-consumer.test.js b/test/events-consumer.test.js
iindex 5f1eb08..f8af4ad 100644
---- a/test/events-consumer.test.js
++++ b/test/events-consumer.test.js
@@@ -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');
ddiff --git a/test/outbox.test.js b/test/outbox.test.js
iindex c2b4604..e454565 100644
---- a/test/outbox.test.js
++++ b/test/outbox.test.js
@@@ -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;
ddiff --git a/test/tasks-api.test.js b/test/tasks-api.test.js
iindex c7738ac..e2a9d1a 100644
---- a/test/tasks-api.test.js
++++ b/test/tasks-api.test.js
@@@ -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 () => {