@relayflows/sdk 2.0.34 → 2.0.35

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 (53) hide show
  1. package/dist/authored-dispatch.d.ts +24 -0
  2. package/dist/authored-dispatch.d.ts.map +1 -0
  3. package/dist/authored-dispatch.js +81 -0
  4. package/dist/authored-dispatch.js.map +1 -0
  5. package/dist/authored-flow-error.d.ts +1 -1
  6. package/dist/authored-flow-error.d.ts.map +1 -1
  7. package/dist/authored-flow-error.js.map +1 -1
  8. package/dist/authored-flow-executor.d.ts +17 -0
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +92 -16
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-flow-loader.d.ts.map +1 -1
  13. package/dist/authored-flow-loader.js +9 -0
  14. package/dist/authored-flow-loader.js.map +1 -1
  15. package/dist/authored-node-entry.js +1 -0
  16. package/dist/authored-node-entry.js.map +1 -1
  17. package/dist/authored-root.js +1 -0
  18. package/dist/authored-root.js.map +1 -1
  19. package/dist/authored-worker-step.d.ts +3 -1
  20. package/dist/authored-worker-step.d.ts.map +1 -1
  21. package/dist/authored-worker-step.js +18 -7
  22. package/dist/authored-worker-step.js.map +1 -1
  23. package/dist/cli/run.d.ts +5 -0
  24. package/dist/cli/run.d.ts.map +1 -1
  25. package/dist/cli/run.js +8 -0
  26. package/dist/cli/run.js.map +1 -1
  27. package/dist/cli.d.ts.map +1 -1
  28. package/dist/cli.js +3 -0
  29. package/dist/cli.js.map +1 -1
  30. package/dist/cloud-mirror-step.d.ts +2 -0
  31. package/dist/cloud-mirror-step.d.ts.map +1 -1
  32. package/dist/cloud-mirror-step.js +9 -1
  33. package/dist/cloud-mirror-step.js.map +1 -1
  34. package/dist/cloud-mirror.d.ts.map +1 -1
  35. package/dist/cloud-mirror.js +17 -2
  36. package/dist/cloud-mirror.js.map +1 -1
  37. package/dist/worker-slots.d.ts +15 -0
  38. package/dist/worker-slots.d.ts.map +1 -1
  39. package/dist/worker-slots.js +40 -3
  40. package/dist/worker-slots.js.map +1 -1
  41. package/package.json +2 -2
  42. package/src/authored-dispatch.ts +113 -0
  43. package/src/authored-flow-error.ts +3 -0
  44. package/src/authored-flow-executor.ts +126 -15
  45. package/src/authored-flow-loader.ts +12 -0
  46. package/src/authored-node-entry.ts +1 -0
  47. package/src/authored-root.ts +1 -0
  48. package/src/authored-worker-step.ts +17 -6
  49. package/src/cli/run.ts +10 -0
  50. package/src/cli.ts +3 -0
  51. package/src/cloud-mirror-step.ts +16 -2
  52. package/src/cloud-mirror.ts +15 -2
  53. package/src/worker-slots.ts +63 -4
@@ -26,20 +26,38 @@ export class WorkerSlots {
26
26
  throw new RangeError(`worker capacity must be an integer from 1 to ${MAX_LOCAL_AGENT_CAPACITY} (got ${capacity})`);
27
27
  }
28
28
  async run(work) {
29
+ return this.runInScope(work);
30
+ }
31
+ /** One independently closable admission scope over this shared capacity. */
32
+ scope() {
33
+ const scope = {};
34
+ return Object.freeze({
35
+ run: (work) => this.runInScope(work, scope),
36
+ close: (reason) => this.closeScope(scope, reason),
37
+ });
38
+ }
39
+ async runInScope(work, scope) {
29
40
  if (this.closed !== undefined)
30
41
  throw this.closed.reason;
42
+ if (scope?.closed !== undefined)
43
+ throw scope.closed.reason;
31
44
  if (this.held < this.capacity)
32
45
  this.held++;
33
46
  // A released slot is handed straight to the next waiter, so `held` never
34
47
  // dips below capacity while anyone is queued and no later caller can jump it.
35
48
  else {
36
- await new Promise((resolve, reject) => this.waiting.push({ resolve, reject }));
49
+ await new Promise((resolve, reject) => this.waiting.push({ resolve, reject, scope }));
37
50
  // Woken with the slot, but the body may have failed in between (see release).
38
51
  const closed = this.closedReason();
39
52
  if (closed !== undefined) {
40
53
  this.release();
41
54
  throw closed.reason;
42
55
  }
56
+ const scopeClosed = scopeClosedReason(scope);
57
+ if (scopeClosed !== undefined) {
58
+ this.release();
59
+ throw scopeClosed.reason;
60
+ }
43
61
  }
44
62
  try {
45
63
  return await work();
@@ -66,16 +84,28 @@ export class WorkerSlots {
66
84
  return;
67
85
  }
68
86
  setImmediate(() => {
69
- if (this.closed === undefined) {
87
+ if (this.closed === undefined && next.scope?.closed === undefined) {
70
88
  next.resolve();
71
89
  return;
72
90
  }
73
91
  // Closed meanwhile. `close` could not see this waiter (already taken off
74
92
  // the queue), so refuse it here, and pass the slot on instead of leaking it.
75
- next.reject(this.closed.reason);
93
+ next.reject(this.closed?.reason ?? next.scope.closed.reason);
76
94
  this.release();
77
95
  });
78
96
  }
97
+ closeScope(scope, reason) {
98
+ if (scope.closed !== undefined)
99
+ return;
100
+ scope.closed = { reason };
101
+ for (let index = this.waiting.length - 1; index >= 0; index--) {
102
+ const waiter = this.waiting[index];
103
+ if (waiter.scope !== scope)
104
+ continue;
105
+ this.waiting.splice(index, 1);
106
+ waiter.reject(reason);
107
+ }
108
+ }
79
109
  /**
80
110
  * Refuse every queued and future admission with `reason`; work already
81
111
  * holding a slot is not interrupted. The authored body calls this when it
@@ -91,4 +121,11 @@ export class WorkerSlots {
91
121
  waiter.reject(reason);
92
122
  }
93
123
  }
124
+ /** Read through a call so a check after an `await` is not narrowed away. */
125
+ function scopeClosedReason(scope) {
126
+ return scope?.closed;
127
+ }
128
+ export function authoredWorkerSlots(capacity) {
129
+ return Object.freeze({ agent: new WorkerSlots(capacity), llm: new WorkerSlots(capacity) });
130
+ }
94
131
  //# sourceMappingURL=worker-slots.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"worker-slots.js","sourceRoot":"","sources":["../src/worker-slots.ts"],"names":[],"mappings":"AAAA,gGAAgG;AAChG,MAAM,CAAC,MAAM,4BAA4B,GAAG,CAAC,CAAC;AAC9C,MAAM,CAAC,MAAM,wBAAwB,GAAG,EAAE,CAAC;AAE3C,sFAAsF;AACtF,MAAM,UAAU,eAAe,CAAC,KAAc;IAC5C,OAAO,MAAM,CAAC,SAAS,CAAC,KAAK,CAAC,IAAK,KAAgB,IAAI,CAAC,IAAK,KAAgB,IAAI,wBAAwB,CAAC;AAC5G,CAAC;AAED;;;;;;;;;GASG;AACH,MAAM,OAAO,WAAW;IAKD;IAJb,IAAI,GAAG,CAAC,CAAC;IACA,OAAO,GAAsE,EAAE,CAAC;IACzF,MAAM,CAA2C;IAEzD,YAAqB,QAAgB;QAAhB,aAAQ,GAAR,QAAQ,CAAQ;QACnC,IAAI,CAAC,eAAe,CAAC,QAAQ,CAAC;YAAE,MAAM,IAAI,UAAU,CAAC,gDAAgD,wBAAwB,SAAS,QAAQ,GAAG,CAAC,CAAC;IACrJ,CAAC;IAED,KAAK,CAAC,GAAG,CAAI,IAAsB;QACjC,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,MAAM,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC;QACxD,IAAI,IAAI,CAAC,IAAI,GAAG,IAAI,CAAC,QAAQ;YAAE,IAAI,CAAC,IAAI,EAAE,CAAC;QAC3C,yEAAyE;QACzE,8EAA8E;aACzE,CAAC;YACJ,MAAM,IAAI,OAAO,CAAO,CAAC,OAAO,EAAE,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC,CAAC;YACrF,8EAA8E;YAC9E,MAAM,MAAM,GAAG,IAAI,CAAC,YAAY,EAAE,CAAC;YACnC,IAAI,MAAM,KAAK,SAAS,EAAE,CAAC;gBACzB,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,MAAM,MAAM,CAAC,MAAM,CAAC;YACtB,CAAC;QACH,CAAC;QACD,IAAI,CAAC;YACH,OAAO,MAAM,IAAI,EAAE,CAAC;QACtB,CAAC;gBAAS,CAAC;YACT,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,CAAC;IACH,CAAC;IAED,4EAA4E;IACpE,YAAY;QAClB,OAAO,IAAI,CAAC,MAAM,CAAC;IACrB,CAAC;IAED;;;;;;OAMG;IACK,OAAO;QACb,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,EAAE,CAAC;QAClC,IAAI,IAAI,KAAK,SAAS,EAAE,CAAC;YACvB,IAAI,CAAC,IAAI,EAAE,CAAC;YACZ,OAAO;QACT,CAAC;QACD,YAAY,CAAC,GAAG,EAAE;YAChB,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS,EAAE,CAAC;gBAC9B,IAAI,CAAC,OAAO,EAAE,CAAC;gBACf,OAAO;YACT,CAAC;YACD,yEAAyE;YACzE,6EAA6E;YAC7E,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;YAChC,IAAI,CAAC,OAAO,EAAE,CAAC;QACjB,CAAC,CAAC,CAAC;IACL,CAAC;IAED;;;;;;OAMG;IACH,KAAK,CAAC,MAAe;QACnB,IAAI,IAAI,CAAC,MAAM,KAAK,SAAS;YAAE,OAAO;QACtC,IAAI,CAAC,MAAM,GAAG,EAAE,MAAM,EAAE,CAAC;QACzB,KAAK,MAAM,MAAM,IAAI,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC;YAAE,MAAM,CAAC,MAAM,CAAC,MAAM,CAAC,CAAC;IACrE,CAAC;CACF"}
1
+ {"version":3,"file":"worker-slots.js","sourceRoot":"","sources":["../src/worker-slots.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@relayflows/sdk",
3
- "version": "2.0.34",
3
+ "version": "2.0.35",
4
4
  "description": "TypeScript-first authoring SDK for Relayflows. Compiles specs; speaks the journal protocol.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -49,7 +49,7 @@
49
49
  "@modelcontextprotocol/sdk": "^1.30.0",
50
50
  "@relayfile/adapter-core": "0.6.2",
51
51
  "@relayfile/relay-helpers": "0.4.12",
52
- "@relayflows/surface": "2.0.34",
52
+ "@relayflows/surface": "2.0.35",
53
53
  "@types/js-yaml": "^4.0.9",
54
54
  "acorn": "^8.18.0",
55
55
  "ai-hist": "0.4.1",
@@ -0,0 +1,113 @@
1
+ import type { DispatchResult } from '@relayflows/surface';
2
+ import type { FlowHandle } from './authored-flow.js';
3
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
4
+ import type { LoadedAuthoredFlowNode } from './authored-flow-loader.js';
5
+ import { flowRequirements, type RequirementsFlowDefinition } from './flow-requirements.js';
6
+
7
+ export const MAX_DISPATCH_DEPTH = 3;
8
+
9
+ /** Resolve one attenuated direct child from the statically loaded `use` graph. */
10
+ export function resolveDispatchChild(options: {
11
+ readonly handle: FlowHandle;
12
+ readonly definition: RequirementsFlowDefinition & { readonly name: string };
13
+ readonly graph?: readonly LoadedAuthoredFlowNode[];
14
+ readonly flowName: string;
15
+ readonly depth: number;
16
+ }): LoadedAuthoredFlowNode {
17
+ if (typeof options.flowName !== 'string' || options.flowName.trim() === '') {
18
+ throw new AuthoredFlowExecutionError('dispatch_invalid', 'f.dispatch requires a non-empty declared child flow name');
19
+ }
20
+ if (options.depth >= MAX_DISPATCH_DEPTH) {
21
+ throw new AuthoredFlowExecutionError(
22
+ 'dispatch_depth_exceeded',
23
+ `f.dispatch depth exceeds ${MAX_DISPATCH_DEPTH}; flatten the flow tree or move work into the current child`,
24
+ );
25
+ }
26
+ const current = options.graph?.find(node => node.handle === options.handle);
27
+ const direct = current?.use.map(path => options.graph!.find(node => node.path === path)) ?? [];
28
+ const matches = direct.filter((node): node is LoadedAuthoredFlowNode =>
29
+ node !== undefined && node.getDefinition(node.handle).name === options.flowName);
30
+ if (matches.length !== 1) {
31
+ const declared = direct.filter((node): node is LoadedAuthoredFlowNode => node !== undefined)
32
+ .map(node => node.getDefinition(node.handle).name).sort();
33
+ throw new AuthoredFlowExecutionError(
34
+ matches.length > 1 ? 'dispatch_invalid' : 'dispatch_unknown',
35
+ `flow "${options.definition.name}" cannot dispatch ${JSON.stringify(options.flowName)}; direct use children: ${declared.length === 0 ? '(none)' : declared.join(', ')}`,
36
+ );
37
+ }
38
+ const child = matches[0]!;
39
+ const childDefinition = child.getDefinition(child.handle);
40
+ assertChildCapabilities(options.definition, childDefinition);
41
+ if (childDefinition.header.budget !== undefined) {
42
+ throw new AuthoredFlowExecutionError(
43
+ 'dispatch_invalid',
44
+ `child flow "${childDefinition.name}" declares its own budget; put the run-tree ceiling on "${options.definition.name}" until nested budget intersection ships`,
45
+ );
46
+ }
47
+ return child;
48
+ }
49
+
50
+ export function parseDispatchReceipt(value: string, step: string): DispatchResult {
51
+ let parsed: unknown;
52
+ try { parsed = JSON.parse(value); } catch {
53
+ throw new AuthoredFlowExecutionError('journal_protocol_violation', `dispatch step "${step}" recorded invalid JSON`);
54
+ }
55
+ const receipt = parsed as Partial<DispatchResult> | null;
56
+ if (typeof receipt !== 'object' || receipt === null || typeof receipt.name !== 'string'
57
+ || receipt.completionReason !== 'success'
58
+ || (receipt.completionDetail !== undefined && typeof receipt.completionDetail !== 'string')) {
59
+ throw new AuthoredFlowExecutionError('journal_protocol_violation', `dispatch step "${step}" recorded an invalid child receipt`);
60
+ }
61
+ return {
62
+ name: receipt.name,
63
+ completionReason: 'success',
64
+ ...(receipt.completionDetail === undefined ? {} : { completionDetail: receipt.completionDetail }),
65
+ };
66
+ }
67
+
68
+ /** Last visible child operations; the dispatch receipt joins all of them. */
69
+ export function childLeaves(steps: readonly { readonly id: string; readonly after?: readonly string[] }[]): string[] {
70
+ const visible = steps.filter(step => !/(?:^|--)complete-[1-9][0-9]*$/.test(step.id));
71
+ const parents = new Set(visible.flatMap(step => step.after ?? []));
72
+ return visible.map(step => step.id).filter(id => !parents.has(id));
73
+ }
74
+
75
+ /** A child may use only provider/MCP capabilities its parent already declared. */
76
+ function assertChildCapabilities(
77
+ parent: RequirementsFlowDefinition & { readonly name: string },
78
+ child: RequirementsFlowDefinition & { readonly name: string },
79
+ ): void {
80
+ const parentTools = parent.header?.tools ?? {};
81
+ const childTools = child.header?.tools ?? {};
82
+ for (const [tool, declared] of Object.entries(childTools)) {
83
+ if (tool === 'mcp') {
84
+ const allowed = new Set(Array.isArray(parentTools.mcp) ? parentTools.mcp as readonly unknown[] : []);
85
+ const requested = Array.isArray(declared) ? declared : [];
86
+ if (requested.some(server => !allowed.has(server))) {
87
+ throw new AuthoredFlowExecutionError(
88
+ 'dispatch_invalid',
89
+ `child flow "${child.name}" widens tools.mcp beyond parent "${parent.name}"`,
90
+ );
91
+ }
92
+ }
93
+ }
94
+ const parentHelpers = helperAuthority(parent);
95
+ for (const requirement of flowRequirements(child).integrations) {
96
+ if ((requirement.from === 'tools' || requirement.from === 'helper')
97
+ && !parentHelpers.has(requirement.provider)) {
98
+ throw new AuthoredFlowExecutionError(
99
+ 'dispatch_invalid',
100
+ `child flow "${child.name}" requires ${requirement.detail}, which parent "${parent.name}" did not grant`,
101
+ );
102
+ }
103
+ }
104
+ }
105
+
106
+ /** Helper providers this flow can delegate, from explicit or statically visible use. */
107
+ export function helperAuthority(
108
+ definition: RequirementsFlowDefinition,
109
+ ): ReadonlySet<string> {
110
+ return new Set(flowRequirements(definition).integrations
111
+ .filter(requirement => requirement.from === 'tools' || requirement.from === 'helper')
112
+ .map(requirement => requirement.provider));
113
+ }
@@ -16,6 +16,9 @@ export type AuthoredFlowExecutionErrorCode =
16
16
  | 'llm_cli_unresolved'
17
17
  | 'llm_parked'
18
18
  | 'duplicate_completion'
19
+ | 'dispatch_depth_exceeded'
20
+ | 'dispatch_invalid'
21
+ | 'dispatch_unknown'
19
22
  | 'journal_protocol_violation'
20
23
  | 'missing_completion'
21
24
  | 'memory_unreachable'
@@ -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,6 +39,7 @@ 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,
@@ -66,6 +68,8 @@ import type {
66
68
  CompletionReason as ProtocolCompletionReason,
67
69
  RunCompletionReason as ProtocolRunCompletionReason,
68
70
  } from './protocol.js';
71
+ import type { AuthoredWorkerSlots } from './worker-slots.js';
72
+ import { childLeaves, helperAuthority, parseDispatchReceipt, resolveDispatchChild } from './authored-dispatch.js';
69
73
 
70
74
  type Assert<T extends true> = T;
71
75
  type Equal<A, B> = [A] extends [B]
@@ -185,6 +189,20 @@ export interface ExecuteAuthoredFlowOptions {
185
189
  * from authored input: direct `/workflows/run` callers control that JSON.
186
190
  */
187
191
  readonly extensionDispatch?: HostedExtensionDispatch;
192
+ /** Pinned static `use:` graph available to `f.dispatch`. */
193
+ readonly flowGraph?: readonly LoadedAuthoredFlowNode[];
194
+ /** Internal namespace for descendant step/admission identities. */
195
+ readonly stepPrefix?: string;
196
+ /** Current child depth. Root is zero; dispatch is capped at three. */
197
+ readonly dispatchDepth?: number;
198
+ /** Parent predecessors inherited by the first operation in a child body. */
199
+ readonly entryAfter?: readonly string[];
200
+ /** Root-owned budget accumulator shared by every descendant. */
201
+ readonly sharedBudget?: AuthoredBudget;
202
+ /** Root-owned worker capacity shared by every descendant. */
203
+ readonly workerSlots?: AuthoredWorkerSlots;
204
+ /** Parent helper authority inherited by this dispatched child. */
205
+ readonly allowedHelperProviders?: ReadonlySet<string>;
188
206
  }
189
207
 
190
208
  export async function executeAuthoredFlow<Input = undefined>(
@@ -209,7 +227,7 @@ export async function executeAuthoredFlow<Input = undefined>(
209
227
  };
210
228
  const definition = getDefinition<Input>(handle);
211
229
  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');
230
+ const headerFields = Object.keys(definition.header).filter(key => key !== 'tools' && key !== 'budget' && key !== 'memory' && key !== 'version' && key !== 'hooks' && key !== 'use');
213
231
  if (definition.header.tools && Object.keys(definition.header.tools).some(key => !['mcp', ...helperProviders.map(p => p.namespace)].includes(key))) headerFields.push('tools');
214
232
  if (definition.header.tools?.relayfile !== undefined) headerFields.push('tools.relayfile');
215
233
  const helperPreflight = checkSlackHelpers(definition);
@@ -242,7 +260,7 @@ export async function executeAuthoredFlow<Input = undefined>(
242
260
  );
243
261
  }
244
262
 
245
- const budget = new AuthoredBudget(definition.header.budget);
263
+ const budget = options.sharedBudget ?? new AuthoredBudget(definition.header.budget);
246
264
  if (definition.header.memory?.agent === true) {
247
265
  throw new AuthoredFlowExecutionError('unsupported_header', 'memory.agent requires the follow-up identity-scoped agent memory adapter');
248
266
  }
@@ -255,10 +273,28 @@ export async function executeAuthoredFlow<Input = undefined>(
255
273
  const journalSteps: AuthoredFlowJournalStep[] = [];
256
274
  const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
257
275
  const lifecycle = new AuthoredFlowLifecycle();
258
- const stepEdges = (step: string): AuthoredStepEdges | undefined => lifecycle.stepEdges(step);
276
+ const edgeOverrides = new Map<string, AuthoredStepEdges>();
277
+ const stepEdges = (step: string): AuthoredStepEdges | undefined => {
278
+ const overridden = edgeOverrides.get(step);
279
+ if (overridden !== undefined) return overridden;
280
+ const local = lifecycle.stepEdges(step);
281
+ if (local === undefined || (local.after !== undefined && local.after.length > 0) || options.entryAfter === undefined
282
+ || options.entryAfter.length === 0) return local;
283
+ return { ...local, after: [...options.entryAfter] };
284
+ };
259
285
  let nextStep = 1;
260
286
  let requestedCompletion: LoweredCompletionReason | undefined;
261
287
  let requestedDetail: string | undefined;
288
+ const qualify = (id: string): string => `${options.stepPrefix ?? ''}${id}`;
289
+ const nextId = (kind: string): string => qualify(`${kind}-${nextStep++}`);
290
+ const assertHelperAllowed = (provider: string): void => {
291
+ if (options.allowedHelperProviders !== undefined && !options.allowedHelperProviders.has(provider)) {
292
+ throw new AuthoredFlowExecutionError(
293
+ 'dispatch_invalid',
294
+ `child flow "${definition.name}" attempted helper provider "${provider}" outside its parent's granted integrations`,
295
+ );
296
+ }
297
+ };
262
298
 
263
299
  const lowerDeterministic = authoredDeterministicRunner(
264
300
  definition.name, journal, journalSteps, budget, {
@@ -271,6 +307,7 @@ export async function executeAuthoredFlow<Input = undefined>(
271
307
  const worker = authoredWorkerRunner(
272
308
  definition, journal, flowPath, journalSteps, waitOptions,
273
309
  localAgentStream, budget, definition.header.budget, options.rootRunId, options.workerCapacity, stepEdges,
310
+ options.workerSlots,
274
311
  );
275
312
 
276
313
  /**
@@ -373,7 +410,7 @@ export async function executeAuthoredFlow<Input = undefined>(
373
410
  function llmOperation(prompt: string, options: LlmOptions): Step<unknown>;
374
411
  function llmOperation(prompt: string | TemplateStringsArray, ...values: unknown[]): Step<unknown> {
375
412
  assertOperationAllowed('llm', definition.name, requestedCompletion);
376
- const id = `llm-${nextStep++}`;
413
+ const id = nextId('llm');
377
414
  // Hoist the operation reference so the start closure can read the
378
415
  // caller's `.gate(config)` at spec-build time. AuthoredFlowOperation
379
416
  // begins after construction returns, so `llmOp` is defined by then.
@@ -420,7 +457,7 @@ export async function executeAuthoredFlow<Input = undefined>(
420
457
  `f.run options.onNonZero must be 'fail' or 'record' (got ${JSON.stringify(policy)}).`,
421
458
  );
422
459
  }
423
- const id = `run-${nextStep++}`;
460
+ const id = nextId('run');
424
461
  if (policy === 'record') {
425
462
  let recordOp!: AuthoredFlowOperation<RunResult>;
426
463
  recordOp = new AuthoredFlowOperation<RunResult>(
@@ -451,8 +488,9 @@ export async function executeAuthoredFlow<Input = undefined>(
451
488
  const slackRun = options.rootRunId ?? randomUUID();
452
489
  function slackOperation<T>(call: SlackCall): Step<T> {
453
490
  assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion);
491
+ assertHelperAllowed(call.provider);
454
492
  const snapshot = snapshotJsonValue(call, 'f.slack call') as unknown as SlackCall;
455
- const id = `slack-${slackRun}-${nextStep++}`;
493
+ const id = qualify(`slack-${slackRun}-${nextStep++}`);
456
494
  return trackStep(authoredSteps, new AuthoredFlowOperation<T>(
457
495
  id, `slack.${call.verb}`,
458
496
  () => assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion),
@@ -480,8 +518,9 @@ export async function executeAuthoredFlow<Input = undefined>(
480
518
  ...createHelpers(<T>(call: HelperCall): Step<T> => {
481
519
  const verb = `${call.provider}.${call.verb}`;
482
520
  assertOperationAllowed(verb, definition.name, requestedCompletion);
521
+ assertHelperAllowed(call.provider);
483
522
  const snapshot = snapshotJsonValue(call, `f.${verb} call`) as unknown as HelperCall;
484
- const id = `${call.provider}-${slackRun}-${nextStep++}`;
523
+ const id = qualify(`${call.provider}-${slackRun}-${nextStep++}`);
485
524
  return trackStep(authoredSteps, new AuthoredFlowOperation<T>(id, verb,
486
525
  () => assertOperationAllowed(verb, definition.name, requestedCompletion),
487
526
  async () => await runHelperEffect(journal, definition.name, id, snapshot,
@@ -496,7 +535,7 @@ export async function executeAuthoredFlow<Input = undefined>(
496
535
  },
497
536
  mcp: buildMcpProxy(checkedMcp.inventory, (server, tool, args, known) => {
498
537
  assertOperationAllowed('mcp', definition.name, requestedCompletion);
499
- const id = `mcp-${nextStep++}`;
538
+ const id = nextId('mcp');
500
539
  return trackStep(authoredSteps, new AuthoredFlowOperation(
501
540
  id, 'mcp',
502
541
  () => assertOperationAllowed('mcp', definition.name, requestedCompletion),
@@ -516,7 +555,7 @@ export async function executeAuthoredFlow<Input = undefined>(
516
555
  assertOperationAllowed('agent', definition.name, requestedCompletion);
517
556
  // Authored headers do not yet declare reusable named agents, so the name
518
557
  // identifies nothing to the kernel; it is the step's label in the DAG.
519
- const id = `agent-${nextStep++}`;
558
+ const id = nextId('agent');
520
559
  let agentOp!: AuthoredFlowOperation<AgentResult>;
521
560
  agentOp = new AuthoredFlowOperation<AgentResult>(
522
561
  id,
@@ -545,6 +584,12 @@ export async function executeAuthoredFlow<Input = undefined>(
545
584
  */
546
585
  human(question, humanOptions) {
547
586
  assertOperationAllowed('human', definition.name, requestedCompletion);
587
+ if ((options.dispatchDepth ?? 0) > 0) {
588
+ throw new AuthoredFlowExecutionError(
589
+ 'dispatch_invalid',
590
+ `child flow "${definition.name}" cannot call f.human in this release; the parent owns approval authority`,
591
+ );
592
+ }
548
593
  if (typeof question !== 'string' || question.trim() === '') {
549
594
  throw new AuthoredFlowExecutionError('human_answer_invalid', 'f.human requires a non-empty question');
550
595
  }
@@ -558,7 +603,7 @@ export async function executeAuthoredFlow<Input = undefined>(
558
603
  if (!parsedTo.ok) {
559
604
  throw new AuthoredFlowExecutionError('human_to_invalid', `f.human to ${parsedTo.reason}`);
560
605
  }
561
- const id = `human-${nextStep++}`;
606
+ const id = nextId('human');
562
607
  const to = humanOptions.to;
563
608
  // Hoisted like `llmOp`/`runOp`: the start closure reads the caller's
564
609
  // `.gate(config)` at spec-build time, so a named gate on the answer is
@@ -590,13 +635,79 @@ export async function executeAuthoredFlow<Input = undefined>(
590
635
  );
591
636
  return trackStep(authoredSteps, humanOp);
592
637
  },
593
- dispatch<T>() {
638
+ dispatch(flowName, dispatchInput) {
594
639
  assertOperationAllowed('dispatch', definition.name, requestedCompletion);
595
- throw unsupportedVerb('dispatch');
640
+ const depth = options.dispatchDepth ?? 0;
641
+ const child = resolveDispatchChild({
642
+ handle, definition, graph: options.flowGraph,
643
+ flowName, depth,
644
+ });
645
+ const childDefinition = child.getDefinition(child.handle);
646
+ const inputSnapshot = snapshotJsonValue(dispatchInput, `f.dispatch(${JSON.stringify(flowName)}) input`);
647
+ const id = nextId('dispatch');
648
+ let dispatchOp!: AuthoredFlowOperation<DispatchResult>;
649
+ dispatchOp = new AuthoredFlowOperation<DispatchResult>(
650
+ id,
651
+ 'dispatch',
652
+ () => assertOperationAllowed('dispatch', definition.name, requestedCompletion),
653
+ async () => {
654
+ const inheritedAfter = stepEdges(id)?.after ?? [];
655
+ const currentHelperAuthority = helperAuthority(definition);
656
+ const allowedHelperProviders = options.allowedHelperProviders === undefined
657
+ ? currentHelperAuthority
658
+ : new Set([...currentHelperAuthority].filter(provider => options.allowedHelperProviders!.has(provider)));
659
+ const childResult = await executeAuthoredFlow(child.handle, journal, inputSnapshot, {
660
+ getDefinition: child.getDefinition,
661
+ dataDir: options.dataDir,
662
+ flowPath: child.path,
663
+ ...(localAgentStream === undefined ? {} : { localAgentStream }),
664
+ ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
665
+ ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
666
+ flowGraph: options.flowGraph,
667
+ stepPrefix: `${id}--`,
668
+ dispatchDepth: depth + 1,
669
+ entryAfter: inheritedAfter,
670
+ sharedBudget: budget,
671
+ ...(worker.slots === undefined ? {} : { workerSlots: worker.slots }),
672
+ allowedHelperProviders,
673
+ ...(options.extensions === undefined ? {} : { extensions: options.extensions }),
674
+ onProgress,
675
+ ...(options.onWait === undefined ? {} : { onWait: options.onWait }),
676
+ ...(options.signal === undefined ? {} : { signal: options.signal }),
677
+ });
678
+ if (childResult.completionReason !== 'success') {
679
+ throw new AuthoredFlowExecutionError(
680
+ 'step_failed',
681
+ `child flow "${childResult.name}" completed with ${childResult.completionReason}`
682
+ + (childResult.completionDetail === undefined ? '' : `: ${childResult.completionDetail}`),
683
+ childResult.completionReason === 'step_failed' ? 'step_failed' : undefined,
684
+ );
685
+ }
686
+ const leaves = childLeaves(childResult.journalSteps);
687
+ edgeOverrides.set(id, {
688
+ ...displayLabel(childResult.name),
689
+ ...(leaves.length > 0 ? { after: leaves } : inheritedAfter.length === 0
690
+ ? {} : { after: inheritedAfter }),
691
+ });
692
+ const receipt: DispatchResult = {
693
+ name: childResult.name,
694
+ completionReason: 'success',
695
+ ...(childResult.completionDetail === undefined ? {} : { completionDetail: childResult.completionDetail }),
696
+ };
697
+ const literal = `'${JSON.stringify(receipt).replaceAll("'", "'\\''")}'`;
698
+ const recorded = await lowerDeterministic(
699
+ id, `printf '%s' ${literal}`, false, undefined, dispatchOp.namedGate,
700
+ );
701
+ return parseDispatchReceipt(recorded, id);
702
+ },
703
+ lifecycle,
704
+ displayLabel(flowName),
705
+ );
706
+ return trackStep(authoredSteps, dispatchOp);
596
707
  },
597
708
  hook(name, input) {
598
709
  assertOperationAllowed('hook', definition.name, requestedCompletion);
599
- const id = `hook-${nextStep++}`;
710
+ const id = nextId('hook');
600
711
  const snapshot = snapshotJsonValue(input, 'f.hook input');
601
712
  return trackStep(authoredSteps, new AuthoredFlowOperation<boolean>(
602
713
  id, 'hook',
@@ -661,7 +772,7 @@ export async function executeAuthoredFlow<Input = undefined>(
661
772
  Object.assign(context, pluginHelpers(checkedMcp.plugins ?? [], (plugin, verb, args) => {
662
773
  const label = `${verb.namespace}.${verb.method}`;
663
774
  assertOperationAllowed(label, definition.name, requestedCompletion);
664
- const id = `plugin-${nextStep++}`;
775
+ const id = nextId('plugin');
665
776
  return trackStep(authoredSteps, new AuthoredFlowOperation(
666
777
  id, label, () => assertOperationAllowed(label, definition.name, requestedCompletion),
667
778
  () => runPluginEffect(journal, definition.name, id, plugin, verb, args,
@@ -735,7 +846,7 @@ export async function executeAuthoredFlow<Input = undefined>(
735
846
  // explanation the root had already journaled to `run_admission_conflict`.
736
847
  // With no detail there is no environment in the command and nothing to
737
848
  // commit, so that path is untouched.
738
- const terminalId = `complete-${nextStep}`;
849
+ const terminalId = qualify(`complete-${nextStep}`);
739
850
  const verdict = await commitAuthoredVerdict(journal, options.rootRunId, terminalId, {
740
851
  reason: requestedCompletion,
741
852
  ...(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;
@@ -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 }),
@@ -228,6 +228,7 @@ async function driveRoot(
228
228
  ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
229
229
  rootRunId: dispatch.run_id,
230
230
  extensions: loaded.extensions,
231
+ flowGraph: loaded.graph,
231
232
  ...options.lifecycle,
232
233
  signal: callerSignal === undefined
233
234
  ? rootSignal
@@ -16,7 +16,7 @@ import { snapshotJsonValue } from './json-value.js';
16
16
  import { authoredChildAdmissionKey } from './authored-admission.js';
17
17
  import { alsoRecord, recordAuthoredChild } from './authored-step-index.js';
18
18
  import type { StepFailedDetails } from './failure-kinds.js';
19
- import { WorkerSlots } from './worker-slots.js';
19
+ import { authoredWorkerSlots, type AuthoredWorkerSlots } from './worker-slots.js';
20
20
 
21
21
  const WORKSPACE_PERMISSION_ANNOTATION = /:\s*(readonly|readwrite)\s*$/i;
22
22
 
@@ -26,11 +26,16 @@ export function authoredWorkerRunner(
26
26
  journalSteps: AuthoredFlowJournalStep[], waitOptions: RunLifecycleOptions,
27
27
  localAgentStream?: string, budget?: AuthoredBudget, headerBudget?: unknown,
28
28
  rootRunId?: string, workerCapacity?: number, stepEdges?: AuthoredStepContext['stepEdges'],
29
+ sharedSlots?: AuthoredWorkerSlots,
29
30
  ) {
30
31
  // Sized to the attached local workers, so concurrent calls wait here for a
31
32
  // slot instead of being admitted and parked for want of a free worker.
32
- const slots = workerCapacity === undefined ? undefined
33
- : { agent: new WorkerSlots(workerCapacity), llm: new WorkerSlots(workerCapacity) };
33
+ const ownsSlots = sharedSlots === undefined;
34
+ const slots = sharedSlots ?? (workerCapacity === undefined ? undefined : authoredWorkerSlots(workerCapacity));
35
+ const slotScopes = slots === undefined ? undefined : {
36
+ agent: slots.agent.scope(),
37
+ llm: slots.llm.scope(),
38
+ };
34
39
  const check = authoredPreflight(flowPath);
35
40
  const context: AuthoredStepContext = {
36
41
  ...(rootRunId === undefined ? {} : { rootRunId }),
@@ -136,14 +141,20 @@ export function authoredWorkerRunner(
136
141
  const admit = async () => budget === undefined
137
142
  ? consume(await journal.runStart(spec, undefined, admissionKey))
138
143
  : budget.execute(journal, spec, consume, admissionKey);
139
- return slots === undefined ? admit() : slots[step.type === 'llm' ? 'llm' : 'agent'].run(admit);
144
+ return slotScopes === undefined ? admit() : slotScopes[step.type === 'llm' ? 'llm' : 'agent'].run(admit);
140
145
  }
141
146
 
142
147
  return {
148
+ slots,
143
149
  /** Refuse every agent/LLM call still waiting for a worker slot (body teardown). */
144
150
  stop(reason: unknown): void {
145
- slots?.agent.close(reason);
146
- slots?.llm.close(reason);
151
+ if (ownsSlots) {
152
+ slots?.agent.close(reason);
153
+ slots?.llm.close(reason);
154
+ } else {
155
+ slotScopes?.agent.close(reason);
156
+ slotScopes?.llm.close(reason);
157
+ }
147
158
  },
148
159
  async agent(id: string, options: AgentOptions, verification?: NamedGate): Promise<AgentResult> {
149
160
  if (options.workspace !== undefined && localAgentStream !== undefined) {
package/src/cli/run.ts CHANGED
@@ -122,6 +122,8 @@ export interface RunLifecycleOptions {
122
122
  onJournalEntry?: (entry: JournalEvent) => void;
123
123
  /** An authored root was admitted: its id is known before its body runs. */
124
124
  onRunStarted?: (run: { runId: string; flow: string; resumed?: boolean }) => void;
125
+ /** A declarative root returned its admission receipt, even if no watched entry arrived. */
126
+ onRunReceipt?: (run: { runId: string; flow: string }) => void;
125
127
  localAgent?: boolean;
126
128
  /** `--agent-capacity`: the local workers' concurrency; the default is `DEFAULT_LOCAL_AGENT_CAPACITY`. */
127
129
  agentCapacity?: number;
@@ -191,6 +193,14 @@ async function executeCheckedFlow(
191
193
  }
192
194
  if (options.onJournalEntry !== undefined) client.on('entry', options.onJournalEntry);
193
195
  const outcome = await startWatched(client, spec, options);
196
+ // `run.start { watch: true }` is an event stream, not the source of the
197
+ // root identity. A short deterministic run can finish before its first
198
+ // watched entry is delivered (and an older daemon may refuse `watch`), so
199
+ // receipt consumers such as the Cloud mirror need the admitted id too.
200
+ // This stays separate from `onRunStarted`: opening the observer without a
201
+ // streamed journal would manufacture an empty projection and suppress its
202
+ // truthful "not projected" diagnostic.
203
+ options.onRunReceipt?.({ runId: outcome.run_id, flow: spec.name ?? 'flow' });
194
204
  const execution = await classifyOutcome(client, 'run', outcome, base, socketPath, { ...options, dataDir });
195
205
  if (options.reuseFromRunId !== undefined) {
196
206
  execution.report.reuse = await reuseSummary(client, outcome.run_id, options.reuseFromRunId);
package/src/cli.ts CHANGED
@@ -380,6 +380,9 @@ export async function runCli(
380
380
  observer?.onRunStarted(run);
381
381
  mirror?.onRunStarted(run);
382
382
  },
383
+ ...(mirror === undefined ? {} : {
384
+ onRunReceipt: (run: { runId: string }) => { mirror.onRunStarted(run); },
385
+ }),
383
386
  }),
384
387
  onWait: (progress: RunProgress) => {
385
388
  emitWait(progress, logged);