@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,378 @@
1
+ /**
2
+ * `flows tick start` — drive a scheduled relayflow.
3
+ *
4
+ * A sibling of `hn-monitor.ts`, not a new species: a public function that
5
+ * composes the primitives directly — connect journal → hello → loop
6
+ * `emitDueTicks` → drain on abort → close. The differences from hn-monitor are
7
+ * only the ones the grid forces.
8
+ *
9
+ * ## Why the cursor is persisted, and why that is the whole point
10
+ *
11
+ * `emitDueTicks` starts a FRESH cursor at the current slot:
12
+ *
13
+ * const firstDue = cursor.lastEmittedSlot === undefined
14
+ * ? currentSlot
15
+ * : cursor.lastEmittedSlot + 1;
16
+ *
17
+ * That is right for a schedule's first ever poll — a new hourly schedule must
18
+ * not backfill from the epoch. But it means an in-memory-only cursor makes a
19
+ * restart SKIP every slot between shutdown and restart, silently. The dedupe
20
+ * key `(schedule_id, scheduled_for_ms)` makes re-delivery of a slot harmless,
21
+ * so a lost cursor cannot double-fire; nothing in the primitive protects
22
+ * against the skip. The runner is where that is either handled or lost, so it
23
+ * persists the cursor and reloads it on start.
24
+ *
25
+ * The distinction the file keeps: "no cursor on disk" means first run, start
26
+ * at the current slot. "A cursor on disk behind the grid" means catch up, and
27
+ * report anything past `maxCatchUp` as skipped. Conflating the two either
28
+ * backfills a new schedule from 1970 or silently drops a restart's arrears.
29
+ *
30
+ * ## Why a skip must reach the operator
31
+ *
32
+ * `skippedSlots` and `TickEmitError` exist because a skip that is only a
33
+ * return value is a skip that a `throw` can discard. `emitDueTicks` guarantees
34
+ * the accounting survives its own failure; this runner is the consumer that
35
+ * makes it visible. A skipped slot is real scheduled work that did not run.
36
+ */
37
+
38
+ import { mkdir, readFile, writeFile } from 'node:fs/promises';
39
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
40
+ import { JournalClient } from '../journal-client.js';
41
+ import {
42
+ DEFAULT_MAX_CATCH_UP,
43
+ TickEmitError,
44
+ assertTickScheduleValid,
45
+ emitDueTicks,
46
+ scheduledForMs,
47
+ slotFor,
48
+ type TickCursor,
49
+ type TickEmitResult,
50
+ type TickSchedule,
51
+ } from '../tick-source.js';
52
+ import type { EventSubmitResult, HelloResult } from '../protocol.js';
53
+ import type { CliIo } from '../cli.js';
54
+ import { sleepInterruptible } from './interruptible-sleep.js';
55
+
56
+ /** How often to look for due slots when the caller does not say. */
57
+ const DEFAULT_POLL_INTERVAL_MS = 15_000;
58
+
59
+ /**
60
+ * Minimum client surface the runner uses. Concrete protocol return types
61
+ * rather than `unknown`, so a rename in the journal protocol fails to compile
62
+ * here instead of drifting silently past the interface — the same reason
63
+ * `HnMonitorClient` is shaped this way.
64
+ */
65
+ export interface TickRunnerClient {
66
+ hello(client: string): Promise<HelloResult>;
67
+ eventSubmit(
68
+ spec: unknown,
69
+ event: { type: string; payload?: unknown; key?: string },
70
+ ): Promise<EventSubmitResult>;
71
+ close(): void;
72
+ }
73
+
74
+ /** Where the runner's durable cursor lives, and what it holds. */
75
+ export interface TickRunnerState {
76
+ scheduleId: string;
77
+ /** Mirror of `TickCursor.lastEmittedSlot`. Absent before the first emit. */
78
+ lastEmittedSlot?: number;
79
+ /**
80
+ * Wall clock of the last successful submit. Not used for scheduling — the
81
+ * grid owns that — but it is what makes a dead runner detectable, so
82
+ * `staleAfterMs` means something end to end rather than only inside the
83
+ * kernel's own sweep.
84
+ */
85
+ lastEmittedAtMs?: number;
86
+ /** Every slot ever passed over, oldest first. Append-only. */
87
+ skippedSlots: number[];
88
+ }
89
+
90
+ interface TickRunnerArgsBase {
91
+ /** Data directory containing `relayflowd.sock`. Required. */
92
+ dataDir: string;
93
+ /** Absolute path to the canonical flow spec JSON. Required. */
94
+ specPath: string;
95
+ /** The grid. Validated at declaration, before anything connects. */
96
+ schedule: TickSchedule;
97
+ /** How often to look for due slots. Default 15000ms. */
98
+ pollIntervalMs?: number;
99
+ /**
100
+ * Where to persist the cursor. Defaults to
101
+ * `<dataDir>/tick-state/<scheduleId>.json`.
102
+ */
103
+ statePath?: string;
104
+ /**
105
+ * Cap on poll iterations. Undefined = unbounded (production). 0 means
106
+ * "connect, poll zero times, drain, exit 0" — used by tests that only need
107
+ * the setup and teardown paths.
108
+ */
109
+ maxPolls?: number;
110
+ /** AbortSignal for external cancellation (tests, SIGINT wiring). */
111
+ signal?: AbortSignal;
112
+ /** Injectable clock so tests can place `now` on the grid deliberately. */
113
+ now?: () => number;
114
+ }
115
+
116
+ /**
117
+ * Test injection surface. `connectClient` is supplied alone here — unlike
118
+ * hn-monitor, this runner attaches no worker, so there is no pairing to
119
+ * enforce: a tick is submitted through `event.submit` and the kernel dispatches
120
+ * to whatever worker the flow's trigger names.
121
+ */
122
+ export interface TickRunnerArgs extends TickRunnerArgsBase {
123
+ /**
124
+ * Async, because a real `JournalClient` needs `connect()` before `hello()`.
125
+ * A synchronous injection surface hid that: the unit tests' fake client has
126
+ * no transport, so it connected vacuously and the missing `connect()` only
127
+ * surfaced against a live daemon with `journal client: not connected
128
+ * (hello)`. The default path below owns connect+hello so no caller can
129
+ * forget half of it.
130
+ */
131
+ connectClient?: (socketPath: string) => Promise<TickRunnerClient>;
132
+ }
133
+
134
+ /** Connect and handshake. Both steps, or neither. */
135
+ async function defaultConnectClient(socketPath: string): Promise<TickRunnerClient> {
136
+ const client = new JournalClient(socketPath);
137
+ await client.connect();
138
+ await client.hello('flows-tick-runner');
139
+ return client;
140
+ }
141
+
142
+ /** Absolute path to the state file for one schedule. */
143
+ export function tickStatePath(dataDir: string, scheduleId: string): string {
144
+ return join(dataDir, 'tick-state', `${encodeURIComponent(scheduleId)}.json`);
145
+ }
146
+
147
+ /**
148
+ * Load the durable cursor, distinguishing "no state" from "state behind the
149
+ * grid". A missing file is a first run and must start at the current slot; a
150
+ * present file must be honoured however far behind it is, so the arrears are
151
+ * either caught up or reported.
152
+ *
153
+ * A malformed or foreign-schedule state file is a hard error rather than a
154
+ * silent reset: resetting would convert an operator's corrupted file into a
155
+ * silent skip of everything since the last good emit, which is the failure
156
+ * this runner exists to prevent.
157
+ */
158
+ export async function loadTickState(
159
+ path: string,
160
+ scheduleId: string,
161
+ ): Promise<TickRunnerState> {
162
+ let raw: string;
163
+ try {
164
+ raw = await readFile(path, 'utf8');
165
+ } catch (cause) {
166
+ if ((cause as { code?: string }).code === 'ENOENT') {
167
+ return { scheduleId, skippedSlots: [] };
168
+ }
169
+ throw cause;
170
+ }
171
+ let parsed: unknown;
172
+ try {
173
+ parsed = JSON.parse(raw);
174
+ } catch (cause) {
175
+ throw new Error(`tick state at ${path} is not valid JSON`, { cause });
176
+ }
177
+ if (typeof parsed !== 'object' || parsed === null) {
178
+ throw new Error(`tick state at ${path} is not an object`);
179
+ }
180
+ const state = parsed as Partial<TickRunnerState>;
181
+ if (state.scheduleId !== scheduleId) {
182
+ throw new Error(
183
+ `tick state at ${path} belongs to schedule ${String(state.scheduleId)}, not ${scheduleId}`,
184
+ );
185
+ }
186
+ if (state.lastEmittedSlot !== undefined && !Number.isInteger(state.lastEmittedSlot)) {
187
+ throw new Error(`tick state at ${path} has a non-integer lastEmittedSlot`);
188
+ }
189
+ // Fail closed rather than coerce. `Array.isArray(...) ? ... : []` silently
190
+ // turned a malformed value into "no slots were skipped" — which is the exact
191
+ // claim this runner exists to make trustworthy. A state file that cannot say
192
+ // what it missed must stop the runner, not quietly report that it missed
193
+ // nothing.
194
+ if (state.skippedSlots !== undefined && !Array.isArray(state.skippedSlots)) {
195
+ throw new Error(`tick state at ${path} has a non-array skippedSlots`);
196
+ }
197
+ if (state.skippedSlots?.some((slot) => !Number.isInteger(slot))) {
198
+ throw new Error(`tick state at ${path} has a non-integer entry in skippedSlots`);
199
+ }
200
+ return {
201
+ scheduleId,
202
+ ...(state.lastEmittedSlot === undefined ? {} : { lastEmittedSlot: state.lastEmittedSlot }),
203
+ ...(state.lastEmittedAtMs === undefined ? {} : { lastEmittedAtMs: state.lastEmittedAtMs }),
204
+ skippedSlots: state.skippedSlots ?? [],
205
+ };
206
+ }
207
+
208
+ /**
209
+ * Persist the cursor. Written to a temp file and renamed, so a crash mid-write
210
+ * leaves the previous good state rather than a truncated file — a truncated
211
+ * file would be a hard error on next start, which is safe but needlessly
212
+ * blocks a runner that had a perfectly good cursor a moment earlier.
213
+ */
214
+ export async function saveTickState(path: string, state: TickRunnerState): Promise<void> {
215
+ await mkdir(dirname(path), { recursive: true });
216
+ const temp = `${path}.tmp`;
217
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, 'utf8');
218
+ const { rename } = await import('node:fs/promises');
219
+ await rename(temp, path);
220
+ }
221
+
222
+ /** One poll's accounting, for logging and for tests to assert on. */
223
+ export interface TickPollReport {
224
+ emittedSlots: number[];
225
+ skippedSlots: number[];
226
+ /** Set when the poll threw. The partial accounting is still present. */
227
+ failure?: unknown;
228
+ }
229
+
230
+ /**
231
+ * Run `flows tick start`. Returns 0 on clean shutdown, 1 on a fatal error.
232
+ *
233
+ * Fatal means: the schedule is invalid, the state file is unreadable or
234
+ * belongs to another schedule, the journal cannot be reached, or a submit
235
+ * failed. A submit failure is fatal by design — `emitDueTicks` leaves the slot
236
+ * due, so retrying is the next poll's job, but a runner that swallows journal
237
+ * failures and keeps looping is the silent-zero this is meant to prevent.
238
+ */
239
+ export async function runTickRunner(args: TickRunnerArgs, io: CliIo): Promise<number> {
240
+ const now = args.now ?? Date.now;
241
+ const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
242
+
243
+ // Refuse a bad grid BEFORE connecting. A runner that attaches and only then
244
+ // discovers `--interval-ms` was 1.5 has already told the operator it started.
245
+ try {
246
+ assertTickScheduleValid(args.schedule, now());
247
+ } catch (cause) {
248
+ io.stderr(`REFUSED [invalid_schedule] ${(cause as Error).message}`);
249
+ return 1;
250
+ }
251
+ if (!Number.isInteger(pollIntervalMs) || pollIntervalMs <= 0) {
252
+ io.stderr('REFUSED [invalid_schedule] pollIntervalMs must be a positive integer');
253
+ return 1;
254
+ }
255
+
256
+ const specPath = isAbsolute(args.specPath) ? args.specPath : resolve(args.specPath);
257
+ const statePath = args.statePath ?? tickStatePath(args.dataDir, args.schedule.scheduleId);
258
+
259
+ let spec: unknown;
260
+ let state: TickRunnerState;
261
+ try {
262
+ spec = JSON.parse(await readFile(specPath, 'utf8'));
263
+ state = await loadTickState(statePath, args.schedule.scheduleId);
264
+ } catch (cause) {
265
+ io.stderr(`REFUSED [invalid_state] ${(cause as Error).message}`);
266
+ return 1;
267
+ }
268
+
269
+ const resuming = state.lastEmittedSlot !== undefined;
270
+ const cursor: TickCursor = resuming ? { lastEmittedSlot: state.lastEmittedSlot } : {};
271
+
272
+ const socketPath = join(args.dataDir, 'relayflowd.sock');
273
+ let client: TickRunnerClient;
274
+ try {
275
+ client = args.connectClient
276
+ ? await args.connectClient(socketPath)
277
+ : await defaultConnectClient(socketPath);
278
+ } catch (cause) {
279
+ io.stderr(`TICK_RUNNER_FAILED ${(cause as Error).constructor.name}: ${(cause as Error).message}`);
280
+ return 1;
281
+ }
282
+
283
+ let exitCode = 0;
284
+ try {
285
+ const startSlot = slotFor(args.schedule, now());
286
+ io.stdout(
287
+ resuming
288
+ ? `TICK_RUNNER schedule=${args.schedule.scheduleId} resuming from slot ${String(state.lastEmittedSlot)}; current slot ${startSlot}`
289
+ : `TICK_RUNNER schedule=${args.schedule.scheduleId} first run; starting at slot ${startSlot}`,
290
+ );
291
+
292
+ let polls = 0;
293
+ while (!(args.signal?.aborted ?? false)) {
294
+ if (args.maxPolls !== undefined && polls >= args.maxPolls) break;
295
+ polls += 1;
296
+
297
+ const report = await pollOnce(spec, client, args.schedule, cursor, now());
298
+
299
+ // Persist before reporting. The cursor is the thing a restart depends
300
+ // on; losing it costs slots, whereas losing a log line costs a message.
301
+ if (report.emittedSlots.length > 0) {
302
+ state.lastEmittedSlot = cursor.lastEmittedSlot;
303
+ state.lastEmittedAtMs = now();
304
+ }
305
+ if (report.skippedSlots.length > 0) {
306
+ state.skippedSlots.push(...report.skippedSlots);
307
+ }
308
+ if (report.emittedSlots.length > 0 || report.skippedSlots.length > 0) {
309
+ await saveTickState(statePath, state);
310
+ }
311
+
312
+ for (const slot of report.skippedSlots) {
313
+ // Loud, per slot, with the instant it stood for. A count would let a
314
+ // reader skim past "3 skipped"; an instant is a thing an operator can
315
+ // go and look for in the journal and fail to find.
316
+ io.stderr(
317
+ `TICK_SKIPPED schedule=${args.schedule.scheduleId} slot=${slot} scheduled_for_ms=${scheduledForMs(args.schedule, slot)} — past maxCatchUp=${args.schedule.maxCatchUp ?? DEFAULT_MAX_CATCH_UP}; this scheduled work did NOT run`,
318
+ );
319
+ }
320
+ for (const slot of report.emittedSlots) {
321
+ io.stdout(
322
+ `TICK_EMITTED schedule=${args.schedule.scheduleId} slot=${slot} scheduled_for_ms=${scheduledForMs(args.schedule, slot)}`,
323
+ );
324
+ }
325
+
326
+ if (report.failure !== undefined) {
327
+ const failure = report.failure;
328
+ const name = failure instanceof Error ? failure.constructor.name : typeof failure;
329
+ const message = failure instanceof Error ? failure.message : String(failure);
330
+ io.stderr(`TICK_RUNNER_FAILED ${name}: ${message}`);
331
+ return 1;
332
+ }
333
+
334
+ if (args.signal?.aborted ?? false) break;
335
+ if (args.maxPolls !== undefined && polls >= args.maxPolls) break;
336
+ await sleepInterruptible(pollIntervalMs, args.signal);
337
+ }
338
+ io.stdout(`TICK_RUNNER_STOPPED schedule=${args.schedule.scheduleId} polls=${polls}`);
339
+ } catch (cause) {
340
+ const name = cause instanceof Error ? cause.constructor.name : typeof cause;
341
+ io.stderr(`TICK_RUNNER_FAILED ${name}: ${(cause as Error).message}`);
342
+ exitCode = 1;
343
+ } finally {
344
+ client.close();
345
+ }
346
+ return exitCode;
347
+ }
348
+
349
+ /**
350
+ * One poll. Normalises the two shapes `emitDueTicks` can produce — a result,
351
+ * or a `TickEmitError` carrying the partial result — into one report, so the
352
+ * caller has exactly one accounting path and cannot handle the success case
353
+ * and forget the failure case.
354
+ */
355
+ async function pollOnce(
356
+ spec: unknown,
357
+ sink: TickRunnerClient,
358
+ schedule: TickSchedule,
359
+ cursor: TickCursor,
360
+ nowMs: number,
361
+ ): Promise<TickPollReport> {
362
+ try {
363
+ const result: TickEmitResult = await emitDueTicks(spec, sink, { schedule, cursor, nowMs });
364
+ return { emittedSlots: result.emittedSlots, skippedSlots: result.skippedSlots };
365
+ } catch (cause) {
366
+ if (cause instanceof TickEmitError) {
367
+ // The partial accounting travelled with the failure. Surface it, then
368
+ // let the caller treat the failure as fatal — the unemitted slots are
369
+ // still due, because `emitDueTicks` only advances on success.
370
+ return {
371
+ emittedSlots: cause.emittedSlots,
372
+ skippedSlots: cause.skippedSlots,
373
+ failure: cause.cause ?? cause,
374
+ };
375
+ }
376
+ return { emittedSlots: [], skippedSlots: [], failure: cause };
377
+ }
378
+ }
@@ -0,0 +1,115 @@
1
+ import { basename } from 'node:path';
2
+
3
+ export type CliAdapterKind = 'claude' | 'codex' | 'relayflows-wrapper-v1';
4
+
5
+ export interface CliInvocation {
6
+ args: string[];
7
+ timeoutMs: number;
8
+ /** Set only for wrapper readiness probes; raw providers receive a model flag. */
9
+ modelEnv?: string;
10
+ }
11
+
12
+ export interface CliAdapterIdentification {
13
+ invocation: CliInvocation;
14
+ expectedStdout?: string;
15
+ }
16
+
17
+ export const WRAPPER_IDENTIFY_ARG = '--relayflows-adapter-v1';
18
+ export const WRAPPER_IDENTIFY_TOKEN = 'relayflows-agent-cli-v1';
19
+ export const WRAPPER_EXECUTE_TOKEN = 'relayflows-agent-cli-v1-execute';
20
+
21
+ const MODEL_PROBE_PROMPT = 'Reply with exactly RELAYFLOWS_MODEL_READY and nothing else.';
22
+
23
+ /** Select a closed adapter from the resolved executable's basename. */
24
+ export function cliAdapterKind(executable: string): CliAdapterKind {
25
+ const name = basename(executable).replace(/\.exe$/i, '');
26
+ if (name === 'claude') return 'claude';
27
+ if (name === 'codex') return 'codex';
28
+ return 'relayflows-wrapper-v1';
29
+ }
30
+
31
+ /** Prove the adapter command shape before classifying an auth failure. */
32
+ export function adapterIdentification(kind: CliAdapterKind): CliAdapterIdentification {
33
+ if (kind === 'claude') {
34
+ return { invocation: { args: ['auth', 'status', '--help'], timeoutMs: 10_000 } };
35
+ }
36
+ if (kind === 'codex') {
37
+ return { invocation: { args: ['login', 'status', '--help'], timeoutMs: 10_000 } };
38
+ }
39
+ return {
40
+ invocation: { args: [WRAPPER_IDENTIFY_ARG], timeoutMs: 10_000 },
41
+ expectedStdout: WRAPPER_IDENTIFY_TOKEN,
42
+ };
43
+ }
44
+
45
+ export function authenticationProbe(kind: CliAdapterKind): CliInvocation {
46
+ if (kind === 'codex') return { args: ['login', 'status'], timeoutMs: 10_000 };
47
+ return { args: ['auth', 'status'], timeoutMs: 10_000 };
48
+ }
49
+
50
+ /**
51
+ * A provider model probe is a real, noninteractive model round trip. The
52
+ * wrapper protocol keeps its established auth-status shape and receives the
53
+ * exact model through its explicitly identified environment contract.
54
+ */
55
+ export function modelReadinessProbe(kind: CliAdapterKind, model: string): CliInvocation {
56
+ if (kind === 'claude') {
57
+ return {
58
+ args: [
59
+ '-p', '--model', model, '--tools', '', '--no-session-persistence',
60
+ MODEL_PROBE_PROMPT,
61
+ ],
62
+ timeoutMs: 60_000,
63
+ };
64
+ }
65
+ if (kind === 'codex') {
66
+ return {
67
+ args: [
68
+ 'exec', '--ephemeral', '--sandbox', 'read-only', '--skip-git-repo-check',
69
+ '--model', model, MODEL_PROBE_PROMPT,
70
+ ],
71
+ timeoutMs: 60_000,
72
+ };
73
+ }
74
+ return {
75
+ args: ['auth', 'status'],
76
+ timeoutMs: 60_000,
77
+ modelEnv: model,
78
+ };
79
+ }
80
+
81
+ /** Build the actual worker argv; this is shared contract, not probe-only lore. */
82
+ export function agentExecution(
83
+ kind: CliAdapterKind,
84
+ instruction: string,
85
+ model?: string,
86
+ ): CliInvocation {
87
+ if (kind === 'claude') {
88
+ return {
89
+ args: ['-p', ...(model === undefined ? [] : ['--model', model]), instruction],
90
+ timeoutMs: 0,
91
+ };
92
+ }
93
+ if (kind === 'codex') {
94
+ return {
95
+ args: [
96
+ 'exec', '--ephemeral', '--skip-git-repo-check',
97
+ ...(model === undefined ? [] : ['--model', model]),
98
+ instruction,
99
+ ],
100
+ timeoutMs: 0,
101
+ };
102
+ }
103
+ throw new Error('custom wrapper execution requires the runAgentCli same-process session');
104
+ }
105
+
106
+ export function displayInvocation(cli: string, invocation: CliInvocation): string {
107
+ const command = [cli, ...invocation.args].map(shellDisplayWord).join(' ');
108
+ return invocation.modelEnv === undefined
109
+ ? command
110
+ : `RELAYFLOW_MODEL=${shellDisplayWord(invocation.modelEnv)} ${command}`;
111
+ }
112
+
113
+ function shellDisplayWord(word: string): string {
114
+ return /^[A-Za-z0-9_./:-]+$/.test(word) ? word : JSON.stringify(word);
115
+ }
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { runCli } from './cli.js';
4
+
5
+ async function main(): Promise<void> {
6
+ process.exitCode = await runCli(process.argv.slice(2));
7
+ }
8
+
9
+ void main().catch((error: unknown) => {
10
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
11
+ process.exitCode = 1;
12
+ });