@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
@@ -0,0 +1,245 @@
1
+ import ts from 'typescript';
2
+ import { authoredFunctionSource } from './authored-flow-lifecycle.js';
3
+
4
+ const reservedCertifier = '__certifyParallelBranch';
5
+
6
+ /** Keep admission and call-site instrumentation in agreement on parentheses. */
7
+ export function unwrapParallelExpression(node: ts.Expression): ts.Expression {
8
+ return ts.isParenthesizedExpression(node) ? unwrapParallelExpression(node.expression) : node;
9
+ }
10
+
11
+ /** A closed grammar for deterministic Promise.all branches. Unknown bodies stay serial. */
12
+ export function parallelRunEligible(body: Function, moduleSource: string): boolean {
13
+ // Promise.all is intercepted only when `Promise` resolves to the intrinsic.
14
+ // Function.toString cannot see a module-level lexical binding, so inspect
15
+ // the complete source before using the body's narrower grammar.
16
+ const bodySource = authoredFunctionSource(body);
17
+ return moduleUsesIntrinsicPromise(moduleSource, bodySource) && parallelBodySyntaxEligible(body);
18
+ }
19
+
20
+ /** Independently re-check the closed body grammar at execution time. */
21
+ export function parallelBodySyntaxEligible(body: Function): boolean {
22
+ const bodySource = authoredFunctionSource(body);
23
+ const file = ts.createSourceFile('authored-body.js', `const body = ${bodySource};`,
24
+ ts.ScriptTarget.ES2022, true, ts.ScriptKind.JS);
25
+ if (file.statements.length !== 1) return false;
26
+ const declaration = file.statements[0];
27
+ if (!declaration || !ts.isVariableStatement(declaration)) return false;
28
+ const expression = declaration.declarationList.declarations[0]?.initializer;
29
+ // Function expressions can obtain the context through `arguments[0]`.
30
+ if (expression === undefined || !ts.isArrowFunction(expression) || !ts.isBlock(expression.body)
31
+ || expression.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) !== true) return false;
32
+ const parameter = expression.parameters[0]?.name;
33
+ if (parameter === undefined || !ts.isIdentifier(parameter) || expression.parameters.length !== 1) return false;
34
+ const context = parameter.text;
35
+ if (['Promise', 'globalThis', 'global'].includes(context)) return false;
36
+ // The instrumented body is re-evaluated with a private callback parameter.
37
+ // A lexical binding or reference with that spelling could capture it.
38
+ let namesCertifier = false;
39
+ const checkCertifierName = (node: ts.Node): void => {
40
+ if (ts.isIdentifier(node) && node.text === reservedCertifier) namesCertifier = true;
41
+ ts.forEachChild(node, checkCertifierName);
42
+ };
43
+ checkCertifierName(expression);
44
+ if (namesCertifier) return false;
45
+ const branches = new Map<string, ts.ArrowFunction>();
46
+ const rootValues = new Set<string>();
47
+ let groups = 0;
48
+ let runs = 0;
49
+
50
+ const unwrap = unwrapParallelExpression;
51
+ const method = (node: ts.Expression, owner: string, name: string): node is ts.CallExpression => {
52
+ const value = unwrap(node);
53
+ return ts.isCallExpression(value) && ts.isPropertyAccessExpression(value.expression)
54
+ && ts.isIdentifier(value.expression.expression) && value.expression.expression.text === owner
55
+ && value.expression.name.text === name;
56
+ };
57
+ const pure = (node: ts.Expression, values: ReadonlySet<string>): boolean => {
58
+ const value = unwrap(node);
59
+ if (ts.isStringLiteral(value) || ts.isNumericLiteral(value) || ts.isNoSubstitutionTemplateLiteral(value)
60
+ || value.kind === ts.SyntaxKind.TrueKeyword || value.kind === ts.SyntaxKind.FalseKeyword
61
+ || value.kind === ts.SyntaxKind.NullKeyword) return true;
62
+ if (ts.isIdentifier(value)) return values.has(value.text);
63
+ if (ts.isTemplateExpression(value)) return value.templateSpans.every(span => pure(span.expression, values));
64
+ if (ts.isPrefixUnaryExpression(value)) return [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken,
65
+ ts.SyntaxKind.ExclamationToken].includes(value.operator) && pure(value.operand, values);
66
+ if (ts.isBinaryExpression(value)) return [ts.SyntaxKind.PlusToken, ts.SyntaxKind.MinusToken,
67
+ ts.SyntaxKind.AsteriskToken, ts.SyntaxKind.SlashToken, ts.SyntaxKind.EqualsEqualsEqualsToken,
68
+ ts.SyntaxKind.ExclamationEqualsEqualsToken, ts.SyntaxKind.LessThanToken,
69
+ ts.SyntaxKind.GreaterThanToken].includes(value.operatorToken.kind)
70
+ && pure(value.left, values) && pure(value.right, values);
71
+ if (ts.isConditionalExpression(value)) return pure(value.condition, values)
72
+ && pure(value.whenTrue, values) && pure(value.whenFalse, values);
73
+ if (ts.isElementAccessExpression(value)) return value.argumentExpression !== undefined
74
+ && pure(value.expression, values) && pure(value.argumentExpression, values);
75
+ if (ts.isPropertyAccessExpression(value)) return pure(value.expression, values);
76
+ if (ts.isArrayLiteralExpression(value)) return value.elements.every(element =>
77
+ ts.isExpression(element) && pure(element, values));
78
+ if (ts.isObjectLiteralExpression(value)) return value.properties.every(property =>
79
+ ts.isPropertyAssignment(property) && (ts.isIdentifier(property.name) || ts.isStringLiteral(property.name))
80
+ && pure(property.initializer, values));
81
+ return false;
82
+ };
83
+ const run = (node: ts.Expression, values: ReadonlySet<string>): boolean => {
84
+ const value = unwrap(node);
85
+ if (!method(value, context, 'run') || value.arguments.length < 1 || value.arguments.length > 2
86
+ || !value.arguments.every(argument => pure(argument, values))) return false;
87
+ runs++;
88
+ return true;
89
+ };
90
+ const group = (node: ts.Expression, values: ReadonlySet<string>, depth: number): boolean => {
91
+ const value = unwrap(node);
92
+ if (!method(value, 'Promise', 'all') || value.arguments.length !== 1 || depth > 4) return false;
93
+ groups++;
94
+ const members = value.arguments[0]!;
95
+ if (ts.isArrayLiteralExpression(members)) {
96
+ if (members.elements.length < 2) return false;
97
+ return members.elements.every(member => {
98
+ if (!ts.isExpression(member)) return false;
99
+ if (run(member, values)) return true;
100
+ const call = unwrap(member);
101
+ if (depth === 0 && ts.isCallExpression(call) && ts.isIdentifier(call.expression)
102
+ && call.arguments.length === 0 && branches.has(call.expression.text)) {
103
+ return branch(branches.get(call.expression.text)!, depth + 1);
104
+ }
105
+ if (depth === 0 && ts.isCallExpression(call) && call.arguments.length === 0
106
+ && ts.isParenthesizedExpression(call.expression) && ts.isArrowFunction(call.expression.expression)) {
107
+ return branch(call.expression.expression, depth + 1);
108
+ }
109
+ return false;
110
+ });
111
+ }
112
+ return false;
113
+ };
114
+ const awaitStep = (node: ts.Expression, values: ReadonlySet<string>, depth: number): boolean => {
115
+ const value = unwrap(node);
116
+ if (!ts.isAwaitExpression(value)) return false;
117
+ return run(value.expression, values) || group(value.expression, values, depth);
118
+ };
119
+ const statements = (block: ts.Block, values: Set<string>, depth: number): boolean => {
120
+ for (const statement of block.statements) {
121
+ if (ts.isExpressionStatement(statement)) {
122
+ if (awaitStep(statement.expression, values, depth)) continue;
123
+ const value = unwrap(statement.expression);
124
+ if (depth === 0 && method(value, context, 'done') && value.arguments.length >= 1
125
+ && value.arguments.every(argument => pure(argument, values))
126
+ && statement === block.statements[block.statements.length - 1]) continue;
127
+ return false;
128
+ }
129
+ if (ts.isReturnStatement(statement) && depth > 0 && statement.expression !== undefined
130
+ && awaitStep(statement.expression, values, depth)) continue;
131
+ if (!ts.isVariableStatement(statement) || (statement.declarationList.flags & ts.NodeFlags.Const) === 0) return false;
132
+ for (const item of statement.declarationList.declarations) {
133
+ if (!ts.isIdentifier(item.name) || item.initializer === undefined) return false;
134
+ if (['Promise', 'globalThis', 'global'].includes(item.name.text)) return false;
135
+ if (depth === 0 && ts.isArrowFunction(item.initializer)) {
136
+ if (branches.has(item.name.text) || item.initializer.parameters.length !== 0
137
+ || item.initializer.modifiers?.some(modifier => modifier.kind === ts.SyntaxKind.AsyncKeyword) !== true
138
+ ) return false;
139
+ branches.set(item.name.text, item.initializer);
140
+ continue;
141
+ }
142
+ if (!awaitStep(item.initializer, values, depth)) return false;
143
+ values.add(item.name.text);
144
+ }
145
+ }
146
+ return true;
147
+ };
148
+ const branch = (arrow: ts.ArrowFunction, depth: number): boolean => {
149
+ if (arrow.parameters.length !== 0) return false;
150
+ const before = runs;
151
+ const valid = ts.isBlock(arrow.body) ? statements(arrow.body, new Set(rootValues), depth)
152
+ : awaitStep(arrow.body, rootValues, depth);
153
+ // An empty async branch creates a join with no causal step predecessor.
154
+ return valid && runs > before;
155
+ };
156
+
157
+ if (!statements(expression.body, rootValues, 0)) return false;
158
+ return groups > 0 && runs > 0;
159
+ }
160
+
161
+ /** Reject any source that can change what a free `Promise` identifier means. */
162
+ function moduleUsesIntrinsicPromise(source: string, bodySource: string): boolean {
163
+ const file = ts.createSourceFile('authored-module.ts', source, ts.ScriptTarget.ES2022, true, ts.ScriptKind.TS);
164
+ if (((file as ts.SourceFile & { parseDiagnostics?: readonly ts.Diagnostic[] }).parseDiagnostics?.length ?? 0) > 0) {
165
+ return false;
166
+ }
167
+ // Top-level code can alter the constructor or its methods without binding
168
+ // the name. Leave these modules on the established serial scheduler too.
169
+ const touchesPromiseOutsideDefinition = (node: ts.Node): boolean => {
170
+ if (ts.isIdentifier(node) && ['Promise', 'globalThis', 'global'].includes(node.text)) return true;
171
+ return ts.forEachChild(node, touchesPromiseOutsideDefinition) === true;
172
+ };
173
+ let definesDefaultBody = false;
174
+ let importsSurfaceFlow = false;
175
+ for (const statement of file.statements) {
176
+ if (ts.isImportDeclaration(statement)) {
177
+ // Imports can execute code before the body starts. Only the direct,
178
+ // immutable Surface binding can certify the exported body's identity.
179
+ if (statement.moduleSpecifier.getText(file) !== "'@relayflows/surface'"
180
+ && statement.moduleSpecifier.getText(file) !== '"@relayflows/surface"') return false;
181
+ const bindings = statement.importClause?.namedBindings;
182
+ if (statement.importClause?.name !== undefined || bindings === undefined
183
+ || !ts.isNamedImports(bindings) || bindings.elements.length !== 1
184
+ || bindings.elements[0]?.name.text !== 'flow'
185
+ || bindings.elements[0]?.propertyName !== undefined || importsSurfaceFlow) return false;
186
+ importsSurfaceFlow = true;
187
+ continue;
188
+ }
189
+ if (ts.isExportAssignment(statement)) {
190
+ // Only the final body argument is allowed to mention Promise.all. Do
191
+ // not let an expression around flow() mutate Promise while we skip it.
192
+ const call = statement.expression;
193
+ if (statement.isExportEquals || !ts.isCallExpression(call)
194
+ || !ts.isIdentifier(call.expression) || call.expression.text !== 'flow') return false;
195
+ const args = call.arguments;
196
+ if (args.length !== 2 || !ts.isStringLiteral(args[0]!)) return false;
197
+ if (!ts.isArrowFunction(args[args.length - 1]!)) return false;
198
+ if (args[args.length - 1]!.getText(file) !== bodySource) return false;
199
+ if (args.slice(0, -1).some(touchesPromiseOutsideDefinition)) return false;
200
+ if (definesDefaultBody) return false;
201
+ definesDefaultBody = true;
202
+ continue;
203
+ }
204
+ // The certified body cannot use module captures. Other top-level code is
205
+ // unnecessary for it and may schedule a later mutation through an alias.
206
+ return false;
207
+ }
208
+ const forbidden = new Set(['Promise', 'globalThis']);
209
+ const bindsForbidden = (name: ts.BindingName): boolean => ts.isIdentifier(name)
210
+ ? forbidden.has(name.text)
211
+ : name.elements.some(element => ts.isBindingElement(element) && bindsForbidden(element.name));
212
+ const isGlobalPromise = (node: ts.Node): boolean => {
213
+ if (ts.isIdentifier(node)) return forbidden.has(node.text);
214
+ if (ts.isPropertyAccessExpression(node)) return ts.isIdentifier(node.expression)
215
+ && node.expression.text === 'globalThis' && node.name.text === 'Promise';
216
+ return ts.isElementAccessExpression(node) && ts.isIdentifier(node.expression)
217
+ && node.expression.text === 'globalThis' && ts.isStringLiteral(node.argumentExpression)
218
+ && node.argumentExpression.text === 'Promise';
219
+ };
220
+ let unsafe = false;
221
+ const visit = (node: ts.Node): void => {
222
+ if (unsafe) return;
223
+ if ((ts.isVariableDeclaration(node) || ts.isParameter(node) || ts.isBindingElement(node))
224
+ && bindsForbidden(node.name)) unsafe = true;
225
+ if ((ts.isFunctionDeclaration(node) || ts.isFunctionExpression(node)
226
+ || ts.isClassDeclaration(node) || ts.isClassExpression(node)
227
+ || ts.isEnumDeclaration(node) || ts.isImportEqualsDeclaration(node)
228
+ || ts.isTypeAliasDeclaration(node) || ts.isInterfaceDeclaration(node))
229
+ && node.name !== undefined && forbidden.has(node.name.text)) unsafe = true;
230
+ if (ts.isImportClause(node) && node.name !== undefined && forbidden.has(node.name.text)) unsafe = true;
231
+ if (ts.isNamespaceImport(node) && forbidden.has(node.name.text)) unsafe = true;
232
+ if (ts.isImportSpecifier(node) && forbidden.has(node.name.text)) unsafe = true;
233
+ if (ts.isBinaryExpression(node)
234
+ && node.operatorToken.kind >= ts.SyntaxKind.FirstAssignment
235
+ && node.operatorToken.kind <= ts.SyntaxKind.LastAssignment
236
+ && isGlobalPromise(node.left)) unsafe = true;
237
+ if ((ts.isPrefixUnaryExpression(node) || ts.isPostfixUnaryExpression(node))
238
+ && isGlobalPromise(node.operand)) unsafe = true;
239
+ ts.forEachChild(node, visit);
240
+ };
241
+ visit(file);
242
+ // The body may come from another module, where a free Promise name can be
243
+ // shadowed without appearing in this source. In that case use serial IDs.
244
+ return importsSurfaceFlow && definesDefaultBody && !unsafe;
245
+ }
@@ -30,12 +30,15 @@ export class AuthoredPromiseGraph {
30
30
  private readonly triggers = new Map<number, number>();
31
31
  private readonly creationContexts = new Map<number, number>();
32
32
  private readonly resolutionCauses = new Map<number, number>();
33
+ /** Promises attributed only when another promise's reaction resolved them. */
34
+ private readonly adoptedFromResolvingContext = new Set<number>();
33
35
  private readonly promiseIds = new WeakMap<object, number>();
34
36
  private readonly handles = new Map<number, Promise<unknown>>();
35
37
  private readonly unattributedChildren = new Map<number, number[]>();
36
38
  private readonly attributedRoots = new Map<number, number>();
37
39
  private readonly roots = new Set<number>();
38
40
  private readonly pending = new Set<number>();
41
+ private readonly combinatorCaptures: Set<number>[] = [];
39
42
  private dependencyCache: { readonly start: number; readonly found: ReadonlySet<number>; readonly version: number } | undefined;
40
43
  /** Bumped by every graph change, so derived answers can be cached between changes. */
41
44
  private changes = 0;
@@ -52,6 +55,7 @@ export class AuthoredPromiseGraph {
52
55
  this.triggers.set(asyncId, triggerAsyncId);
53
56
  this.creationContexts.set(asyncId, executionAsyncId());
54
57
  this.promiseIds.set(resource, asyncId);
58
+ for (const capture of this.combinatorCaptures) capture.add(asyncId);
55
59
  this.handles.set(asyncId, resource as Promise<unknown>);
56
60
  this.pending.add(asyncId);
57
61
  const root = this.roots.has(triggerAsyncId)
@@ -77,6 +81,7 @@ export class AuthoredPromiseGraph {
77
81
  // success. Measured; see ops/probes/pr134-repair-0903/combinators.mjs.
78
82
  const adopted = this.attributedRoots.get(cause);
79
83
  if (adopted !== undefined) {
84
+ this.adoptedFromResolvingContext.add(asyncId);
80
85
  this.attribute(asyncId, adopted);
81
86
  return;
82
87
  }
@@ -100,6 +105,18 @@ export class AuthoredPromiseGraph {
100
105
  return this.promiseIds.get(value);
101
106
  }
102
107
 
108
+ isPending(asyncId: number): boolean {
109
+ return this.pending.has(asyncId);
110
+ }
111
+
112
+ /** The intrinsic creates per-member reaction promises as well as its aggregate. */
113
+ captureCombinator<T>(call: () => T): { readonly value: T; readonly created: ReadonlySet<number> } {
114
+ const created = new Set<number>();
115
+ this.combinatorCaptures.push(created);
116
+ try { return { value: call(), created }; }
117
+ finally { this.combinatorCaptures.pop(); }
118
+ }
119
+
103
120
  /** Mark a promise whose descendants belong to an authored operation. */
104
121
  registerRoot(asyncId: number): void {
105
122
  if (asyncId <= 0 || this.roots.has(asyncId)) return;
@@ -145,6 +162,22 @@ export class AuthoredPromiseGraph {
145
162
  return causes;
146
163
  }
147
164
 
165
+ /**
166
+ * A promise created directly in one async context but settled in another
167
+ * can be a join. Its resolution edge names only the settling member, so a
168
+ * frontier may use it only when a combinator registered all members.
169
+ */
170
+ hasUnobservedJoinShape(asyncId: number): boolean {
171
+ const trigger = this.triggers.get(asyncId);
172
+ const resolution = this.resolutionCauses.get(asyncId);
173
+ return trigger !== undefined && trigger === this.creationContexts.get(asyncId)
174
+ && resolution !== undefined && resolution !== trigger;
175
+ }
176
+
177
+ wasAdoptedFromResolvingContext(asyncId: number): boolean {
178
+ return this.adoptedFromResolvingContext.has(asyncId);
179
+ }
180
+
148
181
  dependsOn(descendant: number, ancestor: number): boolean {
149
182
  return this.dependenciesOf(descendant).has(ancestor);
150
183
  }
@@ -167,11 +200,13 @@ export class AuthoredPromiseGraph {
167
200
  this.triggers.clear();
168
201
  this.creationContexts.clear();
169
202
  this.resolutionCauses.clear();
203
+ this.adoptedFromResolvingContext.clear();
170
204
  this.handles.clear();
171
205
  this.unattributedChildren.clear();
172
206
  this.attributedRoots.clear();
173
207
  this.roots.clear();
174
208
  this.pending.clear();
209
+ this.combinatorCaptures.length = 0;
175
210
  this.dependencyCache = undefined;
176
211
  }
177
212
 
@@ -3,7 +3,6 @@ import type { AuthoredFlowExecutionResult } from './authored-flow-executor.js';
3
3
  import { AuthoredFlowExecutionError } from './authored-flow-error.js';
4
4
  import { isSurfaceCompletionReason } from './authored-step-output.js';
5
5
  import { JournalFrameError, JournalProtocolError, type JournalClient } from './journal-client.js';
6
- import type { RunOutcome } from './protocol.js';
7
6
 
8
7
  /*
9
8
  * Reading an authored root back from its journal, and what a failed read
@@ -44,7 +43,7 @@ export async function rootStepIfPresent(journal: JournalClient, runId: string):
44
43
  * it back is interrupted, the outcome is still known: say so, rather than
45
44
  * recasting a finished run as resumable.
46
45
  */
47
- export function completedResult(journal: JournalClient, outcome: RunOutcome, rootRunId: string): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
46
+ export function completedResult(journal: JournalClient, rootRunId: string): Promise<AuthoredFlowExecutionResult & { readonly rootRunId: string }> {
48
47
  return completedRootResult(journal, rootRunId).catch(error => {
49
48
  // A bad frame or a journal with no stored result is a definite fact; any
50
49
  // other failure — interrupted, canceled, or a daemon-side read error such
@@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises';
6
6
  import { canonicalize } from './canonical.js';
7
7
  import { compileSpec, toKernelSpec } from './compile.js';
8
8
  import { executeAuthoredFlow, type AuthoredFlowExecutionResult, type AuthoredFlowSuspendedResult } from './authored-flow-executor.js';
9
- import { AUTHORED_ROOT_KIND } from './authored-verdict.js';
9
+ import { AUTHORED_PARALLEL_ROOT_KIND, AUTHORED_ROOT_KIND } from './authored-verdict.js';
10
10
  import {
11
11
  loadAuthoredFlow,
12
12
  type LoadedAuthoredFlow,
@@ -23,6 +23,7 @@ import { helperReceiptWaitId, releaseHelperReceiptWaits } from './helper-park.js
23
23
  import { readSubscriptionPark } from './authored-subscription-park.js';
24
24
  import { localAgentCredentialEnvironment } from './local-agent-environment.js';
25
25
  import { completedResult, rootKernelStep, rootStepIfPresent } from './authored-root-readback.js';
26
+ import { parallelPromiseIntrinsicsIntact } from './authored-flow-lifecycle.js';
26
27
 
27
28
  export type DurableAuthoredFlowResult =
28
29
  | (AuthoredFlowExecutionResult & { readonly rootRunId: string })
@@ -42,7 +43,7 @@ export interface AuthoredRootExtension {
42
43
  }
43
44
 
44
45
  export interface AuthoredRootMetadata {
45
- readonly kind: typeof ROOT_KIND;
46
+ readonly kind: typeof ROOT_KIND | typeof AUTHORED_PARALLEL_ROOT_KIND;
46
47
  readonly flowName: string;
47
48
  readonly flowPath: string;
48
49
  readonly sourceSha256: string;
@@ -50,6 +51,10 @@ export interface AuthoredRootMetadata {
50
51
  readonly sources: readonly AuthoredRootSourceAuthority[];
51
52
  /** Plugin-level provenance in lock order; empty when the project declares none. */
52
53
  readonly extensions: readonly AuthoredRootExtension[];
54
+ /** Absent on roots admitted before causal identities existed. */
55
+ readonly identityVersion?: 2;
56
+ /** Immutable admission choice; serial uses the 2.0.50 child identity contract. */
57
+ readonly executionMode?: 'serial' | 'parallel';
53
58
  readonly localAgentStream?: string;
54
59
  readonly inputPresent: boolean;
55
60
  readonly input?: unknown;
@@ -89,8 +94,19 @@ export async function executeDurableAuthoredFlow(
89
94
  surface: node.surfaceAuthority,
90
95
  })));
91
96
  const definition = loaded.getDefinition(loaded.handle);
97
+ let parallelEligible = false;
98
+ if (loaded.graph.length === 1 && (loaded.extensions?.length ?? 0) === 0
99
+ && definition.header.budget === undefined && parallelPromiseIntrinsicsIntact()) {
100
+ // The parser is needed only for an authored run, never for an ordinary
101
+ // CLI check or watch reload. Recheck intrinsics after the async import;
102
+ // author code could have replaced them while it was loading.
103
+ const { parallelRunEligible } = await import('./authored-parallel-eligibility.js');
104
+ parallelEligible = parallelPromiseIntrinsicsIntact()
105
+ && parallelRunEligible(definition.body, source.toString('utf8'));
106
+ }
107
+ const executionMode = parallelEligible ? 'parallel' : 'serial';
92
108
  const metadata: AuthoredRootMetadata = Object.freeze({
93
- kind: ROOT_KIND,
109
+ kind: executionMode === 'parallel' ? AUTHORED_PARALLEL_ROOT_KIND : ROOT_KIND,
94
110
  flowName: definition.name,
95
111
  flowPath: loaded.sourcePath,
96
112
  sourceSha256: sha256(source),
@@ -99,6 +115,8 @@ export async function executeDurableAuthoredFlow(
99
115
  extensions: Object.freeze((loaded.extensions ?? []).map(extension => Object.freeze({
100
116
  name: extension.name, digest: extension.digest, ref: extension.ref,
101
117
  }))),
118
+ identityVersion: 2,
119
+ executionMode,
102
120
  ...(options.localAgentStream === undefined ? {} : { localAgentStream: options.localAgentStream }),
103
121
  inputPresent: input !== undefined,
104
122
  ...(input === undefined ? {} : { input: jsonSnapshot(input, 'authored root input') }),
@@ -121,7 +139,7 @@ export async function executeDurableAuthoredFlow(
121
139
  options.lifecycle?.onRunReceipt?.({ runId: outcome.run_id, flow: definition.name });
122
140
  if (outcome.status === 'completed') {
123
141
  dispatchWait.cancel();
124
- return await completedResult(journal, outcome, outcome.run_id);
142
+ return await completedResult(journal, outcome.run_id);
125
143
  }
126
144
  assertRootCanDispatch(outcome);
127
145
  await releaseHelperReceiptWaits(journal, outcome.run_id);
@@ -152,11 +170,23 @@ export async function resumeDurableAuthoredFlow(
152
170
  ): Promise<DurableAuthoredFlowResult | undefined> {
153
171
  const metadata = await readAuthoredRootMetadata(journal, rootRunId);
154
172
  if (metadata === undefined) return undefined;
155
- assertAuthoredRuntimeAvailable();
156
- const loaded = await loadPinnedAuthoredSource(metadata);
157
173
  if (metadata.localAgentStream !== options.localAgentStream) {
158
174
  throw new Error('authored root local agent surface mismatch');
159
175
  }
176
+ const completed = await completedParallelRootOrAssertRuntime(metadata, rootRunId, journal);
177
+ if (completed !== undefined) {
178
+ options.lifecycle?.onRunReceipt?.({ runId: rootRunId, flow: metadata.flowName });
179
+ return completed;
180
+ }
181
+ assertAuthoredRuntimeAvailable();
182
+ const loaded = await loadPinnedAuthoredSource(metadata);
183
+ // Source loading itself can alter a process-wide intrinsic. Refuse before
184
+ // run.resume dispatches a pinned parallel root in that environment.
185
+ const completedAfterLoad = await completedParallelRootOrAssertRuntime(metadata, rootRunId, journal);
186
+ if (completedAfterLoad !== undefined) {
187
+ options.lifecycle?.onRunReceipt?.({ runId: rootRunId, flow: metadata.flowName });
188
+ return completedAfterLoad;
189
+ }
160
190
  const stream = rootStreamFromMetadata(await rootKernelStep(journal, rootRunId));
161
191
  const peer = journal.createPeer();
162
192
  await peer.connect();
@@ -173,7 +203,7 @@ export async function resumeDurableAuthoredFlow(
173
203
  options.lifecycle?.onRunReceipt?.({ runId: rootRunId, flow: metadata.flowName });
174
204
  if (outcome.status === 'completed') {
175
205
  dispatchWait.cancel();
176
- return await completedResult(journal, outcome, rootRunId);
206
+ return await completedResult(journal, rootRunId);
177
207
  }
178
208
  assertRootCanDispatch(outcome);
179
209
  await assertNoOpenHumanWait(journal, outcome);
@@ -189,6 +219,22 @@ export async function resumeDurableAuthoredFlow(
189
219
  }
190
220
  }
191
221
 
222
+ async function completedParallelRootOrAssertRuntime(
223
+ metadata: AuthoredRootMetadata, rootRunId: string, journal: JournalClient,
224
+ ): Promise<DurableAuthoredFlowResult | undefined> {
225
+ if (metadata.identityVersion !== 2 || metadata.executionMode !== 'parallel'
226
+ || parallelPromiseIntrinsicsIntact()) return undefined;
227
+ // A completed root has nothing left to dispatch. A read-only snapshot avoids
228
+ // refusing it under a preload while preserving the pre-dispatch guard for
229
+ // an active root. Readback still uses the journaled authored verdict.
230
+ const snapshot = await journal.runGet(rootRunId);
231
+ if (snapshot.status === 'completed') return completedResult(journal, rootRunId);
232
+ const error = new AuthoredFlowExecutionError('unsafe_parallel_runtime',
233
+ `pinned parallel root ${rootRunId} cannot resume while a Promise or Array intrinsic is replaced or a Node preload is active. The run remains resumable in a clean process.`);
234
+ error.rootRunId = rootRunId;
235
+ throw error;
236
+ }
237
+
192
238
  /**
193
239
  * A root parked on an unanswered `f.human` will not be dispatched: the
194
240
  * kernel holds it in `needs_human` until `event.emit` closes the wait. Report
@@ -353,6 +399,8 @@ async function driveRoot(
353
399
  ...(options.agentEnvironment === undefined ? {} : { agentEnvironment: options.agentEnvironment }),
354
400
  ...(options.workerCapacity === undefined ? {} : { workerCapacity: options.workerCapacity }),
355
401
  rootRunId: dispatch.run_id,
402
+ executionMode: metadata.identityVersion === 2 && metadata.executionMode === 'parallel'
403
+ ? 'parallel' : 'serial',
356
404
  extensions: loaded.extensions,
357
405
  flowGraph: loaded.graph,
358
406
  ...options.lifecycle,
@@ -600,7 +648,10 @@ function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
600
648
  const root = value as Partial<AuthoredRootMetadata>;
601
649
  const surface = root.surface as Partial<SurfaceModuleAuthority> | undefined;
602
650
  const sources = root.sources;
603
- return root.kind === ROOT_KIND && typeof root.flowName === 'string'
651
+ return (root.kind === ROOT_KIND || root.kind === AUTHORED_PARALLEL_ROOT_KIND)
652
+ && (root.kind !== AUTHORED_PARALLEL_ROOT_KIND
653
+ || (root.identityVersion === 2 && root.executionMode === 'parallel'))
654
+ && typeof root.flowName === 'string'
604
655
  && typeof root.flowPath === 'string' && /^[a-f0-9]{64}$/.test(root.sourceSha256 ?? '')
605
656
  && typeof root.inputPresent === 'boolean' && surface?.packageName === '@relayflows/surface'
606
657
  && typeof surface.version === 'string' && /^[a-f0-9]{64}$/.test(surface.packageSha256 ?? '')
@@ -608,7 +659,9 @@ function isRootMetadata(value: unknown): value is AuthoredRootMetadata {
608
659
  && Array.isArray(sources) && sources.length > 0 && sources.every(isRootSourceAuthority)
609
660
  && (root.extensions === undefined || (Array.isArray(root.extensions) && root.extensions.every(isRootExtension)))
610
661
  && (root.localAgentStream === undefined
611
- || /^local-agent-[a-f0-9-]+$/.test(root.localAgentStream));
662
+ || /^local-agent-[a-f0-9-]+$/.test(root.localAgentStream))
663
+ && ((root.identityVersion === undefined && root.executionMode === undefined)
664
+ || (root.identityVersion === 2 && (root.executionMode === 'serial' || root.executionMode === 'parallel')));
612
665
  }
613
666
 
614
667
  function isRootExtension(value: unknown): value is AuthoredRootExtension {
@@ -32,14 +32,16 @@ import { MAX_AFTER, displayLabel, type AuthoredStepEdges } from './authored-step
32
32
  * body walks a few nodes per step. A walk that still exceeds `walkLimit` stops
33
33
  * and says so (`afterTruncated`) rather than guessing silently.
34
34
  *
35
- * This is observational metadata: it never gates execution, and it changes no
36
- * step id, admission key or resume identity.
35
+ * Most consumers use this as observational metadata. Parallel f.run follow-up
36
+ * IDs also use the causal frontier; the executor refuses a truncated frontier
37
+ * rather than choosing a replay key from incomplete predecessors.
37
38
  */
38
39
  export const MAX_WALK = 10_000;
39
40
 
40
41
  interface Frontier {
41
42
  readonly steps: readonly string[];
42
43
  readonly truncated: boolean;
44
+ readonly unregisteredAggregate: boolean;
43
45
  }
44
46
 
45
47
  interface StepNode {
@@ -62,6 +64,7 @@ const EXPANDS_ON: Readonly<Record<Combinator, Outcome | 'always' | undefined>> =
62
64
 
63
65
  interface Aggregate {
64
66
  readonly promise: Promise<unknown>;
67
+ readonly combinator: Combinator;
65
68
  /** Cached once settled: a settled promise never changes state. */
66
69
  outcome?: Outcome;
67
70
  readonly expandsOn: Outcome | 'always';
@@ -71,6 +74,10 @@ interface Aggregate {
71
74
  export class AuthoredStepGraph {
72
75
  private readonly stepPromises = new Map<number, string>();
73
76
  private readonly aggregates = new Map<number, Aggregate>();
77
+ private readonly registeredAggregates = new Set<number>();
78
+ private readonly registeredCombinatorInternals = new Set<number>();
79
+ private readonly certifiedMembers = new Set<number>();
80
+ private readonly unsafeAggregates = new Set<number>();
74
81
  private readonly contexts = new Map<number, Frontier>();
75
82
  private readonly nodes = new Map<string, StepNode>();
76
83
  private readonly edges = new Map<string, AuthoredStepEdges>();
@@ -78,6 +85,7 @@ export class AuthoredStepGraph {
78
85
  constructor(
79
86
  private readonly causesOf: (asyncId: number) => readonly number[],
80
87
  private readonly walkLimit = MAX_WALK,
88
+ private readonly unobservedJoinShape: (asyncId: number) => boolean = () => false,
81
89
  ) {}
82
90
 
83
91
  /** A combinator's aggregate and its members, for expansion once it settles. */
@@ -86,10 +94,19 @@ export class AuthoredStepGraph {
86
94
  promise: Promise<unknown>,
87
95
  combinator: Combinator,
88
96
  members: readonly number[],
97
+ created: ReadonlySet<number> = new Set(),
89
98
  ): void {
99
+ this.registeredAggregates.add(aggregateId);
100
+ for (const id of created) this.registeredCombinatorInternals.add(id);
101
+ if (combinator === 'race' || combinator === 'any') this.unsafeAggregates.add(aggregateId);
90
102
  const expandsOn = EXPANDS_ON[combinator];
91
103
  if (expandsOn === undefined || members.length === 0) return;
92
- this.aggregates.set(aggregateId, { promise, expandsOn, members });
104
+ this.aggregates.set(aggregateId, { promise, combinator, expandsOn, members });
105
+ }
106
+
107
+ /** Only direct steps and branch calls proven by the body grammar are safe members. */
108
+ certifyMember(asyncId: number): void {
109
+ this.certifiedMembers.add(asyncId);
93
110
  }
94
111
 
95
112
  /**
@@ -119,10 +136,19 @@ export class AuthoredStepGraph {
119
136
  return this.edges.get(step);
120
137
  }
121
138
 
139
+ /** Causal predecessors of a step being declared in the current async context. */
140
+ predecessorsAt(contextId: number): Frontier {
141
+ return this.frontierOf(contextId);
142
+ }
143
+
122
144
  /** Drop the walk state; recorded edges stay readable for late record writes. */
123
145
  release(): void {
124
146
  this.stepPromises.clear();
125
147
  this.aggregates.clear();
148
+ this.registeredAggregates.clear();
149
+ this.registeredCombinatorInternals.clear();
150
+ this.certifiedMembers.clear();
151
+ this.unsafeAggregates.clear();
126
152
  this.contexts.clear();
127
153
  this.nodes.clear();
128
154
  }
@@ -132,6 +158,7 @@ export class AuthoredStepGraph {
132
158
  if (memo !== undefined) return memo;
133
159
  const candidates = new Set<string>();
134
160
  let truncated = false;
161
+ let unregisteredAggregate = false;
135
162
  const visited = new Set<number>([start]);
136
163
  const queue = [start];
137
164
  // Breadth-first, so that a walk cut short by the limit has already found
@@ -143,10 +170,24 @@ export class AuthoredStepGraph {
143
170
  candidates.add(step);
144
171
  continue;
145
172
  }
173
+ // A join's resolution edge contains only its settling member. In
174
+ // parallel mode the full member list is safe only if our combinator
175
+ // observer registered it before the promise settled. The creation and
176
+ // resolution contexts also catch an unobserved join created after a
177
+ // previous step, where root-adoption alone cannot detect it.
178
+ const aggregate = this.aggregates.get(current);
179
+ const rejectedAll = aggregate?.combinator === 'all'
180
+ && promiseOutcome(aggregate.promise) === 'rejected';
181
+ const possibleJoin = this.unsafeAggregates.has(current) || rejectedAll
182
+ || (this.unobservedJoinShape(current)
183
+ && !this.registeredAggregates.has(current) && !this.certifiedMembers.has(current)
184
+ && !this.registeredCombinatorInternals.has(current));
185
+ unregisteredAggregate ||= possibleJoin;
146
186
  const answered = current === start ? undefined : this.contexts.get(current);
147
187
  if (answered !== undefined) {
148
188
  for (const found of answered.steps) candidates.add(found);
149
189
  truncated ||= answered.truncated;
190
+ unregisteredAggregate ||= answered.unregisteredAggregate;
150
191
  continue;
151
192
  }
152
193
  for (const id of [...this.waitedFor(current), ...this.causesOf(current)]) {
@@ -162,7 +203,11 @@ export class AuthoredStepGraph {
162
203
  }
163
204
  }
164
205
  const reduced = this.reduce(candidates);
165
- const frontier = Object.freeze({ steps: reduced.steps, truncated: truncated || reduced.truncated });
206
+ const frontier = Object.freeze({
207
+ steps: reduced.steps,
208
+ truncated: truncated || reduced.truncated,
209
+ unregisteredAggregate,
210
+ });
166
211
  this.contexts.set(start, frontier);
167
212
  return frontier;
168
213
  }
@@ -184,7 +229,7 @@ export class AuthoredStepGraph {
184
229
  * reduction reads recorded `after` lists; when one it reads is incomplete,
185
230
  * a redundant ancestor can survive, and the result says so.
186
231
  */
187
- private reduce(candidates: ReadonlySet<string>): Frontier {
232
+ private reduce(candidates: ReadonlySet<string>): Pick<Frontier, 'steps' | 'truncated'> {
188
233
  const ordered = [...candidates].sort((a, b) => this.orderOf(a) - this.orderOf(b));
189
234
  if (ordered.length <= 1) return { steps: ordered, truncated: false };
190
235
  // An ancestor is always registered before its descendant, so nothing
@@ -150,7 +150,8 @@ export async function alsoRecord(
150
150
  * Fold the root's stream back into one record per authored step.
151
151
  *
152
152
  * A `completed` record supersedes the `admitted` one for the same step; first
153
- * admission order is preserved so the index reads in the order the body ran.
153
+ * admission order is preserved for each step, while different children can
154
+ * first appear in completion order when they finish concurrently.
154
155
  * Records this version does not recognise are skipped rather than trusted.
155
156
  */
156
157
  export async function readAuthoredStepIndex(