@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.
@@ -0,0 +1,102 @@
1
+ /**
2
+ * The exits an action declares that the AUTHOR named — a human task's outcomes,
3
+ * an HTTP call's response branches — and the port each one leaves on.
4
+ *
5
+ * This is what `.stepSwitch()` routes over, and the reason it exists as a table
6
+ * rather than as two more `if (spec.kind === …)` arms: `serialize` has to turn
7
+ * an arm's `value` into a port, `check` has to say which values are legal and
8
+ * which were left out, and `decompile` has to turn a port back into a value.
9
+ * Those three read the same list here instead of each carrying their own copy
10
+ * of "how a human task names its ports" and "how an HTTP branch names its
11
+ * ports".
12
+ *
13
+ * `undefined` means the family has no author-declared fan-out at all, which is
14
+ * what makes `.stepSwitch()` on it an error rather than a no-op.
15
+ *
16
+ * The `error` port is deliberately NOT here. It is declared by the definition
17
+ * rather than by the author, it exists on families that have no other fan-out,
18
+ * and it is already routed by `.onError()` / `.stepToList('error', …)`. Folding
19
+ * it in would make every action a `.stepSwitch` candidate and would put failure
20
+ * handling and outcome routing in one list, where an omitted arm would mean two
21
+ * different things.
22
+ */
23
+ import { hitlRoutesPerOutcome, outcomeSlug } from './hitl-routing.js';
24
+ /**
25
+ * The author-declared exits of `spec`, or a `NoExits` explaining why there are
26
+ * none to route.
27
+ */
28
+ export function declaredExits(spec, pinnedVersion) {
29
+ const kind = spec && 'kind' in spec ? spec.kind : undefined;
30
+ if (kind === 'hitl') {
31
+ const inputs = spec?.inputs;
32
+ const outcomes = (inputs?.outcomes ?? []);
33
+ if (outcomes.length === 0) {
34
+ return { reason: 'it declares no outcomes', suggestion: `outcomes: ['Approve', 'Reject']` };
35
+ }
36
+ if (!hitlRoutesPerOutcome(inputs, pinnedVersion)) {
37
+ // WHY it kept the single exit decides what the author can do about it, so
38
+ // the caller gets the specific one rather than "ports are off".
39
+ if (inputs?.outcomePorts === false) {
40
+ return {
41
+ reason: 'outcomePorts: false keeps its single `completed` exit',
42
+ suggestion: 'Drop `outcomePorts: false`.',
43
+ };
44
+ }
45
+ if (inputs?.variant !== undefined) {
46
+ return {
47
+ reason: `the '${inputs.variant}' variant has no per-outcome definition version to select`,
48
+ suggestion: `Drop the variant, or use .step() and switch on out('<step>', 'Action').`,
49
+ };
50
+ }
51
+ if (pinnedVersion !== undefined) {
52
+ return {
53
+ reason: `{ version: '${pinnedVersion}' } pins the definition whose only exit is 'completed'`,
54
+ suggestion: 'Drop the pin.',
55
+ };
56
+ }
57
+ if (outcomes.length === 1) {
58
+ return {
59
+ reason: 'a single outcome has a single exit, so there is nothing to route',
60
+ suggestion: 'Use .step() — the next step follows it unconditionally.',
61
+ };
62
+ }
63
+ return {
64
+ reason: 'every outcome ends the process, so no exit distinguishes them',
65
+ suggestion: 'Use .step().',
66
+ };
67
+ }
68
+ return outcomes.map((o, i) => {
69
+ const name = (typeof o === 'string' ? o : o?.name) ?? '';
70
+ return {
71
+ value: String(name),
72
+ port: `outcome-${outcomeSlug(name, i)}`,
73
+ ...(typeof o !== 'string' && o?.action === 'End' ? { endsProcess: true } : {}),
74
+ };
75
+ });
76
+ }
77
+ if (kind === 'http') {
78
+ const branches = (spec?.inputs?.branches
79
+ ?? []);
80
+ if (branches.length === 0) {
81
+ return {
82
+ reason: 'it declares no response branches',
83
+ suggestion: `branches: [{ name: 'rateLimited', condition: … }]`,
84
+ };
85
+ }
86
+ // The fall-through is an exit like any other here — it is what `.switch()`
87
+ // spells as its default arm, and leaving it out is the same omission as
88
+ // leaving out a branch.
89
+ return [
90
+ ...branches.map((b) => ({ value: String(b?.name ?? ''), port: `branch-${b?.name ?? ''}` })),
91
+ { value: 'default', port: 'default', isDefault: true },
92
+ ];
93
+ }
94
+ return {
95
+ reason: `a ${kind ?? 'custom'} step declares no author-named exits`,
96
+ suggestion: 'Use .step(); route failures with .onError().',
97
+ };
98
+ }
99
+ /** Narrowing helper — `declaredExits` returns one or the other. */
100
+ export function hasExits(r) {
101
+ return Array.isArray(r);
102
+ }
package/dist/decompile.js CHANGED
@@ -408,16 +408,39 @@ class Graph {
408
408
  const first = outcomes[0]?.id;
409
409
  return typeof first === 'string' && first !== '' ? `outcome-${first}` : undefined;
410
410
  }
411
+ /**
412
+ * Every declared exit of `id`, when ALL of them are wired — the `.stepSwitch`
413
+ * shape. `undefined` otherwise, which leaves the node with a tacit main path
414
+ * and its siblings as side arms.
415
+ */
416
+ fullyWiredExits(id) {
417
+ const node = this.byId.get(id);
418
+ const declared = node ? declaredExitsOfNode(node) : undefined;
419
+ if (declared === undefined || declared.length < 2)
420
+ return undefined;
421
+ const wired = new Set(this.outEdges(id).map((e) => e.sourcePort));
422
+ if (!declared.every((d) => wired.has(d.port)))
423
+ return undefined;
424
+ return new Set(declared.map((d) => d.port));
425
+ }
411
426
  /**
412
427
  * Out-edges excluding the side paths recovered as port lists: `error`,
413
428
  * `branch-*`, and every `outcome-*` except the primary one.
429
+ *
430
+ * UNLESS every declared exit is wired. Then the node genuinely FORKS — there
431
+ * is no main path, each arm is a successor — and the post-dominator has to see
432
+ * that or it computes the join as the primary arm's first node. Which it did:
433
+ * a converged two-outcome task decompiled with one arm empty and the other
434
+ * swallowing the tail, because `ipdom(review)` came back as `publish`.
414
435
  */
415
436
  successorEdges(id) {
416
437
  const primary = this.primaryOutcomePort(id);
438
+ const forked = this.fullyWiredExits(id);
417
439
  return this.outEdges(id).filter((e) => e.sourcePort !== 'error'
418
440
  && !ARTIFACT_PORTS.has(e.sourcePort)
419
- && !e.sourcePort.startsWith('branch-')
420
- && (!e.sourcePort.startsWith('outcome-') || e.sourcePort === primary));
441
+ && (forked?.has(e.sourcePort)
442
+ || (!e.sourcePort.startsWith('branch-')
443
+ && (!e.sourcePort.startsWith('outcome-') || e.sourcePort === primary))));
421
444
  }
422
445
  }
423
446
  // ─── Node → step-spec source ──────────────────────────────────────────────────
@@ -1952,6 +1975,25 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
1952
1975
  }
1953
1976
  // A regular action step (mock / script / http / transform / connector / …).
1954
1977
  const spec = emitStepSpec(node, ctx.imp, ctx.inputNames, ctx.o, ctx.graph);
1978
+ // `.stepSwitch` when the node's author-declared exits are ALL wired and the
1979
+ // arms rejoin: that graph has no tacit main path to recover, so the
1980
+ // `.step` + `.stepToList` form below cannot express it. It used to come back
1981
+ // as `.stepToRef("end")` — a reference to the synthetic End node, which
1982
+ // `compile` then refused (REF_UNKNOWN_TARGET), so a converged port graph did
1983
+ // not round-trip at all. Reachable from the designer long before
1984
+ // `.stepSwitch` existed; the arms just had nothing to decompile INTO.
1985
+ const exitPorts = portArmExits(node, ctx);
1986
+ if (exitPorts && exitPorts.length > 1) {
1987
+ const r = ipdom.get(node.id) ?? stop;
1988
+ const armSrcs = exitPorts.map(({ value, port }) => {
1989
+ const armSegs = region(portTarget(ctx.graph, node.id, port), r, scope, ipdom, ctx, refInto);
1990
+ return `{ value: ${str(value)}, body: ${armCb('b', armSegs)} }`;
1991
+ });
1992
+ const armList = `[\n${armSrcs.map((a) => reindent(a, ' ')).join(',\n')},\n]`;
1993
+ segs.push(`.stepSwitch(${str(node.id)}, ${spec}, ${armList}${optsArg(node, ctx)})`);
1994
+ cursor = r === EXIT ? undefined : r;
1995
+ continue;
1996
+ }
1955
1997
  segs.push(`.step(${str(node.id)}, ${spec}${optsArg(node, ctx)})`);
1956
1998
  // An error handler on this step (source port `error` + `errorHandlingEnabled`).
1957
1999
  const succ = scopeSucc(ctx.graph, node.id, scope);
@@ -2037,6 +2079,53 @@ function forwardReach(entries, scope, graph) {
2037
2079
  }
2038
2080
  return seen;
2039
2081
  }
2082
+ /**
2083
+ * The node's author-declared exits when EVERY one of them is wired — the shape
2084
+ * `.stepSwitch` authors. `undefined` when the node has no such exits, or when
2085
+ * one is unwired, in which case the `.step` + `.stepToList` recovery still
2086
+ * applies and the unwired port stays unwired.
2087
+ *
2088
+ * Deliberately strict about "every one": a partially wired node still has a
2089
+ * tacit main path, and recovering THAT as `.stepSwitch` would invent arms the
2090
+ * flow does not have.
2091
+ */
2092
+ function portArmExits(node, ctx) {
2093
+ // One predicate, on the Graph, because `successorEdges` has to agree with this
2094
+ // exactly: if the CFG forks here the arms must be emitted as arms, and if it
2095
+ // does not they must not be.
2096
+ if (ctx.graph.fullyWiredExits(node.id) === undefined)
2097
+ return undefined;
2098
+ return declaredExitsOfNode(node);
2099
+ }
2100
+ /**
2101
+ * The exits a node declares, read back off the EMITTED artifact rather than off
2102
+ * an authored spec — `inputs.schema.outcomes` for a human task at 1.1/1.2,
2103
+ * `inputs.branches` plus `default` for an HTTP call. The authoring-side twin is
2104
+ * `declaredExits` in `core/step-ports.ts`; they answer about different inputs,
2105
+ * which is why this is not a call into it.
2106
+ */
2107
+ function declaredExitsOfNode(node) {
2108
+ const inputs = (node.inputs ?? {});
2109
+ if (String(node.type).startsWith('uipath.human-in-the-loop')) {
2110
+ const version = String(node.typeVersion ?? '');
2111
+ if (version !== '1.1' && version !== '1.2')
2112
+ return undefined;
2113
+ const outcomes = (inputs.schema?.outcomes ?? []);
2114
+ if (outcomes.length === 0)
2115
+ return undefined;
2116
+ return outcomes.map((o) => ({ value: String(o?.name ?? ''), port: `outcome-${o?.id ?? ''}` }));
2117
+ }
2118
+ if (node.type === T.http || node.type === T.httpV2) {
2119
+ const branches = (inputs.branches ?? []);
2120
+ if (branches.length === 0)
2121
+ return undefined;
2122
+ return [
2123
+ ...branches.map((b) => ({ value: String(b?.name ?? ''), port: `branch-${b?.name ?? ''}` })),
2124
+ { value: 'default', port: 'default' },
2125
+ ];
2126
+ }
2127
+ return undefined;
2128
+ }
2040
2129
  /**
2041
2130
  * Emit a `.switch(...)` from a `core.logic.switch` node. Each case's serialized
2042
2131
  * expression is `<discriminant> === <JSON-literal>`; the discriminant (shared by
@@ -85,6 +85,10 @@ function addStepNames(steps, roots) {
85
85
  if (s.default)
86
86
  addStepNames(s.default, roots);
87
87
  break;
88
+ case 'stepSwitch':
89
+ for (const c of s.cases)
90
+ addStepNames(c.body, roots);
91
+ break;
88
92
  case 'loop':
89
93
  addStepNames(s.body, roots);
90
94
  break;
@@ -145,6 +149,13 @@ function walk(steps, roots, out) {
145
149
  walk(s.default, caseRoots, out);
146
150
  break;
147
151
  }
152
+ case 'stepSwitch':
153
+ // The action's own inputs, then each arm — no discriminant to bind, the
154
+ // exits are named by the step rather than compared against a value.
155
+ deepExprs(s.spec.inputs, s.name, roots, out);
156
+ for (const c of s.cases)
157
+ walk(c.body, roots, out);
158
+ break;
148
159
  case 'loop':
149
160
  pushExpr(s.collection.js, s.collection.literal, s.name, roots, out);
150
161
  if (s.options?.completionCondition) {
@@ -292,6 +292,19 @@ export interface SwitchArm {
292
292
  label?: string;
293
293
  body: Step[];
294
294
  }
295
+ /**
296
+ * One arm of a built `.stepSwitch` — the same shape as {@link SwitchArm}, but
297
+ * `value` names an exit the ACTION declares (a human-task outcome, an HTTP
298
+ * response branch) rather than a value to compare against. No `label`: a port
299
+ * edge carries none, and the exit's own name is already the label the canvas
300
+ * draws.
301
+ */
302
+ export interface PortArm {
303
+ /** The exit this arm routes — a human-task outcome name, an HTTP branch name, or `'default'`. */
304
+ value: string;
305
+ /** The steps that run on that exit. Ends in `.return()` to be terminal; otherwise it converges. */
306
+ body: Step[];
307
+ }
295
308
  /**
296
309
  * Options shared by every builder method that creates a definition-backed node:
297
310
  * `version` selects the exact node definition to compile against, and `updates`
@@ -425,6 +438,12 @@ export type Step = {
425
438
  cases: SwitchArm[];
426
439
  default?: Step[];
427
440
  options?: NodeOptions;
441
+ } | {
442
+ kind: 'stepSwitch';
443
+ name: string;
444
+ spec: FlowActionSpec;
445
+ cases: PortArm[];
446
+ options?: NodeOptions;
428
447
  } | {
429
448
  kind: 'loop';
430
449
  name: string;
@@ -693,6 +712,48 @@ declare class StepList {
693
712
  * @returns This builder, so calls chain.
694
713
  */
695
714
  step(name: string, spec: FlowActionSpec | FlowAction, options?: NodeOptions): this;
715
+ /**
716
+ * Add an action node and route EVERY exit it declares, one arm per exit.
717
+ *
718
+ * The symmetric form of `.step()` + `.stepToList()`. Where those make the
719
+ * first exit the tacit next step and the rest side branches, this makes all
720
+ * of them arms of one construct — so nothing about a human task's routing
721
+ * depends on which outcome happens to be listed first.
722
+ *
723
+ * Arms behave exactly like `.switch()`'s: one that ends in `.return()` is
724
+ * terminal, and one that does not CONVERGES, so the step after the
725
+ * `.stepSwitch` fans in from every arm that reaches it. (That is the
726
+ * difference from `.stepToList`, whose arms get an End of their own.)
727
+ *
728
+ * `value` names an exit the ACTION declares — a human task's outcome name, an
729
+ * HTTP response branch's name, or `'default'` for HTTP's fall-through — not a
730
+ * value to compare against. `check` refuses a value the step does not declare
731
+ * and warns about a declared exit left out, which compiles to an End node.
732
+ *
733
+ * @param name - The step's id, as `.step()`.
734
+ * @param spec - What the node does, from an action factory.
735
+ * @param cases - One arm per declared exit.
736
+ * @param options - Node options, as `.step()`.
737
+ * @returns This builder, so calls chain.
738
+ *
739
+ * @example
740
+ * **Route a human task's outcomes, and converge on one return**
741
+ * ```ts
742
+ * export default flow('draft-review')
743
+ * .output({ summary: types.string })
744
+ * .var('summary', types.string)
745
+ * .stepSwitch('review', hitl({ fields: [], outcomes: ['Publish', 'Revise'] }), [
746
+ * { value: 'Publish', body: (b) => b.step('publish', script({ code: 'return "published";', returns: 'string' }), { updates: { summary: out('publish') } }) },
747
+ * { value: 'Revise', body: (b) => b.step('sendBack', script({ code: 'return "revise";', returns: 'string' }), { updates: { summary: out('sendBack') } }) },
748
+ * ])
749
+ * .return({ summary: v('summary') })
750
+ * .build();
751
+ * ```
752
+ */
753
+ stepSwitch(name: string, spec: FlowActionSpec | FlowAction, cases: {
754
+ value: string;
755
+ body: (b: ArmBuilder) => void;
756
+ }[], options?: NodeOptions): this;
696
757
  /**
697
758
  * Handle the PREVIOUS step's failure: if it fails, the flow runs `bodyFn`'s
698
759
  * steps instead of continuing.
package/dist/flow-sdk.js CHANGED
@@ -303,6 +303,67 @@ class StepList {
303
303
  this.steps.push({ kind: 'action', name, spec: spec, ...(options ? { options } : {}) });
304
304
  return this;
305
305
  }
306
+ /**
307
+ * Add an action node and route EVERY exit it declares, one arm per exit.
308
+ *
309
+ * The symmetric form of `.step()` + `.stepToList()`. Where those make the
310
+ * first exit the tacit next step and the rest side branches, this makes all
311
+ * of them arms of one construct — so nothing about a human task's routing
312
+ * depends on which outcome happens to be listed first.
313
+ *
314
+ * Arms behave exactly like `.switch()`'s: one that ends in `.return()` is
315
+ * terminal, and one that does not CONVERGES, so the step after the
316
+ * `.stepSwitch` fans in from every arm that reaches it. (That is the
317
+ * difference from `.stepToList`, whose arms get an End of their own.)
318
+ *
319
+ * `value` names an exit the ACTION declares — a human task's outcome name, an
320
+ * HTTP response branch's name, or `'default'` for HTTP's fall-through — not a
321
+ * value to compare against. `check` refuses a value the step does not declare
322
+ * and warns about a declared exit left out, which compiles to an End node.
323
+ *
324
+ * @param name - The step's id, as `.step()`.
325
+ * @param spec - What the node does, from an action factory.
326
+ * @param cases - One arm per declared exit.
327
+ * @param options - Node options, as `.step()`.
328
+ * @returns This builder, so calls chain.
329
+ *
330
+ * @example
331
+ * **Route a human task's outcomes, and converge on one return**
332
+ * ```ts
333
+ * export default flow('draft-review')
334
+ * .output({ summary: types.string })
335
+ * .var('summary', types.string)
336
+ * .stepSwitch('review', hitl({ fields: [], outcomes: ['Publish', 'Revise'] }), [
337
+ * { value: 'Publish', body: (b) => b.step('publish', script({ code: 'return "published";', returns: 'string' }), { updates: { summary: out('publish') } }) },
338
+ * { value: 'Revise', body: (b) => b.step('sendBack', script({ code: 'return "revise";', returns: 'string' }), { updates: { summary: out('sendBack') } }) },
339
+ * ])
340
+ * .return({ summary: v('summary') })
341
+ * .build();
342
+ * ```
343
+ */
344
+ stepSwitch(name, spec, cases, options) {
345
+ if (!Array.isArray(cases) || cases.length === 0) {
346
+ throw new TypeError(`.stepSwitch('${name}'): needs at least one arm. With nothing to route, use .step().`);
347
+ }
348
+ const arms = cases.map((c) => {
349
+ const arm = new ArmBuilder();
350
+ c.body?.(arm);
351
+ // A `.switch()` arm's label becomes `inputs.cases[].label` on the decision
352
+ // node. There is no such slot here: an edge carries no label, and the port
353
+ // is already named by the outcome or branch it leaves. Refusing beats
354
+ // accepting it and dropping it on the floor.
355
+ if (arm.armLabel !== undefined) {
356
+ throw new TypeError(`.stepSwitch('${name}') arm '${c.value}': .label() has nowhere to go — a port edge carries no `
357
+ + `label, and the exit is named by "${c.value}" already. Use options.label to rename the NODE.`);
358
+ }
359
+ return { value: String(c.value), body: arm.steps };
360
+ });
361
+ this.steps.push({
362
+ kind: 'stepSwitch', name, spec: spec, cases: arms,
363
+ ...(options ? { options } : {}),
364
+ });
365
+ return this;
366
+ }
306
367
  /**
307
368
  * Handle the PREVIOUS step's failure: if it fails, the flow runs `bodyFn`'s
308
369
  * steps instead of continuing.
@@ -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
  )