test(ingestion): real-host attribution case and the #342 testing contract

The managed host disables the image monitor, so the owned fixture pack
gains a route that starts and stops the plugin's monitor singleton for
the attribution case only. The case runs in both renderers: a typed Pair
image links, a Pair image whose positive is only linked prepend text
stays unlinked with nothing stored, an unknown node feeding text links
through its run key, and its cached re-run links again.
This commit is contained in:
Vito Sansevero
2026-10-25 13:02:09 -07:00
parent 144e6b1145
commit 628e000765
3 changed files with 107 additions and 6 deletions
+61 -6
View File
@@ -802,8 +802,8 @@ Recorded shell deviations:
`file_size` equal to the file's size in this walk. Both paths go through
`normalize_image_path` (absolute, normalized, `os.path.normcase`); a row without
`file_path` matches by its `image_path`.
- Read again: files linked without a pair (the monitor's fallback and FIFO links, the
legacy positive-only import), linked to more than one prompt or pair, not linked, a
- Read again: files linked without a pair (the monitor's queue and run-key links,
fallback links made before #342, the legacy positive-only import), linked to more than one prompt or pair, not linked, a
file whose size changed (one replaced at the same path, e.g. a reused ComfyUI
counter name, is read and its conflict lands under Needs review), and a row with no
stored size (unknown is not known). The scan now stores each file's size when it
@@ -3279,14 +3279,14 @@ generation did not change meanwhile (`PromptModel.get_connection`).
Writes racing the switch: a background write that was waiting on the old file's lock
when the switch closed its connection fails, and run counting (`count_runs`,
`count_pair_runs`) and the image monitor's link (`ImageGenerationHandler.
link_image_to_prompt`: the resolved-pair ingest and the fallback or recent-prompt link)
link_image_to_prompt`: the resolved-pair ingest and the plain prompt link)
run it once more through `PromptDatabase.survive_library_switch` when the model's
generation changed during the attempt; the retry opens the new library. The monitor
links through `PromptDatabase.link_image_to_prompt_or_raise`, which raises database
errors (`link_image_to_prompt` keeps its log-and-return-0 contract for other callers);
each attempt is one transaction, so a failed attempt rolls back and a committed one
does not retry, and the image is linked once. `TestBackgroundWritesSurviveTheSwitch`
races a real UPDATE (run counting) and a real fallback image link against the lock and
races a real UPDATE (run counting) and a real plain image link against the lock and
asserts each lands in the new file only. Any other failure is not retried. Known limit: that
blocked writer, and the mover with it, waits out the writer's busy timeout (30 s)
before its connection closes: SQLite holds the connection's mutex during the busy
@@ -3856,10 +3856,65 @@ Viewer, including nested modal focus, mobile layouts and per-role sending.
real Classic and Nodes 2.0 widgets and verifies workflow serialization without
loading a workflow or queueing generation.
## Image attribution: certain or unlinked (#342)
The image monitor and the output scan link an output only to a prompt that
provably produced it; anything uncertain stays unlinked and the monitor logs it
at info ("No certain prompt for <file>; left unlinked"). There is no recency
fallback (`_get_fallback_prompt` is gone) and no unproven tracker entry.
- **Empty editor.** Prepend and append text are never stored as the prompt
(maintainer's product decision; #343 tracks letting a link drive `text`). A
PromptManager, Text, Focused, Split or Pair node whose `text` is empty saves,
counts and tracks nothing for the positive side, logs at info (no "Could not
save or track" warning), and marks its run key empty, so no earlier entry of
that key can be used and a batch item keeps its queue place
(`PromptTracker.mark_empty`). The queue hook counts nothing for it either.
- **Run keys** (`utils/run_keys.py`): sha256 of a prompt node plus its whole
upstream closure (`class_type` and `inputs`) in the API graph. Nodes register
tracker entries under it; the monitor computes it from the image's embedded
graph. A queued entry is used only by key (batch items in order, a placeholder
leaves its image unlinked, a new run of a key drops the old run's entries). The
snapshot taken when the file appears is used only when its run id is known
(`comfy_execution.utils.get_executing_context`) and that run registered one
entry for the key, which covers a cached re-run (sampler seed changed) without
applying a batch's last item to every image. More than one positive
PromptManager node in the image graph: never linked from the tracker.
- **Recorded history** (`database/run_attribution.py`): a file is attributed when
exactly one recorded run reported its output location (root key, subfolder,
name), its mtime lies within that run (dequeue to terminal or callback, +-2 s),
and every positive context names the same stored prompt (the live
`positive_prompt_id` hint). The monitor tries it last; the scan tries it when the
#170 browser-parser floor finds nothing, and never creates a prompt from it.
- **Blank positive.** A resolved pair whose stored positive is empty (the #342
image with its Pair execution record) is `unresolved` for the scan and for
`ingest_resolved_pair` (it used to raise in `_save_pair`).
- **Parity floor.** Metadata linking, the scan's resolver and parser floor are
unchanged for every non-blank result; only guesses are gone.
Tests: `tests/test_prompt_attribution.py` (nodes, tracker, monitor, run keys),
`tests/test_run_attribution.py` (history attribution, monitor and scan),
`tests/test_review_image_links_tool.py`, and the real-host
`tests/comfyui/image-attribution.spec.mjs` (both renderers): a typed Pair image
links, a Pair image whose positive is only linked prepend text from an unknown
node stays unlinked with nothing stored, an unknown node feeding `text` links
through its run key, and its cached re-run with a new sampler seed links again.
The managed host config disables monitoring, so that case starts and stops the
plugin's monitor through the owned fixture route `/pm_execution_fixture/monitor`.
On origin/main the prepend image was linked to an unrelated prompt.
Repair: `tools/review_image_links.py <db> --output-dir <ComfyUI output> --ids
<ids>` (or `--prompt <id>`) judges links by these rules (`keep`, `relink`,
`unlink`, `conflict`, `missing`) on a backup copy and changes nothing.
`--apply` writes `relink`/`unlink` only for ids named with `--ids`, after saving
`<db>.pre-relink_<stamp>`. Queue-linked batch images can't be proven after the
fact and read `unlink`, so review before applying. Stop ComfyUI first.
## Durable execution ledger
`npm run --prefix tools/vendor test:executions` runs 46 real execution cases
(23 each in Classic and Nodes 2.0, retaining all 42 prior execution cases),
`npm run --prefix tools/vendor test:executions` runs 50 real execution cases
(25 each in Classic and Nodes 2.0, retaining all 48 prior execution cases and adding
the #342 image-attribution case),
alongside the existing 80 stock host cases (the original 78 plus the native
editor host case) and six pack compatibility cases. Run
these suites serially because they share report cleanup and owned host resources. Use the same pinned disposable `PM_COMFY_ROOT`,
+38
View File
@@ -255,3 +255,41 @@ async def cache_mode(request: web.Request) -> web.Response:
raise web.HTTPBadRequest()
_CACHE_MODE = mode
return web.json_response({"configured": True})
def _plugin_module(suffix: str):
"""A loaded PromptManager module (ComfyUI names the package by its path)."""
import sys
for name, module in list(sys.modules.items()):
if name.endswith("promptmanager." + suffix):
return module
raise web.HTTPServiceUnavailable(text=f"PromptManager {suffix} not loaded")
@PromptServer.instance.routes.post("/pm_execution_fixture/monitor")
async def image_monitor(request: web.Request) -> web.Response:
"""Start or stop PromptManager's image monitor on the host output folder.
The managed host's config disables monitoring; only the attribution cases
(#342) turn it on, and they turn it off again.
"""
enabled = (await request.json())["enabled"]
if type(enabled) is not bool:
raise web.HTTPBadRequest()
import folder_paths
monitor_module = _plugin_module("utils.image_monitor")
config = _plugin_module("py.config").GalleryConfig
monitor = monitor_module._monitor_instance
if monitor is None:
database = _plugin_module("database.operations").PromptDatabase()
tracker = _plugin_module("utils.prompt_tracker").get_prompt_tracker(database)
monitor = monitor_module.get_image_monitor(database, tracker)
if enabled:
config.MONITORING_ENABLED = True
monitor.start_monitoring([folder_paths.get_output_directory()])
else:
monitor.stop_monitoring()
config.MONITORING_ENABLED = False
return web.json_response({"monitoring": monitor.is_monitoring})
+8
View File
@@ -56,9 +56,17 @@ async function linkedTo(execution, filename, expected) {
{ timeout: 20000, message: `${filename} is linked by the image monitor` }).toBe(expected);
}
async function monitoring(execution, enabled) {
const response = await execution.request.post('/pm_execution_fixture/monitor', { data: { enabled } });
expect(response.ok()).toBe(true);
expect((await response.json()).monitoring).toBe(enabled);
}
test.beforeEach(async ({ execution }) => {
expect((await execution.request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true);
await monitoring(execution, true);
});
test.afterEach(async ({ execution }) => { await monitoring(execution, false); });
test('an output whose positive is only linked prepend text stays unlinked, never given an earlier prompt', async ({ execution }, testInfo) => {
const tag = `${testInfo.project.name} ${randomUUID()}`;