diff --git a/TESTING.md b/TESTING.md index acdb15e6..4bbeea39 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2555,7 +2555,8 @@ until this browser dismissed the 3.2.0 update notice (`pm_update_notice_dismisse the legacy admin page's key); Later dismisses it without scanning, and Review scan dismisses it as the legacy Scan does. "+ Add source" asks validate-source, then adds the folder to the host draft of `gallery_root_paths`; Remove takes one out; "Watch for -new outputs" (`monitoring_enabled`) and Worker threads (`worker_threads`) are host +new outputs" (`monitoring_enabled`), "Save prompts from other workflows" +(`save_foreign_runs`, D147) and Worker threads (`worker_threads`) are host drafts too, and Save writes them with the revision. A save the server refuses because a scan runs toasts "Another job is running: ." with a link to that job (D57) and keeps the drafts; a save refused for one source says the server's message as the status @@ -2804,7 +2805,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged `-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no reader can keep it byte-identical (`test_health_check_never_writes`). -Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 assigned). D94, +Recorded deviations (plan D60–D75, D79–D82 renumbered D92–D107, D109–D110 and D111–D114; D121–D134 and D147 assigned). D94, the per-section hand-off panel, was retired by S12 (#329): every section is native. - **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No @@ -3179,6 +3180,21 @@ the per-section hand-off panel, was retired by S12 (#329): every section is nati `Watch for new outputs saves monitoring_enabled; a restart applies it` and `a save refused for one unusable source names it and marks its row` (workspace-system-sources.spec) and the system-state and system-sources node tests. +- **D147** (Sources, #247 D-Q4): the concept has no switch for prompts from workflows + without a PromptManager node; the maintainer chose a visible switch over a config key. + A `.setting-row` in the concept's toggle markup follows "Watch for new outputs": + "Save prompts from other workflows" (`#foreign-runs`, host key `save_foreign_runs`, + config.json `gallery.monitoring.save_foreign_runs`, default on), "New outputs of + workflows without a PromptManager node add their prompt to the library when it is + certain.". It is saved with the revision like the other host rows and applied at once + (the monitor reads it for every file), so a save names no restart. While "Watch for + new outputs" is drafted off the switch is disabled and a `p.small` under the + description says "Applies while Watch for new outputs is on." (the value is kept). + Pinned by `sources matches the concept` (the mapping inserts the row after the + monitoring row), `sources comparison detects mutations` (a missing row and a changed + label are caught), `D147: Save prompts from other workflows saves save_foreign_runs + live; off while monitoring is off (#247)` (workspace-system-sources.spec: keyboard on + desktop, a tap on touch) and the system-sources node test. - **D129** (library location, S6b): the concept shows neither where the library lives nor a way to move it. System › Overview's "Library health" gains a "Library location" `.operation` row after Backup status, in that row's own markup: "<path> · <why>", @@ -5084,6 +5100,57 @@ Tests: `tests/test_file_prompt.py` (one test per ladder step and junk class), saver, a legacy-linked file left as it is, `_BatchHistory.recorded`) and `tests/test_recorded_saver_anchor.py` `TestInspectionResolution`. +**In the monitor.** An output whose workflow has no positive PromptManager node +(`_find_prompt_from_metadata` says `foreign`) used to be logged and skipped. It now +goes through the same ladder with the `MONITOR` policy (`utils/foreign_runs.py`, the +handler's `_ingest_foreign`, `_recheck_foreign`, `_store_foreign`): + +- **At settle.** The ladder runs without history. Certain (`pair` or `positive`): the + prompt is stored and the file linked at once. `history` (an undecided graph inside + ComfyUI's output folder): the file is registered in its own waiting set, then the + ladder runs again at once with the file's recorded output, because the run may have + been sealed before the file settled; certain then means stored, otherwise it keeps + waiting. Anything else is logged "No certain prompt for <file>; left unlinked". +- **After the run ends.** When the run's outputs are sealed, each waiting foreign file + gets one re-check (`ForeignRecheck(reference)`) with the sealed output reference, + so history is asked with the spelling it recorded (a Windows folder whose case + differs from the disk still links). Not certain: "No certain prompt for <file> + after its run ended; left unlinked". An ambiguous inspector answer is never given + the legacy floor in the monitor (D-Q3); an untraceable one still is. +- **Waiting sets.** `_awaiting_foreign` and `_foreign_due` sit beside the keyless sets + with the same bounds (1000 files, one hour) but are separate, so a large foreign run + never evicts a waiting keyless file. An expired or evicted entry is logged "No + recorded run for <file>; left unlinked". `awaiting_foreign()` lists them; + `shutdown()` and `close()` clear them, and a closed handler re-checks nothing. +- **Store.** One write through `survive_switch` (rerun after a Move library): a pair + through `ingest_resolved_pair(..., marks=RUN_MARKS)`, a positive through + `store_scanned_prompt_or_raise(..., RUN_MARKS)`. A created prompt carries category + `scanned`, tag `auto-scanned` and the note `Saved from a workflow run: <file>`; a + file linked to another prompt is `conflict` and keeps its link. Logged at info + "Foreign run: <status> prompt <id> for <file> (<source>)"; a failure is logged and + the file stays unlinked. +- **Never.** No tracker FIFO entry is read or consumed, nothing is counted (run count + stays 0, no `last_used_at`), and nothing is queued for Needs review. +- **The setting.** `GalleryConfig.SAVE_FOREIGN_RUNS` (`gallery.monitoring. + save_foreign_runs`, default true, only a bool applies) is read for every file, at + settle and at the re-check; off logs "No PromptManager prompt in workflow, skipping + image" as before. System › Sources & scans "Save prompts from other workflows" (D147) + saves it through `POST /prompt_manager/settings` with no restart field; the + diagnostics report lists it. + +Tests: `tests/test_run_attribution.py` `TestMonitorForeignRuns` (dataset branches +linked after the seal, a seal before settle, a name two runs reported, the setting +off at settle and at the re-check, a plain workflow linked at settle with no run +count and the tracker queue untouched, the sealed reference's spelling, the separate +bound, expiry, a seal while the pending set is full, shutdown, a failed store), +`tests/test_image_linking.py` `TestForeignWorkflows`, `tests/test_image_monitor_singleton.py` +(close drops a foreign re-check), `tests/test_config.py`, +`tests/test_settings_sources.py` `TestSaveForeignRunsSetting`, and the real-host +`tests/comfyui/image-attribution.spec.mjs` case "an output of a workflow without a +PromptManager node creates and links its prompt; a second run reuses it" (ComfyUI's +own `CLIPTextEncode`, `KSampler` and `SaveImage`; the fixture route's GET also lists +`foreign`, the waiting foreign files). + ## Linked prompt text (#343) A link drives `text` on every PromptManager node, and `negative_text` on the Pair, diff --git a/tests/comfyui/fixtures/ledger_nodes.py b/tests/comfyui/fixtures/ledger_nodes.py index 844249af..3110580d 100644 --- a/tests/comfyui/fixtures/ledger_nodes.py +++ b/tests/comfyui/fixtures/ledger_nodes.py @@ -301,8 +301,15 @@ async def image_monitor(request: web.Request) -> web.Response: @PromptServer.instance.routes.get("/pm_execution_fixture/monitor") async def image_monitor_state(request: web.Request) -> web.Response: - """File names of keyless images waiting for their run's recorded outputs.""" + """File names of images waiting for their run's recorded outputs: keyless + ones (``awaiting``) and foreign ones (``foreign``, #247).""" monitor = _plugin_module("utils.image_monitor")._monitor_instance handler = getattr(monitor, "handler", None) waiting = handler.awaiting_history() if handler is not None else [] - return web.json_response({"awaiting": [os.path.basename(p) for p in waiting]}) + foreign = handler.awaiting_foreign() if handler is not None else [] + return web.json_response( + { + "awaiting": [os.path.basename(p) for p in waiting], + "foreign": [os.path.basename(p) for p in foreign], + } + ) diff --git a/tests/comfyui/image-attribution.spec.mjs b/tests/comfyui/image-attribution.spec.mjs index 64f14e68..7a8d8eeb 100644 --- a/tests/comfyui/image-attribution.spec.mjs +++ b/tests/comfyui/image-attribution.spec.mjs @@ -1,6 +1,7 @@ // #342 on the real pinned host: the image monitor links an output only to a prompt // that provably produced it. An unknown node (PMHistoryCached) stands in for the -// maintainer's LLM; KSampler is the fixture's deterministic stand-in. +// maintainer's LLM; KSampler is the fixture's deterministic stand-in. #247: an output +// of a workflow without a PromptManager node gets its own prompt. import { randomUUID } from 'node:crypto'; import { execFile } from 'node:child_process'; import { promisify } from 'node:util'; @@ -140,3 +141,30 @@ test('an output saved without metadata links once its recorded run names a store expect((await request.post('/pm_execution_fixture/metadata', { data: { disabled: false } })).ok()).toBe(true); } }); + +// #247: no PromptManager node at all, only ComfyUI's own text encoders, sampler and +// saver (the fixture's inputs and decoder stand in for the model files). +function foreignGraph({ text, seed = 1, prefix }) { + return { + '10': { class_type: 'PMHistoryInputs', inputs: {} }, + '20': { class_type: 'CLIPTextEncode', inputs: { text, clip: ['10', 0] } }, + '30': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['10', 0] } }, + '40': { class_type: 'KSampler', inputs: { model: ['10', 1], latent_image: ['10', 2], positive: ['20', 0], + negative: ['30', 0], seed, steps: 1, cfg: 1, sampler_name: 'euler', scheduler: 'normal', denoise: 1 } }, + '60': { class_type: 'PMHistoryImages', inputs: { value: 64, batch: 1, latent: ['40', 0] } }, + '70': { class_type: 'SaveImage', inputs: { images: ['60', 0], filename_prefix: prefix } }, + }; +} + +test('an output of a workflow without a PromptManager node creates and links its prompt; a second run reuses it', async ({ execution }, testInfo) => { + const text = `foreign lighthouse at dusk ${testInfo.project.name} ${randomUUID()}`; + const first = await saved(execution.request, foreignGraph({ text, prefix: 'foreign/run' })); + await linkedTo(execution, first, text); + // Only the seed changes: a new file, the same text, so the same stored prompt. + const second = await saved(execution.request, foreignGraph({ text, seed: 2, prefix: 'foreign/run' })); + await linkedTo(execution, second, text); + const state = await readLinks(execution.databasePath, [first, second], [text]); + expect(state).toEqual({ links: { [first]: text, [second]: text }, stored: { [text]: true } }); + // Decided from the graph at settle: nothing waits for the run's recorded outputs. + expect((await (await execution.request.get('/pm_execution_fixture/monitor')).json()).foreign).toEqual([]); +});