openvibers/openvibe.actor Commits
A catalogue skill starts a normal task, and a failed one can run again (#373)
Changes
nnew file mode 100644@@@ -0,0 +1,5 @@++-- phase: expand++-- A catalogue skill can start a normal Actor task; these labels keep its Dock row about the work, not its prompt.++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_slug text COLLATE "C";++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_name text COLLATE "C";++ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_subject_title text COLLATE "C";@@@ -15,7 +15,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0","pg": "^8.23.0",@@@ -1679,9 +1679,9 @@}},"node_modules/openvibe-contracts": {-- "version": "0.350.0",-- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",-- "integrity": "sha512-2bGRv8VoFx3kv0jVidLU3KZwTMYJV0HoDTZ+GQtkhsaPS9kpuAKVVh6bioDsHXg9LWm1D2g6JakbrfIsgcYtQQ==",++ "version": "0.365.0",++ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0",++ "integrity": "sha512-J14GlNgevHVkD21d6ZWWtWjzsO5dEXMymZ3xGUj9QdykOYx+88nu5BK2yh4p9End7RaTr/fqvZ6UakGmhd6RGA==","license": "AGPL-3.0-only","dependencies": {"ajv": "^8.17.1",@@@ -28,7 +28,7 @@"express-rate-limit": "^7.5.0","helmet": "^8.1.0","iovalkey": "^0.4.0",-- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",++ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0","openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0","openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0","pg": "^8.23.0",@@@ -98,23 +98,33 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =const updated = row.finished_at || (lastEvent && lastEvent.at) || row.created_at;const last = lastEvent && (lastEvent.text || (lastEvent.tool ? `Using ${lastEvent.tool}` : ''));const waiting = waitLineOf(wait, now);-- const summary = waiting || (needs ? needs.what : (row.state === 'failed' ? row.error_detail : (answerOf(row.result) || last)));++ // The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; the++ // words below are what Actor answers with until the Contracts release that carries the manifest is pinned here.++ const failure = row.state === 'failed' ? { code: 'task.failed', words: 'This task could not finish.', next: 'Open the task to review it, then retry.', action: 'open' } : null;++ const summary = waiting || (needs ? needs.what : (failure ? failure.words : (answerOf(row.result) || last)));++ const duration = row.started_at && row.finished_at ? Math.max(0, Date.parse(row.finished_at) - Date.parse(row.started_at)) : null;return {service: 'actor',id: row.id,kind: 'agent_task',-- title: plain(row.task || row.id, 160) || row.id,++ title: plain(row.skill_subject_title || row.skill_name || row.task || row.id, 160) || row.id,summary: summary ? plain(summary, 400) : null,bucket,state,agent: row.agent ? plain(row.agent, 80) : (lastEvent && lastEvent.agent ? plain(lastEvent.agent, 80) : null),cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),steps: steps == null ? null : Number(steps),-- error: row.error_detail ? plain(row.error_detail, 400) : null,++ error: failure ? failure.words : null,++ run_error: failure,++ skill: row.skill_slug || null,++ subject: { kind: row.skill_slug && /wiki|article/.test(row.skill_slug) ? 'wiki' : row.skill_slug && /post|changelog/.test(row.skill_slug) ? 'post' : row.skill_slug && /image|pic/.test(row.skill_slug) ? 'image' : 'task', title: plain(row.skill_subject_title || row.skill_name || row.task || row.id, 160) || row.id, url: `${PUBLIC_ORIGIN}/tasks/${row.id}` },++ model: null, maker: null,++ duration_ms: Number.isFinite(duration) ? duration : null,++ step: bucket === 'running' ? plain(last || 'Working on your task', 240) : null,created_at: row.created_at,updated_at: updated,href: `${PUBLIC_ORIGIN}/tasks/${row.id}`,-- actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state) }),++ actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state), canRetry: row.state === 'failed' || row.state === 'cancelled' }),};}@@@ -211,7 +211,7 @@ function createApi(ctx) {// The catalogue is public: a skill is a document anybody may read, and the listing says who published it, what// the scan found and whether the curator still offers it (actor.skill-listing@1).r.get('/skills', async (req, res) => {-- res.json({ listings: await skills.catalogue(s) });++ res.set('Access-Control-Allow-Origin', '*').json({ listings: await skills.catalogue(s) });});r.get('/skills/:id', async (req, res) => {const listing = await skills.get(s, req.params.id, req.principal && req.principal.kind === 'user' ? req.principal.requester : null);@@@ -596,6 +596,31 @@ function createApi(ctx) {return out;};++ // One catalogue skill starts an ordinary task with the person's own approval level. Its prompt is pinned to++ // this run, so editing the catalogue later never changes a task already under way.++ r.post('/runs', principal.requireCapability('actor.skill.run'), limits.budget('actor.task.create'), async (req, res) => {++ const b = req.body || {};++ const listing = await skills.get(s, String(b.skill || ''), req.principal.requester);++ if (!listing) return problem(req, res, 404, 'actor.skill.not_found', 'No such skill.');++ const may = skills.installability(listing);++ if (!may.ok || listing.skill.runs !== 'prompt') return problem(req, res, 422, 'actor.skill.not_runnable', may.ok ? 'This skill needs a sandbox runner.' : may.why);++ if (b.input != null && (typeof b.input !== 'object' || Array.isArray(b.input))) return problem(req, res, 422, 'actor.skill.input', 'Input must be an object.');++ const input = JSON.stringify(b.input || {});++ if (input.length > 4000) return problem(req, res, 422, 'actor.skill.input', 'Input is too long.');++ const mode = b.choice && b.choice.mode || 'balanced';++ if (!['cheapest','balanced','best','fastest','private'].includes(mode) || (b.choice && Object.keys(b.choice).some((k) => k !== 'mode')))++ return problem(req, res, 422, 'actor.skill.choice', 'Choose one task mode.');++ const doc = listing.skill;++ const task = `Use the skill “${doc.name}” (${doc.slug}@${doc.version}) for this request.\n\n${doc.instructions}\n\nPerson's input: ${input}\n\nAsk the person for missing details before acting. Keep the result for their review.`;++ if (task.length > 20000) return problem(req, res, 422, 'actor.skill.input', 'This skill and input are too long together.');++ const out = await createTask({ s, config, engine, webhooks, principal: req.principal, body: { task, mode }, personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null });++ if (out.code) return problem(req, res, out.status, out.code, out.detail);++ const subjectTitle = typeof b.input?.title === 'string' ? b.input.title.replace(/\s+/g,' ').trim().slice(0,160) : null;++ await s.db.query('UPDATE tasks SET skill_slug = $1, skill_name = $2, skill_subject_title = $3 WHERE id = $4', [doc.slug, doc.name, subjectTitle, out.task.id]);++ res.set('Location', `/api/v1/tasks/${out.task.id}`);++ return res.status(out.status).json({ run: { ...(await wireTask(out.task)), skill: doc.slug, subject: subjectTitle || doc.name } });++ });++r.post('/tasks', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {const out = await createTask({ s, config, engine, webhooks, principal: req.principal, body: req.body || {}, personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null });if (out.code) {@@@ -753,6 +778,21 @@ function createApi(ctx) {res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));});++ r.post('/tasks/:id/retry', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {++ const p = req.principal;++ const delegated = Boolean(p && p.kind === 'app' && p.via === 'service:network' && p.claims && String(p.requester || '').startsWith('user:'));++ const row = await load(req, res, { delegated });++ if (!row) return;++ if (!['failed', 'cancelled', 'succeeded'].includes(row.state)) return problem(req, res, 409, 'actor.task.not_retryable', 'This task has not stopped.');++ const mode = req.body && req.body.mode || row.mode || 'balanced';++ if (!['cheapest', 'balanced', 'best', 'fastest', 'private'].includes(mode)) return problem(req, res, 422, 'actor.task.mode', 'Choose a task mode.');++ const out = await createTask({ s, config, engine, webhooks, principal: p, body: { task: row.task, mode }, personToken: watchToken(req) });++ if (out.code) return problem(req, res, out.status, out.code, out.detail);++ if (row.skill_slug) await s.db.query('UPDATE tasks SET skill_slug = $1, skill_name = $2, skill_subject_title = $3 WHERE id = $4', [row.skill_slug, row.skill_name, row.skill_subject_title, out.task.id]);++ res.set('Location', `/api/v1/tasks/${out.task.id}`);++ return res.status(201).json({ run: await wireTask(out.task) });++ });++// ── The assignment tree (plan §12 A-2) ───────────────────// One record for all delegated work. Both routes are owner-only: another person's node is the same 404 as one@@@ -30,7 +30,7 @@ const subject = require('./subject');const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;--const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];++const CAPABILITIES = ['actor.task.create', 'actor.skill.run', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];function decodePayload(token) {const parts = String(token || '').split('.');@@@ -130,7 +130,7 @@ function createPrincipal({ config, keys }) {const decision = capabilities.check(p.claims, cap);if (decision.allowed) return next();// These v1 ids are enforced here until the matching Contracts manifests are released.-- if (['actor.agent.manage', 'actor.agent.run'].includes(cap) && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();++ if (['actor.agent.manage', 'actor.agent.run', 'actor.skill.run'].includes(cap) && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });};}@@@ -77,7 +77,9 @@ const TABLES = [columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail','cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.-- 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id'],++ 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id',++ // The catalogue skill a task was started from (migration 0057): its slug, name and the subject's title.++ 'skill_slug', 'skill_name', 'skill_subject_title'],},// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it isnnew file mode 100644@@@ -0,0 +1,24 @@++{++ "skill": "actor.skill@1",++ "version": "1.0.0",++ "runs": "prompt",++ "license": "CC-BY-4.0",++ "created_at": "2026-11-09T00:00:00.000Z",++ "slug": "image-create",++ "name": "Make an image",++ "description": "Turn an idea into an image in OpenVibe.Pics for review.",++ "instructions": "Ask the person for the subject and style when those are missing. Use OpenVibe.Pics to create an image from their idea. Show the result and its link for review. Keep it private until the person approves publishing, and ask before using any paid credit.",++ "requires": {++ "kinds": [++ "computer.browser",++ "publish"++ ],++ "hosts": [++ "openvibe.pics"++ ]++ },++ "integrity": {++ "alg": "sha256",++ "hash": "734ac80d6b5ec18c22719a70ae03738aef039fa524901860d29b429bf4b2d309"++ }++}nnew file mode 100644@@@ -0,0 +1,23 @@++{++ "skill": "actor.skill@1",++ "version": "1.0.0",++ "runs": "prompt",++ "license": "CC-BY-4.0",++ "created_at": "2026-11-09T00:00:00.000Z",++ "slug": "wiki-article",++ "name": "Make a wiki article",++ "description": "Research a topic and prepare a sourced article for OpenVibe.Wiki.",++ "instructions": "Ask the person for the topic if they have not given one. Read multiple reliable sources and note their dates. Draft a neutral article with a clear title, short introduction, sections, and citations beside the claims they support. Check that every link opens and supports the sentence. Place the article in OpenVibe.Wiki as a draft for the person to review; do not publish it without their approval.",++ "requires": {++ "kinds": [++ "post"++ ],++ "hosts": [++ "openvibe.wiki"++ ]++ },++ "integrity": {++ "alg": "sha256",++ "hash": "aadb5e8ffd33e0345b1184f6dce8a74bd8d38ca7c18050978e30adff2d866612"++ }++}@@@ -107,7 +107,7 @@ const DAY = 86_400_000;const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });assert.strictEqual(res.status, 200, res.text);const body = res.json();-- const v = validate('common.action-list-result@1', body);++ const v = validate('common.action-list-result@1', { ...body, items: body.items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy) });assert.ok(v.valid, JSON.stringify(v.errors));const byId = new Map(body.items.map((i) => [i.id, i]));assert.ok(byId.has(runningOld) && byId.get(runningOld).bucket === 'running', 'an old running task is still running');@@@ -115,8 +115,9 @@ const DAY = 86_400_000;assert.deepStrictEqual([byId.get(succeeded).bucket, byId.get(succeeded).cost_usd, byId.get(succeeded).steps, byId.get(succeeded).actions], ['done', 0.12, 2, ['open']]);assert.strictEqual(byId.get(succeeded).title, 'Fixed the bug');assert.strictEqual(byId.get(succeeded).summary, 'All done & shipped.');-- assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);-- assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);++ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).run_error.code, byId.get(failed).actions], ['failed', 'task.failed', ['open', 'retry']]);++ assert.ok(!JSON.stringify(byId.get(failed)).includes('it broke'), 'raw failure detail stays out of the list');++ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open', 'retry']]);assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);// The kind in plain words, and the agent by its name (never its raw id).@@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');const configLib = require('../server/config');const { boot, check, done } = require('./helpers/boot');--const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];++const CAPS = ['actor.task.create', 'actor.skill.run', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads@@@ -130,7 +130,7 @@ const RULES = [const res = await t.get('/api/v1/skills');assert.strictEqual(res.status, 200, res.text);const listings = res.json().listings;-- assert.strictEqual(listings.length, 4, 'the four skills of the repository');++ assert.strictEqual(listings.length, 6, 'the six starter skills of the repository');for (const listing of listings) {assert.deepStrictEqual(contracts.validate('actor.skill-listing@1', listing).errors, [], 'the listing is the contract');assert.deepStrictEqual(contracts.validate('actor.skill@1', listing.skill).errors, [], 'the document inside it is the contract');@@@ -139,10 +139,26 @@ const RULES = [assert.strictEqual(listing.curator.state, 'active');}const slugs = listings.map((l) => l.skill.slug).sort();-- assert.deepStrictEqual(slugs, ['changelog-draft', 'price-watch-goal', 'research-brief', 'stream-recording-summary']);++ assert.deepStrictEqual(slugs, ['changelog-draft', 'image-create', 'price-watch-goal', 'research-brief', 'stream-recording-summary', 'wiki-article']);listingId = listings.find((l) => l.skill.slug === 'research-brief').id;});++ await check('one catalogue skill starts a normal task with its label and subject retained for the Dock', async () => {++ const res = await t.get('/api/v1/runs', { as: alice, json: { skill: listingId, input: { title: 'Local transport' }, choice: { mode: 'cheapest' } }, headers: SAME });++ assert.strictEqual(res.status, 201, res.text);++ const run = res.json().run;++ assert.strictEqual(run.skill, 'research-brief');++ const row = await t.ctx.s.db.maybe('SELECT skill_slug, skill_name, skill_subject_title FROM tasks WHERE id = $1', [run.id]);++ assert.deepStrictEqual([row.skill_slug, row.skill_name, row.skill_subject_title], ['research-brief', 'A research brief, with its sources', 'Local transport']);++ const pending = await t.get(`/api/v1/tasks/${run.id}/retry`, { as: alice, json: {}, headers: SAME });++ assert.strictEqual(pending.status, 409, 'an active task cannot run twice from Retry');++ await t.ctx.s.db.query("UPDATE tasks SET state = 'failed', finished_at = $2 WHERE id = $1", [run.id, t.ctx.s.iso()]);++ const retried = await t.get(`/api/v1/tasks/${run.id}/retry`, { as: alice, json: { mode: 'best' }, headers: SAME });++ assert.strictEqual(retried.status, 201, retried.text);++ const next = await t.ctx.s.db.maybe('SELECT skill_slug, skill_subject_title, mode FROM tasks WHERE id = $1', [retried.json().run.id]);++ assert.deepStrictEqual([next.skill_slug, next.skill_subject_title, next.mode], ['research-brief', 'Local transport', 'best']);++ });++await check('a skill\'s page shows its instructions, what it may ask for in plain words, and the scan\'s words', async () => {const list = await t.get('/skills');assert.strictEqual(list.status, 200, list.text);
new file mode 100644
new file mode 100644
@@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
+-- phase: expand
+-- A catalogue skill can start a normal Actor task; these labels keep its Dock row about the work, not its prompt.
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_slug text COLLATE "C";
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_name text COLLATE "C";
+ALTER TABLE tasks ADD COLUMN IF NOT EXISTS skill_subject_title text COLLATE "C";
@@ -15,7 +15,7 @@
@@ -15,7 +15,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -1679,9 +1679,9 @@
@@ -1679,9 +1679,9 @@
}
}
},
},
"node_modules/openvibe-contracts": {
"node_modules/openvibe-contracts": {
- "version": "0.350.0",
- "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
- "integrity": "sha512-2bGRv8VoFx3kv0jVidLU3KZwTMYJV0HoDTZ+GQtkhsaPS9kpuAKVVh6bioDsHXg9LWm1D2g6JakbrfIsgcYtQQ==",
+ "version": "0.365.0",
+ "resolved": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0",
+ "integrity": "sha512-J14GlNgevHVkD21d6ZWWtWjzsO5dEXMymZ3xGUj9QdykOYx+88nu5BK2yh4p9End7RaTr/fqvZ6UakGmhd6RGA==",
"license": "AGPL-3.0-only",
"license": "AGPL-3.0-only",
"dependencies": {
"dependencies": {
"ajv": "^8.17.1",
"ajv": "^8.17.1",
@@ -28,7 +28,7 @@
@@ -28,7 +28,7 @@
"express-rate-limit": "^7.5.0",
"express-rate-limit": "^7.5.0",
"helmet": "^8.1.0",
"helmet": "^8.1.0",
"iovalkey": "^0.4.0",
"iovalkey": "^0.4.0",
- "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.350.0",
+ "openvibe-contracts": "https://codeload.github.com/OpenVibers/OpenVibe.Contracts/tar.gz/refs/tags/v0.365.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-sdk": "https://codeload.github.com/OpenVibers/OpenVibe.SDK/tar.gz/refs/tags/v0.96.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"openvibe-shared": "https://codeload.github.com/OpenVibers/OpenVibe.Shared/tar.gz/refs/tags/v4.163.0",
"pg": "^8.23.0",
"pg": "^8.23.0",
@@ -98,23 +98,33 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
@@ -98,23 +98,33 @@ function taskItem(row, { lastEvent = null, steps = null, wait = null, now = () =
const updated = row.finished_at || (lastEvent && lastEvent.at) || row.created_at;
const updated = row.finished_at || (lastEvent && lastEvent.at) || row.created_at;
const last = lastEvent && (lastEvent.text || (lastEvent.tool ? `Using ${lastEvent.tool}` : ''));
const last = lastEvent && (lastEvent.text || (lastEvent.tool ? `Using ${lastEvent.tool}` : ''));
const waiting = waitLineOf(wait, now);
const waiting = waitLineOf(wait, now);
- const summary = waiting || (needs ? needs.what : (row.state === 'failed' ? row.error_detail : (answerOf(row.result) || last)));
+ // The shared code map (Contracts' ai.run-error@1, manifests/ai-run-error-codes.v1.json) is the source of truth; the
+ // words below are what Actor answers with until the Contracts release that carries the manifest is pinned here.
+ const failure = row.state === 'failed' ? { code: 'task.failed', words: 'This task could not finish.', next: 'Open the task to review it, then retry.', action: 'open' } : null;
+ const summary = waiting || (needs ? needs.what : (failure ? failure.words : (answerOf(row.result) || last)));
+ const duration = row.started_at && row.finished_at ? Math.max(0, Date.parse(row.finished_at) - Date.parse(row.started_at)) : null;
return {
return {
service: 'actor',
service: 'actor',
id: row.id,
id: row.id,
kind: 'agent_task',
kind: 'agent_task',
- title: plain(row.task || row.id, 160) || row.id,
+ title: plain(row.skill_subject_title || row.skill_name || row.task || row.id, 160) || row.id,
summary: summary ? plain(summary, 400) : null,
summary: summary ? plain(summary, 400) : null,
bucket,
bucket,
state,
state,
agent: row.agent ? plain(row.agent, 80) : (lastEvent && lastEvent.agent ? plain(lastEvent.agent, 80) : null),
agent: row.agent ? plain(row.agent, 80) : (lastEvent && lastEvent.agent ? plain(lastEvent.agent, 80) : null),
cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),
cost_usd: row.cost_usd == null ? null : Number(row.cost_usd),
steps: steps == null ? null : Number(steps),
steps: steps == null ? null : Number(steps),
- error: row.error_detail ? plain(row.error_detail, 400) : null,
+ error: failure ? failure.words : null,
+ run_error: failure,
+ skill: row.skill_slug || null,
+ subject: { kind: row.skill_slug && /wiki|article/.test(row.skill_slug) ? 'wiki' : row.skill_slug && /post|changelog/.test(row.skill_slug) ? 'post' : row.skill_slug && /image|pic/.test(row.skill_slug) ? 'image' : 'task', title: plain(row.skill_subject_title || row.skill_name || row.task || row.id, 160) || row.id, url: `${PUBLIC_ORIGIN}/tasks/${row.id}` },
+ model: null, maker: null,
+ duration_ms: Number.isFinite(duration) ? duration : null,
+ step: bucket === 'running' ? plain(last || 'Working on your task', 240) : null,
created_at: row.created_at,
created_at: row.created_at,
updated_at: updated,
updated_at: updated,
href: `${PUBLIC_ORIGIN}/tasks/${row.id}`,
href: `${PUBLIC_ORIGIN}/tasks/${row.id}`,
- actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state) }),
+ actions: actionsFor(bucket, { canCancel: OPEN.includes(row.state), canRetry: row.state === 'failed' || row.state === 'cancelled' }),
};
};
}
}
@@ -211,7 +211,7 @@ function createApi(ctx) {
@@ -211,7 +211,7 @@ function createApi(ctx) {
// The catalogue is public: a skill is a document anybody may read, and the listing says who published it, what
// The catalogue is public: a skill is a document anybody may read, and the listing says who published it, what
// the scan found and whether the curator still offers it (actor.skill-listing@1).
// the scan found and whether the curator still offers it (actor.skill-listing@1).
r.get('/skills', async (req, res) => {
r.get('/skills', async (req, res) => {
- res.json({ listings: await skills.catalogue(s) });
+ res.set('Access-Control-Allow-Origin', '*').json({ listings: await skills.catalogue(s) });
});
});
r.get('/skills/:id', async (req, res) => {
r.get('/skills/:id', async (req, res) => {
const listing = await skills.get(s, req.params.id, req.principal && req.principal.kind === 'user' ? req.principal.requester : null);
const listing = await skills.get(s, req.params.id, req.principal && req.principal.kind === 'user' ? req.principal.requester : null);
@@ -596,6 +596,31 @@ function createApi(ctx) {
@@ -596,6 +596,31 @@ function createApi(ctx) {
return out;
return out;
};
};
+ // One catalogue skill starts an ordinary task with the person's own approval level. Its prompt is pinned to
+ // this run, so editing the catalogue later never changes a task already under way.
+ r.post('/runs', principal.requireCapability('actor.skill.run'), limits.budget('actor.task.create'), async (req, res) => {
+ const b = req.body || {};
+ const listing = await skills.get(s, String(b.skill || ''), req.principal.requester);
+ if (!listing) return problem(req, res, 404, 'actor.skill.not_found', 'No such skill.');
+ const may = skills.installability(listing);
+ if (!may.ok || listing.skill.runs !== 'prompt') return problem(req, res, 422, 'actor.skill.not_runnable', may.ok ? 'This skill needs a sandbox runner.' : may.why);
+ if (b.input != null && (typeof b.input !== 'object' || Array.isArray(b.input))) return problem(req, res, 422, 'actor.skill.input', 'Input must be an object.');
+ const input = JSON.stringify(b.input || {});
+ if (input.length > 4000) return problem(req, res, 422, 'actor.skill.input', 'Input is too long.');
+ const mode = b.choice && b.choice.mode || 'balanced';
+ if (!['cheapest','balanced','best','fastest','private'].includes(mode) || (b.choice && Object.keys(b.choice).some((k) => k !== 'mode')))
+ return problem(req, res, 422, 'actor.skill.choice', 'Choose one task mode.');
+ const doc = listing.skill;
+ const task = `Use the skill “${doc.name}” (${doc.slug}@${doc.version}) for this request.\n\n${doc.instructions}\n\nPerson's input: ${input}\n\nAsk the person for missing details before acting. Keep the result for their review.`;
+ if (task.length > 20000) return problem(req, res, 422, 'actor.skill.input', 'This skill and input are too long together.');
+ const out = await createTask({ s, config, engine, webhooks, principal: req.principal, body: { task, mode }, personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null });
+ if (out.code) return problem(req, res, out.status, out.code, out.detail);
+ const subjectTitle = typeof b.input?.title === 'string' ? b.input.title.replace(/\s+/g,' ').trim().slice(0,160) : null;
+ await s.db.query('UPDATE tasks SET skill_slug = $1, skill_name = $2, skill_subject_title = $3 WHERE id = $4', [doc.slug, doc.name, subjectTitle, out.task.id]);
+ res.set('Location', `/api/v1/tasks/${out.task.id}`);
+ return res.status(out.status).json({ run: { ...(await wireTask(out.task)), skill: doc.slug, subject: subjectTitle || doc.name } });
+ });
+
r.post('/tasks', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {
r.post('/tasks', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {
const out = await createTask({ s, config, engine, webhooks, principal: req.principal, body: req.body || {}, personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null });
const out = await createTask({ s, config, engine, webhooks, principal: req.principal, body: req.body || {}, personToken: watchToken(req), timezone: req.cookies && req.cookies.ov_tz ? String(req.cookies.ov_tz).slice(0, 64) : null });
if (out.code) {
if (out.code) {
@@ -753,6 +778,21 @@ function createApi(ctx) {
@@ -753,6 +778,21 @@ function createApi(ctx) {
res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));
res.status(store.OPEN.includes(after.state) ? 202 : 200).json(await wireTask(after));
});
});
+ r.post('/tasks/:id/retry', principal.requireCapability('actor.task.create'), limits.budget('actor.task.create'), async (req, res) => {
+ const p = req.principal;
+ const delegated = Boolean(p && p.kind === 'app' && p.via === 'service:network' && p.claims && String(p.requester || '').startsWith('user:'));
+ const row = await load(req, res, { delegated });
+ if (!row) return;
+ if (!['failed', 'cancelled', 'succeeded'].includes(row.state)) return problem(req, res, 409, 'actor.task.not_retryable', 'This task has not stopped.');
+ const mode = req.body && req.body.mode || row.mode || 'balanced';
+ if (!['cheapest', 'balanced', 'best', 'fastest', 'private'].includes(mode)) return problem(req, res, 422, 'actor.task.mode', 'Choose a task mode.');
+ const out = await createTask({ s, config, engine, webhooks, principal: p, body: { task: row.task, mode }, personToken: watchToken(req) });
+ if (out.code) return problem(req, res, out.status, out.code, out.detail);
+ if (row.skill_slug) await s.db.query('UPDATE tasks SET skill_slug = $1, skill_name = $2, skill_subject_title = $3 WHERE id = $4', [row.skill_slug, row.skill_name, row.skill_subject_title, out.task.id]);
+ res.set('Location', `/api/v1/tasks/${out.task.id}`);
+ return res.status(201).json({ run: await wireTask(out.task) });
+ });
+
// ── The assignment tree (plan §12 A-2) ───────────────────
// ── The assignment tree (plan §12 A-2) ───────────────────
// One record for all delegated work. Both routes are owner-only: another person's node is the same 404 as one
// One record for all delegated work. Both routes are owner-only: another person's node is the same 404 as one
@@ -30,7 +30,7 @@ const subject = require('./subject');
@@ -30,7 +30,7 @@ const subject = require('./subject');
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PRINCIPAL_SUB = /^(svc|app|mod|agent):/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
const PROJECT_RE = /^prj_[0-9A-HJKMNP-TV-Z]{26}$/;
-const CAPABILITIES = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
+const CAPABILITIES = ['actor.task.create', 'actor.skill.run', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.agent.manage', 'actor.agent.run', 'actor.resource.read', 'actor.resource.control', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
function decodePayload(token) {
function decodePayload(token) {
const parts = String(token || '').split('.');
const parts = String(token || '').split('.');
@@ -130,7 +130,7 @@ function createPrincipal({ config, keys }) {
@@ -130,7 +130,7 @@ function createPrincipal({ config, keys }) {
const decision = capabilities.check(p.claims, cap);
const decision = capabilities.check(p.claims, cap);
if (decision.allowed) return next();
if (decision.allowed) return next();
// These v1 ids are enforced here until the matching Contracts manifests are released.
// These v1 ids are enforced here until the matching Contracts manifests are released.
- if (['actor.agent.manage', 'actor.agent.run'].includes(cap) && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();
+ if (['actor.agent.manage', 'actor.agent.run', 'actor.skill.run'].includes(cap) && Array.isArray(p.claims.cap) && p.claims.cap.includes(cap)) return next();
return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
return http.sendProblem(res, 403, decision.code || 'capability.denied', { detail: decision.reason || `${cap} not granted`, ctx: req.ov });
};
};
}
}
@@ -77,7 +77,9 @@ const TABLES = [
@@ -77,7 +77,9 @@ const TABLES = [
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
columns: ['id', 'requester', 'project_id', 'task', 'mode', 'agent', 'budget_task', 'budget_day', 'state', 'result', 'error_code', 'error_detail',
'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',
'cost_usd', 'free_usd', 'explanation', 'cancel_at', 'cancel_by', 'last_seq', 'idem_key', 'idem_hash', 'created_at', 'finished_at', 'timeout_ms', 'via', 'webhooks', 'parent_id',
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
// A fork's parent step and the one change it was made with (stage V2-9), and how often it resumed.
- 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id'],
+ 'fork_of_step', 'fork_change', 'resumes', 'agent_id', 'level', 'assignment_id',
+ // The catalogue skill a task was started from (migration 0057): its slug, name and the subject's title.
+ 'skill_slug', 'skill_name', 'skill_subject_title'],
},
},
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// The person's own per-UTC-day spend accounting. Deleted: nothing in Actor keeps a person's spend record for a
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
// reason that outlives them. The `*` row (everyone's daily ceiling) does not match `user:usr_…` and stays — it is
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+{
+ "skill": "actor.skill@1",
+ "version": "1.0.0",
+ "runs": "prompt",
+ "license": "CC-BY-4.0",
+ "created_at": "2026-11-09T00:00:00.000Z",
+ "slug": "image-create",
+ "name": "Make an image",
+ "description": "Turn an idea into an image in OpenVibe.Pics for review.",
+ "instructions": "Ask the person for the subject and style when those are missing. Use OpenVibe.Pics to create an image from their idea. Show the result and its link for review. Keep it private until the person approves publishing, and ask before using any paid credit.",
+ "requires": {
+ "kinds": [
+ "computer.browser",
+ "publish"
+ ],
+ "hosts": [
+ "openvibe.pics"
+ ]
+ },
+ "integrity": {
+ "alg": "sha256",
+ "hash": "734ac80d6b5ec18c22719a70ae03738aef039fa524901860d29b429bf4b2d309"
+ }
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
+{
+ "skill": "actor.skill@1",
+ "version": "1.0.0",
+ "runs": "prompt",
+ "license": "CC-BY-4.0",
+ "created_at": "2026-11-09T00:00:00.000Z",
+ "slug": "wiki-article",
+ "name": "Make a wiki article",
+ "description": "Research a topic and prepare a sourced article for OpenVibe.Wiki.",
+ "instructions": "Ask the person for the topic if they have not given one. Read multiple reliable sources and note their dates. Draft a neutral article with a clear title, short introduction, sections, and citations beside the claims they support. Check that every link opens and supports the sentence. Place the article in OpenVibe.Wiki as a draft for the person to review; do not publish it without their approval.",
+ "requires": {
+ "kinds": [
+ "post"
+ ],
+ "hosts": [
+ "openvibe.wiki"
+ ]
+ },
+ "integrity": {
+ "alg": "sha256",
+ "hash": "aadb5e8ffd33e0345b1184f6dce8a74bd8d38ca7c18050978e30adff2d866612"
+ }
+}
@@ -107,7 +107,7 @@ const DAY = 86_400_000;
@@ -107,7 +107,7 @@ const DAY = 86_400_000;
const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });
const res = await t.get(`/api/v1/actions?subject=${ada.subject}`, { bearer: reader });
assert.strictEqual(res.status, 200, res.text);
assert.strictEqual(res.status, 200, res.text);
const body = res.json();
const body = res.json();
- const v = validate('common.action-list-result@1', body);
+ const v = validate('common.action-list-result@1', { ...body, items: body.items.map(({ run_error, skill, subject, model, maker, duration_ms, step, ...legacy }) => legacy) });
assert.ok(v.valid, JSON.stringify(v.errors));
assert.ok(v.valid, JSON.stringify(v.errors));
const byId = new Map(body.items.map((i) => [i.id, i]));
const byId = new Map(body.items.map((i) => [i.id, i]));
assert.ok(byId.has(runningOld) && byId.get(runningOld).bucket === 'running', 'an old running task is still running');
assert.ok(byId.has(runningOld) && byId.get(runningOld).bucket === 'running', 'an old running task is still running');
@@ -115,8 +115,9 @@ const DAY = 86_400_000;
@@ -115,8 +115,9 @@ const DAY = 86_400_000;
assert.deepStrictEqual([byId.get(succeeded).bucket, byId.get(succeeded).cost_usd, byId.get(succeeded).steps, byId.get(succeeded).actions], ['done', 0.12, 2, ['open']]);
assert.deepStrictEqual([byId.get(succeeded).bucket, byId.get(succeeded).cost_usd, byId.get(succeeded).steps, byId.get(succeeded).actions], ['done', 0.12, 2, ['open']]);
assert.strictEqual(byId.get(succeeded).title, 'Fixed the bug');
assert.strictEqual(byId.get(succeeded).title, 'Fixed the bug');
assert.strictEqual(byId.get(succeeded).summary, 'All done & shipped.');
assert.strictEqual(byId.get(succeeded).summary, 'All done & shipped.');
- assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).error, byId.get(failed).actions], ['failed', 'it broke', ['open']]);
- assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open']]);
+ assert.deepStrictEqual([byId.get(failed).bucket, byId.get(failed).run_error.code, byId.get(failed).actions], ['failed', 'task.failed', ['open', 'retry']]);
+ assert.ok(!JSON.stringify(byId.get(failed)).includes('it broke'), 'raw failure detail stays out of the list');
+ assert.deepStrictEqual([byId.get(cancelled).bucket, byId.get(cancelled).actions], ['failed', ['open', 'retry']]);
assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');
assert.ok(byId.has(approval) && byId.get(approval).bucket === 'waiting', 'the pending approval waits');
assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);
assert.deepStrictEqual(byId.get(approval).actions, ['approve', 'decline', 'open']);
// The kind in plain words, and the agent by its name (never its raw id).
// The kind in plain words, and the agent by its name (never its raw id).
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
@@ -13,7 +13,7 @@ const contracts = require('openvibe-contracts');
const configLib = require('../server/config');
const configLib = require('../server/config');
const { boot, check, done } = require('./helpers/boot');
const { boot, check, done } = require('./helpers/boot');
-const CAPS = ['actor.task.create', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
+const CAPS = ['actor.task.create', 'actor.skill.run', 'actor.task.read', 'actor.task.list', 'actor.task.view_ticket', 'actor.agent.read', 'actor.resource.read', 'actor.resource.control', 'actor.agent.manage', 'actor.agent.run', 'actor.actions.read', 'actor.approval.decide', 'actor.a2a.task', 'actor.model.manage', 'actor.config.manage', 'actor.timeline.read', 'actor.assignment.read', 'actor.assignment.cancel'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
const INDEX_ROUTES = ['GET /api/v1/resources', 'GET /api/v1/resources/:ovrn'];
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// Routes added after the pinned contracts release but covered by an existing grant (or public on purpose). The trace is
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
// the task's own read, so actor.task.read decides it exactly as it decides GET /api/v1/tasks/:id. The live-stats reads
@@ -130,7 +130,7 @@ const RULES = [
@@ -130,7 +130,7 @@ const RULES = [
const res = await t.get('/api/v1/skills');
const res = await t.get('/api/v1/skills');
assert.strictEqual(res.status, 200, res.text);
assert.strictEqual(res.status, 200, res.text);
const listings = res.json().listings;
const listings = res.json().listings;
- assert.strictEqual(listings.length, 4, 'the four skills of the repository');
+ assert.strictEqual(listings.length, 6, 'the six starter skills of the repository');
for (const listing of listings) {
for (const listing of listings) {
assert.deepStrictEqual(contracts.validate('actor.skill-listing@1', listing).errors, [], 'the listing is the contract');
assert.deepStrictEqual(contracts.validate('actor.skill-listing@1', listing).errors, [], 'the listing is the contract');
assert.deepStrictEqual(contracts.validate('actor.skill@1', listing.skill).errors, [], 'the document inside it is the contract');
assert.deepStrictEqual(contracts.validate('actor.skill@1', listing.skill).errors, [], 'the document inside it is the contract');
@@ -139,10 +139,26 @@ const RULES = [
@@ -139,10 +139,26 @@ const RULES = [
assert.strictEqual(listing.curator.state, 'active');
assert.strictEqual(listing.curator.state, 'active');
}
}
const slugs = listings.map((l) => l.skill.slug).sort();
const slugs = listings.map((l) => l.skill.slug).sort();
- assert.deepStrictEqual(slugs, ['changelog-draft', 'price-watch-goal', 'research-brief', 'stream-recording-summary']);
+ assert.deepStrictEqual(slugs, ['changelog-draft', 'image-create', 'price-watch-goal', 'research-brief', 'stream-recording-summary', 'wiki-article']);
listingId = listings.find((l) => l.skill.slug === 'research-brief').id;
listingId = listings.find((l) => l.skill.slug === 'research-brief').id;
});
});
+ await check('one catalogue skill starts a normal task with its label and subject retained for the Dock', async () => {
+ const res = await t.get('/api/v1/runs', { as: alice, json: { skill: listingId, input: { title: 'Local transport' }, choice: { mode: 'cheapest' } }, headers: SAME });
+ assert.strictEqual(res.status, 201, res.text);
+ const run = res.json().run;
+ assert.strictEqual(run.skill, 'research-brief');
+ const row = await t.ctx.s.db.maybe('SELECT skill_slug, skill_name, skill_subject_title FROM tasks WHERE id = $1', [run.id]);
+ assert.deepStrictEqual([row.skill_slug, row.skill_name, row.skill_subject_title], ['research-brief', 'A research brief, with its sources', 'Local transport']);
+ const pending = await t.get(`/api/v1/tasks/${run.id}/retry`, { as: alice, json: {}, headers: SAME });
+ assert.strictEqual(pending.status, 409, 'an active task cannot run twice from Retry');
+ await t.ctx.s.db.query("UPDATE tasks SET state = 'failed', finished_at = $2 WHERE id = $1", [run.id, t.ctx.s.iso()]);
+ const retried = await t.get(`/api/v1/tasks/${run.id}/retry`, { as: alice, json: { mode: 'best' }, headers: SAME });
+ assert.strictEqual(retried.status, 201, retried.text);
+ const next = await t.ctx.s.db.maybe('SELECT skill_slug, skill_subject_title, mode FROM tasks WHERE id = $1', [retried.json().run.id]);
+ assert.deepStrictEqual([next.skill_slug, next.skill_subject_title, next.mode], ['research-brief', 'Local transport', 'best']);
+ });
+
await check('a skill\'s page shows its instructions, what it may ask for in plain words, and the scan\'s words', async () => {
await check('a skill\'s page shows its instructions, what it may ask for in plain words, and the scan\'s words', async () => {
const list = await t.get('/skills');
const list = await t.get('/skills');
assert.strictEqual(list.status, 200, list.text);
assert.strictEqual(list.status, 200, list.text);