openvibers/openvibe.watch Commits
Actions: an event action for watch.watch.triggered is recorded sent (#32)
Changes
@@@ -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.@@@ -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 {@@@ -87,6 +87,15 @@ t('a check that does not fire queues nothing; one that fires queues each actionassert.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);
@@ -176,7 +176,8 @@ A fired condition queues the watch's actions in the same transaction as the chec
@@ -176,7 +176,8 @@ A fired condition queues the watch's actions in the same transaction as the chec
| kind | what happens |
| 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` |
| `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.
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.
Network keeps no idempotency record for a push, so a lost answer can repeat a notification: delivery is at least once.
@@ -16,7 +16,10 @@
@@ -16,7 +16,10 @@
* 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
* once. A watch owned by an agent or a service has no one to notify: `skipped`.
* 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
* `unavailable` with `watch.action_unavailable`: their dispatchers are later steps of the plan, and
* the watch page says so rather than pretending they ran.
* 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]
@@ -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 LEASE_MS = 2 * 60_000;
const TITLE_MAX = 120;
const TITLE_MAX = 120;
const MESSAGE_MAX = 300;
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; };
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(
@@ -90,6 +94,10 @@ function createActions({ db, config, notifications = null, now = () => Date.now(
if (kind === 'notification') {
if (kind === 'notification') {
if (USER_SUBJECT.test(String(owner || ''))) request = notificationFor({ config, watch, run, action, value, previousValue, observedAt });
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 { 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)) {
} else if (LATER.has(kind)) {
state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;
state = 'unavailable'; error = `watch.action_unavailable: ${kind} actions are a later step (plan T18)`;
} else {
} else {
@@ -87,6 +87,15 @@ t('a check that does not fire queues nothing; one that fires queues each action
@@ -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);
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 () => {
t('a refusal waits with backoff; the next tick does not retry early; a later success sends it', async () => {
pushes.length = 0; mode = 'fail';
pushes.length = 0; mode = 'fail';
const w = await create(person);
const w = await create(person);