@relayflows/sdk 2.0.10 → 2.0.12
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/agent-relay-receipt.d.ts +24 -0
- package/dist/agent-relay-receipt.d.ts.map +1 -0
- package/dist/agent-relay-receipt.js +66 -0
- package/dist/agent-relay-receipt.js.map +1 -0
- package/dist/agent-relay-state.d.ts +18 -0
- package/dist/agent-relay-state.d.ts.map +1 -0
- package/dist/agent-relay-state.js +58 -0
- package/dist/agent-relay-state.js.map +1 -0
- package/dist/agent-relay-transport.d.ts +17 -70
- package/dist/agent-relay-transport.d.ts.map +1 -1
- package/dist/agent-relay-transport.js +177 -112
- package/dist/agent-relay-transport.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 +3 -3
- 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 +10 -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 +17 -3
- package/dist/budget-preflight.d.ts.map +1 -1
- package/dist/budget-preflight.js +31 -24
- package/dist/budget-preflight.js.map +1 -1
- package/dist/cli/check.js +3 -1
- package/dist/cli/check.js.map +1 -1
- package/dist/cli/direct-run.d.ts.map +1 -1
- package/dist/cli/direct-run.js +18 -14
- 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 +56 -1
- package/dist/cli/run.js.map +1 -1
- 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.js +4 -4
- 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/failure-kinds.d.ts +7 -3
- package/dist/failure-kinds.d.ts.map +1 -1
- package/dist/failure-kinds.js +5 -1
- package/dist/failure-kinds.js.map +1 -1
- package/dist/journal-client.d.ts +1 -1
- 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 +3 -4
- package/dist/model-pricing.d.ts.map +1 -1
- package/dist/model-pricing.js +4 -4
- package/dist/model-pricing.js.map +1 -1
- package/dist/preflight.d.ts +4 -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 +2 -0
- package/dist/protocol.d.ts.map +1 -1
- package/dist/spec.d.ts +5 -4
- package/dist/spec.d.ts.map +1 -1
- package/dist/spec.js.map +1 -1
- package/dist/worker-cli.d.ts +5 -2
- package/dist/worker-cli.d.ts.map +1 -1
- package/dist/worker-cli.js +43 -46
- package/dist/worker-cli.js.map +1 -1
- package/dist/worker-spend.d.ts +4 -3
- package/dist/worker-spend.d.ts.map +1 -1
- package/dist/worker-spend.js +13 -4
- package/dist/worker-spend.js.map +1 -1
- package/dist/worker.d.ts.map +1 -1
- package/dist/worker.js +12 -4
- 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/agent-relay-receipt.ts +120 -0
- package/src/agent-relay-state.ts +73 -0
- package/src/agent-relay-transport.ts +242 -156
- package/src/authored-admission.ts +17 -0
- package/src/authored-budget.ts +4 -3
- package/src/authored-flow-error.ts +0 -1
- package/src/authored-flow-executor.ts +27 -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 +41 -25
- package/src/cli/check.ts +3 -1
- package/src/cli/direct-run.ts +24 -16
- package/src/cli/run.ts +58 -1
- package/src/cli-adapter.ts +27 -0
- package/src/cli.ts +5 -5
- package/src/cloud-run.ts +74 -21
- package/src/failure-kinds.ts +5 -1
- package/src/journal-client.ts +6 -2
- package/src/llm-worker.ts +4 -2
- package/src/local-agent.ts +7 -2
- package/src/model-pricing.ts +4 -4
- package/src/preflight.ts +40 -18
- package/src/protocol.ts +2 -0
- package/src/spec.ts +5 -4
- package/src/worker-cli.ts +50 -54
- package/src/worker-spend.ts +15 -4
- package/src/worker.ts +12 -4
|
@@ -1,6 +1,8 @@
|
|
|
1
|
-
import { accessSync, constants, realpathSync } from 'node:fs';
|
|
1
|
+
import { accessSync, constants, existsSync, realpathSync } from 'node:fs';
|
|
2
|
+
import { readFile, readdir } from 'node:fs/promises';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
2
4
|
import { createRequire } from 'node:module';
|
|
3
|
-
import { dirname, resolve } from 'node:path';
|
|
5
|
+
import { dirname, join, relative, resolve, sep } from 'node:path';
|
|
4
6
|
import { pathToFileURL } from 'node:url';
|
|
5
7
|
import {
|
|
6
8
|
getAuthoredFlowDefinition,
|
|
@@ -18,7 +20,15 @@ export class AuthoredFlowLoadError extends Error {
|
|
|
18
20
|
/** Same signature as `getAuthoredFlowDefinition`, resolved from wherever a flow was loaded. */
|
|
19
21
|
export type GetFlowDefinition = <Input = unknown>(handle: FlowHandle) => AuthoredFlowDefinition<Input>;
|
|
20
22
|
|
|
23
|
+
export interface SurfaceModuleAuthority {
|
|
24
|
+
readonly packageName: '@relayflows/surface';
|
|
25
|
+
readonly version: string;
|
|
26
|
+
readonly packageSha256: string;
|
|
27
|
+
readonly runtimeSha256: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
21
30
|
export interface LoadedAuthoredFlow {
|
|
31
|
+
readonly sourcePath: string;
|
|
22
32
|
readonly handle: FlowHandle;
|
|
23
33
|
/**
|
|
24
34
|
* Bound to the SAME `@relayflows/surface` module instance the flow file
|
|
@@ -28,6 +38,8 @@ export interface LoadedAuthoredFlow {
|
|
|
28
38
|
* `getAuthoredFlowDefinition` import.
|
|
29
39
|
*/
|
|
30
40
|
readonly getDefinition: GetFlowDefinition;
|
|
41
|
+
/** Exact Surface package/runtime that owns the handle's WeakMap identity. */
|
|
42
|
+
readonly surfaceAuthority: SurfaceModuleAuthority;
|
|
31
43
|
/** Dependency-first load order, each canonical absolute path appearing once. */
|
|
32
44
|
readonly graph: readonly LoadedAuthoredFlowNode[];
|
|
33
45
|
}
|
|
@@ -36,6 +48,7 @@ export interface LoadedAuthoredFlowNode {
|
|
|
36
48
|
readonly path: string;
|
|
37
49
|
readonly handle: FlowHandle;
|
|
38
50
|
readonly getDefinition: GetFlowDefinition;
|
|
51
|
+
readonly surfaceAuthority: SurfaceModuleAuthority;
|
|
39
52
|
readonly use: readonly string[];
|
|
40
53
|
}
|
|
41
54
|
|
|
@@ -58,7 +71,7 @@ export async function loadAuthoredFlow(path: string): Promise<LoadedAuthoredFlow
|
|
|
58
71
|
if (cached !== undefined) return cached;
|
|
59
72
|
visiting.add(absolutePath);
|
|
60
73
|
try {
|
|
61
|
-
const { handle, getDefinition } = await importAuthoredFlow(absolutePath);
|
|
74
|
+
const { handle, getDefinition, surfaceAuthority } = await importAuthoredFlow(absolutePath);
|
|
62
75
|
const dependencies: string[] = [];
|
|
63
76
|
for (const entry of getDefinition(handle).header.use ?? []) {
|
|
64
77
|
// Validate again at the SDK boundary: the author's surface package may
|
|
@@ -72,7 +85,7 @@ export async function loadAuthoredFlow(path: string): Promise<LoadedAuthoredFlow
|
|
|
72
85
|
}
|
|
73
86
|
dependencies.push(child.path);
|
|
74
87
|
}
|
|
75
|
-
const node = Object.freeze({ path: absolutePath, handle, getDefinition, use: Object.freeze(dependencies) });
|
|
88
|
+
const node = Object.freeze({ path: absolutePath, handle, getDefinition, surfaceAuthority, use: Object.freeze(dependencies) });
|
|
76
89
|
loaded.set(absolutePath, node);
|
|
77
90
|
return node;
|
|
78
91
|
} catch (error) {
|
|
@@ -85,10 +98,11 @@ export async function loadAuthoredFlow(path: string): Promise<LoadedAuthoredFlow
|
|
|
85
98
|
}
|
|
86
99
|
}
|
|
87
100
|
const root = await visit(resolve(path), true);
|
|
88
|
-
return Object.freeze({ handle: root.handle, getDefinition: root.getDefinition,
|
|
101
|
+
return Object.freeze({ sourcePath: root.path, handle: root.handle, getDefinition: root.getDefinition,
|
|
102
|
+
surfaceAuthority: root.surfaceAuthority, graph: Object.freeze([...loaded.values()]) });
|
|
89
103
|
}
|
|
90
104
|
|
|
91
|
-
async function importAuthoredFlow(path: string): Promise<Pick<LoadedAuthoredFlow, 'handle' | 'getDefinition'>> {
|
|
105
|
+
async function importAuthoredFlow(path: string): Promise<Pick<LoadedAuthoredFlow, 'handle' | 'getDefinition' | 'surfaceAuthority'>> {
|
|
92
106
|
const absolutePath = resolve(path);
|
|
93
107
|
try {
|
|
94
108
|
accessSync(absolutePath, constants.R_OK);
|
|
@@ -98,14 +112,14 @@ async function importAuthoredFlow(path: string): Promise<Pick<LoadedAuthoredFlow
|
|
|
98
112
|
|
|
99
113
|
let authoredModule: Record<string, unknown>;
|
|
100
114
|
try {
|
|
101
|
-
authoredModule = await import(pathToFileURL(absolutePath).href) as Record<string, unknown>;
|
|
115
|
+
authoredModule = await import(/* @vite-ignore */ pathToFileURL(absolutePath).href) as Record<string, unknown>;
|
|
102
116
|
} catch (error) {
|
|
103
117
|
throw new AuthoredFlowLoadError(
|
|
104
118
|
`Flow "${path}" could not be imported: ${errorMessage(error)}`,
|
|
105
119
|
);
|
|
106
120
|
}
|
|
107
121
|
|
|
108
|
-
const getDefinition = await
|
|
122
|
+
const { getDefinition, surfaceAuthority } = await resolveSurfaceRuntime(absolutePath, path);
|
|
109
123
|
const handle = authoredModule['default'] as FlowHandle;
|
|
110
124
|
try {
|
|
111
125
|
getDefinition(handle);
|
|
@@ -114,7 +128,7 @@ async function importAuthoredFlow(path: string): Promise<Pick<LoadedAuthoredFlow
|
|
|
114
128
|
`Flow "${path}" must default-export flow(...): ${errorMessage(error)}`,
|
|
115
129
|
);
|
|
116
130
|
}
|
|
117
|
-
return { handle, getDefinition };
|
|
131
|
+
return { handle, getDefinition, surfaceAuthority };
|
|
118
132
|
}
|
|
119
133
|
|
|
120
134
|
/**
|
|
@@ -138,21 +152,27 @@ async function importAuthoredFlow(path: string): Promise<Pick<LoadedAuthoredFlow
|
|
|
138
152
|
* compatible `@relayflows/surface` is provably resolvable from this same
|
|
139
153
|
* anchor.
|
|
140
154
|
*/
|
|
141
|
-
async function
|
|
155
|
+
async function resolveSurfaceRuntime(
|
|
142
156
|
absolutePath: string,
|
|
143
157
|
displayPath: string,
|
|
144
|
-
): Promise<GetFlowDefinition> {
|
|
158
|
+
): Promise<{ getDefinition: GetFlowDefinition; surfaceAuthority: SurfaceModuleAuthority }> {
|
|
145
159
|
const require = createRequire(pathToFileURL(absolutePath));
|
|
146
160
|
let resolvedRuntimePath: string;
|
|
147
161
|
try {
|
|
148
162
|
resolvedRuntimePath = require.resolve('@relayflows/surface/runtime');
|
|
163
|
+
// Some ESM test/load hooks return a percent-encoded absolute path without
|
|
164
|
+
// a file: scheme. Normalize it only when the literal path does not exist.
|
|
165
|
+
if (!existsSync(resolvedRuntimePath) && resolvedRuntimePath.includes('%')) {
|
|
166
|
+
const decoded = decodeURI(resolvedRuntimePath);
|
|
167
|
+
if (existsSync(decoded)) resolvedRuntimePath = decoded;
|
|
168
|
+
}
|
|
149
169
|
} catch (error) {
|
|
150
170
|
throw new AuthoredFlowLoadError(
|
|
151
171
|
`Flow "${displayPath}" imports @relayflows/surface, but @relayflows/surface/runtime `
|
|
152
172
|
+ `could not be resolved from the same location: ${errorMessage(error)}`,
|
|
153
173
|
);
|
|
154
174
|
}
|
|
155
|
-
const runtimeModule = await import(
|
|
175
|
+
const runtimeModule = await import(/* @vite-ignore */ resolvedRuntimePath) as {
|
|
156
176
|
getFlowDefinition?: unknown;
|
|
157
177
|
};
|
|
158
178
|
if (typeof runtimeModule.getFlowDefinition !== 'function') {
|
|
@@ -161,7 +181,70 @@ async function resolveGetFlowDefinition(
|
|
|
161
181
|
+ 'does not export getFlowDefinition — check its @relayflows/surface version.',
|
|
162
182
|
);
|
|
163
183
|
}
|
|
164
|
-
|
|
184
|
+
let packagePath = dirname(resolvedRuntimePath);
|
|
185
|
+
for (;;) {
|
|
186
|
+
const candidate = resolve(packagePath, 'package.json');
|
|
187
|
+
try {
|
|
188
|
+
const packageBytes = await readFile(candidate);
|
|
189
|
+
const manifest = JSON.parse(packageBytes.toString('utf8')) as { name?: unknown; version?: unknown };
|
|
190
|
+
if (manifest.name === '@relayflows/surface') {
|
|
191
|
+
if (typeof manifest.version !== 'string' || manifest.version.length === 0) {
|
|
192
|
+
throw new AuthoredFlowLoadError(`Flow "${displayPath}": @relayflows/surface has no pinned package version.`);
|
|
193
|
+
}
|
|
194
|
+
const runtimeBytes = await readFile(resolvedRuntimePath);
|
|
195
|
+
return {
|
|
196
|
+
getDefinition: runtimeModule.getFlowDefinition as GetFlowDefinition,
|
|
197
|
+
surfaceAuthority: Object.freeze({
|
|
198
|
+
packageName: '@relayflows/surface', version: manifest.version,
|
|
199
|
+
packageSha256: await packageTreeSha256(packagePath),
|
|
200
|
+
runtimeSha256: createHash('sha256').update(runtimeBytes).digest('hex'),
|
|
201
|
+
}),
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
} catch (error) {
|
|
205
|
+
if (!(error instanceof SyntaxError) && (error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
|
|
206
|
+
}
|
|
207
|
+
const parent = dirname(packagePath);
|
|
208
|
+
if (parent === packagePath) break;
|
|
209
|
+
packagePath = parent;
|
|
210
|
+
}
|
|
211
|
+
throw new AuthoredFlowLoadError(
|
|
212
|
+
`Flow "${displayPath}": the resolved @relayflows/surface/runtime is not inside its declared package.`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Hash the exact installed Surface package payload, including path boundaries.
|
|
218
|
+
*
|
|
219
|
+
* npm may materialize a package-local node_modules tree for workspace and
|
|
220
|
+
* file: dependencies. That tree is installation state, not part of the
|
|
221
|
+
* published Surface package, and can contain platform-specific .bin symlinks.
|
|
222
|
+
* Keep it outside the authority boundary while pinning every package payload
|
|
223
|
+
* file, including the runtime module hashed separately by the caller.
|
|
224
|
+
*/
|
|
225
|
+
export async function packageTreeSha256(root: string): Promise<string> {
|
|
226
|
+
const files: string[] = [];
|
|
227
|
+
async function visit(directory: string): Promise<void> {
|
|
228
|
+
const entries = await readdir(directory, { withFileTypes: true });
|
|
229
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
230
|
+
for (const entry of entries) {
|
|
231
|
+
if (directory === root && entry.name === 'node_modules') continue;
|
|
232
|
+
const path = join(directory, entry.name);
|
|
233
|
+
if (entry.isDirectory()) await visit(path);
|
|
234
|
+
else if (entry.isFile()) files.push(path);
|
|
235
|
+
else throw new AuthoredFlowLoadError(
|
|
236
|
+
`@relayflows/surface package contains unsupported entry "${relative(root, path)}".`,
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
await visit(root);
|
|
241
|
+
const hash = createHash('sha256');
|
|
242
|
+
for (const path of files) {
|
|
243
|
+
const bytes = await readFile(path);
|
|
244
|
+
const name = relative(root, path).split(sep).join('/');
|
|
245
|
+
hash.update(name).update('\0').update(String(bytes.length)).update('\0').update(bytes);
|
|
246
|
+
}
|
|
247
|
+
return hash.digest('hex');
|
|
165
248
|
}
|
|
166
249
|
|
|
167
250
|
// Exported for callers (internal SDK tests, and any co-located flow that is
|
|
@@ -13,6 +13,7 @@ import { helperProviders, type HelperCall } from '@relayflows/surface/runtime';
|
|
|
13
13
|
import { helperWriteback, HelperDeliveryError } from './helper-writeback.js';
|
|
14
14
|
import { checkSlackHelpers } from './slack-preflight.js';
|
|
15
15
|
import { atomicJson, readSlackReceipt, receiptPath, slackWriteback, type SlackCall } from './slack-writeback.js';
|
|
16
|
+
import { authoredChildAdmissionKey } from './authored-admission.js';
|
|
16
17
|
|
|
17
18
|
type ProviderCall = HelperCall | SlackCall;
|
|
18
19
|
|
|
@@ -30,20 +31,21 @@ export function assertHelperCredentials(provider: string): void {
|
|
|
30
31
|
/** Each helper uses the existing agent lease + effect protocol; no new kernel verb. */
|
|
31
32
|
export async function runHelperEffect(
|
|
32
33
|
journal: JournalClient, name: string, stepId: string, call: ProviderCall,
|
|
33
|
-
dataDir: string, journalSteps: AuthoredFlowJournalStep[],
|
|
34
|
+
dataDir: string, journalSteps: AuthoredFlowJournalStep[], rootRunId?: string,
|
|
34
35
|
): Promise<unknown> {
|
|
35
36
|
assertHelperCredentials(call.provider);
|
|
36
37
|
if (call.provider === 'notion' && call.verb === 'appendBlock' && process.env.RELAYFLOWS_NOTION_MOCK !== '1') {
|
|
37
38
|
throw new AuthoredFlowExecutionError('helper_provider.unsupported', 'Notion appendBlock has no upstream mount writeback route');
|
|
38
39
|
}
|
|
39
|
-
const
|
|
40
|
+
const admissionKey = authoredChildAdmissionKey(rootRunId, stepId);
|
|
41
|
+
const stream = `${call.provider}-helper-${admissionKey?.slice(-32) ?? randomUUID()}`;
|
|
40
42
|
const spec = toKernelSpec(compileSpec({
|
|
41
43
|
version: SPEC_SCHEMA_VERSION, name: `${name}/${stepId}`,
|
|
42
44
|
steps: [{ id: stepId, type: 'agent', instruction: JSON.stringify(call),
|
|
43
45
|
maxIterations: 3, recoveryMode: 'reset',
|
|
44
46
|
surfaces: { streams: [{ stream }], external: [`/${call.provider}`] } }],
|
|
45
47
|
}));
|
|
46
|
-
const outcome = await journal.runStart(spec);
|
|
48
|
+
const outcome = await journal.runStart(spec, undefined, admissionKey);
|
|
47
49
|
await atomicJson(join(dataDir, 'helper-runs', `${outcome.run_id}.json`), { provider: call.provider });
|
|
48
50
|
await driveHelperEffect(journal, outcome.run_id, spec.steps[0] as KernelAgentStep, call, dataDir);
|
|
49
51
|
const output = await readCompletedStepOutput(journal, outcome.run_id, stepId, journalSteps);
|
package/src/authored-mcp.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
|
11
11
|
import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
|
|
12
12
|
import { readCompletedStepOutput } from './authored-step-output.js';
|
|
13
13
|
import { withWorkerLease } from './worker-lease.js';
|
|
14
|
+
import { authoredChildAdmissionKey } from './authored-admission.js';
|
|
14
15
|
|
|
15
16
|
/** Own keys expose precisely the preflight inventory, including prototype-like names. */
|
|
16
17
|
export function buildMcpProxy(
|
|
@@ -49,11 +50,12 @@ export class McpStepError extends AuthoredFlowExecutionError {
|
|
|
49
50
|
export async function runMcpEffect(
|
|
50
51
|
journal: JournalClient, flowName: string, id: string, server: string, tool: string,
|
|
51
52
|
args: unknown, known: boolean, config: McpServerConfig,
|
|
52
|
-
journalSteps: AuthoredFlowJournalStep[],
|
|
53
|
+
journalSteps: AuthoredFlowJournalStep[], rootRunId?: string,
|
|
53
54
|
): Promise<unknown> {
|
|
54
55
|
const idempotencyKey = `mcp:${server}:${tool}:${createHash('sha256').update(JSON.stringify(args)).digest('hex')}`;
|
|
55
56
|
const surfacePath = `/mcp/${pathPart(server)}/${pathPart(tool)}`;
|
|
56
|
-
const
|
|
57
|
+
const admissionKey = authoredChildAdmissionKey(rootRunId, id);
|
|
58
|
+
const stream = `mcp-worker-${admissionKey?.slice(-32) ?? randomUUID()}`;
|
|
57
59
|
const instruction = JSON.stringify({ type: 'mcp', server, tool, input: args });
|
|
58
60
|
const peer = journal.createPeer();
|
|
59
61
|
let diagnostic: string | undefined;
|
|
@@ -132,11 +134,11 @@ export async function runMcpEffect(
|
|
|
132
134
|
failed(new Error('MCP worker dispatch deadline exceeded'));
|
|
133
135
|
}, 30_000);
|
|
134
136
|
// Propagate a dispatch failure even if run.start itself is still pending.
|
|
135
|
-
const starting = journal.runStart(spec);
|
|
137
|
+
const starting = journal.runStart(spec, undefined, admissionKey);
|
|
136
138
|
await Promise.race([starting, completed]);
|
|
137
139
|
const outcome = await starting;
|
|
138
140
|
childRunId = outcome.run_id;
|
|
139
|
-
await completed;
|
|
141
|
+
if (outcome.status !== 'completed') await completed;
|
|
140
142
|
if (diagnostic !== undefined) {
|
|
141
143
|
try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
|
|
142
144
|
catch (error) {
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { Worker } from 'node:worker_threads';
|
|
2
|
+
import { createHash, createHmac } from 'node:crypto';
|
|
3
|
+
import { readFileSync, writeSync } from 'node:fs';
|
|
4
|
+
import { JournalClient } from './journal-client.js';
|
|
5
|
+
import { executeAuthoredFlow } from './authored-flow-executor.js';
|
|
6
|
+
import { loadPinnedAuthoredSource } from './authored-source-authority.js';
|
|
7
|
+
import { assertAuthoredNodeVersion, parseAuthoredParentPid } from './authored-runtime-capability.js';
|
|
8
|
+
import { AuthoredFlowExecutionError } from './authored-flow-error.js';
|
|
9
|
+
import type { AuthoredRootMetadata } from './authored-root.js';
|
|
10
|
+
|
|
11
|
+
let channelKey: string | undefined, sequence = 0;
|
|
12
|
+
// Capture writers before loading authored modules; credentials never enter env.
|
|
13
|
+
const writeFrame = writeSync, mac = createHmac;
|
|
14
|
+
const send = (message: unknown): void => {
|
|
15
|
+
const payload = JSON.stringify(message);
|
|
16
|
+
if (channelKey === undefined) { writeFrame(3, payload + '\n'); return; }
|
|
17
|
+
const seq = ++sequence;
|
|
18
|
+
writeFrame(3, JSON.stringify({ seq, payload,
|
|
19
|
+
mac: mac('sha256', channelKey).update(`${seq}\0${payload}`).digest('hex') }) + '\n');
|
|
20
|
+
};
|
|
21
|
+
const hash = (path: string): string => createHash('sha256').update(readFileSync(path)).digest('hex');
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
let finished = false;
|
|
24
|
+
const abort = (): void => {
|
|
25
|
+
controller.abort();
|
|
26
|
+
setTimeout(() => process.exit(1), 2000).unref();
|
|
27
|
+
};
|
|
28
|
+
process.on('SIGINT', abort); process.on('SIGTERM', abort);
|
|
29
|
+
// The parent owns the root lease. Do not continue authored effects after it dies.
|
|
30
|
+
process.stdin.on('end', () => { if (!finished) process.exit(1); });
|
|
31
|
+
process.stdin.on('error', () => process.exit(1));
|
|
32
|
+
// A separate event loop enforces parent loss even while authored JS is blocked.
|
|
33
|
+
// Capture the expected PID in the parent's spawn arguments, before any child work.
|
|
34
|
+
const parentPid = parseAuthoredParentPid(process.argv[2]);
|
|
35
|
+
const watchdog = new Worker(`
|
|
36
|
+
const {parentPort,workerData}=require('node:worker_threads');
|
|
37
|
+
function check(){if(process.ppid!==workerData.parentPid)process.kill(process.pid,'SIGKILL');}
|
|
38
|
+
check();setInterval(check,50);parentPort.postMessage('ready');
|
|
39
|
+
`, { eval: true, workerData: { parentPid } });
|
|
40
|
+
// Enforcement must not silently disappear after the readiness Promise settles.
|
|
41
|
+
const watchdogLost = (): void => { if (!finished) process.kill(process.pid, 'SIGKILL'); };
|
|
42
|
+
watchdog.on('error', watchdogLost);
|
|
43
|
+
watchdog.on('exit', watchdogLost);
|
|
44
|
+
let client: JournalClient | undefined;
|
|
45
|
+
try {
|
|
46
|
+
assertAuthoredNodeVersion();
|
|
47
|
+
await new Promise<void>((resolve,reject)=>{watchdog.once('message',()=>resolve());watchdog.once('error',reject);});
|
|
48
|
+
send({ type: 'ready', runtime: { kind: 'node', version: process.versions.node,
|
|
49
|
+
executableSha256: hash(process.execPath), payloadSha256: hash(process.argv[1]!) } });
|
|
50
|
+
const request = await new Promise<{ channelKey: string; metadata: AuthoredRootMetadata; socketPath: string;
|
|
51
|
+
rootRunId: string; dataDir: string; localAgentStream?: string }>((resolve, reject) => {
|
|
52
|
+
let buffer = '';
|
|
53
|
+
process.stdin.setEncoding('utf8');
|
|
54
|
+
const onData = (chunk: string): void => {
|
|
55
|
+
buffer += chunk;
|
|
56
|
+
if (Buffer.byteLength(buffer) > 2 * 1024 * 1024) { reject(new Error('authored runtime request exceeded limit')); return; }
|
|
57
|
+
const end = buffer.indexOf('\n');
|
|
58
|
+
if (end < 0) return;
|
|
59
|
+
process.stdin.off('data', onData);
|
|
60
|
+
try { resolve(JSON.parse(buffer.slice(0, end))); } catch { reject(new Error('invalid authored runtime request')); }
|
|
61
|
+
};
|
|
62
|
+
process.stdin.on('data', onData);
|
|
63
|
+
});
|
|
64
|
+
if (typeof request.channelKey !== 'string' || !/^[a-f0-9]{64}$/.test(request.channelKey)) throw new Error('invalid authored channel key');
|
|
65
|
+
channelKey = request.channelKey;
|
|
66
|
+
controller.signal.throwIfAborted();
|
|
67
|
+
const loaded = await loadPinnedAuthoredSource(request.metadata, true);
|
|
68
|
+
if (request.localAgentStream !== request.metadata.localAgentStream) throw new Error('authored root local agent surface mismatch');
|
|
69
|
+
client = new JournalClient(request.socketPath);
|
|
70
|
+
await client.connect(); await client.hello('flows-authored-node');
|
|
71
|
+
const result = await executeAuthoredFlow(loaded.handle, client,
|
|
72
|
+
request.metadata.inputPresent ? request.metadata.input : undefined, {
|
|
73
|
+
getDefinition: loaded.getDefinition, dataDir: request.dataDir,
|
|
74
|
+
flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
|
|
75
|
+
localAgentStream: request.localAgentStream, signal: controller.signal,
|
|
76
|
+
onProgress: event => send({ type: 'progress', event }),
|
|
77
|
+
onWait: event => send({ type: 'wait', event }),
|
|
78
|
+
});
|
|
79
|
+
send({ type: 'result', result });
|
|
80
|
+
} catch (error) {
|
|
81
|
+
const message = error instanceof Error ? error.message : 'authored body failed';
|
|
82
|
+
const prefix = error instanceof AuthoredFlowExecutionError ? `${error.code}: ` : '';
|
|
83
|
+
send({ type: 'error', message: prefix && message.startsWith(prefix) ? message.slice(prefix.length) : message,
|
|
84
|
+
...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
|
|
85
|
+
completionReason: error.completionReason, runId: error.runId } : {}) });
|
|
86
|
+
process.exitCode = 1;
|
|
87
|
+
} finally {
|
|
88
|
+
finished = true; await watchdog.terminate(); client?.close(); process.stdin.destroy();
|
|
89
|
+
process.off('SIGINT', abort); process.off('SIGTERM', abort);
|
|
90
|
+
}
|
|
@@ -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();
|