@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/check.js +377 -118
- package/dist/core/actions.d.ts +20 -20
- package/dist/core/cli-spelling.d.ts +10 -0
- package/dist/core/cli-spelling.js +24 -0
- package/dist/core/hitl-routing.d.ts +129 -34
- package/dist/core/hitl-routing.js +97 -13
- package/dist/core/lookups.js +4 -2
- package/dist/core/step-ports.d.ts +8 -0
- package/dist/core/step-ports.js +37 -30
- package/dist/decompile.js +85 -14
- package/dist/registry/generators/markdown/sections/lookups.js +20 -5
- package/dist/registry/prepare/ledger.d.ts +3 -1
- package/dist/registry/prepare/ledger.js +55 -5
- package/dist/registry/prepare/messages.d.ts +8 -0
- package/dist/registry/prepare/messages.js +17 -0
- package/dist/registry/prepare/pipeline.js +4 -1
- package/dist/registry/prepare/resolve.d.ts +27 -2
- package/dist/registry/prepare/resolve.js +69 -7
- package/dist/serialize.js +259 -66
- package/lib/library-json.samples/index.json +18 -0
- package/lib/library-json.samples/uipath-getresponse-getresponse/send-newsletter@1.0.0.json +159 -0
- package/lib/library-json.samples/uipath-getresponse-getresponse/send-newsletter@1.0.0.v1def.json +323 -0
- package/lib/library-json.samples/uipath-salesforce-slack/create-group-direct-message@1.0.0.json +149 -0
- package/lib/library-json.samples/uipath-salesforce-slack/create-group-direct-message@1.0.0.v1def.json +237 -0
- package/package.json +2 -2
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
|
-
//
|
|
1421
|
-
//
|
|
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
|
|
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
|
|
2171
|
-
*
|
|
2172
|
-
* `
|
|
2173
|
-
*
|
|
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
|
-
|
|
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
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
135
|
+
const wire = pyStr(pyOr(f.name, authoringField(f)[0]));
|
|
121
136
|
const names = pyOr(strList(reference.lookupNames), ["id"]);
|
|
122
|
-
return ` --resolve ${
|
|
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
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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,
|
|
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
|
|
101
|
-
*
|
|
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
|
|
127
|
-
*
|
|
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.
|
|
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
|
-
|
|
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}
|
|
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
|
}
|