@intx/workflow-host 0.2.2
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/LICENSE +176 -0
- package/README.md +287 -0
- package/dist/adapters/blob-substrate.d.ts +49 -0
- package/dist/adapters/blob-substrate.js +140 -0
- package/dist/adapters/repo-store.d.ts +39 -0
- package/dist/adapters/repo-store.js +344 -0
- package/dist/adapters/spawn-child.d.ts +74 -0
- package/dist/adapters/spawn-child.js +152 -0
- package/dist/adapters/step-invoker.d.ts +114 -0
- package/dist/adapters/step-invoker.js +360 -0
- package/dist/child/env-bootstrap.d.ts +56 -0
- package/dist/child/env-bootstrap.js +120 -0
- package/dist/child/from-process-env.d.ts +127 -0
- package/dist/child/from-process-env.js +183 -0
- package/dist/child/index.d.ts +9 -0
- package/dist/child/index.js +9 -0
- package/dist/child/outbound-mail-bridge.d.ts +36 -0
- package/dist/child/outbound-mail-bridge.js +143 -0
- package/dist/child/proxy-repo-store.d.ts +27 -0
- package/dist/child/proxy-repo-store.js +200 -0
- package/dist/child/run-child.d.ts +320 -0
- package/dist/child/run-child.js +900 -0
- package/dist/child/self-discovery.d.ts +29 -0
- package/dist/child/self-discovery.js +57 -0
- package/dist/child/substrate-write-bridge.d.ts +72 -0
- package/dist/child/substrate-write-bridge.js +188 -0
- package/dist/child/supervisor-backed-transport.d.ts +10 -0
- package/dist/child/supervisor-backed-transport.js +113 -0
- package/dist/child/warm-agent-cache.d.ts +78 -0
- package/dist/child/warm-agent-cache.js +112 -0
- package/dist/drain-controller.d.ts +37 -0
- package/dist/drain-controller.js +46 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.js +10 -0
- package/dist/ipc/control-channel.d.ts +336 -0
- package/dist/ipc/control-channel.js +532 -0
- package/dist/ipc/crypto.d.ts +46 -0
- package/dist/ipc/crypto.js +126 -0
- package/dist/ipc/envelope.d.ts +53 -0
- package/dist/ipc/envelope.js +88 -0
- package/dist/ipc/event-channel.d.ts +677 -0
- package/dist/ipc/event-channel.js +278 -0
- package/dist/ipc/index.d.ts +4 -0
- package/dist/ipc/index.js +143 -0
- package/dist/mail-bus/hub-transport-adapter.d.ts +30 -0
- package/dist/mail-bus/hub-transport-adapter.js +76 -0
- package/dist/mail-bus/index.d.ts +1 -0
- package/dist/mail-bus/index.js +1 -0
- package/dist/seams/index.d.ts +3 -0
- package/dist/seams/index.js +3 -0
- package/dist/seams/scheduler-adapter.d.ts +3 -0
- package/dist/seams/scheduler-adapter.js +24 -0
- package/dist/seams/scheduler.d.ts +94 -0
- package/dist/seams/scheduler.js +397 -0
- package/dist/seams/signal-channel.d.ts +74 -0
- package/dist/seams/signal-channel.js +304 -0
- package/dist/supervisor/cancel-signing.d.ts +68 -0
- package/dist/supervisor/cancel-signing.js +144 -0
- package/dist/supervisor/child-termination.d.ts +51 -0
- package/dist/supervisor/child-termination.js +76 -0
- package/dist/supervisor/credentials.d.ts +101 -0
- package/dist/supervisor/credentials.js +153 -0
- package/dist/supervisor/dispatch-attribution.d.ts +37 -0
- package/dist/supervisor/dispatch-attribution.js +114 -0
- package/dist/supervisor/drain-timeout.d.ts +127 -0
- package/dist/supervisor/drain-timeout.js +231 -0
- package/dist/supervisor/index.d.ts +7 -0
- package/dist/supervisor/index.js +6 -0
- package/dist/supervisor/recycle.d.ts +212 -0
- package/dist/supervisor/recycle.js +440 -0
- package/dist/supervisor/run-event-compaction.d.ts +34 -0
- package/dist/supervisor/run-event-compaction.js +115 -0
- package/dist/supervisor/spawn-env.d.ts +39 -0
- package/dist/supervisor/spawn-env.js +36 -0
- package/dist/supervisor/supervisor.d.ts +202 -0
- package/dist/supervisor/supervisor.js +2244 -0
- package/dist/supervisor/terminal-broadcaster.d.ts +45 -0
- package/dist/supervisor/terminal-broadcaster.js +184 -0
- package/dist/supervisor/types.d.ts +542 -0
- package/dist/supervisor/types.js +10 -0
- package/package.json +35 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { Principal, RepoId, RepoStore } from "@intx/hub-sessions/substrate";
|
|
2
|
+
/**
|
|
3
|
+
* Path inside each step's `agent-state` repo that carries the step's
|
|
4
|
+
* grants. The agent-state kind handler accepts state writes under the
|
|
5
|
+
* `state/` subtree; the grants snapshot rides at
|
|
6
|
+
* `state/grants.json` as a single canonical JSON document.
|
|
7
|
+
*/
|
|
8
|
+
export declare const STEP_GRANTS_PATH = "state/grants.json";
|
|
9
|
+
/**
|
|
10
|
+
* Ref the supervisor reads each step's grants from. The agent-state
|
|
11
|
+
* kind handler defines the deploy ref under `refs/heads/deploy`; the
|
|
12
|
+
* state-bearing refs the sidecar writes to use the default
|
|
13
|
+
* `refs/heads/main` convention.
|
|
14
|
+
*/
|
|
15
|
+
export declare const STEP_GRANTS_REF = "refs/heads/main";
|
|
16
|
+
export type CredentialsSnapshotStep = {
|
|
17
|
+
/** Workflow step id from `WorkflowDefinition.stepOrder`. */
|
|
18
|
+
stepId: string;
|
|
19
|
+
/** Mail address the step's agent presents to the bus. */
|
|
20
|
+
address: string;
|
|
21
|
+
/** Opaque `grants` array as committed in the step's repo. */
|
|
22
|
+
grants: readonly unknown[];
|
|
23
|
+
/** sha256 hex over the canonical JSON of `grants`. */
|
|
24
|
+
contentHash: string;
|
|
25
|
+
};
|
|
26
|
+
export type CredentialsSnapshot = {
|
|
27
|
+
/** Step-id keyed entries in `stepOrder` traversal order. */
|
|
28
|
+
steps: readonly CredentialsSnapshotStep[];
|
|
29
|
+
};
|
|
30
|
+
/**
|
|
31
|
+
* Caller-supplied derivation of the per-step mail address from the
|
|
32
|
+
* deployment id and step id. The supervisor cannot encode the
|
|
33
|
+
* deployment-domain inside library code; the wiring module supplies
|
|
34
|
+
* the strategy the host owns.
|
|
35
|
+
*/
|
|
36
|
+
export type DeriveStepAddress = (args: {
|
|
37
|
+
deploymentId: string;
|
|
38
|
+
stepId: string;
|
|
39
|
+
}) => string;
|
|
40
|
+
/**
|
|
41
|
+
* Caller-supplied override of the per-step `agent-state` repo identity
|
|
42
|
+
* the supervisor reads grants from. Defaults to the
|
|
43
|
+
* `<deploymentId>-<stepId>` convention (`defaultStepRepoId`); the
|
|
44
|
+
* single-step launched-agent deploy supplies a derivation that returns
|
|
45
|
+
* the legacy agent-state repo so the child reads grants from the same
|
|
46
|
+
* repo the legacy agent identity already keys.
|
|
47
|
+
*/
|
|
48
|
+
export type DeriveStepRepoId = (args: {
|
|
49
|
+
deploymentId: string;
|
|
50
|
+
stepId: string;
|
|
51
|
+
}) => RepoId;
|
|
52
|
+
export type AssembleCredentialsSnapshotOpts = {
|
|
53
|
+
/** Substrate handle the supervisor reads from. */
|
|
54
|
+
repoStore: RepoStore;
|
|
55
|
+
/** Principal presented for each step's read. */
|
|
56
|
+
principal: Principal;
|
|
57
|
+
/**
|
|
58
|
+
* Step ids in the deployment's `stepOrder`. The trivial workflow
|
|
59
|
+
* passes a single entry; multi-step deployments pass every step in
|
|
60
|
+
* the order the workflow asset declared.
|
|
61
|
+
*/
|
|
62
|
+
stepOrder: readonly string[];
|
|
63
|
+
/** Deployment id used in agent-state repo identity and address derivation. */
|
|
64
|
+
deploymentId: string;
|
|
65
|
+
/** Per-step mail-address derivation callback. */
|
|
66
|
+
deriveStepAddress: DeriveStepAddress;
|
|
67
|
+
/**
|
|
68
|
+
* Optional override for the `agent-state` repo's id. Callers that
|
|
69
|
+
* follow the documented convention (`<deploymentId>-<stepId>`) can
|
|
70
|
+
* omit this; tests and bespoke layouts can supply their own.
|
|
71
|
+
*/
|
|
72
|
+
deriveStepRepoId?: DeriveStepRepoId;
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* Default mapping from `(deploymentId, stepId)` to the agent-state
|
|
76
|
+
* repo id: `<deploymentId>-<stepId>`, isolating each step's grants in
|
|
77
|
+
* its own repo. Applied to a one-step `stepOrder` it yields a single
|
|
78
|
+
* such repo. The single-step launched-agent deploy overrides this
|
|
79
|
+
* default (see `DeriveStepRepoId`) to reuse the legacy agent-state
|
|
80
|
+
* repo.
|
|
81
|
+
*/
|
|
82
|
+
export declare function defaultStepRepoId(args: {
|
|
83
|
+
deploymentId: string;
|
|
84
|
+
stepId: string;
|
|
85
|
+
}): RepoId;
|
|
86
|
+
/**
|
|
87
|
+
* Compute the per-step content hash used to pin a credentialsSnapshot
|
|
88
|
+
* push to a specific grants payload. Stable across processes because
|
|
89
|
+
* the JSON.stringify pass produces the same byte string for a given
|
|
90
|
+
* grants array.
|
|
91
|
+
*/
|
|
92
|
+
export declare function hashGrants(grants: readonly unknown[]): Promise<string>;
|
|
93
|
+
/**
|
|
94
|
+
* Assemble the per-deployment `credentialsSnapshot` from each step's
|
|
95
|
+
* `agent-state` repo. The supervisor invokes this at spawn time and
|
|
96
|
+
* again on every `grants-updated` mail. The result rides the
|
|
97
|
+
* control IPC as a single payload; the child pins each step's grants
|
|
98
|
+
* to the supplied hash so it can ignore an out-of-order push that
|
|
99
|
+
* arrives after a fresher one.
|
|
100
|
+
*/
|
|
101
|
+
export declare function assembleCredentialsSnapshot(opts: AssembleCredentialsSnapshotOpts): Promise<CredentialsSnapshot>;
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
// Per-step `credentialsSnapshot` assembly.
|
|
2
|
+
//
|
|
3
|
+
// At spawn time, the supervisor reads each workflow step's grants out
|
|
4
|
+
// of its `agent-state` repo and forwards the resulting per-step
|
|
5
|
+
// snapshot to the workflow-process over the control IPC. The snapshot
|
|
6
|
+
// never lands on disk in the child's view and is never placed in
|
|
7
|
+
// spawn-time env -- the env carries only the IPC trust anchors. The
|
|
8
|
+
// child receives credentials over the authenticated control channel
|
|
9
|
+
// once both halves are connected.
|
|
10
|
+
//
|
|
11
|
+
// Each step's snapshot is pinned to a content hash so the child can
|
|
12
|
+
// detect a stale snapshot (e.g. one that arrived after a `grants-
|
|
13
|
+
// updated` push the child already processed). The hash is the
|
|
14
|
+
// sha256 of the canonicalized JSON serialization of the snapshot's
|
|
15
|
+
// per-step `grants` array -- not the git tree SHA of the underlying
|
|
16
|
+
// `agent-state` repo's `grants` ref. The substrate's tree SHA is
|
|
17
|
+
// implementation-coupled to the path of the file inside the repo;
|
|
18
|
+
// hashing the canonical JSON gives a stable identifier the child can
|
|
19
|
+
// compare across pushes without taking a dependency on the
|
|
20
|
+
// substrate's git layout.
|
|
21
|
+
//
|
|
22
|
+
// Per-step address derivation (Q6.4 discovery decision):
|
|
23
|
+
// - Multi-step deployments use `<deploymentId>-<stepId>@<domain>`.
|
|
24
|
+
// - Trivial (single-step) deployments use the deployment's own
|
|
25
|
+
// mail address as the sole step's address.
|
|
26
|
+
// The derivation is supplied by the caller as a `deriveStepAddress`
|
|
27
|
+
// callback so the supervisor doesn't have to encode the deployment-
|
|
28
|
+
// domain into the workflow-host package.
|
|
29
|
+
import { type } from "arktype";
|
|
30
|
+
import { hexEncode } from "@intx/types";
|
|
31
|
+
/**
|
|
32
|
+
* Path inside each step's `agent-state` repo that carries the step's
|
|
33
|
+
* grants. The agent-state kind handler accepts state writes under the
|
|
34
|
+
* `state/` subtree; the grants snapshot rides at
|
|
35
|
+
* `state/grants.json` as a single canonical JSON document.
|
|
36
|
+
*/
|
|
37
|
+
export const STEP_GRANTS_PATH = "state/grants.json";
|
|
38
|
+
/**
|
|
39
|
+
* Ref the supervisor reads each step's grants from. The agent-state
|
|
40
|
+
* kind handler defines the deploy ref under `refs/heads/deploy`; the
|
|
41
|
+
* state-bearing refs the sidecar writes to use the default
|
|
42
|
+
* `refs/heads/main` convention.
|
|
43
|
+
*/
|
|
44
|
+
export const STEP_GRANTS_REF = "refs/heads/main";
|
|
45
|
+
/**
|
|
46
|
+
* Shape of the per-step grants file. The supervisor surfaces this
|
|
47
|
+
* untyped to the child -- the child's authorize layer narrows the
|
|
48
|
+
* inner `grants` entries against its own grant-rule validator.
|
|
49
|
+
*/
|
|
50
|
+
const StepGrantsFile = type({
|
|
51
|
+
grants: "unknown[]",
|
|
52
|
+
}).onUndeclaredKey("ignore");
|
|
53
|
+
/**
|
|
54
|
+
* Default mapping from `(deploymentId, stepId)` to the agent-state
|
|
55
|
+
* repo id: `<deploymentId>-<stepId>`, isolating each step's grants in
|
|
56
|
+
* its own repo. Applied to a one-step `stepOrder` it yields a single
|
|
57
|
+
* such repo. The single-step launched-agent deploy overrides this
|
|
58
|
+
* default (see `DeriveStepRepoId`) to reuse the legacy agent-state
|
|
59
|
+
* repo.
|
|
60
|
+
*/
|
|
61
|
+
export function defaultStepRepoId(args) {
|
|
62
|
+
return {
|
|
63
|
+
kind: "agent-state",
|
|
64
|
+
id: `${args.deploymentId}-${args.stepId}`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Read one step's grants file from disk via the substrate's working-
|
|
69
|
+
* tree directory. The substrate documents `getRepoDir` as a pure path
|
|
70
|
+
* computation -- the sibling production adapters (`repo-store.ts`,
|
|
71
|
+
* `spawn-child.ts`) use the same working-tree-read pattern when they
|
|
72
|
+
* need synchronous access to a ref's tip without round-tripping
|
|
73
|
+
* through the git object database.
|
|
74
|
+
*
|
|
75
|
+
* A missing grants file is treated as "no grants" (empty array) so
|
|
76
|
+
* a step whose repo carries no grants file does not crash; a
|
|
77
|
+
* malformed file does crash, because the file's presence implies
|
|
78
|
+
* the deploy orchestrator intended a snapshot and a structural
|
|
79
|
+
* failure is a programming bug at the boundary.
|
|
80
|
+
*/
|
|
81
|
+
async function readStepGrants(opts, repoId) {
|
|
82
|
+
const fs = await import("node:fs/promises");
|
|
83
|
+
const path = await import("node:path");
|
|
84
|
+
const dir = opts.repoStore.getRepoDir(repoId);
|
|
85
|
+
const filePath = path.join(dir, STEP_GRANTS_PATH);
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = await fs.readFile(filePath, "utf8");
|
|
89
|
+
}
|
|
90
|
+
catch (cause) {
|
|
91
|
+
if (isErrnoNotFound(cause))
|
|
92
|
+
return [];
|
|
93
|
+
throw cause;
|
|
94
|
+
}
|
|
95
|
+
let parsed;
|
|
96
|
+
try {
|
|
97
|
+
parsed = JSON.parse(raw);
|
|
98
|
+
}
|
|
99
|
+
catch (cause) {
|
|
100
|
+
throw new Error(`supervisor credentialsSnapshot: ${repoId.kind}/${repoId.id}:${STEP_GRANTS_PATH} is not valid JSON`, { cause });
|
|
101
|
+
}
|
|
102
|
+
const validated = StepGrantsFile(parsed);
|
|
103
|
+
if (validated instanceof type.errors) {
|
|
104
|
+
throw new Error(`supervisor credentialsSnapshot: ${repoId.kind}/${repoId.id}:${STEP_GRANTS_PATH} failed validation: ${validated.summary}`);
|
|
105
|
+
}
|
|
106
|
+
return validated.grants;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Compute the per-step content hash used to pin a credentialsSnapshot
|
|
110
|
+
* push to a specific grants payload. Stable across processes because
|
|
111
|
+
* the JSON.stringify pass produces the same byte string for a given
|
|
112
|
+
* grants array.
|
|
113
|
+
*/
|
|
114
|
+
export async function hashGrants(grants) {
|
|
115
|
+
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(JSON.stringify(grants)));
|
|
116
|
+
return hexEncode(new Uint8Array(digest));
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Assemble the per-deployment `credentialsSnapshot` from each step's
|
|
120
|
+
* `agent-state` repo. The supervisor invokes this at spawn time and
|
|
121
|
+
* again on every `grants-updated` mail. The result rides the
|
|
122
|
+
* control IPC as a single payload; the child pins each step's grants
|
|
123
|
+
* to the supplied hash so it can ignore an out-of-order push that
|
|
124
|
+
* arrives after a fresher one.
|
|
125
|
+
*/
|
|
126
|
+
export async function assembleCredentialsSnapshot(opts) {
|
|
127
|
+
const deriveRepoId = opts.deriveStepRepoId ?? defaultStepRepoId;
|
|
128
|
+
const steps = [];
|
|
129
|
+
for (const stepId of opts.stepOrder) {
|
|
130
|
+
const repoId = deriveRepoId({
|
|
131
|
+
deploymentId: opts.deploymentId,
|
|
132
|
+
stepId,
|
|
133
|
+
});
|
|
134
|
+
const grants = await readStepGrants(opts, repoId);
|
|
135
|
+
const address = opts.deriveStepAddress({
|
|
136
|
+
deploymentId: opts.deploymentId,
|
|
137
|
+
stepId,
|
|
138
|
+
});
|
|
139
|
+
steps.push({
|
|
140
|
+
stepId,
|
|
141
|
+
address,
|
|
142
|
+
grants,
|
|
143
|
+
contentHash: await hashGrants(grants),
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
return { steps };
|
|
147
|
+
}
|
|
148
|
+
function isErrnoNotFound(cause) {
|
|
149
|
+
if (cause === null || typeof cause !== "object")
|
|
150
|
+
return false;
|
|
151
|
+
const code = cause.code;
|
|
152
|
+
return code === "ENOENT";
|
|
153
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { DispatchStructuralCounters } from "./types.js";
|
|
2
|
+
/**
|
|
3
|
+
* Sample the four structural counters against the workflow-run repo's
|
|
4
|
+
* on-disk working tree. Cheap filesystem reads; invoked only when the
|
|
5
|
+
* D2 observer is wired, at each leg's `"end"` mark, so the per-leg slope
|
|
6
|
+
* can be correlated with the grower that explains it.
|
|
7
|
+
*/
|
|
8
|
+
export declare function sampleStructuralCounters(repoDir: string): DispatchStructuralCounters;
|
|
9
|
+
/**
|
|
10
|
+
* Measurement-only forced-repack toggle for the §10c A/B. When the boot
|
|
11
|
+
* edge supplies a non-zero `everyMessages`, the supervisor invokes
|
|
12
|
+
* `maybeRepack` once per dispatched message (after `markConsumed`, still
|
|
13
|
+
* under the single-writer discipline -- the dispatch loop processes one
|
|
14
|
+
* message at a time and no concurrent commit is in flight at that point),
|
|
15
|
+
* and every `everyMessages`-th message forces a `git gc`/repack of the
|
|
16
|
+
* workflow-run repo. If forcing a repack flattens the per-leg slope, the
|
|
17
|
+
* cost is loose-object/pack growth (cheap pack/gc fix); if it does not,
|
|
18
|
+
* the cost is the per-commit root-tree rewrite scaling with `runs/` +
|
|
19
|
+
* `consumed/` fan-out (run-model change). Absent toggle => never repacks.
|
|
20
|
+
*/
|
|
21
|
+
export type RepackToggle = {
|
|
22
|
+
everyMessages: number;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Force a synchronous `git gc` of the repo at `repoDir`. Synchronous
|
|
26
|
+
* (`spawnSync`) so the supervisor's single-writer invariant is trivially
|
|
27
|
+
* preserved: the dispatch loop is the sole caller and blocks here, so no
|
|
28
|
+
* `writeTreePreservingPrefix` commit can interleave with the repack.
|
|
29
|
+
* Returns the wall-clock duration in ms and whether the gc succeeded;
|
|
30
|
+
* a non-zero exit is surfaced (not swallowed) so a broken A/B run does
|
|
31
|
+
* not silently report "repack had no effect".
|
|
32
|
+
*/
|
|
33
|
+
export declare function forceRepack(repoDir: string): {
|
|
34
|
+
ok: boolean;
|
|
35
|
+
durationMs: number;
|
|
36
|
+
detail: string;
|
|
37
|
+
};
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
// D2 per-leg substrate-attribution instrumentation (measurement-only).
|
|
2
|
+
//
|
|
3
|
+
// This module is the off-by-default observability + A/B surface the D2
|
|
4
|
+
// latency re-attribution drives. NONE of it runs in production: the
|
|
5
|
+
// supervisor only reaches these helpers when a `onDispatchTiming`
|
|
6
|
+
// observer is wired (the same env-gated seam the 4.7 latency gate added)
|
|
7
|
+
// and only consults the repack toggle when the boot edge supplies a
|
|
8
|
+
// non-zero repack interval. With the observer unwired and the toggle
|
|
9
|
+
// absent, the supervisor samples no clock, reads no directory, and forks
|
|
10
|
+
// no `git gc` -- the dispatch path is byte-for-byte unchanged.
|
|
11
|
+
//
|
|
12
|
+
// The counters are deliberately filesystem-level reads against the
|
|
13
|
+
// workflow-run repo's on-disk working tree (resolved via
|
|
14
|
+
// `RepoStore.getRepoDir`, a pure path computation), NOT reaches into
|
|
15
|
+
// isogit internals. That keeps the attribution honest about what it can
|
|
16
|
+
// observe cheaply and avoids coupling the measurement to the storage
|
|
17
|
+
// layer's private object model. `runs/` and `addresses/<addr>/consumed/`
|
|
18
|
+
// fan-out is exactly the never-pruned tree growth the design (§9)
|
|
19
|
+
// implicates; loose-object count + `.git` byte size are the pack-growth
|
|
20
|
+
// proxies the §10c repack A/B discriminates against.
|
|
21
|
+
import { spawnSync } from "node:child_process";
|
|
22
|
+
import fs from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import { countLooseObjects, gitBytes } from "@intx/storage-isogit";
|
|
25
|
+
const RUNS_DIR = "runs";
|
|
26
|
+
const ADDRESSES_DIR = "addresses";
|
|
27
|
+
const CONSUMED_DIR = "consumed";
|
|
28
|
+
/**
|
|
29
|
+
* Count the immediate child entries of `dir`. Returns 0 when the
|
|
30
|
+
* directory does not exist yet (the repo's first commit has not created
|
|
31
|
+
* the subtree) -- absence is a real "fan-out is zero", not an error to
|
|
32
|
+
* surface, because the sampler runs on every leg including ones that fire
|
|
33
|
+
* before the subtree exists.
|
|
34
|
+
*/
|
|
35
|
+
function countEntries(dir) {
|
|
36
|
+
let entries;
|
|
37
|
+
try {
|
|
38
|
+
entries = fs.readdirSync(dir);
|
|
39
|
+
}
|
|
40
|
+
catch (cause) {
|
|
41
|
+
if (cause instanceof Error &&
|
|
42
|
+
cause.code === "ENOENT") {
|
|
43
|
+
return 0;
|
|
44
|
+
}
|
|
45
|
+
throw cause;
|
|
46
|
+
}
|
|
47
|
+
return entries.length;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Total entries under every `addresses/<segment>/consumed/`. The address
|
|
51
|
+
* segment is `urlEncoded(address)` on disk; rather than re-deriving the
|
|
52
|
+
* exact encoding (and risking drift from the kind handler), enumerate the
|
|
53
|
+
* address segments present and sum each one's `consumed/` fan-out. For a
|
|
54
|
+
* single warm deployment there is exactly one segment, but the sum is
|
|
55
|
+
* correct for any number.
|
|
56
|
+
*/
|
|
57
|
+
function countConsumed(repoDir) {
|
|
58
|
+
const addressesDir = path.join(repoDir, ADDRESSES_DIR);
|
|
59
|
+
let segments;
|
|
60
|
+
try {
|
|
61
|
+
segments = fs.readdirSync(addressesDir);
|
|
62
|
+
}
|
|
63
|
+
catch (cause) {
|
|
64
|
+
if (cause instanceof Error &&
|
|
65
|
+
cause.code === "ENOENT") {
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
throw cause;
|
|
69
|
+
}
|
|
70
|
+
let total = 0;
|
|
71
|
+
for (const segment of segments) {
|
|
72
|
+
total += countEntries(path.join(addressesDir, segment, CONSUMED_DIR));
|
|
73
|
+
}
|
|
74
|
+
return total;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Sample the four structural counters against the workflow-run repo's
|
|
78
|
+
* on-disk working tree. Cheap filesystem reads; invoked only when the
|
|
79
|
+
* D2 observer is wired, at each leg's `"end"` mark, so the per-leg slope
|
|
80
|
+
* can be correlated with the grower that explains it.
|
|
81
|
+
*/
|
|
82
|
+
export function sampleStructuralCounters(repoDir) {
|
|
83
|
+
return {
|
|
84
|
+
runsFanOut: countEntries(path.join(repoDir, RUNS_DIR)),
|
|
85
|
+
consumedFanOut: countConsumed(repoDir),
|
|
86
|
+
looseObjects: countLooseObjects(repoDir),
|
|
87
|
+
gitBytes: gitBytes(repoDir),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Force a synchronous `git gc` of the repo at `repoDir`. Synchronous
|
|
92
|
+
* (`spawnSync`) so the supervisor's single-writer invariant is trivially
|
|
93
|
+
* preserved: the dispatch loop is the sole caller and blocks here, so no
|
|
94
|
+
* `writeTreePreservingPrefix` commit can interleave with the repack.
|
|
95
|
+
* Returns the wall-clock duration in ms and whether the gc succeeded;
|
|
96
|
+
* a non-zero exit is surfaced (not swallowed) so a broken A/B run does
|
|
97
|
+
* not silently report "repack had no effect".
|
|
98
|
+
*/
|
|
99
|
+
export function forceRepack(repoDir) {
|
|
100
|
+
const t0 = performance.now();
|
|
101
|
+
const result = spawnSync("git", ["-C", repoDir, "gc", "--quiet", "--prune=now"], { encoding: "utf8" });
|
|
102
|
+
const durationMs = performance.now() - t0;
|
|
103
|
+
if (result.error !== undefined) {
|
|
104
|
+
return { ok: false, durationMs, detail: result.error.message };
|
|
105
|
+
}
|
|
106
|
+
if (result.status !== 0) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
durationMs,
|
|
110
|
+
detail: `git gc exited ${String(result.status)}: ${result.stderr}`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { ok: true, durationMs, detail: "" };
|
|
114
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { PrincipalSigner, TerminalEventSource } from "./types.js";
|
|
2
|
+
import type { RepoId, RepoStore as SubstrateRepoStore } from "@intx/hub-sessions/substrate";
|
|
3
|
+
/**
|
|
4
|
+
* Default `drainTimeout` per deployment. Operators override this via
|
|
5
|
+
* a per-deployment policy; this value is the spec's locked default.
|
|
6
|
+
*/
|
|
7
|
+
export declare const DEFAULT_DRAIN_TIMEOUT_MS = 60000;
|
|
8
|
+
export type DrainTimeoutOpts = {
|
|
9
|
+
/**
|
|
10
|
+
* Substrate handle through which the escalation commit lands.
|
|
11
|
+
* Reused from the supervisor's bindings -- the accumulator does
|
|
12
|
+
* not own a substrate of its own.
|
|
13
|
+
*/
|
|
14
|
+
substrate: SubstrateRepoStore;
|
|
15
|
+
/** Workflow-run repo for this deployment. */
|
|
16
|
+
repoId: RepoId;
|
|
17
|
+
/** Workflow-run repo ref the supervisor commits events to. */
|
|
18
|
+
ref: string;
|
|
19
|
+
/** Deployment id baked into the supervisor's signing principal. */
|
|
20
|
+
deploymentId: string;
|
|
21
|
+
/** Run id the drain is being escalated against. */
|
|
22
|
+
runId: string;
|
|
23
|
+
/**
|
|
24
|
+
* Host-supplied per-principal signing callback the supervisor uses
|
|
25
|
+
* to mint the CancelRequested signature. The drain origin is
|
|
26
|
+
* `supervisor-drain`.
|
|
27
|
+
*/
|
|
28
|
+
signAsPrincipal: PrincipalSigner;
|
|
29
|
+
/** ISO-8601 reason for the CancelRequested escalation. */
|
|
30
|
+
reason?: string;
|
|
31
|
+
/**
|
|
32
|
+
* Operator-overridable per-deployment timeout. Defaults to
|
|
33
|
+
* `DEFAULT_DRAIN_TIMEOUT_MS`.
|
|
34
|
+
*/
|
|
35
|
+
drainTimeoutMs?: number;
|
|
36
|
+
/**
|
|
37
|
+
* Deterministic clock for the accumulator. Tests inject a
|
|
38
|
+
* controllable clock; production wires `() => Date.now()`.
|
|
39
|
+
*/
|
|
40
|
+
now: () => number;
|
|
41
|
+
/**
|
|
42
|
+
* Scheduling primitive the accumulator uses for its tick callback.
|
|
43
|
+
* Tests inject a deterministic timer; production wires
|
|
44
|
+
* `(cb, ms) => setTimeout(cb, ms)`.
|
|
45
|
+
*/
|
|
46
|
+
setTimer: (cb: () => void, ms: number) => unknown;
|
|
47
|
+
/**
|
|
48
|
+
* Disposer paired with `setTimer`. Production wires
|
|
49
|
+
* `(handle) => clearTimeout(handle as ReturnType<typeof setTimeout>)`.
|
|
50
|
+
*/
|
|
51
|
+
clearTimer: (handle: unknown) => void;
|
|
52
|
+
/**
|
|
53
|
+
* Optional per-runId terminal-event source the accumulator consumes
|
|
54
|
+
* to settle early when the tracked run reaches a terminal phase
|
|
55
|
+
* before the drainTimeout fires. When absent, the accumulator falls
|
|
56
|
+
* back to timer-only settlement (the pre-binding behaviour). The
|
|
57
|
+
* supervisor wires this against its `terminalEventSource` binding;
|
|
58
|
+
* the iterator is finalised via `return()` whenever the accumulator
|
|
59
|
+
* settles (terminal, timeout, or `stop()`).
|
|
60
|
+
*/
|
|
61
|
+
terminalEventSource?: TerminalEventSource;
|
|
62
|
+
};
|
|
63
|
+
/**
|
|
64
|
+
* Handle the supervisor holds onto so it can pause/resume ticking
|
|
65
|
+
* during step-kind transitions, and so it can stop the accumulator
|
|
66
|
+
* cleanly when the run reaches a terminal phase before the
|
|
67
|
+
* drainTimeout expires.
|
|
68
|
+
*/
|
|
69
|
+
export interface DrainTimeoutAccumulator {
|
|
70
|
+
/**
|
|
71
|
+
* Begin counting cancel-time. Idempotent; a second call after the
|
|
72
|
+
* accumulator has already started is a no-op so the supervisor's
|
|
73
|
+
* control loop can re-issue drain without breaking ordering.
|
|
74
|
+
*/
|
|
75
|
+
start(): void;
|
|
76
|
+
/**
|
|
77
|
+
* Pause the accumulator. The supervisor calls this when its view
|
|
78
|
+
* of the workflow's in-flight steps transitions from cancel-mode
|
|
79
|
+
* to wait-mode (e.g., the last cancel-mode step completed and only
|
|
80
|
+
* `awaitSignal` steps remain). Time spent paused does not count
|
|
81
|
+
* toward the timeout.
|
|
82
|
+
*/
|
|
83
|
+
pause(): void;
|
|
84
|
+
/**
|
|
85
|
+
* Resume the accumulator. Inverse of `pause`. Time elapsed since
|
|
86
|
+
* the last `start` or `resume` carries forward -- pause/resume is
|
|
87
|
+
* about wall-clock attribution, not zeroing the count.
|
|
88
|
+
*/
|
|
89
|
+
resume(): void;
|
|
90
|
+
/**
|
|
91
|
+
* Stop the accumulator without escalating. Called when the run
|
|
92
|
+
* reaches a terminal phase before drainTimeout expires.
|
|
93
|
+
*/
|
|
94
|
+
stop(): void;
|
|
95
|
+
/** Total accumulated cancel-time at the current moment, in ms. */
|
|
96
|
+
accumulatedMs(): number;
|
|
97
|
+
/** Whether the accumulator has already escalated. */
|
|
98
|
+
readonly escalated: boolean;
|
|
99
|
+
/**
|
|
100
|
+
* Resolves when every asynchronous resource the accumulator owns has
|
|
101
|
+
* been disposed: the escalation commit (if armed) has finished, and
|
|
102
|
+
* the per-runId terminal-event watcher (if armed) has had its
|
|
103
|
+
* iterator finalised. The supervisor awaits this in `shutdown` so a
|
|
104
|
+
* still-running watcher cannot fire a settle against a torn-down
|
|
105
|
+
* supervisor.
|
|
106
|
+
*
|
|
107
|
+
* Idempotent: calling `disposed()` multiple times returns the same
|
|
108
|
+
* promise. Resolves once `stop()` or the timer-driven escalation has
|
|
109
|
+
* run.
|
|
110
|
+
*/
|
|
111
|
+
disposed(): Promise<void>;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* Factory shape the supervisor binds against. Mirrors
|
|
115
|
+
* `createDrainTimeoutAccumulator` exactly; surfaced so tests can
|
|
116
|
+
* inject a mock factory through `WorkflowSupervisorBindings.
|
|
117
|
+
* drainTimeoutAccumulatorFactory` and observe the supervisor's
|
|
118
|
+
* drain-arming sequence without rigging a real timer host.
|
|
119
|
+
*/
|
|
120
|
+
export type DrainTimeoutAccumulatorFactory = (opts: DrainTimeoutOpts) => DrainTimeoutAccumulator;
|
|
121
|
+
/**
|
|
122
|
+
* Construct the supervisor's drainTimeout accumulator. The returned
|
|
123
|
+
* handle is single-use: once `escalated` flips true the accumulator
|
|
124
|
+
* has committed `CancelRequested{origin: "supervisor-drain"}` and
|
|
125
|
+
* cannot tick again.
|
|
126
|
+
*/
|
|
127
|
+
export declare function createDrainTimeoutAccumulator(opts: DrainTimeoutOpts): DrainTimeoutAccumulator;
|