@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.
Files changed (231) hide show
  1. package/dist/authored-flow-error.d.ts +9 -0
  2. package/dist/authored-flow-error.d.ts.map +1 -0
  3. package/dist/authored-flow-error.js +13 -0
  4. package/dist/authored-flow-error.js.map +1 -0
  5. package/dist/authored-flow-executor.d.ts +28 -0
  6. package/dist/authored-flow-executor.d.ts.map +1 -0
  7. package/dist/authored-flow-executor.js +203 -0
  8. package/dist/authored-flow-executor.js.map +1 -0
  9. package/dist/authored-flow-lifecycle.d.ts +61 -0
  10. package/dist/authored-flow-lifecycle.d.ts.map +1 -0
  11. package/dist/authored-flow-lifecycle.js +284 -0
  12. package/dist/authored-flow-lifecycle.js.map +1 -0
  13. package/dist/authored-flow-loader.d.ts +7 -0
  14. package/dist/authored-flow-loader.d.ts.map +1 -0
  15. package/dist/authored-flow-loader.js +39 -0
  16. package/dist/authored-flow-loader.js.map +1 -0
  17. package/dist/authored-flow-operation.d.ts +43 -0
  18. package/dist/authored-flow-operation.d.ts.map +1 -0
  19. package/dist/authored-flow-operation.js +176 -0
  20. package/dist/authored-flow-operation.js.map +1 -0
  21. package/dist/authored-flow.d.ts +12 -0
  22. package/dist/authored-flow.d.ts.map +1 -0
  23. package/dist/authored-flow.js +13 -0
  24. package/dist/authored-flow.js.map +1 -0
  25. package/dist/authored-promise-graph.d.ts +67 -0
  26. package/dist/authored-promise-graph.d.ts.map +1 -0
  27. package/dist/authored-promise-graph.js +206 -0
  28. package/dist/authored-promise-graph.js.map +1 -0
  29. package/dist/backlog-picker.d.ts +55 -0
  30. package/dist/backlog-picker.d.ts.map +1 -0
  31. package/dist/backlog-picker.js +117 -0
  32. package/dist/backlog-picker.js.map +1 -0
  33. package/dist/canonical.d.ts +18 -0
  34. package/dist/canonical.d.ts.map +1 -0
  35. package/dist/canonical.js +55 -0
  36. package/dist/canonical.js.map +1 -0
  37. package/dist/cli/check.d.ts +30 -0
  38. package/dist/cli/check.d.ts.map +1 -0
  39. package/dist/cli/check.js +324 -0
  40. package/dist/cli/check.js.map +1 -0
  41. package/dist/cli/direct-run.d.ts +3 -0
  42. package/dist/cli/direct-run.d.ts.map +1 -0
  43. package/dist/cli/direct-run.js +69 -0
  44. package/dist/cli/direct-run.js.map +1 -0
  45. package/dist/cli/hn-monitor.d.ts +107 -0
  46. package/dist/cli/hn-monitor.d.ts.map +1 -0
  47. package/dist/cli/hn-monitor.js +220 -0
  48. package/dist/cli/hn-monitor.js.map +1 -0
  49. package/dist/cli/interruptible-sleep.d.ts +13 -0
  50. package/dist/cli/interruptible-sleep.d.ts.map +1 -0
  51. package/dist/cli/interruptible-sleep.js +31 -0
  52. package/dist/cli/interruptible-sleep.js.map +1 -0
  53. package/dist/cli/run.d.ts +54 -0
  54. package/dist/cli/run.d.ts.map +1 -0
  55. package/dist/cli/run.js +316 -0
  56. package/dist/cli/run.js.map +1 -0
  57. package/dist/cli/tick-runner.d.ts +151 -0
  58. package/dist/cli/tick-runner.d.ts.map +1 -0
  59. package/dist/cli/tick-runner.js +258 -0
  60. package/dist/cli/tick-runner.js.map +1 -0
  61. package/dist/cli-adapter.d.ts +29 -0
  62. package/dist/cli-adapter.d.ts.map +1 -0
  63. package/dist/cli-adapter.js +92 -0
  64. package/dist/cli-adapter.js.map +1 -0
  65. package/dist/cli-executable.d.ts +3 -0
  66. package/dist/cli-executable.d.ts.map +1 -0
  67. package/dist/cli-executable.js +10 -0
  68. package/dist/cli-executable.js.map +1 -0
  69. package/dist/cli.d.ts +9 -0
  70. package/dist/cli.d.ts.map +1 -0
  71. package/dist/cli.js +348 -0
  72. package/dist/cli.js.map +1 -0
  73. package/dist/compile.d.ts +42 -0
  74. package/dist/compile.d.ts.map +1 -0
  75. package/dist/compile.js +515 -0
  76. package/dist/compile.js.map +1 -0
  77. package/dist/demo-hn-monitor.d.ts +2 -0
  78. package/dist/demo-hn-monitor.d.ts.map +1 -0
  79. package/dist/demo-hn-monitor.js +118 -0
  80. package/dist/demo-hn-monitor.js.map +1 -0
  81. package/dist/dir-watcher-poller.d.ts +73 -0
  82. package/dist/dir-watcher-poller.d.ts.map +1 -0
  83. package/dist/dir-watcher-poller.js +80 -0
  84. package/dist/dir-watcher-poller.js.map +1 -0
  85. package/dist/direct-input.d.ts +10 -0
  86. package/dist/direct-input.d.ts.map +1 -0
  87. package/dist/direct-input.js +61 -0
  88. package/dist/direct-input.js.map +1 -0
  89. package/dist/failure-kinds.d.ts +25 -0
  90. package/dist/failure-kinds.d.ts.map +1 -0
  91. package/dist/failure-kinds.js +64 -0
  92. package/dist/failure-kinds.js.map +1 -0
  93. package/dist/gate-contract.d.ts +24 -0
  94. package/dist/gate-contract.d.ts.map +1 -0
  95. package/dist/gate-contract.js +50 -0
  96. package/dist/gate-contract.js.map +1 -0
  97. package/dist/hn-poller.d.ts +50 -0
  98. package/dist/hn-poller.d.ts.map +1 -0
  99. package/dist/hn-poller.js +79 -0
  100. package/dist/hn-poller.js.map +1 -0
  101. package/dist/index.d.ts +21 -0
  102. package/dist/index.d.ts.map +1 -0
  103. package/dist/index.js +30 -0
  104. package/dist/index.js.map +1 -0
  105. package/dist/journal-client.d.ts +122 -0
  106. package/dist/journal-client.d.ts.map +1 -0
  107. package/dist/journal-client.js +277 -0
  108. package/dist/journal-client.js.map +1 -0
  109. package/dist/json-schema-bound.d.ts +7 -0
  110. package/dist/json-schema-bound.d.ts.map +1 -0
  111. package/dist/json-schema-bound.js +353 -0
  112. package/dist/json-schema-bound.js.map +1 -0
  113. package/dist/json-schema.d.ts +5 -0
  114. package/dist/json-schema.d.ts.map +1 -0
  115. package/dist/json-schema.js +72 -0
  116. package/dist/json-schema.js.map +1 -0
  117. package/dist/json-value.d.ts +6 -0
  118. package/dist/json-value.d.ts.map +1 -0
  119. package/dist/json-value.js +94 -0
  120. package/dist/json-value.js.map +1 -0
  121. package/dist/model-name.d.ts +7 -0
  122. package/dist/model-name.d.ts.map +1 -0
  123. package/dist/model-name.js +21 -0
  124. package/dist/model-name.js.map +1 -0
  125. package/dist/output-schema.d.ts +8 -0
  126. package/dist/output-schema.d.ts.map +1 -0
  127. package/dist/output-schema.js +28 -0
  128. package/dist/output-schema.js.map +1 -0
  129. package/dist/preflight.d.ts +80 -0
  130. package/dist/preflight.d.ts.map +1 -0
  131. package/dist/preflight.js +361 -0
  132. package/dist/preflight.js.map +1 -0
  133. package/dist/protocol.d.ts +359 -0
  134. package/dist/protocol.d.ts.map +1 -0
  135. package/dist/protocol.js +15 -0
  136. package/dist/protocol.js.map +1 -0
  137. package/dist/spec.d.ts +294 -0
  138. package/dist/spec.d.ts.map +1 -0
  139. package/dist/spec.js +13 -0
  140. package/dist/spec.js.map +1 -0
  141. package/dist/step-dependencies.d.ts +2 -0
  142. package/dist/step-dependencies.d.ts.map +1 -0
  143. package/dist/step-dependencies.js +88 -0
  144. package/dist/step-dependencies.js.map +1 -0
  145. package/dist/step-fields.d.ts +17 -0
  146. package/dist/step-fields.d.ts.map +1 -0
  147. package/dist/step-fields.js +32 -0
  148. package/dist/step-fields.js.map +1 -0
  149. package/dist/tick-source.d.ts +205 -0
  150. package/dist/tick-source.d.ts.map +1 -0
  151. package/dist/tick-source.js +234 -0
  152. package/dist/tick-source.js.map +1 -0
  153. package/dist/unknown-keys.d.ts +7 -0
  154. package/dist/unknown-keys.d.ts.map +1 -0
  155. package/dist/unknown-keys.js +47 -0
  156. package/dist/unknown-keys.js.map +1 -0
  157. package/dist/validate.d.ts +7 -0
  158. package/dist/validate.d.ts.map +1 -0
  159. package/dist/validate.js +458 -0
  160. package/dist/validate.js.map +1 -0
  161. package/dist/work-package-consumer.d.ts +33 -0
  162. package/dist/work-package-consumer.d.ts.map +1 -0
  163. package/dist/work-package-consumer.js +44 -0
  164. package/dist/work-package-consumer.js.map +1 -0
  165. package/dist/work-package-validator.d.ts +15 -0
  166. package/dist/work-package-validator.d.ts.map +1 -0
  167. package/dist/work-package-validator.js +72 -0
  168. package/dist/work-package-validator.js.map +1 -0
  169. package/dist/worker-cli.d.ts +16 -0
  170. package/dist/worker-cli.d.ts.map +1 -0
  171. package/dist/worker-cli.js +77 -0
  172. package/dist/worker-cli.js.map +1 -0
  173. package/dist/worker.d.ts +57 -0
  174. package/dist/worker.d.ts.map +1 -0
  175. package/dist/worker.js +129 -0
  176. package/dist/worker.js.map +1 -0
  177. package/dist/wrapper-runtime.d.ts +10 -0
  178. package/dist/wrapper-runtime.d.ts.map +1 -0
  179. package/dist/wrapper-runtime.js +83 -0
  180. package/dist/wrapper-runtime.js.map +1 -0
  181. package/dist/wrapper-session.d.ts +16 -0
  182. package/dist/wrapper-session.d.ts.map +1 -0
  183. package/dist/wrapper-session.js +244 -0
  184. package/dist/wrapper-session.js.map +1 -0
  185. package/package.json +50 -0
  186. package/src/authored-flow-error.ts +31 -0
  187. package/src/authored-flow-executor.ts +365 -0
  188. package/src/authored-flow-lifecycle.ts +344 -0
  189. package/src/authored-flow-loader.ts +44 -0
  190. package/src/authored-flow-operation.ts +252 -0
  191. package/src/authored-flow.ts +21 -0
  192. package/src/authored-promise-graph.ts +196 -0
  193. package/src/backlog-picker.ts +153 -0
  194. package/src/canonical.ts +57 -0
  195. package/src/cli/check.ts +391 -0
  196. package/src/cli/direct-run.ts +86 -0
  197. package/src/cli/hn-monitor.ts +286 -0
  198. package/src/cli/interruptible-sleep.ts +27 -0
  199. package/src/cli/run.ts +439 -0
  200. package/src/cli/tick-runner.ts +378 -0
  201. package/src/cli-adapter.ts +115 -0
  202. package/src/cli-executable.ts +12 -0
  203. package/src/cli.ts +364 -0
  204. package/src/compile.ts +573 -0
  205. package/src/demo-hn-monitor.ts +132 -0
  206. package/src/dir-watcher-poller.ts +122 -0
  207. package/src/direct-input.ts +69 -0
  208. package/src/failure-kinds.ts +77 -0
  209. package/src/gate-contract.ts +72 -0
  210. package/src/hn-poller.ts +104 -0
  211. package/src/index.ts +195 -0
  212. package/src/journal-client.ts +379 -0
  213. package/src/json-schema-bound.ts +357 -0
  214. package/src/json-schema.ts +65 -0
  215. package/src/json-value.ts +110 -0
  216. package/src/model-name.ts +20 -0
  217. package/src/output-schema.ts +33 -0
  218. package/src/preflight.ts +482 -0
  219. package/src/protocol.ts +364 -0
  220. package/src/spec.ts +343 -0
  221. package/src/step-dependencies.ts +100 -0
  222. package/src/step-fields.ts +36 -0
  223. package/src/tick-source.ts +334 -0
  224. package/src/unknown-keys.ts +52 -0
  225. package/src/validate.ts +503 -0
  226. package/src/work-package-consumer.ts +73 -0
  227. package/src/work-package-validator.ts +90 -0
  228. package/src/worker-cli.ts +110 -0
  229. package/src/worker.ts +150 -0
  230. package/src/wrapper-runtime.ts +93 -0
  231. 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
+ }