@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/inbox.ts ADDED
@@ -0,0 +1,405 @@
1
+ // What an automation Turn leaves behind, and what the Bot's next conversational
2
+ // Turn picks up.
3
+ //
4
+ // A Routine firing cannot speak to its User: `send_to_user` is not in the
5
+ // automation catalog, and nothing an automation Turn writes reaches the visible
6
+ // transcript. Its outcome therefore has to land somewhere durable, and it lands
7
+ // in two records written in the same transaction that settles the Turn.
8
+ //
9
+ // * `RoutineInboxEntryV1` is the User-facing half — the completion inbox
10
+ // GrokBot spells `automation_completion_inbox`, carrying the hand-off text,
11
+ // the attribution "Automation: <name>", and an `acknowledged` flag the User
12
+ // clears. Every completed automation Turn writes one, whether or not it
13
+ // called `wake_parent`.
14
+ //
15
+ // * `PendingBotInputV1` is the Bot-facing half, and only a hand-off writes
16
+ // one. `AGENTS.md`: "its outcome is delivered to the Bot's next
17
+ // conversational Turn as durable input". It is a queue of one input each,
18
+ // drained at exactly two points and idempotent on its id, so an eviction
19
+ // between the firing and the next chat Turn loses nothing and a recovery
20
+ // never delivers the same hand-off twice.
21
+ //
22
+ // `PendingBotInputV1` is deliberately wider than Routines. The `wake` variant is
23
+ // the one this slice produced; the `approval` variant is the approval card's,
24
+ // and the `machine-result` variant is the registered machine's. Each new
25
+ // producer widens this union rather than opening a second queue: two queues
26
+ // would mean two drains, two receipts, and two chances to double-deliver.
27
+ import {
28
+ isRoutineIdV1,
29
+ RoutineDecodeError,
30
+ routineExactKeys,
31
+ routineText,
32
+ routineTimestamp,
33
+ } from "./records.js";
34
+
35
+ /** "Automation: " plus a Routine name capped at its record's own limit. */
36
+ export const ROUTINE_NAME_ATTRIBUTION_MAX = 128;
37
+
38
+ export function routineAttributionV1(name: string): string {
39
+ return `Automation: ${name}`.slice(0, ROUTINE_NAME_ATTRIBUTION_MAX);
40
+ }
41
+
42
+ /**
43
+ * What produced one completion-inbox entry.
44
+ *
45
+ * The queue was always wider than Routines — the `approval` pending input says
46
+ * so — and a background subagent lands in exactly the same two records for
47
+ * exactly the same reason: it is a Turn that cannot speak to its User, whose
48
+ * outcome has to be durable somewhere the next conversational Turn reads.
49
+ * Absent means `routine`, so every entry written before subagents existed still
50
+ * decodes and still reads as what it was.
51
+ */
52
+ export type CompletionSourceV1 = "routine" | "subagent";
53
+
54
+ /** "Subagent: " plus the task's description, capped like an attribution. */
55
+ export function subagentAttributionV1(description: string): string {
56
+ return `Subagent: ${description}`.slice(0, ROUTINE_NAME_ATTRIBUTION_MAX);
57
+ }
58
+
59
+ /** Longest hand-off an inbox entry or a pending wake carries. */
60
+ export const ROUTINE_INBOX_TEXT_MAX = 4_000;
61
+ /** Longest title a pending wake carries. */
62
+ export const ROUTINE_WAKE_TITLE_MAX = 200;
63
+
64
+ /**
65
+ * One completed automation Turn, waiting to be read. `attribution` is the
66
+ * rendered "Automation: <name>" line rather than a name the reader must
67
+ * assemble, because the Routine may have been deleted since it fired.
68
+ */
69
+ export interface RoutineInboxEntryV1 {
70
+ schemaVersion: 1;
71
+ entryId: string;
72
+ runId: string;
73
+ routineId: string;
74
+ text: string;
75
+ attribution: string;
76
+ createdAt: string;
77
+ acknowledged: boolean;
78
+ /** Present when the Turn also handed off, naming the wake it queued. */
79
+ wakeId?: string;
80
+ acknowledgedAt?: string;
81
+ /**
82
+ * What produced this entry. Absent means `routine`; `routineId` then carries
83
+ * the task id, because the field names the automation the entry came from
84
+ * and a subagent task is one.
85
+ */
86
+ source?: CompletionSourceV1;
87
+ }
88
+
89
+ /** A hand-off the Bot has not yet been told about. */
90
+ export interface RoutinePendingWakeV1 {
91
+ schemaVersion: 1;
92
+ kind: "wake";
93
+ wakeId: string;
94
+ runId: string;
95
+ routineId: string;
96
+ title: string;
97
+ text: string;
98
+ createdAt: string;
99
+ /**
100
+ * GrokBot's `quietOrigin.automation`: the wake came from the Bot's own
101
+ * automation rather than from a person, so replaying it must not read as the
102
+ * User having said something.
103
+ */
104
+ quiet: { automation: true };
105
+ /** Set once the alarm has re-emitted this wake's notification intent. */
106
+ renotifiedAt?: string;
107
+ /** What produced this wake. Absent means `routine`. */
108
+ source?: CompletionSourceV1;
109
+ }
110
+
111
+ /**
112
+ * A decision the User has already made, waiting to be told to the Bot. Decoded
113
+ * here and produced nowhere: the approval-card slice supplies the producer, and
114
+ * because the variant already crosses the seam it adds no wire change.
115
+ */
116
+ export interface RoutinePendingApprovalV1 {
117
+ schemaVersion: 1;
118
+ kind: "approval";
119
+ approvalId: string;
120
+ decision: "approved" | "denied" | "expired";
121
+ createdAt: string;
122
+ }
123
+
124
+ /**
125
+ * A machine command that has finished, waiting to be told to the Bot.
126
+ *
127
+ * The third variant, and the reason there is not a second queue: `plugin-shell`
128
+ * already drains this one at exactly two points and de-duplicates on an id, so
129
+ * a machine result rides the same rails a Routine hand-off and an approval
130
+ * decision do. It carries a *preview* and never the output — the full result is
131
+ * read on demand with `machine_command_check`, so a megabyte of stdout can
132
+ * never push a person's own words out of the next Turn's context.
133
+ */
134
+ export interface RoutinePendingMachineResultV1 {
135
+ schemaVersion: 1;
136
+ kind: "machine-result";
137
+ commandId: string;
138
+ machineId: string;
139
+ outcome: "ok" | "error" | "refused" | "timeout";
140
+ preview: string;
141
+ createdAt: string;
142
+ }
143
+
144
+ /** The longest preview a machine-result input carries. */
145
+ export const MACHINE_RESULT_PREVIEW_MAX_V1 = 400;
146
+
147
+ /** One durable input the Bot's next conversational Turn is owed. */
148
+ export type PendingBotInputV1 =
149
+ | RoutinePendingWakeV1
150
+ | RoutinePendingApprovalV1
151
+ | RoutinePendingMachineResultV1;
152
+
153
+ /** The id one pending input is keyed and de-duplicated by. */
154
+ export function pendingBotInputIdV1(input: PendingBotInputV1): string {
155
+ if (input.kind === "wake") return input.wakeId;
156
+ if (input.kind === "approval") return input.approvalId;
157
+ return `machine-result:${input.commandId}`;
158
+ }
159
+
160
+ function record(value: unknown, label: string): Record<string, unknown> {
161
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
162
+ throw new RoutineDecodeError(`${label} must be an object`);
163
+ }
164
+ return value as Record<string, unknown>;
165
+ }
166
+
167
+ function completionSourceV1(value: unknown, label: string): CompletionSourceV1 {
168
+ if (value !== "routine" && value !== "subagent") {
169
+ throw new RoutineDecodeError(`${label} is invalid`);
170
+ }
171
+ return value;
172
+ }
173
+
174
+ export function decodeRoutineInboxEntryV1(
175
+ value: unknown,
176
+ label = "Routine inbox entry",
177
+ ): RoutineInboxEntryV1 {
178
+ const candidate = record(value, label);
179
+ routineExactKeys(
180
+ candidate,
181
+ [
182
+ "schemaVersion",
183
+ "entryId",
184
+ "runId",
185
+ "routineId",
186
+ "text",
187
+ "attribution",
188
+ "createdAt",
189
+ "acknowledged",
190
+ ],
191
+ ["wakeId", "acknowledgedAt", "source"],
192
+ label,
193
+ );
194
+ if (candidate.schemaVersion !== 1) {
195
+ throw new RoutineDecodeError(`${label} schemaVersion is unsupported`);
196
+ }
197
+ if (!isRoutineIdV1(candidate.routineId)) {
198
+ throw new RoutineDecodeError(`${label} routineId is invalid`);
199
+ }
200
+ if (typeof candidate.acknowledged !== "boolean") {
201
+ throw new RoutineDecodeError(`${label} acknowledged must be a boolean`);
202
+ }
203
+ return {
204
+ schemaVersion: 1,
205
+ entryId: routineText(candidate.entryId, 256, `${label} entryId`),
206
+ runId: routineText(candidate.runId, 256, `${label} runId`),
207
+ routineId: candidate.routineId,
208
+ text: routineText(candidate.text, ROUTINE_INBOX_TEXT_MAX, `${label} text`),
209
+ attribution: routineText(
210
+ candidate.attribution,
211
+ ROUTINE_NAME_ATTRIBUTION_MAX,
212
+ `${label} attribution`,
213
+ ),
214
+ createdAt: routineTimestamp(candidate.createdAt, `${label} createdAt`),
215
+ acknowledged: candidate.acknowledged,
216
+ ...(candidate.wakeId === undefined
217
+ ? {}
218
+ : { wakeId: routineText(candidate.wakeId, 256, `${label} wakeId`) }),
219
+ ...(candidate.acknowledgedAt === undefined
220
+ ? {}
221
+ : {
222
+ acknowledgedAt: routineTimestamp(
223
+ candidate.acknowledgedAt,
224
+ `${label} acknowledgedAt`,
225
+ ),
226
+ }),
227
+ ...(candidate.source === undefined
228
+ ? {}
229
+ : { source: completionSourceV1(candidate.source, `${label} source`) }),
230
+ };
231
+ }
232
+
233
+ export function decodePendingBotInputV1(
234
+ value: unknown,
235
+ label = "pending Bot input",
236
+ ): PendingBotInputV1 {
237
+ const candidate = record(value, label);
238
+ if (candidate.schemaVersion !== 1) {
239
+ throw new RoutineDecodeError(`${label} schemaVersion is unsupported`);
240
+ }
241
+ if (candidate.kind === "approval") {
242
+ routineExactKeys(
243
+ candidate,
244
+ ["schemaVersion", "kind", "approvalId", "decision", "createdAt"],
245
+ [],
246
+ label,
247
+ );
248
+ if (
249
+ candidate.decision !== "approved" &&
250
+ candidate.decision !== "denied" &&
251
+ candidate.decision !== "expired"
252
+ ) {
253
+ throw new RoutineDecodeError(`${label} decision is invalid`);
254
+ }
255
+ return {
256
+ schemaVersion: 1,
257
+ kind: "approval",
258
+ approvalId: routineText(candidate.approvalId, 256, `${label} approvalId`),
259
+ decision: candidate.decision,
260
+ createdAt: routineTimestamp(candidate.createdAt, `${label} createdAt`),
261
+ };
262
+ }
263
+ if (candidate.kind === "machine-result") {
264
+ routineExactKeys(
265
+ candidate,
266
+ [
267
+ "schemaVersion",
268
+ "kind",
269
+ "commandId",
270
+ "machineId",
271
+ "outcome",
272
+ "preview",
273
+ "createdAt",
274
+ ],
275
+ [],
276
+ label,
277
+ );
278
+ if (
279
+ candidate.outcome !== "ok" &&
280
+ candidate.outcome !== "error" &&
281
+ candidate.outcome !== "refused" &&
282
+ candidate.outcome !== "timeout"
283
+ ) {
284
+ throw new RoutineDecodeError(`${label} outcome is invalid`);
285
+ }
286
+ return {
287
+ schemaVersion: 1,
288
+ kind: "machine-result",
289
+ commandId: routineText(candidate.commandId, 256, `${label} commandId`),
290
+ machineId: routineText(candidate.machineId, 256, `${label} machineId`),
291
+ outcome: candidate.outcome,
292
+ preview: routineText(
293
+ candidate.preview,
294
+ MACHINE_RESULT_PREVIEW_MAX_V1,
295
+ `${label} preview`,
296
+ ),
297
+ createdAt: routineTimestamp(candidate.createdAt, `${label} createdAt`),
298
+ };
299
+ }
300
+ if (candidate.kind !== "wake") {
301
+ throw new RoutineDecodeError(`${label} kind is invalid`);
302
+ }
303
+ routineExactKeys(
304
+ candidate,
305
+ [
306
+ "schemaVersion",
307
+ "kind",
308
+ "wakeId",
309
+ "runId",
310
+ "routineId",
311
+ "title",
312
+ "text",
313
+ "createdAt",
314
+ "quiet",
315
+ ],
316
+ ["renotifiedAt", "source"],
317
+ label,
318
+ );
319
+ if (!isRoutineIdV1(candidate.routineId)) {
320
+ throw new RoutineDecodeError(`${label} routineId is invalid`);
321
+ }
322
+ const quiet = record(candidate.quiet, `${label} quiet`);
323
+ routineExactKeys(quiet, ["automation"], [], `${label} quiet`);
324
+ if (quiet.automation !== true) {
325
+ throw new RoutineDecodeError(`${label} quiet.automation must be true`);
326
+ }
327
+ return {
328
+ schemaVersion: 1,
329
+ kind: "wake",
330
+ wakeId: routineText(candidate.wakeId, 256, `${label} wakeId`),
331
+ runId: routineText(candidate.runId, 256, `${label} runId`),
332
+ routineId: candidate.routineId,
333
+ title: routineText(
334
+ candidate.title,
335
+ ROUTINE_WAKE_TITLE_MAX,
336
+ `${label} title`,
337
+ ),
338
+ text: routineText(candidate.text, ROUTINE_INBOX_TEXT_MAX, `${label} text`),
339
+ createdAt: routineTimestamp(candidate.createdAt, `${label} createdAt`),
340
+ quiet: { automation: true },
341
+ ...(candidate.renotifiedAt === undefined
342
+ ? {}
343
+ : {
344
+ renotifiedAt: routineTimestamp(
345
+ candidate.renotifiedAt,
346
+ `${label} renotifiedAt`,
347
+ ),
348
+ }),
349
+ ...(candidate.source === undefined
350
+ ? {}
351
+ : { source: completionSourceV1(candidate.source, `${label} source`) }),
352
+ };
353
+ }
354
+
355
+ /**
356
+ * The hand-off text an automation Turn produced, if it produced one. The
357
+ * `wake/parent` event is the only durable statement a Routine can make to its
358
+ * parent, so the last one recorded in the Turn wins.
359
+ */
360
+ export function routineHandoffTextV1(
361
+ events: readonly { type: string }[],
362
+ ): string | undefined {
363
+ const handoff = events.findLast((event) => event.type === "wake/parent") as
364
+ { type: "wake/parent"; message: string } | undefined;
365
+ return handoff?.message;
366
+ }
367
+
368
+ /**
369
+ * How the drained hand-offs are rendered into the next chat Turn's input.
370
+ *
371
+ * They are prefixed, never merged: the User's own text stays verbatim and last,
372
+ * so the model reads the hand-off as context that arrived before the person
373
+ * spoke rather than as something the person said.
374
+ */
375
+ export function pendingBotInputPreambleV1(
376
+ inputs: readonly PendingBotInputV1[],
377
+ ): string {
378
+ if (inputs.length === 0) return "";
379
+ const lines: string[] = [];
380
+ for (const input of inputs) {
381
+ if (input.kind === "wake") {
382
+ lines.push(
383
+ input.source === "subagent"
384
+ ? `[${input.title}] While you were away, the subagent "${input.routineId}" you dispatched finished. Its summary — not its transcript, which you cannot see — is:`
385
+ : `[${input.title}] While you were away, your Routine "${input.routineId}" finished and handed off:`,
386
+ input.text,
387
+ "",
388
+ );
389
+ continue;
390
+ }
391
+ if (input.kind === "approval") {
392
+ lines.push(
393
+ `[Approval] The decision on "${input.approvalId}" is ${input.decision}.`,
394
+ "",
395
+ );
396
+ continue;
397
+ }
398
+ lines.push(
399
+ `[Machine] Command "${input.commandId}" on machine ${input.machineId} finished ${input.outcome}: ${input.preview}`,
400
+ `Call machine_command_check with commandId "${input.commandId}" to read the whole result.`,
401
+ "",
402
+ );
403
+ }
404
+ return lines.join("\n");
405
+ }
package/src/index.ts ADDED
@@ -0,0 +1,9 @@
1
+ export * from "./cron.js";
2
+ export * from "./firing.js";
3
+ export * from "./hook.js";
4
+ export * from "./records.js";
5
+ export * from "./scheduler.js";
6
+ export * from "./shared.js";
7
+ export * from "./storage-keys.js";
8
+ export * from "./store.js";
9
+ export * from "./testing.js";
@@ -0,0 +1,3 @@
1
+ import manifest from "../frockbot.json" with { type: "json" };
2
+
3
+ export default manifest;
@@ -0,0 +1,138 @@
1
+ import { describe, expect, test } from "bun:test";
2
+ import {
3
+ decodeRoutineRecordV1,
4
+ decodeRoutineRunEntryV1,
5
+ decodeRoutineWriterV1,
6
+ RoutineDecodeError,
7
+ ROUTINE_PROMPT_MAX_LENGTH,
8
+ } from "./records.js";
9
+
10
+ const base = {
11
+ schemaVersion: 1,
12
+ routineId: "morning-brief",
13
+ name: "Morning brief",
14
+ prompt: "Summarize overnight email.",
15
+ schedule: "0 7 * * *",
16
+ timezone: "Australia/Sydney",
17
+ enabled: true,
18
+ createdBy: { kind: "user" },
19
+ updatedBy: { kind: "user" },
20
+ createdAt: "2026-08-31T00:00:00.000Z",
21
+ updatedAt: "2026-08-31T00:00:00.000Z",
22
+ };
23
+
24
+ describe("RoutineRecordV1", () => {
25
+ test("decodes a scheduled Routine written by its User", () => {
26
+ expect(decodeRoutineRecordV1(base)).toMatchObject({
27
+ routineId: "morning-brief",
28
+ schedule: "0 7 * * *",
29
+ enabled: true,
30
+ createdBy: { kind: "user" },
31
+ });
32
+ });
33
+
34
+ test("decodes a webhook Routine, which records the trigger kind and no key", () => {
35
+ const { schedule: _schedule, ...rest } = base;
36
+ const decoded = decodeRoutineRecordV1({
37
+ ...rest,
38
+ trigger: { kind: "webhook" },
39
+ });
40
+ expect(decoded.trigger).toEqual({ kind: "webhook" });
41
+ expect(decoded.schedule).toBeUndefined();
42
+ expect(Object.keys(decoded)).not.toContain("key");
43
+ });
44
+
45
+ test("refuses both a schedule and a trigger, and refuses neither", () => {
46
+ expect(() =>
47
+ decodeRoutineRecordV1({ ...base, trigger: { kind: "webhook" } }),
48
+ ).toThrow(/never both/);
49
+ const { schedule: _schedule, ...rest } = base;
50
+ expect(() => decodeRoutineRecordV1(rest)).toThrow(
51
+ /needs a schedule or a trigger/,
52
+ );
53
+ });
54
+
55
+ test("refuses an oversized prompt", () => {
56
+ expect(() =>
57
+ decodeRoutineRecordV1({
58
+ ...base,
59
+ prompt: "x".repeat(ROUTINE_PROMPT_MAX_LENGTH + 1),
60
+ }),
61
+ ).toThrow(/at most 8000 characters/);
62
+ });
63
+
64
+ test("refuses an unknown field, a missing field, and a future version", () => {
65
+ expect(() => decodeRoutineRecordV1({ ...base, extra: 1 })).toThrow(
66
+ /unknown field "extra"/,
67
+ );
68
+ const { name: _name, ...missing } = base;
69
+ expect(() => decodeRoutineRecordV1(missing)).toThrow(/is missing "name"/);
70
+ expect(() => decodeRoutineRecordV1({ ...base, schemaVersion: 2 })).toThrow(
71
+ /schemaVersion is unsupported/,
72
+ );
73
+ });
74
+
75
+ test("refuses a trigger kind no delivery exists for", () => {
76
+ const { schedule: _schedule, ...rest } = base;
77
+ expect(() =>
78
+ decodeRoutineRecordV1({ ...rest, trigger: { kind: "slack" } }),
79
+ ).toThrow(RoutineDecodeError);
80
+ });
81
+ });
82
+
83
+ describe("RoutineWriterV1", () => {
84
+ test("a Bot writer names the Session and Turn that produced the write", () => {
85
+ expect(
86
+ decodeRoutineWriterV1({
87
+ kind: "bot",
88
+ botId: "scout",
89
+ sessionId: "user:scout",
90
+ turnId: "turn-3",
91
+ }),
92
+ ).toEqual({
93
+ kind: "bot",
94
+ botId: "scout",
95
+ sessionId: "user:scout",
96
+ turnId: "turn-3",
97
+ });
98
+ });
99
+
100
+ test("refuses a Bot writer with no provenance and an unknown kind", () => {
101
+ expect(() =>
102
+ decodeRoutineWriterV1({ kind: "bot", botId: "scout" }),
103
+ ).toThrow(/is missing "sessionId"/);
104
+ expect(() => decodeRoutineWriterV1({ kind: "cron" })).toThrow(
105
+ /kind is invalid/,
106
+ );
107
+ });
108
+ });
109
+
110
+ describe("RoutineRunEntryV1", () => {
111
+ const entry = {
112
+ schemaVersion: 1,
113
+ entryId: "entry-1",
114
+ routineId: "morning-brief",
115
+ runId: "fire-1",
116
+ fireId: "fire-1",
117
+ trigger: "cron",
118
+ status: "ok",
119
+ startedAt: "2026-08-31T07:00:00.000Z",
120
+ };
121
+
122
+ test("decodes every declared status", () => {
123
+ for (const status of ["running", "ok", "failed", "skipped", "cancelled"]) {
124
+ expect(decodeRoutineRunEntryV1({ ...entry, status }).status).toBe(
125
+ status as never,
126
+ );
127
+ }
128
+ });
129
+
130
+ test("refuses a status and a trigger outside the vocabulary", () => {
131
+ expect(() => decodeRoutineRunEntryV1({ ...entry, status: "done" })).toThrow(
132
+ /status is invalid/,
133
+ );
134
+ expect(() =>
135
+ decodeRoutineRunEntryV1({ ...entry, trigger: "clock" }),
136
+ ).toThrow(/trigger is invalid/);
137
+ });
138
+ });