@relayflows/sdk 2.0.11 → 2.0.13
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/adapters/base.d.ts +6 -0
- package/dist/adapters/base.d.ts.map +1 -1
- package/dist/adapters/claude.d.ts.map +1 -1
- package/dist/adapters/claude.js +1 -0
- package/dist/adapters/claude.js.map +1 -1
- package/dist/authored-admission.d.ts +5 -0
- package/dist/authored-admission.d.ts.map +1 -0
- package/dist/authored-admission.js +14 -0
- package/dist/authored-admission.js.map +1 -0
- package/dist/authored-budget.d.ts +1 -1
- package/dist/authored-budget.d.ts.map +1 -1
- package/dist/authored-budget.js +18 -9
- package/dist/authored-budget.js.map +1 -1
- package/dist/authored-flow-error.d.ts +1 -1
- package/dist/authored-flow-error.d.ts.map +1 -1
- package/dist/authored-flow-error.js.map +1 -1
- package/dist/authored-flow-executor.d.ts +10 -0
- package/dist/authored-flow-executor.d.ts.map +1 -1
- package/dist/authored-flow-executor.js +15 -7
- package/dist/authored-flow-executor.js.map +1 -1
- package/dist/authored-flow-loader.d.ts +20 -0
- package/dist/authored-flow-loader.d.ts.map +1 -1
- package/dist/authored-flow-loader.js +86 -11
- package/dist/authored-flow-loader.js.map +1 -1
- package/dist/authored-helper-effect.d.ts +1 -1
- package/dist/authored-helper-effect.d.ts.map +1 -1
- package/dist/authored-helper-effect.js +5 -3
- package/dist/authored-helper-effect.js.map +1 -1
- package/dist/authored-mcp.d.ts +1 -1
- package/dist/authored-mcp.d.ts.map +1 -1
- package/dist/authored-mcp.js +7 -4
- package/dist/authored-mcp.js.map +1 -1
- package/dist/authored-node-entry.d.ts +2 -0
- package/dist/authored-node-entry.d.ts.map +1 -0
- package/dist/authored-node-entry.js +111 -0
- package/dist/authored-node-entry.js.map +1 -0
- package/dist/authored-node-runner.d.ts +11 -0
- package/dist/authored-node-runner.d.ts.map +1 -0
- package/dist/authored-node-runner.js +246 -0
- package/dist/authored-node-runner.js.map +1 -0
- package/dist/authored-plugin-effect.d.ts +1 -1
- package/dist/authored-plugin-effect.d.ts.map +1 -1
- package/dist/authored-plugin-effect.js +7 -4
- package/dist/authored-plugin-effect.js.map +1 -1
- package/dist/authored-root.d.ts +38 -0
- package/dist/authored-root.d.ts.map +1 -0
- package/dist/authored-root.js +329 -0
- package/dist/authored-root.js.map +1 -0
- package/dist/authored-runtime-capability.d.ts +6 -0
- package/dist/authored-runtime-capability.d.ts.map +1 -0
- package/dist/authored-runtime-capability.js +40 -0
- package/dist/authored-runtime-capability.js.map +1 -0
- package/dist/authored-source-authority.d.ts +4 -0
- package/dist/authored-source-authority.d.ts.map +1 -0
- package/dist/authored-source-authority.js +61 -0
- package/dist/authored-source-authority.js.map +1 -0
- package/dist/authored-worker-step.d.ts +2 -2
- package/dist/authored-worker-step.d.ts.map +1 -1
- package/dist/authored-worker-step.js +11 -6
- package/dist/authored-worker-step.js.map +1 -1
- package/dist/budget-preflight.d.ts +28 -3
- package/dist/budget-preflight.d.ts.map +1 -1
- package/dist/budget-preflight.js +34 -24
- package/dist/budget-preflight.js.map +1 -1
- package/dist/cli/build.d.ts.map +1 -1
- package/dist/cli/build.js +8 -0
- package/dist/cli/build.js.map +1 -1
- package/dist/cli/check.js +3 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/deploy.d.ts.map +1 -1
- package/dist/cli/deploy.js +5 -0
- package/dist/cli/deploy.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +46 -14
- package/dist/cli/direct-run.js.map +1 -1
- package/dist/cli/run.d.ts +10 -0
- package/dist/cli/run.d.ts.map +1 -1
- package/dist/cli/run.js +90 -12
- package/dist/cli/run.js.map +1 -1
- package/dist/cli/step-failure.d.ts +40 -0
- package/dist/cli/step-failure.d.ts.map +1 -0
- package/dist/cli/step-failure.js +150 -0
- package/dist/cli/step-failure.js.map +1 -0
- package/dist/cli-adapter.d.ts +16 -0
- package/dist/cli-adapter.d.ts.map +1 -1
- package/dist/cli-adapter.js +17 -0
- package/dist/cli-adapter.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -8
- package/dist/cli.js.map +1 -1
- package/dist/cloud-run.d.ts +11 -1
- package/dist/cloud-run.d.ts.map +1 -1
- package/dist/cloud-run.js +65 -19
- package/dist/cloud-run.js.map +1 -1
- package/dist/daemon-connection.d.ts +7 -0
- package/dist/daemon-connection.d.ts.map +1 -1
- package/dist/daemon-connection.js +7 -0
- package/dist/daemon-connection.js.map +1 -1
- package/dist/failure-kinds.d.ts +30 -4
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +8 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/journal-client.d.ts +3 -7
- package/dist/journal-client.d.ts.map +1 -1
- package/dist/journal-client.js +6 -2
- package/dist/journal-client.js.map +1 -1
- package/dist/llm-worker.d.ts.map +1 -1
- package/dist/llm-worker.js +4 -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 +2 -2
- package/dist/local-agent.js.map +1 -1
- package/dist/model-pricing.d.ts +7 -6
- package/dist/model-pricing.d.ts.map +1 -1
- package/dist/model-pricing.js +8 -6
- package/dist/model-pricing.js.map +1 -1
- package/dist/preflight.d.ts +9 -1
- package/dist/preflight.d.ts.map +1 -1
- package/dist/preflight.js +36 -18
- package/dist/preflight.js.map +1 -1
- package/dist/protocol.d.ts +24 -5
- package/dist/protocol.d.ts.map +1 -1
- package/dist/spec.d.ts +27 -11
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +6 -5
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-spend.d.ts +13 -8
- package/dist/worker-spend.d.ts.map +1 -1
- package/dist/worker-spend.js +27 -4
- package/dist/worker-spend.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +4 -2
- package/dist/worker.js.map +1 -1
- package/package.json +2 -2
- package/src/adapters/base.ts +7 -0
- package/src/adapters/claude.ts +1 -0
- package/src/authored-admission.ts +17 -0
- package/src/authored-budget.ts +38 -11
- package/src/authored-flow-error.ts +0 -1
- package/src/authored-flow-executor.ts +32 -7
- package/src/authored-flow-loader.ts +96 -13
- package/src/authored-helper-effect.ts +5 -3
- package/src/authored-mcp.ts +6 -4
- package/src/authored-node-entry.ts +90 -0
- package/src/authored-node-runner.ts +219 -0
- package/src/authored-plugin-effect.ts +6 -3
- package/src/authored-root.ts +396 -0
- package/src/authored-runtime-capability.ts +40 -0
- package/src/authored-source-authority.ts +62 -0
- package/src/authored-worker-step.ts +15 -4
- package/src/budget-preflight.ts +52 -25
- package/src/cli/build.ts +7 -0
- package/src/cli/check.ts +3 -1
- package/src/cli/deploy.ts +4 -0
- package/src/cli/direct-run.ts +52 -16
- package/src/cli/run.ts +101 -12
- package/src/cli/step-failure.ts +160 -0
- package/src/cli-adapter.ts +27 -0
- package/src/cli.ts +9 -9
- package/src/cloud-run.ts +74 -21
- package/src/daemon-connection.ts +8 -0
- package/src/failure-kinds.ts +28 -2
- package/src/journal-client.ts +8 -3
- package/src/llm-worker.ts +4 -2
- package/src/local-agent.ts +7 -2
- package/src/model-pricing.ts +8 -6
- package/src/preflight.ts +45 -18
- package/src/protocol.ts +17 -2
- package/src/spec.ts +28 -5
- package/src/worker-cli.ts +7 -5
- package/src/worker-spend.ts +32 -5
- package/src/worker.ts +4 -2
- package/dist/cli/deterministic-failure.d.ts +0 -5
- package/dist/cli/deterministic-failure.d.ts.map +0 -1
- package/dist/cli/deterministic-failure.js +0 -66
- package/dist/cli/deterministic-failure.js.map +0 -1
- package/src/cli/deterministic-failure.ts +0 -69
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import { JournalClient } from './journal-client.js';
|
|
2
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
3
|
+
import { createHash, createHmac, randomBytes, timingSafeEqual } from 'node:crypto';
|
|
4
|
+
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
|
|
5
|
+
import { readFileSync, realpathSync } from 'node:fs';
|
|
6
|
+
import { tmpdir } from 'node:os';
|
|
7
|
+
import { isAbsolute, join } from 'node:path';
|
|
8
|
+
import type { Readable } from 'node:stream';
|
|
9
|
+
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
10
|
+
import type { AuthoredExecutionRuntime, AuthoredFlowExecutionResult, ExecuteAuthoredFlowOptions } from './authored-flow-executor.js';
|
|
11
|
+
import { AuthoredFlowExecutionError, type AuthoredFlowExecutionErrorCode } from './authored-flow-error.js';
|
|
12
|
+
import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
|
|
13
|
+
|
|
14
|
+
let embeddedSource: string | undefined;
|
|
15
|
+
/** Installed by the standalone build; never fetched or resolved from a workspace. */
|
|
16
|
+
export function installAuthoredNodeSource(source: string): void {
|
|
17
|
+
if (embeddedSource !== undefined || source.length === 0) throw new Error('invalid authored runtime payload');
|
|
18
|
+
embeddedSource = source;
|
|
19
|
+
}
|
|
20
|
+
const hash = (value: Uint8Array | string): string => createHash('sha256').update(value).digest('hex');
|
|
21
|
+
const refusal = (): AuthoredFlowExecutionError => new AuthoredFlowExecutionError(
|
|
22
|
+
'unsupported_promise_lifecycle',
|
|
23
|
+
'authored execution requires the embedded Node runner and Node >=22.14 on PATH (or an absolute FLOWS_AUTHORED_NODE); no workflow body was executed',
|
|
24
|
+
);
|
|
25
|
+
interface NodeAuthority { path: string; version: string; executableSha256: string }
|
|
26
|
+
function nodeAuthority(): NodeAuthority {
|
|
27
|
+
if (embeddedSource === undefined) throw refusal();
|
|
28
|
+
const override = process.env['FLOWS_AUTHORED_NODE'];
|
|
29
|
+
if (override !== undefined && !isAbsolute(override)) throw refusal();
|
|
30
|
+
const probe = spawnSync(override ?? 'node', ['--input-type=module', '--eval', `
|
|
31
|
+
import {createHook} from 'node:async_hooks';
|
|
32
|
+
import {stripTypeScriptTypes,register} from 'node:module';
|
|
33
|
+
const [major,minor]=process.versions.node.split('.').map(Number);
|
|
34
|
+
let init=false,resolve=false;
|
|
35
|
+
const h=createHook({init(_id,type){if(type==='PROMISE')init=true},promiseResolve(){resolve=true}}).enable();
|
|
36
|
+
new Promise(r=>r());h.disable();
|
|
37
|
+
if(process.versions.bun || major<22 || (major===22 && minor<14) || !init || !resolve || typeof stripTypeScriptTypes!=='function' || typeof register!=='function') process.exit(2);
|
|
38
|
+
process.stdout.write(JSON.stringify({path:process.execPath,version:process.versions.node}));
|
|
39
|
+
`], { encoding: 'utf8', timeout: 10_000, maxBuffer: 4096, env: process.env });
|
|
40
|
+
if (probe.error || probe.status !== 0) throw refusal();
|
|
41
|
+
try {
|
|
42
|
+
const value = JSON.parse(probe.stdout) as { path: string; version: string };
|
|
43
|
+
if (!isAbsolute(value.path) || !/^\d+\.\d+\.\d+$/.test(value.version)) throw refusal();
|
|
44
|
+
const [major, minor] = value.version.split('.').map(Number);
|
|
45
|
+
if (major! < 22 || (major === 22 && minor! < 14)) throw refusal();
|
|
46
|
+
const path = realpathSync(value.path);
|
|
47
|
+
return { path, version: value.version, executableSha256: hash(readFileSync(path)) };
|
|
48
|
+
} catch { throw refusal(); }
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Called before root admission/resume; declarative commands never reach it. */
|
|
52
|
+
export function assertAuthoredRuntimeAvailable(): void {
|
|
53
|
+
if (process.versions['bun'] === undefined) assertAuthoredPromiseHooks();
|
|
54
|
+
else nodeAuthority();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Bun retains the root lease and local workers. Only the authored body uses Node. */
|
|
58
|
+
export async function runAuthoredInNode(
|
|
59
|
+
metadata: AuthoredRootMetadata,
|
|
60
|
+
socketPath: string,
|
|
61
|
+
rootRunId: string,
|
|
62
|
+
options: ExecuteAuthoredFlowOptions,
|
|
63
|
+
): Promise<AuthoredFlowExecutionResult> {
|
|
64
|
+
const authority = nodeAuthority();
|
|
65
|
+
const source = embeddedSource!;
|
|
66
|
+
const runtime: AuthoredExecutionRuntime = {
|
|
67
|
+
kind: 'node', version: authority.version,
|
|
68
|
+
executableSha256: authority.executableSha256, payloadSha256: hash(source),
|
|
69
|
+
};
|
|
70
|
+
options.signal?.throwIfAborted();
|
|
71
|
+
const directory = await mkdtemp(join(tmpdir(), 'flows-authored-node-'));
|
|
72
|
+
const entry = join(directory, 'runner.mjs');
|
|
73
|
+
try {
|
|
74
|
+
await writeFile(entry, source, { mode: 0o400, flag: 'wx' });
|
|
75
|
+
if (hash(await readFile(entry)) !== runtime.payloadSha256) throw refusal();
|
|
76
|
+
const child = spawn(authority.path, ['--experimental-transform-types', entry, String(process.pid)], {
|
|
77
|
+
cwd: process.cwd(), env: process.env,
|
|
78
|
+
stdio: ['pipe', 'inherit', 'inherit', 'pipe'],
|
|
79
|
+
});
|
|
80
|
+
const result = await new Promise<AuthoredFlowExecutionResult>((resolve, reject) => {
|
|
81
|
+
const channelKey = randomBytes(32).toString('hex');
|
|
82
|
+
let expectedSequence = 1;
|
|
83
|
+
let buffer = '', ready = false, result: AuthoredFlowExecutionResult | undefined;
|
|
84
|
+
let failure: Error | undefined, killTimer: ReturnType<typeof setTimeout> | undefined;
|
|
85
|
+
const stop = (error: Error): void => {
|
|
86
|
+
failure ??= error;
|
|
87
|
+
child.kill('SIGTERM');
|
|
88
|
+
killTimer ??= setTimeout(() => child.kill('SIGKILL'), 2500);
|
|
89
|
+
killTimer.unref();
|
|
90
|
+
};
|
|
91
|
+
const abort = (): void => stop(new Error('authored root execution was aborted'));
|
|
92
|
+
options.signal?.addEventListener('abort', abort, { once: true });
|
|
93
|
+
if (options.signal?.aborted) abort();
|
|
94
|
+
// Do not intercept the parent's signals: its normal exit keeps the root
|
|
95
|
+
// recoverable. The child observes parent EOF (and shared process-group signals).
|
|
96
|
+
const startupTimer = setTimeout(() => stop(new Error('authored runtime readiness timed out')), 10_000);
|
|
97
|
+
startupTimer.unref();
|
|
98
|
+
const pipe = child.stdio[3] as Readable;
|
|
99
|
+
pipe.setEncoding('utf8');
|
|
100
|
+
pipe.on('data', (chunk: string) => {
|
|
101
|
+
buffer += chunk;
|
|
102
|
+
if (Buffer.byteLength(buffer) > 16 * 1024 * 1024) { stop(new Error('authored runtime response exceeded limit')); return; }
|
|
103
|
+
for (;;) {
|
|
104
|
+
const end = buffer.indexOf('\n'); if (end < 0) break;
|
|
105
|
+
const line = buffer.slice(0, end); buffer = buffer.slice(end + 1);
|
|
106
|
+
try {
|
|
107
|
+
let message = JSON.parse(line);
|
|
108
|
+
if (ready) {
|
|
109
|
+
if (message.seq !== expectedSequence || typeof message.payload !== 'string'
|
|
110
|
+
|| typeof message.mac !== 'string' || !/^[a-f0-9]{64}$/.test(message.mac)) {
|
|
111
|
+
throw new Error('invalid authored runtime authenticated frame');
|
|
112
|
+
}
|
|
113
|
+
const expected = createHmac('sha256', channelKey)
|
|
114
|
+
.update(`${expectedSequence}\0${message.payload}`).digest();
|
|
115
|
+
if (!timingSafeEqual(expected, Buffer.from(message.mac, 'hex'))) {
|
|
116
|
+
throw new Error('invalid authored runtime authenticated frame');
|
|
117
|
+
}
|
|
118
|
+
expectedSequence++;
|
|
119
|
+
message = JSON.parse(message.payload);
|
|
120
|
+
}
|
|
121
|
+
if (message.type === 'ready' && !ready && !result) {
|
|
122
|
+
if (JSON.stringify(message.runtime) !== JSON.stringify(runtime)) throw refusal();
|
|
123
|
+
ready = true;
|
|
124
|
+
clearTimeout(startupTimer);
|
|
125
|
+
// Keep stdin open: EOF tells the child its lease-owning parent died.
|
|
126
|
+
child.stdin!.write(JSON.stringify({ channelKey, metadata, socketPath, rootRunId,
|
|
127
|
+
dataDir: options.dataDir, localAgentStream: options.localAgentStream }) + '\n');
|
|
128
|
+
} else if (!ready || result) throw new Error('unexpected authored runtime message');
|
|
129
|
+
else if (message.type === 'progress') options.onProgress?.(message.event);
|
|
130
|
+
else if (message.type === 'wait') options.onWait?.(message.event);
|
|
131
|
+
else if (message.type === 'result') result = { ...message.result, executionRuntime: runtime };
|
|
132
|
+
else if (message.type === 'error') {
|
|
133
|
+
failure = typeof message.code === 'string'
|
|
134
|
+
? new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
|
|
135
|
+
message.message, message.completionReason, message.runId)
|
|
136
|
+
: new Error(message.message);
|
|
137
|
+
} else throw new Error('unknown authored runtime message');
|
|
138
|
+
} catch (error) { stop(error instanceof Error ? error : new Error('invalid authored runtime message')); }
|
|
139
|
+
}
|
|
140
|
+
});
|
|
141
|
+
child.stdin!.on('error', () => stop(new Error('authored runtime request pipe closed')));
|
|
142
|
+
pipe.on('error', () => stop(new Error('authored runtime result pipe failed')));
|
|
143
|
+
child.once('error', () => { failure = refusal(); });
|
|
144
|
+
child.once('close', (code) => {
|
|
145
|
+
clearTimeout(startupTimer);
|
|
146
|
+
if (killTimer !== undefined) clearTimeout(killTimer);
|
|
147
|
+
options.signal?.removeEventListener('abort', abort);
|
|
148
|
+
if (failure) reject(failure);
|
|
149
|
+
else if (code !== 0 || !ready || result === undefined || buffer.length > 0) reject(new Error('authored runtime exited without a complete result'));
|
|
150
|
+
else resolve(result);
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
await verifyAuthoredNodeResult(result, metadata, rootRunId, socketPath);
|
|
154
|
+
return result;
|
|
155
|
+
} finally { await rm(directory, { recursive: true, force: true }); }
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The IPC frame is a claim, not a durable terminal fact or a sandbox boundary. */
|
|
159
|
+
export async function verifyAuthoredNodeResult(
|
|
160
|
+
result: AuthoredFlowExecutionResult, metadata: AuthoredRootMetadata,
|
|
161
|
+
rootRunId: string, socketPath: string,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
const invalid = (): never => { throw new Error('authored runtime result has no matching durable completion'); };
|
|
164
|
+
if (result.rootRunId !== rootRunId || result.name !== metadata.flowName
|
|
165
|
+
|| !['success', 'needs_human'].includes(result.completionReason)
|
|
166
|
+
|| !Array.isArray(result.journalSteps) || result.journalSteps.length === 0) invalid();
|
|
167
|
+
const terminal = result.journalSteps.at(-1)!;
|
|
168
|
+
if (!terminal || !/^complete-[1-9][0-9]*$/.test(terminal.id)) invalid();
|
|
169
|
+
const count = Number(terminal.id.slice('complete-'.length));
|
|
170
|
+
if (!Number.isSafeInteger(count) || result.journalSteps.length !== count) invalid();
|
|
171
|
+
const ordinal = (id: string): number => Number(/-([1-9][0-9]*)$/.exec(id)?.[1]);
|
|
172
|
+
// Parallel awaits may finish in either order; validate a copy in declaration order.
|
|
173
|
+
const ordered = [...result.journalSteps].sort((a,b)=>ordinal(a.id)-ordinal(b.id));
|
|
174
|
+
const runs = new Set<string>();
|
|
175
|
+
const journal = new JournalClient(socketPath);
|
|
176
|
+
await journal.connect();
|
|
177
|
+
try {
|
|
178
|
+
await journal.hello('flows-authored-result-verifier');
|
|
179
|
+
for (const [index, claimed] of ordered.entries()) {
|
|
180
|
+
if (!claimed || typeof claimed.id !== 'string' || typeof claimed.runId !== 'string'
|
|
181
|
+
|| ordinal(claimed.id) !== index+1 || claimed.completionReason !== 'success'
|
|
182
|
+
|| runs.has(claimed.runId)) invalid();
|
|
183
|
+
runs.add(claimed.runId);
|
|
184
|
+
const state = await journal.runGet(claimed.runId);
|
|
185
|
+
if (state.run_id !== claimed.runId || state.status !== 'completed'
|
|
186
|
+
|| state.steps[claimed.id]?.state !== 'done') invalid();
|
|
187
|
+
const entries: Array<{
|
|
188
|
+
seq: number; entry_type: string; step_id?: string; payload?: {
|
|
189
|
+
completionReason?: string; spec?: { name?: string; steps?: Array<{id?:string;type?:string;command?:string}> };
|
|
190
|
+
};
|
|
191
|
+
}> = [];
|
|
192
|
+
let fromSeq = 1;
|
|
193
|
+
for (;;) {
|
|
194
|
+
const page = (await journal.journalRead(claimed.runId, fromSeq, 100)).entries;
|
|
195
|
+
if (page.length === 0) break;
|
|
196
|
+
for (const raw of page) {
|
|
197
|
+
const entry = raw as typeof entries[number];
|
|
198
|
+
if (!Number.isSafeInteger(entry?.seq) || entry.seq < fromSeq) invalid();
|
|
199
|
+
fromSeq = entry.seq + 1;
|
|
200
|
+
// Keep only completion evidence; streaming logs can span many pages.
|
|
201
|
+
if (['run.spawned', 'step.completed', 'run.completed'].includes(entry.entry_type)) entries.push(entry);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
const spec = entries.find(entry => entry.entry_type === 'run.spawned')?.payload?.spec;
|
|
205
|
+
const step = spec?.steps?.[0];
|
|
206
|
+
const completed = entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === claimed.id);
|
|
207
|
+
const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
|
|
208
|
+
if (spec?.name !== `${metadata.flowName}/${claimed.id}` || spec?.steps?.length !== 1
|
|
209
|
+
|| step?.id !== claimed.id || completed.length !== 1
|
|
210
|
+
|| completed[0]?.payload?.completionReason !== 'success'
|
|
211
|
+
|| terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid();
|
|
212
|
+
if (claimed === terminal) {
|
|
213
|
+
const command = result.completionReason === 'needs_human'
|
|
214
|
+
? `printf '%s' '{"completionReason":"needs_human"}'` : ':';
|
|
215
|
+
if (step?.type !== 'deterministic' || step.command !== command) invalid();
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
} finally { journal.close(); }
|
|
219
|
+
}
|
|
@@ -10,6 +10,7 @@ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
|
10
10
|
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
|
|
11
11
|
import { readCompletedStepOutput } from './authored-step-output.js';
|
|
12
12
|
import { withWorkerLease } from './worker-lease.js';
|
|
13
|
+
import { authoredChildAdmissionKey } from './authored-admission.js';
|
|
13
14
|
|
|
14
15
|
export class PluginStepError extends AuthoredFlowExecutionError {
|
|
15
16
|
constructor(readonly diagnostic: string, runId: string) {
|
|
@@ -24,12 +25,14 @@ export class PluginStepError extends AuthoredFlowExecutionError {
|
|
|
24
25
|
export async function runPluginEffect(
|
|
25
26
|
journal: JournalClient, flowName: string, id: string, plugin: LoadedPlugin, verb: PluginVerb,
|
|
26
27
|
args: unknown, journalSteps: AuthoredFlowJournalStep[], budget: AuthoredBudget,
|
|
28
|
+
rootRunId?: string,
|
|
27
29
|
): Promise<unknown> {
|
|
28
30
|
const server = verb.namespace;
|
|
29
31
|
const tool = verb.method;
|
|
30
32
|
const idempotencyKey = `plugin:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`;
|
|
31
33
|
const surfacePath = `/plugins/${pathPart(server)}/${pathPart(tool)}`;
|
|
32
|
-
const
|
|
34
|
+
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
35
|
+
const stream = `plugin-worker-${admissionKey?.slice(-32) ?? randomUUID()}`;
|
|
33
36
|
const instruction = JSON.stringify({ type: 'effect', server, tool, input: args });
|
|
34
37
|
const peer = journal.createPeer();
|
|
35
38
|
let diagnostic: string | undefined;
|
|
@@ -120,7 +123,7 @@ export async function runPluginEffect(
|
|
|
120
123
|
childRunId = outcome.run_id;
|
|
121
124
|
// run.start may answer after the deadline and the outer cleanup.
|
|
122
125
|
if (dispatchExpired) await cancelChildRun();
|
|
123
|
-
await completed;
|
|
126
|
+
if (outcome.status !== 'completed') await completed;
|
|
124
127
|
if (diagnostic !== undefined) {
|
|
125
128
|
try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
|
|
126
129
|
catch (error) {
|
|
@@ -130,7 +133,7 @@ export async function runPluginEffect(
|
|
|
130
133
|
}
|
|
131
134
|
const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
|
|
132
135
|
return receipt.output;
|
|
133
|
-
}), deadline]);
|
|
136
|
+
}, admissionKey), deadline]);
|
|
134
137
|
} finally {
|
|
135
138
|
clearTimeout(timer);
|
|
136
139
|
finishDeadline();
|
|
@@ -0,0 +1,396 @@
|
|
|
1
|
+
import { loadPinnedAuthoredSource } from './authored-source-authority.js';
|
|
2
|
+
import { assertAuthoredRuntimeAvailable, runAuthoredInNode } from './authored-node-runner.js';
|
|
3
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
4
|
+
import { readFile } from 'node:fs/promises';
|
|
5
|
+
import { canonicalize } from './canonical.js';
|
|
6
|
+
import { compileSpec, toKernelSpec } from './compile.js';
|
|
7
|
+
import { executeAuthoredFlow, type AuthoredFlowExecutionResult } from './authored-flow-executor.js';
|
|
8
|
+
import {
|
|
9
|
+
loadAuthoredFlow,
|
|
10
|
+
type LoadedAuthoredFlow,
|
|
11
|
+
type SurfaceModuleAuthority,
|
|
12
|
+
} from './authored-flow-loader.js';
|
|
13
|
+
import { JournalClient } from './journal-client.js';
|
|
14
|
+
import type { RunOutcome, StepDispatchEvent } from './protocol.js';
|
|
15
|
+
import { SPEC_SCHEMA_VERSION } from './spec.js';
|
|
16
|
+
import type { RunLifecycleOptions } from './cli/run.js';
|
|
17
|
+
import { withWorkerLease } from './worker-lease.js';
|
|
18
|
+
import { isSurfaceCompletionReason } from './authored-step-output.js';
|
|
19
|
+
|
|
20
|
+
const ROOT_KIND = 'relayflows.authored-root.v1';
|
|
21
|
+
|
|
22
|
+
export interface AuthoredRootMetadata {
|
|
23
|
+
readonly kind: typeof ROOT_KIND;
|
|
24
|
+
readonly flowName: string;
|
|
25
|
+
readonly flowPath: string;
|
|
26
|
+
readonly sourceSha256: string;
|
|
27
|
+
readonly surface: SurfaceModuleAuthority;
|
|
28
|
+
readonly sources: readonly AuthoredRootSourceAuthority[];
|
|
29
|
+
readonly localAgentStream?: string;
|
|
30
|
+
readonly inputPresent: boolean;
|
|
31
|
+
readonly input?: unknown;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AuthoredRootSourceAuthority {
|
|
35
|
+
readonly path: string;
|
|
36
|
+
readonly sourceSha256: string;
|
|
37
|
+
readonly surface: SurfaceModuleAuthority;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface DurableAuthoredOptions {
|
|
41
|
+
readonly dataDir: string;
|
|
42
|
+
readonly admissionKey?: string;
|
|
43
|
+
readonly localAgentStream?: string;
|
|
44
|
+
readonly lifecycle?: RunLifecycleOptions;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Admit and execute one authored body under a durable kernel root. */
|
|
48
|
+
export async function executeDurableAuthoredFlow(
|
|
49
|
+
loaded: LoadedAuthoredFlow,
|
|
50
|
+
journal: JournalClient,
|
|
51
|
+
input: unknown,
|
|
52
|
+
options: DurableAuthoredOptions,
|
|
53
|
+
): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
|
|
54
|
+
assertAuthoredRuntimeAvailable();
|
|
55
|
+
const source = await readFile(loaded.sourcePath);
|
|
56
|
+
const sources = await Promise.all(loaded.graph.map(async node => Object.freeze({
|
|
57
|
+
path: node.path,
|
|
58
|
+
sourceSha256: sha256(await readFile(node.path)),
|
|
59
|
+
surface: node.surfaceAuthority,
|
|
60
|
+
})));
|
|
61
|
+
const definition = loaded.getDefinition(loaded.handle);
|
|
62
|
+
const metadata: AuthoredRootMetadata = Object.freeze({
|
|
63
|
+
kind: ROOT_KIND,
|
|
64
|
+
flowName: definition.name,
|
|
65
|
+
flowPath: loaded.sourcePath,
|
|
66
|
+
sourceSha256: sha256(source),
|
|
67
|
+
surface: loaded.surfaceAuthority,
|
|
68
|
+
sources: Object.freeze(sources),
|
|
69
|
+
...(options.localAgentStream === undefined ? {} : { localAgentStream: options.localAgentStream }),
|
|
70
|
+
inputPresent: input !== undefined,
|
|
71
|
+
...(input === undefined ? {} : { input: jsonSnapshot(input, 'authored root input') }),
|
|
72
|
+
});
|
|
73
|
+
const admissionKey = rootAdmissionKey(options.admissionKey ?? randomUUID());
|
|
74
|
+
const stream = `authored-root-${admissionKey.slice(-40)}`;
|
|
75
|
+
const spec = rootSpec(metadata, stream);
|
|
76
|
+
const peer = journal.createPeer();
|
|
77
|
+
await peer.connect();
|
|
78
|
+
await peer.hello('flows-authored-root');
|
|
79
|
+
let cancelDispatch = () => {};
|
|
80
|
+
try {
|
|
81
|
+
const dispatchWait = nextRootDispatch(peer);
|
|
82
|
+
cancelDispatch = dispatchWait.cancel;
|
|
83
|
+
await peer.workerAttach(`worker-${stream}`, ['agent'], {
|
|
84
|
+
workspace: [], streams: [{ stream, read_offset: 0 }],
|
|
85
|
+
});
|
|
86
|
+
const outcome = await journal.runStart(spec, undefined, admissionKey);
|
|
87
|
+
if (outcome.status === 'completed') {
|
|
88
|
+
dispatchWait.cancel();
|
|
89
|
+
return await completedRootResult(journal, outcome.run_id);
|
|
90
|
+
}
|
|
91
|
+
assertRootCanDispatch(outcome);
|
|
92
|
+
// `run.start` is an idempotent receipt. If the first caller died after
|
|
93
|
+
// the daemon dispatched this root, a same-daemon retry sees the existing
|
|
94
|
+
// active run but receives no second dispatch from start itself. Resume is
|
|
95
|
+
// safe for the first caller too: the daemon preserves a live lease and
|
|
96
|
+
// redelivers only when the former worker connection is gone.
|
|
97
|
+
await journal.runResume(outcome.run_id);
|
|
98
|
+
const dispatch = await dispatchWait.promise;
|
|
99
|
+
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
100
|
+
} finally {
|
|
101
|
+
cancelDispatch();
|
|
102
|
+
peer.close();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Resume the exact source and Surface instance recorded by an authored root. */
|
|
107
|
+
export async function resumeDurableAuthoredFlow(
|
|
108
|
+
rootRunId: string,
|
|
109
|
+
journal: JournalClient,
|
|
110
|
+
options: Omit<DurableAuthoredOptions, 'admissionKey'>,
|
|
111
|
+
): Promise<(AuthoredFlowExecutionResult & { readonly rootRunId: string }) | undefined> {
|
|
112
|
+
const metadata = await readAuthoredRootMetadata(journal, rootRunId);
|
|
113
|
+
if (metadata === undefined) return undefined;
|
|
114
|
+
assertAuthoredRuntimeAvailable();
|
|
115
|
+
const loaded = await loadPinnedAuthoredSource(metadata);
|
|
116
|
+
if (metadata.localAgentStream !== options.localAgentStream) {
|
|
117
|
+
throw new Error('authored root local agent surface mismatch');
|
|
118
|
+
}
|
|
119
|
+
const stream = rootStreamFromMetadata(await rootKernelStep(journal, rootRunId));
|
|
120
|
+
const peer = journal.createPeer();
|
|
121
|
+
await peer.connect();
|
|
122
|
+
await peer.hello('flows-authored-root-resume');
|
|
123
|
+
let cancelDispatch = () => {};
|
|
124
|
+
try {
|
|
125
|
+
const dispatchWait = nextRootDispatch(peer);
|
|
126
|
+
cancelDispatch = dispatchWait.cancel;
|
|
127
|
+
await peer.workerAttach(`worker-${stream}-${randomUUID()}`, ['agent'], {
|
|
128
|
+
workspace: [], streams: [{ stream, read_offset: 0 }],
|
|
129
|
+
});
|
|
130
|
+
const outcome = await journal.runResume(rootRunId);
|
|
131
|
+
if (outcome.status === 'completed') {
|
|
132
|
+
dispatchWait.cancel();
|
|
133
|
+
return await completedRootResult(journal, rootRunId);
|
|
134
|
+
}
|
|
135
|
+
assertRootCanDispatch(outcome);
|
|
136
|
+
const dispatch = await dispatchWait.promise;
|
|
137
|
+
return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
|
|
138
|
+
} finally {
|
|
139
|
+
cancelDispatch();
|
|
140
|
+
peer.close();
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export async function readAuthoredRootMetadata(
|
|
145
|
+
journal: JournalClient,
|
|
146
|
+
runId: string,
|
|
147
|
+
): Promise<AuthoredRootMetadata | undefined> {
|
|
148
|
+
const step = await rootKernelStep(journal, runId).catch(() => undefined);
|
|
149
|
+
if (step === undefined || step.id !== 'authored-root' || !hasRootStream(step)) return undefined;
|
|
150
|
+
if (typeof step.instruction !== 'string') {
|
|
151
|
+
throw new Error('authored root journal has malformed authority metadata');
|
|
152
|
+
}
|
|
153
|
+
let value: unknown;
|
|
154
|
+
try { value = JSON.parse(step.instruction); } catch {
|
|
155
|
+
throw new Error('authored root journal has malformed authority metadata');
|
|
156
|
+
}
|
|
157
|
+
if (!isRootMetadata(value)) {
|
|
158
|
+
throw new Error('authored root journal has malformed authority metadata');
|
|
159
|
+
}
|
|
160
|
+
return value;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
async function driveRoot(
|
|
164
|
+
loaded: LoadedAuthoredFlow,
|
|
165
|
+
metadata: AuthoredRootMetadata,
|
|
166
|
+
journal: JournalClient,
|
|
167
|
+
peer: JournalClient,
|
|
168
|
+
dispatch: StepDispatchEvent,
|
|
169
|
+
options: Omit<DurableAuthoredOptions, 'admissionKey'>,
|
|
170
|
+
): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
|
|
171
|
+
try {
|
|
172
|
+
const result = await withWorkerLease(peer, dispatch, async rootSignal => {
|
|
173
|
+
const callerSignal = options.lifecycle?.signal;
|
|
174
|
+
const signal = callerSignal === undefined ? rootSignal : AbortSignal.any([callerSignal, rootSignal]);
|
|
175
|
+
if (process.versions['bun'] !== undefined) {
|
|
176
|
+
return runAuthoredInNode(metadata, journal.socketPath, dispatch.run_id, {
|
|
177
|
+
dataDir: options.dataDir, localAgentStream: options.localAgentStream,
|
|
178
|
+
...options.lifecycle, signal,
|
|
179
|
+
});
|
|
180
|
+
}
|
|
181
|
+
return await executeAuthoredFlow(
|
|
182
|
+
loaded.handle,
|
|
183
|
+
journal,
|
|
184
|
+
metadata.inputPresent ? metadata.input : undefined,
|
|
185
|
+
{
|
|
186
|
+
getDefinition: loaded.getDefinition,
|
|
187
|
+
dataDir: options.dataDir,
|
|
188
|
+
flowPath: metadata.flowPath,
|
|
189
|
+
localAgentStream: options.localAgentStream,
|
|
190
|
+
rootRunId: dispatch.run_id,
|
|
191
|
+
...options.lifecycle,
|
|
192
|
+
signal: callerSignal === undefined
|
|
193
|
+
? rootSignal
|
|
194
|
+
: AbortSignal.any([callerSignal, rootSignal]),
|
|
195
|
+
},
|
|
196
|
+
);
|
|
197
|
+
});
|
|
198
|
+
await peer.stepComplete(
|
|
199
|
+
dispatch.run_id, dispatch.step_id, dispatch.attempt,
|
|
200
|
+
dispatch.idempotency_key, 'success', {
|
|
201
|
+
output: { name: result.name, completionReason: result.completionReason,
|
|
202
|
+
journalSteps: result.journalSteps,
|
|
203
|
+
...(result.executionRuntime === undefined ? {} : { executionRuntime: result.executionRuntime }) },
|
|
204
|
+
started_pins: dispatch.pins, end_pins: dispatch.pins,
|
|
205
|
+
},
|
|
206
|
+
);
|
|
207
|
+
return Object.freeze({ ...result, rootRunId: dispatch.run_id });
|
|
208
|
+
} catch (error) {
|
|
209
|
+
await terminalizeRootFailure(peer, dispatch, error);
|
|
210
|
+
throw error;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* A returned body failure is deterministic for this root attempt. Complete
|
|
216
|
+
* every kernel retry with the same declared failure so the run reaches its
|
|
217
|
+
* durable `step_failed` terminal instead of being stranded waiting for a
|
|
218
|
+
* worker after the CLI has already returned. A process crash never enters
|
|
219
|
+
* this path: the running attempt remains recoverable by `run.resume`.
|
|
220
|
+
*/
|
|
221
|
+
async function terminalizeRootFailure(
|
|
222
|
+
peer: JournalClient,
|
|
223
|
+
firstDispatch: StepDispatchEvent,
|
|
224
|
+
error: unknown,
|
|
225
|
+
): Promise<void> {
|
|
226
|
+
let dispatch = firstDispatch;
|
|
227
|
+
const output = { error: error instanceof Error ? error.message : 'authored root failed' };
|
|
228
|
+
for (;;) {
|
|
229
|
+
const next = nextRootDispatch(peer);
|
|
230
|
+
let outcome: RunOutcome;
|
|
231
|
+
try {
|
|
232
|
+
outcome = await peer.stepComplete(
|
|
233
|
+
dispatch.run_id, dispatch.step_id, dispatch.attempt,
|
|
234
|
+
dispatch.idempotency_key, 'worker_error', {
|
|
235
|
+
output, started_pins: dispatch.pins, end_pins: dispatch.pins,
|
|
236
|
+
},
|
|
237
|
+
);
|
|
238
|
+
} catch (completionError) {
|
|
239
|
+
next.cancel();
|
|
240
|
+
throw completionError;
|
|
241
|
+
}
|
|
242
|
+
if (outcome.status === 'failed') {
|
|
243
|
+
next.cancel();
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
try { assertRootCanDispatch(outcome); } catch (outcomeError) {
|
|
247
|
+
next.cancel();
|
|
248
|
+
throw outcomeError;
|
|
249
|
+
}
|
|
250
|
+
dispatch = await next.promise;
|
|
251
|
+
if (dispatch.run_id !== firstDispatch.run_id) {
|
|
252
|
+
throw new Error('authored root retry was dispatched for a different run');
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
function rootSpec(metadata: AuthoredRootMetadata, stream: string) {
|
|
258
|
+
return toKernelSpec(compileSpec({
|
|
259
|
+
version: SPEC_SCHEMA_VERSION,
|
|
260
|
+
name: `authored-root/${metadata.flowName}`,
|
|
261
|
+
steps: [{
|
|
262
|
+
id: 'authored-root', type: 'agent',
|
|
263
|
+
instruction: canonicalize(metadata),
|
|
264
|
+
surfaces: { streams: [{ stream }] },
|
|
265
|
+
recoveryMode: 'reset', maxIterations: 8,
|
|
266
|
+
}],
|
|
267
|
+
}));
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function rootKernelStep(journal: JournalClient, runId: string): Promise<Record<string, unknown>> {
|
|
271
|
+
const entries = (await journal.journalRead(runId, 1)).entries as Array<Record<string, unknown>>;
|
|
272
|
+
const spawned = entries.find(entry => entry.entry_type === 'run.spawned');
|
|
273
|
+
const payload = spawned?.payload as { spec?: { steps?: unknown[] } } | undefined;
|
|
274
|
+
const step = payload?.spec?.steps?.[0];
|
|
275
|
+
if (typeof step !== 'object' || step === null || Array.isArray(step)) {
|
|
276
|
+
throw new Error('authored root journal has no root step');
|
|
277
|
+
}
|
|
278
|
+
return step as Record<string, unknown>;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function rootStreamFromMetadata(step: Record<string, unknown>): string {
|
|
282
|
+
const surfaces = step.surfaces as { streams?: Array<{ stream?: unknown }> } | undefined;
|
|
283
|
+
const stream = surfaces?.streams?.[0]?.stream;
|
|
284
|
+
if (typeof stream !== 'string' || !stream.startsWith('authored-root-')) {
|
|
285
|
+
throw new Error('authored root journal has no pinned stream');
|
|
286
|
+
}
|
|
287
|
+
return stream;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function hasRootStream(step: Record<string, unknown>): boolean {
|
|
291
|
+
try { rootStreamFromMetadata(step); return true; } catch { return false; }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
function nextRootDispatch(peer: JournalClient): {
|
|
295
|
+
promise: Promise<StepDispatchEvent>;
|
|
296
|
+
cancel(): void;
|
|
297
|
+
} {
|
|
298
|
+
let cancel = () => {};
|
|
299
|
+
const promise = new Promise<StepDispatchEvent>((resolve, reject) => {
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
peer.off('step.dispatch', onDispatch);
|
|
302
|
+
reject(new Error('authored root was not dispatched'));
|
|
303
|
+
}, 30_000);
|
|
304
|
+
const onDispatch = (dispatch: StepDispatchEvent) => {
|
|
305
|
+
if (dispatch.step_id !== 'authored-root') return;
|
|
306
|
+
clearTimeout(timer);
|
|
307
|
+
peer.off('step.dispatch', onDispatch);
|
|
308
|
+
resolve(dispatch);
|
|
309
|
+
};
|
|
310
|
+
peer.on('step.dispatch', onDispatch);
|
|
311
|
+
cancel = () => {
|
|
312
|
+
clearTimeout(timer);
|
|
313
|
+
peer.off('step.dispatch', onDispatch);
|
|
314
|
+
};
|
|
315
|
+
});
|
|
316
|
+
return { promise, cancel };
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
async function completedRootResult(
|
|
320
|
+
journal: JournalClient,
|
|
321
|
+
rootRunId: string,
|
|
322
|
+
): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
|
|
323
|
+
const entries = (await journal.journalRead(rootRunId, 1)).entries as Array<Record<string, unknown>>;
|
|
324
|
+
const completed = entries.slice().reverse().find(entry => entry.entry_type === 'step.completed'
|
|
325
|
+
&& entry.step_id === 'authored-root'
|
|
326
|
+
&& (entry.payload as { completionReason?: unknown } | undefined)?.completionReason === 'success');
|
|
327
|
+
const output = (completed?.payload as { output?: unknown } | undefined)?.output;
|
|
328
|
+
if (!isCompletedRootOutput(output)) {
|
|
329
|
+
throw new Error('completed authored root has no durable result');
|
|
330
|
+
}
|
|
331
|
+
return Object.freeze({
|
|
332
|
+
name: output.name,
|
|
333
|
+
completionReason: output.completionReason,
|
|
334
|
+
journalSteps: Object.freeze(output.journalSteps.map(step => Object.freeze({ ...step }))),
|
|
335
|
+
rootRunId,
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function isCompletedRootOutput(value: unknown): value is Omit<AuthoredFlowExecutionResult, 'rootRunId'> {
|
|
340
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
341
|
+
const output = value as Partial<AuthoredFlowExecutionResult>;
|
|
342
|
+
return typeof output.name === 'string'
|
|
343
|
+
&& (output.completionReason === 'success' || output.completionReason === 'needs_human')
|
|
344
|
+
&& Array.isArray(output.journalSteps)
|
|
345
|
+
&& output.journalSteps.every(step => typeof step === 'object' && step !== null
|
|
346
|
+
&& typeof step.id === 'string' && typeof step.runId === 'string'
|
|
347
|
+
&& isSurfaceCompletionReason(step.completionReason));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function assertRootCanDispatch(outcome: RunOutcome): void {
|
|
351
|
+
// The kernel reports `parked` after handing an agent lease to its worker;
|
|
352
|
+
// the pushed dispatch is the authority that work is live. An idempotent
|
|
353
|
+
// start retry may instead observe the same active state as `running`.
|
|
354
|
+
if (outcome.status === 'running' || outcome.status === 'parked') return;
|
|
355
|
+
throw new Error(`authored root "${outcome.run_id}" is ${outcome.status}`
|
|
356
|
+
+ (outcome.completion_reason === null ? '' : ` (${outcome.completion_reason})`));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function rootAdmissionKey(value: string): string {
|
|
360
|
+
return `authored-root:${createHash('sha256').update(value).digest('hex')}`;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function sha256(bytes: Uint8Array): string {
|
|
364
|
+
return createHash('sha256').update(bytes).digest('hex');
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function jsonSnapshot(value: unknown, label: string): unknown {
|
|
368
|
+
const encoded = JSON.stringify(value);
|
|
369
|
+
if (encoded === undefined) throw new Error(`${label} is not JSON serializable`);
|
|
370
|
+
return JSON.parse(encoded) as unknown;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
|
|
374
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
375
|
+
const root = value as Partial<AuthoredRootMetadata>;
|
|
376
|
+
const surface = root.surface as Partial<SurfaceModuleAuthority> | undefined;
|
|
377
|
+
const sources = root.sources;
|
|
378
|
+
return root.kind === ROOT_KIND && typeof root.flowName === 'string'
|
|
379
|
+
&& typeof root.flowPath === 'string' && /^[a-f0-9]{64}$/.test(root.sourceSha256 ?? '')
|
|
380
|
+
&& typeof root.inputPresent === 'boolean' && surface?.packageName === '@relayflows/surface'
|
|
381
|
+
&& typeof surface.version === 'string' && /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
|
|
382
|
+
&& /^[a-f0-9]{64}$/.test(surface.runtimeSha256 ?? '')
|
|
383
|
+
&& Array.isArray(sources) && sources.length > 0 && sources.every(isRootSourceAuthority)
|
|
384
|
+
&& (root.localAgentStream === undefined
|
|
385
|
+
|| /^local-agent-[a-f0-9-]+$/.test(root.localAgentStream));
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function isRootSourceAuthority(value: unknown): value is AuthoredRootSourceAuthority {
|
|
389
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
|
|
390
|
+
const source = value as Partial<AuthoredRootSourceAuthority>;
|
|
391
|
+
const surface = source.surface as Partial<SurfaceModuleAuthority> | undefined;
|
|
392
|
+
return typeof source.path === 'string' && /^[a-f0-9]{64}$/.test(source.sourceSha256 ?? '')
|
|
393
|
+
&& surface?.packageName === '@relayflows/surface' && typeof surface.version === 'string'
|
|
394
|
+
&& /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
|
|
395
|
+
&& /^[a-f0-9]{64}$/.test(surface.runtimeSha256 ?? '');
|
|
396
|
+
}
|