@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
@@ -0,0 +1,396 @@
1
+ import { loadPinnedAuthoredSource } from './authored-source-authority.js';
2
+ import { assertAuthoredRuntimeAvailable, runAuthoredInNode } from './authored-node-runner.js';
3
+ import { createHash, randomUUID } from 'node:crypto';
4
+ import { readFile } from 'node:fs/promises';
5
+ import { canonicalize } from './canonical.js';
6
+ import { compileSpec, toKernelSpec } from './compile.js';
7
+ import { executeAuthoredFlow, type AuthoredFlowExecutionResult } from './authored-flow-executor.js';
8
+ import {
9
+ loadAuthoredFlow,
10
+ type LoadedAuthoredFlow,
11
+ type SurfaceModuleAuthority,
12
+ } from './authored-flow-loader.js';
13
+ import { JournalClient } from './journal-client.js';
14
+ import type { RunOutcome, StepDispatchEvent } from './protocol.js';
15
+ import { SPEC_SCHEMA_VERSION } from './spec.js';
16
+ import type { RunLifecycleOptions } from './cli/run.js';
17
+ import { withWorkerLease } from './worker-lease.js';
18
+ import { isSurfaceCompletionReason } from './authored-step-output.js';
19
+
20
+ const ROOT_KIND = 'relayflows.authored-root.v1';
21
+
22
+ export interface AuthoredRootMetadata {
23
+ readonly kind: typeof ROOT_KIND;
24
+ readonly flowName: string;
25
+ readonly flowPath: string;
26
+ readonly sourceSha256: string;
27
+ readonly surface: SurfaceModuleAuthority;
28
+ readonly sources: readonly AuthoredRootSourceAuthority[];
29
+ readonly localAgentStream?: string;
30
+ readonly inputPresent: boolean;
31
+ readonly input?: unknown;
32
+ }
33
+
34
+ export interface AuthoredRootSourceAuthority {
35
+ readonly path: string;
36
+ readonly sourceSha256: string;
37
+ readonly surface: SurfaceModuleAuthority;
38
+ }
39
+
40
+ export interface DurableAuthoredOptions {
41
+ readonly dataDir: string;
42
+ readonly admissionKey?: string;
43
+ readonly localAgentStream?: string;
44
+ readonly lifecycle?: RunLifecycleOptions;
45
+ }
46
+
47
+ /** Admit and execute one authored body under a durable kernel root. */
48
+ export async function executeDurableAuthoredFlow(
49
+ loaded: LoadedAuthoredFlow,
50
+ journal: JournalClient,
51
+ input: unknown,
52
+ options: DurableAuthoredOptions,
53
+ ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
54
+ assertAuthoredRuntimeAvailable();
55
+ const source = await readFile(loaded.sourcePath);
56
+ const sources = await Promise.all(loaded.graph.map(async node => Object.freeze({
57
+ path: node.path,
58
+ sourceSha256: sha256(await readFile(node.path)),
59
+ surface: node.surfaceAuthority,
60
+ })));
61
+ const definition = loaded.getDefinition(loaded.handle);
62
+ const metadata: AuthoredRootMetadata = Object.freeze({
63
+ kind: ROOT_KIND,
64
+ flowName: definition.name,
65
+ flowPath: loaded.sourcePath,
66
+ sourceSha256: sha256(source),
67
+ surface: loaded.surfaceAuthority,
68
+ sources: Object.freeze(sources),
69
+ ...(options.localAgentStream === undefined ? {} : { localAgentStream: options.localAgentStream }),
70
+ inputPresent: input !== undefined,
71
+ ...(input === undefined ? {} : { input: jsonSnapshot(input, 'authored root input') }),
72
+ });
73
+ const admissionKey = rootAdmissionKey(options.admissionKey ?? randomUUID());
74
+ const stream = `authored-root-${admissionKey.slice(-40)}`;
75
+ const spec = rootSpec(metadata, stream);
76
+ const peer = journal.createPeer();
77
+ await peer.connect();
78
+ await peer.hello('flows-authored-root');
79
+ let cancelDispatch = () => {};
80
+ try {
81
+ const dispatchWait = nextRootDispatch(peer);
82
+ cancelDispatch = dispatchWait.cancel;
83
+ await peer.workerAttach(`worker-${stream}`, ['agent'], {
84
+ workspace: [], streams: [{ stream, read_offset: 0 }],
85
+ });
86
+ const outcome = await journal.runStart(spec, undefined, admissionKey);
87
+ if (outcome.status === 'completed') {
88
+ dispatchWait.cancel();
89
+ return await completedRootResult(journal, outcome.run_id);
90
+ }
91
+ assertRootCanDispatch(outcome);
92
+ // `run.start` is an idempotent receipt. If the first caller died after
93
+ // the daemon dispatched this root, a same-daemon retry sees the existing
94
+ // active run but receives no second dispatch from start itself. Resume is
95
+ // safe for the first caller too: the daemon preserves a live lease and
96
+ // redelivers only when the former worker connection is gone.
97
+ await journal.runResume(outcome.run_id);
98
+ const dispatch = await dispatchWait.promise;
99
+ return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
100
+ } finally {
101
+ cancelDispatch();
102
+ peer.close();
103
+ }
104
+ }
105
+
106
+ /** Resume the exact source and Surface instance recorded by an authored root. */
107
+ export async function resumeDurableAuthoredFlow(
108
+ rootRunId: string,
109
+ journal: JournalClient,
110
+ options: Omit<DurableAuthoredOptions, 'admissionKey'>,
111
+ ): Promise<(AuthoredFlowExecutionResult & { readonly rootRunId: string }) | undefined> {
112
+ const metadata = await readAuthoredRootMetadata(journal, rootRunId);
113
+ if (metadata === undefined) return undefined;
114
+ assertAuthoredRuntimeAvailable();
115
+ const loaded = await loadPinnedAuthoredSource(metadata);
116
+ if (metadata.localAgentStream !== options.localAgentStream) {
117
+ throw new Error('authored root local agent surface mismatch');
118
+ }
119
+ const stream = rootStreamFromMetadata(await rootKernelStep(journal, rootRunId));
120
+ const peer = journal.createPeer();
121
+ await peer.connect();
122
+ await peer.hello('flows-authored-root-resume');
123
+ let cancelDispatch = () => {};
124
+ try {
125
+ const dispatchWait = nextRootDispatch(peer);
126
+ cancelDispatch = dispatchWait.cancel;
127
+ await peer.workerAttach(`worker-${stream}-${randomUUID()}`, ['agent'], {
128
+ workspace: [], streams: [{ stream, read_offset: 0 }],
129
+ });
130
+ const outcome = await journal.runResume(rootRunId);
131
+ if (outcome.status === 'completed') {
132
+ dispatchWait.cancel();
133
+ return await completedRootResult(journal, rootRunId);
134
+ }
135
+ assertRootCanDispatch(outcome);
136
+ const dispatch = await dispatchWait.promise;
137
+ return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
138
+ } finally {
139
+ cancelDispatch();
140
+ peer.close();
141
+ }
142
+ }
143
+
144
+ export async function readAuthoredRootMetadata(
145
+ journal: JournalClient,
146
+ runId: string,
147
+ ): Promise<AuthoredRootMetadata | undefined> {
148
+ const step = await rootKernelStep(journal, runId).catch(() => undefined);
149
+ if (step === undefined || step.id !== 'authored-root' || !hasRootStream(step)) return undefined;
150
+ if (typeof step.instruction !== 'string') {
151
+ throw new Error('authored root journal has malformed authority metadata');
152
+ }
153
+ let value: unknown;
154
+ try { value = JSON.parse(step.instruction); } catch {
155
+ throw new Error('authored root journal has malformed authority metadata');
156
+ }
157
+ if (!isRootMetadata(value)) {
158
+ throw new Error('authored root journal has malformed authority metadata');
159
+ }
160
+ return value;
161
+ }
162
+
163
+ async function driveRoot(
164
+ loaded: LoadedAuthoredFlow,
165
+ metadata: AuthoredRootMetadata,
166
+ journal: JournalClient,
167
+ peer: JournalClient,
168
+ dispatch: StepDispatchEvent,
169
+ options: Omit<DurableAuthoredOptions, 'admissionKey'>,
170
+ ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
171
+ try {
172
+ const result = await withWorkerLease(peer, dispatch, async rootSignal => {
173
+ const callerSignal = options.lifecycle?.signal;
174
+ const signal = callerSignal === undefined ? rootSignal : AbortSignal.any([callerSignal, rootSignal]);
175
+ if (process.versions['bun'] !== undefined) {
176
+ return runAuthoredInNode(metadata, journal.socketPath, dispatch.run_id, {
177
+ dataDir: options.dataDir, localAgentStream: options.localAgentStream,
178
+ ...options.lifecycle, signal,
179
+ });
180
+ }
181
+ return await executeAuthoredFlow(
182
+ loaded.handle,
183
+ journal,
184
+ metadata.inputPresent ? metadata.input : undefined,
185
+ {
186
+ getDefinition: loaded.getDefinition,
187
+ dataDir: options.dataDir,
188
+ flowPath: metadata.flowPath,
189
+ localAgentStream: options.localAgentStream,
190
+ rootRunId: dispatch.run_id,
191
+ ...options.lifecycle,
192
+ signal: callerSignal === undefined
193
+ ? rootSignal
194
+ : AbortSignal.any([callerSignal, rootSignal]),
195
+ },
196
+ );
197
+ });
198
+ await peer.stepComplete(
199
+ dispatch.run_id, dispatch.step_id, dispatch.attempt,
200
+ dispatch.idempotency_key, 'success', {
201
+ output: { name: result.name, completionReason: result.completionReason,
202
+ journalSteps: result.journalSteps,
203
+ ...(result.executionRuntime === undefined ? {} : { executionRuntime: result.executionRuntime }) },
204
+ started_pins: dispatch.pins, end_pins: dispatch.pins,
205
+ },
206
+ );
207
+ return Object.freeze({ ...result, rootRunId: dispatch.run_id });
208
+ } catch (error) {
209
+ await terminalizeRootFailure(peer, dispatch, error);
210
+ throw error;
211
+ }
212
+ }
213
+
214
+ /**
215
+ * A returned body failure is deterministic for this root attempt. Complete
216
+ * every kernel retry with the same declared failure so the run reaches its
217
+ * durable `step_failed` terminal instead of being stranded waiting for a
218
+ * worker after the CLI has already returned. A process crash never enters
219
+ * this path: the running attempt remains recoverable by `run.resume`.
220
+ */
221
+ async function terminalizeRootFailure(
222
+ peer: JournalClient,
223
+ firstDispatch: StepDispatchEvent,
224
+ error: unknown,
225
+ ): Promise<void> {
226
+ let dispatch = firstDispatch;
227
+ const output = { error: error instanceof Error ? error.message : 'authored root failed' };
228
+ for (;;) {
229
+ const next = nextRootDispatch(peer);
230
+ let outcome: RunOutcome;
231
+ try {
232
+ outcome = await peer.stepComplete(
233
+ dispatch.run_id, dispatch.step_id, dispatch.attempt,
234
+ dispatch.idempotency_key, 'worker_error', {
235
+ output, started_pins: dispatch.pins, end_pins: dispatch.pins,
236
+ },
237
+ );
238
+ } catch (completionError) {
239
+ next.cancel();
240
+ throw completionError;
241
+ }
242
+ if (outcome.status === 'failed') {
243
+ next.cancel();
244
+ return;
245
+ }
246
+ try { assertRootCanDispatch(outcome); } catch (outcomeError) {
247
+ next.cancel();
248
+ throw outcomeError;
249
+ }
250
+ dispatch = await next.promise;
251
+ if (dispatch.run_id !== firstDispatch.run_id) {
252
+ throw new Error('authored root retry was dispatched for a different run');
253
+ }
254
+ }
255
+ }
256
+
257
+ function rootSpec(metadata: AuthoredRootMetadata, stream: string) {
258
+ return toKernelSpec(compileSpec({
259
+ version: SPEC_SCHEMA_VERSION,
260
+ name: `authored-root/${metadata.flowName}`,
261
+ steps: [{
262
+ id: 'authored-root', type: 'agent',
263
+ instruction: canonicalize(metadata),
264
+ surfaces: { streams: [{ stream }] },
265
+ recoveryMode: 'reset', maxIterations: 8,
266
+ }],
267
+ }));
268
+ }
269
+
270
+ async function rootKernelStep(journal: JournalClient, runId: string): Promise<Record<string, unknown>> {
271
+ const entries = (await journal.journalRead(runId, 1)).entries as Array<Record<string, unknown>>;
272
+ const spawned = entries.find(entry => entry.entry_type === 'run.spawned');
273
+ const payload = spawned?.payload as { spec?: { steps?: unknown[] } } | undefined;
274
+ const step = payload?.spec?.steps?.[0];
275
+ if (typeof step !== 'object' || step === null || Array.isArray(step)) {
276
+ throw new Error('authored root journal has no root step');
277
+ }
278
+ return step as Record<string, unknown>;
279
+ }
280
+
281
+ function rootStreamFromMetadata(step: Record<string, unknown>): string {
282
+ const surfaces = step.surfaces as { streams?: Array<{ stream?: unknown }> } | undefined;
283
+ const stream = surfaces?.streams?.[0]?.stream;
284
+ if (typeof stream !== 'string' || !stream.startsWith('authored-root-')) {
285
+ throw new Error('authored root journal has no pinned stream');
286
+ }
287
+ return stream;
288
+ }
289
+
290
+ function hasRootStream(step: Record<string, unknown>): boolean {
291
+ try { rootStreamFromMetadata(step); return true; } catch { return false; }
292
+ }
293
+
294
+ function nextRootDispatch(peer: JournalClient): {
295
+ promise: Promise<StepDispatchEvent>;
296
+ cancel(): void;
297
+ } {
298
+ let cancel = () => {};
299
+ const promise = new Promise<StepDispatchEvent>((resolve, reject) => {
300
+ const timer = setTimeout(() => {
301
+ peer.off('step.dispatch', onDispatch);
302
+ reject(new Error('authored root was not dispatched'));
303
+ }, 30_000);
304
+ const onDispatch = (dispatch: StepDispatchEvent) => {
305
+ if (dispatch.step_id !== 'authored-root') return;
306
+ clearTimeout(timer);
307
+ peer.off('step.dispatch', onDispatch);
308
+ resolve(dispatch);
309
+ };
310
+ peer.on('step.dispatch', onDispatch);
311
+ cancel = () => {
312
+ clearTimeout(timer);
313
+ peer.off('step.dispatch', onDispatch);
314
+ };
315
+ });
316
+ return { promise, cancel };
317
+ }
318
+
319
+ async function completedRootResult(
320
+ journal: JournalClient,
321
+ rootRunId: string,
322
+ ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
323
+ const entries = (await journal.journalRead(rootRunId, 1)).entries as Array<Record<string, unknown>>;
324
+ const completed = entries.slice().reverse().find(entry => entry.entry_type === 'step.completed'
325
+ && entry.step_id === 'authored-root'
326
+ && (entry.payload as { completionReason?: unknown } | undefined)?.completionReason === 'success');
327
+ const output = (completed?.payload as { output?: unknown } | undefined)?.output;
328
+ if (!isCompletedRootOutput(output)) {
329
+ throw new Error('completed authored root has no durable result');
330
+ }
331
+ return Object.freeze({
332
+ name: output.name,
333
+ completionReason: output.completionReason,
334
+ journalSteps: Object.freeze(output.journalSteps.map(step => Object.freeze({ ...step }))),
335
+ rootRunId,
336
+ });
337
+ }
338
+
339
+ function isCompletedRootOutput(value: unknown): value is Omit<AuthoredFlowExecutionResult, 'rootRunId'> {
340
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
341
+ const output = value as Partial<AuthoredFlowExecutionResult>;
342
+ return typeof output.name === 'string'
343
+ && (output.completionReason === 'success' || output.completionReason === 'needs_human')
344
+ && Array.isArray(output.journalSteps)
345
+ && output.journalSteps.every(step => typeof step === 'object' && step !== null
346
+ && typeof step.id === 'string' && typeof step.runId === 'string'
347
+ && isSurfaceCompletionReason(step.completionReason));
348
+ }
349
+
350
+ function assertRootCanDispatch(outcome: RunOutcome): void {
351
+ // The kernel reports `parked` after handing an agent lease to its worker;
352
+ // the pushed dispatch is the authority that work is live. An idempotent
353
+ // start retry may instead observe the same active state as `running`.
354
+ if (outcome.status === 'running' || outcome.status === 'parked') return;
355
+ throw new Error(`authored root "${outcome.run_id}" is ${outcome.status}`
356
+ + (outcome.completion_reason === null ? '' : ` (${outcome.completion_reason})`));
357
+ }
358
+
359
+ function rootAdmissionKey(value: string): string {
360
+ return `authored-root:${createHash('sha256').update(value).digest('hex')}`;
361
+ }
362
+
363
+ function sha256(bytes: Uint8Array): string {
364
+ return createHash('sha256').update(bytes).digest('hex');
365
+ }
366
+
367
+ function jsonSnapshot(value: unknown, label: string): unknown {
368
+ const encoded = JSON.stringify(value);
369
+ if (encoded === undefined) throw new Error(`${label} is not JSON serializable`);
370
+ return JSON.parse(encoded) as unknown;
371
+ }
372
+
373
+ function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
374
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
375
+ const root = value as Partial<AuthoredRootMetadata>;
376
+ const surface = root.surface as Partial<SurfaceModuleAuthority> | undefined;
377
+ const sources = root.sources;
378
+ return root.kind === ROOT_KIND && typeof root.flowName === 'string'
379
+ && typeof root.flowPath === 'string' && /^[a-f0-9]{64}$/.test(root.sourceSha256 ?? '')
380
+ && typeof root.inputPresent === 'boolean' && surface?.packageName === '@relayflows/surface'
381
+ && typeof surface.version === 'string' && /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
382
+ && /^[a-f0-9]{64}$/.test(surface.runtimeSha256 ?? '')
383
+ && Array.isArray(sources) && sources.length > 0 && sources.every(isRootSourceAuthority)
384
+ && (root.localAgentStream === undefined
385
+ || /^local-agent-[a-f0-9-]+$/.test(root.localAgentStream));
386
+ }
387
+
388
+ function isRootSourceAuthority(value: unknown): value is AuthoredRootSourceAuthority {
389
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
390
+ const source = value as Partial<AuthoredRootSourceAuthority>;
391
+ const surface = source.surface as Partial<SurfaceModuleAuthority> | undefined;
392
+ return typeof source.path === 'string' && /^[a-f0-9]{64}$/.test(source.sourceSha256 ?? '')
393
+ && surface?.packageName === '@relayflows/surface' && typeof surface.version === 'string'
394
+ && /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
395
+ && /^[a-f0-9]{64}$/.test(surface.runtimeSha256 ?? '');
396
+ }
@@ -0,0 +1,40 @@
1
+ import { stripTypeScriptTypes, register } from 'node:module';
2
+ import { createHook } from 'node:async_hooks';
3
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
4
+
5
+ /** A runtime with no-op promise hooks cannot distinguish await from ignored then. */
6
+ export function assertAuthoredPromiseHooks(): void {
7
+ let initialized = false;
8
+ let resolved = false;
9
+ const hook = createHook({
10
+ init(_id, type) { if (type === 'PROMISE') initialized = true; },
11
+ promiseResolve() { resolved = true; },
12
+ });
13
+ try {
14
+ hook.enable();
15
+ void new Promise<void>((resolve) => resolve());
16
+ } finally { hook.disable(); }
17
+ if (!initialized || !resolved) throw new AuthoredFlowExecutionError(
18
+ 'unsupported_promise_lifecycle',
19
+ 'authored execution requires working Node promise hooks; use the standalone Node-authored runner with Node >=22.14',
20
+ );
21
+ }
22
+
23
+ export function assertAuthoredNodeVersion(version = process.versions.node): void {
24
+ const [major, minor] = version.split('.').map(Number);
25
+ if (!Number.isInteger(major) || !Number.isInteger(minor)
26
+ || major! < 22 || (major === 22 && minor! < 14)
27
+ || process.versions['bun'] !== undefined
28
+ || typeof stripTypeScriptTypes !== 'function' || typeof register !== 'function') {
29
+ throw new AuthoredFlowExecutionError('unsupported_promise_lifecycle',
30
+ 'the authored runner requires Node >=22.14; no workflow body was executed');
31
+ }
32
+ assertAuthoredPromiseHooks();
33
+ }
34
+
35
+ /** PID 1 is valid when the standalone CLI is a container's init process. */
36
+ export function parseAuthoredParentPid(value: string | undefined): number {
37
+ const pid = Number(value);
38
+ if (!Number.isSafeInteger(pid) || pid <= 0) throw new Error('invalid authored parent identity');
39
+ return pid;
40
+ }
@@ -0,0 +1,62 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { readFile } from 'node:fs/promises';
3
+ import { register } from 'node:module';
4
+ import { pathToFileURL } from 'node:url';
5
+ import { canonicalize } from './canonical.js';
6
+ import { loadAuthoredFlow } from './authored-flow-loader.js';
7
+ import type { AuthoredRootMetadata } from './authored-root.js';
8
+ const sha256 = (bytes: Uint8Array): string => createHash('sha256').update(bytes).digest('hex');
9
+
10
+ /** Re-open the exact source graph and Surface instance pinned by the durable root. */
11
+ export async function loadPinnedAuthoredSource(metadata: AuthoredRootMetadata, immutableNodeImport = false) {
12
+ const source = await readFile(metadata.flowPath);
13
+ if (sha256(source) !== metadata.sourceSha256) {
14
+ throw new Error('authored root source authority mismatch');
15
+ }
16
+ const sources = new Map([[pathToFileURL(metadata.flowPath).href, source.toString('utf8')]]);
17
+ for (const pinned of metadata.sources) {
18
+ const bytes = await readFile(pinned.path);
19
+ if (sha256(bytes) !== pinned.sourceSha256) {
20
+ throw new Error(`authored root source authority mismatch for "${pinned.path}"`);
21
+ }
22
+ sources.set(pathToFileURL(pinned.path).href, bytes.toString('utf8'));
23
+ }
24
+ if (immutableNodeImport) installPinnedSourceLoader(sources);
25
+ const loaded = await loadAuthoredFlow(metadata.flowPath);
26
+ if (canonicalize(loaded.surfaceAuthority) !== canonicalize(metadata.surface)
27
+ || loaded.getDefinition(loaded.handle).name !== metadata.flowName) {
28
+ throw new Error('authored root Surface module authority mismatch');
29
+ }
30
+ const loadedSources = loaded.graph.map(node => ({
31
+ path: node.path,
32
+ sourceSha256: metadata.sources.find(source => source.path === node.path)?.sourceSha256 ?? '',
33
+ surface: node.surfaceAuthority,
34
+ }));
35
+ if (canonicalize(loadedSources) !== canonicalize(metadata.sources)) {
36
+ throw new Error('authored root declared source graph authority mismatch');
37
+ }
38
+ return loaded;
39
+ }
40
+
41
+ let pinnedSourceLoaderInstalled = false;
42
+
43
+ /** Isolated Node child only: preserve original URLs/resolution, load verified bytes. */
44
+ function installPinnedSourceLoader(sources: Map<string, string>): void {
45
+ if (pinnedSourceLoaderInstalled) throw new Error('authored Node process already owns a source graph');
46
+ pinnedSourceLoaderInstalled = true;
47
+ // Node >=22.14 supports register and stripTypeScriptTypes. A loader thread
48
+ // receives the captured bytes, never reopening mutable authored paths.
49
+ const loader = `
50
+ import {stripTypeScriptTypes} from 'node:module';
51
+ let sources;
52
+ export function initialize(data){sources=new Map(data);}
53
+ export async function load(url,context,next){
54
+ if(sources.has(url))return {format:'module',shortCircuit:true,
55
+ source:stripTypeScriptTypes(sources.get(url),{mode:'transform',sourceUrl:url})};
56
+ if(url.startsWith('file:') && new URL(url).pathname.endsWith('.flow.ts'))
57
+ throw new Error('authored root declared source graph authority mismatch');
58
+ return next(url,context);
59
+ }
60
+ `;
61
+ register('data:text/javascript,' + encodeURIComponent(loader), { data: [...sources] });
62
+ }
@@ -11,6 +11,7 @@ import { SPEC_SCHEMA_VERSION, type FlowSpec, type StepSpec } from './spec.js';
11
11
  import { isSurfaceCompletionReason, readCompletedStepOutput, readSuccessfulOutput } from './authored-step-output.js';
12
12
  import type { AuthoredFlowJournalStep } from './authored-flow-executor.js';
13
13
  import { snapshotJsonValue } from './json-value.js';
14
+ import { authoredChildAdmissionKey } from './authored-admission.js';
14
15
 
15
16
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
16
17
 
@@ -19,6 +20,7 @@ export function authoredWorkerRunner(
19
20
  definition: { name: string }, journal: JournalClient, flowPath: string,
20
21
  journalSteps: AuthoredFlowJournalStep[], waitOptions: RunLifecycleOptions,
21
22
  localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
23
+ rootRunId?: string,
22
24
  ) {
23
25
  async function run(step: StepSpec): Promise<unknown> {
24
26
  const id = step.id;
@@ -35,7 +37,7 @@ export function authoredWorkerRunner(
35
37
  diagnostic.severity === 'refusal',
36
38
  );
37
39
  throw new AuthoredFlowExecutionError(
38
- refusal?.kind === 'budget_missing_price' || refusal?.kind === 'budget_syntax_invalid' ? refusal.kind
40
+ refusal?.kind === 'budget_syntax_invalid' ? refusal.kind
39
41
  : step.type === 'llm' ? 'llm_cli_unresolved' : 'agent_cli_unresolved',
40
42
  refusal?.message
41
43
  ?? `flow "${definition.name}" step "${id}": no CLI could be resolved for f.${step.type} `
@@ -79,7 +81,10 @@ export function authoredWorkerRunner(
79
81
  }
80
82
  return readCompletedStepOutput(journal, outcome.run_id, id, journalSteps);
81
83
  };
82
- return budget === undefined ? consume(await journal.runStart(spec)) : budget.execute(journal, spec, consume);
84
+ const admissionKey = authoredChildAdmissionKey(rootRunId, id);
85
+ return budget === undefined
86
+ ? consume(await journal.runStart(spec, undefined, admissionKey))
87
+ : budget.execute(journal, spec, consume, admissionKey);
83
88
  }
84
89
 
85
90
  return {
@@ -164,6 +169,7 @@ export function authoredWorkerRunner(
164
169
  /** Deterministic commands execute inline under their per-invocation lease. */
165
170
  export function authoredDeterministicRunner(
166
171
  name: string, journal: JournalClient, journalSteps: AuthoredFlowJournalStep[], budget: AuthoredBudget,
172
+ rootRunId?: string,
167
173
  ) {
168
174
  return async (id: string, command: string, terminal = false, leaseMs?: number, verification?: NamedGate): Promise<string> => {
169
175
  const spec = toKernelSpec(compileSpec({
@@ -175,7 +181,12 @@ export function authoredDeterministicRunner(
175
181
  ...(verification === undefined ? {} : { verification }),
176
182
  }],
177
183
  }));
178
- if (terminal) return readSuccessfulOutput(journal, await journal.runStart(spec), id, journalSteps);
179
- return budget.execute(journal, spec, outcome => readSuccessfulOutput(journal, outcome, id, journalSteps));
184
+ const admissionKey = authoredChildAdmissionKey(rootRunId, id);
185
+ if (terminal) return readSuccessfulOutput(
186
+ journal, await journal.runStart(spec, undefined, admissionKey), id, journalSteps,
187
+ );
188
+ return budget.execute(
189
+ journal, spec, outcome => readSuccessfulOutput(journal, outcome, id, journalSteps), admissionKey,
190
+ );
180
191
  };
181
192
  }
@@ -1,31 +1,47 @@
1
1
  import type { CompiledFlowSpec } from './compile.js';
2
- import type { PreflightRefusal } from './preflight.js';
3
- import { MODEL_PRICING } from './model-pricing.js';
2
+ import type { PreflightWarning } from './preflight.js';
3
+ import { hasPricing } from './model-pricing.js';
4
+ import { resolveAdapterKind } from './adapters/index.js';
4
5
 
5
- /** Legacy explicit envelopes keep their worker-supplied pricing contract. */
6
- export function budgetDiagnostics(flow: CompiledFlowSpec): PreflightRefusal[] {
7
- if (flow.budget?.pricing !== 'frozen') return [];
8
- const diagnostics: PreflightRefusal[] = [];
9
- const declared = [
10
- ...Object.entries(flow.agents ?? {}).map(([agent, d]) => ({ agent, model: d.model })),
11
- ...flow.steps.flatMap(s => s.type !== 'deterministic' && s.model !== undefined
12
- ? [{ stepId: s.id, model: s.model }] : []),
13
- ];
6
+ export interface BudgetStepResolution {
7
+ readonly cli?: string;
8
+ readonly model?: string;
9
+ }
10
+
11
+ /**
12
+ * Dollar budgets are light enforcement: pricing never refuses a run. A step
13
+ * whose model has no frozen price journals zero dollars (see `workerSpend`),
14
+ * so it cannot trip `maxDollars`, while its reported tokens still count toward
15
+ * any token budget; priced steps still accrue and a crossed limit still stops
16
+ * the run in the kernel. This warning names each dollar-unmetered step so the
17
+ * gap is reported, not silent.
18
+ *
19
+ * Codex selects its own model when none is declared, so a Codex step is
20
+ * expected to be unmetered and says so rather than asking for a fake price.
21
+ */
22
+ export function budgetDiagnostics(
23
+ flow: CompiledFlowSpec,
24
+ resolved: ReadonlyMap<string, BudgetStepResolution> = new Map(),
25
+ ): PreflightWarning[] {
26
+ if (flow.budget?.pricing !== 'frozen' || flow.budget.maxDollars === undefined) return [];
27
+ const warnings: PreflightWarning[] = [];
14
28
  for (const step of flow.steps) {
15
- if (step.type === 'deterministic' || flow.budget.maxDollars === undefined) continue;
16
- const model = step.model ?? (step.type === 'agent' && step.agent !== undefined
17
- ? flow.agents?.[step.agent]?.model : undefined);
18
- if (model === undefined) diagnostics.push({
19
- severity: 'refusal', kind: 'budget_missing_price', stepId: step.id,
20
- message: `Step "${step.id}" needs a declared, priced model for its dollar budget.`,
21
- });
22
- }
23
- for (const declaration of declared) {
24
- if (Object.hasOwn(MODEL_PRICING, declaration.model)) continue;
25
- diagnostics.push({
26
- severity: 'refusal', kind: 'budget_missing_price', ...declaration,
27
- message: `Model "${declaration.model}" has no frozen price for budget accounting.`,
29
+ if (step.type === 'deterministic') continue;
30
+ const resolution = resolved.get(step.id);
31
+ const model = resolution?.model
32
+ ?? step.model ?? (step.type === 'agent' && step.agent !== undefined
33
+ ? flow.agents?.[step.agent]?.model : undefined);
34
+ if (hasPricing(model)) continue;
35
+ const cli = resolution?.cli ?? step.cli;
36
+ const reason = model !== undefined
37
+ ? `model "${model}" has no frozen price`
38
+ : cli !== undefined && resolveAdapterKind(cli) === 'codex'
39
+ ? 'Codex selects its own model'
40
+ : 'no model is declared';
41
+ warnings.push({
42
+ severity: 'warning', kind: 'budget_unmetered', stepId: step.id,
43
+ message: `Step "${step.id}" is unmetered (${reason}); it does not count toward the dollar budget.`,
28
44
  });
29
45
  }
30
- return diagnostics;
46
+ return warnings;
31
47
  }
package/src/cli/check.ts CHANGED
@@ -358,7 +358,9 @@ function bindResolvedCliPaths(
358
358
  const resolution = byStep.get(step.id);
359
359
  if (resolution === undefined) return step;
360
360
  const directory = resolution.source === 'project' ? configDirectory : flowDirectory;
361
- return { ...step, cli: canonicalCli(resolution.cli, directory) };
361
+ return { ...step, cli: canonicalCli(resolution.cli, directory),
362
+ ...(resolution.modelSource === 'adapter' && resolution.model !== undefined
363
+ ? { model: resolution.model } : {}) };
362
364
  }),
363
365
  };
364
366
  }