openvibers/openvibe.watch Commits
Watch: the codes-run action starts a coding task on the owner's own machine (OpenVibe.Codes) (#55)
Changes
@@@ -229,7 +229,7 @@ A fired condition queues the watch's actions in the same transaction as the chec| `actor-task` | a task for the owner on OpenVibe.Actor ([server/actor.js](server/actor.js)): the action's `brief`, followed by the firing as context (the watch, the value and what it was, its page), at most `budget_usd`. Watch names the owner in `X-OV-Subject`, so the task runs under **the owner's** allowance and appears in their task list, and Actor records Watch as `via`. The idempotency key `watch.<check run>.<action index>` makes a retried delivery the same task. Created is `sent`, with the task id recorded. A request Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once. Anything else, including a day's allowance spent, is retried on the backoff. An agent- or service-owned watch is `skipped`. Needs `actor.task.create` for `openvibe.actor` (Network grants it) and `WATCH_ACTOR_URL` || `actor-agent` | runs one of the person's OpenVibe.Actor agents by `agent_id`. Its optional `note` precedes the firing context (watch name and link, value and previous value, observation time); input is capped at 8000 characters without cutting the note. Watch uses `X-OV-Subject` for the owner. A created run is `sent` with its task id; a missing agent, limit or free-tier refusal is `failed` for that firing; service errors retry on the usual backoff. Only person-owned watches may use it. Needs the `actor.agent.run` grant for `openvibe.actor` and `WATCH_ACTOR_URL` || `run-job` | one OpenVibe.Run job for the owner in the watch's project ([server/run-jobs.js](server/run-jobs.js)): the action's `run.job-create-request@1` fields (`class`, `artifact`, `args`, `limits`, `egress`, `ttl_ms`), with the firing (watch, check run, value, previous value, time) under `args[firing_arg]` when the action names one. Watch submits as itself with the owner in `X-OV-Subject` and the project in `X-OV-Project` (project delegation, ADR-003 amendment 2026-10-12): Run bills the project, checks the owner is still an admin or owner of it, makes the owner the requester and records Watch as `via`. Key `watch.<check run>.<action index>`, so a retry is the same job. Created is `sent` with the job id; Run's final refusals (400, 403, 404, 409, 413, 422) are `failed` at once; anything else retries on the backoff. `WATCH_RUN_URL` is Run's internal address (default `http://127.0.0.1:4920`). |--| `codes-run` | recorded `unavailable` (`watch.action_unavailable`): its dispatcher is a later step, and the watch page says so |++| `codes-run` | a coding task on OpenVibe.Codes for the owner, run on the owner's own machine ([server/codes-tasks.js](server/codes-tasks.js), Contracts 0.162 `codes.task.start`): the action's `brief` with the firing as context (at most 8000 characters), in the `repo` it names. Watch starts it as itself with the owner in `X-OV-Subject`; Codes runs it in the project of the owner's latest openvibe.codes/run task and lists it on their /run page. Key `watch.<check run>.<action index>`, so a retry is the same task. Created is `sent` with the task's page; a refusal a retry cannot change (400, 403, 404, 409 — `codes.no_project` when the owner has never started a task on /run — 413, 422) is `failed` at once; anything else retries on the backoff. A person must own the watch: otherwise `skipped`. The create and edit forms offer it ("Start a coding task on my own machine"). `WATCH_CODES_URL` is Codes' internal address (default `http://127.0.0.1:4900`). |A watch with a `run-job` action names a **project** (`project_id` on `watch.watch-request@1`, required for it:`422 watch.project_required`). Only a person names one, and only a project OpenVibe.Network says they are an admin or@@@ -47,8 +47,14 @@* 413, 422: an invalid job, a project the owner may no longer use, over the project's caps) is* `failed` at once; anything else, Run or Network unreachable included, is retried on the backoff.* It needs a person as owner and a project: otherwise `skipped` / `unavailable`.-- * codes-run `unavailable` with `watch.action_unavailable`: its dispatcher is a later step of the plan, and the-- * watch page says so rather than pretending it ran.++ * codes-run a coding task on OpenVibe.Codes for the owner, on the owner's own machine (server/codes-tasks.js):++ * the action's brief with the firing as context, in the repository it names. Watch starts it as itself++ * with the owner in X-OV-Subject (Contracts 0.162 codes.task.start); the key++ * `watch.<check run>.<action index>` makes a retried delivery the same task. Created (or the same task++ * again) is `sent` with the task's page. A refusal a retry cannot change (400, 403, 404, 409 — the owner++ * has not used openvibe.codes/run yet, codes.no_project — 413, 422) is `failed` at once; anything++ * else is retried on the backoff. It needs a person as owner: an agent- or service-owned watch is++ * `skipped`.** The worker claims due rows with a short lease (FOR UPDATE SKIP LOCKED, next_attempt_at moved past the lease), so* two processes never deliver one row at the same time, and a process that dies mid-delivery leaves the row to the@@@ -64,7 +70,7 @@ const BACKOFF_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000]const LEASE_MS = 2 * 60_000;const TITLE_MAX = 120;const MESSAGE_MAX = 300;--const LATER = new Set(['codes-run']);++const LATER = new Set();// Run's answers that a retry cannot change: the job is invalid, over the project's caps, or the owner may no longer// act in the project (403 project.not_delegated / capability.denied).const RUN_FINAL = new Set([400, 403, 404, 409, 413, 422]);@@@ -124,6 +130,13 @@ function actorTaskFor({ config, watch, action, value, previousValue, observedAtreturn `${brief.slice(0, TASK_MAX - context.length - 2)}\n\n${context}`;}++/** A codes-run action's prompt: the brief, then the firing (at most Codes' 8000 characters). */++function codesTaskFor({ config, watch, action, value, previousValue, observedAt }) {++ const context = actorContextFor({ config, watch, value, previousValue, observedAt }, 1500);++ const brief = String(action.brief || '').trim();++ return `${brief.slice(0, 8000 - context.length - 2)}\n\n${context}`;++}++function actorContextFor({ config, watch, value, previousValue, observedAt }, maxLength = Infinity) {const now = valueText(value);const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);@@@ -164,7 +177,7 @@ function actorAgentInputFor(args) {return prefix + actorContextFor(args, 8000 - prefix.length);}--function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, now = () => Date.now(), log = console }) {++function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, now = () => Date.now(), log = console }) {// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.const grants = () => config.secretGrants;const enabled = Boolean(notifications) && config.actions.enabled;@@@ -216,6 +229,9 @@ function createActions({ db, config, notifications = null, post = null, actor =if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = 'watch.action_no_person: a Run job is started for a person in their project, and this watch belongs to an agent or a service'; }else if (!PROJECT_ID.test(String(watch.project_id || ''))) { state = 'unavailable'; error = 'watch.project_required: a run-job action is billed to the watch\'s project, and this watch names none'; }else request = { project_id: watch.project_id, job: runJobFor({ config, watch, run, action, value, previousValue, observedAt }) };++ } else if (kind === 'codes-run') {++ if (USER_SUBJECT.test(String(owner || ''))) request = { repo: String(action.repo || ''), prompt: codesTaskFor({ config, watch, action, value, previousValue, observedAt }) };++ else { state = 'skipped'; error = 'watch.action_no_person: a coding task runs on a person\'s own machine, and this watch belongs to an agent or a service'; }} else if (LATER.has(kind)) {state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;} else {@@@ -295,12 +311,24 @@ function createActions({ db, config, notifications = null, post = null, actor =}}++ async function deliverCodesRun(row, request) {++ if (!codes) throw Object.assign(new Error('Codes delivery is not configured in this process'), { code: 'codes_unconfigured' });++ try {++ const task = await codes.start({ subjectId: row.owner_sub, repo: request.repo, prompt: request.prompt, idempotencyKey: `watch.${row.check_run_id}.${row.action_index}` });++ return { state: 'sent', error: `Codes task ${task.id || '(no id)'}${task.url ? ` ${task.url}` : ''}${task.created ? '' : ' (already started)'}` };++ } catch (err) {++ if (err && RUN_FINAL.has(err.status)) return { state: 'failed', error: `watch.action_refused: ${err.code || err.status}: ${String(err.detail || err.message || '').slice(0, 200)}` };++ throw err;++ }++ }++async function deliver(row, attempt) {const request = typeof row.request === 'string' ? JSON.parse(row.request) : row.request;if (row.kind === 'webhook') return await deliverWebhook(row, request, attempt);if (row.kind === 'actor-task') return await deliverActorTask(row, request);if (row.kind === 'actor-agent') return await deliverActorAgent(row, request);if (row.kind === 'run-job') return await deliverRunJob(row, request);++ if (row.kind === 'codes-run') return await deliverCodesRun(row, request);if (row.kind !== 'notification') return { state: 'unavailable', error: `watch.action_unavailable: ${row.kind}` };const out = await notifications.push({ subjectId: row.owner_sub, ...request });if (out.sent) return { state: 'sent', error: out.skipped ? 'delivered; the person switched this category off' : null };nnew file mode 100644@@@ -0,0 +1,29 @@++'use strict';++/**++ * The codes-run action (plan T18 actions, T16): a coding task on OpenVibe.Codes for the watch's owner, run on the++ * owner's own machine (openvibe.codes/run through OpenVibe.Run).++ *++ * const codes = createCodesTasks(client, { baseUrl }); // client: openvibe-sdk/core createClient with Watch's token++ * await codes.start({ subjectId, repo, prompt, idempotencyKey }) → { id, state, url, created }++ *++ * start() POSTs a codes.task-start-request@1 body to Codes (Contracts 0.162 codes.task.start, audience openvibe.codes)++ * with Watch's own token and the owner in X-OV-Subject. Codes starts it in the project of the owner's latest /run task,++ * shows Watch only the tasks Watch started, and lists it on the owner's /run page. The idempotency key makes a retried++ * delivery the same task. The call is made once; the action worker retries on its own backoff. Refusals arrive as++ * openvibe-sdk OpenVibeError (status, code).++ */++++function createCodesTasks(client, { baseUrl }) {++ async function start({ subjectId, repo, prompt, idempotencyKey }) {++ const out = await client.json({++ service: 'codes', baseUrl, audience: 'openvibe.codes', method: 'POST', path: '/api/v1/tasks',++ headers: { 'X-OV-Subject': subjectId }, json: { repo, prompt, idempotency_key: idempotencyKey },++ retries: 0, idempotencyKey: false,++ });++ const t = (out && out.task) || {};++ return { id: t.id || null, state: t.state || null, url: t.url || null, created: !!(out && out.created) };++ }++ return { start };++}++++module.exports = { createCodesTasks };@@@ -103,6 +103,8 @@ function load(env = process.env) {actorUrl: (env.WATCH_ACTOR_URL || 'http://127.0.0.1:4950').replace(/\/+$/, ''),// OpenVibe.Run's internal address, for run-job actions (a job for the owner in the watch's project).runUrl: (env.WATCH_RUN_URL || 'http://127.0.0.1:4920').replace(/\/+$/, ''),++ // OpenVibe.Codes' internal address, for codes-run actions (a coding task on the owner's own machine).++ codesUrl: (env.WATCH_CODES_URL || 'http://127.0.0.1:4900').replace(/\/+$/, ''),},// Who may use which named secret (server/secret-grants.js): a WATCH_CRED_* credential (owners and the origins@@@ -121,7 +121,7 @@ function createPageRoutes(ctx) {template, name: '', url: '', kind: t.kind,extraction: t.extraction, selector: '', value_path: t.valuePath || '',op: t.defaultOp || t.ops[0], value: '', for_sec: '',-- cadence: '3600', notify: 'on', agent_on: '', agent_id: '', agent_note: '',++ cadence: '3600', notify: 'on', agent_on: '', agent_id: '', agent_note: '', codes_on: '', codes_repo: '', codes_brief: '',};}@@@ -133,6 +133,7 @@ function createPageRoutes(ctx) {const fields = { ...blankFields(template), ...(body || {}) };fields.notify = body && body.notify === 'on' ? 'on' : '';fields.agent_on = body && body.agent_on === 'on' ? 'on' : '';++ fields.codes_on = body && body.codes_on === 'on' ? 'on' : '';return fields;}@@@ -167,10 +168,14 @@ function createPageRoutes(ctx) {agent_on: agentAction(watch.action) ? 'on' : '',agent_id: (agentAction(watch.action) || {}).agent_id || '',agent_note: (agentAction(watch.action) || {}).note || '',++ codes_on: codesAction(watch.action) ? 'on' : '',++ codes_repo: (codesAction(watch.action) || {}).repo || '',++ codes_brief: (codesAction(watch.action) || {}).brief || '',};}const notifyOn = (action) => Array.isArray(action) && action.some((a) => a && a.kind === 'notification');const agentAction = (action) => Array.isArray(action) ? action.find((a) => a && a.kind === 'actor-agent') : null;++ const codesAction = (action) => Array.isArray(action) ? action.find((a) => a && a.kind === 'codes-run') : null;function actionsFrom(fields, errors) {const action = fields.notify === 'on' ? [...NOTIFY_ACTION] : [];@@@ -181,6 +186,16 @@ function createPageRoutes(ctx) {if (note.length > 2000) errors.agent_note = 'The note can be at most 2000 characters.';action.push({ kind: 'actor-agent', agent_id: agentId, ...(note ? { note } : {}) });}++ if (fields.codes_on === 'on') {++ const repo = String(fields.codes_repo || '').trim();++ const brief = String(fields.codes_brief || '').trim();++ let ok = false;++ try { const u = new URL(repo); ok = u.protocol === 'https:' && !u.username && !u.password && u.pathname.split('/').filter(Boolean).length >= 2; } catch { ok = false; }++ if (!ok || repo.length > 300) errors.codes_repo = 'Give the repository\'s https address, for example https://github.com/you/project.';++ if (!brief) errors.codes_brief = 'Say what the coding task should do when the watch fires.';++ else if (brief.length > 6000) errors.codes_brief = 'The task can be at most 6000 characters.';++ action.push({ kind: 'codes-run', repo, brief });++ }return action.length ? action : QUIET_ACTION;}@@@ -384,9 +399,12 @@ ${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify =${checkbox('Run one of my agents on OpenVibe.Actor', { name: 'agent_on', checked: fields.agent_on === 'on', hint: 'When the condition fires, Watch gives your agent the value and a link to this watch.' })}${field('Agent id', html`<input type="text" id="${idOf('agent_id')}" name="agent_id" value="${fields.agent_id}" maxlength="30" placeholder="agt_…">`, { name: 'agent_id', error: errors.agent_id, hint: 'Find the id on your agent’s OpenVibe.Actor page. Required when running an agent.' })}${field('Note for the agent (optional)', html`<textarea id="${idOf('agent_note')}" name="agent_note" maxlength="2000" rows="3">${fields.agent_note}</textarea>`, { name: 'agent_note', error: errors.agent_note })}++${checkbox('Start a coding task on my own machine (OpenVibe.Codes)', { name: 'codes_on', checked: fields.codes_on === 'on', hint: 'When the condition fires, OpenVibe.Codes runs the task on your machine (add it on openvibe.bot/machines and start one task on openvibe.codes/run first), with the value and a link to this watch. You get the summary and the patch on its page.' })}++${field('Repository', html`<input type="url" id="${idOf('codes_repo')}" name="codes_repo" value="${fields.codes_repo}" maxlength="300" placeholder="https://github.com/you/project">`, { name: 'codes_repo', error: errors.codes_repo })}++${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="codes_brief" maxlength="6000" rows="3" placeholder="The dependency in the value changed: update it and run the tests.">${fields.codes_brief}</textarea>`, { name: 'codes_brief', error: errors.codes_brief })}</fieldset><p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>--<p class="muted small">Notifications and Actor agents are delivered when the watch fires. Codes and Run actions are later steps.</p>++<p class="muted small">Notifications, Actor agents and coding tasks are delivered when the watch fires.</p></form>`;}@@@ -595,7 +613,9 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Createconst acts = actions ? await actions.recent(row.id, 10) : [];const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`-- : html`<div><code>${a.kind}</code></div>`);++ : a.kind === 'codes-run'++ ? html`<div>Coding task on <code>${a.repo}</code> (OpenVibe.Codes, on your machine) — ${String(a.brief || '').slice(0, 200)}</div>`++ : html`<div><code>${a.kind}</code></div>`);const obsRows = obs.map((o) => [time(new Date(Number(o.observed_at)).toISOString()),@@@ -800,6 +820,7 @@ ${watchForm({ template: fields.template, fields, errors: {}, action: `/watches/$const fields = { ...fieldsOfWatch(registry.view(row)), ...(req.body || {}) };fields.notify = req.body && req.body.notify === 'on' ? 'on' : '';fields.agent_on = req.body && req.body.agent_on === 'on' ? 'on' : '';++ fields.codes_on = req.body && req.body.codes_on === 'on' ? 'on' : '';const built = buildBody(fields, template);let errors = built.errors;if (!errors) {@@@ -17,6 +17,7 @@ const { createGuard } = require('./net/guard');const { createPoster } = require('./net/post');const { createActorTasks } = require('./actor');const { createProjects, createRunJobs } = require('./run-jobs');++const { createCodesTasks } = require('./codes-tasks');const { createFetcher } = require('./net/fetcher');const { createSpacer } = require('./spacer');const { createCarriers } = require('./carriers');@@@ -41,7 +42,7 @@ const { createApp } = require('./app');const accountDataLib = require('./account-data');const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');--async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, projects: givenProjects = null } = {}) {++async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null } = {}) {config = config || load();// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.const db = givenDb || await openDb(config, { log });@@@ -59,7 +60,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetchconst client = config.oauth.clientSecret? createClient({tokenProvider: createServiceTokenClient({ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }),-- baseUrls: { network: config.networkInternalUrl, actor: config.actions.actorUrl, run: config.actions.runUrl }, fetch: fetchImpl,++ baseUrls: { network: config.networkInternalUrl, actor: config.actions.actorUrl, run: config.actions.runUrl, codes: config.actions.codesUrl }, fetch: fetchImpl,}): null;// A watch that names a project: Network says whether its owner is an admin or owner of it, when it is saved (Run checks@@@ -79,7 +80,8 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch// Webhook actions leave through the same guard as every fetch (server/net/post.js).const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;-- const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, now, log });++ const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;++ const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, now, log });const check = createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, actions, now, log });const scheduler = createScheduler({ db, check, config, now, log });// Event sources (plan T18 step 5): watches woken by OpenVibe.Events events, with their subscriptions kept in step.@@@ -62,6 +62,17 @@ const actor = {return { id: 'tsk_AGENT', state: 'queued' };},};++// OpenVibe.Codes, as server/codes-tasks.js would reach it: every start recorded; codesMode decides the answer.++const codesStarts = [];++let codesMode = 'ok';++const codes = {++ async start(req) {++ codesStarts.push(req);++ if (codesMode === 'no_project') throw Object.assign(new Error('no /run task yet'), { status: 409, code: 'codes.no_project', detail: 'this person has not run a task on openvibe.codes/run yet' });++ if (codesMode === 'busy') throw Object.assign(new Error('unavailable'), { status: 503, code: 'run.unavailable' });++ return { id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNCD', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_01JAB2C3D4E5F6G7H8J9K0MNCD', created: codesStarts.length === 1 };++ },++};const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';const def = (overrides = {}) => ({name: 'Widget price',@@@ -92,7 +103,7 @@ t('boot', async () => {'/set': (req, _res, ctx) => { price = ctx.url.searchParams.get('v') || price; return { body: 'ok' }; },});svc = await boot({-- notifications, post, actor,++ notifications, post, actor, codes,env: {WATCH_HOOK_TEST: HOOK_KEY, WATCH_HOOK_SHORT: 'too-short', WATCH_HOOK_THEIRS: HOOK_KEY,WATCH_SECRET_GRANTS: JSON.stringify({@@@ -104,7 +115,7 @@ t('boot', async () => {t('a check that does not fire queues nothing; one that fires queues each action once, with its run', async () => {mode = 'ok'; pushes.length = 0;-- const w = await create(person, { action: [{ kind: 'notification', priority: 'high' }, { kind: 'codes-run', repo: 'OpenVibers/OpenVibe.Watch', brief: 'look into it' }] });++ const w = await create(person, { action: [{ kind: 'notification', priority: 'high' }, { kind: 'event', event_type: 'watch.something.else' }] });await setPrice('19.99');await check(w.id);assert.deepStrictEqual(await acts(w.id), [], 'no trigger, no action');@@@ -112,7 +123,7 @@ t('a check that does not fire queues nothing; one that fires queues each actionconst out = await check(w.id);const rows = await acts(w.id);assert.deepStrictEqual(rows.map((r) => [r.kind, r.state, r.check_run_id, Number(r.action_index)]),-- [['notification', 'queued', out.run.id, 0], ['codes-run', 'unavailable', out.run.id, 1]]);++ [['notification', 'queued', out.run.id, 0], ['event', 'unavailable', out.run.id, 1]]);assert.match(rows[1].last_error, /watch\.action_unavailable/);const r = await svc.actions.tick();@@@ -315,6 +326,55 @@ t('an actor-task action gives Actor a task for the owner, with the firing as conassert.strictEqual(tasks.length, before, 'no task for an agent-owned watch');});++t('a codes-run action starts a coding task for the owner on Codes, with the firing as context and one idempotency key', async () => {++ codesStarts.length = 0; codesMode = 'busy';++ const w = await create(person, { name: 'dependency bump', action: [{ kind: 'codes-run', repo: 'https://github.com/OpenVibers/OpenVibe.Watch', brief: 'Update the dependency and run the tests.' }] });++ const out = await fire(w);++ let r = await svc.actions.tick();++ assert.deepStrictEqual(r, { sent: 0, skipped: 0, retried: 1, failed: 0 }, 'a busy Codes is retried');++ codesMode = 'ok';++ await svc.db.prepare('UPDATE action_runs SET next_attempt_at = 0 WHERE watch_id = ?').run(w.id);++ r = await svc.actions.tick();++ assert.deepStrictEqual(r, { sent: 1, skipped: 0, retried: 0, failed: 0 });++ const [first, second] = codesStarts;++ assert.strictEqual(first.subjectId, person.sub);++ assert.strictEqual(first.repo, 'https://github.com/OpenVibers/OpenVibe.Watch');++ assert.strictEqual(first.idempotencyKey, `watch.${out.run.id}.0`);++ assert.strictEqual(second.idempotencyKey, first.idempotencyKey, 'the retry is the same task');++ assert.match(first.prompt, /^Update the dependency and run the tests\.\n\nContext: your watch “dependency bump” fired at /);++ assert.ok(first.prompt.length <= 8000);++ const [row] = await acts(w.id);++ assert.deepStrictEqual([row.state, row.last_error], ['sent', 'Codes task tsk_01JAB2C3D4E5F6G7H8J9K0MNCD https://openvibe.codes/run/tasks/tsk_01JAB2C3D4E5F6G7H8J9K0MNCD (already started)']);++++ codesMode = 'no_project';++ const fresh = await create(person, { name: 'no project yet', action: [{ kind: 'codes-run', repo: 'https://github.com/a/b', brief: 'x' }] });++ await fire(fresh);++ r = await svc.actions.tick();++ assert.deepStrictEqual(r, { sent: 0, skipped: 0, retried: 0, failed: 1 }, 'no /run task yet fails at once');++ assert.match((await acts(fresh.id))[0].last_error, /watch\.action_refused: codes\.no_project/);++ codesMode = 'ok';++++ const before = codesStarts.length;++ const bot = await create(agent, { name: 'agent coding', action: [{ kind: 'codes-run', repo: 'https://github.com/a/b', brief: 'x' }] });++ await fire(bot);++ const [b] = await acts(bot.id);++ assert.deepStrictEqual([b.state, /action_no_person/.test(b.last_error)], ['skipped', true]);++ await svc.actions.tick();++ assert.strictEqual(codesStarts.length, before, 'no task for an agent-owned watch');++});++++t('server/codes-tasks.js asks Codes as Watch, for the owner, once', async () => {++ const { createCodesTasks } = require('../server/codes-tasks');++ const calls = [];++ const client = { async json(o) { calls.push(o); return { task: { id: 'tsk_X', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_X' }, created: true }; } };++ const out = await createCodesTasks(client, { baseUrl: 'http://127.0.0.1:4900' }).start({ subjectId: person.sub, repo: 'https://github.com/a/b', prompt: 'do it', idempotencyKey: 'watch.r.0' });++ assert.deepStrictEqual(out, { id: 'tsk_X', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_X', created: true });++ const c = calls[0];++ assert.deepStrictEqual([c.service, c.baseUrl, c.audience, c.method, c.path, c.retries], ['codes', 'http://127.0.0.1:4900', 'openvibe.codes', 'POST', '/api/v1/tasks', 0]);++ assert.deepStrictEqual(c.headers, { 'X-OV-Subject': person.sub });++ assert.deepStrictEqual(c.json, { repo: 'https://github.com/a/b', prompt: 'do it', idempotency_key: 'watch.r.0' });++});++t('server/actor.js asks Actor as Watch, for the owner, once', async () => {const { createActorTasks } = require('../server/actor');const calls = [];@@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).*/--async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, projects = null } = {}) {++async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null } = {}) {// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real// to fetch and verify against (no test touches the internet). Its URL is overridable via env.const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });@@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l});// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.const testdb = await require('./db').testDb();-- const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(projects ? { projects } : {}), ...(now ? { now } : {}) });++ const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(now ? { now } : {}) });const base = `http://127.0.0.1:${h.server.address().port}`;/** A session cookie for a person, as /auth/callback would have set it. */@@@ -177,6 +177,25 @@ t('the plain create and edit forms save an Actor agent action', async () => {assert.strictEqual(updated.body.watch.action[0].note, 'New note.');});++t('the plain create and edit forms save a coding task on the owner\'s machine (OpenVibe.Codes)', async () => {++ const form = await svc.get('/watches/new?template=page', { cookie: session });++ assert.match(form.text, /Start a coding task on my own machine/);++ const bad = await create({ template: 'page', url: `${web.origin}/price`, cadence: '3600', codes_on: 'on', codes_repo: '[email protected]:a/b.git', codes_brief: '' });++ assert.strictEqual(bad.status, 422);++ assert.match(bad.text, /https address/);++ assert.match(bad.text, /Say what the coding task should do/);++ const made = await create({ template: 'page', url: `${web.origin}/price`, cadence: '3600', codes_on: 'on', codes_repo: 'https://github.com/you/project', codes_brief: 'Update it and run the tests.' });++ assert.strictEqual(made.status, 303);++ const id = indexOf(made);++ const view = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME });++ assert.deepStrictEqual(view.body.watch.action, [{ kind: 'codes-run', repo: 'https://github.com/you/project', brief: 'Update it and run the tests.' }]);++ const page = await svc.get(`/watches/${id}`, { cookie: session });++ assert.match(page.text, /Coding task on <code>https:\/\/github\.com\/you\/project<\/code>/);++ const edit = await svc.get(`/watches/${id}/edit`, { cookie: session });++ assert.match(edit.text, /name="codes_on"[^>]*checked/);++ assert.ok(edit.text.includes('Update it and run the tests.'));++});++t('validation errors re-render the form with the registry\'s message and the person\'s input', async () => {const badUrl = await create({ template: 'page', name: 'Keep me', url: 'not-a-url', selector: '.x', cadence: '3600' });assert.strictEqual(badUrl.status, 422);
@@ -229,7 +229,7 @@ A fired condition queues the watch's actions in the same transaction as the chec
@@ -229,7 +229,7 @@ A fired condition queues the watch's actions in the same transaction as the chec
| `actor-task` | a task for the owner on OpenVibe.Actor ([server/actor.js](server/actor.js)): the action's `brief`, followed by the firing as context (the watch, the value and what it was, its page), at most `budget_usd`. Watch names the owner in `X-OV-Subject`, so the task runs under **the owner's** allowance and appears in their task list, and Actor records Watch as `via`. The idempotency key `watch.<check run>.<action index>` makes a retried delivery the same task. Created is `sent`, with the task id recorded. A request Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once. Anything else, including a day's allowance spent, is retried on the backoff. An agent- or service-owned watch is `skipped`. Needs `actor.task.create` for `openvibe.actor` (Network grants it) and `WATCH_ACTOR_URL` |
| `actor-task` | a task for the owner on OpenVibe.Actor ([server/actor.js](server/actor.js)): the action's `brief`, followed by the firing as context (the watch, the value and what it was, its page), at most `budget_usd`. Watch names the owner in `X-OV-Subject`, so the task runs under **the owner's** allowance and appears in their task list, and Actor records Watch as `via`. The idempotency key `watch.<check run>.<action index>` makes a retried delivery the same task. Created is `sent`, with the task id recorded. A request Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once. Anything else, including a day's allowance spent, is retried on the backoff. An agent- or service-owned watch is `skipped`. Needs `actor.task.create` for `openvibe.actor` (Network grants it) and `WATCH_ACTOR_URL` |
| `actor-agent` | runs one of the person's OpenVibe.Actor agents by `agent_id`. Its optional `note` precedes the firing context (watch name and link, value and previous value, observation time); input is capped at 8000 characters without cutting the note. Watch uses `X-OV-Subject` for the owner. A created run is `sent` with its task id; a missing agent, limit or free-tier refusal is `failed` for that firing; service errors retry on the usual backoff. Only person-owned watches may use it. Needs the `actor.agent.run` grant for `openvibe.actor` and `WATCH_ACTOR_URL` |
| `actor-agent` | runs one of the person's OpenVibe.Actor agents by `agent_id`. Its optional `note` precedes the firing context (watch name and link, value and previous value, observation time); input is capped at 8000 characters without cutting the note. Watch uses `X-OV-Subject` for the owner. A created run is `sent` with its task id; a missing agent, limit or free-tier refusal is `failed` for that firing; service errors retry on the usual backoff. Only person-owned watches may use it. Needs the `actor.agent.run` grant for `openvibe.actor` and `WATCH_ACTOR_URL` |
| `run-job` | one OpenVibe.Run job for the owner in the watch's project ([server/run-jobs.js](server/run-jobs.js)): the action's `run.job-create-request@1` fields (`class`, `artifact`, `args`, `limits`, `egress`, `ttl_ms`), with the firing (watch, check run, value, previous value, time) under `args[firing_arg]` when the action names one. Watch submits as itself with the owner in `X-OV-Subject` and the project in `X-OV-Project` (project delegation, ADR-003 amendment 2026-10-12): Run bills the project, checks the owner is still an admin or owner of it, makes the owner the requester and records Watch as `via`. Key `watch.<check run>.<action index>`, so a retry is the same job. Created is `sent` with the job id; Run's final refusals (400, 403, 404, 409, 413, 422) are `failed` at once; anything else retries on the backoff. `WATCH_RUN_URL` is Run's internal address (default `http://127.0.0.1:4920`). |
| `run-job` | one OpenVibe.Run job for the owner in the watch's project ([server/run-jobs.js](server/run-jobs.js)): the action's `run.job-create-request@1` fields (`class`, `artifact`, `args`, `limits`, `egress`, `ttl_ms`), with the firing (watch, check run, value, previous value, time) under `args[firing_arg]` when the action names one. Watch submits as itself with the owner in `X-OV-Subject` and the project in `X-OV-Project` (project delegation, ADR-003 amendment 2026-10-12): Run bills the project, checks the owner is still an admin or owner of it, makes the owner the requester and records Watch as `via`. Key `watch.<check run>.<action index>`, so a retry is the same job. Created is `sent` with the job id; Run's final refusals (400, 403, 404, 409, 413, 422) are `failed` at once; anything else retries on the backoff. `WATCH_RUN_URL` is Run's internal address (default `http://127.0.0.1:4920`). |
-| `codes-run` | recorded `unavailable` (`watch.action_unavailable`): its dispatcher is a later step, and the watch page says so |
+| `codes-run` | a coding task on OpenVibe.Codes for the owner, run on the owner's own machine ([server/codes-tasks.js](server/codes-tasks.js), Contracts 0.162 `codes.task.start`): the action's `brief` with the firing as context (at most 8000 characters), in the `repo` it names. Watch starts it as itself with the owner in `X-OV-Subject`; Codes runs it in the project of the owner's latest openvibe.codes/run task and lists it on their /run page. Key `watch.<check run>.<action index>`, so a retry is the same task. Created is `sent` with the task's page; a refusal a retry cannot change (400, 403, 404, 409 — `codes.no_project` when the owner has never started a task on /run — 413, 422) is `failed` at once; anything else retries on the backoff. A person must own the watch: otherwise `skipped`. The create and edit forms offer it ("Start a coding task on my own machine"). `WATCH_CODES_URL` is Codes' internal address (default `http://127.0.0.1:4900`). |
A watch with a `run-job` action names a **project** (`project_id` on `watch.watch-request@1`, required for it:
A watch with a `run-job` action names a **project** (`project_id` on `watch.watch-request@1`, required for it:
`422 watch.project_required`). Only a person names one, and only a project OpenVibe.Network says they are an admin or
`422 watch.project_required`). Only a person names one, and only a project OpenVibe.Network says they are an admin or
@@ -47,8 +47,14 @@
@@ -47,8 +47,14 @@
* 413, 422: an invalid job, a project the owner may no longer use, over the project's caps) is
* 413, 422: an invalid job, a project the owner may no longer use, over the project's caps) is
* `failed` at once; anything else, Run or Network unreachable included, is retried on the backoff.
* `failed` at once; anything else, Run or Network unreachable included, is retried on the backoff.
* It needs a person as owner and a project: otherwise `skipped` / `unavailable`.
* It needs a person as owner and a project: otherwise `skipped` / `unavailable`.
- * codes-run `unavailable` with `watch.action_unavailable`: its dispatcher is a later step of the plan, and the
- * watch page says so rather than pretending it ran.
+ * codes-run a coding task on OpenVibe.Codes for the owner, on the owner's own machine (server/codes-tasks.js):
+ * the action's brief with the firing as context, in the repository it names. Watch starts it as itself
+ * with the owner in X-OV-Subject (Contracts 0.162 codes.task.start); the key
+ * `watch.<check run>.<action index>` makes a retried delivery the same task. Created (or the same task
+ * again) is `sent` with the task's page. A refusal a retry cannot change (400, 403, 404, 409 — the owner
+ * has not used openvibe.codes/run yet, codes.no_project — 413, 422) is `failed` at once; anything
+ * else is retried on the backoff. It needs a person as owner: an agent- or service-owned watch is
+ * `skipped`.
*
*
* The worker claims due rows with a short lease (FOR UPDATE SKIP LOCKED, next_attempt_at moved past the lease), so
* The worker claims due rows with a short lease (FOR UPDATE SKIP LOCKED, next_attempt_at moved past the lease), so
* two processes never deliver one row at the same time, and a process that dies mid-delivery leaves the row to the
* two processes never deliver one row at the same time, and a process that dies mid-delivery leaves the row to the
@@ -64,7 +70,7 @@ const BACKOFF_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000]
@@ -64,7 +70,7 @@ const BACKOFF_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000]
const LEASE_MS = 2 * 60_000;
const LEASE_MS = 2 * 60_000;
const TITLE_MAX = 120;
const TITLE_MAX = 120;
const MESSAGE_MAX = 300;
const MESSAGE_MAX = 300;
-const LATER = new Set(['codes-run']);
+const LATER = new Set();
// Run's answers that a retry cannot change: the job is invalid, over the project's caps, or the owner may no longer
// Run's answers that a retry cannot change: the job is invalid, over the project's caps, or the owner may no longer
// act in the project (403 project.not_delegated / capability.denied).
// act in the project (403 project.not_delegated / capability.denied).
const RUN_FINAL = new Set([400, 403, 404, 409, 413, 422]);
const RUN_FINAL = new Set([400, 403, 404, 409, 413, 422]);
@@ -124,6 +130,13 @@ function actorTaskFor({ config, watch, action, value, previousValue, observedAt
@@ -124,6 +130,13 @@ function actorTaskFor({ config, watch, action, value, previousValue, observedAt
return `${brief.slice(0, TASK_MAX - context.length - 2)}\n\n${context}`;
return `${brief.slice(0, TASK_MAX - context.length - 2)}\n\n${context}`;
}
}
+/** A codes-run action's prompt: the brief, then the firing (at most Codes' 8000 characters). */
+function codesTaskFor({ config, watch, action, value, previousValue, observedAt }) {
+ const context = actorContextFor({ config, watch, value, previousValue, observedAt }, 1500);
+ const brief = String(action.brief || '').trim();
+ return `${brief.slice(0, 8000 - context.length - 2)}\n\n${context}`;
+}
+
function actorContextFor({ config, watch, value, previousValue, observedAt }, maxLength = Infinity) {
function actorContextFor({ config, watch, value, previousValue, observedAt }, maxLength = Infinity) {
const now = valueText(value);
const now = valueText(value);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
@@ -164,7 +177,7 @@ function actorAgentInputFor(args) {
@@ -164,7 +177,7 @@ function actorAgentInputFor(args) {
return prefix + actorContextFor(args, 8000 - prefix.length);
return prefix + actorContextFor(args, 8000 - prefix.length);
}
}
-function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, now = () => Date.now(), log = console }) {
+function createActions({ db, config, notifications = null, post = null, actor = null, run: runJobs = null, codes = null, now = () => Date.now(), log = console }) {
// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.
// Read at each use, not once: what a process was started with is what it enforces, for rows queued before it too.
const grants = () => config.secretGrants;
const grants = () => config.secretGrants;
const enabled = Boolean(notifications) && config.actions.enabled;
const enabled = Boolean(notifications) && config.actions.enabled;
@@ -216,6 +229,9 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -216,6 +229,9 @@ function createActions({ db, config, notifications = null, post = null, actor =
if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = 'watch.action_no_person: a Run job is started for a person in their project, and this watch belongs to an agent or a service'; }
if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = 'watch.action_no_person: a Run job is started for a person in their project, and this watch belongs to an agent or a service'; }
else if (!PROJECT_ID.test(String(watch.project_id || ''))) { state = 'unavailable'; error = 'watch.project_required: a run-job action is billed to the watch\'s project, and this watch names none'; }
else if (!PROJECT_ID.test(String(watch.project_id || ''))) { state = 'unavailable'; error = 'watch.project_required: a run-job action is billed to the watch\'s project, and this watch names none'; }
else request = { project_id: watch.project_id, job: runJobFor({ config, watch, run, action, value, previousValue, observedAt }) };
else request = { project_id: watch.project_id, job: runJobFor({ config, watch, run, action, value, previousValue, observedAt }) };
+ } else if (kind === 'codes-run') {
+ if (USER_SUBJECT.test(String(owner || ''))) request = { repo: String(action.repo || ''), prompt: codesTaskFor({ config, watch, action, value, previousValue, observedAt }) };
+ else { state = 'skipped'; error = 'watch.action_no_person: a coding task runs on a person\'s own machine, and this watch belongs to an agent or a service'; }
} else if (LATER.has(kind)) {
} else if (LATER.has(kind)) {
state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;
state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;
} else {
} else {
@@ -295,12 +311,24 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -295,12 +311,24 @@ function createActions({ db, config, notifications = null, post = null, actor =
}
}
}
}
+ async function deliverCodesRun(row, request) {
+ if (!codes) throw Object.assign(new Error('Codes delivery is not configured in this process'), { code: 'codes_unconfigured' });
+ try {
+ const task = await codes.start({ subjectId: row.owner_sub, repo: request.repo, prompt: request.prompt, idempotencyKey: `watch.${row.check_run_id}.${row.action_index}` });
+ return { state: 'sent', error: `Codes task ${task.id || '(no id)'}${task.url ? ` ${task.url}` : ''}${task.created ? '' : ' (already started)'}` };
+ } catch (err) {
+ if (err && RUN_FINAL.has(err.status)) return { state: 'failed', error: `watch.action_refused: ${err.code || err.status}: ${String(err.detail || err.message || '').slice(0, 200)}` };
+ throw err;
+ }
+ }
+
async function deliver(row, attempt) {
async function deliver(row, attempt) {
const request = typeof row.request === 'string' ? JSON.parse(row.request) : row.request;
const request = typeof row.request === 'string' ? JSON.parse(row.request) : row.request;
if (row.kind === 'webhook') return await deliverWebhook(row, request, attempt);
if (row.kind === 'webhook') return await deliverWebhook(row, request, attempt);
if (row.kind === 'actor-task') return await deliverActorTask(row, request);
if (row.kind === 'actor-task') return await deliverActorTask(row, request);
if (row.kind === 'actor-agent') return await deliverActorAgent(row, request);
if (row.kind === 'actor-agent') return await deliverActorAgent(row, request);
if (row.kind === 'run-job') return await deliverRunJob(row, request);
if (row.kind === 'run-job') return await deliverRunJob(row, request);
+ if (row.kind === 'codes-run') return await deliverCodesRun(row, request);
if (row.kind !== 'notification') return { state: 'unavailable', error: `watch.action_unavailable: ${row.kind}` };
if (row.kind !== 'notification') return { state: 'unavailable', error: `watch.action_unavailable: ${row.kind}` };
const out = await notifications.push({ subjectId: row.owner_sub, ...request });
const out = await notifications.push({ subjectId: row.owner_sub, ...request });
if (out.sent) return { state: 'sent', error: out.skipped ? 'delivered; the person switched this category off' : null };
if (out.sent) return { state: 'sent', error: out.skipped ? 'delivered; the person switched this category off' : null };
new file mode 100644
new file mode 100644
@@ -0,0 +1,29 @@
@@ -0,0 +1,29 @@
+'use strict';
+/**
+ * The codes-run action (plan T18 actions, T16): a coding task on OpenVibe.Codes for the watch's owner, run on the
+ * owner's own machine (openvibe.codes/run through OpenVibe.Run).
+ *
+ * const codes = createCodesTasks(client, { baseUrl }); // client: openvibe-sdk/core createClient with Watch's token
+ * await codes.start({ subjectId, repo, prompt, idempotencyKey }) → { id, state, url, created }
+ *
+ * start() POSTs a codes.task-start-request@1 body to Codes (Contracts 0.162 codes.task.start, audience openvibe.codes)
+ * with Watch's own token and the owner in X-OV-Subject. Codes starts it in the project of the owner's latest /run task,
+ * shows Watch only the tasks Watch started, and lists it on the owner's /run page. The idempotency key makes a retried
+ * delivery the same task. The call is made once; the action worker retries on its own backoff. Refusals arrive as
+ * openvibe-sdk OpenVibeError (status, code).
+ */
+
+function createCodesTasks(client, { baseUrl }) {
+ async function start({ subjectId, repo, prompt, idempotencyKey }) {
+ const out = await client.json({
+ service: 'codes', baseUrl, audience: 'openvibe.codes', method: 'POST', path: '/api/v1/tasks',
+ headers: { 'X-OV-Subject': subjectId }, json: { repo, prompt, idempotency_key: idempotencyKey },
+ retries: 0, idempotencyKey: false,
+ });
+ const t = (out && out.task) || {};
+ return { id: t.id || null, state: t.state || null, url: t.url || null, created: !!(out && out.created) };
+ }
+ return { start };
+}
+
+module.exports = { createCodesTasks };
@@ -103,6 +103,8 @@ function load(env = process.env) {
@@ -103,6 +103,8 @@ function load(env = process.env) {
actorUrl: (env.WATCH_ACTOR_URL || 'http://127.0.0.1:4950').replace(/\/+$/, ''),
actorUrl: (env.WATCH_ACTOR_URL || 'http://127.0.0.1:4950').replace(/\/+$/, ''),
// OpenVibe.Run's internal address, for run-job actions (a job for the owner in the watch's project).
// OpenVibe.Run's internal address, for run-job actions (a job for the owner in the watch's project).
runUrl: (env.WATCH_RUN_URL || 'http://127.0.0.1:4920').replace(/\/+$/, ''),
runUrl: (env.WATCH_RUN_URL || 'http://127.0.0.1:4920').replace(/\/+$/, ''),
+ // OpenVibe.Codes' internal address, for codes-run actions (a coding task on the owner's own machine).
+ codesUrl: (env.WATCH_CODES_URL || 'http://127.0.0.1:4900').replace(/\/+$/, ''),
},
},
// Who may use which named secret (server/secret-grants.js): a WATCH_CRED_* credential (owners and the origins
// Who may use which named secret (server/secret-grants.js): a WATCH_CRED_* credential (owners and the origins
@@ -121,7 +121,7 @@ function createPageRoutes(ctx) {
@@ -121,7 +121,7 @@ function createPageRoutes(ctx) {
template, name: '', url: '', kind: t.kind,
template, name: '', url: '', kind: t.kind,
extraction: t.extraction, selector: '', value_path: t.valuePath || '',
extraction: t.extraction, selector: '', value_path: t.valuePath || '',
op: t.defaultOp || t.ops[0], value: '', for_sec: '',
op: t.defaultOp || t.ops[0], value: '', for_sec: '',
- cadence: '3600', notify: 'on', agent_on: '', agent_id: '', agent_note: '',
+ cadence: '3600', notify: 'on', agent_on: '', agent_id: '', agent_note: '', codes_on: '', codes_repo: '', codes_brief: '',
};
};
}
}
@@ -133,6 +133,7 @@ function createPageRoutes(ctx) {
@@ -133,6 +133,7 @@ function createPageRoutes(ctx) {
const fields = { ...blankFields(template), ...(body || {}) };
const fields = { ...blankFields(template), ...(body || {}) };
fields.notify = body && body.notify === 'on' ? 'on' : '';
fields.notify = body && body.notify === 'on' ? 'on' : '';
fields.agent_on = body && body.agent_on === 'on' ? 'on' : '';
fields.agent_on = body && body.agent_on === 'on' ? 'on' : '';
+ fields.codes_on = body && body.codes_on === 'on' ? 'on' : '';
return fields;
return fields;
}
}
@@ -167,10 +168,14 @@ function createPageRoutes(ctx) {
@@ -167,10 +168,14 @@ function createPageRoutes(ctx) {
agent_on: agentAction(watch.action) ? 'on' : '',
agent_on: agentAction(watch.action) ? 'on' : '',
agent_id: (agentAction(watch.action) || {}).agent_id || '',
agent_id: (agentAction(watch.action) || {}).agent_id || '',
agent_note: (agentAction(watch.action) || {}).note || '',
agent_note: (agentAction(watch.action) || {}).note || '',
+ codes_on: codesAction(watch.action) ? 'on' : '',
+ codes_repo: (codesAction(watch.action) || {}).repo || '',
+ codes_brief: (codesAction(watch.action) || {}).brief || '',
};
};
}
}
const notifyOn = (action) => Array.isArray(action) && action.some((a) => a && a.kind === 'notification');
const notifyOn = (action) => Array.isArray(action) && action.some((a) => a && a.kind === 'notification');
const agentAction = (action) => Array.isArray(action) ? action.find((a) => a && a.kind === 'actor-agent') : null;
const agentAction = (action) => Array.isArray(action) ? action.find((a) => a && a.kind === 'actor-agent') : null;
+ const codesAction = (action) => Array.isArray(action) ? action.find((a) => a && a.kind === 'codes-run') : null;
function actionsFrom(fields, errors) {
function actionsFrom(fields, errors) {
const action = fields.notify === 'on' ? [...NOTIFY_ACTION] : [];
const action = fields.notify === 'on' ? [...NOTIFY_ACTION] : [];
@@ -181,6 +186,16 @@ function createPageRoutes(ctx) {
@@ -181,6 +186,16 @@ function createPageRoutes(ctx) {
if (note.length > 2000) errors.agent_note = 'The note can be at most 2000 characters.';
if (note.length > 2000) errors.agent_note = 'The note can be at most 2000 characters.';
action.push({ kind: 'actor-agent', agent_id: agentId, ...(note ? { note } : {}) });
action.push({ kind: 'actor-agent', agent_id: agentId, ...(note ? { note } : {}) });
}
}
+ if (fields.codes_on === 'on') {
+ const repo = String(fields.codes_repo || '').trim();
+ const brief = String(fields.codes_brief || '').trim();
+ let ok = false;
+ try { const u = new URL(repo); ok = u.protocol === 'https:' && !u.username && !u.password && u.pathname.split('/').filter(Boolean).length >= 2; } catch { ok = false; }
+ if (!ok || repo.length > 300) errors.codes_repo = 'Give the repository\'s https address, for example https://github.com/you/project.';
+ if (!brief) errors.codes_brief = 'Say what the coding task should do when the watch fires.';
+ else if (brief.length > 6000) errors.codes_brief = 'The task can be at most 6000 characters.';
+ action.push({ kind: 'codes-run', repo, brief });
+ }
return action.length ? action : QUIET_ACTION;
return action.length ? action : QUIET_ACTION;
}
}
@@ -384,9 +399,12 @@ ${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify =
@@ -384,9 +399,12 @@ ${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify =
${checkbox('Run one of my agents on OpenVibe.Actor', { name: 'agent_on', checked: fields.agent_on === 'on', hint: 'When the condition fires, Watch gives your agent the value and a link to this watch.' })}
${checkbox('Run one of my agents on OpenVibe.Actor', { name: 'agent_on', checked: fields.agent_on === 'on', hint: 'When the condition fires, Watch gives your agent the value and a link to this watch.' })}
${field('Agent id', html`<input type="text" id="${idOf('agent_id')}" name="agent_id" value="${fields.agent_id}" maxlength="30" placeholder="agt_…">`, { name: 'agent_id', error: errors.agent_id, hint: 'Find the id on your agent’s OpenVibe.Actor page. Required when running an agent.' })}
${field('Agent id', html`<input type="text" id="${idOf('agent_id')}" name="agent_id" value="${fields.agent_id}" maxlength="30" placeholder="agt_…">`, { name: 'agent_id', error: errors.agent_id, hint: 'Find the id on your agent’s OpenVibe.Actor page. Required when running an agent.' })}
${field('Note for the agent (optional)', html`<textarea id="${idOf('agent_note')}" name="agent_note" maxlength="2000" rows="3">${fields.agent_note}</textarea>`, { name: 'agent_note', error: errors.agent_note })}
${field('Note for the agent (optional)', html`<textarea id="${idOf('agent_note')}" name="agent_note" maxlength="2000" rows="3">${fields.agent_note}</textarea>`, { name: 'agent_note', error: errors.agent_note })}
+${checkbox('Start a coding task on my own machine (OpenVibe.Codes)', { name: 'codes_on', checked: fields.codes_on === 'on', hint: 'When the condition fires, OpenVibe.Codes runs the task on your machine (add it on openvibe.bot/machines and start one task on openvibe.codes/run first), with the value and a link to this watch. You get the summary and the patch on its page.' })}
+${field('Repository', html`<input type="url" id="${idOf('codes_repo')}" name="codes_repo" value="${fields.codes_repo}" maxlength="300" placeholder="https://github.com/you/project">`, { name: 'codes_repo', error: errors.codes_repo })}
+${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="codes_brief" maxlength="6000" rows="3" placeholder="The dependency in the value changed: update it and run the tests.">${fields.codes_brief}</textarea>`, { name: 'codes_brief', error: errors.codes_brief })}
</fieldset>
</fieldset>
<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>
<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>
-<p class="muted small">Notifications and Actor agents are delivered when the watch fires. Codes and Run actions are later steps.</p>
+<p class="muted small">Notifications, Actor agents and coding tasks are delivered when the watch fires.</p>
</form>`;
</form>`;
}
}
@@ -595,7 +613,9 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
@@ -595,7 +613,9 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
const acts = actions ? await actions.recent(row.id, 10) : [];
const acts = actions ? await actions.recent(row.id, 10) : [];
const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'
const configuredActions = (v.action || []).map((a) => a.kind === 'actor-agent'
? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`
? html`<div>Run agent <a href="https://openvibe.actor/me/agents/${a.agent_id}"><code>${a.agent_id}</code></a>${a.note ? html` — ${a.note}` : ''}</div>`
- : html`<div><code>${a.kind}</code></div>`);
+ : a.kind === 'codes-run'
+ ? html`<div>Coding task on <code>${a.repo}</code> (OpenVibe.Codes, on your machine) — ${String(a.brief || '').slice(0, 200)}</div>`
+ : html`<div><code>${a.kind}</code></div>`);
const obsRows = obs.map((o) => [
const obsRows = obs.map((o) => [
time(new Date(Number(o.observed_at)).toISOString()),
time(new Date(Number(o.observed_at)).toISOString()),
@@ -800,6 +820,7 @@ ${watchForm({ template: fields.template, fields, errors: {}, action: `/watches/$
@@ -800,6 +820,7 @@ ${watchForm({ template: fields.template, fields, errors: {}, action: `/watches/$
const fields = { ...fieldsOfWatch(registry.view(row)), ...(req.body || {}) };
const fields = { ...fieldsOfWatch(registry.view(row)), ...(req.body || {}) };
fields.notify = req.body && req.body.notify === 'on' ? 'on' : '';
fields.notify = req.body && req.body.notify === 'on' ? 'on' : '';
fields.agent_on = req.body && req.body.agent_on === 'on' ? 'on' : '';
fields.agent_on = req.body && req.body.agent_on === 'on' ? 'on' : '';
+ fields.codes_on = req.body && req.body.codes_on === 'on' ? 'on' : '';
const built = buildBody(fields, template);
const built = buildBody(fields, template);
let errors = built.errors;
let errors = built.errors;
if (!errors) {
if (!errors) {
@@ -17,6 +17,7 @@ const { createGuard } = require('./net/guard');
@@ -17,6 +17,7 @@ const { createGuard } = require('./net/guard');
const { createPoster } = require('./net/post');
const { createPoster } = require('./net/post');
const { createActorTasks } = require('./actor');
const { createActorTasks } = require('./actor');
const { createProjects, createRunJobs } = require('./run-jobs');
const { createProjects, createRunJobs } = require('./run-jobs');
+const { createCodesTasks } = require('./codes-tasks');
const { createFetcher } = require('./net/fetcher');
const { createFetcher } = require('./net/fetcher');
const { createSpacer } = require('./spacer');
const { createSpacer } = require('./spacer');
const { createCarriers } = require('./carriers');
const { createCarriers } = require('./carriers');
@@ -41,7 +42,7 @@ const { createApp } = require('./app');
@@ -41,7 +42,7 @@ const { createApp } = require('./app');
const accountDataLib = require('./account-data');
const accountDataLib = require('./account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
const { createNetworkSender, startSubscriptions } = require('openvibe-sdk/account-data');
-async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, projects: givenProjects = null } = {}) {
+async function start({ config, db: givenDb = null, now = () => Date.now(), fetchImpl = globalThis.fetch, lookupImpl, log = console, listen = true, limitsNow = null, accountSend: givenSend = null, notifications: givenNotifications = null, post: givenPost = null, actor: givenActor = null, run: givenRun = null, codes: givenCodes = null, projects: givenProjects = null } = {}) {
config = config || load();
config = config || load();
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
// PostgreSQL (ADR-035): opened and migrated here unless the caller (a test) hands in a migrated handle.
const db = givenDb || await openDb(config, { log });
const db = givenDb || await openDb(config, { log });
@@ -59,7 +60,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -59,7 +60,7 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
const client = config.oauth.clientSecret
const client = config.oauth.clientSecret
? createClient({
? createClient({
tokenProvider: createServiceTokenClient({ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }),
tokenProvider: createServiceTokenClient({ network: config.networkInternalUrl, clientId: config.oauth.clientId, clientSecret: config.oauth.clientSecret, fetch: fetchImpl }),
- baseUrls: { network: config.networkInternalUrl, actor: config.actions.actorUrl, run: config.actions.runUrl }, fetch: fetchImpl,
+ baseUrls: { network: config.networkInternalUrl, actor: config.actions.actorUrl, run: config.actions.runUrl, codes: config.actions.codesUrl }, fetch: fetchImpl,
})
})
: null;
: null;
// A watch that names a project: Network says whether its owner is an admin or owner of it, when it is saved (Run checks
// A watch that names a project: Network says whether its owner is an admin or owner of it, when it is saved (Run checks
@@ -79,7 +80,8 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
@@ -79,7 +80,8 @@ async function start({ config, db: givenDb = null, now = () => Date.now(), fetch
// Webhook actions leave through the same guard as every fetch (server/net/post.js).
// Webhook actions leave through the same guard as every fetch (server/net/post.js).
const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });
const post = givenPost || createPoster({ guard, userAgent: config.fetch.userAgent, timeoutMs: config.actions.webhookTimeoutMs });
const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;
const runJobs = client ? createRunJobs(client, { baseUrl: config.actions.runUrl }) : null;
- const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, now, log });
+ const codesTasks = client ? createCodesTasks(client, { baseUrl: config.actions.codesUrl }) : null;
+ const actions = createActions({ db, config, notifications: givenNotifications || notifications, post, actor: givenActor || actorTasks, run: givenRun || runJobs, codes: givenCodes || codesTasks, now, log });
const check = createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, actions, now, log });
const check = createCheck({ db, registry, config, carriers, extract, condition, observations, outbox, actions, now, log });
const scheduler = createScheduler({ db, check, config, now, log });
const scheduler = createScheduler({ db, check, config, now, log });
// Event sources (plan T18 step 5): watches woken by OpenVibe.Events events, with their subscriptions kept in step.
// Event sources (plan T18 step 5): watches woken by OpenVibe.Events events, with their subscriptions kept in step.
@@ -62,6 +62,17 @@ const actor = {
@@ -62,6 +62,17 @@ const actor = {
return { id: 'tsk_AGENT', state: 'queued' };
return { id: 'tsk_AGENT', state: 'queued' };
},
},
};
};
+// OpenVibe.Codes, as server/codes-tasks.js would reach it: every start recorded; codesMode decides the answer.
+const codesStarts = [];
+let codesMode = 'ok';
+const codes = {
+ async start(req) {
+ codesStarts.push(req);
+ if (codesMode === 'no_project') throw Object.assign(new Error('no /run task yet'), { status: 409, code: 'codes.no_project', detail: 'this person has not run a task on openvibe.codes/run yet' });
+ if (codesMode === 'busy') throw Object.assign(new Error('unavailable'), { status: 503, code: 'run.unavailable' });
+ return { id: 'tsk_01JAB2C3D4E5F6G7H8J9K0MNCD', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_01JAB2C3D4E5F6G7H8J9K0MNCD', created: codesStarts.length === 1 };
+ },
+};
const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
const def = (overrides = {}) => ({
const def = (overrides = {}) => ({
name: 'Widget price',
name: 'Widget price',
@@ -92,7 +103,7 @@ t('boot', async () => {
@@ -92,7 +103,7 @@ t('boot', async () => {
'/set': (req, _res, ctx) => { price = ctx.url.searchParams.get('v') || price; return { body: 'ok' }; },
'/set': (req, _res, ctx) => { price = ctx.url.searchParams.get('v') || price; return { body: 'ok' }; },
});
});
svc = await boot({
svc = await boot({
- notifications, post, actor,
+ notifications, post, actor, codes,
env: {
env: {
WATCH_HOOK_TEST: HOOK_KEY, WATCH_HOOK_SHORT: 'too-short', WATCH_HOOK_THEIRS: HOOK_KEY,
WATCH_HOOK_TEST: HOOK_KEY, WATCH_HOOK_SHORT: 'too-short', WATCH_HOOK_THEIRS: HOOK_KEY,
WATCH_SECRET_GRANTS: JSON.stringify({
WATCH_SECRET_GRANTS: JSON.stringify({
@@ -104,7 +115,7 @@ t('boot', async () => {
@@ -104,7 +115,7 @@ t('boot', async () => {
t('a check that does not fire queues nothing; one that fires queues each action once, with its run', async () => {
t('a check that does not fire queues nothing; one that fires queues each action once, with its run', async () => {
mode = 'ok'; pushes.length = 0;
mode = 'ok'; pushes.length = 0;
- const w = await create(person, { action: [{ kind: 'notification', priority: 'high' }, { kind: 'codes-run', repo: 'OpenVibers/OpenVibe.Watch', brief: 'look into it' }] });
+ const w = await create(person, { action: [{ kind: 'notification', priority: 'high' }, { kind: 'event', event_type: 'watch.something.else' }] });
await setPrice('19.99');
await setPrice('19.99');
await check(w.id);
await check(w.id);
assert.deepStrictEqual(await acts(w.id), [], 'no trigger, no action');
assert.deepStrictEqual(await acts(w.id), [], 'no trigger, no action');
@@ -112,7 +123,7 @@ t('a check that does not fire queues nothing; one that fires queues each action
@@ -112,7 +123,7 @@ t('a check that does not fire queues nothing; one that fires queues each action
const out = await check(w.id);
const out = await check(w.id);
const rows = await acts(w.id);
const rows = await acts(w.id);
assert.deepStrictEqual(rows.map((r) => [r.kind, r.state, r.check_run_id, Number(r.action_index)]),
assert.deepStrictEqual(rows.map((r) => [r.kind, r.state, r.check_run_id, Number(r.action_index)]),
- [['notification', 'queued', out.run.id, 0], ['codes-run', 'unavailable', out.run.id, 1]]);
+ [['notification', 'queued', out.run.id, 0], ['event', 'unavailable', out.run.id, 1]]);
assert.match(rows[1].last_error, /watch\.action_unavailable/);
assert.match(rows[1].last_error, /watch\.action_unavailable/);
const r = await svc.actions.tick();
const r = await svc.actions.tick();
@@ -315,6 +326,55 @@ t('an actor-task action gives Actor a task for the owner, with the firing as con
@@ -315,6 +326,55 @@ t('an actor-task action gives Actor a task for the owner, with the firing as con
assert.strictEqual(tasks.length, before, 'no task for an agent-owned watch');
assert.strictEqual(tasks.length, before, 'no task for an agent-owned watch');
});
});
+t('a codes-run action starts a coding task for the owner on Codes, with the firing as context and one idempotency key', async () => {
+ codesStarts.length = 0; codesMode = 'busy';
+ const w = await create(person, { name: 'dependency bump', action: [{ kind: 'codes-run', repo: 'https://github.com/OpenVibers/OpenVibe.Watch', brief: 'Update the dependency and run the tests.' }] });
+ const out = await fire(w);
+ let r = await svc.actions.tick();
+ assert.deepStrictEqual(r, { sent: 0, skipped: 0, retried: 1, failed: 0 }, 'a busy Codes is retried');
+ codesMode = 'ok';
+ await svc.db.prepare('UPDATE action_runs SET next_attempt_at = 0 WHERE watch_id = ?').run(w.id);
+ r = await svc.actions.tick();
+ assert.deepStrictEqual(r, { sent: 1, skipped: 0, retried: 0, failed: 0 });
+ const [first, second] = codesStarts;
+ assert.strictEqual(first.subjectId, person.sub);
+ assert.strictEqual(first.repo, 'https://github.com/OpenVibers/OpenVibe.Watch');
+ assert.strictEqual(first.idempotencyKey, `watch.${out.run.id}.0`);
+ assert.strictEqual(second.idempotencyKey, first.idempotencyKey, 'the retry is the same task');
+ assert.match(first.prompt, /^Update the dependency and run the tests\.\n\nContext: your watch “dependency bump” fired at /);
+ assert.ok(first.prompt.length <= 8000);
+ const [row] = await acts(w.id);
+ assert.deepStrictEqual([row.state, row.last_error], ['sent', 'Codes task tsk_01JAB2C3D4E5F6G7H8J9K0MNCD https://openvibe.codes/run/tasks/tsk_01JAB2C3D4E5F6G7H8J9K0MNCD (already started)']);
+
+ codesMode = 'no_project';
+ const fresh = await create(person, { name: 'no project yet', action: [{ kind: 'codes-run', repo: 'https://github.com/a/b', brief: 'x' }] });
+ await fire(fresh);
+ r = await svc.actions.tick();
+ assert.deepStrictEqual(r, { sent: 0, skipped: 0, retried: 0, failed: 1 }, 'no /run task yet fails at once');
+ assert.match((await acts(fresh.id))[0].last_error, /watch\.action_refused: codes\.no_project/);
+ codesMode = 'ok';
+
+ const before = codesStarts.length;
+ const bot = await create(agent, { name: 'agent coding', action: [{ kind: 'codes-run', repo: 'https://github.com/a/b', brief: 'x' }] });
+ await fire(bot);
+ const [b] = await acts(bot.id);
+ assert.deepStrictEqual([b.state, /action_no_person/.test(b.last_error)], ['skipped', true]);
+ await svc.actions.tick();
+ assert.strictEqual(codesStarts.length, before, 'no task for an agent-owned watch');
+});
+
+t('server/codes-tasks.js asks Codes as Watch, for the owner, once', async () => {
+ const { createCodesTasks } = require('../server/codes-tasks');
+ const calls = [];
+ const client = { async json(o) { calls.push(o); return { task: { id: 'tsk_X', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_X' }, created: true }; } };
+ const out = await createCodesTasks(client, { baseUrl: 'http://127.0.0.1:4900' }).start({ subjectId: person.sub, repo: 'https://github.com/a/b', prompt: 'do it', idempotencyKey: 'watch.r.0' });
+ assert.deepStrictEqual(out, { id: 'tsk_X', state: 'queued', url: 'https://openvibe.codes/run/tasks/tsk_X', created: true });
+ const c = calls[0];
+ assert.deepStrictEqual([c.service, c.baseUrl, c.audience, c.method, c.path, c.retries], ['codes', 'http://127.0.0.1:4900', 'openvibe.codes', 'POST', '/api/v1/tasks', 0]);
+ assert.deepStrictEqual(c.headers, { 'X-OV-Subject': person.sub });
+ assert.deepStrictEqual(c.json, { repo: 'https://github.com/a/b', prompt: 'do it', idempotency_key: 'watch.r.0' });
+});
+
t('server/actor.js asks Actor as Watch, for the owner, once', async () => {
t('server/actor.js asks Actor as Watch, for the owner, once', async () => {
const { createActorTasks } = require('../server/actor');
const { createActorTasks } = require('../server/actor');
const calls = [];
const calls = [];
@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
@@ -49,7 +49,7 @@ const newAgent = () => require('openvibe-contracts').ids.newId('agent');
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* the caller says otherwise (a test of the SSRF guard boots with allowPrivate: false).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
* log: Watch's logger (default silent); limitsNow: the per-actor limiter's clock (default the wall clock).
*/
*/
-async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, projects = null } = {}) {
+async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, limitsNow = null, allowPrivate = true, accountSend = null, notifications = null, post = null, actor = null, run = null, codes = null, projects = null } = {}) {
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// A stub Network JWKS serving the generated signing key, so the SDK's JWKS client has something real
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
// to fetch and verify against (no test touches the internet). Its URL is overridable via env.
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
const jwksSite = await site({ '/api/.well-known/jwks': () => ({ body: JSON.stringify({ keys: [publicJwk] }) }) });
@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l
@@ -79,7 +79,7 @@ async function boot({ env = {}, worker = 'off', lookupImpl, now, log = silent, l
});
});
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
// One database per boot (PGlite, or WATCH_TEST_STORE=pg: the containers), dropped when the boot stops.
const testdb = await require('./db').testDb();
const testdb = await require('./db').testDb();
- const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(projects ? { projects } : {}), ...(now ? { now } : {}) });
+ const h = await start({ config, db: testdb.db, log, lookupImpl, limitsNow, accountSend, ...(notifications ? { notifications } : {}), ...(post ? { post } : {}), ...(actor ? { actor } : {}), ...(run ? { run } : {}), ...(codes ? { codes } : {}), ...(projects ? { projects } : {}), ...(now ? { now } : {}) });
const base = `http://127.0.0.1:${h.server.address().port}`;
const base = `http://127.0.0.1:${h.server.address().port}`;
/** A session cookie for a person, as /auth/callback would have set it. */
/** A session cookie for a person, as /auth/callback would have set it. */
@@ -177,6 +177,25 @@ t('the plain create and edit forms save an Actor agent action', async () => {
@@ -177,6 +177,25 @@ t('the plain create and edit forms save an Actor agent action', async () => {
assert.strictEqual(updated.body.watch.action[0].note, 'New note.');
assert.strictEqual(updated.body.watch.action[0].note, 'New note.');
});
});
+t('the plain create and edit forms save a coding task on the owner\'s machine (OpenVibe.Codes)', async () => {
+ const form = await svc.get('/watches/new?template=page', { cookie: session });
+ assert.match(form.text, /Start a coding task on my own machine/);
+ const bad = await create({ template: 'page', url: `${web.origin}/price`, cadence: '3600', codes_on: 'on', codes_repo: '[email protected]:a/b.git', codes_brief: '' });
+ assert.strictEqual(bad.status, 422);
+ assert.match(bad.text, /https address/);
+ assert.match(bad.text, /Say what the coding task should do/);
+ const made = await create({ template: 'page', url: `${web.origin}/price`, cadence: '3600', codes_on: 'on', codes_repo: 'https://github.com/you/project', codes_brief: 'Update it and run the tests.' });
+ assert.strictEqual(made.status, 303);
+ const id = indexOf(made);
+ const view = await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token: ME });
+ assert.deepStrictEqual(view.body.watch.action, [{ kind: 'codes-run', repo: 'https://github.com/you/project', brief: 'Update it and run the tests.' }]);
+ const page = await svc.get(`/watches/${id}`, { cookie: session });
+ assert.match(page.text, /Coding task on <code>https:\/\/github\.com\/you\/project<\/code>/);
+ const edit = await svc.get(`/watches/${id}/edit`, { cookie: session });
+ assert.match(edit.text, /name="codes_on"[^>]*checked/);
+ assert.ok(edit.text.includes('Update it and run the tests.'));
+});
+
t('validation errors re-render the form with the registry\'s message and the person\'s input', async () => {
t('validation errors re-render the form with the registry\'s message and the person\'s input', async () => {
const badUrl = await create({ template: 'page', name: 'Keep me', url: 'not-a-url', selector: '.x', cadence: '3600' });
const badUrl = await create({ template: 'page', name: 'Keep me', url: 'not-a-url', selector: '.x', cadence: '3600' });
assert.strictEqual(badUrl.status, 422);
assert.strictEqual(badUrl.status, 422);