@intx/workflow-host 0.3.0 → 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 (78) hide show
  1. package/README.md +21 -4
  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.js +5 -14
  5. package/dist/adapters/spawn-child.d.ts +42 -6
  6. package/dist/adapters/spawn-child.js +8 -18
  7. package/dist/adapters/step-invoker.d.ts +52 -2
  8. package/dist/adapters/step-invoker.js +230 -60
  9. package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
  10. package/dist/adapters/substrate-mailbox-store.js +404 -0
  11. package/dist/child/child-mailbox-reader.d.ts +10 -0
  12. package/dist/child/child-mailbox-reader.js +23 -0
  13. package/dist/child/credential-cell.d.ts +8 -0
  14. package/dist/child/credential-cell.js +66 -0
  15. package/dist/child/from-process-env.d.ts +12 -0
  16. package/dist/child/from-process-env.js +6 -0
  17. package/dist/child/index.d.ts +4 -1
  18. package/dist/child/index.js +4 -1
  19. package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
  20. package/dist/child/mailbox-mutation-bridge.js +101 -0
  21. package/dist/child/mailbox-watch-registry.d.ts +17 -0
  22. package/dist/child/mailbox-watch-registry.js +61 -0
  23. package/dist/child/outbound-mail-bridge.d.ts +3 -2
  24. package/dist/child/outbound-mail-bridge.js +20 -32
  25. package/dist/child/pending-request.d.ts +89 -0
  26. package/dist/child/pending-request.js +80 -0
  27. package/dist/child/run-child.d.ts +69 -7
  28. package/dist/child/run-child.js +307 -75
  29. package/dist/child/substrate-write-bridge.d.ts +3 -2
  30. package/dist/child/substrate-write-bridge.js +21 -38
  31. package/dist/child/supervisor-backed-transport.d.ts +52 -6
  32. package/dist/child/supervisor-backed-transport.js +205 -62
  33. package/dist/child/warm-agent-cache.d.ts +44 -4
  34. package/dist/child/warm-agent-cache.js +41 -10
  35. package/dist/index.d.ts +4 -3
  36. package/dist/index.js +4 -3
  37. package/dist/ipc/control-channel.d.ts +93 -2
  38. package/dist/ipc/control-channel.js +147 -47
  39. package/dist/ipc/index.d.ts +1 -1
  40. package/dist/ipc/index.js +1 -1
  41. package/dist/run-body-then-cleanup.d.ts +17 -0
  42. package/dist/run-body-then-cleanup.js +38 -0
  43. package/dist/seams/scheduler.d.ts +12 -0
  44. package/dist/seams/scheduler.js +13 -4
  45. package/dist/supervisor/cancel-signing.js +3 -7
  46. package/dist/supervisor/credentials.d.ts +17 -5
  47. package/dist/supervisor/recycle.d.ts +5 -1
  48. package/dist/supervisor/run-event-compaction.d.ts +2 -2
  49. package/dist/supervisor/run-event-compaction.js +11 -16
  50. package/dist/supervisor/run-event-recovery.d.ts +34 -0
  51. package/dist/supervisor/run-event-recovery.js +45 -0
  52. package/dist/supervisor/supervisor.d.ts +27 -4
  53. package/dist/supervisor/supervisor.js +644 -58
  54. package/dist/supervisor/terminal-commit.js +3 -7
  55. package/dist/supervisor/types.d.ts +30 -0
  56. package/dist/testing/change-notifier.d.ts +12 -0
  57. package/dist/testing/change-notifier.js +63 -0
  58. package/dist/testing/index.d.ts +8 -0
  59. package/dist/testing/index.js +16 -0
  60. package/dist/testing/log-capture.d.ts +52 -0
  61. package/dist/testing/log-capture.js +124 -0
  62. package/dist/testing/mail-bus.d.ts +22 -0
  63. package/dist/testing/mail-bus.js +78 -0
  64. package/dist/testing/memory-streams.d.ts +43 -0
  65. package/dist/testing/memory-streams.js +211 -0
  66. package/dist/testing/spawn-observer.d.ts +12 -0
  67. package/dist/testing/spawn-observer.js +36 -0
  68. package/dist/testing/stub-repo-store.d.ts +10 -0
  69. package/dist/testing/stub-repo-store.js +39 -0
  70. package/dist/testing/supervisor-reaper.d.ts +24 -0
  71. package/dist/testing/supervisor-reaper.js +49 -0
  72. package/dist/testing/upstream-frames.d.ts +47 -0
  73. package/dist/testing/upstream-frames.js +94 -0
  74. package/dist/workflow-definition-loader.d.ts +56 -0
  75. package/dist/workflow-definition-loader.js +106 -0
  76. package/package.json +17 -11
  77. package/dist/conversation-text.d.ts +0 -23
  78. package/dist/conversation-text.js +0 -56
@@ -17,12 +17,12 @@
17
17
  // anchorRunId`); terminal events have no per-type authorship check.
18
18
  import { type } from "arktype";
19
19
  import { getLogger } from "@intx/log";
20
+ import { parseEventSeq } from "@intx/hub-sessions/substrate";
20
21
  import { workflowEventToOnDisk } from "../adapters/repo-store.js";
21
22
  const logger = getLogger(["workflow-host", "supervisor", "terminal-commit"]);
22
23
  /** Path layout inside the workflow-run repo: `runs/<runId>/events/<seq>.json`. */
23
24
  const RUNS_PREFIX = "runs";
24
25
  const EVENTS_DIR = "events";
25
- const EVENT_FILENAME_RE = /^(0|[1-9][0-9]*)\.json$/;
26
26
  /**
27
27
  * Terminal run-event kinds, mirroring the runtime's terminal vocabulary
28
28
  * (`RunCompleted`/`RunFailed`/`RunCancelled`). Inlined because the workflow
@@ -72,13 +72,9 @@ export async function commitRunFailed(opts) {
72
72
  let maxPath = null;
73
73
  for (const filepath of existing.keys()) {
74
74
  const name = filepath.slice(prefix.length);
75
- const match = EVENT_FILENAME_RE.exec(name);
76
- if (match === null)
75
+ const seq = parseEventSeq(name);
76
+ if (seq === null)
77
77
  continue;
78
- const seqStr = match[1];
79
- if (seqStr === undefined)
80
- continue;
81
- const seq = Number.parseInt(seqStr, 10);
82
78
  if (seq > maxSeq) {
83
79
  maxSeq = seq;
84
80
  maxPath = filepath;
@@ -244,6 +244,36 @@ export interface WorkflowSupervisorBindings {
244
244
  * or abort a re-emit partway through the parked set.
245
245
  */
246
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;
247
277
  /**
248
278
  * Per-run grants source the dispatch loop consults before it forwards a
249
279
  * `trigger.fire`. Unlike `onSuspensionRegister` (best-effort, fire-and-
@@ -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;
@@ -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;