@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.
- package/README.md +21 -4
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.js +5 -14
- package/dist/adapters/spawn-child.d.ts +42 -6
- package/dist/adapters/spawn-child.js +8 -18
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +230 -60
- package/dist/adapters/substrate-mailbox-store.d.ts +80 -0
- package/dist/adapters/substrate-mailbox-store.js +404 -0
- package/dist/child/child-mailbox-reader.d.ts +10 -0
- package/dist/child/child-mailbox-reader.js +23 -0
- package/dist/child/credential-cell.d.ts +8 -0
- package/dist/child/credential-cell.js +66 -0
- package/dist/child/from-process-env.d.ts +12 -0
- package/dist/child/from-process-env.js +6 -0
- package/dist/child/index.d.ts +4 -1
- package/dist/child/index.js +4 -1
- package/dist/child/mailbox-mutation-bridge.d.ts +61 -0
- package/dist/child/mailbox-mutation-bridge.js +101 -0
- package/dist/child/mailbox-watch-registry.d.ts +17 -0
- package/dist/child/mailbox-watch-registry.js +61 -0
- package/dist/child/outbound-mail-bridge.d.ts +3 -2
- package/dist/child/outbound-mail-bridge.js +20 -32
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/run-child.d.ts +69 -7
- package/dist/child/run-child.js +307 -75
- package/dist/child/substrate-write-bridge.d.ts +3 -2
- package/dist/child/substrate-write-bridge.js +21 -38
- package/dist/child/supervisor-backed-transport.d.ts +52 -6
- package/dist/child/supervisor-backed-transport.js +205 -62
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +4 -3
- package/dist/index.js +4 -3
- package/dist/ipc/control-channel.d.ts +93 -2
- package/dist/ipc/control-channel.js +147 -47
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/run-body-then-cleanup.d.ts +17 -0
- package/dist/run-body-then-cleanup.js +38 -0
- package/dist/seams/scheduler.d.ts +12 -0
- package/dist/seams/scheduler.js +13 -4
- package/dist/supervisor/cancel-signing.js +3 -7
- package/dist/supervisor/credentials.d.ts +17 -5
- package/dist/supervisor/recycle.d.ts +5 -1
- package/dist/supervisor/run-event-compaction.d.ts +2 -2
- package/dist/supervisor/run-event-compaction.js +11 -16
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/supervisor.d.ts +27 -4
- package/dist/supervisor/supervisor.js +644 -58
- package/dist/supervisor/terminal-commit.js +3 -7
- package/dist/supervisor/types.d.ts +30 -0
- package/dist/testing/change-notifier.d.ts +12 -0
- package/dist/testing/change-notifier.js +63 -0
- package/dist/testing/index.d.ts +8 -0
- package/dist/testing/index.js +16 -0
- package/dist/testing/log-capture.d.ts +52 -0
- package/dist/testing/log-capture.js +124 -0
- package/dist/testing/mail-bus.d.ts +22 -0
- package/dist/testing/mail-bus.js +78 -0
- package/dist/testing/memory-streams.d.ts +43 -0
- package/dist/testing/memory-streams.js +211 -0
- package/dist/testing/spawn-observer.d.ts +12 -0
- package/dist/testing/spawn-observer.js +36 -0
- package/dist/testing/stub-repo-store.d.ts +10 -0
- package/dist/testing/stub-repo-store.js +39 -0
- package/dist/testing/supervisor-reaper.d.ts +24 -0
- package/dist/testing/supervisor-reaper.js +49 -0
- package/dist/testing/upstream-frames.d.ts +47 -0
- package/dist/testing/upstream-frames.js +94 -0
- package/dist/workflow-definition-loader.d.ts +56 -0
- package/dist/workflow-definition-loader.js +106 -0
- package/package.json +17 -11
- package/dist/conversation-text.d.ts +0 -23
- 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
|
|
76
|
-
if (
|
|
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;
|