openvibers/openvibe.watch Commits
Watch: the condition in words, a quiet test before arming, and the evidence in every alert (#172)
Changes
@@@ -3,6 +3,21 @@What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry namesthe step it landed.++## 2026-11-01 — the condition in words, a quiet test before arming, and the evidence in every alert++++- The watch list and each watch page say the watch's structured condition in one sentence — what it reads, where,++ and what makes it fire — instead of the condition operator alone (`server/render/watch.js`, `server/http/pages.js`).++- A polling watch made on the site is saved paused and read once by a quiet test check before it arms. The watch page++ shows the value that check found (or the state it recorded and why, when it found none), nothing is notified and no++ action is queued, and the person arms it when ready. The first arm is stored in `watches.armed_at`++ (migration `0014_armed_at.sql`, `server/check.js`, `server/http/pages.js`).++- Every alert now carries its evidence: a notification's message and `rich_content`, a webhook body, an Actor task's++ context and a run-job's firing argument state the value before and after, the source address and the time the value++ was fetched. A part the watch did not store is left out (`server/actions.js`). The watch page's alert history shows++ the same evidence (`server/http/pages.js`).++- Tests: `test/site.test.js` (the quiet test, the failed test, arming, the condition in words, the arm badge),++ `test/actions.test.js` (the notification and webhook evidence, the omitted line, the page history).++## 2026-10-31 — a person's Network session token on the API- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the samennew file mode 100644@@@ -0,0 +1,7 @@++-- phase: expand++-- When a watch was first armed. A watch the site creates to run its quiet test is paused with++-- armed_at NULL: the test check is the only read, and resuming (arming) sets it. Watches that++-- existed before this column were armed at creation, so they are backfilled rather than shown as++-- untested: nothing about a watch already in the registry is invented.++ALTER TABLE watches ADD COLUMN IF NOT EXISTS armed_at bigint;++UPDATE watches SET armed_at = created_at WHERE armed_at IS NULL;@@@ -11,7 +11,8 @@** Delivery, by kind:* notification the owner gets one OpenVibe notification through Network (openvibe-sdk/notifications, type-- * WATCH_TRIGGERED): the watch's name, the value and a link to the watch page. A person who switched++ * WATCH_TRIGGERED): the watch's name, the value before and after, the source address, the time it was++ * fetched and a link to the watch page. A person who switched* the category off is Network's decision and counts as sent; an owner Network does not know (a* deleted account) is `skipped`. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then* `failed`. Network keeps no idempotency record for a push, so a lost answer can repeat one: at least@@@ -19,7 +20,7 @@* event `watch.watch.triggered` is emitted with every firing, in the run's own commit, so that action is* `sent` as it is queued (it needs a person as owner, as the event does). Any other event type is not* declared in Watch's manifest: `unavailable`.-- * webhook a POST of the firing (webhookFor below) to the action's https URL through the SSRF guard++ * webhook a POST of the firing (webhookFor below, with its evidence bundle) to the action's https URL through the SSRF guard* (server/net/post.js), signed with the key its secret_env names exactly as OpenVibe.Events signs a* delivery (openvibe-sdk/events signDeliveryHeaders: X-OpenVibe-Timestamp + X-OpenVibe-Signature-V2,* which a receiver checks with the SDK's verifier). X-OpenVibe-Delivery-Id (`<check run>.<action@@@ -29,7 +30,7 @@* set (32 characters or more), checked when it is queued and again at delivery: otherwise* `unavailable`.* actor-task a task for the owner on OpenVibe.Actor (server/actor.js): the action's brief, with the firing as-- * context, under the owner's own allowance (Watch names them in X-OV-Subject; Actor records Watch as++ * context (before and after, source and fetch time), under the owner's own allowance (Watch names them in X-OV-Subject; Actor records Watch as* `via`), at most the action's budget_usd. The idempotency key `watch.<check run>.<action index>`* makes a retried delivery the same task. Created is `sent` (the task id is recorded). A request* Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once; anything@@@ -92,10 +93,28 @@ function valueText(v) {try { return JSON.stringify(v); } catch { return String(v); }}++/**++ * The evidence every alert carries: the value before and after, the source address it came from and++ * when it was fetched. A part the watch does not store is null, and the line is left out of the words.++ */++function evidenceFor({ watch, value, previousValue, observedAt }) {++ const source = (watch && watch.source) || {};++ return {++ before: previousValue === undefined ? null : previousValue,++ after: value === undefined ? null : value,++ source_url: source.url || (source.probe && source.probe.target) || null,++ fetched_at: observedAt ? new Date(observedAt).toISOString() : null,++ };++}++/** The notification a fired watch sends its owner (network.notification-push-request@1 fields). */function notificationFor({ config, watch, run, action, value, previousValue, observedAt }) {const now = valueText(value);const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);++ const evidence = evidenceFor({ watch, value, previousValue, observedAt });++ const facts = [before !== null && before !== now ? `${now} (was ${before})` : now];++ if (evidence.source_url) facts.push(`from ${evidence.source_url}`);++ if (evidence.fetched_at) facts.push(`fetched ${evidence.fetched_at}`);return {type: 'WATCH_TRIGGERED',service: 'watch',@@@ -103,13 +122,18 @@ function notificationFor({ config, watch, run, action, value, previousValue, obspriority: action.priority || 'normal',icon: '👁',title: clip(`“${watch.name}” fired`, TITLE_MAX),-- message: clip(before !== null && before !== now ? `${now} (was ${before})` : now, MESSAGE_MAX),++ message: clip(facts.join(' · '), MESSAGE_MAX),url: `${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`,-- rich_content: { watch_id: watch.id, check_run_id: run.id, observed_at: observedAt ? new Date(observedAt).toISOString() : null },++ rich_content: {++ watch_id: watch.id,++ check_run_id: run.id,++ observed_at: observedAt ? new Date(observedAt).toISOString() : null,++ evidence,++ },};}--/** The body a webhook action POSTs: the firing, as the watch.watch.triggered event states it. */++/** The body a webhook action POSTs: the firing, as the watch.watch.triggered event states it, with its evidence. */function webhookFor({ config, watch, run, value, previousValue, observedAt }) {return {type: TRIGGERED,@@@ -121,6 +145,7 @@ function webhookFor({ config, watch, run, value, previousValue, observedAt }) {value: value === undefined ? null : value,previous_value: previousValue === undefined ? null : previousValue,},++ evidence: evidenceFor({ watch, value, previousValue, observedAt }),};}@@@ -142,11 +167,15 @@ function actorContextFor({ config, watch, value, previousValue, observedAt }, maconst now = valueText(value);const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);const when = observedAt ? new Date(observedAt).toISOString() : 'just now';++ const evidence = evidenceFor({ watch, value, previousValue, observedAt });const first = `Context: your watch “${watch.name}” fired at ${when}.`;const last = `Watch: ${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`;const valueLine = `Value: ${now}${before !== null && before !== now ? ` (was ${before})` : ''}.`;-- const room = Math.max(0, maxLength - first.length - last.length - 2);-- return [first, valueLine.length > room ? `${valueLine.slice(0, Math.max(0, room - 1))}…` : valueLine, last].join('\n').slice(0, maxLength);++ const fromLine = evidence.source_url ? `Source: ${evidence.source_url}.` : null;++ const fetchedLine = evidence.fetched_at ? `Fetched: ${evidence.fetched_at}.` : null;++ const tail = [fromLine, fetchedLine, last].filter(Boolean);++ const room = Math.max(0, maxLength - first.length - tail.join('\n').length - 2);++ return [first, valueLine.length > room ? `${valueLine.slice(0, Math.max(0, room - 1))}…` : valueLine, ...tail].join('\n').slice(0, maxLength);}/**@@@ -157,6 +186,7 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedAconst job = {};for (const k of JOB_FIELDS) if (action[k] !== undefined && action[k] !== null) job[k] = action[k];if (action.firing_arg) {++ const evidence = evidenceFor({ watch, value, previousValue, observedAt });job.args = {...(job.args || {}),[action.firing_arg]: {@@@ -166,6 +196,8 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedAobserved_at: observedAt ? new Date(observedAt).toISOString() : null,value: value === undefined ? null : value,previous_value: previousValue === undefined ? null : previousValue,++ source_url: evidence.source_url,++ fetched_at: evidence.fetched_at,},};}@@@ -195,7 +227,7 @@ function createActions({ db, config, notifications = null, post = null, actor =retry: db.prepare('UPDATE action_runs SET next_attempt_at = ?, last_error = ? WHERE rid = ?'),counts: db.prepare(`SELECT COUNT(*) FILTER (WHERE state = 'queued') AS queued, COUNT(*) FILTER (WHERE state = 'failed') AS failed,COUNT(*) FILTER (WHERE state = 'sent') AS sent FROM action_runs`),-- recent: db.prepare(`SELECT check_run_id, action_index, kind, state, attempts, last_error, created_at, finished_at FROM action_runs++ recent: db.prepare(`SELECT check_run_id, action_index, kind, state, attempts, last_error, request, created_at, finished_at FROM action_runsWHERE watch_id = ? ORDER BY rid DESC LIMIT ?`),};@@@ -203,6 +235,7 @@ function createActions({ db, config, notifications = null, post = null, actor =async function queue({ watch, run, owner, value, previousValue, observedAt }) {const list = Array.isArray(watch.action) ? watch.action : [];const at = now();++ const evidence = evidenceFor({ watch, value, previousValue, observedAt });for (let i = 0; i < list.length; i++) {const action = list[i] || {};const kind = String(action.kind || '');@@@ -242,6 +275,8 @@ function createActions({ db, config, notifications = null, post = null, actor =} else {state = 'unavailable'; error = `watch.action_unavailable: unknown action kind ${kind || '(none)'}`;}++ // The UI shows the same evidence the alert carried; a notification keeps it in rich_content.++ if (Object.keys(request).length && kind !== 'notification') request = { ...request, evidence };await st.insert.run(watch.id, run.id, i, kind || 'unknown', String(owner || ''), JSON.stringify(request), state, error, at, state === 'queued' ? null : at);}}@@@ -415,6 +450,12 @@ function createActions({ db, config, notifications = null, post = null, actor =return (await st.recent.all(watchId, limit)).map((r) => ({check_run_id: r.check_run_id, kind: r.kind, state: r.state, attempts: Number(r.attempts),error: r.last_error || null,++ // The delivery body the action carried (the evidence bundle), so the page can show it.++ request: (() => {++ let request = r.request;++ if (typeof request === 'string') { try { request = JSON.parse(request); } catch { request = null; } }++ return request == null ? null : request;++ })(),created_at: new Date(Number(r.created_at)).toISOString(),finished_at: r.finished_at == null ? null : new Date(Number(r.finished_at)).toISOString(),}));@@@ -186,7 +186,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser// ── One check ────────────────────────────────────────────────────────────────────────────-- async function runOnce(row, trigger, input = null) {++ async function runOnce(row, trigger, input = null, { quiet = false } = {}) {const watch = registry.fromRow(row);const startedAt = now();const carrier = watch.source && watch.source.kind ? watch.source.kind : null;@@@ -197,12 +197,13 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser...baseRun(watch, trigger, carrier, startedAt),state, error_code, detail, finished_at: now(),};-- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });++ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });return await settle({ watch, run, failures: failuresBefore + 1, events });};-- // A watch that is not active is never fetched, and says so.-- if (row.status !== 'active') {++ // A watch that is not active is never fetched, and says so. The one exception is the site's++ // quiet test of a just-created paused watch: it runs once with `quiet` and sends nothing.++ if (row.status !== 'active' && !(quiet && row.status === 'paused')) {return { disabled: true, ...(await skip('disabled', `watch_${row.status}`, `the watch is ${row.status}`)) };}@@@ -249,7 +250,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obserbytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,};const failures = failuresBefore + 1;-- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });++ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures });const result = await settle({ watch, run, failures, events, endpoint, notBefore: out.notBefore || 0 });return { ...result, rateLimitedUntil: out.notBefore || null };}@@@ -285,7 +286,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obserfinished_at: finishedAt,};const failures = failuresBefore + 1;-- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });++ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures });return await settle({ watch, run, failures, events });}} else {@@@ -297,7 +298,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obserdetail: out.detail ? String(out.detail).slice(0, 500) : null,bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,};-- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });++ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });return await settle({ watch, run, failures: 0, successAt: finishedAt, events, endpoint });}value = held.value;@@@ -323,7 +324,8 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser// with its finish time; the firing run's start is always earlier, so the last firing is compared by when it// finished (comparing its start made every check while the condition held fire again).const repeat = verdict.met && !verdict.changed && lastFired != null && verdict.since != null && Number(lastFired.finished_at) >= verdict.since;-- const met = verdict.met && !repeat;++ // A quiet test records the value and what it found, but it fires nothing and queues no action.++ const met = !quiet && verdict.met && !repeat;if (verdict.error && !patternError) patternError = verdict.error;const retentionDays = watch.retention && Number.isInteger(watch.retention.observations_days)@@@ -359,27 +361,31 @@ function createCheck({ db, registry, config, carriers, extract, condition, obserpreviousValue = beforeLatest ? beforeLatest.value : null;}-- const events = await eventsFor({++ const events = quiet ? [] : await eventsFor({watch, owner: row.owner_sub, observation, value, met, run,firedOn, firedAt: finishedAt, previousValue, failures: 0,});const fired = met ? { owner: row.owner_sub, value, previousValue, observedAt: firedOn ? Number(firedOn.observed_at) : finishedAt } : null;const result = await settle({ watch, run, failures: 0, successAt: finishedAt, events, observation, endpoint, fired });return {-- ...result, met, value, verdict: { ...verdict, met, repeat },++ ...result, met, holds: verdict.met, quiet, value, verdict: { ...verdict, met, repeat },observation: observation ? observations.view(observation) : null,};}/**-- * run(id, { trigger, event }) →++ * run(id, { trigger, input, quiet }) →* null no such watch* { busy: true } a check of this watch is already in flight* { disabled: true } the watch is paused/disabled/failed; recorded, not fetched* { unavailable: { code, detail } } the carrier (or extraction) is a later step; nothing fetched-- * { run, observation, met, value } the check ran++ * { run, observation, met, holds, value } the check ran++ *++ * `quiet: true` is the site's test check of a watch it created paused: it may run once, it records++ * the run and the observation like any check, but it emits no event, fires no condition and queues++ * no action. It is never the API's or the scheduler's trigger.*/-- async function run(id, { trigger = 'schedule', input = null } = {}) {++ async function run(id, { trigger = 'schedule', input = null, quiet = false } = {}) {const key = String(id);// Claimed before the first await, so two callers racing on one watch cannot both proceed.if (inflight.has(key)) return { busy: true };@@@ -387,7 +393,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obsertry {const row = await registry.get(key);if (!row) return null;-- return await runOnce(row, trigger, input);++ return await runOnce(row, trigger, input, { quiet });} finally {inflight.delete(key);}@@@ -100,6 +100,9 @@ const TEMPLATES = {},};const TEMPLATE_NAMES = Object.keys(TEMPLATES);++/** The templates whose source is polled: the site can run one quiet test check before arming these. */++const POLLING_TEMPLATES = new Set(['page', 'price', 'feed', 'json', 'lan']);++const CREATE_SUBMIT = (template) => (POLLING_TEMPLATES.has(template) ? 'Create and test this watch' : 'Create this watch');// The public events the event template offers (Contracts: each is `public`, so any watch may read it): which payload// field narrows it, and which field is new on every occurrence (so `changed` fires once per occurrence).const EVENT_CHOICES = [@@@ -497,16 +500,18 @@ ${extractionBlock}${conditionBlock}<fieldset><legend>${pushed ? 'How to tell you' : 'How often, and how to tell you'}</legend>${pushed ? html`<p class="muted small">${template === 'event' ? 'Checked each time the event arrives (at most 60 an hour), never on a cadence.' : 'Checked each time a signed delivery arrives (at most 60 an hour), never on a cadence.'}</p>` : field('Cadence', cadence, { name: 'cadence', error: errors.cadence, hint: 'Watch never checks more often than this, and can wait longer when the site asks it to.' })}--${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'When the condition fires you get one notification on OpenVibe (your notifications on any OpenVibe site), with the value and a link here.' })}--${checkbox('Run one of my agents on OpenVibe.Actor', { name: 'agent_on', checked: fields.agent_on === 'on', hint: 'When the condition fires, Watch gives your agent the value and a link to this watch.' })}++${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'When the condition fires you get one notification on OpenVibe (your notifications on any OpenVibe site), with the before and after values, the source address, the time it was fetched and a link here.' })}++${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 before and after values, the source address, the time it was fetched 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.' })}++${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 before and after values, the source address, the time it was fetched 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, Actor agents and coding tasks are delivered when the watch fires.</p>++<p class="muted small">${POLLING_TEMPLATES.has(template)++ ? 'Saving reads it once, quietly: the next page shows what the check found, and nothing is sent until you arm it.'++ : 'Saving arms it at once: it waits for its event or signed delivery, and nothing is polled.'} Notifications, Actor agents and coding tasks are delivered when the watch fires.</p></form>`;}@@@ -533,7 +538,7 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code['What can I watch?', 'A web page or one part of it, a price or any number on a page, an RSS or Atom feed, a value in a JSON API, a webhook that something calls, an OpenVibe event such as a channel going live, or a device on your own network.'],['How often does it check?', 'On the cadence you pick: every 15 minutes, hourly, every 6 hours or daily. Events and webhooks are not polled at all: the watch wakes the moment they arrive.'],['Will it hammer the sites I watch?', 'No. It asks whether anything changed before downloading again, never sends two requests to one site at once, keeps a minimum gap per site, and waits when a site asks it to.'],-- ['How am I told?', 'A notification on your OpenVibe account by default. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],++ ['How am I told?', 'A notification on your OpenVibe account by default, with the before and after values, the source address and the time it was fetched. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],['Does it guess when a check fails?', 'Never. A failed check is recorded as failed with its reason, and a value that is missing stays missing. Every check is kept with the time and what it found.'],['What does it cost?', 'Nothing while it is in alpha. Reading this page needs no account; making a watch needs your OpenVibe account.'],['Can my app make watches?', 'Yes. The same API the site uses creates, pauses and checks watches and reads every observation, with the watch.* capabilities granted on OpenVibe.Services.'],@@@ -614,6 +619,8 @@ ${raw(S.pickBlock({ key: 'ready', label: 'Ready-made watches', panes: readyPanes<li><strong>Polite per-site spacing.</strong> Two requests to one host never leave at once, and no two requests to a host are closer than the host’s minimum interval — whatever the watch says.</li><li><strong>Never faster than your cadence.</strong> A watch is due on the cadence you pick; a failing site is checked less often (backoff), and a site that asks Watch to wait is waited for (<code>Retry-After</code>).</li><li><strong>Every check is recorded.</strong> Read, no change, changed, condition met, a site error, a timeout, an unreadable answer, a wait, a skipped kind — the state is kept with the value, the time and a capped snapshot, and the watch is re-armed on every path.</li>++<li><strong>A quiet test before arming.</strong> A new polling watch is saved paused, read once, and shows what that read found. Nothing is sent and no cadence starts until you arm it.</li>++<li><strong>Every alert carries its evidence.</strong> The value before and after, the source address, the time it was fetched, and a link to the watch.</li></ul><p><a href="/how-it-works">The full picture, in plain words</a>.</p></div></details></section><section class="sc-sec" id="api" aria-labelledby="h-api"><details class="hm-fold"><summary><h2 id="h-api">Make watches from your own code</h2><span>the API, with curl</span></summary>${raw(showcase.code({@@@ -648,11 +655,12 @@ ${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-youconst withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));const watchRows = withLatest.map(({ row, latest }) => {const v = registry.view(row);++ const notArmed = v.status === 'paused' && row.armed_at == null;return [html`<a href="/watches/${row.id}">${v.name}</a>`,html`<code>${W.host(v.source.url)}</code>`,-- W.conditionWords(v.condition),-- html`${badge(W.statusWords(v.status), W.statusKind(v.status))}`,++ W.conditionSentence(v),++ html`${badge(notArmed ? 'Not armed yet' : W.statusWords(v.status), notArmed ? 'warn' : W.statusKind(v.status))}`,v.health.last_state? html`${W.stateWords(v.health.last_state)} <span class="muted small">${v.health.last_check_at ? time(v.health.last_check_at) : ''}</span>`: html`<span class="muted">not checked yet</span>`,@@@ -704,7 +712,7 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span clbody: html`<h1>New watch</h1><p class="muted">${TEMPLATES[template].title}.</p>${templateChooser(template)}--${watchForm({ template, fields: prefilled(template, req.query), errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,++${watchForm({ template, fields: prefilled(template, req.query), errors: {}, action: '/watches/new', submit: CREATE_SUBMIT(template) })}`,});});@@@ -724,7 +732,19 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, actilet errors = built.errors;if (!errors) {try {-- const row = await registry.create(built.body, principalOf(req));++ // A polling watch is created paused, checked once quietly, and armed by the person after++ // they have seen what the check found. Nothing is notified and no cadence starts yet.++ const polled = POLLING_TEMPLATES.has(template);++ const body = polled ? { ...built.body, status: 'paused' } : built.body;++ const row = await registry.create(body, principalOf(req));++ if (polled) {++ try {++ await check.run(row.id, { trigger: 'manual', quiet: true });++ } catch (err) {++ log.error(`[watch] ${row.id}: the quiet test check failed: ${err.stack || err}`);++ }++ return res.redirect(303, `/watches/${row.id}?tested=1`);++ }if (template === 'webhook' && ctx.webhookSources) return secretPage(req, res, registry.view(row), await ctx.webhookSources.issue(row.id), 201);return res.redirect(303, `/watches/${row.id}`);} catch (err) {@@@ -739,7 +759,7 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, acti${errors.form ? notice(errors.form, 'warn') : ''}<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and create it again.</p>${templateChooser(template)}--${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create this watch' })}`,++${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_SUBMIT(template) })}`,}, 422);});@@@ -758,6 +778,28 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Createconst obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);++ /** What a quiet test check found, in words: the value, its source and the time read. Missing parts are left out. */++ function testCheckNotice(runRow, observation, view) {++ const run = check.runView(runRow);++ const from = view.source.url || (view.source.probe && view.source.probe.target) || null;++ const at = observation ? time(new Date(Number(observation.observed_at)).toISOString()) : null;++ const read = run.observations && observation++ ? html` It read ${W.valueWords(observation.value, 160)}${from ? html` from <code>${from}</code>` : ''}${at ? html` at ${at}` : ''}.`++ : '';++ const why = !run.observations && run.detail ? html` ${String(run.detail).replace(/\.?$/, '.')}` : '';++ const holds = observation && Boolean(Number(observation.condition_met));++ return notice(html`<strong>Test check:</strong> ${badge(W.stateWords(run.state, run), W.stateKind(run.state))}${read || why ? html` —` : ''}${read}${why} Nothing was sent.${holds ? html` This value already satisfies the condition.` : ''}`,++ W.stateKind(run.state) === 'bad' ? 'warn' : 'info');++ }++++ /** The evidence an alert carried, from the request stored for its action (notification keeps it in rich_content). */++ function carriedEvidence(action) {++ let request = action.request || {};++ if (typeof request === 'string') { try { request = JSON.parse(request); } catch { request = {}; } }++ if (action.kind === 'notification') return request.rich_content && request.rich_content.evidence;++ return request.evidence || (request.body && request.body.evidence) || null;++ }++r.get('/watches/:id', async (req, res) => {if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);const row = await owned(req, res);@@@ -792,19 +834,31 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create});const ACT_WORDS = { queued: ['waiting to send', 'info'], sent: ['sent', 'ok'], skipped: ['not sent', 'warn'], failed: ['failed', 'bad'], unavailable: ['not available yet', 'warn'] };-- const actRows = acts.map((a) => [-- time(a.created_at),-- a.kind === 'notification' ? 'Notification' : html`<code>${a.kind}</code>`,-- badge((ACT_WORDS[a.state] || [a.state])[0], (ACT_WORDS[a.state] || [null, 'info'])[1]),-- a.error ? html`<span class="muted small">${a.error}</span>` : (a.attempts > 1 ? html`<span class="muted small">${a.attempts} attempts</span>` : ''),-- ]);++ const actRows = acts.map((a) => {++ const notes = [];++ if (a.error) notes.push(html`<span class="muted small">${a.error}</span>`);++ if (a.attempts > 1) notes.push(html`<span class="muted small">${a.attempts} attempts</span>`);++ const evidence = W.evidenceWords(carriedEvidence(a));++ if (evidence) notes.push(evidence);++ return [++ time(a.created_at),++ a.kind === 'notification' ? 'Notification' : html`<code>${a.kind}</code>`,++ badge((ACT_WORDS[a.state] || [a.state])[0], (ACT_WORDS[a.state] || [null, 'info'])[1]),++ notes.length ? html`${notes.map((n, i) => (i ? html`<br>${n}` : n))}` : '',++ ];++ });const ran = String(req.query.ran || '');++ const unarmed = v.status === 'paused' && row.armed_at == null;++ // While a watch is unarmed its only runs are the quiet test checks the site ran, so the latest++ // run is the test whose result the person has not armed past yet.++ const testRun = unarmed && runs.length ? runs[0] : null;++ const testObs = testRun ? obs.find((o) => o.check_run_id === testRun.id) || null : null;const actionForms = html`<div class="actions">${v.status === 'active'? html`<form class="inline" method="post" action="/watches/${v.id}/pause"><button class="btn" type="submit">Pause</button></form>`-- : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="btn" type="submit">Resume</button></form>`}--<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>++ : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="${unarmed ? 'btn primary' : 'btn'}" type="submit">${unarmed ? 'Arm this watch' : 'Resume'}</button></form>`}++${unarmed ? '' : html`<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>`}<a class="btn" href="/watches/${v.id}/edit">Edit</a><form class="inline" method="post" action="/watches/${v.id}/delete"><button class="btn danger" type="submit">Delete</button></form></div>`;@@@ -817,8 +871,9 @@ ${v.status === 'active'body: html`<h1>${v.name}</h1><p class="in-words">${W.inWords(v)}</p>${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}++${testRun ? testCheckNotice(testRun, testObs, v) : ''}<dl class="facts">--<dt>Status</dt><dd>${badge(W.statusWords(v.status), W.statusKind(v.status))}</dd>++<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>${v.source.kind === 'webhook'? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">The secret was shown once, when the watch was made. A new one replaces it at once.</span></dd>`: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}@@@ -830,6 +885,7 @@ ${v.health.consecutive_failures ? html`<dt>Failing</dt><dd>${v.health.consecutiv<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd></dl>${actionForms}++${unarmed ? html`<p class="muted small">This watch has not started its cadence. Arm it to begin checking; the test check is not repeated by itself.</p>` : ''}<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet. Watch records one when a check reads a value.' })}<nav class="pager">${nextObs ? html`<a class="btn" href="${nextObs}">Older observations</a>` : html`<span class="muted">That is every observation kept.</span>`}</nav></section>@@@ -1039,12 +1095,17 @@ ${table(['State', 'In plain words'], [['skipped', 'This kind of check is not run yet (or an AI reading is not supported yet). Nothing was fetched.'],['disabled', 'The watch is paused, disabled or failing, so it was not checked.'],].map(([state, words]) => [html`<code>${state}</code>`, words]))}++<h2>Before it arms</h2>++<ul>++<li><strong>The quiet test.</strong> A polling watch made on the site is saved paused and read once before it checks on a cadence. The watch page shows the value that read found and what state it recorded; nothing is sent, and no action is queued, until you arm it.</li>++<li><strong>The evidence in every alert.</strong> A notification, a webhook body and an Actor task carry the value before and after, the source address and the time the value was fetched. A part the source did not state is left out, never guessed.</li>++</ul><h2>What is honest about it</h2><ul><li>A value the source did not state is <code>null</code>; Watch never invents one, and an absent value stays absent.</li><li>A failed check is a recorded state, never a silent skip; a failed check records no observation.</li><li>A site that is unavailable is retried with backoff, not hammered.</li>--<li>The kinds that arrive later (event, webhook, Run, Node) and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot.</li>++<li>Run and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot. Event, webhook and Node watches are.</li></ul><p><a href="/watches/new?template=page">Start a watch</a> · <a href="/">Back home</a></p>`,}));@@@ -205,9 +205,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxget: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,-- action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)++ action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by, armed_at)VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,-- @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),++ @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by, @armed_at)`),};/** Every column a patch may write, and nothing else. */@@@ -369,6 +369,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxcreated_at: t,updated_at: t,updated_by: owner,++ // A watch created paused by the site has not been armed: its quiet test check is the only read++ // and the watch page offers to arm it. An active watch (the API's default) is armed at creation.++ armed_at: rec.status === 'active' ? t : null,};// The row and its event commit together: an event exists exactly when its effect does.await db.tx(async () => {@@@ -413,6 +416,11 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outboxconst rec = { ...current, ...changes };args.next_due_at = rec.status === 'active' ? arm(rec, args.updated_at) : 0;sets.push('next_due_at = @next_due_at');++ // The first resume is the arm: a watch the site tested is paused with no armed_at until now.++ if (rec.status === 'active' && row.armed_at == null) {++ args.armed_at = args.updated_at;++ sets.push('armed_at = @armed_at');++ }sets.push('updated_at = @updated_at', 'updated_by = @updated_by');const type = changes.status === 'paused' ? 'watch.watch.paused' : 'watch.watch.updated';const touched = Object.keys(changes).filter((k) => k !== 'labels');@@@ -5,7 +5,7 @@* honesty rule applied to the site: the words are derived from the stored watch and the recorded* state, never a guess, and everything goes through the escaping `html` tag.*/--const { html } = require('./html');++const { html, time } = require('./html');const CADENCES = [{ sec: 900, label: 'every 15 minutes' },@@@ -38,6 +38,19 @@ function valueWords(value, max = 90) {return html`<code title="${String(text).slice(0, 200)}">${short(text, max)}</code>`;}++/**++ * The evidence one alert carries, in words: the value before and after, the source address and when++ * the value was fetched. A part the watch did not store is left out, never filled in.++ */++function evidenceWords(evidence) {++ if (!evidence) return '';++ const before = evidence.before == null ? html`<span class="muted">nothing recorded</span>` : valueWords(evidence.before, 60);++ const after = evidence.after == null ? html`<span class="muted">nothing stated</span>` : valueWords(evidence.after, 60);++ const from = evidence.source_url ? html`, from <code>${evidence.source_url}</code>` : '';++ const fetched = evidence.fetched_at ? html`, fetched ${time(evidence.fetched_at)}` : '';++ return html`<span class="muted small">Before ${before}; after ${after}${from}${fetched}.</span>`;++}++function extractionWords(extraction) {const e = extraction || {};if (e.kind === 'text') return 'the page text';@@@ -74,30 +87,44 @@ function conditionWords(condition, { forSec = true } = {}) {return core;}--/** The watch as one sentence: "Tell me when … on …, checked every …". */--function inWords(watch) {++/** What the watch reads, as words: "the text of .price on shop.example". */++function subjectWords(watch) {const source = watch.source || {};const e = watch.extraction || {};const path = (e.kind === 'json' || e.kind === 'jsonpath') && e.value_path ? e.value_path : null;-- let what;if (source.kind === 'feed') {-- what = e.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed';-- } else if (source.kind === 'webhook') {-- what = path ? html`the value at <code>${path}</code> in a webhook delivery` : (e.kind === 'text' ? 'what a webhook delivery says' : 'a webhook delivery');-- } else if (source.kind === 'node') {++ return e.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed';++ }++ if (source.kind === 'webhook') {++ return path ? html`the value at <code>${path}</code> in a webhook delivery` : (e.kind === 'text' ? 'what a webhook delivery says' : 'a webhook delivery');++ }++ if (source.kind === 'node') {const target = (source.probe && source.probe.target) || '';-- what = /^tcp:/.test(target) ? html`<b>${host(target)}</b>, seen from your computer,` : html`${extractionWords(watch.extraction)} on <b>${host(target)}</b>, seen from your computer,`;-- } else if (source.kind === 'event') {-- what = path ? html`the value at <code>${path}</code> of a <b>${source.pattern}</b> event` : html`a <b>${source.pattern}</b> event`;-- } else {-- what = html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;++ return /^tcp:/.test(target) ? html`<b>${host(target)}</b>, seen from your computer` : html`${extractionWords(watch.extraction)} on <b>${host(target)}</b>, seen from your computer`;}-- const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';++ if (source.kind === 'event') {++ return path ? html`the value at <code>${path}</code> of a <b>${source.pattern}</b> event` : html`a <b>${source.pattern}</b> event`;++ }++ return html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;++}++++/**++ * The structured condition as one sentence without the request around it:++ * "the text of .price on shop.example drops below 20". The watch list and the watch page both use it.++ */++function conditionSentence(watch) {++ const source = watch.source || {};const c = watch.condition || {};if (source.kind === 'node' && /^tcp:/.test((source.probe && source.probe.target) || '') && c.op === 'eq' && c.value === 'down') {-- return html`Tell me when <b>${host(source.probe.target)}</b> stops answering, seen from your computer${every}.`;++ return html`<b>${host(source.probe.target)}</b> stops answering, seen from your computer`;}-- return html`Tell me when ${what} ${conditionWords(watch.condition)}${every}.`;++ return html`${subjectWords(watch)} ${conditionWords(watch.condition)}`;++}++++/** The watch as one sentence: "Tell me when … on …, checked every …". */++function inWords(watch) {++ const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';++ return html`Tell me when ${conditionSentence(watch)}${every}.`;}/** The plain words for one check state; `run` supplies the HTTP status a failure answered with. */@@@ -132,6 +159,6 @@ const statusWords = (status) => STATUS_WORDS[status] || String(status || 'unknowconst statusKind = (status) => (status === 'active' ? 'ok' : status === 'paused' ? 'warn' : status === 'failed' ? 'bad' : '');module.exports = {-- inWords, conditionWords, extractionWords, valueWords, everyWords, stateWords, stateKind,++ inWords, conditionSentence, conditionWords, extractionWords, valueWords, evidenceWords, everyWords, stateWords, stateKind,statusWords, statusKind, host, short, CADENCES, CADENCE_OPTIONS, MIN_CADENCE_SEC,};@@@ -133,9 +133,13 @@ t('a check that does not fire queues nothing; one that fires queues each actionassert.strictEqual(p.subjectId, person.sub);assert.deepStrictEqual([p.type, p.service, p.category, p.priority], ['WATCH_TRIGGERED', 'watch', 'service', 'high']);assert.strictEqual(p.title, '“Widget price” fired');-- assert.strictEqual(p.message, '7.50 (was 19.99)');++ assert.match(p.message, /^7\.50 \(was 19\.99\) · from http:\/\/127\.0\.0\.1:\d+\/price · fetched \d{4}-\d{2}-\d{2}T/);assert.strictEqual(p.url, `https://openvibe.watch/watches/${w.id}`);assert.strictEqual(p.rich_content.check_run_id, out.run.id);++ // The alert carries its evidence: before and after, the source and when it was fetched.++ assert.deepStrictEqual(p.rich_content.evidence, {++ before: '19.99', after: '7.50', source_url: `${web.origin}/price`, fetched_at: p.rich_content.observed_at,++ });assert.strictEqual((await acts(w.id))[0].state, 'sent');await svc.actions.tick();@@@ -220,6 +224,9 @@ t('a webhook action POSTs the firing, signed as an Events delivery; the receiverassert.deepStrictEqual([body.type, body.watch.id, body.watch.name, body.check_run_id], ['watch.watch.triggered', w.id, 'hooked', out.run.id]);assert.strictEqual(body.watch.url, `https://openvibe.watch/watches/${w.id}`);assert.deepStrictEqual([body.observation.value, body.observation.previous_value], ['7.50', '19.99']);++ assert.deepStrictEqual(body.evidence, {++ before: '19.99', after: '7.50', source_url: `${web.origin}/price`, fetched_at: body.observation.observed_at,++ });[row] = await acts(w.id);assert.deepStrictEqual([row.state, row.last_error], ['sent', null]);const stored = await svc.db.prepare('SELECT request FROM action_runs WHERE rid = ?').get(row.rid);@@@ -305,6 +312,8 @@ t('an actor-task action gives Actor a task for the owner, with the firing as conassert.strictEqual(first.budgetUsd, 0.1);assert.match(first.task, /^Buy two if it is still in stock\.\n\nContext: your watch “cheap widget” fired at /);assert.match(first.task, /Value: 7\.50 \(was 19\.99\)\./);++ assert.match(first.task, new RegExp(`Source: ${web.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/price\\.`));++ assert.match(first.task, /Fetched: \d{4}-\d{2}-\d{2}T/);assert.ok(first.task.includes(`https://openvibe.watch/watches/${w.id}`));const [row] = await acts(w.id);assert.deepStrictEqual([row.state, row.last_error], ['sent', 'Actor task tsk_01JAB2C3D4E5F6G7H8J9K0MN02']);@@@ -460,6 +469,21 @@ t('readiness counts the queue, and the watch page says what the watch did when iassert.match(body, /What it did when it fired/);assert.match(body, /Notification/);assert.match(body, /class="badge ok">sent</);++ // The page shows what the alert carried: before, after, source and fetch time.++ assert.match(body, /Before <code[^>]*>19\.99<\/code>; after <code[^>]*>7\.50<\/code>/);++ assert.match(body, new RegExp(`from <code>${web.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/price<\\/code>, fetched <time`));++});++++t('an alert leaves out the source and fetched lines when the watch stored neither', () => {++ const { notificationFor } = require('../server/actions');++ const p = notificationFor({++ config: { baseUrl: 'https://openvibe.watch' },++ watch: { id: 'wch_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'an event', source: { kind: 'event', pattern: 'live.stream.started' } },++ run: { id: 'ckr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },++ action: {}, value: 'v1', previousValue: undefined, observedAt: null,++ });++ assert.strictEqual(p.message, 'v1', 'no invented URL or time');++ assert.deepStrictEqual(p.rich_content.evidence, { before: null, after: 'v1', source_url: null, fetched_at: null });});t('done', async () => { await web.close(); await svc.stop(); });@@@ -67,7 +67,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>t('rows wait with the relay off and an aborted change has no event', async () => {// The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).-- await db.migrate({ dir: migrations, log: silent });++ // windowDays 0: this simulated old database applies the 0013 contract at once, then today's 0014.++ await db.migrate({ dir: migrations, windowDays: 0, log: silent });svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });await assert.rejects(db.tx(async () => {@@@ -23,7 +23,12 @@ const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';t('boot', async () => {web = await site({ '/price': () => ({ body: '<html><body><span class="price">19.99</span></body></html>' }) });-- svc = await boot();++ // The quiet test runs on create, so an example.org address must fail fast and never reach the internet.++ svc = await boot({++ lookupImpl: (hostname, _options, callback) => (hostname === '127.0.0.1'++ ? callback(null, [{ address: '127.0.0.1', family: 4 }])++ : callback(Object.assign(new Error(`test: ${hostname} is not fetched`), { code: 'ENOTFOUND' }))),++ });me = svc.network.addUser('ada');other = svc.network.addUser('grace');ME = userToken(me.subject);@@@ -308,6 +313,67 @@ t('another person\'s watch is 404 on every page and form', async () => {assert.match(mine.text, /Mine/);});++t('a polling watch is created paused, tested quietly once, and armed by the person', async () => {++ const owner = svc.network.addUser('quiet-tester');++ const cookie = await svc.signIn(owner);++ const token = userToken(owner.subject);++ const hitsBefore = web.hits('/price').length;++ const made = await svc.get('/watches/new', {++ cookie,++ form: { template: 'price', name: 'Quiet price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900', notify: 'on' },++ });++ assert.strictEqual(made.status, 303, made.text);++ const id = indexOf(made);++ assert.match(made.headers.get('location'), /\?tested=1$/, 'the created watch opens its test result');++ assert.strictEqual(web.hits('/price').length, hitsBefore + 1, 'the test check fetched the page once, not twice');++++ const view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token })).body.watch;++ assert.strictEqual(view.status, 'paused', 'nothing checks on a cadence until the person arms it');++ assert.ok(view.health.last_check_at, 'the test check is recorded');++ assert.strictEqual(Number((await svc.db.prepare('SELECT COUNT(*) AS n FROM action_runs WHERE watch_id = ?').get(id)).n), 0, 'no action was queued by the test');++ const run = await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid DESC LIMIT 1').get(id);++ assert.deepStrictEqual([run.trigger, Number(run.observations), Number(run.triggers)], ['manual', 1, 0]);++++ const page = await svc.get(`/watches/${id}?tested=1`, { cookie });++ assert.match(page.text, /Test check:/);++ assert.match(page.text, /It read <code[^>]*>19\.99<\/code> from <code>[^<]*\/price<\/code> at <time/);++ assert.match(page.text, /This value already satisfies the condition/);++ assert.match(page.text, /Nothing was sent/);++ assert.match(page.text, /Arm this watch/);++ assert.match(page.text, /Not armed yet/, 'the watch says it has not started its cadence');++ const plain = await svc.get(`/watches/${id}`, { cookie });++ assert.match(plain.text, /Test check:/, 'the test result stays visible until the watch is armed');++++ // The watch list says the structured condition in words, not just the operator.++ const list = await svc.get('/watches', { cookie });++ assert.match(list.text, /the text of <code>\.price<\/code> on <b>127\.0\.0\.1:\d+<\/b> drops below <b>20<\/b>/);++ assert.match(list.text, /Not armed yet/, 'the list says a tested watch has not started its cadence');++++ const armed = await svc.get(`/watches/${id}/resume`, { cookie, form: {} });++ assert.strictEqual(armed.status, 303);++ const armedView = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token })).body.watch;++ assert.strictEqual(armedView.status, 'active');++ assert.ok(armedView.health.next_due_at, 'arming starts the cadence');++ assert.ok((await svc.db.prepare('SELECT armed_at FROM watches WHERE id = ?').get(id)).armed_at, 'arming is recorded');++});++++t('a quiet test that reads nothing shows the failure and no value line', async () => {++ const owner = svc.network.addUser('quiet-failer');++ const cookie = await svc.signIn(owner);++ const made = await svc.get('/watches/new', {++ cookie,++ form: { template: 'page', name: 'Nowhere', url: 'https://nowhere.example/page', cadence: '3600' },++ });++ assert.strictEqual(made.status, 303, made.text);++ const id = indexOf(made);++ const page = await svc.get(`/watches/${id}?tested=1`, { cookie });++ assert.match(page.text, /Test check:/);++ assert.match(page.text, /The site answered an error/, 'the failure is stated, not hidden');++ assert.doesNotMatch(page.text, /It read /, 'no value is claimed when the check read none');++ assert.match(page.text, /Nothing was sent/);++ assert.match(page.text, /Arm this watch/, 'the person may still arm it after seeing a failed test');++});++t('pause, resume, check now (against a stub site), and delete', async () => {const created = await create({ template: 'price', name: 'Stub price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });const id = indexOf(created);@@@ -325,10 +391,11 @@ t('pause, resume, check now (against a stub site), and delete', async () => {const resumed = await svc.get(`/watches/${id}/resume`, { cookie: session, form: {} });assert.strictEqual(resumed.status, 303);++ const hitsBefore = web.hits('/price').length;const ran = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });assert.strictEqual(ran.status, 303);assert.match(ran.headers.get('location'), /ran=condition_met/, '19.99 is below 20: the condition fired');-- assert.strictEqual(web.hits('/price').length, 1);++ assert.strictEqual(web.hits('/price').length, hitsBefore + 1, 'the manual check fetched the page once');const detail = await svc.get(`/watches/${id}`, { cookie: session });assert.match(detail.text, /19\.99/, 'the observation history shows the value');
@@ -3,6 +3,21 @@
@@ -3,6 +3,21 @@
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
What shipped on OpenVibe.Watch, newest first. The plan is T18 ("Watch complete"); each entry names
the step it landed.
the step it landed.
+## 2026-11-01 — the condition in words, a quiet test before arming, and the evidence in every alert
+
+- The watch list and each watch page say the watch's structured condition in one sentence — what it reads, where,
+ and what makes it fire — instead of the condition operator alone (`server/render/watch.js`, `server/http/pages.js`).
+- A polling watch made on the site is saved paused and read once by a quiet test check before it arms. The watch page
+ shows the value that check found (or the state it recorded and why, when it found none), nothing is notified and no
+ action is queued, and the person arms it when ready. The first arm is stored in `watches.armed_at`
+ (migration `0014_armed_at.sql`, `server/check.js`, `server/http/pages.js`).
+- Every alert now carries its evidence: a notification's message and `rich_content`, a webhook body, an Actor task's
+ context and a run-job's firing argument state the value before and after, the source address and the time the value
+ was fetched. A part the watch did not store is left out (`server/actions.js`). The watch page's alert history shows
+ the same evidence (`server/http/pages.js`).
+- Tests: `test/site.test.js` (the quiet test, the failed test, arming, the condition in words, the arm badge),
+ `test/actions.test.js` (the notification and webhook evidence, the omitted line, the page history).
+
## 2026-10-31 — a person's Network session token on the API
## 2026-10-31 — a person's Network session token on the API
- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the same
- The watch API accepts a signed-in person's Network session token (`aud: openvibe.network`) as a Bearer, the same
new file mode 100644
new file mode 100644
@@ -0,0 +1,7 @@
@@ -0,0 +1,7 @@
+-- phase: expand
+-- When a watch was first armed. A watch the site creates to run its quiet test is paused with
+-- armed_at NULL: the test check is the only read, and resuming (arming) sets it. Watches that
+-- existed before this column were armed at creation, so they are backfilled rather than shown as
+-- untested: nothing about a watch already in the registry is invented.
+ALTER TABLE watches ADD COLUMN IF NOT EXISTS armed_at bigint;
+UPDATE watches SET armed_at = created_at WHERE armed_at IS NULL;
@@ -11,7 +11,8 @@
@@ -11,7 +11,8 @@
*
*
* Delivery, by kind:
* Delivery, by kind:
* notification the owner gets one OpenVibe notification through Network (openvibe-sdk/notifications, type
* notification the owner gets one OpenVibe notification through Network (openvibe-sdk/notifications, type
- * WATCH_TRIGGERED): the watch's name, the value and a link to the watch page. A person who switched
+ * WATCH_TRIGGERED): the watch's name, the value before and after, the source address, the time it was
+ * fetched and a link to the watch page. A person who switched
* the category off is Network's decision and counts as sent; an owner Network does not know (a
* the category off is Network's decision and counts as sent; an owner Network does not know (a
* deleted account) is `skipped`. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then
* deleted account) is `skipped`. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then
* `failed`. Network keeps no idempotency record for a push, so a lost answer can repeat one: at least
* `failed`. Network keeps no idempotency record for a push, so a lost answer can repeat one: at least
@@ -19,7 +20,7 @@
@@ -19,7 +20,7 @@
* event `watch.watch.triggered` is emitted with every firing, in the run's own commit, so that action is
* event `watch.watch.triggered` is emitted with every firing, in the run's own commit, so that action is
* `sent` as it is queued (it needs a person as owner, as the event does). Any other event type is not
* `sent` as it is queued (it needs a person as owner, as the event does). Any other event type is not
* declared in Watch's manifest: `unavailable`.
* declared in Watch's manifest: `unavailable`.
- * webhook a POST of the firing (webhookFor below) to the action's https URL through the SSRF guard
+ * webhook a POST of the firing (webhookFor below, with its evidence bundle) to the action's https URL through the SSRF guard
* (server/net/post.js), signed with the key its secret_env names exactly as OpenVibe.Events signs a
* (server/net/post.js), signed with the key its secret_env names exactly as OpenVibe.Events signs a
* delivery (openvibe-sdk/events signDeliveryHeaders: X-OpenVibe-Timestamp + X-OpenVibe-Signature-V2,
* delivery (openvibe-sdk/events signDeliveryHeaders: X-OpenVibe-Timestamp + X-OpenVibe-Signature-V2,
* which a receiver checks with the SDK's verifier). X-OpenVibe-Delivery-Id (`<check run>.<action
* which a receiver checks with the SDK's verifier). X-OpenVibe-Delivery-Id (`<check run>.<action
@@ -29,7 +30,7 @@
@@ -29,7 +30,7 @@
* set (32 characters or more), checked when it is queued and again at delivery: otherwise
* set (32 characters or more), checked when it is queued and again at delivery: otherwise
* `unavailable`.
* `unavailable`.
* actor-task a task for the owner on OpenVibe.Actor (server/actor.js): the action's brief, with the firing as
* actor-task a task for the owner on OpenVibe.Actor (server/actor.js): the action's brief, with the firing as
- * context, under the owner's own allowance (Watch names them in X-OV-Subject; Actor records Watch as
+ * context (before and after, source and fetch time), under the owner's own allowance (Watch names them in X-OV-Subject; Actor records Watch as
* `via`), at most the action's budget_usd. The idempotency key `watch.<check run>.<action index>`
* `via`), at most the action's budget_usd. The idempotency key `watch.<check run>.<action index>`
* makes a retried delivery the same task. Created is `sent` (the task id is recorded). A request
* makes a retried delivery the same task. Created is `sent` (the task id is recorded). A request
* Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once; anything
* Actor refuses as invalid (over the tier's budget, an unknown agent) is `failed` at once; anything
@@ -92,10 +93,28 @@ function valueText(v) {
@@ -92,10 +93,28 @@ function valueText(v) {
try { return JSON.stringify(v); } catch { return String(v); }
try { return JSON.stringify(v); } catch { return String(v); }
}
}
+/**
+ * The evidence every alert carries: the value before and after, the source address it came from and
+ * when it was fetched. A part the watch does not store is null, and the line is left out of the words.
+ */
+function evidenceFor({ watch, value, previousValue, observedAt }) {
+ const source = (watch && watch.source) || {};
+ return {
+ before: previousValue === undefined ? null : previousValue,
+ after: value === undefined ? null : value,
+ source_url: source.url || (source.probe && source.probe.target) || null,
+ fetched_at: observedAt ? new Date(observedAt).toISOString() : null,
+ };
+}
+
/** The notification a fired watch sends its owner (network.notification-push-request@1 fields). */
/** The notification a fired watch sends its owner (network.notification-push-request@1 fields). */
function notificationFor({ config, watch, run, action, value, previousValue, observedAt }) {
function notificationFor({ config, watch, run, action, value, previousValue, observedAt }) {
const now = valueText(value);
const now = valueText(value);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
+ const evidence = evidenceFor({ watch, value, previousValue, observedAt });
+ const facts = [before !== null && before !== now ? `${now} (was ${before})` : now];
+ if (evidence.source_url) facts.push(`from ${evidence.source_url}`);
+ if (evidence.fetched_at) facts.push(`fetched ${evidence.fetched_at}`);
return {
return {
type: 'WATCH_TRIGGERED',
type: 'WATCH_TRIGGERED',
service: 'watch',
service: 'watch',
@@ -103,13 +122,18 @@ function notificationFor({ config, watch, run, action, value, previousValue, obs
@@ -103,13 +122,18 @@ function notificationFor({ config, watch, run, action, value, previousValue, obs
priority: action.priority || 'normal',
priority: action.priority || 'normal',
icon: '👁',
icon: '👁',
title: clip(`“${watch.name}” fired`, TITLE_MAX),
title: clip(`“${watch.name}” fired`, TITLE_MAX),
- message: clip(before !== null && before !== now ? `${now} (was ${before})` : now, MESSAGE_MAX),
+ message: clip(facts.join(' · '), MESSAGE_MAX),
url: `${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`,
url: `${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`,
- rich_content: { watch_id: watch.id, check_run_id: run.id, observed_at: observedAt ? new Date(observedAt).toISOString() : null },
+ rich_content: {
+ watch_id: watch.id,
+ check_run_id: run.id,
+ observed_at: observedAt ? new Date(observedAt).toISOString() : null,
+ evidence,
+ },
};
};
}
}
-/** The body a webhook action POSTs: the firing, as the watch.watch.triggered event states it. */
+/** The body a webhook action POSTs: the firing, as the watch.watch.triggered event states it, with its evidence. */
function webhookFor({ config, watch, run, value, previousValue, observedAt }) {
function webhookFor({ config, watch, run, value, previousValue, observedAt }) {
return {
return {
type: TRIGGERED,
type: TRIGGERED,
@@ -121,6 +145,7 @@ function webhookFor({ config, watch, run, value, previousValue, observedAt }) {
@@ -121,6 +145,7 @@ function webhookFor({ config, watch, run, value, previousValue, observedAt }) {
value: value === undefined ? null : value,
value: value === undefined ? null : value,
previous_value: previousValue === undefined ? null : previousValue,
previous_value: previousValue === undefined ? null : previousValue,
},
},
+ evidence: evidenceFor({ watch, value, previousValue, observedAt }),
};
};
}
}
@@ -142,11 +167,15 @@ function actorContextFor({ config, watch, value, previousValue, observedAt }, ma
@@ -142,11 +167,15 @@ function actorContextFor({ config, watch, value, previousValue, observedAt }, ma
const now = valueText(value);
const now = valueText(value);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
const before = previousValue === undefined || previousValue === null ? null : valueText(previousValue);
const when = observedAt ? new Date(observedAt).toISOString() : 'just now';
const when = observedAt ? new Date(observedAt).toISOString() : 'just now';
+ const evidence = evidenceFor({ watch, value, previousValue, observedAt });
const first = `Context: your watch “${watch.name}” fired at ${when}.`;
const first = `Context: your watch “${watch.name}” fired at ${when}.`;
const last = `Watch: ${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`;
const last = `Watch: ${config.baseUrl}/watches/${encodeURIComponent(watch.id)}`;
const valueLine = `Value: ${now}${before !== null && before !== now ? ` (was ${before})` : ''}.`;
const valueLine = `Value: ${now}${before !== null && before !== now ? ` (was ${before})` : ''}.`;
- const room = Math.max(0, maxLength - first.length - last.length - 2);
- return [first, valueLine.length > room ? `${valueLine.slice(0, Math.max(0, room - 1))}…` : valueLine, last].join('\n').slice(0, maxLength);
+ const fromLine = evidence.source_url ? `Source: ${evidence.source_url}.` : null;
+ const fetchedLine = evidence.fetched_at ? `Fetched: ${evidence.fetched_at}.` : null;
+ const tail = [fromLine, fetchedLine, last].filter(Boolean);
+ const room = Math.max(0, maxLength - first.length - tail.join('\n').length - 2);
+ return [first, valueLine.length > room ? `${valueLine.slice(0, Math.max(0, room - 1))}…` : valueLine, ...tail].join('\n').slice(0, maxLength);
}
}
/**
/**
@@ -157,6 +186,7 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedA
@@ -157,6 +186,7 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedA
const job = {};
const job = {};
for (const k of JOB_FIELDS) if (action[k] !== undefined && action[k] !== null) job[k] = action[k];
for (const k of JOB_FIELDS) if (action[k] !== undefined && action[k] !== null) job[k] = action[k];
if (action.firing_arg) {
if (action.firing_arg) {
+ const evidence = evidenceFor({ watch, value, previousValue, observedAt });
job.args = {
job.args = {
...(job.args || {}),
...(job.args || {}),
[action.firing_arg]: {
[action.firing_arg]: {
@@ -166,6 +196,8 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedA
@@ -166,6 +196,8 @@ function runJobFor({ config, watch, run, action, value, previousValue, observedA
observed_at: observedAt ? new Date(observedAt).toISOString() : null,
observed_at: observedAt ? new Date(observedAt).toISOString() : null,
value: value === undefined ? null : value,
value: value === undefined ? null : value,
previous_value: previousValue === undefined ? null : previousValue,
previous_value: previousValue === undefined ? null : previousValue,
+ source_url: evidence.source_url,
+ fetched_at: evidence.fetched_at,
},
},
};
};
}
}
@@ -195,7 +227,7 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -195,7 +227,7 @@ function createActions({ db, config, notifications = null, post = null, actor =
retry: db.prepare('UPDATE action_runs SET next_attempt_at = ?, last_error = ? WHERE rid = ?'),
retry: db.prepare('UPDATE action_runs SET next_attempt_at = ?, last_error = ? WHERE rid = ?'),
counts: db.prepare(`SELECT COUNT(*) FILTER (WHERE state = 'queued') AS queued, COUNT(*) FILTER (WHERE state = 'failed') AS failed,
counts: db.prepare(`SELECT COUNT(*) FILTER (WHERE state = 'queued') AS queued, COUNT(*) FILTER (WHERE state = 'failed') AS failed,
COUNT(*) FILTER (WHERE state = 'sent') AS sent FROM action_runs`),
COUNT(*) FILTER (WHERE state = 'sent') AS sent FROM action_runs`),
- recent: db.prepare(`SELECT check_run_id, action_index, kind, state, attempts, last_error, created_at, finished_at FROM action_runs
+ recent: db.prepare(`SELECT check_run_id, action_index, kind, state, attempts, last_error, request, created_at, finished_at FROM action_runs
WHERE watch_id = ? ORDER BY rid DESC LIMIT ?`),
WHERE watch_id = ? ORDER BY rid DESC LIMIT ?`),
};
};
@@ -203,6 +235,7 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -203,6 +235,7 @@ function createActions({ db, config, notifications = null, post = null, actor =
async function queue({ watch, run, owner, value, previousValue, observedAt }) {
async function queue({ watch, run, owner, value, previousValue, observedAt }) {
const list = Array.isArray(watch.action) ? watch.action : [];
const list = Array.isArray(watch.action) ? watch.action : [];
const at = now();
const at = now();
+ const evidence = evidenceFor({ watch, value, previousValue, observedAt });
for (let i = 0; i < list.length; i++) {
for (let i = 0; i < list.length; i++) {
const action = list[i] || {};
const action = list[i] || {};
const kind = String(action.kind || '');
const kind = String(action.kind || '');
@@ -242,6 +275,8 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -242,6 +275,8 @@ function createActions({ db, config, notifications = null, post = null, actor =
} else {
} else {
state = 'unavailable'; error = `watch.action_unavailable: unknown action kind ${kind || '(none)'}`;
state = 'unavailable'; error = `watch.action_unavailable: unknown action kind ${kind || '(none)'}`;
}
}
+ // The UI shows the same evidence the alert carried; a notification keeps it in rich_content.
+ if (Object.keys(request).length && kind !== 'notification') request = { ...request, evidence };
await st.insert.run(watch.id, run.id, i, kind || 'unknown', String(owner || ''), JSON.stringify(request), state, error, at, state === 'queued' ? null : at);
await st.insert.run(watch.id, run.id, i, kind || 'unknown', String(owner || ''), JSON.stringify(request), state, error, at, state === 'queued' ? null : at);
}
}
}
}
@@ -415,6 +450,12 @@ function createActions({ db, config, notifications = null, post = null, actor =
@@ -415,6 +450,12 @@ function createActions({ db, config, notifications = null, post = null, actor =
return (await st.recent.all(watchId, limit)).map((r) => ({
return (await st.recent.all(watchId, limit)).map((r) => ({
check_run_id: r.check_run_id, kind: r.kind, state: r.state, attempts: Number(r.attempts),
check_run_id: r.check_run_id, kind: r.kind, state: r.state, attempts: Number(r.attempts),
error: r.last_error || null,
error: r.last_error || null,
+ // The delivery body the action carried (the evidence bundle), so the page can show it.
+ request: (() => {
+ let request = r.request;
+ if (typeof request === 'string') { try { request = JSON.parse(request); } catch { request = null; } }
+ return request == null ? null : request;
+ })(),
created_at: new Date(Number(r.created_at)).toISOString(),
created_at: new Date(Number(r.created_at)).toISOString(),
finished_at: r.finished_at == null ? null : new Date(Number(r.finished_at)).toISOString(),
finished_at: r.finished_at == null ? null : new Date(Number(r.finished_at)).toISOString(),
}));
}));
@@ -186,7 +186,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -186,7 +186,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
// ── One check ────────────────────────────────────────────────────────────────────────────
// ── One check ────────────────────────────────────────────────────────────────────────────
- async function runOnce(row, trigger, input = null) {
+ async function runOnce(row, trigger, input = null, { quiet = false } = {}) {
const watch = registry.fromRow(row);
const watch = registry.fromRow(row);
const startedAt = now();
const startedAt = now();
const carrier = watch.source && watch.source.kind ? watch.source.kind : null;
const carrier = watch.source && watch.source.kind ? watch.source.kind : null;
@@ -197,12 +197,13 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -197,12 +197,13 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
...baseRun(watch, trigger, carrier, startedAt),
...baseRun(watch, trigger, carrier, startedAt),
state, error_code, detail, finished_at: now(),
state, error_code, detail, finished_at: now(),
};
};
- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });
+ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures: failuresBefore + 1 });
return await settle({ watch, run, failures: failuresBefore + 1, events });
return await settle({ watch, run, failures: failuresBefore + 1, events });
};
};
- // A watch that is not active is never fetched, and says so.
- if (row.status !== 'active') {
+ // A watch that is not active is never fetched, and says so. The one exception is the site's
+ // quiet test of a just-created paused watch: it runs once with `quiet` and sends nothing.
+ if (row.status !== 'active' && !(quiet && row.status === 'paused')) {
return { disabled: true, ...(await skip('disabled', `watch_${row.status}`, `the watch is ${row.status}`)) };
return { disabled: true, ...(await skip('disabled', `watch_${row.status}`, `the watch is ${row.status}`)) };
}
}
@@ -249,7 +250,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -249,7 +250,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
};
};
const failures = failuresBefore + 1;
const failures = failuresBefore + 1;
- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });
+ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures });
const result = await settle({ watch, run, failures, events, endpoint, notBefore: out.notBefore || 0 });
const result = await settle({ watch, run, failures, events, endpoint, notBefore: out.notBefore || 0 });
return { ...result, rateLimitedUntil: out.notBefore || null };
return { ...result, rateLimitedUntil: out.notBefore || null };
}
}
@@ -285,7 +286,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -285,7 +286,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
finished_at: finishedAt,
finished_at: finishedAt,
};
};
const failures = failuresBefore + 1;
const failures = failuresBefore + 1;
- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures });
+ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures });
return await settle({ watch, run, failures, events });
return await settle({ watch, run, failures, events });
}
}
} else {
} else {
@@ -297,7 +298,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -297,7 +298,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
detail: out.detail ? String(out.detail).slice(0, 500) : null,
detail: out.detail ? String(out.detail).slice(0, 500) : null,
bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
bytes: out.bytes ?? null, raw_body_hash: out.raw_body_hash ?? null, finished_at: finishedAt,
};
};
- const events = await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });
+ const events = quiet ? [] : await eventsFor({ watch, owner: row.owner_sub, run, failures: 0 });
return await settle({ watch, run, failures: 0, successAt: finishedAt, events, endpoint });
return await settle({ watch, run, failures: 0, successAt: finishedAt, events, endpoint });
}
}
value = held.value;
value = held.value;
@@ -323,7 +324,8 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -323,7 +324,8 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
// with its finish time; the firing run's start is always earlier, so the last firing is compared by when it
// with its finish time; the firing run's start is always earlier, so the last firing is compared by when it
// finished (comparing its start made every check while the condition held fire again).
// finished (comparing its start made every check while the condition held fire again).
const repeat = verdict.met && !verdict.changed && lastFired != null && verdict.since != null && Number(lastFired.finished_at) >= verdict.since;
const repeat = verdict.met && !verdict.changed && lastFired != null && verdict.since != null && Number(lastFired.finished_at) >= verdict.since;
- const met = verdict.met && !repeat;
+ // A quiet test records the value and what it found, but it fires nothing and queues no action.
+ const met = !quiet && verdict.met && !repeat;
if (verdict.error && !patternError) patternError = verdict.error;
if (verdict.error && !patternError) patternError = verdict.error;
const retentionDays = watch.retention && Number.isInteger(watch.retention.observations_days)
const retentionDays = watch.retention && Number.isInteger(watch.retention.observations_days)
@@ -359,27 +361,31 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -359,27 +361,31 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
previousValue = beforeLatest ? beforeLatest.value : null;
previousValue = beforeLatest ? beforeLatest.value : null;
}
}
- const events = await eventsFor({
+ const events = quiet ? [] : await eventsFor({
watch, owner: row.owner_sub, observation, value, met, run,
watch, owner: row.owner_sub, observation, value, met, run,
firedOn, firedAt: finishedAt, previousValue, failures: 0,
firedOn, firedAt: finishedAt, previousValue, failures: 0,
});
});
const fired = met ? { owner: row.owner_sub, value, previousValue, observedAt: firedOn ? Number(firedOn.observed_at) : finishedAt } : null;
const fired = met ? { owner: row.owner_sub, value, previousValue, observedAt: firedOn ? Number(firedOn.observed_at) : finishedAt } : null;
const result = await settle({ watch, run, failures: 0, successAt: finishedAt, events, observation, endpoint, fired });
const result = await settle({ watch, run, failures: 0, successAt: finishedAt, events, observation, endpoint, fired });
return {
return {
- ...result, met, value, verdict: { ...verdict, met, repeat },
+ ...result, met, holds: verdict.met, quiet, value, verdict: { ...verdict, met, repeat },
observation: observation ? observations.view(observation) : null,
observation: observation ? observations.view(observation) : null,
};
};
}
}
/**
/**
- * run(id, { trigger, event }) →
+ * run(id, { trigger, input, quiet }) →
* null no such watch
* null no such watch
* { busy: true } a check of this watch is already in flight
* { busy: true } a check of this watch is already in flight
* { disabled: true } the watch is paused/disabled/failed; recorded, not fetched
* { disabled: true } the watch is paused/disabled/failed; recorded, not fetched
* { unavailable: { code, detail } } the carrier (or extraction) is a later step; nothing fetched
* { unavailable: { code, detail } } the carrier (or extraction) is a later step; nothing fetched
- * { run, observation, met, value } the check ran
+ * { run, observation, met, holds, value } the check ran
+ *
+ * `quiet: true` is the site's test check of a watch it created paused: it may run once, it records
+ * the run and the observation like any check, but it emits no event, fires no condition and queues
+ * no action. It is never the API's or the scheduler's trigger.
*/
*/
- async function run(id, { trigger = 'schedule', input = null } = {}) {
+ async function run(id, { trigger = 'schedule', input = null, quiet = false } = {}) {
const key = String(id);
const key = String(id);
// Claimed before the first await, so two callers racing on one watch cannot both proceed.
// Claimed before the first await, so two callers racing on one watch cannot both proceed.
if (inflight.has(key)) return { busy: true };
if (inflight.has(key)) return { busy: true };
@@ -387,7 +393,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
@@ -387,7 +393,7 @@ function createCheck({ db, registry, config, carriers, extract, condition, obser
try {
try {
const row = await registry.get(key);
const row = await registry.get(key);
if (!row) return null;
if (!row) return null;
- return await runOnce(row, trigger, input);
+ return await runOnce(row, trigger, input, { quiet });
} finally {
} finally {
inflight.delete(key);
inflight.delete(key);
}
}
@@ -100,6 +100,9 @@ const TEMPLATES = {
@@ -100,6 +100,9 @@ const TEMPLATES = {
},
},
};
};
const TEMPLATE_NAMES = Object.keys(TEMPLATES);
const TEMPLATE_NAMES = Object.keys(TEMPLATES);
+/** The templates whose source is polled: the site can run one quiet test check before arming these. */
+const POLLING_TEMPLATES = new Set(['page', 'price', 'feed', 'json', 'lan']);
+const CREATE_SUBMIT = (template) => (POLLING_TEMPLATES.has(template) ? 'Create and test this watch' : 'Create this watch');
// The public events the event template offers (Contracts: each is `public`, so any watch may read it): which payload
// The public events the event template offers (Contracts: each is `public`, so any watch may read it): which payload
// field narrows it, and which field is new on every occurrence (so `changed` fires once per occurrence).
// field narrows it, and which field is new on every occurrence (so `changed` fires once per occurrence).
const EVENT_CHOICES = [
const EVENT_CHOICES = [
@@ -497,16 +500,18 @@ ${extractionBlock}
@@ -497,16 +500,18 @@ ${extractionBlock}
${conditionBlock}
${conditionBlock}
<fieldset><legend>${pushed ? 'How to tell you' : 'How often, and how to tell you'}</legend>
<fieldset><legend>${pushed ? 'How to tell you' : 'How often, and how to tell you'}</legend>
${pushed ? html`<p class="muted small">${template === 'event' ? 'Checked each time the event arrives (at most 60 an hour), never on a cadence.' : 'Checked each time a signed delivery arrives (at most 60 an hour), never on a cadence.'}</p>` : field('Cadence', cadence, { name: 'cadence', error: errors.cadence, hint: 'Watch never checks more often than this, and can wait longer when the site asks it to.' })}
${pushed ? html`<p class="muted small">${template === 'event' ? 'Checked each time the event arrives (at most 60 an hour), never on a cadence.' : 'Checked each time a signed delivery arrives (at most 60 an hour), never on a cadence.'}</p>` : field('Cadence', cadence, { name: 'cadence', error: errors.cadence, hint: 'Watch never checks more often than this, and can wait longer when the site asks it to.' })}
-${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'When the condition fires you get one notification on OpenVibe (your notifications on any OpenVibe site), with the value and a link here.' })}
-${checkbox('Run one of my agents on OpenVibe.Actor', { name: 'agent_on', checked: fields.agent_on === 'on', hint: 'When the condition fires, Watch gives your agent the value and a link to this watch.' })}
+${checkbox('Notify me when it fires', { name: 'notify', checked: fields.notify === 'on', hint: 'When the condition fires you get one notification on OpenVibe (your notifications on any OpenVibe site), with the before and after values, the source address, the time it was fetched and a link here.' })}
+${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 before and after values, the source address, the time it was fetched and a link to this watch.' })}
${field('Agent id', html`<input type="text" id="${idOf('agent_id')}" name="agent_id" value="${fields.agent_id}" maxlength="30" placeholder="agt_…">`, { name: 'agent_id', error: errors.agent_id, hint: 'Find the id on your agent’s OpenVibe.Actor page. Required when running an agent.' })}
${field('Agent id', html`<input type="text" id="${idOf('agent_id')}" name="agent_id" value="${fields.agent_id}" maxlength="30" placeholder="agt_…">`, { name: 'agent_id', error: errors.agent_id, hint: 'Find the id on your agent’s OpenVibe.Actor page. Required when running an agent.' })}
${field('Note for the agent (optional)', html`<textarea id="${idOf('agent_note')}" name="agent_note" maxlength="2000" rows="3">${fields.agent_note}</textarea>`, { name: 'agent_note', error: errors.agent_note })}
${field('Note for the agent (optional)', html`<textarea id="${idOf('agent_note')}" name="agent_note" maxlength="2000" rows="3">${fields.agent_note}</textarea>`, { name: 'agent_note', error: errors.agent_note })}
-${checkbox('Start a coding task on my own machine (OpenVibe.Codes)', { name: 'codes_on', checked: fields.codes_on === 'on', hint: 'When the condition fires, OpenVibe.Codes runs the task on your machine (add it on openvibe.bot/machines and start one task on openvibe.codes/run first), with the value and a link to this watch. You get the summary and the patch on its page.' })}
+${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 before and after values, the source address, the time it was fetched 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('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 })}
${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="codes_brief" maxlength="6000" rows="3" placeholder="The dependency in the value changed: update it and run the tests.">${fields.codes_brief}</textarea>`, { name: 'codes_brief', error: errors.codes_brief })}
</fieldset>
</fieldset>
<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>
<p class="form-actions"><button class="btn primary" type="submit">${submit}</button> <a href="/watches">Cancel</a></p>
-<p class="muted small">Notifications, Actor agents and coding tasks are delivered when the watch fires.</p>
+<p class="muted small">${POLLING_TEMPLATES.has(template)
+ ? 'Saving reads it once, quietly: the next page shows what the check found, and nothing is sent until you arm it.'
+ : 'Saving arms it at once: it waits for its event or signed delivery, and nothing is polled.'} Notifications, Actor agents and coding tasks are delivered when the watch fires.</p>
</form>`;
</form>`;
}
}
@@ -533,7 +538,7 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
@@ -533,7 +538,7 @@ ${field('The coding task', html`<textarea id="${idOf('codes_brief')}" name="code
['What can I watch?', 'A web page or one part of it, a price or any number on a page, an RSS or Atom feed, a value in a JSON API, a webhook that something calls, an OpenVibe event such as a channel going live, or a device on your own network.'],
['What can I watch?', 'A web page or one part of it, a price or any number on a page, an RSS or Atom feed, a value in a JSON API, a webhook that something calls, an OpenVibe event such as a channel going live, or a device on your own network.'],
['How often does it check?', 'On the cadence you pick: every 15 minutes, hourly, every 6 hours or daily. Events and webhooks are not polled at all: the watch wakes the moment they arrive.'],
['How often does it check?', 'On the cadence you pick: every 15 minutes, hourly, every 6 hours or daily. Events and webhooks are not polled at all: the watch wakes the moment they arrive.'],
['Will it hammer the sites I watch?', 'No. It asks whether anything changed before downloading again, never sends two requests to one site at once, keeps a minimum gap per site, and waits when a site asks it to.'],
['Will it hammer the sites I watch?', 'No. It asks whether anything changed before downloading again, never sends two requests to one site at once, keeps a minimum gap per site, and waits when a site asks it to.'],
- ['How am I told?', 'A notification on your OpenVibe account by default. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],
+ ['How am I told?', 'A notification on your OpenVibe account by default, with the before and after values, the source address and the time it was fetched. A watch can also call your webhook, hand a task to your OpenVibe.Actor agent or start a coding task on your own machine.'],
['Does it guess when a check fails?', 'Never. A failed check is recorded as failed with its reason, and a value that is missing stays missing. Every check is kept with the time and what it found.'],
['Does it guess when a check fails?', 'Never. A failed check is recorded as failed with its reason, and a value that is missing stays missing. Every check is kept with the time and what it found.'],
['What does it cost?', 'Nothing while it is in alpha. Reading this page needs no account; making a watch needs your OpenVibe account.'],
['What does it cost?', 'Nothing while it is in alpha. Reading this page needs no account; making a watch needs your OpenVibe account.'],
['Can my app make watches?', 'Yes. The same API the site uses creates, pauses and checks watches and reads every observation, with the watch.* capabilities granted on OpenVibe.Services.'],
['Can my app make watches?', 'Yes. The same API the site uses creates, pauses and checks watches and reads every observation, with the watch.* capabilities granted on OpenVibe.Services.'],
@@ -614,6 +619,8 @@ ${raw(S.pickBlock({ key: 'ready', label: 'Ready-made watches', panes: readyPanes
@@ -614,6 +619,8 @@ ${raw(S.pickBlock({ key: 'ready', label: 'Ready-made watches', panes: readyPanes
<li><strong>Polite per-site spacing.</strong> Two requests to one host never leave at once, and no two requests to a host are closer than the host’s minimum interval — whatever the watch says.</li>
<li><strong>Polite per-site spacing.</strong> Two requests to one host never leave at once, and no two requests to a host are closer than the host’s minimum interval — whatever the watch says.</li>
<li><strong>Never faster than your cadence.</strong> A watch is due on the cadence you pick; a failing site is checked less often (backoff), and a site that asks Watch to wait is waited for (<code>Retry-After</code>).</li>
<li><strong>Never faster than your cadence.</strong> A watch is due on the cadence you pick; a failing site is checked less often (backoff), and a site that asks Watch to wait is waited for (<code>Retry-After</code>).</li>
<li><strong>Every check is recorded.</strong> Read, no change, changed, condition met, a site error, a timeout, an unreadable answer, a wait, a skipped kind — the state is kept with the value, the time and a capped snapshot, and the watch is re-armed on every path.</li>
<li><strong>Every check is recorded.</strong> Read, no change, changed, condition met, a site error, a timeout, an unreadable answer, a wait, a skipped kind — the state is kept with the value, the time and a capped snapshot, and the watch is re-armed on every path.</li>
+<li><strong>A quiet test before arming.</strong> A new polling watch is saved paused, read once, and shows what that read found. Nothing is sent and no cadence starts until you arm it.</li>
+<li><strong>Every alert carries its evidence.</strong> The value before and after, the source address, the time it was fetched, and a link to the watch.</li>
</ul>
</ul>
<p><a href="/how-it-works">The full picture, in plain words</a>.</p></div></details></section>
<p><a href="/how-it-works">The full picture, in plain words</a>.</p></div></details></section>
<section class="sc-sec" id="api" aria-labelledby="h-api"><details class="hm-fold"><summary><h2 id="h-api">Make watches from your own code</h2><span>the API, with curl</span></summary>${raw(showcase.code({
<section class="sc-sec" id="api" aria-labelledby="h-api"><details class="hm-fold"><summary><h2 id="h-api">Make watches from your own code</h2><span>the API, with curl</span></summary>${raw(showcase.code({
@@ -648,11 +655,12 @@ ${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-you
@@ -648,11 +655,12 @@ ${summary ? html`<section class="sc-sec" aria-labelledby="h-yours"><h2 id="h-you
const withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));
const withLatest = await Promise.all(rows.map(async (row) => ({ row, latest: await observations.latest(row.id) })));
const watchRows = withLatest.map(({ row, latest }) => {
const watchRows = withLatest.map(({ row, latest }) => {
const v = registry.view(row);
const v = registry.view(row);
+ const notArmed = v.status === 'paused' && row.armed_at == null;
return [
return [
html`<a href="/watches/${row.id}">${v.name}</a>`,
html`<a href="/watches/${row.id}">${v.name}</a>`,
html`<code>${W.host(v.source.url)}</code>`,
html`<code>${W.host(v.source.url)}</code>`,
- W.conditionWords(v.condition),
- html`${badge(W.statusWords(v.status), W.statusKind(v.status))}`,
+ W.conditionSentence(v),
+ html`${badge(notArmed ? 'Not armed yet' : W.statusWords(v.status), notArmed ? 'warn' : W.statusKind(v.status))}`,
v.health.last_state
v.health.last_state
? html`${W.stateWords(v.health.last_state)} <span class="muted small">${v.health.last_check_at ? time(v.health.last_check_at) : ''}</span>`
? html`${W.stateWords(v.health.last_state)} <span class="muted small">${v.health.last_check_at ? time(v.health.last_check_at) : ''}</span>`
: html`<span class="muted">not checked yet</span>`,
: html`<span class="muted">not checked yet</span>`,
@@ -704,7 +712,7 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
@@ -704,7 +712,7 @@ ${older ? html`<a class="btn" href="${older}">Older watches</a>` : html`<span cl
body: html`<h1>New watch</h1>
body: html`<h1>New watch</h1>
<p class="muted">${TEMPLATES[template].title}.</p>
<p class="muted">${TEMPLATES[template].title}.</p>
${templateChooser(template)}
${templateChooser(template)}
-${watchForm({ template, fields: prefilled(template, req.query), errors: {}, action: '/watches/new', submit: 'Create this watch' })}`,
+${watchForm({ template, fields: prefilled(template, req.query), errors: {}, action: '/watches/new', submit: CREATE_SUBMIT(template) })}`,
});
});
});
});
@@ -724,7 +732,19 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, acti
@@ -724,7 +732,19 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, acti
let errors = built.errors;
let errors = built.errors;
if (!errors) {
if (!errors) {
try {
try {
- const row = await registry.create(built.body, principalOf(req));
+ // A polling watch is created paused, checked once quietly, and armed by the person after
+ // they have seen what the check found. Nothing is notified and no cadence starts yet.
+ const polled = POLLING_TEMPLATES.has(template);
+ const body = polled ? { ...built.body, status: 'paused' } : built.body;
+ const row = await registry.create(body, principalOf(req));
+ if (polled) {
+ try {
+ await check.run(row.id, { trigger: 'manual', quiet: true });
+ } catch (err) {
+ log.error(`[watch] ${row.id}: the quiet test check failed: ${err.stack || err}`);
+ }
+ return res.redirect(303, `/watches/${row.id}?tested=1`);
+ }
if (template === 'webhook' && ctx.webhookSources) return secretPage(req, res, registry.view(row), await ctx.webhookSources.issue(row.id), 201);
if (template === 'webhook' && ctx.webhookSources) return secretPage(req, res, registry.view(row), await ctx.webhookSources.issue(row.id), 201);
return res.redirect(303, `/watches/${row.id}`);
return res.redirect(303, `/watches/${row.id}`);
} catch (err) {
} catch (err) {
@@ -739,7 +759,7 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, acti
@@ -739,7 +759,7 @@ ${watchForm({ template, fields: prefilled(template, req.query), errors: {}, acti
${errors.form ? notice(errors.form, 'warn') : ''}
${errors.form ? notice(errors.form, 'warn') : ''}
<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and create it again.</p>
<p class="muted">Fix the ${Object.keys(errors).length === 1 ? 'entry' : 'entries'} marked below and create it again.</p>
${templateChooser(template)}
${templateChooser(template)}
-${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create this watch' })}`,
+${watchForm({ template, fields, errors, action: '/watches/new', submit: CREATE_SUBMIT(template) })}`,
}, 422);
}, 422);
});
});
@@ -758,6 +778,28 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
@@ -758,6 +778,28 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
const obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);
const obsLimit = (q) => Math.min(Math.max(INT(q, 20), 1), 100);
const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
const ridBefore = (q) => (/^\d{1,18}$/.test(String(q || '')) ? Number(q) : null);
+ /** What a quiet test check found, in words: the value, its source and the time read. Missing parts are left out. */
+ function testCheckNotice(runRow, observation, view) {
+ const run = check.runView(runRow);
+ const from = view.source.url || (view.source.probe && view.source.probe.target) || null;
+ const at = observation ? time(new Date(Number(observation.observed_at)).toISOString()) : null;
+ const read = run.observations && observation
+ ? html` It read ${W.valueWords(observation.value, 160)}${from ? html` from <code>${from}</code>` : ''}${at ? html` at ${at}` : ''}.`
+ : '';
+ const why = !run.observations && run.detail ? html` ${String(run.detail).replace(/\.?$/, '.')}` : '';
+ const holds = observation && Boolean(Number(observation.condition_met));
+ return notice(html`<strong>Test check:</strong> ${badge(W.stateWords(run.state, run), W.stateKind(run.state))}${read || why ? html` —` : ''}${read}${why} Nothing was sent.${holds ? html` This value already satisfies the condition.` : ''}`,
+ W.stateKind(run.state) === 'bad' ? 'warn' : 'info');
+ }
+
+ /** The evidence an alert carried, from the request stored for its action (notification keeps it in rich_content). */
+ function carriedEvidence(action) {
+ let request = action.request || {};
+ if (typeof request === 'string') { try { request = JSON.parse(request); } catch { request = {}; } }
+ if (action.kind === 'notification') return request.rich_content && request.rich_content.evidence;
+ return request.evidence || (request.body && request.body.evidence) || null;
+ }
+
r.get('/watches/:id', async (req, res) => {
r.get('/watches/:id', async (req, res) => {
if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
if (!signedIn(req)) return res.redirect(303, `/auth/login?next=${encodeURIComponent(req.originalUrl)}`);
const row = await owned(req, res);
const row = await owned(req, res);
@@ -792,19 +834,31 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
@@ -792,19 +834,31 @@ ${watchForm({ template, fields, errors, action: '/watches/new', submit: 'Create
});
});
const ACT_WORDS = { queued: ['waiting to send', 'info'], sent: ['sent', 'ok'], skipped: ['not sent', 'warn'], failed: ['failed', 'bad'], unavailable: ['not available yet', 'warn'] };
const ACT_WORDS = { queued: ['waiting to send', 'info'], sent: ['sent', 'ok'], skipped: ['not sent', 'warn'], failed: ['failed', 'bad'], unavailable: ['not available yet', 'warn'] };
- const actRows = acts.map((a) => [
- time(a.created_at),
- a.kind === 'notification' ? 'Notification' : html`<code>${a.kind}</code>`,
- badge((ACT_WORDS[a.state] || [a.state])[0], (ACT_WORDS[a.state] || [null, 'info'])[1]),
- a.error ? html`<span class="muted small">${a.error}</span>` : (a.attempts > 1 ? html`<span class="muted small">${a.attempts} attempts</span>` : ''),
- ]);
+ const actRows = acts.map((a) => {
+ const notes = [];
+ if (a.error) notes.push(html`<span class="muted small">${a.error}</span>`);
+ if (a.attempts > 1) notes.push(html`<span class="muted small">${a.attempts} attempts</span>`);
+ const evidence = W.evidenceWords(carriedEvidence(a));
+ if (evidence) notes.push(evidence);
+ return [
+ time(a.created_at),
+ a.kind === 'notification' ? 'Notification' : html`<code>${a.kind}</code>`,
+ badge((ACT_WORDS[a.state] || [a.state])[0], (ACT_WORDS[a.state] || [null, 'info'])[1]),
+ notes.length ? html`${notes.map((n, i) => (i ? html`<br>${n}` : n))}` : '',
+ ];
+ });
const ran = String(req.query.ran || '');
const ran = String(req.query.ran || '');
+ const unarmed = v.status === 'paused' && row.armed_at == null;
+ // While a watch is unarmed its only runs are the quiet test checks the site ran, so the latest
+ // run is the test whose result the person has not armed past yet.
+ const testRun = unarmed && runs.length ? runs[0] : null;
+ const testObs = testRun ? obs.find((o) => o.check_run_id === testRun.id) || null : null;
const actionForms = html`<div class="actions">
const actionForms = html`<div class="actions">
${v.status === 'active'
${v.status === 'active'
? html`<form class="inline" method="post" action="/watches/${v.id}/pause"><button class="btn" type="submit">Pause</button></form>`
? html`<form class="inline" method="post" action="/watches/${v.id}/pause"><button class="btn" type="submit">Pause</button></form>`
- : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="btn" type="submit">Resume</button></form>`}
-<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>
+ : html`<form class="inline" method="post" action="/watches/${v.id}/resume"><button class="${unarmed ? 'btn primary' : 'btn'}" type="submit">${unarmed ? 'Arm this watch' : 'Resume'}</button></form>`}
+${unarmed ? '' : html`<form class="inline" method="post" action="/watches/${v.id}/check"><button class="btn" type="submit">Check now</button></form>`}
<a class="btn" href="/watches/${v.id}/edit">Edit</a>
<a class="btn" href="/watches/${v.id}/edit">Edit</a>
<form class="inline" method="post" action="/watches/${v.id}/delete"><button class="btn danger" type="submit">Delete</button></form>
<form class="inline" method="post" action="/watches/${v.id}/delete"><button class="btn danger" type="submit">Delete</button></form>
</div>`;
</div>`;
@@ -817,8 +871,9 @@ ${v.status === 'active'
@@ -817,8 +871,9 @@ ${v.status === 'active'
body: html`<h1>${v.name}</h1>
body: html`<h1>${v.name}</h1>
<p class="in-words">${W.inWords(v)}</p>
<p class="in-words">${W.inWords(v)}</p>
${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}
${ran ? notice(html`The check ran: <strong>${W.stateWords(ran)}</strong>.`, ran === 'changed' || ran === 'condition_met' ? 'ok' : 'info') : ''}
+${testRun ? testCheckNotice(testRun, testObs, v) : ''}
<dl class="facts">
<dl class="facts">
-<dt>Status</dt><dd>${badge(W.statusWords(v.status), W.statusKind(v.status))}</dd>
+<dt>Status</dt><dd>${badge(unarmed ? 'Not armed yet' : W.statusWords(v.status), unarmed ? 'warn' : W.statusKind(v.status))}</dd>
${v.source.kind === 'webhook'
${v.source.kind === 'webhook'
? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">The secret was shown once, when the watch was made. A new one replaces it at once.</span></dd>`
? html`<dt>Webhook address</dt><dd><code>${config.baseUrl}/hooks/${v.id}</code> <form class="inline" method="post" action="/watches/${v.id}/webhook-secret"><button class="btn" type="submit">Make a new secret</button></form><br><span class="muted small">The secret was shown once, when the watch was made. A new one replaces it at once.</span></dd>`
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
: html`<dt>Source</dt><dd><code>${v.source.kind}</code> · <code>${v.source.url || (v.source.pattern ? v.source.pattern : '(none)')}</code></dd>`}
@@ -830,6 +885,7 @@ ${v.health.consecutive_failures ? html`<dt>Failing</dt><dd>${v.health.consecutiv
@@ -830,6 +885,7 @@ ${v.health.consecutive_failures ? html`<dt>Failing</dt><dd>${v.health.consecutiv
<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd>
<dt>Last value</dt><dd>${latest ? W.valueWords(latest.value, 200) : html`<span class="muted">nothing recorded yet</span>`}</dd>
</dl>
</dl>
${actionForms}
${actionForms}
+${unarmed ? html`<p class="muted small">This watch has not started its cadence. Arm it to begin checking; the test check is not repeated by itself.</p>` : ''}
<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>
<section aria-labelledby="h-obs"><h2 id="h-obs">Observation history</h2>
${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet. Watch records one when a check reads a value.' })}
${table(['When', 'Value', 'Changed', 'Condition'], obsRows, { empty: 'No observations yet. Watch records one when a check reads a value.' })}
<nav class="pager">${nextObs ? html`<a class="btn" href="${nextObs}">Older observations</a>` : html`<span class="muted">That is every observation kept.</span>`}</nav></section>
<nav class="pager">${nextObs ? html`<a class="btn" href="${nextObs}">Older observations</a>` : html`<span class="muted">That is every observation kept.</span>`}</nav></section>
@@ -1039,12 +1095,17 @@ ${table(['State', 'In plain words'], [
@@ -1039,12 +1095,17 @@ ${table(['State', 'In plain words'], [
['skipped', 'This kind of check is not run yet (or an AI reading is not supported yet). Nothing was fetched.'],
['skipped', 'This kind of check is not run yet (or an AI reading is not supported yet). Nothing was fetched.'],
['disabled', 'The watch is paused, disabled or failing, so it was not checked.'],
['disabled', 'The watch is paused, disabled or failing, so it was not checked.'],
].map(([state, words]) => [html`<code>${state}</code>`, words]))}
].map(([state, words]) => [html`<code>${state}</code>`, words]))}
+<h2>Before it arms</h2>
+<ul>
+<li><strong>The quiet test.</strong> A polling watch made on the site is saved paused and read once before it checks on a cadence. The watch page shows the value that read found and what state it recorded; nothing is sent, and no action is queued, until you arm it.</li>
+<li><strong>The evidence in every alert.</strong> A notification, a webhook body and an Actor task carry the value before and after, the source address and the time the value was fetched. A part the source did not state is left out, never guessed.</li>
+</ul>
<h2>What is honest about it</h2>
<h2>What is honest about it</h2>
<ul>
<ul>
<li>A value the source did not state is <code>null</code>; Watch never invents one, and an absent value stays absent.</li>
<li>A value the source did not state is <code>null</code>; Watch never invents one, and an absent value stays absent.</li>
<li>A failed check is a recorded state, never a silent skip; a failed check records no observation.</li>
<li>A failed check is a recorded state, never a silent skip; a failed check records no observation.</li>
<li>A site that is unavailable is retried with backoff, not hammered.</li>
<li>A site that is unavailable is retried with backoff, not hammered.</li>
-<li>The kinds that arrive later (event, webhook, Run, Node) and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot.</li>
+<li>Run and an AI reading are not offered in the form, because Watch will not pretend to check what it cannot. Event, webhook and Node watches are.</li>
</ul>
</ul>
<p><a href="/watches/new?template=page">Start a watch</a> · <a href="/">Back home</a></p>`,
<p><a href="/watches/new?template=page">Start a watch</a> · <a href="/">Back home</a></p>`,
}));
}));
@@ -205,9 +205,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -205,9 +205,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
get: db.prepare('SELECT * FROM watches WHERE id = ? AND deleted_at IS NULL'),
countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),
countOwned: db.prepare('SELECT COUNT(*) AS n FROM watches WHERE owner_sub = ? AND deleted_at IS NULL'),
insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,
insert: db.prepare(`INSERT INTO watches (id, project_id, project_env, owner_sub, name, status, source, cadence, extraction, comparison, condition,
- action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by)
+ action, budget, retention, labels, next_due_at, not_before, created_at, updated_at, updated_by, armed_at)
VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
VALUES (@id, @project_id, @project_env, @owner_sub, @name, @status, @source, @cadence, @extraction, @comparison, @condition,
- @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by)`),
+ @action, @budget, @retention, @labels, @next_due_at, 0, @created_at, @updated_at, @updated_by, @armed_at)`),
};
};
/** Every column a patch may write, and nothing else. */
/** Every column a patch may write, and nothing else. */
@@ -369,6 +369,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -369,6 +369,9 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
created_at: t,
created_at: t,
updated_at: t,
updated_at: t,
updated_by: owner,
updated_by: owner,
+ // A watch created paused by the site has not been armed: its quiet test check is the only read
+ // and the watch page offers to arm it. An active watch (the API's default) is armed at creation.
+ armed_at: rec.status === 'active' ? t : null,
};
};
// The row and its event commit together: an event exists exactly when its effect does.
// The row and its event commit together: an event exists exactly when its effect does.
await db.tx(async () => {
await db.tx(async () => {
@@ -413,6 +416,11 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
@@ -413,6 +416,11 @@ function createRegistry({ db, now = () => Date.now(), ids = contractIds, outbox
const rec = { ...current, ...changes };
const rec = { ...current, ...changes };
args.next_due_at = rec.status === 'active' ? arm(rec, args.updated_at) : 0;
args.next_due_at = rec.status === 'active' ? arm(rec, args.updated_at) : 0;
sets.push('next_due_at = @next_due_at');
sets.push('next_due_at = @next_due_at');
+ // The first resume is the arm: a watch the site tested is paused with no armed_at until now.
+ if (rec.status === 'active' && row.armed_at == null) {
+ args.armed_at = args.updated_at;
+ sets.push('armed_at = @armed_at');
+ }
sets.push('updated_at = @updated_at', 'updated_by = @updated_by');
sets.push('updated_at = @updated_at', 'updated_by = @updated_by');
const type = changes.status === 'paused' ? 'watch.watch.paused' : 'watch.watch.updated';
const type = changes.status === 'paused' ? 'watch.watch.paused' : 'watch.watch.updated';
const touched = Object.keys(changes).filter((k) => k !== 'labels');
const touched = Object.keys(changes).filter((k) => k !== 'labels');
@@ -5,7 +5,7 @@
@@ -5,7 +5,7 @@
* honesty rule applied to the site: the words are derived from the stored watch and the recorded
* honesty rule applied to the site: the words are derived from the stored watch and the recorded
* state, never a guess, and everything goes through the escaping `html` tag.
* state, never a guess, and everything goes through the escaping `html` tag.
*/
*/
-const { html } = require('./html');
+const { html, time } = require('./html');
const CADENCES = [
const CADENCES = [
{ sec: 900, label: 'every 15 minutes' },
{ sec: 900, label: 'every 15 minutes' },
@@ -38,6 +38,19 @@ function valueWords(value, max = 90) {
@@ -38,6 +38,19 @@ function valueWords(value, max = 90) {
return html`<code title="${String(text).slice(0, 200)}">${short(text, max)}</code>`;
return html`<code title="${String(text).slice(0, 200)}">${short(text, max)}</code>`;
}
}
+/**
+ * The evidence one alert carries, in words: the value before and after, the source address and when
+ * the value was fetched. A part the watch did not store is left out, never filled in.
+ */
+function evidenceWords(evidence) {
+ if (!evidence) return '';
+ const before = evidence.before == null ? html`<span class="muted">nothing recorded</span>` : valueWords(evidence.before, 60);
+ const after = evidence.after == null ? html`<span class="muted">nothing stated</span>` : valueWords(evidence.after, 60);
+ const from = evidence.source_url ? html`, from <code>${evidence.source_url}</code>` : '';
+ const fetched = evidence.fetched_at ? html`, fetched ${time(evidence.fetched_at)}` : '';
+ return html`<span class="muted small">Before ${before}; after ${after}${from}${fetched}.</span>`;
+}
+
function extractionWords(extraction) {
function extractionWords(extraction) {
const e = extraction || {};
const e = extraction || {};
if (e.kind === 'text') return 'the page text';
if (e.kind === 'text') return 'the page text';
@@ -74,30 +87,44 @@ function conditionWords(condition, { forSec = true } = {}) {
@@ -74,30 +87,44 @@ function conditionWords(condition, { forSec = true } = {}) {
return core;
return core;
}
}
-/** The watch as one sentence: "Tell me when … on …, checked every …". */
-function inWords(watch) {
+/** What the watch reads, as words: "the text of .price on shop.example". */
+function subjectWords(watch) {
const source = watch.source || {};
const source = watch.source || {};
const e = watch.extraction || {};
const e = watch.extraction || {};
const path = (e.kind === 'json' || e.kind === 'jsonpath') && e.value_path ? e.value_path : null;
const path = (e.kind === 'json' || e.kind === 'jsonpath') && e.value_path ? e.value_path : null;
- let what;
if (source.kind === 'feed') {
if (source.kind === 'feed') {
- what = e.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed';
- } else if (source.kind === 'webhook') {
- what = path ? html`the value at <code>${path}</code> in a webhook delivery` : (e.kind === 'text' ? 'what a webhook delivery says' : 'a webhook delivery');
- } else if (source.kind === 'node') {
+ return e.value_path ? html`${extractionWords(watch.extraction)} of the newest item of the feed` : 'the newest item of the feed';
+ }
+ if (source.kind === 'webhook') {
+ return path ? html`the value at <code>${path}</code> in a webhook delivery` : (e.kind === 'text' ? 'what a webhook delivery says' : 'a webhook delivery');
+ }
+ if (source.kind === 'node') {
const target = (source.probe && source.probe.target) || '';
const target = (source.probe && source.probe.target) || '';
- what = /^tcp:/.test(target) ? html`<b>${host(target)}</b>, seen from your computer,` : html`${extractionWords(watch.extraction)} on <b>${host(target)}</b>, seen from your computer,`;
- } else if (source.kind === 'event') {
- what = path ? html`the value at <code>${path}</code> of a <b>${source.pattern}</b> event` : html`a <b>${source.pattern}</b> event`;
- } else {
- what = html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;
+ return /^tcp:/.test(target) ? html`<b>${host(target)}</b>, seen from your computer` : html`${extractionWords(watch.extraction)} on <b>${host(target)}</b>, seen from your computer`;
}
}
- const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';
+ if (source.kind === 'event') {
+ return path ? html`the value at <code>${path}</code> of a <b>${source.pattern}</b> event` : html`a <b>${source.pattern}</b> event`;
+ }
+ return html`${extractionWords(watch.extraction)} on <b>${host(source.url)}</b>`;
+}
+
+/**
+ * The structured condition as one sentence without the request around it:
+ * "the text of .price on shop.example drops below 20". The watch list and the watch page both use it.
+ */
+function conditionSentence(watch) {
+ const source = watch.source || {};
const c = watch.condition || {};
const c = watch.condition || {};
if (source.kind === 'node' && /^tcp:/.test((source.probe && source.probe.target) || '') && c.op === 'eq' && c.value === 'down') {
if (source.kind === 'node' && /^tcp:/.test((source.probe && source.probe.target) || '') && c.op === 'eq' && c.value === 'down') {
- return html`Tell me when <b>${host(source.probe.target)}</b> stops answering, seen from your computer${every}.`;
+ return html`<b>${host(source.probe.target)}</b> stops answering, seen from your computer`;
}
}
- return html`Tell me when ${what} ${conditionWords(watch.condition)}${every}.`;
+ return html`${subjectWords(watch)} ${conditionWords(watch.condition)}`;
+}
+
+/** The watch as one sentence: "Tell me when … on …, checked every …". */
+function inWords(watch) {
+ const every = watch.cadence ? `, checked ${everyWords(watch.cadence.every_sec)}` : '';
+ return html`Tell me when ${conditionSentence(watch)}${every}.`;
}
}
/** The plain words for one check state; `run` supplies the HTTP status a failure answered with. */
/** The plain words for one check state; `run` supplies the HTTP status a failure answered with. */
@@ -132,6 +159,6 @@ const statusWords = (status) => STATUS_WORDS[status] || String(status || 'unknow
@@ -132,6 +159,6 @@ const statusWords = (status) => STATUS_WORDS[status] || String(status || 'unknow
const statusKind = (status) => (status === 'active' ? 'ok' : status === 'paused' ? 'warn' : status === 'failed' ? 'bad' : '');
const statusKind = (status) => (status === 'active' ? 'ok' : status === 'paused' ? 'warn' : status === 'failed' ? 'bad' : '');
module.exports = {
module.exports = {
- inWords, conditionWords, extractionWords, valueWords, everyWords, stateWords, stateKind,
+ inWords, conditionSentence, conditionWords, extractionWords, valueWords, evidenceWords, everyWords, stateWords, stateKind,
statusWords, statusKind, host, short, CADENCES, CADENCE_OPTIONS, MIN_CADENCE_SEC,
statusWords, statusKind, host, short, CADENCES, CADENCE_OPTIONS, MIN_CADENCE_SEC,
};
};
@@ -133,9 +133,13 @@ t('a check that does not fire queues nothing; one that fires queues each action
@@ -133,9 +133,13 @@ t('a check that does not fire queues nothing; one that fires queues each action
assert.strictEqual(p.subjectId, person.sub);
assert.strictEqual(p.subjectId, person.sub);
assert.deepStrictEqual([p.type, p.service, p.category, p.priority], ['WATCH_TRIGGERED', 'watch', 'service', 'high']);
assert.deepStrictEqual([p.type, p.service, p.category, p.priority], ['WATCH_TRIGGERED', 'watch', 'service', 'high']);
assert.strictEqual(p.title, '“Widget price” fired');
assert.strictEqual(p.title, '“Widget price” fired');
- assert.strictEqual(p.message, '7.50 (was 19.99)');
+ assert.match(p.message, /^7\.50 \(was 19\.99\) · from http:\/\/127\.0\.0\.1:\d+\/price · fetched \d{4}-\d{2}-\d{2}T/);
assert.strictEqual(p.url, `https://openvibe.watch/watches/${w.id}`);
assert.strictEqual(p.url, `https://openvibe.watch/watches/${w.id}`);
assert.strictEqual(p.rich_content.check_run_id, out.run.id);
assert.strictEqual(p.rich_content.check_run_id, out.run.id);
+ // The alert carries its evidence: before and after, the source and when it was fetched.
+ assert.deepStrictEqual(p.rich_content.evidence, {
+ before: '19.99', after: '7.50', source_url: `${web.origin}/price`, fetched_at: p.rich_content.observed_at,
+ });
assert.strictEqual((await acts(w.id))[0].state, 'sent');
assert.strictEqual((await acts(w.id))[0].state, 'sent');
await svc.actions.tick();
await svc.actions.tick();
@@ -220,6 +224,9 @@ t('a webhook action POSTs the firing, signed as an Events delivery; the receiver
@@ -220,6 +224,9 @@ t('a webhook action POSTs the firing, signed as an Events delivery; the receiver
assert.deepStrictEqual([body.type, body.watch.id, body.watch.name, body.check_run_id], ['watch.watch.triggered', w.id, 'hooked', out.run.id]);
assert.deepStrictEqual([body.type, body.watch.id, body.watch.name, body.check_run_id], ['watch.watch.triggered', w.id, 'hooked', out.run.id]);
assert.strictEqual(body.watch.url, `https://openvibe.watch/watches/${w.id}`);
assert.strictEqual(body.watch.url, `https://openvibe.watch/watches/${w.id}`);
assert.deepStrictEqual([body.observation.value, body.observation.previous_value], ['7.50', '19.99']);
assert.deepStrictEqual([body.observation.value, body.observation.previous_value], ['7.50', '19.99']);
+ assert.deepStrictEqual(body.evidence, {
+ before: '19.99', after: '7.50', source_url: `${web.origin}/price`, fetched_at: body.observation.observed_at,
+ });
[row] = await acts(w.id);
[row] = await acts(w.id);
assert.deepStrictEqual([row.state, row.last_error], ['sent', null]);
assert.deepStrictEqual([row.state, row.last_error], ['sent', null]);
const stored = await svc.db.prepare('SELECT request FROM action_runs WHERE rid = ?').get(row.rid);
const stored = await svc.db.prepare('SELECT request FROM action_runs WHERE rid = ?').get(row.rid);
@@ -305,6 +312,8 @@ t('an actor-task action gives Actor a task for the owner, with the firing as con
@@ -305,6 +312,8 @@ t('an actor-task action gives Actor a task for the owner, with the firing as con
assert.strictEqual(first.budgetUsd, 0.1);
assert.strictEqual(first.budgetUsd, 0.1);
assert.match(first.task, /^Buy two if it is still in stock\.\n\nContext: your watch “cheap widget” fired at /);
assert.match(first.task, /^Buy two if it is still in stock\.\n\nContext: your watch “cheap widget” fired at /);
assert.match(first.task, /Value: 7\.50 \(was 19\.99\)\./);
assert.match(first.task, /Value: 7\.50 \(was 19\.99\)\./);
+ assert.match(first.task, new RegExp(`Source: ${web.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/price\\.`));
+ assert.match(first.task, /Fetched: \d{4}-\d{2}-\d{2}T/);
assert.ok(first.task.includes(`https://openvibe.watch/watches/${w.id}`));
assert.ok(first.task.includes(`https://openvibe.watch/watches/${w.id}`));
const [row] = await acts(w.id);
const [row] = await acts(w.id);
assert.deepStrictEqual([row.state, row.last_error], ['sent', 'Actor task tsk_01JAB2C3D4E5F6G7H8J9K0MN02']);
assert.deepStrictEqual([row.state, row.last_error], ['sent', 'Actor task tsk_01JAB2C3D4E5F6G7H8J9K0MN02']);
@@ -460,6 +469,21 @@ t('readiness counts the queue, and the watch page says what the watch did when i
@@ -460,6 +469,21 @@ t('readiness counts the queue, and the watch page says what the watch did when i
assert.match(body, /What it did when it fired/);
assert.match(body, /What it did when it fired/);
assert.match(body, /Notification/);
assert.match(body, /Notification/);
assert.match(body, /class="badge ok">sent</);
assert.match(body, /class="badge ok">sent</);
+ // The page shows what the alert carried: before, after, source and fetch time.
+ assert.match(body, /Before <code[^>]*>19\.99<\/code>; after <code[^>]*>7\.50<\/code>/);
+ assert.match(body, new RegExp(`from <code>${web.origin.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\/price<\\/code>, fetched <time`));
+});
+
+t('an alert leaves out the source and fetched lines when the watch stored neither', () => {
+ const { notificationFor } = require('../server/actions');
+ const p = notificationFor({
+ config: { baseUrl: 'https://openvibe.watch' },
+ watch: { id: 'wch_01JAB2C3D4E5F6G7H8J9K0MNPQ', name: 'an event', source: { kind: 'event', pattern: 'live.stream.started' } },
+ run: { id: 'ckr_01JAB2C3D4E5F6G7H8J9K0MNPQ' },
+ action: {}, value: 'v1', previousValue: undefined, observedAt: null,
+ });
+ assert.strictEqual(p.message, 'v1', 'no invented URL or time');
+ assert.deepStrictEqual(p.rich_content.evidence, { before: null, after: 'v1', source_url: null, fetched_at: null });
});
});
t('done', async () => { await web.close(); await svc.stop(); });
t('done', async () => { await web.close(); await svc.stop(); });
@@ -67,7 +67,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>
@@ -67,7 +67,8 @@ t('expand copies only pending legacy rows into the exact SDK table', async () =>
t('rows wait with the relay off and an aborted change has no event', async () => {
t('rows wait with the relay off and an aborted change has no event', async () => {
// The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).
// The rest of the schema (0005 on): the service writes today's columns (watches.project_env since 0010).
- await db.migrate({ dir: migrations, log: silent });
+ // windowDays 0: this simulated old database applies the 0013 contract at once, then today's 0014.
+ await db.migrate({ dir: migrations, windowDays: 0, log: silent });
svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });
svc = await start({ config: config(''), db, fetchImpl: fakeFetch, log: silent, listen: false });
assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });
assert.deepStrictEqual(await svc.outbox.status(), { enabled: false, pending: 1, rejected: 0, last_error: null });
await assert.rejects(db.tx(async () => {
await assert.rejects(db.tx(async () => {
@@ -23,7 +23,12 @@ const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
@@ -23,7 +23,12 @@ const agentId = 'agt_01JAB2C3D4E5F6G7H8J9K0MNPQ';
t('boot', async () => {
t('boot', async () => {
web = await site({ '/price': () => ({ body: '<html><body><span class="price">19.99</span></body></html>' }) });
web = await site({ '/price': () => ({ body: '<html><body><span class="price">19.99</span></body></html>' }) });
- svc = await boot();
+ // The quiet test runs on create, so an example.org address must fail fast and never reach the internet.
+ svc = await boot({
+ lookupImpl: (hostname, _options, callback) => (hostname === '127.0.0.1'
+ ? callback(null, [{ address: '127.0.0.1', family: 4 }])
+ : callback(Object.assign(new Error(`test: ${hostname} is not fetched`), { code: 'ENOTFOUND' }))),
+ });
me = svc.network.addUser('ada');
me = svc.network.addUser('ada');
other = svc.network.addUser('grace');
other = svc.network.addUser('grace');
ME = userToken(me.subject);
ME = userToken(me.subject);
@@ -308,6 +313,67 @@ t('another person\'s watch is 404 on every page and form', async () => {
@@ -308,6 +313,67 @@ t('another person\'s watch is 404 on every page and form', async () => {
assert.match(mine.text, /Mine/);
assert.match(mine.text, /Mine/);
});
});
+t('a polling watch is created paused, tested quietly once, and armed by the person', async () => {
+ const owner = svc.network.addUser('quiet-tester');
+ const cookie = await svc.signIn(owner);
+ const token = userToken(owner.subject);
+ const hitsBefore = web.hits('/price').length;
+ const made = await svc.get('/watches/new', {
+ cookie,
+ form: { template: 'price', name: 'Quiet price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900', notify: 'on' },
+ });
+ assert.strictEqual(made.status, 303, made.text);
+ const id = indexOf(made);
+ assert.match(made.headers.get('location'), /\?tested=1$/, 'the created watch opens its test result');
+ assert.strictEqual(web.hits('/price').length, hitsBefore + 1, 'the test check fetched the page once, not twice');
+
+ const view = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token })).body.watch;
+ assert.strictEqual(view.status, 'paused', 'nothing checks on a cadence until the person arms it');
+ assert.ok(view.health.last_check_at, 'the test check is recorded');
+ assert.strictEqual(Number((await svc.db.prepare('SELECT COUNT(*) AS n FROM action_runs WHERE watch_id = ?').get(id)).n), 0, 'no action was queued by the test');
+ const run = await svc.db.prepare('SELECT * FROM check_runs WHERE watch_id = ? ORDER BY rid DESC LIMIT 1').get(id);
+ assert.deepStrictEqual([run.trigger, Number(run.observations), Number(run.triggers)], ['manual', 1, 0]);
+
+ const page = await svc.get(`/watches/${id}?tested=1`, { cookie });
+ assert.match(page.text, /Test check:/);
+ assert.match(page.text, /It read <code[^>]*>19\.99<\/code> from <code>[^<]*\/price<\/code> at <time/);
+ assert.match(page.text, /This value already satisfies the condition/);
+ assert.match(page.text, /Nothing was sent/);
+ assert.match(page.text, /Arm this watch/);
+ assert.match(page.text, /Not armed yet/, 'the watch says it has not started its cadence');
+ const plain = await svc.get(`/watches/${id}`, { cookie });
+ assert.match(plain.text, /Test check:/, 'the test result stays visible until the watch is armed');
+
+ // The watch list says the structured condition in words, not just the operator.
+ const list = await svc.get('/watches', { cookie });
+ assert.match(list.text, /the text of <code>\.price<\/code> on <b>127\.0\.0\.1:\d+<\/b> drops below <b>20<\/b>/);
+ assert.match(list.text, /Not armed yet/, 'the list says a tested watch has not started its cadence');
+
+ const armed = await svc.get(`/watches/${id}/resume`, { cookie, form: {} });
+ assert.strictEqual(armed.status, 303);
+ const armedView = (await request(svc.base, 'GET', `/api/v1/watches/${id}`, { token })).body.watch;
+ assert.strictEqual(armedView.status, 'active');
+ assert.ok(armedView.health.next_due_at, 'arming starts the cadence');
+ assert.ok((await svc.db.prepare('SELECT armed_at FROM watches WHERE id = ?').get(id)).armed_at, 'arming is recorded');
+});
+
+t('a quiet test that reads nothing shows the failure and no value line', async () => {
+ const owner = svc.network.addUser('quiet-failer');
+ const cookie = await svc.signIn(owner);
+ const made = await svc.get('/watches/new', {
+ cookie,
+ form: { template: 'page', name: 'Nowhere', url: 'https://nowhere.example/page', cadence: '3600' },
+ });
+ assert.strictEqual(made.status, 303, made.text);
+ const id = indexOf(made);
+ const page = await svc.get(`/watches/${id}?tested=1`, { cookie });
+ assert.match(page.text, /Test check:/);
+ assert.match(page.text, /The site answered an error/, 'the failure is stated, not hidden');
+ assert.doesNotMatch(page.text, /It read /, 'no value is claimed when the check read none');
+ assert.match(page.text, /Nothing was sent/);
+ assert.match(page.text, /Arm this watch/, 'the person may still arm it after seeing a failed test');
+});
+
t('pause, resume, check now (against a stub site), and delete', async () => {
t('pause, resume, check now (against a stub site), and delete', async () => {
const created = await create({ template: 'price', name: 'Stub price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });
const created = await create({ template: 'price', name: 'Stub price', url: `${web.origin}/price`, extraction: 'css', selector: '.price', op: 'lt', value: '20', cadence: '900' });
const id = indexOf(created);
const id = indexOf(created);
@@ -325,10 +391,11 @@ t('pause, resume, check now (against a stub site), and delete', async () => {
@@ -325,10 +391,11 @@ t('pause, resume, check now (against a stub site), and delete', async () => {
const resumed = await svc.get(`/watches/${id}/resume`, { cookie: session, form: {} });
const resumed = await svc.get(`/watches/${id}/resume`, { cookie: session, form: {} });
assert.strictEqual(resumed.status, 303);
assert.strictEqual(resumed.status, 303);
+ const hitsBefore = web.hits('/price').length;
const ran = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });
const ran = await svc.get(`/watches/${id}/check`, { cookie: session, form: {} });
assert.strictEqual(ran.status, 303);
assert.strictEqual(ran.status, 303);
assert.match(ran.headers.get('location'), /ran=condition_met/, '19.99 is below 20: the condition fired');
assert.match(ran.headers.get('location'), /ran=condition_met/, '19.99 is below 20: the condition fired');
- assert.strictEqual(web.hits('/price').length, 1);
+ assert.strictEqual(web.hits('/price').length, hitsBefore + 1, 'the manual check fetched the page once');
const detail = await svc.get(`/watches/${id}`, { cookie: session });
const detail = await svc.get(`/watches/${id}`, { cookie: session });
assert.match(detail.text, /19\.99/, 'the observation history shows the value');
assert.match(detail.text, /19\.99/, 'the observation history shows the value');