@uipath/maestro-builder-sdk 6.43.0 → 6.44.0

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.
package/dist/check.js CHANGED
@@ -5,7 +5,7 @@
5
5
  * duplicate step names, references to undeclared variables/steps, empty branch
6
6
  * arms, and missing terminals.
7
7
  */
8
- import { Expr, SCHEDULE_PRESETS, parseIxpProjectType } from './flow-sdk.js';
8
+ import { Expr, SCHEDULE_PRESETS, parseIxpProjectType, parallelFork } from './flow-sdk.js';
9
9
  import { FlowNode } from './core/node-classes.js';
10
10
  import { planContributionBindings } from './core/contribution-bindings.js';
11
11
  import { definitionHasErrorPort, errorPortRefusal } from './core/error-port.js';
@@ -26,10 +26,11 @@ import { terminateStepClashMessage, terminateTwiceMessage } from './flow-termina
26
26
  import { endContentKey } from './flow-end-content.js';
27
27
  import { FLOW_NODE_TYPES } from './flow-node-registry.js';
28
28
  import { stepExits } from './flow-step-exits.js';
29
+ import { stepDeclaredVariables } from './flow-step-variables.js';
29
30
  import { plainHttpOnErrorMessage } from './nodes/http/flow/family.js';
30
31
  import { resolveVersion, resolveVersionCarried } from './core/nodes/versioned.js';
31
32
  import { declaredErrorPortRefusal } from './core/error-port.js';
32
- import { bodyUsesBreak, loopParallelWarning, loopVersionRefusal } from './loop-version.js';
33
+ import { bodyUsesBreak, loopParallelWarning, loopVersionRefusal, resolveLoopDefinition, loopVersion } from './loop-version.js';
33
34
  const newResourceIdLedger = () => ({ explicit: new Map(), taken: new Map(), agentSources: new Map() });
34
35
  /** INLINE_AGENT_RESOURCE_NODE_ID for each explicit `nodeId` another node of the file also gets. */
35
36
  function resourceNodeIdClashes(ledger) {
@@ -337,6 +338,11 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
337
338
  }
338
339
  stepNames.add(s.name);
339
340
  declared.add(s.name); // a step's output is referenceable by name
341
+ // …and so is every variable the step declares by running: a quick
342
+ // form's asked field lands in its `variable` (#1309).
343
+ if (s.kind === 'action')
344
+ for (const v of stepDeclaredVariables(s.spec, s.options?.version))
345
+ declared.add(v);
340
346
  }
341
347
  if ((s.kind === 'stepToList' || s.kind === 'stepToRef') && s.port === 'error')
342
348
  checkOnError(s, prevPortSource, diags, prevPortSource && checkedSteps.get(prevPortSource.name), carriedLookup([built, ...ancestors]));
@@ -687,6 +693,20 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
687
693
  }
688
694
  else if (s.kind === 'parallel') {
689
695
  parallelNames.add(s.name);
696
+ // The fork node is a node too (#1310): its name is a step name for
697
+ // clashes, and a read of it is a read of a merge — nothing.
698
+ const fork = parallelFork(s.options);
699
+ if (fork) {
700
+ if (stepNames.has(fork.name)) {
701
+ diags.push({
702
+ level: 'error', code: 'DUP_STEP', step: s.name,
703
+ message: `Duplicate step name "${fork.name}": the fork of parallel "${s.name}" and another step share it.`,
704
+ });
705
+ }
706
+ stepNames.add(fork.name);
707
+ declared.add(fork.name);
708
+ parallelNames.add(fork.name);
709
+ }
690
710
  checkParallel(s, diags);
691
711
  for (const arm of s.arms)
692
712
  walk(arm, inLoop ? 'arm' : false);
@@ -701,10 +721,14 @@ function checkFlow(built, ancestors, opts = {}, ledger = newResourceIdLedger())
701
721
  addExpr(s.options.completionCondition.js, s.name);
702
722
  // The same refusal serialize throws, from the same helper: an option or
703
723
  // a `.break()` the pinned version's definition cannot express (#1186).
704
- const versionRefusal = loopVersionRefusal(s.name, s.options, bodyUsesBreak(s.body));
724
+ // Against the definition the loop compiles against — bundled, else
725
+ // carried — as serialize resolves it, so a carried 1.0's `.break()`
726
+ // and `parallel: true` are judged here and not first at compile (#1311).
727
+ const loopDef = resolveLoopDefinition(loopVersion(s.options), carriedLookup([built, ...ancestors]));
728
+ const versionRefusal = loopVersionRefusal(s.name, s.options, bodyUsesBreak(s.body), loopDef);
705
729
  if (versionRefusal)
706
730
  diags.push({ level: 'error', code: 'LOOP_VERSION_UNSUPPORTED', step: s.name, message: versionRefusal });
707
- const parallelWarning = loopParallelWarning(s.name, s.options);
731
+ const parallelWarning = loopParallelWarning(s.name, s.options, loopDef);
708
732
  if (parallelWarning)
709
733
  diags.push({ level: 'warning', code: 'LOOP_PARALLEL_LEGACY', step: s.name, message: parallelWarning });
710
734
  loopNames.set(s.name, s.options?.version === '1.0.0' ? LOOP_FIELDS_1_0_0 : LOOP_FIELDS);
@@ -3316,8 +3340,22 @@ function checkStepRefs(built, diags, reservedIds = []) {
3316
3340
  s.cases.forEach((c) => index(c.body, portList, scope, arm));
3317
3341
  index(s.default ?? [], portList, scope, arm);
3318
3342
  }
3319
- else if (s.kind === 'parallel')
3343
+ else if (s.kind === 'parallel') {
3344
+ // The fork node (`{ fork }`, #1310) is a node in this scope like the
3345
+ // join: a `.stepToRef(<fork>)` is an edge serialize resolves.
3346
+ const fork = parallelFork(s.options);
3347
+ if (fork) {
3348
+ stepNamesSeen.add(fork.name);
3349
+ if (!order.has(fork.name))
3350
+ order.set(fork.name, seq);
3351
+ if (portList)
3352
+ inPortList.add(fork.name);
3353
+ scopeOf.set(fork.name, scope);
3354
+ if (arm)
3355
+ armOf.set(fork.name, arm);
3356
+ }
3320
3357
  s.arms.forEach((a, i) => index(a, portList, scope, `${s.name}#${i}`));
3358
+ }
3321
3359
  // A loop body is a bpmn:SubProcess — its own containment scope.
3322
3360
  else if (s.kind === 'loop' || s.kind === 'doWhile')
3323
3361
  index(s.body, portList, s.name, arm);
@@ -3406,12 +3444,27 @@ function checkStepRefs(built, diags, reservedIds = []) {
3406
3444
  s.cases.forEach((c) => collect(c.body, source, scope, arm, enclosing));
3407
3445
  collect(s.default ?? [], source, scope, arm, enclosing);
3408
3446
  }
3409
- else if (s.kind === 'parallel')
3410
- s.arms.forEach((a, i) => collect(a, source, scope, `${s.name}#${i}`, enclosing));
3447
+ else if (s.kind === 'parallel') {
3448
+ // Arms that all end in `.stepToRef(<one step>)` (or stop) have no Merge:
3449
+ // serialize writes none when no arm hands it a tail, and the refs ARE the
3450
+ // join — the canvas shape of two steps feeding one (#1310). A ref there
3451
+ // is not leaving the arm without reaching its merge.
3452
+ const refJoinTarget = parallelRefJoin(s.arms);
3453
+ s.arms.forEach((a, i) => {
3454
+ const key = `${s.name}#${i}`;
3455
+ if (refJoinTarget !== undefined)
3456
+ refJoin.set(key, refJoinTarget);
3457
+ collect(a, source, scope, key, enclosing);
3458
+ });
3459
+ }
3411
3460
  else if (s.kind === 'loop' || s.kind === 'doWhile')
3412
3461
  collect(s.body, source, s.name, arm, [...enclosing, s.name]);
3413
3462
  }
3414
3463
  };
3464
+ /** The step every arm of a parallel refs instead of a Merge, by `<parallel>#<arm>`. */
3465
+ const refJoin = new Map();
3466
+ /** `<parallel>\0<target>` pairs PARALLEL_REF_JOIN has been said for. */
3467
+ const refJoinSaid = new Set();
3415
3468
  collect(built.steps, undefined, '', undefined);
3416
3469
  for (const ep of built.entryPoints ?? [])
3417
3470
  collect(ep.steps, ep.id, '', undefined);
@@ -3498,6 +3551,22 @@ function checkStepRefs(built, diags, reservedIds = []) {
3498
3551
  + `on is a bpmn:ParallelGateway, which waits for every branch it forked — an extra token arriving inside `
3499
3552
  + `an arm is not one of them, so the join may never settle. Verify with uip maestro flow validate.`);
3500
3553
  }
3554
+ else if (arm !== undefined && refJoin.get(arm) === target) {
3555
+ // The arms meet at a plain step, not a Merge: that step has one incoming
3556
+ // edge per arm, and the runtime starts a step once per token that
3557
+ // arrives (only a gateway waits for every incoming flow). So it runs
3558
+ // once per arm, which is not what `.parallel`'s join promises. Said
3559
+ // once per parallel, not once per arm (#1310).
3560
+ const parallel = arm.slice(0, arm.lastIndexOf('#'));
3561
+ if (!refJoinSaid.has(`${parallel}\0${target}`)) {
3562
+ refJoinSaid.add(`${parallel}\0${target}`);
3563
+ say('warning', 'PARALLEL_REF_JOIN', `"${target}" is where every arm of parallel "${parallel}" ends by .stepToRef, so it has one incoming edge `
3564
+ + `per arm and no Merge node. The runtime starts a step once per token that arrives, so "${target}" runs `
3565
+ + `once per arm — not once after all arms, as .parallel's Merge would. To run it once, end each arm on a `
3566
+ + `step of its own and put "${target}" after the .parallel. A canvas flow drawn this way behaves the same, `
3567
+ + `so leave a decompiled one as it is.`);
3568
+ }
3569
+ }
3501
3570
  else if (arm !== undefined) {
3502
3571
  say('warning', 'REF_OUT_OF_PARALLEL_ARM', `.stepToRef('${target}') ${where} leaves a parallel arm without reaching its Merge. That Merge waits for `
3503
3572
  + `every branch it forked, so the token this arm owed it never arrives and the join may hang. Verify with `
@@ -3648,3 +3717,28 @@ function checkContribution(node, requestedVersion, step, ownError, diags) {
3648
3717
  return;
3649
3718
  diags.push({ level: 'error', code: 'CONTRIBUTION_BINDING_UNRESOLVED', step, message: plan.problems.join(' ') });
3650
3719
  }
3720
+ /**
3721
+ * The one step every arm of a `.parallel` ends by referring to, when the arms
3722
+ * join there instead of on a Merge: each arm ends in `.stepToRef(<that step>)`
3723
+ * on its default port, or stops (`.return()`, `.terminate()`), and at least one
3724
+ * refs. `undefined` when some arm has a tail (serialize then emits the Merge) or
3725
+ * the refs disagree. The decompiler writes a step fed by several steps this way
3726
+ * (#1310).
3727
+ */
3728
+ function parallelRefJoin(arms) {
3729
+ let target;
3730
+ for (const arm of arms) {
3731
+ const last = arm[arm.length - 1];
3732
+ if (last === undefined)
3733
+ return undefined;
3734
+ // An arm that ends the run or the iteration hands the join nothing either.
3735
+ if (last.kind === 'return' || last.kind === 'terminate' || last.kind === 'break' || last.kind === 'continue')
3736
+ continue;
3737
+ if (last.kind !== 'stepToRef' || last.port !== 'output')
3738
+ return undefined;
3739
+ if (target !== undefined && target !== last.target)
3740
+ return undefined;
3741
+ target = last.target;
3742
+ }
3743
+ return target;
3744
+ }
@@ -228,6 +228,15 @@ export interface AskedField extends FormFieldSettings {
228
228
  direction: 'output';
229
229
  /** Reviewer must fill it in before the task can be completed. */
230
230
  required?: boolean;
231
+ /**
232
+ * The flow variable the answer lands in, read downstream as `$vars.<variable>`
233
+ * (`v('<variable>')`) — the name only, without the `vars.` prefix. Defaults to
234
+ * the field id. Studio Web names it `<Step>_<field>` (`P_comments`) and later
235
+ * steps read `$vars.P_comments` or bind `vars.P_comments`; declaring it here is
236
+ * what makes those reads resolve, so a designer-saved approval flow round-trips
237
+ * through the SDK. The answer is also always `out('<step>', '<id>')`.
238
+ */
239
+ variable?: string;
231
240
  }
232
241
  /**
233
242
  * A value the reviewer READS **and can correct**: pre-filled from the flow,
@@ -205,6 +205,13 @@ export declare function formFieldSettingsSource(settings: FormFieldSettings, val
205
205
  * neither (the agent fills the form).
206
206
  */
207
207
  export type FormRowSite = 'human task' | 'escalation';
208
+ /**
209
+ * The variable NAME an asked field's `variable` row key names — `P_comments`
210
+ * for `vars.P_comments` — or `undefined` when the value is not a `vars.<name>`
211
+ * path to one identifier. That is the only shape the SDK writes (the field's
212
+ * `variable` option, else its id), so anything else is reported as a loss.
213
+ */
214
+ export declare function formFieldVariableName(variable: unknown): string | undefined;
208
215
  /**
209
216
  * The keys of a form row the SDK cannot carry into source, each with why.
210
217
  * Empty when decompile → compile writes the row back as it was. The lifts
@@ -462,6 +462,16 @@ export function liftFormFieldSettings(row) {
462
462
  export function formFieldSettingsSource(settings, value) {
463
463
  return FORM_FIELD_SETTINGS_KEYS.map((key) => [key, settings[key] === undefined ? undefined : value(settings[key])]);
464
464
  }
465
+ /**
466
+ * The variable NAME an asked field's `variable` row key names — `P_comments`
467
+ * for `vars.P_comments` — or `undefined` when the value is not a `vars.<name>`
468
+ * path to one identifier. That is the only shape the SDK writes (the field's
469
+ * `variable` option, else its id), so anything else is reported as a loss.
470
+ */
471
+ export function formFieldVariableName(variable) {
472
+ const m = typeof variable === 'string' ? /^vars\.([A-Za-z_$][\w$]*)$/.exec(variable) : null;
473
+ return m ? m[1] : undefined;
474
+ }
465
475
  /**
466
476
  * The keys of a form row the SDK cannot carry into source, each with why.
467
477
  * Empty when decompile → compile writes the row back as it was. The lifts
@@ -521,8 +531,8 @@ export function formFieldRowLoss(row, site) {
521
531
  say(key, 'on an escalation form, which the SDK writes without variables');
522
532
  else if (direction !== 'output')
523
533
  say(key, 'on a shown or pre-filled field, which the SDK writes without one');
524
- else if (row.variable !== `vars.${id}`)
525
- say(key, `the SDK writes 'vars.${id}', the field's own id`);
534
+ else if (formFieldVariableName(row.variable) === undefined)
535
+ say(key, `not a 'vars.<name>' path, which is what the SDK writes ('vars.${id}' by default)`);
526
536
  }
527
537
  }
528
538
  return loss;
package/dist/decompile.js CHANGED
@@ -191,14 +191,30 @@ function shippedVersions(type) {
191
191
  * The options argument of `.branch()`, `.switch()` and `.parallel()`: the exact
192
192
  * `version`, and a canvas `label` that differs from the node id (what compile
193
193
  * writes when none is given). No `updates`: those constructs refuse them.
194
+ *
195
+ * `node` is the construct's own node (a `.parallel`'s join); `fork` is the
196
+ * merge node a designer-drawn parallel split leaves from, written as
197
+ * `{ fork }` (#1310). A `.parallel` whose arms rejoin on a plain step has no
198
+ * join node, so `node` may be absent; the fork then supplies the version.
194
199
  */
195
- function forkOptsArg(node) {
200
+ function forkOptsArg(node, fork) {
196
201
  const parts = [];
197
- const ver = verClause(node);
202
+ const ver = node ? verClause(node) : fork ? verClause(fork) : '';
198
203
  if (ver)
199
204
  parts.push(ver);
200
- if (hasRenamedLabel(node, node.id))
205
+ if (node && hasRenamedLabel(node, node.id))
201
206
  parts.push(`label: ${tsLiteral(node.display.label)}`);
207
+ if (fork) {
208
+ // The fork's own version, when the join's clause does not already say it
209
+ // (no join node, or a different version on each).
210
+ const forkVer = node && node.typeVersion !== fork.typeVersion ? verClause(fork) : '';
211
+ const forkParts = [
212
+ `name: ${tsLiteral(fork.id)}`,
213
+ ...(hasRenamedLabel(fork, fork.id) ? [`label: ${tsLiteral(fork.display.label)}`] : []),
214
+ ...(forkVer ? [forkVer] : []),
215
+ ];
216
+ parts.push(forkParts.length === 1 ? `fork: ${tsLiteral(fork.id)}` : `fork: { ${forkParts.join(', ')} }`);
217
+ }
202
218
  return parts.length ? `, { ${parts.join(', ')} }` : '';
203
219
  }
204
220
  /**
@@ -322,6 +338,19 @@ function unwrapSource(v) {
322
338
  }
323
339
  return undefined;
324
340
  }
341
+ /**
342
+ * The JS body of a condition slot (a decision's `expression`, a switch case's
343
+ * `expression`), whichever spelling the file uses: a bare JS string (the classic
344
+ * CLI), a `{ type: 'jsExpression', expression }` envelope (what Studio Web
345
+ * writes), or a `=js:`-prefixed string, with or without a space after the colon
346
+ * (`=js: ($vars.x ?? 0) > 0`). Before this, the envelope was stringified to
347
+ * `[object Object]` and the prefixed spelling was kept inside the template, so
348
+ * every designer-saved decision came back as source compile refused (#1308).
349
+ */
350
+ function conditionText(v) {
351
+ const raw = unwrapSource(v);
352
+ return raw === undefined ? String(v ?? '') : wireExprBody(raw).trim();
353
+ }
325
354
  /** A plain (boolean/number) input value, unwrapping a literal EV envelope if present. */
326
355
  function unwrapPlain(v) {
327
356
  if (v && typeof v === 'object' && 'expression' in v && v.type === 'literal') {
@@ -1513,7 +1542,11 @@ function forkRegion(edges, stop, scope, ipdom, ctx, refInto) {
1513
1542
  const targets = edges.map((e) => e.targetNodeId);
1514
1543
  const join = commonPostDominator(targets, ipdom);
1515
1544
  const joinNode = join !== undefined ? ctx.graph.byId.get(join) : undefined;
1516
- if (join === undefined || join === stop || joinNode?.type !== T.merge) {
1545
+ // Arms may rejoin at the region's own continuation (`stop`) when that is a
1546
+ // plain step: the arms end in `.stepToRef(stop)` and nothing follows them
1547
+ // here. Not at a merge node that is `stop`: the enclosing construct continues
1548
+ // from it, so a `.parallel` inside this arm cannot also own it.
1549
+ if (join === undefined || joinNode === undefined || !parallelJoinable(joinNode) || (join === stop && joinNode.type === T.merge)) {
1517
1550
  if (ctx.o.bestEffort === false)
1518
1551
  throw new Error(`decompile: unstructured fan-out (${edges.length} edges).`);
1519
1552
  ctx.graph.accounted.add(edges[0]);
@@ -1525,22 +1558,75 @@ function forkRegion(edges, stop, scope, ipdom, ctx, refInto) {
1525
1558
  }
1526
1559
  for (const e of edges)
1527
1560
  ctx.graph.accounted.add(e);
1528
- const fork = parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto);
1561
+ const fork = parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto, undefined, edges[0].sourceNodeId);
1529
1562
  if (fork.next.brk || fork.next.cont)
1530
1563
  return [fork.seg, fork.next.brk ? '.break()' : '.continue()'];
1531
1564
  return [fork.seg, ...region(fork.next.cursor, stop, scope, ipdom, ctx, refInto)];
1532
1565
  }
1566
+ /**
1567
+ * Whether a fan-out whose arms all reconverge at `join` folds to a
1568
+ * `.parallel(...)`: at a merge node, the SDK's own join; at any other step (a
1569
+ * step, a decision, a loop, …), the canvas shape of several steps feeding one
1570
+ * (#1310), which the arms spell by ending in `.stepToRef(join)` — serialize
1571
+ * writes no merge node when no arm hands it a tail. Not at an End or a
1572
+ * Terminate: arms that all end share the End by name (#1231/#1239) and nothing
1573
+ * follows, which is another fold (#1267).
1574
+ */
1575
+ function parallelJoinable(join) {
1576
+ return join.type !== T.end && join.type !== T.terminate;
1577
+ }
1533
1578
  /**
1534
1579
  * The `.parallel(join, [arms])` segment for a fork whose `targets` (one per
1535
- * edge; the caller accounts those edges) meet at the merge node `join`. Each
1536
- * arm is the region up to `join`. `next` is where the walk goes after the
1537
- * merge: its `output` edge, accounted by `continueOn` (`brk` / `cont` when
1538
- * that edge leaves through the enclosing loop's `break` / spelled `continue`).
1580
+ * edge; the caller accounts those edges) meet at `join`. Each arm is the
1581
+ * region up to `join`.
1582
+ *
1583
+ * At a merge node, `join` names the `.parallel` and `next` is where the walk
1584
+ * goes after it: its `output` edge, accounted by `continueOn` (`brk` / `cont`
1585
+ * when that edge leaves through the enclosing loop's `break` / spelled
1586
+ * `continue`). At any other step (#1310) the arms end in `.stepToRef(join)`,
1587
+ * the `.parallel` gets a fresh name (`forkName`, after `source`) because no
1588
+ * node carries one, and `next` is `join` itself: the main path emits it, and
1589
+ * its only incoming edges are the arms' refs.
1590
+ *
1591
+ * `fork` is the merge node a designer-drawn split leaves from, written as
1592
+ * `{ fork }` (#1310); the caller accounts its outgoing edges.
1539
1593
  */
1540
- function parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto) {
1541
- const arms = targets.map((t, i) => armCb(`a${i}`, region(t, join, scope, ipdom, ctx, refInto)));
1542
- const seg = `.parallel(${tsLiteral(join)}, [\n${arms.map((a) => reindent(a, ' ')).join(',\n')},\n]${forkOptsArg(joinNode)})`;
1543
- return { seg, next: continueOn(joinNode, 'output', ctx, refInto) };
1594
+ function parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto, fork, source) {
1595
+ const atMerge = joinNode.type === T.merge;
1596
+ const name = atMerge ? join : forkName(source ?? fork?.id, ctx);
1597
+ const arms = targets.map((t, i) => {
1598
+ const segs = region(t, join, scope, ipdom, ctx, refInto);
1599
+ // The arm hands the join a tail only when its walk ran up to the join; a
1600
+ // path that already left (`.break()`, `.continue()`, a ref, an End, a
1601
+ // Terminate) needs no ref — and gets none.
1602
+ if (!atMerge && !endsPath(segs[segs.length - 1]))
1603
+ segs.push(`.stepToRef(${tsLiteral(join)})`);
1604
+ return armCb(`a${i}`, segs);
1605
+ });
1606
+ const seg = `.parallel(${tsLiteral(name)}, [\n${arms.map((a) => reindent(a, ' ')).join(',\n')},\n]${forkOptsArg(atMerge ? joinNode : undefined, fork)})`;
1607
+ return { seg, next: atMerge ? continueOn(joinNode, 'output', ctx, refInto) : { cursor: join } };
1608
+ }
1609
+ /** Whether a chain segment ends its path, so nothing may follow it in the same list. */
1610
+ function endsPath(seg) {
1611
+ return seg !== undefined && /^\.(stepToRef|break|continue|return|terminate)\(/.test(seg);
1612
+ }
1613
+ /**
1614
+ * A name for a `.parallel(...)` that creates no merge node: `<source>Fork`,
1615
+ * numbered when that is a node id, a declared input, variable or output, or a
1616
+ * name already minted. No node carries it (the arms join on a plain step by
1617
+ * ref), but it must still not collide with any of those: check reads a
1618
+ * `$vars.<name>` of a parallel's name as a read of its merge, so a variable
1619
+ * of that name would fail PARALLEL_HAS_NO_OUTPUT on recompile.
1620
+ */
1621
+ function forkName(source, ctx) {
1622
+ const base = `${source ?? 'parallel'}Fork`;
1623
+ const globals = new Set(readFlowGlobals(ctx.flow.variables).map((g) => g.name ?? g.id));
1624
+ const taken = (n) => ctx.graph.byId.has(n) || globals.has(n) || (ctx.forkNames?.has(n) ?? false);
1625
+ let name = base;
1626
+ for (let i = 2; taken(name); i++)
1627
+ name = `${base}${i}`;
1628
+ (ctx.forkNames ??= new Set()).add(name);
1629
+ return name;
1544
1630
  }
1545
1631
  /** Re-indent every non-empty line of a (possibly multi-line) segment by `pad`. */
1546
1632
  function reindent(seg, pad) {
@@ -1707,21 +1793,50 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
1707
1793
  return segs;
1708
1794
  }
1709
1795
  if (node.type === T.merge) {
1710
- // A bare merge reached on the main path (its fork was not detected as a
1711
- // parallel). Skip it and continue from its output — best effort.
1712
- const next = continueOn(node, 'output', ctx, refInto);
1713
- if (next.brk || next.cont) {
1714
- segs.push(next.brk ? '.break()' : '.continue()');
1796
+ // A merge node with several `output` edges is a FORK the designer drew
1797
+ // (#1310): Studio Web's parallel split is a `core.logic.merge` with one
1798
+ // incoming edge and one edge per arm, the arms rejoining at a second
1799
+ // merge node (or at a plain step). `.parallel(join, arms, { fork })`
1800
+ // recreates both nodes and every edge.
1801
+ const outs = scopeSuccEdges(ctx.graph, node.id, scope).filter((e) => e.sourcePort === 'output');
1802
+ const join = outs.length >= 2 ? ipdom.get(node.id) : undefined;
1803
+ const joinNode = join !== undefined && join !== EXIT ? ctx.graph.byId.get(join) : undefined;
1804
+ // As in `forkRegion`: a plain-step join may be the region's own `stop`.
1805
+ if (join !== undefined && joinNode !== undefined && parallelJoinable(joinNode) && !(join === stop && joinNode.type === T.merge)) {
1806
+ const targets = outs.map((e) => follow(ctx.graph, node.id, e.targetNodeId));
1807
+ const fork = parallelSeg(join, joinNode, targets, scope, ipdom, ctx, refInto, node);
1808
+ segs.push(fork.seg);
1715
1809
  flagDropped(node, ctx, segs);
1716
- return segs;
1810
+ if (fork.next.brk || fork.next.cont) {
1811
+ segs.push(fork.next.brk ? '.break()' : '.continue()');
1812
+ return segs;
1813
+ }
1814
+ cursor = fork.next.cursor;
1815
+ continue;
1816
+ }
1817
+ // A merge node with one edge in and one out is a pass-through the
1818
+ // designer left in place (a join whose arms were later removed). The
1819
+ // layout names it, so it is kept: it falls through to the regular step
1820
+ // path below and comes back as the raw node of its own definition
1821
+ // (#1310). Skipping it dropped the node, and compile then refused the
1822
+ // `.layout()` key. A bare merge with several edges in (a join whose fork
1823
+ // was not detected) is still skipped — best effort.
1824
+ const ins = ctx.graph.inEdges(node.id).filter((e) => scope.has(e.sourceNodeId));
1825
+ if (outs.length !== 1 || ins.length !== 1) {
1826
+ const next = continueOn(node, 'output', ctx, refInto);
1827
+ if (next.brk || next.cont) {
1828
+ segs.push(next.brk ? '.break()' : '.continue()');
1829
+ flagDropped(node, ctx, segs);
1830
+ return segs;
1831
+ }
1832
+ cursor = next.cursor;
1833
+ flagDropped(node, ctx, segs);
1834
+ continue;
1717
1835
  }
1718
- cursor = next.cursor;
1719
- flagDropped(node, ctx, segs);
1720
- continue;
1721
1836
  }
1722
1837
  if (node.type === T.decision) {
1723
1838
  const r = ipdom.get(node.id) ?? stop;
1724
- const cond = emitBareExpr(String((node.inputs ?? {}).expression ?? ''), ctx.imp, ctx.inputNames);
1839
+ const cond = emitBareExpr(conditionText((node.inputs ?? {}).expression), ctx.imp, ctx.inputNames);
1725
1840
  const thenSegs = forkRegion(portEdges(ctx.graph, node.id, 'true'), r, scope, ipdom, ctx, refInto);
1726
1841
  const elseSegs = forkRegion(portEdges(ctx.graph, node.id, 'false'), r, scope, ipdom, ctx, refInto);
1727
1842
  const labels = armLabels(node);
@@ -1804,7 +1919,14 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
1804
1919
  }
1805
1920
  if (node.type === T.subflow) {
1806
1921
  segs.push(`.step(${tsLiteral(node.id)}, ${emitSubflow(node, ctx)}${optsArg(node, ctx)})`);
1807
- cursor = follow(ctx.graph, node.id, scopeSucc(ctx.graph, node.id, scope)[0]);
1922
+ // A subflow step guards its body like any step: `core.subflow` declares
1923
+ // the `error` handle, and `.onError()` after the step authors it. Without
1924
+ // this the handler's edge had no spelling and a recompile dropped it.
1925
+ const subflowSucc = scopeSucc(ctx.graph, node.id, scope);
1926
+ const subflowHandler = errorHandlerSeg(node.id, subflowSucc, scope, ipdom, ctx);
1927
+ if (subflowHandler)
1928
+ segs.push(subflowHandler);
1929
+ cursor = follow(ctx.graph, node.id, subflowSucc[0]);
1808
1930
  if (cursor === undefined)
1809
1931
  accountImplicitTail(node, ctx, refInto);
1810
1932
  flagDropped(node, ctx, segs);
@@ -1920,11 +2042,13 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
1920
2042
  flagDropped(node, ctx, segs);
1921
2043
  continue;
1922
2044
  }
1923
- // Parallel: the fork's ipdom is the merge join; each successor is one arm.
2045
+ // Parallel: the fork's ipdom is the join — the merge node of a `.parallel`,
2046
+ // or a plain step several steps feed (#1310); each successor is one arm.
1924
2047
  const join = ipdom.get(node.id);
1925
2048
  const joinNode = join !== undefined ? ctx.graph.byId.get(join) : undefined;
1926
- if (join && join !== EXIT && joinNode?.type === T.merge) {
1927
- const fork = parallelSeg(join, joinNode, succ.map((t) => follow(ctx.graph, node.id, t)), scope, ipdom, ctx, refInto);
2049
+ // A plain-step join may be the region's own `stop`: the arms end in refs to it and the walk ends there.
2050
+ if (join && join !== EXIT && joinNode && parallelJoinable(joinNode)) {
2051
+ const fork = parallelSeg(join, joinNode, succ.map((t) => follow(ctx.graph, node.id, t)), scope, ipdom, ctx, refInto, undefined, node.id);
1928
2052
  segs.push(fork.seg);
1929
2053
  flagDropped(node, ctx, segs);
1930
2054
  if (fork.next.brk || fork.next.cont) {
@@ -2164,7 +2288,7 @@ function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2164
2288
  // First pass: is every case one shared `<disc> === <literal>`? If not, every
2165
2289
  // case is emitted as `when:` with its expression as written.
2166
2290
  const parsed = cases.map((c) => {
2167
- const p = parseSwitchCase(String(c.expression ?? ''));
2291
+ const p = parseSwitchCase(conditionText(c.expression));
2168
2292
  if (p) {
2169
2293
  if (disc === undefined)
2170
2294
  disc = p.lhs;
@@ -2186,13 +2310,13 @@ function emitSwitch(node, ipdom, scope, ctx, stop, refInto) {
2186
2310
  // edges leave from the ORIGINAL node's handles, which merge keeps (#1130).
2187
2311
  const idPart = typeof c.id === 'string' && c.id !== '' && c.id !== `case_${caseIndex}` ? `, id: ${tsLiteral(c.id)}` : '';
2188
2312
  const head = nonUniform || !p
2189
- ? `when: ${emitBareExpr(String(c.expression ?? ''), ctx.imp, ctx.inputNames)}`
2313
+ ? `when: ${emitBareExpr(conditionText(c.expression), ctx.imp, ctx.inputNames)}`
2190
2314
  : `value: ${p.value}`;
2191
2315
  return `{ ${head}${labelPart}${idPart}, body: ${armCb('b', armSegs)} }`;
2192
2316
  });
2193
2317
  // A case with no condition has no `when:` that compiles: emitting `js\`\``
2194
2318
  // would fail at compile ([EXPR_SYNTAX]) with no warning here.
2195
- const empty = cases.filter((c) => String(c.expression ?? '').trim() === '');
2319
+ const empty = cases.filter((c) => conditionText(c.expression) === '');
2196
2320
  if (empty.length > 0) {
2197
2321
  const listing = empty.map((c) => tsLiteral(c.label ?? c.id)).join(', ');
2198
2322
  const message = `switch ${tsLiteral(node.id)} has a case with no condition (${listing}). `
@@ -2365,7 +2489,10 @@ function layoutSeg(flow, nodes, edges) {
2365
2489
  */
2366
2490
  function emitSubflow(node, ctx) {
2367
2491
  const child = ctx.flow.subflows?.[node.id];
2368
- const inputsSrc = emitValMap(node.inputs, ctx.imp, ctx.inputNames) || '{}';
2492
+ // `errorHandlingEnabled` is the flag serialize sets for `.onError()`, not a
2493
+ // child input; `.onError(...)` after the step puts it back.
2494
+ const { errorHandlingEnabled: _handled, ...inputs } = (node.inputs ?? {});
2495
+ const inputsSrc = emitValMap(inputs, ctx.imp, ctx.inputNames) || '{}';
2369
2496
  if (!child) {
2370
2497
  if (ctx.o.bestEffort === false)
2371
2498
  throw new Error(`decompile: subflow "${node.id}" has no body in subflows[].`);
@@ -2381,7 +2508,52 @@ function emitSubflow(node, ctx) {
2381
2508
  * own scope; the child's trigger is its `<caller>Start` node, written as
2382
2509
  * `.trigger(manual(), { label })` only when its label is not `SUBFLOW_START_LABEL`.
2383
2510
  */
2384
- function emitChildFlow(name, child, ctx) {
2511
+ function emitChildFlow(name, childFile, ctx) {
2512
+ // Body: the child's trigger is its own manual Start; walk from its successor.
2513
+ const trigger = (childFile.nodes ?? []).find((n) => isTrigger(n.type) && !(childFile.edges ?? []).some((e) => e.targetNodeId === n.id));
2514
+ // The child's nodes read its inputs through that Start (`$vars.<caller>Start.
2515
+ // output.<input>`); read it back as `start`, the one name both `input()` and
2516
+ // check know a child's trigger by (#1313). Not when a child node is itself
2517
+ // named `start`: the rename would then point the reads at that node.
2518
+ const readAsStart = trigger !== undefined && trigger.id !== DEFAULT_TRIGGER_ID
2519
+ && !(childFile.nodes ?? []).some((n) => n.id === DEFAULT_TRIGGER_ID);
2520
+ const child = readAsStart ? readChildStartAsStart(childFile, trigger.id) : childFile;
2521
+ const outerTriggerId = currentTriggerId;
2522
+ currentTriggerId = readAsStart ? DEFAULT_TRIGGER_ID : (trigger?.id ?? DEFAULT_TRIGGER_ID);
2523
+ try {
2524
+ return emitChildFlowIn(name, child, trigger, ctx);
2525
+ }
2526
+ finally {
2527
+ currentTriggerId = outerTriggerId;
2528
+ }
2529
+ }
2530
+ /**
2531
+ * A child flow's reads of its own Start node, `$vars.<caller>Start…`, read as
2532
+ * `$vars.start…`. serialize names a child's trigger `<caller>Start` and renames
2533
+ * `start` to it in the child scope, so `input('x')` — which lowers to
2534
+ * `$vars.start.output.x` — reaches it; this is the inverse. Rewritten in code
2535
+ * spans (`renameVarRoot`: `=js:` values, bare `$vars.` paths, the raw-JS
2536
+ * fields) and in the BINDING slots that hold a variable path instead of an
2537
+ * expression: a form row's `binding` (`vars.<caller>Start.output.x`), an agent
2538
+ * input's `binding`, and an action app's `appInputBindings` (`=vars.…`). Only
2539
+ * those: a literal string elsewhere that happens to read `vars.<caller>Start…`
2540
+ * (a variable's default, a label) is data and stays as written.
2541
+ */
2542
+ function readChildStartAsStart(child, startId) {
2543
+ const renamed = renameVarRoot(child, startId, DEFAULT_TRIGGER_ID);
2544
+ const path = new RegExp(`^(=?vars\\.)${startId.replace(/[$]/g, '\\$&')}(?![A-Za-z0-9_$])`);
2545
+ const walk = (x, key, parentKey) => {
2546
+ if (typeof x === 'string')
2547
+ return key === 'binding' || parentKey === 'appInputBindings' ? x.replace(path, `$1${DEFAULT_TRIGGER_ID}`) : x;
2548
+ if (Array.isArray(x))
2549
+ return x.map((y) => walk(y, key, parentKey));
2550
+ if (x && typeof x === 'object')
2551
+ return Object.fromEntries(Object.entries(x).map(([k, y]) => [k, walk(y, k, key)]));
2552
+ return x;
2553
+ };
2554
+ return walk(renamed);
2555
+ }
2556
+ function emitChildFlowIn(name, child, trigger, ctx) {
2385
2557
  const globals = readFlowGlobals(child.variables, runStrict);
2386
2558
  const childCtx = {
2387
2559
  graph: new Graph(child.nodes ?? [], child.edges ?? []),
@@ -2405,8 +2577,6 @@ function emitChildFlow(name, child, ctx) {
2405
2577
  const def = g.defaultValue !== undefined ? `, ${tsLiteral(g.defaultValue)}` : '';
2406
2578
  parts.push(`.var(${tsLiteral(g.name ?? g.id)}, ${declExpr(g, false, ctx.imp)}${def})`);
2407
2579
  }
2408
- // Body: the child's trigger is its own manual Start; walk from its successor.
2409
- const trigger = (child.nodes ?? []).find((n) => isTrigger(n.type) && !(child.edges ?? []).some((e) => e.targetNodeId === n.id));
2410
2580
  if (trigger?.type === T.triggerManual && hasRenamedLabel(trigger, SUBFLOW_START_LABEL)) {
2411
2581
  parts.push(`.trigger(${ctx.imp.use('manual')}(), { ${triggerOptParts(trigger, SUBFLOW_START_LABEL).join(', ')} })`);
2412
2582
  }
@@ -29,6 +29,7 @@
29
29
  */
30
30
  import { Expr, cookedEscapesOf } from './core/expr.js';
31
31
  import { checkExpression, cookedEscapeDiagnostics } from './core/expr-check.js';
32
+ import { stepDeclaredVariables } from './flow-step-variables.js';
32
33
  const NOUN = 'input, variable, or step';
33
34
  /**
34
35
  * Every `$vars` root a reference may legitimately resolve to, across the WHOLE
@@ -77,6 +78,10 @@ function addStepNames(steps, roots) {
77
78
  // they are edges, not nodes, so there is nothing to reference them by.
78
79
  if (s.kind !== 'return' && s.kind !== 'stepToRef' && s.kind !== 'stepToList' && s.kind !== 'break' && s.kind !== 'continue')
79
80
  roots.add(s.name);
81
+ // A step declares the variables its fields land in too (`check.ts` mirrors this, #1309).
82
+ if (s.kind === 'action' || s.kind === 'stepSwitch')
83
+ for (const v of stepDeclaredVariables(s.spec, s.options?.version))
84
+ roots.add(v);
80
85
  switch (s.kind) {
81
86
  case 'stepToList':
82
87
  addStepNames(s.body, roots);
@@ -441,6 +441,15 @@ export interface FlowNodeType<Spec> extends NodeType<Spec, FlowNodeContribution,
441
441
  * one of these (`.onError()` routes it).
442
442
  */
443
443
  exits?(spec: Spec, pinnedVersion?: string): DeclaredExit[] | NoExits;
444
+ /**
445
+ * The flow variables a step of this type DECLARES by running: names a later
446
+ * `$vars.<name>` read resolves to that are neither a `.var()` nor a step
447
+ * name. A quick form's asked field lands its answer in its `variable`
448
+ * (`vars.<Step>_<field>`, as the designer names it), so the task declares
449
+ * that variable. `check` adds them to its declared set (#1309). Omitted: the
450
+ * type declares none.
451
+ */
452
+ declares?(spec: Spec, pinnedVersion?: string): string[];
444
453
  /**
445
454
  * Whether `port` is a forward handle the PRODUCT derives for this spec, which
446
455
  * the definition does not list — a human task's `outcome-<id>` handles, drawn