From a3010e151e1a2010635d7b18a26c854491a67627 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Sun, 18 Oct 2026 12:53:34 -0700 Subject: [PATCH] fix(history): recover receipts and refresh capture evidence (#209) --- tests/browser/server.py | 27 +++++- tests/browser/workspace-history.spec.mjs | 100 +++++++++++++++++++++ tests/browser/workspace_history_fixture.py | 32 +++++++ web/js/workspace/recorded-run.mjs | 22 +++-- 4 files changed, 172 insertions(+), 9 deletions(-) diff --git a/tests/browser/server.py b/tests/browser/server.py index 6c5afb5..84efbbc 100644 --- a/tests/browser/server.py +++ b/tests/browser/server.py @@ -16,7 +16,10 @@ from PIL.PngImagePlugin import PngInfo from tests.browser.workspace_visual_fixture import seed_workspace_visual from tests.browser.workspace_inspector_fixture import seed_workspace_inspector -from tests.browser.workspace_history_fixture import seed_workspace_history +from tests.browser.workspace_history_fixture import ( + capture_workspace_history_zero_outputs, + seed_workspace_history, +) class TestModel: @@ -224,8 +227,26 @@ def create_app(root: Path) -> web.Application: api.db.link_image_to_prompt(video_prompt_id, str(video)) api.invalidate_gallery_cache() + history_fixture = None + + @routes.post("/__test__/history/capture-zero-outputs") + async def capture_history_outputs(request: web.Request) -> web.Response: + data = await request.json() if request.can_read_body else None + if ( + not isinstance(data, dict) + or set(data) != {"run_id"} + or history_fixture is None + or data["run_id"] not in history_fixture["runs"].values() + ): + raise web.HTTPBadRequest() + captured = await api._run_in_executor( + capture_workspace_history_zero_outputs, api, data["run_id"] + ) + return web.json_response({"success": captured}) + @routes.post("/__test__/reset") async def reset(request: web.Request) -> web.Response: + nonlocal history_fixture data = await request.json() if request.can_read_body else {} if not isinstance(data, dict): raise web.HTTPBadRequest() @@ -247,6 +268,7 @@ def create_app(root: Path) -> web.Application: manifest = await api._run_in_executor( lambda: seed_workspace_history(api, root, history_runs=history_runs) ) + history_fixture = manifest return web.json_response({"success": True, **manifest}) workspace_visual = data.get("workspace_visual", False) full_inspector = data.get("full_inspector", False) @@ -299,15 +321,18 @@ def create_app(root: Path) -> web.Application: ): raise web.HTTPBadRequest() if workspace_visual: + history_fixture = None await api._run_in_executor(seed_workspace_visual, api, root) return web.json_response({"success": True}) if full_inspector: + history_fixture = None await api._run_in_executor( lambda: seed_workspace_inspector( api, root, extra_images=extra, include_video=include_video ) ) return web.json_response({"success": True}) + history_fixture = None await api._run_in_executor( seed, extra, diff --git a/tests/browser/workspace-history.spec.mjs b/tests/browser/workspace-history.spec.mjs index 131f309..5ed5107 100644 --- a/tests/browser/workspace-history.spec.mjs +++ b/tests/browser/workspace-history.spec.mjs @@ -259,3 +259,103 @@ test('a bounded saved-parent prefix leaves previously loaded variants unverified await expect(choices.last()).toHaveAttribute('aria-pressed','true');expect(await saved(page).getByRole('region',{name:'Negative prompt',exact:true}).textContent()).toBe(negative); await expect(page.getByRole('button',{name:'Reload saved Library view',exact:true})).toBeVisible();await expect(choices.last()).toBeDisabled(); }); + +async function retainSavedSelection(page,request,fixture) { + await open(page,fixture.detail_id); + const details=await(await request.get(`/prompt_manager/library/${fixture.detail_id}`)).json(); + const index=details.variants.findIndex(variant=>variant.id===fixture.saved_pair); + await page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true}).click(); + await expect(gallery(page).locator('article[data-image-key]')).toHaveCount(1); + const negative=await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre')); + await gallery(page).locator('article[data-image-key]').first().evaluate(node=>{window.fix1SavedGalleryCard=node;}); + let reads=0;page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/library/${fixture.detail_id}/images`)reads++;}); + return async()=>{ + await expect(page.getByRole('button',{name:`Select negative variant ${index+1}`,exact:true})).toHaveAttribute('aria-pressed','true'); + expect(await exact(saved(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(negative); + expect(await gallery(page).locator('article[data-image-key]').first().evaluate(node=>node===window.fix1SavedGalleryCard)).toBe(true);expect(reads).toBe(0); + }; +} + +for(const change of ['mode','anchor']) test(`initial receipt read restarts after a held ${change} cancellation`,async({page,request})=>{ + const f=page.fixture;const retained=await retainSavedSelection(page,request,f);let reads=0,release,entered; + const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;}); + let aborted=0;page.on('requestfailed',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.shared}/outputs`)aborted++;}); + await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;if(reads===1){entered();await held;}await route.continue().catch(()=>{});}); + try { + await marker(page,f.runs.shared).click();await started; + if(change==='mode')await dialog(page).getByRole('button',{name:'Effective',exact:true}).click(); + else await dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /}).click(); + release();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(2); + await expect.poll(()=>aborted).toBeGreaterThan(0); + if(change==='mode')await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true'); + else await expect(dialog(page).getByRole('group',{name:'Recorded anchors',exact:true}).getByRole('button',{name:/^Anchor z /})).toHaveAttribute('aria-pressed','true'); + await page.keyboard.press('Escape');await retained(); + } finally {release();} +}); + +test('same-run reopen loads page one after cancellation and a held fresh detail read',async({page,request})=>{ + const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseOutputs,enteredOutputs,releaseDetail,enteredDetail; + const outputsHeld=new Promise(resolve=>{releaseOutputs=resolve;}),outputsStarted=new Promise(resolve=>{enteredOutputs=resolve;}); + const detailHeld=new Promise(resolve=>{releaseDetail=resolve;}),detailStarted=new Promise(resolve=>{enteredDetail=resolve;}); + await page.route(new RegExp(`/history/runs/${f.runs.shared}/outputs\\?`),async route=>{reads++;enteredOutputs();await outputsHeld;await route.continue().catch(()=>{});}); + try { + await marker(page,f.runs.shared).click();await outputsStarted;await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Close recorded run',exact:true}).click(); + await page.route(new RegExp(`/history/runs/${f.runs.shared}\\?`),async route=>{enteredDetail();await detailHeld;await route.continue().catch(()=>{});}); + await marker(page,f.runs.shared).click();await detailStarted;await expect(dialog(page).locator('[data-receipt]')).toHaveCount(0);await expect(dialog(page)).toContainText('Loading recorded run'); + releaseDetail();releaseOutputs();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(5);expect(reads).toBe(3); + await expect(dialog(page).getByRole('button',{name:'Effective',exact:true})).toHaveAttribute('aria-pressed','true');await page.keyboard.press('Escape');await retained(); + } finally {releaseDetail();releaseOutputs();} +}); + +test('late output capture replaces the earlier uncaptured detail summary with real complete-zero evidence',async({page,request})=>{ + const f=page.fixture,retained=await retainSavedSelection(page,request,f);let release,entered; + const held=new Promise(resolve=>{release=resolve;}),started=new Promise(resolve=>{entered=resolve;}); + for(const data of [{}, {run_id:f.runs.equal_looking,extra:true}, {run_id:[]}, {run_id:'not-a-run'}])expect((await request.post('/__test__/history/capture-zero-outputs',{data})).status()).toBe(400); + await page.route(new RegExp(`/history/runs/${f.runs.equal_looking}/outputs\\?`),async route=>{entered();await held;await route.continue().catch(()=>{});}); + const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}`); + try { + await marker(page,f.runs.equal_looking).click();await started;const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('not_captured'); + const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.equal_looking}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true); + const pageRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.equal_looking}/outputs`);release();const outputs=await(await pageRead).json(); + expect(outputs.capture).toMatchObject({state:'complete',reason:'none_reported'});expect(outputs.retained_occurrence_count).toBe(0);expect(outputs.list_status).toBe('complete'); + await expect(dialog(page).locator('.recorded-output-status')).toHaveText('0 reported outputs · complete · All retained receipts loaded.'); + await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · none_reported'); + await expect(dialog(page).getByText('Context capture',{exact:true}).locator('..').locator('dd')).toHaveText('complete · captured'); + await page.keyboard.press('Escape');await retained(); + } finally {release();} +}); + +test('capture-only recovery uses the changed real output capture without re-reading run or saved gallery',async({page,request})=>{ + const f=page.fixture,retained=await retainSavedSelection(page,request,f);let outputsReads=0,runReads=0; + page.on('request',request=>{if(new URL(request.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`)runReads++;}); + await page.route(new RegExp(`/history/runs/${f.runs.whitespace}/outputs\\?`),async route=>{ + outputsReads++; + if(outputsReads===1) { + const write=await request.post('/__test__/history/capture-zero-outputs',{data:{run_id:f.runs.whitespace}});await expect(write).toBeOK();expect((await write.json()).success).toBe(true); + return route.fulfill({status:409,json:{success:false,history_version:1,code:'capture-changed',error:'Reported output capture changed',recovery:{action:'refresh-outputs'}}}); + } + await route.continue(); + }); + const detailRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}`); + const outputsRead=page.waitForResponse(response=>new URL(response.url()).pathname===`/prompt_manager/history/runs/${f.runs.whitespace}/outputs`&&response.status()===200); + await marker(page,f.runs.whitespace).click();const detail=await(await detailRead).json();expect(detail.output_summary.state).toBe('partial'); + const outputs=await(await outputsRead).json();expect(outputs.capture.state).toBe('conflicting');expect(outputs.retained_occurrence_count).toBe(1);expect(outputs.list_status).toBe('incomplete'); + await expect(dialog(page).locator('[data-receipt]')).toHaveCount(1);await expect(dialog(page).locator('.recorded-output-status')).toContainText('1 retained reported output · incomplete · conflicting'); + await expect(dialog(page).getByText('Output capture',{exact:true}).locator('..').locator('dd')).toHaveText('conflicting · conflicting');expect(runReads).toBe(1);expect(outputsReads).toBe(2); + expect(await exact(dialog(page).getByRole('region',{name:'Negative prompt',exact:true}).locator('pre'))).toBe(' \n\t ');await page.keyboard.press('Escape');await retained(); +}); + +test('completed page one stays bounded across mode changes until explicit output paging',async({page,request})=>{ + const f=page.fixture,retained=await retainSavedSelection(page,request,f);let reads=0,releaseNext,enteredNext; + const held=new Promise(resolve=>{releaseNext=resolve;}),started=new Promise(resolve=>{enteredNext=resolve;}); + await page.route(new RegExp(`/history/runs/${f.runs.paged}/outputs\\?`),async route=>{ + reads++;if(new URL(route.request().url()).searchParams.has('cursor')){enteredNext();await held;}await route.continue().catch(()=>{}); + }); + try { + await marker(page,f.runs.paged).click();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40);expect(reads).toBe(1); + await dialog(page).getByRole('button',{name:'Effective',exact:true}).click();await dialog(page).getByRole('button',{name:'Raw',exact:true}).click(); + const more=dialog(page).getByRole('button',{name:'Load more reported outputs',exact:true});await expect(more).toBeEnabled();expect(reads).toBe(1);await expect(dialog(page).locator('[data-receipt]')).toHaveCount(40); + await more.click();await started;expect(reads).toBe(2);releaseNext();await expect(dialog(page).locator('[data-receipt]')).toHaveCount(41);await expect(more).toBeHidden(); + await page.keyboard.press('Escape');await retained(); + } finally {releaseNext();} +}); diff --git a/tests/browser/workspace_history_fixture.py b/tests/browser/workspace_history_fixture.py index 1b74aec..a863d9e 100644 --- a/tests/browser/workspace_history_fixture.py +++ b/tests/browser/workspace_history_fixture.py @@ -41,6 +41,28 @@ def _snapshot(raw: str, suffix: str = "") -> PromptSnapshot: return PromptSnapshot(_known(raw), _known(raw + suffix), _known(""), _known(suffix)) +def capture_workspace_history_zero_outputs(api: PromptManagerAPI, run_id: str) -> bool: + """Apply one late host capture through the normal immutable storage command.""" + run = api.db.get_execution_run(run_id) + finish = make_finish( + run["session_id"], + run["queue_item_id"], + run["storage_epoch"], + SimpleNamespace( + messages=[ + ( + "execution_success", + {"prompt_id": run["job_id"], "timestamp": run["terminal_event_at"]}, + ) + ] + ), + run["observed_callback_at"], + ) + return api.db.write_execution_outputs_for_finish( + finish, OutputBundle((), "complete", "none_reported") + ) + + def seed_workspace_history( api: PromptManagerAPI, root: Path, *, history_runs: int = 0 ) -> dict: @@ -226,6 +248,16 @@ def seed_workspace_history( "conflicting": run( (x,), receipts, conflicting=True, observed=9_223_372_036_854_775_807 ), + "paged": run( + (x,), + OutputBundle( + tuple( + reference("paged", ordinal, "current.png") for ordinal in range(41) + ), + "complete", + "reported", + ), + ), } api.invalidate_gallery_cache() return { diff --git a/web/js/workspace/recorded-run.mjs b/web/js/workspace/recorded-run.mjs index 6d1ee7f..a7a93f2 100644 --- a/web/js/workspace/recorded-run.mjs +++ b/web/js/workspace/recorded-run.mjs @@ -12,9 +12,9 @@ function el(tag, text, className) { if (className) node.className = className; return node; } -function facts(entries) { +function facts(entries, values) { const list = el('dl', undefined, 'recorded-run-facts'); - for (const [label, value] of entries) {const row = el('div');row.append(el('dt', label), el('dd', value ?? 'Not recorded'));list.append(row);} + for (const [label, value] of entries) {const row = el('div'), description = el('dd', value ?? 'Not recorded');row.append(el('dt', label), description);list.append(row);values.set(label, description);} return list; } function observed(value) { @@ -64,8 +64,9 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop const inspectorContainer = el('div');current.append(inspectorContainer);current.hidden = true; body.append(metadata, anchors, mode, promptSection, outputs, current);dialog.append(header, message, retry, reload, body);container.append(dialog); const inspection = createInspectionController({api, extractFacts:window.ComfyMetadata.extractGenerationFacts, adapter:reportedLocationAdapter}); - let closed = false, renderedDetail = null, renderedRoles = '', outputsRequested = false, inspected = null, viewerOpen = false; + let closed = false, renderedDetail = null, renderedRoles = '', initialOutputQueued = false, inspected = null, viewerOpen = false; const receiptNodes = new Map(); + const evidenceValues = new Map(); const blocks = mountPromptBlocks(prompts, {sender, parentScope:local}); const inspector = mountFileInspector(inspectorContainer, {sender, parentScope:local, inline:true, onRetry:() => inspection.retry(), onPreview:() => showViewer(), onRefresh:refreshCurrent}); function selectedReceipt() { @@ -150,7 +151,7 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop ['Output capture', `${detail.output_summary.state} · ${detail.output_summary.reason ?? 'not captured'}`], ['Safe diagnostic node', run.node_id], ['Safe diagnostic node type', run.node_type], ['Safe exception type', run.exception_type], ['Diagnostics omitted', run.diagnostics_omitted ? 'Yes' : 'No'], - ])); + ], evidenceValues)); if (detail.context_capture.state === 'conflicting') metadata.append(el('p', 'First captured context · conflicting evidence', 'muted')); anchors.replaceChildren(); for (const context of detail.contexts) { @@ -175,8 +176,10 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop } } const output = next.outputs; + const outputEvidence = output.capture ? {...output.capture, retained_occurrence_count:output.retained_occurrence_count, list_status:output.list_status} : detail.output_summary; + evidenceValues.get('Output capture').textContent = `${outputEvidence.state} · ${outputEvidence.reason ?? 'not captured'}`; outputState.setAttribute('role', output.error ? 'alert' : 'status'); - outputState.textContent = `${outputSummary(detail.output_summary)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`; + outputState.textContent = `${outputSummary(outputEvidence)}${output.loading ? ' · Loading reported outputs…' : output.error ? ' · Reported outputs could not be loaded.' : output.capture && !output.has_more ? ' · All retained receipts loaded.' : ''}`; more.hidden = !output.has_more;more.disabled = output.loading || !enabled; retryOutputs.hidden = !output.error;retryOutputs.disabled = !!next.refreshRequired; const keep = new Set(output.items.map(item => occurrenceKey(item.occurrence))); @@ -196,9 +199,12 @@ export function mountRecordedRun(container, {api, controller, sender, parentScop const selected = selectedReceipt();current.hidden = !selected; if (!selected && inspected !== null) {inspected = null;inspection.clear();if (viewerOpen) resetViewer();} if (!enabled && viewerOpen) resetViewer(); - if (!outputsRequested && !output.capture && !output.loading && !output.error) { - outputsRequested = true; - queueMicrotask(() => {if (!closed && latest.selection.run_id === runId && !local.signal.aborted) controller.loadOutputs();}); + if (enabled && !initialOutputQueued && !output.capture && !output.loading && !output.error) { + initialOutputQueued = true; + queueMicrotask(() => { + initialOutputQueued = false; + if (!closed && !local.signal.aborted && latest.selection.run_id === runId && latest.actionsEnabled && latest.run.detail && !latest.run.loading && !latest.outputs.capture && !latest.outputs.loading && !latest.outputs.error) controller.loadOutputs(); + }); } } active = {close:closeRun, render};