@uipath/maestro-builder-sdk 6.8.3 → 6.8.5

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/decompile.js CHANGED
@@ -37,6 +37,7 @@ import { inferScriptShape } from './core/script-return.js';
37
37
  import { DEFAULT_TRIGGER_ID } from './core/expr.js';
38
38
  import { parseIxpProjectType } from './core/actions.js';
39
39
  import { readFlowGlobals } from './flow-variables.js';
40
+ import { OUTCOME_HANDLE_NODE_TYPES } from './core/hitl-routing.js';
40
41
  import { eventFilterFromTreeLeaf } from './event-filters.js';
41
42
  /**
42
43
  * The trigger id of the flow currently being decompiled, so a reference through a
@@ -410,13 +411,43 @@ class Graph {
410
411
  const first = outcomes[0]?.id;
411
412
  return typeof first === 'string' && first !== '' ? `outcome-${first}` : undefined;
412
413
  }
414
+ /**
415
+ * A base or quick-form human task whose EVERY outcome is wired, all to ONE
416
+ * step, with no other exit edge — what `serialize` emits for `.step()` after a
417
+ * task that does not route per outcome (quick-form's default, a pinned 1.0,
418
+ * `outcomePorts: false`), and what the designer saves when every outcome
419
+ * handle is dragged to the same node. It decompiles to a `.step()` with
420
+ * `outcomePorts: false` (2+ outcomes), which recompiles to the same edges.
421
+ *
422
+ * A base task on its 1.1/1.2 definition is left out on purpose: that version
423
+ * IS its routing (`outcomePorts: true` / `exposeError: true` select it), and
424
+ * it already round-trips exactly as a `.stepSwitch` with empty arms.
425
+ */
426
+ convergedFanOut(id) {
427
+ const n = this.byId.get(id);
428
+ if (!n || !OUTCOME_HANDLE_NODE_TYPES.has(String(n.type)) || versionRoutesHitl(n))
429
+ return false;
430
+ const ids = outcomeIdsOfNode(n);
431
+ if (ids.length === 0)
432
+ return false;
433
+ const exits = this.outEdges(id).filter((e) => e.sourcePort !== 'error' && !ARTIFACT_PORTS.has(e.sourcePort));
434
+ if (exits.length !== ids.length)
435
+ return false;
436
+ const ports = new Set(exits.map((e) => e.sourcePort));
437
+ if (!ids.every((i) => ports.has(`outcome-${i}`)))
438
+ return false;
439
+ return new Set(exits.map((e) => e.targetNodeId)).size === 1;
440
+ }
413
441
  /**
414
442
  * Every declared exit of `id`, when ALL of them are wired — the `.stepSwitch`
415
443
  * shape. `undefined` otherwise, which leaves the node with a tacit main path
416
- * and its siblings as side arms.
444
+ * and its siblings as side arms — and for a converged fan-out, whose one
445
+ * target is the main path.
417
446
  */
418
447
  fullyWiredExits(id) {
419
448
  const node = this.byId.get(id);
449
+ if (this.convergedFanOut(id))
450
+ return undefined;
420
451
  const declared = node ? declaredExitsOfNode(node) : undefined;
421
452
  if (declared === undefined || declared.length < 2)
422
453
  return undefined;
@@ -609,7 +640,7 @@ function emitStepSpec(node, imp, inputNames, o, graph) {
609
640
  case T.hitlQuickForm:
610
641
  case T.hitlActionApp:
611
642
  case T.hitlDocValidation:
612
- return emitHitl(node, imp, inputNames);
643
+ return emitHitl(node, imp, inputNames, graph);
613
644
  case T.queueCreate:
614
645
  case T.queueCreateAndWait: {
615
646
  // `itemData` is the node's name for the surface's `item` (the reference
@@ -1320,7 +1351,7 @@ function emitAgentCluster(node, imp, inputNames, graph) {
1320
1351
  * definition's own `inputDefaults.recipient` for delivery. Recovering those
1321
1352
  * would add arguments the author never wrote to every task.
1322
1353
  */
1323
- function emitHitl(node, imp, inputNames) {
1354
+ function emitHitl(node, imp, inputNames, graph) {
1324
1355
  const i = (node.inputs ?? {});
1325
1356
  const schema = (i.schema ?? {});
1326
1357
  const plain = (v) => {
@@ -1417,11 +1448,39 @@ function emitHitl(node, imp, inputNames) {
1417
1448
  ['render', render !== undefined && render !== 'standard' ? str(render) : undefined],
1418
1449
  ['taxonomy', stationExpr(i.taxonomy)],
1419
1450
  ]);
1420
- // Per-outcome exits and the error output are VERSION-selected: 1.1 routes per
1421
- // outcome, 1.2 adds the error output (and implies the routing).
1451
+ // On the base node the VERSION says it: 1.1 routes per outcome, 1.2 adds the
1452
+ // error output (and implies the routing). Everywhere else — quick-form, a
1453
+ // base task on 1.0 — the EDGES say it: outcomes that lead to different places
1454
+ // are routed per outcome (`outcomePorts: true`, which a pinned 1.0 keeps at
1455
+ // 1.0), and outcomes that all lead to one step are a fan-out, which the
1456
+ // task's default recompiles to.
1422
1457
  const version = String(node.typeVersion ?? '');
1423
1458
  const exposeError = version === '1.2';
1424
- const outcomePorts = version === '1.1';
1459
+ const outEdges = graph?.outEdges(node.id) ?? [];
1460
+ const derivesHandles = OUTCOME_HANDLE_NODE_TYPES.has(String(node.type));
1461
+ const outcomePorts = (node.type === T.hitl && version === '1.1')
1462
+ || (derivesHandles && !versionRoutesHitl(node) && graph !== undefined && !graph.convergedFanOut(node.id)
1463
+ && outEdges.some((e) => e.sourcePort.startsWith('outcome-')));
1464
+ // A lone `completed` edge on a task with several outcomes is the shape the
1465
+ // designer stopped drawing: before flow-workbench #3512 (09-08) it was
1466
+ // unconditioned, so every outcome continued down it; since then Studio Web and
1467
+ // the converter remap it to the primary outcome only. Recompiling writes one
1468
+ // `outcome-<id>` edge per outcome to the same step — the first meaning — so
1469
+ // this is the one place decompile→compile is deliberately not exact. Say so.
1470
+ const legacyCompleted = derivesHandles && outcomeIdsOfNode(node).length > 1
1471
+ && outEdges.some((e) => e.sourcePort === 'completed');
1472
+ // Both a converged fan-out and a legacy lone `completed` mean "every outcome
1473
+ // continues to the next step". Say so in the source with `outcomePorts: false`
1474
+ // rather than leaving it to the `{ version }` pin, so the intent survives an
1475
+ // author dropping the pin.
1476
+ const fanOut = !outcomePorts && derivesHandles && !versionRoutesHitl(node) && outcomeIdsOfNode(node).length > 1
1477
+ && (legacyCompleted || graph?.convergedFanOut(node.id) === true);
1478
+ if (legacyCompleted) {
1479
+ warnOnce(`human task ${str(node.id)} leaves on the legacy 'completed' handle, which Studio Web no longer draws `
1480
+ + `once a task has outcomes (it reads the edge as the primary outcome only). Recompiling emits one `
1481
+ + `outcome-<id> edge per outcome to the same step, so every outcome continues — what the edge meant `
1482
+ + `before flow-workbench #3512.`);
1483
+ }
1425
1484
  const title = plain(i.title);
1426
1485
  const priority = plain(i.priority);
1427
1486
  return `${imp.use('hitl')}(${opts([
@@ -1438,7 +1497,7 @@ function emitHitl(node, imp, inputNames) {
1438
1497
  ['fields', fields.length ? `[${fields.join(', ')}]`
1439
1498
  : (variant === 'action-app' || variant === 'document-validation') ? undefined : '[]'],
1440
1499
  ['outcomes', `[${outcomes.join(', ')}]`],
1441
- ['outcomePorts', outcomePorts ? 'true' : undefined],
1500
+ ['outcomePorts', outcomePorts ? 'true' : fanOut ? 'false' : undefined],
1442
1501
  ['exposeError', exposeError ? 'true' : undefined],
1443
1502
  ])})`;
1444
1503
  }
@@ -2081,7 +2140,9 @@ function region(cursor, stop, scope, ipdom, ctx, refInto) {
2081
2140
  // A per-outcome human task routes each NON-primary outcome from its own
2082
2141
  // `outcome-<slug>` exit; the primary one continues the main path, so it is
2083
2142
  // already a successor (see Graph.primaryOutcomePort).
2084
- {
2143
+ // A converged fan-out has none: every outcome continues to the one
2144
+ // successor, which is what `.step()` recompiles to.
2145
+ if (!ctx.graph.convergedFanOut(node.id)) {
2085
2146
  const primary = ctx.graph.primaryOutcomePort(node.id);
2086
2147
  for (const oe of ctx.graph.outEdges(node.id)
2087
2148
  .filter((e) => e.sourcePort.startsWith('outcome-') && e.sourcePort !== primary)) {
@@ -2165,18 +2226,28 @@ function portArmExits(node, ctx) {
2165
2226
  return undefined;
2166
2227
  return declaredExitsOfNode(node);
2167
2228
  }
2229
+ /** A base human task on its 1.1/1.2 definition, where the VERSION selects per-outcome routing. */
2230
+ function versionRoutesHitl(node) {
2231
+ const version = String(node.typeVersion ?? '');
2232
+ return node.type === T.hitl && (version === '1.1' || version === '1.2');
2233
+ }
2234
+ /** The outcome ids in a human task's `inputs.schema.outcomes`. */
2235
+ function outcomeIdsOfNode(node) {
2236
+ const outcomes = (node.inputs?.schema?.outcomes ?? []);
2237
+ return outcomes.map((o) => o?.id).filter((id) => typeof id === 'string' && id !== '');
2238
+ }
2168
2239
  /**
2169
2240
  * The exits a node declares, read back off the EMITTED artifact rather than off
2170
- * an authored spec — `inputs.schema.outcomes` for a human task at 1.1/1.2,
2171
- * `inputs.branches` plus `default` for an HTTP call. The authoring-side twin is
2172
- * `declaredExits` in `core/step-ports.ts`; they answer about different inputs,
2173
- * which is why this is not a call into it.
2241
+ * an authored spec — `inputs.schema.outcomes` for a base or quick-form human
2242
+ * task (the product draws one `outcome-<id>` handle per outcome on both, at
2243
+ * every version), `inputs.branches` plus `default` for an HTTP call. The
2244
+ * authoring-side twin is `declaredExits` in `core/step-ports.ts`; they answer
2245
+ * about different inputs, which is why this is not a call into it.
2174
2246
  */
2175
2247
  function declaredExitsOfNode(node) {
2176
2248
  const inputs = (node.inputs ?? {});
2177
2249
  if (String(node.type).startsWith('uipath.human-in-the-loop')) {
2178
- const version = String(node.typeVersion ?? '');
2179
- if (version !== '1.1' && version !== '1.2')
2250
+ if (!OUTCOME_HANDLE_NODE_TYPES.has(String(node.type)))
2180
2251
  return undefined;
2181
2252
  const outcomes = (inputs.schema?.outcomes ?? []);
2182
2253
  if (outcomes.length === 0)
@@ -11,6 +11,7 @@ import { sortedStrings } from "../../../compat/compare.js";
11
11
  import { pyStr, splitOnce } from "../../../compat/pystr.js";
12
12
  import { classify, referenceOf } from "../../../lookup-specs.js";
13
13
  import { byAliases } from "../../../naming.js";
14
+ import { resolveFlagArg } from "../../../../core/cli-spelling.js";
14
15
  import { authoringField, referenceCollection, referenceQuery } from "../common.js";
15
16
  import { firstValue, IDENT, pyOr, pyTruthy, strList } from "../pyvalue.js";
16
17
  /** The `resources run list` call that shows what an identity-lookup field accepts. */
@@ -85,9 +86,21 @@ export function lookupLines(connectorKey, action, fields, symbol, invariant) {
85
86
  "different connection. Resolve it late:");
86
87
  out.push("");
87
88
  out.push("```ts");
88
- // A dotted wire name is a legal object key only when quoted.
89
- const key = IDENT.test(firstName) ? firstName : pyJsonString(firstName);
90
- out.push(`${key}: ${call}${sample}`);
89
+ // A plain multiselect (`users[*]`) is authored as an array of lookups under
90
+ // its authoring name, the same form `## Example` prints and the one that
91
+ // compiles to one id per element. Any other wire name is written as a key —
92
+ // a dotted one is a legal object key only when quoted — and one ending in
93
+ // `[*]` (`recipients.listIds[*]`) still names ONE list, so its value is an
94
+ // array too: the flat key compiles to the same list the nested
95
+ // `recipients: { listIds: [...] }` of `## Example` does.
96
+ const [authoringName, , multiselectWire] = authoringField(firstField);
97
+ if (multiselectWire) {
98
+ out.push(`${authoringName}: [${call}${sample}]`);
99
+ }
100
+ else {
101
+ const key = IDENT.test(firstName) ? firstName : pyJsonString(firstName);
102
+ out.push(firstName.endsWith("[*]") ? `${key}: [${call}${sample}]` : `${key}: ${call}${sample}`);
103
+ }
91
104
  out.push("```");
92
105
  out.push("");
93
106
  out.push("| field | helpers | matches on | sends |");
@@ -116,10 +129,12 @@ export function lookupLines(connectorKey, action, fields, symbol, invariant) {
116
129
  // ONE command carrying every --resolve, and no `--connection-id`.
117
130
  out.push("```bash");
118
131
  out.push(`npx flow-sdk registry prepare ${connectorKey} ${action} \\`);
132
+ // The WIRE name (`users[*]`), which is what prepare's lookup table is keyed
133
+ // on — the authoring name (`users`) is refused as RESOLVE_UNKNOWN_FIELD.
119
134
  const resolves = resolvable.map(([f, reference]) => {
120
- const [name] = authoringField(f);
135
+ const wire = pyStr(pyOr(f.name, authoringField(f)[0]));
121
136
  const names = pyOr(strList(reference.lookupNames), ["id"]);
122
- return ` --resolve ${name}:${names[0]}=<value>`;
137
+ return ` --resolve ${resolveFlagArg(wire, names[0], "<value>")}`;
123
138
  });
124
139
  for (const line of resolves.slice(0, -1))
125
140
  out.push(`${line} \\`);
@@ -55,7 +55,9 @@ export interface ResolutionPlan {
55
55
  export declare class ResolutionsExhausted extends LookupExhausted {
56
56
  readonly first: LookupExhausted;
57
57
  readonly plan: ResolutionPlan;
58
- constructor(first: LookupExhausted, plan: ResolutionPlan);
58
+ constructor(first: LookupExhausted, plan: ResolutionPlan,
59
+ /** Every miss of the run, when there was more to say than `first`'s own message. */
60
+ message?: string);
59
61
  }
60
62
  /** What one `--resolve` run needs; `objectName` narrows the overlay entry the specs are read from. */
61
63
  export interface ResolveRun {
@@ -20,7 +20,7 @@
20
20
  import { lookupKey } from "../../core/lookups.js";
21
21
  import { pyJsonStringify } from "../codecs/pyjson.js";
22
22
  import { sortedStrings } from "../compat/compare.js";
23
- import { PrepareRefusal, resolutionLine, unknownLookupMessage, wroteResolutionsLine } from "./messages.js";
23
+ import { PrepareRefusal, incompleteRunMessage, notTriedLine, resolutionLine, unknownLookupMessage, wroteResolutionsLine, } from "./messages.js";
24
24
  import { LedgerRefusal, RESOLUTIONS_FILENAME, ledgerPath, readLedger, writeLedger } from "./objects.js";
25
25
  import { lookupSpecsFromOverlay } from "./overlay.js";
26
26
  import { LookupExhausted, orderResolutions, parseResolveFlag, resolveOne } from "./resolve.js";
@@ -87,8 +87,10 @@ export function findResolution(existing, nodeType, field, by, value) {
87
87
  export class ResolutionsExhausted extends LookupExhausted {
88
88
  first;
89
89
  plan;
90
- constructor(first, plan) {
91
- super(first.message, first);
90
+ constructor(first, plan,
91
+ /** Every miss of the run, when there was more to say than `first`'s own message. */
92
+ message = first.message) {
93
+ super(message, first);
92
94
  this.first = first;
93
95
  this.plan = plan;
94
96
  this.name = "ResolutionsExhausted";
@@ -117,13 +119,29 @@ export async function writeResolutions(run, specs) {
117
119
  const recorded = pyGet(ledger, "resolutions", null);
118
120
  const existing = isPlainObject(recorded) ? { ...recorded } : {};
119
121
  const resolved = new Map();
122
+ // An exhaustive miss no longer ends the run: the rest are still tried, so ONE
123
+ // run reports every value this connection does not hold — a multiselect of
124
+ // three names used to cost three runs to learn about three misses. A lookup
125
+ // addressed through a field that missed is skipped, since its path cannot be
126
+ // built. The WRITE stays all-or-nothing; see the throw below.
127
+ const misses = [];
128
+ const unresolved = new Set();
129
+ const notTried = [];
130
+ const answered = [];
120
131
  for (const [field, by, value] of ordered) {
132
+ const blockedBy = (table.get(field)?.dependsOn ?? []).find((prerequisite) => unresolved.has(prerequisite));
133
+ if (blockedBy !== undefined) {
134
+ unresolved.add(field);
135
+ notTried.push(notTriedLine(field, by, value, blockedBy));
136
+ continue;
137
+ }
121
138
  const key = resolutionKey(run.nodeType, field, by, value);
122
139
  const found = findResolution(existing, run.nodeType, field, by, value);
123
140
  if (found && !run.refreshLookups) {
124
141
  const kept = pyStrAny(pyGet(found.row, "value", null));
125
142
  resolved.set(field, kept);
126
143
  run.reporter.info(resolutionLine(field, by, value, kept, true));
144
+ answered.push(resolutionLine(field, by, value, kept, true));
127
145
  continue;
128
146
  }
129
147
  let got;
@@ -152,8 +170,25 @@ export async function writeResolutions(run, specs) {
152
170
  // refusal, whose text already names the remedy ("the value may be
153
171
  // further in"). Upstream's own tests never covered the row, which is
154
172
  // how it shipped unimplemented.
155
- if (error instanceof LookupExhausted && !error.capped)
156
- throw new ResolutionsExhausted(error, { specs: table, ordered });
173
+ //
174
+ // Only that exhaustive miss is collected; every other refusal — capped,
175
+ // transport, unsearchable `by` — still ends the run at once, as before,
176
+ // so none of them can reach the cross-connection retry.
177
+ if (error instanceof LookupExhausted && !error.capped) {
178
+ misses.push(error);
179
+ unresolved.add(field);
180
+ continue;
181
+ }
182
+ // An exhaustive miss collected EARLIER still decides the run: it is
183
+ // evidence against this connection, and before the misses were collected
184
+ // it would have ended the run and started the cross-connection retry
185
+ // before this lookup was ever tried. So that retry still runs, and its
186
+ // refusal names the collected misses and then this error.
187
+ if (misses.length > 0) {
188
+ const text = error instanceof Error ? error.message : String(error);
189
+ const lines = [...misses.map((miss) => miss.message), ...notTried, text.startsWith(`${field}:`) ? text : `${field}: ${text}`];
190
+ throw new ResolutionsExhausted(misses[0], { specs: table, ordered }, incompleteRunMessage(lines, [], answered, ordered.length));
191
+ }
157
192
  throw error;
158
193
  }
159
194
  resolved.set(field, got);
@@ -163,6 +198,21 @@ export async function writeResolutions(run, specs) {
163
198
  delete existing[found.key];
164
199
  existing[key] = { nodeType: run.nodeType, field, by, match: value, value: got };
165
200
  run.reporter.info(resolutionLine(field, by, value, got));
201
+ answered.push(resolutionLine(field, by, value, got));
202
+ }
203
+ // ALL-OR-NOTHING, deliberately, even though the rows that DID resolve are
204
+ // correct for this connection. A ledger row carries no connection: it is
205
+ // keyed `[nodeType, field, by, value]`, and a later run REUSES a recorded row
206
+ // without asking again unless `--refresh-lookups` is passed (above). So a
207
+ // partial write from connection A would hand A's workspace ids to the next
208
+ // run bound to connection B — the 2026-09-23 run resolved one recipient on
209
+ // one Slack workspace and then re-pinned to another. Until rows are keyed by
210
+ // connection, a run that misses records nothing and says what it found.
211
+ if (misses.length > 0) {
212
+ const message = misses.length === 1 && notTried.length === 0 && answered.length === 0
213
+ ? misses[0].message
214
+ : incompleteRunMessage(misses.map((miss) => miss.message), notTried, answered, ordered.length);
215
+ throw new ResolutionsExhausted(misses[0], { specs: table, ordered }, message);
166
216
  }
167
217
  const written = { ...ledger, schemaVersion: "1", resolutions: existing };
168
218
  writeLedger(path, written);
@@ -111,6 +111,14 @@ export declare function lookupVocabularyLines(connectorKey: string, action: stri
111
111
  export declare function unknownLookupMessage(unknown: readonly string[], nodeType: string, known: readonly string[]): string;
112
112
  /** ` <field>: <by>=<value> -> <id>`, with ` (already resolved)` when the ledger answered. */
113
113
  export declare function resolutionLine(field: string, by: string, value: string, got: unknown, alreadyResolved?: boolean): string;
114
+ /** A lookup skipped because the field its collection path is built from did not resolve. */
115
+ export declare function notTriedLine(field: string, by: string, value: string, prerequisite: string): string;
116
+ /**
117
+ * Every miss of one `--resolve` run, then what DID resolve. Nothing is recorded
118
+ * when anything misses, and the reader needs both halves to fix every value in
119
+ * one pass rather than one per run.
120
+ */
121
+ export declare function incompleteRunMessage(misses: readonly string[], notTried: readonly string[], answered: readonly string[], requested: number): string;
114
122
  export declare function wroteResolutionsLine(count: number, ledgerPath: string): string;
115
123
  /**
116
124
  * `generated N connector module(s) → <out>` — the descriptor generator's own
@@ -262,6 +262,23 @@ export function unknownLookupMessage(unknown, nodeType, known) {
262
262
  export function resolutionLine(field, by, value, got, alreadyResolved = false) {
263
263
  return ` ${field}: ${by}=${value} -> ${pyStrAny(got)}${alreadyResolved ? " (already resolved)" : ""}`;
264
264
  }
265
+ /** A lookup skipped because the field its collection path is built from did not resolve. */
266
+ export function notTriedLine(field, by, value, prerequisite) {
267
+ return `${field}: ${by}=${pyRepr(value)} not tried — its collection is addressed through ${pyRepr(prerequisite)}, which did not resolve.`;
268
+ }
269
+ /**
270
+ * Every miss of one `--resolve` run, then what DID resolve. Nothing is recorded
271
+ * when anything misses, and the reader needs both halves to fix every value in
272
+ * one pass rather than one per run.
273
+ */
274
+ export function incompleteRunMessage(misses, notTried, answered, requested) {
275
+ const failed = misses.length + notTried.length;
276
+ return (`${failed} of ${requested} lookup(s) did not resolve, so nothing was recorded:\n` +
277
+ [...misses, ...notTried].map((line) => ` ${line}`).join("\n") +
278
+ (answered.length > 0
279
+ ? `\nThese resolved, and are recorded only once every lookup of the run does:\n${answered.join("\n")}`
280
+ : ""));
281
+ }
265
282
  export function wroteResolutionsLine(count, ledgerPath) {
266
283
  return ` wrote ${count} resolution(s) to ${ledgerPath}`;
267
284
  }
@@ -163,7 +163,10 @@ export async function prepareConnector(options, ports) {
163
163
  connectionId: connected.connectionId,
164
164
  connection: options.connection,
165
165
  pinned,
166
- }, error.plan, error.first);
166
+ }, error.plan,
167
+ // The wrapper, not `error.first`: same facts, and its message names every
168
+ // miss of the run rather than only the first.
169
+ error);
167
170
  // Undo the bindings this run wrote for the connection that cannot serve
168
171
  // the lookups, then start over pinned to the one that can. Re-running
169
172
  // rather than switching in place is deliberate: the overlay and its
@@ -36,7 +36,21 @@ export interface LookupExhaustedFacts {
36
36
  pages: number;
37
37
  /** True when a cap, not the collection's end, stopped the scan. */
38
38
  capped: boolean;
39
+ /**
40
+ * Every distinct value the scan saw at `by`, when the whole collection was
41
+ * read and holds at most {@link CANDIDATE_LIMIT} records — what to pick from.
42
+ */
43
+ candidates?: readonly string[];
44
+ /** A value the scan saw that equals the wanted one ignoring case; the match itself is exact. */
45
+ near?: string;
39
46
  }
47
+ /**
48
+ * A collection this small is listed in full when a value is not in it. The
49
+ * 2026-09-23 run guessed `send_as` 'Bot token' from the field's description
50
+ * while `available_tokens` held two records, 'Bot' and 'User', and the refusal
51
+ * said only "scanned 2 record(s)".
52
+ */
53
+ export declare const CANDIDATE_LIMIT = 20;
40
54
  /**
41
55
  * The collection was read to the end on THIS connection and had no match
42
56
  * (`_resolve.LookupExhausted`).
@@ -63,6 +77,8 @@ export declare class LookupExhausted extends ResolutionRefusal {
63
77
  readonly scanned: number;
64
78
  readonly pages: number;
65
79
  readonly capped: boolean;
80
+ readonly candidates?: readonly string[];
81
+ readonly near?: string;
66
82
  constructor(message: string, facts: LookupExhaustedFacts);
67
83
  }
68
84
  /** One `--resolve <field>:<by>=<value>`, parsed. */
@@ -97,8 +113,17 @@ type SpecTable = ReadonlyMap<string, ResolveSpec> | Readonly<Record<string, Reso
97
113
  * unsubstituted `{placeholder}` in a URL, and the error the connector returns
98
114
  * for that names neither field.
99
115
  *
100
- * A field requested twice keeps its first position and its LAST `by`/`value`,
101
- * as the Python's dict did.
116
+ * A field requested more than once keeps its first position and EVERY distinct
117
+ * `by`/`value`, in the order given; an exact repeat collapses. DELIBERATE
118
+ * DIVERGENCE FROM THE PYTHON, whose dict kept one request per field — its LAST
119
+ * `by`/`value` — and dropped the rest without a word. That is fatal for a
120
+ * multiselect: `--resolve users[*]:…=A --resolve users[*]:…=B --resolve
121
+ * users[*]:…=C`, one command as the library page prints it, resolved and
122
+ * recorded only C (2026-09-23, skill-flow-ipe-multiselect), and the probe of
123
+ * the other connections asked only about C too.
124
+ *
125
+ * A PREREQUISITE given more than one value is refused: its id is substituted
126
+ * into the dependent's collection path, and one placeholder takes one id.
102
127
  */
103
128
  export declare function orderResolutions(requested: readonly ResolveRequest[], specs: SpecTable): ResolveRequest[];
104
129
  /**
@@ -50,6 +50,13 @@ export class ResolutionRefusal extends Refusal {
50
50
  this.name = "ResolutionRefusal";
51
51
  }
52
52
  }
53
+ /**
54
+ * A collection this small is listed in full when a value is not in it. The
55
+ * 2026-09-23 run guessed `send_as` 'Bot token' from the field's description
56
+ * while `available_tokens` held two records, 'Bot' and 'User', and the refusal
57
+ * said only "scanned 2 record(s)".
58
+ */
59
+ export const CANDIDATE_LIMIT = 20;
53
60
  /**
54
61
  * The collection was read to the end on THIS connection and had no match
55
62
  * (`_resolve.LookupExhausted`).
@@ -76,6 +83,8 @@ export class LookupExhausted extends ResolutionRefusal {
76
83
  scanned;
77
84
  pages;
78
85
  capped;
86
+ candidates;
87
+ near;
79
88
  constructor(message, facts) {
80
89
  super(message, "RESOLVE_NO_MATCH");
81
90
  this.name = "LookupExhausted";
@@ -86,6 +95,10 @@ export class LookupExhausted extends ResolutionRefusal {
86
95
  this.scanned = facts.scanned;
87
96
  this.pages = facts.pages;
88
97
  this.capped = facts.capped;
98
+ if (facts.candidates !== undefined)
99
+ this.candidates = facts.candidates;
100
+ if (facts.near !== undefined)
101
+ this.near = facts.near;
89
102
  }
90
103
  }
91
104
  /**
@@ -123,13 +136,27 @@ function specOf(specs, field) {
123
136
  * unsubstituted `{placeholder}` in a URL, and the error the connector returns
124
137
  * for that names neither field.
125
138
  *
126
- * A field requested twice keeps its first position and its LAST `by`/`value`,
127
- * as the Python's dict did.
139
+ * A field requested more than once keeps its first position and EVERY distinct
140
+ * `by`/`value`, in the order given; an exact repeat collapses. DELIBERATE
141
+ * DIVERGENCE FROM THE PYTHON, whose dict kept one request per field — its LAST
142
+ * `by`/`value` — and dropped the rest without a word. That is fatal for a
143
+ * multiselect: `--resolve users[*]:…=A --resolve users[*]:…=B --resolve
144
+ * users[*]:…=C`, one command as the library page prints it, resolved and
145
+ * recorded only C (2026-09-23, skill-flow-ipe-multiselect), and the probe of
146
+ * the other connections asked only about C too.
147
+ *
148
+ * A PREREQUISITE given more than one value is refused: its id is substituted
149
+ * into the dependent's collection path, and one placeholder takes one id.
128
150
  */
129
151
  export function orderResolutions(requested, specs) {
130
152
  const byField = new Map();
131
- for (const request of requested)
132
- byField.set(request[0], request);
153
+ for (const request of requested) {
154
+ const same = byField.get(request[0]);
155
+ if (same === undefined)
156
+ byField.set(request[0], [request]);
157
+ else if (!same.some(([, by, value]) => by === request[1] && value === request[2]))
158
+ same.push(request);
159
+ }
133
160
  const ordered = [];
134
161
  const placed = new Set();
135
162
  const place = (field, trail) => {
@@ -141,7 +168,14 @@ export function orderResolutions(requested, specs) {
141
168
  }
142
169
  const dependsOn = specOf(specs, field)?.dependsOn;
143
170
  for (const prerequisite of Array.isArray(dependsOn) ? dependsOn : []) {
144
- if (byField.has(prerequisite)) {
171
+ const given = byField.get(prerequisite);
172
+ if (given !== undefined && given.length > 1) {
173
+ throw new ResolutionRefusal(`cannot resolve ${pyRepr(field)}: its collection is addressed through ` +
174
+ `${pyRepr(prerequisite)}, which was given ${given.length} values ` +
175
+ `(${given.map(([, by, value]) => `${by}=${pyRepr(value)}`).join(", ")}). ` +
176
+ `The path takes one id; keep one --resolve ${prerequisite}:<by>=<value> and re-run.`, "RESOLVE_AMBIGUOUS_PREREQUISITE");
177
+ }
178
+ if (given !== undefined) {
145
179
  place(prerequisite, [...trail, field]);
146
180
  }
147
181
  else {
@@ -151,7 +185,7 @@ export function orderResolutions(requested, specs) {
151
185
  }
152
186
  }
153
187
  placed.add(field);
154
- ordered.push(byField.get(field));
188
+ ordered.push(...byField.get(field));
155
189
  };
156
190
  for (const [field] of requested)
157
191
  place(field, []);
@@ -433,9 +467,21 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
433
467
  query = query ? `${query}&${filtered}` : filtered;
434
468
  }
435
469
  const scan = scanCollection(client, connectorKey, collection, connectionId, query);
470
+ // What the collection DOES hold at `by`, for the refusal: kept only while it
471
+ // could still be listed in full, plus the first case-insensitive equal.
472
+ const held = [];
473
+ let near;
436
474
  let step = await scan.next();
437
475
  while (!step.done) {
438
476
  for (const record of step.value) {
477
+ const at = readPath(record, by);
478
+ if (at !== null && at !== undefined) {
479
+ const text = pyStrAny(at);
480
+ if (held.length <= CANDIDATE_LIMIT && !held.includes(text))
481
+ held.push(text);
482
+ if (near === undefined && text !== value && text.toLowerCase() === value.toLowerCase())
483
+ near = text;
484
+ }
439
485
  if (matchRecord(record, by, value)) {
440
486
  const got = readPath(record, send);
441
487
  if (got === null) {
@@ -463,8 +509,24 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
463
509
  // The listing command is still offered, once, by the caller's terminal
464
510
  // report — where every connection has been tried and seeing what IS there
465
511
  // is finally the next step rather than a detour.
512
+ //
513
+ // What it DOES carry is what the collection holds, when that is small enough
514
+ // to read in one line, and a case-only near miss: facts about this
515
+ // connection, not a remedy. `matchRecord` stays exact — nothing is picked.
516
+ //
517
+ // Never under a server-side filter: the scan then read only the records the
518
+ // filter returned (`name like '%Coder Eval%'` gives 'Coder Evals' and 'Coder
519
+ // Eval Test'), and listing that subset as what the collection "holds" would
520
+ // be false. 693 library operations declare a `filterPattern`.
521
+ const candidates = !capped && !pyTruthy(filterPattern) && scanned <= CANDIDATE_LIMIT && held.length > 0 ? held : undefined;
466
522
  throw new LookupExhausted(`${field}: no record in ${pyRepr(collection)} has ${by}=${pyRepr(value)} ` +
467
523
  `(scanned ${scanned} record(s) across ${pages + 1} page(s)` +
468
524
  (capped ? `; STOPPED AT THE ${MAX_SCANNED}-record cap, so the value may be further in` : "; collection exhausted") +
469
- `) on connection ${connectionId}.`, { field, collection, by, value, scanned, pages: pages + 1, capped });
525
+ `) on connection ${connectionId}.` +
526
+ (near !== undefined ? ` Did you mean ${pyRepr(near)}? The match is exact, case included.` : "") +
527
+ (candidates !== undefined ? ` It holds: ${candidates.map(pyRepr).join(", ")}.` : ""), {
528
+ field, collection, by, value, scanned, pages: pages + 1, capped,
529
+ ...(candidates !== undefined ? { candidates } : {}),
530
+ ...(near !== undefined ? { near } : {}),
531
+ });
470
532
  }