@uipath/maestro-builder-sdk 6.10.3 → 6.11.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.
@@ -130,6 +130,7 @@ export function registerRegistryCommands(program) {
130
130
  // `uip is connections list` and writes both keys into bindings.json.
131
131
  .option('--connection-id <id>', 'connection to read the schema through')
132
132
  .option('--connection <name>', 'pick the connection by name when several match')
133
+ .option('--connection-folder <folder>', 'pick the connection in the folder the flow runs in (name or key) when several match')
133
134
  .option('--bindings <file>', 'bindings file to update (default: ./bindings.json)')
134
135
  .option('--bind-connection <name>', 'symbolic connection name (default: the one label the source uses for this connector, else the connector suffix)')
135
136
  .option('--bind-folder <name>', 'symbolic folder name (default: the one folder label the source uses, else shared)')
@@ -170,6 +171,7 @@ export function registerRegistryCommands(program) {
170
171
  const argv = [connectorKey, action];
171
172
  addValue(argv, '--connection-id', options.connectionId);
172
173
  addValue(argv, '--connection', options.connection);
174
+ addValue(argv, '--connection-folder', options.connectionFolder);
173
175
  addValue(argv, '--bindings', options.bindings);
174
176
  addValue(argv, '--bind-connection', options.bindConnection);
175
177
  addValue(argv, '--bind-folder', options.bindFolder);
@@ -53,7 +53,68 @@ export declare function selectConnection(rows: readonly ConnectionRow[], connect
53
53
  * connection where the tenant-wide view is many. Widening first would turn an
54
54
  * unambiguous case into an ambiguous one.
55
55
  */
56
- export declare function discoverConnection(client: UipClient, connectorKey: string, name?: string): Promise<ConnectionRow>;
56
+ export declare function discoverConnection(client: UipClient, connectorKey: string, name?: string, prefer?: ConnectionPreference): Promise<ConnectionRow>;
57
+ /**
58
+ * What settles a choice among several enabled connections for one connector,
59
+ * consulted in this order, each only when the one before it did not decide.
60
+ *
61
+ * The eval tenant holds `is-sandboxes` (Slack), `is.sandboxes.test@gmail.com`
62
+ * (Drive) and the Outlook connections twice each, once in the
63
+ * `uipath-maestro-flow` folder and once in `uipath-maestro-case`. A name
64
+ * cannot tell them apart, and a lookup cannot either when both reach the same
65
+ * workspace — so every connector task paid one refused call and a re-run with
66
+ * `--connection-id` (flow-builder-sdk#818: 13 refusals in one run). What does
67
+ * tell them apart is WHERE the flow runs, and there are two honest sources for
68
+ * that: the author saying so, and the solution having already recorded it.
69
+ */
70
+ export interface ConnectionPreference {
71
+ /**
72
+ * `--connection-folder`: the folder the flow runs in, by name
73
+ * (case-insensitive, as Orchestrator folder names are) or by folder key.
74
+ */
75
+ folder?: string;
76
+ /**
77
+ * Connection ids the solution already records for this connector —
78
+ * `resources/<folder>/connection/<connectorKey>/*.json`, whose `key` is the
79
+ * connection id `uip solution resources refresh` bound. See
80
+ * {@link solutionConnectionIds}.
81
+ */
82
+ solutionIds?: ReadonlySet<string>;
83
+ }
84
+ /** How {@link chooseConnection} settled on its row, for the line that reports it. */
85
+ export type ConnectionChoice = "only" | "folder" | "solution";
86
+ /** True when `row` is in `folder`: its `Folder` name ignoring case, or its `FolderKey` exactly. */
87
+ export declare function inFolder(row: ConnectionRow, folder: string): boolean;
88
+ /**
89
+ * {@link discoverConnection} with the reason it chose. Never guesses: each
90
+ * step either narrows to exactly one row or hands the choice on, and the last
91
+ * step is the refusal listing every candidate.
92
+ *
93
+ * 1. `--connection-folder` given: every enabled connection in the tenant (own
94
+ * folder first, as {@link candidateConnections} orders them) in that
95
+ * folder. Exactly one is the answer; none or several is a refusal, because
96
+ * the author named a folder and a silent fallback would ignore them.
97
+ * 2. Otherwise the default discovery — own folder, then the tenant. One row is
98
+ * the answer as before.
99
+ * 3. Several: those the solution already records. Exactly one is the answer.
100
+ * 4. Otherwise the ambiguity refusal, which names both flags.
101
+ */
102
+ export declare function chooseConnection(client: UipClient, connectorKey: string, name?: string, prefer?: ConnectionPreference): Promise<{
103
+ row: ConnectionRow;
104
+ by: ConnectionChoice;
105
+ }>;
106
+ /**
107
+ * The connection ids a solution records for `connectorKey`: the `key` of every
108
+ * `resources/<folder>/connection/<connectorKey>/*.json` under each solution
109
+ * (a directory holding a `.uipx`) at `cwd`, above it, or one level below it —
110
+ * the flow-sdk layout keeps the solution as a child of the working directory.
111
+ *
112
+ * `uip solution resources refresh` writes these, and a seeded or re-edited
113
+ * solution carries them, so they are the tenant's own record of which
114
+ * connection this solution deploys with. A file that will not parse, or whose
115
+ * `type` names another connector, contributes nothing.
116
+ */
117
+ export declare function solutionConnectionIds(cwd: string, connectorKey: string): Set<string>;
57
118
  /**
58
119
  * Every enabled connection for `connectorKey`, own folder FIRST
59
120
  * (`_connections.candidate_connections`).
@@ -21,7 +21,8 @@
21
21
  * a {@link UipClient} and hand the rows to them. Every refusal's text is kept
22
22
  * verbatim — agents read these messages and follow them.
23
23
  */
24
- import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
24
+ import { existsSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
25
+ import { dirname, join, resolve as resolvePath } from "node:path";
25
26
  import { pyJsonStringify } from "../codecs/pyjson.js";
26
27
  import { Refusal, messageOf } from "../errors.js";
27
28
  import { isConnectionsList } from "../uip/commands.js";
@@ -102,10 +103,11 @@ export function selectConnection(rows, connectorKey, name, scope = "the tenant")
102
103
  const names = new Set(matched.map((row) => pyStrAny(row.Name)));
103
104
  // Python tested `name is not None`: an empty-string name takes this branch too.
104
105
  const remedy = (name !== undefined && name !== null) || names.size < matched.length
105
- ? "They share a name, so --connection <name> cannot pick one; pass " +
106
- "the id of the one in the folder you mean with --connection-id:"
107
- : "Name the one you want with --connection <name>, or pass its id " +
108
- "with --connection-id:";
106
+ ? "They share a name, so --connection <name> cannot pick one; name " +
107
+ "the folder the flow runs in with --connection-folder <folder>, or " +
108
+ "pass the id of the one you mean with --connection-id:"
109
+ : "Name the one you want with --connection <name>, name its folder " +
110
+ "with --connection-folder <folder>, or pass its id with --connection-id:";
109
111
  throw new ConnectionRefusal(`${matched.length} enabled ${connectorKey} connections in ${scope}, and they ` +
110
112
  "are not interchangeable — a record that exists in one workspace need " +
111
113
  `not exist in another. ${remedy}\n${listed}`, "CONNECTION_AMBIGUOUS");
@@ -121,14 +123,137 @@ export function selectConnection(rows, connectorKey, name, scope = "the tenant")
121
123
  * connection where the tenant-wide view is many. Widening first would turn an
122
124
  * unambiguous case into an ambiguous one.
123
125
  */
124
- export async function discoverConnection(client, connectorKey, name) {
126
+ export async function discoverConnection(client, connectorKey, name, prefer = {}) {
127
+ return (await chooseConnection(client, connectorKey, name, prefer)).row;
128
+ }
129
+ /** True when `row` is in `folder`: its `Folder` name ignoring case, or its `FolderKey` exactly. */
130
+ export function inFolder(row, folder) {
131
+ const wanted = folder.trim();
132
+ if (!wanted)
133
+ return false;
134
+ return (pyStrAny(pyGet(row, "Folder", "")).toLowerCase() === wanted.toLowerCase() ||
135
+ pyStrAny(pyGet(row, "FolderKey", "")) === wanted);
136
+ }
137
+ /**
138
+ * {@link discoverConnection} with the reason it chose. Never guesses: each
139
+ * step either narrows to exactly one row or hands the choice on, and the last
140
+ * step is the refusal listing every candidate.
141
+ *
142
+ * 1. `--connection-folder` given: every enabled connection in the tenant (own
143
+ * folder first, as {@link candidateConnections} orders them) in that
144
+ * folder. Exactly one is the answer; none or several is a refusal, because
145
+ * the author named a folder and a silent fallback would ignore them.
146
+ * 2. Otherwise the default discovery — own folder, then the tenant. One row is
147
+ * the answer as before.
148
+ * 3. Several: those the solution already records. Exactly one is the answer.
149
+ * 4. Otherwise the ambiguity refusal, which names both flags.
150
+ */
151
+ export async function chooseConnection(client, connectorKey, name, prefer = {}) {
152
+ if (prefer.folder !== undefined && prefer.folder.trim() !== "") {
153
+ const folder = prefer.folder.trim();
154
+ const all = await candidateConnections(client, connectorKey, name);
155
+ const there = all.filter((row) => inFolder(row, folder));
156
+ if (there.length === 1)
157
+ return { row: there[0], by: "folder" };
158
+ if (there.length === 0) {
159
+ const qualifier = name ? ` named ${pyRepr(name)}` : "";
160
+ throw new ConnectionRefusal(`no enabled ${connectorKey} connection${qualifier} in folder ${pyRepr(folder)}. ` +
161
+ (all.length > 0
162
+ ? `The enabled ones are:\n${candidateLines(all)}`
163
+ : "List what exists with:\n uip is connections list --all-folders"), "CONNECTION_NONE_IN_FOLDER");
164
+ }
165
+ return { row: selectConnection(there, connectorKey, name, "the tenant"), by: "folder" };
166
+ }
125
167
  let rows = enabledConnections(await listConnections(client, { allFolders: false }), connectorKey, name);
126
168
  let scope = "your folder";
127
169
  if (rows.length === 0) {
128
170
  rows = enabledConnections(await listConnections(client, { allFolders: true }), connectorKey, name);
129
171
  scope = "the tenant";
130
172
  }
131
- return selectConnection(rows, connectorKey, name, scope);
173
+ if (rows.length > 1 && prefer.solutionIds !== undefined && prefer.solutionIds.size > 0) {
174
+ const recorded = rows.filter((row) => prefer.solutionIds.has(pyStrAny(pyGet(row, "Id", ""))));
175
+ if (recorded.length === 1)
176
+ return { row: recorded[0], by: "solution" };
177
+ }
178
+ return { row: selectConnection(rows, connectorKey, name, scope), by: "only" };
179
+ }
180
+ /**
181
+ * The connection ids a solution records for `connectorKey`: the `key` of every
182
+ * `resources/<folder>/connection/<connectorKey>/*.json` under each solution
183
+ * (a directory holding a `.uipx`) at `cwd`, above it, or one level below it —
184
+ * the flow-sdk layout keeps the solution as a child of the working directory.
185
+ *
186
+ * `uip solution resources refresh` writes these, and a seeded or re-edited
187
+ * solution carries them, so they are the tenant's own record of which
188
+ * connection this solution deploys with. A file that will not parse, or whose
189
+ * `type` names another connector, contributes nothing.
190
+ */
191
+ export function solutionConnectionIds(cwd, connectorKey) {
192
+ const ids = new Set();
193
+ const solutions = new Set();
194
+ const hasUipx = (dir) => {
195
+ try {
196
+ return readdirSync(dir).some((entry) => entry.endsWith(".uipx"));
197
+ }
198
+ catch {
199
+ return false;
200
+ }
201
+ };
202
+ let dir = resolvePath(cwd);
203
+ for (let depth = 0; depth < 6; depth++) {
204
+ if (hasUipx(dir))
205
+ solutions.add(dir);
206
+ const parent = dirname(dir);
207
+ if (parent === dir)
208
+ break;
209
+ dir = parent;
210
+ }
211
+ try {
212
+ for (const entry of readdirSync(cwd, { withFileTypes: true })) {
213
+ if (entry.isDirectory() && hasUipx(join(cwd, entry.name)))
214
+ solutions.add(resolvePath(cwd, entry.name));
215
+ }
216
+ }
217
+ catch {
218
+ // An unreadable cwd records nothing.
219
+ }
220
+ for (const solution of solutions) {
221
+ const resources = join(solution, "resources");
222
+ let folders;
223
+ try {
224
+ folders = readdirSync(resources);
225
+ }
226
+ catch {
227
+ continue;
228
+ }
229
+ for (const folder of folders) {
230
+ const connectorDir = join(resources, folder, "connection", connectorKey);
231
+ let files;
232
+ try {
233
+ files = readdirSync(connectorDir).filter((file) => file.endsWith(".json"));
234
+ }
235
+ catch {
236
+ continue;
237
+ }
238
+ for (const file of files) {
239
+ try {
240
+ const resource = pyGet(JSON.parse(readFileSync(join(connectorDir, file), "utf8")), "resource", null);
241
+ if (!isPlainObject(resource))
242
+ continue;
243
+ const type = pyGet(resource, "type", null);
244
+ if (pyTruthy(type) && type !== connectorKey)
245
+ continue;
246
+ const key = pyStrAny(pyGet(resource, "key", ""));
247
+ if (key)
248
+ ids.add(key);
249
+ }
250
+ catch {
251
+ // Not JSON: records nothing.
252
+ }
253
+ }
254
+ }
255
+ }
256
+ return ids;
132
257
  }
133
258
  /**
134
259
  * Every enabled connection for `connectorKey`, own folder FIRST
@@ -146,7 +146,10 @@ export async function writeResolutions(run, specs) {
146
146
  }
147
147
  let got;
148
148
  try {
149
- got = await resolveOne(run.client, run.connectorKey, table.get(field), field, by, value, run.connectionId, resolved);
149
+ const notes = [];
150
+ got = await resolveOne(run.client, run.connectorKey, table.get(field), field, by, value, run.connectionId, resolved, notes);
151
+ for (const note of notes)
152
+ run.reporter.info(note);
150
153
  }
151
154
  catch (error) {
152
155
  // The collection was read to the END on this connection and the value
@@ -42,6 +42,11 @@ export declare class PrepareRefusal extends Refusal {
42
42
  export declare function exitText(error: unknown): string;
43
43
  /** ` connection: <Name> (<id>)` — `found.get('Name')` renders `None` when the row has none, as Python did. */
44
44
  export declare function connectionLine(row: Record<string, unknown>, connectionId: string): string;
45
+ /**
46
+ * Why discovery chose this connection when several matched: the folder the
47
+ * author named, or the solution's own record of the connection.
48
+ */
49
+ export declare function connectionChoiceLine(row: Record<string, unknown>, by: "folder" | "solution", folder?: string): string;
45
50
  /** ` bindings: <symbolic name> -> <bindings.json>`, once per row this run changed. */
46
51
  export declare function bindingsLine(symbolicName: string, bindingsPath: string): string;
47
52
  /** ` (the label Demo.flow.ts uses)` — appended to a bindings line whose name came from the source. */
@@ -58,6 +58,16 @@ export function exitText(error) {
58
58
  export function connectionLine(row, connectionId) {
59
59
  return ` connection: ${pyStrAny(pyGet(row, "Name", null))} (${connectionId})`;
60
60
  }
61
+ /**
62
+ * Why discovery chose this connection when several matched: the folder the
63
+ * author named, or the solution's own record of the connection.
64
+ */
65
+ export function connectionChoiceLine(row, by, folder) {
66
+ const where = pyStrAny(row.Folder);
67
+ return by === "folder"
68
+ ? ` connection: chose the one in folder ${pyRepr(pyOr(folder, where))} (--connection-folder)`
69
+ : ` connection: chose the one the solution records (resources/*/connection/…, folder: ${where})`;
70
+ }
61
71
  /** ` bindings: <symbolic name> -> <bindings.json>`, once per row this run changed. */
62
72
  export function bindingsLine(symbolicName, bindingsPath) {
63
73
  return ` bindings: ${symbolicName} -> ${bindingsPath}`;
@@ -10,6 +10,12 @@ export interface PrepareOptions {
10
10
  connectionId?: string;
11
11
  /** `--connection <name>`: pick the connection by name when several match. */
12
12
  connection?: string;
13
+ /**
14
+ * `--connection-folder <folder>`: the folder the flow runs in (name or key).
15
+ * Picks among several enabled connections; without it, the ids the solution
16
+ * already records are tried before the ambiguity refusal.
17
+ */
18
+ connectionFolder?: string;
13
19
  /** `--bindings` (default `./bindings.json`). */
14
20
  bindings?: string;
15
21
  /**
@@ -36,11 +36,11 @@ import { expandGenericForConnector, genericTemplatesByOp } from "../library-buil
36
36
  import { planLibrary } from "../library-build/plan.js";
37
37
  import { MemoryResponseCache } from "../uip/response-cache.js";
38
38
  import { registryCatalog } from "./actions.js";
39
- import { discoverConnection, listConnections, lookupConnection, mergeBindings, restoreBindings, snapshotBindings, } from "./connections.js";
39
+ import { chooseConnection, listConnections, lookupConnection, mergeBindings, restoreBindings, snapshotBindings, solutionConnectionIds, } from "./connections.js";
40
40
  import { prepareEvent } from "./event.js";
41
41
  import { descriptorUsage } from "./handoff.js";
42
42
  import { ResolutionsExhausted, allParentsResolved, resolvedParentValues, writeResolutions } from "./ledger.js";
43
- import { PREPARE_COMMAND, PrepareRefusal, ambiguousLabelsMessage, bindingsLine, candidateLabel, connectionLine, generatedModulesLine, genericActionNeedsObjectMessage, inputFieldNote, flagDiffersFromSourceLine, keptSchemaMessage, labelTakenMessage, lookupVocabularyLines, missingGenericSchemaMessage, missingParentsNote, nextLine, noCanonicalEntryMessage, objectFlagsOnlyForGenericMessage, objectNoteLine, objectRecordedLine, parentFieldNamesMismatchMessage, parentOnlyMessage, prefixed, preparedSummaryLine, rejectedParentFieldsMessage, rerunAgainstLine, resolvedParentsNote, registryGetFailedMessage, sharedLabelMessage, sourceLabelSuffix, tsNote, partlyUnreadableLabelLine, unreadableLabelLine, unresolvedOperationMessage, } from "./messages.js";
43
+ import { PREPARE_COMMAND, PrepareRefusal, ambiguousLabelsMessage, connectionChoiceLine, bindingsLine, candidateLabel, connectionLine, generatedModulesLine, genericActionNeedsObjectMessage, inputFieldNote, flagDiffersFromSourceLine, keptSchemaMessage, labelTakenMessage, lookupVocabularyLines, missingGenericSchemaMessage, missingParentsNote, nextLine, noCanonicalEntryMessage, objectFlagsOnlyForGenericMessage, objectNoteLine, objectRecordedLine, parentFieldNamesMismatchMessage, parentOnlyMessage, prefixed, preparedSummaryLine, rejectedParentFieldsMessage, rerunAgainstLine, resolvedParentsNote, registryGetFailedMessage, sharedLabelMessage, sourceLabelSuffix, tsNote, partlyUnreadableLabelLine, unreadableLabelLine, unresolvedOperationMessage, } from "./messages.js";
44
44
  import { ObjectMatchRefusal, RESOLUTIONS_FILENAME, matchObject, recordObjectResolution } from "./objects.js";
45
45
  import { countInputFields, keepPreparedSchema, lookupSpecsFromOverlay, mergeFilesInto, parentFields as overlayParentFields, schemaActionParents, } from "./overlay.js";
46
46
  import { declaredParents, objectActionRequest, objectActionSpecs, parseFields } from "./parents.js";
@@ -195,12 +195,19 @@ async function connect(options, ports) {
195
195
  const labels = options.noBindings ? undefined : chooseLabels(options, ports);
196
196
  let row;
197
197
  let connectionId;
198
+ let chosenBy = "only";
198
199
  if (!options.connectionId) {
199
200
  // Discover the connection when the caller did not name one, and record it
200
201
  // in bindings.json so the author never has to look up either key — the
201
202
  // step that removes 4-7 tool calls per task.
202
- row = await discoverConnection(ports.client, options.connectorKey, options.connection);
203
+ const cwd = ports.cwd ?? process.cwd();
204
+ const choice = await chooseConnection(ports.client, options.connectorKey, options.connection, {
205
+ folder: options.connectionFolder,
206
+ solutionIds: solutionConnectionIds(cwd, options.connectorKey),
207
+ });
208
+ row = choice.row;
203
209
  connectionId = pyStrAny(row.Id);
210
+ chosenBy = choice.by;
204
211
  }
205
212
  else {
206
213
  // An explicit id gets the same gate discovery applies: a `State: Failed`
@@ -209,6 +216,8 @@ async function connect(options, ports) {
209
216
  row = await lookupConnection(ports.client, options.connectorKey, connectionId);
210
217
  }
211
218
  info(connectionLine(row, connectionId));
219
+ if (chosenBy !== "only")
220
+ info(connectionChoiceLine(row, chosenBy, options.connectionFolder));
212
221
  if (!labels)
213
222
  return { connectionId, row };
214
223
  // Recorded on BOTH routes. Until 3.30.5 only discovery wrote it, and the
@@ -251,7 +260,9 @@ export async function prepareConnector(options, ports) {
251
260
  // Whether the CALLER chose the connection, recorded before discovery answers
252
261
  // with its own. An explicit choice is never second-guessed: the retry below
253
262
  // is for a connection this command picked, not for one the author named.
254
- const pinned = pyTruthy(options.connectionId) || pyTruthy(options.connection);
263
+ // A named folder is a choice too: probing the other folders' connections
264
+ // after the author said which folder the flow runs in would overrule them.
265
+ const pinned = pyTruthy(options.connectionId) || pyTruthy(options.connection) || pyTruthy(options.connectionFolder);
255
266
  const connected = await connect(options, ports);
256
267
  try {
257
268
  return await prepareOnConnection(options, ports, connected);
@@ -229,7 +229,7 @@ export declare function scanCollection(client: UipClient, connectorKey: string,
229
229
  * A collection read to its end with no match throws {@link LookupExhausted},
230
230
  * the subclass the caller's retry keys on; every other failure is the parent.
231
231
  */
232
- export declare function resolveOne(client: UipClient, connectorKey: string, spec: ResolveSpec, field: string, by: string, value: string, connectionId: string, resolved?: Resolved): Promise<string>;
232
+ export declare function resolveOne(client: UipClient, connectorKey: string, spec: ResolveSpec, field: string, by: string, value: string, connectionId: string, resolved?: Resolved, notes?: string[]): Promise<string>;
233
233
  /**
234
234
  * The child collection of `parent`: `childPath` with each `{Field}` read off
235
235
  * the parent record (`{ID}` → the folder's id), split into collection and
@@ -449,7 +449,7 @@ export async function* scanCollection(client, connectorKey, collection, connecti
449
449
  * A collection read to its end with no match throws {@link LookupExhausted},
450
450
  * the subclass the caller's retry keys on; every other failure is the parent.
451
451
  */
452
- export async function resolveOne(client, connectorKey, spec, field, by, value, connectionId, resolved = new Map()) {
452
+ export async function resolveOne(client, connectorKey, spec, field, by, value, connectionId, resolved = new Map(), notes) {
453
453
  const searchableBy = Array.isArray(spec.by) ? spec.by : [];
454
454
  if (!searchableBy.includes(by)) {
455
455
  const searchable = searchableBy.map(pyRepr).join(", ");
@@ -482,13 +482,15 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
482
482
  // pasting a literal id (flow-builder-sdk#818). A server-side filter still
483
483
  // wins: it answers from the whole collection in one request.
484
484
  if (pyTruthy(spec.childPath) && !pyTruthy(filterPattern)) {
485
- return resolveInTree(client, connectorKey, spec, field, by, value, connectionId, resolved, collection, query, send);
485
+ return resolveInTree(client, connectorKey, spec, field, by, value, connectionId, resolved, collection, query, send, notes);
486
486
  }
487
487
  const scan = scanCollection(client, connectorKey, collection, connectionId, query);
488
488
  // What the collection DOES hold at `by`, for the refusal: kept only while it
489
489
  // could still be listed in full, plus the first case-insensitive equal.
490
490
  const held = [];
491
- let near;
491
+ // Records whose `by` equals the value ignoring case only, first per distinct
492
+ // spelling: the fallback when no record matches exactly.
493
+ const caseOnly = new Map();
492
494
  let step = await scan.next();
493
495
  while (!step.done) {
494
496
  for (const record of step.value) {
@@ -497,8 +499,8 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
497
499
  const text = pyStrAny(at);
498
500
  if (held.length <= CANDIDATE_LIMIT && !held.includes(text))
499
501
  held.push(text);
500
- if (near === undefined && text !== value && text.toLowerCase() === value.toLowerCase())
501
- near = text;
502
+ if (text !== value && text.toLowerCase() === value.toLowerCase() && !caseOnly.has(text))
503
+ caseOnly.set(text, record);
502
504
  }
503
505
  if (matchRecord(record, by, value)) {
504
506
  const got = readPath(record, send);
@@ -512,6 +514,27 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
512
514
  step = await scan.next();
513
515
  }
514
516
  const { scanned, pages, capped } = step.value;
517
+ // No exact match anywhere in the collection: try the case-only ones. Exact
518
+ // first, because an id picked by a looser rule while the exact record sat
519
+ // on a later page would be the wrong id. Only once the scan has read to the
520
+ // END — a capped scan may still hold the exact record further in, so it
521
+ // keeps the refusal and the hint.
522
+ //
523
+ // `send_as:name=user` against `available_tokens` ('Bot', 'User') and
524
+ // `E2E-testing` against `E2E-Testing` each cost a refused `prepare` and a
525
+ // re-run whose only change was the letter case the refusal had already
526
+ // named (flow-builder-sdk#818). One spelling is unambiguous, so it is taken
527
+ // and said; two spellings that differ only in case are two records, and
528
+ // choosing between them would be a guess.
529
+ const near = caseOnly.size > 0 ? [...caseOnly.keys()][0] : undefined;
530
+ if (!capped && caseOnly.size > 0) {
531
+ const record = findRecord([...caseOnly.values()], field, by, value, notes);
532
+ const got = readPath(record, send);
533
+ if (got === null) {
534
+ throw new ResolutionRefusal(`${field}: matched a record on ${by}=${pyRepr(pyStrAny(readPath(record, by)))}, but it carries no ${pyRepr(send)} to send.`, "RESOLVE_NO_VALUE");
535
+ }
536
+ return pyStrAny(got);
537
+ }
515
538
  // This states the FACT and stops; `prepare` owns the remedy, because
516
539
  // `prepare` is what knows the other candidate connections.
517
540
  //
@@ -529,8 +552,8 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
529
552
  // is finally the next step rather than a detour.
530
553
  //
531
554
  // What it DOES carry is what the collection holds, when that is small enough
532
- // to read in one line, and a case-only near miss: facts about this
533
- // connection, not a remedy. `matchRecord` stays exact — nothing is picked.
555
+ // to read in one line, and — when the cap stopped the scan — a case-only
556
+ // near miss that was NOT taken: facts about this connection, not a remedy.
534
557
  //
535
558
  // Never under a server-side filter: the scan then read only the records the
536
559
  // filter returned (`name like '%Coder Eval%'` gives 'Coder Evals' and 'Coder
@@ -541,13 +564,43 @@ export async function resolveOne(client, connectorKey, spec, field, by, value, c
541
564
  `(scanned ${scanned} record(s) across ${pages + 1} page(s)` +
542
565
  (capped ? `; STOPPED AT THE ${MAX_SCANNED}-record cap, so the value may be further in` : "; collection exhausted") +
543
566
  `) on connection ${connectionId}.` +
544
- (near !== undefined ? ` Did you mean ${pyRepr(near)}? The match is exact, case included.` : "") +
567
+ (near !== undefined
568
+ ? ` Did you mean ${pyRepr(near)}? It differs only in case and was not taken: the scan stopped before the end, where an exact match may still be.`
569
+ : "") +
545
570
  (candidates !== undefined ? ` It holds: ${candidates.map(pyRepr).join(", ")}.` : ""), {
546
571
  field, collection, by, value, scanned, pages: pages + 1, capped,
547
572
  ...(candidates !== undefined ? { candidates } : {}),
548
573
  ...(near !== undefined ? { near } : {}),
549
574
  });
550
575
  }
576
+ /**
577
+ * The record in `records` matching `value` on `by`: exact first, else the one
578
+ * case-only spelling (reported through `notes`), else `undefined`. Two
579
+ * case-only spellings are a refusal, as in the flat scan.
580
+ */
581
+ function findRecord(records, field, by, value, notes) {
582
+ const exact = records.find((record) => matchRecord(record, by, value));
583
+ if (exact !== undefined)
584
+ return exact;
585
+ const caseOnly = new Map();
586
+ for (const record of records) {
587
+ const at = readPath(record, by);
588
+ if (at === null || at === undefined)
589
+ continue;
590
+ const text = pyStrAny(at);
591
+ if (text !== value && text.toLowerCase() === value.toLowerCase() && !caseOnly.has(text))
592
+ caseOnly.set(text, record);
593
+ }
594
+ if (caseOnly.size === 0)
595
+ return undefined;
596
+ if (caseOnly.size > 1) {
597
+ throw new ResolutionRefusal(`${field}: no record has ${by}=${pyRepr(value)} exactly, and ${caseOnly.size} differ from it only in case: ` +
598
+ `${[...caseOnly.keys()].map(pyRepr).join(", ")}. They are different records; pass the one you mean exactly.`, "RESOLVE_CASE_AMBIGUOUS");
599
+ }
600
+ const [[text, record]] = [...caseOnly.entries()];
601
+ notes?.push(` ${field}: no record has ${by}=${pyRepr(value)} exactly; took ${pyRepr(text)}, the only one that differs from it only in case`);
602
+ return record;
603
+ }
551
604
  async function listAll(client, connectorKey, collection, connectionId, query) {
552
605
  const records = [];
553
606
  const scan = scanCollection(client, connectorKey, collection, connectionId, query);
@@ -602,7 +655,7 @@ function isContainer(record) {
602
655
  * connections. A path whose segment is missing from a listing read to the end
603
656
  * IS evidence, and is reported as an exhaustive miss like a flat scan's.
604
657
  */
605
- async function resolveInTree(client, connectorKey, spec, field, by, value, connectionId, resolved, rootCollection, rootQuery, send) {
658
+ async function resolveInTree(client, connectorKey, spec, field, by, value, connectionId, resolved, rootCollection, rootQuery, send, notes) {
606
659
  const childPath = pyStrAny(spec.childPath);
607
660
  const answer = (record) => {
608
661
  const got = readPath(record, send);
@@ -625,12 +678,14 @@ async function resolveInTree(client, connectorKey, spec, field, by, value, conne
625
678
  const exact = root.records.find((record) => matchRecord(record, by, value));
626
679
  if (exact !== undefined)
627
680
  return answer(exact);
681
+ // A whole-value case-only match at the root is deferred: a path value, or a
682
+ // bare name one level down, may still match exactly.
628
683
  const segments = value.split("/").map((segment) => pyStrip(segment)).filter((segment) => segment !== "");
629
684
  if (segments.length > 1) {
630
685
  let level = root;
631
686
  let where = rootCollection;
632
687
  for (const [i, segment] of segments.entries()) {
633
- const hit = level.records.find((record) => matchRecord(record, by, segment));
688
+ const hit = findRecord(level.records, field, by, segment, notes);
634
689
  if (hit === undefined) {
635
690
  const held = level.records.map((record) => pyStrAny(readPath(record, by))).filter((text) => text !== "None");
636
691
  const shown = !level.outcome.capped && held.length > 0 && held.length <= CANDIDATE_LIMIT ? ` It holds: ${held.map(pyRepr).join(", ")}.` : "";
@@ -651,6 +706,7 @@ async function resolveInTree(client, connectorKey, spec, field, by, value, conne
651
706
  }
652
707
  }
653
708
  // Bare name: one level below every root container.
709
+ const below = [];
654
710
  for (const parent of root.records) {
655
711
  if (!isContainer(parent))
656
712
  continue;
@@ -660,10 +716,16 @@ async function resolveInTree(client, connectorKey, spec, field, by, value, conne
660
716
  if (child === undefined)
661
717
  continue;
662
718
  const level = await list(child.collection, child.query);
663
- const hit = level.records.find((record) => matchRecord(record, by, value));
664
- if (hit !== undefined)
665
- return answer(hit);
719
+ const exactBelow = level.records.find((record) => matchRecord(record, by, value));
720
+ if (exactBelow !== undefined)
721
+ return answer(exactBelow);
722
+ below.push(...level.records);
666
723
  }
724
+ // No exact match at either depth: the case-only one, root and one level
725
+ // down together, so a spelling in each is two records and a refusal.
726
+ const loose = findRecord([...root.records, ...below], field, by, value, notes);
727
+ if (loose !== undefined)
728
+ return answer(loose);
667
729
  const top = root.records.map((record) => pyStrAny(readPath(record, by))).filter((text) => text !== "None");
668
730
  const example = `${top[0] ?? "<top-level>"}/<folder>/${value}`;
669
731
  throw new LookupExhausted(`${field}: no record in ${pyRepr(rootCollection)} or one level below it has ${by}=${pyRepr(value)} ` +
@@ -26,6 +26,8 @@ export interface PrepareConnectorOptions {
26
26
  connectionId?: string;
27
27
  /** Pick the connection by name when several match. */
28
28
  connection?: string;
29
+ /** `--connection-folder`: the folder the flow runs in, when several connections match. */
30
+ connectionFolder?: string;
29
31
  /** bindings file to add the discovered connection to. */
30
32
  bindings?: string;
31
33
  bindConnection?: string;
@@ -15,7 +15,8 @@ import { defaultUipClient } from "./uip/client.js";
15
15
  const DEFAULT_CONNECTOR_IMPORT = PACKAGE_NAME;
16
16
  const USAGE = [
17
17
  "Usage: flow-sdk registry prepare <connector-key> --action <name> [--connection-id <id>]",
18
- " [--connection <name>] [--bindings <file>] [--no-bindings]",
18
+ " [--connection <name>] [--connection-folder <folder>]",
19
+ " [--bindings <file>] [--no-bindings]",
19
20
  " [--object <name> | --all-objects] [-f NAME=VALUE ...]",
20
21
  " [--resolve FIELD:BY=VALUE ...] [--refresh-lookups]",
21
22
  " [--out <dir>] [--version <v>] [--import <specifier>]",
@@ -29,6 +30,7 @@ export function pipelineOptions(options) {
29
30
  action: options.action,
30
31
  connectionId: options.connectionId,
31
32
  connection: options.connection,
33
+ connectionFolder: options.connectionFolder,
32
34
  bindings: options.bindings,
33
35
  bindConnection: options.bindConnection,
34
36
  bindFolder: options.bindFolder,
@@ -101,6 +103,7 @@ export function parseArgs(argv) {
101
103
  for (const name of [
102
104
  "--connection-id",
103
105
  "--connection",
106
+ "--connection-folder",
104
107
  "--bindings",
105
108
  "--bind-connection",
106
109
  "--bind-folder",
@@ -147,6 +150,7 @@ export function parseArgs(argv) {
147
150
  action: positionals[1],
148
151
  connectionId: value(argv, "--connection-id"),
149
152
  connection: value(argv, "--connection"),
153
+ connectionFolder: value(argv, "--connection-folder"),
150
154
  bindings: value(argv, "--bindings"),
151
155
  bindConnection: value(argv, "--bind-connection"),
152
156
  bindFolder: value(argv, "--bind-folder"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uipath/maestro-builder-sdk",
3
- "version": "6.10.3",
3
+ "version": "6.11.1",
4
4
  "description": "Build UiPath Flow, Case, and BPMN artifacts by writing TypeScript.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://docs.uipath.com/maestro",
@@ -92,5 +92,5 @@
92
92
  "@types/node": "^22.7.0",
93
93
  "esbuild": "^0.28.1"
94
94
  },
95
- "gitref": "d3639fb590ef392d76c174521c9cc1d280f7125a"
95
+ "gitref": "85e9b8d11773bb7e83aed5deb38eea2ecd25bd2a"
96
96
  }