comfyui-mcp 0.52.46 → 0.52.47

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,87 @@
1
+ /**
2
+ * #2004 — panel_edit_node(collapsed:false) can reject a collapsed node's own
3
+ * stored height 0 ("size must be two positive numbers within a reasonable
4
+ * canvas range"). LiteGraph serializes a title-chip as size[1]===0; the panel
5
+ * then validates that stored pair when expanding if a size is supplied, and
6
+ * the verified workaround is to pass an explicit positive size.
7
+ *
8
+ * These helpers restore a usable [width, height] from the live query BEFORE
9
+ * graph_edit_node, so the caller does not have to re-supply geometry they
10
+ * never asked to change. Fail-open: an unreadable probe leaves the original
11
+ * command untouched.
12
+ *
13
+ * Fallback extents match create-group-membership / the panel's finiteExtent
14
+ * defaults (DEFAULT_NODE_WIDTH / DEFAULT_NODE_BODY_HEIGHT). full_height on a
15
+ * collapsed node is the 30px chip, not the pre-collapse body — do not use it.
16
+ */
17
+ import { DEFAULT_NODE_BODY_HEIGHT, DEFAULT_NODE_WIDTH, parseDetailNodesFromQueryText, } from "./create-group-membership.js";
18
+ export function pairSize(size) {
19
+ if (!Array.isArray(size) || size.length < 2)
20
+ return null;
21
+ const w = Number(size[0]);
22
+ const h = Number(size[1]);
23
+ return Number.isFinite(w) && Number.isFinite(h) ? [w, h] : null;
24
+ }
25
+ /** True when the stored pair would fail the panel's sizeSane (both > 0). */
26
+ export function needsUncollapseSizeRestore(size) {
27
+ const p = pairSize(size);
28
+ return !p || !(p[0] > 0 && p[1] > 0);
29
+ }
30
+ export function restoredUncollapseSize(size) {
31
+ const p = pairSize(size);
32
+ const w = p && p[0] > 0 ? p[0] : DEFAULT_NODE_WIDTH;
33
+ const h = p && p[1] > 0 ? p[1] : DEFAULT_NODE_BODY_HEIGHT;
34
+ return [w, h];
35
+ }
36
+ function parseToolJson(res) {
37
+ if (!res || res.isError)
38
+ return null;
39
+ const text = res.content.find((c) => c.type === "text")?.text;
40
+ if (typeof text !== "string")
41
+ return null;
42
+ try {
43
+ const parsed = JSON.parse(text);
44
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
45
+ ? parsed
46
+ : null;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * When uncollapsing a SINGLE node without an explicit size, query the live
54
+ * geometry and restore a positive size if the stored height is 0 (or otherwise
55
+ * not sizeSane). Bulk node_ids keep one shared size, so they are left alone.
56
+ * Never throws.
57
+ */
58
+ export async function sizeForUncollapse(args, call) {
59
+ if (args.collapsed !== false)
60
+ return undefined;
61
+ if (args.size !== undefined)
62
+ return undefined;
63
+ if (args.node_ids !== undefined)
64
+ return undefined;
65
+ if (args.node_id === undefined)
66
+ return undefined;
67
+ try {
68
+ const query = await call({
69
+ cmd: "graph_query",
70
+ ids: [args.node_id],
71
+ fields: "detail",
72
+ limit: 1,
73
+ }, 8000);
74
+ if (query.isError)
75
+ return undefined;
76
+ const nodes = parseDetailNodesFromQueryText(parseToolJson(query)?.text);
77
+ const wanted = String(args.node_id);
78
+ const node = nodes.find((n) => String(n.id) === wanted) ?? nodes[0];
79
+ if (!node || !needsUncollapseSizeRestore(node.size))
80
+ return undefined;
81
+ return restoredUncollapseSize(node.size);
82
+ }
83
+ catch {
84
+ return undefined;
85
+ }
86
+ }
87
+ //# sourceMappingURL=edit-node-uncollapse.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"edit-node-uncollapse.js","sourceRoot":"","sources":["../../src/orchestrator/edit-node-uncollapse.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,569 @@
1
+ /**
2
+ * #1973 — panel_get_errors must not present a clean `errored_count: 0` while
3
+ * its live combo scan still has nodes unchecked.
4
+ *
5
+ * The panel's graph_get_errors shares one elective server-call budget and
6
+ * gives the live combo scan only a 4 s STEP cap (GET_ERRORS_STEP_CAP_MS), so a
7
+ * ~77-node graph routinely returns `unchecked_budget_exhausted: true` with the
8
+ * sampler / decoder / assembler / SaveVideo still in `unchecked_nodes`, plus
9
+ * the clean-scan note. The orchestrator already waits 30 s for that reply, so
10
+ * after a budget-exhausted payload it finishes the leftover nodes from ONE
11
+ * batched `graph_get_object_info` plus a targeted `graph_query` — not another
12
+ * per-class round trip. If that completion cannot run, the reply still leads
13
+ * with `audit_complete: false` and checked/unchecked counts rather than a
14
+ * primary clean 0.
15
+ *
16
+ * TWO RULES THIS MODULE IS BUILT AROUND, both learned the expensive way:
17
+ *
18
+ * 1. ANY abstention makes the audit incomplete, not just a budget one. The
19
+ * panel abstains for five different reasons (live-combo-availability.js) and
20
+ * only two of them raise `unchecked_budget_exhausted`. The asset-probe cap,
21
+ * a failed /object_info lookup and an unanswered file check produce the
22
+ * SAME misread this issue reports — `errored_count: 0` first, "no errors
23
+ * recorded" attached — so completeness is judged from the abstention LIST,
24
+ * never from the budget flag alone.
25
+ *
26
+ * 2. This pass may retire an abstention; it may never manufacture a verdict the
27
+ * panel's own scanner would have refused to give. It has no /view probe, so
28
+ * on an UPLOAD input — the one option list ComfyUI structurally cannot
29
+ * enumerate (#1357/#387) — non-membership is not evidence of absence and the
30
+ * node stays unchecked. The upload-flag list here is ComfyUI's own
31
+ * (comfy_api/latest/_io.py `UploadType`), which is WIDER than the panel's:
32
+ * `UploadType.model` serialises as `file_upload`, and abstaining on a flag
33
+ * the panel misses is the fail-closed direction.
34
+ */
35
+ import { LIMIT_CEILING, MAX_CHARS_CEILING } from "../services/graph-query.js";
36
+ /**
37
+ * The abstentions that a second, batched read can actually retire: the two the
38
+ * panel raises when it runs out of the shared budget or its per-call class cap.
39
+ * Everything else it abstains for (a failed lookup, an unanswered file probe,
40
+ * the probe cap) is NOT retried here — but it still counts as an incomplete
41
+ * audit. See `isGetErrorsAuditIncomplete`.
42
+ */
43
+ const RETRYABLE_UNCHECKED_RE = /ran out of its shared server-call budget|lookup cap was reached/i;
44
+ const FILE_LIKE = /\.[A-Za-z0-9_]{2,12}$/;
45
+ /**
46
+ * ComfyUI's own upload-input flags, from `UploadType` in
47
+ * comfy_api/latest/_io.py — image/audio/video plus `file_upload`, which is what
48
+ * `UploadType.model` (Load3D, Load3DAnimation) serialises to. The panel's list
49
+ * omits `file_upload`; matching ComfyUI rather than the panel is deliberate,
50
+ * because every flag added here only ever makes this pass ABSTAIN more.
51
+ * `model_upload` is kept for panels/packs that spell it that way.
52
+ */
53
+ const UPLOAD_CONFIG_FLAGS = [
54
+ "image_upload",
55
+ "video_upload",
56
+ "audio_upload",
57
+ "model_upload",
58
+ "file_upload",
59
+ ];
60
+ export function parseToolResultJson(res) {
61
+ if (!res || res.isError)
62
+ return null;
63
+ const entry = res.content?.find((c) => c.type === "text");
64
+ const text = entry && entry.type === "text" ? entry.text : undefined;
65
+ if (typeof text !== "string")
66
+ return null;
67
+ try {
68
+ const parsed = JSON.parse(text);
69
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed)
70
+ ? parsed
71
+ : null;
72
+ }
73
+ catch {
74
+ return null;
75
+ }
76
+ }
77
+ function asUncheckedList(payload) {
78
+ return Array.isArray(payload.unchecked_nodes)
79
+ ? payload.unchecked_nodes
80
+ : [];
81
+ }
82
+ /** An abstention a second batched read has a chance of retiring. */
83
+ function isRetryableUnchecked(entry) {
84
+ return typeof entry?.reason === "string" && RETRYABLE_UNCHECKED_RE.test(entry.reason);
85
+ }
86
+ /** Distinct node ids the scan abstained on — same counting rule as the panel. */
87
+ export function uncheckedNodeCount(unchecked) {
88
+ return new Set(unchecked.map((u, i) => (u?.id == null ? `#${i}` : `id:${String(u.id)}`))).size;
89
+ }
90
+ /**
91
+ * Judged from the abstention LIST, not from the budget flag.
92
+ *
93
+ * `unchecked_budget_exhausted` covers only two of the panel's five abstention
94
+ * reasons. A scan stopped by the file-probe cap emits `unchecked_nodes` and
95
+ * `unchecked_asset_probe_limit` with NO budget flag, and the panel's own `clean`
96
+ * predicate does not consider abstentions at all — so that payload ships
97
+ * `errored_count: 0` plus "no errors recorded" next to a list of nodes nobody
98
+ * judged. That is the reported misread with a different cause, and the reporter
99
+ * asked for the completeness header "when ANY nodes are unchecked".
100
+ */
101
+ export function isGetErrorsAuditIncomplete(payload) {
102
+ if (payload.unchecked_budget_exhausted === true)
103
+ return true;
104
+ if (payload.unchecked_class_limit != null)
105
+ return true;
106
+ if (payload.unchecked_asset_probe_limit != null)
107
+ return true;
108
+ return asUncheckedList(payload).length > 0;
109
+ }
110
+ /**
111
+ * The option list for an input spec, in either schema form, or null when this
112
+ * pass has no authority over it.
113
+ *
114
+ * ComfyUI serialises a V1 combo as `[[...allowed], {cfg}]` and a V3 one as
115
+ * `["COMBO", {options: [...], ...}]` (`add_to_dict_v1` writes
116
+ * `(io_type, as_dict())`). Both are read here. A MULTISELECT combo is refused:
117
+ * its stored value is a selection of several options, so membership in the list
118
+ * is the wrong question and asking it reports every such widget as invalid.
119
+ */
120
+ function comboOptions(spec) {
121
+ if (!Array.isArray(spec) || spec.length === 0)
122
+ return null;
123
+ const cfg = spec[1] && typeof spec[1] === "object" && !Array.isArray(spec[1])
124
+ ? spec[1]
125
+ : null;
126
+ if (cfg?.multiselect)
127
+ return null;
128
+ const type = spec[0];
129
+ if (Array.isArray(type))
130
+ return type.filter((v) => typeof v === "string");
131
+ if (typeof type !== "string" || !/COMBO/i.test(type) || /DYNAMIC/i.test(type))
132
+ return null;
133
+ const opts = cfg?.options;
134
+ if (!Array.isArray(opts))
135
+ return null;
136
+ const strings = opts.filter((v) => typeof v === "string");
137
+ return strings.length === opts.length ? strings : null;
138
+ }
139
+ /**
140
+ * An UPLOAD input, whose valid values live on DISK and not only in the combo
141
+ * snapshot. Any truthy flag counts — the panel reads these the same way
142
+ * (`UPLOAD_CONFIG_FLAGS.some((f) => config[f])`), and a pack that writes `1`
143
+ * rather than `true` must not fall through to a hard verdict.
144
+ */
145
+ function isUploadCombo(spec) {
146
+ if (!Array.isArray(spec))
147
+ return false;
148
+ const cfg = spec[1];
149
+ if (!cfg || typeof cfg !== "object" || Array.isArray(cfg))
150
+ return false;
151
+ const c = cfg;
152
+ return UPLOAD_CONFIG_FLAGS.some((f) => !!c[f]);
153
+ }
154
+ function optionsLookLikeFiles(options) {
155
+ if (options.length === 0)
156
+ return false;
157
+ return options.filter((s) => FILE_LIKE.test(s)).length * 2 >= options.length;
158
+ }
159
+ function rewriteTextPayload(res, payload) {
160
+ const idx = res.content.findIndex((c) => c.type === "text");
161
+ if (idx < 0)
162
+ return res;
163
+ return {
164
+ ...res,
165
+ content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(payload, null, 2) } : c),
166
+ };
167
+ }
168
+ function parseQueryNodes(query) {
169
+ const out = [];
170
+ const take = (raw) => {
171
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
172
+ return;
173
+ const n = raw;
174
+ if (n.id == null)
175
+ return;
176
+ const type = typeof n.type === "string" ? n.type : typeof n.class_type === "string" ? n.class_type : "";
177
+ const widgets = n.widgets && typeof n.widgets === "object" && !Array.isArray(n.widgets)
178
+ ? n.widgets
179
+ : {};
180
+ const linkedInputs = new Set();
181
+ if (Array.isArray(n.inputs)) {
182
+ for (const rawInput of n.inputs) {
183
+ if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput))
184
+ continue;
185
+ const input = rawInput;
186
+ if (typeof input.name !== "string")
187
+ continue;
188
+ if (input.link != null || input.connected_from != null)
189
+ linkedInputs.add(input.name);
190
+ }
191
+ }
192
+ if (n.driven_by_link && typeof n.driven_by_link === "object" && !Array.isArray(n.driven_by_link)) {
193
+ for (const name of Object.keys(n.driven_by_link))
194
+ linkedInputs.add(name);
195
+ }
196
+ out.push({ id: n.id, type, widgets, linkedInputs });
197
+ };
198
+ if (Array.isArray(query.nodes))
199
+ for (const n of query.nodes)
200
+ take(n);
201
+ if (typeof query.text === "string") {
202
+ for (const line of query.text.split("\n")) {
203
+ const t = line.trim();
204
+ if (!t.startsWith("{"))
205
+ continue;
206
+ try {
207
+ take(JSON.parse(t));
208
+ }
209
+ catch {
210
+ /* a compact/ids line is not a detail row */
211
+ }
212
+ }
213
+ }
214
+ return out;
215
+ }
216
+ function objectInfoFromReply(reply) {
217
+ const info = reply.object_info;
218
+ if (info && typeof info === "object" && !Array.isArray(info) && Object.keys(info).length > 0) {
219
+ return info;
220
+ }
221
+ // A panel that returns the map at the top level (no wrapper) is still a schema.
222
+ const looksLikeDef = (v) => !!v && typeof v === "object" && !Array.isArray(v) && ("input" in v || "output" in v);
223
+ if (Object.values(reply).some(looksLikeDef) && !("cmd" in reply)) {
224
+ return reply;
225
+ }
226
+ return null;
227
+ }
228
+ function inputSpecsOf(def) {
229
+ if (!def || typeof def !== "object" || Array.isArray(def))
230
+ return {};
231
+ const input = def.input;
232
+ if (!input || typeof input !== "object")
233
+ return {};
234
+ return {
235
+ ...(input.required && typeof input.required === "object" && !Array.isArray(input.required)
236
+ ? input.required
237
+ : {}),
238
+ ...(input.optional && typeof input.optional === "object" && !Array.isArray(input.optional)
239
+ ? input.optional
240
+ : {}),
241
+ };
242
+ }
243
+ function judgeLeftoverCombos(leftover, nodes, objectInfo) {
244
+ const byId = new Map(nodes.map((n) => [String(n.id), n]));
245
+ const unavailable = [];
246
+ const stillUnchecked = [];
247
+ // One pass per leftover NODE: a budget skip is a node-level abstention, and
248
+ // judging its combos from the batched schema retires that skip.
249
+ const leftoverById = new Map();
250
+ for (const entry of leftover) {
251
+ if (entry?.id == null) {
252
+ stillUnchecked.push(entry);
253
+ continue;
254
+ }
255
+ leftoverById.set(String(entry.id), entry);
256
+ }
257
+ for (const [id, entry] of leftoverById) {
258
+ const node = byId.get(id);
259
+ const className = node?.type || (typeof entry.type === "string" ? entry.type : "");
260
+ if (!node || !className) {
261
+ stillUnchecked.push(entry);
262
+ continue;
263
+ }
264
+ // Own properties only. `objectInfo[className]` walks the prototype, so a
265
+ // leftover type named `toString` (or `constructor`, `valueOf`, …) against
266
+ // any ordinary non-empty /object_info map would resolve to a Function,
267
+ // look "found", yield no combo specs, and retire the node — audit_complete
268
+ // with unchecked_count:0 for a class that is not in the schema at all.
269
+ const def = Object.hasOwn(objectInfo, className) ? objectInfo[className] : undefined;
270
+ if (!def) {
271
+ stillUnchecked.push({
272
+ id: node.id,
273
+ type: className,
274
+ reason: "node type not found in /object_info",
275
+ });
276
+ continue;
277
+ }
278
+ const specs = inputSpecsOf(def);
279
+ const comboNames = Object.entries(specs)
280
+ .filter(([, spec]) => comboOptions(spec) !== null)
281
+ .map(([name]) => name);
282
+ // A linked combo is driven by its upstream value, not by the stored widget
283
+ // value. This completion pass has no execution-value probe, so it must not
284
+ // judge the stale/absent stored value as if it were live.
285
+ const linkedCombos = comboNames.filter((name) => node.linkedInputs.has(name));
286
+ if (linkedCombos.length > 0) {
287
+ stillUnchecked.push({
288
+ ...entry,
289
+ reason: `not checked: combo widget(s) ${linkedCombos.join(", ")} are driven by a live link`,
290
+ });
291
+ continue;
292
+ }
293
+ // A def with a combo input whose widget value is absent is an unread node,
294
+ // even when another non-combo widget (for example seed) is present. The old
295
+ // empty-map check retired { widgets: { seed: 1 }, linked checkpoint }.
296
+ const unreadCombos = comboNames.filter((name) => !Object.hasOwn(node.widgets, name));
297
+ if (unreadCombos.length > 0) {
298
+ stillUnchecked.push(entry);
299
+ continue;
300
+ }
301
+ for (const [name, value] of Object.entries(node.widgets)) {
302
+ const spec = specs[name];
303
+ const options = comboOptions(spec);
304
+ if (!options)
305
+ continue;
306
+ if (typeof value !== "string" || value === "")
307
+ continue;
308
+ // Upload inputs can accept a freshly uploaded root-level filename that
309
+ // never appears in /object_info's enumerated options. This pass has no
310
+ // /view probe, so every non-member on an upload combo is uncheckable —
311
+ // not a missing asset verdict.
312
+ if (isUploadCombo(spec) && !options.includes(value)) {
313
+ stillUnchecked.push({
314
+ id: node.id,
315
+ type: className,
316
+ widget: name,
317
+ value,
318
+ reason: "not checked: this value names a file below the input root (or under an [output]/[temp]/[input] annotation), which /object_info's combo list cannot enumerate",
319
+ });
320
+ continue;
321
+ }
322
+ if (options.includes(value))
323
+ continue;
324
+ unavailable.push({
325
+ id: node.id,
326
+ type: className,
327
+ widget: name,
328
+ value,
329
+ option_count: options.length,
330
+ kind: options.length === 0 || optionsLookLikeFiles(options) ? "missing_asset" : "invalid_value",
331
+ // WHO JUDGED THIS. Until #1973 every entry in unavailable_widget_values came
332
+ // from the panel's own scanner, so the field name carried an implied
333
+ // provenance. Appending here silently invalidates that: this pass reads the
334
+ // V3 `["COMBO", {...}]` form the panel's parseClassCombos structurally cannot
335
+ // see, and it has no /view probe to fall back on. Merging an unvetted verdict
336
+ // into a vetted list under the same field is the attribution defect — a reader
337
+ // that cannot tell which scanner spoke cannot weigh the answer, and a wrong
338
+ // entry here would be untraceable in a bug report.
339
+ source: "orchestrator_completion",
340
+ });
341
+ }
342
+ }
343
+ return { unavailable, stillUnchecked };
344
+ }
345
+ function mergeUnavailable(existing, extra) {
346
+ const cur = Array.isArray(existing) ? [...existing] : [];
347
+ const seen = new Set(cur.map((u) => JSON.stringify([u?.id, u?.widget, u?.value])));
348
+ for (const u of extra) {
349
+ const key = JSON.stringify([u.id, u.widget, u.value]);
350
+ if (seen.has(key))
351
+ continue;
352
+ seen.add(key);
353
+ cur.push(u);
354
+ }
355
+ return cur;
356
+ }
357
+ /**
358
+ * Every surface in this payload that contradicts "no errors recorded".
359
+ *
360
+ * STRUCTURAL ON PURPOSE — the predicate this replaces matched the panel's note
361
+ * prose (`/no errors recorded since the last execution/`), and that note ships
362
+ * through `tr()`. It is translated in all twelve bundled locales, so on a
363
+ * Japanese or French panel the match failed and the clean note survived beside
364
+ * a populated `unavailable_widget_values`: exactly the #399/#984
365
+ * self-contradiction, invisible to any English test.
366
+ */
367
+ function payloadHasDefects(payload) {
368
+ const nonEmpty = (k) => Array.isArray(payload[k]) && payload[k].length > 0;
369
+ if (typeof payload.errored_count === "number" && payload.errored_count > 0)
370
+ return true;
371
+ if (payload.node_errors != null && Object.keys(payload.node_errors).length > 0)
372
+ return true;
373
+ if (payload.last_execution_error != null)
374
+ return true;
375
+ if (typeof payload.missing_node_count === "number" && payload.missing_node_count > 0)
376
+ return true;
377
+ return (nonEmpty("unavailable_widget_values") ||
378
+ nonEmpty("missing_models") ||
379
+ nonEmpty("missing_media") ||
380
+ nonEmpty("missing_node_types") ||
381
+ nonEmpty("stale_placeholders"));
382
+ }
383
+ /**
384
+ * Put audit completeness FIRST so `errored_count: 0` cannot be read as a
385
+ * finished clean scan, and never ship the panel's "no errors recorded" note
386
+ * beside something that contradicts it.
387
+ */
388
+ export function presentGetErrorsAudit(payload) {
389
+ const incomplete = isGetErrorsAuditIncomplete(payload);
390
+ const unchecked = asUncheckedList(payload);
391
+ const uncheckedCount = uncheckedNodeCount(unchecked);
392
+ const nodeCount = typeof payload.node_count === "number" && Number.isFinite(payload.node_count)
393
+ ? payload.node_count
394
+ : null;
395
+ const checkedCount = nodeCount != null ? Math.max(0, nodeCount - uncheckedCount) : null;
396
+ const note = payload.note;
397
+ const rest = { ...payload };
398
+ delete rest.note;
399
+ delete rest.audit_complete;
400
+ delete rest.audit_incomplete_reason;
401
+ delete rest.checked_count;
402
+ delete rest.unchecked_count;
403
+ const out = {};
404
+ out.audit_complete = !incomplete;
405
+ if (incomplete) {
406
+ out.audit_incomplete_reason =
407
+ payload.unchecked_budget_exhausted === true
408
+ ? "get_errors ran out of its shared server-call budget"
409
+ : "some nodes were not judged before the scan stopped";
410
+ }
411
+ if (nodeCount != null)
412
+ out.node_count = nodeCount;
413
+ if (checkedCount != null)
414
+ out.checked_count = checkedCount;
415
+ out.unchecked_count = uncheckedCount;
416
+ if ("errored_count" in rest) {
417
+ out.errored_count = rest.errored_count;
418
+ delete rest.errored_count;
419
+ }
420
+ delete rest.node_count;
421
+ for (const [k, v] of Object.entries(rest)) {
422
+ if (!(k in out))
423
+ out[k] = v;
424
+ }
425
+ if (incomplete) {
426
+ const shown = typeof out.errored_count === "number" ? out.errored_count : 0;
427
+ out.note =
428
+ `AUDIT INCOMPLETE: ${uncheckedCount} node(s) were not checked. ` +
429
+ `errored_count (${shown}) counts only the nodes this scan judged — ` +
430
+ `it is not a clean bill of health for the rest.`;
431
+ }
432
+ else if (typeof note === "string" && !payloadHasDefects(payload)) {
433
+ out.note = note;
434
+ }
435
+ return out;
436
+ }
437
+ async function followUpJson(ctx, cmd, timeoutMs, primaryTabId) {
438
+ const tabBefore = ctx.tabId;
439
+ try {
440
+ const res = await ctx.call(cmd, timeoutMs);
441
+ const tabAfter = ctx.tabId;
442
+ return {
443
+ payload: parseToolResultJson(res),
444
+ stayedOnPrimaryTab: primaryTabId == null || (tabBefore === primaryTabId && tabAfter === primaryTabId),
445
+ };
446
+ }
447
+ catch {
448
+ return {
449
+ payload: null,
450
+ stayedOnPrimaryTab: primaryTabId == null || tabBefore === primaryTabId,
451
+ };
452
+ }
453
+ }
454
+ function viewingIdentity(payload) {
455
+ const raw = payload?.viewing;
456
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
457
+ return null;
458
+ const v = raw;
459
+ const keys = ["workflow_uuid", "workflow", "kind", "scope", "owner_node_id", "title"];
460
+ const identity = Object.fromEntries(keys.filter((key) => key in v).map((key) => [key, v[key]]));
461
+ return Object.keys(identity).length > 0 ? identity : null;
462
+ }
463
+ /**
464
+ * A follow-up may only retire leftovers when it identifies the same live graph
465
+ * as the primary read. Compare every stable identity field both replies expose;
466
+ * requiring a shared field makes an omitted/opaque identity fail closed.
467
+ */
468
+ function sameViewingIdentity(primary, followUp) {
469
+ const a = viewingIdentity(primary);
470
+ const b = viewingIdentity(followUp);
471
+ if (!a || !b)
472
+ return false;
473
+ // A UUID/path identity cannot be downgraded to a coincidentally equal root
474
+ // scope. If either side publishes one, both must publish the same value.
475
+ for (const key of ["workflow_uuid", "workflow"]) {
476
+ if (key in a || key in b) {
477
+ if (!(key in a) || !(key in b) || !Object.is(a[key], b[key]))
478
+ return false;
479
+ }
480
+ }
481
+ const shared = Object.keys(a).filter((key) => key in b);
482
+ return shared.length > 0 && shared.every((key) => Object.is(a[key], b[key]));
483
+ }
484
+ /**
485
+ * After a budget-exhausted graph_get_errors, finish leftover combo checks from
486
+ * one batched object_info + a targeted graph_query, then present completeness
487
+ * honestly. Never throws: a failed follow-up still returns the incomplete audit.
488
+ *
489
+ * A payload the panel already finished still goes through presentGetErrorsAudit.
490
+ * Completeness is not consistency: `errored_count: 0` plus a translated
491
+ * "no errors recorded" note beside `unavailable_widget_values` is a completed
492
+ * audit that still contradicts itself. Skipping the sanitizer because nobody
493
+ * was left unchecked is how that contradiction would ship.
494
+ */
495
+ export async function completeGetErrorsAudit(ctx, res, timeoutMs, primaryTabId) {
496
+ const payload = parseToolResultJson(res);
497
+ if (!payload)
498
+ return res;
499
+ if (!isGetErrorsAuditIncomplete(payload)) {
500
+ return rewriteTextPayload(res, presentGetErrorsAudit(payload));
501
+ }
502
+ const leftover = asUncheckedList(payload).filter(isRetryableUnchecked);
503
+ const leftoverIds = [
504
+ ...new Set(leftover.map((e) => e.id).filter((id) => id != null)),
505
+ ];
506
+ if (leftoverIds.length > 0) {
507
+ const [queryRead, infoRead] = await Promise.all([
508
+ followUpJson(ctx, {
509
+ cmd: "graph_query",
510
+ ids: leftoverIds,
511
+ fields: "detail",
512
+ limit: LIMIT_CEILING,
513
+ max_chars: MAX_CHARS_CEILING,
514
+ }, timeoutMs, primaryTabId),
515
+ followUpJson(ctx, { cmd: "graph_get_object_info" }, timeoutMs, primaryTabId),
516
+ ]);
517
+ const query = queryRead.payload;
518
+ const infoReply = infoRead.payload;
519
+ const objectInfo = infoReply ? objectInfoFromReply(infoReply) : null;
520
+ const nodes = query ? parseQueryNodes(query) : [];
521
+ const sameGraph = queryRead.stayedOnPrimaryTab &&
522
+ infoRead.stayedOnPrimaryTab &&
523
+ sameViewingIdentity(payload, query);
524
+ if (sameGraph && objectInfo && nodes.length > 0) {
525
+ const judged = judgeLeftoverCombos(leftover, nodes, objectInfo);
526
+ // Non-retryable abstentions (the probe cap, a failed lookup, an unenumerable
527
+ // path the panel already disclosed) stay; retryable leftovers are replaced by
528
+ // whatever this pass still could not judge.
529
+ const nextUnchecked = [
530
+ ...asUncheckedList(payload).filter((e) => !isRetryableUnchecked(e)),
531
+ ...judged.stillUnchecked,
532
+ ];
533
+ payload.unchecked_nodes = nextUnchecked;
534
+ // The panel's note counts the PRE-completion list. Drop it rather than
535
+ // leave "NOT CHECKED: 40" next to a shorter remainder.
536
+ delete payload.unchecked_nodes_note;
537
+ if (nextUnchecked.length === 0) {
538
+ delete payload.unchecked_nodes;
539
+ }
540
+ const stillRetryable = nextUnchecked.some(isRetryableUnchecked);
541
+ if (!stillRetryable) {
542
+ delete payload.unchecked_budget_exhausted;
543
+ delete payload.unchecked_class_limit;
544
+ }
545
+ if (judged.unavailable.length) {
546
+ const merged = mergeUnavailable(payload.unavailable_widget_values, judged.unavailable);
547
+ const added = merged.length - (Array.isArray(payload.unavailable_widget_values)
548
+ ? payload.unavailable_widget_values.length
549
+ : 0);
550
+ payload.unavailable_widget_values = merged;
551
+ // The panel's note carries a COUNT of the pre-completion list
552
+ // (comboAvailabilityNote), so appending to the list without replacing it
553
+ // ships "1 widget value(s)" above two entries — the same stale-count
554
+ // defect the unchecked_nodes_note delete above exists to avoid.
555
+ if (added > 0) {
556
+ payload.unavailable_widget_values_note =
557
+ `LIVE SCAN + ORCHESTRATOR COMPLETION: ${merged.length} widget value(s) the server ` +
558
+ `does not offer, ${added} of them judged after the panel's scan ran out of budget, ` +
559
+ `from one batched /object_info read and marked source:"orchestrator_completion". ` +
560
+ `Values on an UPLOAD input that the combo list cannot enumerate were NOT judged ` +
561
+ `here — those stay in unchecked_nodes.`;
562
+ }
563
+ }
564
+ payload.audit_completed_by = "orchestrator";
565
+ }
566
+ }
567
+ return rewriteTextPayload(res, presentGetErrorsAudit(payload));
568
+ }
569
+ //# sourceMappingURL=get-errors-audit.js.map