@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
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { ControlPayload } from "../ipc/control-channel.js";
|
|
2
|
+
/**
|
|
3
|
+
* Decode every payload of `type_` from `lines`, in arrival order.
|
|
4
|
+
*
|
|
5
|
+
* Frames that fail envelope or payload validation are skipped, so a test
|
|
6
|
+
* asserting on one payload kind is not fooled by an unrelated frame sharing
|
|
7
|
+
* the stream.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readPayloadsOfType<T extends string>(lines: readonly string[], type_: T): Extract<typeof ControlPayload.infer, {
|
|
10
|
+
type: T;
|
|
11
|
+
}>[];
|
|
12
|
+
/** The runIds carried on every `trigger.fire` frame in `lines`, in order. */
|
|
13
|
+
export declare function parseTriggerFireRunIds(lines: readonly string[]): string[];
|
|
14
|
+
/** The part of a stream double this module reads. */
|
|
15
|
+
export type UpstreamFrameSource = {
|
|
16
|
+
flushed(): readonly string[];
|
|
17
|
+
nextWrite(): Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resolve with the first payload of `type_` that `match` accepts, whether it
|
|
21
|
+
* is already buffered or arrives later.
|
|
22
|
+
*
|
|
23
|
+
* The buffer is re-read only when a line actually arrives, and the wait
|
|
24
|
+
* carries no deadline: a frame that never comes is caught by the lane
|
|
25
|
+
* timeout, per "Synchronizing on State, Not Time" in CONVENTIONS.md.
|
|
26
|
+
*/
|
|
27
|
+
export declare function waitForUpstreamPayload<T extends string>(stream: UpstreamFrameSource, type_: T, match?: (payload: Extract<typeof ControlPayload.infer, {
|
|
28
|
+
type: T;
|
|
29
|
+
}>) => boolean): Promise<Extract<typeof ControlPayload.infer, {
|
|
30
|
+
type: T;
|
|
31
|
+
}>>;
|
|
32
|
+
/**
|
|
33
|
+
* Resolve once at least `count` payloads of `type_` have been written, with
|
|
34
|
+
* every matching payload in arrival order.
|
|
35
|
+
*
|
|
36
|
+
* The count form exists because most waits here are for the Nth frame rather
|
|
37
|
+
* than for a particular one; a caller wanting a specific frame should use
|
|
38
|
+
* `waitForUpstreamPayload` with a predicate.
|
|
39
|
+
*/
|
|
40
|
+
export declare function waitForUpstreamPayloads<T extends string>(stream: UpstreamFrameSource, type_: T, count?: number): Promise<Extract<typeof ControlPayload.infer, {
|
|
41
|
+
type: T;
|
|
42
|
+
}>[]>;
|
|
43
|
+
/**
|
|
44
|
+
* Resolve once at least `count` `trigger.fire` frames have been written, with
|
|
45
|
+
* every runId seen in order.
|
|
46
|
+
*/
|
|
47
|
+
export declare function waitForTriggerFireRunIds(stream: UpstreamFrameSource, count: number): Promise<string[]>;
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Reading and awaiting the control frames a supervisor writes upstream.
|
|
2
|
+
//
|
|
3
|
+
// A test that drives a supervisor asserts on the frames it emitted, and has
|
|
4
|
+
// to wait for the one it cares about first. That wait was written as a
|
|
5
|
+
// deadline plus a one-millisecond tick that re-decoded the whole buffer --
|
|
6
|
+
// two wall-clock numbers deciding a run whose subject is what a frame
|
|
7
|
+
// CONTAINS, never how quickly it appears. Waiting on the write removes the
|
|
8
|
+
// window instead of widening it.
|
|
9
|
+
import { type } from "arktype";
|
|
10
|
+
import { ControlPayload } from "../ipc/control-channel.js";
|
|
11
|
+
import { SignedEnvelope } from "../ipc/envelope.js";
|
|
12
|
+
/**
|
|
13
|
+
* Decode every payload of `type_` from `lines`, in arrival order.
|
|
14
|
+
*
|
|
15
|
+
* Frames that fail envelope or payload validation are skipped, so a test
|
|
16
|
+
* asserting on one payload kind is not fooled by an unrelated frame sharing
|
|
17
|
+
* the stream.
|
|
18
|
+
*/
|
|
19
|
+
export function readPayloadsOfType(lines, type_) {
|
|
20
|
+
const out = [];
|
|
21
|
+
for (const line of lines) {
|
|
22
|
+
let raw;
|
|
23
|
+
try {
|
|
24
|
+
raw = JSON.parse(line);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
const signed = SignedEnvelope(raw);
|
|
30
|
+
if (signed instanceof type.errors)
|
|
31
|
+
continue;
|
|
32
|
+
const payload = ControlPayload(signed.envelope.payload);
|
|
33
|
+
if (payload instanceof type.errors)
|
|
34
|
+
continue;
|
|
35
|
+
if (payload.type !== type_)
|
|
36
|
+
continue;
|
|
37
|
+
// eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion -- the arktype narrow above pins the discriminator; the cast walks the union to the matching branch
|
|
38
|
+
out.push(payload);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
}
|
|
42
|
+
/** The runIds carried on every `trigger.fire` frame in `lines`, in order. */
|
|
43
|
+
export function parseTriggerFireRunIds(lines) {
|
|
44
|
+
return readPayloadsOfType(lines, "trigger.fire").map((p) => p.data.runId);
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Resolve with the first payload of `type_` that `match` accepts, whether it
|
|
48
|
+
* is already buffered or arrives later.
|
|
49
|
+
*
|
|
50
|
+
* The buffer is re-read only when a line actually arrives, and the wait
|
|
51
|
+
* carries no deadline: a frame that never comes is caught by the lane
|
|
52
|
+
* timeout, per "Synchronizing on State, Not Time" in CONVENTIONS.md.
|
|
53
|
+
*/
|
|
54
|
+
export async function waitForUpstreamPayload(stream, type_, match = () => true) {
|
|
55
|
+
for (;;) {
|
|
56
|
+
// Re-read on every pass, so a frame already in the buffer resolves this
|
|
57
|
+
// rather than leaving it waiting for the next write.
|
|
58
|
+
const arrived = stream.nextWrite();
|
|
59
|
+
const found = readPayloadsOfType(stream.flushed(), type_).find(match);
|
|
60
|
+
if (found !== undefined)
|
|
61
|
+
return found;
|
|
62
|
+
await arrived;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* Resolve once at least `count` payloads of `type_` have been written, with
|
|
67
|
+
* every matching payload in arrival order.
|
|
68
|
+
*
|
|
69
|
+
* The count form exists because most waits here are for the Nth frame rather
|
|
70
|
+
* than for a particular one; a caller wanting a specific frame should use
|
|
71
|
+
* `waitForUpstreamPayload` with a predicate.
|
|
72
|
+
*/
|
|
73
|
+
export async function waitForUpstreamPayloads(stream, type_, count = 1) {
|
|
74
|
+
for (;;) {
|
|
75
|
+
const arrived = stream.nextWrite();
|
|
76
|
+
const found = readPayloadsOfType(stream.flushed(), type_);
|
|
77
|
+
if (found.length >= count)
|
|
78
|
+
return found;
|
|
79
|
+
await arrived;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Resolve once at least `count` `trigger.fire` frames have been written, with
|
|
84
|
+
* every runId seen in order.
|
|
85
|
+
*/
|
|
86
|
+
export async function waitForTriggerFireRunIds(stream, count) {
|
|
87
|
+
for (;;) {
|
|
88
|
+
const arrived = stream.nextWrite();
|
|
89
|
+
const ids = parseTriggerFireRunIds(stream.flushed());
|
|
90
|
+
if (ids.length >= count)
|
|
91
|
+
return ids;
|
|
92
|
+
await arrived;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type AnnotatedPluginFactory, type DirectorRegistry, type ToolDeclaration } from "@intx/agent";
|
|
2
2
|
import type { WorkflowDefinition } from "@intx/workflow/definition";
|
|
3
|
+
import type { ActionHandler, LoopFnRegistry } from "@intx/workflow";
|
|
3
4
|
export interface LoadWorkflowDefinitionFromClosureArgs {
|
|
4
5
|
/**
|
|
5
6
|
* Directory of the materialized workflow package within the closure:
|
|
@@ -75,6 +76,61 @@ export interface LoadWorkflowDirectorRegistryFromClosureArgs {
|
|
|
75
76
|
* cannot be imported, or it exports no `AnnotatedDirectorFactory` value
|
|
76
77
|
*/
|
|
77
78
|
export declare function loadWorkflowDirectorRegistryFromClosure(args: LoadWorkflowDirectorRegistryFromClosureArgs): Promise<DirectorRegistry>;
|
|
79
|
+
export interface LoadWorkflowLoopFnsFromClosureArgs {
|
|
80
|
+
/**
|
|
81
|
+
* Directory of the materialized workflow package within the closure --
|
|
82
|
+
* the same directory `loadWorkflowDefinitionFromClosure` reads.
|
|
83
|
+
*/
|
|
84
|
+
readonly packageDir: string;
|
|
85
|
+
/** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */
|
|
86
|
+
readonly importCacheKey?: string;
|
|
87
|
+
/** Test seam for dynamic import; see the definition loader's variant. */
|
|
88
|
+
readonly importModule?: (importUrl: string) => Promise<unknown>;
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Compose the `LoopFnRegistry` for a workflow closure from the closure
|
|
92
|
+
* package's OWN `interchange.loops` module. A `loop` primitive's `while` and
|
|
93
|
+
* `carry` refs resolve by EXPORT NAME against that module's exports.
|
|
94
|
+
*
|
|
95
|
+
* Unlike directors there is NO built-in default: a package with no
|
|
96
|
+
* `interchange.loops` field composes to an EMPTY registry that throws on any
|
|
97
|
+
* ref lookup. A workflow that declares a `loop` but ships no loops module thus
|
|
98
|
+
* fails closed when its refs are resolved (eagerly, at establish); a workflow
|
|
99
|
+
* with no `loop` primitive never resolves a ref, so an absent field is valid
|
|
100
|
+
* there. Loading OUTSIDE the definition-hash re-verify is safe: the approved
|
|
101
|
+
* hash pins each ref string, and the closure's SRI pins the module bytes.
|
|
102
|
+
*
|
|
103
|
+
* @throws (from the returned registry) if a requested ref names no export, or
|
|
104
|
+
* names an export that is not a function.
|
|
105
|
+
* @throws if the loops entry path escapes the package or cannot be imported.
|
|
106
|
+
*/
|
|
107
|
+
export declare function loadWorkflowLoopFnsFromClosure(args: LoadWorkflowLoopFnsFromClosureArgs): Promise<LoopFnRegistry>;
|
|
108
|
+
export interface LoadWorkflowActionHandlersFromClosureArgs {
|
|
109
|
+
/** Directory of the materialized workflow package within the closure. */
|
|
110
|
+
readonly packageDir: string;
|
|
111
|
+
/** See `LoadWorkflowDefinitionFromClosureArgs.importCacheKey`. */
|
|
112
|
+
readonly importCacheKey?: string;
|
|
113
|
+
/** Test seam for dynamic import; see the definition loader's variant. */
|
|
114
|
+
readonly importModule?: (importUrl: string) => Promise<unknown>;
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Compose the action-handler resolver for a workflow closure from the closure
|
|
118
|
+
* package's OWN `interchange.actions` module. An `action` primitive's `handler`
|
|
119
|
+
* ref resolves by EXPORT NAME against that module's exports.
|
|
120
|
+
*
|
|
121
|
+
* Mirrors {@link loadWorkflowLoopFnsFromClosure}: there is NO built-in default,
|
|
122
|
+
* so a package with no `interchange.actions` field composes to a resolver that
|
|
123
|
+
* throws on any lookup. A workflow that declares an `action` but ships no
|
|
124
|
+
* actions module fails closed when its handler is resolved (eagerly, at
|
|
125
|
+
* establish); a workflow with no `action` primitive never resolves a handler.
|
|
126
|
+
* Loading OUTSIDE the definition-hash re-verify is safe: the approved hash pins
|
|
127
|
+
* each handler ref string, and the closure's SRI pins the module bytes.
|
|
128
|
+
*
|
|
129
|
+
* @throws (from the returned resolver) if a requested ref names no export, or an
|
|
130
|
+
* export that is not a function.
|
|
131
|
+
* @throws if the actions entry path escapes the package or cannot be imported.
|
|
132
|
+
*/
|
|
133
|
+
export declare function loadWorkflowActionHandlersFromClosure(args: LoadWorkflowActionHandlersFromClosureArgs): Promise<(ref: string) => ActionHandler>;
|
|
78
134
|
export interface LoadWorkflowPluginsFromClosureArgs {
|
|
79
135
|
/**
|
|
80
136
|
* Directory of the materialized workflow package within the closure --
|