@frockbot/plugin-routines 0.0.0 → 0.1.0

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/src/store.ts ADDED
@@ -0,0 +1,789 @@
1
+ // The Routines authority: the Bot Durable Object's durable Routine records.
2
+ //
3
+ // "The Bot's Durable Object is the authority for everything Bot-scoped: …
4
+ // durable scheduling, Routines, Assignments." This class is that authority's
5
+ // implementation; the Durable Object hands it a storage seam and calls it. It is
6
+ // a deep module: `execute`, `list` and `listRuns` are the whole surface, and
7
+ // every command — from the hosted client, from the `routine_manage` tool, from a
8
+ // replay — reaches the durable record through `execute` alone.
9
+ //
10
+ // Two rules are enforced here and nowhere else:
11
+ //
12
+ // * Every durable write records its writer. A User write and a Bot write are
13
+ // the same write with different provenance, and a Bot writer names the
14
+ // Session and Turn that produced it.
15
+ // * One command id applies once. The receipt is durable and fingerprinted, so
16
+ // a retried command replays its recorded outcome and a reused key carrying
17
+ // different bytes is an error rather than a silent second write.
18
+ //
19
+ // D1 fires nothing. There is no alarm, no cron evaluation beyond the syntax
20
+ // check a write must pass, and no webhook key: a webhook Routine records the
21
+ // trigger kind, and minting is D3's.
22
+ import {
23
+ isRoutineTimezoneV1,
24
+ normalizeRoutineScheduleV1,
25
+ RoutineScheduleError,
26
+ } from "./cron.js";
27
+ import {
28
+ decodeRoutineRecordV1,
29
+ decodeRoutineRunEntryV1,
30
+ isRoutineIdV1,
31
+ requireScheduleXorTriggerV1,
32
+ RoutineDecodeError,
33
+ type RoutineRecordV1,
34
+ type RoutineRunEntryV1,
35
+ type RoutineWriterV1,
36
+ } from "./records.js";
37
+ import {
38
+ constantTimeEqualsV1,
39
+ decodeRoutineHookKeyV1,
40
+ renderRoutineDeliveryV1,
41
+ RoutineHookError,
42
+ ROUTINE_DELIVERY_LIMIT,
43
+ ROUTINE_DELIVERY_TTL_MS,
44
+ type RoutineDeliveryReceiptV1,
45
+ type RoutineHookKeyV1,
46
+ } from "./hook.js";
47
+ import {
48
+ ROUTINE_DELIVERY_PREFIX,
49
+ ROUTINE_LIMIT_PER_BOT,
50
+ ROUTINE_PREFIX,
51
+ ROUTINE_RUN_LOG_LIMIT,
52
+ routineFireKeyV1,
53
+ routineKeyV1,
54
+ routineQueuePrefixV1,
55
+ routineReceiptKeyV1,
56
+ routineRunKeyV1,
57
+ routineRunPrefixV1,
58
+ routineDeliveryKeyV1,
59
+ routineHookKeyRecordV1,
60
+ routineScheduleKeyV1,
61
+ nextRunSequenceV1,
62
+ } from "./storage-keys.js";
63
+ import {
64
+ routineCommandFingerprintV1,
65
+ type RoutineCommandReceiptV1,
66
+ type RoutineCommandV1,
67
+ type RoutineHookMintV1,
68
+ type RoutineListViewV1,
69
+ type RoutineRunListViewV1,
70
+ type RoutineViewV1,
71
+ type RoutineWriterViewV1,
72
+ } from "./shared.js";
73
+
74
+ /** A Routine the Bot does not hold. */
75
+ export class RoutineNotFoundError extends Error {
76
+ override readonly name = "RoutineNotFoundError";
77
+ constructor(routineId: string) {
78
+ super(`Routine "${routineId}" is unknown`);
79
+ }
80
+ }
81
+
82
+ /** The reads a Routine listing needs. */
83
+ export interface RoutineStorageReadsV1 {
84
+ get<T>(key: string): Promise<T | undefined>;
85
+ list<T>(options: { prefix: string; limit?: number }): Promise<Map<string, T>>;
86
+ }
87
+
88
+ /** The writes one transaction performs. */
89
+ export interface RoutineStorageWritesV1 extends RoutineStorageReadsV1 {
90
+ put(key: string, value: unknown): Promise<void>;
91
+ delete(key: string): Promise<boolean>;
92
+ }
93
+
94
+ /** The Durable Object storage seam. `DurableObjectStorage` satisfies it. */
95
+ export interface RoutineStorageV1 extends RoutineStorageWritesV1 {
96
+ transaction<T>(
97
+ closure: (transaction: RoutineStorageWritesV1) => Promise<T>,
98
+ ): Promise<T>;
99
+ }
100
+
101
+ /**
102
+ * The receipt as it is stored. A minted key is on the one the caller receives
103
+ * and on no other: a key a replay could re-read would not be a secret.
104
+ */
105
+ function strippedReceipt(
106
+ receipt: RoutineCommandReceiptV1,
107
+ ): RoutineCommandReceiptV1 {
108
+ if (receipt.status !== "applied" || receipt.hook === undefined) {
109
+ return receipt;
110
+ }
111
+ const { hook: _hook, ...rest } = receipt;
112
+ return rest;
113
+ }
114
+
115
+ interface StoredRoutineReceiptV1 {
116
+ commandFingerprint: string;
117
+ receipt: RoutineCommandReceiptV1;
118
+ }
119
+
120
+ /**
121
+ * The scheduler, as the command path needs it. `routine/run` asks for a firing
122
+ * and gets an id back; it never runs one itself, because the caller may be a
123
+ * Turn already in flight. `RoutineScheduler` satisfies this structurally, so
124
+ * the authority does not import the scheduler to hold one.
125
+ */
126
+ /**
127
+ * The minter, as the command path needs it. The token is derived from the
128
+ * Worker secret and the Routine's identity, which the Durable Object holds and
129
+ * this Package deliberately does not.
130
+ */
131
+ export interface RoutineHookMinterV1 {
132
+ mint(input: {
133
+ routineId: string;
134
+ keyVersion: number;
135
+ }): Promise<{ token: string; digest: string; path: string }>;
136
+ }
137
+
138
+ export interface RoutineFiringSeamV1 {
139
+ enqueueWithin(
140
+ transaction: RoutineStorageWritesV1,
141
+ input: {
142
+ routineId: string;
143
+ trigger: "manual" | "webhook";
144
+ discriminator: string;
145
+ delivery?: string;
146
+ },
147
+ ): Promise<{ fireId: string; queued: boolean }>;
148
+ }
149
+
150
+ export interface RoutineStoreOptionsV1 {
151
+ /** The zone a Routine that names none is scheduled in. */
152
+ defaultTimezone?: string;
153
+ /** Injected so a test can pin a clock; production passes nothing. */
154
+ now?(): Date;
155
+ /** Injected so a test can pin an id; production passes nothing. */
156
+ newRoutineId?(): string;
157
+ /** Absent means `routine/run` is refused rather than silently doing nothing. */
158
+ firings?: RoutineFiringSeamV1;
159
+ /** Absent means a webhook Routine gets no key, and says so. */
160
+ hookKeys?: RoutineHookMinterV1;
161
+ }
162
+
163
+ function writerView(writer: RoutineWriterV1): RoutineWriterViewV1 {
164
+ return writer.kind === "user"
165
+ ? { kind: "user" }
166
+ : { kind: "bot", botId: writer.botId };
167
+ }
168
+
169
+ /**
170
+ * The DTO for one record. Never carries key material, by construction.
171
+ *
172
+ * `nextRunAt` is passed in rather than computed: the scheduler owns the clock,
173
+ * and a projection that recomputed one would be a second opinion on when the
174
+ * Routine fires.
175
+ */
176
+ export function routineViewV1(
177
+ record: RoutineRecordV1,
178
+ nextRunAt?: string,
179
+ hookKeyVersion?: number,
180
+ ): RoutineViewV1 {
181
+ return {
182
+ schemaVersion: 1,
183
+ routineId: record.routineId,
184
+ name: record.name,
185
+ prompt: record.prompt,
186
+ timezone: record.timezone,
187
+ enabled: record.enabled,
188
+ createdBy: writerView(record.createdBy),
189
+ updatedBy: writerView(record.updatedBy),
190
+ createdAt: record.createdAt,
191
+ updatedAt: record.updatedAt,
192
+ ...(record.schedule === undefined ? {} : { schedule: record.schedule }),
193
+ ...(record.trigger === undefined ? {} : { trigger: record.trigger }),
194
+ ...(record.lastRunAt === undefined ? {} : { lastRunAt: record.lastRunAt }),
195
+ ...(nextRunAt === undefined ||
196
+ !record.enabled ||
197
+ record.schedule === undefined
198
+ ? {}
199
+ : { nextRunAt }),
200
+ ...(hookKeyVersion === undefined ? {} : { hookKeyVersion }),
201
+ };
202
+ }
203
+
204
+ /**
205
+ * Append one entry to a Routine's run log, inside a transaction the caller
206
+ * already holds, and trim the log to its bound.
207
+ *
208
+ * The scheduler writes the `running` entry in the same transaction that mints
209
+ * the firing, and rewrites that entry when the firing settles, so it appends
210
+ * through this rather than through `RoutineStore.recordRun`, which opens a
211
+ * transaction of its own. An entry id that is already present is rewritten in
212
+ * place: a settlement never appends a second row for a firing that has one.
213
+ */
214
+ export async function appendRoutineRunEntryV1(
215
+ transaction: RoutineStorageWritesV1,
216
+ entry: RoutineRunEntryV1,
217
+ ): Promise<void> {
218
+ const decoded = decodeRoutineRunEntryV1(entry);
219
+ const existing = await transaction.list<unknown>({
220
+ prefix: routineRunPrefixV1(decoded.routineId),
221
+ });
222
+ const seen = [...existing.entries()].find(
223
+ ([, value]) => decodeRoutineRunEntryV1(value).entryId === decoded.entryId,
224
+ );
225
+ if (seen) {
226
+ await transaction.put(seen[0], decoded);
227
+ return;
228
+ }
229
+ await transaction.put(
230
+ routineRunKeyV1(decoded.routineId, nextRunSequenceV1([...existing.keys()])),
231
+ decoded,
232
+ );
233
+ // Keys descend, so the oldest entries sort last.
234
+ const keys = [...existing.keys()].sort();
235
+ for (const key of keys.slice(ROUTINE_RUN_LOG_LIMIT - 1)) {
236
+ await transaction.delete(key);
237
+ }
238
+ }
239
+
240
+ export class RoutineStore {
241
+ readonly #storage: RoutineStorageV1;
242
+ readonly #defaultTimezone: string;
243
+ readonly #now: () => Date;
244
+ readonly #newRoutineId: () => string;
245
+ readonly #firings: RoutineFiringSeamV1 | undefined;
246
+ readonly #hookKeys: RoutineHookMinterV1 | undefined;
247
+
248
+ constructor(storage: RoutineStorageV1, options: RoutineStoreOptionsV1 = {}) {
249
+ this.#storage = storage;
250
+ this.#defaultTimezone = options.defaultTimezone ?? "UTC";
251
+ this.#now = options.now ?? (() => new Date());
252
+ this.#newRoutineId = options.newRoutineId ?? (() => crypto.randomUUID());
253
+ this.#firings = options.firings;
254
+ this.#hookKeys = options.hookKeys;
255
+ }
256
+
257
+ /**
258
+ * Every Routine this Bot holds, newest first. `nextRuns` comes from the
259
+ * scheduler, so "next run" in the UI is the moment an alarm is actually armed
260
+ * on and not a time this projection guessed.
261
+ */
262
+ async list(
263
+ botId: string,
264
+ nextRuns?: ReadonlyMap<string, string>,
265
+ ): Promise<RoutineListViewV1> {
266
+ const stored = await this.#storage.list<unknown>({
267
+ prefix: ROUTINE_PREFIX,
268
+ limit: ROUTINE_LIMIT_PER_BOT,
269
+ });
270
+ const routines = [...stored.values()].map((value) =>
271
+ decodeRoutineRecordV1(value),
272
+ );
273
+ const views: RoutineViewV1[] = [];
274
+ for (const record of routines) {
275
+ const key = await this.#storage.get<unknown>(
276
+ routineHookKeyRecordV1(record.routineId),
277
+ );
278
+ views.push(
279
+ routineViewV1(
280
+ record,
281
+ nextRuns?.get(record.routineId),
282
+ key === undefined
283
+ ? undefined
284
+ : decodeRoutineHookKeyV1(key).keyVersion,
285
+ ),
286
+ );
287
+ }
288
+ views.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
289
+ return { schemaVersion: 1, botId, routines: views };
290
+ }
291
+
292
+ async read(routineId: string): Promise<RoutineRecordV1 | undefined> {
293
+ if (!isRoutineIdV1(routineId)) {
294
+ throw new RoutineDecodeError("Routine id is invalid");
295
+ }
296
+ const stored = await this.#storage.get<unknown>(routineKeyV1(routineId));
297
+ return stored === undefined ? undefined : decodeRoutineRecordV1(stored);
298
+ }
299
+
300
+ /** One Routine's bounded run log, newest first. Empty until D2 fires one. */
301
+ async listRuns(
302
+ botId: string,
303
+ routineId: string,
304
+ ): Promise<RoutineRunListViewV1> {
305
+ const record = await this.read(routineId);
306
+ if (!record) throw new RoutineNotFoundError(routineId);
307
+ const stored = await this.#storage.list<unknown>({
308
+ prefix: routineRunPrefixV1(routineId),
309
+ limit: ROUTINE_RUN_LOG_LIMIT,
310
+ });
311
+ return {
312
+ schemaVersion: 1,
313
+ botId,
314
+ routineId,
315
+ entries: [...stored.values()].map((value) => {
316
+ const entry = decodeRoutineRunEntryV1(value);
317
+ return {
318
+ schemaVersion: 1 as const,
319
+ entryId: entry.entryId,
320
+ runId: entry.runId,
321
+ trigger: entry.trigger,
322
+ status: entry.status,
323
+ startedAt: entry.startedAt,
324
+ ...(entry.finishedAt === undefined
325
+ ? {}
326
+ : { finishedAt: entry.finishedAt }),
327
+ ...(entry.summary === undefined ? {} : { summary: entry.summary }),
328
+ };
329
+ }),
330
+ };
331
+ }
332
+
333
+ /**
334
+ * Append one entry to a Routine's run log and trim it to its bound.
335
+ *
336
+ * The log holds no authority: every entry names its `runId`, and the stored
337
+ * run carries `admission.origin.routineId`, so the whole log is rebuildable
338
+ * from the run index. Trimming loses index rows, never facts.
339
+ */
340
+ async recordRun(entry: RoutineRunEntryV1): Promise<void> {
341
+ await this.#storage.transaction((transaction) =>
342
+ appendRoutineRunEntryV1(transaction, entry),
343
+ );
344
+ }
345
+
346
+ /** One Routine's live webhook key record, or nothing. Never a token. */
347
+ async readHookKey(routineId: string): Promise<RoutineHookKeyV1 | undefined> {
348
+ const stored = await this.#storage.get<unknown>(
349
+ routineHookKeyRecordV1(routineId),
350
+ );
351
+ return stored === undefined ? undefined : decodeRoutineHookKeyV1(stored);
352
+ }
353
+
354
+ /**
355
+ * Accept one webhook delivery.
356
+ *
357
+ * The token already proved at the edge that it was minted by this deployment;
358
+ * this is where it proves it is still *this Routine's* key. The durable record
359
+ * is the authority, so a rotated or revoked key is refused here even though
360
+ * its signature is perfectly good — which is what makes rotation and
361
+ * revocation real rather than cosmetic.
362
+ *
363
+ * Everything else the door promises happens in one transaction: the replay
364
+ * guard, the firing, and the receipt that lets a replay answer with the
365
+ * firing it already made.
366
+ */
367
+ async deliverHook(input: {
368
+ routineId: string;
369
+ keyVersion: number;
370
+ digest: string;
371
+ deliveryId: string;
372
+ body: string;
373
+ contentType?: string | null;
374
+ }): Promise<{ status: "accepted" | "duplicate"; fireId: string }> {
375
+ if (!this.#firings) {
376
+ throw new RoutineHookError(500, "this Bot cannot accept a delivery");
377
+ }
378
+ const firings = this.#firings;
379
+ const now = this.#now();
380
+ return this.#storage.transaction(async (transaction) => {
381
+ const stored = await transaction.get<unknown>(
382
+ routineKeyV1(input.routineId),
383
+ );
384
+ if (stored === undefined) {
385
+ throw new RoutineHookError(404, "Routine not found");
386
+ }
387
+ const record = decodeRoutineRecordV1(stored);
388
+ const held = await transaction.get<unknown>(
389
+ routineHookKeyRecordV1(input.routineId),
390
+ );
391
+ if (record.trigger === undefined || held === undefined) {
392
+ // No live key: the same answer a forged one gets, because saying
393
+ // "revoked" would tell a caller its guess named a real Routine.
394
+ throw new RoutineHookError(401, "webhook key is invalid");
395
+ }
396
+ const key = decodeRoutineHookKeyV1(held);
397
+ if (
398
+ key.keyVersion !== input.keyVersion ||
399
+ !constantTimeEqualsV1(key.digest, input.digest)
400
+ ) {
401
+ throw new RoutineHookError(401, "webhook key is invalid");
402
+ }
403
+ if (!record.enabled) {
404
+ // The key is good and the Routine is real; it is simply paused. That
405
+ // is worth telling the caller, so a delivery can be retried later.
406
+ throw new RoutineHookError(409, "Routine is paused");
407
+ }
408
+ const receiptKey = routineDeliveryKeyV1(input.deliveryId);
409
+ const seen = await transaction.get<RoutineDeliveryReceiptV1>(receiptKey);
410
+ if (
411
+ seen &&
412
+ Date.parse(seen.acceptedAt) > now.getTime() - ROUTINE_DELIVERY_TTL_MS
413
+ ) {
414
+ return { status: "duplicate" as const, fireId: seen.fireId };
415
+ }
416
+ const { fireId } = await firings.enqueueWithin(transaction, {
417
+ routineId: input.routineId,
418
+ trigger: "webhook",
419
+ discriminator: `hook-${input.deliveryId.slice(0, 40)}`,
420
+ delivery: renderRoutineDeliveryV1(input.body, input.contentType),
421
+ });
422
+ await transaction.put(receiptKey, {
423
+ schemaVersion: 1,
424
+ routineId: input.routineId,
425
+ fireId,
426
+ acceptedAt: now.toISOString(),
427
+ } satisfies RoutineDeliveryReceiptV1);
428
+ await this.#trimDeliveries(transaction, now);
429
+ return { status: "accepted" as const, fireId };
430
+ });
431
+ }
432
+
433
+ /** Keep the replay guard bounded, and drop what is past its window. */
434
+ async #trimDeliveries(
435
+ transaction: RoutineStorageWritesV1,
436
+ now: Date,
437
+ ): Promise<void> {
438
+ const held = await transaction.list<RoutineDeliveryReceiptV1>({
439
+ prefix: ROUTINE_DELIVERY_PREFIX,
440
+ });
441
+ const live: Array<[string, RoutineDeliveryReceiptV1]> = [];
442
+ for (const [key, receipt] of held) {
443
+ const acceptedAt = Date.parse(receipt?.acceptedAt ?? "");
444
+ if (
445
+ Number.isNaN(acceptedAt) ||
446
+ acceptedAt <= now.getTime() - ROUTINE_DELIVERY_TTL_MS
447
+ ) {
448
+ await transaction.delete(key);
449
+ continue;
450
+ }
451
+ live.push([key, receipt]);
452
+ }
453
+ if (live.length <= ROUTINE_DELIVERY_LIMIT) return;
454
+ live.sort(
455
+ ([, left], [, right]) =>
456
+ Date.parse(left.acceptedAt) - Date.parse(right.acceptedAt),
457
+ );
458
+ for (const [key] of live.slice(0, live.length - ROUTINE_DELIVERY_LIMIT)) {
459
+ await transaction.delete(key);
460
+ }
461
+ }
462
+
463
+ /**
464
+ * Apply one command. The receipt is durable and fingerprinted, so a retry of
465
+ * the same command id replays its outcome and a reused id carrying different
466
+ * bytes is refused.
467
+ */
468
+ async execute(
469
+ command: RoutineCommandV1,
470
+ writer: RoutineWriterV1,
471
+ ): Promise<RoutineCommandReceiptV1> {
472
+ const fingerprint = routineCommandFingerprintV1(command);
473
+ // A refused command is returned out of the transaction rather than thrown
474
+ // through it: every refusal happens before the first write, so rolling back
475
+ // would be a no-op, and a transaction that rejects surfaces the failure
476
+ // twice inside a Durable Object.
477
+ const outcome = await this.#storage.transaction<
478
+ | { ok: true; receipt: RoutineCommandReceiptV1 }
479
+ | { ok: false; error: unknown }
480
+ >(async (transaction) => {
481
+ const receiptKey = routineReceiptKeyV1(command.commandId);
482
+ const existing =
483
+ await transaction.get<StoredRoutineReceiptV1>(receiptKey);
484
+ if (existing) {
485
+ if (existing.commandFingerprint !== fingerprint) {
486
+ return {
487
+ ok: false,
488
+ error: new RoutineDecodeError(
489
+ `Routine command idempotency key "${command.commandId}" was reused for a different command`,
490
+ ),
491
+ };
492
+ }
493
+ return { ok: true, receipt: existing.receipt };
494
+ }
495
+ let receipt: RoutineCommandReceiptV1;
496
+ try {
497
+ receipt = await this.#apply(transaction, command, writer);
498
+ } catch (error) {
499
+ return { ok: false, error };
500
+ }
501
+ await transaction.put(receiptKey, {
502
+ commandFingerprint: fingerprint,
503
+ // The minted key is stripped before the receipt is stored. A key a
504
+ // replay could re-read would not be a secret, so a replayed command id
505
+ // answers with the Routine and no key at all.
506
+ receipt: strippedReceipt(receipt),
507
+ } satisfies StoredRoutineReceiptV1);
508
+ return { ok: true, receipt };
509
+ });
510
+ if (!outcome.ok) throw outcome.error;
511
+ return outcome.receipt;
512
+ }
513
+
514
+ async #apply(
515
+ transaction: RoutineStorageWritesV1,
516
+ command: RoutineCommandV1,
517
+ writer: RoutineWriterV1,
518
+ ): Promise<RoutineCommandReceiptV1> {
519
+ const at = this.#now().toISOString();
520
+ if (command.type === "routine/create") {
521
+ const held = await transaction.list<unknown>({
522
+ prefix: ROUTINE_PREFIX,
523
+ limit: ROUTINE_LIMIT_PER_BOT + 1,
524
+ });
525
+ if (held.size >= ROUTINE_LIMIT_PER_BOT) {
526
+ throw new RoutineDecodeError(
527
+ `a Bot may hold at most ${ROUTINE_LIMIT_PER_BOT} Routines`,
528
+ );
529
+ }
530
+ const routineId = command.routineId ?? this.#newRoutineId();
531
+ if (!isRoutineIdV1(routineId)) {
532
+ throw new RoutineDecodeError("Routine id is invalid");
533
+ }
534
+ if (await transaction.get<unknown>(routineKeyV1(routineId))) {
535
+ throw new RoutineDecodeError(`Routine "${routineId}" already exists`);
536
+ }
537
+ const timezone = command.timezone ?? this.#defaultTimezone;
538
+ const draft: RoutineRecordV1 = {
539
+ schemaVersion: 1,
540
+ routineId,
541
+ name: command.name,
542
+ prompt: command.prompt,
543
+ timezone,
544
+ enabled: true,
545
+ createdBy: writer,
546
+ updatedBy: writer,
547
+ createdAt: at,
548
+ updatedAt: at,
549
+ ...(command.schedule === undefined
550
+ ? {}
551
+ : { schedule: command.schedule }),
552
+ ...(command.trigger === undefined ? {} : { trigger: command.trigger }),
553
+ };
554
+ const record = this.#validated(draft);
555
+ await transaction.put(routineKeyV1(routineId), record);
556
+ // A webhook Routine is useless without a door key, so creating one mints
557
+ // it in the same transaction. It is handed back once and never stored.
558
+ const minted =
559
+ record.trigger === undefined
560
+ ? undefined
561
+ : await this.#mint(transaction, record.routineId, at);
562
+ return {
563
+ schemaVersion: 1,
564
+ commandId: command.commandId,
565
+ status: "applied",
566
+ routine: routineViewV1(record, undefined, minted?.keyVersion),
567
+ ...(minted ? { hook: minted.mint } : {}),
568
+ };
569
+ }
570
+
571
+ const stored = await transaction.get<unknown>(
572
+ routineKeyV1(command.routineId),
573
+ );
574
+ if (stored === undefined) throw new RoutineNotFoundError(command.routineId);
575
+ const current = decodeRoutineRecordV1(stored);
576
+
577
+ if (command.type === "routine/run") {
578
+ if (!this.#firings) {
579
+ throw new RoutineDecodeError(
580
+ "this Bot cannot fire a Routine on demand",
581
+ );
582
+ }
583
+ const { fireId } = await this.#firings.enqueueWithin(transaction, {
584
+ routineId: command.routineId,
585
+ trigger: "manual",
586
+ discriminator: `manual-${command.commandId}`,
587
+ });
588
+ return {
589
+ schemaVersion: 1,
590
+ commandId: command.commandId,
591
+ status: "fired",
592
+ routineId: command.routineId,
593
+ fireId,
594
+ };
595
+ }
596
+
597
+ if (
598
+ command.type === "routine/rotate-key" ||
599
+ command.type === "routine/revoke-key"
600
+ ) {
601
+ if (current.trigger === undefined) {
602
+ throw new RoutineDecodeError(
603
+ `Routine "${command.routineId}" has no webhook trigger to key`,
604
+ );
605
+ }
606
+ if (command.type === "routine/revoke-key") {
607
+ await transaction.delete(routineHookKeyRecordV1(command.routineId));
608
+ return {
609
+ schemaVersion: 1,
610
+ commandId: command.commandId,
611
+ status: "applied",
612
+ routine: routineViewV1(current),
613
+ };
614
+ }
615
+ if (!this.#hookKeys) {
616
+ throw new RoutineDecodeError(
617
+ "this Bot cannot mint a webhook key; ROUTINE_HOOK_SECRET is not configured",
618
+ );
619
+ }
620
+ const minted = await this.#mint(transaction, command.routineId, at);
621
+ return {
622
+ schemaVersion: 1,
623
+ commandId: command.commandId,
624
+ status: "applied",
625
+ routine: routineViewV1(current, undefined, minted?.keyVersion),
626
+ ...(minted ? { hook: minted.mint } : {}),
627
+ };
628
+ }
629
+
630
+ if (command.type === "routine/delete") {
631
+ await transaction.delete(routineKeyV1(command.routineId));
632
+ // The key goes with the Routine: a delivery to a deleted Routine is a
633
+ // 404 rather than a key that verifies against nothing.
634
+ await transaction.delete(routineHookKeyRecordV1(command.routineId));
635
+ const delivered = await transaction.list<{ routineId?: string }>({
636
+ prefix: ROUTINE_DELIVERY_PREFIX,
637
+ });
638
+ for (const [key, receipt] of delivered) {
639
+ if (receipt?.routineId === command.routineId) {
640
+ await transaction.delete(key);
641
+ }
642
+ }
643
+ // The clock, the unsettled firing and anything queued behind it go with
644
+ // the record: nothing may fire a Routine that no longer exists.
645
+ await transaction.delete(routineScheduleKeyV1(command.routineId));
646
+ await transaction.delete(routineFireKeyV1(command.routineId));
647
+ const waiting = await transaction.list<unknown>({
648
+ prefix: routineQueuePrefixV1(command.routineId),
649
+ });
650
+ for (const key of waiting.keys()) await transaction.delete(key);
651
+ const runs = await transaction.list<unknown>({
652
+ prefix: routineRunPrefixV1(command.routineId),
653
+ });
654
+ for (const key of runs.keys()) await transaction.delete(key);
655
+ return {
656
+ schemaVersion: 1,
657
+ commandId: command.commandId,
658
+ status: "deleted",
659
+ routineId: command.routineId,
660
+ };
661
+ }
662
+
663
+ let next: RoutineRecordV1;
664
+ if (command.type === "routine/pause" || command.type === "routine/resume") {
665
+ next = {
666
+ ...current,
667
+ enabled: command.type === "routine/resume",
668
+ updatedBy: writer,
669
+ updatedAt: at,
670
+ };
671
+ } else {
672
+ // Partial update: an absent key leaves the durable field exactly as it
673
+ // was. Naming a schedule clears a trigger and the reverse, because the
674
+ // record may carry only one.
675
+ const replacesTiming =
676
+ command.schedule !== undefined || command.trigger !== undefined;
677
+ next = {
678
+ ...current,
679
+ ...(command.name === undefined ? {} : { name: command.name }),
680
+ ...(command.prompt === undefined ? {} : { prompt: command.prompt }),
681
+ ...(command.timezone === undefined
682
+ ? {}
683
+ : { timezone: command.timezone }),
684
+ ...(command.enabled === undefined ? {} : { enabled: command.enabled }),
685
+ updatedBy: writer,
686
+ updatedAt: at,
687
+ };
688
+ if (replacesTiming) {
689
+ delete next.schedule;
690
+ delete next.trigger;
691
+ if (command.schedule !== undefined) next.schedule = command.schedule;
692
+ if (command.trigger !== undefined) next.trigger = command.trigger;
693
+ }
694
+ }
695
+ const record = this.#validated(next);
696
+ await transaction.put(routineKeyV1(record.routineId), record);
697
+ // A Routine that has just become a webhook needs a key; one that has just
698
+ // stopped being one must not keep a live door.
699
+ let minted: { keyVersion: number; mint: RoutineHookMintV1 } | undefined;
700
+ const held = await transaction.get<unknown>(
701
+ routineHookKeyRecordV1(record.routineId),
702
+ );
703
+ if (record.trigger !== undefined && held === undefined) {
704
+ minted = await this.#mint(transaction, record.routineId, at);
705
+ } else if (record.trigger === undefined && held !== undefined) {
706
+ await transaction.delete(routineHookKeyRecordV1(record.routineId));
707
+ }
708
+ return {
709
+ schemaVersion: 1,
710
+ commandId: command.commandId,
711
+ status: "applied",
712
+ routine: routineViewV1(
713
+ record,
714
+ undefined,
715
+ minted?.keyVersion ??
716
+ (record.trigger !== undefined && held !== undefined
717
+ ? decodeRoutineHookKeyV1(held).keyVersion
718
+ : undefined),
719
+ ),
720
+ ...(minted ? { hook: minted.mint } : {}),
721
+ };
722
+ }
723
+
724
+ /**
725
+ * Mint the next key version for a Routine and record its digest.
726
+ *
727
+ * The plaintext leaves on the returned receipt and is written nowhere: the
728
+ * durable record holds `SHA-256(token)` and the version, which is everything
729
+ * a delivery needs to be checked and everything a rotation needs to retire.
730
+ */
731
+ async #mint(
732
+ transaction: RoutineStorageWritesV1,
733
+ routineId: string,
734
+ at: string,
735
+ ): Promise<{ keyVersion: number; mint: RoutineHookMintV1 } | undefined> {
736
+ // A deployment with no signing secret can still record a webhook Routine;
737
+ // it simply has no door key, and the delivery route refuses everything for
738
+ // it. Recording the Routine and refusing the key is honest; minting a key
739
+ // nothing could verify would not be.
740
+ if (!this.#hookKeys) return undefined;
741
+ const held = await transaction.get<unknown>(
742
+ routineHookKeyRecordV1(routineId),
743
+ );
744
+ const keyVersion =
745
+ held === undefined ? 1 : decodeRoutineHookKeyV1(held).keyVersion + 1;
746
+ const { token, digest, path } = await this.#hookKeys.mint({
747
+ routineId,
748
+ keyVersion,
749
+ });
750
+ await transaction.put(routineHookKeyRecordV1(routineId), {
751
+ schemaVersion: 1,
752
+ routineId,
753
+ keyVersion,
754
+ digest,
755
+ createdAt: at,
756
+ } satisfies RoutineHookKeyV1);
757
+ return {
758
+ keyVersion,
759
+ mint: { schemaVersion: 1, routineId, keyVersion, token, path },
760
+ };
761
+ }
762
+
763
+ /**
764
+ * The one gate every write passes. Syntax only: a schedule is parsed and a
765
+ * timezone is resolved so a bad one is a rejected command rather than a dead
766
+ * alarm, and nothing here computes a next firing.
767
+ */
768
+ #validated(record: RoutineRecordV1): RoutineRecordV1 {
769
+ const decoded = decodeRoutineRecordV1(record);
770
+ requireScheduleXorTriggerV1(decoded);
771
+ if (!isRoutineTimezoneV1(decoded.timezone)) {
772
+ throw new RoutineDecodeError(
773
+ `timezone "${decoded.timezone}" is not an IANA time zone`,
774
+ );
775
+ }
776
+ if (decoded.schedule !== undefined) {
777
+ try {
778
+ normalizeRoutineScheduleV1(decoded.schedule, decoded.timezone);
779
+ } catch (error) {
780
+ throw new RoutineDecodeError(
781
+ error instanceof RoutineScheduleError
782
+ ? error.message
783
+ : `Routine schedule is invalid: ${String(error)}`,
784
+ );
785
+ }
786
+ }
787
+ return decoded;
788
+ }
789
+ }