@relayflows/sdk 2.0.26 → 2.0.28
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/dist/authored-flow-executor.d.ts +8 -1
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +16 -7
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-lifecycle.d.ts +68 -1
- package/dist/authored-flow-lifecycle.d.ts.map +1 -1
- package/dist/authored-flow-lifecycle.js +108 -10
- package/dist/authored-flow-lifecycle.js.map +1 -1
- package/dist/authored-flow-operation.d.ts +9 -1
- package/dist/authored-flow-operation.d.ts.map +1 -1
- package/dist/authored-flow-operation.js +7 -1
- package/dist/authored-flow-operation.js.map +1 -1
- package/dist/authored-node-entry.js +4 -0
- package/dist/authored-node-entry.js.map +1 -1
- package/dist/authored-node-runner.d.ts.map +1 -1
- package/dist/authored-node-runner.js +2 -1
- package/dist/authored-node-runner.js.map +1 -1
- package/dist/authored-promise-graph.d.ts +19 -4
- package/dist/authored-promise-graph.d.ts.map +1 -1
- package/dist/authored-promise-graph.js +44 -19
- package/dist/authored-promise-graph.js.map +1 -1
- package/dist/authored-root.d.ts +2 -0
- package/dist/authored-root.d.ts.map +1 -1
- package/dist/authored-root.js +2 -0
- package/dist/authored-root.js.map +1 -1
- package/dist/authored-step-graph.d.ts +83 -0
- package/dist/authored-step-graph.d.ts.map +1 -0
- package/dist/authored-step-graph.js +217 -0
- package/dist/authored-step-graph.js.map +1 -0
- package/dist/authored-step-index.d.ts +30 -0
- package/dist/authored-step-index.d.ts.map +1 -1
- package/dist/authored-step-index.js +26 -1
- package/dist/authored-step-index.js.map +1 -1
- package/dist/authored-step-output.d.ts +3 -0
- package/dist/authored-step-output.d.ts.map +1 -1
- package/dist/authored-step-output.js +4 -2
- package/dist/authored-step-output.js.map +1 -1
- package/dist/authored-worker-step.d.ts +3 -1
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +25 -6
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +5 -2
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +2 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +9 -5
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/step-excerpt.d.ts +48 -0
- package/dist/cli/step-excerpt.d.ts.map +1 -0
- package/dist/cli/step-excerpt.js +302 -0
- package/dist/cli/step-excerpt.js.map +1 -0
- package/dist/cli/step-failure.d.ts.map +1 -1
- package/dist/cli/step-failure.js +9 -20
- package/dist/cli/step-failure.js.map +1 -1
- package/dist/cli-commands.d.ts +8 -0
- package/dist/cli-commands.d.ts.map +1 -1
- package/dist/cli-commands.js +6 -0
- package/dist/cli-commands.js.map +1 -1
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +38 -7
- package/dist/cli.js.map +1 -1
- package/dist/cloud-transcript-codex.d.ts +42 -0
- package/dist/cloud-transcript-codex.d.ts.map +1 -0
- package/dist/cloud-transcript-codex.js +411 -0
- package/dist/cloud-transcript-codex.js.map +1 -0
- package/dist/cloud-transcript-types.d.ts +161 -0
- package/dist/cloud-transcript-types.d.ts.map +1 -0
- package/dist/cloud-transcript-types.js +47 -0
- package/dist/cloud-transcript-types.js.map +1 -0
- package/dist/cloud-transcript.d.ts +2 -78
- package/dist/cloud-transcript.d.ts.map +1 -1
- package/dist/cloud-transcript.js +36 -38
- package/dist/cloud-transcript.js.map +1 -1
- package/dist/failure-kinds.d.ts +10 -2
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/journal-client.d.ts +8 -2
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +22 -6
- package/dist/journal-client.js.map +1 -1
- package/dist/llm-worker.d.ts +2 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +5 -2
- package/dist/llm-worker.js.map +1 -1
- package/dist/local-agent.d.ts +1 -1
- package/dist/local-agent.d.ts.map +1 -1
- package/dist/local-agent.js +8 -3
- package/dist/local-agent.js.map +1 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +29 -3
- package/dist/preflight.js.map +1 -1
- package/dist/promise-ancestry.d.ts +20 -0
- package/dist/promise-ancestry.d.ts.map +1 -0
- package/dist/promise-ancestry.js +104 -0
- package/dist/promise-ancestry.js.map +1 -0
- package/dist/validate.d.ts.map +1 -1
- package/dist/validate.js +7 -0
- package/dist/validate.js.map +1 -1
- package/dist/worker-cli.d.ts +7 -0
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +42 -10
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-slots.d.ts +42 -0
- package/dist/worker-slots.d.ts.map +1 -0
- package/dist/worker-slots.js +94 -0
- package/dist/worker-slots.js.map +1 -0
- package/package.json +2 -2
- package/src/authored-flow-executor.ts +22 -3
- package/src/authored-flow-lifecycle.ts +111 -16
- package/src/authored-flow-operation.ts +10 -0
- package/src/authored-node-entry.ts +4 -1
- package/src/authored-node-runner.ts +2 -1
- package/src/authored-promise-graph.ts +46 -19
- package/src/authored-root.ts +4 -0
- package/src/authored-step-graph.ts +246 -0
- package/src/authored-step-index.ts +44 -1
- package/src/authored-step-output.ts +7 -3
- package/src/authored-worker-step.ts +25 -6
- package/src/cli/direct-run.ts +5 -2
- package/src/cli/run.ts +11 -4
- package/src/cli/step-excerpt.ts +332 -0
- package/src/cli/step-failure.ts +9 -20
- package/src/cli-commands.ts +6 -0
- package/src/cli.ts +40 -9
- package/src/cloud-transcript-codex.ts +429 -0
- package/src/cloud-transcript-types.ts +214 -0
- package/src/cloud-transcript.ts +44 -136
- package/src/failure-kinds.ts +10 -2
- package/src/journal-client.ts +25 -6
- package/src/llm-worker.ts +7 -2
- package/src/local-agent.ts +8 -2
- package/src/preflight.ts +37 -2
- package/src/promise-ancestry.ts +98 -0
- package/src/validate.ts +7 -0
- package/src/worker-cli.ts +41 -9
- package/src/worker-slots.ts +92 -0
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
AuthoredFlowLifecycle,
|
|
6
6
|
type AuthoredOperationInvocation,
|
|
7
7
|
} from './authored-flow-lifecycle.js';
|
|
8
|
+
import type { AuthoredStepEdges } from './authored-step-index.js';
|
|
8
9
|
|
|
9
10
|
/** Slice-P kinds the surface `.gate(config)` accepts and the SDK lowers. */
|
|
10
11
|
const NAMED_GATE_KINDS = new Set([
|
|
@@ -39,6 +40,11 @@ export class AuthoredFlowOperation<T> {
|
|
|
39
40
|
* function itself is never serialized. `flows check` cannot prove it.
|
|
40
41
|
*/
|
|
41
42
|
predicateGate: { predicate: (value: T) => boolean; because?: string } | undefined = undefined;
|
|
43
|
+
/**
|
|
44
|
+
* This step's label and causal predecessors in the run's DAG, captured when
|
|
45
|
+
* it was invoked. Undefined for an operation the step index does not draw.
|
|
46
|
+
*/
|
|
47
|
+
readonly edges: AuthoredStepEdges | undefined;
|
|
42
48
|
private state: OperationState = 'created';
|
|
43
49
|
private thenInvoked = false;
|
|
44
50
|
private rootFailureRecorded = false;
|
|
@@ -55,6 +61,7 @@ export class AuthoredFlowOperation<T> {
|
|
|
55
61
|
private readonly assertCanStart: () => void,
|
|
56
62
|
private readonly start: () => Promise<T>,
|
|
57
63
|
private readonly scope: AuthoredFlowLifecycle,
|
|
64
|
+
node?: { readonly label?: string },
|
|
58
65
|
) {
|
|
59
66
|
let resolve!: (value: T | PromiseLike<T>) => void;
|
|
60
67
|
let reject!: (reason?: unknown) => void;
|
|
@@ -64,6 +71,9 @@ export class AuthoredFlowOperation<T> {
|
|
|
64
71
|
});
|
|
65
72
|
this.resolve = resolve;
|
|
66
73
|
this.reject = reject;
|
|
74
|
+
this.edges = scope.registerOperation(
|
|
75
|
+
this, this.promise, node === undefined ? undefined : { step: id, ...node },
|
|
76
|
+
);
|
|
67
77
|
|
|
68
78
|
observeRejection(this.promise, (error) => this.recordRootFailure(error));
|
|
69
79
|
const operation = this;
|
|
@@ -6,6 +6,7 @@ import { executeAuthoredFlow } from './authored-flow-executor.js';
|
|
|
6
6
|
import { loadPinnedAuthoredSource } from './authored-source-authority.js';
|
|
7
7
|
import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-runtime-capability.js';
|
|
8
8
|
import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
|
|
9
|
+
import { isAgentCapacity } from './worker-slots.js';
|
|
9
10
|
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
10
11
|
|
|
11
12
|
let channelKey: string | undefined, sequence = 0;
|
|
@@ -48,7 +49,7 @@ try {
|
|
|
48
49
|
send({ type: 'ready', runtime: { kind: 'node', version: process.versions.node,
|
|
49
50
|
executableSha256: hash(process.execPath), payloadSha256: hash(process.argv[1]!) } });
|
|
50
51
|
const request = await new Promise<{ channelKey: string; metadata: AuthoredRootMetadata; socketPath: string;
|
|
51
|
-
rootRunId: string; dataDir: string; localAgentStream?: string }>((resolve, reject) => {
|
|
52
|
+
rootRunId: string; dataDir: string; localAgentStream?: string; workerCapacity?: number }>((resolve, reject) => {
|
|
52
53
|
let buffer = '';
|
|
53
54
|
process.stdin.setEncoding('utf8');
|
|
54
55
|
const onData = (chunk: string): void => {
|
|
@@ -66,6 +67,7 @@ try {
|
|
|
66
67
|
controller.signal.throwIfAborted();
|
|
67
68
|
const loaded = await loadPinnedAuthoredSource(request.metadata, true);
|
|
68
69
|
if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
|
|
70
|
+
if (request.workerCapacity !== undefined && !isAgentCapacity(request.workerCapacity)) throw new Error('invalid authored worker capacity');
|
|
69
71
|
client = new JournalClient(request.socketPath);
|
|
70
72
|
await client.connect(); await client.hello('flows-authored-node');
|
|
71
73
|
const result = await executeAuthoredFlow(loaded.handle, client,
|
|
@@ -74,6 +76,7 @@ try {
|
|
|
74
76
|
flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
|
|
75
77
|
extensions: loaded.extensions,
|
|
76
78
|
localAgentStream: request.localAgentStream, signal: controller.signal,
|
|
79
|
+
...(request.workerCapacity === undefined ? {} : { workerCapacity: request.workerCapacity }),
|
|
77
80
|
onProgress: event => send({ type: 'progress', event }),
|
|
78
81
|
onWait: event => send({ type: 'wait', event }),
|
|
79
82
|
});
|
|
@@ -130,7 +130,8 @@ export async function runAuthoredInNode(
|
|
|
130
130
|
clearTimeout(startupTimer);
|
|
131
131
|
// Keep stdin open: EOF tells the child its lease-owning parent died.
|
|
132
132
|
child.stdin!.write(JSON.stringify({ channelKey, metadata, socketPath, rootRunId,
|
|
133
|
-
dataDir: options.dataDir, localAgentStream: options.localAgentStream
|
|
133
|
+
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
|
|
134
|
+
workerCapacity: options.workerCapacity }) + '\n');
|
|
134
135
|
} else if (!ready || result) throw new Error('unexpected authored runtime message');
|
|
135
136
|
else if (message.type === 'progress') options.onProgress?.(message.event);
|
|
136
137
|
else if (message.type === 'wait') options.onWait?.(message.event);
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createHook, executionAsyncId, type AsyncHook } from 'node:async_hooks';
|
|
2
|
+
import { reachableTargets } from './promise-ancestry.js';
|
|
2
3
|
|
|
3
4
|
/**
|
|
4
5
|
* The promise graph an authored flow body actually creates.
|
|
@@ -35,7 +36,9 @@ export class AuthoredPromiseGraph {
|
|
|
35
36
|
private readonly attributedRoots = new Map<number, number>();
|
|
36
37
|
private readonly roots = new Set<number>();
|
|
37
38
|
private readonly pending = new Set<number>();
|
|
38
|
-
private dependencyCache: { readonly start: number; readonly found: ReadonlySet<number
|
|
39
|
+
private dependencyCache: { readonly start: number; readonly found: ReadonlySet<number>; readonly version: number } | undefined;
|
|
40
|
+
/** Bumped by every graph change, so derived answers can be cached between changes. */
|
|
41
|
+
private changes = 0;
|
|
39
42
|
|
|
40
43
|
constructor(
|
|
41
44
|
private readonly inScope: () => boolean,
|
|
@@ -45,6 +48,7 @@ export class AuthoredPromiseGraph {
|
|
|
45
48
|
init: (asyncId, type, triggerAsyncId, resource) => {
|
|
46
49
|
if (type !== 'PROMISE' || typeof resource !== 'object' || resource === null) return;
|
|
47
50
|
if (!this.inScope()) return;
|
|
51
|
+
this.changes++;
|
|
48
52
|
this.triggers.set(asyncId, triggerAsyncId);
|
|
49
53
|
this.creationContexts.set(asyncId, executionAsyncId());
|
|
50
54
|
this.promiseIds.set(resource, asyncId);
|
|
@@ -59,6 +63,7 @@ export class AuthoredPromiseGraph {
|
|
|
59
63
|
promiseResolve: (asyncId) => {
|
|
60
64
|
this.onPromiseResolve(asyncId);
|
|
61
65
|
if (!this.triggers.has(asyncId)) return;
|
|
66
|
+
this.changes++;
|
|
62
67
|
const cause = executionAsyncId();
|
|
63
68
|
if (cause !== asyncId) this.resolutionCauses.set(asyncId, cause);
|
|
64
69
|
this.pending.delete(asyncId);
|
|
@@ -98,6 +103,7 @@ export class AuthoredPromiseGraph {
|
|
|
98
103
|
/** Mark a promise whose descendants belong to an authored operation. */
|
|
99
104
|
registerRoot(asyncId: number): void {
|
|
100
105
|
if (asyncId <= 0 || this.roots.has(asyncId)) return;
|
|
106
|
+
this.changes++;
|
|
101
107
|
this.roots.add(asyncId);
|
|
102
108
|
const parked = this.unattributedChildren.get(asyncId);
|
|
103
109
|
if (parked === undefined) return;
|
|
@@ -105,31 +111,58 @@ export class AuthoredPromiseGraph {
|
|
|
105
111
|
for (const child of parked) this.attribute(child, asyncId);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
|
-
/**
|
|
109
|
-
|
|
110
|
-
const found
|
|
114
|
+
/** The roots that still have derived promises in flight. One pass over `pending`. */
|
|
115
|
+
rootsInFlight(): Set<number> {
|
|
116
|
+
const found = new Set<number>();
|
|
111
117
|
for (const asyncId of this.pending) {
|
|
112
118
|
const root = this.attributedRoots.get(asyncId);
|
|
113
|
-
if (root !== undefined
|
|
119
|
+
if (root !== undefined) found.add(root);
|
|
114
120
|
}
|
|
115
121
|
return found;
|
|
116
122
|
}
|
|
117
123
|
|
|
118
|
-
/**
|
|
119
|
-
|
|
120
|
-
const found: Promise<unknown>
|
|
124
|
+
/** Every settled derived promise with its root, in attribution order. One pass. */
|
|
125
|
+
settledWithRoots(): Array<{ readonly root: number; readonly handle: Promise<unknown> }> {
|
|
126
|
+
const found: Array<{ root: number; handle: Promise<unknown> }> = [];
|
|
121
127
|
for (const [asyncId, root] of this.attributedRoots) {
|
|
122
|
-
if (
|
|
128
|
+
if (this.pending.has(asyncId)) continue;
|
|
123
129
|
const handle = this.handles.get(asyncId);
|
|
124
|
-
if (handle !== undefined) found.push(handle);
|
|
130
|
+
if (handle !== undefined) found.push({ root, handle });
|
|
125
131
|
}
|
|
126
132
|
return found;
|
|
127
133
|
}
|
|
128
134
|
|
|
135
|
+
/** The three recorded edges out of one promise, for walks that stop early. */
|
|
136
|
+
causesOf(asyncId: number): number[] {
|
|
137
|
+
const causes: number[] = [];
|
|
138
|
+
for (const edge of [
|
|
139
|
+
this.triggers.get(asyncId),
|
|
140
|
+
this.resolutionCauses.get(asyncId),
|
|
141
|
+
this.creationContexts.get(asyncId),
|
|
142
|
+
]) {
|
|
143
|
+
if (edge !== undefined && edge !== asyncId) causes.push(edge);
|
|
144
|
+
}
|
|
145
|
+
return causes;
|
|
146
|
+
}
|
|
147
|
+
|
|
129
148
|
dependsOn(descendant: number, ancestor: number): boolean {
|
|
130
149
|
return this.dependenciesOf(descendant).has(ancestor);
|
|
131
150
|
}
|
|
132
151
|
|
|
152
|
+
/** A counter that changes whenever the graph does; equal values mean equal answers. */
|
|
153
|
+
get version(): number {
|
|
154
|
+
return this.changes;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* `dependsOn(start, target)` for every pair at once: for each start, the
|
|
159
|
+
* targets it depends on. One pass over the shared ancestry instead of one
|
|
160
|
+
* walk per pair (see promise-ancestry.ts).
|
|
161
|
+
*/
|
|
162
|
+
dependenciesAmong(starts: readonly number[], targets: readonly number[]): Map<number, ReadonlySet<number>> {
|
|
163
|
+
return reachableTargets(starts, targets, (node) => this.causesOf(node));
|
|
164
|
+
}
|
|
165
|
+
|
|
133
166
|
clear(): void {
|
|
134
167
|
this.triggers.clear();
|
|
135
168
|
this.creationContexts.clear();
|
|
@@ -175,22 +208,16 @@ export class AuthoredPromiseGraph {
|
|
|
175
208
|
*/
|
|
176
209
|
private dependenciesOf(start: number): ReadonlySet<number> {
|
|
177
210
|
const cached = this.dependencyCache;
|
|
178
|
-
if (cached !== undefined && cached.start === start) return cached.found;
|
|
211
|
+
if (cached !== undefined && cached.start === start && cached.version === this.changes) return cached.found;
|
|
179
212
|
const found = new Set<number>();
|
|
180
213
|
const stack = [start];
|
|
181
214
|
while (stack.length > 0) {
|
|
182
215
|
const current = stack.pop()!;
|
|
183
216
|
if (found.has(current)) continue;
|
|
184
217
|
found.add(current);
|
|
185
|
-
for (const edge of
|
|
186
|
-
this.triggers.get(current),
|
|
187
|
-
this.resolutionCauses.get(current),
|
|
188
|
-
this.creationContexts.get(current),
|
|
189
|
-
]) {
|
|
190
|
-
if (edge !== undefined && edge !== current) stack.push(edge);
|
|
191
|
-
}
|
|
218
|
+
for (const edge of this.causesOf(current)) stack.push(edge);
|
|
192
219
|
}
|
|
193
|
-
this.dependencyCache = { start, found };
|
|
220
|
+
this.dependencyCache = { start, found, version: this.changes };
|
|
194
221
|
return found;
|
|
195
222
|
}
|
|
196
223
|
}
|
package/src/authored-root.ts
CHANGED
|
@@ -51,6 +51,8 @@ export interface DurableAuthoredOptions {
|
|
|
51
51
|
readonly dataDir: string;
|
|
52
52
|
readonly admissionKey?: string;
|
|
53
53
|
readonly localAgentStream?: string;
|
|
54
|
+
/** Concurrency of the attached local workers; see `ExecuteAuthoredFlowOptions.workerCapacity`. */
|
|
55
|
+
readonly workerCapacity?: number;
|
|
54
56
|
readonly lifecycle?: RunLifecycleOptions;
|
|
55
57
|
}
|
|
56
58
|
|
|
@@ -201,6 +203,7 @@ async function driveRoot(
|
|
|
201
203
|
if (process.versions['bun'] !== undefined) {
|
|
202
204
|
return runAuthoredInNode(metadata, journal.socketPath, dispatch.run_id, {
|
|
203
205
|
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
|
|
206
|
+
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
|
|
204
207
|
...options.lifecycle, signal,
|
|
205
208
|
});
|
|
206
209
|
}
|
|
@@ -213,6 +216,7 @@ async function driveRoot(
|
|
|
213
216
|
dataDir: options.dataDir,
|
|
214
217
|
flowPath: metadata.flowPath,
|
|
215
218
|
localAgentStream: options.localAgentStream,
|
|
219
|
+
...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
|
|
216
220
|
rootRunId: dispatch.run_id,
|
|
217
221
|
extensions: loaded.extensions,
|
|
218
222
|
...options.lifecycle,
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { inspect } from 'node:util';
|
|
2
|
+
import { MAX_AFTER, displayLabel, type AuthoredStepEdges } from './authored-step-index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The named DAG an authored body draws, recorded as it runs.
|
|
6
|
+
*
|
|
7
|
+
* An authored flow is plain TypeScript, so the shape Cloud wants to draw — which
|
|
8
|
+
* `f.agent` waited for which — is not declared anywhere. It is, however, a fact
|
|
9
|
+
* the runtime already holds: the promise graph (`authored-promise-graph.ts`)
|
|
10
|
+
* records, for every promise the body creates, what triggered it, what resolved
|
|
11
|
+
* it, and which context created it. A step's causal predecessors are the step
|
|
12
|
+
* promises reachable backward from the context that invoked it.
|
|
13
|
+
*
|
|
14
|
+
* Three rules make that walk an answer rather than an approximation:
|
|
15
|
+
*
|
|
16
|
+
* 1. **Stop at steps.** A step promise is a frontier; what it waited for is its
|
|
17
|
+
* own `after`, not ours.
|
|
18
|
+
* 2. **Expand an aggregate that waited for every member.** The runtime links a
|
|
19
|
+
* combinator's aggregate only to the member whose settlement settled it.
|
|
20
|
+
* That member is the whole answer when the aggregate settled on the first
|
|
21
|
+
* member that could decide it — a rejected `all`, a fulfilled `any`, any
|
|
22
|
+
* `race` — and a fraction of it when the aggregate had to wait for all of
|
|
23
|
+
* them: a fulfilled `all`, a rejected `any`, every `allSettled`. Which case
|
|
24
|
+
* applies is known only once the aggregate settles, so it is decided when a
|
|
25
|
+
* walk reaches the aggregate (`registerAggregate`, `promiseOutcome`).
|
|
26
|
+
* 3. **Reduce transitively.** `a → b → c` reaches both `a` and `b` from `c`;
|
|
27
|
+
* `a` is an ancestor of `b`, so it is dropped.
|
|
28
|
+
*
|
|
29
|
+
* Cost is bounded, not quadratic. Each invocation context's frontier is
|
|
30
|
+
* memoized, and a walk that reaches an already-answered context takes its
|
|
31
|
+
* answer instead of re-walking the history behind it — so a long sequential
|
|
32
|
+
* body walks a few nodes per step. A walk that still exceeds `walkLimit` stops
|
|
33
|
+
* and says so (`afterTruncated`) rather than guessing silently.
|
|
34
|
+
*
|
|
35
|
+
* This is observational metadata: it never gates execution, and it changes no
|
|
36
|
+
* step id, admission key or resume identity.
|
|
37
|
+
*/
|
|
38
|
+
export const MAX_WALK = 10_000;
|
|
39
|
+
|
|
40
|
+
interface Frontier {
|
|
41
|
+
readonly steps: readonly string[];
|
|
42
|
+
readonly truncated: boolean;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface StepNode {
|
|
46
|
+
readonly order: number;
|
|
47
|
+
readonly after: readonly string[];
|
|
48
|
+
/** `after` may be incomplete, so a reduction that relies on it may keep an ancestor. */
|
|
49
|
+
readonly truncated: boolean;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
type Combinator = 'all' | 'allSettled' | 'any' | 'race';
|
|
53
|
+
type Outcome = 'pending' | 'fulfilled' | 'rejected';
|
|
54
|
+
|
|
55
|
+
/** The settlement on which a combinator had waited for every member. */
|
|
56
|
+
const EXPANDS_ON: Readonly<Record<Combinator, Outcome | 'always' | undefined>> = {
|
|
57
|
+
all: 'fulfilled',
|
|
58
|
+
allSettled: 'always',
|
|
59
|
+
any: 'rejected',
|
|
60
|
+
race: undefined,
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
interface Aggregate {
|
|
64
|
+
readonly promise: Promise<unknown>;
|
|
65
|
+
/** Cached once settled: a settled promise never changes state. */
|
|
66
|
+
outcome?: Outcome;
|
|
67
|
+
readonly expandsOn: Outcome | 'always';
|
|
68
|
+
readonly members: readonly number[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export class AuthoredStepGraph {
|
|
72
|
+
private readonly stepPromises = new Map<number, string>();
|
|
73
|
+
private readonly aggregates = new Map<number, Aggregate>();
|
|
74
|
+
private readonly contexts = new Map<number, Frontier>();
|
|
75
|
+
private readonly nodes = new Map<string, StepNode>();
|
|
76
|
+
private readonly edges = new Map<string, AuthoredStepEdges>();
|
|
77
|
+
|
|
78
|
+
constructor(
|
|
79
|
+
private readonly causesOf: (asyncId: number) => readonly number[],
|
|
80
|
+
private readonly walkLimit = MAX_WALK,
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
/** A combinator's aggregate and its members, for expansion once it settles. */
|
|
84
|
+
registerAggregate(
|
|
85
|
+
aggregateId: number,
|
|
86
|
+
promise: Promise<unknown>,
|
|
87
|
+
combinator: Combinator,
|
|
88
|
+
members: readonly number[],
|
|
89
|
+
): void {
|
|
90
|
+
const expandsOn = EXPANDS_ON[combinator];
|
|
91
|
+
if (expandsOn === undefined || members.length === 0) return;
|
|
92
|
+
this.aggregates.set(aggregateId, { promise, expandsOn, members });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Record a step invoked from `contextId`. `promiseId` is the step's own
|
|
97
|
+
* promise, when the promise graph saw it, so later walks stop there.
|
|
98
|
+
*/
|
|
99
|
+
registerStep(
|
|
100
|
+
step: string,
|
|
101
|
+
promiseId: number | undefined,
|
|
102
|
+
contextId: number,
|
|
103
|
+
label?: string,
|
|
104
|
+
): AuthoredStepEdges {
|
|
105
|
+
const frontier = this.frontierOf(contextId);
|
|
106
|
+
this.nodes.set(step, { order: this.nodes.size, after: frontier.steps, truncated: frontier.truncated });
|
|
107
|
+
if (promiseId !== undefined) this.stepPromises.set(promiseId, step);
|
|
108
|
+
const truncated = frontier.truncated || frontier.steps.length > MAX_AFTER;
|
|
109
|
+
const edges: AuthoredStepEdges = Object.freeze({
|
|
110
|
+
...displayLabel(label),
|
|
111
|
+
...(frontier.steps.length === 0 ? {} : { after: Object.freeze(frontier.steps.slice(0, MAX_AFTER)) }),
|
|
112
|
+
...(truncated ? { afterTruncated: true as const } : {}),
|
|
113
|
+
});
|
|
114
|
+
this.edges.set(step, edges);
|
|
115
|
+
return edges;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
edgesOf(step: string): AuthoredStepEdges | undefined {
|
|
119
|
+
return this.edges.get(step);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Drop the walk state; recorded edges stay readable for late record writes. */
|
|
123
|
+
release(): void {
|
|
124
|
+
this.stepPromises.clear();
|
|
125
|
+
this.aggregates.clear();
|
|
126
|
+
this.contexts.clear();
|
|
127
|
+
this.nodes.clear();
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
private frontierOf(start: number): Frontier {
|
|
131
|
+
const memo = this.contexts.get(start);
|
|
132
|
+
if (memo !== undefined) return memo;
|
|
133
|
+
const candidates = new Set<string>();
|
|
134
|
+
let truncated = false;
|
|
135
|
+
const visited = new Set<number>([start]);
|
|
136
|
+
const queue = [start];
|
|
137
|
+
// Breadth-first, so that a walk cut short by the limit has already found
|
|
138
|
+
// the nearest predecessors and lost only distant history.
|
|
139
|
+
for (let head = 0; head < queue.length; head++) {
|
|
140
|
+
const current = queue[head]!;
|
|
141
|
+
const step = this.stepPromises.get(current);
|
|
142
|
+
if (step !== undefined) {
|
|
143
|
+
candidates.add(step);
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const answered = current === start ? undefined : this.contexts.get(current);
|
|
147
|
+
if (answered !== undefined) {
|
|
148
|
+
for (const found of answered.steps) candidates.add(found);
|
|
149
|
+
truncated ||= answered.truncated;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
for (const id of [...this.waitedFor(current), ...this.causesOf(current)]) {
|
|
153
|
+
if (visited.has(id)) continue;
|
|
154
|
+
// At the limit, stop growing the walk but still drain what is queued:
|
|
155
|
+
// those nodes are already known and cost one lookup each.
|
|
156
|
+
if (visited.size >= this.walkLimit) {
|
|
157
|
+
truncated = true;
|
|
158
|
+
break;
|
|
159
|
+
}
|
|
160
|
+
visited.add(id);
|
|
161
|
+
queue.push(id);
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
const reduced = this.reduce(candidates);
|
|
165
|
+
const frontier = Object.freeze({ steps: reduced.steps, truncated: truncated || reduced.truncated });
|
|
166
|
+
this.contexts.set(start, frontier);
|
|
167
|
+
return frontier;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The members an aggregate waited for beyond its own resolution edge. */
|
|
171
|
+
private waitedFor(asyncId: number): readonly number[] {
|
|
172
|
+
const aggregate = this.aggregates.get(asyncId);
|
|
173
|
+
if (aggregate === undefined) return [];
|
|
174
|
+
if (aggregate.expandsOn === 'always') return aggregate.members;
|
|
175
|
+
if (aggregate.outcome === undefined) {
|
|
176
|
+
const outcome = promiseOutcome(aggregate.promise);
|
|
177
|
+
if (outcome !== 'pending') aggregate.outcome = outcome;
|
|
178
|
+
}
|
|
179
|
+
return aggregate.outcome === aggregate.expandsOn ? aggregate.members : [];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Drop every candidate that is an ancestor of another candidate. The
|
|
184
|
+
* reduction reads recorded `after` lists; when one it reads is incomplete,
|
|
185
|
+
* a redundant ancestor can survive, and the result says so.
|
|
186
|
+
*/
|
|
187
|
+
private reduce(candidates: ReadonlySet<string>): Frontier {
|
|
188
|
+
const ordered = [...candidates].sort((a, b) => this.orderOf(a) - this.orderOf(b));
|
|
189
|
+
if (ordered.length <= 1) return { steps: ordered, truncated: false };
|
|
190
|
+
// An ancestor is always registered before its descendant, so nothing
|
|
191
|
+
// older than the oldest candidate can be one: the walk stops there.
|
|
192
|
+
const oldest = this.orderOf(ordered[0]!);
|
|
193
|
+
const reached = new Set<string>();
|
|
194
|
+
// Predecessor lists are read through cursors, never copied onto the stack, and every entry
|
|
195
|
+
// inspected counts toward the limit — duplicates included — so a wide fan-in bounds both
|
|
196
|
+
// allocation and work, not just the number of distinct steps reached.
|
|
197
|
+
const stack: Array<{ readonly values: readonly string[]; next: number }> = [];
|
|
198
|
+
const pushList = (values: readonly string[] | undefined): void => {
|
|
199
|
+
if (values !== undefined && values.length > 0) stack.push({ values, next: 0 });
|
|
200
|
+
};
|
|
201
|
+
for (const step of ordered) pushList(this.nodes.get(step)?.after);
|
|
202
|
+
let inspected = 0;
|
|
203
|
+
let truncated = ordered.some((step) => this.nodes.get(step)?.truncated === true);
|
|
204
|
+
while (stack.length > 0) {
|
|
205
|
+
const frame = stack[stack.length - 1]!;
|
|
206
|
+
const step = frame.values[frame.next++]!;
|
|
207
|
+
if (frame.next === frame.values.length) stack.pop();
|
|
208
|
+
if (++inspected > this.walkLimit) {
|
|
209
|
+
truncated = true;
|
|
210
|
+
break;
|
|
211
|
+
}
|
|
212
|
+
if (reached.has(step)) continue;
|
|
213
|
+
reached.add(step);
|
|
214
|
+
const node = this.nodes.get(step);
|
|
215
|
+
if (node === undefined || node.order <= oldest) continue;
|
|
216
|
+
truncated ||= node.truncated;
|
|
217
|
+
pushList(node.after);
|
|
218
|
+
}
|
|
219
|
+
return { steps: ordered.filter((step) => !reached.has(step)), truncated };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
private orderOf(step: string): number {
|
|
223
|
+
return this.nodes.get(step)?.order ?? -1;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Whether a promise has settled, and how, read without attaching a handler.
|
|
229
|
+
*
|
|
230
|
+
* Attaching one would mark an author's rejected aggregate as handled and hide
|
|
231
|
+
* an unhandled rejection the process would otherwise report, so the state is
|
|
232
|
+
* read the one public way that has no such effect: Node's own inspection.
|
|
233
|
+
* `customInspect`, `getters` and `showProxy` keep author code and proxy traps
|
|
234
|
+
* from running; `depth: 0` and the length caps keep the rendered value small.
|
|
235
|
+
* Every option the match depends on is set here, because `inspect` fills the
|
|
236
|
+
* rest from `util.inspect.defaultOptions`, which any code in the process can
|
|
237
|
+
* change — `colors: true` there would wrap `<rejected>` in ANSI escapes.
|
|
238
|
+
*/
|
|
239
|
+
export function promiseOutcome(promise: Promise<unknown>): Outcome {
|
|
240
|
+
const shown = inspect(promise, {
|
|
241
|
+
depth: 0, customInspect: false, getters: false, showProxy: true, colors: false,
|
|
242
|
+
compact: 3, sorted: false, maxArrayLength: 0, maxStringLength: 0, breakLength: Infinity,
|
|
243
|
+
});
|
|
244
|
+
const state = /^[^{]*\{\s*<(pending|rejected)>/u.exec(shown)?.[1];
|
|
245
|
+
return state === 'pending' ? 'pending' : state === 'rejected' ? 'rejected' : 'fulfilled';
|
|
246
|
+
}
|
|
@@ -57,6 +57,40 @@ export interface AuthoredStepRecord {
|
|
|
57
57
|
* `<step>` — saying which is the difference between an index and a guess.
|
|
58
58
|
*/
|
|
59
59
|
readonly kernelStep?: string;
|
|
60
|
+
/**
|
|
61
|
+
* Human name for the step: the author-chosen `f.agent` or `f.hook` name,
|
|
62
|
+
* whole or not at all (`displayLabel`).
|
|
63
|
+
*/
|
|
64
|
+
readonly label?: string;
|
|
65
|
+
/**
|
|
66
|
+
* The steps this one causally waited for, transitively reduced
|
|
67
|
+
* (`authored-step-graph.ts`). Absent means none were found, not unknown.
|
|
68
|
+
*/
|
|
69
|
+
readonly after?: readonly string[];
|
|
70
|
+
/** Present when `after` is incomplete: the walk hit its limit or the list its cap. */
|
|
71
|
+
readonly afterTruncated?: true;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** The graph fields one authored step carries on every record about it. */
|
|
75
|
+
export type AuthoredStepEdges = Pick<AuthoredStepRecord, 'label' | 'after' | 'afterTruncated'>;
|
|
76
|
+
|
|
77
|
+
/** Most predecessors one record lists; the rest are marked `afterTruncated`. */
|
|
78
|
+
export const MAX_AFTER = 32;
|
|
79
|
+
|
|
80
|
+
/** Longest label a record carries. A longer one is omitted, never cut. */
|
|
81
|
+
export const MAX_LABEL = 256;
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* A label, whole, or nothing.
|
|
85
|
+
*
|
|
86
|
+
* A label is displayed and persisted by Cloud after its redactor runs, and the
|
|
87
|
+
* redactor matches whole secret values: a label cut mid-secret would arrive as
|
|
88
|
+
* an unredactable prefix. So a label is never truncated — one over the bound
|
|
89
|
+
* is dropped — and only author-chosen names are labels at all. Commands are
|
|
90
|
+
* not: they carry literal tokens and URLs, so `f.run` has no label.
|
|
91
|
+
*/
|
|
92
|
+
export function displayLabel(label: string | undefined): { readonly label?: string } {
|
|
93
|
+
return label === undefined || label.trim() === '' || label.length > MAX_LABEL ? {} : { label };
|
|
60
94
|
}
|
|
61
95
|
|
|
62
96
|
/**
|
|
@@ -85,6 +119,10 @@ export async function recordAuthoredChild(
|
|
|
85
119
|
...(record.completionReason === undefined ? {} : { completionReason: bound(record.completionReason) }),
|
|
86
120
|
...(record.kernelStep === undefined || record.kernelStep === record.step
|
|
87
121
|
? {} : { kernelStep: bound(record.kernelStep) }),
|
|
122
|
+
...displayLabel(record.label),
|
|
123
|
+
...(record.after === undefined ? {} : { after: record.after.slice(0, MAX_AFTER).map(bound) }),
|
|
124
|
+
...(record.afterTruncated === true || (record.after?.length ?? 0) > MAX_AFTER
|
|
125
|
+
? { afterTruncated: true } : {}),
|
|
88
126
|
});
|
|
89
127
|
}
|
|
90
128
|
|
|
@@ -149,7 +187,12 @@ function isAuthoredStepRecord(value: unknown): value is AuthoredStepRecord {
|
|
|
149
187
|
&& typeof record.runId === 'string' && record.runId.length > 0
|
|
150
188
|
&& (record.state === 'admitted' || record.state === 'completed')
|
|
151
189
|
&& (record.completionReason === undefined || typeof record.completionReason === 'string')
|
|
152
|
-
&& (record.kernelStep === undefined || typeof record.kernelStep === 'string')
|
|
190
|
+
&& (record.kernelStep === undefined || typeof record.kernelStep === 'string')
|
|
191
|
+
&& (record.label === undefined || (typeof record.label === 'string' && record.label.length <= MAX_LABEL))
|
|
192
|
+
&& (record.after === undefined || (Array.isArray(record.after)
|
|
193
|
+
&& record.after.length <= MAX_AFTER
|
|
194
|
+
&& record.after.every((step) => typeof step === 'string' && step.length > 0)))
|
|
195
|
+
&& (record.afterTruncated === undefined || record.afterTruncated === true);
|
|
153
196
|
}
|
|
154
197
|
|
|
155
198
|
function bound(value: string): string {
|
|
@@ -5,7 +5,7 @@ import type { CompletionReason as ProtocolCompletionReason, RunCompletionReason
|
|
|
5
5
|
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
|
|
6
6
|
import type { StepFailedDetails } from './failure-kinds.js';
|
|
7
7
|
import { inspectionHint, renderInspection, renderStepEvidence, stepFailureDetails } from './cli/step-failure.js';
|
|
8
|
-
import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
|
|
8
|
+
import { alsoRecord, recordAuthoredChild, type AuthoredStepEdges } from './authored-step-index.js';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
11
|
* What an authored operation needs in order to leave readable evidence:
|
|
@@ -17,6 +17,8 @@ import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
|
|
|
17
17
|
export interface AuthoredStepContext {
|
|
18
18
|
readonly rootRunId?: string;
|
|
19
19
|
readonly dataDir?: string;
|
|
20
|
+
/** The step's label and causal predecessors, carried on every record about it. */
|
|
21
|
+
readonly stepEdges?: (step: string) => AuthoredStepEdges | undefined;
|
|
20
22
|
}
|
|
21
23
|
|
|
22
24
|
/**
|
|
@@ -58,7 +60,8 @@ export async function readCompletedStepOutput(
|
|
|
58
60
|
}
|
|
59
61
|
|
|
60
62
|
const reason = completed.payload.completionReason;
|
|
61
|
-
|
|
63
|
+
const edges = context.stepEdges?.(stepId);
|
|
64
|
+
journalSteps.push(Object.freeze({ id: stepId, runId, completionReason: reason, ...edges }));
|
|
62
65
|
if (reason !== 'success') {
|
|
63
66
|
let message = `journal step "${stepId}" completed with ${reason}`;
|
|
64
67
|
let details: StepFailedDetails | undefined;
|
|
@@ -77,11 +80,12 @@ export async function readCompletedStepOutput(
|
|
|
77
80
|
message += await alsoRecord(journal, context.rootRunId, {
|
|
78
81
|
step: stepId, runId, state: 'completed', completionReason: reason,
|
|
79
82
|
...(details?.stepId === undefined ? {} : { kernelStep: details.stepId }),
|
|
83
|
+
...edges,
|
|
80
84
|
});
|
|
81
85
|
throw new AuthoredFlowExecutionError('step_failed', message, reason, runId, { ...details, ...where });
|
|
82
86
|
}
|
|
83
87
|
await recordAuthoredChild(journal, context.rootRunId, {
|
|
84
|
-
step: stepId, runId, state: 'completed', completionReason: reason,
|
|
88
|
+
step: stepId, runId, state: 'completed', completionReason: reason, ...edges,
|
|
85
89
|
});
|
|
86
90
|
return completed.payload.output;
|
|
87
91
|
}
|