@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/cron.test.ts
ADDED
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import {
|
|
3
|
+
isRoutineTimezoneV1,
|
|
4
|
+
missedRoutineRunsV1,
|
|
5
|
+
nextRoutineRunV1,
|
|
6
|
+
normalizeRoutineScheduleV1,
|
|
7
|
+
RoutineScheduleError,
|
|
8
|
+
} from "./cron.js";
|
|
9
|
+
|
|
10
|
+
describe("normalizeRoutineScheduleV1", () => {
|
|
11
|
+
test("accepts a five-field cron expression in the record's zone", () => {
|
|
12
|
+
expect(
|
|
13
|
+
normalizeRoutineScheduleV1("0 9 * * 1-5", "Australia/Sydney"),
|
|
14
|
+
).toEqual({
|
|
15
|
+
kind: "cron",
|
|
16
|
+
pattern: "0 9 * * 1-5",
|
|
17
|
+
timezone: "Australia/Sydney",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
test("expands every shorthand alias to five fields", () => {
|
|
22
|
+
const cases: Array<[string, string]> = [
|
|
23
|
+
["@hourly", "0 * * * *"],
|
|
24
|
+
["@daily", "0 0 * * *"],
|
|
25
|
+
["@weekly", "0 0 * * 0"],
|
|
26
|
+
["@monthly", "0 0 1 * *"],
|
|
27
|
+
];
|
|
28
|
+
for (const [alias, pattern] of cases) {
|
|
29
|
+
expect(normalizeRoutineScheduleV1(alias, "UTC")).toEqual({
|
|
30
|
+
kind: "cron",
|
|
31
|
+
pattern,
|
|
32
|
+
timezone: "UTC",
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test("reads a CRON_TZ= prefix as the schedule's own zone", () => {
|
|
38
|
+
expect(
|
|
39
|
+
normalizeRoutineScheduleV1("CRON_TZ=Europe/Berlin 30 6 * * *", "UTC"),
|
|
40
|
+
).toEqual({
|
|
41
|
+
kind: "cron",
|
|
42
|
+
pattern: "30 6 * * *",
|
|
43
|
+
timezone: "Europe/Berlin",
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("parses @every as a fixed interval", () => {
|
|
48
|
+
expect(normalizeRoutineScheduleV1("@every 15m", "UTC")).toEqual({
|
|
49
|
+
kind: "interval",
|
|
50
|
+
intervalMs: 900_000,
|
|
51
|
+
timezone: "UTC",
|
|
52
|
+
});
|
|
53
|
+
expect(
|
|
54
|
+
(
|
|
55
|
+
normalizeRoutineScheduleV1("@every 2h30m", "UTC") as {
|
|
56
|
+
intervalMs: number;
|
|
57
|
+
}
|
|
58
|
+
).intervalMs,
|
|
59
|
+
).toBe(9_000_000);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test("refuses a malformed cron expression", () => {
|
|
63
|
+
for (const bad of ["not a cron", "0 9 * *", "99 * * * *", "0 9 * * * *"]) {
|
|
64
|
+
expect(() => normalizeRoutineScheduleV1(bad, "UTC")).toThrow(
|
|
65
|
+
RoutineScheduleError,
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("refuses an unknown alias and an unusable @every", () => {
|
|
71
|
+
expect(() => normalizeRoutineScheduleV1("@fortnightly", "UTC")).toThrow(
|
|
72
|
+
/not a known schedule alias/,
|
|
73
|
+
);
|
|
74
|
+
expect(() => normalizeRoutineScheduleV1("@every", "UTC")).toThrow(
|
|
75
|
+
/needs a duration/,
|
|
76
|
+
);
|
|
77
|
+
expect(() => normalizeRoutineScheduleV1("@every 5s", "UTC")).toThrow(
|
|
78
|
+
/at least one minute/,
|
|
79
|
+
);
|
|
80
|
+
expect(() => normalizeRoutineScheduleV1("@every 400d", "UTC")).toThrow(
|
|
81
|
+
/at most one year/,
|
|
82
|
+
);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("refuses a time zone this runtime cannot format in", () => {
|
|
86
|
+
expect(() => normalizeRoutineScheduleV1("@daily", "Mars/Olympus")).toThrow(
|
|
87
|
+
/not an IANA time zone/,
|
|
88
|
+
);
|
|
89
|
+
expect(() =>
|
|
90
|
+
normalizeRoutineScheduleV1("CRON_TZ=Mars/Olympus @daily", "UTC"),
|
|
91
|
+
).toThrow(/CRON_TZ/);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("refuses an empty or oversized schedule", () => {
|
|
95
|
+
expect(() => normalizeRoutineScheduleV1(" ", "UTC")).toThrow(
|
|
96
|
+
/must not be empty/,
|
|
97
|
+
);
|
|
98
|
+
expect(() => normalizeRoutineScheduleV1("0 ".repeat(200), "UTC")).toThrow(
|
|
99
|
+
/at most 256 characters/,
|
|
100
|
+
);
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe("isRoutineTimezoneV1", () => {
|
|
105
|
+
test("accepts IANA names and refuses anything else", () => {
|
|
106
|
+
expect(isRoutineTimezoneV1("Australia/Sydney")).toBe(true);
|
|
107
|
+
expect(isRoutineTimezoneV1("UTC")).toBe(true);
|
|
108
|
+
expect(isRoutineTimezoneV1("")).toBe(false);
|
|
109
|
+
expect(isRoutineTimezoneV1("Not/AZone")).toBe(false);
|
|
110
|
+
expect(isRoutineTimezoneV1(7)).toBe(false);
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe("nextRoutineRunV1", () => {
|
|
115
|
+
const anchor = new Date("2026-01-01T00:00:00.000Z");
|
|
116
|
+
|
|
117
|
+
function next(schedule: string, timezone: string, from: string): string {
|
|
118
|
+
const run = nextRoutineRunV1(
|
|
119
|
+
normalizeRoutineScheduleV1(schedule, timezone),
|
|
120
|
+
new Date(from),
|
|
121
|
+
anchor,
|
|
122
|
+
);
|
|
123
|
+
if (!run) throw new Error("expected a next run");
|
|
124
|
+
return run.toISOString();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
test("keeps a wall-clock time across the end of Sydney daylight saving", () => {
|
|
128
|
+
// Sydney leaves AEDT (UTC+11) for AEST (UTC+10) at 03:00 on 5 April 2026.
|
|
129
|
+
// 09:00 local is 22:00Z the day before while AEDT holds, and 23:00Z after.
|
|
130
|
+
expect(next("0 9 * * *", "Australia/Sydney", "2026-04-02T12:00:00Z")).toBe(
|
|
131
|
+
"2026-04-02T22:00:00.000Z",
|
|
132
|
+
);
|
|
133
|
+
expect(next("0 9 * * *", "Australia/Sydney", "2026-04-04T12:00:00Z")).toBe(
|
|
134
|
+
"2026-04-04T23:00:00.000Z",
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
test("keeps a wall-clock time across the start of New York daylight saving", () => {
|
|
139
|
+
// New York enters EDT at 02:00 on 8 March 2026: 09:00 local moves from
|
|
140
|
+
// 14:00Z to 13:00Z, and the schedule does not drift with it.
|
|
141
|
+
expect(next("0 9 * * *", "America/New_York", "2026-03-06T20:00:00Z")).toBe(
|
|
142
|
+
"2026-03-07T14:00:00.000Z",
|
|
143
|
+
);
|
|
144
|
+
expect(next("0 9 * * *", "America/New_York", "2026-03-08T00:00:00Z")).toBe(
|
|
145
|
+
"2026-03-08T13:00:00.000Z",
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("reads CRON_TZ= as the zone the pattern is evaluated in", () => {
|
|
150
|
+
// The record says UTC; the schedule the user typed overrides it. At
|
|
151
|
+
// 00:00Z it is already 10:00 in Sydney, so the next 09:00 there is the
|
|
152
|
+
// following morning — 23:00Z, not 09:00Z.
|
|
153
|
+
expect(
|
|
154
|
+
next("CRON_TZ=Australia/Sydney 0 9 * * *", "UTC", "2026-06-01T00:00:00Z"),
|
|
155
|
+
).toBe("2026-06-01T23:00:00.000Z");
|
|
156
|
+
expect(next("0 9 * * *", "UTC", "2026-06-01T00:00:00Z")).toBe(
|
|
157
|
+
"2026-06-01T09:00:00.000Z",
|
|
158
|
+
);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("fires @daily at local midnight", () => {
|
|
162
|
+
expect(next("@daily", "Australia/Sydney", "2026-06-01T00:00:00Z")).toBe(
|
|
163
|
+
"2026-06-01T14:00:00.000Z",
|
|
164
|
+
);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
test("counts @every 5m forward from its anchor, not from the asking time", () => {
|
|
168
|
+
const normalized = normalizeRoutineScheduleV1("@every 5m", "UTC");
|
|
169
|
+
expect(
|
|
170
|
+
nextRoutineRunV1(
|
|
171
|
+
normalized,
|
|
172
|
+
new Date("2026-01-01T00:07:30.000Z"),
|
|
173
|
+
anchor,
|
|
174
|
+
)?.toISOString(),
|
|
175
|
+
).toBe("2026-01-01T00:10:00.000Z");
|
|
176
|
+
expect(nextRoutineRunV1(normalized, anchor, anchor)?.toISOString()).toBe(
|
|
177
|
+
"2026-01-01T00:05:00.000Z",
|
|
178
|
+
);
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
describe("missedRoutineRunsV1", () => {
|
|
183
|
+
const anchor = new Date("2026-01-01T00:00:00.000Z");
|
|
184
|
+
|
|
185
|
+
test("counts the elapsed occurrences of a cron, the one firing included", () => {
|
|
186
|
+
expect(
|
|
187
|
+
missedRoutineRunsV1(
|
|
188
|
+
normalizeRoutineScheduleV1("0 * * * *", "UTC"),
|
|
189
|
+
new Date("2026-01-01T01:00:00Z"),
|
|
190
|
+
new Date("2026-01-01T04:30:00Z"),
|
|
191
|
+
anchor,
|
|
192
|
+
),
|
|
193
|
+
).toBe(4);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("counts an interval schedule arithmetically", () => {
|
|
197
|
+
expect(
|
|
198
|
+
missedRoutineRunsV1(
|
|
199
|
+
normalizeRoutineScheduleV1("@every 5m", "UTC"),
|
|
200
|
+
new Date("2026-01-01T00:05:00Z"),
|
|
201
|
+
new Date("2026-01-01T00:32:00Z"),
|
|
202
|
+
anchor,
|
|
203
|
+
),
|
|
204
|
+
).toBe(6);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test("counts nothing when the occurrence has not arrived", () => {
|
|
208
|
+
expect(
|
|
209
|
+
missedRoutineRunsV1(
|
|
210
|
+
normalizeRoutineScheduleV1("@daily", "UTC"),
|
|
211
|
+
new Date("2026-01-02T00:00:00Z"),
|
|
212
|
+
new Date("2026-01-01T00:00:00Z"),
|
|
213
|
+
anchor,
|
|
214
|
+
),
|
|
215
|
+
).toBe(0);
|
|
216
|
+
});
|
|
217
|
+
});
|
package/src/cron.ts
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
// Schedules: the one place a Routine's `schedule` string is understood — both
|
|
2
|
+
// the syntax a write must pass and the next firing the scheduler arms an alarm
|
|
3
|
+
// on.
|
|
4
|
+
//
|
|
5
|
+
// "Timezone is validated
|
|
6
|
+
// with `Intl.DateTimeFormat` at *write* time so a bad TZ is a rejected command,
|
|
7
|
+
// not a dead alarm" — the same rule applies to the pattern itself: a Routine
|
|
8
|
+
// whose schedule cannot be parsed is never written, so the scheduler can assume
|
|
9
|
+
// every stored schedule is parseable.
|
|
10
|
+
//
|
|
11
|
+
// `croner` owns 5-field cron and IANA timezones. It does not understand
|
|
12
|
+
// GrokBot's `CRON_TZ=` prefix, its `@shorthand` aliases, or `@every <duration>`,
|
|
13
|
+
// so this module owns exactly that normalization and hands the rest over.
|
|
14
|
+
import { Cron } from "croner";
|
|
15
|
+
|
|
16
|
+
/** Longest schedule string a command may carry. */
|
|
17
|
+
export const ROUTINE_SCHEDULE_MAX_LENGTH = 256;
|
|
18
|
+
|
|
19
|
+
/** The shortest `@every` interval a Routine may ask for. */
|
|
20
|
+
export const ROUTINE_MIN_INTERVAL_MS = 60_000;
|
|
21
|
+
/** The longest `@every` interval a Routine may ask for: one year. */
|
|
22
|
+
export const ROUTINE_MAX_INTERVAL_MS = 366 * 24 * 60 * 60 * 1000;
|
|
23
|
+
|
|
24
|
+
/** A schedule after normalization: either a cron pattern or a fixed interval. */
|
|
25
|
+
export type NormalizedScheduleV1 =
|
|
26
|
+
| { kind: "cron"; pattern: string; timezone: string }
|
|
27
|
+
| { kind: "interval"; intervalMs: number; timezone: string };
|
|
28
|
+
|
|
29
|
+
export class RoutineScheduleError extends Error {
|
|
30
|
+
override readonly name = "RoutineScheduleError";
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const CRON_SHORTHANDS: Record<string, string> = {
|
|
34
|
+
"@yearly": "0 0 1 1 *",
|
|
35
|
+
"@annually": "0 0 1 1 *",
|
|
36
|
+
"@monthly": "0 0 1 * *",
|
|
37
|
+
"@weekly": "0 0 * * 0",
|
|
38
|
+
"@daily": "0 0 * * *",
|
|
39
|
+
"@midnight": "0 0 * * *",
|
|
40
|
+
"@hourly": "0 * * * *",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const DURATION_UNITS_MS: Record<string, number> = {
|
|
44
|
+
s: 1_000,
|
|
45
|
+
m: 60_000,
|
|
46
|
+
h: 3_600_000,
|
|
47
|
+
d: 86_400_000,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* True when the IANA name is one this runtime can actually format in. An
|
|
52
|
+
* unknown zone throws `RangeError` here rather than silently resolving to UTC
|
|
53
|
+
* later, which is the whole reason the check happens at write time.
|
|
54
|
+
*/
|
|
55
|
+
export function isRoutineTimezoneV1(value: unknown): value is string {
|
|
56
|
+
if (typeof value !== "string" || value.length === 0 || value.length > 64) {
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
try {
|
|
60
|
+
new Intl.DateTimeFormat("en-US", { timeZone: value });
|
|
61
|
+
return true;
|
|
62
|
+
} catch {
|
|
63
|
+
return false;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `@every 15m`, `@every 2h30m`, `@every 90s`. */
|
|
68
|
+
function parseEveryDuration(rest: string): number {
|
|
69
|
+
const trimmed = rest.trim();
|
|
70
|
+
if (trimmed.length === 0) {
|
|
71
|
+
throw new RoutineScheduleError(
|
|
72
|
+
"@every needs a duration, such as @every 15m",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
const parts = trimmed.match(/\d+[smhd]/g);
|
|
76
|
+
if (!parts || parts.join("") !== trimmed.replace(/\s+/g, "")) {
|
|
77
|
+
throw new RoutineScheduleError(
|
|
78
|
+
`"@every ${rest.trim()}" is not a duration; use units s, m, h or d, such as @every 15m`,
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
let total = 0;
|
|
82
|
+
for (const part of parts) {
|
|
83
|
+
const unit = part.slice(-1);
|
|
84
|
+
total += Number(part.slice(0, -1)) * DURATION_UNITS_MS[unit]!;
|
|
85
|
+
}
|
|
86
|
+
if (total < ROUTINE_MIN_INTERVAL_MS) {
|
|
87
|
+
throw new RoutineScheduleError("@every must be at least one minute apart");
|
|
88
|
+
}
|
|
89
|
+
if (total > ROUTINE_MAX_INTERVAL_MS) {
|
|
90
|
+
throw new RoutineScheduleError("@every must be at most one year apart");
|
|
91
|
+
}
|
|
92
|
+
return total;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Normalize and validate a schedule string against the Routine's timezone.
|
|
97
|
+
*
|
|
98
|
+
* A `CRON_TZ=` prefix wins over the record's `timezone`, matching GrokBot,
|
|
99
|
+
* because it is written into the schedule the user typed. Both are validated;
|
|
100
|
+
* neither is guessed.
|
|
101
|
+
*/
|
|
102
|
+
export function normalizeRoutineScheduleV1(
|
|
103
|
+
schedule: string,
|
|
104
|
+
timezone: string,
|
|
105
|
+
): NormalizedScheduleV1 {
|
|
106
|
+
if (typeof schedule !== "string") {
|
|
107
|
+
throw new RoutineScheduleError("schedule must be a string");
|
|
108
|
+
}
|
|
109
|
+
const raw = schedule.trim();
|
|
110
|
+
if (raw.length === 0) {
|
|
111
|
+
throw new RoutineScheduleError("schedule must not be empty");
|
|
112
|
+
}
|
|
113
|
+
if (raw.length > ROUTINE_SCHEDULE_MAX_LENGTH) {
|
|
114
|
+
throw new RoutineScheduleError(
|
|
115
|
+
`schedule must be at most ${ROUTINE_SCHEDULE_MAX_LENGTH} characters`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!isRoutineTimezoneV1(timezone)) {
|
|
119
|
+
throw new RoutineScheduleError(
|
|
120
|
+
`timezone "${timezone}" is not an IANA time zone`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
let body = raw;
|
|
124
|
+
let zone = timezone;
|
|
125
|
+
const prefix = /^CRON_TZ=(\S+)\s+(.*)$/.exec(body);
|
|
126
|
+
if (prefix) {
|
|
127
|
+
const declared = prefix[1]!;
|
|
128
|
+
if (!isRoutineTimezoneV1(declared)) {
|
|
129
|
+
throw new RoutineScheduleError(
|
|
130
|
+
`CRON_TZ="${declared}" is not an IANA time zone`,
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
zone = declared;
|
|
134
|
+
body = prefix[2]!.trim();
|
|
135
|
+
}
|
|
136
|
+
if (body.length === 0) {
|
|
137
|
+
throw new RoutineScheduleError("schedule must not be empty");
|
|
138
|
+
}
|
|
139
|
+
if (body.startsWith("@every")) {
|
|
140
|
+
const rest = body.slice("@every".length);
|
|
141
|
+
if (rest.length > 0 && !/^\s/.test(rest)) {
|
|
142
|
+
throw new RoutineScheduleError(`"${body}" is not a known schedule alias`);
|
|
143
|
+
}
|
|
144
|
+
return {
|
|
145
|
+
kind: "interval",
|
|
146
|
+
intervalMs: parseEveryDuration(rest),
|
|
147
|
+
timezone: zone,
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
if (body.startsWith("@")) {
|
|
151
|
+
const expanded = CRON_SHORTHANDS[body.toLowerCase()];
|
|
152
|
+
if (!expanded) {
|
|
153
|
+
throw new RoutineScheduleError(`"${body}" is not a known schedule alias`);
|
|
154
|
+
}
|
|
155
|
+
body = expanded;
|
|
156
|
+
}
|
|
157
|
+
const fields = body.split(/\s+/);
|
|
158
|
+
if (fields.length !== 5) {
|
|
159
|
+
throw new RoutineScheduleError(
|
|
160
|
+
`cron expression "${body}" must have five fields (minute hour day month weekday)`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
try {
|
|
164
|
+
// Constructing without a callback parses and validates; it schedules
|
|
165
|
+
// nothing. `stop` is called so no timer survives the check.
|
|
166
|
+
const parsed = new Cron(body, { timezone: zone, paused: true });
|
|
167
|
+
parsed.stop();
|
|
168
|
+
} catch (error) {
|
|
169
|
+
throw new RoutineScheduleError(
|
|
170
|
+
`cron expression "${body}" is invalid: ${
|
|
171
|
+
error instanceof Error ? error.message : String(error)
|
|
172
|
+
}`,
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
return { kind: "cron", pattern: body, timezone: zone };
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A one-line summary of a schedule for a list row. */
|
|
179
|
+
export function describeRoutineScheduleV1(schedule: string): string {
|
|
180
|
+
return schedule.trim();
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* The first firing strictly after `from`, or `undefined` when the schedule has
|
|
185
|
+
* no further occurrence. D1 only parsed; this is the evaluation the scheduler
|
|
186
|
+
* rests on.
|
|
187
|
+
*
|
|
188
|
+
* An interval schedule (`@every 15m`) has no calendar to consult, so it counts
|
|
189
|
+
* forward from its own anchor: the moment the Routine's timing was last
|
|
190
|
+
* written. That keeps `@every 15m` fifteen minutes apart across a firing, an
|
|
191
|
+
* eviction and a redeploy, instead of drifting to whenever the object happened
|
|
192
|
+
* to wake.
|
|
193
|
+
*/
|
|
194
|
+
export function nextRoutineRunV1(
|
|
195
|
+
normalized: NormalizedScheduleV1,
|
|
196
|
+
from: Date,
|
|
197
|
+
anchor: Date,
|
|
198
|
+
): Date | undefined {
|
|
199
|
+
if (normalized.kind === "interval") {
|
|
200
|
+
const elapsed = from.getTime() - anchor.getTime();
|
|
201
|
+
const periods =
|
|
202
|
+
elapsed < 0 ? 0 : Math.floor(elapsed / normalized.intervalMs) + 1;
|
|
203
|
+
return new Date(anchor.getTime() + periods * normalized.intervalMs);
|
|
204
|
+
}
|
|
205
|
+
const cron = new Cron(normalized.pattern, {
|
|
206
|
+
timezone: normalized.timezone,
|
|
207
|
+
paused: true,
|
|
208
|
+
});
|
|
209
|
+
try {
|
|
210
|
+
return cron.nextRun(from) ?? undefined;
|
|
211
|
+
} finally {
|
|
212
|
+
cron.stop();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Most missed occurrences one coalescing report counts before it gives up. */
|
|
217
|
+
export const ROUTINE_MISSED_COUNT_CAP = 1_000;
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* How many firings a Routine slept through: occurrences in `(due, now]`, the
|
|
221
|
+
* one about to fire included. Capped, because a Routine dormant for a year on
|
|
222
|
+
* `@every 5m` must not be counted one occurrence at a time.
|
|
223
|
+
*/
|
|
224
|
+
export function missedRoutineRunsV1(
|
|
225
|
+
normalized: NormalizedScheduleV1,
|
|
226
|
+
due: Date,
|
|
227
|
+
now: Date,
|
|
228
|
+
anchor: Date,
|
|
229
|
+
): number {
|
|
230
|
+
if (now.getTime() < due.getTime()) return 0;
|
|
231
|
+
if (normalized.kind === "interval") {
|
|
232
|
+
return Math.min(
|
|
233
|
+
ROUTINE_MISSED_COUNT_CAP,
|
|
234
|
+
Math.floor((now.getTime() - due.getTime()) / normalized.intervalMs) + 1,
|
|
235
|
+
);
|
|
236
|
+
}
|
|
237
|
+
let counted = 1;
|
|
238
|
+
let cursor = due;
|
|
239
|
+
while (counted < ROUTINE_MISSED_COUNT_CAP) {
|
|
240
|
+
const next = nextRoutineRunV1(normalized, cursor, anchor);
|
|
241
|
+
if (!next || next.getTime() > now.getTime()) break;
|
|
242
|
+
cursor = next;
|
|
243
|
+
counted += 1;
|
|
244
|
+
}
|
|
245
|
+
return counted;
|
|
246
|
+
}
|
package/src/env.d.ts
ADDED
package/src/firing.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// The durable records the scheduler owns: when a Routine is next due, and the
|
|
2
|
+
// one firing of it that has not settled yet.
|
|
3
|
+
//
|
|
4
|
+
// Two records, two jobs.
|
|
5
|
+
//
|
|
6
|
+
// * `RoutineScheduleStateV1` is the Routine's clock. `dueAt` is the debt: the
|
|
7
|
+
// moment the Routine was owed a firing, and it moves only when a firing is
|
|
8
|
+
// minted for it. `deferredUntil` is a hold, set when the object is busy, and
|
|
9
|
+
// it never moves `dueAt` — pushing `dueAt` forward would silently skip a
|
|
10
|
+
// firing, which is the one thing a scheduler must not do. `anchor` records
|
|
11
|
+
// the record revision the clock was computed under, so editing a Routine's
|
|
12
|
+
// schedule recomputes it instead of inheriting a due time from the old one.
|
|
13
|
+
//
|
|
14
|
+
// * `RoutineFireV1` is one firing, written durably *before* the Turn it
|
|
15
|
+
// admits. It doubles as the same-Routine lock: at most one exists per
|
|
16
|
+
// Routine, and a firing that arrives while one is unsettled queues behind
|
|
17
|
+
// it. "Record durable execution intent before invoking an external side
|
|
18
|
+
// effect", and "retries reuse the fire id as the run id".
|
|
19
|
+
import {
|
|
20
|
+
isRoutineIdV1,
|
|
21
|
+
RoutineDecodeError,
|
|
22
|
+
routineExactKeys,
|
|
23
|
+
routineText,
|
|
24
|
+
routineTimestamp,
|
|
25
|
+
ROUTINE_TRIGGER_KINDS,
|
|
26
|
+
type RoutineTriggerKindV1,
|
|
27
|
+
} from "./records.js";
|
|
28
|
+
|
|
29
|
+
/** A Routine's durable clock. Absent means "compute it from the record". */
|
|
30
|
+
export interface RoutineScheduleStateV1 {
|
|
31
|
+
schemaVersion: 1;
|
|
32
|
+
routineId: string;
|
|
33
|
+
/** The record revision this clock was computed under: the record's `updatedAt`. */
|
|
34
|
+
anchor: string;
|
|
35
|
+
/** Epoch milliseconds the Routine is next owed a firing. */
|
|
36
|
+
dueAt: number;
|
|
37
|
+
/** Epoch milliseconds before which the alarm must not settle this Routine. */
|
|
38
|
+
deferredUntil?: number;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** One firing, durable before the Turn it admits. The same-Routine lock. */
|
|
42
|
+
export interface RoutineFireV1 {
|
|
43
|
+
schemaVersion: 1;
|
|
44
|
+
routineId: string;
|
|
45
|
+
/** The run id the Turn is admitted under; a retry reuses it. */
|
|
46
|
+
fireId: string;
|
|
47
|
+
trigger: RoutineTriggerKindV1;
|
|
48
|
+
/** The cue text the Turn is admitted with. */
|
|
49
|
+
cue: string;
|
|
50
|
+
/** When the firing was minted. */
|
|
51
|
+
mintedAt: string;
|
|
52
|
+
/** The occurrence this firing settles, for a scheduled Routine. */
|
|
53
|
+
dueAt?: number;
|
|
54
|
+
/** How many occurrences this firing coalesces, when it fired late. */
|
|
55
|
+
missedCount?: number;
|
|
56
|
+
/** The run-log entry this firing writes and later rewrites. */
|
|
57
|
+
entryId: string;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Longest cue a firing may carry. A Routine prompt is capped at 8 000
|
|
62
|
+
* characters and a webhook rendering at 4 KiB; this leaves room for both and
|
|
63
|
+
* the framing between them.
|
|
64
|
+
*/
|
|
65
|
+
export const ROUTINE_CUE_MAX_LENGTH = 16_000;
|
|
66
|
+
|
|
67
|
+
function record(value: unknown, label: string): Record<string, unknown> {
|
|
68
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
|
69
|
+
throw new RoutineDecodeError(`${label} must be an object`);
|
|
70
|
+
}
|
|
71
|
+
return value as Record<string, unknown>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function epoch(value: unknown, label: string): number {
|
|
75
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
76
|
+
throw new RoutineDecodeError(`${label} must be epoch milliseconds`);
|
|
77
|
+
}
|
|
78
|
+
return value as number;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function count(value: unknown, label: string): number {
|
|
82
|
+
if (!Number.isSafeInteger(value) || (value as number) < 0) {
|
|
83
|
+
throw new RoutineDecodeError(`${label} must be a non-negative integer`);
|
|
84
|
+
}
|
|
85
|
+
return value as number;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function decodeRoutineScheduleStateV1(
|
|
89
|
+
value: unknown,
|
|
90
|
+
): RoutineScheduleStateV1 {
|
|
91
|
+
const candidate = record(value, "Routine schedule state");
|
|
92
|
+
routineExactKeys(
|
|
93
|
+
candidate,
|
|
94
|
+
["schemaVersion", "routineId", "anchor", "dueAt"],
|
|
95
|
+
["deferredUntil"],
|
|
96
|
+
"Routine schedule state",
|
|
97
|
+
);
|
|
98
|
+
if (candidate.schemaVersion !== 1) {
|
|
99
|
+
throw new RoutineDecodeError(
|
|
100
|
+
"Routine schedule state schemaVersion is unsupported",
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
if (!isRoutineIdV1(candidate.routineId)) {
|
|
104
|
+
throw new RoutineDecodeError("Routine schedule state routineId is invalid");
|
|
105
|
+
}
|
|
106
|
+
return {
|
|
107
|
+
schemaVersion: 1,
|
|
108
|
+
routineId: candidate.routineId,
|
|
109
|
+
anchor: routineTimestamp(candidate.anchor, "Routine schedule state anchor"),
|
|
110
|
+
dueAt: epoch(candidate.dueAt, "Routine schedule state dueAt"),
|
|
111
|
+
...(candidate.deferredUntil === undefined
|
|
112
|
+
? {}
|
|
113
|
+
: {
|
|
114
|
+
deferredUntil: epoch(
|
|
115
|
+
candidate.deferredUntil,
|
|
116
|
+
"Routine schedule state deferredUntil",
|
|
117
|
+
),
|
|
118
|
+
}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function decodeRoutineFireV1(value: unknown): RoutineFireV1 {
|
|
123
|
+
const candidate = record(value, "Routine firing");
|
|
124
|
+
routineExactKeys(
|
|
125
|
+
candidate,
|
|
126
|
+
[
|
|
127
|
+
"schemaVersion",
|
|
128
|
+
"routineId",
|
|
129
|
+
"fireId",
|
|
130
|
+
"trigger",
|
|
131
|
+
"cue",
|
|
132
|
+
"mintedAt",
|
|
133
|
+
"entryId",
|
|
134
|
+
],
|
|
135
|
+
["dueAt", "missedCount"],
|
|
136
|
+
"Routine firing",
|
|
137
|
+
);
|
|
138
|
+
if (candidate.schemaVersion !== 1) {
|
|
139
|
+
throw new RoutineDecodeError("Routine firing schemaVersion is unsupported");
|
|
140
|
+
}
|
|
141
|
+
if (!isRoutineIdV1(candidate.routineId)) {
|
|
142
|
+
throw new RoutineDecodeError("Routine firing routineId is invalid");
|
|
143
|
+
}
|
|
144
|
+
const trigger = ROUTINE_TRIGGER_KINDS.find(
|
|
145
|
+
(known) => known === candidate.trigger,
|
|
146
|
+
);
|
|
147
|
+
if (!trigger) {
|
|
148
|
+
throw new RoutineDecodeError("Routine firing trigger is invalid");
|
|
149
|
+
}
|
|
150
|
+
return {
|
|
151
|
+
schemaVersion: 1,
|
|
152
|
+
routineId: candidate.routineId,
|
|
153
|
+
fireId: routineText(candidate.fireId, 256, "Routine firing fireId"),
|
|
154
|
+
trigger,
|
|
155
|
+
cue: routineText(
|
|
156
|
+
candidate.cue,
|
|
157
|
+
ROUTINE_CUE_MAX_LENGTH,
|
|
158
|
+
"Routine firing cue",
|
|
159
|
+
),
|
|
160
|
+
mintedAt: routineTimestamp(candidate.mintedAt, "Routine firing mintedAt"),
|
|
161
|
+
entryId: routineText(candidate.entryId, 128, "Routine firing entryId"),
|
|
162
|
+
...(candidate.dueAt === undefined
|
|
163
|
+
? {}
|
|
164
|
+
: { dueAt: epoch(candidate.dueAt, "Routine firing dueAt") }),
|
|
165
|
+
...(candidate.missedCount === undefined
|
|
166
|
+
? {}
|
|
167
|
+
: {
|
|
168
|
+
missedCount: count(
|
|
169
|
+
candidate.missedCount,
|
|
170
|
+
"Routine firing missedCount",
|
|
171
|
+
),
|
|
172
|
+
}),
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The Session a Routine's firings run in. One per Routine, never the User's. */
|
|
177
|
+
export function routineSessionIdV1(routineId: string): string {
|
|
178
|
+
return `routine:${routineId}`;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* The run id one firing is admitted under. It is derived, not random: a
|
|
183
|
+
* scheduled occurrence names its own due time and a delivered one names its
|
|
184
|
+
* delivery, so a retry after eviction reuses the id and the kernel's own
|
|
185
|
+
* fingerprint idempotency refuses the second admission rather than running the
|
|
186
|
+
* Routine twice.
|
|
187
|
+
*/
|
|
188
|
+
export function routineFireIdV1(
|
|
189
|
+
routineId: string,
|
|
190
|
+
discriminator: string,
|
|
191
|
+
): string {
|
|
192
|
+
const sanitized = discriminator.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 80);
|
|
193
|
+
return `rf-${routineId}-${sanitized}`.slice(0, 250);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* What the Turn is admitted with. A firing is not a message from a person, so
|
|
198
|
+
* the cue says what fired and why, and then hands over the Routine's own
|
|
199
|
+
* instruction verbatim.
|
|
200
|
+
*/
|
|
201
|
+
export function routineCueV1(input: {
|
|
202
|
+
name: string;
|
|
203
|
+
prompt: string;
|
|
204
|
+
trigger: RoutineTriggerKindV1;
|
|
205
|
+
missedCount?: number;
|
|
206
|
+
delivery?: string;
|
|
207
|
+
}): string {
|
|
208
|
+
const lines = [
|
|
209
|
+
`Routine "${input.name}" fired (${input.trigger}).`,
|
|
210
|
+
...(input.missedCount && input.missedCount > 1
|
|
211
|
+
? [
|
|
212
|
+
`It was late: ${input.missedCount} scheduled occurrences elapsed and this firing covers all of them.`,
|
|
213
|
+
]
|
|
214
|
+
: []),
|
|
215
|
+
"",
|
|
216
|
+
input.prompt,
|
|
217
|
+
];
|
|
218
|
+
if (input.delivery !== undefined) {
|
|
219
|
+
lines.push("", "Delivered payload:", input.delivery);
|
|
220
|
+
}
|
|
221
|
+
return lines.join("\n").slice(0, ROUTINE_CUE_MAX_LENGTH);
|
|
222
|
+
}
|