@relayflows/sdk 2.0.34 → 2.0.36

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 (162) hide show
  1. package/dist/adapters/base.d.ts +16 -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 +15 -0
  5. package/dist/adapters/claude.js.map +1 -1
  6. package/dist/adapters/codex.d.ts.map +1 -1
  7. package/dist/adapters/codex.js +4 -0
  8. package/dist/adapters/codex.js.map +1 -1
  9. package/dist/adapters/usage-limit.d.ts +2 -0
  10. package/dist/adapters/usage-limit.d.ts.map +1 -0
  11. package/dist/adapters/usage-limit.js +17 -0
  12. package/dist/adapters/usage-limit.js.map +1 -0
  13. package/dist/authored-activity.d.ts +17 -0
  14. package/dist/authored-activity.d.ts.map +1 -0
  15. package/dist/authored-activity.js +218 -0
  16. package/dist/authored-activity.js.map +1 -0
  17. package/dist/authored-child-run.d.ts +34 -0
  18. package/dist/authored-child-run.d.ts.map +1 -0
  19. package/dist/authored-child-run.js +128 -0
  20. package/dist/authored-child-run.js.map +1 -0
  21. package/dist/authored-dispatch.d.ts +24 -0
  22. package/dist/authored-dispatch.d.ts.map +1 -0
  23. package/dist/authored-dispatch.js +81 -0
  24. package/dist/authored-dispatch.js.map +1 -0
  25. package/dist/authored-flow-error.d.ts +32 -3
  26. package/dist/authored-flow-error.d.ts.map +1 -1
  27. package/dist/authored-flow-error.js +3 -1
  28. package/dist/authored-flow-error.js.map +1 -1
  29. package/dist/authored-flow-executor.d.ts +26 -1
  30. package/dist/authored-flow-executor.d.ts.map +1 -1
  31. package/dist/authored-flow-executor.js +122 -16
  32. package/dist/authored-flow-executor.js.map +1 -1
  33. package/dist/authored-flow-loader.d.ts.map +1 -1
  34. package/dist/authored-flow-loader.js +9 -0
  35. package/dist/authored-flow-loader.js.map +1 -1
  36. package/dist/authored-mcp.js +2 -2
  37. package/dist/authored-mcp.js.map +1 -1
  38. package/dist/authored-node-entry.js +4 -1
  39. package/dist/authored-node-entry.js.map +1 -1
  40. package/dist/authored-node-runner.d.ts.map +1 -1
  41. package/dist/authored-node-runner.js +40 -8
  42. package/dist/authored-node-runner.js.map +1 -1
  43. package/dist/authored-plugin-effect.js +2 -2
  44. package/dist/authored-plugin-effect.js.map +1 -1
  45. package/dist/authored-root.d.ts +10 -7
  46. package/dist/authored-root.d.ts.map +1 -1
  47. package/dist/authored-root.js +20 -0
  48. package/dist/authored-root.js.map +1 -1
  49. package/dist/authored-step-output.d.ts +24 -8
  50. package/dist/authored-step-output.d.ts.map +1 -1
  51. package/dist/authored-step-output.js +81 -15
  52. package/dist/authored-step-output.js.map +1 -1
  53. package/dist/authored-subscription-park.d.ts +5 -0
  54. package/dist/authored-subscription-park.d.ts.map +1 -0
  55. package/dist/authored-subscription-park.js +47 -0
  56. package/dist/authored-subscription-park.js.map +1 -0
  57. package/dist/authored-worker-step.d.ts +3 -1
  58. package/dist/authored-worker-step.d.ts.map +1 -1
  59. package/dist/authored-worker-step.js +25 -9
  60. package/dist/authored-worker-step.js.map +1 -1
  61. package/dist/cli/check-activities.d.ts +8 -0
  62. package/dist/cli/check-activities.d.ts.map +1 -0
  63. package/dist/cli/check-activities.js +155 -0
  64. package/dist/cli/check-activities.js.map +1 -0
  65. package/dist/cli/cli-probe.d.ts.map +1 -1
  66. package/dist/cli/cli-probe.js +14 -2
  67. package/dist/cli/cli-probe.js.map +1 -1
  68. package/dist/cli/direct-run.d.ts.map +1 -1
  69. package/dist/cli/direct-run.js +10 -2
  70. package/dist/cli/direct-run.js.map +1 -1
  71. package/dist/cli/run.d.ts +23 -5
  72. package/dist/cli/run.d.ts.map +1 -1
  73. package/dist/cli/run.js +32 -5
  74. package/dist/cli/run.js.map +1 -1
  75. package/dist/cli/subscription-report.d.ts +5 -0
  76. package/dist/cli/subscription-report.d.ts.map +1 -0
  77. package/dist/cli/subscription-report.js +48 -0
  78. package/dist/cli/subscription-report.js.map +1 -0
  79. package/dist/cli-adapter.d.ts +4 -2
  80. package/dist/cli-adapter.d.ts.map +1 -1
  81. package/dist/cli-adapter.js +4 -0
  82. package/dist/cli-adapter.js.map +1 -1
  83. package/dist/cli.d.ts +1 -1
  84. package/dist/cli.d.ts.map +1 -1
  85. package/dist/cli.js +16 -5
  86. package/dist/cli.js.map +1 -1
  87. package/dist/cloud-mirror-step.d.ts +2 -0
  88. package/dist/cloud-mirror-step.d.ts.map +1 -1
  89. package/dist/cloud-mirror-step.js +9 -1
  90. package/dist/cloud-mirror-step.js.map +1 -1
  91. package/dist/cloud-mirror.d.ts.map +1 -1
  92. package/dist/cloud-mirror.js +17 -2
  93. package/dist/cloud-mirror.js.map +1 -1
  94. package/dist/failure-kinds.d.ts +2 -2
  95. package/dist/failure-kinds.d.ts.map +1 -1
  96. package/dist/failure-kinds.js +2 -0
  97. package/dist/failure-kinds.js.map +1 -1
  98. package/dist/index.d.ts +1 -1
  99. package/dist/index.d.ts.map +1 -1
  100. package/dist/index.js.map +1 -1
  101. package/dist/journal-client.d.ts +17 -2
  102. package/dist/journal-client.d.ts.map +1 -1
  103. package/dist/journal-client.js +33 -2
  104. package/dist/journal-client.js.map +1 -1
  105. package/dist/journal-reader.d.ts.map +1 -1
  106. package/dist/journal-reader.js +2 -0
  107. package/dist/journal-reader.js.map +1 -1
  108. package/dist/local-agent.d.ts +4 -1
  109. package/dist/local-agent.d.ts.map +1 -1
  110. package/dist/local-agent.js +14 -4
  111. package/dist/local-agent.js.map +1 -1
  112. package/dist/preflight.d.ts +6 -0
  113. package/dist/preflight.d.ts.map +1 -1
  114. package/dist/preflight.js +36 -10
  115. package/dist/preflight.js.map +1 -1
  116. package/dist/protocol.d.ts +166 -1
  117. package/dist/protocol.d.ts.map +1 -1
  118. package/dist/step-settling.d.ts +11 -0
  119. package/dist/step-settling.d.ts.map +1 -0
  120. package/dist/step-settling.js +22 -0
  121. package/dist/step-settling.js.map +1 -0
  122. package/dist/worker-slots.d.ts +15 -0
  123. package/dist/worker-slots.d.ts.map +1 -1
  124. package/dist/worker-slots.js +40 -3
  125. package/dist/worker-slots.js.map +1 -1
  126. package/package.json +2 -2
  127. package/src/adapters/base.ts +19 -0
  128. package/src/adapters/claude.ts +15 -0
  129. package/src/adapters/codex.ts +6 -0
  130. package/src/adapters/usage-limit.ts +17 -0
  131. package/src/authored-activity.ts +251 -0
  132. package/src/authored-child-run.ts +138 -0
  133. package/src/authored-dispatch.ts +113 -0
  134. package/src/authored-flow-error.ts +36 -0
  135. package/src/authored-flow-executor.ts +166 -15
  136. package/src/authored-flow-loader.ts +12 -0
  137. package/src/authored-mcp.ts +2 -2
  138. package/src/authored-node-entry.ts +4 -1
  139. package/src/authored-node-runner.ts +41 -8
  140. package/src/authored-plugin-effect.ts +2 -2
  141. package/src/authored-root.ts +28 -4
  142. package/src/authored-step-output.ts +88 -15
  143. package/src/authored-subscription-park.ts +46 -0
  144. package/src/authored-worker-step.ts +24 -8
  145. package/src/cli/check-activities.ts +144 -0
  146. package/src/cli/cli-probe.ts +15 -3
  147. package/src/cli/direct-run.ts +9 -0
  148. package/src/cli/run.ts +56 -9
  149. package/src/cli/subscription-report.ts +51 -0
  150. package/src/cli-adapter.ts +8 -2
  151. package/src/cli.ts +16 -6
  152. package/src/cloud-mirror-step.ts +16 -2
  153. package/src/cloud-mirror.ts +15 -2
  154. package/src/failure-kinds.ts +2 -0
  155. package/src/index.ts +6 -0
  156. package/src/journal-client.ts +47 -3
  157. package/src/journal-reader.ts +2 -0
  158. package/src/local-agent.ts +17 -3
  159. package/src/preflight.ts +46 -10
  160. package/src/protocol.ts +129 -0
  161. package/src/step-settling.ts +19 -0
  162. package/src/worker-slots.ts +63 -4
@@ -22,6 +22,7 @@ import {
22
22
  import { commitAuthoredVerdict } from './authored-completion-record.js';
23
23
  import {
24
24
  type AgentResult,
25
+ type DispatchResult,
25
26
  type LlmOptions,
26
27
  type CloudHelper,
27
28
  type CompletionReason as SurfaceCompletionReason,
@@ -38,9 +39,11 @@ import { observeStep, type ProgressEvent } from './progress.js';
38
39
  import { parseStepTimeout } from './compile.js';
39
40
  import { getAuthoredFlowDefinition } from './authored-flow.js';
40
41
  import type { GetFlowDefinition } from './authored-flow-loader.js';
42
+ import type { LoadedAuthoredFlowNode } from './authored-flow-loader.js';
41
43
  import type { RunLifecycleOptions } from './cli/run.js';
42
44
  import {
43
45
  AuthoredFlowExecutionError,
46
+ type AuthoredFlowSuspension,
44
47
  AuthoredHumanParked,
45
48
  type AuthoredFlowExecutionErrorCode,
46
49
  } from './authored-flow-error.js';
@@ -52,6 +55,7 @@ import {
52
55
  verifyAuthoredOperations,
53
56
  } from './authored-flow-operation.js';
54
57
  import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js';
58
+ import { AuthoredActivities } from './authored-activity.js';
55
59
  import { displayLabel, type AuthoredStepEdges } from './authored-step-index.js';
56
60
  import { JournalClient } from './journal-client.js';
57
61
  import { PluginError } from './plugin-manifest.js';
@@ -66,6 +70,8 @@ import type {
66
70
  CompletionReason as ProtocolCompletionReason,
67
71
  RunCompletionReason as ProtocolRunCompletionReason,
68
72
  } from './protocol.js';
73
+ import type { AuthoredWorkerSlots } from './worker-slots.js';
74
+ import { childLeaves, helperAuthority, parseDispatchReceipt, resolveDispatchChild } from './authored-dispatch.js';
69
75
 
70
76
  type Assert<T extends true> = T;
71
77
  type Equal<A, B> = [A] extends [B]
@@ -101,6 +107,7 @@ export interface AuthoredExecutionRuntime {
101
107
  }
102
108
 
103
109
  export interface AuthoredFlowExecutionResult {
110
+ readonly state?: undefined;
104
111
  readonly executionRuntime?: AuthoredExecutionRuntime;
105
112
  readonly rootRunId?: string;
106
113
  readonly name: string;
@@ -114,6 +121,14 @@ export interface AuthoredFlowExecutionResult {
114
121
  readonly journalSteps: readonly AuthoredFlowJournalStep[];
115
122
  }
116
123
 
124
+ /** A body reached a durable event boundary and released its worker lease. */
125
+ export interface AuthoredFlowSuspendedResult {
126
+ readonly state: 'suspended';
127
+ readonly name: string;
128
+ readonly suspension: AuthoredFlowSuspension;
129
+ readonly journalSteps: readonly AuthoredFlowJournalStep[];
130
+ }
131
+
117
132
  // The authoring SURFACE stays wide: `done()` accepts every
118
133
  // `FlowCompletionReason` and refuses the kernel-owned ones at runtime with a
119
134
  // diagnostic that names the alternative. The RESULT is narrow, because `done()`
@@ -185,6 +200,20 @@ export interface ExecuteAuthoredFlowOptions {
185
200
  * from authored input: direct `/workflows/run` callers control that JSON.
186
201
  */
187
202
  readonly extensionDispatch?: HostedExtensionDispatch;
203
+ /** Pinned static `use:` graph available to `f.dispatch`. */
204
+ readonly flowGraph?: readonly LoadedAuthoredFlowNode[];
205
+ /** Internal namespace for descendant step/admission identities. */
206
+ readonly stepPrefix?: string;
207
+ /** Current child depth. Root is zero; dispatch is capped at three. */
208
+ readonly dispatchDepth?: number;
209
+ /** Parent predecessors inherited by the first operation in a child body. */
210
+ readonly entryAfter?: readonly string[];
211
+ /** Root-owned budget accumulator shared by every descendant. */
212
+ readonly sharedBudget?: AuthoredBudget;
213
+ /** Root-owned worker capacity shared by every descendant. */
214
+ readonly workerSlots?: AuthoredWorkerSlots;
215
+ /** Parent helper authority inherited by this dispatched child. */
216
+ readonly allowedHelperProviders?: ReadonlySet<string>;
188
217
  }
189
218
 
190
219
  export async function executeAuthoredFlow<Input = undefined>(
@@ -209,7 +238,7 @@ export async function executeAuthoredFlow<Input = undefined>(
209
238
  };
210
239
  const definition = getDefinition<Input>(handle);
211
240
  const hostedHandler = extensionHandlerForHostedDispatch(options.extensionDispatch, options.extensions ?? []);
212
- const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory' && key !== 'version' && key !== 'hooks');
241
+ const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory' && key !== 'version' && key !== 'hooks' && key !== 'use');
213
242
  if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['mcp', ...helperProviders.map(p => p.namespace)].includes(key))) headerFields.push('tools');
214
243
  if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile');
215
244
  const helperPreflight = checkSlackHelpers(definition);
@@ -242,7 +271,7 @@ export async function executeAuthoredFlow<Input = undefined>(
242
271
  );
243
272
  }
244
273
 
245
- const budget = new AuthoredBudget(definition.header.budget);
274
+ const budget = options.sharedBudget ?? new AuthoredBudget(definition.header.budget);
246
275
  if (definition.header.memory?.agent === true) {
247
276
  throw new AuthoredFlowExecutionError('unsupported_header', 'memory.agent requires the follow-up identity-scoped agent memory adapter');
248
277
  }
@@ -255,15 +284,35 @@ export async function executeAuthoredFlow<Input = undefined>(
255
284
  const journalSteps: AuthoredFlowJournalStep[] = [];
256
285
  const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
257
286
  const lifecycle = new AuthoredFlowLifecycle();
258
- const stepEdges = (step: string): AuthoredStepEdges | undefined => lifecycle.stepEdges(step);
287
+ const activities = new AuthoredActivities(journal, options.rootRunId);
288
+ const edgeOverrides = new Map<string, AuthoredStepEdges>();
289
+ const stepEdges = (step: string): AuthoredStepEdges | undefined => {
290
+ const overridden = edgeOverrides.get(step);
291
+ if (overridden !== undefined) return overridden;
292
+ const local = lifecycle.stepEdges(step);
293
+ if (local === undefined || (local.after !== undefined && local.after.length > 0) || options.entryAfter === undefined
294
+ || options.entryAfter.length === 0) return local;
295
+ return { ...local, after: [...options.entryAfter] };
296
+ };
259
297
  let nextStep = 1;
260
298
  let requestedCompletion: LoweredCompletionReason | undefined;
261
299
  let requestedDetail: string | undefined;
300
+ const qualify = (id: string): string => `${options.stepPrefix ?? ''}${id}`;
301
+ const nextId = (kind: string): string => qualify(`${kind}-${nextStep++}`);
302
+ const assertHelperAllowed = (provider: string): void => {
303
+ if (options.allowedHelperProviders !== undefined && !options.allowedHelperProviders.has(provider)) {
304
+ throw new AuthoredFlowExecutionError(
305
+ 'dispatch_invalid',
306
+ `child flow "${definition.name}" attempted helper provider "${provider}" outside its parent's granted integrations`,
307
+ );
308
+ }
309
+ };
262
310
 
263
311
  const lowerDeterministic = authoredDeterministicRunner(
264
312
  definition.name, journal, journalSteps, budget, {
265
313
  ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
266
314
  ...(options.dataDir === undefined ? {} : { dataDir: options.dataDir }),
315
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
267
316
  stepEdges,
268
317
  },
269
318
  );
@@ -271,6 +320,7 @@ export async function executeAuthoredFlow<Input = undefined>(
271
320
  const worker = authoredWorkerRunner(
272
321
  definition, journal, flowPath, journalSteps, waitOptions,
273
322
  localAgentStream, budget, definition.header.budget, options.rootRunId, options.workerCapacity, stepEdges,
323
+ options.workerSlots,
274
324
  );
275
325
 
276
326
  /**
@@ -373,7 +423,7 @@ export async function executeAuthoredFlow<Input = undefined>(
373
423
  function llmOperation(prompt: string, options: LlmOptions): Step<unknown>;
374
424
  function llmOperation(prompt: string | TemplateStringsArray, ...values: unknown[]): Step<unknown> {
375
425
  assertOperationAllowed('llm', definition.name, requestedCompletion);
376
- const id = `llm-${nextStep++}`;
426
+ const id = nextId('llm');
377
427
  // Hoist the operation reference so the start closure can read the
378
428
  // caller's `.gate(config)` at spec-build time. AuthoredFlowOperation
379
429
  // begins after construction returns, so `llmOp` is defined by then.
@@ -420,7 +470,7 @@ export async function executeAuthoredFlow<Input = undefined>(
420
470
  `f.run options.onNonZero must be 'fail' or 'record' (got ${JSON.stringify(policy)}).`,
421
471
  );
422
472
  }
423
- const id = `run-${nextStep++}`;
473
+ const id = nextId('run');
424
474
  if (policy === 'record') {
425
475
  let recordOp!: AuthoredFlowOperation<RunResult>;
426
476
  recordOp = new AuthoredFlowOperation<RunResult>(
@@ -451,8 +501,9 @@ export async function executeAuthoredFlow<Input = undefined>(
451
501
  const slackRun = options.rootRunId ?? randomUUID();
452
502
  function slackOperation<T>(call: SlackCall): Step<T> {
453
503
  assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion);
504
+ assertHelperAllowed(call.provider);
454
505
  const snapshot = snapshotJsonValue(call, 'f.slack call') as unknown as SlackCall;
455
- const id = `slack-${slackRun}-${nextStep++}`;
506
+ const id = qualify(`slack-${slackRun}-${nextStep++}`);
456
507
  return trackStep(authoredSteps, new AuthoredFlowOperation<T>(
457
508
  id, `slack.${call.verb}`,
458
509
  () => assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion),
@@ -480,8 +531,9 @@ export async function executeAuthoredFlow<Input = undefined>(
480
531
  ...createHelpers(<T>(call: HelperCall): Step<T> => {
481
532
  const verb = `${call.provider}.${call.verb}`;
482
533
  assertOperationAllowed(verb, definition.name, requestedCompletion);
534
+ assertHelperAllowed(call.provider);
483
535
  const snapshot = snapshotJsonValue(call, `f.${verb} call`) as unknown as HelperCall;
484
- const id = `${call.provider}-${slackRun}-${nextStep++}`;
536
+ const id = qualify(`${call.provider}-${slackRun}-${nextStep++}`);
485
537
  return trackStep(authoredSteps, new AuthoredFlowOperation<T>(id, verb,
486
538
  () => assertOperationAllowed(verb, definition.name, requestedCompletion),
487
539
  async () => await runHelperEffect(journal, definition.name, id, snapshot,
@@ -496,7 +548,7 @@ export async function executeAuthoredFlow<Input = undefined>(
496
548
  },
497
549
  mcp: buildMcpProxy(checkedMcp.inventory, (server, tool, args, known) => {
498
550
  assertOperationAllowed('mcp', definition.name, requestedCompletion);
499
- const id = `mcp-${nextStep++}`;
551
+ const id = nextId('mcp');
500
552
  return trackStep(authoredSteps, new AuthoredFlowOperation(
501
553
  id, 'mcp',
502
554
  () => assertOperationAllowed('mcp', definition.name, requestedCompletion),
@@ -516,7 +568,7 @@ export async function executeAuthoredFlow<Input = undefined>(
516
568
  assertOperationAllowed('agent', definition.name, requestedCompletion);
517
569
  // Authored headers do not yet declare reusable named agents, so the name
518
570
  // identifies nothing to the kernel; it is the step's label in the DAG.
519
- const id = `agent-${nextStep++}`;
571
+ const id = nextId('agent');
520
572
  let agentOp!: AuthoredFlowOperation<AgentResult>;
521
573
  agentOp = new AuthoredFlowOperation<AgentResult>(
522
574
  id,
@@ -528,6 +580,17 @@ export async function executeAuthoredFlow<Input = undefined>(
528
580
  );
529
581
  return trackStep(authoredSteps, agentOp);
530
582
  },
583
+ on(source, activityOptions) {
584
+ assertOperationAllowed('on', definition.name, requestedCompletion);
585
+ // Activity ids are root-scoped (`activity-N`); a child's own counter would collide.
586
+ if ((options.dispatchDepth ?? 0) > 0) {
587
+ throw new AuthoredFlowExecutionError(
588
+ 'dispatch_invalid',
589
+ `child flow "${definition.name}" cannot call f.on in this release; activities are root-scoped`,
590
+ );
591
+ }
592
+ return activities.open(source, activityOptions);
593
+ },
531
594
  /**
532
595
  * `f.human` (docs/SURFACE.md §1, §7). The question is not a child run: it
533
596
  * is the ROOT attempt parking on the kernel's `wait.human`. The body
@@ -545,6 +608,12 @@ export async function executeAuthoredFlow<Input = undefined>(
545
608
  */
546
609
  human(question, humanOptions) {
547
610
  assertOperationAllowed('human', definition.name, requestedCompletion);
611
+ if ((options.dispatchDepth ?? 0) > 0) {
612
+ throw new AuthoredFlowExecutionError(
613
+ 'dispatch_invalid',
614
+ `child flow "${definition.name}" cannot call f.human in this release; the parent owns approval authority`,
615
+ );
616
+ }
548
617
  if (typeof question !== 'string' || question.trim() === '') {
549
618
  throw new AuthoredFlowExecutionError('human_answer_invalid', 'f.human requires a non-empty question');
550
619
  }
@@ -558,7 +627,7 @@ export async function executeAuthoredFlow<Input = undefined>(
558
627
  if (!parsedTo.ok) {
559
628
  throw new AuthoredFlowExecutionError('human_to_invalid', `f.human to ${parsedTo.reason}`);
560
629
  }
561
- const id = `human-${nextStep++}`;
630
+ const id = nextId('human');
562
631
  const to = humanOptions.to;
563
632
  // Hoisted like `llmOp`/`runOp`: the start closure reads the caller's
564
633
  // `.gate(config)` at spec-build time, so a named gate on the answer is
@@ -590,13 +659,79 @@ export async function executeAuthoredFlow<Input = undefined>(
590
659
  );
591
660
  return trackStep(authoredSteps, humanOp);
592
661
  },
593
- dispatch<T>() {
662
+ dispatch(flowName, dispatchInput) {
594
663
  assertOperationAllowed('dispatch', definition.name, requestedCompletion);
595
- throw unsupportedVerb('dispatch');
664
+ const depth = options.dispatchDepth ?? 0;
665
+ const child = resolveDispatchChild({
666
+ handle, definition, graph: options.flowGraph,
667
+ flowName, depth,
668
+ });
669
+ const childDefinition = child.getDefinition(child.handle);
670
+ const inputSnapshot = snapshotJsonValue(dispatchInput, `f.dispatch(${JSON.stringify(flowName)}) input`);
671
+ const id = nextId('dispatch');
672
+ let dispatchOp!: AuthoredFlowOperation<DispatchResult>;
673
+ dispatchOp = new AuthoredFlowOperation<DispatchResult>(
674
+ id,
675
+ 'dispatch',
676
+ () => assertOperationAllowed('dispatch', definition.name, requestedCompletion),
677
+ async () => {
678
+ const inheritedAfter = stepEdges(id)?.after ?? [];
679
+ const currentHelperAuthority = helperAuthority(definition);
680
+ const allowedHelperProviders = options.allowedHelperProviders === undefined
681
+ ? currentHelperAuthority
682
+ : new Set([...currentHelperAuthority].filter(provider => options.allowedHelperProviders!.has(provider)));
683
+ const childResult = await executeAuthoredFlow(child.handle, journal, inputSnapshot, {
684
+ getDefinition: child.getDefinition,
685
+ dataDir: options.dataDir,
686
+ flowPath: child.path,
687
+ ...(localAgentStream === undefined ? {} : { localAgentStream }),
688
+ ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
689
+ ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
690
+ flowGraph: options.flowGraph,
691
+ stepPrefix: `${id}--`,
692
+ dispatchDepth: depth + 1,
693
+ entryAfter: inheritedAfter,
694
+ sharedBudget: budget,
695
+ ...(worker.slots === undefined ? {} : { workerSlots: worker.slots }),
696
+ allowedHelperProviders,
697
+ ...(options.extensions === undefined ? {} : { extensions: options.extensions }),
698
+ onProgress,
699
+ ...(options.onWait === undefined ? {} : { onWait: options.onWait }),
700
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
701
+ });
702
+ if (childResult.completionReason !== 'success') {
703
+ throw new AuthoredFlowExecutionError(
704
+ 'step_failed',
705
+ `child flow "${childResult.name}" completed with ${childResult.completionReason}`
706
+ + (childResult.completionDetail === undefined ? '' : `: ${childResult.completionDetail}`),
707
+ childResult.completionReason === 'step_failed' ? 'step_failed' : undefined,
708
+ );
709
+ }
710
+ const leaves = childLeaves(childResult.journalSteps);
711
+ edgeOverrides.set(id, {
712
+ ...displayLabel(childResult.name),
713
+ ...(leaves.length > 0 ? { after: leaves } : inheritedAfter.length === 0
714
+ ? {} : { after: inheritedAfter }),
715
+ });
716
+ const receipt: DispatchResult = {
717
+ name: childResult.name,
718
+ completionReason: 'success',
719
+ ...(childResult.completionDetail === undefined ? {} : { completionDetail: childResult.completionDetail }),
720
+ };
721
+ const literal = `'${JSON.stringify(receipt).replaceAll("'", "'\\''")}'`;
722
+ const recorded = await lowerDeterministic(
723
+ id, `printf '%s' ${literal}`, false, undefined, dispatchOp.namedGate,
724
+ );
725
+ return parseDispatchReceipt(recorded, id);
726
+ },
727
+ lifecycle,
728
+ displayLabel(flowName),
729
+ );
730
+ return trackStep(authoredSteps, dispatchOp);
596
731
  },
597
732
  hook(name, input) {
598
733
  assertOperationAllowed('hook', definition.name, requestedCompletion);
599
- const id = `hook-${nextStep++}`;
734
+ const id = nextId('hook');
600
735
  const snapshot = snapshotJsonValue(input, 'f.hook input');
601
736
  return trackStep(authoredSteps, new AuthoredFlowOperation<boolean>(
602
737
  id, 'hook',
@@ -661,7 +796,7 @@ export async function executeAuthoredFlow<Input = undefined>(
661
796
  Object.assign(context, pluginHelpers(checkedMcp.plugins ?? [], (plugin, verb, args) => {
662
797
  const label = `${verb.namespace}.${verb.method}`;
663
798
  assertOperationAllowed(label, definition.name, requestedCompletion);
664
- const id = `plugin-${nextStep++}`;
799
+ const id = nextId('plugin');
665
800
  return trackStep(authoredSteps, new AuthoredFlowOperation(
666
801
  id, label, () => assertOperationAllowed(label, definition.name, requestedCompletion),
667
802
  () => runPluginEffect(journal, definition.name, id, plugin, verb, args,
@@ -683,6 +818,11 @@ export async function executeAuthoredFlow<Input = undefined>(
683
818
  try {
684
819
  worker.stop(bodyFailure);
685
820
  await stopAuthoredOperations(authoredSteps, bodyFailure);
821
+ // A durable wait hands execution back to the control plane. Its
822
+ // subscriptions must keep receiving events while no body is running.
823
+ const parked = bodyFailure instanceof AuthoredFlowExecutionError
824
+ && (bodyFailure.code === 'subscription_suspended' || bodyFailure.code === 'human_parked');
825
+ if (!parked) await activities.closeAll('canceled');
686
826
  } finally {
687
827
  lifecycle.close();
688
828
  }
@@ -710,6 +850,7 @@ export async function executeAuthoredFlow<Input = undefined>(
710
850
  try {
711
851
  worker.stop(missingCompletion);
712
852
  await stopAuthoredOperations(authoredSteps, missingCompletion);
853
+ await activities.closeAll('canceled');
713
854
  } finally {
714
855
  lifecycle.close();
715
856
  }
@@ -717,6 +858,16 @@ export async function executeAuthoredFlow<Input = undefined>(
717
858
  }
718
859
  try {
719
860
  await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle);
861
+ } catch (error) {
862
+ try {
863
+ await activities.closeAll('canceled');
864
+ } finally {
865
+ lifecycle.close();
866
+ }
867
+ throw error;
868
+ }
869
+ try {
870
+ await activities.closeAll('run_completed');
720
871
  } finally {
721
872
  lifecycle.close();
722
873
  }
@@ -735,7 +886,7 @@ export async function executeAuthoredFlow<Input = undefined>(
735
886
  // explanation the root had already journaled to `run_admission_conflict`.
736
887
  // With no detail there is no environment in the command and nothing to
737
888
  // commit, so that path is untouched.
738
- const terminalId = `complete-${nextStep}`;
889
+ const terminalId = qualify(`complete-${nextStep}`);
739
890
  const verdict = await commitAuthoredVerdict(journal, options.rootRunId, terminalId, {
740
891
  reason: requestedCompletion,
741
892
  ...(requestedDetail === undefined ? {} : { detail: requestedDetail }),
@@ -100,6 +100,18 @@ export async function loadAuthoredFlow(path: string, options: LoadAuthoredFlowOp
100
100
  }
101
101
  dependencies.push(child.path);
102
102
  }
103
+ const childNames = new Set<string>();
104
+ for (const path of dependencies) {
105
+ const child = loaded.get(path)!;
106
+ const name = child.getDefinition(child.handle).name;
107
+ if (childNames.has(name)) {
108
+ throw new AuthoredFlowLoadError(
109
+ `Flow "${absolutePath}" declares two direct use children named "${name}".`,
110
+ 'use_invalid',
111
+ );
112
+ }
113
+ childNames.add(name);
114
+ }
103
115
  const node = Object.freeze({ path: absolutePath, handle, getDefinition, surfaceAuthority, use: Object.freeze(dependencies) });
104
116
  loaded.set(absolutePath, node);
105
117
  return node;
@@ -140,13 +140,13 @@ export async function runMcpEffect(
140
140
  childRunId = outcome.run_id;
141
141
  if (outcome.status !== 'completed') await completed;
142
142
  if (diagnostic !== undefined) {
143
- try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
143
+ try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status); }
144
144
  catch (error) {
145
145
  if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
146
146
  throw new McpStepError(diagnostic, outcome.run_id);
147
147
  }
148
148
  }
149
- const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
149
+ const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status) as { output: unknown };
150
150
  return receipt.output;
151
151
  } finally {
152
152
  clearTimeout(timer);
@@ -75,6 +75,7 @@ try {
75
75
  getDefinition: loaded.getDefinition, dataDir: request.dataDir,
76
76
  flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
77
77
  extensions: loaded.extensions,
78
+ flowGraph: loaded.graph,
78
79
  localAgentStream: request.localAgentStream, signal: controller.signal,
79
80
  ...(request.workerCapacity === undefined ? {} : { workerCapacity: request.workerCapacity }),
80
81
  onProgress: event => send({ type: 'progress', event }),
@@ -93,7 +94,9 @@ try {
93
94
  // the only one that saw the child's classification.
94
95
  ...(error instanceof AuthoredFlowExecutionError ? { code: error.code,
95
96
  completionReason: error.completionReason, runId: error.runId,
96
- details: error.details, parkCause: error.parkCause } : {}),
97
+ details: error.details,
98
+ parkCause: error.parkCause,
99
+ ...(error.suspension === undefined ? {} : { suspension: error.suspension }) } : {}),
97
100
  ...(error instanceof AuthoredHumanParked ? { wait: error.wait } : {}) });
98
101
  process.exitCode = 1;
99
102
  } finally {
@@ -18,6 +18,7 @@ import type {
18
18
  } from './failure-kinds.js';
19
19
  import { HUMAN_WAIT_ID } from './authored-human.js';
20
20
  import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
21
+ import { settlingCompletion } from './step-settling.js';
21
22
 
22
23
  let embeddedSource: string | undefined;
23
24
  /** Installed by the standalone build; never fetched or resolved from a workspace. */
@@ -144,7 +145,8 @@ export async function runAuthoredInNode(
144
145
  } else if (typeof message.code === 'string') {
145
146
  const authored = new AuthoredFlowExecutionError(message.code as AuthoredFlowExecutionErrorCode,
146
147
  message.message, message.completionReason, message.runId,
147
- stepFailedFrame(message.details));
148
+ stepFailedFrame(message.details),
149
+ isSuspension(message.suspension) ? message.suspension : undefined);
148
150
  // Validated, not trusted, like `details`: an unrecognised cause
149
151
  // is dropped so the boundary says nothing about workers rather
150
152
  // than acting on a value this frame could have invented.
@@ -172,6 +174,35 @@ export async function runAuthoredInNode(
172
174
  } finally { await rm(directory, { recursive: true, force: true }); }
173
175
  }
174
176
 
177
+ function isSuspension(value: unknown): value is import('./authored-flow-error.js').AuthoredFlowSuspension {
178
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) return false;
179
+ const record = value as Record<string, unknown>;
180
+ const common = typeof record['subscriptionId'] === 'string' && record['subscriptionId'].length > 0
181
+ && typeof record['stream'] === 'string' && record['stream'].length > 0
182
+ && Number.isSafeInteger(record['deadlineAtMs']) && (record['deadlineAtMs'] as number) >= 0;
183
+ if (!common) return false;
184
+ if (record['kind'] === 'event_wait') return true;
185
+ return record['kind'] === 'activation'
186
+ && Array.isArray(record['eventTypes']) && record['eventTypes'].length > 0
187
+ && record['eventTypes'].every(type => typeof type === 'string' && type.length > 0)
188
+ && (record['pattern'] === undefined || isJsonRecord(record['pattern']))
189
+ && Number.isSafeInteger(record['settleMs']) && (record['settleMs'] as number) >= 0
190
+ && Number.isSafeInteger(record['idleMs']) && (record['idleMs'] as number) > 0
191
+ && typeof record['includeSelf'] === 'boolean';
192
+ }
193
+
194
+ function isJsonRecord(value: unknown): value is Record<string, unknown> {
195
+ return typeof value === 'object' && value !== null && !Array.isArray(value)
196
+ && Object.values(value).every(isJsonValue);
197
+ }
198
+
199
+ function isJsonValue(value: unknown): boolean {
200
+ return value === null || typeof value === 'boolean' || typeof value === 'string'
201
+ || (typeof value === 'number' && Number.isFinite(value))
202
+ || (Array.isArray(value) && value.every(isJsonValue))
203
+ || isJsonRecord(value);
204
+ }
205
+
175
206
  /** A park signal from the child names the question the parent must journal. */
176
207
  function isHumanWaitFrame(value: unknown): value is AuthoredHumanWait {
177
208
  const wait = value as Partial<AuthoredHumanWait> | null;
@@ -342,15 +373,17 @@ export async function verifyAuthoredNodeResult(
342
373
  // step a child spec may carry, and it must have completed too.
343
374
  const lowered = spec?.steps ?? [];
344
375
  const specShape = lowered.length === 1 || (lowered.length === 2 && lowered[1]?.id === `${claimed.id}.gate`);
345
- const completed = entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === claimed.id);
346
- const gateCompleted = lowered.length === 2
347
- ? entries.filter(entry => entry.entry_type === 'step.completed' && entry.step_id === `${claimed.id}.gate`) : [];
376
+ // Judged against the step's SETTLING completion, the same one the
377
+ // authored reader returned: a retried attempt (`disposition: retry`,
378
+ // e.g. crash recovery's `crashed`) journals a completion of its own
379
+ // ahead of it, so neither a count nor the first entry is the verdict.
380
+ const completed = settlingCompletion(entries, claimed.id);
381
+ const gateCompleted = lowered.length === 2 ? settlingCompletion(entries, `${claimed.id}.gate`) : undefined;
348
382
  const terminalFacts = entries.filter(entry => entry.entry_type === 'run.completed');
349
383
  if (spec?.name !== `${metadata.flowName}/${claimed.id}` || !specShape
350
- || step?.id !== claimed.id || completed.length !== 1
351
- || completed[0]?.payload?.completionReason !== 'success'
352
- || (lowered.length === 2 && (gateCompleted.length !== 1 || gateCompleted[0]?.payload?.completionReason !== 'success'))
353
- || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed.length}`);
384
+ || step?.id !== claimed.id || completed?.payload?.completionReason !== 'success'
385
+ || (lowered.length === 2 && gateCompleted?.payload?.completionReason !== 'success')
386
+ || terminalFacts.length !== 1 || terminalFacts[0]?.payload?.completionReason !== 'success') invalid(`evidence ${claimed.id} spec=${spec?.name} step=${step?.id} completed=${completed?.payload?.completionReason}`);
354
387
  if (claimed === terminal) {
355
388
  // The claimed verdict must match the marker the journal actually
356
389
  // recorded, so an IPC frame cannot claim `success` over a run whose
@@ -125,13 +125,13 @@ export async function runPluginEffect(
125
125
  if (dispatchExpired) await cancelChildRun();
126
126
  if (outcome.status !== 'completed') await completed;
127
127
  if (diagnostic !== undefined) {
128
- try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps); }
128
+ try { await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status); }
129
129
  catch (error) {
130
130
  if (!(error instanceof AuthoredFlowExecutionError) || error.code !== 'step_failed') throw error;
131
131
  throw new PluginStepError(diagnostic, outcome.run_id);
132
132
  }
133
133
  }
134
- const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps) as { output: unknown };
134
+ const receipt = await readCompletedStepOutput(journal, outcome.run_id, id, journalSteps, {}, outcome.status) as { output: unknown };
135
135
  return receipt.output;
136
136
  }, admissionKey), deadline]);
137
137
  } finally {
@@ -4,7 +4,7 @@ import { createHash, randomUUID } from 'node:crypto';
4
4
  import { readFile } from 'node:fs/promises';
5
5
  import { canonicalize } from './canonical.js';
6
6
  import { compileSpec, toKernelSpec } from './compile.js';
7
- import { executeAuthoredFlow, type AuthoredFlowExecutionResult } from './authored-flow-executor.js';
7
+ import { executeAuthoredFlow, type AuthoredFlowExecutionResult, type AuthoredFlowSuspendedResult } from './authored-flow-executor.js';
8
8
  import { isDurableCompletionDetail, isLoweredCompletion } from './authored-completion.js';
9
9
  import { AUTHORED_ROOT_KIND } from './authored-verdict.js';
10
10
  import {
@@ -20,6 +20,11 @@ import { withWorkerLease } from './worker-lease.js';
20
20
  import { AuthoredFlowExecutionError, AuthoredHumanParked } from './authored-flow-error.js';
21
21
  import { readOpenHumanWaits } from './authored-human.js';
22
22
  import { isSurfaceCompletionReason } from './authored-step-output.js';
23
+ import { readSubscriptionPark } from './authored-subscription-park.js';
24
+
25
+ export type DurableAuthoredFlowResult =
26
+ | (AuthoredFlowExecutionResult & { readonly rootRunId: string })
27
+ | (AuthoredFlowSuspendedResult & { readonly rootRunId: string });
23
28
 
24
29
  /**
25
30
  * Taken from the projection module rather than spelled twice: `flows status`
@@ -55,6 +60,8 @@ export interface AuthoredRootSourceAuthority {
55
60
  }
56
61
 
57
62
  export interface DurableAuthoredOptions {
63
+ /** Preserve root authority even if a child later fails or parks. */
64
+ readonly onAdmitted?: (runId: string) => void;
58
65
  readonly dataDir: string;
59
66
  readonly admissionKey?: string;
60
67
  readonly localAgentStream?: string;
@@ -69,7 +76,7 @@ export async function executeDurableAuthoredFlow(
69
76
  journal: JournalClient,
70
77
  input: unknown,
71
78
  options: DurableAuthoredOptions,
72
- ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
79
+ ): Promise<DurableAuthoredFlowResult> {
73
80
  assertAuthoredRuntimeAvailable();
74
81
  const source = await readFile(loaded.sourcePath);
75
82
  const sources = await Promise.all(loaded.graph.map(async node => Object.freeze({
@@ -106,6 +113,7 @@ export async function executeDurableAuthoredFlow(
106
113
  workspace: [], streams: [{ stream, read_offset: 0 }],
107
114
  });
108
115
  const outcome = await journal.runStart(spec, undefined, admissionKey);
116
+ options.onAdmitted?.(outcome.run_id);
109
117
  if (outcome.status === 'completed') {
110
118
  dispatchWait.cancel();
111
119
  return await completedRootResult(journal, outcome.run_id);
@@ -119,6 +127,9 @@ export async function executeDurableAuthoredFlow(
119
127
  // redelivers only when the former worker connection is gone.
120
128
  const resumed = await journal.runResume(outcome.run_id);
121
129
  await assertNoOpenHumanWait(journal, resumed);
130
+ const parked = await readSubscriptionPark(journal, outcome.run_id);
131
+ if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
132
+ suspension: parked, journalSteps: [], rootRunId: outcome.run_id };
122
133
  const dispatch = await dispatchWait.promise;
123
134
  return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
124
135
  } finally {
@@ -132,7 +143,7 @@ export async function resumeDurableAuthoredFlow(
132
143
  rootRunId: string,
133
144
  journal: JournalClient,
134
145
  options: Omit<DurableAuthoredOptions, 'admissionKey'>,
135
- ): Promise<(AuthoredFlowExecutionResult & { readonly rootRunId: string }) | undefined> {
146
+ ): Promise<DurableAuthoredFlowResult | undefined> {
136
147
  const metadata = await readAuthoredRootMetadata(journal, rootRunId);
137
148
  if (metadata === undefined) return undefined;
138
149
  assertAuthoredRuntimeAvailable();
@@ -159,6 +170,9 @@ export async function resumeDurableAuthoredFlow(
159
170
  assertRootCanDispatch(outcome);
160
171
  await assertNoOpenHumanWait(journal, outcome);
161
172
  options.lifecycle?.onRunStarted?.({ runId: rootRunId, flow: metadata.flowName, resumed: true });
173
+ const parked = await readSubscriptionPark(journal, rootRunId);
174
+ if (parked !== undefined) return { state: 'suspended', name: metadata.flowName,
175
+ suspension: parked, journalSteps: [], rootRunId };
162
176
  const dispatch = await dispatchWait.promise;
163
177
  return await driveRoot(loaded, metadata, journal, peer, dispatch, options);
164
178
  } finally {
@@ -204,7 +218,7 @@ async function driveRoot(
204
218
  peer: JournalClient,
205
219
  dispatch: StepDispatchEvent,
206
220
  options: Omit<DurableAuthoredOptions, 'admissionKey'>,
207
- ): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
221
+ ): Promise<DurableAuthoredFlowResult> {
208
222
  try {
209
223
  const result = await withWorkerLease(peer, dispatch, async rootSignal => {
210
224
  const callerSignal = options.lifecycle?.signal;
@@ -228,6 +242,7 @@ async function driveRoot(
228
242
  ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
229
243
  rootRunId: dispatch.run_id,
230
244
  extensions: loaded.extensions,
245
+ flowGraph: loaded.graph,
231
246
  ...options.lifecycle,
232
247
  signal: callerSignal === undefined
233
248
  ? rootSignal
@@ -247,6 +262,15 @@ async function driveRoot(
247
262
  );
248
263
  return Object.freeze({ ...result, rootRunId: dispatch.run_id });
249
264
  } catch (error) {
265
+ if (error instanceof AuthoredFlowExecutionError
266
+ && error.code === 'subscription_suspended'
267
+ && error.suspension !== undefined) {
268
+ await peer.subscriptionPark({ run_id: dispatch.run_id, step_id: dispatch.step_id,
269
+ attempt: dispatch.attempt, idempotency_key: dispatch.idempotency_key,
270
+ subscription_id: error.suspension.subscriptionId, phase: error.suspension.kind });
271
+ return Object.freeze({ state: 'suspended' as const, name: metadata.flowName,
272
+ suspension: error.suspension, journalSteps: Object.freeze([]), rootRunId: dispatch.run_id });
273
+ }
250
274
  if (error instanceof AuthoredHumanParked) {
251
275
  // Not a failure: the body reached a question nobody has answered. Park
252
276
  // THIS attempt on the kernel's `wait.human` under the body's own wait