comfyui-mcp
Version:
Local-first, agent-native control plane for ComfyUI — MCP server + autonomous sidebar agent that drives your live graph in natural language on ANY LLM: Claude/ChatGPT/Gemini on your subscription (no API key), free local models via Ollama (fully offline),
753 lines • 34.5 kB
JavaScript
/**
* #1973 — panel_get_errors must not present a clean `errored_count: 0` while
* its live combo scan still has nodes unchecked.
*
* The panel's graph_get_errors shares one elective server-call budget and
* gives the live combo scan only a 4 s STEP cap (GET_ERRORS_STEP_CAP_MS), so a
* ~77-node graph routinely returns `unchecked_budget_exhausted: true` with the
* sampler / decoder / assembler / SaveVideo still in `unchecked_nodes`, plus
* the clean-scan note. The orchestrator already waits 30 s for that reply, so
* after a budget-exhausted payload it finishes the leftover nodes from ONE
* batched `graph_get_object_info` plus bounded explicit-ID `graph_query` pages —
* not another per-class round trip. If that completion cannot run, the reply still leads
* with `audit_complete: false` and checked/unchecked counts rather than a
* primary clean 0.
*
* TWO RULES THIS MODULE IS BUILT AROUND, both learned the expensive way:
*
* 1. ANY abstention makes the audit incomplete, not just a budget one. The
* panel abstains for five different reasons (live-combo-availability.js) and
* only two of them raise `unchecked_budget_exhausted`. The asset-probe cap,
* a failed /object_info lookup and an unanswered file check produce the
* SAME misread this issue reports — `errored_count: 0` first, "no errors
* recorded" attached — so completeness is judged from the abstention LIST,
* never from the budget flag alone.
*
* 2. This pass may retire an abstention; it may never manufacture a verdict the
* panel's own scanner would have refused to give. It has no /view probe, so
* on an UPLOAD input — the one option list ComfyUI structurally cannot
* enumerate (#1357/#387) — non-membership is not evidence of absence and the
* node stays unchecked. The upload-flag list here is ComfyUI's own
* (comfy_api/latest/_io.py `UploadType`), which is WIDER than the panel's:
* `UploadType.model` serialises as `file_upload`, and abstaining on a flag
* the panel misses is the fail-closed direction.
*/
import { LIMIT_CEILING, MAX_CHARS_CEILING } from "../services/graph-query.js";
/**
* The abstentions that a second, batched read can actually retire: the two the
* panel raises when it runs out of the shared budget or its per-call class cap.
* Everything else it abstains for (a failed lookup, an unanswered file probe,
* the probe cap) is NOT retried here — but it still counts as an incomplete
* audit. See `isGetErrorsAuditIncomplete`.
*/
const RETRYABLE_UNCHECKED_RE = /ran out of its shared server-call budget|lookup cap was reached/i;
const FILE_LIKE = /\.[A-Za-z0-9_]{2,12}$/;
/**
* `graph_query` has no cursor/offset. Explicit-ID pages are therefore the only
* pagination available to this completion pass, and keeping each detail page
* small leaves room under its 60,000-character ceiling for ordinary workflows.
* Pages are issued in graph order under one aggregate deadline; the graph-query
* ceiling also bounds the pass at 200 leftover ids.
*/
const GET_ERRORS_QUERY_PAGE_SIZE = 10;
/**
* ComfyUI's own upload-input flags, from `UploadType` in
* comfy_api/latest/_io.py — image/audio/video plus `file_upload`, which is what
* `UploadType.model` (Load3D, Load3DAnimation) serialises to. The panel's list
* omits `file_upload`; matching ComfyUI rather than the panel is deliberate,
* because every flag added here only ever makes this pass ABSTAIN more.
* `model_upload` is kept for panels/packs that spell it that way.
*/
const UPLOAD_CONFIG_FLAGS = [
"image_upload",
"video_upload",
"audio_upload",
"model_upload",
"file_upload",
];
const GET_ERRORS_DEADLINE = Symbol("get-errors-completion-deadline");
export function parseToolResultJson(res) {
if (!res || res.isError)
return null;
const entry = res.content?.find((c) => c.type === "text");
const text = entry && entry.type === "text" ? entry.text : undefined;
if (typeof text !== "string")
return null;
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === "object" && !Array.isArray(parsed)
? parsed
: null;
}
catch {
return null;
}
}
function asUncheckedList(payload) {
return Array.isArray(payload.unchecked_nodes)
? payload.unchecked_nodes
: [];
}
/** Red outlines are normally cosmetic, but a stale LoadImage outline can hide a
* current combo value that the panel would refuse to write. Keep this separate from
* unchecked_nodes: a stale flag is not itself an abstention and must remain untouched
* unless a fresh same-view graph/schema read proves the value is unavailable. */
function asStaleFlags(payload) {
return Array.isArray(payload.stale_flags)
? payload.stale_flags
: [];
}
/** An abstention a second batched read has a chance of retiring. */
function isRetryableUnchecked(entry) {
return typeof entry?.reason === "string" && RETRYABLE_UNCHECKED_RE.test(entry.reason);
}
function chunks(values, size) {
const out = [];
for (let i = 0; i < values.length; i += size)
out.push(values.slice(i, i + size));
return out;
}
/** Distinct node ids the scan abstained on — same counting rule as the panel. */
export function uncheckedNodeCount(unchecked) {
return new Set(unchecked.map((u, i) => (u?.id == null ? `#${i}` : `id:${String(u.id)}`))).size;
}
/**
* Judged from the abstention LIST, not from the budget flag.
*
* `unchecked_budget_exhausted` covers only two of the panel's five abstention
* reasons. A scan stopped by the file-probe cap emits `unchecked_nodes` and
* `unchecked_asset_probe_limit` with NO budget flag, and the panel's own `clean`
* predicate does not consider abstentions at all — so that payload ships
* `errored_count: 0` plus "no errors recorded" next to a list of nodes nobody
* judged. That is the reported misread with a different cause, and the reporter
* asked for the completeness header "when ANY nodes are unchecked".
*/
export function isGetErrorsAuditIncomplete(payload) {
if (payload.unchecked_budget_exhausted === true)
return true;
if (payload.unchecked_class_limit != null)
return true;
if (payload.unchecked_asset_probe_limit != null)
return true;
return asUncheckedList(payload).length > 0;
}
/**
* The option list for an input spec, in either schema form, or null when this
* pass has no authority over it.
*
* ComfyUI serialises a V1 combo as `[[...allowed], {cfg}]` and a V3 one as
* `["COMBO", {options: [...], ...}]` (`add_to_dict_v1` writes
* `(io_type, as_dict())`). Both are read here. A MULTISELECT combo is refused:
* its stored value is a selection of several options, so membership in the list
* is the wrong question and asking it reports every such widget as invalid.
*/
function comboOptions(spec) {
if (!Array.isArray(spec) || spec.length === 0)
return null;
const cfg = spec[1] && typeof spec[1] === "object" && !Array.isArray(spec[1])
? spec[1]
: null;
if (cfg?.multiselect)
return null;
const type = spec[0];
if (Array.isArray(type))
return type.filter((v) => typeof v === "string");
if (typeof type !== "string" || !/COMBO/i.test(type) || /DYNAMIC/i.test(type))
return null;
const opts = cfg?.options;
if (!Array.isArray(opts))
return null;
const strings = opts.filter((v) => typeof v === "string");
return strings.length === opts.length ? strings : null;
}
/**
* An UPLOAD input, whose valid values live on DISK and not only in the combo
* snapshot. Any truthy flag counts — the panel reads these the same way
* (`UPLOAD_CONFIG_FLAGS.some((f) => config[f])`), and a pack that writes `1`
* rather than `true` must not fall through to a hard verdict.
*/
function isUploadCombo(spec) {
if (!Array.isArray(spec))
return false;
const cfg = spec[1];
if (!cfg || typeof cfg !== "object" || Array.isArray(cfg))
return false;
const c = cfg;
return UPLOAD_CONFIG_FLAGS.some((f) => !!c[f]);
}
function optionsLookLikeFiles(options) {
if (options.length === 0)
return false;
return options.filter((s) => FILE_LIKE.test(s)).length * 2 >= options.length;
}
/**
* Upload combos can carry paths that the combo list deliberately cannot
* enumerate. The panel's /view probe is the authority for those values, so a
* stale outline must remain unknown until that evidence exists. Root-level
* names are different: those are the values the refreshed list is expected to
* enumerate, which is the evidence #2587 needs for the missing-value report.
*/
function isNonEnumerableUploadPath(value) {
return /^\[(?:input|output|temp)\]/i.test(value) || /[\\/]/.test(value);
}
function rewriteTextPayload(res, payload) {
const idx = res.content.findIndex((c) => c.type === "text");
if (idx < 0)
return res;
return {
...res,
content: res.content.map((c, i) => i === idx && c.type === "text" ? { ...c, text: JSON.stringify(payload, null, 2) } : c),
};
}
function parseQueryNodes(query) {
const out = [];
const take = (raw) => {
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return;
const n = raw;
if (n.id == null)
return;
const type = typeof n.type === "string" ? n.type : typeof n.class_type === "string" ? n.class_type : "";
const widgets = n.widgets && typeof n.widgets === "object" && !Array.isArray(n.widgets)
? n.widgets
: {};
const linkedInputs = new Set();
if (Array.isArray(n.inputs)) {
for (const rawInput of n.inputs) {
if (!rawInput || typeof rawInput !== "object" || Array.isArray(rawInput))
continue;
const input = rawInput;
if (typeof input.name !== "string")
continue;
if (input.link != null || input.connected_from != null)
linkedInputs.add(input.name);
}
}
if (n.driven_by_link && typeof n.driven_by_link === "object" && !Array.isArray(n.driven_by_link)) {
for (const name of Object.keys(n.driven_by_link))
linkedInputs.add(name);
}
out.push({ id: n.id, type, widgets, linkedInputs });
};
if (Array.isArray(query.nodes))
for (const n of query.nodes)
take(n);
if (typeof query.text === "string") {
for (const line of query.text.split("\n")) {
const t = line.trim();
if (!t.startsWith("{"))
continue;
try {
take(JSON.parse(t));
}
catch {
/* a compact/ids line is not a detail row */
}
}
}
return out;
}
function objectInfoFromReply(reply) {
const info = reply.object_info;
if (info && typeof info === "object" && !Array.isArray(info) && Object.keys(info).length > 0) {
return info;
}
// A panel that returns the map at the top level (no wrapper) is still a schema.
const looksLikeDef = (v) => !!v && typeof v === "object" && !Array.isArray(v) && ("input" in v || "output" in v);
if (Object.values(reply).some(looksLikeDef) && !("cmd" in reply)) {
return reply;
}
return null;
}
function inputSpecsOf(def) {
if (!def || typeof def !== "object" || Array.isArray(def))
return {};
const input = def.input;
if (!input || typeof input !== "object")
return {};
return {
...(input.required && typeof input.required === "object" && !Array.isArray(input.required)
? input.required
: {}),
...(input.optional && typeof input.optional === "object" && !Array.isArray(input.optional)
? input.optional
: {}),
};
}
function judgeLeftoverCombos(leftover, nodes, objectInfo) {
const byId = new Map(nodes.map((n) => [String(n.id), n]));
const unavailable = [];
const stillUnchecked = [];
// One pass per leftover NODE: a budget skip is a node-level abstention, and
// judging its combos from the batched schema retires that skip.
const leftoverById = new Map();
for (const entry of leftover) {
if (entry?.id == null) {
stillUnchecked.push(entry);
continue;
}
leftoverById.set(String(entry.id), entry);
}
for (const [id, entry] of leftoverById) {
const node = byId.get(id);
const className = node?.type || (typeof entry.type === "string" ? entry.type : "");
if (!node || !className) {
stillUnchecked.push(entry);
continue;
}
// Own properties only. `objectInfo[className]` walks the prototype, so a
// leftover type named `toString` (or `constructor`, `valueOf`, …) against
// any ordinary non-empty /object_info map would resolve to a Function,
// look "found", yield no combo specs, and retire the node — audit_complete
// with unchecked_count:0 for a class that is not in the schema at all.
const def = Object.hasOwn(objectInfo, className) ? objectInfo[className] : undefined;
if (!def) {
stillUnchecked.push({
id: node.id,
type: className,
reason: "node type not found in /object_info",
});
continue;
}
const specs = inputSpecsOf(def);
const comboNames = Object.entries(specs)
.filter(([, spec]) => comboOptions(spec) !== null)
.map(([name]) => name);
// A linked combo is driven by its upstream value, not by the stored widget
// value. This completion pass has no execution-value probe, so it must not
// judge the stale/absent stored value as if it were live.
const linkedCombos = comboNames.filter((name) => node.linkedInputs.has(name));
if (linkedCombos.length > 0) {
stillUnchecked.push({
...entry,
reason: `not checked: combo widget(s) ${linkedCombos.join(", ")} are driven by a live link`,
});
continue;
}
// A def with a combo input whose widget value is absent is an unread node,
// even when another non-combo widget (for example seed) is present. The old
// empty-map check retired { widgets: { seed: 1 }, linked checkpoint }.
const unreadCombos = comboNames.filter((name) => !Object.hasOwn(node.widgets, name));
if (unreadCombos.length > 0) {
stillUnchecked.push(entry);
continue;
}
for (const [name, value] of Object.entries(node.widgets)) {
const spec = specs[name];
const options = comboOptions(spec);
if (!options)
continue;
if (typeof value !== "string" || value === "")
continue;
// Upload inputs can accept a freshly uploaded root-level filename that
// never appears in /object_info's enumerated options. This pass has no
// /view probe, so every non-member on an upload combo is uncheckable —
// not a missing asset verdict.
if (isUploadCombo(spec) && !options.includes(value)) {
stillUnchecked.push({
id: node.id,
type: className,
widget: name,
value,
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",
});
continue;
}
if (options.includes(value))
continue;
unavailable.push({
id: node.id,
type: className,
widget: name,
value,
option_count: options.length,
kind: options.length === 0 || optionsLookLikeFiles(options) ? "missing_asset" : "invalid_value",
// WHO JUDGED THIS. Until #1973 every entry in unavailable_widget_values came
// from the panel's own scanner, so the field name carried an implied
// provenance. Appending here silently invalidates that: this pass reads the
// V3 `["COMBO", {...}]` form the panel's parseClassCombos structurally cannot
// see, and it has no /view probe to fall back on. Merging an unvetted verdict
// into a vetted list under the same field is the attribution defect — a reader
// that cannot tell which scanner spoke cannot weigh the answer, and a wrong
// entry here would be untraceable in a bug report.
source: "orchestrator_completion",
});
}
}
return { unavailable, stillUnchecked };
}
/**
* #2587 — a panel can leave a red LoadImage under `stale_flags` after its live
* scan has otherwise completed. A fresh whole `/object_info` plus a detail read
* of the same graph can turn that particular cosmetic flag into an actionable
* unavailable widget finding. This is deliberately narrower than the budget
* completion scanner: only the concrete LoadImage.image combo is reclassified,
* and only when both reads prove the current node and current view.
*
* Upload-input paths that are not enumerated by `/object_info` stay unknown here:
* only the panel's `/view`/existence evidence can distinguish a valid nested or
* annotated path from a missing file. A root-level value absent from the refreshed
* list is the #2587 case. Unreadable schema, missing node detail, a linked image
* input, and a value that is present in the list all preserve the original stale
* flag.
*/
function judgeStaleLoadImages(staleFlags, nodes, objectInfo) {
const byId = new Map(nodes.map((n) => [String(n.id), n]));
const unavailable = [];
const reclassifiedIds = new Set();
for (const flag of staleFlags) {
if (flag?.id == null || flag.type !== "LoadImage")
continue;
const node = byId.get(String(flag.id));
if (!node || node.type !== "LoadImage" || node.linkedInputs.has("image"))
continue;
const def = Object.hasOwn(objectInfo, "LoadImage") ? objectInfo.LoadImage : undefined;
const specs = inputSpecsOf(def);
const options = comboOptions(specs.image);
if (!options || !Object.hasOwn(node.widgets, "image"))
continue;
const value = node.widgets.image;
if (typeof value !== "string" || value === "")
continue;
if (options.includes(value))
continue;
if (isUploadCombo(specs.image) && isNonEnumerableUploadPath(value))
continue;
unavailable.push({
id: node.id,
type: "LoadImage",
widget: "image",
value,
option_count: options.length,
kind: options.length === 0 || optionsLookLikeFiles(options) ? "missing_asset" : "invalid_value",
source: "orchestrator_completion",
});
reclassifiedIds.add(String(flag.id));
}
return { unavailable, reclassifiedIds };
}
function mergeUnavailable(existing, extra) {
const cur = Array.isArray(existing) ? [...existing] : [];
const seen = new Set(cur.map((u) => JSON.stringify([u?.id, u?.widget, u?.value])));
for (const u of extra) {
const key = JSON.stringify([u.id, u.widget, u.value]);
if (seen.has(key))
continue;
seen.add(key);
cur.push(u);
}
return cur;
}
/**
* Every surface in this payload that contradicts "no errors recorded".
*
* STRUCTURAL ON PURPOSE — the predicate this replaces matched the panel's note
* prose (`/no errors recorded since the last execution/`), and that note ships
* through `tr()`. It is translated in all twelve bundled locales, so on a
* Japanese or French panel the match failed and the clean note survived beside
* a populated `unavailable_widget_values`: exactly the #399/#984
* self-contradiction, invisible to any English test.
*/
function payloadHasDefects(payload) {
const nonEmpty = (k) => Array.isArray(payload[k]) && payload[k].length > 0;
if (typeof payload.errored_count === "number" && payload.errored_count > 0)
return true;
if (payload.node_errors != null && Object.keys(payload.node_errors).length > 0)
return true;
if (payload.last_execution_error != null)
return true;
if (typeof payload.missing_node_count === "number" && payload.missing_node_count > 0)
return true;
return (nonEmpty("unavailable_widget_values") ||
nonEmpty("missing_models") ||
nonEmpty("missing_media") ||
nonEmpty("missing_node_types") ||
nonEmpty("stale_placeholders"));
}
/**
* Put audit completeness FIRST so `errored_count: 0` cannot be read as a
* finished clean scan, and never ship the panel's "no errors recorded" note
* beside something that contradicts it.
*/
export function presentGetErrorsAudit(payload) {
const incomplete = isGetErrorsAuditIncomplete(payload);
const unchecked = asUncheckedList(payload);
const uncheckedCount = uncheckedNodeCount(unchecked);
const nodeCount = typeof payload.node_count === "number" && Number.isFinite(payload.node_count)
? payload.node_count
: null;
const checkedCount = nodeCount != null ? Math.max(0, nodeCount - uncheckedCount) : null;
const note = payload.note;
const rest = { ...payload };
delete rest.note;
delete rest.audit_complete;
delete rest.audit_incomplete_reason;
delete rest.checked_count;
delete rest.unchecked_count;
const out = {};
out.audit_complete = !incomplete;
if (incomplete) {
out.audit_incomplete_reason =
payload.unchecked_budget_exhausted === true
? "get_errors ran out of its shared server-call budget"
: "some nodes were not judged before the scan stopped";
}
if (nodeCount != null)
out.node_count = nodeCount;
if (checkedCount != null)
out.checked_count = checkedCount;
out.unchecked_count = uncheckedCount;
if ("errored_count" in rest) {
out.errored_count = rest.errored_count;
delete rest.errored_count;
}
delete rest.node_count;
for (const [k, v] of Object.entries(rest)) {
if (!(k in out))
out[k] = v;
}
if (incomplete) {
const shown = typeof out.errored_count === "number" ? out.errored_count : 0;
out.note =
`AUDIT INCOMPLETE: ${uncheckedCount} node(s) were not checked. ` +
`errored_count (${shown}) counts only the nodes this scan judged — ` +
`it is not a clean bill of health for the rest.`;
}
else if (typeof note === "string" && !payloadHasDefects(payload)) {
out.note = note;
}
return out;
}
async function followUpJson(ctx, cmd, timeoutMs, deadlineAt, primaryTabId) {
const tabBefore = ctx.tabId;
const remaining = Math.min(timeoutMs, deadlineAt - Date.now());
if (!(remaining > 0)) {
return {
payload: null,
stayedOnPrimaryTab: primaryTabId == null || tabBefore === primaryTabId,
};
}
let timer;
try {
const timeout = new Promise((resolve) => {
timer = setTimeout(() => resolve(GET_ERRORS_DEADLINE), remaining);
});
const res = await Promise.race([ctx.call(cmd, remaining), timeout]);
if (res === GET_ERRORS_DEADLINE) {
return {
payload: null,
stayedOnPrimaryTab: primaryTabId == null || tabBefore === primaryTabId,
};
}
const tabAfter = ctx.tabId;
return {
payload: parseToolResultJson(res),
stayedOnPrimaryTab: primaryTabId == null || (tabBefore === primaryTabId && tabAfter === primaryTabId),
};
}
catch {
return {
payload: null,
stayedOnPrimaryTab: primaryTabId == null || tabBefore === primaryTabId,
};
}
finally {
if (timer)
clearTimeout(timer);
}
}
function viewingIdentity(payload) {
const raw = payload?.viewing;
if (!raw || typeof raw !== "object" || Array.isArray(raw))
return null;
const v = raw;
const keys = [
"workflow_uuid",
"workflow",
"graph_identity",
"kind",
"scope",
"owner_node_id",
"title",
];
const identity = Object.fromEntries(keys.filter((key) => key in v).map((key) => [key, v[key]]));
return Object.keys(identity).length > 0 ? identity : null;
}
function queryReplyWasTruncated(payload) {
if (!payload)
return true;
if (payload.truncated === true)
return true;
return (typeof payload.matched === "number" &&
typeof payload.shown === "number" &&
payload.shown < payload.matched);
}
/**
* A follow-up may only retire leftovers when it identifies the same live graph
* as the primary read. Compare every stable identity field both replies expose;
* requiring a shared field makes an omitted/opaque identity fail closed.
*/
function sameViewingIdentity(primary, followUp) {
const a = viewingIdentity(primary);
const b = viewingIdentity(followUp);
if (!a || !b)
return false;
// Scope and owner/title are not workflow-instance identity. A tab can switch
// from root workflow A to root workflow B, or from subgraph A to a same-shaped
// subgraph B, without changing the tab id. The panel's graph replies publish
// the root workflow_uuid for this purpose; without it, never retire A's
// leftovers from an opaque follow-up read.
if (typeof a.workflow_uuid !== "string" || typeof b.workflow_uuid !== "string")
return false;
if (!Object.is(a.workflow_uuid, b.workflow_uuid))
return false;
// A path is useful corroboration when both replies publish one, but it cannot
// replace the per-instance UUID: the same saved path may be reopened in place.
if ("workflow" in a || "workflow" in b) {
if (!("workflow" in a) || !("workflow" in b) || !Object.is(a.workflow, b.workflow))
return false;
}
// Ownerless subgraphs in one workflow are still distinct graphs. A graph
// replacement/reconnect can also retain the workflow UUID while changing
// this object-keyed identity, so never let an omitted or mismatched witness
// retire the primary read's leftovers.
if ("graph_identity" in a || "graph_identity" in b) {
if (typeof a.graph_identity !== "string" ||
typeof b.graph_identity !== "string" ||
a.graph_identity.length === 0 ||
b.graph_identity.length === 0 ||
!Object.is(a.graph_identity, b.graph_identity)) {
return false;
}
}
const shared = Object.keys(a).filter((key) => key in b);
return shared.length > 0 && shared.every((key) => Object.is(a[key], b[key]));
}
/**
* After a graph_get_errors, finish retryable combo checks and stale LoadImage
* reclassification from one batched object_info + bounded targeted graph_query
* pages, then present completeness honestly. Never throws: a failed follow-up
* leaves the original audit untouched.
*
* A payload the panel already finished still goes through presentGetErrorsAudit.
* Completeness is not consistency: `errored_count: 0` plus a translated
* "no errors recorded" note beside `unavailable_widget_values` is a completed
* audit that still contradicts itself. Skipping the sanitizer because nobody
* was left unchecked is how that contradiction would ship.
*/
export async function completeGetErrorsAudit(ctx, res, timeoutMs, primaryTabId) {
const payload = parseToolResultJson(res);
if (!payload)
return res;
const staleLoadImageFlags = asStaleFlags(payload).filter((entry) => entry?.type === "LoadImage");
if (!isGetErrorsAuditIncomplete(payload) && staleLoadImageFlags.length === 0) {
return rewriteTextPayload(res, presentGetErrorsAudit(payload));
}
const leftover = asUncheckedList(payload).filter(isRetryableUnchecked);
const leftoverIds = [
...new Set(leftover.map((e) => e.id).filter((id) => id != null)),
];
const staleLoadImageIds = staleLoadImageFlags
.map((entry) => entry.id)
.filter((id) => id != null);
const candidateIds = [
...new Map([...leftoverIds, ...staleLoadImageIds]
.filter((id) => id != null)
.map((id) => [String(id), id])).values(),
];
if (candidateIds.length > 0) {
// graph_query has no cursor/offset, and a single detail reply can be cut by
// max_chars even when its limit is high. Page by explicit ids so every normal
// 50-node workflow gets a complete detail read within one bounded follow-up
// pass. A truncated page stays wholly unchecked below; it must never silently
// retire only the rows that happened to fit in the reply.
const deadlineAt = Date.now() + Math.max(0, timeoutMs);
const infoRead = await followUpJson(ctx, { cmd: "graph_get_object_info" }, timeoutMs, deadlineAt, primaryTabId);
const infoReply = infoRead.payload;
const objectInfo = infoReply ? objectInfoFromReply(infoReply) : null;
const queryReads = [];
if (objectInfo && infoRead.stayedOnPrimaryTab) {
for (const ids of chunks(candidateIds.slice(0, LIMIT_CEILING), GET_ERRORS_QUERY_PAGE_SIZE)) {
const read = await followUpJson(ctx, {
cmd: "graph_query",
ids,
fields: "detail",
limit: Math.min(LIMIT_CEILING, ids.length),
max_chars: MAX_CHARS_CEILING,
}, timeoutMs, deadlineAt, primaryTabId);
queryReads.push({ ids, read });
if (Date.now() >= deadlineAt)
break;
}
}
const incompletePageIds = new Set(candidateIds.slice(LIMIT_CEILING).map((id) => String(id)));
const nodes = queryReads.flatMap(({ ids, read }) => {
if (queryReplyWasTruncated(read.payload)) {
for (const id of ids)
incompletePageIds.add(String(id));
}
return read.payload ? parseQueryNodes(read.payload) : [];
});
const sameGraph = queryReads.every(({ read }) => read.stayedOnPrimaryTab && sameViewingIdentity(payload, read.payload)) &&
infoRead.stayedOnPrimaryTab &&
queryReads.length > 0;
if (sameGraph && objectInfo && nodes.length > 0) {
const judgeable = leftover.filter((entry) => !incompletePageIds.has(String(entry.id)));
const judged = judgeLeftoverCombos(judgeable, nodes, objectInfo);
const judgeableStaleLoadImages = staleLoadImageFlags.filter((entry) => !incompletePageIds.has(String(entry.id)));
const judgedStaleLoadImages = judgeStaleLoadImages(judgeableStaleLoadImages, nodes, objectInfo);
// Non-retryable abstentions (the probe cap, a failed lookup, an unenumerable
// path the panel already disclosed) stay; retryable leftovers are replaced by
// whatever this pass still could not judge.
const nextUnchecked = [
...asUncheckedList(payload).filter((e) => !isRetryableUnchecked(e)),
...leftover.filter((entry) => incompletePageIds.has(String(entry.id))),
...judged.stillUnchecked,
];
payload.unchecked_nodes = nextUnchecked;
// The panel's note counts the PRE-completion list. Drop it rather than
// leave "NOT CHECKED: 40" next to a shorter remainder.
delete payload.unchecked_nodes_note;
if (nextUnchecked.length === 0) {
delete payload.unchecked_nodes;
}
const stillRetryable = nextUnchecked.some(isRetryableUnchecked);
if (!stillRetryable) {
delete payload.unchecked_budget_exhausted;
delete payload.unchecked_class_limit;
}
if (judgedStaleLoadImages.reclassifiedIds.size > 0) {
const remainingStaleFlags = asStaleFlags(payload).filter((entry) => !judgedStaleLoadImages.reclassifiedIds.has(String(entry.id)));
if (remainingStaleFlags.length > 0)
payload.stale_flags = remainingStaleFlags;
else
delete payload.stale_flags;
}
const completionUnavailable = [
...judged.unavailable,
...judgedStaleLoadImages.unavailable,
];
if (completionUnavailable.length) {
const merged = mergeUnavailable(payload.unavailable_widget_values, completionUnavailable);
const added = merged.length - (Array.isArray(payload.unavailable_widget_values)
? payload.unavailable_widget_values.length
: 0);
payload.unavailable_widget_values = merged;
// The panel's note carries a COUNT of the pre-completion list
// (comboAvailabilityNote), so appending to the list without replacing it
// ships "1 widget value(s)" above two entries — the same stale-count
// defect the unchecked_nodes_note delete above exists to avoid.
if (added > 0) {
payload.unavailable_widget_values_note =
`LIVE SCAN + ORCHESTRATOR COMPLETION: ${merged.length} widget value(s) the server ` +
`does not offer, ${added} of them judged from one fresh /object_info read and ` +
`same-view graph detail read; entries added here are marked ` +
`source:"orchestrator_completion".`;
}
}
payload.audit_completed_by = "orchestrator";
}
}
return rewriteTextPayload(res, presentGetErrorsAudit(payload));
}
//# sourceMappingURL=get-errors-audit.js.map