@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.
- package/README.md +77 -14
- package/dist/adapters/mail-part-store.d.ts +46 -0
- package/dist/adapters/mail-part-store.js +251 -0
- package/dist/adapters/repo-store.d.ts +22 -1
- package/dist/adapters/repo-store.js +56 -65
- package/dist/adapters/spawn-child.d.ts +109 -44
- package/dist/adapters/spawn-child.js +77 -81
- package/dist/adapters/step-invoker.d.ts +52 -2
- package/dist/adapters/step-invoker.js +284 -37
- 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/env-bootstrap.d.ts +20 -6
- package/dist/child/env-bootstrap.js +9 -1
- 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 +6 -2
- 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/parked-correlations.d.ts +42 -0
- package/dist/child/parked-correlations.js +80 -0
- package/dist/child/pending-request.d.ts +89 -0
- package/dist/child/pending-request.js +80 -0
- package/dist/child/proxy-repo-store.d.ts +3 -2
- package/dist/child/proxy-repo-store.js +2 -0
- package/dist/child/run-child.d.ts +170 -14
- package/dist/child/run-child.js +569 -155
- package/dist/child/self-discovery.d.ts +10 -0
- package/dist/child/self-discovery.js +25 -1
- 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/verified-definition-loader.d.ts +33 -0
- package/dist/child/verified-definition-loader.js +43 -0
- package/dist/child/warm-agent-cache.d.ts +44 -4
- package/dist/child/warm-agent-cache.js +41 -10
- package/dist/index.d.ts +6 -4
- package/dist/index.js +6 -4
- package/dist/ipc/control-channel.d.ts +151 -2
- package/dist/ipc/control-channel.js +222 -29
- package/dist/ipc/event-channel.d.ts +32 -1
- package/dist/ipc/index.d.ts +1 -1
- package/dist/ipc/index.js +1 -1
- package/dist/mail-bus/hub-transport-adapter.d.ts +12 -7
- package/dist/mail-bus/hub-transport-adapter.js +9 -5
- 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 +16 -6
- package/dist/seams/scheduler.js +87 -97
- package/dist/supervisor/cancel-signing.d.ts +2 -2
- package/dist/supervisor/cancel-signing.js +4 -8
- package/dist/supervisor/credentials.d.ts +28 -15
- package/dist/supervisor/credentials.js +7 -7
- package/dist/supervisor/dispatch-attribution.js +1 -1
- package/dist/supervisor/drain-timeout.d.ts +2 -2
- package/dist/supervisor/drain-timeout.js +1 -1
- package/dist/supervisor/index.d.ts +3 -3
- package/dist/supervisor/index.js +2 -2
- package/dist/supervisor/recycle.d.ts +10 -3
- package/dist/supervisor/recycle.js +18 -7
- package/dist/supervisor/run-event-compaction.d.ts +5 -5
- package/dist/supervisor/run-event-compaction.js +14 -19
- package/dist/supervisor/run-event-recovery.d.ts +34 -0
- package/dist/supervisor/run-event-recovery.js +45 -0
- package/dist/supervisor/spawn-env.d.ts +2 -2
- package/dist/supervisor/spawn-env.js +1 -1
- package/dist/supervisor/supervisor.d.ts +106 -26
- package/dist/supervisor/supervisor.js +1903 -414
- package/dist/supervisor/terminal-commit.d.ts +36 -0
- package/dist/supervisor/terminal-commit.js +130 -0
- package/dist/supervisor/types.d.ts +180 -23
- 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 +187 -0
- package/dist/workflow-definition-loader.js +422 -0
- package/package.json +18 -11
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
|
|
2
|
+
export type CommitRunFailedOpts = {
|
|
3
|
+
/** Substrate handle the supervisor writes through. */
|
|
4
|
+
substrate: SubstrateRepoStore;
|
|
5
|
+
/** Workflow-run repo for this deployment. */
|
|
6
|
+
repoId: RepoId;
|
|
7
|
+
/** Events ref the workflow-run repo writes to. */
|
|
8
|
+
ref: string;
|
|
9
|
+
/** Anchor run id used to construct the supervisor principal. */
|
|
10
|
+
anchorRunId: string;
|
|
11
|
+
/** Run id whose event log receives the RunFailed entry. */
|
|
12
|
+
runId: string;
|
|
13
|
+
/** ISO-8601 commit timestamp the event carries. */
|
|
14
|
+
at: string;
|
|
15
|
+
/** Operator-facing failure reason on `RunFailed.error.message`. */
|
|
16
|
+
message: string;
|
|
17
|
+
};
|
|
18
|
+
export type CommitRunFailedResult = {
|
|
19
|
+
/** Substrate-assigned commit SHA the write produced. */
|
|
20
|
+
commitSha: string;
|
|
21
|
+
/**
|
|
22
|
+
* True when a `RunFailed` was appended; false when the run was already
|
|
23
|
+
* terminal and the write was a no-op (terminal-lock respected).
|
|
24
|
+
*/
|
|
25
|
+
appended: boolean;
|
|
26
|
+
};
|
|
27
|
+
/**
|
|
28
|
+
* Append a supervisor-authored `RunFailed` to a run's event log, unless the
|
|
29
|
+
* run is already terminal. The next seq is computed inside the substrate
|
|
30
|
+
* merge (atomic against any concurrent writer under the per-repo lock): the
|
|
31
|
+
* first event on an empty tree lands at seq 1 (the runtime's convention),
|
|
32
|
+
* otherwise at `maxSeq + 1` so the log stays seq-contiguous. If the run's
|
|
33
|
+
* highest-seq event is already terminal, the write is a no-op so push
|
|
34
|
+
* validation's terminal-lock is never tripped.
|
|
35
|
+
*/
|
|
36
|
+
export declare function commitRunFailed(opts: CommitRunFailedOpts): Promise<CommitRunFailedResult>;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
// Supervisor-authored terminal-event commit for the crash-loop guard.
|
|
2
|
+
//
|
|
3
|
+
// When the crash-loop guard latches, the workflow-process child is dead
|
|
4
|
+
// and cannot commit its own terminal event, yet the deployment's run must
|
|
5
|
+
// reach a terminal state so its external `workflow_run.status` flips to
|
|
6
|
+
// `failed` -- the sole durable, queryable signal of a crash-loop. The
|
|
7
|
+
// supervisor's `crash-looping` phase is in-memory and per-process; no
|
|
8
|
+
// external reader observes it. The supervisor, as the sole writer of the
|
|
9
|
+
// workflow-run repo, authors a `RunFailed` for the deployment's stable
|
|
10
|
+
// run so the crash-loop leaves a durable tombstone.
|
|
11
|
+
//
|
|
12
|
+
// Unlike `commitCancelRequested`, a terminal workflow event carries no
|
|
13
|
+
// signature: only `CancelRequested` is signed (for its origin<->principal
|
|
14
|
+
// cross-check at push validation), so this writer is unsigned. It writes
|
|
15
|
+
// under the `supervisor` principal, which the workflow-run kind handler
|
|
16
|
+
// authorizes for the deployment's own event log (`repoId.id ===
|
|
17
|
+
// anchorRunId`); terminal events have no per-type authorship check.
|
|
18
|
+
import { type } from "arktype";
|
|
19
|
+
import { getLogger } from "@intx/log";
|
|
20
|
+
import { parseEventSeq } from "@intx/hub-sessions/substrate";
|
|
21
|
+
import { workflowEventToOnDisk } from "../adapters/repo-store.js";
|
|
22
|
+
const logger = getLogger(["workflow-host", "supervisor", "terminal-commit"]);
|
|
23
|
+
/** Path layout inside the workflow-run repo: `runs/<runId>/events/<seq>.json`. */
|
|
24
|
+
const RUNS_PREFIX = "runs";
|
|
25
|
+
const EVENTS_DIR = "events";
|
|
26
|
+
/**
|
|
27
|
+
* Terminal run-event kinds, mirroring the runtime's terminal vocabulary
|
|
28
|
+
* (`RunCompleted`/`RunFailed`/`RunCancelled`). Inlined because the workflow
|
|
29
|
+
* package exports only the phase-level `isTerminalRunPhase`, not an
|
|
30
|
+
* event-kind set; the child runtime (`run-child`) and the substrate adapter
|
|
31
|
+
* (`repo-store`) inline the same three kinds. Reducing the log to a phase to
|
|
32
|
+
* reuse `isTerminalRunPhase` would be strictly heavier for a last-event
|
|
33
|
+
* type check.
|
|
34
|
+
*/
|
|
35
|
+
const TERMINAL_EVENT_KINDS = new Set([
|
|
36
|
+
"RunCompleted",
|
|
37
|
+
"RunFailed",
|
|
38
|
+
"RunCancelled",
|
|
39
|
+
]);
|
|
40
|
+
const SUPERVISOR_PRINCIPAL_KIND = "supervisor";
|
|
41
|
+
/**
|
|
42
|
+
* On-disk event envelope, validated at the substrate read boundary. Only
|
|
43
|
+
* `seq` and `type` are load-bearing here (max-seq computation and the
|
|
44
|
+
* terminal-lock check); the rest of the event body is ignored.
|
|
45
|
+
*/
|
|
46
|
+
const OnDiskEventEnvelope = type({
|
|
47
|
+
seq: "number >= 0",
|
|
48
|
+
type: "string",
|
|
49
|
+
"+": "ignore",
|
|
50
|
+
});
|
|
51
|
+
/**
|
|
52
|
+
* Append a supervisor-authored `RunFailed` to a run's event log, unless the
|
|
53
|
+
* run is already terminal. The next seq is computed inside the substrate
|
|
54
|
+
* merge (atomic against any concurrent writer under the per-repo lock): the
|
|
55
|
+
* first event on an empty tree lands at seq 1 (the runtime's convention),
|
|
56
|
+
* otherwise at `maxSeq + 1` so the log stays seq-contiguous. If the run's
|
|
57
|
+
* highest-seq event is already terminal, the write is a no-op so push
|
|
58
|
+
* validation's terminal-lock is never tripped.
|
|
59
|
+
*/
|
|
60
|
+
export async function commitRunFailed(opts) {
|
|
61
|
+
const prefix = `${RUNS_PREFIX}/${opts.runId}/${EVENTS_DIR}/`;
|
|
62
|
+
const principal = {
|
|
63
|
+
kind: SUPERVISOR_PRINCIPAL_KIND,
|
|
64
|
+
anchorRunId: opts.anchorRunId,
|
|
65
|
+
};
|
|
66
|
+
const decoder = new TextDecoder();
|
|
67
|
+
let appended = false;
|
|
68
|
+
const { commitSha } = await opts.substrate.writeTreePreservingPrefix(principal, opts.repoId, opts.ref, {
|
|
69
|
+
preservePrefix: prefix,
|
|
70
|
+
merge: async (existing) => {
|
|
71
|
+
let maxSeq = -1;
|
|
72
|
+
let maxPath = null;
|
|
73
|
+
for (const filepath of existing.keys()) {
|
|
74
|
+
const name = filepath.slice(prefix.length);
|
|
75
|
+
const seq = parseEventSeq(name);
|
|
76
|
+
if (seq === null)
|
|
77
|
+
continue;
|
|
78
|
+
if (seq > maxSeq) {
|
|
79
|
+
maxSeq = seq;
|
|
80
|
+
maxPath = filepath;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const carried = {};
|
|
84
|
+
for (const [k, v] of existing)
|
|
85
|
+
carried[k] = v;
|
|
86
|
+
// Terminal-lock: appending a terminal event after an existing one
|
|
87
|
+
// is rejected at push validation. If the run already ended, its
|
|
88
|
+
// `workflow_run.status` is already terminal, so the crash-loop
|
|
89
|
+
// tombstone is redundant -- no-op rather than push a rejected write.
|
|
90
|
+
//
|
|
91
|
+
// This detects the per-event (`events/<seq>.json`) form only, not
|
|
92
|
+
// the sealed combined-log (`events.jsonl`) form, which lives outside
|
|
93
|
+
// `preservePrefix`. That is sufficient here because the anchor run
|
|
94
|
+
// this commit targets is sealed only when the DEPLOYMENT itself is
|
|
95
|
+
// terminal, and the crash-loop latch fires only while the deployment
|
|
96
|
+
// is running -- so the run is never in the sealed form at this call.
|
|
97
|
+
// If a sealed run ever reached here, the append would produce a tree
|
|
98
|
+
// carrying both forms, which push validation rejects, and the caller
|
|
99
|
+
// logs the failure (best-effort tombstone) rather than corrupting.
|
|
100
|
+
if (maxPath !== null) {
|
|
101
|
+
const raw = existing.get(maxPath);
|
|
102
|
+
if (raw !== undefined) {
|
|
103
|
+
const parsed = OnDiskEventEnvelope(JSON.parse(decoder.decode(raw)));
|
|
104
|
+
if (parsed instanceof type.errors) {
|
|
105
|
+
throw new Error(`commitRunFailed: event blob ${maxPath} failed validation: ${parsed.summary}`);
|
|
106
|
+
}
|
|
107
|
+
if (TERMINAL_EVENT_KINDS.has(parsed.type)) {
|
|
108
|
+
return carried;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
const nextSeq = maxSeq < 0 ? 1 : maxSeq + 1;
|
|
113
|
+
const event = {
|
|
114
|
+
kind: "RunFailed",
|
|
115
|
+
seq: nextSeq,
|
|
116
|
+
at: opts.at,
|
|
117
|
+
error: { message: opts.message },
|
|
118
|
+
};
|
|
119
|
+
const onDisk = workflowEventToOnDisk(event, nextSeq);
|
|
120
|
+
carried[`${prefix}${String(nextSeq)}.json`] = JSON.stringify(onDisk);
|
|
121
|
+
appended = true;
|
|
122
|
+
return carried;
|
|
123
|
+
},
|
|
124
|
+
message: `append RunFailed (crash-loop) for run ${opts.runId}`,
|
|
125
|
+
});
|
|
126
|
+
if (!appended) {
|
|
127
|
+
logger.info `commitRunFailed: run ${opts.runId} already terminal; no RunFailed appended`;
|
|
128
|
+
}
|
|
129
|
+
return { commitSha, appended };
|
|
130
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import type { DequeueToProcessingResult, EnqueueInboxArgs,
|
|
2
|
-
import type {
|
|
1
|
+
import type { DequeueToProcessingResult, EnqueueInboxArgs, EnqueueInboxOutcome, MarkConsumedArgs, MarkConsumedResult, Principal, RepoId, RepoStore as SubstrateRepoStore, ReplayProcessingToInboxOpts, ReplayProcessingToInboxResult } from "@intx/hub-sessions/substrate";
|
|
2
|
+
import type { SignalKind } from "@intx/types";
|
|
3
|
+
import type { ApprovalSnapshot, OutboundMessage, SendReceipt } from "@intx/types/runtime";
|
|
3
4
|
import type { RunCancelled, RunCompleted, RunFailed } from "@intx/workflow";
|
|
4
5
|
import type { FrameReader, NdjsonReader, NdjsonWriter } from "../ipc/index.js";
|
|
5
6
|
/**
|
|
@@ -69,7 +70,13 @@ export type PrincipalSigner = (kind: WorkflowSupervisorPrincipalKind, payload: U
|
|
|
69
70
|
*
|
|
70
71
|
* `subscribeMailForAddress` returns a disposer the supervisor calls
|
|
71
72
|
* during teardown. The supplied handler is invoked with the raw RFC
|
|
72
|
-
* 2822 message bytes of each inbound message at the address
|
|
73
|
+
* 2822 message bytes of each inbound message at the address, and returns
|
|
74
|
+
* a promise that resolves once the message is durably accepted (its inbox
|
|
75
|
+
* write landed, or the message was already durably present) and rejects
|
|
76
|
+
* when it was not (a transient failure, a stale refusal, or a phase where
|
|
77
|
+
* the deployment is tearing down). The host propagates that settlement to
|
|
78
|
+
* the wire so a durable-receipt ack is sent only on resolution -- resolve
|
|
79
|
+
* is the ack signal, reject is the withhold signal.
|
|
73
80
|
*
|
|
74
81
|
* `sendOutbound` is the OUTBOUND half of mailbox ownership (§3a). The
|
|
75
82
|
* supervisor is the sole mail owner: the workflow-process child never
|
|
@@ -92,7 +99,7 @@ export type PrincipalSigner = (kind: WorkflowSupervisorPrincipalKind, payload: U
|
|
|
92
99
|
export interface MailBusBindings {
|
|
93
100
|
registerAddress(address: string): void;
|
|
94
101
|
unregisterAddress(address: string): void;
|
|
95
|
-
subscribeMailForAddress(address: string, handler: (rawMessage: Uint8Array) => void): () => void;
|
|
102
|
+
subscribeMailForAddress(address: string, handler: (rawMessage: Uint8Array) => Promise<void>): () => void;
|
|
96
103
|
sendOutbound(senderAddress: string, message: OutboundMessage): Promise<SendReceipt>;
|
|
97
104
|
}
|
|
98
105
|
/**
|
|
@@ -181,11 +188,33 @@ export type DeriveMailAuditRef = (messageId: string, rawMessage: Uint8Array) =>
|
|
|
181
188
|
* surprise.
|
|
182
189
|
*/
|
|
183
190
|
export interface InboxPrimitives {
|
|
184
|
-
enqueueInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: EnqueueInboxArgs): Promise<
|
|
191
|
+
enqueueInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: EnqueueInboxArgs): Promise<EnqueueInboxOutcome>;
|
|
185
192
|
dequeueToProcessing(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, address: string): Promise<DequeueToProcessingResult>;
|
|
186
193
|
markConsumed(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, args: MarkConsumedArgs): Promise<MarkConsumedResult>;
|
|
187
194
|
replayProcessingToInbox(store: SubstrateRepoStore, principal: Principal, repoId: RepoId, address: string, opts?: ReplayProcessingToInboxOpts): Promise<ReplayProcessingToInboxResult>;
|
|
188
195
|
}
|
|
196
|
+
/**
|
|
197
|
+
* A control-plane suspension the supervisor forwards to the host after a
|
|
198
|
+
* workflow-process child reports a park. The child supplies `runId`,
|
|
199
|
+
* `correlationId`, and `kind`; the supervisor stamps `anchorRunId` and
|
|
200
|
+
* `agentAddress` from its own bindings before invoking the host's
|
|
201
|
+
* `onSuspensionRegister`. The host (production: the sidecar) turns this into
|
|
202
|
+
* a `signal.correlation.register` frame the hub co-writes the run's routing +
|
|
203
|
+
* approval rows from.
|
|
204
|
+
*/
|
|
205
|
+
export interface SuspensionRegistration {
|
|
206
|
+
runId: string;
|
|
207
|
+
correlationId: string;
|
|
208
|
+
kind: SignalKind;
|
|
209
|
+
anchorRunId: string;
|
|
210
|
+
agentAddress: string;
|
|
211
|
+
/**
|
|
212
|
+
* Approver-facing snapshot of the parked tool call, forwarded from the
|
|
213
|
+
* child's `park.notify`. Present only for an ask-rail suspension; the host
|
|
214
|
+
* turns it into the register frame's snapshot.
|
|
215
|
+
*/
|
|
216
|
+
approvalSnapshot?: ApprovalSnapshot;
|
|
217
|
+
}
|
|
189
218
|
/**
|
|
190
219
|
* Constructor arguments for `createWorkflowSupervisor`. One
|
|
191
220
|
* `RepoStore` handle plus a `signAsPrincipal` callback that mints
|
|
@@ -201,6 +230,79 @@ export interface WorkflowSupervisorBindings {
|
|
|
201
230
|
signAsPrincipal: PrincipalSigner;
|
|
202
231
|
/** Mail-bus surface for address registration and inbound subscription. */
|
|
203
232
|
mailBus: MailBusBindings;
|
|
233
|
+
/**
|
|
234
|
+
* Optional control-plane suspension sink. Two callers invoke it, both
|
|
235
|
+
* stamping `anchorRunId` and `deploymentMailAddress` (as `agentAddress`)
|
|
236
|
+
* onto the child-supplied `runId`/`correlationId`/`kind`: the upstream-control
|
|
237
|
+
* pump's `park.notify` arm (the happy-path emit at suspend), and
|
|
238
|
+
* `reEmitParkedCorrelations` (the re-establishment re-emit that recovers a
|
|
239
|
+
* register the hub missed while it was down). Production wires this to the
|
|
240
|
+
* sidecar's hub link so a `signal.correlation.register` frame reaches the hub;
|
|
241
|
+
* a host that does not wire it does not register suspensions (today, the
|
|
242
|
+
* tests). Best-effort: a throwing sink is logged and
|
|
243
|
+
* both callers keep going, so one bad register cannot wedge the control pump
|
|
244
|
+
* or abort a re-emit partway through the parked set.
|
|
245
|
+
*/
|
|
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;
|
|
277
|
+
/**
|
|
278
|
+
* Per-run grants source the dispatch loop consults before it forwards a
|
|
279
|
+
* `trigger.fire`. Unlike `onSuspensionRegister` (best-effort, fire-and-
|
|
280
|
+
* forget), this is a request/response contract: the supervisor awaits the
|
|
281
|
+
* returned `CredentialsSnapshot` and pushes it to the child over the
|
|
282
|
+
* control channel BEFORE firing the run's trigger, so the child's
|
|
283
|
+
* authorize closure binds to the run's grants rather than a stale
|
|
284
|
+
* spawn-time snapshot. A throwing sink is NOT swallowed -- the dispatch
|
|
285
|
+
* loop fails that run (a synthesized `RunFailed`) rather than firing the
|
|
286
|
+
* trigger against absent grants.
|
|
287
|
+
*
|
|
288
|
+
* When this binding is wired, it is the SOLE grants push: `spawn` does not
|
|
289
|
+
* push a spawn-time snapshot, so the per-run push is the only thing that
|
|
290
|
+
* satisfies the child's throw-on-null authorize guard. A caller that
|
|
291
|
+
* injects no per-run sink -- today, the supervisor's own tests -- keeps the
|
|
292
|
+
* spawn-time push instead. Production (the sidecar) wires it to the
|
|
293
|
+
* walk-derived per-step credentials assembly.
|
|
294
|
+
*/
|
|
295
|
+
onRunStart?: (args: {
|
|
296
|
+
runId: string;
|
|
297
|
+
anchorRunId: string;
|
|
298
|
+
}) => Promise<import("./credentials.js").CredentialsSnapshot>;
|
|
299
|
+
/**
|
|
300
|
+
* Decrypted credential material for the deployment's tools, delivered to the
|
|
301
|
+
* child on the pre-trigger barrier alongside the grants. Absent when the
|
|
302
|
+
* deployment binds no credentials. A rotation flows through
|
|
303
|
+
* `deliverCredentials`, not this static binding.
|
|
304
|
+
*/
|
|
305
|
+
credentialDelivery?: import("@intx/types/sidecar").CredentialDelivery;
|
|
204
306
|
/** Subprocess spawner the supervisor invokes per spawn. */
|
|
205
307
|
subprocessSpawner: SubprocessSpawner;
|
|
206
308
|
/**
|
|
@@ -235,8 +337,8 @@ export interface WorkflowSupervisorBindings {
|
|
|
235
337
|
workflowRunRepoId: import("@intx/hub-sessions").RepoId;
|
|
236
338
|
/** Workflow-run repo ref the supervisor commits events to. */
|
|
237
339
|
workflowRunRef: string;
|
|
238
|
-
/**
|
|
239
|
-
|
|
340
|
+
/** Anchor run id baked into the supervisor's principal claims. */
|
|
341
|
+
anchorRunId: string;
|
|
240
342
|
/**
|
|
241
343
|
* Number of steps in the deployed `WorkflowDefinition`
|
|
242
344
|
* (`stepOrder.length`). The supervisor threads this into the child's
|
|
@@ -268,7 +370,7 @@ export interface WorkflowSupervisorBindings {
|
|
|
268
370
|
deriveStepAddress: import("./credentials.js").DeriveStepAddress;
|
|
269
371
|
/**
|
|
270
372
|
* Optional override for the step's agent-state repo identity. The
|
|
271
|
-
* default convention is `<
|
|
373
|
+
* default convention is `<anchorRunId>-<stepId>`.
|
|
272
374
|
*/
|
|
273
375
|
deriveStepRepoId?: import("./credentials.js").DeriveStepRepoId;
|
|
274
376
|
/**
|
|
@@ -377,11 +479,11 @@ export interface WorkflowSupervisorBindings {
|
|
|
377
479
|
/**
|
|
378
480
|
* Workflow-run substrate principal the supervisor uses to author
|
|
379
481
|
* inbox/processing/consumed writes. The substrate's workflow-run
|
|
380
|
-
* kind handler accepts a `{ kind: "supervisor",
|
|
482
|
+
* kind handler accepts a `{ kind: "supervisor", anchorRunId }`
|
|
381
483
|
* principal for claim-check writes; the supervisor constructs this
|
|
382
484
|
* value once at bindings construction and reuses it for every
|
|
383
485
|
* claim-check operation. Defaults to `{ kind: "supervisor",
|
|
384
|
-
*
|
|
486
|
+
* anchorRunId }` derived from `bindings.anchorRunId`; tests
|
|
385
487
|
* override it when they need to assert on a structurally distinct
|
|
386
488
|
* principal shape.
|
|
387
489
|
*/
|
|
@@ -413,14 +515,65 @@ export interface WorkflowSupervisorBindings {
|
|
|
413
515
|
*/
|
|
414
516
|
readyTimeoutMs?: number;
|
|
415
517
|
/**
|
|
416
|
-
*
|
|
417
|
-
*
|
|
418
|
-
*
|
|
419
|
-
*
|
|
420
|
-
*
|
|
421
|
-
*
|
|
518
|
+
* Crash-loop guard: the maximum number of UNEXPECTED workflow-process
|
|
519
|
+
* child exits (crash, OOM, panic, signal) the supervisor tolerates
|
|
520
|
+
* within `crashLoopWindowMs` before it stops respawning and latches the
|
|
521
|
+
* deployment to a terminal state. The boot edge resolves the operator's
|
|
522
|
+
* config and supplies it; absent, `DEFAULT_CRASH_LOOP_MAX_COUNT` (3)
|
|
523
|
+
* applies. The operator owns this bound.
|
|
524
|
+
*/
|
|
525
|
+
crashLoopMaxCount?: number;
|
|
526
|
+
/**
|
|
527
|
+
* Sliding window (ms) over which `crashLoopMaxCount` unexpected exits
|
|
528
|
+
* latch the deployment. Absent, `DEFAULT_CRASH_LOOP_WINDOW_MS` (60s)
|
|
529
|
+
* applies.
|
|
530
|
+
*/
|
|
531
|
+
crashLoopWindowMs?: number;
|
|
532
|
+
/**
|
|
533
|
+
* Stable-run duration (ms) after a respawn: once a respawned child has
|
|
534
|
+
* stayed up this long, the crash counter resets, so a burst of crashes
|
|
535
|
+
* followed by stability does not permanently latch. Absent,
|
|
536
|
+
* `DEFAULT_CRASH_LOOP_STABLE_RESET_MS` (60s) applies. Driven by the
|
|
537
|
+
* injectable `setTimer`/`clearTimer` pair so tests are deterministic.
|
|
538
|
+
*/
|
|
539
|
+
crashLoopStableResetMs?: number;
|
|
540
|
+
/**
|
|
541
|
+
* Initial respawn backoff (ms): the wait before the FIRST respawn after
|
|
542
|
+
* an unexpected exit. Each subsequent respawn doubles the wait, capped at
|
|
543
|
+
* `respawnBackoffMaxMs`; a stable run resets it to this value. Absent,
|
|
544
|
+
* `DEFAULT_RESPAWN_BACKOFF_INITIAL_MS` (1s) applies.
|
|
422
545
|
*/
|
|
423
|
-
|
|
546
|
+
respawnBackoffInitialMs?: number;
|
|
547
|
+
/**
|
|
548
|
+
* Maximum respawn backoff (ms) the exponential doubling is capped at, so
|
|
549
|
+
* a rapidly-flapping child does not saturate the host. Absent,
|
|
550
|
+
* `DEFAULT_RESPAWN_BACKOFF_MAX_MS` (30s) applies.
|
|
551
|
+
*
|
|
552
|
+
* Config invariant: keep this comfortably below `crashLoopWindowMs`.
|
|
553
|
+
* Crashes must fall within the window to accumulate toward the latch, and
|
|
554
|
+
* the backoff spaces them apart; a cap at or above the window guarantees a
|
|
555
|
+
* slow flapper's timestamps age out before the count reaches
|
|
556
|
+
* `crashLoopMaxCount`, so the guard never latches. This is necessary but
|
|
557
|
+
* not sufficient: the child's own lifetime also spaces crashes, so a child
|
|
558
|
+
* whose healthy lifetime approaches `crashLoopStableResetMs` can still
|
|
559
|
+
* out-space the window (and never earn a counter reset either), respawning
|
|
560
|
+
* indefinitely. Windowed crash detection cannot catch an arbitrarily slow
|
|
561
|
+
* flapper; the window/backoff/stable-reset trio bounds the FAST flap this
|
|
562
|
+
* guard targets.
|
|
563
|
+
*/
|
|
564
|
+
respawnBackoffMaxMs?: number;
|
|
565
|
+
/**
|
|
566
|
+
* Watchdog timeout (ms) for `reEmitParkedCorrelations`' wait on the
|
|
567
|
+
* child's `parked-correlations.response`. Caps the wait so a
|
|
568
|
+
* wedged-but-alive child (whose cohort never tears down, so the
|
|
569
|
+
* cohort-abort rejection never fires) cannot hang the re-registration
|
|
570
|
+
* driver -- and therefore the reconnect/re-establishment caller that
|
|
571
|
+
* awaits it. On expiry the pending query is dropped and the driver
|
|
572
|
+
* returns; the next re-establishment re-drives it. Defaults to
|
|
573
|
+
* `DEFAULT_PARKED_QUERY_WATCHDOG_MS`. Tests inject a small value so the
|
|
574
|
+
* timeout path is observable.
|
|
575
|
+
*/
|
|
576
|
+
parkedQueryWatchdogMs?: number;
|
|
424
577
|
/**
|
|
425
578
|
* Optional per-message dispatch-timing observer. When supplied, the
|
|
426
579
|
* dispatch loop invokes it twice per dispatched inbox entry: once with
|
|
@@ -490,9 +643,8 @@ export type DispatchSubstrateLeg = "enqueue" | "dequeue" | "runevent" | "markcon
|
|
|
490
643
|
* (design §10b). All are cheap filesystem reads against the workflow-run
|
|
491
644
|
* repo's on-disk working tree, taken only when the observer is wired.
|
|
492
645
|
*
|
|
493
|
-
* - `runsFanOut` — entry count under `runs/` (
|
|
494
|
-
*
|
|
495
|
-
* win is sized by this.
|
|
646
|
+
* - `runsFanOut` — entry count under `runs/` (the stable top-level run
|
|
647
|
+
* plus any internal body-child runs).
|
|
496
648
|
* - `consumedFanOut` — entry count under
|
|
497
649
|
* `addresses/<addr>/consumed/` (one dedup entry per
|
|
498
650
|
* message; never pruned). The candidate-(iv) "prune
|
|
@@ -512,8 +664,13 @@ export type DispatchStructuralCounters = {
|
|
|
512
664
|
/**
|
|
513
665
|
* One observation emitted by `WorkflowSupervisorBindings.onDispatchTiming`.
|
|
514
666
|
*
|
|
667
|
+
* Both variants key on `messageId`, the per-message identifier (the mail's
|
|
668
|
+
* Message-ID). The top-level run id cannot serve as the key: one deployment
|
|
669
|
+
* keeps that stable id across all of its live trigger occurrences, so it does
|
|
670
|
+
* not distinguish one dispatched message from the next.
|
|
671
|
+
*
|
|
515
672
|
* The `"roundtrip"` variant is the 4.7 latency-gate bracket: pair the
|
|
516
|
-
* `"dispatch-start"` and `"reply-produced"` marks for the same `
|
|
673
|
+
* `"dispatch-start"` and `"reply-produced"` marks for the same `messageId` to
|
|
517
674
|
* recover the per-message round-trip. `atMs` is a high-resolution
|
|
518
675
|
* monotonic timestamp (`performance.now()`).
|
|
519
676
|
*
|
|
@@ -528,12 +685,12 @@ export type DispatchStructuralCounters = {
|
|
|
528
685
|
*/
|
|
529
686
|
export type DispatchTimingMark = {
|
|
530
687
|
kind: "roundtrip";
|
|
531
|
-
|
|
688
|
+
messageId: string;
|
|
532
689
|
marker: "dispatch-start" | "reply-produced";
|
|
533
690
|
atMs: number;
|
|
534
691
|
} | {
|
|
535
692
|
kind: "leg";
|
|
536
|
-
|
|
693
|
+
messageId: string;
|
|
537
694
|
leg: DispatchSubstrateLeg;
|
|
538
695
|
phase: "start" | "end";
|
|
539
696
|
atMs: number;
|
|
@@ -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;
|