openvibers/openvibe.watch Commits

Actions: an event action for watch.watch.triggered is recorded sent (#32)

OP OpenVibers committed 10 Oct 2026 · d497a66419 · 3 files, +21 −3

A watch with notifications off defaults to an `event` action for `watch.watch.triggered` (the contract requires an action). That event is emitted with every firing, in the run's own commit, so `action_runs` now records it `sent` rather than `unavailable`, which read as if nothing happened. An agent- or service-owned watch is `skipped`, because the event names a person. Any other event type is not declared in Watch's manifest and stays `unavailable`, now with that reason. The README actions table says the same. `test/actions.test.js`: 7/7, with a new case for both.

Changes

ddiff --git a/README.md b/README.md
iindex 23a76c8..3afffa5 100644
---- a/README.md
++++ b/README.md
@@@ -176,7 +176,8 @@ A fired condition queues the watch's actions in the same transaction as the chec
| kind | what happens |
|---|---|
| `notification` | the owner gets one OpenVibe notification through OpenVibe.Network (`openvibe-sdk/notifications`, type `WATCH_TRIGGERED`): the watch's name, the value (and the value before), and a link to the watch page. A refusal retries after 1, 5 and 30 minutes, 2 and 6 hours, then `failed`; an owner Network does not know (a deleted account) is `skipped`; a person who switched the category off is Network's decision and counts as `sent`. A watch owned by an agent or a service has no one to notify (`skipped`). Needs the Network OAuth client secret and the grants `identity.subject.resolve` + `network.notifications.push` |
--| `event`, `webhook`, `actor-task`, `codes-run`, `run-job` | recorded `unavailable` (`watch.action_unavailable`): their dispatchers are later steps, and the watch page says so |
++| `event` | `watch.watch.triggered` is emitted with every firing in the run's own commit, so that action is recorded `sent` as it is queued (the event names a person: an agent- or service-owned watch is `skipped`). Any other event type is not declared by Watch: `unavailable` |
++| `webhook`, `actor-task`, `codes-run`, `run-job` | recorded `unavailable` (`watch.action_unavailable`): their dispatchers are later steps, and the watch page says so |
Rows are claimed with a short lease (FOR UPDATE SKIP LOCKED), so two processes never deliver one row at once.
Network keeps no idempotency record for a push, so a lost answer can repeat a notification: delivery is at least once.
ddiff --git a/server/actions.js b/server/actions.js
iindex f1ccfef..8d5fb17 100644
---- a/server/actions.js
++++ b/server/actions.js
@@@ -16,7 +16,10 @@
* 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
* once. A watch owned by an agent or a service has no one to notify: `skipped`.
-- * event, webhook, actor-task, codes-run, run-job
++ * 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, actor-task, codes-run, run-job
* `unavailable` with `watch.action_unavailable`: their dispatchers are later steps of the plan, and
* the watch page says so rather than pretending they ran.
*
@@@ -31,7 +34,8 @@ const BACKOFF_MS = [60_000, 5 * 60_000, 30 * 60_000, 2 * 3600_000, 6 * 3600_000]
const LEASE_MS = 2 * 60_000;
const TITLE_MAX = 120;
const MESSAGE_MAX = 300;
--const LATER = new Set(['event', 'webhook', 'actor-task', 'codes-run', 'run-job']);
++const LATER = new Set(['webhook', 'actor-task', 'codes-run', 'run-job']);
++const TRIGGERED = 'watch.watch.triggered';
const clip = (s, n) => { const t = String(s == null ? '' : s).replace(/\s+/g, ' ').trim(); return t.length > n ? `${t.slice(0, n - 1)}…` : t; };
@@@ -90,6 +94,10 @@ function createActions({ db, config, notifications = null, now = () => Date.now(
if (kind === 'notification') {
if (USER_SUBJECT.test(String(owner || ''))) request = notificationFor({ config, watch, run, action, value, previousValue, observedAt });
else { state = 'skipped'; error = 'watch.action_no_person: the watch belongs to an agent or a service, not a person'; }
++ } else if (kind === 'event') {
++ if (action.event_type !== TRIGGERED) { state = 'unavailable'; error = `watch.action_unavailable: ${action.event_type || 'an event'} is not an event Watch declares (only ${TRIGGERED})`; }
++ else if (!USER_SUBJECT.test(String(owner || ''))) { state = 'skipped'; error = `watch.action_no_person: ${TRIGGERED} names a person, and this watch belongs to an agent or a service`; }
++ else { state = 'sent'; error = 'emitted with the check run'; }
} else if (LATER.has(kind)) {
state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;
} else {
ddiff --git a/test/actions.test.js b/test/actions.test.js
iindex 1a1a616..28c9f53 100644
---- a/test/actions.test.js
++++ b/test/actions.test.js
@@@ -87,6 +87,15 @@ t('a check that does not fire queues nothing; one that fires queues each action
assert.strictEqual((await acts(w.id)).length, 2);
});
++t('an event action for watch.watch.triggered is what the firing emitted (sent); another event type is not declared', async () => {
++ const w = await create(person, { name: 'quiet', action: [{ kind: 'event', event_type: 'watch.watch.triggered' }, { kind: 'event', event_type: 'shop.price.dropped' }] });
++ await fire(w);
++ const rows = await acts(w.id);
++ assert.deepStrictEqual(rows.map((r) => r.state), ['sent', 'unavailable']);
++ assert.match(rows[0].last_error, /emitted with the check run/);
++ assert.match(rows[1].last_error, /not an event Watch declares/);
++});
++
t('a refusal waits with backoff; the next tick does not retry early; a later success sends it', async () => {
pushes.length = 0; mode = 'fail';
const w = await create(person);