@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/frockbot.json +39 -0
- package/package.json +53 -6
- package/src/agent.test.ts +206 -0
- package/src/agent.ts +345 -0
- package/src/backend.test.ts +181 -0
- package/src/backend.ts +375 -0
- package/src/client/RoutineInboxBadge.vue +216 -0
- package/src/client/RoutinesSection.vue +601 -0
- package/src/client/RoutinesSummary.vue +150 -0
- package/src/client/index.test.ts +209 -0
- package/src/client/index.ts +289 -0
- package/src/client/state.ts +65 -0
- package/src/cron.test.ts +217 -0
- package/src/cron.ts +246 -0
- package/src/env.d.ts +6 -0
- package/src/firing.ts +222 -0
- package/src/hook.test.ts +394 -0
- package/src/hook.ts +405 -0
- package/src/inbox-store.ts +405 -0
- package/src/inbox.test.ts +402 -0
- package/src/inbox.ts +405 -0
- package/src/index.ts +9 -0
- package/src/manifest.ts +3 -0
- package/src/records.test.ts +138 -0
- package/src/records.ts +341 -0
- package/src/scheduler.test.ts +482 -0
- package/src/scheduler.ts +551 -0
- package/src/shared.test.ts +141 -0
- package/src/shared.ts +988 -0
- package/src/storage-keys.ts +202 -0
- package/src/store.test.ts +261 -0
- package/src/store.ts +789 -0
- package/src/testing.ts +55 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +31 -0
- package/README.md +0 -3
package/src/scheduler.ts
ADDED
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
// The scheduler: the half of a Routine that makes it fire.
|
|
2
|
+
//
|
|
3
|
+
// It owns no alarm of its own. "The Bot's Durable Object is the authority for
|
|
4
|
+
// … durable scheduling", and that authority is one `alarm()` with three
|
|
5
|
+
// Package-supplied hooks. This class is what the Shell composes into them:
|
|
6
|
+
//
|
|
7
|
+
// deadlines(tx) → the moments a Routine wants the object woken
|
|
8
|
+
// defer(tx) → the object is busy; hold, but keep the debt
|
|
9
|
+
// settle(fire) → nothing is executing; drain what is owed
|
|
10
|
+
//
|
|
11
|
+
// Three rules are enforced here and nowhere else.
|
|
12
|
+
//
|
|
13
|
+
// * **A deferral never moves `dueAt`.** Pushing the due time forward while a
|
|
14
|
+
// Turn runs would silently skip the firing. The hold is a separate field, so
|
|
15
|
+
// when the object frees up the debt is still there and the firing lands.
|
|
16
|
+
// * **One unsettled firing per Routine.** `routine-fire:<id>` is written
|
|
17
|
+
// before the Turn is admitted and deleted when it settles, so it is both the
|
|
18
|
+
// durable intent and the lock. A firing owed while one is unsettled queues
|
|
19
|
+
// behind it, bounded; it is never run in parallel and never dropped in
|
|
20
|
+
// silence.
|
|
21
|
+
// * **Lateness coalesces, it never backfills.** A Routine the object slept
|
|
22
|
+
// through fires once, records how many occurrences that covered, and
|
|
23
|
+
// recomputes forward from now.
|
|
24
|
+
import {
|
|
25
|
+
missedRoutineRunsV1,
|
|
26
|
+
nextRoutineRunV1,
|
|
27
|
+
normalizeRoutineScheduleV1,
|
|
28
|
+
type NormalizedScheduleV1,
|
|
29
|
+
} from "./cron.js";
|
|
30
|
+
import {
|
|
31
|
+
decodeRoutineFireV1,
|
|
32
|
+
decodeRoutineScheduleStateV1,
|
|
33
|
+
routineCueV1,
|
|
34
|
+
routineFireIdV1,
|
|
35
|
+
type RoutineFireV1,
|
|
36
|
+
type RoutineScheduleStateV1,
|
|
37
|
+
} from "./firing.js";
|
|
38
|
+
import {
|
|
39
|
+
decodeRoutineRecordV1,
|
|
40
|
+
type RoutineRecordV1,
|
|
41
|
+
type RoutineRunEntryV1,
|
|
42
|
+
type RoutineRunStatusV1,
|
|
43
|
+
type RoutineTriggerKindV1,
|
|
44
|
+
} from "./records.js";
|
|
45
|
+
import {
|
|
46
|
+
appendRoutineRunEntryV1,
|
|
47
|
+
type RoutineStorageV1,
|
|
48
|
+
type RoutineStorageWritesV1,
|
|
49
|
+
} from "./store.js";
|
|
50
|
+
import {
|
|
51
|
+
nextQueueSequenceV1,
|
|
52
|
+
ROUTINE_DEFERRAL_MS,
|
|
53
|
+
ROUTINE_LIMIT_PER_BOT,
|
|
54
|
+
ROUTINE_MISSED_GRACE_MS,
|
|
55
|
+
ROUTINE_PREFIX,
|
|
56
|
+
ROUTINE_QUEUE_LIMIT,
|
|
57
|
+
ROUTINE_QUEUE_PREFIX,
|
|
58
|
+
routineFireKeyV1,
|
|
59
|
+
routineKeyV1,
|
|
60
|
+
routineQueueKeyV1,
|
|
61
|
+
routineQueuePrefixV1,
|
|
62
|
+
routineScheduleKeyV1,
|
|
63
|
+
} from "./storage-keys.js";
|
|
64
|
+
|
|
65
|
+
/** How many firings one `settle` drains before it hands the object back. */
|
|
66
|
+
export const ROUTINE_SETTLE_BATCH = 8;
|
|
67
|
+
|
|
68
|
+
/** What running one firing produced. The scheduler never decides this itself. */
|
|
69
|
+
export interface RoutineFireOutcomeV1 {
|
|
70
|
+
status: Extract<RoutineRunStatusV1, "ok" | "failed" | "cancelled">;
|
|
71
|
+
summary?: string;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The seam that actually admits the Turn. The Shell supplies it, because only
|
|
76
|
+
* the Durable Object holds `authority.run` — and `authority.run` is a direct
|
|
77
|
+
* method call, so no HTTP path can reach a Routine firing.
|
|
78
|
+
*/
|
|
79
|
+
export type RoutineFireExecutorV1 = (
|
|
80
|
+
fire: RoutineFireV1,
|
|
81
|
+
) => Promise<RoutineFireOutcomeV1>;
|
|
82
|
+
|
|
83
|
+
export interface RoutineSchedulerOptionsV1 {
|
|
84
|
+
now?(): Date;
|
|
85
|
+
/** Injected so a test can watch the drain without a Durable Object. */
|
|
86
|
+
onSettled?(fire: RoutineFireV1, outcome: RoutineFireOutcomeV1): void;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A firing waiting behind an unsettled one. */
|
|
90
|
+
interface QueuedFiringV1 {
|
|
91
|
+
schemaVersion: 1;
|
|
92
|
+
trigger: RoutineTriggerKindV1;
|
|
93
|
+
discriminator: string;
|
|
94
|
+
requestedAt: string;
|
|
95
|
+
delivery?: string;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function queued(value: unknown): QueuedFiringV1 | undefined {
|
|
99
|
+
if (!value || typeof value !== "object") return undefined;
|
|
100
|
+
const candidate = value as Record<string, unknown>;
|
|
101
|
+
if (
|
|
102
|
+
candidate.schemaVersion !== 1 ||
|
|
103
|
+
typeof candidate.trigger !== "string" ||
|
|
104
|
+
typeof candidate.discriminator !== "string" ||
|
|
105
|
+
typeof candidate.requestedAt !== "string"
|
|
106
|
+
) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return candidate as unknown as QueuedFiringV1;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* A Routine's clock, computed if it has never been written or if the Routine's
|
|
114
|
+
* timing has been rewritten since it was.
|
|
115
|
+
*
|
|
116
|
+
* The anchor is the record's `updatedAt`: editing a schedule is a new anchor, so
|
|
117
|
+
* `@every 15m` restarts from the edit rather than inheriting a due time the old
|
|
118
|
+
* schedule chose.
|
|
119
|
+
*/
|
|
120
|
+
export function routineScheduleStateV1(
|
|
121
|
+
record: RoutineRecordV1,
|
|
122
|
+
stored: RoutineScheduleStateV1 | undefined,
|
|
123
|
+
normalized: NormalizedScheduleV1,
|
|
124
|
+
): RoutineScheduleStateV1 {
|
|
125
|
+
if (stored && stored.anchor === record.updatedAt) return stored;
|
|
126
|
+
const anchor = new Date(record.updatedAt);
|
|
127
|
+
const next = nextRoutineRunV1(normalized, anchor, anchor);
|
|
128
|
+
return {
|
|
129
|
+
schemaVersion: 1,
|
|
130
|
+
routineId: record.routineId,
|
|
131
|
+
anchor: record.updatedAt,
|
|
132
|
+
dueAt: (next ?? anchor).getTime(),
|
|
133
|
+
};
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* When the alarm must next consider this Routine.
|
|
138
|
+
*
|
|
139
|
+
* A hold wins while it lasts, and expires on its own: the deadline is the later
|
|
140
|
+
* of the debt and the hold. Taking the earlier one would arm an alarm on a due
|
|
141
|
+
* time already in the past for as long as the object stayed busy, which is a
|
|
142
|
+
* spin rather than a deferral.
|
|
143
|
+
*/
|
|
144
|
+
export function routineDeadlineV1(state: RoutineScheduleStateV1): number {
|
|
145
|
+
return state.deferredUntil === undefined
|
|
146
|
+
? state.dueAt
|
|
147
|
+
: Math.max(state.dueAt, state.deferredUntil);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
interface ClaimedFiringV1 {
|
|
151
|
+
fire: RoutineFireV1;
|
|
152
|
+
record: RoutineRecordV1;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class RoutineScheduler {
|
|
156
|
+
readonly #storage: RoutineStorageV1;
|
|
157
|
+
readonly #now: () => Date;
|
|
158
|
+
readonly #onSettled:
|
|
159
|
+
((fire: RoutineFireV1, outcome: RoutineFireOutcomeV1) => void) | undefined;
|
|
160
|
+
|
|
161
|
+
constructor(
|
|
162
|
+
storage: RoutineStorageV1,
|
|
163
|
+
options: RoutineSchedulerOptionsV1 = {},
|
|
164
|
+
) {
|
|
165
|
+
this.#storage = storage;
|
|
166
|
+
this.#now = options.now ?? (() => new Date());
|
|
167
|
+
this.#onSettled = options.onSettled;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Every moment a Routine wants the object woken, for the kernel's single
|
|
172
|
+
* alarm to take the minimum of alongside the Shell's saga deadlines.
|
|
173
|
+
*/
|
|
174
|
+
async deadlines(reads: RoutineStorageWritesV1): Promise<number[]> {
|
|
175
|
+
const deadlines: number[] = [];
|
|
176
|
+
for (const { record, state } of await this.#clocks(reads)) {
|
|
177
|
+
// A Routine with an unsettled firing is already being dealt with, and one
|
|
178
|
+
// with a queue wants the object as soon as that firing settles.
|
|
179
|
+
const locked = await reads.get<unknown>(
|
|
180
|
+
routineFireKeyV1(record.routineId),
|
|
181
|
+
);
|
|
182
|
+
if (locked) continue;
|
|
183
|
+
deadlines.push(routineDeadlineV1(state));
|
|
184
|
+
}
|
|
185
|
+
for (const routineId of await this.#queuedRoutineIds(reads)) {
|
|
186
|
+
if (await reads.get<unknown>(routineFireKeyV1(routineId))) continue;
|
|
187
|
+
deadlines.push(this.#now().getTime());
|
|
188
|
+
}
|
|
189
|
+
return deadlines;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* The object is busy. Hold every Routine off for a short interval and leave
|
|
194
|
+
* `dueAt` exactly where it is, so the debt survives the Turn that displaced
|
|
195
|
+
* it.
|
|
196
|
+
*/
|
|
197
|
+
async defer(writes: RoutineStorageWritesV1): Promise<void> {
|
|
198
|
+
const deferredUntil = this.#now().getTime() + ROUTINE_DEFERRAL_MS;
|
|
199
|
+
for (const { record, state } of await this.#clocks(writes)) {
|
|
200
|
+
await writes.put(routineScheduleKeyV1(record.routineId), {
|
|
201
|
+
...state,
|
|
202
|
+
deferredUntil,
|
|
203
|
+
} satisfies RoutineScheduleStateV1);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Nothing is executing. Drain what is owed, one firing at a time: mint the
|
|
209
|
+
* durable firing, run it, settle it, then look again.
|
|
210
|
+
*/
|
|
211
|
+
async settle(execute: RoutineFireExecutorV1): Promise<void> {
|
|
212
|
+
for (let drained = 0; drained < ROUTINE_SETTLE_BATCH; drained += 1) {
|
|
213
|
+
const claimed = await this.#claim();
|
|
214
|
+
if (!claimed) return;
|
|
215
|
+
let outcome: RoutineFireOutcomeV1;
|
|
216
|
+
try {
|
|
217
|
+
outcome = await execute(claimed.fire);
|
|
218
|
+
} catch (error) {
|
|
219
|
+
outcome = {
|
|
220
|
+
status: "failed",
|
|
221
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
await this.#settleFiring(claimed.fire, outcome);
|
|
225
|
+
this.#onSettled?.(claimed.fire, outcome);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Ask for a firing now, outside the clock: `run_now`, or a delivered webhook.
|
|
231
|
+
*
|
|
232
|
+
* It is enqueued rather than run, because the caller may itself be a Turn in
|
|
233
|
+
* flight and "queue, never drop, never parallel" holds for a manual firing
|
|
234
|
+
* exactly as it does for a scheduled one. The alarm drains it.
|
|
235
|
+
*/
|
|
236
|
+
async enqueue(input: {
|
|
237
|
+
routineId: string;
|
|
238
|
+
trigger: RoutineTriggerKindV1;
|
|
239
|
+
discriminator: string;
|
|
240
|
+
delivery?: string;
|
|
241
|
+
}): Promise<{ fireId: string; queued: boolean }> {
|
|
242
|
+
return this.#storage.transaction((transaction) =>
|
|
243
|
+
this.enqueueWithin(transaction, input),
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The same request, inside a transaction the caller already holds — the
|
|
249
|
+
* `routine/run` command path, which writes its durable receipt in the same
|
|
250
|
+
* transaction so a replayed command id enqueues nothing a second time.
|
|
251
|
+
*/
|
|
252
|
+
async enqueueWithin(
|
|
253
|
+
transaction: RoutineStorageWritesV1,
|
|
254
|
+
input: {
|
|
255
|
+
routineId: string;
|
|
256
|
+
trigger: RoutineTriggerKindV1;
|
|
257
|
+
discriminator: string;
|
|
258
|
+
delivery?: string;
|
|
259
|
+
},
|
|
260
|
+
): Promise<{ fireId: string; queued: boolean }> {
|
|
261
|
+
const fireId = routineFireIdV1(input.routineId, input.discriminator);
|
|
262
|
+
const existing = await transaction.get<unknown>(
|
|
263
|
+
routineFireKeyV1(input.routineId),
|
|
264
|
+
);
|
|
265
|
+
if (existing && decodeRoutineFireV1(existing).fireId === fireId) {
|
|
266
|
+
// The same firing, asked for twice. One firing.
|
|
267
|
+
return { fireId, queued: false };
|
|
268
|
+
}
|
|
269
|
+
const waiting = await transaction.list<unknown>({
|
|
270
|
+
prefix: routineQueuePrefixV1(input.routineId),
|
|
271
|
+
});
|
|
272
|
+
for (const value of waiting.values()) {
|
|
273
|
+
if (queued(value)?.discriminator === input.discriminator) {
|
|
274
|
+
return { fireId, queued: false };
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (waiting.size >= ROUTINE_QUEUE_LIMIT) {
|
|
278
|
+
throw new Error(
|
|
279
|
+
`Routine "${input.routineId}" already has ${ROUTINE_QUEUE_LIMIT} firings waiting`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
await transaction.put(
|
|
283
|
+
routineQueueKeyV1(
|
|
284
|
+
input.routineId,
|
|
285
|
+
nextQueueSequenceV1([...waiting.keys()]),
|
|
286
|
+
),
|
|
287
|
+
{
|
|
288
|
+
schemaVersion: 1,
|
|
289
|
+
trigger: input.trigger,
|
|
290
|
+
discriminator: input.discriminator,
|
|
291
|
+
requestedAt: this.#now().toISOString(),
|
|
292
|
+
...(input.delivery === undefined ? {} : { delivery: input.delivery }),
|
|
293
|
+
} satisfies QueuedFiringV1,
|
|
294
|
+
);
|
|
295
|
+
return { fireId, queued: true };
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** The unsettled firing of one Routine, if it has one. */
|
|
299
|
+
async readFire(routineId: string): Promise<RoutineFireV1 | undefined> {
|
|
300
|
+
const stored = await this.#storage.get<unknown>(
|
|
301
|
+
routineFireKeyV1(routineId),
|
|
302
|
+
);
|
|
303
|
+
return stored === undefined ? undefined : decodeRoutineFireV1(stored);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** When each scheduled Routine is next owed a firing, for the "next run" row. */
|
|
307
|
+
async nextRuns(): Promise<Map<string, string>> {
|
|
308
|
+
const next = new Map<string, string>();
|
|
309
|
+
for (const { record, state } of await this.#clocks(this.#storage)) {
|
|
310
|
+
next.set(record.routineId, new Date(state.dueAt).toISOString());
|
|
311
|
+
}
|
|
312
|
+
return next;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Every enabled scheduled Routine with the clock it is read under. */
|
|
316
|
+
async #clocks(
|
|
317
|
+
reads: RoutineStorageWritesV1,
|
|
318
|
+
): Promise<
|
|
319
|
+
Array<{ record: RoutineRecordV1; state: RoutineScheduleStateV1 }>
|
|
320
|
+
> {
|
|
321
|
+
const stored = await reads.list<unknown>({
|
|
322
|
+
prefix: ROUTINE_PREFIX,
|
|
323
|
+
limit: ROUTINE_LIMIT_PER_BOT,
|
|
324
|
+
});
|
|
325
|
+
const clocks: Array<{
|
|
326
|
+
record: RoutineRecordV1;
|
|
327
|
+
state: RoutineScheduleStateV1;
|
|
328
|
+
}> = [];
|
|
329
|
+
for (const value of stored.values()) {
|
|
330
|
+
const record = decodeRoutineRecordV1(value);
|
|
331
|
+
if (!record.enabled || record.schedule === undefined) continue;
|
|
332
|
+
const normalized = normalizeRoutineScheduleV1(
|
|
333
|
+
record.schedule,
|
|
334
|
+
record.timezone,
|
|
335
|
+
);
|
|
336
|
+
const persisted = await reads.get<unknown>(
|
|
337
|
+
routineScheduleKeyV1(record.routineId),
|
|
338
|
+
);
|
|
339
|
+
clocks.push({
|
|
340
|
+
record,
|
|
341
|
+
state: routineScheduleStateV1(
|
|
342
|
+
record,
|
|
343
|
+
persisted === undefined
|
|
344
|
+
? undefined
|
|
345
|
+
: decodeRoutineScheduleStateV1(persisted),
|
|
346
|
+
normalized,
|
|
347
|
+
),
|
|
348
|
+
});
|
|
349
|
+
}
|
|
350
|
+
return clocks;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
async #queuedRoutineIds(reads: RoutineStorageWritesV1): Promise<string[]> {
|
|
354
|
+
const waiting = await reads.list<unknown>({ prefix: ROUTINE_QUEUE_PREFIX });
|
|
355
|
+
const ids = new Set<string>();
|
|
356
|
+
for (const key of waiting.keys()) {
|
|
357
|
+
const rest = key.slice(ROUTINE_QUEUE_PREFIX.length);
|
|
358
|
+
const separator = rest.lastIndexOf(":");
|
|
359
|
+
if (separator > 0) ids.add(rest.slice(0, separator));
|
|
360
|
+
}
|
|
361
|
+
return [...ids];
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Mint the next firing that is owed, durably, before anything runs it.
|
|
366
|
+
*
|
|
367
|
+
* Everything the firing needs to be reconstructed after an eviction is
|
|
368
|
+
* written in this one transaction: the firing itself, the advanced clock, the
|
|
369
|
+
* `running` run-log entry, and the Routine's `lastRunAt`.
|
|
370
|
+
*/
|
|
371
|
+
async #claim(): Promise<ClaimedFiringV1 | undefined> {
|
|
372
|
+
const now = this.#now();
|
|
373
|
+
return this.#storage.transaction<ClaimedFiringV1 | undefined>(
|
|
374
|
+
async (transaction) => {
|
|
375
|
+
// A queued firing outranks the clock: it was already owed.
|
|
376
|
+
for (const routineId of await this.#queuedRoutineIds(transaction)) {
|
|
377
|
+
if (await transaction.get<unknown>(routineFireKeyV1(routineId))) {
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
const claimed = await this.#claimQueued(transaction, routineId, now);
|
|
381
|
+
if (claimed) return claimed;
|
|
382
|
+
}
|
|
383
|
+
for (const { record, state } of await this.#clocks(transaction)) {
|
|
384
|
+
if (
|
|
385
|
+
await transaction.get<unknown>(routineFireKeyV1(record.routineId))
|
|
386
|
+
) {
|
|
387
|
+
// Locked. The debt is preserved on the clock; queueing it here
|
|
388
|
+
// would double-count, because the clock has not advanced.
|
|
389
|
+
continue;
|
|
390
|
+
}
|
|
391
|
+
if (routineDeadlineV1(state) > now.getTime()) continue;
|
|
392
|
+
return this.#claimScheduled(transaction, record, state, now);
|
|
393
|
+
}
|
|
394
|
+
return undefined;
|
|
395
|
+
},
|
|
396
|
+
);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
async #claimQueued(
|
|
400
|
+
transaction: RoutineStorageWritesV1,
|
|
401
|
+
routineId: string,
|
|
402
|
+
now: Date,
|
|
403
|
+
): Promise<ClaimedFiringV1 | undefined> {
|
|
404
|
+
const waiting = await transaction.list<unknown>({
|
|
405
|
+
prefix: routineQueuePrefixV1(routineId),
|
|
406
|
+
});
|
|
407
|
+
const first = [...waiting.entries()].sort(([left], [right]) =>
|
|
408
|
+
left.localeCompare(right),
|
|
409
|
+
)[0];
|
|
410
|
+
if (!first) return undefined;
|
|
411
|
+
await transaction.delete(first[0]);
|
|
412
|
+
const request = queued(first[1]);
|
|
413
|
+
const stored = await transaction.get<unknown>(routineKeyV1(routineId));
|
|
414
|
+
if (!request || stored === undefined) {
|
|
415
|
+
// The Routine was deleted under a waiting firing. Drop the request; the
|
|
416
|
+
// record it would have run is gone, and a firing needs one.
|
|
417
|
+
return undefined;
|
|
418
|
+
}
|
|
419
|
+
const record = decodeRoutineRecordV1(stored);
|
|
420
|
+
const fire: RoutineFireV1 = {
|
|
421
|
+
schemaVersion: 1,
|
|
422
|
+
routineId,
|
|
423
|
+
fireId: routineFireIdV1(routineId, request.discriminator),
|
|
424
|
+
trigger: request.trigger,
|
|
425
|
+
cue: routineCueV1({
|
|
426
|
+
name: record.name,
|
|
427
|
+
prompt: record.prompt,
|
|
428
|
+
trigger: request.trigger,
|
|
429
|
+
...(request.delivery === undefined
|
|
430
|
+
? {}
|
|
431
|
+
: { delivery: request.delivery }),
|
|
432
|
+
}),
|
|
433
|
+
mintedAt: now.toISOString(),
|
|
434
|
+
entryId: `${routineFireIdV1(routineId, request.discriminator)}-entry`,
|
|
435
|
+
};
|
|
436
|
+
await this.#writeClaim(transaction, record, fire, now);
|
|
437
|
+
return { fire, record };
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
async #claimScheduled(
|
|
441
|
+
transaction: RoutineStorageWritesV1,
|
|
442
|
+
record: RoutineRecordV1,
|
|
443
|
+
state: RoutineScheduleStateV1,
|
|
444
|
+
now: Date,
|
|
445
|
+
): Promise<ClaimedFiringV1> {
|
|
446
|
+
const normalized = normalizeRoutineScheduleV1(
|
|
447
|
+
record.schedule!,
|
|
448
|
+
record.timezone,
|
|
449
|
+
);
|
|
450
|
+
const anchor = new Date(record.updatedAt);
|
|
451
|
+
const late = now.getTime() - state.dueAt > ROUTINE_MISSED_GRACE_MS;
|
|
452
|
+
const missedCount = late
|
|
453
|
+
? missedRoutineRunsV1(normalized, new Date(state.dueAt), now, anchor)
|
|
454
|
+
: 1;
|
|
455
|
+
const fire: RoutineFireV1 = {
|
|
456
|
+
schemaVersion: 1,
|
|
457
|
+
routineId: record.routineId,
|
|
458
|
+
fireId: routineFireIdV1(record.routineId, String(state.dueAt)),
|
|
459
|
+
trigger: "cron",
|
|
460
|
+
cue: routineCueV1({
|
|
461
|
+
name: record.name,
|
|
462
|
+
prompt: record.prompt,
|
|
463
|
+
trigger: "cron",
|
|
464
|
+
...(missedCount > 1 ? { missedCount } : {}),
|
|
465
|
+
}),
|
|
466
|
+
mintedAt: now.toISOString(),
|
|
467
|
+
dueAt: state.dueAt,
|
|
468
|
+
...(missedCount > 1 ? { missedCount } : {}),
|
|
469
|
+
entryId: `${routineFireIdV1(record.routineId, String(state.dueAt))}-entry`,
|
|
470
|
+
};
|
|
471
|
+
// Recompute forward from now when the firing was late, and from the
|
|
472
|
+
// occurrence itself when it was on time; either way the clock advances
|
|
473
|
+
// before the Turn runs, so a crash mid-Turn cannot re-owe this occurrence.
|
|
474
|
+
const from = late ? now : new Date(state.dueAt);
|
|
475
|
+
const next = nextRoutineRunV1(normalized, from, anchor);
|
|
476
|
+
await transaction.put(routineScheduleKeyV1(record.routineId), {
|
|
477
|
+
schemaVersion: 1,
|
|
478
|
+
routineId: record.routineId,
|
|
479
|
+
anchor: record.updatedAt,
|
|
480
|
+
dueAt: (
|
|
481
|
+
next ?? new Date(now.getTime() + ROUTINE_MISSED_GRACE_MS)
|
|
482
|
+
).getTime(),
|
|
483
|
+
} satisfies RoutineScheduleStateV1);
|
|
484
|
+
if (missedCount > 1) {
|
|
485
|
+
// One entry says what was slept through. It is `skipped`, not `ok`: the
|
|
486
|
+
// occurrences it names never ran and the log must not imply they did.
|
|
487
|
+
await appendRoutineRunEntryV1(transaction, {
|
|
488
|
+
schemaVersion: 1,
|
|
489
|
+
entryId: `${fire.entryId}-missed`,
|
|
490
|
+
routineId: record.routineId,
|
|
491
|
+
runId: fire.fireId,
|
|
492
|
+
fireId: fire.fireId,
|
|
493
|
+
trigger: "cron",
|
|
494
|
+
status: "skipped",
|
|
495
|
+
startedAt: new Date(state.dueAt).toISOString(),
|
|
496
|
+
finishedAt: now.toISOString(),
|
|
497
|
+
summary: `${missedCount - 1} scheduled occurrences elapsed while the Routine was not fired; this firing covers them.`,
|
|
498
|
+
} satisfies RoutineRunEntryV1);
|
|
499
|
+
}
|
|
500
|
+
await this.#writeClaim(transaction, record, fire, now);
|
|
501
|
+
return { fire, record };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
async #writeClaim(
|
|
505
|
+
transaction: RoutineStorageWritesV1,
|
|
506
|
+
record: RoutineRecordV1,
|
|
507
|
+
fire: RoutineFireV1,
|
|
508
|
+
now: Date,
|
|
509
|
+
): Promise<void> {
|
|
510
|
+
await transaction.put(routineFireKeyV1(record.routineId), fire);
|
|
511
|
+
await appendRoutineRunEntryV1(transaction, {
|
|
512
|
+
schemaVersion: 1,
|
|
513
|
+
entryId: fire.entryId,
|
|
514
|
+
routineId: record.routineId,
|
|
515
|
+
runId: fire.fireId,
|
|
516
|
+
fireId: fire.fireId,
|
|
517
|
+
trigger: fire.trigger,
|
|
518
|
+
status: "running",
|
|
519
|
+
startedAt: now.toISOString(),
|
|
520
|
+
} satisfies RoutineRunEntryV1);
|
|
521
|
+
await transaction.put(routineKeyV1(record.routineId), {
|
|
522
|
+
...record,
|
|
523
|
+
lastRunAt: now.toISOString(),
|
|
524
|
+
} satisfies RoutineRecordV1);
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
async #settleFiring(
|
|
528
|
+
fire: RoutineFireV1,
|
|
529
|
+
outcome: RoutineFireOutcomeV1,
|
|
530
|
+
): Promise<void> {
|
|
531
|
+
const finishedAt = this.#now().toISOString();
|
|
532
|
+
await this.#storage.transaction(async (transaction) => {
|
|
533
|
+
await transaction.delete(routineFireKeyV1(fire.routineId));
|
|
534
|
+
const startedAt = fire.mintedAt;
|
|
535
|
+
await appendRoutineRunEntryV1(transaction, {
|
|
536
|
+
schemaVersion: 1,
|
|
537
|
+
entryId: fire.entryId,
|
|
538
|
+
routineId: fire.routineId,
|
|
539
|
+
runId: fire.fireId,
|
|
540
|
+
fireId: fire.fireId,
|
|
541
|
+
trigger: fire.trigger,
|
|
542
|
+
status: outcome.status,
|
|
543
|
+
startedAt,
|
|
544
|
+
finishedAt,
|
|
545
|
+
...(outcome.summary === undefined
|
|
546
|
+
? {}
|
|
547
|
+
: { summary: outcome.summary.slice(0, 2_000) }),
|
|
548
|
+
} satisfies RoutineRunEntryV1);
|
|
549
|
+
});
|
|
550
|
+
}
|
|
551
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodeRoutineCommandV1,
|
|
4
|
+
decodeRoutineCommandReceiptV1,
|
|
5
|
+
decodeRoutineListViewV1,
|
|
6
|
+
decodeRoutineRunListViewV1,
|
|
7
|
+
decodeRoutineViewV1,
|
|
8
|
+
routineCommandFingerprintV1,
|
|
9
|
+
} from "./shared.js";
|
|
10
|
+
import { RoutineStore, routineViewV1 } from "./store.js";
|
|
11
|
+
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
12
|
+
|
|
13
|
+
const CREATE = {
|
|
14
|
+
schemaVersion: 1,
|
|
15
|
+
type: "routine/create",
|
|
16
|
+
commandId: "cmd-1",
|
|
17
|
+
botId: "scout",
|
|
18
|
+
name: "Morning brief",
|
|
19
|
+
prompt: "Summarize overnight email.",
|
|
20
|
+
schedule: "0 7 * * *",
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
describe("decodeRoutineCommandV1", () => {
|
|
24
|
+
test("decodes each command in the vocabulary", () => {
|
|
25
|
+
expect(decodeRoutineCommandV1(CREATE)).toMatchObject({
|
|
26
|
+
type: "routine/create",
|
|
27
|
+
});
|
|
28
|
+
for (const type of ["routine/pause", "routine/resume", "routine/delete"]) {
|
|
29
|
+
expect(
|
|
30
|
+
decodeRoutineCommandV1({
|
|
31
|
+
schemaVersion: 1,
|
|
32
|
+
type,
|
|
33
|
+
commandId: "cmd-2",
|
|
34
|
+
botId: "scout",
|
|
35
|
+
routineId: "brief",
|
|
36
|
+
}),
|
|
37
|
+
).toMatchObject({ type });
|
|
38
|
+
}
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("refuses a create carrying both a schedule and a trigger, or neither", () => {
|
|
42
|
+
expect(() =>
|
|
43
|
+
decodeRoutineCommandV1({ ...CREATE, trigger: { kind: "webhook" } }),
|
|
44
|
+
).toThrow(/never both/);
|
|
45
|
+
const { schedule: _schedule, ...rest } = CREATE;
|
|
46
|
+
expect(() => decodeRoutineCommandV1(rest)).toThrow(
|
|
47
|
+
/needs a schedule or a trigger/,
|
|
48
|
+
);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("refuses an update that changes nothing and one with unknown fields", () => {
|
|
52
|
+
expect(() =>
|
|
53
|
+
decodeRoutineCommandV1({
|
|
54
|
+
schemaVersion: 1,
|
|
55
|
+
type: "routine/update",
|
|
56
|
+
commandId: "cmd-2",
|
|
57
|
+
botId: "scout",
|
|
58
|
+
routineId: "brief",
|
|
59
|
+
}),
|
|
60
|
+
).toThrow(/changes nothing/);
|
|
61
|
+
expect(() => decodeRoutineCommandV1({ ...CREATE, sneaky: true })).toThrow(
|
|
62
|
+
/unknown field "sneaky"/,
|
|
63
|
+
);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("refuses an unknown type and an unsupported version", () => {
|
|
67
|
+
expect(() =>
|
|
68
|
+
decodeRoutineCommandV1({ ...CREATE, type: "routine/backfill" }),
|
|
69
|
+
).toThrow(/type is unknown/);
|
|
70
|
+
expect(() =>
|
|
71
|
+
decodeRoutineCommandV1({ ...CREATE, schemaVersion: 2 }),
|
|
72
|
+
).toThrow(/schemaVersion is unsupported/);
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("routineCommandFingerprintV1", () => {
|
|
77
|
+
test("ignores the command id and key order, and separates meanings", () => {
|
|
78
|
+
const a = decodeRoutineCommandV1(CREATE);
|
|
79
|
+
const b = decodeRoutineCommandV1({ ...CREATE, commandId: "cmd-99" });
|
|
80
|
+
expect(routineCommandFingerprintV1(a)).toBe(routineCommandFingerprintV1(b));
|
|
81
|
+
const c = decodeRoutineCommandV1({ ...CREATE, name: "Evening brief" });
|
|
82
|
+
expect(routineCommandFingerprintV1(a)).not.toBe(
|
|
83
|
+
routineCommandFingerprintV1(c),
|
|
84
|
+
);
|
|
85
|
+
expect(routineCommandFingerprintV1(a)).toStartWith("routine-command-v1:");
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
describe("RoutineViewV1", () => {
|
|
90
|
+
test("carries no key material, and round-trips through its codec", async () => {
|
|
91
|
+
const store = new RoutineStore(createMemoryRoutineStorageV1());
|
|
92
|
+
const receipt = await store.execute(decodeRoutineCommandV1(CREATE), {
|
|
93
|
+
kind: "bot",
|
|
94
|
+
botId: "scout",
|
|
95
|
+
sessionId: "tim:scout",
|
|
96
|
+
turnId: "turn-1",
|
|
97
|
+
});
|
|
98
|
+
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
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");
|
|
104
|
+
expect(decodeRoutineViewV1(JSON.parse(JSON.stringify(view)))).toEqual(view);
|
|
105
|
+
expect(
|
|
106
|
+
decodeRoutineCommandReceiptV1(JSON.parse(JSON.stringify(receipt))),
|
|
107
|
+
).toEqual(receipt);
|
|
108
|
+
|
|
109
|
+
const record = await store.read(view.routineId);
|
|
110
|
+
expect(record?.createdBy).toMatchObject({ sessionId: "tim:scout" });
|
|
111
|
+
expect(routineViewV1(record!)).toEqual(view);
|
|
112
|
+
|
|
113
|
+
const listed = await store.list("scout");
|
|
114
|
+
expect(decodeRoutineListViewV1(JSON.parse(JSON.stringify(listed)))).toEqual(
|
|
115
|
+
listed,
|
|
116
|
+
);
|
|
117
|
+
const runs = await store.listRuns("scout", view.routineId);
|
|
118
|
+
expect(
|
|
119
|
+
decodeRoutineRunListViewV1(JSON.parse(JSON.stringify(runs))),
|
|
120
|
+
).toEqual(runs);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("refuses a view with an unknown field", () => {
|
|
124
|
+
expect(() =>
|
|
125
|
+
decodeRoutineViewV1({
|
|
126
|
+
schemaVersion: 1,
|
|
127
|
+
routineId: "brief",
|
|
128
|
+
name: "Brief",
|
|
129
|
+
prompt: "Do it",
|
|
130
|
+
schedule: "@daily",
|
|
131
|
+
timezone: "UTC",
|
|
132
|
+
enabled: true,
|
|
133
|
+
createdBy: { kind: "user" },
|
|
134
|
+
updatedBy: { kind: "user" },
|
|
135
|
+
createdAt: "2026-08-31T00:00:00.000Z",
|
|
136
|
+
updatedAt: "2026-08-31T00:00:00.000Z",
|
|
137
|
+
webhookKey: "secret",
|
|
138
|
+
}),
|
|
139
|
+
).toThrow(/unknown field "webhookKey"/);
|
|
140
|
+
});
|
|
141
|
+
});
|