@intx/workflow-host 0.2.2

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 (81) hide show
  1. package/LICENSE +176 -0
  2. package/README.md +287 -0
  3. package/dist/adapters/blob-substrate.d.ts +49 -0
  4. package/dist/adapters/blob-substrate.js +140 -0
  5. package/dist/adapters/repo-store.d.ts +39 -0
  6. package/dist/adapters/repo-store.js +344 -0
  7. package/dist/adapters/spawn-child.d.ts +74 -0
  8. package/dist/adapters/spawn-child.js +152 -0
  9. package/dist/adapters/step-invoker.d.ts +114 -0
  10. package/dist/adapters/step-invoker.js +360 -0
  11. package/dist/child/env-bootstrap.d.ts +56 -0
  12. package/dist/child/env-bootstrap.js +120 -0
  13. package/dist/child/from-process-env.d.ts +127 -0
  14. package/dist/child/from-process-env.js +183 -0
  15. package/dist/child/index.d.ts +9 -0
  16. package/dist/child/index.js +9 -0
  17. package/dist/child/outbound-mail-bridge.d.ts +36 -0
  18. package/dist/child/outbound-mail-bridge.js +143 -0
  19. package/dist/child/proxy-repo-store.d.ts +27 -0
  20. package/dist/child/proxy-repo-store.js +200 -0
  21. package/dist/child/run-child.d.ts +320 -0
  22. package/dist/child/run-child.js +900 -0
  23. package/dist/child/self-discovery.d.ts +29 -0
  24. package/dist/child/self-discovery.js +57 -0
  25. package/dist/child/substrate-write-bridge.d.ts +72 -0
  26. package/dist/child/substrate-write-bridge.js +188 -0
  27. package/dist/child/supervisor-backed-transport.d.ts +10 -0
  28. package/dist/child/supervisor-backed-transport.js +113 -0
  29. package/dist/child/warm-agent-cache.d.ts +78 -0
  30. package/dist/child/warm-agent-cache.js +112 -0
  31. package/dist/drain-controller.d.ts +37 -0
  32. package/dist/drain-controller.js +46 -0
  33. package/dist/index.d.ts +10 -0
  34. package/dist/index.js +10 -0
  35. package/dist/ipc/control-channel.d.ts +336 -0
  36. package/dist/ipc/control-channel.js +532 -0
  37. package/dist/ipc/crypto.d.ts +46 -0
  38. package/dist/ipc/crypto.js +126 -0
  39. package/dist/ipc/envelope.d.ts +53 -0
  40. package/dist/ipc/envelope.js +88 -0
  41. package/dist/ipc/event-channel.d.ts +677 -0
  42. package/dist/ipc/event-channel.js +278 -0
  43. package/dist/ipc/index.d.ts +4 -0
  44. package/dist/ipc/index.js +143 -0
  45. package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
  46. package/dist/mail-bus/hub-transport-adapter.js +76 -0
  47. package/dist/mail-bus/index.d.ts +1 -0
  48. package/dist/mail-bus/index.js +1 -0
  49. package/dist/seams/index.d.ts +3 -0
  50. package/dist/seams/index.js +3 -0
  51. package/dist/seams/scheduler-adapter.d.ts +3 -0
  52. package/dist/seams/scheduler-adapter.js +24 -0
  53. package/dist/seams/scheduler.d.ts +94 -0
  54. package/dist/seams/scheduler.js +397 -0
  55. package/dist/seams/signal-channel.d.ts +74 -0
  56. package/dist/seams/signal-channel.js +304 -0
  57. package/dist/supervisor/cancel-signing.d.ts +68 -0
  58. package/dist/supervisor/cancel-signing.js +144 -0
  59. package/dist/supervisor/child-termination.d.ts +51 -0
  60. package/dist/supervisor/child-termination.js +76 -0
  61. package/dist/supervisor/credentials.d.ts +101 -0
  62. package/dist/supervisor/credentials.js +153 -0
  63. package/dist/supervisor/dispatch-attribution.d.ts +37 -0
  64. package/dist/supervisor/dispatch-attribution.js +114 -0
  65. package/dist/supervisor/drain-timeout.d.ts +127 -0
  66. package/dist/supervisor/drain-timeout.js +231 -0
  67. package/dist/supervisor/index.d.ts +7 -0
  68. package/dist/supervisor/index.js +6 -0
  69. package/dist/supervisor/recycle.d.ts +212 -0
  70. package/dist/supervisor/recycle.js +440 -0
  71. package/dist/supervisor/run-event-compaction.d.ts +34 -0
  72. package/dist/supervisor/run-event-compaction.js +115 -0
  73. package/dist/supervisor/spawn-env.d.ts +39 -0
  74. package/dist/supervisor/spawn-env.js +36 -0
  75. package/dist/supervisor/supervisor.d.ts +202 -0
  76. package/dist/supervisor/supervisor.js +2244 -0
  77. package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
  78. package/dist/supervisor/terminal-broadcaster.js +184 -0
  79. package/dist/supervisor/types.d.ts +542 -0
  80. package/dist/supervisor/types.js +10 -0
  81. package/package.json +35 -0
@@ -0,0 +1,94 @@
1
+ import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate";
2
+ /**
3
+ * Substrate-shape envelope for the workflow-event blob committed to
4
+ * `runs/<runId>/events/<seq>.json`. The validator covers the two
5
+ * event types the scheduler reads (TimerSet, TimerFired) and the
6
+ * single type it writes (TimerFired). Non-timer blobs at the same
7
+ * path prefix do not match this validator and are skipped silently
8
+ * by the recovery walk. `seq` mirrors the integer in the filename,
9
+ * matching the workflow-run kind handler's `EventEnvelope` contract.
10
+ */
11
+ export declare const TimerEventEnvelope: import("arktype/internal/variants/object.ts").ObjectType<{
12
+ seq: number;
13
+ type: "TimerSet" | "TimerFired";
14
+ data: {
15
+ timerId: string;
16
+ fireAt?: string;
17
+ stepId?: string | null;
18
+ cron?: string | null;
19
+ };
20
+ }, {}>;
21
+ export type TimerEventEnvelope = typeof TimerEventEnvelope.infer;
22
+ export type SchedulerOpts = {
23
+ /**
24
+ * Substrate handle the scheduler reads from and writes to. The
25
+ * caller wires this against the workflow-run kind handler's
26
+ * registered substrate -- the scheduler does not care about the
27
+ * kind discriminator, but the handler's `validatePush` must accept
28
+ * the `runs/<runId>/events/<seq>.json` writes the scheduler emits.
29
+ */
30
+ repoStore: RepoStore;
31
+ /**
32
+ * Principal the scheduler presents to the substrate. The substrate
33
+ * gates every operation behind `authorize`; the scheduler's
34
+ * principal must be granted `writeTree` and `resolveRef` against
35
+ * the workflow-run repos it services.
36
+ */
37
+ principal: Principal;
38
+ /**
39
+ * Callback that enumerates the active deployment workflow-run
40
+ * repos the scheduler is responsible for. The scheduler invokes
41
+ * this at `start()` time to seed its recovery walk and again on
42
+ * every `TimerFired` commit to attribute the runId to its owning
43
+ * deployment.
44
+ */
45
+ listActiveDeployments: () => Promise<readonly RepoId[]> | readonly RepoId[];
46
+ /**
47
+ * Events ref to tail. The workflow-run repo layout pins all
48
+ * `runs/<runId>/events/` blobs under a single moving ref. Callers
49
+ * typically supply `"refs/heads/main"`.
50
+ */
51
+ ref: string;
52
+ /**
53
+ * Clock used to compute delay-from-fireAt for queueing setTimeout
54
+ * callbacks and to skip past-due cron entries on recovery.
55
+ */
56
+ clock: () => Date;
57
+ };
58
+ export type SchedulerHandle = {
59
+ /**
60
+ * Run start-time recovery against every active deployment. After
61
+ * the recovery walk completes, every unfired one-shot timer is
62
+ * queued and every missed cron tick has been skipped per the spec.
63
+ * Idempotent: a second `start()` is a no-op while the first is
64
+ * still pending.
65
+ */
66
+ start(): Promise<void>;
67
+ /**
68
+ * Tear down every queued timer. Idempotent. Outstanding
69
+ * `setTimeout` handles are cancelled. After `stop()` the scheduler
70
+ * holds no host resources.
71
+ */
72
+ stop(): Promise<void>;
73
+ /**
74
+ * Cancel any queued timer matching `(runId, timerId)`. The
75
+ * runtime-shaped `Scheduler.scheduleIn` returns a disposer the
76
+ * runtime body invokes when the awaiting site settles on a sibling
77
+ * event before the timer's deadline; the adapter routes that
78
+ * disposer here so the host scheduler does not commit a
79
+ * `TimerFired` after the runtime has moved on. Idempotent: a call
80
+ * for an unknown key is a no-op.
81
+ */
82
+ cancelQueued(runId: string, timerId: string): void;
83
+ /**
84
+ * Test-visible view of currently-queued timers. The shape is
85
+ * intentionally narrow: the runtime body never inspects the
86
+ * scheduler's queue; tests assert on it directly.
87
+ */
88
+ queuedTimers(): readonly {
89
+ runId: string;
90
+ timerId: string;
91
+ fireAtMs: number;
92
+ }[];
93
+ };
94
+ export declare function createWorkflowHostScheduler(opts: SchedulerOpts): SchedulerHandle;
@@ -0,0 +1,397 @@
1
+ // Production workflow-host scheduler (Seam 1, event-sourced wait).
2
+ //
3
+ // The scheduler is a singleton per host process. It is the single
4
+ // writer of `TimerFired` to every active deployment's workflow-run
5
+ // log. The runtime body's `waitForTimer` helper subscribes to the
6
+ // run's log via `subscribeKind` and resolves when this scheduler
7
+ // commits the matching `TimerFired` event. No other component in
8
+ // the host -- workflow-process child, supervisor, sidecar handler --
9
+ // commits `TimerFired`. This single-writer invariant is what makes
10
+ // the recovery story work: at startup the scheduler walks the
11
+ // persisted log for unfired `TimerSet` events and re-queues them,
12
+ // confident that any `TimerFired` it does not find was never
13
+ // committed and is its responsibility to commit now.
14
+ //
15
+ // Recovery semantics:
16
+ // - One-shot timers (`TimerSet` without a `cron` discriminator) on
17
+ // resume are queued with their stored `fireAt`. If `fireAt` is
18
+ // already in the past the scheduler fires immediately so the
19
+ // paired `TimerFired` lands and the awaiting runtime body
20
+ // unblocks. The runtime is responsible for its own jitter
21
+ // tolerance.
22
+ // - Cron-style timers whose `fireAt` is in the past on resume are
23
+ // SKIPPED -- per the spec, missed cron ticks do not replay. The
24
+ // scheduler waits for the next future tick to be committed and
25
+ // queues that one.
26
+ //
27
+ // The scheduler reads workflow-event blobs at the canonical layout
28
+ // `runs/<runId>/events/<seq>.json` and writes a fresh blob at the
29
+ // next-seq slot for each `TimerFired` commit. The blob envelope
30
+ // carries `{ seq, type, data }` at the top level: `seq` is the
31
+ // integer that also appears in the filename, `type` is the
32
+ // `subscribeKind` discriminator the scheduler narrows on, and `data`
33
+ // carries the timer payload. The workflow-run kind handler's
34
+ // `validatePush` enforces that every event blob's body `seq` matches
35
+ // the filename's seq, so the scheduler mints the next seq inside the
36
+ // `writeTreePreservingPrefix` merge step and writes both into the
37
+ // envelope and the filename.
38
+ import { type } from "arktype";
39
+ import { subscribeKind } from "@intx/hub-sessions/substrate";
40
+ /**
41
+ * Substrate-shape envelope for the workflow-event blob committed to
42
+ * `runs/<runId>/events/<seq>.json`. The validator covers the two
43
+ * event types the scheduler reads (TimerSet, TimerFired) and the
44
+ * single type it writes (TimerFired). Non-timer blobs at the same
45
+ * path prefix do not match this validator and are skipped silently
46
+ * by the recovery walk. `seq` mirrors the integer in the filename,
47
+ * matching the workflow-run kind handler's `EventEnvelope` contract.
48
+ */
49
+ export const TimerEventEnvelope = type({
50
+ seq: "number >= 0",
51
+ type: "'TimerSet' | 'TimerFired'",
52
+ data: {
53
+ timerId: "string",
54
+ "fireAt?": "string",
55
+ "stepId?": "string | null",
56
+ "cron?": "string | null",
57
+ },
58
+ });
59
+ export function createWorkflowHostScheduler(opts) {
60
+ const queues = new Map();
61
+ const liveSubscriptions = [];
62
+ let started = false;
63
+ let stopped = false;
64
+ function queueKey(runId, timerId) {
65
+ return `${runId} ${timerId}`;
66
+ }
67
+ async function fireTimer(runId, timerId) {
68
+ const key = queueKey(runId, timerId);
69
+ const entry = queues.get(key);
70
+ if (entry === undefined)
71
+ return;
72
+ queues.delete(key);
73
+ if (stopped)
74
+ return;
75
+ await commitTimerFired(opts, runId, timerId);
76
+ }
77
+ function enqueue(repoId, runId, timerId, fireAtMs, cron) {
78
+ if (stopped)
79
+ return;
80
+ const key = queueKey(runId, timerId);
81
+ if (queues.has(key))
82
+ return; // idempotent
83
+ const delayMs = Math.max(0, fireAtMs - opts.clock().getTime());
84
+ const timeout = setTimeout(() => {
85
+ void fireTimer(runId, timerId).catch((cause) => {
86
+ // The scheduler's commit failed. Surface as unhandled so
87
+ // operators see it; the runtime body's awaiter will hang
88
+ // until restart triggers recovery.
89
+ throw cause instanceof Error
90
+ ? cause
91
+ : new Error(`scheduler ${String(repoId.id)}/${runId}/${timerId} commit failed: ${String(cause)}`);
92
+ });
93
+ }, delayMs);
94
+ queues.set(key, { runId, timerId, fireAtMs, timeout, cron });
95
+ }
96
+ function startLiveSubscription(repoId) {
97
+ const abort = new AbortController();
98
+ const done = (async () => {
99
+ const iter = subscribeKind(opts.repoStore, opts.principal, repoId, opts.ref, TimerEventEnvelope, {
100
+ signal: abort.signal,
101
+ from: "head",
102
+ kinds: ["TimerSet"],
103
+ });
104
+ for await (const entry of iter) {
105
+ if (stopped)
106
+ break;
107
+ if (entry.event.type !== "TimerSet")
108
+ continue;
109
+ const fireAt = entry.event.data.fireAt;
110
+ if (fireAt === undefined) {
111
+ throw new Error(`scheduler live ingest: TimerSet in ${String(repoId.id)} run ${entry.runId} timer ${entry.event.data.timerId} missing fireAt`);
112
+ }
113
+ const fireAtMs = Date.parse(fireAt);
114
+ if (Number.isNaN(fireAtMs)) {
115
+ throw new Error(`scheduler live ingest: TimerSet in ${String(repoId.id)} run ${entry.runId} timer ${entry.event.data.timerId} fireAt unparseable: ${fireAt}`);
116
+ }
117
+ const cron = entry.event.data.cron !== undefined && entry.event.data.cron !== null;
118
+ if (cron && fireAtMs < opts.clock().getTime()) {
119
+ // Same missed-cron-tick spec as recovery: a cron TimerSet
120
+ // whose fireAt is in the past on arrival is dropped.
121
+ continue;
122
+ }
123
+ enqueue(repoId, entry.runId, entry.event.data.timerId, fireAtMs, cron);
124
+ }
125
+ })();
126
+ liveSubscriptions.push({ abort, done });
127
+ }
128
+ async function recoverDeployment(repoId) {
129
+ const events = await readAllEvents(opts, repoId);
130
+ // Build per-(runId, timerId) ledger: a TimerSet without a
131
+ // matching TimerFired is unfired. The walk is order-insensitive
132
+ // because the second pass deletes matched entries.
133
+ const unfired = new Map();
134
+ for (const e of events) {
135
+ if (e.envelope.type === "TimerSet") {
136
+ const fireAt = e.envelope.data.fireAt;
137
+ if (fireAt === undefined) {
138
+ throw new Error(`scheduler recovery: TimerSet in ${String(repoId.id)} run ${e.runId} timer ${e.envelope.data.timerId} missing fireAt`);
139
+ }
140
+ const fireAtMs = Date.parse(fireAt);
141
+ if (Number.isNaN(fireAtMs)) {
142
+ throw new Error(`scheduler recovery: TimerSet in ${String(repoId.id)} run ${e.runId} timer ${e.envelope.data.timerId} fireAt unparseable: ${fireAt}`);
143
+ }
144
+ const cron = e.envelope.data.cron !== undefined && e.envelope.data.cron !== null;
145
+ unfired.set(`${e.runId} ${e.envelope.data.timerId}`, {
146
+ runId: e.runId,
147
+ timerId: e.envelope.data.timerId,
148
+ fireAtMs,
149
+ cron,
150
+ });
151
+ }
152
+ else {
153
+ unfired.delete(`${e.runId} ${e.envelope.data.timerId}`);
154
+ }
155
+ }
156
+ const now = opts.clock().getTime();
157
+ for (const entry of unfired.values()) {
158
+ if (entry.cron && entry.fireAtMs < now) {
159
+ // Spec: missed cron ticks are skipped on resume. The next
160
+ // cron tick will be committed by whoever owns the cron
161
+ // emitter; the scheduler simply does not replay this one.
162
+ continue;
163
+ }
164
+ enqueue(repoId, entry.runId, entry.timerId, entry.fireAtMs, entry.cron);
165
+ }
166
+ }
167
+ return {
168
+ async start() {
169
+ if (started)
170
+ return;
171
+ started = true;
172
+ const repoIds = await opts.listActiveDeployments();
173
+ for (const repoId of repoIds) {
174
+ await recoverDeployment(repoId);
175
+ }
176
+ // Live `TimerSet` ingestion. After the recovery walk, open a
177
+ // per-deployment `subscribeKind` loop against the workflow-run
178
+ // events ref with `from: "head"` and `kinds: ["TimerSet"]`. Each
179
+ // yielded entry carries its owning runId (the substrate's
180
+ // `SubscribeKindEntry` surfaces the path-derived runId
181
+ // alongside the workflow-event seq), and the TimerSet payload
182
+ // carries the wall-clock `fireAt` plus the optional `cron`
183
+ // discriminator. `enqueue` is idempotent on `(runId, timerId)`,
184
+ // so a TimerSet that the recovery walk already queued (i.e.
185
+ // committed before subscribe could install its watcher) is
186
+ // safely re-yielded without duplicating the queue entry.
187
+ // `TimerFired` is not in the `kinds` filter: the scheduler
188
+ // commits `TimerFired` itself and does not need to ingest its
189
+ // own writes.
190
+ for (const repoId of repoIds) {
191
+ startLiveSubscription(repoId);
192
+ }
193
+ },
194
+ async stop() {
195
+ if (stopped)
196
+ return;
197
+ stopped = true;
198
+ for (const t of queues.values())
199
+ clearTimeout(t.timeout);
200
+ queues.clear();
201
+ for (const sub of liveSubscriptions.splice(0)) {
202
+ sub.abort.abort();
203
+ await sub.done.catch(() => {
204
+ /* swallow aborted-iterator surface */
205
+ });
206
+ }
207
+ },
208
+ cancelQueued(runId, timerId) {
209
+ const key = queueKey(runId, timerId);
210
+ const entry = queues.get(key);
211
+ if (entry === undefined)
212
+ return;
213
+ clearTimeout(entry.timeout);
214
+ queues.delete(key);
215
+ },
216
+ queuedTimers() {
217
+ return [...queues.values()].map((t) => ({
218
+ runId: t.runId,
219
+ timerId: t.timerId,
220
+ fireAtMs: t.fireAtMs,
221
+ }));
222
+ },
223
+ };
224
+ }
225
+ /**
226
+ * Read every timer-event blob across every run under the given
227
+ * workflow-run repo. The recovery walk reads blobs from the
228
+ * substrate's on-disk working tree directly via `enumerateEventBlobs`:
229
+ * `subscribeKind` is a diff-shaped iterator over new commits, not a
230
+ * "list everything at HEAD" primitive, so the startup ledger needs a
231
+ * path-aware enumeration of the current ref tip rather than a tail
232
+ * subscription. The substrate writes commit-then-checkout for every
233
+ * ref-update, so the working tree is a coherent snapshot of the
234
+ * current ref tip.
235
+ */
236
+ async function readAllEvents(opts, repoId) {
237
+ const entries = [];
238
+ const records = await enumerateEventBlobs(opts, repoId);
239
+ for (const r of records) {
240
+ const env = TimerEventEnvelope(r.payload);
241
+ if (env instanceof type.errors) {
242
+ // A non-timer event blob (StepStarted, RunStarted, ...) at the
243
+ // same path prefix is expected -- the scheduler skips it
244
+ // silently. A timer-shaped blob whose narrow fails is a
245
+ // substrate-level invariant violation; the kind handler's
246
+ // validatePush is the layer that should catch it. Here we skip
247
+ // to avoid crashing the scheduler over a single bad blob; a
248
+ // separate audit pass surfaces the integrity problem.
249
+ continue;
250
+ }
251
+ entries.push({ runId: r.runId, envelope: env });
252
+ }
253
+ return entries;
254
+ }
255
+ /**
256
+ * Walk the workflow-run repo's `runs/<runId>/events/<seq>.json`
257
+ * subtree at the current ref tip and return every event blob's
258
+ * payload, attributed to its run. A terminated run whose events have
259
+ * been compacted into a combined `events.jsonl` (no `events/` subtree)
260
+ * is skipped: this walk recovers pending timers, and a terminated run
261
+ * has none.
262
+ */
263
+ async function enumerateEventBlobs(opts, repoId) {
264
+ const dir = opts.repoStore.getRepoDir(repoId);
265
+ const fs = await import("node:fs/promises");
266
+ const path = await import("node:path");
267
+ const runsDir = path.join(dir, "runs");
268
+ const out = [];
269
+ let runEntries;
270
+ try {
271
+ runEntries = await fs.readdir(runsDir);
272
+ }
273
+ catch (cause) {
274
+ if (isErrnoNotFound(cause))
275
+ return out;
276
+ throw cause;
277
+ }
278
+ for (const runId of runEntries) {
279
+ const eventsDir = path.join(runsDir, runId, "events");
280
+ let blobs;
281
+ try {
282
+ blobs = await fs.readdir(eventsDir);
283
+ }
284
+ catch (cause) {
285
+ if (isErrnoNotFound(cause))
286
+ continue;
287
+ throw cause;
288
+ }
289
+ for (const blob of blobs) {
290
+ if (!/^(0|[1-9][0-9]*)\.json$/.test(blob))
291
+ continue;
292
+ const raw = await fs.readFile(path.join(eventsDir, blob), "utf8");
293
+ let parsed;
294
+ try {
295
+ parsed = JSON.parse(raw);
296
+ }
297
+ catch (cause) {
298
+ throw new Error(`scheduler recovery: cannot parse ${String(repoId.id)}/${runId}/events/${blob}: ${String(cause)}`);
299
+ }
300
+ out.push({ runId, payload: parsed });
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+ function isErrnoNotFound(cause) {
306
+ if (cause === null || typeof cause !== "object")
307
+ return false;
308
+ const code = cause.code;
309
+ return code === "ENOENT";
310
+ }
311
+ /**
312
+ * Commit a `TimerFired` event blob to the workflow-run repo. The
313
+ * commit goes through `writeTreePreservingPrefix` so concurrent
314
+ * commits on the same runId's events subtree serialize at the
315
+ * substrate's per-repo lock. Idempotent: if a TimerFired for the
316
+ * same `(runId, timerId)` already exists at the prefix, the merge
317
+ * returns the existing tree unchanged.
318
+ */
319
+ async function commitTimerFired(opts, runId, timerId) {
320
+ const owningRepoId = await findOwningDeployment(opts, runId);
321
+ if (owningRepoId === undefined) {
322
+ throw new Error(`scheduler commit: cannot find deployment owning run ${runId}`);
323
+ }
324
+ const prefix = `runs/${runId}/events/`;
325
+ await opts.repoStore.writeTreePreservingPrefix(opts.principal, owningRepoId, opts.ref, {
326
+ preservePrefix: prefix,
327
+ merge: async (existing) => {
328
+ let maxSeq = -1;
329
+ let alreadyFired = false;
330
+ for (const [filepath, contents] of existing) {
331
+ const name = filepath.slice(prefix.length);
332
+ const match = /^(0|[1-9][0-9]*)\.json$/.exec(name);
333
+ if (match === null)
334
+ continue;
335
+ const seqStr = match[1];
336
+ if (seqStr === undefined)
337
+ continue;
338
+ const seq = Number.parseInt(seqStr, 10);
339
+ if (seq > maxSeq)
340
+ maxSeq = seq;
341
+ try {
342
+ const parsed = JSON.parse(new TextDecoder().decode(contents));
343
+ if (isMatchingTimerFired(parsed, timerId)) {
344
+ alreadyFired = true;
345
+ }
346
+ }
347
+ catch {
348
+ // Skip on parse failure -- a corrupt blob would have
349
+ // been rejected by validatePush at write time; treat as
350
+ // a non-matching entry.
351
+ }
352
+ }
353
+ const out = {};
354
+ for (const [filepath, contents] of existing) {
355
+ out[filepath] = new TextDecoder().decode(contents);
356
+ }
357
+ if (alreadyFired)
358
+ return out;
359
+ const nextSeq = maxSeq + 1;
360
+ out[`${prefix}${String(nextSeq)}.json`] = JSON.stringify({
361
+ seq: nextSeq,
362
+ type: "TimerFired",
363
+ data: { timerId },
364
+ });
365
+ return out;
366
+ },
367
+ message: `TimerFired ${timerId} for run ${runId}`,
368
+ });
369
+ }
370
+ function isMatchingTimerFired(parsed, timerId) {
371
+ if (typeof parsed !== "object" || parsed === null)
372
+ return false;
373
+ const obj = parsed;
374
+ if (obj.type !== "TimerFired")
375
+ return false;
376
+ if (obj.data === undefined)
377
+ return false;
378
+ return obj.data.timerId === timerId;
379
+ }
380
+ async function findOwningDeployment(opts, runId) {
381
+ const repoIds = await opts.listActiveDeployments();
382
+ const fs = await import("node:fs/promises");
383
+ const path = await import("node:path");
384
+ for (const repoId of repoIds) {
385
+ const dir = opts.repoStore.getRepoDir(repoId);
386
+ try {
387
+ await fs.access(path.join(dir, "runs", runId, "events"));
388
+ return repoId;
389
+ }
390
+ catch (cause) {
391
+ if (isErrnoNotFound(cause))
392
+ continue;
393
+ throw cause;
394
+ }
395
+ }
396
+ return undefined;
397
+ }
@@ -0,0 +1,74 @@
1
+ import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { RunState, SignalChannel } from "@intx/workflow";
3
+ /**
4
+ * Substrate-shape envelope for the `SignalReceived` event blob
5
+ * committed to `runs/<runId>/events/<seq>.json`. The validator covers
6
+ * the single event type the signal channel both reads (live tail) and
7
+ * writes (deliver). Fields ride at the top level so the shape is
8
+ * symmetric with the runtime body's append shape -- a downstream
9
+ * reader that hydrates the envelope as a state-machine `WorkflowEvent`
10
+ * sees `signalName`/`signalId`/`payload` regardless of whether the
11
+ * commit came from the signal channel's `deliver` or the runtime
12
+ * body's `commit` of a SignalReceived after `awaitNext`. Non-signal
13
+ * blobs at the same path prefix do not match the kinds filter inside
14
+ * `subscribeKind`.
15
+ */
16
+ export declare const SignalReceivedEnvelope: import("arktype/internal/variants/object.ts").ObjectType<{
17
+ type: "SignalReceived";
18
+ signalName: string;
19
+ signalId: string;
20
+ payload: unknown;
21
+ }, {}>;
22
+ export type SignalReceivedEnvelope = typeof SignalReceivedEnvelope.infer;
23
+ export type SignalChannelOpts = {
24
+ /**
25
+ * Substrate handle the channel reads from and writes to. The caller
26
+ * wires this against the workflow-run kind handler's registered
27
+ * substrate -- the channel's writes land under
28
+ * `runs/<runId>/events/<seq>.json` and the handler's `validatePush`
29
+ * must accept that path layout.
30
+ */
31
+ repoStore: RepoStore;
32
+ /**
33
+ * Principal the channel presents to the substrate. The substrate
34
+ * gates every operation behind `authorize`; the principal must be
35
+ * granted `writeTree` (for `deliver`) and `subscribe` (for
36
+ * `awaitNext`'s log tail) against the workflow-run repo.
37
+ */
38
+ principal: Principal;
39
+ /** Workflow-run repo this channel operates against. */
40
+ repoId: RepoId;
41
+ /**
42
+ * Events ref the channel tails and writes to. The workflow-run
43
+ * repo layout pins all `runs/<runId>/events/` blobs under a single
44
+ * moving ref. Callers typically supply `"refs/heads/main"`.
45
+ */
46
+ ref: string;
47
+ /**
48
+ * The run this channel belongs to. The channel filters
49
+ * `subscribeKind` entries on this runId so a host-wide events ref
50
+ * carrying multiple runs does not cross-resolve awaiters.
51
+ */
52
+ runId: string;
53
+ /**
54
+ * Reader for the in-memory `RunState`. The runtime body owns the
55
+ * state; the channel reads `unconsumedSignals` (pre-await
56
+ * delivery / resume rehydration) and `observedSignalIds` (dedup)
57
+ * on every `awaitNext`. A reader rather than a snapshot keeps the
58
+ * channel coherent with the runtime body's latest reduction.
59
+ */
60
+ readState: () => RunState;
61
+ /** Generator for synthesized `signalId`s when `deliver`'s caller omits one. */
62
+ newId: () => string;
63
+ /** Clock used to stamp the committed `SignalReceived` blob's `at`. */
64
+ clock: () => Date;
65
+ };
66
+ export type SignalChannelHandle = SignalChannel & {
67
+ /**
68
+ * Tear down every per-name subscription and reject every pending
69
+ * awaiter. Idempotent. After `stop()` the channel holds no
70
+ * substrate watcher handles.
71
+ */
72
+ stop(): Promise<void>;
73
+ };
74
+ export declare function createWorkflowHostSignalChannel(opts: SignalChannelOpts): SignalChannelHandle;