@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
package/src/ingestion/types.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* private dispatch fields locally) and the app upload UI (which derives its
|
|
8
8
|
* `accept` attribute and supported-formats copy from this).
|
|
9
9
|
*
|
|
10
|
-
* Types
|
|
10
|
+
* Types plus the `FORMAT_CLASSES` and `TREATMENTS` vocabulary constants. The backend-only normalizer routing
|
|
11
11
|
* (`canonicalKind`, `normalizerKey`) deliberately does NOT live here; it is an
|
|
12
12
|
* implementation detail of the backend dispatch and would pollute the shared
|
|
13
13
|
* vocabulary.
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
/**
|
|
19
19
|
* The semantic family a format belongs to. A SURFACE (an upload picker and the
|
|
20
|
-
* route behind it) declares
|
|
20
|
+
* route behind it) declares a treatment per family it consumes; the accept-set and the
|
|
21
21
|
* byte ceiling for that surface derive from the registry through this field.
|
|
22
22
|
* Public and semantic — NOT the backend normalizer dispatch (`canonicalKind` /
|
|
23
23
|
* `normalizerKey` stay backend-local, ADR-CONTRACTS-075).
|
|
@@ -27,6 +27,29 @@
|
|
|
27
27
|
export type IngestionFamily =
|
|
28
28
|
"text" | "document" | "image" | "diagram" | "audio" | "video";
|
|
29
29
|
|
|
30
|
+
/**
|
|
31
|
+
* The coarse, provider-agnostic kind of a format, used wherever the system
|
|
32
|
+
* classes a file without reading it: the Source Sync inventory `mime_class`,
|
|
33
|
+
* the discovery topology mix, probe groups, and the spreadsheet `source_kind`.
|
|
34
|
+
* Declared ON the format entry — no consumer keeps an extension → class table.
|
|
35
|
+
* Canonical order (the inventory CHECK and every derived list follow it).
|
|
36
|
+
*/
|
|
37
|
+
export const FORMAT_CLASSES = [
|
|
38
|
+
"docs",
|
|
39
|
+
"slides",
|
|
40
|
+
"sheets",
|
|
41
|
+
"pdf",
|
|
42
|
+
"text",
|
|
43
|
+
"image",
|
|
44
|
+
"diagram",
|
|
45
|
+
"audio",
|
|
46
|
+
"video",
|
|
47
|
+
] as const;
|
|
48
|
+
export type FormatClass = (typeof FORMAT_CLASSES)[number];
|
|
49
|
+
|
|
50
|
+
/** A Source Sync inventory class: a format class, or a provider shortcut row. */
|
|
51
|
+
export type InventoryClass = FormatClass | "shortcut";
|
|
52
|
+
|
|
30
53
|
/**
|
|
31
54
|
* One accepted upload format, keyed by its canonical MIME type.
|
|
32
55
|
*
|
|
@@ -35,14 +58,17 @@ export type IngestionFamily =
|
|
|
35
58
|
* - `extensions` are lowercase, dot-prefixed (e.g. `.docx`).
|
|
36
59
|
* - `label` is the human display GROUP and is intentionally shared across
|
|
37
60
|
* related formats (`.docx` + `.doc` are both `Word`; the rasters are all
|
|
38
|
-
* `Image`; `.svg`
|
|
61
|
+
* `Image`; `.svg` is `Diagram`).
|
|
39
62
|
* - `family` is the semantic family a surface selects on; every entry has one.
|
|
63
|
+
* - `class` is the coarse class (one of `FORMAT_CLASSES`); every entry has one.
|
|
40
64
|
*/
|
|
41
65
|
export interface IngestionFormat {
|
|
42
66
|
/** Canonical MIME type (the registry key). */
|
|
43
67
|
mime: string;
|
|
44
|
-
/** Semantic family the format belongs to (what a surface
|
|
68
|
+
/** Semantic family the format belongs to (what a surface declares a treatment for). */
|
|
45
69
|
family: IngestionFamily;
|
|
70
|
+
/** Coarse class for classing without reading (inventory, topology, probes). */
|
|
71
|
+
class: FormatClass;
|
|
46
72
|
/** Lowercase, dot-prefixed filename extensions for this format. */
|
|
47
73
|
extensions: readonly string[];
|
|
48
74
|
/** Human display group label (e.g. `Word`, `Image`, `Audio`). */
|
|
@@ -51,23 +77,62 @@ export interface IngestionFormat {
|
|
|
51
77
|
maxBytes: number;
|
|
52
78
|
}
|
|
53
79
|
|
|
54
|
-
/** The upload surfaces that consume the registry. */
|
|
55
|
-
export type IngestionSurfaceKey = "company-md" | "org-chart";
|
|
56
|
-
|
|
57
80
|
/**
|
|
58
|
-
* What
|
|
59
|
-
*
|
|
81
|
+
* What a surface does with an admitted family. The backend proves at load that
|
|
82
|
+
* it can perform every declared (surface, family, treatment) for every admitted
|
|
83
|
+
* format, or it refuses to start.
|
|
60
84
|
*
|
|
61
|
-
*
|
|
85
|
+
* - `text`: normalize to text only (a PDF is text-extracted).
|
|
86
|
+
* - `vision`: hand the model the richest native form — images as image parts,
|
|
87
|
+
* a PDF as a binary document, text-native formats (docx, csv, …) as text.
|
|
88
|
+
* - `transcript`: audio/video become a transcript.
|
|
89
|
+
* - `store`: bytes are kept as-is (after format-preserving preparation such as
|
|
90
|
+
* HEIC → JPEG); nothing is extracted.
|
|
91
|
+
*/
|
|
92
|
+
export const TREATMENTS = ["text", "vision", "transcript", "store"] as const;
|
|
93
|
+
export type Treatment = (typeof TREATMENTS)[number];
|
|
94
|
+
|
|
95
|
+
/** The surfaces that consume the registry. */
|
|
96
|
+
export type IngestionSurfaceKey =
|
|
97
|
+
"company-md" | "org-chart" | "chat" | "profile-image";
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* One surface: the treatment it applies per admitted family, and its byte
|
|
101
|
+
* ceiling. `families` is derived (`familiesOf`), never declared.
|
|
62
102
|
*/
|
|
63
103
|
export interface IngestionSurface {
|
|
64
104
|
/** The surface this declaration describes. */
|
|
65
105
|
key: IngestionSurfaceKey;
|
|
66
|
-
/**
|
|
67
|
-
|
|
106
|
+
/** Admitted family → what the surface does with it. Absent family = refused (415). */
|
|
107
|
+
treatments: Readonly<Partial<Record<IngestionFamily, Treatment>>>;
|
|
108
|
+
/** Surface byte ceiling (required). Caps every admitted format below its registry `maxBytes`. */
|
|
109
|
+
maxBytes: number;
|
|
68
110
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
111
|
+
* Most files one turn may carry. Declared only on `chat` (required there,
|
|
112
|
+
* pinned by test); upload surfaces take one file per request and omit it.
|
|
71
113
|
*/
|
|
72
|
-
|
|
114
|
+
maxFilesPerTurn?: number;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** External sources that hand the pipeline provider-native files. */
|
|
118
|
+
export type IngestionProviderKey = "google-drive";
|
|
119
|
+
|
|
120
|
+
/** A provider-native type that is exported to a registry format before ingestion. */
|
|
121
|
+
export interface ProviderNativeFormat {
|
|
122
|
+
/** The provider's own MIME (e.g. a Google Workspace type). */
|
|
123
|
+
mime: string;
|
|
124
|
+
/** Human label for the native type (e.g. `Google Doc`). */
|
|
125
|
+
label: string;
|
|
126
|
+
/** Registry MIME the provider exports this type to. Must resolve via `byMime`. */
|
|
127
|
+
exportsTo: string;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One provider: its native types and the structural container MIMEs it lists. */
|
|
131
|
+
export interface IngestionProvider {
|
|
132
|
+
/** The provider this declaration describes. */
|
|
133
|
+
key: IngestionProviderKey;
|
|
134
|
+
/** Native types the provider exports to registry formats. */
|
|
135
|
+
nativeFormats: readonly ProviderNativeFormat[];
|
|
136
|
+
/** Structural (non-file) MIMEs the provider lists: folders and shortcuts. */
|
|
137
|
+
containerMimes: { readonly folder: string; readonly shortcut: string };
|
|
73
138
|
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
# schedules/
|
|
2
|
+
|
|
3
|
+
The shared vocabulary of scheduled execution: what a schedule promises, how one
|
|
4
|
+
execution of it is named, the states it moves through, and the UTC cron
|
|
5
|
+
evaluator both control and the backend use to decide when a slot is due.
|
|
6
|
+
|
|
7
|
+
## Purpose
|
|
8
|
+
|
|
9
|
+
Name, time and track every scheduled job across the fleet with one vocabulary,
|
|
10
|
+
so control (which authors the registry) and the backend (which stores the
|
|
11
|
+
ledger and reconciles) can never disagree about a slot.
|
|
12
|
+
|
|
13
|
+
## Invariants
|
|
14
|
+
|
|
15
|
+
- An execution is named by its INTENDED slot: `execution_id = <schedule_id>/<YYYY-MM-DDTHH:MM:00Z>`;
|
|
16
|
+
a dispatch attempt is `<execution_id>#<attempt>` and is the workflow's `run-name`.
|
|
17
|
+
A run without one never satisfies a slot.
|
|
18
|
+
- `parseCron` is the only cron evaluator: five fields, UTC, Vixie day matching,
|
|
19
|
+
no names / `L` / `W` / `#`; an expression that can never fire does not parse.
|
|
20
|
+
- Every ScheduleDefinition declares `effects`; `fenced` and `unsafe` require
|
|
21
|
+
`idempotency: "required"`; `catchUp: "all"` never replays `unsafe` effects.
|
|
22
|
+
- `canonicalScheduleRegistryJson` is the only serialization the snapshot's
|
|
23
|
+
`contentHash` is computed over.
|
|
24
|
+
- Response schemas only; request bodies live in the backend beside their routes
|
|
25
|
+
(ScheduleTickRequest is a type, not a schema).
|
|
26
|
+
- Pure: no clock reads, no I/O, only `zod`.
|
|
27
|
+
|
|
28
|
+
## Dependencies
|
|
29
|
+
|
|
30
|
+
`zod` only. Reached through the package root; there is no `./schedules` subpath.
|
|
31
|
+
|
|
32
|
+
<!-- BEGIN GENERATED: readme-public-api — derived from code by `pnpm readme-api`. Do not edit. -->
|
|
33
|
+
|
|
34
|
+
## Public API
|
|
35
|
+
|
|
36
|
+
- `CronField` _(type)_ — Five-field UTC cron evaluator for the schedule registry.
|
|
37
|
+
- `CronParseResult` _(type)_
|
|
38
|
+
- `ParsedCron` _(type)_
|
|
39
|
+
- `ParsedDisplayTitle` _(type)_
|
|
40
|
+
- `ParsedExecutionId` _(type)_
|
|
41
|
+
- `SCHEDULE_CATCH_UP_POLICIES`
|
|
42
|
+
- `SCHEDULE_CLAIM_REFUSALS`
|
|
43
|
+
- `SCHEDULE_CLOCK_SOURCES` — Independent tick sources.
|
|
44
|
+
- `SCHEDULE_DEFINITION_KEYS` — Key order used by canonicalScheduleRegistryJson.
|
|
45
|
+
- `SCHEDULE_DISPATCH_INPUTS` — The workflowdispatch inputs every registered workflow declares once it leaves mode `off`.
|
|
46
|
+
- `SCHEDULE_DISPATCH_OUTCOMES`
|
|
47
|
+
- `SCHEDULE_EFFECTS` — What the workflow's side effects tolerate.
|
|
48
|
+
- `SCHEDULE_EXECUTION_STATES` — Scheduled-execution lifecycle, recovery and incident vocabulary.
|
|
49
|
+
- `SCHEDULE_EXECUTION_TRANSITIONS` — Allowed moves.
|
|
50
|
+
- `SCHEDULE_FINISH_REJECTIONS`
|
|
51
|
+
- `SCHEDULE_IDEMPOTENCY_LEVELS`
|
|
52
|
+
- `SCHEDULE_ID_MAX_LENGTH`
|
|
53
|
+
- `SCHEDULE_ID_PATTERN` — Kebab-case, no `/` or `#`, so an executionid and a display title parse unambiguously.
|
|
54
|
+
- `SCHEDULE_INCIDENT_CLASSES`
|
|
55
|
+
- `SCHEDULE_INFRA_LABEL`
|
|
56
|
+
- `SCHEDULE_LOSS_ACK_LABEL`
|
|
57
|
+
- `SCHEDULE_MAX_CATCH_UP_SLOTS`
|
|
58
|
+
- `SCHEDULE_MAX_TIMEOUT_MINUTES` — GitHub-hosted job ceiling.
|
|
59
|
+
- `SCHEDULE_METRICS_NAMESPACE` — CloudWatch EMF metric names (backend emits; control's watchdog script alarms).
|
|
60
|
+
- `SCHEDULE_METRIC_ENVIRONMENT_DIMENSION` — Every Schedules metric carries this dimension (value: the backend deploy environment, e.g. "production" /…
|
|
61
|
+
- `SCHEDULE_METRIC_RECONCILE_SUCCESS`
|
|
62
|
+
- `SCHEDULE_METRIC_TICK`
|
|
63
|
+
- `SCHEDULE_METRIC_TICK_SOURCE_DIMENSION`
|
|
64
|
+
- `SCHEDULE_MODES` — off: GitHub cron only, clock ignores it. shadow: clock plans, never dispatches. live: clock dispatches…
|
|
65
|
+
- `SCHEDULE_OUTPUT_VERIFIERS` — Closed catalog. v0 has one verifier.
|
|
66
|
+
- `SCHEDULE_REGISTRY_SCHEMA_VERSION`
|
|
67
|
+
- `SCHEDULE_RUN_CONCLUSIONS`
|
|
68
|
+
- `SCHEDULE_RUN_NAME` — The exact `run-name:` a registered workflow declares, so displaytitle = executionid#attempt.
|
|
69
|
+
- `SCHEDULE_SEVERITIES`
|
|
70
|
+
- `SCHEDULE_SLOT_ISO_PATTERN`
|
|
71
|
+
- `SCHEDULE_TICK_PATH` — The tick route.
|
|
72
|
+
- `SCHEDULE_TIMEZONE`
|
|
73
|
+
- `ScheduleCatchUpPolicy` _(type)_
|
|
74
|
+
- `ScheduleClaimRefusal` _(type)_
|
|
75
|
+
- `ScheduleClaimResponse` _(type)_
|
|
76
|
+
- `ScheduleClaimResponseSchema`
|
|
77
|
+
- `ScheduleClockSource` _(type)_
|
|
78
|
+
- `ScheduleDefinition` _(type)_
|
|
79
|
+
- `ScheduleDefinitionSchema`
|
|
80
|
+
- `ScheduleDispatchInput` _(type)_
|
|
81
|
+
- `ScheduleDispatchOutcome` _(type)_
|
|
82
|
+
- `ScheduleEffects` _(type)_
|
|
83
|
+
- `ScheduleExecutionSettlementInput` _(type)_
|
|
84
|
+
- `ScheduleExecutionState` _(type)_
|
|
85
|
+
- `ScheduleFinishRejection` _(type)_
|
|
86
|
+
- `ScheduleFinishResponse` _(type)_
|
|
87
|
+
- `ScheduleFinishResponseSchema`
|
|
88
|
+
- `ScheduleIdempotency` _(type)_
|
|
89
|
+
- `ScheduleIncidentClass` _(type)_
|
|
90
|
+
- `ScheduleMode` _(type)_
|
|
91
|
+
- `ScheduleOutputVerifier` _(type)_
|
|
92
|
+
- `ScheduleRegistrySnapshot` _(type)_
|
|
93
|
+
- `ScheduleRegistrySnapshotSchema`
|
|
94
|
+
- `ScheduleRunConclusion` _(type)_
|
|
95
|
+
- `ScheduleSeverity` _(type)_
|
|
96
|
+
- `ScheduleTickRequest` _(type)_ — Tick request body, as a TYPE only: request schemas stay in the backend beside the route (contracts…
|
|
97
|
+
- `ScheduleTickResponse` _(type)_
|
|
98
|
+
- `ScheduleTickResponseSchema` — One tick's outcome, reported to the tick source that triggered it.
|
|
99
|
+
- `SlotWindow` _(type)_
|
|
100
|
+
- `canTransitionScheduleExecution`
|
|
101
|
+
- `canonicalScheduleRegistryJson` — Deterministic serialization: definitions sorted by id, keys in SCHEDULEDEFINITIONKEYS order, no whitespace.
|
|
102
|
+
- `cronMatches` — True when `instant` is exactly a slot of `cron` (whole minute, UTC).
|
|
103
|
+
- `displayTitleFor`
|
|
104
|
+
- `executionIdFor`
|
|
105
|
+
- `isScheduleExecutionSettled` — Settled = nothing further will happen to this slot without a human.
|
|
106
|
+
- `isScheduleId`
|
|
107
|
+
- `maxIntervalMinutes` — The longest gap, in minutes, between consecutive slots among the `samples` intervals ending at or before…
|
|
108
|
+
- `mayReexecuteAutomatically` — none: read-only. idempotent: downstream writes keyed by executionid. fenced: downstream writes carry…
|
|
109
|
+
- `nextSlot` — The earliest slot strictly after `after`, or null when none exists in the search window.
|
|
110
|
+
- `parseCron` — Parse a five-field UTC cron expression.
|
|
111
|
+
- `parseDisplayTitle` — Inverse of displayTitleFor; null for any run title that is not a scheduled dispatch.
|
|
112
|
+
- `parseExecutionId`
|
|
113
|
+
- `previousSlot` — The latest slot at or before `atOrBefore`, or null when none exists in the search window.
|
|
114
|
+
- `scheduleIncidentKey` — Dedupe key for an incident.
|
|
115
|
+
- `slotIso` — `YYYY-MM-DDTHH:MM:00Z`.
|
|
116
|
+
- `slotsBetween` — Slots in (afterExclusive, atOrBefore, newest `limit` kept, returned ascending.
|
|
117
|
+
|
|
118
|
+
<!-- END GENERATED: readme-public-api -->
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# schedules/\_\_tests\_\_/
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Locks the scheduled-execution vocabulary's behavior with table tests.
|
|
6
|
+
|
|
7
|
+
- `cron.test.ts` — locks the UTC cron evaluator: every cron the fleet's
|
|
8
|
+
workflows carry today parses (the `IN_USE` table, measured 2026-10-10);
|
|
9
|
+
unsupported syntax (names, `L`, `W`, `#`, `?`, macros, a step on a single
|
|
10
|
+
value) and expressions that can never fire are rejected; day-of-week 7 folds
|
|
11
|
+
into Sunday; Vixie day matching (OR only when both day fields are restricted);
|
|
12
|
+
previous/next slots across month ends and leap days; truncated slot windows
|
|
13
|
+
keep the newest slots; and the longest interval of an irregular schedule.
|
|
14
|
+
|
|
15
|
+
## Invariants
|
|
16
|
+
|
|
17
|
+
- Every instant is passed in explicitly; no test reads the clock.
|
|
18
|
+
- The `IN_USE` table is a measurement of the fleet's workflows. A new cron in a
|
|
19
|
+
workflow belongs here too, so the evaluator is proven against it.
|
|
20
|
+
|
|
21
|
+
## Public API
|
|
22
|
+
|
|
23
|
+
None — test-only.
|
|
24
|
+
|
|
25
|
+
## Dependencies
|
|
26
|
+
|
|
27
|
+
`vitest` and `../cron`.
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SCHEDULE_METRICS_NAMESPACE,
|
|
4
|
+
SCHEDULE_METRIC_RECONCILE_SUCCESS,
|
|
5
|
+
SCHEDULE_METRIC_TICK,
|
|
6
|
+
SCHEDULE_METRIC_TICK_SOURCE_DIMENSION,
|
|
7
|
+
SCHEDULE_METRIC_ENVIRONMENT_DIMENSION,
|
|
8
|
+
SCHEDULE_TICK_PATH,
|
|
9
|
+
ScheduleClaimResponseSchema,
|
|
10
|
+
ScheduleFinishResponseSchema,
|
|
11
|
+
ScheduleTickResponseSchema,
|
|
12
|
+
} from "../api";
|
|
13
|
+
|
|
14
|
+
describe("schedules api responses", () => {
|
|
15
|
+
it("admits a legacy run without an execution id", () => {
|
|
16
|
+
expect(
|
|
17
|
+
ScheduleClaimResponseSchema.safeParse({
|
|
18
|
+
outcome: "legacy",
|
|
19
|
+
scheduleId: "pin-audit",
|
|
20
|
+
mode: "shadow",
|
|
21
|
+
}).success,
|
|
22
|
+
).toBe(true);
|
|
23
|
+
expect(
|
|
24
|
+
ScheduleClaimResponseSchema.safeParse({
|
|
25
|
+
outcome: "legacy",
|
|
26
|
+
scheduleId: "pin-audit",
|
|
27
|
+
mode: "shadow",
|
|
28
|
+
executionId: "x",
|
|
29
|
+
}).success,
|
|
30
|
+
).toBe(false);
|
|
31
|
+
expect(
|
|
32
|
+
ScheduleClaimResponseSchema.safeParse({
|
|
33
|
+
outcome: "refused",
|
|
34
|
+
reason: "legacy_run_not_admitted",
|
|
35
|
+
}).success,
|
|
36
|
+
).toBe(true);
|
|
37
|
+
expect(
|
|
38
|
+
ScheduleFinishResponseSchema.safeParse({
|
|
39
|
+
recorded: false,
|
|
40
|
+
reason: "stale_epoch",
|
|
41
|
+
}).success,
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
ScheduleTickResponseSchema.safeParse({
|
|
45
|
+
source: "edge",
|
|
46
|
+
tickedAt: "2026-10-10T10:00:00Z",
|
|
47
|
+
reconcileOk: true,
|
|
48
|
+
registryVersion: null,
|
|
49
|
+
dispatched: 0,
|
|
50
|
+
incidentsOpened: 0,
|
|
51
|
+
incidentsResolved: 0,
|
|
52
|
+
}).success,
|
|
53
|
+
).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("keeps the tick path and metric names stable", () => {
|
|
57
|
+
expect(SCHEDULE_TICK_PATH).toBe("/api/schedules/tick");
|
|
58
|
+
expect([
|
|
59
|
+
SCHEDULE_METRICS_NAMESPACE,
|
|
60
|
+
SCHEDULE_METRIC_TICK,
|
|
61
|
+
SCHEDULE_METRIC_TICK_SOURCE_DIMENSION,
|
|
62
|
+
SCHEDULE_METRIC_RECONCILE_SUCCESS,
|
|
63
|
+
SCHEDULE_METRIC_ENVIRONMENT_DIMENSION,
|
|
64
|
+
]).toEqual([
|
|
65
|
+
"Schedules",
|
|
66
|
+
"Tick",
|
|
67
|
+
"source",
|
|
68
|
+
"ReconcileSuccess",
|
|
69
|
+
"environment",
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
maxIntervalMinutes,
|
|
4
|
+
nextSlot,
|
|
5
|
+
parseCron,
|
|
6
|
+
previousSlot,
|
|
7
|
+
slotsBetween,
|
|
8
|
+
type ParsedCron,
|
|
9
|
+
} from "../cron";
|
|
10
|
+
|
|
11
|
+
function cron(expression: string): ParsedCron {
|
|
12
|
+
const result = parseCron(expression);
|
|
13
|
+
if (!result.ok) throw new Error(`${expression}: ${result.reason}`);
|
|
14
|
+
return result.cron;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const at = (iso: string) => new Date(iso);
|
|
18
|
+
const iso = (d: Date | null) => (d === null ? null : d.toISOString());
|
|
19
|
+
|
|
20
|
+
/** Every distinct cron on the seven Node repos' origin/main workflows, measured 2026-10-10. */
|
|
21
|
+
const IN_USE = [
|
|
22
|
+
"*/30 * * * *",
|
|
23
|
+
"7 * * * *",
|
|
24
|
+
"13,43 * * * *",
|
|
25
|
+
"17 * * * *",
|
|
26
|
+
"30 */6 * * *",
|
|
27
|
+
"0 6 * * *",
|
|
28
|
+
"0 7 * * *",
|
|
29
|
+
"0 8 * * *",
|
|
30
|
+
"0 10 * * *",
|
|
31
|
+
"0 14 * * *",
|
|
32
|
+
"0 15 * * *",
|
|
33
|
+
"17 16 * * *",
|
|
34
|
+
"17 9 * * 1",
|
|
35
|
+
"30 9 * * 1,4",
|
|
36
|
+
"0 2 * * *",
|
|
37
|
+
"0 3 * * *",
|
|
38
|
+
"0 4 * * *",
|
|
39
|
+
"20 4 * * *",
|
|
40
|
+
"30 4 * * *",
|
|
41
|
+
"40 4 * * *",
|
|
42
|
+
"17 5 * * *",
|
|
43
|
+
"50 5 * * *",
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
describe("schedules cron", () => {
|
|
47
|
+
it("parses every cron the registry carries today", () => {
|
|
48
|
+
for (const expression of IN_USE)
|
|
49
|
+
expect(parseCron(expression).ok).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("rejects names, L/W/#, a step on a single value and impossible dates", () => {
|
|
53
|
+
for (const expression of [
|
|
54
|
+
"0 0 * JAN *",
|
|
55
|
+
"0 0 * * MON",
|
|
56
|
+
"0 0 L * *",
|
|
57
|
+
"0 0 1W * *",
|
|
58
|
+
"0 0 * * 1#2",
|
|
59
|
+
"0 0 ? * *",
|
|
60
|
+
"@hourly",
|
|
61
|
+
"5/15 * * * *",
|
|
62
|
+
"60 * * * *",
|
|
63
|
+
"* * *",
|
|
64
|
+
"0 0 31 2 *",
|
|
65
|
+
]) {
|
|
66
|
+
expect(parseCron(expression).ok).toBe(false);
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("folds day-of-week 7 into Sunday", () => {
|
|
71
|
+
expect(iso(nextSlot(cron("0 0 * * 7"), at("2026-10-10T13:25:00Z")))).toBe(
|
|
72
|
+
"2026-10-11T00:00:00.000Z",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("applies Vixie OR when both day fields are restricted", () => {
|
|
77
|
+
const c = cron("0 0 1 * 0");
|
|
78
|
+
// Sunday 2026-10-11 matches day-of-week even though it is not the 1st.
|
|
79
|
+
expect(iso(nextSlot(c, at("2026-10-10T13:25:00Z")))).toBe(
|
|
80
|
+
"2026-10-11T00:00:00.000Z",
|
|
81
|
+
);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it("treats a star-step day-of-month as unrestricted (Vixie)", () => {
|
|
85
|
+
// */2 counts as unrestricted, so both must match: odd day AND Sunday.
|
|
86
|
+
const c = cron("0 0 */2 * 0");
|
|
87
|
+
expect(iso(nextSlot(c, at("2026-10-12T00:00:00Z")))).toBe(
|
|
88
|
+
"2026-10-25T00:00:00.000Z",
|
|
89
|
+
);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("finds the previous and next slot across month ends and leap days", () => {
|
|
93
|
+
expect(iso(nextSlot(cron("0 0 31 * *"), at("2026-10-31T00:00:00Z")))).toBe(
|
|
94
|
+
"2026-12-31T00:00:00.000Z",
|
|
95
|
+
);
|
|
96
|
+
const leap = cron("0 0 29 2 *");
|
|
97
|
+
expect(iso(previousSlot(leap, at("2026-10-10T00:00:00Z")))).toBe(
|
|
98
|
+
"2024-02-29T00:00:00.000Z",
|
|
99
|
+
);
|
|
100
|
+
expect(iso(nextSlot(leap, at("2026-10-10T00:00:00Z")))).toBe(
|
|
101
|
+
"2028-02-29T00:00:00.000Z",
|
|
102
|
+
);
|
|
103
|
+
expect(
|
|
104
|
+
iso(previousSlot(cron("*/30 * * * *"), at("2026-10-10T13:00:00Z"))),
|
|
105
|
+
).toBe("2026-10-10T13:00:00.000Z");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("keeps the newest slots when a window is truncated", () => {
|
|
109
|
+
const window = slotsBetween(
|
|
110
|
+
cron("*/5 * * * *"),
|
|
111
|
+
at("2026-10-10T12:00:00Z"),
|
|
112
|
+
at("2026-10-10T13:00:00Z"),
|
|
113
|
+
4,
|
|
114
|
+
);
|
|
115
|
+
expect(window.truncated).toBe(true);
|
|
116
|
+
expect(window.slots.map(iso)).toEqual([
|
|
117
|
+
"2026-10-10T12:45:00.000Z",
|
|
118
|
+
"2026-10-10T12:50:00.000Z",
|
|
119
|
+
"2026-10-10T12:55:00.000Z",
|
|
120
|
+
"2026-10-10T13:00:00.000Z",
|
|
121
|
+
]);
|
|
122
|
+
const exact = slotsBetween(
|
|
123
|
+
cron("*/30 * * * *"),
|
|
124
|
+
at("2026-10-10T12:00:00Z"),
|
|
125
|
+
at("2026-10-10T13:00:00Z"),
|
|
126
|
+
10,
|
|
127
|
+
);
|
|
128
|
+
expect(exact).toEqual({
|
|
129
|
+
slots: [at("2026-10-10T12:30:00Z"), at("2026-10-10T13:00:00Z")],
|
|
130
|
+
truncated: false,
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("measures the longest interval of an irregular schedule", () => {
|
|
135
|
+
const now = at("2026-10-10T13:25:52Z");
|
|
136
|
+
expect(maxIntervalMinutes(cron("13,43 * * * *"), now)).toBe(30);
|
|
137
|
+
expect(maxIntervalMinutes(cron("30 9 * * 1,4"), now)).toBe(4 * 24 * 60);
|
|
138
|
+
expect(maxIntervalMinutes(cron("17 9 * * 1"), now)).toBe(7 * 24 * 60);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
ScheduleDefinitionSchema,
|
|
4
|
+
ScheduleRegistrySnapshotSchema,
|
|
5
|
+
canonicalScheduleRegistryJson,
|
|
6
|
+
type ScheduleDefinition,
|
|
7
|
+
} from "../definition";
|
|
8
|
+
|
|
9
|
+
const PRODUCTION_SIGNALS: ScheduleDefinition = {
|
|
10
|
+
id: "production-signals-signals",
|
|
11
|
+
repo: "company-semantics-control",
|
|
12
|
+
workflow: "production-signals.yml",
|
|
13
|
+
cron: "*/30 * * * *",
|
|
14
|
+
timezone: "UTC",
|
|
15
|
+
graceMinutes: 5,
|
|
16
|
+
timeoutMinutes: 15,
|
|
17
|
+
admissionTimeoutMinutes: 10,
|
|
18
|
+
catchUp: "latest",
|
|
19
|
+
maxCatchUpSlots: 4,
|
|
20
|
+
idempotency: "required",
|
|
21
|
+
effects: "idempotent",
|
|
22
|
+
verify: "production_signal_fresh",
|
|
23
|
+
mode: "off",
|
|
24
|
+
severity: "red-main",
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
describe("schedules definition", () => {
|
|
28
|
+
it("accepts the production-signals definition", () => {
|
|
29
|
+
expect(ScheduleDefinitionSchema.safeParse(PRODUCTION_SIGNALS).success).toBe(
|
|
30
|
+
true,
|
|
31
|
+
);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("requires idempotency required for fenced or unsafe effects", () => {
|
|
35
|
+
for (const effects of ["fenced", "unsafe"] as const) {
|
|
36
|
+
const result = ScheduleDefinitionSchema.safeParse({
|
|
37
|
+
...PRODUCTION_SIGNALS,
|
|
38
|
+
effects,
|
|
39
|
+
idempotency: "best_effort",
|
|
40
|
+
});
|
|
41
|
+
expect(result.success).toBe(false);
|
|
42
|
+
}
|
|
43
|
+
const replayUnsafe = ScheduleDefinitionSchema.safeParse({
|
|
44
|
+
...PRODUCTION_SIGNALS,
|
|
45
|
+
effects: "unsafe",
|
|
46
|
+
catchUp: "all",
|
|
47
|
+
});
|
|
48
|
+
expect(replayUnsafe.success).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("rejects an unknown field", () => {
|
|
52
|
+
expect(
|
|
53
|
+
ScheduleDefinitionSchema.safeParse({ ...PRODUCTION_SIGNALS, extra: 1 })
|
|
54
|
+
.success,
|
|
55
|
+
).toBe(false);
|
|
56
|
+
expect(
|
|
57
|
+
ScheduleDefinitionSchema.safeParse({
|
|
58
|
+
...PRODUCTION_SIGNALS,
|
|
59
|
+
cron: "0 0 31 2 *",
|
|
60
|
+
}).success,
|
|
61
|
+
).toBe(false);
|
|
62
|
+
expect(
|
|
63
|
+
ScheduleDefinitionSchema.safeParse({
|
|
64
|
+
...PRODUCTION_SIGNALS,
|
|
65
|
+
timezone: "America/New_York",
|
|
66
|
+
}).success,
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("serializes the registry canonically regardless of input order", () => {
|
|
71
|
+
const other: ScheduleDefinition = {
|
|
72
|
+
...PRODUCTION_SIGNALS,
|
|
73
|
+
id: "audit-rollup",
|
|
74
|
+
cron: "0 6 * * *",
|
|
75
|
+
};
|
|
76
|
+
const reordered = Object.fromEntries(
|
|
77
|
+
Object.entries(PRODUCTION_SIGNALS).reverse(),
|
|
78
|
+
) as ScheduleDefinition;
|
|
79
|
+
expect(canonicalScheduleRegistryJson([PRODUCTION_SIGNALS, other])).toBe(
|
|
80
|
+
canonicalScheduleRegistryJson([other, reordered]),
|
|
81
|
+
);
|
|
82
|
+
expect(
|
|
83
|
+
canonicalScheduleRegistryJson([other]).startsWith(
|
|
84
|
+
'[{"id":"audit-rollup","repo"',
|
|
85
|
+
),
|
|
86
|
+
).toBe(true);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it("rejects a snapshot with duplicate ids", () => {
|
|
90
|
+
const result = ScheduleRegistrySnapshotSchema.safeParse({
|
|
91
|
+
schemaVersion: 1,
|
|
92
|
+
contentHash: "a".repeat(64),
|
|
93
|
+
schedules: [PRODUCTION_SIGNALS, PRODUCTION_SIGNALS],
|
|
94
|
+
});
|
|
95
|
+
expect(result.success).toBe(false);
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
SCHEDULE_EXECUTION_STATES,
|
|
4
|
+
SCHEDULE_EXECUTION_TRANSITIONS,
|
|
5
|
+
canTransitionScheduleExecution,
|
|
6
|
+
isScheduleExecutionSettled,
|
|
7
|
+
mayReexecuteAutomatically,
|
|
8
|
+
scheduleIncidentKey,
|
|
9
|
+
} from "../execution";
|
|
10
|
+
|
|
11
|
+
describe("schedules execution lifecycle", () => {
|
|
12
|
+
it("allows no transition out of a terminal state", () => {
|
|
13
|
+
for (const terminal of [
|
|
14
|
+
"output_verified",
|
|
15
|
+
"superseded",
|
|
16
|
+
"missed",
|
|
17
|
+
"acknowledged_loss",
|
|
18
|
+
] as const) {
|
|
19
|
+
expect(SCHEDULE_EXECUTION_TRANSITIONS[terminal]).toEqual([]);
|
|
20
|
+
}
|
|
21
|
+
for (const state of SCHEDULE_EXECUTION_STATES) {
|
|
22
|
+
for (const target of SCHEDULE_EXECUTION_TRANSITIONS[state]) {
|
|
23
|
+
expect(SCHEDULE_EXECUTION_STATES).toContain(target);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
expect(
|
|
27
|
+
canTransitionScheduleExecution(
|
|
28
|
+
"dispatch_requested",
|
|
29
|
+
"dispatch_requested",
|
|
30
|
+
),
|
|
31
|
+
).toBe(true);
|
|
32
|
+
expect(canTransitionScheduleExecution("started", "superseded")).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("settles a successful completion only without a verifier", () => {
|
|
36
|
+
expect(
|
|
37
|
+
isScheduleExecutionSettled({
|
|
38
|
+
state: "completed",
|
|
39
|
+
conclusion: "success",
|
|
40
|
+
requiresVerification: false,
|
|
41
|
+
}),
|
|
42
|
+
).toBe(true);
|
|
43
|
+
expect(
|
|
44
|
+
isScheduleExecutionSettled({
|
|
45
|
+
state: "completed",
|
|
46
|
+
conclusion: "success",
|
|
47
|
+
requiresVerification: true,
|
|
48
|
+
}),
|
|
49
|
+
).toBe(false);
|
|
50
|
+
expect(
|
|
51
|
+
isScheduleExecutionSettled({
|
|
52
|
+
state: "completed",
|
|
53
|
+
conclusion: "failure",
|
|
54
|
+
requiresVerification: false,
|
|
55
|
+
}),
|
|
56
|
+
).toBe(false);
|
|
57
|
+
expect(
|
|
58
|
+
isScheduleExecutionSettled({
|
|
59
|
+
state: "timed_out",
|
|
60
|
+
conclusion: null,
|
|
61
|
+
requiresVerification: false,
|
|
62
|
+
}),
|
|
63
|
+
).toBe(false);
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("never re-executes unsafe effects automatically", () => {
|
|
67
|
+
expect(mayReexecuteAutomatically("unsafe")).toBe(false);
|
|
68
|
+
for (const effects of ["none", "idempotent", "fenced"] as const) {
|
|
69
|
+
expect(mayReexecuteAutomatically(effects)).toBe(true);
|
|
70
|
+
}
|
|
71
|
+
expect(scheduleIncidentKey("delivery", "pin-audit")).toBe(
|
|
72
|
+
"schedule:delivery:pin-audit",
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
});
|