@intx/workflow-host 0.2.2 → 0.4.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 (101) hide show
  1. package/README.md +77 -14
  2. package/dist/adapters/mail-part-store.d.ts +46 -0
  3. package/dist/adapters/mail-part-store.js +251 -0
  4. package/dist/adapters/repo-store.d.ts +22 -1
  5. package/dist/adapters/repo-store.js +56 -65
  6. package/dist/adapters/spawn-child.d.ts +109 -44
  7. package/dist/adapters/spawn-child.js +77 -81
  8. package/dist/adapters/step-invoker.d.ts +52 -2
  9. package/dist/adapters/step-invoker.js +284 -37
  10. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  11. package/dist/adapters/substrate-mailbox-store.js +404 -0
  12. package/dist/child/child-mailbox-reader.d.ts +10 -0
  13. package/dist/child/child-mailbox-reader.js +23 -0
  14. package/dist/child/credential-cell.d.ts +8 -0
  15. package/dist/child/credential-cell.js +66 -0
  16. package/dist/child/env-bootstrap.d.ts +20 -6
  17. package/dist/child/env-bootstrap.js +9 -1
  18. package/dist/child/from-process-env.d.ts +12 -0
  19. package/dist/child/from-process-env.js +6 -0
  20. package/dist/child/index.d.ts +6 -2
  21. package/dist/child/index.js +4 -1
  22. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  23. package/dist/child/mailbox-mutation-bridge.js +101 -0
  24. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  25. package/dist/child/mailbox-watch-registry.js +61 -0
  26. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  27. package/dist/child/outbound-mail-bridge.js +20 -32
  28. package/dist/child/parked-correlations.d.ts +42 -0
  29. package/dist/child/parked-correlations.js +80 -0
  30. package/dist/child/pending-request.d.ts +89 -0
  31. package/dist/child/pending-request.js +80 -0
  32. package/dist/child/proxy-repo-store.d.ts +3 -2
  33. package/dist/child/proxy-repo-store.js +2 -0
  34. package/dist/child/run-child.d.ts +170 -14
  35. package/dist/child/run-child.js +569 -155
  36. package/dist/child/self-discovery.d.ts +10 -0
  37. package/dist/child/self-discovery.js +25 -1
  38. package/dist/child/substrate-write-bridge.d.ts +3 -2
  39. package/dist/child/substrate-write-bridge.js +21 -38
  40. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  41. package/dist/child/supervisor-backed-transport.js +205 -62
  42. package/dist/child/verified-definition-loader.d.ts +33 -0
  43. package/dist/child/verified-definition-loader.js +43 -0
  44. package/dist/child/warm-agent-cache.d.ts +44 -4
  45. package/dist/child/warm-agent-cache.js +41 -10
  46. package/dist/index.d.ts +6 -4
  47. package/dist/index.js +6 -4
  48. package/dist/ipc/control-channel.d.ts +151 -2
  49. package/dist/ipc/control-channel.js +222 -29
  50. package/dist/ipc/event-channel.d.ts +32 -1
  51. package/dist/ipc/index.d.ts +1 -1
  52. package/dist/ipc/index.js +1 -1
  53. package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
  54. package/dist/mail-bus/hub-transport-adapter.js +9 -5
  55. package/dist/run-body-then-cleanup.d.ts +17 -0
  56. package/dist/run-body-then-cleanup.js +38 -0
  57. package/dist/seams/scheduler.d.ts +16 -6
  58. package/dist/seams/scheduler.js +87 -97
  59. package/dist/supervisor/cancel-signing.d.ts +2 -2
  60. package/dist/supervisor/cancel-signing.js +4 -8
  61. package/dist/supervisor/credentials.d.ts +28 -15
  62. package/dist/supervisor/credentials.js +7 -7
  63. package/dist/supervisor/dispatch-attribution.js +1 -1
  64. package/dist/supervisor/drain-timeout.d.ts +2 -2
  65. package/dist/supervisor/drain-timeout.js +1 -1
  66. package/dist/supervisor/index.d.ts +3 -3
  67. package/dist/supervisor/index.js +2 -2
  68. package/dist/supervisor/recycle.d.ts +10 -3
  69. package/dist/supervisor/recycle.js +18 -7
  70. package/dist/supervisor/run-event-compaction.d.ts +5 -5
  71. package/dist/supervisor/run-event-compaction.js +14 -19
  72. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  73. package/dist/supervisor/run-event-recovery.js +45 -0
  74. package/dist/supervisor/spawn-env.d.ts +2 -2
  75. package/dist/supervisor/spawn-env.js +1 -1
  76. package/dist/supervisor/supervisor.d.ts +106 -26
  77. package/dist/supervisor/supervisor.js +1903 -414
  78. package/dist/supervisor/terminal-commit.d.ts +36 -0
  79. package/dist/supervisor/terminal-commit.js +130 -0
  80. package/dist/supervisor/types.d.ts +180 -23
  81. package/dist/testing/change-notifier.d.ts +12 -0
  82. package/dist/testing/change-notifier.js +63 -0
  83. package/dist/testing/index.d.ts +8 -0
  84. package/dist/testing/index.js +16 -0
  85. package/dist/testing/log-capture.d.ts +52 -0
  86. package/dist/testing/log-capture.js +124 -0
  87. package/dist/testing/mail-bus.d.ts +22 -0
  88. package/dist/testing/mail-bus.js +78 -0
  89. package/dist/testing/memory-streams.d.ts +43 -0
  90. package/dist/testing/memory-streams.js +211 -0
  91. package/dist/testing/spawn-observer.d.ts +12 -0
  92. package/dist/testing/spawn-observer.js +36 -0
  93. package/dist/testing/stub-repo-store.d.ts +10 -0
  94. package/dist/testing/stub-repo-store.js +39 -0
  95. package/dist/testing/supervisor-reaper.d.ts +24 -0
  96. package/dist/testing/supervisor-reaper.js +49 -0
  97. package/dist/testing/upstream-frames.d.ts +47 -0
  98. package/dist/testing/upstream-frames.js +94 -0
  99. package/dist/workflow-definition-loader.d.ts +187 -0
  100. package/dist/workflow-definition-loader.js +422 -0
  101. package/package.json +18 -11
@@ -0,0 +1,36 @@
1
+ import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ export type CommitRunFailedOpts = {
3
+ /** Substrate handle the supervisor writes through. */
4
+ substrate: SubstrateRepoStore;
5
+ /** Workflow-run repo for this deployment. */
6
+ repoId: RepoId;
7
+ /** Events ref the workflow-run repo writes to. */
8
+ ref: string;
9
+ /** Anchor run id used to construct the supervisor principal. */
10
+ anchorRunId: string;
11
+ /** Run id whose event log receives the RunFailed entry. */
12
+ runId: string;
13
+ /** ISO-8601 commit timestamp the event carries. */
14
+ at: string;
15
+ /** Operator-facing failure reason on `RunFailed.error.message`. */
16
+ message: string;
17
+ };
18
+ export type CommitRunFailedResult = {
19
+ /** Substrate-assigned commit SHA the write produced. */
20
+ commitSha: string;
21
+ /**
22
+ * True when a `RunFailed` was appended; false when the run was already
23
+ * terminal and the write was a no-op (terminal-lock respected).
24
+ */
25
+ appended: boolean;
26
+ };
27
+ /**
28
+ * Append a supervisor-authored `RunFailed` to a run's event log, unless the
29
+ * run is already terminal. The next seq is computed inside the substrate
30
+ * merge (atomic against any concurrent writer under the per-repo lock): the
31
+ * first event on an empty tree lands at seq 1 (the runtime's convention),
32
+ * otherwise at `maxSeq + 1` so the log stays seq-contiguous. If the run's
33
+ * highest-seq event is already terminal, the write is a no-op so push
34
+ * validation's terminal-lock is never tripped.
35
+ */
36
+ export declare function commitRunFailed(opts: CommitRunFailedOpts): Promise<CommitRunFailedResult>;
@@ -0,0 +1,130 @@
1
+ // Supervisor-authored terminal-event commit for the crash-loop guard.
2
+ //
3
+ // When the crash-loop guard latches, the workflow-process child is dead
4
+ // and cannot commit its own terminal event, yet the deployment's run must
5
+ // reach a terminal state so its external `workflow_run.status` flips to
6
+ // `failed` -- the sole durable, queryable signal of a crash-loop. The
7
+ // supervisor's `crash-looping` phase is in-memory and per-process; no
8
+ // external reader observes it. The supervisor, as the sole writer of the
9
+ // workflow-run repo, authors a `RunFailed` for the deployment's stable
10
+ // run so the crash-loop leaves a durable tombstone.
11
+ //
12
+ // Unlike `commitCancelRequested`, a terminal workflow event carries no
13
+ // signature: only `CancelRequested` is signed (for its origin<->principal
14
+ // cross-check at push validation), so this writer is unsigned. It writes
15
+ // under the `supervisor` principal, which the workflow-run kind handler
16
+ // authorizes for the deployment's own event log (`repoId.id ===
17
+ // anchorRunId`); terminal events have no per-type authorship check.
18
+ import { type } from "arktype";
19
+ import { getLogger } from "@intx/log";
20
+ import { parseEventSeq } from "@intx/hub-sessions/substrate";
21
+ import { workflowEventToOnDisk } from "../adapters/repo-store.js";
22
+ const logger = getLogger(["workflow-host", "supervisor", "terminal-commit"]);
23
+ /** Path layout inside the workflow-run repo: `runs/<runId>/events/<seq>.json`. */
24
+ const RUNS_PREFIX = "runs";
25
+ const EVENTS_DIR = "events";
26
+ /**
27
+ * Terminal run-event kinds, mirroring the runtime's terminal vocabulary
28
+ * (`RunCompleted`/`RunFailed`/`RunCancelled`). Inlined because the workflow
29
+ * package exports only the phase-level `isTerminalRunPhase`, not an
30
+ * event-kind set; the child runtime (`run-child`) and the substrate adapter
31
+ * (`repo-store`) inline the same three kinds. Reducing the log to a phase to
32
+ * reuse `isTerminalRunPhase` would be strictly heavier for a last-event
33
+ * type check.
34
+ */
35
+ const TERMINAL_EVENT_KINDS = new Set([
36
+ "RunCompleted",
37
+ "RunFailed",
38
+ "RunCancelled",
39
+ ]);
40
+ const SUPERVISOR_PRINCIPAL_KIND = "supervisor";
41
+ /**
42
+ * On-disk event envelope, validated at the substrate read boundary. Only
43
+ * `seq` and `type` are load-bearing here (max-seq computation and the
44
+ * terminal-lock check); the rest of the event body is ignored.
45
+ */
46
+ const OnDiskEventEnvelope = type({
47
+ seq: "number >= 0",
48
+ type: "string",
49
+ "+": "ignore",
50
+ });
51
+ /**
52
+ * Append a supervisor-authored `RunFailed` to a run's event log, unless the
53
+ * run is already terminal. The next seq is computed inside the substrate
54
+ * merge (atomic against any concurrent writer under the per-repo lock): the
55
+ * first event on an empty tree lands at seq 1 (the runtime's convention),
56
+ * otherwise at `maxSeq + 1` so the log stays seq-contiguous. If the run's
57
+ * highest-seq event is already terminal, the write is a no-op so push
58
+ * validation's terminal-lock is never tripped.
59
+ */
60
+ export async function commitRunFailed(opts) {
61
+ const prefix = `${RUNS_PREFIX}/${opts.runId}/${EVENTS_DIR}/`;
62
+ const principal = {
63
+ kind: SUPERVISOR_PRINCIPAL_KIND,
64
+ anchorRunId: opts.anchorRunId,
65
+ };
66
+ const decoder = new TextDecoder();
67
+ let appended = false;
68
+ const { commitSha } = await opts.substrate.writeTreePreservingPrefix(principal, opts.repoId, opts.ref, {
69
+ preservePrefix: prefix,
70
+ merge: async (existing) => {
71
+ let maxSeq = -1;
72
+ let maxPath = null;
73
+ for (const filepath of existing.keys()) {
74
+ const name = filepath.slice(prefix.length);
75
+ const seq = parseEventSeq(name);
76
+ if (seq === null)
77
+ continue;
78
+ if (seq > maxSeq) {
79
+ maxSeq = seq;
80
+ maxPath = filepath;
81
+ }
82
+ }
83
+ const carried = {};
84
+ for (const [k, v] of existing)
85
+ carried[k] = v;
86
+ // Terminal-lock: appending a terminal event after an existing one
87
+ // is rejected at push validation. If the run already ended, its
88
+ // `workflow_run.status` is already terminal, so the crash-loop
89
+ // tombstone is redundant -- no-op rather than push a rejected write.
90
+ //
91
+ // This detects the per-event (`events/<seq>.json`) form only, not
92
+ // the sealed combined-log (`events.jsonl`) form, which lives outside
93
+ // `preservePrefix`. That is sufficient here because the anchor run
94
+ // this commit targets is sealed only when the DEPLOYMENT itself is
95
+ // terminal, and the crash-loop latch fires only while the deployment
96
+ // is running -- so the run is never in the sealed form at this call.
97
+ // If a sealed run ever reached here, the append would produce a tree
98
+ // carrying both forms, which push validation rejects, and the caller
99
+ // logs the failure (best-effort tombstone) rather than corrupting.
100
+ if (maxPath !== null) {
101
+ const raw = existing.get(maxPath);
102
+ if (raw !== undefined) {
103
+ const parsed = OnDiskEventEnvelope(JSON.parse(decoder.decode(raw)));
104
+ if (parsed instanceof type.errors) {
105
+ throw new Error(`commitRunFailed: event blob ${maxPath} failed validation: ${parsed.summary}`);
106
+ }
107
+ if (TERMINAL_EVENT_KINDS.has(parsed.type)) {
108
+ return carried;
109
+ }
110
+ }
111
+ }
112
+ const nextSeq = maxSeq < 0 ? 1 : maxSeq + 1;
113
+ const event = {
114
+ kind: "RunFailed",
115
+ seq: nextSeq,
116
+ at: opts.at,
117
+ error: { message: opts.message },
118
+ };
119
+ const onDisk = workflowEventToOnDisk(event, nextSeq);
120
+ carried[`${prefix}${String(nextSeq)}.json`] = JSON.stringify(onDisk);
121
+ appended = true;
122
+ return carried;
123
+ },
124
+ message: `append RunFailed (crash-loop) for run ${opts.runId}`,
125
+ });
126
+ if (!appended) {
127
+ logger.info `commitRunFailed: run ${opts.runId} already terminal; no RunFailed appended`;
128
+ }
129
+ return { commitSha, appended };
130
+ }
@@ -1,5 +1,6 @@
1
- import type { DequeueToProcessingResult, EnqueueInboxArgs, EnqueueInboxResult, MarkConsumedArgs, MarkConsumedResult, Principal, RepoId, RepoStore as SubstrateRepoStore, ReplayProcessingToInboxOpts, ReplayProcessingToInboxResult } from "@intx/hub-sessions/substrate";
2
- import type { OutboundMessage, SendReceipt } from "@intx/types/runtime";
1
+ import type { DequeueToProcessingResult, EnqueueInboxArgs, EnqueueInboxOutcome, MarkConsumedArgs, MarkConsumedResult, Principal, RepoId, RepoStore as SubstrateRepoStore, ReplayProcessingToInboxOpts, ReplayProcessingToInboxResult } from "@intx/hub-sessions/substrate";
2
+ import type { SignalKind } from "@intx/types";
3
+ import type { ApprovalSnapshot, OutboundMessage, SendReceipt } from "@intx/types/runtime";
3
4
  import type { RunCancelled, RunCompleted, RunFailed } from "@intx/workflow";
4
5
  import type { FrameReader, NdjsonReader, NdjsonWriter } from "../ipc/index.js";
5
6
  /**
@@ -69,7 +70,13 @@ export type PrincipalSigner = (kind: WorkflowSupervisorPrincipalKind, payload: U
69
70
  *
70
71
  * `subscribeMailForAddress` returns a disposer the supervisor calls
71
72
  * during teardown. The supplied handler is invoked with the raw RFC
72
- * 2822 message bytes of each inbound message at the address.
73
+ * 2822 message bytes of each inbound message at the address, and returns
74
+ * a promise that resolves once the message is durably accepted (its inbox
75
+ * write landed, or the message was already durably present) and rejects
76
+ * when it was not (a transient failure, a stale refusal, or a phase where
77
+ * the deployment is tearing down). The host propagates that settlement to
78
+ * the wire so a durable-receipt ack is sent only on resolution -- resolve
79
+ * is the ack signal, reject is the withhold signal.
73
80
  *
74
81
  * `sendOutbound` is the OUTBOUND half of mailbox ownership (§3a). The
75
82
  * supervisor is the sole mail owner: the workflow-process child never
@@ -92,7 +99,7 @@ export type PrincipalSigner = (kind: WorkflowSupervisorPrincipalKind, payload: U
92
99
  export interface MailBusBindings {
93
100
  registerAddress(address: string): void;
94
101
  unregisterAddress(address: string): void;
95
- subscribeMailForAddress(address: string, handler: (rawMessage: Uint8Array) => void): () => void;
102
+ subscribeMailForAddress(address: string, handler: (rawMessage: Uint8Array) => Promise<void>): () => void;
96
103
  sendOutbound(senderAddress: string, message: OutboundMessage): Promise<SendReceipt>;
97
104
  }
98
105
  /**
@@ -181,11 +188,33 @@ export type DeriveMailAuditRef = (messageId: string, rawMessage: Uint8Array) =>
181
188
  * surprise.
182
189
  */
183
190
  export interface InboxPrimitives {
184
- enqueueInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: EnqueueInboxArgs): Promise<EnqueueInboxResult>;
191
+ enqueueInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: EnqueueInboxArgs): Promise<EnqueueInboxOutcome>;
185
192
  dequeueToProcessing(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, address: string): Promise<DequeueToProcessingResult>;
186
193
  markConsumed(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: MarkConsumedArgs): Promise<MarkConsumedResult>;
187
194
  replayProcessingToInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, address: string, opts?: ReplayProcessingToInboxOpts): Promise<ReplayProcessingToInboxResult>;
188
195
  }
196
+ /**
197
+ * A control-plane suspension the supervisor forwards to the host after a
198
+ * workflow-process child reports a park. The child supplies `runId`,
199
+ * `correlationId`, and `kind`; the supervisor stamps `anchorRunId` and
200
+ * `agentAddress` from its own bindings before invoking the host's
201
+ * `onSuspensionRegister`. The host (production: the sidecar) turns this into
202
+ * a `signal.correlation.register` frame the hub co-writes the run's routing +
203
+ * approval rows from.
204
+ */
205
+ export interface SuspensionRegistration {
206
+ runId: string;
207
+ correlationId: string;
208
+ kind: SignalKind;
209
+ anchorRunId: string;
210
+ agentAddress: string;
211
+ /**
212
+ * Approver-facing snapshot of the parked tool call, forwarded from the
213
+ * child's `park.notify`. Present only for an ask-rail suspension; the host
214
+ * turns it into the register frame's snapshot.
215
+ */
216
+ approvalSnapshot?: ApprovalSnapshot;
217
+ }
189
218
  /**
190
219
  * Constructor arguments for `createWorkflowSupervisor`. One
191
220
  * `RepoStore` handle plus a `signAsPrincipal` callback that mints
@@ -201,6 +230,79 @@ export interface WorkflowSupervisorBindings {
201
230
  signAsPrincipal: PrincipalSigner;
202
231
  /** Mail-bus surface for address registration and inbound subscription. */
203
232
  mailBus: MailBusBindings;
233
+ /**
234
+ * Optional control-plane suspension sink. Two callers invoke it, both
235
+ * stamping `anchorRunId` and `deploymentMailAddress` (as `agentAddress`)
236
+ * onto the child-supplied `runId`/`correlationId`/`kind`: the upstream-control
237
+ * pump's `park.notify` arm (the happy-path emit at suspend), and
238
+ * `reEmitParkedCorrelations` (the re-establishment re-emit that recovers a
239
+ * register the hub missed while it was down). Production wires this to the
240
+ * sidecar's hub link so a `signal.correlation.register` frame reaches the hub;
241
+ * a host that does not wire it does not register suspensions (today, the
242
+ * tests). Best-effort: a throwing sink is logged and
243
+ * both callers keep going, so one bad register cannot wedge the control pump
244
+ * or abort a re-emit partway through the parked set.
245
+ */
246
+ onSuspensionRegister?: (registration: SuspensionRegistration) => void;
247
+ /**
248
+ * Self-termination sink. The supervisor invokes it when it reaches a terminal
249
+ * phase on its own -- the crash-loop latch (`crash-looping`), a channel crash
250
+ * while recycling, or a recycle failure (both `stopped`) -- but NOT when the
251
+ * host drives it down through the public `shutdown()`, and NOT for a failure
252
+ * of the initial spawn handshake (that is the deploy's to unwind). Production
253
+ * wires this to the sidecar so it reclaims the deployment address (drops the
254
+ * supervisor from its active map and releases the address's routing state)
255
+ * and the address becomes redeployable without a manual undeploy.
256
+ *
257
+ * The handler MUST be idempotent. Firing is not exactly-once: two
258
+ * self-terminating callers interleaving through teardown (e.g. a channel
259
+ * crash while recycling plus the recycle-failure catch) can each fire. The
260
+ * sidecar's reclaim absorbs a repeat because its `activeSupervisors.has`
261
+ * guard makes the second run a no-op.
262
+ *
263
+ * Unlike `onSuspensionRegister`, this sink does NOT share the same
264
+ * log-and-continue contract on the host side. A missed suspension has an
265
+ * independent recovery path (`reEmitParkedCorrelations`); a missed reclaim
266
+ * does not -- the address stays stranded until an operator undeploys. So
267
+ * the host's handler is engineered to be total, and a failure there is
268
+ * logged loudly rather than swallowed. The supervisor still invokes this
269
+ * best-effort (a throwing sink cannot break the terminal transition), but a
270
+ * host that copies `onSuspensionRegister`'s quiet-swallow semantics onto its
271
+ * reclaim handler reintroduces the stranding bug.
272
+ */
273
+ onSelfTerminate?: (info: {
274
+ phase: "stopped" | "crash-looping";
275
+ reason: string;
276
+ }) => void;
277
+ /**
278
+ * Per-run grants source the dispatch loop consults before it forwards a
279
+ * `trigger.fire`. Unlike `onSuspensionRegister` (best-effort, fire-and-
280
+ * forget), this is a request/response contract: the supervisor awaits the
281
+ * returned `CredentialsSnapshot` and pushes it to the child over the
282
+ * control channel BEFORE firing the run's trigger, so the child's
283
+ * authorize closure binds to the run's grants rather than a stale
284
+ * spawn-time snapshot. A throwing sink is NOT swallowed -- the dispatch
285
+ * loop fails that run (a synthesized `RunFailed`) rather than firing the
286
+ * trigger against absent grants.
287
+ *
288
+ * When this binding is wired, it is the SOLE grants push: `spawn` does not
289
+ * push a spawn-time snapshot, so the per-run push is the only thing that
290
+ * satisfies the child's throw-on-null authorize guard. A caller that
291
+ * injects no per-run sink -- today, the supervisor's own tests -- keeps the
292
+ * spawn-time push instead. Production (the sidecar) wires it to the
293
+ * walk-derived per-step credentials assembly.
294
+ */
295
+ onRunStart?: (args: {
296
+ runId: string;
297
+ anchorRunId: string;
298
+ }) => Promise<import("./credentials.js").CredentialsSnapshot>;
299
+ /**
300
+ * Decrypted credential material for the deployment's tools, delivered to the
301
+ * child on the pre-trigger barrier alongside the grants. Absent when the
302
+ * deployment binds no credentials. A rotation flows through
303
+ * `deliverCredentials`, not this static binding.
304
+ */
305
+ credentialDelivery?: import("@intx/types/sidecar").CredentialDelivery;
204
306
  /** Subprocess spawner the supervisor invokes per spawn. */
205
307
  subprocessSpawner: SubprocessSpawner;
206
308
  /**
@@ -235,8 +337,8 @@ export interface WorkflowSupervisorBindings {
235
337
  workflowRunRepoId: import("@intx/hub-sessions").RepoId;
236
338
  /** Workflow-run repo ref the supervisor commits events to. */
237
339
  workflowRunRef: string;
238
- /** Deployment id baked into the supervisor's principal claims. */
239
- deploymentId: string;
340
+ /** Anchor run id baked into the supervisor's principal claims. */
341
+ anchorRunId: string;
240
342
  /**
241
343
  * Number of steps in the deployed `WorkflowDefinition`
242
344
  * (`stepOrder.length`). The supervisor threads this into the child's
@@ -268,7 +370,7 @@ export interface WorkflowSupervisorBindings {
268
370
  deriveStepAddress: import("./credentials.js").DeriveStepAddress;
269
371
  /**
270
372
  * Optional override for the step's agent-state repo identity. The
271
- * default convention is `<deploymentId>-<stepId>`.
373
+ * default convention is `<anchorRunId>-<stepId>`.
272
374
  */
273
375
  deriveStepRepoId?: import("./credentials.js").DeriveStepRepoId;
274
376
  /**
@@ -377,11 +479,11 @@ export interface WorkflowSupervisorBindings {
377
479
  /**
378
480
  * Workflow-run substrate principal the supervisor uses to author
379
481
  * inbox/processing/consumed writes. The substrate's workflow-run
380
- * kind handler accepts a `{ kind: "supervisor", deploymentId }`
482
+ * kind handler accepts a `{ kind: "supervisor", anchorRunId }`
381
483
  * principal for claim-check writes; the supervisor constructs this
382
484
  * value once at bindings construction and reuses it for every
383
485
  * claim-check operation. Defaults to `{ kind: "supervisor",
384
- * deploymentId }` derived from `bindings.deploymentId`; tests
486
+ * anchorRunId }` derived from `bindings.anchorRunId`; tests
385
487
  * override it when they need to assert on a structurally distinct
386
488
  * principal shape.
387
489
  */
@@ -413,14 +515,65 @@ export interface WorkflowSupervisorBindings {
413
515
  */
414
516
  readyTimeoutMs?: number;
415
517
  /**
416
- * Watchdog timeout (ms) for the supervisor's substrate-write
417
- * handler's wait on the dispatch loop's `markConsumed` when a
418
- * terminal-event blob lands in a proxied write. Defaults to
419
- * `DEFAULT_TERMINAL_WRITE_WATCHDOG_MS`. Tests inject a small value
420
- * so the watchdog path is observable without holding a test loop
421
- * for the production duration.
518
+ * Crash-loop guard: the maximum number of UNEXPECTED workflow-process
519
+ * child exits (crash, OOM, panic, signal) the supervisor tolerates
520
+ * within `crashLoopWindowMs` before it stops respawning and latches the
521
+ * deployment to a terminal state. The boot edge resolves the operator's
522
+ * config and supplies it; absent, `DEFAULT_CRASH_LOOP_MAX_COUNT` (3)
523
+ * applies. The operator owns this bound.
524
+ */
525
+ crashLoopMaxCount?: number;
526
+ /**
527
+ * Sliding window (ms) over which `crashLoopMaxCount` unexpected exits
528
+ * latch the deployment. Absent, `DEFAULT_CRASH_LOOP_WINDOW_MS` (60s)
529
+ * applies.
530
+ */
531
+ crashLoopWindowMs?: number;
532
+ /**
533
+ * Stable-run duration (ms) after a respawn: once a respawned child has
534
+ * stayed up this long, the crash counter resets, so a burst of crashes
535
+ * followed by stability does not permanently latch. Absent,
536
+ * `DEFAULT_CRASH_LOOP_STABLE_RESET_MS` (60s) applies. Driven by the
537
+ * injectable `setTimer`/`clearTimer` pair so tests are deterministic.
538
+ */
539
+ crashLoopStableResetMs?: number;
540
+ /**
541
+ * Initial respawn backoff (ms): the wait before the FIRST respawn after
542
+ * an unexpected exit. Each subsequent respawn doubles the wait, capped at
543
+ * `respawnBackoffMaxMs`; a stable run resets it to this value. Absent,
544
+ * `DEFAULT_RESPAWN_BACKOFF_INITIAL_MS` (1s) applies.
422
545
  */
423
- terminalWriteWatchdogMs?: number;
546
+ respawnBackoffInitialMs?: number;
547
+ /**
548
+ * Maximum respawn backoff (ms) the exponential doubling is capped at, so
549
+ * a rapidly-flapping child does not saturate the host. Absent,
550
+ * `DEFAULT_RESPAWN_BACKOFF_MAX_MS` (30s) applies.
551
+ *
552
+ * Config invariant: keep this comfortably below `crashLoopWindowMs`.
553
+ * Crashes must fall within the window to accumulate toward the latch, and
554
+ * the backoff spaces them apart; a cap at or above the window guarantees a
555
+ * slow flapper's timestamps age out before the count reaches
556
+ * `crashLoopMaxCount`, so the guard never latches. This is necessary but
557
+ * not sufficient: the child's own lifetime also spaces crashes, so a child
558
+ * whose healthy lifetime approaches `crashLoopStableResetMs` can still
559
+ * out-space the window (and never earn a counter reset either), respawning
560
+ * indefinitely. Windowed crash detection cannot catch an arbitrarily slow
561
+ * flapper; the window/backoff/stable-reset trio bounds the FAST flap this
562
+ * guard targets.
563
+ */
564
+ respawnBackoffMaxMs?: number;
565
+ /**
566
+ * Watchdog timeout (ms) for `reEmitParkedCorrelations`' wait on the
567
+ * child's `parked-correlations.response`. Caps the wait so a
568
+ * wedged-but-alive child (whose cohort never tears down, so the
569
+ * cohort-abort rejection never fires) cannot hang the re-registration
570
+ * driver -- and therefore the reconnect/re-establishment caller that
571
+ * awaits it. On expiry the pending query is dropped and the driver
572
+ * returns; the next re-establishment re-drives it. Defaults to
573
+ * `DEFAULT_PARKED_QUERY_WATCHDOG_MS`. Tests inject a small value so the
574
+ * timeout path is observable.
575
+ */
576
+ parkedQueryWatchdogMs?: number;
424
577
  /**
425
578
  * Optional per-message dispatch-timing observer. When supplied, the
426
579
  * dispatch loop invokes it twice per dispatched inbox entry: once with
@@ -490,9 +643,8 @@ export type DispatchSubstrateLeg = "enqueue" | "dequeue" | "runevent" | "markcon
490
643
  * (design §10b). All are cheap filesystem reads against the workflow-run
491
644
  * repo's on-disk working tree, taken only when the observer is wired.
492
645
  *
493
- * - `runsFanOut` — entry count under `runs/` (one subdir per message;
494
- * never pruned). The candidate-(i) "collapse runs"
495
- * win is sized by this.
646
+ * - `runsFanOut` — entry count under `runs/` (the stable top-level run
647
+ * plus any internal body-child runs).
496
648
  * - `consumedFanOut` — entry count under
497
649
  * `addresses/<addr>/consumed/` (one dedup entry per
498
650
  * message; never pruned). The candidate-(iv) "prune
@@ -512,8 +664,13 @@ export type DispatchStructuralCounters = {
512
664
  /**
513
665
  * One observation emitted by `WorkflowSupervisorBindings.onDispatchTiming`.
514
666
  *
667
+ * Both variants key on `messageId`, the per-message identifier (the mail's
668
+ * Message-ID). The top-level run id cannot serve as the key: one deployment
669
+ * keeps that stable id across all of its live trigger occurrences, so it does
670
+ * not distinguish one dispatched message from the next.
671
+ *
515
672
  * The `"roundtrip"` variant is the 4.7 latency-gate bracket: pair the
516
- * `"dispatch-start"` and `"reply-produced"` marks for the same `runId` to
673
+ * `"dispatch-start"` and `"reply-produced"` marks for the same `messageId` to
517
674
  * recover the per-message round-trip. `atMs` is a high-resolution
518
675
  * monotonic timestamp (`performance.now()`).
519
676
  *
@@ -528,12 +685,12 @@ export type DispatchStructuralCounters = {
528
685
  */
529
686
  export type DispatchTimingMark = {
530
687
  kind: "roundtrip";
531
- runId: string;
688
+ messageId: string;
532
689
  marker: "dispatch-start" | "reply-produced";
533
690
  atMs: number;
534
691
  } | {
535
692
  kind: "leg";
536
- runId: string;
693
+ messageId: string;
537
694
  leg: DispatchSubstrateLeg;
538
695
  phase: "start" | "end";
539
696
  atMs: number;
@@ -0,0 +1,12 @@
1
+ export type ChangeNotifier = {
2
+ /** Call from the double after any mutation a waiter might care about. */
3
+ notify(): void;
4
+ /**
5
+ * Resolve once `predicate` holds. Evaluated immediately, then again after
6
+ * each `notify`. Carries no deadline: a predicate that never holds is
7
+ * caught by the lane timeout, per "Synchronizing on State, Not Time" in
8
+ * CONVENTIONS.md.
9
+ */
10
+ until(predicate: () => boolean): Promise<void>;
11
+ };
12
+ export declare function createChangeNotifier(): ChangeNotifier;
@@ -0,0 +1,63 @@
1
+ // Level-triggered wait over a mutable test double.
2
+ //
3
+ // A test that needs a double to have reached some state -- two entries
4
+ // consumed, an entry moved to processing -- has no event to await, because
5
+ // the state lives in a map the double mutates in place. The usual stand-in is
6
+ // a predicate re-evaluated on a timer, which makes the tick interval and the
7
+ // deadline part of whether the test passes.
8
+ //
9
+ // This inverts it: the double reports that it changed, and the waiter
10
+ // re-evaluates the predicate only then. The predicate is still a predicate --
11
+ // there is no event to name when the subject is "the map now looks like this"
12
+ // -- but nothing is re-read on a schedule and no duration decides the
13
+ // outcome.
14
+ //
15
+ // `until` evaluates the predicate on entry to every pass of its loop, so a
16
+ // state the double already reached resolves it and a caller never has to know
17
+ // whether it armed the wait in time. Only `notify` is an edge: one with no
18
+ // waiter registered is dropped, which is why the double must call it after
19
+ // every mutation rather than only when it thinks someone is watching.
20
+ //
21
+ // It is deliberately NOT a consolidation of the doubles themselves. The five
22
+ // inbox doubles in this package are genuinely different implementations
23
+ // rather than cosmetic variants, and a merged superset could not be shown to
24
+ // preserve what each test relies on. Sharing the waiting is the part that was
25
+ // worth sharing.
26
+ export function createChangeNotifier() {
27
+ let waiters = [];
28
+ return {
29
+ notify() {
30
+ // Clearing the list is what bounds it. Every pass of the wait loop
31
+ // below registers a resolver, and the pass that returns leaves its own
32
+ // resolver behind unsettled; without the clear those accumulate for the
33
+ // life of the notifier. Measured over 50 notifies with one waiter: 1
34
+ // entry with the clear, 51 without.
35
+ //
36
+ // Clearing BEFORE the wake rather than after is not what makes a
37
+ // re-arming waiter wait for the next change instead of this one. Waking
38
+ // a settled resolver is a no-op, and the loop re-registers only after
39
+ // `await changed` resumes, which is a microtask boundary -- by then this
40
+ // function has finished iterating. A mutant that wakes the live list and
41
+ // clears afterwards passes every test that uses this helper.
42
+ const waking = waiters;
43
+ waiters = [];
44
+ for (const waiter of waking)
45
+ waiter();
46
+ },
47
+ async until(predicate) {
48
+ for (;;) {
49
+ // Re-checked on every pass, which is what makes this usable when the
50
+ // change already happened before the wait was created. The registering
51
+ // executor runs synchronously, so no notify can land between the check
52
+ // and the registration in either order -- the loop is the invariant,
53
+ // not the order of those two lines.
54
+ const changed = new Promise((resolve) => {
55
+ waiters.push(resolve);
56
+ });
57
+ if (predicate())
58
+ return;
59
+ await changed;
60
+ }
61
+ },
62
+ };
63
+ }
@@ -0,0 +1,8 @@
1
+ export { createMemoryFrameStream, createMemoryNdjsonStream, type MemoryFrameStream, type MemoryNdjsonStream, } from "./memory-streams.js";
2
+ export { createSupervisorReaper, type ReapableSupervisor, type SupervisorReaper, } from "./supervisor-reaper.js";
3
+ export { createMockMailBus, type MockMailBus } from "./mail-bus.js";
4
+ export { createSpawnObserver, type SpawnObserver } from "./spawn-observer.js";
5
+ export { parseTriggerFireRunIds, readPayloadsOfType, waitForTriggerFireRunIds, waitForUpstreamPayload, waitForUpstreamPayloads, type UpstreamFrameSource, } from "./upstream-frames.js";
6
+ export { createChangeNotifier, type ChangeNotifier } from "./change-notifier.js";
7
+ export { createLogCapture, type CapturedLogRecord, type LogCapture, } from "./log-capture.js";
8
+ export { createStubRepoStore, type StubRepoStoreOpts } from "./stub-repo-store.js";
@@ -0,0 +1,16 @@
1
+ // @intx/workflow-host/testing -- in-memory doubles for the IPC transports.
2
+ //
3
+ // These exist so a test can drive a supervisor or a workflow-process child
4
+ // over the real channel code without spawning a process. They hold their
5
+ // frames in an array and have no durability, no backpressure, and no
6
+ // framing beyond the newline terminator the event channel expects, so a
7
+ // production caller reaching for this subpath is almost certainly looking
8
+ // for the real transports in the package root instead.
9
+ export { createMemoryFrameStream, createMemoryNdjsonStream, } from "./memory-streams.js";
10
+ export { createSupervisorReaper, } from "./supervisor-reaper.js";
11
+ export { createMockMailBus } from "./mail-bus.js";
12
+ export { createSpawnObserver } from "./spawn-observer.js";
13
+ export { parseTriggerFireRunIds, readPayloadsOfType, waitForTriggerFireRunIds, waitForUpstreamPayload, waitForUpstreamPayloads, } from "./upstream-frames.js";
14
+ export { createChangeNotifier } from "./change-notifier.js";
15
+ export { createLogCapture, } from "./log-capture.js";
16
+ export { createStubRepoStore } from "./stub-repo-store.js";
@@ -0,0 +1,52 @@
1
+ /** One captured record, with its message template already interpolated. */
2
+ export type CapturedLogRecord = {
3
+ readonly category: readonly string[];
4
+ readonly level: string;
5
+ readonly message: string;
6
+ };
7
+ export type LogCapture = {
8
+ /**
9
+ * Replace the process-wide logging configuration with the capturing one.
10
+ * Pass straight to `beforeAll`. Throws if a capture is already installed
11
+ * through this object.
12
+ */
13
+ install(): void;
14
+ /**
15
+ * Put back the configuration `install` replaced. Pass straight to
16
+ * `afterAll`. Throws if nothing was installed.
17
+ */
18
+ restore(): void;
19
+ /**
20
+ * Drop the captured records and abandon every armed waiter. Pass straight
21
+ * to `beforeEach`.
22
+ */
23
+ reset(): void;
24
+ /** Every record captured since the last `reset`, in log order. */
25
+ records(): readonly CapturedLogRecord[];
26
+ /** The messages of the captured `error` records, in log order. */
27
+ errors(): string[];
28
+ /**
29
+ * Resolve with the first record at any level whose message contains
30
+ * `needle`, whether it was logged before this call or arrives after it.
31
+ * Carries no deadline: a record that never arrives is caught by the lane
32
+ * timeout, per "Synchronizing on State, Not Time" in CONVENTIONS.md.
33
+ *
34
+ * `needle` must identify the record uniquely within its own test. Matching
35
+ * on a message some other test also emits is what makes this a barrier in
36
+ * name only: `reset` clears the records a previous test logged, but it
37
+ * cannot exclude the ones still arriving from work that test left running,
38
+ * and nothing in a record says which test caused it. A wait satisfied by
39
+ * such a straggler returns before the awaited work has happened, and the
40
+ * assertion behind it reads pre-barrier state and passes. Where two tests
41
+ * exercise the same path, vary an input the record carries -- see the
42
+ * crash-reason token in `substrate-write.test.ts`.
43
+ *
44
+ * A test that triggers fire-and-forget work owes the next test the same
45
+ * courtesy: await that work's own completion record before returning, so
46
+ * it cannot straggle across the boundary in the first place.
47
+ */
48
+ waitForRecord(needle: string): Promise<CapturedLogRecord>;
49
+ /** As `waitForRecord`, restricted to records at the `error` level. */
50
+ waitForError(needle: string): Promise<CapturedLogRecord>;
51
+ };
52
+ export declare function createLogCapture(): LogCapture;