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.
- package/dist/orchestrator/edit-node-uncollapse.js +87 -0
- package/dist/orchestrator/edit-node-uncollapse.js.map +1 -0
- package/dist/orchestrator/get-errors-audit.js +569 -0
- package/dist/orchestrator/get-errors-audit.js.map +1 -0
- package/dist/orchestrator/panel-tools.js +187 -38
- package/dist/orchestrator/panel-tools.js.map +1 -1
- package/dist/services/process-control.js +50 -21
- package/dist/services/process-control.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|