@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.
@@ -0,0 +1,405 @@
1
+ // The completion inbox and the pending-input queue, as durable state.
2
+ //
3
+ // Two rules live here and nowhere else.
4
+ //
5
+ // * **One terminal transaction.** `routineTerminalRecordsV1` is handed the
6
+ // settled run and a reader bound to the transaction that is settling it, and
7
+ // returns the records that transaction writes. The inbox entry, the pending
8
+ // wake and the notification intent therefore become durable at the same
9
+ // instant the automation Turn does, so there is no window in which a firing
10
+ // has completed and its outcome is nowhere.
11
+ //
12
+ // * **A drain happens once.** `drainInto` moves every pending input into a
13
+ // receipt named by the run that drained it, in one transaction. A Turn that
14
+ // is evicted and recovered reads its own receipt back rather than draining
15
+ // again, so replay is idempotent on the input's id and the same model
16
+ // request is reconstructed.
17
+ //
18
+ // Both bounds — 100 entries, 16 pending inputs — are retention, not
19
+ // correctness, so they are enforced when the records are next read rather than
20
+ // inside the settling transaction, which cannot list.
21
+ import {
22
+ decodePendingBotInputV1,
23
+ decodeRoutineInboxEntryV1,
24
+ pendingBotInputIdV1,
25
+ routineAttributionV1,
26
+ ROUTINE_INBOX_TEXT_MAX,
27
+ ROUTINE_WAKE_TITLE_MAX,
28
+ type PendingBotInputV1,
29
+ type RoutineInboxEntryV1,
30
+ } from "./inbox.js";
31
+ import { RoutineDecodeError } from "./records.js";
32
+ import type { RoutineStorageV1, RoutineStorageWritesV1 } from "./store.js";
33
+ import {
34
+ routineDrainKeyV1,
35
+ routineInboxKeyV1,
36
+ routineSequenceCursorV1,
37
+ routineWakeKeyV1,
38
+ ROUTINE_DRAIN_PREFIX,
39
+ ROUTINE_DRAIN_RECEIPT_LIMIT,
40
+ ROUTINE_INBOX_CURSOR_KEY,
41
+ ROUTINE_INBOX_LIMIT,
42
+ ROUTINE_INBOX_PREFIX,
43
+ ROUTINE_PENDING_INPUT_LIMIT,
44
+ ROUTINE_WAKE_CURSOR_KEY,
45
+ ROUTINE_WAKE_PREFIX,
46
+ } from "./storage-keys.js";
47
+
48
+ /** What one chat Turn drained, kept so a recovered Turn reproduces it. */
49
+ export interface RoutineDrainReceiptV1 {
50
+ schemaVersion: 1;
51
+ runId: string;
52
+ drainedAt: string;
53
+ inputs: PendingBotInputV1[];
54
+ }
55
+
56
+ function decodeDrainReceiptV1(value: unknown): RoutineDrainReceiptV1 {
57
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
58
+ throw new RoutineDecodeError("Routine drain receipt must be an object");
59
+ }
60
+ const candidate = value as Record<string, unknown>;
61
+ if (candidate.schemaVersion !== 1 || typeof candidate.runId !== "string") {
62
+ throw new RoutineDecodeError("Routine drain receipt is invalid");
63
+ }
64
+ if (!Array.isArray(candidate.inputs)) {
65
+ throw new RoutineDecodeError("Routine drain receipt inputs are invalid");
66
+ }
67
+ return {
68
+ schemaVersion: 1,
69
+ runId: candidate.runId,
70
+ drainedAt: String(candidate.drainedAt),
71
+ inputs: candidate.inputs.map((input) => decodePendingBotInputV1(input)),
72
+ };
73
+ }
74
+
75
+ /** The settled automation Turn a terminal record set is computed from. */
76
+ export interface RoutineTerminalInputV1 {
77
+ runId: string;
78
+ routineId: string;
79
+ routineName: string;
80
+ /** The `wake_parent` hand-off, when the Turn made one. */
81
+ handoff?: string;
82
+ /** The Turn's own response text, used when it handed off nothing. */
83
+ responseText?: string;
84
+ now: string;
85
+ read<T>(key: string): Promise<T | undefined>;
86
+ }
87
+
88
+ /** The records a settled automation Turn contributes, and what they say. */
89
+ export interface RoutineTerminalRecordsV1 {
90
+ records: Record<string, unknown>;
91
+ entry: RoutineInboxEntryV1;
92
+ wake?: PendingBotInputV1;
93
+ }
94
+
95
+ function clamp(value: string, maximum: number): string {
96
+ const trimmed = value.trim();
97
+ return trimmed.length > maximum ? trimmed.slice(0, maximum) : trimmed;
98
+ }
99
+
100
+ /**
101
+ * The inbox entry, the pending wake, and the two cursors they advance.
102
+ *
103
+ * A completed automation Turn always writes an entry — GrokBot's "silent final
104
+ * message picked up at a natural safe boundary" — and writes a wake only when
105
+ * the Turn called `wake_parent`, because only a hand-off is addressed to the
106
+ * Bot rather than to the person reading the inbox.
107
+ */
108
+ export async function routineTerminalRecordsV1(
109
+ input: RoutineTerminalInputV1,
110
+ ): Promise<RoutineTerminalRecordsV1 | undefined> {
111
+ const text = clamp(
112
+ input.handoff ??
113
+ input.responseText ??
114
+ "The Routine completed without leaving a message.",
115
+ ROUTINE_INBOX_TEXT_MAX,
116
+ );
117
+ if (text.length === 0) return undefined;
118
+ const inbox = routineSequenceCursorV1(
119
+ await input.read<unknown>(ROUTINE_INBOX_CURSOR_KEY),
120
+ );
121
+ const wakeId = `rw-${input.runId}`;
122
+ const entry: RoutineInboxEntryV1 = {
123
+ schemaVersion: 1,
124
+ entryId: `ri-${input.runId}`,
125
+ runId: input.runId,
126
+ routineId: input.routineId,
127
+ text,
128
+ attribution: routineAttributionV1(input.routineName),
129
+ createdAt: input.now,
130
+ acknowledged: false,
131
+ ...(input.handoff === undefined ? {} : { wakeId }),
132
+ };
133
+ const records: Record<string, unknown> = {
134
+ [routineInboxKeyV1(inbox.nextSeq)]: entry,
135
+ [ROUTINE_INBOX_CURSOR_KEY]: {
136
+ schemaVersion: 1,
137
+ nextSeq: inbox.nextSeq + 1,
138
+ },
139
+ };
140
+ if (input.handoff === undefined) return { records, entry };
141
+ const wakes = routineSequenceCursorV1(
142
+ await input.read<unknown>(ROUTINE_WAKE_CURSOR_KEY),
143
+ );
144
+ const wake: PendingBotInputV1 = {
145
+ schemaVersion: 1,
146
+ kind: "wake",
147
+ wakeId,
148
+ runId: input.runId,
149
+ routineId: input.routineId,
150
+ title: clamp(
151
+ routineAttributionV1(input.routineName),
152
+ ROUTINE_WAKE_TITLE_MAX,
153
+ ),
154
+ text,
155
+ createdAt: input.now,
156
+ quiet: { automation: true },
157
+ };
158
+ records[routineWakeKeyV1(wakes.nextSeq)] = wake;
159
+ records[ROUTINE_WAKE_CURSOR_KEY] = {
160
+ schemaVersion: 1,
161
+ nextSeq: wakes.nextSeq + 1,
162
+ };
163
+ return { records, entry, wake };
164
+ }
165
+
166
+ /**
167
+ * Queue one durable input inside a transaction the caller already holds.
168
+ *
169
+ * Exported because the producer of the `approval` variant is another Package's
170
+ * decision write, and that decision and the input it owes the Bot have to
171
+ * become durable together: a decision recorded with nothing queued would be a
172
+ * question answered that the Bot never hears the answer to.
173
+ *
174
+ * Idempotent on the input's id — an id already waiting writes nothing, so a
175
+ * retried decision cannot tell the Bot the same thing twice.
176
+ */
177
+ export async function enqueuePendingBotInputV1(
178
+ transaction: RoutineStorageWritesV1,
179
+ input: PendingBotInputV1,
180
+ ): Promise<void> {
181
+ const id = pendingBotInputIdV1(input);
182
+ const stored = await transaction.list<unknown>({
183
+ prefix: ROUTINE_WAKE_PREFIX,
184
+ });
185
+ for (const value of stored.values()) {
186
+ if (pendingBotInputIdV1(decodePendingBotInputV1(value)) === id) return;
187
+ }
188
+ const cursor = routineSequenceCursorV1(
189
+ await transaction.get<unknown>(ROUTINE_WAKE_CURSOR_KEY),
190
+ );
191
+ await transaction.put(routineWakeKeyV1(cursor.nextSeq), input);
192
+ await transaction.put(ROUTINE_WAKE_CURSOR_KEY, {
193
+ schemaVersion: 1,
194
+ nextSeq: cursor.nextSeq + 1,
195
+ });
196
+ }
197
+
198
+ export interface RoutineInboxStoreOptionsV1 {
199
+ now?(): Date;
200
+ }
201
+
202
+ /** One pending input, with the key it is stored under. */
203
+ export interface StoredPendingInputV1 {
204
+ key: string;
205
+ input: PendingBotInputV1;
206
+ }
207
+
208
+ export class RoutineInboxStore {
209
+ readonly #storage: RoutineStorageV1;
210
+ readonly #now: () => Date;
211
+
212
+ constructor(
213
+ storage: RoutineStorageV1,
214
+ options: RoutineInboxStoreOptionsV1 = {},
215
+ ) {
216
+ this.#storage = storage;
217
+ this.#now = options.now ?? (() => new Date());
218
+ }
219
+
220
+ /**
221
+ * Every inbox entry, newest first, trimmed to the retention bound first so a
222
+ * read is also the place the bound is enforced.
223
+ */
224
+ async list(): Promise<RoutineInboxEntryV1[]> {
225
+ await this.#trimInbox();
226
+ const stored = await this.#storage.list<unknown>({
227
+ prefix: ROUTINE_INBOX_PREFIX,
228
+ limit: ROUTINE_INBOX_LIMIT,
229
+ });
230
+ return [...stored.values()].map((value) =>
231
+ decodeRoutineInboxEntryV1(value),
232
+ );
233
+ }
234
+
235
+ /**
236
+ * Mark entries read. Acknowledging is monotonic and idempotent: an entry that
237
+ * is already acknowledged keeps the moment it was first acknowledged, so a
238
+ * replayed command changes nothing.
239
+ */
240
+ async acknowledge(entryIds: readonly string[]): Promise<number> {
241
+ const wanted = new Set(entryIds);
242
+ const at = this.#now().toISOString();
243
+ return this.#storage.transaction(async (transaction) => {
244
+ const stored = await transaction.list<unknown>({
245
+ prefix: ROUTINE_INBOX_PREFIX,
246
+ limit: ROUTINE_INBOX_LIMIT,
247
+ });
248
+ let changed = 0;
249
+ for (const [key, value] of stored.entries()) {
250
+ const entry = decodeRoutineInboxEntryV1(value);
251
+ if (entry.acknowledged) continue;
252
+ if (wanted.size > 0 && !wanted.has(entry.entryId)) continue;
253
+ await transaction.put(key, {
254
+ ...entry,
255
+ acknowledged: true,
256
+ acknowledgedAt: at,
257
+ } satisfies RoutineInboxEntryV1);
258
+ changed += 1;
259
+ }
260
+ return changed;
261
+ });
262
+ }
263
+
264
+ /** Every pending input, oldest first, with the key it lives under. */
265
+ async pending(): Promise<StoredPendingInputV1[]> {
266
+ const stored = await this.#storage.list<unknown>({
267
+ prefix: ROUTINE_WAKE_PREFIX,
268
+ });
269
+ return [...stored.entries()]
270
+ .sort(([left], [right]) => left.localeCompare(right))
271
+ .map(([key, value]) => ({ key, input: decodePendingBotInputV1(value) }));
272
+ }
273
+
274
+ /**
275
+ * Queue one durable input the Bot's next conversational Turn is owed.
276
+ *
277
+ * This is the seam the approval-card slice produces through: the queue was
278
+ * always wider than Routines, and a second producer is not a second queue.
279
+ * Idempotent on the input's id — an enqueue for an id already waiting writes
280
+ * nothing, so a retried decision cannot tell the Bot the same thing twice.
281
+ */
282
+ async enqueue(input: PendingBotInputV1): Promise<void> {
283
+ await this.#storage.transaction((transaction) =>
284
+ enqueuePendingBotInputV1(transaction, input),
285
+ );
286
+ }
287
+
288
+ /**
289
+ * Appends one completion-inbox entry outside a settling transaction.
290
+ *
291
+ * Routines write their entry *inside* the transaction that settles the Turn,
292
+ * because the Turn and its outcome are the same object's state. A background
293
+ * subagent settles from a different Durable Object, so its entry is its own
294
+ * transaction — and the property that matters is the same one: idempotent on
295
+ * `entryId`, so a retried settle records one entry, not two.
296
+ */
297
+ async append(entry: RoutineInboxEntryV1): Promise<void> {
298
+ await this.#storage.transaction(async (transaction) => {
299
+ const stored = await transaction.list<unknown>({
300
+ prefix: ROUTINE_INBOX_PREFIX,
301
+ });
302
+ for (const value of stored.values()) {
303
+ if (decodeRoutineInboxEntryV1(value).entryId === entry.entryId) return;
304
+ }
305
+ const cursor = routineSequenceCursorV1(
306
+ await transaction.get<unknown>(ROUTINE_INBOX_CURSOR_KEY),
307
+ );
308
+ await transaction.put(routineInboxKeyV1(cursor.nextSeq), entry);
309
+ await transaction.put(ROUTINE_INBOX_CURSOR_KEY, {
310
+ schemaVersion: 1,
311
+ nextSeq: cursor.nextSeq + 1,
312
+ });
313
+ });
314
+ }
315
+
316
+ /** Record that the alarm has re-emitted this wake's notification intent. */
317
+ async markRenotified(key: string): Promise<void> {
318
+ const at = this.#now().toISOString();
319
+ await this.#storage.transaction(async (transaction) => {
320
+ const stored = await transaction.get<unknown>(key);
321
+ if (stored === undefined) return;
322
+ const input = decodePendingBotInputV1(stored);
323
+ if (input.kind !== "wake" || input.renotifiedAt !== undefined) return;
324
+ await transaction.put(key, {
325
+ ...input,
326
+ renotifiedAt: at,
327
+ } satisfies PendingBotInputV1);
328
+ });
329
+ }
330
+
331
+ /**
332
+ * The durable inputs one chat Turn carries. The first call for a run moves
333
+ * the queue into the run's receipt; every later call — a resumed Turn, a
334
+ * recovered one — reads that receipt back and drains nothing, which is what
335
+ * makes replay idempotent on the input's id.
336
+ */
337
+ async drainInto(runId: string): Promise<PendingBotInputV1[]> {
338
+ const drainedAt = this.#now().toISOString();
339
+ return this.#storage.transaction(async (transaction) => {
340
+ const receiptKey = routineDrainKeyV1(runId);
341
+ const existing = await transaction.get<unknown>(receiptKey);
342
+ if (existing !== undefined) {
343
+ return decodeDrainReceiptV1(existing).inputs;
344
+ }
345
+ const stored = await transaction.list<unknown>({
346
+ prefix: ROUTINE_WAKE_PREFIX,
347
+ });
348
+ const pending = [...stored.entries()].sort(([left], [right]) =>
349
+ left.localeCompare(right),
350
+ );
351
+ const seen = new Set<string>();
352
+ const inputs: PendingBotInputV1[] = [];
353
+ for (const [key, value] of pending) {
354
+ await transaction.delete(key);
355
+ const input = decodePendingBotInputV1(value);
356
+ const id = pendingBotInputIdV1(input);
357
+ if (seen.has(id)) continue;
358
+ seen.add(id);
359
+ inputs.push(input);
360
+ }
361
+ // Nothing owed, nothing recorded. A chat Turn holds the object's one
362
+ // active run, so no firing can settle while it runs and no wake can
363
+ // arrive behind this read; an empty receipt would be a record of nothing.
364
+ if (inputs.length === 0) return [];
365
+ const retained = inputs.slice(-ROUTINE_PENDING_INPUT_LIMIT);
366
+ await transaction.put(receiptKey, {
367
+ schemaVersion: 1,
368
+ runId,
369
+ drainedAt,
370
+ inputs: retained,
371
+ } satisfies RoutineDrainReceiptV1);
372
+ await this.#trimReceipts(transaction, receiptKey);
373
+ return retained;
374
+ });
375
+ }
376
+
377
+ /** Trim the inbox to its retention bound, oldest first. */
378
+ async #trimInbox(): Promise<void> {
379
+ const stored = await this.#storage.list<unknown>({
380
+ prefix: ROUTINE_INBOX_PREFIX,
381
+ });
382
+ const keys = [...stored.keys()].sort();
383
+ if (keys.length <= ROUTINE_INBOX_LIMIT) return;
384
+ for (const key of keys.slice(ROUTINE_INBOX_LIMIT)) {
385
+ await this.#storage.delete(key);
386
+ }
387
+ }
388
+
389
+ async #trimReceipts(
390
+ transaction: RoutineStorageWritesV1,
391
+ keep: string,
392
+ ): Promise<void> {
393
+ const stored = await transaction.list<unknown>({
394
+ prefix: ROUTINE_DRAIN_PREFIX,
395
+ });
396
+ const keys = [...stored.keys()].filter((key) => key !== keep).sort();
397
+ if (keys.length < ROUTINE_DRAIN_RECEIPT_LIMIT) return;
398
+ for (const key of keys.slice(
399
+ 0,
400
+ keys.length - ROUTINE_DRAIN_RECEIPT_LIMIT,
401
+ )) {
402
+ await transaction.delete(key);
403
+ }
404
+ }
405
+ }