openvibers/openvibe.actor Commits

The routine's card in the chat (#326)

OP OpenVibers committed 6 days ago · ae8541a1e8 · 5 files, +270 −9

A turn that made a trigger shows one card per routine: when in words (and its zone), what runs, the next run in the
reader's own time, the last run (linked), the agent, and On, Paused or Deleted. Pause or Resume, Run now, Undo for
ten minutes and then Delete with a confirmation, as plain forms (POST /chat/:id/trigger/:tid: the requester only,
same-origin, a trigger this turn made; Watch gets the person's session). Paused after failures leads with Resume.

Schedules in words: hourly at :30 was called "Every 30 minutes"; hourly, every N minutes, every N hours, twice a day,
monthly and yearly are now said plainly, and anything else is "On a custom schedule, <cron>" instead of "Cron …".
Test: test/trigger-card.test.js (the card, Pause, another person and a cross-site form refused, Undo leaves Deleted).

Changes

ddiff --git a/CHANGELOG.md b/CHANGELOG.md
iindex 436e818..1659788 100644
---- a/CHANGELOG.md
++++ b/CHANGELOG.md
@@@ -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 own
token — never Actor's service token acting for them. The tool parses the sentence, and when it cannot (no time, no
ddiff --git a/public/css/chat.css b/public/css/chat.css
iindex 6142e44..4ce481b 100644
---- a/public/css/chat.css
++++ b/public/css/chat.css
@@@ -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; }
ddiff --git a/server/http/chat-pages.js b/server/http/chat-pages.js
iindex c8b6a98..e88158f 100644
---- a/server/http/chat-pages.js
++++ b/server/http/chat-pages.js
@@@ -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="o
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)}`;
}
++ // 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 on
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 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.
ddiff --git a/server/triggers/parse.js b/server/triggers/parse.js
iindex de93889..ed90194 100644
---- a/server/triggers/parse.js
++++ b/server/triggers/parse.js
@@@ -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". */
ddiff --git a/test/trigger-card.test.js b/test/trigger-card.test.js
nnew file mode 100644
iindex 0000000..184fb57
---- /dev/null
++++ b/test/trigger-card.test.js
@@@ -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); });