@company-semantics/contracts 98.0.0 → 99.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/package.json +1 -1
- package/src/api/generated-spec-hash.ts +2 -2
- package/src/api/generated.ts +51 -2
- package/src/chat/__tests__/runtime-profile.test.ts +23 -5
- package/src/generated/openapi-routes.ts +1 -0
- package/src/index.ts +4 -1
- package/src/ingestion/README.md +51 -21
- package/src/ingestion/__tests__/providers.test.ts +59 -0
- package/src/ingestion/__tests__/registry.test.ts +18 -18
- package/src/ingestion/__tests__/surfaces.test.ts +68 -40
- package/src/ingestion/index.ts +20 -2
- package/src/ingestion/providers.ts +141 -0
- package/src/ingestion/registry.ts +44 -25
- package/src/ingestion/surfaces.ts +81 -28
- package/src/ingestion/types.ts +80 -15
- 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,51 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
displayTitleFor,
|
|
4
|
+
executionIdFor,
|
|
5
|
+
parseDisplayTitle,
|
|
6
|
+
parseExecutionId,
|
|
7
|
+
slotIso,
|
|
8
|
+
} from "../identity";
|
|
9
|
+
|
|
10
|
+
describe("schedules identity", () => {
|
|
11
|
+
it("round-trips an execution id and a display title", () => {
|
|
12
|
+
const slot = new Date("2026-10-10T10:00:00Z");
|
|
13
|
+
const executionId = executionIdFor("production-signals-signals", slot);
|
|
14
|
+
expect(executionId).toBe("production-signals-signals/2026-10-10T10:00:00Z");
|
|
15
|
+
expect(parseExecutionId(executionId)).toEqual({
|
|
16
|
+
scheduleId: "production-signals-signals",
|
|
17
|
+
slot,
|
|
18
|
+
});
|
|
19
|
+
expect(displayTitleFor(executionId, 2)).toBe(
|
|
20
|
+
"production-signals-signals/2026-10-10T10:00:00Z#2",
|
|
21
|
+
);
|
|
22
|
+
expect(parseDisplayTitle(displayTitleFor(executionId, 2))).toEqual({
|
|
23
|
+
scheduleId: "production-signals-signals",
|
|
24
|
+
slot,
|
|
25
|
+
executionId,
|
|
26
|
+
attempt: 2,
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it("refuses a slot that is not a whole UTC minute", () => {
|
|
31
|
+
expect(() => slotIso(new Date("2026-10-10T10:00:30Z"))).toThrow(RangeError);
|
|
32
|
+
expect(parseExecutionId("x/2026-10-10T10:00:30Z")).toBeNull();
|
|
33
|
+
expect(() =>
|
|
34
|
+
executionIdFor("Bad/Id", new Date("2026-10-10T10:00:00Z")),
|
|
35
|
+
).toThrow(RangeError);
|
|
36
|
+
expect(() => displayTitleFor("x/2026-10-10T10:00:00Z", 0)).toThrow(
|
|
37
|
+
RangeError,
|
|
38
|
+
);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("does not parse a manual run title", () => {
|
|
42
|
+
for (const title of [
|
|
43
|
+
"Production signals",
|
|
44
|
+
"x/2026-10-10T10:00:00Z",
|
|
45
|
+
"x/2026-10-10T10:00:00Z#0",
|
|
46
|
+
"#1",
|
|
47
|
+
]) {
|
|
48
|
+
expect(parseDisplayTitle(title)).toBeNull();
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import * as root from "../../index";
|
|
3
|
+
|
|
4
|
+
describe("schedules exports", () => {
|
|
5
|
+
it("exports the scheduled-execution vocabulary from the package root", () => {
|
|
6
|
+
for (const name of [
|
|
7
|
+
"parseCron",
|
|
8
|
+
"previousSlot",
|
|
9
|
+
"slotsBetween",
|
|
10
|
+
"executionIdFor",
|
|
11
|
+
"parseDisplayTitle",
|
|
12
|
+
"SCHEDULE_RUN_NAME",
|
|
13
|
+
"SCHEDULE_DISPATCH_INPUTS",
|
|
14
|
+
"SCHEDULE_EXECUTION_TRANSITIONS",
|
|
15
|
+
"SCHEDULE_EFFECTS",
|
|
16
|
+
"ScheduleDefinitionSchema",
|
|
17
|
+
"ScheduleRegistrySnapshotSchema",
|
|
18
|
+
"canonicalScheduleRegistryJson",
|
|
19
|
+
"ScheduleClaimResponseSchema",
|
|
20
|
+
"SCHEDULE_TICK_PATH",
|
|
21
|
+
"SCHEDULE_METRICS_NAMESPACE",
|
|
22
|
+
]) {
|
|
23
|
+
expect(root).toHaveProperty(name);
|
|
24
|
+
}
|
|
25
|
+
});
|
|
26
|
+
});
|
|
@@ -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
|
+
>;
|