@uipath/maestro-builder-sdk 6.8.4 → 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/check.js CHANGED
@@ -15,7 +15,7 @@ import { hitlDerivesOutcomeHandles, hitlExitModel, hitlRoutesPerOutcome } from '
15
15
  import { declaredExits, hasExits, stopsAtEnd } from './core/step-ports.js';
16
16
  import { FLOW_SCHEMA_VERSIONS } from './format-profile.js';
17
17
  import { compareSchemaVersions, schemaVersionRefusal } from './schema-version.js';
18
- import { prepareCommand } from './core/cli-spelling.js';
18
+ import { prepareCommand, resolveFlagArg } from './core/cli-spelling.js';
19
19
  export function check(built, opts = {}) {
20
20
  return checkFlow(built, [], opts);
21
21
  }
@@ -1264,6 +1264,9 @@ function checkFlow(built, ancestors, opts = {}) {
1264
1264
  function checkConnectorLookups(step, spec, opts, diags) {
1265
1265
  const specs = opts.lookups?.(spec.key, spec.action, spec.version, spec.object);
1266
1266
  const resolutions = opts.resolutions ?? {};
1267
+ // One LOOKUP_LITERAL_ID per field: a multiselect of three pasted ids is one
1268
+ // mistake with one remedy, not three.
1269
+ const literalWarned = new Set();
1267
1270
  const visit = (value, path) => {
1268
1271
  if (isLookupToken(value)) {
1269
1272
  // A match key the field does not declare is certainly wrong, and the
@@ -1304,7 +1307,17 @@ function checkConnectorLookups(step, spec, opts, diags) {
1304
1307
  return;
1305
1308
  }
1306
1309
  if (Array.isArray(value)) {
1307
- value.forEach((v, i) => visit(v, [...path, String(i)]));
1310
+ // An element is named by the WIRE name the lookup table is keyed on —
1311
+ // `users[*]`, `contacts[*].id` — not by its index. `users.0` matched no
1312
+ // spec, so a pasted id, a token with a bad match key, or a runtime value
1313
+ // inside a multiselect passed every rule below unseen. A name that already
1314
+ // is the array's (a flat `'users[*]'` key, or a field typed as an array)
1315
+ // stays as it is.
1316
+ const last = path[path.length - 1] ?? '';
1317
+ const element = last.endsWith('[*]') || specs?.[path.join('.')]
1318
+ ? path
1319
+ : [...path.slice(0, -1), `${last}[*]`];
1320
+ value.forEach((v) => visit(v, element));
1308
1321
  return;
1309
1322
  }
1310
1323
  if (value && typeof value === 'object' && !(value instanceof Expr)) {
@@ -1329,6 +1342,9 @@ function checkConnectorLookups(step, spec, opts, diags) {
1329
1342
  const literal = value instanceof Expr ? value.value : value;
1330
1343
  if (typeof literal !== 'string')
1331
1344
  return;
1345
+ if (literalWarned.has(field))
1346
+ return;
1347
+ literalWarned.add(field);
1332
1348
  const { target, call } = lookupPhrases(field, lookup, spec);
1333
1349
  diags.push({
1334
1350
  level: 'warning',
@@ -1340,7 +1356,7 @@ function checkConnectorLookups(step, spec, opts, diags) {
1340
1356
  + `compile nor deploy can tell. Write what you mean and resolve it late:\n`
1341
1357
  + ` ${call}\n`
1342
1358
  + `then record the value once with \`${prepareCommand(spec.key)} `
1343
- + `${spec.action} --resolve ${field}:${lookup.by[0] ?? 'id'}=<value>\`.`,
1359
+ + `${spec.action} --resolve ${resolveFlagArg(field, lookup.by[0] ?? 'id', '<value>')}\`.`,
1344
1360
  step,
1345
1361
  });
1346
1362
  };
@@ -1557,7 +1573,7 @@ function runtimeLookupDiagnostic(step, spec, field, lookup, value) {
1557
1573
  + `neither compile nor validate can tell. Resolve the value you know instead:\n`
1558
1574
  + ` ${call}\n`
1559
1575
  + `then record it once with \`${prepareCommand(spec.key, spec.action)} `
1560
- + `--resolve ${field}:${lookup.by[0] ?? 'id'}=<value>\`; or read the id from a step whose `
1576
+ + `--resolve ${resolveFlagArg(field, lookup.by[0] ?? 'id', '<value>')}\`; or read the id from a step whose `
1561
1577
  + `output returned one (\`${lookup.value}\`).`
1562
1578
  + (isEmail
1563
1579
  ? `\n\`${single.raw}\` reads \`${leaf}\`, an e-mail address, not a \`${target}\`.`
@@ -37,3 +37,13 @@ export declare function cliSpelling(env?: CliSpellingEnv): string;
37
37
  * ```
38
38
  */
39
39
  export declare function prepareCommand(...args: string[]): string;
40
+ /**
41
+ * The argument of one `--resolve <field>:<by>=<value>`, single-quoted when the
42
+ * shell would otherwise mangle it.
43
+ *
44
+ * @param field - The lookup field's wire name, e.g. `channel` or `users[*]`.
45
+ * @param by - The match key, e.g. `profile.email`.
46
+ * @param value - The value to match, or the `<value>` placeholder.
47
+ * @returns `channel:profile.email=d@x.com` as is; `'users[*]:profile.real_name=Coder Evals'` quoted.
48
+ */
49
+ export declare function resolveFlagArg(field: string, by: string, value: string): string;
@@ -60,3 +60,27 @@ export function cliSpelling(env = ambientEnv()) {
60
60
  export function prepareCommand(...args) {
61
61
  return [cliSpelling(), 'registry', 'prepare', ...args.filter(Boolean)].join(' ');
62
62
  }
63
+ /**
64
+ * Characters that make a `--resolve` argument unsafe to paste unquoted. `[*]`
65
+ * is a shell glob (every multiselect wire name, `users[*]`), whitespace splits
66
+ * a real name (`Coder Evals`) into two arguments, and quotes, `$`, backslash
67
+ * and the control operators change what the shell hands over. The `<value>`
68
+ * placeholder a template prints is stripped before the test, so a template
69
+ * with nothing else unsafe keeps its bytes.
70
+ */
71
+ const SHELL_UNSAFE = /[\s'"`$\\[\]*?;&|(){}!#~<>]/;
72
+ /**
73
+ * The argument of one `--resolve <field>:<by>=<value>`, single-quoted when the
74
+ * shell would otherwise mangle it.
75
+ *
76
+ * @param field - The lookup field's wire name, e.g. `channel` or `users[*]`.
77
+ * @param by - The match key, e.g. `profile.email`.
78
+ * @param value - The value to match, or the `<value>` placeholder.
79
+ * @returns `channel:profile.email=d@x.com` as is; `'users[*]:profile.real_name=Coder Evals'` quoted.
80
+ */
81
+ export function resolveFlagArg(field, by, value) {
82
+ const arg = `${field}:${by}=${value}`;
83
+ if (!SHELL_UNSAFE.test(arg.replaceAll('<value>', '')))
84
+ return arg;
85
+ return `'${arg.replaceAll("'", `'\\''`)}'`;
86
+ }
@@ -27,7 +27,7 @@
27
27
  * degenerate classes the generator excludes and why generating for them would
28
28
  * make the surface worse rather than more complete.
29
29
  */
30
- import { prepareCommand } from './cli-spelling.js';
30
+ import { prepareCommand, resolveFlagArg } from './cli-spelling.js';
31
31
  /** True when `v` is an unresolved {@link LookupToken}. */
32
32
  export function isLookupToken(v) {
33
33
  return (typeof v === 'object' && v !== null &&
@@ -192,7 +192,9 @@ export function unresolvedLookupMessage(token) {
192
192
  return (`input "${field}" is an unresolved lookup (${by}=${JSON.stringify(value)}). ` +
193
193
  `Compile does not make network calls, so the value has to be resolved first:\n` +
194
194
  ` ${prepareCommand(key, action)} \\\n` +
195
- ` --resolve ${field}:${by}=${String(value)}`);
195
+ // Quoted only when the shell would mangle it: a multiselect's `users[*]`
196
+ // is a glob and a real name has spaces, and the last line is meant to paste.
197
+ ` --resolve ${resolveFlagArg(field, by, String(value))}`);
196
198
  }
197
199
  // ─── Classification, mirroring `_lookups.py` ─────────────────────────────────
198
200
  //
@@ -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
  }
package/dist/serialize.js CHANGED
@@ -3474,8 +3474,18 @@ function eventDetail(rc, sub, nodeId, opts) {
3474
3474
  errorState: { issues: [] },
3475
3475
  };
3476
3476
  }
3477
- /** Recursively render Exprs nested inside objects/arrays (for connector inputs). */
3477
+ /**
3478
+ * Recursively render Exprs nested inside objects/arrays (for connector inputs).
3479
+ *
3480
+ * A lookup token is REFUSED, never rendered. `buildConnectorInputs` swaps every
3481
+ * token it can place — a field's whole value, or an element of an array — so a
3482
+ * token that reaches here sits where nothing resolves it: inside an object value,
3483
+ * or in a custom node's inputs. Recursing into it would put `{"__lookup": …}` on
3484
+ * the wire where the connector expects an id.
3485
+ */
3478
3486
  function renderDeep(val, rename = NO_RENAME) {
3487
+ if (isLookupToken(val))
3488
+ throw new Error(misplacedLookupMessage(val));
3479
3489
  if (val instanceof Expr)
3480
3490
  return renderValue(val, rename);
3481
3491
  if (Array.isArray(val))
@@ -3488,6 +3498,13 @@ function renderDeep(val, rename = NO_RENAME) {
3488
3498
  }
3489
3499
  return val;
3490
3500
  }
3501
+ /** Why {@link renderDeep} refused a token: where a lookup CAN go, and what it would have emitted. */
3502
+ function misplacedLookupMessage(token) {
3503
+ const { field, by, value } = token.__lookup;
3504
+ return (`lookup('${field}') (${by}=${JSON.stringify(value)}) is in a position no lookup is resolved from: ` +
3505
+ `a lookup() must be the whole value of a connector input, or an element of an array input. ` +
3506
+ `Emitting it here would send a raw {"__lookup": …} object where the connector expects an id.`);
3507
+ }
3491
3508
  function newScope(outputs, vars = []) {
3492
3509
  return {
3493
3510
  nodes: [],
@@ -6118,16 +6135,35 @@ function buildConnectorInputs(rc, inputs, rename = NO_RENAME, resolutions = {},
6118
6135
  // NOT rendered as a placeholder: emitting something for a value nobody
6119
6136
  // resolved would produce a flow that compiles and then talks to the wrong
6120
6137
  // record, which is exactly the failure the lookup surface exists to remove.
6121
- if (isLookupToken(nested.value)) {
6122
- const resolved = resolvedValue(nested.value, resolutions);
6123
- if (resolved === undefined) {
6124
- if (policy.unresolvedLookups !== 'ignore')
6125
- errs.push(unresolvedLookupMessage(nested.value));
6138
+ //
6139
+ // A token is swapped wherever the field's value can hold one: the whole
6140
+ // value, or an element of an array at any depth. A multiselect is
6141
+ // `users: [lookup(…), lookup(…)]` — the form `ConnectorLookupValue`'s array
6142
+ // branch types and the generated `## Example` prints — and until this swapped
6143
+ // only the whole-value case, so every token inside the array reached
6144
+ // validation as an object ("expected string, got object") with every lookup
6145
+ // recorded. An unresolved element under 'ignore' becomes `null`, which
6146
+ // validation skips, so `check` still type-checks the resolved siblings.
6147
+ const unresolved = [];
6148
+ const swapped = swapLookupTokens(nested.value, resolutions, unresolved);
6149
+ if (unresolved.length) {
6150
+ if (policy.unresolvedLookups !== 'ignore') {
6151
+ for (const token of unresolved)
6152
+ errs.push(unresolvedLookupMessage(token));
6126
6153
  continue;
6127
6154
  }
6128
- nested = { ...nested, value: resolved };
6155
+ if (isLookupToken(nested.value))
6156
+ continue;
6129
6157
  }
6130
- validateConnectorFieldValue(f, nested.value, flat ? 0 : nested.projectedArrays, errs);
6158
+ nested = { ...nested, value: swapped };
6159
+ // A FLAT key whose wire name ends in `[*]` names one array: an array value
6160
+ // there is checked element by element, as the nested form is. A flat scalar
6161
+ // keeps depth 0 and is emitted as the scalar it is — the product wraps it
6162
+ // into a one-element list (flow-v1 `convertDotNotationToNested`).
6163
+ const depth = flat
6164
+ ? (f.name.endsWith('[*]') && Array.isArray(nested.value) ? 1 : 0)
6165
+ : nested.projectedArrays;
6166
+ validateConnectorFieldValue(f, nested.value, depth, errs);
6131
6167
  if (f.location === 'multipart') {
6132
6168
  partValues.set(f.name, renderDeep(nested.value, rename));
6133
6169
  continue;
@@ -6195,6 +6231,29 @@ function buildConnectorInputs(rc, inputs, rename = NO_RENAME, resolutions = {},
6195
6231
  }
6196
6232
  return slots;
6197
6233
  }
6234
+ /**
6235
+ * `value` with every lookup token swapped for its recorded id: the value itself,
6236
+ * or the elements of an array at any depth. An unrecorded token is pushed to
6237
+ * `unresolved` and stands as `null`, so an array keeps its length.
6238
+ *
6239
+ * Objects are NOT descended: a lookup's place is a field's value or an element
6240
+ * of an array field (`ConnectorLookupValue`), and a nested path such as
6241
+ * `contacts[*].id` reaches here already projected to its leaf array. A token
6242
+ * anywhere else is refused by {@link renderDeep}.
6243
+ */
6244
+ function swapLookupTokens(value, resolutions, unresolved) {
6245
+ if (isLookupToken(value)) {
6246
+ const resolved = resolvedValue(value, resolutions);
6247
+ if (resolved === undefined) {
6248
+ unresolved.push(value);
6249
+ return null;
6250
+ }
6251
+ return resolved;
6252
+ }
6253
+ if (Array.isArray(value))
6254
+ return value.map((item) => swapLookupTokens(item, resolutions, unresolved));
6255
+ return value;
6256
+ }
6198
6257
  /**
6199
6258
  * `inputMetadata.multipart.bodyFieldName` names the CONTAINER part — the one the
6200
6259
  * runtime composes from `bodyParameters`. Measured on the designer's own nodes in