@uipath/maestro-builder-sdk 6.0.0 → 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.
@@ -30,6 +30,8 @@ __all__ = [
30
30
  "ConnectionError_",
31
31
  "byoa_connections",
32
32
  "byoa_listing",
33
+ "candidate_connections",
34
+ "candidate_listing",
33
35
  "discover_connection",
34
36
  "lookup_connection",
35
37
  "merge_bindings",
@@ -130,6 +132,48 @@ def discover_connection(connector_key: str, name: str | None = None) -> dict:
130
132
  return rows[0]
131
133
 
132
134
 
135
+ def candidate_connections(connector_key: str, name: str | None = None) -> list[dict]:
136
+ """Every enabled connection for `connector_key`, own folder FIRST.
137
+
138
+ `discover_connection` answers "which one" and stops at the first non-empty
139
+ scope, which is right for picking a connection and wrong for retrying one:
140
+ when the own-folder listing holds exactly one, the tenant-wide connections
141
+ are never even looked at. So a lookup that exhausts the personal-workspace
142
+ connection reports a bad VALUE while the record sits in another folder's
143
+ connection (flow-builder-sdk#744, measured on two Jira connections where
144
+ only the second carried project `TS`).
145
+
146
+ Ordering is the same preference `discover_connection` encodes — the caller's
147
+ own folder is the likeliest and is tried first — but nothing is dropped.
148
+ Deduplicated by `Id`, because the tenant-wide listing repeats the own-folder
149
+ rows.
150
+
151
+ This does NOT decide anything, and in particular does not raise on several
152
+ matches: choosing among them is the caller's job, and the only honest way to
153
+ choose is evidence (a lookup that resolves on exactly one). That keeps this
154
+ module's rule intact — ambiguity is never resolved by guessing.
155
+ """
156
+ ordered: list[dict] = []
157
+ seen: set[str] = set()
158
+ for all_folders in (False, True):
159
+ for row in _matching(_list(all_folders=all_folders), connector_key, name):
160
+ identifier = str(row.get("Id", ""))
161
+ if identifier and identifier in seen:
162
+ continue
163
+ seen.add(identifier)
164
+ ordered.append(row)
165
+ return ordered
166
+
167
+
168
+ def candidate_listing(rows: list[dict], indent: str = " ") -> str:
169
+ """The candidates, one pasteable `--connection-id` line each."""
170
+ return "\n".join(
171
+ f"{indent}{row.get('Name')} --connection-id {row.get('Id')}"
172
+ f" (folder: {row.get('Folder')})"
173
+ for row in rows
174
+ )
175
+
176
+
133
177
  def _enabled_listing(rows: list[dict], connector_key: str) -> str:
134
178
  """The enabled connections for `connector_key`, one per line, pasteable."""
135
179
  enabled = _matching(rows, connector_key, None)
@@ -25,6 +25,7 @@ import subprocess
25
25
  import sys
26
26
 
27
27
  __all__ = [
28
+ "LookupExhausted",
28
29
  "ResolutionError",
29
30
  "parse_resolve_flag",
30
31
  "resolve_one",
@@ -55,6 +56,36 @@ class ResolutionError(Exception):
55
56
  """A lookup that could not be resolved, with the reason a human needs."""
56
57
 
57
58
 
59
+ class LookupExhausted(ResolutionError):
60
+ """The collection was read to the end on THIS connection and had no match.
61
+
62
+ Split out from its parent because the two failures want opposite responses
63
+ and the caller cannot tell them apart from a message. A transport or
64
+ permission failure says nothing about whether the value exists — retrying it
65
+ on another connection would hide a broken call behind a confident "no
66
+ connection has it". An exhaustive miss is positive evidence about this
67
+ connection specifically, and is the signal `prepare` uses to demote it and
68
+ try the next candidate (flow-builder-sdk#744).
69
+
70
+ `capped` flips that: a scan stopped at the record cap did NOT reach the end,
71
+ so it is not evidence of absence and must not demote anything.
72
+
73
+ The attributes carry the counts so a caller can report per-candidate results
74
+ without re-parsing the sentence.
75
+ """
76
+
77
+ def __init__(self, message: str, *, field: str, collection: str, by: str,
78
+ value: str, scanned: int, pages: int, capped: bool) -> None:
79
+ super().__init__(message)
80
+ self.field = field
81
+ self.collection = collection
82
+ self.by = by
83
+ self.value = value
84
+ self.scanned = scanned
85
+ self.pages = pages
86
+ self.capped = capped
87
+
88
+
58
89
  def parse_resolve_flag(raw: str) -> tuple[str, str, str]:
59
90
  """`channel:profile.email=dustin@example.com` -> (field, by, value).
60
91
 
@@ -261,13 +292,28 @@ def resolve_one(
261
292
  query = f"{query}&nextPage={token}" if query else f"nextPage={token}"
262
293
 
263
294
  capped = scanned >= MAX_SCANNED or pages > PAGE_GUARD
264
- raise ResolutionError(
295
+ # This states the FACT and stops; `prepare` owns the remedy, because
296
+ # `prepare` is what knows the other candidate connections.
297
+ #
298
+ # It deliberately carries NO "check the value" and NO `uip is resources`
299
+ # pointer. The old text led with both: it named only the value as suspect
300
+ # when the connection is equally suspect, and then sent the reader to a
301
+ # tenant crawl the skill explicitly rules out ("Tenant discovery is not a
302
+ # phase of either loop", references/CLI-LOOP.md). An agent followed that
303
+ # advice into exactly what it had been told not to do — 50 of 94 Bash calls
304
+ # hand-crawling Integration Service, 41 of them paging this collection, and
305
+ # the run hit its ceiling without producing a flow (flow-builder-sdk#744).
306
+ #
307
+ # The listing command is still offered, once, by the caller's terminal
308
+ # report — where every connection has been tried and seeing what IS there
309
+ # is finally the next step rather than a detour.
310
+ raise LookupExhausted(
265
311
  f"{field}: no record in {collection!r} has {by}={value!r} "
266
312
  f"(scanned {scanned} record(s) across {pages + 1} page(s)"
267
313
  + (f"; STOPPED AT THE {MAX_SCANNED}-record cap, so the value may be "
268
- f"further in" if capped else "")
269
- + ").\n"
270
- f"Check the value, or list the collection to see what is there:\n"
271
- f" uip is resources run list {connector_key} {collection} "
272
- f"--connection-id {connection_id}"
314
+ f"further in" if capped
315
+ else "; collection exhausted")
316
+ + f") on connection {connection_id}.",
317
+ field=field, collection=collection, by=by, value=value,
318
+ scanned=scanned, pages=pages + 1, capped=capped,
273
319
  )
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.0.0",
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": "8c327b39940d34432128dd713c731027bb00fbde"
90
+ "gitref": "21bfc3d02150e62bfca808d6dfdbf8b4c320870f"
91
91
  }