@frockbot/plugin-routines 0.3.9 → 0.3.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@frockbot/plugin-routines",
3
- "version": "0.3.9",
3
+ "version": "0.3.11",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "exports": {
@@ -32,12 +32,12 @@
32
32
  "typecheck": "vue-tsc --noEmit -p tsconfig.json"
33
33
  },
34
34
  "dependencies": {
35
- "@frockbot/client-core": "0.3.9",
36
- "@frockbot/client-ui": "0.3.9",
37
- "@frockbot/configuration-core": "0.3.9",
38
- "@frockbot/kernel-agent-loop": "0.3.9",
39
- "@frockbot/kernel-contracts": "0.3.9",
40
- "@frockbot/plugin-shell": "0.3.9",
35
+ "@frockbot/client-core": "0.3.11",
36
+ "@frockbot/client-ui": "0.3.11",
37
+ "@frockbot/configuration-core": "0.3.11",
38
+ "@frockbot/kernel-agent-loop": "0.3.11",
39
+ "@frockbot/kernel-contracts": "0.3.11",
40
+ "@frockbot/plugin-shell": "0.3.11",
41
41
  "cordis": "4.0.0-rc.8",
42
42
  "croner": "10.0.1",
43
43
  "vue": "3.5.41"
package/src/backend.ts CHANGED
@@ -164,10 +164,11 @@ function errorResponse(error: unknown): Response {
164
164
  error instanceof Error ? error.message : "Routine request is invalid",
165
165
  );
166
166
  }
167
- return jsonError(
168
- 500,
169
- error instanceof Error ? error.message : "Routine request failed",
170
- );
167
+ // A decode refusal above is the User's own request coming back at them and
168
+ // says what to fix. Anything reaching here is ours: it names internals the
169
+ // caller cannot act on, so it goes to the log and the caller gets the fact.
170
+ console.error("Routine request failed", error);
171
+ return jsonError(500, "Routine request failed");
171
172
  }
172
173
 
173
174
  /**
@@ -242,13 +243,17 @@ async function deliverHook(
242
243
  error.name === "RoutineHookError"
243
244
  ) {
244
245
  const hookError = error as RoutineHookError;
245
- return jsonError(hookError.status, hookError.message);
246
+ // The wire body, not the reason. A refusal on this route answers the
247
+ // open internet, and the raw message names deployment internals — which
248
+ // environment variable is unset, which decoder rejected what.
249
+ return jsonError(
250
+ hookError.status,
251
+ hookError.publicMessage ?? "webhook delivery failed",
252
+ );
246
253
  }
247
254
  if (isMissingBot(error)) return jsonError(404, "Routine not found");
248
- return jsonError(
249
- 500,
250
- error instanceof Error ? error.message : "webhook delivery failed",
251
- );
255
+ console.error("routine webhook delivery failed", error);
256
+ return jsonError(500, "webhook delivery failed");
252
257
  }
253
258
  }
254
259
 
@@ -6,7 +6,7 @@
6
6
  // completion becomes visible is here — a count of what has not been read, and a
7
7
  // drawer that reads it. Acknowledging is a command, never a side effect of
8
8
  // opening the drawer, so a glance does not clear the badge.
9
- import { UiButton, UiIcon } from "@frockbot/client-ui";
9
+ import { formatRelativeMomentV1, UiButton, UiIcon } from "@frockbot/client-ui";
10
10
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
11
11
  import { computed, inject, ref, watch } from "vue";
12
12
  import { routinesStateKey } from "./state.js";
@@ -38,10 +38,27 @@ function toggle(): void {
38
38
  if (open.value && botId.value) void routines.value.loadInbox(botId.value);
39
39
  }
40
40
 
41
+ /** What is unread and on screen right now — never "everything unread". */
42
+ const unreadOnScreen = computed(() =>
43
+ routines.value.inbox
44
+ .filter((entry) => !entry.acknowledged)
45
+ .map((entry) => entry.entryId),
46
+ );
47
+
41
48
  function acknowledge(entryIds: string[]): void {
42
- if (!botId.value) return;
49
+ if (!botId.value || entryIds.length === 0) return;
43
50
  void routines.value.acknowledgeInbox(botId.value, entryIds);
44
51
  }
52
+
53
+ /**
54
+ * "Mark all read" means the entries the reader can see, not every unread entry
55
+ * the object holds. An empty list acknowledges everything, including a firing
56
+ * that landed a second ago and has never been rendered — with a `@every 1m`
57
+ * Routine that is a completion silently marked read and never read.
58
+ */
59
+ function markAllRead(): void {
60
+ acknowledge(unreadOnScreen.value);
61
+ }
45
62
  </script>
46
63
 
47
64
  <template>
@@ -61,10 +78,10 @@ function acknowledge(entryIds: string[]): void {
61
78
  <header class="routine-inbox__header">
62
79
  <h2>Routine completions</h2>
63
80
  <UiButton
64
- v-if="count > 0"
81
+ v-if="unreadOnScreen.length > 0"
65
82
  variant="ghost"
66
83
  :disabled="routines.busy"
67
- @click="acknowledge([])"
84
+ @click="markAllRead()"
68
85
  >Mark all read</UiButton
69
86
  >
70
87
  </header>
@@ -81,7 +98,9 @@ function acknowledge(entryIds: string[]): void {
81
98
  <p class="routine-inbox__attribution">{{ entry.attribution }}</p>
82
99
  <p class="routine-inbox__text">{{ entry.text }}</p>
83
100
  <footer class="routine-inbox__meta">
84
- <span>{{ entry.createdAt }}</span>
101
+ <time :datetime="entry.createdAt">{{
102
+ formatRelativeMomentV1(entry.createdAt)
103
+ }}</time>
85
104
  <!-- One thing going wrong repeatedly is one entry and a count. -->
86
105
  <span v-if="(entry.repeatCount ?? 1) > 1"
87
106
  >Happened {{ entry.repeatCount }} times</span
@@ -3,7 +3,15 @@
3
3
  // Routine. It renders durable state and submits versioned commands; it decides
4
4
  // nothing — "Next run" is the moment the scheduler has actually armed an alarm
5
5
  // on, sent down with the Routine, and blank when there is none to promise.
6
- import { UiAnchor, UiButton, UiField, UiIcon } from "@frockbot/client-ui";
6
+ import {
7
+ browserTimeZoneV1,
8
+ formatMomentV1,
9
+ formatRelativeMomentV1,
10
+ UiAnchor,
11
+ UiButton,
12
+ UiField,
13
+ UiIcon,
14
+ } from "@frockbot/client-ui";
7
15
  import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
8
16
  import { settingsLinkV1 } from "@frockbot/plugin-shell/settings-links";
9
17
  import { computed, inject, reactive, ref, watch } from "vue";
@@ -27,7 +35,6 @@ const formOpen = ref(false);
27
35
  const openLog = ref<string>();
28
36
  const copied = ref(false);
29
37
  const openRun = ref<string>();
30
-
31
38
  // An automation Turn never appears in the transcript, so opening a run here is
32
39
  // the only way to read one, and it is read-only in both directions: the view
33
40
  // carries what happened and no way to act on it.
@@ -50,6 +57,31 @@ const form = reactive({
50
57
  timezone: "UTC",
51
58
  });
52
59
 
60
+ /**
61
+ * The reason the last save was refused, held beside the form rather than in
62
+ * the section header. The header sits above every Routine card, so on a Bot
63
+ * with a few Routines the refusal rendered hundreds of pixels off-screen and
64
+ * the form simply appeared to do nothing.
65
+ */
66
+ const saveError = ref<string>();
67
+ /** The Routine a delete has been asked for and not yet confirmed. */
68
+ const pendingDelete = ref<RoutineViewV1>();
69
+
70
+ /**
71
+ * Whether a refusal is about the schedule, so it can be rendered under the
72
+ * Schedule field. Every schedule refusal comes from one validator, and it
73
+ * names cron, the expression, or the time zone.
74
+ */
75
+ const scheduleError = computed(() =>
76
+ saveError.value !== undefined &&
77
+ form.timing === "schedule" &&
78
+ /cron|schedule|expression|time zone|timezone|occurrence/iu.test(
79
+ saveError.value,
80
+ )
81
+ ? saveError.value
82
+ : undefined,
83
+ );
84
+
53
85
  watch(
54
86
  botId,
55
87
  (id) => {
@@ -92,13 +124,22 @@ function summary(routine: RoutineViewV1): string {
92
124
  : "Webhook trigger";
93
125
  }
94
126
 
127
+ /** A durable moment, read in the Routine's own zone — the one it fires on. */
128
+ function moment(routine: RoutineViewV1, iso: string): string {
129
+ return formatMomentV1(iso, { timeZone: routine.timezone });
130
+ }
131
+
95
132
  function startCreate(): void {
96
133
  form.routineId = undefined;
97
134
  form.name = "";
98
135
  form.prompt = "";
99
136
  form.timing = "schedule";
100
137
  form.schedule = "0 9 * * *";
101
- form.timezone = "UTC";
138
+ // The reader's own zone, not UTC: a schedule is almost always meant in the
139
+ // day the person writing it is living in, and the Bot picks the same when it
140
+ // writes one itself.
141
+ form.timezone = browserTimeZoneV1();
142
+ saveError.value = undefined;
102
143
  formOpen.value = true;
103
144
  }
104
145
 
@@ -109,12 +150,14 @@ function startEdit(routine: RoutineViewV1): void {
109
150
  form.timing = routine.schedule ? "schedule" : "webhook";
110
151
  form.schedule = routine.schedule ?? "";
111
152
  form.timezone = routine.timezone;
153
+ saveError.value = undefined;
112
154
  formOpen.value = true;
113
155
  }
114
156
 
115
157
  async function submit(): Promise<void> {
116
158
  const id = botId.value;
117
159
  if (!id) return;
160
+ saveError.value = undefined;
118
161
  try {
119
162
  await routines.value.save(id, {
120
163
  ...(form.routineId ? { routineId: form.routineId } : {}),
@@ -126,11 +169,33 @@ async function submit(): Promise<void> {
126
169
  timezone: form.timezone.trim(),
127
170
  });
128
171
  formOpen.value = false;
129
- } catch {
130
- // The state holds the reason; the form stays open so it can be corrected.
172
+ } catch (error) {
173
+ // The form stays open and holds the reason itself, beside the field that
174
+ // caused it. The section header keeps its copy for the reader who scrolls
175
+ // back up, but the form no longer refuses in silence.
176
+ saveError.value =
177
+ error instanceof Error
178
+ ? error.message
179
+ : (routines.value.error ?? "Could not save the Routine");
131
180
  }
132
181
  }
133
182
 
183
+ /**
184
+ * Deleting takes a Routine, its schedule, its prompt and its whole run log
185
+ * with it, and the button sits in a row of six others. It asks first.
186
+ */
187
+ function askDelete(routine: RoutineViewV1): void {
188
+ pendingDelete.value = routine;
189
+ }
190
+
191
+ async function confirmDelete(): Promise<void> {
192
+ const routine = pendingDelete.value;
193
+ const id = botId.value;
194
+ pendingDelete.value = undefined;
195
+ if (!routine || !id) return;
196
+ await routines.value.remove(id, routine.routineId);
197
+ }
198
+
134
199
  async function toggleLog(routineId: string): Promise<void> {
135
200
  const id = botId.value;
136
201
  if (!id) return;
@@ -220,7 +285,12 @@ async function toggleLog(routineId: string): Promise<void> {
220
285
  <dl class="routine-card__facts">
221
286
  <div>
222
287
  <dt>Next run</dt>
223
- <dd>{{ routine.nextRunAt ?? "—" }}</dd>
288
+ <dd>
289
+ <time v-if="routine.nextRunAt" :datetime="routine.nextRunAt">{{
290
+ moment(routine, routine.nextRunAt)
291
+ }}</time>
292
+ <template v-else>—</template>
293
+ </dd>
224
294
  </div>
225
295
  <div v-if="routine.trigger">
226
296
  <dt>Webhook key</dt>
@@ -234,7 +304,15 @@ async function toggleLog(routineId: string): Promise<void> {
234
304
  </div>
235
305
  <div>
236
306
  <dt>Last run</dt>
237
- <dd>{{ routine.lastRunAt ?? "Never" }}</dd>
307
+ <dd>
308
+ <time
309
+ v-if="routine.lastRunAt"
310
+ :datetime="routine.lastRunAt"
311
+ :title="moment(routine, routine.lastRunAt)"
312
+ >{{ formatRelativeMomentV1(routine.lastRunAt) }}</time
313
+ >
314
+ <template v-else>Never</template>
315
+ </dd>
238
316
  </div>
239
317
  <div>
240
318
  <dt>Written by</dt>
@@ -289,11 +367,36 @@ async function toggleLog(routineId: string): Promise<void> {
289
367
  type="button"
290
368
  variant="danger"
291
369
  :disabled="routines.busy"
292
- @click="botId && routines.remove(botId, routine.routineId)"
370
+ @click="askDelete(routine)"
293
371
  >
294
372
  Delete
295
373
  </UiButton>
296
374
  </div>
375
+ <div
376
+ v-if="pendingDelete?.routineId === routine.routineId"
377
+ class="routine-confirm"
378
+ role="alertdialog"
379
+ :aria-label="`Delete ${routine.name}?`"
380
+ >
381
+ <strong>Delete {{ routine.name }}?</strong>
382
+ <small>
383
+ Its schedule, its prompt and its whole run log go with it. This can't
384
+ be undone.
385
+ </small>
386
+ <div class="routine-card__actions">
387
+ <UiButton type="button" @click="pendingDelete = undefined">
388
+ Cancel
389
+ </UiButton>
390
+ <UiButton
391
+ type="button"
392
+ variant="danger"
393
+ :disabled="routines.busy"
394
+ @click="confirmDelete"
395
+ >
396
+ Delete Routine
397
+ </UiButton>
398
+ </div>
399
+ </div>
297
400
  <div v-if="openLog === routine.routineId" class="routine-card__log">
298
401
  <p
299
402
  v-if="(routines.runs[routine.routineId] ?? []).length === 0"
@@ -312,7 +415,13 @@ async function toggleLog(routineId: string): Promise<void> {
312
415
  :aria-expanded="openRun === entry.runId"
313
416
  @click="toggleRun(routine.routineId, entry.runId)"
314
417
  >
315
- <span>{{ entry.startedAt }}</span>
418
+ <span
419
+ ><time
420
+ :datetime="entry.startedAt"
421
+ :title="moment(routine, entry.startedAt)"
422
+ >{{ formatRelativeMomentV1(entry.startedAt) }}</time
423
+ ></span
424
+ >
316
425
  <span>{{ entry.trigger }}</span>
317
426
  <span>{{ entry.status }}</span>
318
427
  </button>
@@ -361,8 +470,20 @@ async function toggleLog(routineId: string): Promise<void> {
361
470
  v-model="form.schedule"
362
471
  maxlength="256"
363
472
  placeholder="0 9 * * *"
473
+ :aria-invalid="scheduleError ? 'true' : undefined"
474
+ :aria-describedby="
475
+ scheduleError ? 'routine-schedule-error' : undefined
476
+ "
364
477
  />
365
478
  </UiField>
479
+ <p
480
+ v-if="scheduleError"
481
+ id="routine-schedule-error"
482
+ class="routine-form__error"
483
+ role="alert"
484
+ >
485
+ {{ scheduleError }}
486
+ </p>
366
487
  <p v-else class="routines__note">
367
488
  A delivery key is minted when the Routine is saved, and shown once.
368
489
  </p>
@@ -373,6 +494,13 @@ async function toggleLog(routineId: string): Promise<void> {
373
494
  placeholder="Australia/Sydney"
374
495
  />
375
496
  </UiField>
497
+ <p
498
+ v-if="saveError && !scheduleError"
499
+ class="routine-form__error"
500
+ role="alert"
501
+ >
502
+ {{ saveError }}
503
+ </p>
376
504
  <div class="routine-card__actions">
377
505
  <UiButton
378
506
  type="button"
@@ -576,6 +704,33 @@ async function toggleLog(routineId: string): Promise<void> {
576
704
  font-size: var(--frock-text-xs);
577
705
  }
578
706
 
707
+ .routine-form__error {
708
+ margin: 0;
709
+ color: var(--frock-danger-text);
710
+ font-size: var(--frock-text-sm);
711
+ }
712
+
713
+ .routine-confirm {
714
+ display: flex;
715
+ flex-direction: column;
716
+ gap: 8px;
717
+ border: 1px solid var(--frock-danger-text);
718
+ border-radius: var(--frock-radius-card);
719
+ padding: 10px;
720
+ background: var(--frock-surface);
721
+ }
722
+
723
+ .routine-confirm strong {
724
+ color: var(--frock-text);
725
+ font-size: var(--frock-text-sm);
726
+ font-weight: 600;
727
+ }
728
+
729
+ .routine-confirm small {
730
+ color: var(--frock-text-muted);
731
+ font-size: var(--frock-text-sm);
732
+ }
733
+
579
734
  .routine-form__timing {
580
735
  display: flex;
581
736
  gap: 12px;
@@ -3,6 +3,8 @@ import type {
3
3
  ClientPluginContext,
4
4
  ClientSlotRegistration,
5
5
  } from "@frockbot/client-core";
6
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
7
+ import { ref } from "vue";
6
8
  import { routinesClientPlugin } from "./index.js";
7
9
  import { routinesStateKey, type RoutinesClientState } from "./state.js";
8
10
 
@@ -207,3 +209,87 @@ describe("Routines client contribution", () => {
207
209
  mounted.dispose();
208
210
  });
209
211
  });
212
+
213
+ describe("the completion badge and the state channel", () => {
214
+ /**
215
+ * A Routine that finishes cannot speak in the transcript, so the badge is
216
+ * the only place a completion becomes visible — and it used to read the
217
+ * inbox only on a Bot switch and on opening the drawer. A firing that
218
+ * completed while the app sat open left the count stale until something else
219
+ * happened to reload it, which for a `@every 1m` Routine is most of the day.
220
+ */
221
+ test("reads the inbox again when the Bot's runs change", async () => {
222
+ const inboxReads: string[] = [];
223
+ let invalidate:
224
+ ((topic: "computer" | "runs" | undefined) => Promise<void>) | undefined;
225
+ let stopped = 0;
226
+ const shell = ref({ activeBotId: "scout" });
227
+ const context: ClientPluginContext = {
228
+ transport: {
229
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
230
+ hostedRequest: (path) => {
231
+ if (path.endsWith("/inbox")) {
232
+ inboxReads.push(path);
233
+ return Promise.resolve({
234
+ schemaVersion: 1,
235
+ botId: "scout",
236
+ entries: [],
237
+ unacknowledged: inboxReads.length,
238
+ });
239
+ }
240
+ return Promise.resolve({
241
+ schemaVersion: 1,
242
+ botId: "scout",
243
+ routines: [],
244
+ });
245
+ },
246
+ watchBotState: (_botId, listener) => {
247
+ invalidate = listener.invalidate;
248
+ return () => {
249
+ stopped += 1;
250
+ };
251
+ },
252
+ },
253
+ inject: (key) => {
254
+ if (key === frockBotWebDataKey) return shell as never;
255
+ throw new Error("unexpected client provider");
256
+ },
257
+ provide: () => () => {},
258
+ slot: () => () => {},
259
+ };
260
+ const disposers = routinesClientPlugin(context);
261
+ if (!Array.isArray(disposers)) throw new Error("expected registrations");
262
+
263
+ expect(invalidate).toBeDefined();
264
+ // A Turn settling on this Bot — an automation Turn is one — refreshes it.
265
+ await invalidate!("runs");
266
+ // A resynchronise carries no topic and must not be filtered out.
267
+ await invalidate!(undefined);
268
+ // Another subsystem's news is not the badge's business.
269
+ await invalidate!("computer");
270
+ expect(inboxReads).toHaveLength(2);
271
+
272
+ shell.value = { activeBotId: "other" };
273
+ await Promise.resolve();
274
+ expect(stopped).toBe(1);
275
+ for (const dispose of disposers.toReversed()) dispose();
276
+ });
277
+
278
+ test("does not reach for the shell when the client has no state channel", () => {
279
+ // The Cordis local host has no channel. Injecting the shell there would
280
+ // throw on mount and take the whole Contribution with it.
281
+ const context: ClientPluginContext = {
282
+ transport: {
283
+ turn: () => Promise.resolve({ runId: "run", text: "", events: [] }),
284
+ hostedRequest: () =>
285
+ Promise.resolve({ schemaVersion: 1, botId: "scout", routines: [] }),
286
+ },
287
+ inject: () => {
288
+ throw new Error("unexpected client provider");
289
+ },
290
+ provide: () => () => {},
291
+ slot: () => () => {},
292
+ };
293
+ expect(() => routinesClientPlugin(context)).not.toThrow();
294
+ });
295
+ });
@@ -7,7 +7,8 @@
7
7
  // versioned command with its own idempotency key, and every read is decoded at
8
8
  // the seam before a component sees it.
9
9
  import type { ClientPlugin } from "@frockbot/client-core";
10
- import { ref } from "vue";
10
+ import { frockBotWebDataKey } from "@frockbot/plugin-shell/shared";
11
+ import { ref, watch } from "vue";
11
12
  import {
12
13
  decodeRoutineCommandReceiptV1,
13
14
  decodeRoutineInboxReceiptV1,
@@ -262,6 +263,40 @@ export const routinesClientPlugin: ClientPlugin = (ctx) => {
262
263
  },
263
264
  });
264
265
 
266
+ // The badge is the only place a firing that finished becomes visible, and it
267
+ // used to load only on a Bot switch and on opening the drawer: a Routine that
268
+ // completed while the app was open left the count stale until something else
269
+ // happened to reload it. The state channel already says when this Bot's runs
270
+ // changed — an automation Turn settling is one of those — so the badge reads
271
+ // the inbox again on that signal rather than polling or waiting for a click.
272
+ const watchBotState = ctx.transport.watchBotState;
273
+ if (watchBotState) {
274
+ const shell = ctx.inject(frockBotWebDataKey);
275
+ let stopWatching: (() => void) | undefined;
276
+ watch(
277
+ () => shell.value.activeBotId,
278
+ (activeBotId) => {
279
+ stopWatching?.();
280
+ stopWatching = undefined;
281
+ if (!activeBotId) return;
282
+ stopWatching = watchBotState(activeBotId, {
283
+ async invalidate(topic) {
284
+ // `undefined` is a resynchronise, so it is not filtered out.
285
+ if (topic !== undefined && topic !== "runs") return;
286
+ if (shell.value.activeBotId !== activeBotId) return;
287
+ await state.value.loadInbox(activeBotId);
288
+ },
289
+ status() {
290
+ // The badge has nothing to say about the socket itself; a closed
291
+ // channel simply stops refreshing it, and opening the drawer still
292
+ // reads the inbox.
293
+ },
294
+ });
295
+ },
296
+ { immediate: true },
297
+ );
298
+ }
299
+
265
300
  return [
266
301
  ctx.provide(routinesStateKey, state),
267
302
  ctx.slot({
package/src/hook.test.ts CHANGED
@@ -66,7 +66,19 @@ describe("the hook token", () => {
66
66
  (error: unknown) => error,
67
67
  );
68
68
  expect(refusal).toBeInstanceOf(RoutineHookError);
69
- expect((refusal as RoutineHookError).status).toBe(500);
69
+ // A door that was never given a key is unavailable, not broken, and the
70
+ // caller is told that and nothing more: the reason names the deployment's
71
+ // secret variable and stays in the log.
72
+ expect((refusal as RoutineHookError).status).toBe(503);
73
+ expect((refusal as RoutineHookError).message).toContain(
74
+ "ROUTINE_HOOK_SECRET",
75
+ );
76
+ expect((refusal as RoutineHookError).publicMessage).toBe(
77
+ "webhook delivery is not configured",
78
+ );
79
+ expect((refusal as RoutineHookError).publicMessage).not.toContain(
80
+ "ROUTINE_HOOK_SECRET",
81
+ );
70
82
  });
71
83
 
72
84
  test("a key version is part of the token, so a rotation is a new token", async () => {
@@ -92,14 +104,13 @@ describe("the hook token", () => {
92
104
  });
93
105
 
94
106
  describe("delivery identity", () => {
95
- test("is the content when the caller sent no idempotency key", async () => {
96
- expect(await routineDeliveryIdV1("brief", '{"a":1}')).toBe(
97
- await routineDeliveryIdV1("brief", '{"a":1}'),
107
+ test("is unique per request when the caller sent no idempotency key", async () => {
108
+ // Two real events with the same payload are two deliveries. Hashing the
109
+ // body used to turn the second one into a `duplicate` receipt and no
110
+ // firing, with nothing anywhere saying an event had been swallowed.
111
+ expect(await routineDeliveryIdV1("brief", '{"ping":true}')).not.toBe(
112
+ await routineDeliveryIdV1("brief", '{"ping":true}'),
98
113
  );
99
- expect(await routineDeliveryIdV1("brief", '{"a":1}')).not.toBe(
100
- await routineDeliveryIdV1("brief", '{"a":2}'),
101
- );
102
- // Two Routines receiving the same body are two deliveries.
103
114
  expect(await routineDeliveryIdV1("brief", "{}")).not.toBe(
104
115
  await routineDeliveryIdV1("other", "{}"),
105
116
  );
@@ -235,9 +246,11 @@ describe("the durable half of the check", () => {
235
246
  const receipt = await store.execute(create, USER);
236
247
  const token = (receipt as { hook: { token: string } }).hook.token;
237
248
 
238
- const first = await deliver(store, token, '{"event":"push"}');
249
+ // A replay is a delivery the caller itself said was the same one, by
250
+ // sending the key twice.
251
+ const first = await deliver(store, token, '{"event":"push"}', "evt-1");
239
252
  expect(first.status).toBe("accepted");
240
- const second = await deliver(store, token, '{"event":"push"}');
253
+ const second = await deliver(store, token, '{"event":"push"}', "evt-1");
241
254
  expect(second).toEqual({ status: "duplicate", fireId: first.fireId });
242
255
 
243
256
  const fired: string[] = [];
@@ -250,6 +263,29 @@ describe("the durable half of the check", () => {
250
263
  expect(fired).toEqual([first.fireId]);
251
264
  });
252
265
 
266
+ test("two distinct deliveries with identical bodies are two firings", async () => {
267
+ const { scheduler, store, create } = harness();
268
+ const receipt = await store.execute(create, USER);
269
+ const token = (receipt as { hook: { token: string } }).hook.token;
270
+
271
+ // A provider that sends `{"event":"push"}` twice sent two events. Without
272
+ // an `Idempotency-Key` nothing has claimed they are the same delivery, and
273
+ // the second used to be answered `duplicate` and never fired — a swallowed
274
+ // event with no trace anywhere.
275
+ const first = await deliver(store, token, '{"event":"push"}');
276
+ const second = await deliver(store, token, '{"event":"push"}');
277
+ expect(first.status).toBe("accepted");
278
+ expect(second.status).toBe("accepted");
279
+ expect(second.fireId).not.toBe(first.fireId);
280
+
281
+ const fired: string[] = [];
282
+ await scheduler.settle(async (fire) => {
283
+ fired.push(fire.fireId);
284
+ return { status: "ok" };
285
+ });
286
+ expect(fired.sort()).toEqual([first.fireId, second.fireId].sort());
287
+ });
288
+
253
289
  test("a rotated key retires the one before it", async () => {
254
290
  const { store, create } = harness();
255
291
  const created = await store.execute(create, USER);
package/src/hook.ts CHANGED
@@ -70,9 +70,18 @@ export interface RoutineDeliveryReceiptV1 {
70
70
  export class RoutineHookError extends Error {
71
71
  override readonly name = "RoutineHookError";
72
72
  readonly status: number;
73
- constructor(status: number, message: string) {
73
+ /**
74
+ * What an anonymous caller is told. It is deliberately separate from
75
+ * `message`: the delivery route answers the open internet, and a refusal
76
+ * used to hand back the raw reason — including the name of the deployment's
77
+ * signing-secret variable and whether it was missing or merely short. The
78
+ * detail stays in `message` for the log; this is the wire body.
79
+ */
80
+ readonly publicMessage: string;
81
+ constructor(status: number, message: string, publicMessage?: string) {
74
82
  super(message);
75
83
  this.status = status;
84
+ this.publicMessage = publicMessage ?? message;
76
85
  }
77
86
  }
78
87
 
@@ -124,8 +133,10 @@ export function constantTimeEqualsV1(left: string, right: string): boolean {
124
133
  async function signingKey(secret: string): Promise<CryptoKey> {
125
134
  if (typeof secret !== "string" || secret.length < 16) {
126
135
  throw new RoutineHookError(
127
- 500,
136
+ 503,
128
137
  "ROUTINE_HOOK_SECRET is missing or too short for webhook delivery",
138
+ // The caller learns the door is shut, not how it is built.
139
+ "webhook delivery is not configured",
129
140
  );
130
141
  }
131
142
  return crypto.subtle.importKey(
@@ -282,30 +293,39 @@ export function decodeRoutineHookKeyV1(value: unknown): RoutineHookKeyV1 {
282
293
  }
283
294
 
284
295
  /**
285
- * The delivery id one request is remembered by: the caller's `Idempotency-Key`
286
- * when it sent one, and otherwise the content itself. Either way the same
287
- * delivery twice is one firing.
296
+ * The delivery id one request is remembered by.
297
+ *
298
+ * Idempotency is the caller's claim to make, and only the caller can make it:
299
+ * an `Idempotency-Key` says "this is the same delivery I already sent", and
300
+ * two deliveries under one key are one firing. Without a key the id is unique
301
+ * per request, so two deliveries are two firings.
302
+ *
303
+ * It used to hash the body when no key was sent. That reads as a safety net
304
+ * and is really a silent decision about someone else's data: a provider
305
+ * POSTing `{"ping":true}` twice — two real events, identical payloads — got
306
+ * one firing and a `duplicate` receipt for the second, with nothing anywhere
307
+ * saying an event had been dropped. Coalescing without being asked to is worse
308
+ * than firing twice, because the caller can see a double firing and cannot see
309
+ * a swallowed one.
288
310
  */
289
311
  export async function routineDeliveryIdV1(
290
312
  routineId: string,
291
313
  body: string,
292
314
  idempotencyKey?: string | null,
293
315
  ): Promise<string> {
294
- if (idempotencyKey) {
295
- const trimmed = idempotencyKey.trim().slice(0, 256);
296
- if (trimmed.length > 0) {
297
- return hex(
298
- await crypto.subtle.digest(
299
- "SHA-256",
300
- TEXT.encode(`key${routineId}${trimmed}`),
301
- ),
302
- );
303
- }
316
+ const trimmed = idempotencyKey?.trim().slice(0, 256) ?? "";
317
+ if (trimmed.length > 0) {
318
+ return hex(
319
+ await crypto.subtle.digest(
320
+ "SHA-256",
321
+ TEXT.encode(`key${routineId}${trimmed}`),
322
+ ),
323
+ );
304
324
  }
305
325
  return hex(
306
326
  await crypto.subtle.digest(
307
327
  "SHA-256",
308
- TEXT.encode(`body${routineId}${body}`),
328
+ TEXT.encode(`delivery${routineId}${crypto.randomUUID()}`),
309
329
  ),
310
330
  );
311
331
  }
@@ -208,6 +208,33 @@ export interface StoredPendingInputV1 {
208
208
  input: PendingBotInputV1;
209
209
  }
210
210
 
211
+ /**
212
+ * The inputs one drain carries, under the pending-input bound.
213
+ *
214
+ * The bound exists so a burst cannot hand a single Turn an unbounded prompt,
215
+ * and it used to be a flat `slice(-16)` over everything queued. But the four
216
+ * input kinds are not interchangeable. A dropped `wake` still has an inbox
217
+ * entry, so the user can read it and nothing is lost; an `approval`, a
218
+ * `machine-result` or a `superseded-turn` writes no entry anywhere, so
219
+ * dropping one silently loses a decision the user made or a result a machine
220
+ * produced. Those are kept whole and the cap falls on the wakes alone — the
221
+ * only kind that can be dropped and still be read.
222
+ */
223
+ export function retainedPendingInputsV1(
224
+ inputs: readonly PendingBotInputV1[],
225
+ ): PendingBotInputV1[] {
226
+ if (inputs.length <= ROUTINE_PENDING_INPUT_LIMIT) return [...inputs];
227
+ const durable = inputs.filter((input) => input.kind !== "wake");
228
+ const budget = ROUTINE_PENDING_INPUT_LIMIT - durable.length;
229
+ if (budget <= 0) return durable;
230
+ const keptWakes = new Set(
231
+ inputs.filter((input) => input.kind === "wake").slice(-budget),
232
+ );
233
+ return inputs.filter(
234
+ (input) => input.kind !== "wake" || keptWakes.has(input),
235
+ );
236
+ }
237
+
211
238
  export class RoutineInboxStore {
212
239
  readonly #storage: RoutineStorageV1;
213
240
  readonly #now: () => Date;
@@ -365,7 +392,7 @@ export class RoutineInboxStore {
365
392
  // active run, so no firing can settle while it runs and no wake can
366
393
  // arrive behind this read; an empty receipt would be a record of nothing.
367
394
  if (inputs.length === 0) return [];
368
- const retained = inputs.slice(-ROUTINE_PENDING_INPUT_LIMIT);
395
+ const retained = retainedPendingInputsV1(inputs);
369
396
  await transaction.put(receiptKey, {
370
397
  schemaVersion: 1,
371
398
  runId,
@@ -377,15 +404,40 @@ export class RoutineInboxStore {
377
404
  });
378
405
  }
379
406
 
380
- /** Trim the inbox to its retention bound, oldest first. */
407
+ /** Trim the inbox to its retention bound, acknowledged entries first. */
381
408
  async #trimInbox(): Promise<void> {
382
409
  const stored = await this.#storage.list<unknown>({
383
410
  prefix: ROUTINE_INBOX_PREFIX,
384
411
  });
385
- const keys = [...stored.keys()].sort();
386
- if (keys.length <= ROUTINE_INBOX_LIMIT) return;
387
- for (const key of keys.slice(ROUTINE_INBOX_LIMIT)) {
412
+ // Inbox keys are descending, so ascending key order is newest first and
413
+ // the oldest entry is the last of them.
414
+ const entries = [...stored.entries()]
415
+ .sort(([left], [right]) => left.localeCompare(right))
416
+ .reverse();
417
+ let excess = entries.length - ROUTINE_INBOX_LIMIT;
418
+ if (excess <= 0) return;
419
+ // Read entries go first, oldest read before newest, and only then unread
420
+ // ones. Trimming purely by age used to drop a completion the reader had
421
+ // never seen while a dozen they had already acknowledged sat beside it —
422
+ // the inbox is the only place a firing ever speaks, so what has not been
423
+ // read is the last thing to lose.
424
+ const acknowledged: string[] = [];
425
+ const unread: string[] = [];
426
+ for (const [key, value] of entries) {
427
+ let read = false;
428
+ try {
429
+ read = decodeRoutineInboxEntryV1(value).acknowledged;
430
+ } catch {
431
+ // An entry nothing can decode says nothing to anyone; it is the first
432
+ // thing worth reclaiming space from.
433
+ read = true;
434
+ }
435
+ (read ? acknowledged : unread).push(key);
436
+ }
437
+ for (const key of [...acknowledged, ...unread]) {
438
+ if (excess <= 0) return;
388
439
  await this.#storage.delete(key);
440
+ excess -= 1;
389
441
  }
390
442
  }
391
443
 
package/src/inbox.test.ts CHANGED
@@ -7,11 +7,16 @@ import {
7
7
  routineHandoffTextV1,
8
8
  subagentAttributionV1,
9
9
  } from "./inbox.js";
10
- import { RoutineInboxStore, routineTerminalRecordsV1 } from "./inbox-store.js";
10
+ import {
11
+ retainedPendingInputsV1,
12
+ RoutineInboxStore,
13
+ routineTerminalRecordsV1,
14
+ } from "./inbox-store.js";
11
15
  import { createMemoryRoutineStorageV1 } from "./testing.js";
12
16
  import {
13
17
  ROUTINE_INBOX_LIMIT,
14
18
  ROUTINE_INBOX_PREFIX,
19
+ ROUTINE_PENDING_INPUT_LIMIT,
15
20
  ROUTINE_WAKE_PREFIX,
16
21
  } from "./storage-keys.js";
17
22
 
@@ -435,3 +440,99 @@ describe("a Turn the User's next message replaced", () => {
435
440
  ).not.toContain("Subagents");
436
441
  });
437
442
  });
443
+
444
+ describe("the pending-input cap", () => {
445
+ /**
446
+ * The four input kinds are not interchangeable. A dropped `wake` still has
447
+ * an inbox entry the user can read; an `approval`, a `machine-result` or a
448
+ * `superseded-turn` writes no entry anywhere, so dropping one loses a
449
+ * decision the user made or a result a machine produced, silently.
450
+ */
451
+ test("keeps every non-wake input and spends the budget on the wakes", async () => {
452
+ const store = storage();
453
+ const inbox = new RoutineInboxStore(store);
454
+ for (let index = 0; index < ROUTINE_PENDING_INPUT_LIMIT + 4; index += 1) {
455
+ await settle(store, { runId: `rf-${index}`, handoff: `wake ${index}` });
456
+ }
457
+ await inbox.enqueue({
458
+ schemaVersion: 1,
459
+ kind: "approval",
460
+ approvalId: "ap-1",
461
+ decision: "approved",
462
+ createdAt: NOW,
463
+ });
464
+ await inbox.enqueue({
465
+ schemaVersion: 1,
466
+ kind: "superseded-turn",
467
+ runId: "run-9",
468
+ unfinishedWork: true,
469
+ createdAt: NOW,
470
+ });
471
+
472
+ const drained = await inbox.drainInto("chat-run-1");
473
+ expect(drained).toHaveLength(ROUTINE_PENDING_INPUT_LIMIT);
474
+ // The approval decision and the superseded Turn survive; a flat
475
+ // `slice(-16)` used to drop whichever of them sat behind enough wakes.
476
+ expect(drained.filter((input) => input.kind === "approval")).toHaveLength(
477
+ 1,
478
+ );
479
+ expect(
480
+ drained.filter((input) => input.kind === "superseded-turn"),
481
+ ).toHaveLength(1);
482
+ expect(drained.filter((input) => input.kind === "wake")).toHaveLength(
483
+ ROUTINE_PENDING_INPUT_LIMIT - 2,
484
+ );
485
+ });
486
+
487
+ test("keeps every durable input even when they alone exceed the bound", () => {
488
+ const approvals = Array.from(
489
+ { length: ROUTINE_PENDING_INPUT_LIMIT + 3 },
490
+ (_unused, index) =>
491
+ ({
492
+ schemaVersion: 1,
493
+ kind: "approval",
494
+ approvalId: `ap-${index}`,
495
+ decision: "approved",
496
+ createdAt: NOW,
497
+ }) as const,
498
+ );
499
+ // Over the bound is a problem worth having; losing an approval decision
500
+ // with nothing anywhere recording it is not.
501
+ expect(retainedPendingInputsV1(approvals)).toHaveLength(approvals.length);
502
+ });
503
+ });
504
+
505
+ describe("inbox retention", () => {
506
+ test("gives up read entries before unread ones", async () => {
507
+ const store = storage();
508
+ const inbox = new RoutineInboxStore(store);
509
+ for (let index = 0; index < ROUTINE_INBOX_LIMIT; index += 1) {
510
+ await settle(store, {
511
+ runId: `rf-${String(index).padStart(4, "0")}`,
512
+ responseText: `run ${index}`,
513
+ });
514
+ }
515
+ // The reader has caught up on everything so far.
516
+ await inbox.acknowledge([]);
517
+ // Five more land while they are away.
518
+ for (let index = 0; index < 5; index += 1) {
519
+ await settle(store, {
520
+ runId: `rf-new-${index}`,
521
+ responseText: `fresh ${index}`,
522
+ });
523
+ }
524
+
525
+ const entries = await inbox.list();
526
+ expect(entries).toHaveLength(ROUTINE_INBOX_LIMIT);
527
+ // Trimming purely by age used to drop whatever was oldest regardless of
528
+ // whether it had ever been read. Nothing unread is gone here.
529
+ const unread = entries.filter((entry) => !entry.acknowledged);
530
+ expect(unread.map((entry) => entry.text)).toEqual([
531
+ "fresh 4",
532
+ "fresh 3",
533
+ "fresh 2",
534
+ "fresh 1",
535
+ "fresh 0",
536
+ ]);
537
+ });
538
+ });
@@ -10,6 +10,7 @@ import { RoutineInboxStore } from "./inbox-store.js";
10
10
  import { createMemoryRoutineStorageV1 } from "./testing.js";
11
11
  import {
12
12
  routineFireKeyV1,
13
+ routineKeyV1,
13
14
  routineScheduleKeyV1,
14
15
  ROUTINE_FAILURE_PAUSE_AFTER,
15
16
  ROUTINE_FIRE_LEASE_MS,
@@ -575,7 +576,7 @@ describe("an abandoned firing", () => {
575
576
  });
576
577
 
577
578
  test("releases a lock nothing can decode", async () => {
578
- const { storage, scheduler, store, create } = harness({
579
+ const { storage, time, scheduler, store, create } = harness({
579
580
  start: "2026-01-01T08:59:00.000Z",
580
581
  schedule: "0 9 * * *",
581
582
  });
@@ -901,3 +902,100 @@ describe("an undecodable Routine record", () => {
901
902
  expect(await drain(scheduler)).toHaveLength(1);
902
903
  });
903
904
  });
905
+
906
+ describe("a Routine deleted while it was firing", () => {
907
+ /**
908
+ * Delete sweeps the record, the clock, the lock, anything queued and the
909
+ * whole run log. A firing already in flight settled afterwards and wrote a
910
+ * run entry back under the swept key and an inbox entry naming a Routine
911
+ * that no longer exists — an orphan the user cannot open, delete, or
912
+ * explain.
913
+ */
914
+ test("leaves no run entry and no inbox entry behind", async () => {
915
+ const { storage, time, scheduler, store, create } = harness({
916
+ start: "2026-01-01T08:59:00.000Z",
917
+ schedule: "0 9 * * *",
918
+ });
919
+ await store.execute(create, USER);
920
+ time.set("2026-01-01T09:00:00.000Z");
921
+
922
+ const inbox = new RoutineInboxStore(storage);
923
+ await scheduler.settle(async () => {
924
+ // The user deletes it from the panel while the Turn is still running.
925
+ await store.execute(
926
+ {
927
+ schemaVersion: 1,
928
+ type: "routine/delete",
929
+ commandId: "cmd-delete",
930
+ botId: "scout",
931
+ routineId: "brief",
932
+ },
933
+ USER,
934
+ );
935
+ return { status: "failed", summary: "the model refused" };
936
+ });
937
+
938
+ expect(
939
+ storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
940
+ ).toEqual([]);
941
+ expect(await inbox.list()).toEqual([]);
942
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
943
+ expect(await storage.get(routineScheduleKeyV1("brief"))).toBeUndefined();
944
+ expect(await scheduler.deadlines(storage)).toEqual([]);
945
+ });
946
+
947
+ test("a firing whose Routine still exists settles as it always did", async () => {
948
+ const { storage, time, scheduler, store, create } = harness({
949
+ start: "2026-01-01T08:59:00.000Z",
950
+ schedule: "0 9 * * *",
951
+ });
952
+ await store.execute(create, USER);
953
+ time.set("2026-01-01T09:00:00.000Z");
954
+ const inbox = new RoutineInboxStore(storage);
955
+ await drain(scheduler, { status: "failed", summary: "the model refused" });
956
+ expect(
957
+ storage.keys().filter((key) => key.startsWith(ROUTINE_RUN_PREFIX)),
958
+ ).not.toEqual([]);
959
+ expect(await inbox.list()).toHaveLength(1);
960
+ });
961
+ });
962
+
963
+ describe("a stored schedule with no occurrence left", () => {
964
+ /**
965
+ * The write path refuses these now, but a record written before it did — or
966
+ * by an older deploy — is still in storage. The claim used to fall back to
967
+ * `now + ROUTINE_MISSED_GRACE_MS` when the schedule named no next run, so
968
+ * the Routine fired a real model Turn every five minutes for ever, with
969
+ * nothing on screen and no bound on the spend.
970
+ */
971
+ test("turns itself off and says why, instead of firing every five minutes", async () => {
972
+ const { storage, scheduler, store, create } = harness({
973
+ start: "2026-01-01T00:00:00.000Z",
974
+ schedule: "0 9 * * *",
975
+ });
976
+ await store.execute(create, USER);
977
+ // February the 30th, straight into storage, past the write-time guard.
978
+ const stored = (await storage.get(routineKeyV1("brief"))) as Record<
979
+ string,
980
+ unknown
981
+ >;
982
+ await storage.put(routineKeyV1("brief"), {
983
+ ...stored,
984
+ schedule: "0 0 30 2 *",
985
+ });
986
+ await storage.delete(routineScheduleKeyV1("brief"));
987
+
988
+ expect(await drain(scheduler)).toEqual([]);
989
+
990
+ const listed = await store.list("scout");
991
+ expect(listed.routines[0]!.enabled).toBe(false);
992
+ const runs = await store.listRuns("scout", "brief");
993
+ expect(runs.entries[0]!.status).toBe("skipped");
994
+ expect(runs.entries[0]!.summary).toContain("never comes around again");
995
+ // And the alarm no longer has anything to arm on for it.
996
+ expect(await scheduler.deadlines(storage)).toEqual([]);
997
+
998
+ // Draining again does not fire it either: it is off and its clock is gone.
999
+ expect(await drain(scheduler)).toEqual([]);
1000
+ });
1001
+ });
package/src/scheduler.ts CHANGED
@@ -63,7 +63,6 @@ import {
63
63
  ROUTINE_FAILURE_PAUSE_AFTER,
64
64
  ROUTINE_FIRE_TIMEOUT_MS,
65
65
  ROUTINE_LIMIT_PER_BOT,
66
- ROUTINE_MISSED_GRACE_MS,
67
66
  ROUTINE_PREFIX,
68
67
  ROUTINE_QUEUE_LIMIT,
69
68
  ROUTINE_QUEUE_PREFIX,
@@ -71,6 +70,7 @@ import {
71
70
  routineKeyV1,
72
71
  routineQueueKeyV1,
73
72
  routineQueuePrefixV1,
73
+ routineRunPrefixV1,
74
74
  routineScheduleKeyV1,
75
75
  } from "./storage-keys.js";
76
76
 
@@ -603,7 +603,9 @@ export class RoutineScheduler {
603
603
  record: RoutineRecordV1,
604
604
  state: RoutineScheduleStateV1,
605
605
  now: Date,
606
- ): Promise<ClaimedFiringV1> {
606
+ // `undefined` when the schedule has no occurrence left: the Routine turns
607
+ // itself off and there is nothing to fire.
608
+ ): Promise<ClaimedFiringV1 | undefined> {
607
609
  const normalized = normalizeRoutineScheduleV1(
608
610
  record.schedule!,
609
611
  record.timezone,
@@ -642,13 +644,38 @@ export class RoutineScheduler {
642
644
  // re-owe this occurrence.
643
645
  const from = missedCount > 1 ? now : new Date(state.dueAt);
644
646
  const next = nextRoutineRunV1(normalized, from, anchor);
647
+ if (next === undefined) {
648
+ // The schedule has no occurrence left — `0 0 30 2 *` is February the
649
+ // 30th, and a Routine written before this was refused at write time
650
+ // still holds one. Falling back to "five minutes from now" turned that
651
+ // into a real model Turn every five minutes for ever. It is turned off
652
+ // instead, with a run-log entry that says why, and the firing this claim
653
+ // was about is abandoned rather than run.
654
+ await transaction.put(routineKeyV1(record.routineId), {
655
+ ...record,
656
+ enabled: false,
657
+ updatedAt: now.toISOString(),
658
+ } satisfies RoutineRecordV1);
659
+ await transaction.delete(routineScheduleKeyV1(record.routineId));
660
+ await appendRoutineRunEntryV1(transaction, {
661
+ schemaVersion: 1,
662
+ entryId: `${routineFireIdV1(record.routineId, String(state.dueAt))}-unreachable`,
663
+ routineId: record.routineId,
664
+ runId: routineFireIdV1(record.routineId, String(state.dueAt)),
665
+ fireId: routineFireIdV1(record.routineId, String(state.dueAt)),
666
+ trigger: "cron",
667
+ status: "skipped",
668
+ startedAt: now.toISOString(),
669
+ finishedAt: now.toISOString(),
670
+ summary: `The schedule "${record.schedule}" never comes around again in ${record.timezone}, so this Routine has been turned off. Give it a schedule that does and turn it back on.`,
671
+ } satisfies RoutineRunEntryV1);
672
+ return undefined;
673
+ }
645
674
  await transaction.put(routineScheduleKeyV1(record.routineId), {
646
675
  schemaVersion: 1,
647
676
  routineId: record.routineId,
648
677
  anchor: record.updatedAt,
649
- dueAt: (
650
- next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
651
- ).getTime(),
678
+ dueAt: next.getTime(),
652
679
  ...(state.consecutiveFailures === undefined
653
680
  ? {}
654
681
  : { consecutiveFailures: state.consecutiveFailures }),
@@ -889,6 +916,23 @@ export class RoutineScheduler {
889
916
  const finishedAt = at.toISOString();
890
917
  await this.#storage.transaction(async (transaction) => {
891
918
  await transaction.delete(routineFireKeyV1(fire.routineId));
919
+ // The Routine may have been deleted while this firing was in flight.
920
+ // Delete sweeps the record, the clock, the lock and the whole run log,
921
+ // and the settlement then used to re-append a run entry under the swept
922
+ // key and write an inbox entry naming a Routine that no longer exists —
923
+ // an orphan the user cannot open, delete, or explain. Nothing outlives
924
+ // the record it belongs to.
925
+ if (
926
+ (await transaction.get<unknown>(routineKeyV1(fire.routineId))) ===
927
+ undefined
928
+ ) {
929
+ const orphans = await transaction.list<unknown>({
930
+ prefix: routineRunPrefixV1(fire.routineId),
931
+ });
932
+ for (const key of orphans.keys()) await transaction.delete(key);
933
+ await transaction.delete(routineScheduleKeyV1(fire.routineId));
934
+ return;
935
+ }
892
936
  await this.#recordOutcomeOnClock(transaction, fire, outcome, at);
893
937
  if (outcome.status !== "ok") {
894
938
  await this.#recordFailureInbox(transaction, fire, outcome, finishedAt);
@@ -97,10 +97,14 @@ describe("RoutineViewV1", () => {
97
97
  });
98
98
  if (receipt.status !== "applied") throw new Error("unreachable");
99
99
  const view = receipt.routine;
100
- // The Bot writer's Session and Turn stay in the durable record.
101
- expect(view.createdBy).toEqual({ kind: "bot", botId: "scout" });
102
- expect(JSON.stringify(view)).not.toContain("tim:scout");
103
- expect(JSON.stringify(view)).not.toContain("turn-1");
100
+ // The Bot writer's Session and Turn travel with it: they are provenance,
101
+ // not key material, and the journey asks the record to name the Turn.
102
+ expect(view.createdBy).toEqual({
103
+ kind: "bot",
104
+ botId: "scout",
105
+ sessionId: "tim:scout",
106
+ turnId: "turn-1",
107
+ });
104
108
  expect(decodeRoutineViewV1(JSON.parse(JSON.stringify(view)))).toEqual(view);
105
109
  expect(
106
110
  decodeRoutineCommandReceiptV1(JSON.parse(JSON.stringify(receipt))),
package/src/shared.ts CHANGED
@@ -55,12 +55,16 @@ export const ROUTINE_LIST_MAX = 100;
55
55
  export const ROUTINE_RUN_LIST_MAX = 50;
56
56
 
57
57
  /**
58
- * Who wrote a Routine, as the client is told it. The Session and Turn a Bot
59
- * writer records stay in the durable record: the UI needs to say "the Bot wrote
60
- * this", not to replay the Turn that did.
58
+ * Who wrote a Routine, as the client is told it.
59
+ *
60
+ * A Bot writer names the Session and the Turn it wrote from. The panel only
61
+ * says "the Bot wrote this", but provenance that cannot answer "which Turn?"
62
+ * is not provenance: the audit trail and the run log both address a Turn, and
63
+ * a Routine that a Bot wrote must be traceable to the one that wrote it.
61
64
  */
62
65
  export type RoutineWriterViewV1 =
63
- { kind: "user" } | { kind: "bot"; botId: string };
66
+ | { kind: "user" }
67
+ | { kind: "bot"; botId: string; sessionId: string; turnId: string };
64
68
 
65
69
  /** One Routine as the hosted client sees it. Never any key material. */
66
70
  export interface RoutineViewV1 {
@@ -420,10 +424,17 @@ function decodeRoutineWriterViewV1(
420
424
  if (candidate.kind !== "bot") {
421
425
  throw new RoutineDecodeError(`${label} kind is invalid`);
422
426
  }
423
- routineExactKeys(candidate, ["kind", "botId"], [], label);
427
+ routineExactKeys(
428
+ candidate,
429
+ ["kind", "botId", "sessionId", "turnId"],
430
+ [],
431
+ label,
432
+ );
424
433
  return {
425
434
  kind: "bot",
426
435
  botId: routineText(candidate.botId, 128, `${label} botId`),
436
+ sessionId: routineText(candidate.sessionId, 128, `${label} sessionId`),
437
+ turnId: routineText(candidate.turnId, 128, `${label} turnId`),
427
438
  };
428
439
  }
429
440
 
package/src/store.test.ts CHANGED
@@ -54,11 +54,19 @@ describe("RoutineStore.execute", () => {
54
54
  expect(listed.routines).toHaveLength(1);
55
55
  });
56
56
 
57
- test("records a Bot writer as the Bot, and the view names no Session", async () => {
57
+ test("records a Bot writer as the Bot, naming the Turn that wrote it", async () => {
58
58
  const routines = store();
59
59
  const receipt = await routines.execute(create(), BOT);
60
60
  if (receipt.status !== "applied") throw new Error("unreachable");
61
- expect(receipt.routine.createdBy).toEqual({ kind: "bot", botId: "scout" });
61
+ // Provenance that cannot answer "which Turn?" is not provenance: the view
62
+ // used to carry the Bot id alone, so a Routine a Bot wrote could not be
63
+ // traced back to the Turn that wrote it.
64
+ expect(receipt.routine.createdBy).toEqual({
65
+ kind: "bot",
66
+ botId: "scout",
67
+ sessionId: "tim:scout",
68
+ turnId: "turn-7",
69
+ });
62
70
  const stored = await routines.read("brief");
63
71
  expect(stored?.createdBy).toEqual(BOT);
64
72
  });
@@ -259,3 +267,43 @@ describe("RoutineStore run log", () => {
259
267
  expect(log.entries[0]).toMatchObject({ status: "failed" });
260
268
  });
261
269
  });
270
+
271
+ describe("a schedule that never comes around", () => {
272
+ /**
273
+ * `0 0 30 2 *` is February the 30th: croner parses it happily and then never
274
+ * names a next run. It used to be stored, and the scheduler's clock then fell
275
+ * back to "five minutes from now" on every claim, so the Routine burned a
276
+ * whole model Turn every five minutes for ever. A schedule with no future
277
+ * occurrence is not a schedule.
278
+ */
279
+ test("is refused at write time, and nothing is stored", async () => {
280
+ const routines = store();
281
+ await expect(
282
+ routines.execute(create({ schedule: "0 0 30 2 *" }), USER),
283
+ ).rejects.toThrow(/never comes around again/u);
284
+ await expect(
285
+ routines.execute(
286
+ create({ commandId: "cmd-2", schedule: "0 0 31 4 *" }),
287
+ USER,
288
+ ),
289
+ ).rejects.toThrow(/never comes around again/u);
290
+ expect((await routines.list("scout")).routines).toHaveLength(0);
291
+ });
292
+
293
+ test("names the expression and the zone, so the field can be corrected", async () => {
294
+ const routines = store();
295
+ const refusal = await routines
296
+ .execute(create({ schedule: "0 0 30 2 *" }), USER)
297
+ .catch((error: unknown) => error);
298
+ expect((refusal as Error).message).toContain("0 0 30 2 *");
299
+ expect((refusal as Error).message).toContain("Australia/Sydney");
300
+ });
301
+
302
+ test("an ordinary rare schedule is still accepted", async () => {
303
+ const routines = store();
304
+ // February the 29th happens; it is simply not every year.
305
+ await expect(
306
+ routines.execute(create({ schedule: "0 0 29 2 *" }), USER),
307
+ ).resolves.toMatchObject({ status: "applied" });
308
+ });
309
+ });
package/src/store.ts CHANGED
@@ -21,6 +21,7 @@
21
21
  // trigger kind, and minting is D3's.
22
22
  import {
23
23
  isRoutineTimezoneV1,
24
+ nextRoutineRunV1,
24
25
  normalizeRoutineScheduleV1,
25
26
  RoutineScheduleError,
26
27
  } from "./cron.js";
@@ -161,9 +162,17 @@ export interface RoutineStoreOptionsV1 {
161
162
  }
162
163
 
163
164
  function writerView(writer: RoutineWriterV1): RoutineWriterViewV1 {
165
+ // The Session and Turn travel with the Bot writer. A Routine a Bot wrote is
166
+ // provenance, and provenance that cannot name the Turn it came from is only
167
+ // half a record: "the Bot wrote this" is not answerable to "which Turn?".
164
168
  return writer.kind === "user"
165
169
  ? { kind: "user" }
166
- : { kind: "bot", botId: writer.botId };
170
+ : {
171
+ kind: "bot",
172
+ botId: writer.botId,
173
+ sessionId: writer.sessionId,
174
+ turnId: writer.turnId,
175
+ };
167
176
  }
168
177
 
169
178
  /**
@@ -774,8 +783,12 @@ export class RoutineStore {
774
783
  );
775
784
  }
776
785
  if (decoded.schedule !== undefined) {
786
+ let normalized;
777
787
  try {
778
- normalizeRoutineScheduleV1(decoded.schedule, decoded.timezone);
788
+ normalized = normalizeRoutineScheduleV1(
789
+ decoded.schedule,
790
+ decoded.timezone,
791
+ );
779
792
  } catch (error) {
780
793
  throw new RoutineDecodeError(
781
794
  error instanceof RoutineScheduleError
@@ -783,6 +796,17 @@ export class RoutineStore {
783
796
  : `Routine schedule is invalid: ${String(error)}`,
784
797
  );
785
798
  }
799
+ // A syntactically valid expression can still name a moment that never
800
+ // arrives — `0 0 30 2 *` is February the 30th. It used to be accepted,
801
+ // and then the clock fell back to "five minutes from now" on every claim,
802
+ // so the Routine burned a whole model Turn every five minutes for ever.
803
+ // A schedule that never comes around is not a schedule.
804
+ const now = this.#now();
805
+ if (nextRoutineRunV1(normalized, now, now) === undefined) {
806
+ throw new RoutineDecodeError(
807
+ `schedule "${decoded.schedule}" never comes around again in ${decoded.timezone}`,
808
+ );
809
+ }
786
810
  }
787
811
  return decoded;
788
812
  }