@relayflows/sdk 2.0.51 → 2.0.52

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 (82) hide show
  1. package/dist/authored-budget.d.ts +1 -1
  2. package/dist/authored-budget.d.ts.map +1 -1
  3. package/dist/authored-budget.js +3 -3
  4. package/dist/authored-budget.js.map +1 -1
  5. package/dist/authored-flow-error.d.ts +3 -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 +2 -0
  9. package/dist/authored-flow-executor.d.ts.map +1 -1
  10. package/dist/authored-flow-executor.js +93 -12
  11. package/dist/authored-flow-executor.js.map +1 -1
  12. package/dist/authored-flow-lifecycle.d.ts +28 -3
  13. package/dist/authored-flow-lifecycle.d.ts.map +1 -1
  14. package/dist/authored-flow-lifecycle.js +206 -10
  15. package/dist/authored-flow-lifecycle.js.map +1 -1
  16. package/dist/authored-node-entry.js +2 -0
  17. package/dist/authored-node-entry.js.map +1 -1
  18. package/dist/authored-parallel-body.d.ts +10 -0
  19. package/dist/authored-parallel-body.d.ts.map +1 -0
  20. package/dist/authored-parallel-body.js +68 -0
  21. package/dist/authored-parallel-body.js.map +1 -0
  22. package/dist/authored-parallel-eligibility.d.ts +8 -0
  23. package/dist/authored-parallel-eligibility.d.ts.map +1 -0
  24. package/dist/authored-parallel-eligibility.js +287 -0
  25. package/dist/authored-parallel-eligibility.js.map +1 -0
  26. package/dist/authored-promise-graph.d.ts +16 -0
  27. package/dist/authored-promise-graph.d.ts.map +1 -1
  28. package/dist/authored-promise-graph.js +36 -0
  29. package/dist/authored-promise-graph.js.map +1 -1
  30. package/dist/authored-root-readback.d.ts +1 -2
  31. package/dist/authored-root-readback.d.ts.map +1 -1
  32. package/dist/authored-root-readback.js +1 -1
  33. package/dist/authored-root-readback.js.map +1 -1
  34. package/dist/authored-root.d.ts +6 -1
  35. package/dist/authored-root.d.ts.map +1 -1
  36. package/dist/authored-root.js +55 -8
  37. package/dist/authored-root.js.map +1 -1
  38. package/dist/authored-step-graph.d.ts +19 -4
  39. package/dist/authored-step-graph.d.ts.map +1 -1
  40. package/dist/authored-step-graph.js +49 -6
  41. package/dist/authored-step-graph.js.map +1 -1
  42. package/dist/authored-step-index.d.ts +2 -1
  43. package/dist/authored-step-index.d.ts.map +1 -1
  44. package/dist/authored-step-index.js +2 -1
  45. package/dist/authored-step-index.js.map +1 -1
  46. package/dist/authored-verdict.d.ts +2 -0
  47. package/dist/authored-verdict.d.ts.map +1 -1
  48. package/dist/authored-verdict.js +4 -1
  49. package/dist/authored-verdict.js.map +1 -1
  50. package/dist/authored-worker-step.d.ts +2 -1
  51. package/dist/authored-worker-step.d.ts.map +1 -1
  52. package/dist/authored-worker-step.js +12 -5
  53. package/dist/authored-worker-step.js.map +1 -1
  54. package/dist/cli/run.d.ts.map +1 -1
  55. package/dist/cli/run.js +4 -2
  56. package/dist/cli/run.js.map +1 -1
  57. package/dist/failure-kinds.d.ts +1 -1
  58. package/dist/failure-kinds.d.ts.map +1 -1
  59. package/dist/failure-kinds.js +2 -0
  60. package/dist/failure-kinds.js.map +1 -1
  61. package/dist/journal-client.d.ts +3 -1
  62. package/dist/journal-client.d.ts.map +1 -1
  63. package/dist/journal-client.js +18 -1
  64. package/dist/journal-client.js.map +1 -1
  65. package/package.json +3 -2
  66. package/src/authored-budget.ts +4 -3
  67. package/src/authored-flow-error.ts +2 -0
  68. package/src/authored-flow-executor.ts +85 -11
  69. package/src/authored-flow-lifecycle.ts +215 -9
  70. package/src/authored-node-entry.ts +2 -0
  71. package/src/authored-parallel-body.ts +67 -0
  72. package/src/authored-parallel-eligibility.ts +245 -0
  73. package/src/authored-promise-graph.ts +35 -0
  74. package/src/authored-root-readback.ts +1 -2
  75. package/src/authored-root.ts +62 -9
  76. package/src/authored-step-graph.ts +50 -5
  77. package/src/authored-step-index.ts +2 -1
  78. package/src/authored-verdict.ts +4 -1
  79. package/src/authored-worker-step.ts +12 -3
  80. package/src/cli/run.ts +4 -2
  81. package/src/failure-kinds.ts +2 -0
  82. package/src/journal-client.ts +19 -0
@@ -1,7 +1,7 @@
1
1
  import { assertAuthoredPromiseHooks } from './authored-runtime-capability.js';
2
2
  import { pluginHelpers } from './plugin-loader.js';
3
3
  import { runPluginEffect } from './authored-plugin-effect.js';
4
- import { randomUUID } from 'node:crypto';
4
+ import { createHash, randomUUID } from 'node:crypto';
5
5
  import { dirname } from 'node:path';
6
6
  import { runHelperEffect } from './authored-helper-effect.js';
7
7
  import { checkSlackHelpers } from './slack-preflight.js';
@@ -55,7 +55,7 @@ import {
55
55
  stopAuthoredOperations,
56
56
  verifyAuthoredOperations,
57
57
  } from './authored-flow-operation.js';
58
- import { AuthoredFlowLifecycle } from './authored-flow-lifecycle.js';
58
+ import { AuthoredFlowLifecycle, parallelPromiseIntrinsicsIntact } from './authored-flow-lifecycle.js';
59
59
  import { AuthoredActivities } from './authored-activity.js';
60
60
  import { displayLabel, type AuthoredStepEdges } from './authored-step-index.js';
61
61
  import { JournalClient } from './journal-client.js';
@@ -196,6 +196,8 @@ export interface ExecuteAuthoredFlowOptions {
196
196
  readonly workerCapacity?: number;
197
197
  /** Durable kernel root that owns this body's child admission identities. */
198
198
  readonly rootRunId?: string;
199
+ /** Pinned by the durable root. Legacy roots retain 2.0.50 step identities. */
200
+ readonly executionMode?: 'serial' | 'parallel';
199
201
  /** Installed flow-extension plugins, in lock order, so `f.hook` can AND-compose them. */
200
202
  readonly extensions?: readonly LoadedFlowExtension[];
201
203
  /**
@@ -287,7 +289,21 @@ export async function executeAuthoredFlow<Input = undefined>(
287
289
  const journalSteps: AuthoredFlowJournalStep[] = [];
288
290
  const authoredSteps: AuthoredFlowOperation<unknown>[] = [];
289
291
  const memoryWrites = new MemoryWriteTracker();
290
- const lifecycle = new AuthoredFlowLifecycle();
292
+ let parallelViolation: AuthoredFlowExecutionError | undefined;
293
+ let closeParallelSlots: ((reason: unknown) => void) | undefined;
294
+ let signalParallelFailure: ((reason: unknown) => void) | undefined;
295
+ const parallelFailure = options.executionMode === 'parallel'
296
+ ? new Promise<never>((_resolve, reject) => { signalParallelFailure = reject; }) : undefined;
297
+ // A violation can occur synchronously before the body's promise is returned.
298
+ // Keep its rejection handled until the body is raced against it below.
299
+ void parallelFailure?.catch(() => {});
300
+ const markParallelViolation = (message: string): void => {
301
+ parallelViolation ??= new AuthoredFlowExecutionError('step_failed', message);
302
+ closeParallelSlots?.(parallelViolation);
303
+ signalParallelFailure?.(parallelViolation);
304
+ };
305
+ const lifecycle = new AuthoredFlowLifecycle(options.executionMode === 'parallel'
306
+ ? markParallelViolation : undefined);
291
307
  const activities = new AuthoredActivities(journal, options.rootRunId);
292
308
  const edgeOverrides = new Map<string, AuthoredStepEdges>();
293
309
  const stepEdges = (step: string): AuthoredStepEdges | undefined => {
@@ -301,8 +317,42 @@ export async function executeAuthoredFlow<Input = undefined>(
301
317
  let nextStep = 1;
302
318
  let requestedCompletion: LoweredCompletionReason | undefined;
303
319
  let requestedDetail: string | undefined;
320
+ let firstBodyTurn = true;
304
321
  const qualify = (id: string): string => `${options.stepPrefix ?? ''}${id}`;
305
- const nextId = (kind: string): string => qualify(`${kind}-${nextStep++}`);
322
+ const branchOrdinals = new Map<string, number>();
323
+ const rejectParallel = (message: string): never => {
324
+ markParallelViolation(message);
325
+ throw parallelViolation;
326
+ };
327
+ const assertParallelRunOnly = (kind: string): void => {
328
+ if (options.executionMode === 'parallel') rejectParallel(`parallel f.run body attempted ${kind} before admission`);
329
+ };
330
+ const nextId = (kind: string): string => {
331
+ if (parallelViolation !== undefined) throw parallelViolation;
332
+ const sequence = nextStep++;
333
+ if (options.executionMode !== 'parallel') return qualify(`${kind}-${sequence}`);
334
+ if (kind !== 'run') rejectParallel(`parallel f.run body attempted ${kind} before admission`);
335
+ if (!lifecycle.combinatorsObserved()) rejectParallel(
336
+ 'cannot assign a replay-stable f.run identity: a Promise intrinsic was replaced');
337
+ const predecessors = lifecycle.invocationPredecessors();
338
+ if (predecessors.unregisteredAggregate) rejectParallel(
339
+ 'cannot assign a replay-stable f.run identity: its causal frontier passes through an unregistered aggregate');
340
+ if (predecessors.truncated) rejectParallel(
341
+ 'cannot assign a replay-stable f.run identity: its causal predecessor graph is truncated');
342
+ const signature = JSON.stringify([...predecessors.steps].sort());
343
+ if (predecessors.steps.length === 0) {
344
+ if (!firstBodyTurn) rejectParallel('parallel f.run had no causal predecessor after the first body turn');
345
+ return qualify(`run-${sequence}`);
346
+ }
347
+ // Each new root pins parallel mode before executing the body. Its
348
+ // continuations derive their identity only from the causal frontier and
349
+ // declaration order within that frontier; child lifecycle and completion
350
+ // order never participate. Legacy roots take the branch above and retain
351
+ // their original kind-N identities.
352
+ const ordinal = (branchOrdinals.get(signature) ?? 0) + 1;
353
+ branchOrdinals.set(signature, ordinal);
354
+ return qualify(`run-after-${createHash('sha256').update(signature).digest('hex')}-${ordinal}`);
355
+ };
306
356
  const assertHelperAllowed = (provider: string): void => {
307
357
  if (options.allowedHelperProviders !== undefined && !options.allowedHelperProviders.has(provider)) {
308
358
  throw new AuthoredFlowExecutionError(
@@ -319,7 +369,9 @@ export async function executeAuthoredFlow<Input = undefined>(
319
369
  ...(options.signal === undefined ? {} : { signal: options.signal }),
320
370
  stepEdges,
321
371
  },
372
+ options.executionMode === 'parallel',
322
373
  );
374
+ closeParallelSlots = lowerDeterministic.stop;
323
375
 
324
376
  const worker = authoredWorkerRunner(
325
377
  definition, journal, flowPath, journalSteps, waitOptions,
@@ -505,6 +557,7 @@ export async function executeAuthoredFlow<Input = undefined>(
505
557
 
506
558
  const slackRun = options.rootRunId ?? randomUUID();
507
559
  function slackOperation<T>(call: SlackCall): Step<T> {
560
+ assertParallelRunOnly(`slack.${call.verb}`);
508
561
  assertOperationAllowed(`slack.${call.verb}`, definition.name, requestedCompletion);
509
562
  assertHelperAllowed(call.provider);
510
563
  const snapshot = snapshotJsonValue(call, 'f.slack call') as unknown as SlackCall;
@@ -535,6 +588,7 @@ export async function executeAuthoredFlow<Input = undefined>(
535
588
  const context: Ctx = {
536
589
  ...createHelpers(<T>(call: HelperCall): Step<T> => {
537
590
  const verb = `${call.provider}.${call.verb}`;
591
+ assertParallelRunOnly(verb);
538
592
  assertOperationAllowed(verb, definition.name, requestedCompletion);
539
593
  assertHelperAllowed(call.provider);
540
594
  const snapshot = snapshotJsonValue(call, `f.${verb} call`) as unknown as HelperCall;
@@ -564,7 +618,7 @@ export async function executeAuthoredFlow<Input = undefined>(
564
618
  }),
565
619
  memory: authoredMemory(
566
620
  scriptMemoryScope(flowPath, definition.name),
567
- () => assertOperationAllowed('memory', definition.name, requestedCompletion),
621
+ () => { assertParallelRunOnly('memory'); assertOperationAllowed('memory', definition.name, requestedCompletion); },
568
622
  definition.header.memory?.script !== false,
569
623
  memoryWrites,
570
624
  options.rootRunId === undefined ? undefined : journalLearnLedger(journal, options.rootRunId),
@@ -588,6 +642,7 @@ export async function executeAuthoredFlow<Input = undefined>(
588
642
  return trackStep(authoredSteps, agentOp);
589
643
  },
590
644
  on(source, activityOptions) {
645
+ assertParallelRunOnly('on');
591
646
  assertOperationAllowed('on', definition.name, requestedCompletion);
592
647
  // Activity ids are root-scoped (`activity-N`); a child's own counter would collide.
593
648
  if ((options.dispatchDepth ?? 0) > 0) {
@@ -695,6 +750,7 @@ export async function executeAuthoredFlow<Input = undefined>(
695
750
  ...(options.agentEnvironment === undefined ? {} : { agentEnvironment: options.agentEnvironment }),
696
751
  ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
697
752
  ...(options.rootRunId === undefined ? {} : { rootRunId: options.rootRunId }),
753
+ ...(options.executionMode === undefined ? {} : { executionMode: options.executionMode }),
698
754
  flowGraph: options.flowGraph,
699
755
  stepPrefix: `${id}--`,
700
756
  dispatchDepth: depth + 1,
@@ -797,7 +853,7 @@ export async function executeAuthoredFlow<Input = undefined>(
797
853
  requestedDetail = detail;
798
854
  },
799
855
  cloud: unsupportedCloud(
800
- () => assertOperationAllowed('cloud', definition.name, requestedCompletion),
856
+ () => { assertParallelRunOnly('cloud'); assertOperationAllowed('cloud', definition.name, requestedCompletion); },
801
857
  ),
802
858
  };
803
859
 
@@ -816,19 +872,34 @@ export async function executeAuthoredFlow<Input = undefined>(
816
872
  let bodyFailed = false;
817
873
  let bodyFailure: unknown;
818
874
  try {
819
- const bodyPromise = lifecycle.runBody(() => definition.body(context, input as Input));
820
- await bodyPromise;
875
+ let body = definition.body;
876
+ if (options.executionMode === 'parallel') {
877
+ const { instrumentParallelBody } = await import('./authored-parallel-body.js');
878
+ if (!parallelPromiseIntrinsicsIntact()) rejectParallel(
879
+ 'parallel f.run body lost its Promise or Array intrinsic before execution');
880
+ body = instrumentParallelBody(body, promise => lifecycle.certifyBranchPromise(promise));
881
+ queueMicrotask(() => { firstBodyTurn = false; });
882
+ }
883
+ const bodyPromise = lifecycle.runBody(() => body(context, input as Input));
884
+ if (parallelFailure === undefined) await bodyPromise;
885
+ else await Promise.race([bodyPromise, parallelFailure]);
821
886
  } catch (error) {
822
887
  bodyFailed = true;
823
888
  bodyFailure = error;
824
889
  }
890
+ // A source certificate is a performance admission decision, never a way
891
+ // for authored try/catch to turn a refused effect into successful output.
892
+ if (parallelViolation !== undefined) { bodyFailed = true; bodyFailure = parallelViolation; }
825
893
  if (bodyFailed) {
894
+ // Refuse queued work immediately. A slow memory commit must not leave an
895
+ // admission slot open after the body has already failed.
896
+ lowerDeterministic.stop(bodyFailure);
897
+ worker.stop(bodyFailure);
826
898
  // An in-flight learn must land or fail before this attempt gives the root
827
899
  // back: a re-driven attempt reads the memory-learn stream, and must not
828
900
  // race a commit this one is still making.
829
901
  await memoryWrites.settle();
830
902
  try {
831
- worker.stop(bodyFailure);
832
903
  await stopAuthoredOperations(authoredSteps, bodyFailure);
833
904
  // A durable wait hands execution back to the control plane. Its
834
905
  // subscriptions must keep receiving events while no body is running.
@@ -859,10 +930,12 @@ export async function executeAuthoredFlow<Input = undefined>(
859
930
  'missing_completion',
860
931
  `flow "${definition.name}" returned without done()`,
861
932
  );
862
- // Same as a failed body: no learn may outlive the attempt that started it.
933
+ // Close queued admissions before waiting for a learn; the attempt still
934
+ // waits for every write before handing the root back.
935
+ lowerDeterministic.stop(missingCompletion);
936
+ worker.stop(missingCompletion);
863
937
  await memoryWrites.settle();
864
938
  try {
865
- worker.stop(missingCompletion);
866
939
  await stopAuthoredOperations(authoredSteps, missingCompletion);
867
940
  await activities.closeAll('canceled');
868
941
  } finally {
@@ -881,6 +954,7 @@ export async function executeAuthoredFlow<Input = undefined>(
881
954
  }
882
955
  await verifyAuthoredOperations(definition.name, authoredSteps, lifecycle);
883
956
  } catch (error) {
957
+ lowerDeterministic.stop(error);
884
958
  try {
885
959
  await activities.closeAll('canceled');
886
960
  } finally {
@@ -1,7 +1,7 @@
1
1
  import { AsyncLocalStorage, executionAsyncId } from 'node:async_hooks';
2
2
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
3
3
  import { AuthoredPromiseGraph } from './authored-promise-graph.js';
4
- import { AuthoredStepGraph } from './authored-step-graph.js';
4
+ import { AuthoredStepGraph, promiseOutcome } from './authored-step-graph.js';
5
5
  import type { AuthoredStepEdges } from './authored-step-index.js';
6
6
 
7
7
  type OperationToken = object;
@@ -10,12 +10,38 @@ interface PromiseAllGroup {
10
10
  aggregate: number;
11
11
  members: ReadonlySet<number>;
12
12
  }
13
+ interface AllSettlementGuard {
14
+ readonly aggregate: Promise<unknown>;
15
+ readonly aggregateId: number;
16
+ readonly combinator: 'all' | 'allSettled';
17
+ readonly members: readonly number[];
18
+ }
13
19
  export interface AuthoredOperationInvocation {
14
20
  readonly asyncId: number;
15
21
  bound: boolean;
16
22
  }
17
23
 
24
+ const nativePromiseConstructor = Promise;
25
+ const nativePromisePrototype = Promise.prototype;
18
26
  const nativePromiseThen = Promise.prototype.then;
27
+ const nativePromiseCatch = Promise.prototype.catch;
28
+ const nativePromiseFinally = Promise.prototype.finally;
29
+ const nativePromiseResolve = Promise.resolve;
30
+ const nativePromiseReject = Promise.reject;
31
+ const nativePromisePrototypeConstructorDescriptor = Object.getOwnPropertyDescriptor(Promise.prototype, 'constructor');
32
+ const nativePromisePrototypeConstructor = Promise.prototype.constructor;
33
+ const nativePromiseSpeciesDescriptor = Object.getOwnPropertyDescriptor(Promise, Symbol.species);
34
+ const nativePromiseSpeciesGetter = nativePromiseSpeciesDescriptor?.get;
35
+ const nativeFunctionToString = Function.prototype.toString;
36
+ /** Use the startup snapshot even when a source parser is loaded on demand. */
37
+ export function authoredFunctionSource(value: Function): string {
38
+ return nativeFunctionToString.call(value);
39
+ }
40
+ const nativeArrayIteratorDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator);
41
+ const nativeArrayIterator = Array.prototype[Symbol.iterator];
42
+ const nativeArrayIteratorPrototype = Object.getPrototypeOf(nativeArrayIterator.call([])) as Iterator<unknown>;
43
+ const nativeArrayIteratorNextDescriptor = Object.getOwnPropertyDescriptor(nativeArrayIteratorPrototype, 'next');
44
+ const nativeArrayIteratorNext = nativeArrayIteratorPrototype.next;
19
45
  const activeLifecycle = new AsyncLocalStorage<AuthoredFlowLifecycle>();
20
46
  const stepOwners = new WeakMap<object, {
21
47
  lifecycle: AuthoredFlowLifecycle;
@@ -42,7 +68,8 @@ const stepOwners = new WeakMap<object, {
42
68
  * claim was wrong: it covered whichever member happened to resolve the
43
69
  * aggregate. What is true is narrower and is what the code now implements —
44
70
  * these four are exact, and `adoptFromResolvingContext` in the promise graph is
45
- * a best-effort fallback for aggregates built by hand.
71
+ * a fallback for aggregates built by hand. Parallel f.run admission additionally
72
+ * rejects unregistered join shapes on its causal path and checks observer identity.
46
73
  *
47
74
  * The interception is disclosed to authors in `docs/SURFACE.md`. It is spec
48
75
  * transparent: `Symbol.iterator` is read exactly once (as the intrinsic does),
@@ -58,6 +85,51 @@ const nativeCombinators = Object.freeze(
58
85
  Object.fromEntries(COMBINATORS.map((name) => [name, Promise[name] as unknown as Combinator])),
59
86
  ) as Readonly<Record<CombinatorName, Combinator>>;
60
87
 
88
+ const originallyNative = (value: unknown): boolean => typeof value === 'function'
89
+ && /\{\s*\[native code\]\s*\}/u.test(nativeFunctionToString.call(value));
90
+ const nativeAtModuleLoad = [
91
+ nativePromiseConstructor, nativePromiseThen, nativePromiseCatch, nativePromiseFinally,
92
+ nativePromiseResolve, nativePromiseReject, nativePromisePrototypeConstructor,
93
+ nativePromiseSpeciesGetter, nativeFunctionToString, nativeArrayIterator,
94
+ nativeArrayIteratorNext, ...Object.values(nativeCombinators),
95
+ ].every(originallyNative);
96
+
97
+ function sameDescriptor(actual: PropertyDescriptor | undefined, original: PropertyDescriptor | undefined): boolean {
98
+ return actual !== undefined && original !== undefined
99
+ && actual.configurable === original.configurable && actual.enumerable === original.enumerable
100
+ && actual.writable === original.writable && actual.value === original.value
101
+ && actual.get === original.get && actual.set === original.set;
102
+ }
103
+
104
+ /** Preloaded code can replace an intrinsic before this module takes its snapshot. */
105
+ function authoredProcessHasPreload(): boolean {
106
+ const preload = (arg: string): boolean => arg === '-r' || arg.startsWith('-r')
107
+ || ['--import', '--require', '--loader', '--experimental-loader']
108
+ .some(flag => arg === flag || arg.startsWith(`${flag}=`));
109
+ return process.execArgv.some(preload)
110
+ || nodeOptionTokens(process.env['NODE_OPTIONS'] ?? '').some(preload);
111
+ }
112
+
113
+ /** NODE_OPTIONS is a command-line string, while execArgv is already tokenized. */
114
+ function nodeOptionTokens(options: string): string[] {
115
+ const tokens: string[] = [];
116
+ let token = '';
117
+ let quote: '"' | "'" | undefined;
118
+ let escaped = false;
119
+ for (const char of options) {
120
+ if (escaped) { token += char; escaped = false; continue; }
121
+ if (char === '\\' && quote !== "'") { escaped = true; continue; }
122
+ if (char === quote) { quote = undefined; continue; }
123
+ if ((char === '"' || char === "'") && quote === undefined) { quote = char; continue; }
124
+ if (/\s/u.test(char) && quote === undefined) {
125
+ if (token.length > 0) tokens.push(token);
126
+ token = '';
127
+ } else token += char;
128
+ }
129
+ if (token.length > 0) tokens.push(token);
130
+ return tokens;
131
+ }
132
+
61
133
  const observedCombinators: Record<CombinatorName, Combinator> = Object.fromEntries(
62
134
  COMBINATORS.map((name) => {
63
135
  const native = nativeCombinators[name];
@@ -65,11 +137,16 @@ const observedCombinators: Record<CombinatorName, Combinator> = Object.fromEntri
65
137
  this: PromiseConstructor,
66
138
  values: Iterable<unknown>,
67
139
  ): Promise<unknown> {
140
+ const lifecycle = activeLifecycle.getStore();
141
+ const declared = lifecycle?.captureParallelArrayMembers(values);
68
142
  const members = collectMembers(values);
69
- if (members === undefined) return native.call(this, values);
70
- const aggregate = native.call(this, members);
71
- activeLifecycle.getStore()?.registerCombinator(members, aggregate, name);
72
- return aggregate;
143
+ if (members === undefined) {
144
+ if (declared !== undefined) lifecycle?.refuseUnsafeCombinator(
145
+ `parallel Promise.${name} could not inspect its array members`);
146
+ return native.call(this, values);
147
+ }
148
+ if (lifecycle === undefined) return native.call(this, members);
149
+ return lifecycle.observeCombinator(native, this, members, name, declared);
73
150
  };
74
151
  Object.defineProperty(observed, 'name', { value: name, configurable: true });
75
152
  Object.defineProperty(observed, 'length', { value: 1, configurable: true });
@@ -79,6 +156,30 @@ const observedCombinators: Record<CombinatorName, Combinator> = Object.fromEntri
79
156
 
80
157
  let combinatorObservers = 0;
81
158
 
159
+ /** An imported module must not substitute any primitive used to construct joins. */
160
+ export function parallelPromiseIntrinsicsIntact(): boolean {
161
+ if (!nativeAtModuleLoad || authoredProcessHasPreload()
162
+ || Function.prototype.toString !== nativeFunctionToString
163
+ || !sameDescriptor(Object.getOwnPropertyDescriptor(Array.prototype, Symbol.iterator),
164
+ nativeArrayIteratorDescriptor)
165
+ || Array.prototype[Symbol.iterator] !== nativeArrayIterator
166
+ || !sameDescriptor(Object.getOwnPropertyDescriptor(nativeArrayIteratorPrototype, 'next'),
167
+ nativeArrayIteratorNextDescriptor)
168
+ || nativeArrayIteratorPrototype.next !== nativeArrayIteratorNext
169
+ || Promise !== nativePromiseConstructor || Promise.prototype !== nativePromisePrototype
170
+ || !sameDescriptor(Object.getOwnPropertyDescriptor(Promise.prototype, 'constructor'),
171
+ nativePromisePrototypeConstructorDescriptor)
172
+ || Promise.prototype.constructor !== nativePromisePrototypeConstructor
173
+ || !sameDescriptor(Object.getOwnPropertyDescriptor(Promise, Symbol.species), nativePromiseSpeciesDescriptor)
174
+ || Object.getOwnPropertyDescriptor(Promise, Symbol.species)?.get !== nativePromiseSpeciesGetter
175
+ || Promise.prototype.then !== nativePromiseThen
176
+ || Promise.prototype.catch !== nativePromiseCatch
177
+ || Promise.prototype.finally !== nativePromiseFinally
178
+ || Promise.resolve !== nativePromiseResolve || Promise.reject !== nativePromiseReject) return false;
179
+ return COMBINATORS.every(name => Promise[name] === (combinatorObservers > 0
180
+ ? observedCombinators[name] : nativeCombinators[name]) as unknown);
181
+ }
182
+
82
183
  /**
83
184
  * Drain an iterable into an array, reading `Symbol.iterator` exactly once.
84
185
  *
@@ -125,6 +226,8 @@ export class AuthoredFlowLifecycle {
125
226
  private readonly invocations = new Map<OperationToken, AuthoredOperationInvocation[]>();
126
227
  private readonly promiseAllAggregates = new Map<OperationToken, Set<number>>();
127
228
  private readonly promiseAllGroups: PromiseAllGroup[] = [];
229
+ private readonly allSettlementGuards: AllSettlementGuard[] = [];
230
+ private invalidAllSettlement = false;
128
231
  private readonly callbackFailures = new Map<OperationToken, unknown>();
129
232
  private completionAsyncId: number | undefined;
130
233
  private closed = false;
@@ -132,14 +235,19 @@ export class AuthoredFlowLifecycle {
132
235
  private registrations = 0;
133
236
  private groupsByInvocation: { readonly key: string; readonly groups: Map<number, ReadonlySet<number>> } | undefined;
134
237
 
135
- constructor() {
238
+ constructor(private readonly refuseUnknownThenable?: (message: string) => void) {
136
239
  this.graph = new AuthoredPromiseGraph(
137
240
  () => activeLifecycle.getStore() === this,
138
241
  (asyncId) => {
139
242
  for (const probe of this.activeResolverProbes) probe.add(asyncId);
140
243
  },
141
244
  );
142
- this.stepGraph = new AuthoredStepGraph((asyncId) => this.graph.causesOf(asyncId));
245
+ this.stepGraph = new AuthoredStepGraph(
246
+ (asyncId) => this.graph.causesOf(asyncId),
247
+ undefined,
248
+ (asyncId) => this.graph.wasAdoptedFromResolvingContext(asyncId)
249
+ || this.graph.hasUnobservedJoinShape(asyncId),
250
+ );
143
251
  installPromiseAllObserver();
144
252
  try {
145
253
  this.graph.enable();
@@ -153,6 +261,19 @@ export class AuthoredFlowLifecycle {
153
261
  return activeLifecycle.run(this, body);
154
262
  }
155
263
 
264
+ /** Compare the literal array's indexed members with its iterable view. */
265
+ captureParallelArrayMembers(values: Iterable<unknown>): readonly unknown[] | undefined {
266
+ if (this.refuseUnknownThenable === undefined || !Array.isArray(values)) return undefined;
267
+ const members = new Array<unknown>(values.length);
268
+ for (let i = 0; i < values.length; i++) members[i] = values[i];
269
+ return members;
270
+ }
271
+
272
+ refuseUnsafeCombinator(message: string): never {
273
+ this.refuseUnknownThenable?.(message);
274
+ throw new AuthoredFlowExecutionError('step_failed', message);
275
+ }
276
+
156
277
  registerStep(step: object, operation: OperationToken): void {
157
278
  stepOwners.set(step, { lifecycle: this, operation });
158
279
  }
@@ -161,6 +282,7 @@ export class AuthoredFlowLifecycle {
161
282
  values: readonly unknown[],
162
283
  aggregate: Promise<unknown>,
163
284
  combinator: CombinatorName,
285
+ created: ReadonlySet<number>,
164
286
  ): void {
165
287
  const aggregateId = this.graph.idOf(aggregate);
166
288
  if (aggregateId === undefined) return;
@@ -177,6 +299,7 @@ export class AuthoredFlowLifecycle {
177
299
  const awaited = owner?.lifecycle === this ? this.operationPromises.get(owner.operation) : memberId;
178
300
  if (awaited !== undefined) awaitedMembers.push(awaited);
179
301
  if (owner?.lifecycle !== this) continue;
302
+ if (awaited !== undefined) this.stepGraph.certifyMember(awaited);
180
303
  let operationAggregates = this.promiseAllAggregates.get(owner.operation);
181
304
  if (operationAggregates === undefined) {
182
305
  operationAggregates = new Set();
@@ -185,8 +308,53 @@ export class AuthoredFlowLifecycle {
185
308
  operationAggregates.add(aggregateId);
186
309
  }
187
310
  this.promiseAllGroups.push({ aggregate: aggregateId, members: memberIds });
311
+ if (this.refuseUnknownThenable !== undefined
312
+ && (combinator === 'all' || combinator === 'allSettled')) {
313
+ this.allSettlementGuards.push({ aggregate, aggregateId, combinator, members: awaitedMembers });
314
+ }
188
315
  this.registrations++;
189
- this.stepGraph.registerAggregate(aggregateId, aggregate, combinator, awaitedMembers);
316
+ this.stepGraph.registerAggregate(aggregateId, aggregate, combinator, awaitedMembers, created);
317
+ }
318
+
319
+ observeCombinator(
320
+ native: Combinator,
321
+ constructor: PromiseConstructor,
322
+ values: readonly unknown[],
323
+ name: CombinatorName,
324
+ declared?: readonly unknown[],
325
+ ): Promise<unknown> {
326
+ if (declared !== undefined) {
327
+ if (declared.length !== values.length) this.refuseUnsafeCombinator(
328
+ `parallel Promise.${name} iterated a different set of array members`);
329
+ for (let i = 0; i < declared.length; i++) {
330
+ if (declared[i] !== values[i]) this.refuseUnsafeCombinator(
331
+ `parallel Promise.${name} iterated a different set of array members`);
332
+ }
333
+ }
334
+ // Check before the native combinator reads any unknown `then` getter. A
335
+ // member with no tracked promise id or owned step has no causal identity
336
+ // to expand on a later follow-up. This also covers proxy thenables whose
337
+ // `then` is synthesized by a get trap and has no property descriptor.
338
+ if (this.refuseUnknownThenable !== undefined) {
339
+ for (const value of values) {
340
+ if ((typeof value !== 'object' && typeof value !== 'function') || value === null) continue;
341
+ if (stepOwners.get(value)?.lifecycle === this || this.graph.idOf(value) !== undefined) continue;
342
+ const message = `parallel Promise.${name} contains a member with no tracked provenance`;
343
+ this.refuseUnsafeCombinator(message);
344
+ }
345
+ }
346
+ const { value, created } = this.graph.captureCombinator(() => native.call(constructor, values));
347
+ this.registerCombinator(values, value, name, created);
348
+ return value;
349
+ }
350
+
351
+ /** Mark the exact promise returned by a grammar-certified async branch call. */
352
+ certifyBranchPromise(promise: Promise<unknown>): void {
353
+ const id = this.graph.idOf(promise);
354
+ if (id === undefined) {
355
+ throw new AuthoredFlowExecutionError('step_failed', 'certified branch promise was not tracked');
356
+ }
357
+ this.stepGraph.certifyMember(id);
190
358
  }
191
359
 
192
360
  /**
@@ -215,6 +383,43 @@ export class AuthoredFlowLifecycle {
215
383
  return this.stepGraph.edgesOf(step);
216
384
  }
217
385
 
386
+ /** Predecessors for a new invocation, before it has an operation promise. */
387
+ invocationPredecessors(): {
388
+ readonly steps: readonly string[];
389
+ readonly truncated: boolean;
390
+ readonly unregisteredAggregate: boolean;
391
+ } {
392
+ const frontier = this.stepGraph.predecessorsAt(executionAsyncId());
393
+ return this.hasInvalidAllSettlement()
394
+ ? { ...frontier, unregisteredAggregate: true }
395
+ : frontier;
396
+ }
397
+
398
+ /** A fulfilled all (or settled allSettled) must have waited for every member. */
399
+ private hasInvalidAllSettlement(): boolean {
400
+ if (this.invalidAllSettlement) return true;
401
+ let retained = 0;
402
+ for (const guard of this.allSettlementGuards) {
403
+ if (this.graph.isPending(guard.aggregateId)) {
404
+ this.allSettlementGuards[retained++] = guard;
405
+ continue;
406
+ }
407
+ if ((guard.combinator === 'allSettled' || promiseOutcome(guard.aggregate) === 'fulfilled')
408
+ && guard.members.some(id => this.graph.isPending(id))) {
409
+ this.invalidAllSettlement = true;
410
+ return true;
411
+ }
412
+ // Once an honest aggregate has settled, its invariant cannot change.
413
+ }
414
+ this.allSettlementGuards.length = retained;
415
+ return false;
416
+ }
417
+
418
+ /** The observation contract must still be installed when a child is admitted. */
419
+ combinatorsObserved(): boolean {
420
+ return combinatorObservers > 0 && parallelPromiseIntrinsicsIntact();
421
+ }
422
+
218
423
  registerInvocation(
219
424
  operation: OperationToken,
220
425
  asyncId: number,
@@ -350,6 +555,7 @@ export class AuthoredFlowLifecycle {
350
555
  this.invocations.clear();
351
556
  this.promiseAllAggregates.clear();
352
557
  this.promiseAllGroups.length = 0;
558
+ this.allSettlementGuards.length = 0;
353
559
  this.groupsByInvocation = undefined;
354
560
  this.callbackFailures.clear();
355
561
  this.activeResolverProbes.length = 0;
@@ -81,6 +81,8 @@ try {
81
81
  request.metadata.inputPresent ? request.metadata.input : undefined, {
82
82
  getDefinition: loaded.getDefinition, dataDir: request.dataDir,
83
83
  flowPath: request.metadata.flowPath, rootRunId: request.rootRunId,
84
+ executionMode: request.metadata.identityVersion === 2 && request.metadata.executionMode === 'parallel'
85
+ ? 'parallel' : 'serial',
84
86
  extensions: loaded.extensions,
85
87
  flowGraph: loaded.graph,
86
88
  localAgentStream: request.localAgentStream, signal: controller.signal,
@@ -0,0 +1,67 @@
1
+ import ts from 'typescript';
2
+ import { AuthoredFlowExecutionError } from './authored-flow-error.js';
3
+ import { authoredFunctionSource } from './authored-flow-lifecycle.js';
4
+ import { parallelBodySyntaxEligible, unwrapParallelExpression } from './authored-parallel-eligibility.js';
5
+
6
+ /**
7
+ * Execute a certified body with its branch-call promises marked at the call
8
+ * site. A combinator member is not evidence that it is a safe branch: it may
9
+ * be a hand-built join that resolves from whichever child finishes first.
10
+ *
11
+ * The closed grammar has no module captures, so re-evaluating this function
12
+ * preserves its meaning. Unknown source is refused before the first effect.
13
+ */
14
+ export function instrumentParallelBody<T extends Function>(
15
+ body: T,
16
+ certifyBranch: (promise: Promise<unknown>) => void,
17
+ ): T {
18
+ const source = authoredFunctionSource(body);
19
+ if (!parallelBodySyntaxEligible(body)) {
20
+ throw new AuthoredFlowExecutionError('step_failed', 'parallel f.run body lost its source certificate');
21
+ }
22
+ const prefix = 'const body = ';
23
+ const file = ts.createSourceFile('parallel-body.js', `${prefix}${source};`,
24
+ ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS);
25
+ const declaration = file.statements[0];
26
+ if (!declaration || !ts.isVariableStatement(declaration)) throw new Error('certified body parse failed');
27
+ const arrow = declaration.declarationList.declarations[0]?.initializer;
28
+ if (!arrow || !ts.isArrowFunction(arrow) || !ts.isBlock(arrow.body)) throw new Error('certified body changed');
29
+ const branches = new Set<string>();
30
+ for (const statement of arrow.body.statements) {
31
+ if (!ts.isVariableStatement(statement)) continue;
32
+ for (const item of statement.declarationList.declarations) {
33
+ if (ts.isIdentifier(item.name) && item.initializer && ts.isArrowFunction(item.initializer)) {
34
+ branches.add(item.name.text);
35
+ }
36
+ }
37
+ }
38
+ const calls: Array<{ start: number; end: number }> = [];
39
+ const visit = (node: ts.Node): void => {
40
+ if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)
41
+ && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === 'Promise'
42
+ && node.expression.name.text === 'all' && node.arguments.length === 1) {
43
+ const members = node.arguments[0];
44
+ if (members && ts.isArrayLiteralExpression(members)) {
45
+ for (const outer of members.elements) {
46
+ if (!ts.isExpression(outer)) continue;
47
+ const member = unwrapParallelExpression(outer);
48
+ if (!ts.isCallExpression(member) || member.arguments.length !== 0) continue;
49
+ const named = ts.isIdentifier(member.expression) && branches.has(member.expression.text);
50
+ const inline = ts.isParenthesizedExpression(member.expression)
51
+ && ts.isArrowFunction(member.expression.expression);
52
+ if (named || inline) calls.push({ start: outer.getStart(file) - prefix.length,
53
+ end: outer.getEnd() - prefix.length });
54
+ }
55
+ }
56
+ }
57
+ ts.forEachChild(node, visit);
58
+ };
59
+ visit(arrow);
60
+ let instrumented = source;
61
+ for (const { start, end } of calls.sort((left, right) => right.start - left.start)) {
62
+ instrumented = `${instrumented.slice(0, start)}__certifyParallelBranch(${instrumented.slice(start, end)})${instrumented.slice(end)}`;
63
+ }
64
+ const factory = new Function('__certifyParallelBranch', `return (${instrumented});`) as
65
+ (record: (promise: Promise<unknown>) => Promise<unknown>) => T;
66
+ return factory((promise) => { certifyBranch(promise); return promise; });
67
+ }