@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
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
decodePendingBotInputV1,
|
|
4
|
+
decodeRoutineInboxEntryV1,
|
|
5
|
+
pendingBotInputPreambleV1,
|
|
6
|
+
routineAttributionV1,
|
|
7
|
+
routineHandoffTextV1,
|
|
8
|
+
subagentAttributionV1,
|
|
9
|
+
} from "./inbox.js";
|
|
10
|
+
import { RoutineInboxStore, routineTerminalRecordsV1 } from "./inbox-store.js";
|
|
11
|
+
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
12
|
+
import {
|
|
13
|
+
ROUTINE_INBOX_LIMIT,
|
|
14
|
+
ROUTINE_INBOX_PREFIX,
|
|
15
|
+
ROUTINE_WAKE_PREFIX,
|
|
16
|
+
} from "./storage-keys.js";
|
|
17
|
+
|
|
18
|
+
const NOW = "2026-08-31T00:00:00.000Z";
|
|
19
|
+
|
|
20
|
+
function storage() {
|
|
21
|
+
return createMemoryRoutineStorageV1();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function settle(
|
|
25
|
+
store: ReturnType<typeof storage>,
|
|
26
|
+
input: { runId: string; handoff?: string; responseText?: string },
|
|
27
|
+
): Promise<void> {
|
|
28
|
+
const contributed = await routineTerminalRecordsV1({
|
|
29
|
+
runId: input.runId,
|
|
30
|
+
routineId: "morning-brief",
|
|
31
|
+
routineName: "Morning brief",
|
|
32
|
+
now: NOW,
|
|
33
|
+
read: (key) => store.get(key),
|
|
34
|
+
...(input.handoff === undefined ? {} : { handoff: input.handoff }),
|
|
35
|
+
...(input.responseText === undefined
|
|
36
|
+
? {}
|
|
37
|
+
: { responseText: input.responseText }),
|
|
38
|
+
});
|
|
39
|
+
if (!contributed) return;
|
|
40
|
+
for (const [key, value] of Object.entries(contributed.records)) {
|
|
41
|
+
await store.put(key, value);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
describe("the completion inbox", () => {
|
|
46
|
+
test("a hand-off writes an entry and a pending wake together", async () => {
|
|
47
|
+
const store = storage();
|
|
48
|
+
await settle(store, { runId: "rf-1", handoff: "Three emails need you." });
|
|
49
|
+
const inbox = new RoutineInboxStore(store);
|
|
50
|
+
const entries = await inbox.list();
|
|
51
|
+
expect(entries).toHaveLength(1);
|
|
52
|
+
expect(entries[0]!.attribution).toBe("Automation: Morning brief");
|
|
53
|
+
expect(entries[0]!.acknowledged).toBe(false);
|
|
54
|
+
expect(entries[0]!.text).toBe("Three emails need you.");
|
|
55
|
+
const pending = await inbox.pending();
|
|
56
|
+
expect(pending).toHaveLength(1);
|
|
57
|
+
expect(pending[0]!.input.kind).toBe("wake");
|
|
58
|
+
expect(entries[0]!.wakeId).toBe(
|
|
59
|
+
pending[0]!.input.kind === "wake" ? pending[0]!.input.wakeId : "",
|
|
60
|
+
);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("a silent completion writes an entry and no wake", async () => {
|
|
64
|
+
const store = storage();
|
|
65
|
+
await settle(store, { runId: "rf-2", responseText: "Nothing to report." });
|
|
66
|
+
const inbox = new RoutineInboxStore(store);
|
|
67
|
+
expect(await inbox.list()).toHaveLength(1);
|
|
68
|
+
expect(await inbox.pending()).toHaveLength(0);
|
|
69
|
+
expect((await inbox.list())[0]!.wakeId).toBeUndefined();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test("the inbox is trimmed to its bound, oldest first", async () => {
|
|
73
|
+
const store = storage();
|
|
74
|
+
for (let index = 0; index < ROUTINE_INBOX_LIMIT + 5; index += 1) {
|
|
75
|
+
await settle(store, {
|
|
76
|
+
runId: `rf-${String(index).padStart(4, "0")}`,
|
|
77
|
+
responseText: `run ${index}`,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
const inbox = new RoutineInboxStore(store);
|
|
81
|
+
const entries = await inbox.list();
|
|
82
|
+
expect(entries).toHaveLength(ROUTINE_INBOX_LIMIT);
|
|
83
|
+
// Newest first, and the five oldest are the ones that went.
|
|
84
|
+
expect(entries[0]!.text).toBe(`run ${ROUTINE_INBOX_LIMIT + 4}`);
|
|
85
|
+
expect(entries.at(-1)!.text).toBe("run 5");
|
|
86
|
+
expect(
|
|
87
|
+
store.keys().filter((key) => key.startsWith(ROUTINE_INBOX_PREFIX)),
|
|
88
|
+
).toHaveLength(ROUTINE_INBOX_LIMIT);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
test("acknowledging is explicit, targeted, and idempotent", async () => {
|
|
92
|
+
const store = storage();
|
|
93
|
+
await settle(store, { runId: "rf-a", responseText: "first" });
|
|
94
|
+
await settle(store, { runId: "rf-b", responseText: "second" });
|
|
95
|
+
const inbox = new RoutineInboxStore(store);
|
|
96
|
+
expect(await inbox.acknowledge(["ri-rf-a"])).toBe(1);
|
|
97
|
+
expect(await inbox.acknowledge(["ri-rf-a"])).toBe(0);
|
|
98
|
+
const entries = await inbox.list();
|
|
99
|
+
expect(
|
|
100
|
+
entries.filter((entry) => entry.acknowledged).map((entry) => entry.runId),
|
|
101
|
+
).toEqual(["rf-a"]);
|
|
102
|
+
expect(await inbox.acknowledge([])).toBe(1);
|
|
103
|
+
expect((await inbox.list()).every((entry) => entry.acknowledged)).toBe(
|
|
104
|
+
true,
|
|
105
|
+
);
|
|
106
|
+
});
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
describe("the pending-input queue", () => {
|
|
110
|
+
test("a drain is idempotent on the run that performed it", async () => {
|
|
111
|
+
const store = storage();
|
|
112
|
+
await settle(store, { runId: "rf-1", handoff: "one" });
|
|
113
|
+
await settle(store, { runId: "rf-2", handoff: "two" });
|
|
114
|
+
const inbox = new RoutineInboxStore(store);
|
|
115
|
+
const first = await inbox.drainInto("chat-run-1");
|
|
116
|
+
expect(first.map((input) => input.kind)).toEqual(["wake", "wake"]);
|
|
117
|
+
expect(
|
|
118
|
+
store.keys().filter((key) => key.startsWith(ROUTINE_WAKE_PREFIX)),
|
|
119
|
+
).toHaveLength(0);
|
|
120
|
+
// The same run asks again — an eviction and a recovery — and reads back the
|
|
121
|
+
// receipt rather than draining an empty queue.
|
|
122
|
+
expect(await inbox.drainInto("chat-run-1")).toEqual(first);
|
|
123
|
+
// A later Turn drains nothing, because the wakes are already delivered.
|
|
124
|
+
expect(await inbox.drainInto("chat-run-2")).toEqual([]);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("a wake is re-notified at most once", async () => {
|
|
128
|
+
const store = storage();
|
|
129
|
+
await settle(store, { runId: "rf-1", handoff: "one" });
|
|
130
|
+
const inbox = new RoutineInboxStore(store);
|
|
131
|
+
const [pending] = await inbox.pending();
|
|
132
|
+
await inbox.markRenotified(pending!.key);
|
|
133
|
+
await inbox.markRenotified(pending!.key);
|
|
134
|
+
const [after] = await inbox.pending();
|
|
135
|
+
expect(
|
|
136
|
+
after!.input.kind === "wake" ? after!.input.renotifiedAt : undefined,
|
|
137
|
+
).toBeString();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("the preamble names the hand-off and never speaks as the user", () => {
|
|
141
|
+
const preamble = pendingBotInputPreambleV1([
|
|
142
|
+
{
|
|
143
|
+
schemaVersion: 1,
|
|
144
|
+
kind: "wake",
|
|
145
|
+
wakeId: "rw-1",
|
|
146
|
+
runId: "rf-1",
|
|
147
|
+
routineId: "morning-brief",
|
|
148
|
+
title: "Automation: Morning brief",
|
|
149
|
+
text: "Three emails need you.",
|
|
150
|
+
createdAt: NOW,
|
|
151
|
+
quiet: { automation: true },
|
|
152
|
+
},
|
|
153
|
+
]);
|
|
154
|
+
expect(preamble).toContain("Automation: Morning brief");
|
|
155
|
+
expect(preamble).toContain("Three emails need you.");
|
|
156
|
+
expect(pendingBotInputPreambleV1([])).toBe("");
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe("the pending-input codec", () => {
|
|
161
|
+
test("decodes the reserved approval variant that has no producer yet", () => {
|
|
162
|
+
const approval = {
|
|
163
|
+
schemaVersion: 1,
|
|
164
|
+
kind: "approval",
|
|
165
|
+
approvalId: "ap-1",
|
|
166
|
+
decision: "approved",
|
|
167
|
+
createdAt: NOW,
|
|
168
|
+
};
|
|
169
|
+
expect(decodePendingBotInputV1(approval)).toEqual({
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
kind: "approval",
|
|
172
|
+
approvalId: "ap-1",
|
|
173
|
+
decision: "approved",
|
|
174
|
+
createdAt: NOW,
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
test("refuses an unknown kind, an unknown field and a loud wake", () => {
|
|
179
|
+
expect(() =>
|
|
180
|
+
decodePendingBotInputV1({ schemaVersion: 1, kind: "x" }),
|
|
181
|
+
).toThrow("kind is invalid");
|
|
182
|
+
const wake = {
|
|
183
|
+
schemaVersion: 1,
|
|
184
|
+
kind: "wake",
|
|
185
|
+
wakeId: "rw-1",
|
|
186
|
+
runId: "rf-1",
|
|
187
|
+
routineId: "morning-brief",
|
|
188
|
+
title: "Automation: Morning brief",
|
|
189
|
+
text: "hi",
|
|
190
|
+
createdAt: NOW,
|
|
191
|
+
quiet: { automation: true },
|
|
192
|
+
};
|
|
193
|
+
expect(decodePendingBotInputV1(wake)).toEqual(wake as never);
|
|
194
|
+
expect(() =>
|
|
195
|
+
decodePendingBotInputV1({ ...wake, quiet: { automation: false } }),
|
|
196
|
+
).toThrow("quiet.automation must be true");
|
|
197
|
+
expect(() => decodePendingBotInputV1({ ...wake, extra: 1 })).toThrow(
|
|
198
|
+
'unknown field "extra"',
|
|
199
|
+
);
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("an inbox entry is exact-field and versioned", () => {
|
|
203
|
+
const entry = {
|
|
204
|
+
schemaVersion: 1 as const,
|
|
205
|
+
entryId: "ri-1",
|
|
206
|
+
runId: "rf-1",
|
|
207
|
+
routineId: "morning-brief",
|
|
208
|
+
text: "hi",
|
|
209
|
+
attribution: routineAttributionV1("Morning brief"),
|
|
210
|
+
createdAt: NOW,
|
|
211
|
+
acknowledged: false,
|
|
212
|
+
};
|
|
213
|
+
expect(decodeRoutineInboxEntryV1(entry)).toEqual(entry);
|
|
214
|
+
expect(() =>
|
|
215
|
+
decodeRoutineInboxEntryV1({ ...entry, schemaVersion: 2 }),
|
|
216
|
+
).toThrow("schemaVersion is unsupported");
|
|
217
|
+
expect(() =>
|
|
218
|
+
decodeRoutineInboxEntryV1({ ...entry, acknowledged: "yes" }),
|
|
219
|
+
).toThrow("acknowledged must be a boolean");
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("the hand-off is the last `wake/parent` the Turn recorded", () => {
|
|
223
|
+
expect(
|
|
224
|
+
routineHandoffTextV1([
|
|
225
|
+
{ type: "assistant/message" },
|
|
226
|
+
{ type: "wake/parent", message: "first" },
|
|
227
|
+
{ type: "wake/parent", message: "second" },
|
|
228
|
+
] as never),
|
|
229
|
+
).toBe("second");
|
|
230
|
+
expect(
|
|
231
|
+
routineHandoffTextV1([{ type: "turn/end" }] as never),
|
|
232
|
+
).toBeUndefined();
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
describe("the approval variant's producer", () => {
|
|
237
|
+
test("an enqueued approval joins the same queue and drains with the wakes", async () => {
|
|
238
|
+
const store = storage();
|
|
239
|
+
await settle(store, { runId: "rf-1", handoff: "one" });
|
|
240
|
+
const inbox = new RoutineInboxStore(store);
|
|
241
|
+
await inbox.enqueue({
|
|
242
|
+
schemaVersion: 1,
|
|
243
|
+
kind: "approval",
|
|
244
|
+
approvalId: "ap-1",
|
|
245
|
+
decision: "approved",
|
|
246
|
+
createdAt: NOW,
|
|
247
|
+
});
|
|
248
|
+
const drained = await inbox.drainInto("chat-run-1");
|
|
249
|
+
expect(drained.map((input) => input.kind)).toEqual(["wake", "approval"]);
|
|
250
|
+
expect(pendingBotInputPreambleV1(drained)).toContain(
|
|
251
|
+
'The decision on "ap-1" is approved.',
|
|
252
|
+
);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test("enqueuing the same approval twice queues it once", async () => {
|
|
256
|
+
const store = storage();
|
|
257
|
+
const inbox = new RoutineInboxStore(store);
|
|
258
|
+
const input = {
|
|
259
|
+
schemaVersion: 1 as const,
|
|
260
|
+
kind: "approval" as const,
|
|
261
|
+
approvalId: "ap-1",
|
|
262
|
+
decision: "denied" as const,
|
|
263
|
+
createdAt: NOW,
|
|
264
|
+
};
|
|
265
|
+
await inbox.enqueue(input);
|
|
266
|
+
// A retried decision — the same answer arriving twice — must not tell the
|
|
267
|
+
// Bot the same thing twice.
|
|
268
|
+
await inbox.enqueue({ ...input, decision: "expired" });
|
|
269
|
+
const pending = await inbox.pending();
|
|
270
|
+
expect(pending).toHaveLength(1);
|
|
271
|
+
expect(
|
|
272
|
+
pending[0]!.input.kind === "approval"
|
|
273
|
+
? pending[0]!.input.decision
|
|
274
|
+
: undefined,
|
|
275
|
+
).toBe("denied");
|
|
276
|
+
});
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
describe("a subagent completion in the same two records", () => {
|
|
280
|
+
test("attributes a subagent by its description, not as an Automation", () => {
|
|
281
|
+
expect(subagentAttributionV1("Read the release notes")).toBe(
|
|
282
|
+
"Subagent: Read the release notes",
|
|
283
|
+
);
|
|
284
|
+
});
|
|
285
|
+
|
|
286
|
+
test("reads back with its source, so an entry says what produced it", () => {
|
|
287
|
+
const entry = decodeRoutineInboxEntryV1({
|
|
288
|
+
schemaVersion: 1,
|
|
289
|
+
entryId: "ti-tk-1",
|
|
290
|
+
runId: "tk-1",
|
|
291
|
+
routineId: "tk-1",
|
|
292
|
+
text: "executor subagent finished.",
|
|
293
|
+
attribution: "Subagent: Read the release notes",
|
|
294
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
295
|
+
acknowledged: false,
|
|
296
|
+
source: "subagent",
|
|
297
|
+
});
|
|
298
|
+
expect(entry.source).toBe("subagent");
|
|
299
|
+
// An entry written before subagents existed reads as what it was.
|
|
300
|
+
expect(
|
|
301
|
+
decodeRoutineInboxEntryV1({
|
|
302
|
+
schemaVersion: 1,
|
|
303
|
+
entryId: "ri-fire-1",
|
|
304
|
+
runId: "fire-1",
|
|
305
|
+
routineId: "brief",
|
|
306
|
+
text: "The Routine finished.",
|
|
307
|
+
attribution: "Automation: Morning brief",
|
|
308
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
309
|
+
acknowledged: false,
|
|
310
|
+
}).source,
|
|
311
|
+
).toBeUndefined();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
test("refuses a source it does not know", () => {
|
|
315
|
+
expect(() =>
|
|
316
|
+
decodePendingBotInputV1({
|
|
317
|
+
schemaVersion: 1,
|
|
318
|
+
kind: "wake",
|
|
319
|
+
wakeId: "tw-tk-1",
|
|
320
|
+
runId: "tk-1",
|
|
321
|
+
routineId: "tk-1",
|
|
322
|
+
title: "Subagent: Read the release notes",
|
|
323
|
+
text: "It finished.",
|
|
324
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
325
|
+
quiet: { automation: true },
|
|
326
|
+
source: "somewhere-else",
|
|
327
|
+
}),
|
|
328
|
+
).toThrow();
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
test("preambles a subagent wake as a summary, never as a Routine hand-off", () => {
|
|
332
|
+
const preamble = pendingBotInputPreambleV1([
|
|
333
|
+
{
|
|
334
|
+
schemaVersion: 1,
|
|
335
|
+
kind: "wake",
|
|
336
|
+
wakeId: "tw-tk-1",
|
|
337
|
+
runId: "tk-1",
|
|
338
|
+
routineId: "tk-1",
|
|
339
|
+
title: "Subagent: Read the release notes",
|
|
340
|
+
text: "They changed on Tuesday.",
|
|
341
|
+
createdAt: "2026-09-01T00:00:00.000Z",
|
|
342
|
+
quiet: { automation: true },
|
|
343
|
+
source: "subagent",
|
|
344
|
+
},
|
|
345
|
+
]);
|
|
346
|
+
expect(preamble).toContain("the subagent");
|
|
347
|
+
expect(preamble).toContain("not its transcript");
|
|
348
|
+
expect(preamble).toContain("They changed on Tuesday.");
|
|
349
|
+
expect(preamble).not.toContain("your Routine");
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
describe("the machine-result variant", () => {
|
|
354
|
+
const delivery = {
|
|
355
|
+
schemaVersion: 1 as const,
|
|
356
|
+
kind: "machine-result" as const,
|
|
357
|
+
commandId: "cmd-1",
|
|
358
|
+
machineId: "mac-1",
|
|
359
|
+
outcome: "ok" as const,
|
|
360
|
+
preview: "exit 0 — nothing to commit",
|
|
361
|
+
createdAt: NOW,
|
|
362
|
+
};
|
|
363
|
+
|
|
364
|
+
test("round-trips through the codec and refuses an unknown outcome", () => {
|
|
365
|
+
expect(decodePendingBotInputV1(delivery)).toEqual(delivery);
|
|
366
|
+
expect(() =>
|
|
367
|
+
decodePendingBotInputV1({ ...delivery, outcome: "maybe" }),
|
|
368
|
+
).toThrow("outcome is invalid");
|
|
369
|
+
expect(() => decodePendingBotInputV1({ ...delivery, stdout: "…" })).toThrow(
|
|
370
|
+
'unknown field "stdout"',
|
|
371
|
+
);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test("its preamble line names the command and points at the full read", () => {
|
|
375
|
+
const preamble = pendingBotInputPreambleV1([delivery]);
|
|
376
|
+
expect(preamble).toContain('Command "cmd-1" on machine mac-1 finished ok');
|
|
377
|
+
expect(preamble).toContain("exit 0 — nothing to commit");
|
|
378
|
+
// The whole result is read on demand: a megabyte of stdout must never ride
|
|
379
|
+
// the preamble and push the person's own words out of the request.
|
|
380
|
+
expect(preamble).toContain("machine_command_check");
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test("joins the one queue and de-duplicates on its command id", async () => {
|
|
384
|
+
const store = storage();
|
|
385
|
+
await settle(store, { runId: "rf-1", handoff: "one" });
|
|
386
|
+
const inbox = new RoutineInboxStore(store);
|
|
387
|
+
await inbox.enqueue(delivery);
|
|
388
|
+
// A machine that answered twice — its own retry — must not tell the Bot
|
|
389
|
+
// twice; `commandId` is the id the queue de-duplicates on.
|
|
390
|
+
await inbox.enqueue({ ...delivery, preview: "a second telling" });
|
|
391
|
+
const pending = await inbox.pending();
|
|
392
|
+
expect(pending).toHaveLength(2);
|
|
393
|
+
const drained = await inbox.drainInto("rf-2");
|
|
394
|
+
expect(drained.map((input) => input.kind)).toEqual([
|
|
395
|
+
"wake",
|
|
396
|
+
"machine-result",
|
|
397
|
+
]);
|
|
398
|
+
expect(
|
|
399
|
+
drained[1]!.kind === "machine-result" ? drained[1]!.preview : undefined,
|
|
400
|
+
).toBe("exit 0 — nothing to commit");
|
|
401
|
+
});
|
|
402
|
+
});
|