@uipath/maestro-builder-sdk 5.4.1 → 6.0.1

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.
@@ -382,6 +382,12 @@ def main() -> None:
382
382
  # step that removes 4-7 tool calls per task: listing connections, filtering
383
383
  # them, and then hunting Orchestrator for a folder key that was on the
384
384
  # connection record all along.
385
+ # Whether the CALLER chose the connection, recorded before discovery
386
+ # overwrites `connection_id` with its own answer. An explicit choice is never
387
+ # second-guessed: the retry in `_write_resolutions` is for a connection this
388
+ # command picked, not for one the author named (flow-builder-sdk#744).
389
+ args._connection_pinned = bool(args.connection_id or args.connection)
390
+
385
391
  if not args.connection_id:
386
392
  try:
387
393
  found = _connections.discover_connection(args.key, args.connection)
@@ -1125,6 +1131,14 @@ def _write_resolutions(args, node_type: str) -> None:
1125
1131
  args.key, specs[field], field, by, value,
1126
1132
  args.connection_id, resolved_values,
1127
1133
  )
1134
+ except _resolve.LookupExhausted as error:
1135
+ # The collection was read to the END on this connection and the
1136
+ # value is not in it. That is evidence about the CONNECTION, not
1137
+ # only about the value — so before failing, try the other enabled
1138
+ # connections for this connector. A transport failure is the
1139
+ # sibling exception below and is never retried: retrying it would
1140
+ # dress a broken call up as "no connection has it".
1141
+ _retry_or_exit(args, node_type, specs, ordered, error)
1128
1142
  except _resolve.ResolutionError as error:
1129
1143
  sys.exit(f"prepare-connector: {error}")
1130
1144
  resolved_values[field] = got
@@ -1143,6 +1157,164 @@ def _write_resolutions(args, node_type: str) -> None:
1143
1157
  print(f" wrote {len(existing)} resolution(s) to {path}")
1144
1158
 
1145
1159
 
1160
+ def _candidate_label(row: dict) -> str:
1161
+ """`name (folder)` for a per-candidate report line."""
1162
+ return f"{row.get('Name')} ({row.get('Folder')})"
1163
+
1164
+
1165
+ def _probe_candidate(args, specs, ordered, connection_id: str):
1166
+ """Resolve every requested lookup against one connection.
1167
+
1168
+ Returns `None` when all of them resolve, or the first reason they did not:
1169
+ `("exhausted", <LookupExhausted>)` or `("error", <ResolutionError>)`. The
1170
+ two are kept apart all the way up, because only the first is evidence about
1171
+ the connection.
1172
+ """
1173
+ resolved: dict[str, str] = {}
1174
+ for field, by, value in ordered:
1175
+ try:
1176
+ resolved[field] = _resolve.resolve_one(
1177
+ args.key, specs[field], field, by, value, connection_id, resolved,
1178
+ )
1179
+ except _resolve.LookupExhausted as error:
1180
+ return ("exhausted", error)
1181
+ except _resolve.ResolutionError as error:
1182
+ return ("error", error)
1183
+ return None
1184
+
1185
+
1186
+ def _retry_or_exit(args, node_type: str, specs, ordered, first) -> None:
1187
+ """Try the other candidate connections, or exit explaining what was tried.
1188
+
1189
+ NEVER RETURNS. Either it re-execs this command pinned to a connection that
1190
+ resolves everything, or it exits with a report.
1191
+ """
1192
+ if args._connection_pinned:
1193
+ # An explicit --connection-id / --connection is the author's decision and
1194
+ # is not overridden. The alternatives are still named, because the whole
1195
+ # cost of this bug was not knowing they existed.
1196
+ others = [
1197
+ row for row in _connections.candidate_connections(args.key)
1198
+ if str(row.get("Id")) != str(args.connection_id)
1199
+ ]
1200
+ extra = ""
1201
+ if others:
1202
+ extra = (
1203
+ f"\n{len(others)} other enabled {args.key} connection"
1204
+ f"{'s' if len(others) > 1 else ''} exist"
1205
+ f"{'' if len(others) > 1 else 's'}:\n"
1206
+ f"{_connections.candidate_listing(others)}\n"
1207
+ "Re-run against one of those, or check the value. This run kept the "
1208
+ "connection you named."
1209
+ )
1210
+ sys.exit(f"prepare-connector: {first}{extra}")
1211
+
1212
+ try:
1213
+ candidates = _connections.candidate_connections(args.key, args.connection)
1214
+ except _connections.ConnectionError_ as error:
1215
+ # The listing itself failed; report the original lookup failure, which is
1216
+ # the thing the author asked about.
1217
+ sys.exit(f"prepare-connector: {first}\n(could not list other connections: {error})")
1218
+
1219
+ others = [row for row in candidates
1220
+ if str(row.get("Id")) != str(args.connection_id)]
1221
+ if not others:
1222
+ sys.exit(
1223
+ f"prepare-connector: {first}\n"
1224
+ f"This is the only enabled {args.key} connection on the tenant, so the "
1225
+ "value is what to check."
1226
+ )
1227
+
1228
+ print(f" {first.field}: not on this connection; trying "
1229
+ f"{len(others)} other {args.key} connection(s)")
1230
+ winners: list[dict] = []
1231
+ report: list[str] = []
1232
+ for row in others:
1233
+ outcome = _probe_candidate(args, specs, ordered, str(row.get("Id")))
1234
+ if outcome is None:
1235
+ winners.append(row)
1236
+ report.append(f" {_candidate_label(row)} resolves all "
1237
+ f"{len(ordered)} lookup(s)")
1238
+ continue
1239
+ kind, error = outcome
1240
+ if kind == "exhausted":
1241
+ report.append(
1242
+ f" {_candidate_label(row)} {error.field}: "
1243
+ f"{error.scanned} record(s) scanned, no match")
1244
+ else:
1245
+ report.append(f" {_candidate_label(row)} {error}")
1246
+
1247
+ if len(winners) == 1:
1248
+ chosen = winners[0]
1249
+ # Undo the bindings this run wrote for the connection that cannot serve
1250
+ # the lookups, then start over pinned to the one that can. Re-running
1251
+ # rather than switching in place is deliberate: the overlay and its
1252
+ # lookup specs are described THROUGH a connection
1253
+ # (`_lookup_specs_from_overlay`), so adopting a different connection
1254
+ # after the describe would leave the overlay describing one connection
1255
+ # while bindings.json names another.
1256
+ _restore_bindings(args)
1257
+ print(f" connection: {_candidate_label(chosen)} resolves "
1258
+ f"{first.field}; re-running against it "
1259
+ f"(--connection-id {chosen.get('Id')})")
1260
+ argv = [sys.executable, *sys.argv,
1261
+ "--connection-id", str(chosen.get("Id"))]
1262
+ # The ledger keys a resolution by [nodeType, field, by, value] and NOT by
1263
+ # connection, so an id recorded against the connection just abandoned
1264
+ # would be reused as "already resolved". Force the re-run to resolve
1265
+ # against the connection it actually pins.
1266
+ if not args.refresh_lookups:
1267
+ argv.append("--refresh-lookups")
1268
+ sys.stdout.flush()
1269
+ try:
1270
+ os.execv(sys.executable, argv)
1271
+ except OSError as error:
1272
+ # `execv` does not return on success, so reaching here means the
1273
+ # re-run could not start. Hand back the command rather than a
1274
+ # traceback: the author is one paste from the answer.
1275
+ pinned = " ".join(argv[2:])
1276
+ sys.exit(
1277
+ f"prepare-connector: {first}\n"
1278
+ f"{_candidate_label(chosen)} resolves it, but this command could "
1279
+ f"not re-run itself ({error}). Run:\n prepare {pinned}"
1280
+ )
1281
+ # Unreachable in production — execv replaced the process. Kept so the
1282
+ # function can never fall through to the report below, which would
1283
+ # contradict the line just printed.
1284
+ raise SystemExit(0)
1285
+
1286
+ if len(winners) > 1:
1287
+ sys.exit(
1288
+ f"prepare-connector: {len(winners)} {args.key} connections resolve "
1289
+ f"every requested lookup, and they are not interchangeable — the ids "
1290
+ f"they return differ per connection. Pick the one you mean:\n"
1291
+ f"{_connections.candidate_listing(winners)}"
1292
+ )
1293
+
1294
+ # Nothing resolved anywhere. Report every candidate and what it held, the
1295
+ # one connection first, so the reader can see the value really is absent
1296
+ # rather than guess which connection was asked.
1297
+ original = next(
1298
+ (row for row in candidates
1299
+ if str(row.get("Id")) == str(args.connection_id)),
1300
+ {"Name": args.connection_id, "Folder": "?"},
1301
+ )
1302
+ lines = [f" {_candidate_label(original)} {first.field}: "
1303
+ f"{first.scanned} record(s) scanned, no match", *report]
1304
+ # HERE is where the collection listing finally belongs: every connection has
1305
+ # been tried, so seeing what the collection actually holds is the next step
1306
+ # rather than the detour it is while a retry is still available.
1307
+ sys.exit(
1308
+ f"prepare-connector: no {args.key} connection could resolve "
1309
+ f"{first.field}: {first.by}={first.value!r}\n"
1310
+ + "\n".join(lines) + "\n"
1311
+ "Every candidate was read to the end, so the value is what to check now. "
1312
+ f"To see what {first.collection!r} holds:\n"
1313
+ f" uip is resources run list {args.key} {first.collection} "
1314
+ f"--connection-id {args.connection_id}"
1315
+ )
1316
+
1317
+
1146
1318
  def _lookup_specs_from_overlay(
1147
1319
  out_dir: str, node_type: str, object_name: str | None = None,
1148
1320
  ) -> dict:
package/dist/serialize.js CHANGED
@@ -14,6 +14,8 @@ import { Expr, toExpr, SCHEDULE_PRESETS, DELAY_PRESETS, parseIxpProjectType, fla
14
14
  import { FlowAction, FlowTrigger } from './core/node-classes.js';
15
15
  import { connectorRawNodeRefusal } from './core/connector-raw-node.js';
16
16
  import { bindingSelfNameMessage } from './core/binding-messages.js';
17
+ import { hitlRoutesPerOutcome } from './core/hitl-routing.js';
18
+ import { declaredExits, hasExits } from './core/step-ports.js';
17
19
  import { buildConfiguration, transportHttpMethod } from './config.js';
18
20
  import { stableId } from './core/stable-id.js';
19
21
  import { readEventFilter, eventFilterProblem, eventFilterJmes, eventFilterTreeLeaf } from './event-filters.js';
@@ -38,6 +40,7 @@ function bodyUsesBreak(steps) {
38
40
  return steps.some((s) => s.kind === 'break'
39
41
  || (s.kind === 'branch' && (bodyUsesBreak(s.then) || bodyUsesBreak(s.otherwise)))
40
42
  || (s.kind === 'switch' && (s.cases.some((c) => bodyUsesBreak(c.body)) || (s.default ? bodyUsesBreak(s.default) : false)))
43
+ || (s.kind === 'stepSwitch' && s.cases.some((c) => bodyUsesBreak(c.body)))
41
44
  || (s.kind === 'parallel' && s.arms.some(bodyUsesBreak))
42
45
  || (s.kind === 'stepToList' && bodyUsesBreak(s.body)));
43
46
  }
@@ -3676,6 +3679,8 @@ function stepNamesOf(steps, out = []) {
3676
3679
  s.cases.forEach((c) => stepNamesOf(c.body, out));
3677
3680
  stepNamesOf(s.default ?? [], out);
3678
3681
  }
3682
+ else if (s.kind === 'stepSwitch')
3683
+ s.cases.forEach((c) => stepNamesOf(c.body, out));
3679
3684
  else if (s.kind === 'parallel')
3680
3685
  s.arms.forEach((a) => stepNamesOf(a, out));
3681
3686
  else if (s.kind === 'loop')
@@ -4982,18 +4987,24 @@ export function serialize(built, opts = {}) {
4982
4987
  const id = stepUid(scope, step.name);
4983
4988
  const hitlType = hitlNodeType(spec.inputs);
4984
4989
  // Rich options select a newer definition, same rule as the scheduled
4985
- // trigger's cron: `outcomePorts` needs 1.1 (per-outcome `outcome-<id>`
4986
- // exits) and `exposeError` needs 1.2 (adds the `error` output; its exits
4987
- // are the same per-outcome handles). Plain tasks stay on the pinned
4988
- // default, byte-identically.
4989
- const outcomeRouted = spec.inputs?.outcomePorts === true || spec.inputs?.exposeError === true;
4990
+ // trigger's cron: per-outcome routing needs 1.1 (per-outcome
4991
+ // `outcome-<id>` exits) and `exposeError` needs 1.2 (adds the `error`
4992
+ // output; its exits are the same per-outcome handles). A zero- or
4993
+ // one-outcome task stays on the pinned default, byte-identically.
4994
+ //
4995
+ // More than one outcome INFERS the routing — see `hitlRoutesPerOutcome`
4996
+ // for the four things that turn that off, one of which is an explicit
4997
+ // `{ version }`, which is what keeps decompile→compile exact.
4998
+ const pinned = step.options?.version;
4999
+ const outcomeRouted = hitlRoutesPerOutcome(spec.inputs, pinned);
4990
5000
  const neededVersion = spec.inputs?.exposeError ? '1.2' : outcomeRouted ? '1.1' : undefined;
4991
5001
  if (neededVersion !== undefined && hitlType !== NODE_TYPE.hitl) {
4992
5002
  throw new Error(`"${step.name}": outcomePorts/exposeError need the base human task's ${neededVersion} `
4993
5003
  + `definition — the '${spec.inputs?.variant}' variant has no per-outcome version. `
4994
5004
  + `Drop the variant, or route on out('${step.name}', 'Action') downstream.`);
4995
5005
  }
4996
- const pinned = step.options?.version;
5006
+ // Both refusals below can only be reached by an EXPLICIT flag: the
5007
+ // inference already declines on a variant and on a pinned version.
4997
5008
  if (neededVersion !== undefined && pinned !== undefined && pinned !== neededVersion) {
4998
5009
  throw new Error(`"${step.name}": ${spec.inputs?.exposeError ? 'exposeError' : 'outcomePorts'} needs the task's ${neededVersion} `
4999
5010
  + `definition, but { version: '${pinned}' } pins it. Drop the pin or match it.`);
@@ -5307,6 +5318,72 @@ export function serialize(built, opts = {}) {
5307
5318
  else if (step.kind === 'stepToList') {
5308
5319
  tails = emitPortList(step, lastAction ?? '?', tails, scope, parentId, breakTarget);
5309
5320
  }
5321
+ else if (step.kind === 'stepSwitch') {
5322
+ // The action, then EVERY exit it declares as an arm — no tacit tail.
5323
+ //
5324
+ // The exits are resolved BEFORE the node is emitted, because a spec with
5325
+ // no routable fan-out (a human task pinned to 1.0, a variant, an http
5326
+ // with no branches) must refuse rather than emit a node and then
5327
+ // discover it has one port. `declaredExits` carries the reason.
5328
+ const exits = declaredExits(step.spec, step.options?.version);
5329
+ if (!hasExits(exits)) {
5330
+ throw new Error(`.stepSwitch("${step.name}"): ${exits.reason}, so there are no exits to route.`
5331
+ + (exits.suggestion ? ` ${exits.suggestion}` : ''));
5332
+ }
5333
+ const action = {
5334
+ kind: 'action', name: step.name, spec: step.spec,
5335
+ ...(step.options ? { options: step.options } : {}),
5336
+ };
5337
+ // The tail `emitAction` hands back is the family's tacit exit — the
5338
+ // primary outcome, http's `default`. Discarded on purpose: every exit is
5339
+ // an arm here, so that port is wired by the arm that names it (and
5340
+ // wiring both is flow-builder-sdk#741).
5341
+ emitAction(action, tails, scope, parentId);
5342
+ applyUpdates(action);
5343
+ applyLabel(action);
5344
+ const nodeId = scope.stepNodeId.get(step.name);
5345
+ if (!nodeId)
5346
+ throw new Error(`.stepSwitch("${step.name}"): emitted node not found.`);
5347
+ const armTails = [];
5348
+ const routed = new Set();
5349
+ for (const c of step.cases) {
5350
+ const exit = exits.find((e) => e.value === c.value);
5351
+ if (!exit) {
5352
+ throw new Error(`.stepSwitch("${step.name}"): no exit named ${JSON.stringify(c.value)}. `
5353
+ + `It declares ${exits.map((e) => JSON.stringify(e.value)).join(', ')}.`);
5354
+ }
5355
+ routed.add(exit.port);
5356
+ // Arms CONVERGE, exactly as `.switch()`'s do: every arm that does not
5357
+ // end in `.return()` contributes its tail, so the next step fans in
5358
+ // from all of them. (`.stepToList` instead gives each arm a private
5359
+ // End, which silently drops the flow's outputs on that path —
5360
+ // flow-builder-sdk#742.)
5361
+ armTails.push(...emit(c.body, [{ node: nodeId, port: exit.port }], scope, parentId, breakTarget));
5362
+ }
5363
+ // A declared exit with no arm gets an End, so the run FINISHES there
5364
+ // rather than reaching a port with no edge and stalling. `check` warns
5365
+ // (STEP_SWITCH_EXIT_UNROUTED) — this is the compile-side half of it.
5366
+ //
5367
+ // `action: 'End'` outcomes get one TOO, and that is deliberate: the
5368
+ // designer draws a handle for every entry in `inputs.schema.outcomes`
5369
+ // whatever its action, so skipping them left a drawn handle wired to
5370
+ // nothing. UiPath/skills' own gate says the same and is stricter than
5371
+ // this was — `assert_outcome_wiring` requires an edge per outcome with
5372
+ // no exemption, and its convention makes every non-primary outcome an
5373
+ // `End`. What the action changes is the WARNING, not the edge: nobody
5374
+ // needs to be told to write an arm for an outcome that ends the run.
5375
+ for (const e of exits) {
5376
+ if (routed.has(e.port))
5377
+ continue;
5378
+ const endId = uid(scope, `${step.name}${e.port.charAt(0).toUpperCase()}${e.port.slice(1)}End`);
5379
+ const end = makeNode(NODE_TYPE.end, endId, 'End', parentId ? { parentId } : {});
5380
+ end.inputs = {};
5381
+ scope.nodes.push(end);
5382
+ addEdge(scope, { node: nodeId, port: e.port }, endId);
5383
+ }
5384
+ tails = armTails;
5385
+ lastAction = step.name;
5386
+ }
5310
5387
  else if (step.kind === 'branch') {
5311
5388
  const id = stepUid(scope, step.name);
5312
5389
  const node = makeNode(NODE_TYPE.decision, id, step.options?.label ?? step.label ?? step.name, { parentId, requestedVersion: step.options?.version });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "5.4.1",
3
+ "version": "6.0.1",
4
4
  "description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.uipath.com/maestro",
@@ -87,5 +87,5 @@
87
87
  "@types/node": "^22.7.0",
88
88
  "esbuild": "^0.28.1"
89
89
  },
90
- "gitref": "b5437630666554168202df6a5fbfe43ee72c43d0"
90
+ "gitref": "21bfc3d02150e62bfca808d6dfdbf8b4c320870f"
91
91
  }