@frockbot/plugin-routines 0.0.0 → 0.1.1
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,202 @@
|
|
|
1
|
+
// The Bot Durable Object storage keys the Routines Package owns.
|
|
2
|
+
//
|
|
3
|
+
// "The Bot's Durable Object is the authority for everything Bot-scoped: …
|
|
4
|
+
// durable scheduling, Routines, Assignments". The keys live here rather than in
|
|
5
|
+
// `@frockbot/kernel-do` because the kernel imports no Package and holds no
|
|
6
|
+
// product policy; the Durable Object hands this Package a storage seam and this
|
|
7
|
+
// module decides what it writes under.
|
|
8
|
+
|
|
9
|
+
/** One `RoutineRecordV1`. */
|
|
10
|
+
export const ROUTINE_PREFIX = "routine:";
|
|
11
|
+
/** One `RoutineRunEntryV1`, newest first. */
|
|
12
|
+
export const ROUTINE_RUN_PREFIX = "routine-run:";
|
|
13
|
+
/** One durable command receipt, keyed by the command's idempotency key. */
|
|
14
|
+
export const ROUTINE_RECEIPT_PREFIX = "routine-receipt:";
|
|
15
|
+
/** One `RoutineScheduleStateV1`: when the Routine is next owed a firing. */
|
|
16
|
+
export const ROUTINE_SCHEDULE_PREFIX = "routine-schedule:";
|
|
17
|
+
/** The one unsettled `RoutineFireV1` of a Routine. This is the same-Routine lock. */
|
|
18
|
+
export const ROUTINE_FIRE_PREFIX = "routine-fire:";
|
|
19
|
+
/** Firings waiting behind the unsettled one, oldest first. */
|
|
20
|
+
export const ROUTINE_QUEUE_PREFIX = "routine-queue:";
|
|
21
|
+
/** One `RoutineHookKeyV1`: the authoritative digest of a Routine's webhook key. */
|
|
22
|
+
export const ROUTINE_KEY_PREFIX = "routine-key:";
|
|
23
|
+
/** One accepted delivery, so a replay answers with the firing it already made. */
|
|
24
|
+
export const ROUTINE_DELIVERY_PREFIX = "routine-delivery:";
|
|
25
|
+
|
|
26
|
+
/** Most run entries retained per Routine. Trimming loses index rows, never facts. */
|
|
27
|
+
export const ROUTINE_RUN_LOG_LIMIT = 50;
|
|
28
|
+
|
|
29
|
+
/** Most Routines one Bot may hold. */
|
|
30
|
+
export const ROUTINE_LIMIT_PER_BOT = 100;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Most firings that may wait behind a Routine's unsettled one. Beyond it a
|
|
34
|
+
* firing is refused and recorded as `skipped` rather than dropped silently:
|
|
35
|
+
* "Failures are observable through durable state".
|
|
36
|
+
*/
|
|
37
|
+
export const ROUTINE_QUEUE_LIMIT = 8;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* How late a scheduled firing may be before it coalesces. Past it the Routine
|
|
41
|
+
* fires once, records how many occurrences it covered, and recomputes from now;
|
|
42
|
+
* it never backfills.
|
|
43
|
+
*/
|
|
44
|
+
export const ROUTINE_MISSED_GRACE_MS = 5 * 60_000;
|
|
45
|
+
|
|
46
|
+
/** How long a deferral holds the alarm off a Routine while the object is busy. */
|
|
47
|
+
export const ROUTINE_DEFERRAL_MS = 15_000;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Run entries are keyed by a descending sequence so a prefix listing returns
|
|
51
|
+
* the newest first without reading the whole log.
|
|
52
|
+
*/
|
|
53
|
+
const RUN_SEQUENCE_CEILING = 1_000_000_000;
|
|
54
|
+
|
|
55
|
+
export function routineKeyV1(routineId: string): string {
|
|
56
|
+
return `${ROUTINE_PREFIX}${routineId}`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function routineRunPrefixV1(routineId: string): string {
|
|
60
|
+
return `${ROUTINE_RUN_PREFIX}${routineId}:`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function routineRunKeyV1(routineId: string, seq: number): string {
|
|
64
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || seq >= RUN_SEQUENCE_CEILING) {
|
|
65
|
+
throw new Error("Routine run sequence is out of range");
|
|
66
|
+
}
|
|
67
|
+
const descending = RUN_SEQUENCE_CEILING - seq;
|
|
68
|
+
return `${routineRunPrefixV1(routineId)}${String(descending).padStart(10, "0")}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function routineReceiptKeyV1(commandId: string): string {
|
|
72
|
+
return `${ROUTINE_RECEIPT_PREFIX}${commandId}`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The sequence the next run entry takes, given the keys already stored. Keys
|
|
77
|
+
* descend so the newest sorts first; the next entry is one past whichever
|
|
78
|
+
* sequence the newest key encodes, and trimming never reuses a sequence.
|
|
79
|
+
*/
|
|
80
|
+
export function nextRunSequenceV1(keys: readonly string[]): number {
|
|
81
|
+
let highest = -1;
|
|
82
|
+
for (const key of keys) {
|
|
83
|
+
const encoded = Number(key.slice(key.lastIndexOf(":") + 1));
|
|
84
|
+
if (!Number.isSafeInteger(encoded)) continue;
|
|
85
|
+
highest = Math.max(highest, RUN_SEQUENCE_CEILING - encoded);
|
|
86
|
+
}
|
|
87
|
+
return highest + 1;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function routineScheduleKeyV1(routineId: string): string {
|
|
91
|
+
return `${ROUTINE_SCHEDULE_PREFIX}${routineId}`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function routineFireKeyV1(routineId: string): string {
|
|
95
|
+
return `${ROUTINE_FIRE_PREFIX}${routineId}`;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function routineQueuePrefixV1(routineId: string): string {
|
|
99
|
+
return `${ROUTINE_QUEUE_PREFIX}${routineId}:`;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Queue keys ascend, so a prefix listing returns the oldest waiting firing
|
|
104
|
+
* first: a queue drains in the order the firings were owed.
|
|
105
|
+
*/
|
|
106
|
+
export function routineQueueKeyV1(routineId: string, seq: number): string {
|
|
107
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || seq >= RUN_SEQUENCE_CEILING) {
|
|
108
|
+
throw new Error("Routine queue sequence is out of range");
|
|
109
|
+
}
|
|
110
|
+
return `${routineQueuePrefixV1(routineId)}${String(seq).padStart(10, "0")}`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** The sequence the next queued firing takes, given the keys already stored. */
|
|
114
|
+
export function nextQueueSequenceV1(keys: readonly string[]): number {
|
|
115
|
+
let highest = -1;
|
|
116
|
+
for (const key of keys) {
|
|
117
|
+
const encoded = Number(key.slice(key.lastIndexOf(":") + 1));
|
|
118
|
+
if (Number.isSafeInteger(encoded)) highest = Math.max(highest, encoded);
|
|
119
|
+
}
|
|
120
|
+
return highest + 1;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function routineHookKeyRecordV1(routineId: string): string {
|
|
124
|
+
return `${ROUTINE_KEY_PREFIX}${routineId}`;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export function routineDeliveryKeyV1(deliveryId: string): string {
|
|
128
|
+
return `${ROUTINE_DELIVERY_PREFIX}${deliveryId}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** One `RoutineInboxEntryV1`, newest first. */
|
|
132
|
+
export const ROUTINE_INBOX_PREFIX = "routine-inbox:";
|
|
133
|
+
/** The inbox's monotonic sequence. Read by key, because the terminal seam cannot list. */
|
|
134
|
+
export const ROUTINE_INBOX_CURSOR_KEY = "routine-inbox-cursor";
|
|
135
|
+
/** One `PendingBotInputV1`, oldest first: a queue drains in the order it filled. */
|
|
136
|
+
export const ROUTINE_WAKE_PREFIX = "routine-wake:";
|
|
137
|
+
/** The pending-input queue's monotonic sequence. */
|
|
138
|
+
export const ROUTINE_WAKE_CURSOR_KEY = "routine-wake-cursor";
|
|
139
|
+
/** What one chat Turn drained, so a resumed Turn reproduces the same input. */
|
|
140
|
+
export const ROUTINE_DRAIN_PREFIX = "routine-drain:";
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Most completion-inbox entries retained. Past it the oldest are trimmed on the
|
|
144
|
+
* next read: an entry is a convenience index over runs that are still durable,
|
|
145
|
+
* so trimming loses a row and never a fact.
|
|
146
|
+
*/
|
|
147
|
+
export const ROUTINE_INBOX_LIMIT = 100;
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Most pending inputs the Bot's next conversational Turn may be owed. A Bot
|
|
151
|
+
* that has not been spoken to in sixteen firings is not helped by a
|
|
152
|
+
* seventeenth hand-off; the oldest is dropped, and its inbox entry — which is
|
|
153
|
+
* the durable record — stays.
|
|
154
|
+
*/
|
|
155
|
+
export const ROUTINE_PENDING_INPUT_LIMIT = 16;
|
|
156
|
+
|
|
157
|
+
/** Most drain receipts retained; one is only needed while its Turn is running. */
|
|
158
|
+
export const ROUTINE_DRAIN_RECEIPT_LIMIT = 16;
|
|
159
|
+
|
|
160
|
+
export function routineInboxKeyV1(seq: number): string {
|
|
161
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || seq >= RUN_SEQUENCE_CEILING) {
|
|
162
|
+
throw new Error("Routine inbox sequence is out of range");
|
|
163
|
+
}
|
|
164
|
+
const descending = RUN_SEQUENCE_CEILING - seq;
|
|
165
|
+
return `${ROUTINE_INBOX_PREFIX}${String(descending).padStart(10, "0")}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export function routineWakeKeyV1(seq: number): string {
|
|
169
|
+
if (!Number.isSafeInteger(seq) || seq < 0 || seq >= RUN_SEQUENCE_CEILING) {
|
|
170
|
+
throw new Error("Routine wake sequence is out of range");
|
|
171
|
+
}
|
|
172
|
+
return `${ROUTINE_WAKE_PREFIX}${String(seq).padStart(10, "0")}`;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function routineDrainKeyV1(runId: string): string {
|
|
176
|
+
return `${ROUTINE_DRAIN_PREFIX}${runId}`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* The sequence a cursor record holds. The cursor exists because the terminal
|
|
181
|
+
* seam is handed a reader and not a lister: a Package record written in the
|
|
182
|
+
* settling transaction must be addressable by key alone.
|
|
183
|
+
*/
|
|
184
|
+
export interface RoutineSequenceCursorV1 {
|
|
185
|
+
schemaVersion: 1;
|
|
186
|
+
nextSeq: number;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export function routineSequenceCursorV1(
|
|
190
|
+
value: unknown,
|
|
191
|
+
): RoutineSequenceCursorV1 {
|
|
192
|
+
if (
|
|
193
|
+
!value ||
|
|
194
|
+
typeof value !== "object" ||
|
|
195
|
+
(value as { schemaVersion?: unknown }).schemaVersion !== 1 ||
|
|
196
|
+
!Number.isSafeInteger((value as { nextSeq?: unknown }).nextSeq) ||
|
|
197
|
+
(value as { nextSeq: number }).nextSeq < 0
|
|
198
|
+
) {
|
|
199
|
+
return { schemaVersion: 1, nextSeq: 0 };
|
|
200
|
+
}
|
|
201
|
+
return { schemaVersion: 1, nextSeq: (value as { nextSeq: number }).nextSeq };
|
|
202
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { RoutineStore, RoutineNotFoundError } from "./store.js";
|
|
3
|
+
import { createMemoryRoutineStorageV1 } from "./testing.js";
|
|
4
|
+
import { ROUTINE_RUN_LOG_LIMIT } from "./storage-keys.js";
|
|
5
|
+
import type { RoutineCommandV1 } from "./shared.js";
|
|
6
|
+
import type { RoutineRunEntryV1, RoutineWriterV1 } from "./records.js";
|
|
7
|
+
|
|
8
|
+
const USER: RoutineWriterV1 = { kind: "user" };
|
|
9
|
+
const BOT: RoutineWriterV1 = {
|
|
10
|
+
kind: "bot",
|
|
11
|
+
botId: "scout",
|
|
12
|
+
sessionId: "tim:scout",
|
|
13
|
+
turnId: "turn-7",
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function store(): RoutineStore {
|
|
17
|
+
return new RoutineStore(createMemoryRoutineStorageV1(), {
|
|
18
|
+
defaultTimezone: "Australia/Sydney",
|
|
19
|
+
});
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function create(
|
|
23
|
+
overrides: Partial<
|
|
24
|
+
Extract<RoutineCommandV1, { type: "routine/create" }>
|
|
25
|
+
> = {},
|
|
26
|
+
): RoutineCommandV1 {
|
|
27
|
+
return {
|
|
28
|
+
schemaVersion: 1,
|
|
29
|
+
type: "routine/create",
|
|
30
|
+
commandId: "cmd-1",
|
|
31
|
+
botId: "scout",
|
|
32
|
+
routineId: "brief",
|
|
33
|
+
name: "Morning brief",
|
|
34
|
+
prompt: "Summarize overnight email.",
|
|
35
|
+
schedule: "0 7 * * *",
|
|
36
|
+
...overrides,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
describe("RoutineStore.execute", () => {
|
|
41
|
+
test("creates a Routine, records its writer, and lists it", async () => {
|
|
42
|
+
const routines = store();
|
|
43
|
+
const receipt = await routines.execute(create(), USER);
|
|
44
|
+
expect(receipt).toMatchObject({ status: "applied" });
|
|
45
|
+
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
46
|
+
expect(receipt.routine).toMatchObject({
|
|
47
|
+
routineId: "brief",
|
|
48
|
+
enabled: true,
|
|
49
|
+
timezone: "Australia/Sydney",
|
|
50
|
+
createdBy: { kind: "user" },
|
|
51
|
+
updatedBy: { kind: "user" },
|
|
52
|
+
});
|
|
53
|
+
const listed = await routines.list("scout");
|
|
54
|
+
expect(listed.routines).toHaveLength(1);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("records a Bot writer as the Bot, and the view names no Session", async () => {
|
|
58
|
+
const routines = store();
|
|
59
|
+
const receipt = await routines.execute(create(), BOT);
|
|
60
|
+
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
61
|
+
expect(receipt.routine.createdBy).toEqual({ kind: "bot", botId: "scout" });
|
|
62
|
+
const stored = await routines.read("brief");
|
|
63
|
+
expect(stored?.createdBy).toEqual(BOT);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
test("replays one command id and refuses a reused id with new bytes", async () => {
|
|
67
|
+
const routines = store();
|
|
68
|
+
const first = await routines.execute(create(), USER);
|
|
69
|
+
const replay = await routines.execute(create(), USER);
|
|
70
|
+
expect(replay).toEqual(first);
|
|
71
|
+
expect((await routines.list("scout")).routines).toHaveLength(1);
|
|
72
|
+
await expect(
|
|
73
|
+
routines.execute(create({ name: "Something else" }), USER),
|
|
74
|
+
).rejects.toThrow(/was reused for a different command/);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("updates only the fields the command carries", async () => {
|
|
78
|
+
const routines = store();
|
|
79
|
+
await routines.execute(create(), USER);
|
|
80
|
+
const receipt = await routines.execute(
|
|
81
|
+
{
|
|
82
|
+
schemaVersion: 1,
|
|
83
|
+
type: "routine/update",
|
|
84
|
+
commandId: "cmd-2",
|
|
85
|
+
botId: "scout",
|
|
86
|
+
routineId: "brief",
|
|
87
|
+
prompt: "Summarize overnight email and calendar.",
|
|
88
|
+
},
|
|
89
|
+
BOT,
|
|
90
|
+
);
|
|
91
|
+
if (receipt.status !== "applied") throw new Error("unreachable");
|
|
92
|
+
expect(receipt.routine).toMatchObject({
|
|
93
|
+
name: "Morning brief",
|
|
94
|
+
prompt: "Summarize overnight email and calendar.",
|
|
95
|
+
schedule: "0 7 * * *",
|
|
96
|
+
updatedBy: { kind: "bot", botId: "scout" },
|
|
97
|
+
createdBy: { kind: "user" },
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("naming a trigger on an update clears the schedule, and the reverse", async () => {
|
|
102
|
+
const routines = store();
|
|
103
|
+
await routines.execute(create(), USER);
|
|
104
|
+
const toWebhook = await routines.execute(
|
|
105
|
+
{
|
|
106
|
+
schemaVersion: 1,
|
|
107
|
+
type: "routine/update",
|
|
108
|
+
commandId: "cmd-2",
|
|
109
|
+
botId: "scout",
|
|
110
|
+
routineId: "brief",
|
|
111
|
+
trigger: { kind: "webhook" },
|
|
112
|
+
},
|
|
113
|
+
USER,
|
|
114
|
+
);
|
|
115
|
+
if (toWebhook.status !== "applied") throw new Error("unreachable");
|
|
116
|
+
expect(toWebhook.routine.schedule).toBeUndefined();
|
|
117
|
+
expect(toWebhook.routine.trigger).toEqual({ kind: "webhook" });
|
|
118
|
+
|
|
119
|
+
const back = await routines.execute(
|
|
120
|
+
{
|
|
121
|
+
schemaVersion: 1,
|
|
122
|
+
type: "routine/update",
|
|
123
|
+
commandId: "cmd-3",
|
|
124
|
+
botId: "scout",
|
|
125
|
+
routineId: "brief",
|
|
126
|
+
schedule: "@daily",
|
|
127
|
+
},
|
|
128
|
+
USER,
|
|
129
|
+
);
|
|
130
|
+
if (back.status !== "applied") throw new Error("unreachable");
|
|
131
|
+
expect(back.routine.trigger).toBeUndefined();
|
|
132
|
+
expect(back.routine.schedule).toBe("@daily");
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("pause and resume move only `enabled`", async () => {
|
|
136
|
+
const routines = store();
|
|
137
|
+
await routines.execute(create(), USER);
|
|
138
|
+
const paused = await routines.execute(
|
|
139
|
+
{
|
|
140
|
+
schemaVersion: 1,
|
|
141
|
+
type: "routine/pause",
|
|
142
|
+
commandId: "cmd-2",
|
|
143
|
+
botId: "scout",
|
|
144
|
+
routineId: "brief",
|
|
145
|
+
},
|
|
146
|
+
USER,
|
|
147
|
+
);
|
|
148
|
+
if (paused.status !== "applied") throw new Error("unreachable");
|
|
149
|
+
expect(paused.routine.enabled).toBe(false);
|
|
150
|
+
const resumed = await routines.execute(
|
|
151
|
+
{
|
|
152
|
+
schemaVersion: 1,
|
|
153
|
+
type: "routine/resume",
|
|
154
|
+
commandId: "cmd-3",
|
|
155
|
+
botId: "scout",
|
|
156
|
+
routineId: "brief",
|
|
157
|
+
},
|
|
158
|
+
USER,
|
|
159
|
+
);
|
|
160
|
+
if (resumed.status !== "applied") throw new Error("unreachable");
|
|
161
|
+
expect(resumed.routine.enabled).toBe(true);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("delete removes the record and its run log", async () => {
|
|
165
|
+
const routines = store();
|
|
166
|
+
await routines.execute(create(), USER);
|
|
167
|
+
await routines.recordRun(runEntry(1));
|
|
168
|
+
const receipt = await routines.execute(
|
|
169
|
+
{
|
|
170
|
+
schemaVersion: 1,
|
|
171
|
+
type: "routine/delete",
|
|
172
|
+
commandId: "cmd-2",
|
|
173
|
+
botId: "scout",
|
|
174
|
+
routineId: "brief",
|
|
175
|
+
},
|
|
176
|
+
USER,
|
|
177
|
+
);
|
|
178
|
+
expect(receipt).toMatchObject({ status: "deleted", routineId: "brief" });
|
|
179
|
+
expect((await routines.list("scout")).routines).toHaveLength(0);
|
|
180
|
+
await expect(routines.listRuns("scout", "brief")).rejects.toThrow(
|
|
181
|
+
RoutineNotFoundError,
|
|
182
|
+
);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
test("refuses a write whose cron or time zone cannot be parsed", async () => {
|
|
186
|
+
const routines = store();
|
|
187
|
+
await expect(
|
|
188
|
+
routines.execute(create({ schedule: "not a cron" }), USER),
|
|
189
|
+
).rejects.toThrow(/five fields/);
|
|
190
|
+
await expect(
|
|
191
|
+
routines.execute(
|
|
192
|
+
create({ commandId: "cmd-tz", timezone: "Mars/Olympus" }),
|
|
193
|
+
USER,
|
|
194
|
+
),
|
|
195
|
+
).rejects.toThrow(/not an IANA time zone/);
|
|
196
|
+
expect((await routines.list("scout")).routines).toHaveLength(0);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test("acting on an unknown Routine is not found", async () => {
|
|
200
|
+
const routines = store();
|
|
201
|
+
await expect(
|
|
202
|
+
routines.execute(
|
|
203
|
+
{
|
|
204
|
+
schemaVersion: 1,
|
|
205
|
+
type: "routine/pause",
|
|
206
|
+
commandId: "cmd-2",
|
|
207
|
+
botId: "scout",
|
|
208
|
+
routineId: "missing",
|
|
209
|
+
},
|
|
210
|
+
USER,
|
|
211
|
+
),
|
|
212
|
+
).rejects.toThrow(RoutineNotFoundError);
|
|
213
|
+
});
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
function runEntry(seq: number): RoutineRunEntryV1 {
|
|
217
|
+
return {
|
|
218
|
+
schemaVersion: 1,
|
|
219
|
+
entryId: `entry-${seq}`,
|
|
220
|
+
routineId: "brief",
|
|
221
|
+
runId: `fire-${seq}`,
|
|
222
|
+
fireId: `fire-${seq}`,
|
|
223
|
+
trigger: "cron",
|
|
224
|
+
status: "ok",
|
|
225
|
+
startedAt: new Date(Date.UTC(2026, 0, 1, 0, seq)).toISOString(),
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
describe("RoutineStore run log", () => {
|
|
230
|
+
test("is empty until something fires", async () => {
|
|
231
|
+
const routines = store();
|
|
232
|
+
await routines.execute(create(), USER);
|
|
233
|
+
expect((await routines.listRuns("scout", "brief")).entries).toEqual([]);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
test("keeps the newest entries first and trims to its bound", async () => {
|
|
237
|
+
const routines = store();
|
|
238
|
+
await routines.execute(create(), USER);
|
|
239
|
+
for (let seq = 1; seq <= ROUTINE_RUN_LOG_LIMIT + 10; seq += 1) {
|
|
240
|
+
await routines.recordRun(runEntry(seq));
|
|
241
|
+
}
|
|
242
|
+
const log = await routines.listRuns("scout", "brief");
|
|
243
|
+
expect(log.entries).toHaveLength(ROUTINE_RUN_LOG_LIMIT);
|
|
244
|
+
expect(log.entries[0]?.entryId).toBe(`entry-${ROUTINE_RUN_LOG_LIMIT + 10}`);
|
|
245
|
+
expect(log.entries.at(-1)?.entryId).toBe("entry-11");
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("settling a firing rewrites its entry rather than appending a second", async () => {
|
|
249
|
+
const routines = store();
|
|
250
|
+
await routines.execute(create(), USER);
|
|
251
|
+
await routines.recordRun({ ...runEntry(1), status: "running" });
|
|
252
|
+
await routines.recordRun({
|
|
253
|
+
...runEntry(1),
|
|
254
|
+
status: "failed",
|
|
255
|
+
finishedAt: "2026-01-01T00:05:00.000Z",
|
|
256
|
+
});
|
|
257
|
+
const log = await routines.listRuns("scout", "brief");
|
|
258
|
+
expect(log.entries).toHaveLength(1);
|
|
259
|
+
expect(log.entries[0]).toMatchObject({ status: "failed" });
|
|
260
|
+
});
|
|
261
|
+
});
|