@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.
Files changed (180) 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/authored-admission.d.ts +5 -0
  7. package/dist/authored-admission.d.ts.map +1 -0
  8. package/dist/authored-admission.js +14 -0
  9. package/dist/authored-admission.js.map +1 -0
  10. package/dist/authored-budget.d.ts +1 -1
  11. package/dist/authored-budget.d.ts.map +1 -1
  12. package/dist/authored-budget.js +18 -9
  13. package/dist/authored-budget.js.map +1 -1
  14. package/dist/authored-flow-error.d.ts +1 -1
  15. package/dist/authored-flow-error.d.ts.map +1 -1
  16. package/dist/authored-flow-error.js.map +1 -1
  17. package/dist/authored-flow-executor.d.ts +10 -0
  18. package/dist/authored-flow-executor.d.ts.map +1 -1
  19. package/dist/authored-flow-executor.js +15 -7
  20. package/dist/authored-flow-executor.js.map +1 -1
  21. package/dist/authored-flow-loader.d.ts +20 -0
  22. package/dist/authored-flow-loader.d.ts.map +1 -1
  23. package/dist/authored-flow-loader.js +86 -11
  24. package/dist/authored-flow-loader.js.map +1 -1
  25. package/dist/authored-helper-effect.d.ts +1 -1
  26. package/dist/authored-helper-effect.d.ts.map +1 -1
  27. package/dist/authored-helper-effect.js +5 -3
  28. package/dist/authored-helper-effect.js.map +1 -1
  29. package/dist/authored-mcp.d.ts +1 -1
  30. package/dist/authored-mcp.d.ts.map +1 -1
  31. package/dist/authored-mcp.js +7 -4
  32. package/dist/authored-mcp.js.map +1 -1
  33. package/dist/authored-node-entry.d.ts +2 -0
  34. package/dist/authored-node-entry.d.ts.map +1 -0
  35. package/dist/authored-node-entry.js +111 -0
  36. package/dist/authored-node-entry.js.map +1 -0
  37. package/dist/authored-node-runner.d.ts +11 -0
  38. package/dist/authored-node-runner.d.ts.map +1 -0
  39. package/dist/authored-node-runner.js +246 -0
  40. package/dist/authored-node-runner.js.map +1 -0
  41. package/dist/authored-plugin-effect.d.ts +1 -1
  42. package/dist/authored-plugin-effect.d.ts.map +1 -1
  43. package/dist/authored-plugin-effect.js +7 -4
  44. package/dist/authored-plugin-effect.js.map +1 -1
  45. package/dist/authored-root.d.ts +38 -0
  46. package/dist/authored-root.d.ts.map +1 -0
  47. package/dist/authored-root.js +329 -0
  48. package/dist/authored-root.js.map +1 -0
  49. package/dist/authored-runtime-capability.d.ts +6 -0
  50. package/dist/authored-runtime-capability.d.ts.map +1 -0
  51. package/dist/authored-runtime-capability.js +40 -0
  52. package/dist/authored-runtime-capability.js.map +1 -0
  53. package/dist/authored-source-authority.d.ts +4 -0
  54. package/dist/authored-source-authority.d.ts.map +1 -0
  55. package/dist/authored-source-authority.js +61 -0
  56. package/dist/authored-source-authority.js.map +1 -0
  57. package/dist/authored-worker-step.d.ts +2 -2
  58. package/dist/authored-worker-step.d.ts.map +1 -1
  59. package/dist/authored-worker-step.js +11 -6
  60. package/dist/authored-worker-step.js.map +1 -1
  61. package/dist/budget-preflight.d.ts +28 -3
  62. package/dist/budget-preflight.d.ts.map +1 -1
  63. package/dist/budget-preflight.js +34 -24
  64. package/dist/budget-preflight.js.map +1 -1
  65. package/dist/cli/build.d.ts.map +1 -1
  66. package/dist/cli/build.js +8 -0
  67. package/dist/cli/build.js.map +1 -1
  68. package/dist/cli/check.js +3 -1
  69. package/dist/cli/check.js.map +1 -1
  70. package/dist/cli/deploy.d.ts.map +1 -1
  71. package/dist/cli/deploy.js +5 -0
  72. package/dist/cli/deploy.js.map +1 -1
  73. package/dist/cli/direct-run.d.ts.map +1 -1
  74. package/dist/cli/direct-run.js +46 -14
  75. package/dist/cli/direct-run.js.map +1 -1
  76. package/dist/cli/run.d.ts +10 -0
  77. package/dist/cli/run.d.ts.map +1 -1
  78. package/dist/cli/run.js +90 -12
  79. package/dist/cli/run.js.map +1 -1
  80. package/dist/cli/step-failure.d.ts +40 -0
  81. package/dist/cli/step-failure.d.ts.map +1 -0
  82. package/dist/cli/step-failure.js +150 -0
  83. package/dist/cli/step-failure.js.map +1 -0
  84. package/dist/cli-adapter.d.ts +16 -0
  85. package/dist/cli-adapter.d.ts.map +1 -1
  86. package/dist/cli-adapter.js +17 -0
  87. package/dist/cli-adapter.js.map +1 -1
  88. package/dist/cli.d.ts.map +1 -1
  89. package/dist/cli.js +8 -8
  90. package/dist/cli.js.map +1 -1
  91. package/dist/cloud-run.d.ts +11 -1
  92. package/dist/cloud-run.d.ts.map +1 -1
  93. package/dist/cloud-run.js +65 -19
  94. package/dist/cloud-run.js.map +1 -1
  95. package/dist/daemon-connection.d.ts +7 -0
  96. package/dist/daemon-connection.d.ts.map +1 -1
  97. package/dist/daemon-connection.js +7 -0
  98. package/dist/daemon-connection.js.map +1 -1
  99. package/dist/failure-kinds.d.ts +30 -4
  100. package/dist/failure-kinds.d.ts.map +1 -1
  101. package/dist/failure-kinds.js +8 -1
  102. package/dist/failure-kinds.js.map +1 -1
  103. package/dist/journal-client.d.ts +3 -7
  104. package/dist/journal-client.d.ts.map +1 -1
  105. package/dist/journal-client.js +6 -2
  106. package/dist/journal-client.js.map +1 -1
  107. package/dist/llm-worker.d.ts.map +1 -1
  108. package/dist/llm-worker.js +4 -2
  109. package/dist/llm-worker.js.map +1 -1
  110. package/dist/local-agent.d.ts +1 -1
  111. package/dist/local-agent.d.ts.map +1 -1
  112. package/dist/local-agent.js +2 -2
  113. package/dist/local-agent.js.map +1 -1
  114. package/dist/model-pricing.d.ts +7 -6
  115. package/dist/model-pricing.d.ts.map +1 -1
  116. package/dist/model-pricing.js +8 -6
  117. package/dist/model-pricing.js.map +1 -1
  118. package/dist/preflight.d.ts +9 -1
  119. package/dist/preflight.d.ts.map +1 -1
  120. package/dist/preflight.js +36 -18
  121. package/dist/preflight.js.map +1 -1
  122. package/dist/protocol.d.ts +24 -5
  123. package/dist/protocol.d.ts.map +1 -1
  124. package/dist/spec.d.ts +27 -11
  125. package/dist/spec.d.ts.map +1 -1
  126. package/dist/spec.js.map +1 -1
  127. package/dist/worker-cli.d.ts.map +1 -1
  128. package/dist/worker-cli.js +6 -5
  129. package/dist/worker-cli.js.map +1 -1
  130. package/dist/worker-spend.d.ts +13 -8
  131. package/dist/worker-spend.d.ts.map +1 -1
  132. package/dist/worker-spend.js +27 -4
  133. package/dist/worker-spend.js.map +1 -1
  134. package/dist/worker.d.ts.map +1 -1
  135. package/dist/worker.js +4 -2
  136. package/dist/worker.js.map +1 -1
  137. package/package.json +2 -2
  138. package/src/adapters/base.ts +7 -0
  139. package/src/adapters/claude.ts +1 -0
  140. package/src/authored-admission.ts +17 -0
  141. package/src/authored-budget.ts +38 -11
  142. package/src/authored-flow-error.ts +0 -1
  143. package/src/authored-flow-executor.ts +32 -7
  144. package/src/authored-flow-loader.ts +96 -13
  145. package/src/authored-helper-effect.ts +5 -3
  146. package/src/authored-mcp.ts +6 -4
  147. package/src/authored-node-entry.ts +90 -0
  148. package/src/authored-node-runner.ts +219 -0
  149. package/src/authored-plugin-effect.ts +6 -3
  150. package/src/authored-root.ts +396 -0
  151. package/src/authored-runtime-capability.ts +40 -0
  152. package/src/authored-source-authority.ts +62 -0
  153. package/src/authored-worker-step.ts +15 -4
  154. package/src/budget-preflight.ts +52 -25
  155. package/src/cli/build.ts +7 -0
  156. package/src/cli/check.ts +3 -1
  157. package/src/cli/deploy.ts +4 -0
  158. package/src/cli/direct-run.ts +52 -16
  159. package/src/cli/run.ts +101 -12
  160. package/src/cli/step-failure.ts +160 -0
  161. package/src/cli-adapter.ts +27 -0
  162. package/src/cli.ts +9 -9
  163. package/src/cloud-run.ts +74 -21
  164. package/src/daemon-connection.ts +8 -0
  165. package/src/failure-kinds.ts +28 -2
  166. package/src/journal-client.ts +8 -3
  167. package/src/llm-worker.ts +4 -2
  168. package/src/local-agent.ts +7 -2
  169. package/src/model-pricing.ts +8 -6
  170. package/src/preflight.ts +45 -18
  171. package/src/protocol.ts +17 -2
  172. package/src/spec.ts +28 -5
  173. package/src/worker-cli.ts +7 -5
  174. package/src/worker-spend.ts +32 -5
  175. package/src/worker.ts +4 -2
  176. package/dist/cli/deterministic-failure.d.ts +0 -5
  177. package/dist/cli/deterministic-failure.d.ts.map +0 -1
  178. package/dist/cli/deterministic-failure.js +0 -66
  179. package/dist/cli/deterministic-failure.js.map +0 -1
  180. package/src/cli/deterministic-failure.ts +0 -69
@@ -2,14 +2,31 @@ import { BudgetSyntaxError, parseBudget, toKernelBudget } from './budget.js';
2
2
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
3
3
  import type { JournalClient } from './journal-client.js';
4
4
  import type { RunOutcome } from './protocol.js';
5
- import type { KernelBudgetSpec, KernelRunSpec } from './spec.js';
5
+ import type { KernelBudgetSpec, KernelPriorSpend, KernelRunSpec } from './spec.js';
6
+
7
+ /**
8
+ * One journaled charge, in the accumulator's exact integer form.
9
+ *
10
+ * `unmetered` mirrors the journal's `budget.dollars_unmetered`: the charge
11
+ * spent tokens whose dollar cost is unknown, so its `micro` is a lower bound
12
+ * and not a measured amount. It is carried, not derived from `micro`, because
13
+ * an unmetered charge and a genuinely free charge both report zero dollars.
14
+ */
15
+ interface Charge {
16
+ input: bigint;
17
+ output: bigint;
18
+ micro: bigint;
19
+ ms: bigint;
20
+ day: number;
21
+ unmetered: boolean;
22
+ }
6
23
 
7
24
  /** Serialized admission for the internal authored runner's separate step runs. */
8
25
  export class AuthoredBudget {
9
26
  private readonly limit: KernelBudgetSpec | undefined;
10
27
  private failed = false;
11
28
  private tail: Promise<unknown> = Promise.resolve();
12
- private charges: { input: bigint; output: bigint; micro: bigint; ms: bigint; day: number }[] = [];
29
+ private charges: Charge[] = [];
13
30
 
14
31
  constructor(header: unknown) {
15
32
  try {
@@ -20,8 +37,9 @@ export class AuthoredBudget {
20
37
  }
21
38
  }
22
39
 
23
- async execute<T>(journal: JournalClient, spec: KernelRunSpec, consume: (outcome: RunOutcome) => Promise<T>): Promise<T> {
24
- if (this.limit === undefined) return consume(await journal.runStart(spec));
40
+ async execute<T>(journal: JournalClient, spec: KernelRunSpec,
41
+ consume: (outcome: RunOutcome) => Promise<T>, admissionKey?: string): Promise<T> {
42
+ if (this.limit === undefined) return consume(await journal.runStart(spec, undefined, admissionKey));
25
43
  const previous = this.tail;
26
44
  let release!: () => void;
27
45
  this.tail = new Promise<void>(resolve => { release = resolve; });
@@ -31,14 +49,22 @@ export class AuthoredBudget {
31
49
  // Window selection follows journal timestamps, never the SDK host clock.
32
50
  const day = this.charges.at(-1)?.day;
33
51
  const total = this.charges.filter(c => this.limit!.window !== 'day' || c.day === day)
34
- .reduce((s, c) => ({ input: s.input + c.input, output: s.output + c.output, micro: s.micro + c.micro, ms: s.ms + c.ms }),
35
- { input: 0n, output: 0n, micro: 0n, ms: 0n });
52
+ .reduce((s, c) => ({ input: s.input + c.input, output: s.output + c.output, micro: s.micro + c.micro, ms: s.ms + c.ms,
53
+ // Sticky, exactly as the kernel's running total is: once any charge
54
+ // in the window was unmetered, the carried dollars are a lower bound.
55
+ unmetered: s.unmetered || c.unmetered }),
56
+ { input: 0n, output: 0n, micro: 0n, ms: 0n, unmetered: false });
36
57
  const exactNumber = (n: bigint) => { if (n > BigInt(Number.MAX_SAFE_INTEGER)) throw new Error('budget counter overflow'); return Number(n); };
37
- const outcome = await journal.runStart({ ...spec, budget: { ...this.limit, prior_spend: {
58
+ // One explicit conversion to the shared carried-spend shape, so a new
59
+ // accounting field is added here rather than silently dropped inline.
60
+ const priorSpend: KernelPriorSpend = {
38
61
  tokens_in: exactNumber(total.input), tokens_out: exactNumber(total.output),
39
62
  dollars: `${total.micro / 1_000_000n}.${String(total.micro % 1_000_000n).padStart(6, '0')}`,
40
- wallclock_ms: exactNumber(total.ms), ...(this.limit.window === 'day' && day !== undefined ? { day } : {}),
41
- } } });
63
+ wallclock_ms: exactNumber(total.ms),
64
+ ...(this.limit.window === 'day' && day !== undefined ? { day } : {}),
65
+ ...(total.unmetered ? { dollars_unmetered: true as const } : {}),
66
+ };
67
+ const outcome = await journal.runStart({ ...spec, budget: { ...this.limit, prior_spend: priorSpend } }, undefined, admissionKey);
42
68
  try {
43
69
  if (outcome.completion_reason === 'budget_exceeded') throw new AuthoredFlowExecutionError('step_failed', 'Flow budget exceeded before the next step.', 'budget_exceeded', outcome.run_id);
44
70
  return await consume(outcome);
@@ -48,7 +74,7 @@ export class AuthoredBudget {
48
74
  const { entries } = await journal.journalRead(outcome.run_id, seq);
49
75
  if (entries.length === 0) break;
50
76
  for (const raw of entries) {
51
- const e = raw as {seq: number; entry_type: string; at_ms: number; payload: {budget?: {tokens_in: number; tokens_out: number; dollars: string}; spend?: {wallclock_ms: number}}};
77
+ const e = raw as {seq: number; entry_type: string; at_ms: number; payload: {budget?: {tokens_in: number; tokens_out: number; dollars: string; dollars_unmetered?: boolean}; spend?: {wallclock_ms: number}}};
52
78
  seq = e.seq + 1;
53
79
  if (!['step.completed', 'memory.injected'].includes(e.entry_type)) continue;
54
80
  const b = e.payload.budget;
@@ -57,7 +83,8 @@ export class AuthoredBudget {
57
83
  if (fraction.length > 6 && /[1-9]/.test(fraction.slice(6))) throw new Error('budget accounting requires microdollar precision');
58
84
  this.charges.push({input: BigInt(b.tokens_in), output: BigInt(b.tokens_out),
59
85
  micro: BigInt(whole!) * 1_000_000n + BigInt(fraction.slice(0, 6).padEnd(6, '0')),
60
- ms: BigInt(e.payload.spend?.wallclock_ms ?? 0), day: Math.floor(e.at_ms / 86_400_000)});
86
+ ms: BigInt(e.payload.spend?.wallclock_ms ?? 0), day: Math.floor(e.at_ms / 86_400_000),
87
+ unmetered: b.dollars_unmetered === true});
61
88
  }
62
89
  }
63
90
  }
@@ -9,7 +9,6 @@ export type AuthoredFlowExecutionErrorCode =
9
9
  | 'helper_slack.credential_missing'
10
10
  | 'helper_slack.mount_required'
11
11
  | 'budget_syntax_invalid'
12
- | 'budget_missing_price'
13
12
  | 'agent_cli_unresolved'
14
13
  | 'agent_parked'
15
14
  | 'llm_cli_unresolved'
@@ -1,3 +1,4 @@
1
+ import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
1
2
  import { pluginHelpers } from './plugin-loader.js';
2
3
  import { runPluginEffect } from './authored-plugin-effect.js';
3
4
  import { randomUUID } from 'node:crypto';
@@ -71,7 +72,16 @@ export interface AuthoredFlowJournalStep {
71
72
  readonly completionReason: ProtocolCompletionReason;
72
73
  }
73
74
 
75
+ export interface AuthoredExecutionRuntime {
76
+ readonly kind: 'node';
77
+ readonly version: string;
78
+ readonly executableSha256: string;
79
+ readonly payloadSha256: string;
80
+ }
81
+
74
82
  export interface AuthoredFlowExecutionResult {
83
+ readonly executionRuntime?: AuthoredExecutionRuntime;
84
+ readonly rootRunId?: string;
75
85
  readonly name: string;
76
86
  readonly completionReason: FlowCompletionReason;
77
87
  readonly journalSteps: readonly AuthoredFlowJournalStep[];
@@ -122,6 +132,8 @@ export interface ExecuteAuthoredFlowOptions {
122
132
  readonly onWait?: RunLifecycleOptions['onWait'];
123
133
  readonly onProgress?: (event: ProgressEvent) => void;
124
134
  readonly localAgentStream?: string;
135
+ /** Durable kernel root that owns this body's child admission identities. */
136
+ readonly rootRunId?: string;
125
137
  }
126
138
 
127
139
  export async function executeAuthoredFlow<Input = undefined>(
@@ -130,11 +142,17 @@ export async function executeAuthoredFlow<Input = undefined>(
130
142
  input?: Input,
131
143
  options: ExecuteAuthoredFlowOptions = {},
132
144
  ): Promise<AuthoredFlowExecutionResult> {
145
+ assertAuthoredPromiseHooks();
133
146
  const getDefinition = options.getDefinition ?? getAuthoredFlowDefinition;
134
147
  const localAgentStream = options.localAgentStream;
135
148
  const onProgress = options.onProgress;
136
149
  const flowPath = options.flowPath ?? join(process.cwd(), 'flow.ts');
137
150
  const waitOptions: RunLifecycleOptions = {
151
+ // Carried so a failed `f.agent` can name the journal that holds its
152
+ // evidence. Each authored worker call runs as its own kernel run, and
153
+ // without the data dir the diagnostic can name the run id but not where
154
+ // on disk to read it.
155
+ ...(options.dataDir !== undefined ? { dataDir: options.dataDir } : {}),
138
156
  ...(options.signal !== undefined ? { signal: options.signal } : {}),
139
157
  ...(options.onWait !== undefined ? { onWait: options.onWait } : {}),
140
158
  };
@@ -173,9 +191,14 @@ export async function executeAuthoredFlow<Input = undefined>(
173
191
  let nextStep = 1;
174
192
  let requestedCompletion: FlowCompletionReason | undefined;
175
193
 
176
- const lowerDeterministic = authoredDeterministicRunner(definition.name, journal, journalSteps, budget);
194
+ const lowerDeterministic = authoredDeterministicRunner(
195
+ definition.name, journal, journalSteps, budget, options.rootRunId,
196
+ );
177
197
 
178
- const worker = authoredWorkerRunner(definition, journal, flowPath, journalSteps, waitOptions, localAgentStream, budget, definition.header.budget);
198
+ const worker = authoredWorkerRunner(
199
+ definition, journal, flowPath, journalSteps, waitOptions,
200
+ localAgentStream, budget, definition.header.budget, options.rootRunId,
201
+ );
179
202
 
180
203
  function llmOperation(strings: TemplateStringsArray, ...values: unknown[]): Step<string>;
181
204
  function llmOperation(prompt: string, options: LlmOptions): Step<unknown>;
@@ -205,7 +228,7 @@ export async function executeAuthoredFlow<Input = undefined>(
205
228
  return trackStep(authoredSteps, llmOp);
206
229
  }
207
230
 
208
- const slackRun = randomUUID();
231
+ const slackRun = options.rootRunId ?? randomUUID();
209
232
  function slackOperation<T>(call: SlackCall): Step<T> {
210
233
  assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion);
211
234
  const snapshot = snapshotJsonValue(call, 'f.slack call') as unknown as SlackCall;
@@ -215,7 +238,7 @@ export async function executeAuthoredFlow<Input = undefined>(
215
238
  () => assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion),
216
239
  async () => {
217
240
  const receipt = await runHelperEffect(journal, definition.name, id, snapshot,
218
- options.dataDir ?? dirname(journal.socketPath), journalSteps);
241
+ options.dataDir ?? dirname(journal.socketPath), journalSteps, options.rootRunId);
219
242
  return (call.verb === 'react' ? undefined : receipt) as T;
220
243
  },
221
244
  lifecycle,
@@ -231,7 +254,7 @@ export async function executeAuthoredFlow<Input = undefined>(
231
254
  return trackStep(authoredSteps, new AuthoredFlowOperation<T>(id, verb,
232
255
  () => assertOperationAllowed(verb, definition.name, requestedCompletion),
233
256
  async () => await runHelperEffect(journal, definition.name, id, snapshot,
234
- options.dataDir ?? dirname(journal.socketPath), journalSteps) as T,
257
+ options.dataDir ?? dirname(journal.socketPath), journalSteps, options.rootRunId) as T,
235
258
  lifecycle));
236
259
  }),
237
260
  slack: {
@@ -247,7 +270,7 @@ export async function executeAuthoredFlow<Input = undefined>(
247
270
  id, 'mcp',
248
271
  () => assertOperationAllowed('mcp', definition.name, requestedCompletion),
249
272
  () => runMcpEffect(journal, definition.name, id, server, tool, args,
250
- known, checkedMcp.servers[server]!, journalSteps),
273
+ known, checkedMcp.servers[server]!, journalSteps, options.rootRunId),
251
274
  lifecycle,
252
275
  ));
253
276
  }),
@@ -327,7 +350,8 @@ export async function executeAuthoredFlow<Input = undefined>(
327
350
  const id = `plugin-${nextStep++}`;
328
351
  return trackStep(authoredSteps, new AuthoredFlowOperation(
329
352
  id, label, () => assertOperationAllowed(label, definition.name, requestedCompletion),
330
- () => runPluginEffect(journal, definition.name, id, plugin, verb, args, journalSteps, budget),
353
+ () => runPluginEffect(journal, definition.name, id, plugin, verb, args,
354
+ journalSteps, budget, options.rootRunId),
331
355
  lifecycle,
332
356
  ));
333
357
  }));
@@ -387,6 +411,7 @@ export async function executeAuthoredFlow<Input = undefined>(
387
411
  await lowerDeterministic(`complete-${nextStep}`, requestedCompletion === 'needs_human'
388
412
  ? `printf '%s' '{"completionReason":"needs_human"}'` : ':', true);
389
413
  return Object.freeze({
414
+ ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
390
415
  name: definition.name,
391
416
  completionReason: requestedCompletion,
392
417
  journalSteps: Object.freeze([...journalSteps]),
@@ -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
+ }