@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,304 @@
1
+ // Production workflow-host signal channel (Seam 3, log-tail wait).
2
+ //
3
+ // The signal channel is constructed per run by the host's runtime
4
+ // wiring. Its `deliver` commits a `SignalReceived` blob to the run's
5
+ // event log; commit-success is the dedup gate (the state machine's
6
+ // `observedSignalIds` rejects a duplicate `signalId` on the next
7
+ // transition). `awaitNext` consults the state-machine `RunState` for
8
+ // signals that were committed and reduced into `unconsumedSignals`
9
+ // before the awaiter subscribed, then falls back to a per-name
10
+ // `subscribeKind` tail of the run's events ref.
11
+ //
12
+ // Per-name FIFO across concurrent awaiters: the channel maintains an
13
+ // awaiter queue per signal name. A single `subscribeKind` loop per
14
+ // name pulls events from the substrate; each matching event resolves
15
+ // the head of that queue. The loop starts on first `awaitNext` for a
16
+ // name and tears down when the queue empties or `stop()` is called.
17
+ //
18
+ // Commit-ordering invariant: `deliver` commits the `SignalReceived`
19
+ // event blob first, then returns. Resolution of an awaiter happens
20
+ // from the `subscribeKind` loop only after the substrate has surfaced
21
+ // the commit. The awaiter is never resolved from inside `deliver`'s
22
+ // call site, so resume-after-crash sees a coherent log: an awaiter
23
+ // that resolved must have a corresponding committed `SignalReceived`.
24
+ import { type } from "arktype";
25
+ import { subscribeKind } from "@intx/hub-sessions/substrate";
26
+ /**
27
+ * Substrate-shape envelope for the `SignalReceived` event blob
28
+ * committed to `runs/<runId>/events/<seq>.json`. The validator covers
29
+ * the single event type the signal channel both reads (live tail) and
30
+ * writes (deliver). Fields ride at the top level so the shape is
31
+ * symmetric with the runtime body's append shape -- a downstream
32
+ * reader that hydrates the envelope as a state-machine `WorkflowEvent`
33
+ * sees `signalName`/`signalId`/`payload` regardless of whether the
34
+ * commit came from the signal channel's `deliver` or the runtime
35
+ * body's `commit` of a SignalReceived after `awaitNext`. Non-signal
36
+ * blobs at the same path prefix do not match the kinds filter inside
37
+ * `subscribeKind`.
38
+ */
39
+ export const SignalReceivedEnvelope = type({
40
+ type: "'SignalReceived'",
41
+ signalName: "string",
42
+ signalId: "string",
43
+ payload: "unknown",
44
+ });
45
+ export function createWorkflowHostSignalChannel(opts) {
46
+ const awaiters = new Map();
47
+ const subscriptions = new Map();
48
+ let stopped = false;
49
+ function peekState(name) {
50
+ const state = opts.readState();
51
+ const queue = state.unconsumedSignals.get(name);
52
+ if (queue === undefined || queue.length === 0)
53
+ return null;
54
+ const head = queue[0];
55
+ if (head === undefined)
56
+ return null;
57
+ // The state-machine `unconsumedSignals` is drained by the
58
+ // runtime body's next `SignalAwaited` reduction, not by the
59
+ // channel. The channel reads the head; the caller commits the
60
+ // SignalAwaited that consumes it. This separation keeps the
61
+ // channel free of state-machine mutation responsibilities.
62
+ return { payload: head.payload, signalId: head.id };
63
+ }
64
+ function matchesAwaiter(entry, name) {
65
+ if (entry.runId !== opts.runId)
66
+ return false;
67
+ if (entry.event.type !== "SignalReceived")
68
+ return false;
69
+ return entry.event.signalName === name;
70
+ }
71
+ function shiftAwaiter(name) {
72
+ const queue = awaiters.get(name);
73
+ if (queue === undefined || queue.length === 0)
74
+ return null;
75
+ const head = queue.shift();
76
+ if (head === undefined)
77
+ return null;
78
+ if (head.signal !== undefined && head.onAbort !== undefined) {
79
+ head.signal.removeEventListener("abort", head.onAbort);
80
+ }
81
+ if (queue.length === 0)
82
+ awaiters.delete(name);
83
+ return head;
84
+ }
85
+ function awaiterCount(name) {
86
+ const queue = awaiters.get(name);
87
+ if (queue === undefined)
88
+ return 0;
89
+ return queue.length;
90
+ }
91
+ function startNameSubscription(name) {
92
+ if (subscriptions.has(name))
93
+ return;
94
+ const abort = new AbortController();
95
+ // The teardown must run synchronously *before* the awaiter wakes
96
+ // when this loop drains, otherwise the awaiter's continuation
97
+ // (which often calls awaitNext again on the same name) sees the
98
+ // leaked subscription entry and short-circuits in
99
+ // startNameSubscription, stranding the new awaiter on a dead
100
+ // subscribeKind loop. Removing the entry from `subscriptions` and
101
+ // aborting *before* resolving the awaiter keeps the per-name
102
+ // invariant intact across multi-round resolution cycles.
103
+ const teardown = () => {
104
+ if (subscriptions.get(name)?.abort === abort) {
105
+ subscriptions.delete(name);
106
+ }
107
+ abort.abort();
108
+ };
109
+ const done = (async () => {
110
+ try {
111
+ const iter = subscribeKind(opts.repoStore, opts.principal, opts.repoId, opts.ref, SignalReceivedEnvelope, {
112
+ signal: abort.signal,
113
+ from: "head",
114
+ kinds: ["SignalReceived"],
115
+ });
116
+ for await (const entry of iter) {
117
+ if (stopped)
118
+ break;
119
+ if (!matchesAwaiter(entry, name))
120
+ continue;
121
+ const observed = opts.readState().observedSignalIds;
122
+ if (observed.has(entry.event.signalId))
123
+ continue;
124
+ const next = shiftAwaiter(name);
125
+ if (next === null) {
126
+ // No awaiter left -- the queue was drained between the
127
+ // event landing and this loop reaching it. The reduced
128
+ // state machine queues the event into
129
+ // `unconsumedSignals`; the next `awaitNext` for the same
130
+ // name picks it up via the state-reader path.
131
+ break;
132
+ }
133
+ if (awaiterCount(name) === 0) {
134
+ // Resolving the last awaiter on this subscription. Tear
135
+ // down before the resolve so any awaitNext call inside
136
+ // the awaiter's continuation installs a fresh
137
+ // subscription instead of joining a dead one.
138
+ teardown();
139
+ next.resolve({
140
+ payload: entry.event.payload,
141
+ signalId: entry.event.signalId,
142
+ });
143
+ return;
144
+ }
145
+ next.resolve({
146
+ payload: entry.event.payload,
147
+ signalId: entry.event.signalId,
148
+ });
149
+ }
150
+ }
151
+ finally {
152
+ // Catches the queue-drained natural break, the `stopped`
153
+ // bail, and any error escaping the loop. The abort path
154
+ // through `stopSubscription` already deleted the Map entry;
155
+ // the get(...)?.abort guard makes this idempotent.
156
+ teardown();
157
+ }
158
+ })();
159
+ subscriptions.set(name, { abort, done });
160
+ }
161
+ async function stopSubscription(name) {
162
+ const sub = subscriptions.get(name);
163
+ if (sub === undefined)
164
+ return;
165
+ subscriptions.delete(name);
166
+ sub.abort.abort();
167
+ await sub.done.catch(() => {
168
+ /* swallow aborted-iterator surface */
169
+ });
170
+ }
171
+ return {
172
+ async deliver(name, payload, signalId) {
173
+ if (stopped) {
174
+ throw new Error("signal channel: deliver after stop");
175
+ }
176
+ const id = signalId ?? opts.newId();
177
+ const at = opts.clock().toISOString();
178
+ const prefix = `runs/${opts.runId}/events/`;
179
+ await opts.repoStore.writeTreePreservingPrefix(opts.principal, opts.repoId, opts.ref, {
180
+ preservePrefix: prefix,
181
+ merge: async (existing) => {
182
+ let maxSeq = -1;
183
+ let duplicate = false;
184
+ for (const [filepath, contents] of existing) {
185
+ const fname = filepath.slice(prefix.length);
186
+ const match = /^(0|[1-9][0-9]*)\.json$/.exec(fname);
187
+ if (match === null)
188
+ continue;
189
+ const seqStr = match[1];
190
+ if (seqStr === undefined)
191
+ continue;
192
+ const seq = Number.parseInt(seqStr, 10);
193
+ if (seq > maxSeq)
194
+ maxSeq = seq;
195
+ try {
196
+ const parsed = JSON.parse(new TextDecoder().decode(contents));
197
+ if (isMatchingSignalId(parsed, id)) {
198
+ duplicate = true;
199
+ }
200
+ }
201
+ catch {
202
+ // A corrupt blob is rejected by validatePush at write
203
+ // time. Treat as non-matching here.
204
+ }
205
+ }
206
+ const out = {};
207
+ for (const [filepath, contents] of existing) {
208
+ out[filepath] = new TextDecoder().decode(contents);
209
+ }
210
+ if (duplicate)
211
+ return out;
212
+ const nextSeq = maxSeq + 1;
213
+ // The workflow-run kind handler's `EventEnvelope`
214
+ // validator requires `seq: number` on every event blob;
215
+ // the same `nextSeq` we use to mint the filename also
216
+ // carries into the body so a reader that hydrates the
217
+ // envelope (state-machine resume, audit reads) sees a
218
+ // self-describing event without consulting the filename.
219
+ out[`${prefix}${String(nextSeq)}.json`] = JSON.stringify({
220
+ type: "SignalReceived",
221
+ seq: nextSeq,
222
+ signalName: name,
223
+ signalId: id,
224
+ payload,
225
+ at,
226
+ });
227
+ return out;
228
+ },
229
+ message: `SignalReceived ${id} (${name}) for run ${opts.runId}`,
230
+ });
231
+ },
232
+ async awaitNext(name, signal) {
233
+ if (stopped) {
234
+ throw new Error("signal channel: awaitNext after stop");
235
+ }
236
+ if (signal !== undefined && signal.aborted) {
237
+ throw new Error("aborted");
238
+ }
239
+ // Drain the state-machine queue first. A signal that was
240
+ // committed before the awaiter subscribed lives in
241
+ // `unconsumedSignals` after log replay.
242
+ const queued = peekState(name);
243
+ if (queued !== null)
244
+ return queued;
245
+ return new Promise((resolve, reject) => {
246
+ const awaiter = {
247
+ resolve,
248
+ reject,
249
+ ...(signal !== undefined ? { signal } : {}),
250
+ };
251
+ if (signal !== undefined) {
252
+ const onAbort = () => {
253
+ const list = awaiters.get(name);
254
+ if (list !== undefined) {
255
+ const idx = list.indexOf(awaiter);
256
+ if (idx >= 0)
257
+ list.splice(idx, 1);
258
+ if (list.length === 0) {
259
+ awaiters.delete(name);
260
+ void stopSubscription(name);
261
+ }
262
+ }
263
+ reject(new Error("aborted"));
264
+ };
265
+ awaiter.onAbort = onAbort;
266
+ signal.addEventListener("abort", onAbort, { once: true });
267
+ }
268
+ let list = awaiters.get(name);
269
+ if (list === undefined) {
270
+ list = [];
271
+ awaiters.set(name, list);
272
+ }
273
+ list.push(awaiter);
274
+ startNameSubscription(name);
275
+ });
276
+ },
277
+ async stop() {
278
+ if (stopped)
279
+ return;
280
+ stopped = true;
281
+ for (const list of awaiters.values()) {
282
+ for (const awaiter of list) {
283
+ if (awaiter.signal !== undefined && awaiter.onAbort !== undefined) {
284
+ awaiter.signal.removeEventListener("abort", awaiter.onAbort);
285
+ }
286
+ awaiter.reject(new Error("signal channel stopped"));
287
+ }
288
+ }
289
+ awaiters.clear();
290
+ const names = [...subscriptions.keys()];
291
+ for (const name of names) {
292
+ await stopSubscription(name);
293
+ }
294
+ },
295
+ };
296
+ }
297
+ function isMatchingSignalId(parsed, signalId) {
298
+ if (typeof parsed !== "object" || parsed === null)
299
+ return false;
300
+ const obj = parsed;
301
+ if (obj.type !== "SignalReceived")
302
+ return false;
303
+ return obj.signalId === signalId;
304
+ }
@@ -0,0 +1,68 @@
1
+ import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
2
+ import type { CancelOrigin } from "@intx/workflow";
3
+ import type { PrincipalSigner, SignedPayload, WorkflowSupervisorPrincipalKind } from "./types.js";
4
+ /**
5
+ * The supervisor's stable principal kind used for every supervisor-
6
+ * authored commit (CancelRequested for `self`/`supervisor-drain`/
7
+ * `supervisor-operator` origins, plus drain audit frames in later
8
+ * commits). The kind handler reads this off the substrate's per-
9
+ * push principal and enforces the principal-vs-origin map.
10
+ */
11
+ export declare const SUPERVISOR_PRINCIPAL_KIND: WorkflowSupervisorPrincipalKind;
12
+ export type CommitCancelRequestedOpts = {
13
+ /** Substrate handle the supervisor writes through. */
14
+ substrate: SubstrateRepoStore;
15
+ /** Workflow-run repo for this deployment. */
16
+ repoId: RepoId;
17
+ /** Events ref the workflow-run repo writes to. */
18
+ ref: string;
19
+ /** Deployment id used to construct the supervisor principal. */
20
+ deploymentId: string;
21
+ /** Run id whose event log receives the CancelRequested entry. */
22
+ runId: string;
23
+ /** Cancellation origin from the Q3 map. */
24
+ origin: CancelOrigin;
25
+ /**
26
+ * Human-readable reason. For `self`-origin requests the supervisor
27
+ * forwards the workflow-process's stated reason verbatim; the
28
+ * other origins carry the supervisor's own source of truth.
29
+ */
30
+ reason: string;
31
+ /**
32
+ * ISO-8601 commit timestamp the event carries. The supervisor
33
+ * controls this so tests can pin to a deterministic value.
34
+ */
35
+ at: string;
36
+ /**
37
+ * Host-supplied per-principal signing callback. Invoked here with
38
+ * `"supervisor"` and the canonical event-payload bytes; never with
39
+ * the supervisor's private key visible to the supervisor module.
40
+ */
41
+ signAsPrincipal: PrincipalSigner;
42
+ };
43
+ /**
44
+ * Result of a successful CancelRequested commit. The committed
45
+ * payload (including the attached signature envelope) is surfaced so
46
+ * callers that need to audit the exact on-disk bytes have them
47
+ * without re-reading the repo.
48
+ */
49
+ export type CommitCancelRequestedResult = {
50
+ /** Substrate-assigned commit SHA the append produced. */
51
+ commitSha: string;
52
+ /** Per-run sequence number the append landed at. */
53
+ seq: number;
54
+ /** Signature the supervisor attached to the event. */
55
+ signature: SignedPayload;
56
+ };
57
+ declare const OnDiskEnvelope: import("arktype/internal/variants/object.ts").ObjectType<{
58
+ seq: number;
59
+ type: string;
60
+ }, {}>;
61
+ /**
62
+ * Commit a CancelRequested event signed by the supervisor on behalf
63
+ * of the named origin. The `self`-origin path is identical to the
64
+ * other supervisor origins from the substrate's perspective; the
65
+ * caller passes the workflow-process's stated reason through.
66
+ */
67
+ export declare function commitCancelRequested(opts: CommitCancelRequestedOpts): Promise<CommitCancelRequestedResult>;
68
+ export { OnDiskEnvelope as CancelRequestedOnDiskEnvelopeForTest };
@@ -0,0 +1,144 @@
1
+ // `CancelRequested` signing path for the per-deployment supervisor.
2
+ //
3
+ // Every CancelRequested origin flows through the supervisor's
4
+ // signing identity -- including the
5
+ // `self`-origin case where the workflow-process passes its stated
6
+ // reason to the supervisor via the control IPC and the supervisor
7
+ // wraps it into a signed event. The child has no asymmetric keypair
8
+ // of its own; routing all four origins through the same supervisor-
9
+ // signed path keeps the trust anchor inventory at one signing key
10
+ // per deployment (plus the hub's, for `hub-admin`).
11
+ //
12
+ // The supervisor owns the Ed25519 signing key (held in closure by
13
+ // the `signAsPrincipal` callback the host injects); this module
14
+ // composes the call sequence:
15
+ // 1. Build the CancelRequested event payload from the requested
16
+ // origin and reason.
17
+ // 2. Serialize a canonical byte representation the signing
18
+ // callback signs.
19
+ // 3. Attach the signature to the on-the-wire event and append it
20
+ // to the workflow-run repo via the substrate handle.
21
+ // The substrate-side workflow-run kind handler enforces the
22
+ // principal-vs-origin map at push validation (a `self`/`supervisor-
23
+ // drain`/`supervisor-operator` origin must arrive carried by a
24
+ // `supervisor`-kind principal), which is the cross-check the
25
+ // supervisor's runtime-side signing keeps coherent.
26
+ import { type } from "arktype";
27
+ import { hexEncode } from "@intx/types";
28
+ /**
29
+ * Path inside the workflow-run repo each `CancelRequested` event
30
+ * lands under. Matches the layout the workflow-run kind handler
31
+ * validates: `runs/<runId>/events/<seq>.json`.
32
+ */
33
+ const RUNS_PREFIX = "runs";
34
+ const EVENTS_DIR = "events";
35
+ /**
36
+ * The supervisor's stable principal kind used for every supervisor-
37
+ * authored commit (CancelRequested for `self`/`supervisor-drain`/
38
+ * `supervisor-operator` origins, plus drain audit frames in later
39
+ * commits). The kind handler reads this off the substrate's per-
40
+ * push principal and enforces the principal-vs-origin map.
41
+ */
42
+ export const SUPERVISOR_PRINCIPAL_KIND = "supervisor";
43
+ const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
44
+ const OnDiskEnvelope = type({
45
+ seq: "number >= 0",
46
+ type: "string",
47
+ "+": "ignore",
48
+ });
49
+ /**
50
+ * Build the canonical bytes the supervisor signs for a
51
+ * CancelRequested event. The on-wire shape carries the same fields
52
+ * the workflow-run kind handler validates plus a `signature`
53
+ * sub-object the supervisor populates after this byte string is
54
+ * signed. Signing the payload *without* the signature field keeps
55
+ * the verifier's reconstruction trivial: strip `signature` from the
56
+ * blob, canonicalize, verify against `signature.sig`.
57
+ */
58
+ function buildPayloadBytes(args) {
59
+ const canonical = {
60
+ type: "CancelRequested",
61
+ seq: args.seq,
62
+ runId: args.runId,
63
+ at: args.at,
64
+ reason: args.reason,
65
+ origin: args.origin,
66
+ };
67
+ return new TextEncoder().encode(JSON.stringify(canonical));
68
+ }
69
+ /**
70
+ * Commit a CancelRequested event signed by the supervisor on behalf
71
+ * of the named origin. The `self`-origin path is identical to the
72
+ * other supervisor origins from the substrate's perspective; the
73
+ * caller passes the workflow-process's stated reason through.
74
+ */
75
+ export async function commitCancelRequested(opts) {
76
+ const prefix = `${RUNS_PREFIX}/${opts.runId}/${EVENTS_DIR}/`;
77
+ const principal = {
78
+ kind: SUPERVISOR_PRINCIPAL_KIND,
79
+ deploymentId: opts.deploymentId,
80
+ };
81
+ let resolved = null;
82
+ const { commitSha } = await opts.substrate.writeTreePreservingPrefix(principal, opts.repoId, opts.ref, {
83
+ preservePrefix: prefix,
84
+ merge: async (existing) => {
85
+ let maxSeq = -1;
86
+ for (const filepath of existing.keys()) {
87
+ const name = filepath.slice(prefix.length);
88
+ const match = EVENT_FILENAME_RE.exec(name);
89
+ if (match === null)
90
+ continue;
91
+ const seqStr = match[1];
92
+ if (seqStr === undefined)
93
+ continue;
94
+ const seq = Number.parseInt(seqStr, 10);
95
+ if (seq > maxSeq)
96
+ maxSeq = seq;
97
+ }
98
+ const nextSeq = maxSeq + 1;
99
+ const payloadBytes = buildPayloadBytes({
100
+ seq: nextSeq,
101
+ runId: opts.runId,
102
+ reason: opts.reason,
103
+ origin: opts.origin,
104
+ at: opts.at,
105
+ });
106
+ const signature = await opts.signAsPrincipal(SUPERVISOR_PRINCIPAL_KIND, payloadBytes);
107
+ resolved = { seq: nextSeq, signature };
108
+ const onDisk = {
109
+ type: "CancelRequested",
110
+ seq: nextSeq,
111
+ runId: opts.runId,
112
+ at: opts.at,
113
+ reason: opts.reason,
114
+ origin: opts.origin,
115
+ signature: serializeSignedPayload(signature),
116
+ };
117
+ const files = {};
118
+ for (const [k, v] of existing)
119
+ files[k] = v;
120
+ files[`${prefix}${String(nextSeq)}.json`] = JSON.stringify(onDisk);
121
+ return files;
122
+ },
123
+ message: `append CancelRequested ${opts.origin} for run ${opts.runId}`,
124
+ });
125
+ if (resolved === null) {
126
+ throw new Error(`supervisor cancel-signing: merge callback did not assign a sequence number for run ${opts.runId}`);
127
+ }
128
+ const final = resolved;
129
+ return { commitSha, seq: final.seq, signature: final.signature };
130
+ }
131
+ /**
132
+ * Wire shape for a SignedPayload inside a CancelRequested event
133
+ * blob. The signature bytes are hex-encoded for JSON-safety; the
134
+ * principal kind rides alongside so an audit-log walker can verify
135
+ * the signature without consulting a sidecar manifest for which key
136
+ * to load.
137
+ */
138
+ function serializeSignedPayload(signed) {
139
+ return {
140
+ principalKind: signed.principalKind,
141
+ sig: hexEncode(signed.sig),
142
+ };
143
+ }
144
+ export { OnDiskEnvelope as CancelRequestedOnDiskEnvelopeForTest };
@@ -0,0 +1,51 @@
1
+ import { getLogger } from "@intx/log";
2
+ import type { SubprocessHandle } from "./types.js";
3
+ /**
4
+ * Default kill-timeout between SIGTERM and SIGKILL. Used when a caller
5
+ * supplies no override; the recycle path exposes it as a per-deployment
6
+ * override and the spawn ready-timeout teardown uses it directly.
7
+ */
8
+ export declare const DEFAULT_KILL_TIMEOUT_MS = 5000;
9
+ /**
10
+ * Default deadline for a child's `ready` handshake -- the window a freshly
11
+ * spawned child has to emit `ready` before the supervisor kills it and
12
+ * treats the spawn (or recycle respawn) as failed. Used when a caller
13
+ * supplies no override. Shared here, alongside the kill default, so both
14
+ * the spawn path and the recycle path bound the handshake identically
15
+ * without either re-declaring the constant.
16
+ */
17
+ export declare const DEFAULT_READY_TIMEOUT_MS = 30000;
18
+ /**
19
+ * Injected dependencies for `killChildHandle`. `setTimer`/`clearTimer`
20
+ * default to the real `setTimeout`/`clearTimeout` when omitted so tests
21
+ * can substitute a deterministic timer. `logger` is supplied by the
22
+ * caller so the SIGKILL-escalation warning is attributed to the path that
23
+ * initiated the kill (recycle vs. spawn) rather than a single shared
24
+ * namespace.
25
+ */
26
+ export interface KillChildHandleDeps {
27
+ setTimer?: (cb: () => void, ms: number) => unknown;
28
+ clearTimer?: (handle: unknown) => void;
29
+ logger: ReturnType<typeof getLogger>;
30
+ }
31
+ /**
32
+ * Issue SIGTERM and wait for the child to exit. If the exit does not land
33
+ * within `killTimeoutMs`, escalate to SIGKILL and wait again. SIGKILL is
34
+ * unignorable, so `exited` is guaranteed to settle -- a child that traps
35
+ * or never services SIGTERM cannot wedge this call. The supervisor's
36
+ * spawner returns the `exited` promise; this helper does not consult OS
37
+ * primitives directly.
38
+ */
39
+ export declare function killChildHandle(handle: SubprocessHandle, killTimeoutMs: number, deps: KillChildHandleDeps): Promise<void>;
40
+ /**
41
+ * A resolve-only deadline: a promise that resolves after `ms` via the
42
+ * injected `setTimer`, plus the timer handle so the caller can cancel it
43
+ * with the matching `clearTimer` once the race settles. It only ever
44
+ * resolves, so it contributes no rejection of its own to a race.
45
+ */
46
+ export declare function waitDeadline(setTimer: (cb: () => void, ms: number) => unknown, ms: number): {
47
+ promise: Promise<void>;
48
+ handle: unknown;
49
+ };
50
+ export declare function defaultSetTimer(cb: () => void, ms: number): unknown;
51
+ export declare function defaultClearTimer(handle: unknown): void;
@@ -0,0 +1,76 @@
1
+ // Child termination plus the resolve-only deadline / injectable-timer
2
+ // primitives its escalation drives. Two supervisor paths need to bound a
3
+ // wait on a workflow-process child with a timer and then force the child
4
+ // down: the recycle path (SIGTERM -> deadline -> SIGKILL between cohorts)
5
+ // and the spawn path's ready-handshake timeout (a child that spawns but
6
+ // never emits `ready`). Both need the same `killChildHandle` escalation
7
+ // and the same resolve-only `waitDeadline` raced against a child event,
8
+ // and both need injectable timers so tests can drive the deadline
9
+ // deterministically. Factoring them here keeps one implementation instead
10
+ // of a copy per path.
11
+ import { getLogger } from "@intx/log";
12
+ /**
13
+ * Default kill-timeout between SIGTERM and SIGKILL. Used when a caller
14
+ * supplies no override; the recycle path exposes it as a per-deployment
15
+ * override and the spawn ready-timeout teardown uses it directly.
16
+ */
17
+ export const DEFAULT_KILL_TIMEOUT_MS = 5_000;
18
+ /**
19
+ * Default deadline for a child's `ready` handshake -- the window a freshly
20
+ * spawned child has to emit `ready` before the supervisor kills it and
21
+ * treats the spawn (or recycle respawn) as failed. Used when a caller
22
+ * supplies no override. Shared here, alongside the kill default, so both
23
+ * the spawn path and the recycle path bound the handshake identically
24
+ * without either re-declaring the constant.
25
+ */
26
+ export const DEFAULT_READY_TIMEOUT_MS = 30_000;
27
+ /**
28
+ * Issue SIGTERM and wait for the child to exit. If the exit does not land
29
+ * within `killTimeoutMs`, escalate to SIGKILL and wait again. SIGKILL is
30
+ * unignorable, so `exited` is guaranteed to settle -- a child that traps
31
+ * or never services SIGTERM cannot wedge this call. The supervisor's
32
+ * spawner returns the `exited` promise; this helper does not consult OS
33
+ * primitives directly.
34
+ */
35
+ export async function killChildHandle(handle, killTimeoutMs, deps) {
36
+ const setTimer = deps.setTimer ?? defaultSetTimer;
37
+ const clearTimer = deps.clearTimer ?? defaultClearTimer;
38
+ handle.kill("SIGTERM");
39
+ const sigTermDeadline = waitDeadline(setTimer, killTimeoutMs);
40
+ const exitedFirst = await Promise.race([
41
+ handle.exited.then(() => "exited"),
42
+ sigTermDeadline.promise.then(() => "deadline"),
43
+ ]);
44
+ if (exitedFirst === "exited") {
45
+ clearTimer(sigTermDeadline.handle);
46
+ return;
47
+ }
48
+ clearTimer(sigTermDeadline.handle);
49
+ deps.logger
50
+ .warn `child termination: SIGTERM did not land within ${String(killTimeoutMs)}ms; escalating to SIGKILL`;
51
+ handle.kill("SIGKILL");
52
+ await handle.exited.catch(() => {
53
+ /* swallowed: a non-zero exit on SIGKILL is the expected outcome;
54
+ termination treats handle exit as success regardless of code. */
55
+ });
56
+ }
57
+ /**
58
+ * A resolve-only deadline: a promise that resolves after `ms` via the
59
+ * injected `setTimer`, plus the timer handle so the caller can cancel it
60
+ * with the matching `clearTimer` once the race settles. It only ever
61
+ * resolves, so it contributes no rejection of its own to a race.
62
+ */
63
+ export function waitDeadline(setTimer, ms) {
64
+ let h;
65
+ const promise = new Promise((resolve) => {
66
+ h = setTimer(() => resolve(), ms);
67
+ });
68
+ return { promise, handle: h };
69
+ }
70
+ export function defaultSetTimer(cb, ms) {
71
+ return setTimeout(cb, ms);
72
+ }
73
+ export function defaultClearTimer(handle) {
74
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- production wiring; the handle is the value `setTimeout` returned, narrowed back at the boundary
75
+ clearTimeout(handle);
76
+ }