openvibers/openvibe.watch Commits

Watch: the codes-run action starts a coding task on the owner's own machine (OpenVibe.Codes) (#55)

OP OpenVibers committed 30 days ago · 7831dacb23 · 9 files, +177 −16

Plan T18 actions + T16: the `codes-run` action (schema since contracts 0.9x: `repo`, `brief`) was recorded `unavailable` because no dispatcher existed. Codes now runs a person's coding task on their own machine and takes it from a service (Codes#65, Contracts 0.162 `codes.task.*`).

**Dispatcher** (`server/codes-tasks.js`, `server/actions.js`)
- When the watch fires, Watch POSTs `/api/v1/tasks` on Codes as itself, with the owner in `X-OV-Subject`.
- The prompt is the action's brief with the firing as context (≤ 8000 characters), in the action's repository.
- Key `watch.<check run>.<action index>`, so a retry is the same task.
- Created is `sent` with the task's page. Refusals a retry can't change (400/403/404/409, including `codes.no_project`, and 413/422) fail at once; anything else retries on the backoff.
- An agent- or service-owned watch is `skipped`.

**Form**
- The create and edit pages offer "Start a coding task on my own machine (OpenVibe.Codes)": repository (https owner/name, no credentials) and the task.
- The watch page lists it.
- The footer no longer says Codes actions are a later step.

**Config:** `WATCH_CODES_URL` (default `http://127.0.0.1:4900`). Needs Network's `watch → codes.task.start` grant (Network PR alongside).

**Tests:** `actions` (a busy Codes retried with the same key, then sent; `codes.no_project` fails at once; agent-owned skipped; the client's call shape); `site` (the form refuses a non-https repo or an empty task, saves, shows and edits it). Suite: 17/17.

Changes

ddiff --git a/README.md b/README.md
iindex f72898e..9c1592d 100644
---- a/README.md
++++ b/README.md
@@@ -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
ddiff --git a/server/actions.js b/server/actions.js
iindex 42daf7b..a5158c4 100644
---- a/server/actions.js
++++ b/server/actions.js
@@@ -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, observedAt
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) {
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 };
ddiff --git a/server/codes-tasks.js b/server/codes-tasks.js
nnew file mode 100644
iindex 0000000..75730a1
---- /dev/null
++++ b/server/codes-tasks.js
@@@ -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 };
ddiff --git a/server/config.js b/server/config.js
iindex 5c27175..427b303 100644
---- a/server/config.js
++++ b/server/config.js
@@@ -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
ddiff --git a/server/http/pages.js b/server/http/pages.js
iindex 9a86b37..72b5b53 100644
---- a/server/http/pages.js
++++ b/server/http/pages.js
@@@ -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: 'Create
const 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) {
ddiff --git a/server/index.js b/server/index.js
iindex ea80d88..6c953d7 100644
---- a/server/index.js
++++ b/server/index.js
@@@ -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(), fetch
const 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.
ddiff --git a/test/actions.test.js b/test/actions.test.js
iindex fede383..337e08a 100644
---- a/test/actions.test.js
++++ b/test/actions.test.js
@@@ -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 action
const 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 con
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 () => {
const { createActorTasks } = require('../server/actor');
const calls = [];
ddiff --git a/test/helpers.js b/test/helpers.js
iindex 8b7c7b7..f739576 100644
---- a/test/helpers.js
++++ b/test/helpers.js
@@@ -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. */
ddiff --git a/test/site.test.js b/test/site.test.js
iindex bbca804..d0085a5 100644
---- a/test/site.test.js
++++ b/test/site.test.js
@@@ -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);