@frockbot/plugin-routines 0.0.0 → 0.1.1

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.
@@ -0,0 +1,482 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ RoutineScheduler,
4
+ routineDeadlineV1,
5
+ type RoutineFireOutcomeV1,
6
+ } from "./scheduler.js";
7
+ import { decodeRoutineScheduleStateV1, type RoutineFireV1 } from "./firing.js";
8
+ import { RoutineStore } from "./store.js";
9
+ import { createMemoryRoutineStorageV1 } from "./testing.js";
10
+ import {
11
+ routineFireKeyV1,
12
+ routineScheduleKeyV1,
13
+ ROUTINE_QUEUE_LIMIT,
14
+ } from "./storage-keys.js";
15
+ import type { RoutineCommandV1 } from "./shared.js";
16
+
17
+ const USER = { kind: "user" } as const;
18
+
19
+ /** A clock a test drives, so nothing here waits on real time. */
20
+ function clock(start: string) {
21
+ let at = new Date(start);
22
+ return {
23
+ now: () => at,
24
+ set(next: string) {
25
+ at = new Date(next);
26
+ },
27
+ advance(ms: number) {
28
+ at = new Date(at.getTime() + ms);
29
+ },
30
+ };
31
+ }
32
+
33
+ function harness(options: { start: string; schedule?: string }) {
34
+ const storage = createMemoryRoutineStorageV1();
35
+ const time = clock(options.start);
36
+ const scheduler = new RoutineScheduler(storage, { now: time.now });
37
+ const store = new RoutineStore(storage, {
38
+ now: time.now,
39
+ firings: scheduler,
40
+ defaultTimezone: "UTC",
41
+ });
42
+ const create: RoutineCommandV1 = {
43
+ schemaVersion: 1,
44
+ type: "routine/create",
45
+ commandId: "cmd-create",
46
+ botId: "scout",
47
+ routineId: "brief",
48
+ name: "Morning brief",
49
+ prompt: "Summarize overnight email.",
50
+ timezone: "UTC",
51
+ ...(options.schedule === undefined
52
+ ? { trigger: { kind: "webhook" as const } }
53
+ : { schedule: options.schedule }),
54
+ };
55
+ return { storage, time, scheduler, store, create };
56
+ }
57
+
58
+ async function state(storage: ReturnType<typeof createMemoryRoutineStorageV1>) {
59
+ return decodeRoutineScheduleStateV1(
60
+ await storage.get(routineScheduleKeyV1("brief")),
61
+ );
62
+ }
63
+
64
+ /** Drains, recording each firing, and answers with the outcome the test names. */
65
+ function drain(
66
+ scheduler: RoutineScheduler,
67
+ outcome: RoutineFireOutcomeV1 = { status: "ok", summary: "done" },
68
+ ): Promise<RoutineFireV1[]> {
69
+ const fired: RoutineFireV1[] = [];
70
+ return scheduler
71
+ .settle(async (fire) => {
72
+ fired.push(fire);
73
+ return outcome;
74
+ })
75
+ .then(() => fired);
76
+ }
77
+
78
+ describe("RoutineScheduler deadlines", () => {
79
+ test("arms on the next occurrence of an enabled scheduled Routine", async () => {
80
+ const { storage, scheduler, store, create } = harness({
81
+ start: "2026-01-01T00:00:00.000Z",
82
+ schedule: "0 9 * * *",
83
+ });
84
+ await store.execute(create, USER);
85
+
86
+ expect(await scheduler.deadlines(storage)).toEqual([
87
+ Date.parse("2026-01-01T09:00:00.000Z"),
88
+ ]);
89
+ });
90
+
91
+ test("arms on nothing for a paused Routine or a webhook Routine", async () => {
92
+ const paused = harness({
93
+ start: "2026-01-01T00:00:00.000Z",
94
+ schedule: "0 9 * * *",
95
+ });
96
+ await paused.store.execute(paused.create, USER);
97
+ await paused.store.execute(
98
+ {
99
+ schemaVersion: 1,
100
+ type: "routine/pause",
101
+ commandId: "cmd-pause",
102
+ botId: "scout",
103
+ routineId: "brief",
104
+ },
105
+ USER,
106
+ );
107
+ expect(await paused.scheduler.deadlines(paused.storage)).toEqual([]);
108
+
109
+ const webhook = harness({ start: "2026-01-01T00:00:00.000Z" });
110
+ await webhook.store.execute(webhook.create, USER);
111
+ expect(await webhook.scheduler.deadlines(webhook.storage)).toEqual([]);
112
+ });
113
+
114
+ test("a deferral holds the alarm off and never moves the debt", async () => {
115
+ const { storage, time, scheduler, store, create } = harness({
116
+ start: "2026-01-01T08:00:00.000Z",
117
+ schedule: "0 9 * * *",
118
+ });
119
+ await store.execute(create, USER);
120
+ // Nothing has written a clock yet: it is computed from the record until a
121
+ // deferral or a firing has cause to persist one.
122
+ const due = (await scheduler.deadlines(storage))[0]!;
123
+ expect(due).toBe(Date.parse("2026-01-01T09:00:00.000Z"));
124
+
125
+ // The Turn overruns the occurrence: the alarm fires, the object is busy,
126
+ // and the deferral holds.
127
+ time.set("2026-01-01T09:00:30.000Z");
128
+ await scheduler.defer(storage);
129
+
130
+ const deferred = await state(storage);
131
+ expect(deferred.dueAt).toBe(due);
132
+ expect(deferred.deferredUntil).toBe(Date.parse("2026-01-01T09:00:45.000Z"));
133
+ // The debt is in the past, so the deadline is the hold — not the past due
134
+ // time, which would re-arm an immediate alarm and spin.
135
+ expect(routineDeadlineV1(deferred)).toBe(
136
+ Date.parse("2026-01-01T09:00:45.000Z"),
137
+ );
138
+
139
+ // And when the hold lapses the firing still lands: nothing was skipped.
140
+ time.set("2026-01-01T09:00:46.000Z");
141
+ const fired = await drain(scheduler);
142
+ expect(fired).toHaveLength(1);
143
+ expect(fired[0]).toMatchObject({ trigger: "cron", dueAt: due });
144
+ });
145
+
146
+ test("recomputes the clock when the Routine's timing is rewritten", async () => {
147
+ const { storage, scheduler, store, create } = harness({
148
+ start: "2026-01-01T00:00:00.000Z",
149
+ schedule: "0 9 * * *",
150
+ });
151
+ await store.execute(create, USER);
152
+ expect(await scheduler.deadlines(storage)).toEqual([
153
+ Date.parse("2026-01-01T09:00:00.000Z"),
154
+ ]);
155
+
156
+ await store.execute(
157
+ {
158
+ schemaVersion: 1,
159
+ type: "routine/update",
160
+ commandId: "cmd-update",
161
+ botId: "scout",
162
+ routineId: "brief",
163
+ schedule: "0 6 * * *",
164
+ },
165
+ USER,
166
+ );
167
+ expect(await scheduler.deadlines(storage)).toEqual([
168
+ Date.parse("2026-01-01T06:00:00.000Z"),
169
+ ]);
170
+ });
171
+ });
172
+
173
+ describe("RoutineScheduler settle", () => {
174
+ test("mints one firing per occurrence and advances the clock before it runs", async () => {
175
+ const { storage, time, scheduler, store, create } = harness({
176
+ start: "2026-01-01T08:59:00.000Z",
177
+ schedule: "0 9 * * *",
178
+ });
179
+ await store.execute(create, USER);
180
+
181
+ expect(await drain(scheduler)).toEqual([]);
182
+
183
+ time.set("2026-01-01T09:00:00.000Z");
184
+ const fired = await drain(scheduler);
185
+ expect(fired).toHaveLength(1);
186
+ expect(fired[0]).toMatchObject({
187
+ routineId: "brief",
188
+ trigger: "cron",
189
+ dueAt: Date.parse("2026-01-01T09:00:00.000Z"),
190
+ });
191
+ expect(fired[0]!.cue).toContain("Summarize overnight email.");
192
+ expect((await state(storage)).dueAt).toBe(
193
+ Date.parse("2026-01-02T09:00:00.000Z"),
194
+ );
195
+
196
+ // The lock is released and the log records the settled outcome.
197
+ expect(await scheduler.readFire("brief")).toBeUndefined();
198
+ const runs = await store.listRuns("scout", "brief");
199
+ expect(runs.entries).toHaveLength(1);
200
+ expect(runs.entries[0]).toMatchObject({
201
+ status: "ok",
202
+ trigger: "cron",
203
+ summary: "done",
204
+ });
205
+ });
206
+
207
+ test("records the firing durably before the Turn runs, and unlocks after", async () => {
208
+ const { storage, time, scheduler, store, create } = harness({
209
+ start: "2026-01-01T08:00:00.000Z",
210
+ schedule: "0 9 * * *",
211
+ });
212
+ await store.execute(create, USER);
213
+ time.set("2026-01-01T09:00:00.000Z");
214
+
215
+ let lockedDuringRun: unknown;
216
+ await scheduler.settle(async () => {
217
+ lockedDuringRun = await storage.get(routineFireKeyV1("brief"));
218
+ return { status: "ok" };
219
+ });
220
+
221
+ expect(lockedDuringRun).toMatchObject({ routineId: "brief" });
222
+ expect(await storage.get(routineFireKeyV1("brief"))).toBeUndefined();
223
+ });
224
+
225
+ test("a failed Turn is a failed run-log entry, not a lost firing", async () => {
226
+ const { time, scheduler, store, create } = harness({
227
+ start: "2026-01-01T08:00:00.000Z",
228
+ schedule: "0 9 * * *",
229
+ });
230
+ await store.execute(create, USER);
231
+ time.set("2026-01-01T09:00:00.000Z");
232
+
233
+ await scheduler.settle(() => {
234
+ throw new Error("the provider refused");
235
+ });
236
+
237
+ const runs = await store.listRuns("scout", "brief");
238
+ expect(runs.entries).toHaveLength(1);
239
+ expect(runs.entries[0]).toMatchObject({
240
+ status: "failed",
241
+ summary: "the provider refused",
242
+ });
243
+ expect(await scheduler.readFire("brief")).toBeUndefined();
244
+ });
245
+
246
+ test("a Routine three hours late fires once and says what it slept through", async () => {
247
+ const { storage, time, scheduler, store, create } = harness({
248
+ start: "2026-01-01T00:00:00.000Z",
249
+ schedule: "0 * * * *",
250
+ });
251
+ await store.execute(create, USER);
252
+ // Three hours pass with the object evicted.
253
+ time.set("2026-01-01T04:30:00.000Z");
254
+
255
+ const fired = await drain(scheduler);
256
+ expect(fired).toHaveLength(1);
257
+ expect(fired[0]).toMatchObject({ missedCount: 4 });
258
+ expect(fired[0]!.cue).toContain("4 scheduled occurrences elapsed");
259
+
260
+ // Forward from now, never backfilled.
261
+ expect((await state(storage)).dueAt).toBe(
262
+ Date.parse("2026-01-01T05:00:00.000Z"),
263
+ );
264
+
265
+ const runs = await store.listRuns("scout", "brief");
266
+ expect(runs.entries.map((entry) => entry.status).sort()).toEqual([
267
+ "ok",
268
+ "skipped",
269
+ ]);
270
+ expect(
271
+ runs.entries.find((entry) => entry.status === "skipped")?.summary,
272
+ ).toContain("3 scheduled occurrences elapsed");
273
+ });
274
+
275
+ test("a firing that is not late records no skipped entry", async () => {
276
+ const { time, scheduler, store, create } = harness({
277
+ start: "2026-01-01T00:00:00.000Z",
278
+ schedule: "0 * * * *",
279
+ });
280
+ await store.execute(create, USER);
281
+ time.set("2026-01-01T01:00:30.000Z");
282
+
283
+ await drain(scheduler);
284
+ const runs = await store.listRuns("scout", "brief");
285
+ expect(runs.entries).toHaveLength(1);
286
+ expect(runs.entries[0]).toMatchObject({ status: "ok" });
287
+ });
288
+
289
+ test("drains a queue in order and never runs two firings of one Routine at once", async () => {
290
+ const { time, scheduler, store, create } = harness({
291
+ start: "2026-01-01T00:00:00.000Z",
292
+ });
293
+ await store.execute(create, USER);
294
+
295
+ await scheduler.enqueue({
296
+ routineId: "brief",
297
+ trigger: "manual",
298
+ discriminator: "first",
299
+ });
300
+ time.advance(1_000);
301
+ await scheduler.enqueue({
302
+ routineId: "brief",
303
+ trigger: "manual",
304
+ discriminator: "second",
305
+ });
306
+
307
+ const concurrent: number[] = [];
308
+ let running = 0;
309
+ await scheduler.settle(async () => {
310
+ running += 1;
311
+ concurrent.push(running);
312
+ running -= 1;
313
+ return { status: "ok" };
314
+ });
315
+ expect(concurrent).toEqual([1, 1]);
316
+
317
+ const runs = await store.listRuns("scout", "brief");
318
+ expect(runs.entries.map((entry) => entry.runId)).toEqual([
319
+ "rf-brief-second",
320
+ "rf-brief-first",
321
+ ]);
322
+ });
323
+
324
+ test("the same request twice is one firing", async () => {
325
+ const { scheduler, store, create } = harness({
326
+ start: "2026-01-01T00:00:00.000Z",
327
+ });
328
+ await store.execute(create, USER);
329
+ const first = await scheduler.enqueue({
330
+ routineId: "brief",
331
+ trigger: "manual",
332
+ discriminator: "same",
333
+ });
334
+ const second = await scheduler.enqueue({
335
+ routineId: "brief",
336
+ trigger: "manual",
337
+ discriminator: "same",
338
+ });
339
+
340
+ expect(second.fireId).toBe(first.fireId);
341
+ expect(second.queued).toBe(false);
342
+ expect(await drain(scheduler)).toHaveLength(1);
343
+ });
344
+
345
+ test("refuses a ninth waiting firing rather than dropping one in silence", async () => {
346
+ const { scheduler, store, create } = harness({
347
+ start: "2026-01-01T00:00:00.000Z",
348
+ });
349
+ await store.execute(create, USER);
350
+ for (let index = 0; index < ROUTINE_QUEUE_LIMIT; index += 1) {
351
+ await scheduler.enqueue({
352
+ routineId: "brief",
353
+ trigger: "manual",
354
+ discriminator: `q-${index}`,
355
+ });
356
+ }
357
+ await expect(
358
+ scheduler.enqueue({
359
+ routineId: "brief",
360
+ trigger: "manual",
361
+ discriminator: "overflow",
362
+ }),
363
+ ).rejects.toThrow(/8 firings waiting/);
364
+ });
365
+ });
366
+
367
+ describe("routine/run", () => {
368
+ test("queues a manual firing and answers with the run id it will take", async () => {
369
+ const { scheduler, store, create } = harness({
370
+ start: "2026-01-01T00:00:00.000Z",
371
+ schedule: "0 9 * * *",
372
+ });
373
+ await store.execute(create, USER);
374
+
375
+ const receipt = await store.execute(
376
+ {
377
+ schemaVersion: 1,
378
+ type: "routine/run",
379
+ commandId: "cmd-run",
380
+ botId: "scout",
381
+ routineId: "brief",
382
+ },
383
+ USER,
384
+ );
385
+ expect(receipt).toMatchObject({
386
+ status: "fired",
387
+ routineId: "brief",
388
+ fireId: "rf-brief-manual-cmd-run",
389
+ });
390
+
391
+ const fired = await drain(scheduler);
392
+ expect(fired).toHaveLength(1);
393
+ expect(fired[0]).toMatchObject({
394
+ trigger: "manual",
395
+ fireId: "rf-brief-manual-cmd-run",
396
+ });
397
+ });
398
+
399
+ test("a replayed command id fires once", async () => {
400
+ const { scheduler, store, create } = harness({
401
+ start: "2026-01-01T00:00:00.000Z",
402
+ schedule: "0 9 * * *",
403
+ });
404
+ await store.execute(create, USER);
405
+ const command = {
406
+ schemaVersion: 1,
407
+ type: "routine/run",
408
+ commandId: "cmd-run",
409
+ botId: "scout",
410
+ routineId: "brief",
411
+ } satisfies RoutineCommandV1;
412
+
413
+ expect(await store.execute(command, USER)).toEqual(
414
+ await store.execute(command, USER),
415
+ );
416
+ expect(await drain(scheduler)).toHaveLength(1);
417
+ });
418
+
419
+ test("deleting a Routine forgets its clock, its firing and its queue", async () => {
420
+ const { storage, scheduler, store, create } = harness({
421
+ start: "2026-01-01T00:00:00.000Z",
422
+ schedule: "0 9 * * *",
423
+ });
424
+ await store.execute(create, USER);
425
+ await scheduler.enqueue({
426
+ routineId: "brief",
427
+ trigger: "manual",
428
+ discriminator: "pending",
429
+ });
430
+ await scheduler.deadlines(storage);
431
+
432
+ await store.execute(
433
+ {
434
+ schemaVersion: 1,
435
+ type: "routine/delete",
436
+ commandId: "cmd-delete",
437
+ botId: "scout",
438
+ routineId: "brief",
439
+ },
440
+ USER,
441
+ );
442
+
443
+ expect(storage.keys().filter((key) => key.startsWith("routine"))).toEqual([
444
+ "routine-receipt:cmd-create",
445
+ "routine-receipt:cmd-delete",
446
+ ]);
447
+ expect(await drain(scheduler)).toEqual([]);
448
+ });
449
+ });
450
+
451
+ describe("nextRuns", () => {
452
+ test("reports the moment the alarm is armed on, and only for a live schedule", async () => {
453
+ const { scheduler, store, create } = harness({
454
+ start: "2026-01-01T00:00:00.000Z",
455
+ schedule: "0 9 * * *",
456
+ });
457
+ await store.execute(create, USER);
458
+
459
+ expect(await scheduler.nextRuns()).toEqual(
460
+ new Map([["brief", "2026-01-01T09:00:00.000Z"]]),
461
+ );
462
+ expect(
463
+ (await store.list("scout", await scheduler.nextRuns())).routines[0],
464
+ ).toMatchObject({ nextRunAt: "2026-01-01T09:00:00.000Z" });
465
+
466
+ await store.execute(
467
+ {
468
+ schemaVersion: 1,
469
+ type: "routine/pause",
470
+ commandId: "cmd-pause",
471
+ botId: "scout",
472
+ routineId: "brief",
473
+ },
474
+ USER,
475
+ );
476
+ expect(await scheduler.nextRuns()).toEqual(new Map());
477
+ expect(
478
+ (await store.list("scout", await scheduler.nextRuns())).routines[0]
479
+ ?.nextRunAt,
480
+ ).toBeUndefined();
481
+ });
482
+ });