@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/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";
|
package/src/manifest.ts
ADDED
|
@@ -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
|
+
});
|