@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,124 @@
1
+ // Capture LogTape records for one test file, so a test can await a log line
2
+ // instead of polling for it.
3
+ //
4
+ // Some decisions announce themselves only in the log. A guard that returns
5
+ // without touching any observable state leaves the record as the sole
6
+ // evidence it ran, and a test that wants to assert on such a decision has no
7
+ // other signal to await. Waiting on the record is what "Synchronizing on
8
+ // State, Not Time" in CONVENTIONS.md asks for, and it replaces a poll whose
9
+ // deadline had to be guessed against whatever the code under test was
10
+ // waiting on.
11
+ //
12
+ // The capture is per-call, not module-level. The unit pass gives a worker one
13
+ // module registry for every file it runs, so a module-level record array
14
+ // would accumulate another file's records and a module-level waiter list
15
+ // would let another file's log line resolve this file's wait.
16
+ // `createChangeNotifier` and `createSupervisorReaper` are factories for the
17
+ // same reason.
18
+ //
19
+ // `configureSync` is process-global, so `install` saves the configuration it
20
+ // replaces and `restore` puts it back. Wire the three lifecycle methods to
21
+ // `beforeAll`, `afterAll`, and `beforeEach`.
22
+ import { configureSync, getConfig } from "@intx/log";
23
+ export function createLogCapture() {
24
+ const captured = [];
25
+ // Tests awaiting a record that has not been logged yet. The sink resolves
26
+ // these as each record lands.
27
+ const waiters = [];
28
+ // Non-null exactly while this capture is installed, so it doubles as the
29
+ // installed flag.
30
+ let savedConfig = null;
31
+ function matches(record, needle, level) {
32
+ if (level !== null && level !== record.level)
33
+ return false;
34
+ return record.message.includes(needle);
35
+ }
36
+ function waitFor(needle, level) {
37
+ const already = captured.find((record) => matches(record, needle, level));
38
+ if (already !== undefined)
39
+ return Promise.resolve(already);
40
+ return new Promise((resolve, reject) => {
41
+ waiters.push({
42
+ needle,
43
+ level,
44
+ resolve,
45
+ abandon: () => {
46
+ const what = level === null ? "record" : `${level} record`;
47
+ reject(new Error(`no ${what} matching ${needle} arrived before teardown`));
48
+ },
49
+ });
50
+ });
51
+ }
52
+ return {
53
+ install() {
54
+ if (savedConfig !== null) {
55
+ throw new Error("this log capture is already installed");
56
+ }
57
+ const prior = getConfig();
58
+ // A null configuration means this file loaded without `@intx/log`
59
+ // having installed its default sink, which cannot happen -- importing
60
+ // the package runs the install. Failing here rather than at `restore`
61
+ // keeps the worker from running a whole suite it cannot unwind: there
62
+ // would be nothing to put back, and the install cannot re-fire to
63
+ // repair it.
64
+ if (prior === null) {
65
+ throw new Error("no logging configuration was present for the capture to replace");
66
+ }
67
+ savedConfig = prior;
68
+ configureSync({
69
+ reset: true,
70
+ sinks: {
71
+ capture: (record) => {
72
+ const message = record.message
73
+ .map((part) => typeof part === "string" ? part : JSON.stringify(part))
74
+ .join("");
75
+ const entry = {
76
+ category: record.category,
77
+ level: record.level,
78
+ message,
79
+ };
80
+ captured.push(entry);
81
+ // Hand the record to anyone waiting for it. Take only the
82
+ // matching waiters, leaving the rest armed for their own needles.
83
+ for (const waiter of waiters.splice(0)) {
84
+ if (matches(entry, waiter.needle, waiter.level)) {
85
+ waiter.resolve(entry);
86
+ }
87
+ else {
88
+ waiters.push(waiter);
89
+ }
90
+ }
91
+ },
92
+ },
93
+ loggers: [
94
+ { category: [], lowestLevel: "debug", sinks: ["capture"] },
95
+ {
96
+ category: ["logtape", "meta"],
97
+ lowestLevel: "warning",
98
+ sinks: ["capture"],
99
+ },
100
+ ],
101
+ });
102
+ },
103
+ restore() {
104
+ const prior = savedConfig;
105
+ if (prior === null) {
106
+ throw new Error("this log capture was restored without being installed");
107
+ }
108
+ savedConfig = null;
109
+ configureSync({ reset: true, ...prior });
110
+ },
111
+ reset() {
112
+ captured.length = 0;
113
+ // Reject rather than drop: a waiter outstanding from a previous test
114
+ // (its test was killed mid-wait) would otherwise be discarded still
115
+ // armed, and its promise could never settle.
116
+ for (const waiter of waiters.splice(0))
117
+ waiter.abandon();
118
+ },
119
+ records: () => captured.slice(),
120
+ errors: () => captured.filter((r) => r.level === "error").map((r) => r.message),
121
+ waitForRecord: (needle) => waitFor(needle, null),
122
+ waitForError: (needle) => waitFor(needle, "error"),
123
+ };
124
+ }
@@ -0,0 +1,22 @@
1
+ import type { MailBusBindings } from "../supervisor/types.js";
2
+ export type MockMailBus = MailBusBindings & {
3
+ /** Addresses currently registered, in registration order. */
4
+ registered(): readonly string[];
5
+ /**
6
+ * Every registration and unregistration in order, as `register:<address>`
7
+ * and `unregister:<address>`. Distinguishes "never registered" from
8
+ * "registered and then released", which `registered()` cannot.
9
+ */
10
+ registrationHistory(): readonly string[];
11
+ /** Deliver a raw message to every handler subscribed for `address`. */
12
+ deliver(address: string, message: Uint8Array): void;
13
+ /**
14
+ * Resolve once `address` is registered, whether it already is or is
15
+ * registered later.
16
+ *
17
+ * The registration is the event a test waiting for a deployment to come up
18
+ * actually wants; polling `registered()` on a timer was standing in for it.
19
+ */
20
+ awaitRegistered(address: string): Promise<void>;
21
+ };
22
+ export declare function createMockMailBus(): MockMailBus;
@@ -0,0 +1,78 @@
1
+ // In-memory MailBusBindings, for supervisor tests that need mail delivered to
2
+ // a deployment address without a real bus.
3
+ //
4
+ // Seven copies of this had accumulated in five variants. Beyond cosmetics
5
+ // they differed in what they let a test observe: one kept a register/
6
+ // unregister history, one kept only the current set. Both observables are
7
+ // here, because a test asking "is it registered now" and one asking "was it
8
+ // ever unregistered" are asking different questions and the second cannot be
9
+ // answered from the first.
10
+ export function createMockMailBus() {
11
+ const registered = [];
12
+ const history = [];
13
+ const subscribers = new Map();
14
+ let waiters = [];
15
+ function announce() {
16
+ const waiting = waiters;
17
+ waiters = [];
18
+ for (const waiter of waiting)
19
+ waiter();
20
+ }
21
+ return {
22
+ registerAddress(address) {
23
+ registered.push(address);
24
+ history.push(`register:${address}`);
25
+ announce();
26
+ },
27
+ unregisterAddress(address) {
28
+ const idx = registered.lastIndexOf(address);
29
+ if (idx >= 0)
30
+ registered.splice(idx, 1);
31
+ subscribers.delete(address);
32
+ history.push(`unregister:${address}`);
33
+ // Every mutation of the registration set is announced, which is what
34
+ // makes an arbitrary predicate over that set awaitable: a waiter re-reads
35
+ // on any change rather than on the subset of changes someone remembered
36
+ // to report. `awaitRegistered` is the only waiter today, and an
37
+ // unregister can only falsify its predicate, so this wake settles nothing
38
+ // for it -- a fact about that one predicate, not a gap in the reporting.
39
+ announce();
40
+ },
41
+ subscribeMailForAddress(address, handler) {
42
+ let set = subscribers.get(address);
43
+ if (set === undefined) {
44
+ set = new Set();
45
+ subscribers.set(address, set);
46
+ }
47
+ set.add(handler);
48
+ return () => {
49
+ const current = subscribers.get(address);
50
+ current?.delete(handler);
51
+ };
52
+ },
53
+ sendOutbound() {
54
+ throw new Error("sendOutbound not exercised in this test");
55
+ },
56
+ registered: () => registered.slice(),
57
+ registrationHistory: () => history.slice(),
58
+ deliver(address, message) {
59
+ const set = subscribers.get(address);
60
+ if (set === undefined)
61
+ return;
62
+ for (const handler of set)
63
+ void handler(message).catch(() => undefined);
64
+ },
65
+ async awaitRegistered(address) {
66
+ for (;;) {
67
+ // Re-read on every pass, so an address registered before this call
68
+ // resolves it rather than leaving it waiting for a re-registration.
69
+ const changed = new Promise((resolve) => {
70
+ waiters.push(resolve);
71
+ });
72
+ if (registered.includes(address))
73
+ return;
74
+ await changed;
75
+ }
76
+ },
77
+ };
78
+ }
@@ -0,0 +1,43 @@
1
+ import type { NdjsonReader, NdjsonWriter } from "../ipc/control-channel.js";
2
+ import type { FrameReader, FrameWriter } from "../ipc/event-channel.js";
3
+ export type MemoryNdjsonStream = {
4
+ writer: NdjsonWriter;
5
+ reader: NdjsonReader;
6
+ inject(line: string): void;
7
+ flushed(): readonly string[];
8
+ nextWrite(): Promise<void>;
9
+ /** How many lines the consumer has taken off the buffer and finished with. */
10
+ readCount(): number;
11
+ /**
12
+ * Resolves once the consumer has finished with at least `count` lines.
13
+ *
14
+ * The write report says a line arrived; this says the pump reading the
15
+ * stream is done with it. A test that injects a frame the consumer is
16
+ * supposed to IGNORE needs the second one: without it, the assertion that
17
+ * nothing happened can run before the consumer has even looked.
18
+ *
19
+ * "Finished with" is the consumer asking for the next line, which a
20
+ * `for await` loop does only after its body returns -- so the guarantee is
21
+ * exactly as strong as the loop body is. A body that dispatches work with
22
+ * `void` and returns has finished in this sense while its handler is still
23
+ * in flight, and a consumer that stops iterating never asks again, so its
24
+ * last line stays uncounted.
25
+ *
26
+ * Level-triggered on a count rather than edge-triggered on the next read,
27
+ * so a caller that arms it after the read already happened is not left
28
+ * waiting for another one.
29
+ */
30
+ awaitReadCount(count: number): Promise<void>;
31
+ close(): void;
32
+ };
33
+ export declare function createMemoryNdjsonStream(): MemoryNdjsonStream;
34
+ export type MemoryFrameStream = {
35
+ writer: FrameWriter;
36
+ reader: FrameReader;
37
+ inject(bytes: Uint8Array): void;
38
+ injectRaw(bytes: Uint8Array): void;
39
+ flushed(): readonly Uint8Array[];
40
+ nextWrite(): Promise<void>;
41
+ close(): void;
42
+ };
43
+ export declare function createMemoryFrameStream(): MemoryFrameStream;
@@ -0,0 +1,211 @@
1
+ // In-memory stand-ins for the two IPC transports, for tests that drive a
2
+ // supervisor or a workflow-process child without spawning one.
3
+ //
4
+ // Seventeen copies of these two factories accumulated across this package,
5
+ // `apps/sidecar`, and the deploy tests, in nine variants apiece. The
6
+ // divergences were almost entirely cosmetic, but the copies disagreed on one
7
+ // thing that matters: whether `inject` appends the newline terminator that
8
+ // delimits a wire frame.
9
+ //
10
+ // The two doubles answer that differently, because the two channels carry the
11
+ // terminator differently. The event channel is byte-oriented and its
12
+ // terminator is part of the payload, so `createMemoryFrameStream` separates
13
+ // three roles and a caller says which one it means:
14
+ //
15
+ // `writer` what production writes through; bytes go in verbatim
16
+ // `inject` one complete frame arriving on the wire, terminator supplied
17
+ // `injectRaw` bytes verbatim, for a deliberately malformed wire
18
+ //
19
+ // The control channel is line-oriented: one NDJSON line is one frame, and the
20
+ // terminator is the line break the buffer does not store. So
21
+ // `createMemoryNdjsonStream` has no third role -- `writer.write` and `inject`
22
+ // are the same function -- and it strips one trailing newline, so a caller
23
+ // that spells the terminator and one that omits it buffer the same line. A
24
+ // malformed control frame is spelled as a malformed line, which `inject`
25
+ // passes through unchanged apart from that newline, so there is no separate
26
+ // raw role for a caller to reach for.
27
+ //
28
+ // A reader supports ONE `read()` iteration. Both channels are single-consumer
29
+ // by construction -- the supervisor starts one pump per stream per child --
30
+ // and the buffer is drained by whoever iterates, so a second iteration would
31
+ // silently steal frames from the first. `read()` enforces that rather than
32
+ // trusting it, because the way it gets violated is invisible: a spawner
33
+ // double that hands the same streams to a respawned child re-reads them, and
34
+ // the second iteration returns immediately off the closed stream instead of
35
+ // receiving the new child's frames. The child then looks like it died on its
36
+ // handshake, which is indistinguishable from the failure such a test is
37
+ // usually written to examine.
38
+ function announce(observers) {
39
+ const waiting = observers.list;
40
+ observers.list = [];
41
+ for (const observer of waiting)
42
+ observer();
43
+ }
44
+ /**
45
+ * Resolves on the next item to reach the buffer, from either direction.
46
+ *
47
+ * Edge-triggered: it does not see an item already buffered. A caller reads
48
+ * `flushed`, arms this, and re-checks in a loop, with the read and the arm in
49
+ * ONE synchronous block -- an `await` between them is what loses an item.
50
+ * Which of the two comes first inside that block decides nothing, because
51
+ * nothing can land between two synchronous statements. Both orders are in use
52
+ * here (`waitForUpstreamPayload` arms first, the park-notify waits in
53
+ * `apps/sidecar` read first) and both are safe for that reason.
54
+ */
55
+ function nextWriteOf(observers) {
56
+ return new Promise((resolve) => {
57
+ observers.list.push(resolve);
58
+ });
59
+ }
60
+ export function createMemoryNdjsonStream() {
61
+ const buffer = [];
62
+ const observers = { list: [] };
63
+ const readObservers = { list: [] };
64
+ let reads = 0;
65
+ let waiter = null;
66
+ let done = false;
67
+ let reading = false;
68
+ function wake() {
69
+ const w = waiter;
70
+ waiter = null;
71
+ if (w)
72
+ w();
73
+ }
74
+ function push(line) {
75
+ buffer.push(line.replace(/\n$/, ""));
76
+ wake();
77
+ announce(observers);
78
+ }
79
+ const reader = {
80
+ read() {
81
+ if (reading) {
82
+ throw new Error("createMemoryNdjsonStream: a second read() on one stream; hand each spawn its own streams, as a real spawn does");
83
+ }
84
+ reading = true;
85
+ return (async function* () {
86
+ let handedOut = false;
87
+ for (;;) {
88
+ if (handedOut) {
89
+ // Counted here, on re-entry, rather than beside the `yield`. A
90
+ // `for await` loop calls `next()` only after its body has run to
91
+ // completion, so re-entry is the point at which the previous line
92
+ // is finished with. Counting at the `yield` instead resolves a
93
+ // waiter whose continuation is queued AHEAD of the consumer's, so
94
+ // the one assertion the count exists for -- that a frame was
95
+ // ignored -- would run before the consumer had looked at it.
96
+ handedOut = false;
97
+ reads += 1;
98
+ announce(readObservers);
99
+ }
100
+ if (buffer.length > 0) {
101
+ const next = buffer.shift();
102
+ if (next === undefined) {
103
+ throw new Error("buffer shift returned undefined");
104
+ }
105
+ handedOut = true;
106
+ yield next;
107
+ continue;
108
+ }
109
+ if (done)
110
+ return;
111
+ await new Promise((resolve) => {
112
+ waiter = resolve;
113
+ });
114
+ }
115
+ })();
116
+ },
117
+ };
118
+ return {
119
+ writer: { write: push },
120
+ reader,
121
+ inject: push,
122
+ flushed: () => buffer.slice(),
123
+ nextWrite: () => nextWriteOf(observers),
124
+ readCount: () => reads,
125
+ async awaitReadCount(count) {
126
+ for (;;) {
127
+ // Re-checked on every pass: level-triggered on the count, so a read
128
+ // that happened before this call resolves it. An edge wait on the next
129
+ // read deadlocks here, because the consumer usually reads an injected
130
+ // frame before a test can ask to be told about it.
131
+ const read = nextWriteOf(readObservers);
132
+ if (reads >= count)
133
+ return;
134
+ await read;
135
+ }
136
+ },
137
+ close() {
138
+ done = true;
139
+ wake();
140
+ // A closed stream takes nothing further, so release anyone still
141
+ // watching rather than leaving them parked on a write that cannot come.
142
+ announce(observers);
143
+ announce(readObservers);
144
+ },
145
+ };
146
+ }
147
+ export function createMemoryFrameStream() {
148
+ const buffer = [];
149
+ const observers = { list: [] };
150
+ let waiter = null;
151
+ let done = false;
152
+ let reading = false;
153
+ function wake() {
154
+ const w = waiter;
155
+ waiter = null;
156
+ if (w)
157
+ w();
158
+ }
159
+ function pushRaw(bytes) {
160
+ buffer.push(bytes);
161
+ wake();
162
+ announce(observers);
163
+ }
164
+ const reader = {
165
+ read() {
166
+ if (reading) {
167
+ throw new Error("createMemoryFrameStream: a second read() on one stream; hand each spawn its own streams, as a real spawn does");
168
+ }
169
+ reading = true;
170
+ return (async function* () {
171
+ for (;;) {
172
+ if (buffer.length > 0) {
173
+ const next = buffer.shift();
174
+ if (next === undefined) {
175
+ throw new Error("frame buffer shift returned undefined");
176
+ }
177
+ yield next;
178
+ continue;
179
+ }
180
+ if (done)
181
+ return;
182
+ await new Promise((resolve) => {
183
+ waiter = resolve;
184
+ });
185
+ }
186
+ })();
187
+ },
188
+ };
189
+ return {
190
+ writer: { write: pushRaw },
191
+ reader,
192
+ inject(bytes) {
193
+ // The event channel is newline-delimited: its sender terminates every
194
+ // frame, and the receiver splits on that terminator. A caller passing
195
+ // envelope bytes is describing one frame, so the terminator belongs
196
+ // here rather than at each call site.
197
+ const framed = new Uint8Array(bytes.length + 1);
198
+ framed.set(bytes, 0);
199
+ framed[bytes.length] = 0x0a;
200
+ pushRaw(framed);
201
+ },
202
+ injectRaw: pushRaw,
203
+ flushed: () => buffer.slice(),
204
+ nextWrite: () => nextWriteOf(observers),
205
+ close() {
206
+ done = true;
207
+ wake();
208
+ announce(observers);
209
+ },
210
+ };
211
+ }
@@ -0,0 +1,12 @@
1
+ export type SpawnObserver = {
2
+ /** Call from inside the spawner double with the env it received. */
3
+ record(env: Record<string, string>): void;
4
+ /**
5
+ * Resolve with the first recorded env, whether the spawn has happened yet
6
+ * or not. A respawn records again; this always resolves with the first.
7
+ */
8
+ first(): Promise<Record<string, string>>;
9
+ /** Every env recorded so far, in spawn order. */
10
+ all(): readonly Record<string, string>[];
11
+ };
12
+ export declare function createSpawnObserver(): SpawnObserver;
@@ -0,0 +1,36 @@
1
+ // Records the environment each spawn was invoked with, and lets a caller wait
2
+ // for the first one.
3
+ //
4
+ // A test that needs the spawn-time env -- almost always to read the IPC
5
+ // channel id and mint a child-side sender -- has to wait for the spawner to
6
+ // be called. Twenty-two sites did that by assigning a mutable binding inside
7
+ // the spawner and re-reading it on a one-millisecond timer until it stopped
8
+ // being undefined. The spawner being called IS the event, so it is reported
9
+ // rather than inferred.
10
+ export function createSpawnObserver() {
11
+ const envs = [];
12
+ let waiters = [];
13
+ return {
14
+ record(env) {
15
+ envs.push(env);
16
+ const waiting = waiters;
17
+ waiters = [];
18
+ for (const waiter of waiting)
19
+ waiter();
20
+ },
21
+ async first() {
22
+ for (;;) {
23
+ // Re-read on every pass, so a spawn that happened before this call
24
+ // resolves it rather than leaving it waiting for another one.
25
+ const spawned = new Promise((resolve) => {
26
+ waiters.push(resolve);
27
+ });
28
+ const head = envs[0];
29
+ if (head !== undefined)
30
+ return head;
31
+ await spawned;
32
+ }
33
+ },
34
+ all: () => envs.slice(),
35
+ };
36
+ }
@@ -0,0 +1,10 @@
1
+ import type { RepoStore } from "@intx/hub-sessions";
2
+ export type StubRepoStoreOpts = {
3
+ /**
4
+ * Permit `writeTreePreservingPrefix`, returning a fixed commit and no
5
+ * newly-terminal runs. Off by default, so a test that does not ask for it
6
+ * fails on the call rather than accepting it.
7
+ */
8
+ writeTree?: boolean;
9
+ };
10
+ export declare function createStubRepoStore(baseDir: string, opts?: StubRepoStoreOpts): RepoStore;
@@ -0,0 +1,39 @@
1
+ // A RepoStore that implements only what a test declares it needs, and throws
2
+ // for everything else.
3
+ //
4
+ // The throw is the point. These stubs assert, by failing, that the code under
5
+ // them touches nothing beyond the named surface -- so the narrow ones are not
6
+ // an incomplete version of the broad ones, and substituting a working store
7
+ // would silently permit paths that currently fail loudly.
8
+ //
9
+ // Only the two capability sets that were byte-identical across several files
10
+ // live here. The rest of this repo's stub stores are genuinely different
11
+ // fidelities, up to a 218-line in-memory implementation, and merging those
12
+ // would trade a real assertion for a smaller diff.
13
+ import path from "node:path";
14
+ export function createStubRepoStore(baseDir, opts = {}) {
15
+ const stub = {
16
+ getRepoDir(repoId) {
17
+ return path.join(baseDir, repoId.kind, repoId.id);
18
+ },
19
+ ...(opts.writeTree === true
20
+ ? {
21
+ writeTreePreservingPrefix: async () => ({
22
+ commitSha: "deadbeefcafef00d",
23
+ newlyTerminalRuns: [],
24
+ }),
25
+ }
26
+ : {}),
27
+ };
28
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- test stub; every method the opts did not enable surfaces a precise failure via the proxy
29
+ return new Proxy(stub, {
30
+ get(target, prop, receiver) {
31
+ const value = Reflect.get(target, prop, receiver);
32
+ if (value !== undefined)
33
+ return value;
34
+ return () => {
35
+ throw new Error(`stub RepoStore: ${String(prop)} not implemented for this test`);
36
+ };
37
+ },
38
+ });
39
+ }
@@ -0,0 +1,24 @@
1
+ /** The part of a supervisor a reaper needs. */
2
+ export type ReapableSupervisor = {
3
+ shutdown(): Promise<void>;
4
+ };
5
+ export type SupervisorReaper = {
6
+ /** Register a supervisor and return it, for use at the construction site. */
7
+ track<T extends ReapableSupervisor>(supervisor: T): T;
8
+ /**
9
+ * Shut down every supervisor registered since the last call. Pass straight
10
+ * to `afterEach`.
11
+ *
12
+ * The teardowns overlap: every `shutdown()` is called before any is awaited,
13
+ * so they settle in whatever order they finish rather than registration
14
+ * order. Nothing here sequences one supervisor's teardown after another's.
15
+ *
16
+ * `shutdown` early-returns once the phase is terminal. That guard does not
17
+ * cover `stopping`, so a teardown already in flight is re-entered here --
18
+ * harmlessly, because every branch of the teardown is guarded on the prior
19
+ * phase and a second pass through `stopping` does no work. Either way the
20
+ * tests that tear themselves down are unaffected.
21
+ */
22
+ reap(): Promise<void>;
23
+ };
24
+ export declare function createSupervisorReaper(): SupervisorReaper;
@@ -0,0 +1,49 @@
1
+ // Per-file registry of the supervisors a test built, so a lifecycle hook can
2
+ // tear them down whatever way the test ended.
3
+ //
4
+ // A supervisor is normally torn down by a `shutdown()` at the end of the test
5
+ // that built it, which an earlier failed assertion skips -- and some tests
6
+ // never had one. What that abandons is not inert: a dispatch suspended in
7
+ // `waitForRunTerminalOrPark` holds that function's armed five-minute
8
+ // backstop, whose clearing `finally` cannot run while the race it guards is
9
+ // pending. Aborting the cohort is what settles that race, so reaping is what
10
+ // disarms the timer. The unit pass shares a module registry per worker, so an
11
+ // un-reaped supervisor outlives its file and any failure it causes is charged
12
+ // to an unrelated test.
13
+ //
14
+ // The registry is per-call rather than module-level: two test files importing
15
+ // one shared array would reap each other's supervisors if their lifecycles
16
+ // ever overlapped.
17
+ export function createSupervisorReaper() {
18
+ const live = [];
19
+ return {
20
+ track(supervisor) {
21
+ live.push(supervisor);
22
+ return supervisor;
23
+ },
24
+ async reap() {
25
+ // Every `shutdown()` is CALLED before any of them is awaited, so no
26
+ // supervisor's teardown waits on its neighbours': one that REJECTS and
27
+ // one that never SETTLES both leave the rest to run to completion.
28
+ // Awaiting them one at a time would not -- a teardown that never
29
+ // settled would mean the supervisors behind it never started theirs,
30
+ // each keeping its child and its armed backstop.
31
+ //
32
+ // A non-settling `shutdown()` still blocks this function's own
33
+ // resolution, because `allSettled` waits for that one too -- so the
34
+ // `afterEach` calling it does not complete, and the lane timeout is what
35
+ // fails the file. Abandoning the pending one would take a deadline,
36
+ // which "Synchronizing on State, Not Time" in CONVENTIONS.md rules out,
37
+ // and `shutdown()` offers no other signal to race. What this covers is
38
+ // the leak, not the hang.
39
+ //
40
+ // `shutdown` is documented as total, so a throw is a defect and fails
41
+ // the test rather than landing in a log nobody reads.
42
+ const settled = await Promise.allSettled(live.splice(0).map((supervisor) => supervisor.shutdown()));
43
+ const failures = settled.flatMap((result) => result.status === "rejected" ? [result.reason] : []);
44
+ if (failures.length > 0) {
45
+ throw new AggregateError(failures, "supervisor teardown threw");
46
+ }
47
+ },
48
+ };
49
+ }