@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,286 @@
1
+ /**
2
+ * `flows hn-monitor start` — CLI-inlined proactive workload for gate 2.
3
+ *
4
+ * runHnMonitor is a public function (not a class) that composes the
5
+ * primitives directly: connect journal → hello → attach agent worker →
6
+ * loop pollHackerNewsOnce → drain on abort → close.
7
+ *
8
+ * Poll errors classify into two shapes only:
9
+ * - `instanceof HnTransientFetchError` → log and continue next tick.
10
+ * - anything else → non-transient (journal failure OR programmer
11
+ * error); log with the actual class name and terminate (fail-closed
12
+ * per covenant 2).
13
+ */
14
+
15
+ import { readFile } from 'node:fs/promises';
16
+ import { dirname, isAbsolute, resolve } from 'node:path';
17
+ import { pollHackerNewsOnce, HnTransientFetchError, type Fetcher } from '../hn-poller.js';
18
+ import { AgentWorker } from '../worker.js';
19
+ import { JournalClient } from '../journal-client.js';
20
+ import type { EventSubmitResult, HelloResult, Pins } from '../protocol.js';
21
+ import type { CliIo } from '../cli.js';
22
+
23
+ const DEFAULT_POLL_INTERVAL_MS = 60_000;
24
+
25
+ /**
26
+ * Minimum client surface runHnMonitor uses. Concrete return types come
27
+ * from `protocol.ts`, not `unknown`, so a future rename or shape change
28
+ * in the journal protocol fails to compile here rather than drifting
29
+ * silently past the interface.
30
+ */
31
+ export interface HnMonitorClient {
32
+ hello(client: string): Promise<HelloResult>;
33
+ eventSubmit(spec: unknown, event: { type: string; payload?: unknown; key?: string }): Promise<EventSubmitResult>;
34
+ close(): void;
35
+ }
36
+
37
+ /** Minimum worker surface runHnMonitor uses (matches AgentWorker). */
38
+ export interface HnMonitorWorker {
39
+ close(): Promise<void> | void;
40
+ }
41
+
42
+ /**
43
+ * Base fields every caller — production and tests — supplies.
44
+ */
45
+ interface HnMonitorArgsBase {
46
+ /** Data directory containing relayflowd.sock. Required. */
47
+ dataDir: string;
48
+ /** Absolute path to the canonical flow spec JSON. Required. */
49
+ specPath: string;
50
+ /** How often to fetch top-stories. Default 60000ms. */
51
+ pollIntervalMs?: number;
52
+ /**
53
+ * Cap on iterations. Undefined = unbounded (production). Any value
54
+ * ≥ 0 caps; 0 means "attach, run zero polls, drain, exit 0" — used
55
+ * by tests that only need to prove the setup/teardown paths.
56
+ */
57
+ maxPolls?: number;
58
+ /** AbortSignal for external cancellation (tests / SIGINT wiring). */
59
+ signal?: AbortSignal;
60
+ }
61
+
62
+ /**
63
+ * Injection surface for tests. connectClient AND attachWorker MUST be
64
+ * supplied together — the fallback attach path relies on the client
65
+ * being a real JournalClient, which only the default connect path can
66
+ * guarantee. Enforcing the pairing at the type level catches the
67
+ * "override connect, forget attach" foot-gun the M lens flagged at
68
+ * compile time. Fetcher is orthogonal — it plumbs through to
69
+ * `pollHackerNewsOnce`, not the transport.
70
+ */
71
+ interface HnMonitorInjections {
72
+ connectClient: (socketPath: string) => Promise<HnMonitorClient>;
73
+ attachWorker: (client: HnMonitorClient, onWorkerError: (err: unknown) => void) => Promise<HnMonitorWorker>;
74
+ fetcher?: Fetcher;
75
+ }
76
+
77
+ /**
78
+ * Production shape — no injections; runHnMonitor builds a real
79
+ * JournalClient and a real AgentWorker.
80
+ */
81
+ interface HnMonitorProduction {
82
+ connectClient?: never;
83
+ attachWorker?: never;
84
+ fetcher?: Fetcher;
85
+ }
86
+
87
+ /** Args parsed by cli.ts and handed to runHnMonitor. */
88
+ export type HnMonitorArgs = HnMonitorArgsBase & (HnMonitorProduction | HnMonitorInjections);
89
+
90
+ /**
91
+ * Return a COPY of the spec in which each step's relative `cli` is an
92
+ * absolute path anchored at the spec file's directory. The input is not
93
+ * modified — a caller that still needs the declared relative path (for
94
+ * logging, re-serialization, or handing the spec to another tool) keeps it.
95
+ *
96
+ * `flows check` resolves a declared CLI against the spec's directory
97
+ * (sdk/src/cli/check.ts `probeCli`), but AgentWorker ultimately calls
98
+ * `spawn(cli, ...)`, which resolves a relative path against the WORKER
99
+ * PROCESS's cwd. Those two are the same only when the runner happens to
100
+ * be started from the spec's directory. Without this, a spec that
101
+ * `flows check` passes still dies with ENOENT once launched from
102
+ * anywhere else — preflight-green but unrunnable, the worst shape for a
103
+ * gate that is supposed to prove the workload runs.
104
+ *
105
+ * Bare command names (no separator) are left alone: those are PATH
106
+ * lookups, and both probeCli and spawn already agree on them.
107
+ */
108
+ export function resolveSpecCliPaths<T>(spec: T, specPath: string): T {
109
+ if (typeof spec !== 'object' || spec === null) return spec;
110
+ const steps = (spec as { steps?: unknown }).steps;
111
+ if (!Array.isArray(steps)) return spec;
112
+ const base = dirname(resolve(specPath));
113
+ const resolved = steps.map((step) => {
114
+ if (typeof step !== 'object' || step === null) return step;
115
+ const cli = (step as { cli?: unknown }).cli;
116
+ if (typeof cli !== 'string' || isAbsolute(cli)) return step;
117
+ // Both separators: on Windows a relative `preflight\analyzer` contains no
118
+ // '/', so a '/'-only test would misread it as a bare PATH command and
119
+ // leave it unresolved.
120
+ if (!cli.includes('/') && !cli.includes('\\')) return step;
121
+ return { ...step, cli: resolve(base, cli) };
122
+ });
123
+ return { ...spec, steps: resolved };
124
+ }
125
+
126
+ /** Interruptible sleep — wakes on abort as well as timeout. */
127
+ function sleepInterruptible(ms: number, signal?: AbortSignal): Promise<void> {
128
+ return new Promise((resolve) => {
129
+ if (signal?.aborted) { resolve(); return; }
130
+ let timer: ReturnType<typeof setTimeout> | undefined;
131
+ const onAbort = (): void => {
132
+ if (timer !== undefined) clearTimeout(timer);
133
+ resolve();
134
+ };
135
+ timer = setTimeout(() => {
136
+ signal?.removeEventListener('abort', onAbort);
137
+ resolve();
138
+ }, ms);
139
+ signal?.addEventListener('abort', onAbort, { once: true });
140
+ });
141
+ }
142
+
143
+ async function defaultConnectClient(socketPath: string): Promise<HnMonitorClient> {
144
+ const client = new JournalClient(socketPath);
145
+ await client.connect();
146
+ await client.hello('hn-monitor');
147
+ return client;
148
+ }
149
+
150
+ async function defaultAttachWorker(
151
+ client: JournalClient,
152
+ onWorkerError: (err: unknown) => void,
153
+ ): Promise<HnMonitorWorker> {
154
+ const pins: Pins = {
155
+ workspace: [{ surface: 'repo', revision_id: 'live' }],
156
+ streams: [],
157
+ };
158
+ // workerId encodes pid so a same-machine restart shows up as a fresh
159
+ // worker; a container restart that recycles pids before the prior
160
+ // lease expires will collide until `workerRelease` is implemented
161
+ // (documented in worker.ts and tracked as gate-2 follow-up work).
162
+ const worker = new AgentWorker(client, {
163
+ workerId: `hn-monitor-${process.pid}`,
164
+ pins,
165
+ });
166
+ // AgentWorker extends EventEmitter and emits 'error' when a dispatched
167
+ // step's execute() throws. Without a listener, Node re-throws
168
+ // synchronously, bypasses the drain-aware close(), and crashes the
169
+ // process. Subscribe BEFORE attach so an error during attach is not
170
+ // lost.
171
+ worker.on('error', onWorkerError);
172
+ await worker.attach();
173
+ return worker;
174
+ }
175
+
176
+ /**
177
+ * Run the hn-monitor CLI subcommand. Returns 0 on clean shutdown (signal
178
+ * or maxPolls), 1 on any fail-closed condition (spec unreadable, connect
179
+ * failure, hello failure, worker attach failure, journal failure during
180
+ * poll).
181
+ */
182
+ export async function runHnMonitor(args: HnMonitorArgs, io: CliIo): Promise<0 | 1> {
183
+ const pollIntervalMs = args.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
184
+ const socketPath = `${args.dataDir}/relayflowd.sock`;
185
+
186
+ let spec: unknown;
187
+ try {
188
+ spec = resolveSpecCliPaths(JSON.parse(await readFile(args.specPath, 'utf8')), args.specPath);
189
+ } catch (err) {
190
+ io.stderr(`hn-monitor: cannot read spec at ${args.specPath}: ${String(err)}`);
191
+ return 1;
192
+ }
193
+
194
+ // The discriminated union on HnMonitorArgs guarantees connectClient
195
+ // and attachWorker come as a pair OR neither is set. When neither is
196
+ // set, we take the fully-typed default path; when both are set,
197
+ // callers supply their own consistent fake pair. The prior "override
198
+ // one, forget the other" foot-gun no longer typechecks.
199
+ const usingInjections = args.connectClient !== undefined;
200
+ const connect: (socketPath: string) => Promise<HnMonitorClient> =
201
+ usingInjections ? args.connectClient! : defaultConnectClient;
202
+ const attach: (c: HnMonitorClient, onErr: (err: unknown) => void) => Promise<HnMonitorWorker> =
203
+ usingInjections
204
+ ? args.attachWorker!
205
+ : (c, onErr) => defaultAttachWorker(c as JournalClient, onErr);
206
+
207
+ let client: HnMonitorClient;
208
+ try {
209
+ client = await connect(socketPath);
210
+ } catch (err) {
211
+ io.stderr(`hn-monitor: cannot connect to relayflowd at ${socketPath}: ${String(err)}`);
212
+ return 1;
213
+ }
214
+
215
+ // Async event surface: AgentWorker emits 'error' from step-dispatch
216
+ // callbacks that reject. Bubble those up as a fail-closed termination
217
+ // reason via a shared cell so the main loop notices on its next tick.
218
+ let workerErrorEvent: unknown;
219
+ const onWorkerError = (err: unknown): void => {
220
+ if (workerErrorEvent !== undefined) return;
221
+ workerErrorEvent = err;
222
+ };
223
+
224
+ let worker: HnMonitorWorker;
225
+ try {
226
+ worker = await attach(client, onWorkerError);
227
+ } catch (err) {
228
+ io.stderr(`hn-monitor: worker attach failed: ${String(err)}`);
229
+ client.close();
230
+ return 1;
231
+ }
232
+
233
+ io.stdout(`hn-monitor: attached worker at ${socketPath}, poll every ${pollIntervalMs}ms`);
234
+
235
+ let iterations = 0;
236
+ let exit: 0 | 1 = 0;
237
+ try {
238
+ while (!(args.signal?.aborted ?? false)) {
239
+ // Cap check FIRST — maxPolls === 0 must exit before dispatching
240
+ // any poll; the prior placement ran one poll before checking,
241
+ // making the "0 = skip" semantics undocumented and surprising.
242
+ if (args.maxPolls !== undefined && iterations >= args.maxPolls) break;
243
+ // Worker's async error surface preempts the next poll — an emitted
244
+ // 'error' from a prior tick's dispatch means the worker's contract
245
+ // is broken and the loop must terminate.
246
+ if (workerErrorEvent !== undefined) {
247
+ io.stderr(`hn-monitor: worker emitted error, terminating: ${nameAndMessage(workerErrorEvent)}`);
248
+ exit = 1;
249
+ break;
250
+ }
251
+ let terminate = false;
252
+ try {
253
+ await pollHackerNewsOnce(spec, {
254
+ eventSubmit: (specArg, event) => client.eventSubmit(specArg, event),
255
+ }, args.fetcher ? { fetcher: args.fetcher } : {});
256
+ } catch (err) {
257
+ if (err instanceof HnTransientFetchError) {
258
+ io.stderr(`hn-monitor: poll fetch failed (continuing next tick): ${String(err)}`);
259
+ } else {
260
+ // Non-transient: journal failure OR programmer bug in the
261
+ // poller. Log the actual class name so a real bug is not
262
+ // silently attributed to the journal.
263
+ io.stderr(`hn-monitor: non-transient error, terminating: ${nameAndMessage(err)}`);
264
+ exit = 1;
265
+ terminate = true;
266
+ }
267
+ }
268
+ if (terminate) break;
269
+ iterations++;
270
+ if (args.signal?.aborted) break;
271
+ await sleepInterruptible(pollIntervalMs, args.signal);
272
+ }
273
+ } finally {
274
+ // Drain in-flight step executions before closing the socket. Worker's
275
+ // close() awaits Promise.allSettled on its own in-flight tracker.
276
+ await worker.close();
277
+ client.close();
278
+ }
279
+ io.stdout(`hn-monitor: shutdown after ${iterations} iteration(s)`);
280
+ return exit;
281
+ }
282
+
283
+ function nameAndMessage(err: unknown): string {
284
+ if (err instanceof Error) return `${err.name}: ${err.message}`;
285
+ return String(err);
286
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Sleep that wakes on abort as well as on timeout.
3
+ *
4
+ * Extracted from `hn-monitor.ts` when `tick-runner.ts` needed the identical
5
+ * behaviour. A long-running event source that sleeps on a bare `setTimeout`
6
+ * cannot be shut down promptly: SIGINT is observed only after the current
7
+ * sleep elapses, which for a schedule polled once a minute means a minute of
8
+ * apparent hang on every Ctrl-C. Both runners therefore share one
9
+ * implementation rather than each carrying a subtly different copy.
10
+ */
11
+
12
+ /** Resolve after `ms`, or immediately when `signal` aborts — whichever is first. */
13
+ export function sleepInterruptible(ms: number, signal?: AbortSignal): Promise<void> {
14
+ return new Promise((resolve) => {
15
+ if (signal?.aborted) { resolve(); return; }
16
+ let timer: ReturnType<typeof setTimeout> | undefined;
17
+ const onAbort = (): void => {
18
+ if (timer !== undefined) clearTimeout(timer);
19
+ resolve();
20
+ };
21
+ timer = setTimeout(() => {
22
+ signal?.removeEventListener('abort', onAbort);
23
+ resolve();
24
+ }, ms);
25
+ signal?.addEventListener('abort', onAbort, { once: true });
26
+ });
27
+ }