openvibers/openvibe.actor Commits
The routine's card in the chat (#326)
Changes
@@@ -4,6 +4,14 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patch## Unreleased — triggers in conversation++- **The routine's card in the chat** (Opus): the turn that made a trigger shows it — when, in words (with its zone),++ what runs, the next run in the reader's own time, how the last run went (a link to it), which agent runs it, and On,++ Paused or Deleted. Its moves are plain forms that work with no script: Pause or Resume, Run now, and Undo for ten++ minutes, then Delete with a confirmation; a routine paused after failed runs says so and leads with Resume. Only the++ turn's requester, same-origin, and only a trigger that turn made (`POST /chat/:id/trigger/:tid`); Watch is called with++ the person's session. Schedules read the way a person says them: "Every hour at :30" (was "Every 30 minutes"),++ "Every 15 minutes", "Every 6 hours", "Every day at 08:00 and 20:00", "Every month on the 1st at 09:00", "Every year++ on 25 December at 09:00"; a rule with no plain phrase is "On a custom schedule, <cron>". Test: `test/trigger-card.test.js`.- **Triggers made in the conversation** (plan §12 A-4): `schedule_trigger` turns "every weekday at 9, summarise my inbox"or "when a new issue lands in my repo, triage it" into a Watch subscription the person owns, created with their owntoken — never Actor's service token acting for them. The tool parses the sentence, and when it cannot (no time, no@@@ -49,6 +49,29 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app/* The screen: what Actor sees while it works, with Bot's live view one tap away. */.ct-screen { margin: 0; display: grid; gap: 6px; }++/* A routine the turn made (plan §12 A-4): what runs and when, its state, how the last run went, and its moves. */++.ct-trig { display: grid; gap: 6px; }++.ct-trig p { margin: 0; }++.ct-trig .ovt-card-h { align-items: center; }++.ct-trig .ovt-card-h > svg { flex: none; color: var(--ovt-accent, var(--accent, #f97316)); }++.ct-trig-when { flex: 1; min-width: 0; overflow-wrap: anywhere; }++.ct-trig-state { flex: none; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .02em; background: color-mix(in srgb, var(--success-text, #4ade80) 16%, transparent); color: var(--success-text, #4ade80); }++.ct-trig.is-paused .ct-trig-state { background: color-mix(in srgb, var(--warning-text, #fbbf24) 16%, transparent); color: var(--warning-text, #fbbf24); }++.ct-trig.is-gone .ct-trig-state { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-muted, #7f8ea6); }++.ct-trig.is-gone { opacity: .75; }++.ct-trig-task { color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }++.ct-trig-meta, .ct-trig-why { color: var(--text-secondary, #96a7c2); font-size: 13.5px; }++.ct-trig-meta a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }++.ct-trig-why { color: var(--warning-text, #fbbf24); }++.ct-trig .ovt-card-acts { margin-top: 4px; align-items: flex-start; }++.ct-trig-del { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }++.ct-trig-del[open] { flex-basis: 100%; }++.ct-trig-del > summary { list-style: none; display: inline-flex; align-items: center; cursor: pointer; }++.ct-trig-del > summary::-webkit-details-marker { display: none; }++.ct-trig-del[open] > summary { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger-text, #f87171) 55%, var(--ovt-line)); }++.ct-trig-del > p { flex-basis: 100%; margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: 13.5px; }++.ovt-card-btn.is-danger { background: var(--danger-text, #f87171); color: #1a0505; }++@media (max-width: 480px) { .ct-trig .ovt-card-btn { min-height: 44px; } }/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }.ct-screen[hidden] { display: none; }@@@ -45,6 +45,8 @@ const glyphs = require('openvibe-shared/glyphs');const thread = require('openvibe-shared/thread');const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;++const TRIGGER_ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;++const LAST_RUN = { ok: 'went fine', failed: 'failed', needs_you: 'needs you', running: 'is running' };const OPEN = ['queued', 'running', 'verifying'];const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };@@@ -304,6 +306,60 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div>`}</form>`;}++ /**++ * The routines this turn made (plan §12 A-4): one card each, its latest state. What runs and when in words, the++ * next run in the reader's own zone, how the last run went, and the person's moves as plain forms (they work with++ * no script): Pause or Resume, Run now, and Undo for ten minutes, then Delete with a confirmation. A shared, read-only++ * turn shows the card as it was made, with no moves and nothing read beyond the turn itself.++ */++ async function triggerBlocks(row, events, readOnly = false) {++ const latest = new Map();++ for (const e of events || []) {++ if (e && e.kind === 'output' && e.step === 'trigger' && e.trigger && TRIGGER_ID.test(String(e.trigger.id || ''))) latest.set(e.trigger.id, e.trigger);++ }++ if (!latest.size) return '';++ const owner = String(row.requester || '').replace(/^user:/, '');++ const cards = [];++ for (const [id, made] of latest) {++ let card = made;++ let gone = false;++ if (!readOnly && ctx.triggers && typeof ctx.triggers.get === 'function') {++ const live = await ctx.triggers.get(owner, id).catch(() => null);++ if (live) card = live; else gone = true;++ }++ cards.push(triggerCard(row, card, { gone, readOnly }));++ }++ return html`${cards}`;++ }++++ function triggerCard(row, card, { gone = false, readOnly = false } = {}) {++ const when = card.when || {};++ const paused = !gone && card.state === 'paused';++ const state = gone ? 'Deleted' : paused ? 'Paused' : 'On';++ const last = card.last_run && LAST_RUN[card.last_run.state] ? card.last_run : null;++ const undoable = !gone && card.undo_until && Date.parse(card.undo_until) > now();++ const why = paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.` : 'Paused by you.') : '';++ const icon = card.kind === 'event' ? 'zap' : 'calendar';++ const words = String(when.words || (card.kind === 'event' ? 'When it happens' : 'On a schedule'));++ const zone = card.kind === 'schedule' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';++ const meta = [];++ if (!gone && !paused && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);++ if (last) meta.push(html`<a href="/tasks/${last.task_id}">Last run ${LAST_RUN[last.state]}</a>`);++ if (card.agent_name) meta.push(html`Runs as ${card.agent_name}`);++ const moves = gone || readOnly ? '' : html`<div class="ovt-card-acts">++${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>`}++<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>++${undoable ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="undo">Undo</button>`++ : html`<details class="ct-trig-del"><summary class="ovt-card-btn is-quiet">Delete</summary><p>Delete it for good? No future run happens.</p><button type="submit" class="ovt-card-btn is-danger" name="do" value="delete">Delete it</button></details>`}++</div>`;++ return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}">++<p class="ovt-card-h">${raw(glyphs.svg(icon, { size: 18 }))}<span class="ct-trig-when"><b>${words}</b>${zone}</span><span class="ct-trig-state">${state}</span></p>++<p class="ct-trig-task">${String(card.task || '')}</p>++${meta.length ? html`<p class="ct-trig-meta">${meta.map((m, i) => html`${i ? ' · ' : ''}${m}`)}</p>` : ''}++${why ? html`<p class="ct-trig-why">${why}</p>` : ''}++${moves}</form>`;++ }++/** The files beside the question (thread.files draws them). */function filesOf(row, files) {return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));@@@ -324,6 +380,9 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="oconst parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;}++ // A routine the turn made is its card, after whatever the turn said (plan §12 A-4).++ const routines = await triggerBlocks(row, evs, readOnly);++ if (routines) reply = html`${reply}${routines}`;// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));}@@@ -632,6 +691,37 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs onreturn res.redirect(303, `/chat/${row.id}#t-${row.id}`);});++ // A routine's card moves (plan §12 A-4): only the turn's requester, only same-origin, only a trigger this turn made.++ // Watch is called with the person's own session token, never Actor's service token.++ r.post('/chat/:id/trigger/:tid', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {++ res.set('Cache-Control', cache.htmlHeaders({ private: true }));++ const wantsJson = String(req.get('accept') || '').includes('application/json');++ if (!signedIn(req) || !TASK_ID.test(req.params.id) || !TRIGGER_ID.test(req.params.tid)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');++ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change a routine from openvibe.actor itself.');++ const row = await store.getTask(s, req.params.id);++ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');++ const events = await store.eventsAfter(s, row.id, 0);++ const made = events.some((e) => e && e.step === 'trigger' && e.trigger && e.trigger.id === req.params.tid);++ if (!made || !ctx.triggers) return wantsJson ? res.status(404).json({ error: 'No such routine in this chat.' }) : res.redirect(303, `/chat/${row.id}#t-${row.id}`);++ const owner = req.viewer.subject;++ const token = req.token || (req.viewer && req.viewer.token) || null;++ const move = String((req.body && req.body.do) || '');++ const t = ctx.triggers;++ const out = move === 'pause' ? await t.pause(owner, req.params.tid, { token })++ : move === 'resume' ? await t.resume(owner, req.params.tid, { token })++ : move === 'run' ? await t.runNow(owner, req.params.tid)++ : move === 'undo' ? await t.undo(owner, req.params.tid, { token })++ : move === 'delete' ? await t.remove(owner, req.params.tid, { token, confirm: true })++ : { error: { status: 400, detail: 'Say pause, resume, run, undo or delete.' } };++ if (out && out.error) {++ const status = out.error.status || 409;++ if (wantsJson) return res.status(status).json({ error: out.error.detail || 'That did not change.', code: out.error.code || null });++ return render(req, res, { row, problem: out.error.detail || 'That did not change.', status });++ }++ if (wantsJson) return res.json(out);++ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);++ });++// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can// take the mouse. Only the task's requester, only same-origin, only while the turn works.@@@ -84,17 +84,33 @@ function wordsFor(cronExpr, timezone) {return `Every ${days.map((d) => DAY_NAMES[d]).join(', ')} at ${time} (${timezone})`;}}-- if (one(rule.minutes) && one(rule.hours) && rule.hours.has(0) && rule.minutes.has(0) && rule.months.size === 12 && rule.days.size === 31 && rule.weekdays.size === 7) return `Every hour (${timezone})`;-- if (rule.hours.size === 24 && rule.minutes.size === 60) return `Every minute (${timezone})`;-- if (one(rule.hours) && rule.hours.has(0) && rule.minutes.size === 60) return `Every hour (${timezone})`;-- if (rule.hours.size === 24 && rule.minutes.size === 1 && !rule.minutes.has(0)) return `Every ${[...rule.minutes][0]} minutes (${timezone})`;-- if (rule.hours.size === 24 && rule.minutes.size === 1 && [...rule.minutes][0] % 15 === 0 && [...rule.minutes][0] !== 0) return `Every ${[...rule.minutes][0]} minutes (${timezone})`;-- if (rule.minutes.size === 60 && rule.hours.size < 24) {-- const step = 24 / rule.hours.size;-- if (Number.isInteger(step) && rule.hours.has(0)) return `Every ${step} hour${step === 1 ? '' : 's'} (${timezone})`;++ // Every day, all year: steps through the hour or the day, said the way a person says them.++ if (rule.months.size === 12 && rule.dayAny && rule.weekdayAny) {++ if (hours.length === 24) {++ if (minutes.length === 1) return minutes[0] === 0 ? `Every hour (${timezone})` : `Every hour at :${pad(minutes[0])} (${timezone})`;++ const step = minutes[1] - minutes[0];++ if (minutes[0] === 0 && step > 0 && 60 % step === 0 && minutes.length === 60 / step && minutes.every((m, i) => m === i * step)) return `Every ${step} minutes (${timezone})`;++ } else if (minutes.length === 1 && hours.length > 1) {++ const times = hours.map((h) => `${pad(h)}:${pad(minutes[0])}`);++ if (hours.length <= 3) return `Every day at ${times.slice(0, -1).join(', ')} and ${times[times.length - 1]} (${timezone})`;++ const step = hours[1] - hours[0];++ const at = minutes[0] ? ` at :${pad(minutes[0])}` : '';++ if (step > 0 && 24 % step === 0 && hours.length === 24 / step && hours.every((h, i) => h === hours[0] + i * step)) return `Every ${step} hours${at} (${timezone})`;++ }++ }++ // Once a year, on one date.++ if (one(rule.minutes) && one(rule.hours) && rule.months.size === 1 && !rule.dayAny && rule.weekdayAny && rule.days.size === 1) {++ const month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][[...rule.months][0] - 1];++ if (month) return `Every year on ${[...rule.days][0]} ${month} at ${pad(hours[0])}:${pad(minutes[0])} (${timezone})`;++ }++ // Once a month, on one day of it.++ if (one(rule.minutes) && one(rule.hours) && rule.months.size === 12 && !rule.dayAny && rule.weekdayAny && rule.days.size === 1) {++ const d = [...rule.days][0];++ const nth = (d % 100 >= 11 && d % 100 <= 13) ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[d % 10] || 'th');++ return `Every month on the ${d}${nth} at ${pad(hours[0])}:${pad(minutes[0])} (${timezone})`;}} catch { /* fall through */ }-- return `Cron ${cronExpr} (${timezone})`;++ return `On a custom schedule, ${cronExpr} (${timezone})`;}/** The schedule phrase and its cron, or null. Longest match wins so "every weekday at 9" beats "every day". */nnew file mode 100644@@@ -0,0 +1,124 @@++'use strict';++/**++ * A routine's card in the chat (plan §12 A-4): the turn that made a trigger shows it (what runs and when, its state,++ * the next run) with plain-form moves for its owner: Pause and Resume, Run now, Undo in the first ten minutes. Another++ * person cannot move it, a cross-site form is refused, and an undone routine stays in the turn as Deleted, with no moves.++ */++const assert = require('assert');++const http = require('http');++const { ids } = require('openvibe-contracts');++const { boot, check, done } = require('./helpers/boot');++const contacts = require('../server/agents/contacts');++++const SAME = { 'sec-fetch-site': 'same-origin' };++++/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */++async function watchStub() {++ const calls = [];++ const server = http.createServer(async (req, res) => {++ const chunks = [];++ for await (const c of req) chunks.push(c);++ const text = Buffer.concat(chunks).toString('utf8');++ let body = null;++ try { body = text ? JSON.parse(text) : null; } catch { body = text; }++ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });++ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };++ if (req.method === 'POST' && req.url === '/api/v1/watches') {++ const id = ids.newId('watch');++ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });++ }++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) {++ return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });++ }++ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) {++ return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });++ }++ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) {++ return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });++ }++ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) {++ return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });++ }++ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });++ return send(404, { code: 'watch.not_found' });++ });++ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));++ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };++}++++(async () => {++ const watch = await watchStub();++ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop: { close: async () => {}, run: async () => ({ text: 'Done.', sources: [], evidence: [], model: 'fake' }) } });++ try {++ const ada = t.network.addUser('ada');++ const bo = t.network.addUser('bo');++ const owner = `user:${ada.subject}`;++ const s = t.ctx.s;++ const vibe = await contacts.ensureVibe(s, t.config, owner);++ const as = (u, method, p, o = {}) => t.get(p, { as: u, method, headers: SAME, ...o });++ const loc = (r) => (r.headers && typeof r.headers.get === 'function' ? r.headers.get('location') : r.headers && r.headers.location);++ let turn, trg;++++ await check('the turn that made a routine shows its card, on, with Pause, Run now and Undo', async () => {++ const made = await as(ada, 'POST', '/api/v1/tasks', { json: { task: 'every weekday at 9, summarise my inbox' } });++ assert.ok(made.status === 201 || made.status === 202, made.text);++ turn = (made.json().task && made.json().task.id) || made.json().id;++ await t.waitFor(turn);++ const res = await as(ada, 'POST', '/api/v1/triggers', { json: { agent_id: vibe.id, task: 'summarise my inbox', kind: 'schedule', cron: '0 9 * * 1-5', timezone: 'Europe/Berlin', turn_task_id: turn } });++ assert.strictEqual(res.status, 201, res.text);++ trg = res.json().trigger.id;++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.strictEqual(page.status, 200, page.text);++ assert.ok(page.text.includes(`data-ct-trigger="${trg}"`), 'the card is in the turn');++ assert.ok(page.text.includes('summarise my inbox'));++ assert.match(page.text, /class="ct-trig-state">On</);++ assert.match(page.text, /Next run <time datetime="[^"]+" data-ct-local>/);++ assert.ok(['pause', 'run', 'undo'].every((m) => page.text.includes(`value="${m}"`)), 'Pause, Run now and Undo');++ assert.ok(!page.text.includes('value="delete"'), 'Undo, not Delete, in the first ten minutes');++ });++++ await check('a schedule is said in words, never as a raw cron where a person would say it', async () => {++ const { wordsFor } = require('../server/triggers/parse');++ const said = (c) => wordsFor(c, 'UTC').replace(/ \(UTC\)$/, '');++ assert.deepStrictEqual(['30 * * * *', '0 * * * *', '*/15 * * * *', '0 */6 * * *', '0 8,20 * * *', '0 9 1 * *', '0 9 25 12 *'].map(said), [++ 'Every hour at :30', 'Every hour', 'Every 15 minutes', 'Every 6 hours', 'Every day at 08:00 and 20:00',++ 'Every month on the 1st at 09:00', 'Every year on 25 December at 09:00',++ ]);++ assert.match(said('0 9 1-7 * 1'), /^On a custom schedule, 0 9 1-7 \* 1$/, 'a rule with no plain phrase says so and shows it');++ });++++ await check('Pause from the card, and the card says so with Resume', async () => {++ const res = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'pause' } });++ assert.strictEqual(res.status, 303, res.text);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.match(page.text, /class="ct-trig-state">Paused</);++ assert.ok(page.text.includes('Paused by you.'));++ assert.ok(page.text.includes('value="resume"') && !page.text.includes('value="pause"'));++ assert.ok(watch.calls.some((c) => /\/pause$/.test(c.url) && String(c.auth).startsWith('Bearer ')), 'Watch paused it, as the person');++ });++++ await check('another person cannot move it, and a cross-site form is refused', async () => {++ const other = await as(bo, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'resume' } });++ assert.strictEqual(other.status, 303);++ assert.strictEqual(loc(other), '/chat');++ const cross = await t.get(`/chat/${turn}/trigger/${trg}`, { as: ada, method: 'POST', headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' }, form: { do: 'resume' } });++ assert.strictEqual(cross.status, 403);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.match(page.text, /class="ct-trig-state">Paused</, 'still paused');++ });++++ await check('Undo removes it; the turn keeps the card as Deleted, with no moves', async () => {++ const res = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'undo' } });++ assert.strictEqual(res.status, 303, res.text);++ const page = await as(ada, 'GET', `/chat/turn/${turn}`);++ assert.match(page.text, /class="ct-trig-state">Deleted</);++ assert.ok(!/value="(pause|resume|run|undo|delete)"/.test(page.text), 'no moves on a deleted routine');++ const again = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'resume' } });++ assert.ok(again.status >= 400, `nothing to resume: ${again.status}`);++ });++ } finally {++ await t.close();++ await watch.close();++ }++ done();++})().catch((e) => { console.error(e); process.exit(1); });
@@ -4,6 +4,14 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patch
@@ -4,6 +4,14 @@ What changed in OpenVibe.Actor, newest first. Each site also publishes its patch
## Unreleased — triggers in conversation
## Unreleased — triggers in conversation
+- **The routine's card in the chat** (Opus): the turn that made a trigger shows it — when, in words (with its zone),
+ what runs, the next run in the reader's own time, how the last run went (a link to it), which agent runs it, and On,
+ Paused or Deleted. Its moves are plain forms that work with no script: Pause or Resume, Run now, and Undo for ten
+ minutes, then Delete with a confirmation; a routine paused after failed runs says so and leads with Resume. Only the
+ turn's requester, same-origin, and only a trigger that turn made (`POST /chat/:id/trigger/:tid`); Watch is called with
+ the person's session. Schedules read the way a person says them: "Every hour at :30" (was "Every 30 minutes"),
+ "Every 15 minutes", "Every 6 hours", "Every day at 08:00 and 20:00", "Every month on the 1st at 09:00", "Every year
+ on 25 December at 09:00"; a rule with no plain phrase is "On a custom schedule, <cron>". Test: `test/trigger-card.test.js`.
- **Triggers made in the conversation** (plan §12 A-4): `schedule_trigger` turns "every weekday at 9, summarise my inbox"
- **Triggers made in the conversation** (plan §12 A-4): `schedule_trigger` turns "every weekday at 9, summarise my inbox"
or "when a new issue lands in my repo, triage it" into a Watch subscription the person owns, created with their own
or "when a new issue lands in my repo, triage it" into a Watch subscription the person owns, created with their own
token — never Actor's service token acting for them. The tool parses the sentence, and when it cannot (no time, no
token — never Actor's service token acting for them. The tool parses the sentence, and when it cannot (no time, no
@@ -49,6 +49,29 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
@@ -49,6 +49,29 @@ body:has(.page-app) > footer, body:has(.page-app) #ov-footer, body:has(.page-app
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
/* The screen: what Actor sees while it works, with Bot's live view one tap away. */
.ct-screen { margin: 0; display: grid; gap: 6px; }
.ct-screen { margin: 0; display: grid; gap: 6px; }
+/* A routine the turn made (plan §12 A-4): what runs and when, its state, how the last run went, and its moves. */
+.ct-trig { display: grid; gap: 6px; }
+.ct-trig p { margin: 0; }
+.ct-trig .ovt-card-h { align-items: center; }
+.ct-trig .ovt-card-h > svg { flex: none; color: var(--ovt-accent, var(--accent, #f97316)); }
+.ct-trig-when { flex: 1; min-width: 0; overflow-wrap: anywhere; }
+.ct-trig-state { flex: none; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; letter-spacing: .02em; background: color-mix(in srgb, var(--success-text, #4ade80) 16%, transparent); color: var(--success-text, #4ade80); }
+.ct-trig.is-paused .ct-trig-state { background: color-mix(in srgb, var(--warning-text, #fbbf24) 16%, transparent); color: var(--warning-text, #fbbf24); }
+.ct-trig.is-gone .ct-trig-state { background: var(--bg-hover, rgba(255,255,255,.06)); color: var(--text-muted, #7f8ea6); }
+.ct-trig.is-gone { opacity: .75; }
+.ct-trig-task { color: var(--text-primary, #e6edf7); overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
+.ct-trig-meta, .ct-trig-why { color: var(--text-secondary, #96a7c2); font-size: 13.5px; }
+.ct-trig-meta a { color: inherit; text-decoration: underline; text-underline-offset: 2px; }
+.ct-trig-why { color: var(--warning-text, #fbbf24); }
+.ct-trig .ovt-card-acts { margin-top: 4px; align-items: flex-start; }
+.ct-trig-del { display: inline-flex; flex-wrap: wrap; align-items: center; gap: 8px; }
+.ct-trig-del[open] { flex-basis: 100%; }
+.ct-trig-del > summary { list-style: none; display: inline-flex; align-items: center; cursor: pointer; }
+.ct-trig-del > summary::-webkit-details-marker { display: none; }
+.ct-trig-del[open] > summary { box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--danger-text, #f87171) 55%, var(--ovt-line)); }
+.ct-trig-del > p { flex-basis: 100%; margin: 2px 0 0; color: var(--text-secondary, #96a7c2); font-size: 13.5px; }
+.ovt-card-btn.is-danger { background: var(--danger-text, #f87171); color: #1a0505; }
+@media (max-width: 480px) { .ct-trig .ovt-card-btn { min-height: 44px; } }
/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
/* A parked turn (plan A2): what it waits for, until when, and Wake now / Stop waiting. */
.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
.ct-wait .ct-wait-until { margin: 6px 0 0; color: var(--text-secondary, #96a7c2); font-size: 14px; }
.ct-screen[hidden] { display: none; }
.ct-screen[hidden] { display: none; }
@@ -45,6 +45,8 @@ const glyphs = require('openvibe-shared/glyphs');
@@ -45,6 +45,8 @@ const glyphs = require('openvibe-shared/glyphs');
const thread = require('openvibe-shared/thread');
const thread = require('openvibe-shared/thread');
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
const TASK_ID = /^tsk_[0-9A-HJKMNP-TV-Z]{26}$/;
+const TRIGGER_ID = /^trg_[0-9A-HJKMNP-TV-Z]{26}$/;
+const LAST_RUN = { ok: 'went fine', failed: 'failed', needs_you: 'needs you', running: 'is running' };
const OPEN = ['queued', 'running', 'verifying'];
const OPEN = ['queued', 'running', 'verifying'];
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
const MODE_LABEL = { cheapest: 'Cheapest', balanced: 'Balanced', best: 'Best', fastest: 'Fastest', private: 'Private' };
const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };
const MODE_HINT = { cheapest: 'the lowest price that can do it', balanced: 'good and cheap', best: 'the strongest agent', fastest: 'the quickest answer', private: 'open model, nothing leaves OpenVibe' };
@@ -304,6 +306,60 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
@@ -304,6 +306,60 @@ ${acting ? '' : html`<form method="post" action="/tasks/${row.id}/answer" class=
${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div>`}</form>`;
${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="ovt-card-btn" name="do" value="wake">Wake now</button><button type="submit" class="ovt-card-btn is-quiet" name="do" value="cancel">Stop waiting</button></div>`}</form>`;
}
}
+ /**
+ * The routines this turn made (plan §12 A-4): one card each, its latest state. What runs and when in words, the
+ * next run in the reader's own zone, how the last run went, and the person's moves as plain forms (they work with
+ * no script): Pause or Resume, Run now, and Undo for ten minutes, then Delete with a confirmation. A shared, read-only
+ * turn shows the card as it was made, with no moves and nothing read beyond the turn itself.
+ */
+ async function triggerBlocks(row, events, readOnly = false) {
+ const latest = new Map();
+ for (const e of events || []) {
+ if (e && e.kind === 'output' && e.step === 'trigger' && e.trigger && TRIGGER_ID.test(String(e.trigger.id || ''))) latest.set(e.trigger.id, e.trigger);
+ }
+ if (!latest.size) return '';
+ const owner = String(row.requester || '').replace(/^user:/, '');
+ const cards = [];
+ for (const [id, made] of latest) {
+ let card = made;
+ let gone = false;
+ if (!readOnly && ctx.triggers && typeof ctx.triggers.get === 'function') {
+ const live = await ctx.triggers.get(owner, id).catch(() => null);
+ if (live) card = live; else gone = true;
+ }
+ cards.push(triggerCard(row, card, { gone, readOnly }));
+ }
+ return html`${cards}`;
+ }
+
+ function triggerCard(row, card, { gone = false, readOnly = false } = {}) {
+ const when = card.when || {};
+ const paused = !gone && card.state === 'paused';
+ const state = gone ? 'Deleted' : paused ? 'Paused' : 'On';
+ const last = card.last_run && LAST_RUN[card.last_run.state] ? card.last_run : null;
+ const undoable = !gone && card.undo_until && Date.parse(card.undo_until) > now();
+ const why = paused ? (card.paused_reason === 'failed' ? `Paused after ${Number(card.failures) || 5} failed runs in a row. Resume it once the cause is fixed.` : 'Paused by you.') : '';
+ const icon = card.kind === 'event' ? 'zap' : 'calendar';
+ const words = String(when.words || (card.kind === 'event' ? 'When it happens' : 'On a schedule'));
+ const zone = card.kind === 'schedule' && when.timezone && !words.includes(when.timezone) ? ` (${when.timezone})` : '';
+ const meta = [];
+ if (!gone && !paused && card.next_run_at) meta.push(html`Next run <time datetime="${new Date(card.next_run_at).toISOString()}" data-ct-local>${new Date(card.next_run_at).toISOString().slice(0, 16).replace('T', ' ')} UTC</time>`);
+ if (last) meta.push(html`<a href="/tasks/${last.task_id}">Last run ${LAST_RUN[last.state]}</a>`);
+ if (card.agent_name) meta.push(html`Runs as ${card.agent_name}`);
+ const moves = gone || readOnly ? '' : html`<div class="ovt-card-acts">
+${paused ? html`<button type="submit" class="ovt-card-btn" name="do" value="resume">Resume</button>` : html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="pause">Pause</button>`}
+<button type="submit" class="ovt-card-btn is-quiet" name="do" value="run">Run now</button>
+${undoable ? html`<button type="submit" class="ovt-card-btn is-quiet" name="do" value="undo">Undo</button>`
+ : html`<details class="ct-trig-del"><summary class="ovt-card-btn is-quiet">Delete</summary><p>Delete it for good? No future run happens.</p><button type="submit" class="ovt-card-btn is-danger" name="do" value="delete">Delete it</button></details>`}
+</div>`;
+ return html`<form class="ovt-card ct-trig${paused ? ' is-paused' : ''}${gone ? ' is-gone' : ''}" method="post" action="/chat/${row.id}/trigger/${card.id}" data-ct-trigger="${card.id}">
+<p class="ovt-card-h">${raw(glyphs.svg(icon, { size: 18 }))}<span class="ct-trig-when"><b>${words}</b>${zone}</span><span class="ct-trig-state">${state}</span></p>
+<p class="ct-trig-task">${String(card.task || '')}</p>
+${meta.length ? html`<p class="ct-trig-meta">${meta.map((m, i) => html`${i ? ' · ' : ''}${m}`)}</p>` : ''}
+${why ? html`<p class="ct-trig-why">${why}</p>` : ''}
+${moves}</form>`;
+ }
+
/** The files beside the question (thread.files draws them). */
/** The files beside the question (thread.files draws them). */
function filesOf(row, files) {
function filesOf(row, files) {
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
return (files || []).map((f) => ({ href: `/chat/file/${row.id}/${f.id}`, name: f.name, size: f.size, image: inputs.IMAGE.test(f.mime) }));
@@ -324,6 +380,9 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
@@ -324,6 +380,9 @@ ${readOnly ? '' : html`<div class="ovt-card-acts"><button type="submit" class="o
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
const parked = ctx.waits && typeof ctx.waits.listForTask === 'function' ? (await ctx.waits.listForTask(row.id).catch(() => [])).find((w) => w.status === 'waiting') : null;
reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;
reply = html`${workBlock(row, evs, true)}${screenBlock(row, evs, true)}${waitBlock(row, parked, readOnly)}`;
}
}
+ // A routine the turn made is its card, after whatever the turn said (plan §12 A-4).
+ const routines = await triggerBlocks(row, evs, readOnly);
+ if (routines) reply = html`${reply}${routines}`;
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
// data-task and data-last are Actor's (js/chat.js follows the task's event stream from data-last).
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
return raw(thread.turn({ id: row.id, state: row.state, open, you: { text: row.task, files: filesOf(row, attached) }, reply: String(reply), attrs: { 'data-task': row.id, 'data-last': row.last_seq } }));
}
}
@@ -632,6 +691,37 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
@@ -632,6 +691,37 @@ ${runsSaved ? html`<p class="cm-ok" role="status">Saved. Your next task runs on
return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
});
});
+ // A routine's card moves (plan §12 A-4): only the turn's requester, only same-origin, only a trigger this turn made.
+ // Watch is called with the person's own session token, never Actor's service token.
+ r.post('/chat/:id/trigger/:tid', express.urlencoded({ extended: false, limit: '1kb' }), async (req, res) => {
+ res.set('Cache-Control', cache.htmlHeaders({ private: true }));
+ const wantsJson = String(req.get('accept') || '').includes('application/json');
+ if (!signedIn(req) || !TASK_ID.test(req.params.id) || !TRIGGER_ID.test(req.params.tid)) return wantsJson ? res.status(401).json({ error: 'Sign in first.' }) : res.redirect(303, '/auth/login?next=%2Fchat');
+ if (!sameOrigin(req, config.baseUrl)) return res.status(403).type('text/plain').send('Change a routine from openvibe.actor itself.');
+ const row = await store.getTask(s, req.params.id);
+ if (!row || row.requester !== requesterOf(req)) return wantsJson ? res.status(404).json({ error: 'That chat is not here.' }) : res.redirect(303, '/chat');
+ const events = await store.eventsAfter(s, row.id, 0);
+ const made = events.some((e) => e && e.step === 'trigger' && e.trigger && e.trigger.id === req.params.tid);
+ if (!made || !ctx.triggers) return wantsJson ? res.status(404).json({ error: 'No such routine in this chat.' }) : res.redirect(303, `/chat/${row.id}#t-${row.id}`);
+ const owner = req.viewer.subject;
+ const token = req.token || (req.viewer && req.viewer.token) || null;
+ const move = String((req.body && req.body.do) || '');
+ const t = ctx.triggers;
+ const out = move === 'pause' ? await t.pause(owner, req.params.tid, { token })
+ : move === 'resume' ? await t.resume(owner, req.params.tid, { token })
+ : move === 'run' ? await t.runNow(owner, req.params.tid)
+ : move === 'undo' ? await t.undo(owner, req.params.tid, { token })
+ : move === 'delete' ? await t.remove(owner, req.params.tid, { token, confirm: true })
+ : { error: { status: 400, detail: 'Say pause, resume, run, undo or delete.' } };
+ if (out && out.error) {
+ const status = out.error.status || 409;
+ if (wantsJson) return res.status(status).json({ error: out.error.detail || 'That did not change.', code: out.error.code || null });
+ return render(req, res, { row, problem: out.error.detail || 'That did not change.', status });
+ }
+ if (wantsJson) return res.json(out);
+ return res.redirect(303, `/chat/${row.id}#t-${row.id}`);
+ });
+
// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").
// The live desk in a working turn (plan L55 C1b; the owner, 11-01: "show you the display… watch it… and steer it").
// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can
// The page asks once the turn is on a desk; the answer is a short-lived Bot page it frames, where the person can
// take the mouse. Only the task's requester, only same-origin, only while the turn works.
// take the mouse. Only the task's requester, only same-origin, only while the turn works.
@@ -84,17 +84,33 @@ function wordsFor(cronExpr, timezone) {
@@ -84,17 +84,33 @@ function wordsFor(cronExpr, timezone) {
return `Every ${days.map((d) => DAY_NAMES[d]).join(', ')} at ${time} (${timezone})`;
return `Every ${days.map((d) => DAY_NAMES[d]).join(', ')} at ${time} (${timezone})`;
}
}
}
}
- if (one(rule.minutes) && one(rule.hours) && rule.hours.has(0) && rule.minutes.has(0) && rule.months.size === 12 && rule.days.size === 31 && rule.weekdays.size === 7) return `Every hour (${timezone})`;
- if (rule.hours.size === 24 && rule.minutes.size === 60) return `Every minute (${timezone})`;
- if (one(rule.hours) && rule.hours.has(0) && rule.minutes.size === 60) return `Every hour (${timezone})`;
- if (rule.hours.size === 24 && rule.minutes.size === 1 && !rule.minutes.has(0)) return `Every ${[...rule.minutes][0]} minutes (${timezone})`;
- if (rule.hours.size === 24 && rule.minutes.size === 1 && [...rule.minutes][0] % 15 === 0 && [...rule.minutes][0] !== 0) return `Every ${[...rule.minutes][0]} minutes (${timezone})`;
- if (rule.minutes.size === 60 && rule.hours.size < 24) {
- const step = 24 / rule.hours.size;
- if (Number.isInteger(step) && rule.hours.has(0)) return `Every ${step} hour${step === 1 ? '' : 's'} (${timezone})`;
+ // Every day, all year: steps through the hour or the day, said the way a person says them.
+ if (rule.months.size === 12 && rule.dayAny && rule.weekdayAny) {
+ if (hours.length === 24) {
+ if (minutes.length === 1) return minutes[0] === 0 ? `Every hour (${timezone})` : `Every hour at :${pad(minutes[0])} (${timezone})`;
+ const step = minutes[1] - minutes[0];
+ if (minutes[0] === 0 && step > 0 && 60 % step === 0 && minutes.length === 60 / step && minutes.every((m, i) => m === i * step)) return `Every ${step} minutes (${timezone})`;
+ } else if (minutes.length === 1 && hours.length > 1) {
+ const times = hours.map((h) => `${pad(h)}:${pad(minutes[0])}`);
+ if (hours.length <= 3) return `Every day at ${times.slice(0, -1).join(', ')} and ${times[times.length - 1]} (${timezone})`;
+ const step = hours[1] - hours[0];
+ const at = minutes[0] ? ` at :${pad(minutes[0])}` : '';
+ if (step > 0 && 24 % step === 0 && hours.length === 24 / step && hours.every((h, i) => h === hours[0] + i * step)) return `Every ${step} hours${at} (${timezone})`;
+ }
+ }
+ // Once a year, on one date.
+ if (one(rule.minutes) && one(rule.hours) && rule.months.size === 1 && !rule.dayAny && rule.weekdayAny && rule.days.size === 1) {
+ const month = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'][[...rule.months][0] - 1];
+ if (month) return `Every year on ${[...rule.days][0]} ${month} at ${pad(hours[0])}:${pad(minutes[0])} (${timezone})`;
+ }
+ // Once a month, on one day of it.
+ if (one(rule.minutes) && one(rule.hours) && rule.months.size === 12 && !rule.dayAny && rule.weekdayAny && rule.days.size === 1) {
+ const d = [...rule.days][0];
+ const nth = (d % 100 >= 11 && d % 100 <= 13) ? 'th' : ({ 1: 'st', 2: 'nd', 3: 'rd' }[d % 10] || 'th');
+ return `Every month on the ${d}${nth} at ${pad(hours[0])}:${pad(minutes[0])} (${timezone})`;
}
}
} catch { /* fall through */ }
} catch { /* fall through */ }
- return `Cron ${cronExpr} (${timezone})`;
+ return `On a custom schedule, ${cronExpr} (${timezone})`;
}
}
/** The schedule phrase and its cron, or null. Longest match wins so "every weekday at 9" beats "every day". */
/** The schedule phrase and its cron, or null. Longest match wins so "every weekday at 9" beats "every day". */
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+'use strict';
+/**
+ * A routine's card in the chat (plan §12 A-4): the turn that made a trigger shows it (what runs and when, its state,
+ * the next run) with plain-form moves for its owner: Pause and Resume, Run now, Undo in the first ten minutes. Another
+ * person cannot move it, a cross-site form is refused, and an undone routine stays in the turn as Deleted, with no moves.
+ */
+const assert = require('assert');
+const http = require('http');
+const { ids } = require('openvibe-contracts');
+const { boot, check, done } = require('./helpers/boot');
+const contacts = require('../server/agents/contacts');
+
+const SAME = { 'sec-fetch-site': 'same-origin' };
+
+/** OpenVibe.Watch as the person's token reaches it: records every call and answers like the service. */
+async function watchStub() {
+ const calls = [];
+ const server = http.createServer(async (req, res) => {
+ const chunks = [];
+ for await (const c of req) chunks.push(c);
+ const text = Buffer.concat(chunks).toString('utf8');
+ let body = null;
+ try { body = text ? JSON.parse(text) : null; } catch { body = text; }
+ calls.push({ method: req.method, url: req.url, body, auth: req.headers.authorization });
+ const send = (status, value) => { res.writeHead(status, { 'content-type': 'application/json' }); res.end(JSON.stringify(value)); };
+ if (req.method === 'POST' && req.url === '/api/v1/watches') {
+ const id = ids.newId('watch');
+ return send(201, { watch: { id, status: body.status || 'active', name: body.name, labels: body.labels || {}, source: body.source } });
+ }
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/schedule$/.test(req.url)) {
+ return send(200, { id: req.url.split('/')[4], schedule: body, next_due_at: '2026-11-10T08:00:00.000Z' });
+ }
+ if (req.method === 'POST' && /^\/api\/v1\/watches\/wch_[^/]+\/(pause|resume)$/.test(req.url)) {
+ return send(200, { id: req.url.split('/')[4], status: req.url.endsWith('/resume') ? 'active' : 'paused' });
+ }
+ if (req.method === 'PATCH' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) {
+ return send(200, { watch: { id: req.url.split('/')[4], status: 'active', ...body } });
+ }
+ if (req.method === 'GET' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) {
+ return send(200, { watch: { id: req.url.split('/')[4], status: 'active', health: { last_state: 'condition_met', consecutive_failures: 0, next_due_at: '2026-11-10T08:00:00.000Z' } } });
+ }
+ if (req.method === 'DELETE' && /^\/api\/v1\/watches\/wch_[^/]+$/.test(req.url)) return send(200, { id: req.url.split('/')[4], deleted: true });
+ return send(404, { code: 'watch.not_found' });
+ });
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
+ return { url: `http://127.0.0.1:${server.address().port}`, calls, close: () => new Promise((r) => server.close(r)) };
+}
+
+(async () => {
+ const watch = await watchStub();
+ const t = await boot({ env: { ACTOR_WATCH_URL: watch.url }, loop: { close: async () => {}, run: async () => ({ text: 'Done.', sources: [], evidence: [], model: 'fake' }) } });
+ try {
+ const ada = t.network.addUser('ada');
+ const bo = t.network.addUser('bo');
+ const owner = `user:${ada.subject}`;
+ const s = t.ctx.s;
+ const vibe = await contacts.ensureVibe(s, t.config, owner);
+ const as = (u, method, p, o = {}) => t.get(p, { as: u, method, headers: SAME, ...o });
+ const loc = (r) => (r.headers && typeof r.headers.get === 'function' ? r.headers.get('location') : r.headers && r.headers.location);
+ let turn, trg;
+
+ await check('the turn that made a routine shows its card, on, with Pause, Run now and Undo', async () => {
+ const made = await as(ada, 'POST', '/api/v1/tasks', { json: { task: 'every weekday at 9, summarise my inbox' } });
+ assert.ok(made.status === 201 || made.status === 202, made.text);
+ turn = (made.json().task && made.json().task.id) || made.json().id;
+ await t.waitFor(turn);
+ const res = await as(ada, 'POST', '/api/v1/triggers', { json: { agent_id: vibe.id, task: 'summarise my inbox', kind: 'schedule', cron: '0 9 * * 1-5', timezone: 'Europe/Berlin', turn_task_id: turn } });
+ assert.strictEqual(res.status, 201, res.text);
+ trg = res.json().trigger.id;
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.strictEqual(page.status, 200, page.text);
+ assert.ok(page.text.includes(`data-ct-trigger="${trg}"`), 'the card is in the turn');
+ assert.ok(page.text.includes('summarise my inbox'));
+ assert.match(page.text, /class="ct-trig-state">On</);
+ assert.match(page.text, /Next run <time datetime="[^"]+" data-ct-local>/);
+ assert.ok(['pause', 'run', 'undo'].every((m) => page.text.includes(`value="${m}"`)), 'Pause, Run now and Undo');
+ assert.ok(!page.text.includes('value="delete"'), 'Undo, not Delete, in the first ten minutes');
+ });
+
+ await check('a schedule is said in words, never as a raw cron where a person would say it', async () => {
+ const { wordsFor } = require('../server/triggers/parse');
+ const said = (c) => wordsFor(c, 'UTC').replace(/ \(UTC\)$/, '');
+ assert.deepStrictEqual(['30 * * * *', '0 * * * *', '*/15 * * * *', '0 */6 * * *', '0 8,20 * * *', '0 9 1 * *', '0 9 25 12 *'].map(said), [
+ 'Every hour at :30', 'Every hour', 'Every 15 minutes', 'Every 6 hours', 'Every day at 08:00 and 20:00',
+ 'Every month on the 1st at 09:00', 'Every year on 25 December at 09:00',
+ ]);
+ assert.match(said('0 9 1-7 * 1'), /^On a custom schedule, 0 9 1-7 \* 1$/, 'a rule with no plain phrase says so and shows it');
+ });
+
+ await check('Pause from the card, and the card says so with Resume', async () => {
+ const res = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'pause' } });
+ assert.strictEqual(res.status, 303, res.text);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.match(page.text, /class="ct-trig-state">Paused</);
+ assert.ok(page.text.includes('Paused by you.'));
+ assert.ok(page.text.includes('value="resume"') && !page.text.includes('value="pause"'));
+ assert.ok(watch.calls.some((c) => /\/pause$/.test(c.url) && String(c.auth).startsWith('Bearer ')), 'Watch paused it, as the person');
+ });
+
+ await check('another person cannot move it, and a cross-site form is refused', async () => {
+ const other = await as(bo, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'resume' } });
+ assert.strictEqual(other.status, 303);
+ assert.strictEqual(loc(other), '/chat');
+ const cross = await t.get(`/chat/${turn}/trigger/${trg}`, { as: ada, method: 'POST', headers: { 'sec-fetch-site': 'cross-site', origin: 'https://evil.example' }, form: { do: 'resume' } });
+ assert.strictEqual(cross.status, 403);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.match(page.text, /class="ct-trig-state">Paused</, 'still paused');
+ });
+
+ await check('Undo removes it; the turn keeps the card as Deleted, with no moves', async () => {
+ const res = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'undo' } });
+ assert.strictEqual(res.status, 303, res.text);
+ const page = await as(ada, 'GET', `/chat/turn/${turn}`);
+ assert.match(page.text, /class="ct-trig-state">Deleted</);
+ assert.ok(!/value="(pause|resume|run|undo|delete)"/.test(page.text), 'no moves on a deleted routine');
+ const again = await as(ada, 'POST', `/chat/${turn}/trigger/${trg}`, { form: { do: 'resume' } });
+ assert.ok(again.status >= 400, `nothing to resume: ${again.status}`);
+ });
+ } finally {
+ await t.close();
+ await watch.close();
+ }
+ done();
+})().catch((e) => { console.error(e); process.exit(1); });