@company-semantics/contracts 99.0.0 → 99.2.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/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +34 -0
- package/src/index.ts +4 -1
- package/src/schedules/README.md +118 -0
- package/src/schedules/__tests__/README.md +27 -0
- package/src/schedules/__tests__/api.test.ts +72 -0
- package/src/schedules/__tests__/cron.test.ts +140 -0
- package/src/schedules/__tests__/definition.test.ts +97 -0
- package/src/schedules/__tests__/execution.test.ts +75 -0
- package/src/schedules/__tests__/identity.test.ts +51 -0
- package/src/schedules/__tests__/schedules-exports.test.ts +26 -0
- package/src/schedules/api.ts +139 -0
- package/src/schedules/cron.ts +268 -0
- package/src/schedules/definition.ts +148 -0
- package/src/schedules/execution.ts +146 -0
- package/src/schedules/identity.ts +99 -0
- package/src/schedules/index.ts +124 -0
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responses the backend's schedule routes publish.
|
|
3
|
+
*
|
|
4
|
+
* Response schemas only. Request bodies stay in the backend beside their routes
|
|
5
|
+
* (contracts CLAUDE.md "What Does NOT Belong"). Not yet bound to routes: the
|
|
6
|
+
* backend PRD that adds the routes registers them in CONTRACT_SURFACES.
|
|
7
|
+
*
|
|
8
|
+
* Claim (the `cs-schedule start` step, OIDC-authenticated):
|
|
9
|
+
* - `claimed`: this run holds the execution; it must carry `claimEpoch` into
|
|
10
|
+
* every fenced write and into its finish call.
|
|
11
|
+
* - `legacy`: an `on.schedule` (or otherwise unregistered) run of a schedule
|
|
12
|
+
* whose mode is not `live`. It may run, gets NO execution id and counts toward
|
|
13
|
+
* no slot (migration admission).
|
|
14
|
+
* - `refused`: the run must skip its work (`run=false`). Reasons are closed.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { SCHEDULE_CLOCK_SOURCES, SCHEDULE_EXECUTION_STATES } from "./execution";
|
|
19
|
+
import { SCHEDULE_MODES } from "./definition";
|
|
20
|
+
import type { ScheduleClockSource } from "./execution";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The tick route. Both clock sources hit it: the edge Worker over HTTPS with the
|
|
24
|
+
* existing edge bearer (EDGE_AUTH_TOKEN on the edge, verified by the backend's
|
|
25
|
+
* verifyEdgeAuth), the job queue in-process. There is no separate clock token.
|
|
26
|
+
*/
|
|
27
|
+
export const SCHEDULE_TICK_PATH = "/api/schedules/tick";
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Tick request body, as a TYPE only: request schemas stay in the backend beside
|
|
31
|
+
* the route (contracts CLAUDE.md), which binds its zod schema to this type with
|
|
32
|
+
* `satisfies z.ZodType<ScheduleTickRequest>`. Edge and backend both read it.
|
|
33
|
+
* `scheduledFor` is the clock's own scheduled instant (ISO 8601, e.g. the
|
|
34
|
+
* Worker's ScheduledController.scheduledTime); `cron` is the trigger expression
|
|
35
|
+
* that fired (the Worker's controller.cron; the job queue sends its interval
|
|
36
|
+
* label). Neither is a schedule slot: the reconciler computes slots itself.
|
|
37
|
+
*/
|
|
38
|
+
export interface ScheduleTickRequest {
|
|
39
|
+
readonly source: ScheduleClockSource;
|
|
40
|
+
readonly scheduledFor: string;
|
|
41
|
+
readonly cron: string;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* CloudWatch EMF metric names (backend emits; control's watchdog script alarms).
|
|
46
|
+
* `Tick` carries dimension `source` (one series per clock source); a series
|
|
47
|
+
* missing for its window is breaching. `ReconcileSuccess` is emitted once per
|
|
48
|
+
* reconcile that concluded with complete observations.
|
|
49
|
+
*/
|
|
50
|
+
export const SCHEDULE_METRICS_NAMESPACE = "Schedules";
|
|
51
|
+
export const SCHEDULE_METRIC_TICK = "Tick";
|
|
52
|
+
export const SCHEDULE_METRIC_TICK_SOURCE_DIMENSION = "source";
|
|
53
|
+
/**
|
|
54
|
+
* Every Schedules metric carries this dimension (value: the backend deploy
|
|
55
|
+
* environment, e.g. "production" / "staging"), so a staging tick can never
|
|
56
|
+
* feed a production alarm series.
|
|
57
|
+
*/
|
|
58
|
+
export const SCHEDULE_METRIC_ENVIRONMENT_DIMENSION = "environment";
|
|
59
|
+
export const SCHEDULE_METRIC_RECONCILE_SUCCESS = "ReconcileSuccess";
|
|
60
|
+
|
|
61
|
+
export const SCHEDULE_CLAIM_REFUSALS = [
|
|
62
|
+
"already_completed",
|
|
63
|
+
"lease_held",
|
|
64
|
+
"legacy_run_not_admitted",
|
|
65
|
+
"not_dispatched",
|
|
66
|
+
"binding_mismatch",
|
|
67
|
+
"schedule_off",
|
|
68
|
+
] as const;
|
|
69
|
+
export type ScheduleClaimRefusal = (typeof SCHEDULE_CLAIM_REFUSALS)[number];
|
|
70
|
+
|
|
71
|
+
export const ScheduleClaimResponseSchema = z.discriminatedUnion("outcome", [
|
|
72
|
+
z
|
|
73
|
+
.object({
|
|
74
|
+
outcome: z.literal("claimed"),
|
|
75
|
+
executionId: z.string(),
|
|
76
|
+
claimEpoch: z.number().int().min(1),
|
|
77
|
+
leaseUntil: z.iso.datetime(),
|
|
78
|
+
mode: z.enum(SCHEDULE_MODES),
|
|
79
|
+
})
|
|
80
|
+
.strict(),
|
|
81
|
+
z
|
|
82
|
+
.object({
|
|
83
|
+
outcome: z.literal("legacy"),
|
|
84
|
+
scheduleId: z.string(),
|
|
85
|
+
mode: z.enum(SCHEDULE_MODES),
|
|
86
|
+
})
|
|
87
|
+
.strict(),
|
|
88
|
+
z
|
|
89
|
+
.object({
|
|
90
|
+
outcome: z.literal("refused"),
|
|
91
|
+
reason: z.enum(SCHEDULE_CLAIM_REFUSALS),
|
|
92
|
+
})
|
|
93
|
+
.strict(),
|
|
94
|
+
]);
|
|
95
|
+
export type ScheduleClaimResponse = z.infer<typeof ScheduleClaimResponseSchema>;
|
|
96
|
+
|
|
97
|
+
export const SCHEDULE_FINISH_REJECTIONS = [
|
|
98
|
+
"stale_epoch",
|
|
99
|
+
"binding_mismatch",
|
|
100
|
+
"unknown_execution",
|
|
101
|
+
] as const;
|
|
102
|
+
export type ScheduleFinishRejection =
|
|
103
|
+
(typeof SCHEDULE_FINISH_REJECTIONS)[number];
|
|
104
|
+
|
|
105
|
+
export const ScheduleFinishResponseSchema = z.discriminatedUnion("recorded", [
|
|
106
|
+
z
|
|
107
|
+
.object({
|
|
108
|
+
recorded: z.literal(true),
|
|
109
|
+
executionId: z.string(),
|
|
110
|
+
state: z.enum(SCHEDULE_EXECUTION_STATES),
|
|
111
|
+
})
|
|
112
|
+
.strict(),
|
|
113
|
+
z
|
|
114
|
+
.object({
|
|
115
|
+
recorded: z.literal(false),
|
|
116
|
+
reason: z.enum(SCHEDULE_FINISH_REJECTIONS),
|
|
117
|
+
})
|
|
118
|
+
.strict(),
|
|
119
|
+
]);
|
|
120
|
+
export type ScheduleFinishResponse = z.infer<
|
|
121
|
+
typeof ScheduleFinishResponseSchema
|
|
122
|
+
>;
|
|
123
|
+
|
|
124
|
+
/** One tick's outcome, reported to the tick source that triggered it. */
|
|
125
|
+
export const ScheduleTickResponseSchema = z
|
|
126
|
+
.object({
|
|
127
|
+
source: z.enum(SCHEDULE_CLOCK_SOURCES),
|
|
128
|
+
tickedAt: z.iso.datetime(),
|
|
129
|
+
reconcileOk: z.boolean(),
|
|
130
|
+
registryVersion: z
|
|
131
|
+
.string()
|
|
132
|
+
.regex(/^[0-9a-f]{64}$/)
|
|
133
|
+
.nullable(),
|
|
134
|
+
dispatched: z.number().int().min(0),
|
|
135
|
+
incidentsOpened: z.number().int().min(0),
|
|
136
|
+
incidentsResolved: z.number().int().min(0),
|
|
137
|
+
})
|
|
138
|
+
.strict();
|
|
139
|
+
export type ScheduleTickResponse = z.infer<typeof ScheduleTickResponseSchema>;
|
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Five-field UTC cron evaluator for the schedule registry.
|
|
3
|
+
*
|
|
4
|
+
* Pure: no clock reads, no I/O. Every function takes the instant it reasons
|
|
5
|
+
* about. Consumed by control (registry validation at generation time) and by
|
|
6
|
+
* the backend reconciler (slot computation), so both read one definition of
|
|
7
|
+
* "when is this schedule due" (contracts ADR slug schedule-execution-wire-types).
|
|
8
|
+
*
|
|
9
|
+
* Supported per field: `*`, `*\/n`, `n`, `a-b`, `a-b/n`, and comma lists of those.
|
|
10
|
+
* NOT supported (parse fails with a reason): names (JAN, MON), `L`, `W`, `#`, `?`,
|
|
11
|
+
* `@hourly`-style macros, and a step on a single value (`5/15`).
|
|
12
|
+
*
|
|
13
|
+
* Semantics:
|
|
14
|
+
* - All evaluation is in UTC. A slot is a whole-minute instant (seconds = 0).
|
|
15
|
+
* - Day-of-week 7 is Sunday, the same as 0.
|
|
16
|
+
* - Day matching follows Vixie cron: when BOTH day-of-month and day-of-week are
|
|
17
|
+
* restricted (their field text does not start with `*`), a day matches if
|
|
18
|
+
* EITHER matches; otherwise both must match. So `*\/2` in day-of-month counts
|
|
19
|
+
* as unrestricted, exactly as in Vixie cron.
|
|
20
|
+
* - An expression that can never fire (e.g. `0 0 31 2 *`) fails to parse.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export type CronField =
|
|
24
|
+
"minute" | "hour" | "dayOfMonth" | "month" | "dayOfWeek";
|
|
25
|
+
|
|
26
|
+
export interface ParsedCron {
|
|
27
|
+
/** The expression with whitespace normalized to single spaces. */
|
|
28
|
+
readonly source: string;
|
|
29
|
+
/** Ascending, deduplicated. */
|
|
30
|
+
readonly minutes: readonly number[];
|
|
31
|
+
readonly hours: readonly number[];
|
|
32
|
+
readonly daysOfMonth: readonly number[];
|
|
33
|
+
readonly months: readonly number[];
|
|
34
|
+
/** 0..6, Sunday = 0 (7 is folded into 0). */
|
|
35
|
+
readonly daysOfWeek: readonly number[];
|
|
36
|
+
readonly dayOfMonthRestricted: boolean;
|
|
37
|
+
readonly dayOfWeekRestricted: boolean;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type CronParseResult =
|
|
41
|
+
| { readonly ok: true; readonly cron: ParsedCron }
|
|
42
|
+
| {
|
|
43
|
+
readonly ok: false;
|
|
44
|
+
readonly field: CronField | "expression";
|
|
45
|
+
readonly reason: string;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const FIELD_ORDER: readonly CronField[] = [
|
|
49
|
+
"minute",
|
|
50
|
+
"hour",
|
|
51
|
+
"dayOfMonth",
|
|
52
|
+
"month",
|
|
53
|
+
"dayOfWeek",
|
|
54
|
+
];
|
|
55
|
+
|
|
56
|
+
const FIELD_BOUNDS: Readonly<Record<CronField, readonly [number, number]>> = {
|
|
57
|
+
minute: [0, 59],
|
|
58
|
+
hour: [0, 23],
|
|
59
|
+
dayOfMonth: [1, 31],
|
|
60
|
+
month: [1, 12],
|
|
61
|
+
dayOfWeek: [0, 7],
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const TERM = /^(\*|\d+(?:-\d+)?)(?:\/(\d+))?$/;
|
|
65
|
+
const MINUTE_MS = 60_000;
|
|
66
|
+
const DAY_MS = 86_400_000;
|
|
67
|
+
/** Feb 29 schedules can skip a century year: 8 years bounds every satisfiable expression. */
|
|
68
|
+
const MAX_SEARCH_DAYS = 366 * 8 + 1;
|
|
69
|
+
/** Fixed reference instant for the "can this ever fire" check (keeps parse pure). */
|
|
70
|
+
const SATISFIABILITY_REFERENCE = Date.UTC(2028, 0, 1);
|
|
71
|
+
|
|
72
|
+
function parseField(
|
|
73
|
+
text: string,
|
|
74
|
+
field: CronField,
|
|
75
|
+
): readonly number[] | string {
|
|
76
|
+
const [lo, hi] = FIELD_BOUNDS[field];
|
|
77
|
+
const values = new Set<number>();
|
|
78
|
+
for (const term of text.split(",")) {
|
|
79
|
+
const match = TERM.exec(term);
|
|
80
|
+
if (!match) return `unsupported term "${term}"`;
|
|
81
|
+
const base = match[1];
|
|
82
|
+
const stepText = match[2];
|
|
83
|
+
let start: number;
|
|
84
|
+
let end: number;
|
|
85
|
+
if (base === "*") {
|
|
86
|
+
start = lo;
|
|
87
|
+
end = hi;
|
|
88
|
+
} else if (base.includes("-")) {
|
|
89
|
+
const [a, b] = base.split("-").map(Number);
|
|
90
|
+
start = a;
|
|
91
|
+
end = b;
|
|
92
|
+
} else {
|
|
93
|
+
if (stepText !== undefined)
|
|
94
|
+
return `a step on a single value ("${term}") is unsupported`;
|
|
95
|
+
start = Number(base);
|
|
96
|
+
end = start;
|
|
97
|
+
}
|
|
98
|
+
if (start < lo || end > hi || start > end) {
|
|
99
|
+
return `"${term}" is outside ${lo}-${hi}`;
|
|
100
|
+
}
|
|
101
|
+
const step = stepText === undefined ? 1 : Number(stepText);
|
|
102
|
+
if (!Number.isInteger(step) || step < 1)
|
|
103
|
+
return `the step in "${term}" must be at least 1`;
|
|
104
|
+
for (let v = start; v <= end; v += step) {
|
|
105
|
+
values.add(field === "dayOfWeek" && v === 7 ? 0 : v);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return [...values].sort((a, b) => a - b);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function dayMatches(cron: ParsedCron, day: Date): boolean {
|
|
112
|
+
const domOk = cron.daysOfMonth.includes(day.getUTCDate());
|
|
113
|
+
const dowOk = cron.daysOfWeek.includes(day.getUTCDay());
|
|
114
|
+
if (cron.dayOfMonthRestricted && cron.dayOfWeekRestricted)
|
|
115
|
+
return domOk || dowOk;
|
|
116
|
+
return domOk && dowOk;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function dayEligible(cron: ParsedCron, dayStart: number): boolean {
|
|
120
|
+
const day = new Date(dayStart);
|
|
121
|
+
return cron.months.includes(day.getUTCMonth() + 1) && dayMatches(cron, day);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function utcDayStart(ms: number): number {
|
|
125
|
+
return Math.floor(ms / DAY_MS) * DAY_MS;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Parse a five-field UTC cron expression. Never throws. */
|
|
129
|
+
export function parseCron(expression: string): CronParseResult {
|
|
130
|
+
const parts = expression.trim().split(/\s+/);
|
|
131
|
+
if (parts.length !== 5) {
|
|
132
|
+
return {
|
|
133
|
+
ok: false,
|
|
134
|
+
field: "expression",
|
|
135
|
+
reason: `expected 5 fields, got ${parts.length}`,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
const fields: Partial<Record<CronField, readonly number[]>> = {};
|
|
139
|
+
for (let i = 0; i < FIELD_ORDER.length; i++) {
|
|
140
|
+
const field = FIELD_ORDER[i];
|
|
141
|
+
const parsed = parseField(parts[i], field);
|
|
142
|
+
if (typeof parsed === "string") return { ok: false, field, reason: parsed };
|
|
143
|
+
fields[field] = parsed;
|
|
144
|
+
}
|
|
145
|
+
const cron: ParsedCron = {
|
|
146
|
+
source: parts.join(" "),
|
|
147
|
+
minutes: fields.minute ?? [],
|
|
148
|
+
hours: fields.hour ?? [],
|
|
149
|
+
daysOfMonth: fields.dayOfMonth ?? [],
|
|
150
|
+
months: fields.month ?? [],
|
|
151
|
+
daysOfWeek: fields.dayOfWeek ?? [],
|
|
152
|
+
dayOfMonthRestricted: !parts[2].startsWith("*"),
|
|
153
|
+
dayOfWeekRestricted: !parts[4].startsWith("*"),
|
|
154
|
+
};
|
|
155
|
+
if (nextSlot(cron, new Date(SATISFIABILITY_REFERENCE)) === null) {
|
|
156
|
+
return {
|
|
157
|
+
ok: false,
|
|
158
|
+
field: "expression",
|
|
159
|
+
reason: "the expression can never fire",
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, cron };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** True when `instant` is exactly a slot of `cron` (whole minute, UTC). */
|
|
166
|
+
export function cronMatches(cron: ParsedCron, instant: Date): boolean {
|
|
167
|
+
const ms = instant.getTime();
|
|
168
|
+
if (ms % MINUTE_MS !== 0) return false;
|
|
169
|
+
return (
|
|
170
|
+
dayEligible(cron, utcDayStart(ms)) &&
|
|
171
|
+
cron.hours.includes(instant.getUTCHours()) &&
|
|
172
|
+
cron.minutes.includes(instant.getUTCMinutes())
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The latest slot at or before `atOrBefore`, or null when none exists in the search window. */
|
|
177
|
+
export function previousSlot(cron: ParsedCron, atOrBefore: Date): Date | null {
|
|
178
|
+
const floored = Math.floor(atOrBefore.getTime() / MINUTE_MS) * MINUTE_MS;
|
|
179
|
+
let dayStart = utcDayStart(floored);
|
|
180
|
+
let limit = (floored - dayStart) / MINUTE_MS;
|
|
181
|
+
for (let i = 0; i <= MAX_SEARCH_DAYS; i++) {
|
|
182
|
+
if (dayEligible(cron, dayStart)) {
|
|
183
|
+
for (let h = cron.hours.length - 1; h >= 0; h--) {
|
|
184
|
+
const hour = cron.hours[h];
|
|
185
|
+
if (hour * 60 > limit) continue;
|
|
186
|
+
for (let m = cron.minutes.length - 1; m >= 0; m--) {
|
|
187
|
+
const minuteOfDay = hour * 60 + cron.minutes[m];
|
|
188
|
+
if (minuteOfDay <= limit)
|
|
189
|
+
return new Date(dayStart + minuteOfDay * MINUTE_MS);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
dayStart -= DAY_MS;
|
|
194
|
+
limit = 24 * 60 - 1;
|
|
195
|
+
}
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The earliest slot strictly after `after`, or null when none exists in the search window. */
|
|
200
|
+
export function nextSlot(cron: ParsedCron, after: Date): Date | null {
|
|
201
|
+
const start = Math.floor(after.getTime() / MINUTE_MS) * MINUTE_MS + MINUTE_MS;
|
|
202
|
+
let dayStart = utcDayStart(start);
|
|
203
|
+
let floor = (start - dayStart) / MINUTE_MS;
|
|
204
|
+
for (let i = 0; i <= MAX_SEARCH_DAYS; i++) {
|
|
205
|
+
if (dayEligible(cron, dayStart)) {
|
|
206
|
+
for (const hour of cron.hours) {
|
|
207
|
+
if (hour * 60 + 59 < floor) continue;
|
|
208
|
+
for (const minute of cron.minutes) {
|
|
209
|
+
const minuteOfDay = hour * 60 + minute;
|
|
210
|
+
if (minuteOfDay >= floor)
|
|
211
|
+
return new Date(dayStart + minuteOfDay * MINUTE_MS);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
dayStart += DAY_MS;
|
|
216
|
+
floor = 0;
|
|
217
|
+
}
|
|
218
|
+
return null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export interface SlotWindow {
|
|
222
|
+
/** Ascending. At most `limit` slots: the NEWEST ones when truncated. */
|
|
223
|
+
readonly slots: readonly Date[];
|
|
224
|
+
/** True when more slots existed in the window than `limit`. */
|
|
225
|
+
readonly truncated: boolean;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Slots in (afterExclusive, atOrBefore], newest `limit` kept, returned ascending. */
|
|
229
|
+
export function slotsBetween(
|
|
230
|
+
cron: ParsedCron,
|
|
231
|
+
afterExclusive: Date,
|
|
232
|
+
atOrBefore: Date,
|
|
233
|
+
limit: number,
|
|
234
|
+
): SlotWindow {
|
|
235
|
+
const newestFirst: Date[] = [];
|
|
236
|
+
let cursor = previousSlot(cron, atOrBefore);
|
|
237
|
+
while (cursor !== null && cursor.getTime() > afterExclusive.getTime()) {
|
|
238
|
+
if (newestFirst.length === limit)
|
|
239
|
+
return { slots: newestFirst.reverse(), truncated: true };
|
|
240
|
+
newestFirst.push(cursor);
|
|
241
|
+
cursor = previousSlot(cron, new Date(cursor.getTime() - MINUTE_MS));
|
|
242
|
+
}
|
|
243
|
+
return { slots: newestFirst.reverse(), truncated: false };
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/**
|
|
247
|
+
* The longest gap, in minutes, between consecutive slots among the `samples`
|
|
248
|
+
* intervals ending at or before `at`. "N cadences" thresholds use this, so an
|
|
249
|
+
* irregular schedule (`30 9 * * 1,4`) is judged by its longest normal gap.
|
|
250
|
+
*/
|
|
251
|
+
export function maxIntervalMinutes(
|
|
252
|
+
cron: ParsedCron,
|
|
253
|
+
at: Date,
|
|
254
|
+
samples = 8,
|
|
255
|
+
): number {
|
|
256
|
+
let newer = previousSlot(cron, at);
|
|
257
|
+
let longest = 0;
|
|
258
|
+
for (let i = 0; i < samples && newer !== null; i++) {
|
|
259
|
+
const older = previousSlot(cron, new Date(newer.getTime() - MINUTE_MS));
|
|
260
|
+
if (older === null) break;
|
|
261
|
+
longest = Math.max(
|
|
262
|
+
longest,
|
|
263
|
+
(newer.getTime() - older.getTime()) / MINUTE_MS,
|
|
264
|
+
);
|
|
265
|
+
newer = older;
|
|
266
|
+
}
|
|
267
|
+
return longest;
|
|
268
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Schedule definitions and the registry snapshot.
|
|
3
|
+
*
|
|
4
|
+
* A ScheduleDefinition is the whole delivery contract for one scheduled job:
|
|
5
|
+
* when (cron, UTC), what (repo + workflow), how late is still on time (grace),
|
|
6
|
+
* how long it may run (timeout), what to do about missed slots (catchUp), how
|
|
7
|
+
* duplicates are prevented (idempotency, effects), how its output is checked
|
|
8
|
+
* (verify), whether the clock drives it yet (mode) and how loudly it escalates
|
|
9
|
+
* (severity). `id` is permanent: changing the cron never changes the id.
|
|
10
|
+
*
|
|
11
|
+
* control's `scripts/schedules/registry.ts` is the single authored source; control
|
|
12
|
+
* publishes a ScheduleRegistrySnapshot to the backend, which swaps it in
|
|
13
|
+
* atomically. `contentHash` is the sha256 hex of canonicalScheduleRegistryJson,
|
|
14
|
+
* computed by the publisher and re-checked by the receiver.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { z } from "zod";
|
|
18
|
+
import { parseCron } from "./cron";
|
|
19
|
+
import { SCHEDULE_EFFECTS } from "./execution";
|
|
20
|
+
import { SCHEDULE_ID_MAX_LENGTH, SCHEDULE_ID_PATTERN } from "./identity";
|
|
21
|
+
|
|
22
|
+
export const SCHEDULE_CATCH_UP_POLICIES = ["latest", "all", "skip"] as const;
|
|
23
|
+
export type ScheduleCatchUpPolicy = (typeof SCHEDULE_CATCH_UP_POLICIES)[number];
|
|
24
|
+
|
|
25
|
+
export const SCHEDULE_IDEMPOTENCY_LEVELS = ["required", "best_effort"] as const;
|
|
26
|
+
export type ScheduleIdempotency = (typeof SCHEDULE_IDEMPOTENCY_LEVELS)[number];
|
|
27
|
+
|
|
28
|
+
/** Closed catalog. v0 has one verifier. */
|
|
29
|
+
export const SCHEDULE_OUTPUT_VERIFIERS = ["production_signal_fresh"] as const;
|
|
30
|
+
export type ScheduleOutputVerifier = (typeof SCHEDULE_OUTPUT_VERIFIERS)[number];
|
|
31
|
+
|
|
32
|
+
/** off: GitHub cron only, clock ignores it. shadow: clock plans, never dispatches. live: clock dispatches; legacy cron runs refused. */
|
|
33
|
+
export const SCHEDULE_MODES = ["off", "shadow", "live"] as const;
|
|
34
|
+
export type ScheduleMode = (typeof SCHEDULE_MODES)[number];
|
|
35
|
+
|
|
36
|
+
export const SCHEDULE_SEVERITIES = ["red-main", "schedule"] as const;
|
|
37
|
+
export type ScheduleSeverity = (typeof SCHEDULE_SEVERITIES)[number];
|
|
38
|
+
|
|
39
|
+
export const SCHEDULE_TIMEZONE = "UTC" as const;
|
|
40
|
+
export const SCHEDULE_MAX_CATCH_UP_SLOTS = 48;
|
|
41
|
+
/** GitHub-hosted job ceiling. */
|
|
42
|
+
export const SCHEDULE_MAX_TIMEOUT_MINUTES = 360;
|
|
43
|
+
|
|
44
|
+
export const ScheduleDefinitionSchema = z
|
|
45
|
+
.object({
|
|
46
|
+
id: z.string().max(SCHEDULE_ID_MAX_LENGTH).regex(SCHEDULE_ID_PATTERN),
|
|
47
|
+
repo: z.string().regex(/^company-semantics-[a-z]+(?:-[a-z]+)*$/),
|
|
48
|
+
workflow: z.string().regex(/^[A-Za-z0-9._-]+\.ya?ml$/),
|
|
49
|
+
cron: z.string().refine((value) => parseCron(value).ok, {
|
|
50
|
+
message:
|
|
51
|
+
"cron must be a supported five-field UTC expression that can fire",
|
|
52
|
+
}),
|
|
53
|
+
timezone: z.literal(SCHEDULE_TIMEZONE),
|
|
54
|
+
graceMinutes: z.number().int().min(0).max(1440),
|
|
55
|
+
timeoutMinutes: z.number().int().min(1).max(SCHEDULE_MAX_TIMEOUT_MINUTES),
|
|
56
|
+
admissionTimeoutMinutes: z.number().int().min(1).max(60),
|
|
57
|
+
catchUp: z.enum(SCHEDULE_CATCH_UP_POLICIES),
|
|
58
|
+
maxCatchUpSlots: z.number().int().min(1).max(SCHEDULE_MAX_CATCH_UP_SLOTS),
|
|
59
|
+
idempotency: z.enum(SCHEDULE_IDEMPOTENCY_LEVELS),
|
|
60
|
+
effects: z.enum(SCHEDULE_EFFECTS),
|
|
61
|
+
verify: z.enum(SCHEDULE_OUTPUT_VERIFIERS).nullable(),
|
|
62
|
+
mode: z.enum(SCHEDULE_MODES),
|
|
63
|
+
severity: z.enum(SCHEDULE_SEVERITIES),
|
|
64
|
+
})
|
|
65
|
+
.strict()
|
|
66
|
+
.superRefine((definition, ctx) => {
|
|
67
|
+
if (
|
|
68
|
+
(definition.effects === "fenced" || definition.effects === "unsafe") &&
|
|
69
|
+
definition.idempotency !== "required"
|
|
70
|
+
) {
|
|
71
|
+
ctx.addIssue({
|
|
72
|
+
code: "custom",
|
|
73
|
+
path: ["idempotency"],
|
|
74
|
+
message: "fenced or unsafe effects require idempotency 'required'",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (definition.catchUp === "all" && definition.effects === "unsafe") {
|
|
78
|
+
ctx.addIssue({
|
|
79
|
+
code: "custom",
|
|
80
|
+
path: ["catchUp"],
|
|
81
|
+
message: "catchUp 'all' never replays unsafe effects",
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
});
|
|
85
|
+
export type ScheduleDefinition = z.infer<typeof ScheduleDefinitionSchema>;
|
|
86
|
+
|
|
87
|
+
/** Key order used by canonicalScheduleRegistryJson. Every field, in declaration order. */
|
|
88
|
+
export const SCHEDULE_DEFINITION_KEYS = [
|
|
89
|
+
"id",
|
|
90
|
+
"repo",
|
|
91
|
+
"workflow",
|
|
92
|
+
"cron",
|
|
93
|
+
"timezone",
|
|
94
|
+
"graceMinutes",
|
|
95
|
+
"timeoutMinutes",
|
|
96
|
+
"admissionTimeoutMinutes",
|
|
97
|
+
"catchUp",
|
|
98
|
+
"maxCatchUpSlots",
|
|
99
|
+
"idempotency",
|
|
100
|
+
"effects",
|
|
101
|
+
"verify",
|
|
102
|
+
"mode",
|
|
103
|
+
"severity",
|
|
104
|
+
] as const satisfies readonly (keyof ScheduleDefinition)[];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Deterministic serialization: definitions sorted by id, keys in
|
|
108
|
+
* SCHEDULE_DEFINITION_KEYS order, no whitespace. The snapshot's contentHash is
|
|
109
|
+
* sha256(hex) of this string; publisher and receiver both compute it from here.
|
|
110
|
+
*/
|
|
111
|
+
export function canonicalScheduleRegistryJson(
|
|
112
|
+
schedules: readonly ScheduleDefinition[],
|
|
113
|
+
): string {
|
|
114
|
+
const ordered = [...schedules]
|
|
115
|
+
.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0))
|
|
116
|
+
.map((definition) =>
|
|
117
|
+
Object.fromEntries(
|
|
118
|
+
SCHEDULE_DEFINITION_KEYS.map((key) => [key, definition[key]]),
|
|
119
|
+
),
|
|
120
|
+
);
|
|
121
|
+
return JSON.stringify(ordered);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export const SCHEDULE_REGISTRY_SCHEMA_VERSION = 1 as const;
|
|
125
|
+
|
|
126
|
+
export const ScheduleRegistrySnapshotSchema = z
|
|
127
|
+
.object({
|
|
128
|
+
schemaVersion: z.literal(SCHEDULE_REGISTRY_SCHEMA_VERSION),
|
|
129
|
+
contentHash: z.string().regex(/^[0-9a-f]{64}$/),
|
|
130
|
+
schedules: z.array(ScheduleDefinitionSchema),
|
|
131
|
+
})
|
|
132
|
+
.strict()
|
|
133
|
+
.superRefine((snapshot, ctx) => {
|
|
134
|
+
const seen = new Set<string>();
|
|
135
|
+
snapshot.schedules.forEach((definition, index) => {
|
|
136
|
+
if (seen.has(definition.id)) {
|
|
137
|
+
ctx.addIssue({
|
|
138
|
+
code: "custom",
|
|
139
|
+
path: ["schedules", index, "id"],
|
|
140
|
+
message: `duplicate schedule id "${definition.id}"`,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
seen.add(definition.id);
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
export type ScheduleRegistrySnapshot = z.infer<
|
|
147
|
+
typeof ScheduleRegistrySnapshotSchema
|
|
148
|
+
>;
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled-execution lifecycle, recovery and incident vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* The backend ledger stores these states; the reconciler moves rows only along
|
|
5
|
+
* SCHEDULE_EXECUTION_TRANSITIONS. Wire values are lowercase snake_case.
|
|
6
|
+
*
|
|
7
|
+
* Lifecycle: due → dispatch_requested → started → completed → output_verified.
|
|
8
|
+
* - `dispatch_requested` is entered by a compare-and-swap on the attempt number
|
|
9
|
+
* BEFORE the dispatch call (claim-before-call), so two clocks ticking at once
|
|
10
|
+
* dispatch an attempt at most once. A crash before the call, or an ambiguous
|
|
11
|
+
* acceptance, leaves `dispatch_outcome` null/`ambiguous`: after the admission
|
|
12
|
+
* timeout the run is looked up by display_title; if none appeared, a NEW
|
|
13
|
+
* attempt is dispatched (`dispatch_requested` → `dispatch_requested`). Creating
|
|
14
|
+
* a run has no effect of its own; the workflow's claim is the gate.
|
|
15
|
+
* - `started` is entered by the workflow's OIDC-bound claim, which carries a
|
|
16
|
+
* monotonically increasing `claim_epoch` (the fencing token). A lease is not
|
|
17
|
+
* exactly-once: a stale holder's writes are rejected by epoch.
|
|
18
|
+
* - `completed` carries a conclusion. GitHub's run conclusion is authoritative;
|
|
19
|
+
* the workflow's own finish call is an optimization.
|
|
20
|
+
* - `timed_out`: the backend canceled a run past the schedule's timeoutMinutes.
|
|
21
|
+
* - Re-execution (`completed`/`timed_out` → `started` with a new epoch) happens
|
|
22
|
+
* automatically ONLY when the schedule's effects allow it
|
|
23
|
+
* (mayReexecuteAutomatically); `unsafe` effects wait for a human.
|
|
24
|
+
* - `superseded`: under catchUp `latest`, an older missed slot whose successor ran.
|
|
25
|
+
* - `missed`: under catchUp `skip`, a slot recorded as not run.
|
|
26
|
+
* - `acknowledged_loss`: a person applied SCHEDULE_LOSS_ACK_LABEL to its incident.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const SCHEDULE_EXECUTION_STATES = [
|
|
30
|
+
"due",
|
|
31
|
+
"dispatch_requested",
|
|
32
|
+
"started",
|
|
33
|
+
"completed",
|
|
34
|
+
"output_verified",
|
|
35
|
+
"timed_out",
|
|
36
|
+
"superseded",
|
|
37
|
+
"missed",
|
|
38
|
+
"acknowledged_loss",
|
|
39
|
+
] as const;
|
|
40
|
+
export type ScheduleExecutionState = (typeof SCHEDULE_EXECUTION_STATES)[number];
|
|
41
|
+
|
|
42
|
+
export const SCHEDULE_RUN_CONCLUSIONS = ["success", "failure"] as const;
|
|
43
|
+
export type ScheduleRunConclusion = (typeof SCHEDULE_RUN_CONCLUSIONS)[number];
|
|
44
|
+
|
|
45
|
+
export const SCHEDULE_DISPATCH_OUTCOMES = [
|
|
46
|
+
"accepted",
|
|
47
|
+
"rejected",
|
|
48
|
+
"ambiguous",
|
|
49
|
+
] as const;
|
|
50
|
+
export type ScheduleDispatchOutcome =
|
|
51
|
+
(typeof SCHEDULE_DISPATCH_OUTCOMES)[number];
|
|
52
|
+
|
|
53
|
+
/** Allowed moves. A move not listed here is a reconciler bug, never a data repair. */
|
|
54
|
+
export const SCHEDULE_EXECUTION_TRANSITIONS: Readonly<
|
|
55
|
+
Record<ScheduleExecutionState, readonly ScheduleExecutionState[]>
|
|
56
|
+
> = {
|
|
57
|
+
due: ["dispatch_requested", "superseded", "missed", "acknowledged_loss"],
|
|
58
|
+
dispatch_requested: ["dispatch_requested", "started", "acknowledged_loss"],
|
|
59
|
+
started: ["started", "completed", "timed_out"],
|
|
60
|
+
completed: ["output_verified", "started", "acknowledged_loss"],
|
|
61
|
+
timed_out: ["started", "acknowledged_loss"],
|
|
62
|
+
output_verified: [],
|
|
63
|
+
superseded: [],
|
|
64
|
+
missed: [],
|
|
65
|
+
acknowledged_loss: [],
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export function canTransitionScheduleExecution(
|
|
69
|
+
from: ScheduleExecutionState,
|
|
70
|
+
to: ScheduleExecutionState,
|
|
71
|
+
): boolean {
|
|
72
|
+
return SCHEDULE_EXECUTION_TRANSITIONS[from].includes(to);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface ScheduleExecutionSettlementInput {
|
|
76
|
+
readonly state: ScheduleExecutionState;
|
|
77
|
+
readonly conclusion: ScheduleRunConclusion | null;
|
|
78
|
+
/** True when the schedule declares an output verifier. */
|
|
79
|
+
readonly requiresVerification: boolean;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Settled = nothing further will happen to this slot without a human.
|
|
84
|
+
* A successful completion settles only when no verifier is declared.
|
|
85
|
+
*/
|
|
86
|
+
export function isScheduleExecutionSettled(
|
|
87
|
+
row: ScheduleExecutionSettlementInput,
|
|
88
|
+
): boolean {
|
|
89
|
+
switch (row.state) {
|
|
90
|
+
case "output_verified":
|
|
91
|
+
case "superseded":
|
|
92
|
+
case "missed":
|
|
93
|
+
case "acknowledged_loss":
|
|
94
|
+
return true;
|
|
95
|
+
case "completed":
|
|
96
|
+
return row.conclusion === "success" && !row.requiresVerification;
|
|
97
|
+
default:
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** What the workflow's side effects tolerate. Declared per schedule; required. */
|
|
103
|
+
export const SCHEDULE_EFFECTS = [
|
|
104
|
+
"none",
|
|
105
|
+
"idempotent",
|
|
106
|
+
"fenced",
|
|
107
|
+
"unsafe",
|
|
108
|
+
] as const;
|
|
109
|
+
export type ScheduleEffects = (typeof SCHEDULE_EFFECTS)[number];
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* none: read-only. idempotent: downstream writes keyed by execution_id.
|
|
113
|
+
* fenced: downstream writes carry claim_epoch and stale epochs are rejected.
|
|
114
|
+
* unsafe: none of the above: never re-executed without a human.
|
|
115
|
+
*/
|
|
116
|
+
export function mayReexecuteAutomatically(effects: ScheduleEffects): boolean {
|
|
117
|
+
return effects !== "unsafe";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export const SCHEDULE_INCIDENT_CLASSES = [
|
|
121
|
+
"clock",
|
|
122
|
+
"admission",
|
|
123
|
+
"delivery",
|
|
124
|
+
"execution",
|
|
125
|
+
"output",
|
|
126
|
+
] as const;
|
|
127
|
+
export type ScheduleIncidentClass = (typeof SCHEDULE_INCIDENT_CLASSES)[number];
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Dedupe key for an incident. Subject: the clock source (clock), the
|
|
131
|
+
* schedule_id (delivery), or the execution_id (admission, execution, output).
|
|
132
|
+
* Each class resolves on its own subject, never on an unrelated newer run.
|
|
133
|
+
*/
|
|
134
|
+
export function scheduleIncidentKey(
|
|
135
|
+
incidentClass: ScheduleIncidentClass,
|
|
136
|
+
subject: string,
|
|
137
|
+
): string {
|
|
138
|
+
return `schedule:${incidentClass}:${subject}`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export const SCHEDULE_INFRA_LABEL = "schedule-infra";
|
|
142
|
+
export const SCHEDULE_LOSS_ACK_LABEL = "schedule-loss-acknowledged";
|
|
143
|
+
|
|
144
|
+
/** Independent tick sources. Each one's health is tracked and alarmed separately. */
|
|
145
|
+
export const SCHEDULE_CLOCK_SOURCES = ["edge", "jobqueue"] as const;
|
|
146
|
+
export type ScheduleClockSource = (typeof SCHEDULE_CLOCK_SOURCES)[number];
|