@relayflows/sdk 2.0.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/dist/authored-flow-error.d.ts +9 -0
- package/dist/authored-flow-error.d.ts.map +1 -0
- package/dist/authored-flow-error.js +13 -0
- package/dist/authored-flow-error.js.map +1 -0
- package/dist/authored-flow-executor.d.ts +28 -0
- package/dist/authored-flow-executor.d.ts.map +1 -0
- package/dist/authored-flow-executor.js +203 -0
- package/dist/authored-flow-executor.js.map +1 -0
- package/dist/authored-flow-lifecycle.d.ts +61 -0
- package/dist/authored-flow-lifecycle.d.ts.map +1 -0
- package/dist/authored-flow-lifecycle.js +284 -0
- package/dist/authored-flow-lifecycle.js.map +1 -0
- package/dist/authored-flow-loader.d.ts +7 -0
- package/dist/authored-flow-loader.d.ts.map +1 -0
- package/dist/authored-flow-loader.js +39 -0
- package/dist/authored-flow-loader.js.map +1 -0
- package/dist/authored-flow-operation.d.ts +43 -0
- package/dist/authored-flow-operation.d.ts.map +1 -0
- package/dist/authored-flow-operation.js +176 -0
- package/dist/authored-flow-operation.js.map +1 -0
- package/dist/authored-flow.d.ts +12 -0
- package/dist/authored-flow.d.ts.map +1 -0
- package/dist/authored-flow.js +13 -0
- package/dist/authored-flow.js.map +1 -0
- package/dist/authored-promise-graph.d.ts +67 -0
- package/dist/authored-promise-graph.d.ts.map +1 -0
- package/dist/authored-promise-graph.js +206 -0
- package/dist/authored-promise-graph.js.map +1 -0
- package/dist/backlog-picker.d.ts +55 -0
- package/dist/backlog-picker.d.ts.map +1 -0
- package/dist/backlog-picker.js +117 -0
- package/dist/backlog-picker.js.map +1 -0
- package/dist/canonical.d.ts +18 -0
- package/dist/canonical.d.ts.map +1 -0
- package/dist/canonical.js +55 -0
- package/dist/canonical.js.map +1 -0
- package/dist/cli/check.d.ts +30 -0
- package/dist/cli/check.d.ts.map +1 -0
- package/dist/cli/check.js +324 -0
- package/dist/cli/check.js.map +1 -0
- package/dist/cli/direct-run.d.ts +3 -0
- package/dist/cli/direct-run.d.ts.map +1 -0
- package/dist/cli/direct-run.js +69 -0
- package/dist/cli/direct-run.js.map +1 -0
- package/dist/cli/hn-monitor.d.ts +107 -0
- package/dist/cli/hn-monitor.d.ts.map +1 -0
- package/dist/cli/hn-monitor.js +220 -0
- package/dist/cli/hn-monitor.js.map +1 -0
- package/dist/cli/interruptible-sleep.d.ts +13 -0
- package/dist/cli/interruptible-sleep.d.ts.map +1 -0
- package/dist/cli/interruptible-sleep.js +31 -0
- package/dist/cli/interruptible-sleep.js.map +1 -0
- package/dist/cli/run.d.ts +54 -0
- package/dist/cli/run.d.ts.map +1 -0
- package/dist/cli/run.js +316 -0
- package/dist/cli/run.js.map +1 -0
- package/dist/cli/tick-runner.d.ts +151 -0
- package/dist/cli/tick-runner.d.ts.map +1 -0
- package/dist/cli/tick-runner.js +258 -0
- package/dist/cli/tick-runner.js.map +1 -0
- package/dist/cli-adapter.d.ts +29 -0
- package/dist/cli-adapter.d.ts.map +1 -0
- package/dist/cli-adapter.js +92 -0
- package/dist/cli-adapter.js.map +1 -0
- package/dist/cli-executable.d.ts +3 -0
- package/dist/cli-executable.d.ts.map +1 -0
- package/dist/cli-executable.js +10 -0
- package/dist/cli-executable.js.map +1 -0
- package/dist/cli.d.ts +9 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +348 -0
- package/dist/cli.js.map +1 -0
- package/dist/compile.d.ts +42 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +515 -0
- package/dist/compile.js.map +1 -0
- package/dist/demo-hn-monitor.d.ts +2 -0
- package/dist/demo-hn-monitor.d.ts.map +1 -0
- package/dist/demo-hn-monitor.js +118 -0
- package/dist/demo-hn-monitor.js.map +1 -0
- package/dist/dir-watcher-poller.d.ts +73 -0
- package/dist/dir-watcher-poller.d.ts.map +1 -0
- package/dist/dir-watcher-poller.js +80 -0
- package/dist/dir-watcher-poller.js.map +1 -0
- package/dist/direct-input.d.ts +10 -0
- package/dist/direct-input.d.ts.map +1 -0
- package/dist/direct-input.js +61 -0
- package/dist/direct-input.js.map +1 -0
- package/dist/failure-kinds.d.ts +25 -0
- package/dist/failure-kinds.d.ts.map +1 -0
- package/dist/failure-kinds.js +64 -0
- package/dist/failure-kinds.js.map +1 -0
- package/dist/gate-contract.d.ts +24 -0
- package/dist/gate-contract.d.ts.map +1 -0
- package/dist/gate-contract.js +50 -0
- package/dist/gate-contract.js.map +1 -0
- package/dist/hn-poller.d.ts +50 -0
- package/dist/hn-poller.d.ts.map +1 -0
- package/dist/hn-poller.js +79 -0
- package/dist/hn-poller.js.map +1 -0
- package/dist/index.d.ts +21 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +30 -0
- package/dist/index.js.map +1 -0
- package/dist/journal-client.d.ts +122 -0
- package/dist/journal-client.d.ts.map +1 -0
- package/dist/journal-client.js +277 -0
- package/dist/journal-client.js.map +1 -0
- package/dist/json-schema-bound.d.ts +7 -0
- package/dist/json-schema-bound.d.ts.map +1 -0
- package/dist/json-schema-bound.js +353 -0
- package/dist/json-schema-bound.js.map +1 -0
- package/dist/json-schema.d.ts +5 -0
- package/dist/json-schema.d.ts.map +1 -0
- package/dist/json-schema.js +72 -0
- package/dist/json-schema.js.map +1 -0
- package/dist/json-value.d.ts +6 -0
- package/dist/json-value.d.ts.map +1 -0
- package/dist/json-value.js +94 -0
- package/dist/json-value.js.map +1 -0
- package/dist/model-name.d.ts +7 -0
- package/dist/model-name.d.ts.map +1 -0
- package/dist/model-name.js +21 -0
- package/dist/model-name.js.map +1 -0
- package/dist/output-schema.d.ts +8 -0
- package/dist/output-schema.d.ts.map +1 -0
- package/dist/output-schema.js +28 -0
- package/dist/output-schema.js.map +1 -0
- package/dist/preflight.d.ts +80 -0
- package/dist/preflight.d.ts.map +1 -0
- package/dist/preflight.js +361 -0
- package/dist/preflight.js.map +1 -0
- package/dist/protocol.d.ts +359 -0
- package/dist/protocol.d.ts.map +1 -0
- package/dist/protocol.js +15 -0
- package/dist/protocol.js.map +1 -0
- package/dist/spec.d.ts +294 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +13 -0
- package/dist/spec.js.map +1 -0
- package/dist/step-dependencies.d.ts +2 -0
- package/dist/step-dependencies.d.ts.map +1 -0
- package/dist/step-dependencies.js +88 -0
- package/dist/step-dependencies.js.map +1 -0
- package/dist/step-fields.d.ts +17 -0
- package/dist/step-fields.d.ts.map +1 -0
- package/dist/step-fields.js +32 -0
- package/dist/step-fields.js.map +1 -0
- package/dist/tick-source.d.ts +205 -0
- package/dist/tick-source.d.ts.map +1 -0
- package/dist/tick-source.js +234 -0
- package/dist/tick-source.js.map +1 -0
- package/dist/unknown-keys.d.ts +7 -0
- package/dist/unknown-keys.d.ts.map +1 -0
- package/dist/unknown-keys.js +47 -0
- package/dist/unknown-keys.js.map +1 -0
- package/dist/validate.d.ts +7 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +458 -0
- package/dist/validate.js.map +1 -0
- package/dist/work-package-consumer.d.ts +33 -0
- package/dist/work-package-consumer.d.ts.map +1 -0
- package/dist/work-package-consumer.js +44 -0
- package/dist/work-package-consumer.js.map +1 -0
- package/dist/work-package-validator.d.ts +15 -0
- package/dist/work-package-validator.d.ts.map +1 -0
- package/dist/work-package-validator.js +72 -0
- package/dist/work-package-validator.js.map +1 -0
- package/dist/worker-cli.d.ts +16 -0
- package/dist/worker-cli.d.ts.map +1 -0
- package/dist/worker-cli.js +77 -0
- package/dist/worker-cli.js.map +1 -0
- package/dist/worker.d.ts +57 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +129 -0
- package/dist/worker.js.map +1 -0
- package/dist/wrapper-runtime.d.ts +10 -0
- package/dist/wrapper-runtime.d.ts.map +1 -0
- package/dist/wrapper-runtime.js +83 -0
- package/dist/wrapper-runtime.js.map +1 -0
- package/dist/wrapper-session.d.ts +16 -0
- package/dist/wrapper-session.d.ts.map +1 -0
- package/dist/wrapper-session.js +244 -0
- package/dist/wrapper-session.js.map +1 -0
- package/package.json +50 -0
- package/src/authored-flow-error.ts +31 -0
- package/src/authored-flow-executor.ts +365 -0
- package/src/authored-flow-lifecycle.ts +344 -0
- package/src/authored-flow-loader.ts +44 -0
- package/src/authored-flow-operation.ts +252 -0
- package/src/authored-flow.ts +21 -0
- package/src/authored-promise-graph.ts +196 -0
- package/src/backlog-picker.ts +153 -0
- package/src/canonical.ts +57 -0
- package/src/cli/check.ts +391 -0
- package/src/cli/direct-run.ts +86 -0
- package/src/cli/hn-monitor.ts +286 -0
- package/src/cli/interruptible-sleep.ts +27 -0
- package/src/cli/run.ts +439 -0
- package/src/cli/tick-runner.ts +378 -0
- package/src/cli-adapter.ts +115 -0
- package/src/cli-executable.ts +12 -0
- package/src/cli.ts +364 -0
- package/src/compile.ts +573 -0
- package/src/demo-hn-monitor.ts +132 -0
- package/src/dir-watcher-poller.ts +122 -0
- package/src/direct-input.ts +69 -0
- package/src/failure-kinds.ts +77 -0
- package/src/gate-contract.ts +72 -0
- package/src/hn-poller.ts +104 -0
- package/src/index.ts +195 -0
- package/src/journal-client.ts +379 -0
- package/src/json-schema-bound.ts +357 -0
- package/src/json-schema.ts +65 -0
- package/src/json-value.ts +110 -0
- package/src/model-name.ts +20 -0
- package/src/output-schema.ts +33 -0
- package/src/preflight.ts +482 -0
- package/src/protocol.ts +364 -0
- package/src/spec.ts +343 -0
- package/src/step-dependencies.ts +100 -0
- package/src/step-fields.ts +36 -0
- package/src/tick-source.ts +334 -0
- package/src/unknown-keys.ts +52 -0
- package/src/validate.ts +503 -0
- package/src/work-package-consumer.ts +73 -0
- package/src/work-package-validator.ts +90 -0
- package/src/worker-cli.ts +110 -0
- package/src/worker.ts +150 -0
- package/src/wrapper-runtime.ts +93 -0
- package/src/wrapper-session.ts +287 -0
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/** Return author-facing dependency errors without assuming parsed step shapes. */
|
|
2
|
+
const MAX_REPORTED_CYCLE_PATH_IDS = 16;
|
|
3
|
+
|
|
4
|
+
export function stepDependencyErrors(
|
|
5
|
+
steps: readonly unknown[],
|
|
6
|
+
knownIds: ReadonlySet<string>,
|
|
7
|
+
): string[] {
|
|
8
|
+
const errors: string[] = [];
|
|
9
|
+
const adjacency = new Map<string, string[]>();
|
|
10
|
+
|
|
11
|
+
for (const value of steps) {
|
|
12
|
+
// The main validator reports the shape error. This pass must not replace
|
|
13
|
+
// that typed result by dereferencing or iterating a malformed value.
|
|
14
|
+
if (!isObject(value) || !isNonEmptyString(value['id'])) continue;
|
|
15
|
+
const rawDependencies = value['dependsOn'];
|
|
16
|
+
if (
|
|
17
|
+
rawDependencies !== undefined
|
|
18
|
+
&& (!Array.isArray(rawDependencies) || !rawDependencies.every(isNonEmptyString))
|
|
19
|
+
) {
|
|
20
|
+
continue;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const id = value['id'];
|
|
24
|
+
const dependencies = (rawDependencies ?? []) as string[];
|
|
25
|
+
for (const dependency of dependencies) {
|
|
26
|
+
if (!knownIds.has(dependency)) {
|
|
27
|
+
errors.push(`spec.steps: step "${id}" dependsOn unknown step "${dependency}"`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
adjacency.set(id, dependencies);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const WHITE = 0, GRAY = 1, BLACK = 2;
|
|
34
|
+
const color = new Map<string, number>();
|
|
35
|
+
for (const id of adjacency.keys()) color.set(id, WHITE);
|
|
36
|
+
const path: string[] = [];
|
|
37
|
+
for (const start of adjacency.keys()) {
|
|
38
|
+
if (color.get(start) !== WHITE) continue;
|
|
39
|
+
|
|
40
|
+
color.set(start, GRAY);
|
|
41
|
+
path.push(start);
|
|
42
|
+
const frames: Array<{ id: string; nextDependency: number }> = [
|
|
43
|
+
{ id: start, nextDependency: 0 },
|
|
44
|
+
];
|
|
45
|
+
|
|
46
|
+
while (frames.length > 0) {
|
|
47
|
+
const frame = frames[frames.length - 1];
|
|
48
|
+
if (frame === undefined) break;
|
|
49
|
+
const dependencies = adjacency.get(frame.id) ?? [];
|
|
50
|
+
const dependency = dependencies[frame.nextDependency];
|
|
51
|
+
|
|
52
|
+
if (dependency === undefined) {
|
|
53
|
+
frames.pop();
|
|
54
|
+
path.pop();
|
|
55
|
+
color.set(frame.id, BLACK);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
frame.nextDependency += 1;
|
|
60
|
+
const dependencyColor = color.get(dependency);
|
|
61
|
+
if (dependencyColor === GRAY) {
|
|
62
|
+
errors.push(
|
|
63
|
+
`spec.steps: dependency cycle detected at "${dependency}" (path: ${formatCyclePath(path, dependency)})`,
|
|
64
|
+
);
|
|
65
|
+
// One deterministic back edge proves the graph is invalid. Continuing
|
|
66
|
+
// would report every remaining gray edge and amplify diagnostics
|
|
67
|
+
// cubically for dense graphs, unlike the kernel's first-cycle refusal.
|
|
68
|
+
return errors;
|
|
69
|
+
} else if (dependencyColor === WHITE) {
|
|
70
|
+
color.set(dependency, GRAY);
|
|
71
|
+
path.push(dependency);
|
|
72
|
+
frames.push({ id: dependency, nextDependency: 0 });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return errors;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function formatCyclePath(path: readonly string[], dependency: string): string {
|
|
80
|
+
const cycleStart = path.lastIndexOf(dependency);
|
|
81
|
+
const cycle = [...path.slice(cycleStart), dependency];
|
|
82
|
+
if (cycle.length <= MAX_REPORTED_CYCLE_PATH_IDS) return cycle.join(' -> ');
|
|
83
|
+
|
|
84
|
+
const headSize = MAX_REPORTED_CYCLE_PATH_IDS / 2;
|
|
85
|
+
const tailSize = MAX_REPORTED_CYCLE_PATH_IDS - headSize;
|
|
86
|
+
const omitted = cycle.length - MAX_REPORTED_CYCLE_PATH_IDS;
|
|
87
|
+
return [
|
|
88
|
+
...cycle.slice(0, headSize),
|
|
89
|
+
`... (${omitted} steps omitted) ...`,
|
|
90
|
+
...cycle.slice(-tailSize),
|
|
91
|
+
].join(' -> ');
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function isObject(value: unknown): value is Record<string, unknown> {
|
|
95
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function isNonEmptyString(value: unknown): value is string {
|
|
99
|
+
return typeof value === 'string' && value.length > 0;
|
|
100
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { StepType } from './spec.js';
|
|
2
|
+
|
|
3
|
+
/** Closed top-level authoring schema, including named agent declarations. */
|
|
4
|
+
export const FLOW_FIELDS = [
|
|
5
|
+
'version',
|
|
6
|
+
'name',
|
|
7
|
+
'description',
|
|
8
|
+
'cli',
|
|
9
|
+
'agents',
|
|
10
|
+
'triggers',
|
|
11
|
+
'steps',
|
|
12
|
+
'budget',
|
|
13
|
+
] as const;
|
|
14
|
+
|
|
15
|
+
/** Closed named-agent declaration schema. */
|
|
16
|
+
export const AGENT_DECLARATION_FIELDS = ['cli', 'model'] as const;
|
|
17
|
+
|
|
18
|
+
/** Fields shared by every authoring step, regardless of its verb. */
|
|
19
|
+
export const STEP_COMMON_FIELDS = [
|
|
20
|
+
'id',
|
|
21
|
+
'type',
|
|
22
|
+
'dependsOn',
|
|
23
|
+
'verification',
|
|
24
|
+
'maxIterations',
|
|
25
|
+
] as const;
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Closed verb-specific authoring schema (RFC-0001 decision 13). Validation
|
|
29
|
+
* consumes this descriptor directly so a new optional field cannot bypass the
|
|
30
|
+
* per-verb boundary through a second, drifting allowlist.
|
|
31
|
+
*/
|
|
32
|
+
export const STEP_FIELDS_BY_TYPE = {
|
|
33
|
+
deterministic: ['command', 'timeoutMs'],
|
|
34
|
+
llm: ['prompt', 'model', 'cli', 'output'],
|
|
35
|
+
agent: ['instruction', 'agent', 'cli', 'model', 'surfaces', 'recoveryMode', 'permissions', 'output'],
|
|
36
|
+
} as const satisfies Record<StepType, readonly string[]>;
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled ticks -> relayflow events.
|
|
3
|
+
*
|
|
4
|
+
* A relayflow could not be scheduled. `grep -rniE "cron|schedule|interval"` over
|
|
5
|
+
* `spec.ts` and `compile.ts` returned nothing, and every shipped trigger is fed
|
|
6
|
+
* by a poller reacting to something external (`hn-poller.ts`,
|
|
7
|
+
* `dir-watcher-poller.ts`). "Run this flow every ten minutes" had no expression.
|
|
8
|
+
*
|
|
9
|
+
* RFC-0001 already decided the shape, so this is deliberately NOT a `cron:`
|
|
10
|
+
* field on the kernel spec:
|
|
11
|
+
*
|
|
12
|
+
* - gate 2 "proves: triggers are entry conditions, not schedulers";
|
|
13
|
+
* - the first dogfood run (2026-08-27) is on record for "a cron trigger
|
|
14
|
+
* reported `succeeded` into a void with no worker enrolled" — a scheduler
|
|
15
|
+
* inside the kernel is exactly what produced that;
|
|
16
|
+
* - the trigger plane is liveness-checked "because a flow that is never
|
|
17
|
+
* triggered is silently zero — Native's silent-death problem".
|
|
18
|
+
*
|
|
19
|
+
* So a schedule is an EVENT SOURCE, sitting beside the directory watcher and
|
|
20
|
+
* the HN poller, speaking the same `event.submit` path, and subject to the same
|
|
21
|
+
* liveness sweep as any other subscription. The kernel learns about time the
|
|
22
|
+
* way it learns about everything else: as an event.
|
|
23
|
+
*
|
|
24
|
+
* ## The slot grid
|
|
25
|
+
*
|
|
26
|
+
* Time is divided into fixed slots anchored at `epochMs`:
|
|
27
|
+
*
|
|
28
|
+
* slot(t) = floor((t - epochMs) / intervalMs)
|
|
29
|
+
* scheduledForMs(n) = epochMs + n * intervalMs
|
|
30
|
+
*
|
|
31
|
+
* A slot is an interval of the grid, not a moment the poller happened to wake
|
|
32
|
+
* up. That distinction is the whole design. `scheduledForMs` is a pure function
|
|
33
|
+
* of the grid, so the same slot has the same identity no matter when — or how
|
|
34
|
+
* many times — a poller notices it. Wall-clock-at-emit would give two different
|
|
35
|
+
* identities to one scheduled instant and produce two runs.
|
|
36
|
+
*
|
|
37
|
+
* ## Two failure modes, two different mechanisms
|
|
38
|
+
*
|
|
39
|
+
* They are separate on purpose, and neither one covers for the other:
|
|
40
|
+
*
|
|
41
|
+
* - **Duplicates** are prevented by the dedupe key, which is derived from
|
|
42
|
+
* `(schedule_id, scheduled_for_ms)` through the flow's `dedupeKeyTemplate`.
|
|
43
|
+
* The kernel's `(flow_key, subscription_id, dedupe_key)` claim then makes
|
|
44
|
+
* the second delivery of a slot a no-op. This holds for a double-fire, a
|
|
45
|
+
* re-delivery, two pollers racing, and a poller that restarts with a lost
|
|
46
|
+
* cursor and re-emits a slot it already emitted.
|
|
47
|
+
*
|
|
48
|
+
* - **Skips** are prevented by the cursor. `emitDueTicks` emits every slot
|
|
49
|
+
* between the last one it emitted and now, not just the current one, so a
|
|
50
|
+
* poller that was asleep across three slots backfills three ticks rather
|
|
51
|
+
* than silently dropping two. The cursor advances only after a successful
|
|
52
|
+
* submit, so a journal failure mid-backfill leaves the rest for the next
|
|
53
|
+
* poll — the same discipline `dir-watcher-poller` applies to its `seen` set.
|
|
54
|
+
*
|
|
55
|
+
* The cursor is caller-owned (a plain JSON-serializable object) precisely so a
|
|
56
|
+
* caller that wants restart-safe backfill can persist it. A caller that does
|
|
57
|
+
* not persist it loses backfill across a restart but CANNOT double-run a slot,
|
|
58
|
+
* because that bound belongs to the dedupe key rather than to the cursor.
|
|
59
|
+
*/
|
|
60
|
+
|
|
61
|
+
/** Anything that can submit an event through the journal protocol. */
|
|
62
|
+
export interface EventSink {
|
|
63
|
+
eventSubmit(spec: unknown, event: { type: string; payload?: unknown; key?: string }): Promise<unknown>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The event type every tick carries. */
|
|
67
|
+
export const TICK_EVENT_TYPE = 'flows.tick';
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The dedupe key template a tick-triggered flow must declare. Exported so a
|
|
71
|
+
* spec and this source cannot drift: `testdata/tick-heartbeat.flow.yaml` uses
|
|
72
|
+
* this exact string and `tests/tick-source.test.ts` asserts they match.
|
|
73
|
+
*
|
|
74
|
+
* `scheduled_for_ms` — not `emitted_at_ms` — is what makes the key idempotent.
|
|
75
|
+
*/
|
|
76
|
+
export const TICK_DEDUPE_KEY_TEMPLATE =
|
|
77
|
+
'{{event.type}}:{{payload.schedule_id}}:{{payload.scheduled_for_ms}}';
|
|
78
|
+
|
|
79
|
+
/** Default bound on how many missed slots one poll will backfill. */
|
|
80
|
+
export const DEFAULT_MAX_CATCH_UP = 60;
|
|
81
|
+
|
|
82
|
+
/** A declared schedule. Pure data — no timers, no I/O, no ambient clock. */
|
|
83
|
+
export interface TickSchedule {
|
|
84
|
+
/**
|
|
85
|
+
* Stable identity of this schedule. It is half the dedupe key, so changing
|
|
86
|
+
* it re-runs every slot; two schedules on one flow must differ here.
|
|
87
|
+
*/
|
|
88
|
+
scheduleId: string;
|
|
89
|
+
/** Slot width in milliseconds. Must be a positive integer. */
|
|
90
|
+
intervalMs: number;
|
|
91
|
+
/**
|
|
92
|
+
* Grid anchor. Slots are measured from here, so this is what decides whether
|
|
93
|
+
* an hourly schedule fires on the hour or at seven minutes past. Defaults to
|
|
94
|
+
* 0 (the Unix epoch), which puts an hourly schedule on the hour in UTC.
|
|
95
|
+
*/
|
|
96
|
+
epochMs?: number;
|
|
97
|
+
/**
|
|
98
|
+
* Upper bound on slots backfilled in a single poll. A poller down for a week
|
|
99
|
+
* on a one-minute schedule has ten thousand outstanding slots, and replaying
|
|
100
|
+
* all of them would be a stampede, not a recovery. Slots beyond the bound are
|
|
101
|
+
* REPORTED in the result rather than dropped quietly (see `TickEmitResult`).
|
|
102
|
+
*/
|
|
103
|
+
maxCatchUp?: number;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Caller-owned cursor. Plain JSON so a caller can persist it across restarts.
|
|
108
|
+
* `emitDueTicks` mutates it in place, exactly as `pollDirectoryOnce` mutates
|
|
109
|
+
* its `seen` set.
|
|
110
|
+
*/
|
|
111
|
+
export interface TickCursor {
|
|
112
|
+
/** Highest slot successfully submitted, or undefined before the first poll. */
|
|
113
|
+
lastEmittedSlot?: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** The payload of one `flows.tick` event. */
|
|
117
|
+
export interface TickPayload {
|
|
118
|
+
schedule_id: string;
|
|
119
|
+
/** Slot index on the grid. Monotonic, and stable across restarts. */
|
|
120
|
+
slot: number;
|
|
121
|
+
/** The scheduled instant this tick stands for. The dedupe identity. */
|
|
122
|
+
scheduled_for_ms: number;
|
|
123
|
+
interval_ms: number;
|
|
124
|
+
/**
|
|
125
|
+
* Wall clock when the tick was submitted. Observability only — it is
|
|
126
|
+
* deliberately NOT part of the dedupe key, because it differs between a
|
|
127
|
+
* first delivery and a re-delivery of the same slot.
|
|
128
|
+
*/
|
|
129
|
+
emitted_at_ms: number;
|
|
130
|
+
/**
|
|
131
|
+
* How far behind the grid this emission was, in milliseconds
|
|
132
|
+
* (`emitted_at_ms - scheduled_for_ms`). A catch-up tick carries a large
|
|
133
|
+
* value; a punctual one carries roughly zero. Lets a flow tell "I am running
|
|
134
|
+
* for a slot from an hour ago" from "I am running for now".
|
|
135
|
+
*/
|
|
136
|
+
lag_ms: number;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** What one poll did, including what it deliberately did not do. */
|
|
140
|
+
export interface TickEmitResult {
|
|
141
|
+
/** Slots submitted this poll, oldest first. */
|
|
142
|
+
emittedSlots: number[];
|
|
143
|
+
/** The submit outcomes, index-aligned with `emittedSlots`. */
|
|
144
|
+
outcomes: unknown[];
|
|
145
|
+
/**
|
|
146
|
+
* Slots that were due but fell outside `maxCatchUp`, oldest first. Non-empty
|
|
147
|
+
* means real scheduled work was passed over: the caller MUST surface it. It
|
|
148
|
+
* is returned rather than thrown so a poller that was down for a week still
|
|
149
|
+
* recovers to the current slot instead of wedging, and it is returned rather
|
|
150
|
+
* than ignored so the skip cannot be silent.
|
|
151
|
+
*/
|
|
152
|
+
skippedSlots: number[];
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Slot index containing `nowMs` on this schedule's grid. */
|
|
156
|
+
export function slotFor(schedule: TickSchedule, nowMs: number): number {
|
|
157
|
+
return Math.floor((nowMs - (schedule.epochMs ?? 0)) / schedule.intervalMs);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** The scheduled instant of a slot. Pure function of the grid, never of `now`. */
|
|
161
|
+
export function scheduledForMs(schedule: TickSchedule, slot: number): number {
|
|
162
|
+
return (schedule.epochMs ?? 0) + slot * schedule.intervalMs;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* The dedupe key the kernel will derive for a slot, computed here so a test can
|
|
167
|
+
* assert the identity directly without a live kernel. Kept in lockstep with
|
|
168
|
+
* `TICK_DEDUPE_KEY_TEMPLATE`; `tests/tick-source.test.ts` pins the agreement.
|
|
169
|
+
*/
|
|
170
|
+
export function tickDedupeKey(schedule: TickSchedule, slot: number): string {
|
|
171
|
+
return `${TICK_EVENT_TYPE}:${schedule.scheduleId}:${scheduledForMs(schedule, slot)}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Error thrown when a submit inside `emitDueTicks` fails, carrying the poll's
|
|
176
|
+
* accounting so far.
|
|
177
|
+
*
|
|
178
|
+
* A plain rethrow discarded `skippedSlots` — the ONLY record that real
|
|
179
|
+
* scheduled work had been passed over, since a skipped slot never reaches the
|
|
180
|
+
* kernel and nothing downstream would ever see it. That made the skip silent
|
|
181
|
+
* in exactly the failure path where an operator most needs it, contradicting
|
|
182
|
+
* this module's own guarantee and reproducing the silent-death class the whole
|
|
183
|
+
* primitive exists to prevent.
|
|
184
|
+
*/
|
|
185
|
+
export class TickEmitError extends Error {
|
|
186
|
+
readonly emittedSlots: number[];
|
|
187
|
+
readonly outcomes: unknown[];
|
|
188
|
+
readonly skippedSlots: number[];
|
|
189
|
+
constructor(result: TickEmitResult, cause: unknown) {
|
|
190
|
+
const detail = cause instanceof Error ? cause.message : String(cause);
|
|
191
|
+
super(
|
|
192
|
+
`tick emit failed after ${result.emittedSlots.length} slot(s)`
|
|
193
|
+
+ `${result.skippedSlots.length > 0 ? `, with ${result.skippedSlots.length} slot(s) skipped by the catch-up bound` : ''}`
|
|
194
|
+
+ `: ${detail}`,
|
|
195
|
+
{ cause },
|
|
196
|
+
);
|
|
197
|
+
this.name = 'TickEmitError';
|
|
198
|
+
this.emittedSlots = result.emittedSlots;
|
|
199
|
+
this.outcomes = result.outcomes;
|
|
200
|
+
this.skippedSlots = result.skippedSlots;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function requirePositiveInteger(value: number, field: string): void {
|
|
205
|
+
// `Number.isInteger` is false for NaN and for both infinities, so this one
|
|
206
|
+
// check covers all three.
|
|
207
|
+
if (!Number.isInteger(value) || value <= 0) {
|
|
208
|
+
throw new Error(`tick schedule: ${field} must be a positive integer, got ${String(value)}`);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* `epochMs` and `nowMs` reach arithmetic that decides whether ANY slot is due.
|
|
214
|
+
* They were unvalidated while `intervalMs` was not, and the asymmetry was the
|
|
215
|
+
* bug: a NaN made `slotFor` return NaN, every comparison against it false, and
|
|
216
|
+
* the poll returned an empty result — no submit, no skip, no throw. A schedule
|
|
217
|
+
* permanently and silently zero. An infinity was worse than quiet but no
|
|
218
|
+
* better as a diagnosis: the backfill loop died with `Invalid array length`.
|
|
219
|
+
*
|
|
220
|
+
* A grid that cannot be computed must refuse at the call, loudly and by name.
|
|
221
|
+
*/
|
|
222
|
+
function requireNonNegativeInteger(value: number, field: string): void {
|
|
223
|
+
if (!Number.isInteger(value) || value < 0) {
|
|
224
|
+
throw new Error(`tick schedule: ${field} must be a non-negative integer, got ${String(value)}`);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Every bounds check `emitDueTicks` applies, in one exported place.
|
|
230
|
+
*
|
|
231
|
+
* `emitDueTicks` calls this; it is not a second copy of the rules. A runner
|
|
232
|
+
* that accepts a schedule from an operator (`sdk/src/cli/tick-runner.ts`) has
|
|
233
|
+
* to refuse a bad grid at DECLARATION rather than at the first poll — a
|
|
234
|
+
* runner that connects, attaches a worker and only then discovers that
|
|
235
|
+
* `--interval-ms` was `1.5` has already told the operator it started. Sharing
|
|
236
|
+
* this function rather than re-deriving the rules is what keeps the CLI's
|
|
237
|
+
* refusal and the emit path's refusal from drifting apart: a bound added here
|
|
238
|
+
* is enforced at both ends by construction.
|
|
239
|
+
*/
|
|
240
|
+
export function assertTickScheduleValid(schedule: TickSchedule, nowMs: number): void {
|
|
241
|
+
requirePositiveInteger(schedule.intervalMs, 'intervalMs');
|
|
242
|
+
requirePositiveInteger(schedule.maxCatchUp ?? DEFAULT_MAX_CATCH_UP, 'maxCatchUp');
|
|
243
|
+
requireNonNegativeInteger(schedule.epochMs ?? 0, 'epochMs');
|
|
244
|
+
requireNonNegativeInteger(nowMs, 'nowMs');
|
|
245
|
+
if (schedule.scheduleId === '') {
|
|
246
|
+
throw new Error('tick schedule: scheduleId must be a non-empty string');
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Submit a `flows.tick` event for every slot that has come due since the cursor
|
|
252
|
+
* last advanced, and move the cursor.
|
|
253
|
+
*
|
|
254
|
+
* The FIRST poll on a fresh cursor emits only the current slot. Backfilling
|
|
255
|
+
* from the grid anchor instead would replay every slot since the Unix epoch on
|
|
256
|
+
* the first tick of a new schedule.
|
|
257
|
+
*
|
|
258
|
+
* Journal errors from `eventSubmit` propagate, with the cursor left pointing at
|
|
259
|
+
* the last slot that actually reached the kernel.
|
|
260
|
+
*/
|
|
261
|
+
export async function emitDueTicks(
|
|
262
|
+
spec: unknown,
|
|
263
|
+
sink: EventSink,
|
|
264
|
+
options: { schedule: TickSchedule; cursor: TickCursor; nowMs: number },
|
|
265
|
+
): Promise<TickEmitResult> {
|
|
266
|
+
const { schedule, cursor, nowMs } = options;
|
|
267
|
+
assertTickScheduleValid(schedule, nowMs);
|
|
268
|
+
const maxCatchUp = schedule.maxCatchUp ?? DEFAULT_MAX_CATCH_UP;
|
|
269
|
+
|
|
270
|
+
const currentSlot = slotFor(schedule, nowMs);
|
|
271
|
+
const firstDue = cursor.lastEmittedSlot === undefined
|
|
272
|
+
? currentSlot
|
|
273
|
+
: cursor.lastEmittedSlot + 1;
|
|
274
|
+
|
|
275
|
+
// Clock went backwards, or the cursor is ahead of the grid. Emitting nothing
|
|
276
|
+
// is correct: those slots are already claimed, and re-emitting them would be
|
|
277
|
+
// deduped anyway.
|
|
278
|
+
if (firstDue > currentSlot) {
|
|
279
|
+
return { emittedSlots: [], outcomes: [], skippedSlots: [] };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const due: number[] = [];
|
|
283
|
+
for (let slot = firstDue; slot <= currentSlot; slot++) due.push(slot);
|
|
284
|
+
|
|
285
|
+
// Over the bound: keep the NEWEST slots. The current slot is the one whose
|
|
286
|
+
// work is still relevant; the oldest are the most stale. Report the rest.
|
|
287
|
+
const skippedSlots = due.length > maxCatchUp ? due.slice(0, due.length - maxCatchUp) : [];
|
|
288
|
+
const toEmit = due.length > maxCatchUp ? due.slice(due.length - maxCatchUp) : due;
|
|
289
|
+
|
|
290
|
+
// The cursor is NOT advanced past `skippedSlots` here. It used to be, and
|
|
291
|
+
// that lost the skip outright: if a submit then threw, the returned result
|
|
292
|
+
// — the only place `skippedSlots` lived — was discarded, while the cursor
|
|
293
|
+
// had already moved past the evidence, so the next poll could not re-derive
|
|
294
|
+
// it either. Seven skipped slots could vanish with no report anywhere.
|
|
295
|
+
//
|
|
296
|
+
// Instead the skip is accounted for by whichever of these happens:
|
|
297
|
+
// - a submit succeeds, advancing the cursor past the skipped slots as a
|
|
298
|
+
// side effect, and the result carries `skippedSlots` (the happy path,
|
|
299
|
+
// still reported exactly once);
|
|
300
|
+
// - a submit throws, and `TickEmitError` carries `skippedSlots` to the
|
|
301
|
+
// caller;
|
|
302
|
+
// - nothing was submitted at all, so the cursor never moved and the next
|
|
303
|
+
// poll re-derives the identical due range.
|
|
304
|
+
// No path drops it.
|
|
305
|
+
const result: TickEmitResult = { emittedSlots: [], outcomes: [], skippedSlots };
|
|
306
|
+
for (const slot of toEmit) {
|
|
307
|
+
const scheduledFor = scheduledForMs(schedule, slot);
|
|
308
|
+
const payload: TickPayload = {
|
|
309
|
+
schedule_id: schedule.scheduleId,
|
|
310
|
+
slot,
|
|
311
|
+
scheduled_for_ms: scheduledFor,
|
|
312
|
+
interval_ms: schedule.intervalMs,
|
|
313
|
+
emitted_at_ms: nowMs,
|
|
314
|
+
lag_ms: nowMs - scheduledFor,
|
|
315
|
+
};
|
|
316
|
+
let outcome: unknown;
|
|
317
|
+
try {
|
|
318
|
+
outcome = await sink.eventSubmit(spec, { type: TICK_EVENT_TYPE, payload });
|
|
319
|
+
} catch (cause) {
|
|
320
|
+
// Fail closed, but never quietly: the partial accounting travels with
|
|
321
|
+
// the failure instead of dying with the discarded return value.
|
|
322
|
+
throw new TickEmitError(result, cause);
|
|
323
|
+
}
|
|
324
|
+
result.outcomes.push(outcome);
|
|
325
|
+
// Advance ONLY after the submit succeeded. A journal failure must leave
|
|
326
|
+
// this slot due so the next poll retries it — the same rule
|
|
327
|
+
// `pollDirectoryOnce` applies to its `seen` set, and the reason a crash
|
|
328
|
+
// mid-backfill cannot swallow a slot.
|
|
329
|
+
cursor.lastEmittedSlot = slot;
|
|
330
|
+
result.emittedSlots.push(slot);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return result;
|
|
334
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Produce author-facing diagnostics for keys outside a closed schema. A key
|
|
3
|
+
* that differs only in casing/separators always matches; otherwise a small
|
|
4
|
+
* edit distance catches plain misspellings.
|
|
5
|
+
*/
|
|
6
|
+
export function unknownKeyErrors(
|
|
7
|
+
object: Record<string, unknown>,
|
|
8
|
+
allowed: readonly string[],
|
|
9
|
+
at: string,
|
|
10
|
+
): string[] {
|
|
11
|
+
const errors: string[] = [];
|
|
12
|
+
for (const key of Object.keys(object)) {
|
|
13
|
+
if (allowed.includes(key)) continue;
|
|
14
|
+
const suggestion = nearestKey(key, allowed);
|
|
15
|
+
errors.push(
|
|
16
|
+
suggestion !== null
|
|
17
|
+
? `${at}: unknown key "${key}" — did you mean "${suggestion}"?`
|
|
18
|
+
: `${at}: unknown key "${key}" (expected one of ${allowed.join(' | ')})`,
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return errors;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function nearestKey(key: string, allowed: readonly string[]): string | null {
|
|
25
|
+
const normalize = (value: string): string => value.toLowerCase().replace(/[_-]/g, '');
|
|
26
|
+
let best: string | null = null;
|
|
27
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
28
|
+
for (const candidate of allowed) {
|
|
29
|
+
if (normalize(candidate) === normalize(key)) return candidate;
|
|
30
|
+
const distance = levenshtein(key.toLowerCase(), candidate.toLowerCase());
|
|
31
|
+
if (distance < bestDistance) {
|
|
32
|
+
bestDistance = distance;
|
|
33
|
+
best = candidate;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return best !== null && bestDistance <= 3 && bestDistance < best.length ? best : null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function levenshtein(a: string, b: string): number {
|
|
40
|
+
let previous: number[] = Array.from({ length: b.length + 1 }, (_, i) => i);
|
|
41
|
+
for (let i = 1; i <= a.length; i++) {
|
|
42
|
+
const current: number[] = [i];
|
|
43
|
+
for (let j = 1; j <= b.length; j++) {
|
|
44
|
+
const deletion = (previous[j] ?? 0) + 1;
|
|
45
|
+
const insertion = (current[j - 1] ?? 0) + 1;
|
|
46
|
+
const substitution = (previous[j - 1] ?? 0) + (a[i - 1] === b[j - 1] ? 0 : 1);
|
|
47
|
+
current[j] = Math.min(deletion, insertion, substitution);
|
|
48
|
+
}
|
|
49
|
+
previous = current;
|
|
50
|
+
}
|
|
51
|
+
return previous[b.length] ?? 0;
|
|
52
|
+
}
|