@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/records.ts ADDED
@@ -0,0 +1,341 @@
1
+ // The durable Routine records, and their strict codecs.
2
+ //
3
+ // Every record is versioned, exact-field, and decoded at the seam it crosses.
4
+ // There are no migrations: issue #21 states "No compatibility or historical-data
5
+ // migration is needed", so a record the current codec refuses is a visible
6
+ // failure rather than something to reshape.
7
+ //
8
+ // A webhook Routine's record names the trigger *kind* and nothing else. Key
9
+ // material is minted, stored, and verified elsewhere (D3) and never reaches a
10
+ // record a view is projected from.
11
+
12
+ /** Longest values a Routine record may carry. */
13
+ export const ROUTINE_NAME_MAX_LENGTH = 100;
14
+ export const ROUTINE_PROMPT_MAX_LENGTH = 8_000;
15
+ export const ROUTINE_SCHEDULE_MAX = 256;
16
+ export const ROUTINE_TIMEZONE_MAX = 64;
17
+ export const ROUTINE_ID_MAX = 128;
18
+
19
+ /** The statuses one entry of a Routine's run log may hold. */
20
+ export const ROUTINE_RUN_STATUSES = [
21
+ "running",
22
+ "ok",
23
+ "failed",
24
+ "skipped",
25
+ "cancelled",
26
+ ] as const;
27
+
28
+ export type RoutineRunStatusV1 = (typeof ROUTINE_RUN_STATUSES)[number];
29
+
30
+ /** What produced a firing. Mirrors `StoredRunOriginV1.trigger` exactly. */
31
+ export const ROUTINE_TRIGGER_KINDS = [
32
+ "cron",
33
+ "webhook",
34
+ "integration",
35
+ "manual",
36
+ ] as const;
37
+
38
+ export type RoutineTriggerKindV1 = (typeof ROUTINE_TRIGGER_KINDS)[number];
39
+
40
+ /**
41
+ * Who wrote a Routine. "Every write to a durable root records its writer" — a
42
+ * Routine is durable Bot state authored by a User or by the Bot itself, so the
43
+ * same rule binds, and a Bot writer names the Session and Turn that produced it.
44
+ */
45
+ export type RoutineWriterV1 =
46
+ | { kind: "user" }
47
+ | { kind: "bot"; botId: string; sessionId: string; turnId: string };
48
+
49
+ /** A Routine that fires on a delivered event rather than on a clock. */
50
+ export interface RoutineTriggerV1 {
51
+ kind: "webhook";
52
+ }
53
+
54
+ /**
55
+ * One Routine. `schedule` and `trigger` are exclusive: "never both `schedule`
56
+ * and `trigger`", and never neither.
57
+ */
58
+ export interface RoutineRecordV1 {
59
+ schemaVersion: 1;
60
+ routineId: string;
61
+ name: string;
62
+ prompt: string;
63
+ schedule?: string;
64
+ trigger?: RoutineTriggerV1;
65
+ timezone: string;
66
+ enabled: boolean;
67
+ createdBy: RoutineWriterV1;
68
+ updatedBy: RoutineWriterV1;
69
+ createdAt: string;
70
+ updatedAt: string;
71
+ lastRunAt?: string;
72
+ }
73
+
74
+ /** One entry of a Routine's bounded run log. */
75
+ export interface RoutineRunEntryV1 {
76
+ schemaVersion: 1;
77
+ entryId: string;
78
+ routineId: string;
79
+ runId: string;
80
+ fireId: string;
81
+ trigger: RoutineTriggerKindV1;
82
+ status: RoutineRunStatusV1;
83
+ startedAt: string;
84
+ finishedAt?: string;
85
+ summary?: string;
86
+ }
87
+
88
+ export class RoutineDecodeError extends Error {
89
+ override readonly name = "RoutineDecodeError";
90
+ }
91
+
92
+ const IDENTIFIER = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,127}$/;
93
+
94
+ export function isRoutineIdV1(value: unknown): value is string {
95
+ return typeof value === "string" && IDENTIFIER.test(value);
96
+ }
97
+
98
+ function record(value: unknown, label: string): Record<string, unknown> {
99
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
100
+ throw new RoutineDecodeError(`${label} must be an object`);
101
+ }
102
+ return value as Record<string, unknown>;
103
+ }
104
+
105
+ export function routineExactKeys(
106
+ value: Record<string, unknown>,
107
+ required: readonly string[],
108
+ optional: readonly string[],
109
+ label: string,
110
+ ): void {
111
+ const allowed = new Set([...required, ...optional]);
112
+ for (const key of Object.keys(value)) {
113
+ if (!allowed.has(key)) {
114
+ throw new RoutineDecodeError(`${label} has unknown field "${key}"`);
115
+ }
116
+ }
117
+ for (const key of required) {
118
+ if (!Object.hasOwn(value, key)) {
119
+ throw new RoutineDecodeError(`${label} is missing "${key}"`);
120
+ }
121
+ }
122
+ }
123
+
124
+ export function routineText(
125
+ value: unknown,
126
+ maximum: number,
127
+ label: string,
128
+ ): string {
129
+ if (typeof value !== "string") {
130
+ throw new RoutineDecodeError(`${label} must be a string`);
131
+ }
132
+ const trimmed = value.trim();
133
+ if (trimmed.length === 0) {
134
+ throw new RoutineDecodeError(`${label} must not be empty`);
135
+ }
136
+ if (trimmed.length > maximum) {
137
+ throw new RoutineDecodeError(
138
+ `${label} must be at most ${maximum} characters`,
139
+ );
140
+ }
141
+ return trimmed;
142
+ }
143
+
144
+ export function routineTimestamp(value: unknown, label: string): string {
145
+ if (typeof value !== "string" || Number.isNaN(Date.parse(value))) {
146
+ throw new RoutineDecodeError(`${label} must be an ISO-8601 timestamp`);
147
+ }
148
+ return value;
149
+ }
150
+
151
+ export function decodeRoutineWriterV1(
152
+ value: unknown,
153
+ label = "Routine writer",
154
+ ): RoutineWriterV1 {
155
+ const candidate = record(value, label);
156
+ if (candidate.kind === "user") {
157
+ routineExactKeys(candidate, ["kind"], [], label);
158
+ return { kind: "user" };
159
+ }
160
+ if (candidate.kind !== "bot") {
161
+ throw new RoutineDecodeError(`${label} kind is invalid`);
162
+ }
163
+ routineExactKeys(
164
+ candidate,
165
+ ["kind", "botId", "sessionId", "turnId"],
166
+ [],
167
+ label,
168
+ );
169
+ return {
170
+ kind: "bot",
171
+ botId: routineText(candidate.botId, 128, `${label} botId`),
172
+ sessionId: routineText(candidate.sessionId, 256, `${label} sessionId`),
173
+ turnId: routineText(candidate.turnId, 256, `${label} turnId`),
174
+ };
175
+ }
176
+
177
+ export function decodeRoutineTriggerV1(
178
+ value: unknown,
179
+ label = "Routine trigger",
180
+ ): RoutineTriggerV1 {
181
+ const candidate = record(value, label);
182
+ routineExactKeys(candidate, ["kind"], [], label);
183
+ if (candidate.kind !== "webhook") {
184
+ throw new RoutineDecodeError(
185
+ `${label} kind must be "webhook"; no other delivery ships yet`,
186
+ );
187
+ }
188
+ return { kind: "webhook" };
189
+ }
190
+
191
+ /**
192
+ * Exactly one of `schedule` and `trigger`. This is the rule GrokBot states and
193
+ * the one the whole scheduler rests on, so it is enforced in the codec rather
194
+ * than at a call site that might be skipped.
195
+ */
196
+ export function requireScheduleXorTriggerV1(input: {
197
+ schedule?: string;
198
+ trigger?: RoutineTriggerV1;
199
+ }): void {
200
+ const hasSchedule = input.schedule !== undefined;
201
+ const hasTrigger = input.trigger !== undefined;
202
+ if (hasSchedule && hasTrigger) {
203
+ throw new RoutineDecodeError(
204
+ "a Routine carries a schedule or a trigger, never both",
205
+ );
206
+ }
207
+ if (!hasSchedule && !hasTrigger) {
208
+ throw new RoutineDecodeError("a Routine needs a schedule or a trigger");
209
+ }
210
+ }
211
+
212
+ export function decodeRoutineRecordV1(value: unknown): RoutineRecordV1 {
213
+ const candidate = record(value, "Routine record");
214
+ routineExactKeys(
215
+ candidate,
216
+ [
217
+ "schemaVersion",
218
+ "routineId",
219
+ "name",
220
+ "prompt",
221
+ "timezone",
222
+ "enabled",
223
+ "createdBy",
224
+ "updatedBy",
225
+ "createdAt",
226
+ "updatedAt",
227
+ ],
228
+ ["schedule", "trigger", "lastRunAt"],
229
+ "Routine record",
230
+ );
231
+ if (candidate.schemaVersion !== 1) {
232
+ throw new RoutineDecodeError("Routine record schemaVersion is unsupported");
233
+ }
234
+ if (!isRoutineIdV1(candidate.routineId)) {
235
+ throw new RoutineDecodeError("Routine record routineId is invalid");
236
+ }
237
+ if (typeof candidate.enabled !== "boolean") {
238
+ throw new RoutineDecodeError("Routine record enabled must be a boolean");
239
+ }
240
+ const decoded: RoutineRecordV1 = {
241
+ schemaVersion: 1,
242
+ routineId: candidate.routineId,
243
+ name: routineText(candidate.name, ROUTINE_NAME_MAX_LENGTH, "Routine name"),
244
+ prompt: routineText(
245
+ candidate.prompt,
246
+ ROUTINE_PROMPT_MAX_LENGTH,
247
+ "Routine prompt",
248
+ ),
249
+ timezone: routineText(
250
+ candidate.timezone,
251
+ ROUTINE_TIMEZONE_MAX,
252
+ "Routine timezone",
253
+ ),
254
+ enabled: candidate.enabled,
255
+ createdBy: decodeRoutineWriterV1(candidate.createdBy, "Routine createdBy"),
256
+ updatedBy: decodeRoutineWriterV1(candidate.updatedBy, "Routine updatedBy"),
257
+ createdAt: routineTimestamp(candidate.createdAt, "Routine createdAt"),
258
+ updatedAt: routineTimestamp(candidate.updatedAt, "Routine updatedAt"),
259
+ ...(candidate.schedule === undefined
260
+ ? {}
261
+ : {
262
+ schedule: routineText(
263
+ candidate.schedule,
264
+ ROUTINE_SCHEDULE_MAX,
265
+ "Routine schedule",
266
+ ),
267
+ }),
268
+ ...(candidate.trigger === undefined
269
+ ? {}
270
+ : { trigger: decodeRoutineTriggerV1(candidate.trigger) }),
271
+ ...(candidate.lastRunAt === undefined
272
+ ? {}
273
+ : {
274
+ lastRunAt: routineTimestamp(candidate.lastRunAt, "Routine lastRunAt"),
275
+ }),
276
+ };
277
+ requireScheduleXorTriggerV1(decoded);
278
+ return decoded;
279
+ }
280
+
281
+ export function decodeRoutineRunEntryV1(value: unknown): RoutineRunEntryV1 {
282
+ const candidate = record(value, "Routine run entry");
283
+ routineExactKeys(
284
+ candidate,
285
+ [
286
+ "schemaVersion",
287
+ "entryId",
288
+ "routineId",
289
+ "runId",
290
+ "fireId",
291
+ "trigger",
292
+ "status",
293
+ "startedAt",
294
+ ],
295
+ ["finishedAt", "summary"],
296
+ "Routine run entry",
297
+ );
298
+ if (candidate.schemaVersion !== 1) {
299
+ throw new RoutineDecodeError(
300
+ "Routine run entry schemaVersion is unsupported",
301
+ );
302
+ }
303
+ const status = ROUTINE_RUN_STATUSES.find(
304
+ (known) => known === candidate.status,
305
+ );
306
+ if (!status) {
307
+ throw new RoutineDecodeError("Routine run entry status is invalid");
308
+ }
309
+ const trigger = ROUTINE_TRIGGER_KINDS.find(
310
+ (known) => known === candidate.trigger,
311
+ );
312
+ if (!trigger) {
313
+ throw new RoutineDecodeError("Routine run entry trigger is invalid");
314
+ }
315
+ if (!isRoutineIdV1(candidate.routineId)) {
316
+ throw new RoutineDecodeError("Routine run entry routineId is invalid");
317
+ }
318
+ return {
319
+ schemaVersion: 1,
320
+ entryId: routineText(candidate.entryId, 128, "Routine run entryId"),
321
+ routineId: candidate.routineId,
322
+ runId: routineText(candidate.runId, 256, "Routine run runId"),
323
+ fireId: routineText(candidate.fireId, 256, "Routine run fireId"),
324
+ trigger,
325
+ status,
326
+ startedAt: routineTimestamp(candidate.startedAt, "Routine run startedAt"),
327
+ ...(candidate.finishedAt === undefined
328
+ ? {}
329
+ : {
330
+ finishedAt: routineTimestamp(
331
+ candidate.finishedAt,
332
+ "Routine run finishedAt",
333
+ ),
334
+ }),
335
+ ...(candidate.summary === undefined
336
+ ? {}
337
+ : {
338
+ summary: routineText(candidate.summary, 2_000, "Routine run summary"),
339
+ }),
340
+ };
341
+ }