@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.
Files changed (172) hide show
  1. package/dist/adapters/base.d.ts +6 -0
  2. package/dist/adapters/base.d.ts.map +1 -1
  3. package/dist/adapters/claude.d.ts.map +1 -1
  4. package/dist/adapters/claude.js +1 -0
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/agent-relay-receipt.d.ts +24 -0
  7. package/dist/agent-relay-receipt.d.ts.map +1 -0
  8. package/dist/agent-relay-receipt.js +66 -0
  9. package/dist/agent-relay-receipt.js.map +1 -0
  10. package/dist/agent-relay-state.d.ts +18 -0
  11. package/dist/agent-relay-state.d.ts.map +1 -0
  12. package/dist/agent-relay-state.js +58 -0
  13. package/dist/agent-relay-state.js.map +1 -0
  14. package/dist/agent-relay-transport.d.ts +17 -70
  15. package/dist/agent-relay-transport.d.ts.map +1 -1
  16. package/dist/agent-relay-transport.js +177 -112
  17. package/dist/agent-relay-transport.js.map +1 -1
  18. package/dist/authored-admission.d.ts +5 -0
  19. package/dist/authored-admission.d.ts.map +1 -0
  20. package/dist/authored-admission.js +14 -0
  21. package/dist/authored-admission.js.map +1 -0
  22. package/dist/authored-budget.d.ts +1 -1
  23. package/dist/authored-budget.d.ts.map +1 -1
  24. package/dist/authored-budget.js +3 -3
  25. package/dist/authored-budget.js.map +1 -1
  26. package/dist/authored-flow-error.d.ts +1 -1
  27. package/dist/authored-flow-error.d.ts.map +1 -1
  28. package/dist/authored-flow-error.js.map +1 -1
  29. package/dist/authored-flow-executor.d.ts +10 -0
  30. package/dist/authored-flow-executor.d.ts.map +1 -1
  31. package/dist/authored-flow-executor.js +10 -7
  32. package/dist/authored-flow-executor.js.map +1 -1
  33. package/dist/authored-flow-loader.d.ts +20 -0
  34. package/dist/authored-flow-loader.d.ts.map +1 -1
  35. package/dist/authored-flow-loader.js +86 -11
  36. package/dist/authored-flow-loader.js.map +1 -1
  37. package/dist/authored-helper-effect.d.ts +1 -1
  38. package/dist/authored-helper-effect.d.ts.map +1 -1
  39. package/dist/authored-helper-effect.js +5 -3
  40. package/dist/authored-helper-effect.js.map +1 -1
  41. package/dist/authored-mcp.d.ts +1 -1
  42. package/dist/authored-mcp.d.ts.map +1 -1
  43. package/dist/authored-mcp.js +7 -4
  44. package/dist/authored-mcp.js.map +1 -1
  45. package/dist/authored-node-entry.d.ts +2 -0
  46. package/dist/authored-node-entry.d.ts.map +1 -0
  47. package/dist/authored-node-entry.js +111 -0
  48. package/dist/authored-node-entry.js.map +1 -0
  49. package/dist/authored-node-runner.d.ts +11 -0
  50. package/dist/authored-node-runner.d.ts.map +1 -0
  51. package/dist/authored-node-runner.js +246 -0
  52. package/dist/authored-node-runner.js.map +1 -0
  53. package/dist/authored-plugin-effect.d.ts +1 -1
  54. package/dist/authored-plugin-effect.d.ts.map +1 -1
  55. package/dist/authored-plugin-effect.js +7 -4
  56. package/dist/authored-plugin-effect.js.map +1 -1
  57. package/dist/authored-root.d.ts +38 -0
  58. package/dist/authored-root.d.ts.map +1 -0
  59. package/dist/authored-root.js +329 -0
  60. package/dist/authored-root.js.map +1 -0
  61. package/dist/authored-runtime-capability.d.ts +6 -0
  62. package/dist/authored-runtime-capability.d.ts.map +1 -0
  63. package/dist/authored-runtime-capability.js +40 -0
  64. package/dist/authored-runtime-capability.js.map +1 -0
  65. package/dist/authored-source-authority.d.ts +4 -0
  66. package/dist/authored-source-authority.d.ts.map +1 -0
  67. package/dist/authored-source-authority.js +61 -0
  68. package/dist/authored-source-authority.js.map +1 -0
  69. package/dist/authored-worker-step.d.ts +2 -2
  70. package/dist/authored-worker-step.d.ts.map +1 -1
  71. package/dist/authored-worker-step.js +11 -6
  72. package/dist/authored-worker-step.js.map +1 -1
  73. package/dist/budget-preflight.d.ts +17 -3
  74. package/dist/budget-preflight.d.ts.map +1 -1
  75. package/dist/budget-preflight.js +31 -24
  76. package/dist/budget-preflight.js.map +1 -1
  77. package/dist/cli/check.js +3 -1
  78. package/dist/cli/check.js.map +1 -1
  79. package/dist/cli/direct-run.d.ts.map +1 -1
  80. package/dist/cli/direct-run.js +18 -14
  81. package/dist/cli/direct-run.js.map +1 -1
  82. package/dist/cli/run.d.ts +2 -0
  83. package/dist/cli/run.d.ts.map +1 -1
  84. package/dist/cli/run.js +56 -1
  85. package/dist/cli/run.js.map +1 -1
  86. package/dist/cli-adapter.d.ts +16 -0
  87. package/dist/cli-adapter.d.ts.map +1 -1
  88. package/dist/cli-adapter.js +17 -0
  89. package/dist/cli-adapter.js.map +1 -1
  90. package/dist/cli.js +4 -4
  91. package/dist/cli.js.map +1 -1
  92. package/dist/cloud-run.d.ts +11 -1
  93. package/dist/cloud-run.d.ts.map +1 -1
  94. package/dist/cloud-run.js +65 -19
  95. package/dist/cloud-run.js.map +1 -1
  96. package/dist/failure-kinds.d.ts +7 -3
  97. package/dist/failure-kinds.d.ts.map +1 -1
  98. package/dist/failure-kinds.js +5 -1
  99. package/dist/failure-kinds.js.map +1 -1
  100. package/dist/journal-client.d.ts +1 -1
  101. package/dist/journal-client.d.ts.map +1 -1
  102. package/dist/journal-client.js +6 -2
  103. package/dist/journal-client.js.map +1 -1
  104. package/dist/llm-worker.d.ts.map +1 -1
  105. package/dist/llm-worker.js +4 -2
  106. package/dist/llm-worker.js.map +1 -1
  107. package/dist/local-agent.d.ts +1 -1
  108. package/dist/local-agent.d.ts.map +1 -1
  109. package/dist/local-agent.js +2 -2
  110. package/dist/local-agent.js.map +1 -1
  111. package/dist/model-pricing.d.ts +3 -4
  112. package/dist/model-pricing.d.ts.map +1 -1
  113. package/dist/model-pricing.js +4 -4
  114. package/dist/model-pricing.js.map +1 -1
  115. package/dist/preflight.d.ts +4 -1
  116. package/dist/preflight.d.ts.map +1 -1
  117. package/dist/preflight.js +36 -18
  118. package/dist/preflight.js.map +1 -1
  119. package/dist/protocol.d.ts +2 -0
  120. package/dist/protocol.d.ts.map +1 -1
  121. package/dist/spec.d.ts +5 -4
  122. package/dist/spec.d.ts.map +1 -1
  123. package/dist/spec.js.map +1 -1
  124. package/dist/worker-cli.d.ts +5 -2
  125. package/dist/worker-cli.d.ts.map +1 -1
  126. package/dist/worker-cli.js +43 -46
  127. package/dist/worker-cli.js.map +1 -1
  128. package/dist/worker-spend.d.ts +4 -3
  129. package/dist/worker-spend.d.ts.map +1 -1
  130. package/dist/worker-spend.js +13 -4
  131. package/dist/worker-spend.js.map +1 -1
  132. package/dist/worker.d.ts.map +1 -1
  133. package/dist/worker.js +12 -4
  134. package/dist/worker.js.map +1 -1
  135. package/package.json +2 -2
  136. package/src/adapters/base.ts +7 -0
  137. package/src/adapters/claude.ts +1 -0
  138. package/src/agent-relay-receipt.ts +120 -0
  139. package/src/agent-relay-state.ts +73 -0
  140. package/src/agent-relay-transport.ts +242 -156
  141. package/src/authored-admission.ts +17 -0
  142. package/src/authored-budget.ts +4 -3
  143. package/src/authored-flow-error.ts +0 -1
  144. package/src/authored-flow-executor.ts +27 -7
  145. package/src/authored-flow-loader.ts +96 -13
  146. package/src/authored-helper-effect.ts +5 -3
  147. package/src/authored-mcp.ts +6 -4
  148. package/src/authored-node-entry.ts +90 -0
  149. package/src/authored-node-runner.ts +219 -0
  150. package/src/authored-plugin-effect.ts +6 -3
  151. package/src/authored-root.ts +396 -0
  152. package/src/authored-runtime-capability.ts +40 -0
  153. package/src/authored-source-authority.ts +62 -0
  154. package/src/authored-worker-step.ts +15 -4
  155. package/src/budget-preflight.ts +41 -25
  156. package/src/cli/check.ts +3 -1
  157. package/src/cli/direct-run.ts +24 -16
  158. package/src/cli/run.ts +58 -1
  159. package/src/cli-adapter.ts +27 -0
  160. package/src/cli.ts +5 -5
  161. package/src/cloud-run.ts +74 -21
  162. package/src/failure-kinds.ts +5 -1
  163. package/src/journal-client.ts +6 -2
  164. package/src/llm-worker.ts +4 -2
  165. package/src/local-agent.ts +7 -2
  166. package/src/model-pricing.ts +4 -4
  167. package/src/preflight.ts +40 -18
  168. package/src/protocol.ts +2 -0
  169. package/src/spec.ts +5 -4
  170. package/src/worker-cli.ts +50 -54
  171. package/src/worker-spend.ts +15 -4
  172. 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, graph: Object.freeze([...loaded.values()]) });
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 resolveGetFlowDefinition(absolutePath, path);
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 resolveGetFlowDefinition(
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(pathToFileURL(resolvedRuntimePath).href) as {
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
- return runtimeModule.getFlowDefinition as GetFlowDefinition;
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 stream = `${call.provider}-helper-${randomUUID()}`;
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);
@@ -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 stream = `mcp-worker-${randomUUID()}`;
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 stream = `plugin-worker-${randomUUID()}`;
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();