diff --git a/tests/js/workspace-file-inspector.test.js b/tests/js/workspace-file-inspector.test.js index 8843406..d97be19 100644 --- a/tests/js/workspace-file-inspector.test.js +++ b/tests/js/workspace-file-inspector.test.js @@ -48,3 +48,9 @@ test('metadata export preserves representation formats and omits private carrier assert.equal(value.metadata?.record_formats?.workflow,'reserialized-json');assert.equal(value.metadata.raw.workflow,rawWorkflow);assert.ok(!download.bodyText.includes('SECRET')); assert.equal(value.metadata.representations.workflow,'Reserialized container JSON; numeric spelling/precision may differ'); }); + +test('reported location metadata export carries exact occurrence without assigning historical ownership',async()=>{ + const {inspectionDownloads}=await load(),source=record(); + source.identity={kind:'reported-location',key:'b'.repeat(64),path:'folder/a.png',occurrence:{run_id:'22222222-2222-2222-2222-222222222222',output_node_id:'๐€€',image_ordinal:255},history_epoch:'11111111-1111-1111-1111-111111111111',current_scope:'c'.repeat(64),reference:{folder_type:'temp',subfolder:'folder',filename:'a.png',path_style:'posix',root_key:null}}; + const value=JSON.parse(inspectionDownloads(source).metadata.bodyText);assert.deepEqual(value.identity,source.identity);assert.equal(value.identity.root_index,undefined);assert.equal(value.context_id,undefined);assert.equal(value.file_identity,undefined);assert.deepEqual(value.prompts,source.prompts); +}); diff --git a/tests/js/workspace-history-media.test.js b/tests/js/workspace-history-media.test.js new file mode 100644 index 0000000..ca85dd8 --- /dev/null +++ b/tests/js/workspace-history-media.test.js @@ -0,0 +1,30 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {createRequire} from 'node:module'; +import {createInspectionController,originalMediaDownload} from '../../web/js/workspace/inspection-state.mjs'; +import {createViewerSession} from '../../web/js/workspace/viewer-state.mjs'; +const require=createRequire(import.meta.url),{extractGenerationFacts}=require('../../web/js/comfy-metadata.js'); +const m=await import('../../web/js/workspace/history-media.mjs').catch(e=>{if(e.code!=='ERR_MODULE_NOT_FOUND') throw e;return {};}); +const epoch='11111111-1111-1111-1111-111111111111',run='22222222-2222-2222-2222-222222222222',scope='a'.repeat(64),location='b'.repeat(64); +function media(node='1',ordinal=0,key=location) {const occurrence={run_id:run,output_node_id:node,image_ordinal:ordinal},reference={folder_type:'output',subfolder:'folder',filename:'a #%.png',path_style:'posix',root_key:'c'.repeat(64)},base=`/prompt_manager/history/runs/${run}/output/current`,query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope});return {kind:'reported-location',occurrence,reference,history_epoch:epoch,current_scope:scope,location_key:key,filename:reference.filename,is_video:false,url:`${base}/media?${query}`,inspection_url:`${base}/inspection?${query}`,thumbnail_url:`${base}/thumbnail?${query}`};} +const receipt=value=>({occurrence:value.occurrence,reference:value.reference,file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'available',reason:null,evidence_class:'locator_candidate',current_scope:value.current_scope,location_key:value.location_key,relative_path:'folder/a #%.png',source_label:'output',gallery_match:'none',gallery_image_id:null,media:value}}); +function envelope(target=media()) {return {success:true,inspection_version:1,history_version:1,history_epoch:epoch,current_output:{occurrence:target.occurrence,reference:target.reference,history_epoch:epoch,current_scope:scope,location_key:target.location_key,evidence_class:'locator_candidate',observation:'current-file',file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false},prompts:{positive:{status:'resolved',text:'current metadata only',source:'execution'},negative:{status:'unresolved',text:null,source:null}},file:{observation:'current-file',source_label:'output',media_type:'image',extension:'.png',format:'PNG',size:10,width:2,height:3,modified_time:1},metadata:{status:'absent',omitted_reason:null,raw:{},record_formats:{},prompt_graph:{status:'absent',value:null},workflow_graph:{status:'absent',value:null},parameters:null},resolution:{state:'absent',sampler_id:null,reason:'no graph'},library:{status:'none',scope:'saved-library',tags:[],metadata_scope:null,positive_id:null,pair_id:null,title:null,category:null}};} +function adapter(){assert.ok(m.reportedLocationAdapter,'reported location adapter is exported');return m.reportedLocationAdapter;} + +test('reported location uses explicit occurrence routes and never widens rooted defaults',()=>{const target=media(),a=adapter();assert.doesNotThrow(()=>a.validate(target));assert.equal(a.key(target),location);assert.equal(a.scope(target),`${epoch}:${scope}`);assert.equal(a.inspectionPath(target),target.inspection_url);assert.deepEqual(a.originalDownload(target),{url:target.url,filename:'a #%.png'});assert.throws(()=>originalMediaDownload(target),TypeError);assert.throws(()=>createViewerSession({items:[target],initialKey:location}),TypeError);}); + +test('occurrence URLs reject endpoint query origin credentials fragments and identity ambiguities',()=>{const a=adapter(),target=media();for(const patch of [{url:target.url.replace('/media?','/thumbnail?')},{url:target.url+'&node=1'},{url:target.url+'&ordinal=0'},{url:target.url+'&epoch='+epoch},{url:target.url+'¤t_scope='+scope},{url:target.url+'&root=0'},{url:target.url+'#x'},{url:'https://attacker.test'+target.url},{url:'http://user:pass@localhost'+target.url},{url:target.url.replace('ordinal=0','ordinal=00')},{url:target.url.replace('node=1','node=2')},{inspection_url:target.url},{thumbnail_url:target.url},{history_epoch:'bad'},{current_scope:'A'.repeat(64)},{location_key:''},{occurrence:{...target.occurrence,image_ordinal:256}},{reference:{...target.reference,subfolder:'../bad'}}]) assert.throws(()=>a.validate({...target,...patch}),TypeError);const cached={...target,url:target.url+'&_pm_viewer=1'};assert.equal(a.originalDownload(cached).url,cached.url);}); + +test('proxy URLs retain exact configured prefix and reject unprefixed alternatives',()=>{const a=adapter(),previous=globalThis.window;globalThis.window={location:{href:'https://host.test/proxy/prompt_manager/workspace'},ApiClient:{resolveUrl:value=>value.startsWith('/prompt_manager/')?'/proxy'+value:value}};try {const target=media();assert.equal(a.originalDownload(target).url,'/proxy'+target.url);assert.throws(()=>a.originalDownload({...target,url:'https://host.test'+target.url}),TypeError);assert.throws(()=>a.originalDownload({...target,url:'https://host.test.attacker.test/proxy'+target.url}),TypeError);}finally{if(previous===undefined)delete globalThis.window;else globalThis.window=previous;}}); + +test('four repeated receipt coordinates form one loaded Viewer location retaining selected representative',()=>{assert.equal(typeof m.distinctCurrentMedia,'function');const values=[media('1',0),media('1',1),media('2',0),media('2',1)],receipts=values.map(receipt);assert.equal(new Set(receipts.map(r=>m.occurrenceKey(r.occurrence))).size,4);for(const chosen of [values[0],values[3]]) {const items=m.distinctCurrentMedia(receipts,chosen.occurrence);assert.equal(items.length,1);assert.deepEqual(items[0].occurrence,chosen.occurrence);}assert.deepEqual(m.distinctCurrentMedia(receipts,null)[0].occurrence,values[0].occurrence);const unavailable={...receipts[0],current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};assert.deepEqual(m.distinctCurrentMedia([unavailable],null),[]);}); + +test('shared occurrence inspection keeps current facts and exports exact unverified evidence',async()=>{const a=adapter(),target=media('2',1),data=envelope(target);const record=a.toRecord(data,target,extractGenerationFacts);assert.deepEqual(record.identity,{kind:'reported-location',key:location,path:'folder/a #%.png',occurrence:target.occurrence,history_epoch:epoch,current_scope:scope,reference:target.reference});assert.equal(record.prompts.positive.text,'current metadata only');assert.equal(record.generation.seed.status,'not-recorded');assert.equal(record.identity.root_index,undefined);assert.deepEqual(record.download,{url:target.url,filename:target.filename});const {inspectionDownloads}=await import('../../web/js/workspace/file-inspector.mjs');const exported=JSON.parse(inspectionDownloads(record).metadata.bodyText);assert.deepEqual(exported.identity.occurrence,{run_id:run,output_node_id:'2',image_ordinal:1});assert.equal(exported.identity.history_epoch,epoch);assert.equal(exported.prompts.positive.text,'current metadata only');}); + +test('identity scope epoch or occurrence echo mismatches require refresh but malformed metadata is retryable',()=>{const a=adapter();for(const mutate of [p=>p.history_epoch='33333333-3333-3333-3333-333333333333',p=>p.current_output.current_scope='d'.repeat(64),p=>p.current_output.occurrence={...p.current_output.occurrence,image_ordinal:1},p=>p.current_output.reference={...p.current_output.reference,filename:'other.png'},p=>p.current_output.location_key='d'.repeat(64)]){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e.refreshRequired===true && typeof e.code==='string');}for(const mutate of [p=>p.success=false,p=>p.inspection_version=2,p=>p.metadata.parameters='fake',p=>p.current_output.file_identity='verified',p=>p.current_output.observation='historical-file']){const data=envelope();mutate(data);assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),e=>e instanceof TypeError && !e.refreshRequired);}}); + +test('shared inspection controller distinguishes recovery codes and obsolete receipt errors',async()=>{const a=adapter();for(const [status,code,refresh] of [[409,'history-changed',true],[409,'candidate-stale',true],[409,'capture-changed',true],[400,'invalid-request',false],[400,'invalid-cursor',false]]){const calls=[];const c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const p=c.select(media());assert.equal(calls[0].url,media().inspection_url);calls[0].reject({status,code,recovery:{action:'refresh-outputs'}});await p;assert.equal(c.state.refreshRequired,refresh);assert.equal(c.state.code,code);const retry=c.retry();assert.equal(calls.length,refresh?1:2);if(!refresh){calls[1].resolve(envelope());await retry;}c.dispose();}const calls=[],c=createInspectionController({adapter:a,extractFacts:extractGenerationFacts,api:{get:(url,{signal})=>new Promise((resolve,reject)=>calls.push({url,signal,resolve,reject}))}});const first=c.select(media('1',0)),second=c.select(media('1',1));assert.equal(calls.length,2,'same location needs exact occurrence selection');assert.equal(calls[0].signal.aborted,true);calls[1].resolve(envelope(media('1',1)));await second;calls[0].reject({status:409,code:'history-changed'});await first;assert.equal(c.state.record.identity.occurrence.image_ordinal,1);c.dispose();}); + +test('Viewer freezes selected occurrence and location entries through navigation and late timers',()=>{const a=adapter(),first=media('1',1),second=media('2',0,'d'.repeat(64)),items=[first,second],seen=[];const view=createViewerSession({adapter:a,items,initialKey:location,onChange:(state,item)=>seen.push([state,item])});first.occurrence.image_ordinal=9;first.reference.filename='mutated';items.reverse();assert.equal(seen[0][1].occurrence.image_ordinal,1);assert.equal(seen[0][1].reference.filename,'a #%.png');view.navigate(1);assert.equal(view.state.key,'d'.repeat(64));view.navigate(-1);assert.equal(seen.at(-1)[1].occurrence.image_ordinal,1);view.dispose();const count=seen.length;view.navigate(1);view.ready(location);view.play();assert.equal(seen.length,count);}); + +test('malformed occurrence echoes invalidate actions instead of producing retryable current facts',()=>{const a=adapter();for(const occurrence of [{run_id:run,output_node_id:'1',image_ordinal:'0'},{run_id:'bad',output_node_id:'1',image_ordinal:0},{}]){const data=envelope();data.current_output.occurrence=occurrence;assert.throws(()=>a.toRecord(data,media(),extractGenerationFacts),error=>error.refreshRequired===true && error.code==='candidate-stale');}}); diff --git a/tests/js/workspace-history-state.test.js b/tests/js/workspace-history-state.test.js new file mode 100644 index 0000000..fe319a4 --- /dev/null +++ b/tests/js/workspace-history-state.test.js @@ -0,0 +1,58 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +const m = await import('../../web/js/workspace/history-state.mjs').catch(e => {if(e.code !== 'ERR_MODULE_NOT_FOUND') throw e; return {};}); +const epoch='11111111-1111-1111-1111-111111111111', session='22222222-2222-2222-2222-222222222222'; +const id=n=>`00000000-0000-0000-0000-${String(n).padStart(12,'0')}`; +const digest=n=>String(n).repeat(64), fields=['raw','effective','prepend','append']; +const proof=(provenance='recorded')=>({provenance,source:provenance==='recorded'?'runtime_return':'unknown',reason:provenance==='recorded'?null:'missing'}); +function context(anchor='1',branch='a') {return {anchor_id:anchor,context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d'),positive:{raw:'positive\n็Œซ',effective:null,prepend:'',append:''},negative:{raw:'',effective:'negative',prepend:'',append:''},evidence:{format_version:1,role_provenance:'inferred',positive:Object.fromEntries(fields.map(f=>[f,proof(f==='effective'?'unknown':'recorded')])),negative:Object.fromEntries(fields.map(f=>[f,proof()]))},positive_prompt_id:7,negative_prompt_id:8,pair_id:9,reference_epoch:epoch,navigation:{positive_available:true,negative_available:true,pair_available:true}};} +const capture={state:'complete',reason:'captured',format_version:1}; +const output={state:'complete',reason:'reported',format_version:1,source:'host_final_history',retained_occurrence_count:4,list_status:'complete'}; +function run(n,branch='a') {return {run_id:id(n),session_id:session,storage_epoch:epoch,job_id:id(99),record_version:1,queue_item_id:String(n),observed_dequeue_at:String(9007199254740990n+BigInt(n)),observed_callback_at:null,executor_started_at:null,terminal_event_at:null,outcome:'observed',reason:null,diagnostics_omitted:false,diagnostics_available:false,context_capture:capture,output_summary:output,matching_anchors:[{anchor_id:'1',context_id:digest(branch),positive_snapshot_id:digest('c'),negative_snapshot_id:digest('d')}]};} +function windowPage(items=[run(1),run(2),run(3,'b')],more=false) {const summaries=new Map();for(const r of items) {const a=r.matching_anchors[0];if(!summaries.has(a.context_id)) summaries.set(a.context_id,{context_id:a.context_id,positive_snapshot_id:a.positive_snapshot_id,negative_snapshot_id:a.negative_snapshot_id,negative_raw:{state:'explicit_empty',preview:'',truncated:false,evidence:[]}});summaries.get(a.context_id).negative_raw.evidence.push({run_id:r.run_id,anchor_id:'1',...proof()});}return {success:true,history_version:1,history_epoch:epoch,group:{positive_id:7,raw_text_id:digest('e'),relation:'exact_frozen_raw_text',scope_status:'matched'},recorded_run_count:3,items,context_summaries:[...summaries.values()],has_more:more,next_cursor:more?'window-token':null};} +const detail=(n=1)=>({success:true,history_version:1,history_epoch:epoch,run:run(n),contexts:[context(),context('2','b')],context_capture:capture,output_summary:output}); +function receipt(node='1',ordinal=0) {return {occurrence:{run_id:id(1),output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'image.png',path_style:'posix',root_key:digest('a')},file_identity:'unverified',newly_generated:'unknown',context_membership:'unknown',transient:false,current_candidate:{status:'unavailable',reason:'missing',evidence_class:'locator_candidate'}};} +const outputs=(items=[receipt('1',0),receipt('1',1)],more=true)=>({success:true,history_version:1,history_epoch:epoch,capture:{state:'complete',reason:'reported',format_version:1,source:'host_final_history'},capture_fingerprint:digest('f'),retained_occurrence_count:4,list_status:'complete',items,has_more:more,next_cursor:more?'outputs-token':null}); +function setup(restore) {const requests=[],changes=[];assert.equal(typeof m.createHistoryController,'function','history controller is exported');const c=m.createHistoryController({positiveId:7,restore,onChange:s=>changes.push(s),api:{get:(url,{signal})=>new Promise((resolve,reject)=>requests.push({url,signal,resolve,reject}))}});return {c,requests,changes};} +async function loaded(restore) {const s=setup(restore),p=s.c.load();s.requests.at(-1).resolve(windowPage());await p;return s;} +async function selected() {const s=await loaded(),p=s.c.selectRun(id(1));s.requests.at(-1).resolve(detail());await p;return s;} + +test('chronology keeps A A B as three runs and two branches with exact decimal ordering',async()=>{const {c,requests}=await loaded();assert.deepEqual(c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);assert.equal(c.state.window.context_summaries.length,2);assert.equal(c.state.window.items[2].observed_dequeue_at,'9007199254740993');assert.equal(requests[0].url,'/prompt_manager/library/7/history?limit=40');assert.equal(c.state.window.recorded_run_count,3);c.dispose();}); + +test('earlier windows prepend full identities once and refresh replaces bounded count',async()=>{const s=setup();let p=s.c.load();s.requests[0].resolve(windowPage([run(2),run(3,'b')],true));await p;p=s.c.loadEarlier();assert.match(s.requests[1].url,/cursor=window-token/);s.requests[1].resolve(windowPage([run(1),run(2)],false));await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(1),id(2),id(3)]);p=s.c.refresh();s.requests[2].resolve({...windowPage([run(3)]),recorded_run_count:1});await p;assert.deepEqual(s.c.state.window.items.map(r=>r.run_id),[id(3)]);assert.equal(s.c.state.window.recorded_run_count,1);s.c.dispose();}); + +test('malformed windows never enable actions',async()=>{for(const mutate of [p=>p.items[0].queue_item_id='01',p=>p.items[0].observed_dequeue_at='9223372036854775808',p=>p.items[0].run_id='bad',p=>p.history_epoch='bad',p=>p.group.positive_id=8,p=>p.context_summaries[0].negative_raw.preview='x'.repeat(101),p=>p.has_more=true,p=>p.next_cursor='',p=>p.items[0].matching_anchors[0].context_id='x',p=>p.items[0].observed_callback_at=1,p=>p.recorded_run_count=-1]) {const {c,requests}=setup();const p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);assert.equal(c.state.actionsEnabled,false);c.dispose();}}); + +test('frozen roles retain explicit empty and unknown values with their own evidence',()=>{assert.equal(typeof m.frozenPromptRoles,'function');const ctx=context(),raw=m.frozenPromptRoles(ctx,'raw'),effective=m.frozenPromptRoles(ctx,'effective');assert.equal(raw.negative.status,'resolved');assert.equal(raw.negative.text,'');assert.deepEqual(raw.negative.evidence,proof());assert.equal(effective.positive.status,'unresolved');assert.equal(effective.positive.text,null);assert.deepEqual(effective.positive.evidence,proof('unknown'));assert.equal(effective.negative.text,'negative');assert.equal(raw.positive.text,'positive\n็Œซ');assert.throws(()=>m.frozenPromptRoles(ctx,'wrong'),TypeError);}); + +test('detail validates exact roles evidence IDs and epoch before selection',async()=>{for(const mutate of [p=>p.history_epoch=id(4),p=>p.run.run_id=id(2),p=>p.contexts[0].negative_snapshot_id=null,p=>p.contexts[0].positive.raw=42,p=>p.contexts[0].evidence.positive.raw.source='unknown',p=>p.contexts[0].navigation.pair_available=1,p=>p.contexts[0].positive_prompt_id=0,p=>p.contexts.push(p.contexts[0])]) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error || c.state.refreshRequired);assert.equal(c.state.run.detail,null);assert.equal(c.state.actionsEnabled,false);c.dispose();}}); + +test('dependent selections abort late run output success and errors including disposal',async()=>{const {c,requests,changes}=await loaded();const p=c.selectRun(id(1)),q=c.selectRun(id(2));assert.equal(requests[1].signal.aborted,true);requests[2].resolve(detail(2));await q;requests[1].reject({status:409,code:'history-changed'});await p;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.refreshRequired,null);const r=c.selectRun(id(1));requests[3].resolve(detail());await r;for(const change of [()=>c.selectContext('2'),()=>c.setMode('effective')]) {const a=c.loadOutputs();const request=requests.at(-1);change();assert.equal(request.signal.aborted,true);request.resolve(outputs());await a;assert.equal(c.state.outputs.items.length,0);}const a=c.loadOutputs(),request=requests.at(-1);c.dispose();const count=changes.length;request.reject({status:409,code:'history-changed'});await a;assert.equal(changes.length,count);}); + +test('output pagination preserves binary order and exact repeated receipt selection on retry',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:1});assert.equal(c.state.selection.receipt.image_ordinal,1);p=c.loadOutputs();const url=requests.at(-1).url;requests.at(-1).reject(new Error('offline'));await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');p=c.retryOutputs();assert.equal(requests.at(-1).url,url);requests.at(-1).resolve(outputs([receipt('\ue000'),receipt('๐€€')],false));await p;assert.deepEqual(c.state.outputs.items.map(r=>r.occurrence.output_node_id),['1','1','\ue000','๐€€']);assert.equal(c.state.selection.receipt.image_ordinal,1);c.dispose();}); + +test('receipt selection invalidates pending responses without losing loaded coordinates',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();const pending=requests.at(-1);c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0});assert.equal(pending.signal.aborted,true);pending.reject({status:409,code:'capture-changed'});await p;assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.selection.receipt.image_ordinal,0);assert.equal(c.state.refreshRequired,null);c.dispose();}); + +test('response-limit retries only the failed cursor at the permitted smaller limit',async()=>{const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:422,code:'response-limit',recovery:{action:'retry-outputs',limit:20}});await p;assert.equal(c.state.outputs.items.length,2);p=c.retryOutputs();assert.match(requests.at(-1).url,/limit=20/);assert.match(requests.at(-1).url,/cursor=outputs-token/);requests.at(-1).resolve(outputs([receipt('2',0),receipt('2',1)],false));await p;assert.equal(c.state.outputs.items.length,4);c.dispose();}); + +test('history change clears every actionable object and revalidates parent independently',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'history-changed',recovery:{action:'reload-history'}});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);assert.equal(c.state.run.detail,null);assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.next_cursor,null);assert.equal(c.state.actionsEnabled,false);assert.equal(requests.at(-1).url,'/prompt_manager/library/7');requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.parent.detail.positive.id,7);assert.equal(c.state.refreshLabel,'Reload recorded history');c.dispose();}); + +test('capture and candidate changes clear candidates and reload output page one',async()=>{for(const code of ['capture-changed','candidate-stale']) {const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).reject({status:409,code});await p;assert.equal(c.state.outputs.items.length,0);assert.equal(c.state.actionsEnabled,false);assert.doesNotMatch(requests.at(-1).url,/cursor=/);requests.at(-1).resolve({...outputs([],false),retained_occurrence_count:0});await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.outputs.error,null);c.dispose();}}); + +test('reentry validates epoch before actions while restore accepts only harmless intent',async()=>{const {c,requests}=await loaded({search:'็Œซ',scrollTop:120,mode:'effective',state:{window:windowPage()},pairId:9});assert.equal(c.state.intent.search,'็Œซ');assert.equal(c.state.selection.mode,'effective');const p=c.revalidate();assert.equal(c.state.actionsEnabled,false);requests.at(-1).resolve({...windowPage(),history_epoch:id(4)});await p;assert.equal(c.state.refreshRequired,'history-changed');assert.equal(c.state.window.items.length,0);requests.at(-1).resolve({positive:{id:7,text:'saved'},variants:[]});c.dispose();}); + +test('invalid caller identifiers cannot request history or select nonexistent contexts and receipts',async()=>{assert.equal(typeof m.createHistoryController,'function');for(const positiveId of [0,'7',NaN,9007199254740992]) assert.throws(()=>m.createHistoryController({positiveId,api:{}}),TypeError);const {c,requests}=await selected();await assert.rejects(c.selectRun('bad'),TypeError);assert.throws(()=>c.selectContext('missing'),TypeError);assert.throws(()=>c.selectReceipt({run_id:id(1),output_node_id:'1',image_ordinal:0}),TypeError);assert.equal(requests.length,2);c.dispose();}); + +test('malformed output coordinates capture fingerprints evidence and continuations fail closed',async()=>{for(const mutate of [p=>p.items[0].occurrence.image_ordinal=256,p=>p.items[0].occurrence.run_id=id(2),p=>p.capture_fingerprint='bad',p=>p.has_more=false,p=>p.history_epoch=id(4),p=>p.items[0].file_identity='verified',p=>p.items[0].reference.root_key='bad',p=>p.items[0].current_candidate.url='/fake',p=>p.items[0].occurrence.output_node_id='\ud800']) {const {c,requests}=await selected();const p=c.loadOutputs(),data=outputs();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.outputs.error || c.state.refreshRequired);assert.equal(c.state.outputs.items.length,0);c.dispose();}}); + +test('restored identifiers select only matching freshly validated run and context',async()=>{const {c,requests}=setup({runId:id(1),anchorId:'2',contextId:digest('b'),mode:'effective',outputs:outputs(),receipt:receipt().occurrence});assert.equal(c.state.selection.run_id,null);assert.equal(c.state.outputs.items.length,0);let p=c.load();requests[0].resolve(windowPage());await p;assert.equal(requests.length,2);assert.equal(c.state.run.loading,true);requests[1].resolve(detail());await new Promise(resolve=>setImmediate(resolve));assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.selection.context_id,digest('b'));assert.equal(c.state.selection.mode,'effective');assert.equal(c.state.selection.receipt,null);c.dispose();}); + +test('new run selection invalidates a pending reentry epoch check and its late error',async()=>{const {c,requests}=await selected();const validation=c.revalidate(),old=requests.at(-1),next=c.selectRun(id(2));assert.equal(old.signal.aborted,true);requests.at(-1).resolve(detail(2));await next;old.reject({status:409,code:'history-changed'});await validation;assert.equal(c.state.selection.run_id,id(2));assert.equal(c.state.run.detail.run.run_id,id(2));assert.equal(c.state.refreshRequired,null);assert.equal(c.state.validating,false);c.dispose();}); + +test('explicit detail limit errors stay bounded read failures and retry only the detail',async()=>{const {c,requests}=await loaded();let p=c.selectRun(id(1),{anchorId:'2',contextId:digest('b')});requests.at(-1).reject({status:422,code:'response-limit'});await p;assert.equal(c.state.run.error.code,'response-limit');assert.equal(c.state.window.items.length,3);assert.equal(c.state.refreshRequired,null);p=c.retryRun();assert.match(requests.at(-1).url,/\/runs\/00000000-0000-0000-0000-000000000001\?epoch=/);requests.at(-1).resolve(detail());await p;assert.equal(c.state.selection.anchor_id,'2');assert.equal(c.state.run.error,null);assert.equal(requests.length,3);c.dispose();}); + +test('repeated stale output responses stop automatic recovery and retain explicit reload state',async()=>{const {c,requests}=await selected();const p=c.loadOutputs();requests.at(-1).reject({status:409,code:'candidate-stale'});await p;assert.equal(requests.length,4);requests.at(-1).reject({status:409,code:'candidate-stale'});await new Promise(resolve=>setImmediate(resolve));assert.equal(requests.length,4,'recovery must not poll indefinitely');assert.equal(c.state.refreshRequired,'candidate-stale');assert.equal(c.state.outputs.error.code,'candidate-stale');c.dispose();}); + +test('known and unknown frozen evidence cannot contradict their field or declared role provenance',async()=>{for(const mutate of [p=>p.contexts[0].evidence.role_provenance='recorded',p=>p.contexts[0].evidence.negative.raw.reason='missing',p=>p.contexts[0].evidence.positive.effective.reason=null,p=>p.run.outcome='conflicting',p=>p.run.reason='terminal_error']) {const {c,requests}=await loaded();const p=c.selectRun(id(1)),data=detail();mutate(data);requests.at(-1).resolve(data);await p;assert.ok(c.state.run.error);assert.equal(c.state.run.detail,null);c.dispose();}}); + +test('contradictory page counts and unrelated summary evidence are rejected before showing branches',async()=>{for(const mutate of [p=>{p.items=[];p.has_more=true;p.next_cursor='more';},p=>p.context_summaries[0].negative_raw.evidence[0].run_id=id(88),p=>p.context_summaries[0].positive_snapshot_id=digest('f')]){const {c,requests}=setup(),p=c.load(),data=windowPage();mutate(data);requests[0].resolve(data);await p;assert.ok(c.state.window.error);c.dispose();}const {c,requests}=await selected();let p=c.loadOutputs();requests.at(-1).resolve(outputs());await p;p=c.loadOutputs();requests.at(-1).resolve(outputs([receipt('2',0)],false));await p;assert.ok(c.state.outputs.error);assert.equal(c.state.outputs.items.length,2);assert.equal(c.state.outputs.next_cursor,'outputs-token');c.dispose();}); diff --git a/tests/js/workspace-inspection-state.test.js b/tests/js/workspace-inspection-state.test.js index f514ef8..f49c869 100644 --- a/tests/js/workspace-inspection-state.test.js +++ b/tests/js/workspace-inspection-state.test.js @@ -324,3 +324,20 @@ test('invalid attempted selections abort pending reads and suppress late results controller.dispose(); } }); + +test('explicit rooted adapter preserves the strict default identity and download contract', () => { + assert.ok(module.rootedFileAdapter, 'the default rooted adapter is exported'); + const a = module.rootedFileAdapter, entry = image(); + assert.equal(a.key(entry), '0:folder/a #%.png'); assert.equal(a.scope(entry), scopeA); + assert.equal(a.inspectionPath(entry), module.inspectPath(entry)); + assert.deepEqual(a.originalDownload(entry), module.originalMediaDownload(entry)); + assert.deepEqual(a.toRecord(envelope(), entry, extractGenerationFacts), module.toInspectionRecord(envelope(), entry, extractGenerationFacts)); + assert.throws(() => a.validate({kind:'reported-location',path:'a.png'}), TypeError); +}); + +test('rooted controller keeps legacy descriptors with ignored noncloneable fields source compatible', async () => { + const {api,requests}=pendingApi(),controller=module.createInspectionController({api,extractFacts:extractGenerationFacts}); + const selected={...image(),ignoredCallback(){}}; + let pending;assert.doesNotThrow(()=>{pending=controller.select(selected);}); + requests[0].resolve(envelope());await pending;assert.equal(controller.state.record.identity.key,'0:folder/a #%.png');controller.dispose(); +}); diff --git a/tests/js/workspace-media-viewer.test.js b/tests/js/workspace-media-viewer.test.js index b50e1f6..cd2c910 100644 --- a/tests/js/workspace-media-viewer.test.js +++ b/tests/js/workspace-media-viewer.test.js @@ -32,3 +32,81 @@ test('preferences handle denied malformed storage without mutation', () => { raw=JSON.stringify({videoAutoplay:'true',videoMute:0,videoLoop:null}); assert.deepEqual(module.readViewerPreferences(()=>storage,normalize),defaults); }); + +function mountedDom() { + class Node { + constructor(tag) {this.tagName=tag;this.children=[];this.dataset={};this.attributes={};this.listeners=new Map();this._text='';this.classList={add(){},remove(){}};} + get textContent(){return this._text+this.children.map(c=>c.textContent).join('');} + set textContent(value){this._text=String(value);this.children=[];} + get childNodes(){return this.children;} + get isConnected(){return this.root || !!this.parent?.isConnected;} + append(...nodes){for(const node of nodes){node.parent=this;this.children.push(node);}} + prepend(...nodes){for(const node of nodes)node.parent=this;this.children.unshift(...nodes);} + replaceChildren(...nodes){for(const child of this.children)child.parent=null;this.children=[];this._text='';this.append(...nodes);} + remove(){if(this.parent)this.parent.children=this.parent.children.filter(c=>c!==this);this.parent=null;} + setAttribute(key,value){this.attributes[key]=String(value);} + getAttribute(key){return this.attributes[key]??null;} + removeAttribute(key){delete this.attributes[key];} + addEventListener(type,callback,options={}){const list=this.listeners.get(type)||[];list.push({callback,signal:options.signal});this.listeners.set(type,list);} + removeEventListener(type,callback){this.listeners.set(type,(this.listeners.get(type)||[]).filter(e=>e.callback!==callback));} + fire(type,event={}){for(const e of [...(this.listeners.get(type)||[])])if(!e.signal?.aborted)e.callback({target:this,preventDefault(){},...event});} + focus(){this.focused=true;} + showModal(){this.open=true;} + close(){this.open=false;this.fire('close');} + contains(node){return node===this||this.children.some(c=>c.contains(node));} + closest(selector){if(selector==='section'&&this.tagName==='section')return this;if(selector==='[data-send-role]'&&this.dataset.sendRole)return this;if(selector==='[data-copy-role]'&&this.dataset.copyRole)return this;return this.parent?.closest(selector)||null;} + querySelector(selector){return walk(this,n=>n.tagName===selector)[0]||null;} + pause(){this.paused=true;} + load(){this.loaded=true;} + play(){this.played=true;return Promise.resolve();} + } + const walk=(node,predicate)=>[...(predicate(node)?[node]:[]),...node.children.flatMap(child=>walk(child,predicate))]; + const oldDocument=globalThis.document,oldWindow=globalThis.window; + const container=new Node('main');container.root=true; + const doc=new Node('document');doc.createElement=tag=>new Node(tag);doc.createElementNS=(_,tag)=>new Node(tag); + doc.querySelectorAll=()=>walk(container,n=>n.tagName==='dialog'&&n.open);doc.activeElement=new Node('button');doc.activeElement.root=true; + const win=new Node('window');win.location={href:'http://localhost/prompt_manager/workspace'};win.getSelection=()=>({isCollapsed:true}); + globalThis.document=doc;globalThis.window=win; + return {container,doc,win,walk,Node,restore(){globalThis.document=oldDocument;globalThis.window=oldWindow;}}; +} +function occurrenceMedia(node,ordinal,key) { + const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64); + const query=new URLSearchParams({node,ordinal:String(ordinal),epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`; + return {kind:'reported-location',location_key:key.repeat(64),history_epoch:epoch,current_scope:scope,occurrence:{run_id:run,output_node_id:node,image_ordinal:ordinal},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},filename:'a.png',is_video:false,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`}; +} +function borrowedController() { + const listeners=new Set();let disposed=0; + return {get state(){return {loading:false,error:false,record:null};},subscribe(fn){listeners.add(fn);fn(this.state);return ()=>listeners.delete(fn);},publish(state){for(const fn of listeners)fn(state);},retry(){},dispose(){disposed++;},get disposed(){return disposed;},get listeners(){return listeners.size;}}; +} + +test('occurrence Viewer retains exact caller selection and synchronously closes without disposing borrowed controller',async()=>{ + const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');assert.ok(adapter); + const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),selections=[],closes=[],items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')]; + const trigger=f.doc.activeElement; + const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,sender:{open(){}},onSelect:entry=>selections.push(entry.occurrence),onClose(...args){assert.equal(f.walk(f.container,n=>n.tagName==='dialog').length,0);assert.equal(controller.listeners,0);assert.equal(trigger.focused,true);closes.push(args);}}); + try { + view.open({items,selectedKey:'c'.repeat(64),trigger});assert.deepEqual(selections,[{run_id:items[0].occurrence.run_id,output_node_id:'1',image_ordinal:1}]); + const media=f.walk(f.container,n=>n.tagName==='img')[0];assert.match(media.src,/\/history\/runs\/.*\/output\/current\/media\?/);assert.match(media.src,/_pm_viewer=/);assert.doesNotMatch(media.src,/root=/); + const download=f.walk(f.container,n=>n.attributes['aria-label']==='Download original')[0];assert.equal(download.href,items[0].url); + f.walk(f.container,n=>n.attributes['aria-label']==='Next media')[0].fire('click');assert.equal(selections.at(-1).output_node_id,'2'); + f.walk(f.container,n=>n.attributes['aria-label']==='Close preview')[0].fire('click');assert.deepEqual(closes,[[]]);assert.equal(controller.disposed,0); + const count=selections.length;media.fire('load');controller.publish({loading:true});assert.equal(selections.length,count); + view.dispose();view.open({items,selectedKey:'c'.repeat(64)});assert.equal(f.container.children.length,0);assert.equal(controller.disposed,0); + } finally {view.dispose();parentScope.dispose();f.restore();} +}); + +test('slideshow stops for detail reading nested send close and parent disposal with late media callbacks ignored',async()=>{ + const {reportedLocationAdapter:adapter}=await import('../../web/js/workspace/history-media.mjs');const f=mountedDom(),parentScope=createScope(),controller=borrowedController(),items=[occurrenceMedia('1',1,'c'),occurrenceMedia('2',0,'d')];let closes=0,sends=0; + const view=module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter,onClose:()=>closes++,sender:{open(){sends++;}}}); + try { + view.open({items,selectedKey:'c'.repeat(64)}); + const find=label=>f.walk(f.container,n=>n.attributes['aria-label']===label)[0]; + let media=f.walk(f.container,n=>n.tagName==='img')[0];media.fire('load');find('Play slideshow').fire('click');assert.ok(find('Pause slideshow')); + controller.publish({loading:true,error:false});assert.ok(find('Play slideshow'),'detail reading ends slideshow intent'); + const record={identity:{kind:'reported-location',key:'c'.repeat(64),path:'a.png',occurrence:items[0].occurrence,reference:items[0].reference,history_epoch:items[0].history_epoch,current_scope:items[0].current_scope},file:null,library:null,metadata:null,generation:{},prompts:{positive:{status:'resolved',text:'current'},negative:{status:'resolved',text:''}},download:{url:items[0].url,filename:'a.png'}}; + controller.publish({loading:false,error:false,record});assert.ok(f.walk(f.container,n=>n.textContent==='Current file at the reported location ยท identity unverified').length); + find('Play slideshow').fire('click');const pair=f.walk(f.container,n=>n.dataset.sendRole==='pair')[0];pair.fire('click');assert.equal(sends,1);assert.ok(find('Play slideshow')); + find('Play slideshow').fire('click');find('Close preview').fire('click');assert.equal(closes,1);assert.equal(controller.listeners,0);media.fire('load'); + view.open({items,selectedKey:'c'.repeat(64)});media=f.walk(f.container,n=>n.tagName==='img')[0];parentScope.dispose();assert.equal(closes,1,'parent disposal suppresses onClose');assert.equal(f.container.children.length,0);media.fire('load');assert.equal(controller.disposed,0); + } finally {view.dispose();parentScope.dispose();f.restore();} +}); diff --git a/tests/js/workspace-viewer-state.test.js b/tests/js/workspace-viewer-state.test.js index 3bf7265..1440323 100644 --- a/tests/js/workspace-viewer-state.test.js +++ b/tests/js/workspace-viewer-state.test.js @@ -108,3 +108,12 @@ test('slideshow cannot start on video one entry or media error', () => { } const {view,pending}=timedSession();view.failed(view.state.key);view.play?.();assert.equal(view.state.phase,'error');assert.equal(pending.size,0);view.dispose(); }); + +test('explicit occurrence session retains video pause and timer invalidation with location keys',async()=>{ + const history=await import('../../web/js/workspace/history-media.mjs'); + const run='22222222-2222-2222-2222-222222222222',epoch='11111111-1111-1111-1111-111111111111',scope='a'.repeat(64); + const items=['c','d'].map((key,index)=>{const query=new URLSearchParams({node:String(index),ordinal:'0',epoch,current_scope:scope}),base=`/prompt_manager/history/runs/${run}/output/current`;return {kind:'reported-location',occurrence:{run_id:run,output_node_id:String(index),image_ordinal:0},reference:{folder_type:'output',subfolder:'',filename:'a.png',path_style:'posix',root_key:'b'.repeat(64)},history_epoch:epoch,current_scope:scope,location_key:key.repeat(64),filename:'a.png',is_video:index===1,url:`${base}/media?${query}`,thumbnail_url:`${base}/thumbnail?${query}`,inspection_url:`${base}/inspection?${query}`};}); + const timers=[],pending=new Set();let callbacks=0; + const view=session({adapter:history.reportedLocationAdapter,items,initialKey:'c'.repeat(64),onChange:()=>callbacks++,setTimer:fn=>{timers.push(fn);pending.add(fn);return fn;},clearTimer:fn=>pending.delete(fn)}); + view.play();view.ready('c'.repeat(64));assert.equal(pending.size,1);timers[0]();assert.equal(view.state.key,'d'.repeat(64));assert.equal(view.state.phase,'paused-video');view.stop();view.navigate(-1);view.ready('c'.repeat(64));assert.equal(view.state.requested,false);view.dispose();const count=callbacks;timers[0]();view.ready('d'.repeat(64));assert.equal(callbacks,count); +}); diff --git a/web/js/workspace/file-inspector.mjs b/web/js/workspace/file-inspector.mjs index b3edb3f..012ddcb 100644 --- a/web/js/workspace/file-inspector.mjs +++ b/web/js/workspace/file-inspector.mjs @@ -101,7 +101,7 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal status.setAttribute('role', state.error ? 'alert' : 'status'); container.append(status); if (state.error && state.refreshRequired) { if (onRefresh) { - const refresh = createButton('Refresh image collection'); + const refresh = createButton(state.code === 'history-changed' ? 'Reload recorded history' : ['candidate-stale', 'capture-changed'].includes(state.code) ? 'Reload reported outputs' : 'Refresh image collection'); contentScope.listen(refresh, 'click', onRefresh); container.append(refresh); } else container.append(el('p', 'Reopen this file from a refreshed image collection.', 'muted')); } else if (state.error && onRetry) { @@ -161,7 +161,13 @@ export function mountFileInspector(container, {sender, parentScope, inline = fal inner.append(explanation); } const provenance = disclosure('File & provenance'); - provenance.append(el('p', 'Current file at this source; historical bytes are not verified.', 'muted')); + provenance.append(el('p', record.identity.kind === 'reported-location' ? 'Current file at the reported location ยท identity unverified' : 'Current file at this source; historical bytes are not verified.', 'muted')); + if (record.identity.kind === 'reported-location') { + const occurrence = record.identity.occurrence; + provenance.append(definitionList([ + ['Recorded run', occurrence.run_id], ['Output node', occurrence.output_node_id], ['Image ordinal', String(occurrence.image_ordinal)], + ], 'file-occurrence')); + } if (file) provenance.append(definitionList([ ['Source', file.source_label], ['Path', record.identity.path], ['Size', formatBytes(file.size)], ['Dimensions', file.width && file.height ? `${file.width} ร— ${file.height}` : null], diff --git a/web/js/workspace/history-media.mjs b/web/js/workspace/history-media.mjs new file mode 100644 index 0000000..db6cb48 --- /dev/null +++ b/web/js/workspace/history-media.mjs @@ -0,0 +1,91 @@ +import {projectInspectionPayload} from './inspection-state.mjs'; + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value); +const digest = value => typeof value === 'string' && /^[a-f0-9]{64}$/.test(value); +function scalar(value, bytes, nonempty = false) { + if (typeof value !== 'string' || (nonempty && !value) || value.includes('\0') || /[\ud800-\udfff]/u.test(value) || new TextEncoder().encode(value).length > bytes) throw new TypeError('Invalid reported location scalar'); +} +function coordinate(value) { + if (!object(value) || !uuid(value.run_id) || !Number.isSafeInteger(value.image_ordinal) || value.image_ordinal < 0 || value.image_ordinal > 255) throw new TypeError('Invalid output occurrence'); + scalar(value.output_node_id, 512, true); + if ([...value.output_node_id].length > 128) throw new TypeError('Invalid output node'); +} +function reference(value) { + if (!object(value) || !['output', 'temp'].includes(value.folder_type) || !['posix', 'windows'].includes(value.path_style) || !(value.root_key === null || digest(value.root_key))) throw new TypeError('Invalid output reference'); + scalar(value.filename, 1024, true); scalar(value.subfolder, 2048); + const windows = value.path_style === 'windows', separators = windows ? /[/\\]/ : /\//; + if (['.', '..'].includes(value.filename) || separators.test(value.filename) || value.subfolder.startsWith('/') || + (windows && (/^[\\/]/.test(value.subfolder) || /:/.test(value.filename + value.subfolder))) || + (value.subfolder && value.subfolder.split(separators).some(part => !part || part === '.' || part === '..'))) throw new TypeError('Unsafe output reference'); + if (128 + new TextEncoder().encode(value.folder_type + value.path_style + value.subfolder + value.filename + (value.root_key || '')).length > 4096) throw new TypeError('Output reference exceeds limit'); +} +export function occurrenceKey(occurrence) { + coordinate(occurrence); + return JSON.stringify([occurrence.run_id, occurrence.output_node_id, occurrence.image_ordinal]); +} +function validateTarget(target) { + if (!object(target) || target.kind !== 'reported-location' || !uuid(target.history_epoch) || !digest(target.current_scope) || !digest(target.location_key) || typeof target.is_video !== 'boolean') throw new TypeError('Invalid reported location'); + coordinate(target.occurrence); reference(target.reference); + if (target.filename !== target.reference.filename) throw new TypeError('Mismatched reported filename'); +} +function urlFor(target, field, endpoint) { + const location = globalThis.location || globalThis.window?.location; + const base = new URL(location?.href || 'http://localhost/'); + const resolve = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (value => value); + if (typeof target[field] !== 'string' || !target[field]) throw new TypeError('Missing reported media URL'); + const value = resolve(target[field]), url = new URL(value, base); + const expected = new URL(resolve(`/prompt_manager/history/runs/${target.occurrence.run_id}/output/current/${endpoint}`), base); + const identity = {node:target.occurrence.output_node_id, ordinal:String(target.occurrence.image_ordinal), epoch:target.history_epoch, current_scope:target.current_scope}; + if (!['http:', 'https:'].includes(url.protocol) || url.origin !== base.origin || url.username || url.password || url.hash || + url.pathname !== expected.pathname || /\/(?:\.|%2e){1,2}(?:\/|%2f)/i.test(value) || + [...url.searchParams.keys()].some(key => !Object.hasOwn(identity, key) && key !== '_pm_viewer') || url.searchParams.getAll('_pm_viewer').length > 1 || + Object.entries(identity).some(([key, expected]) => url.searchParams.getAll(key).length !== 1 || url.searchParams.get(key) !== expected)) throw new TypeError('Invalid reported media identity'); + return value; +} +function validate(target) { + validateTarget(target); + urlFor(target, 'url', 'media'); urlFor(target, 'inspection_url', 'inspection'); urlFor(target, 'thumbnail_url', 'thumbnail'); +} +function changed(code) { + return Object.assign(new Error('Current recorded location changed; reload before using this file.'), {refreshRequired:true, code, recovery:{action:code === 'history-changed' ? 'reload-history' : 'refresh-outputs'}}); +} +function sameReference(a, b) { + return object(a) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => a[key] === b[key]); +} +export function toHistoryInspectionRecord(result, target, extractFacts) { + validate(target); + if (!object(result) || result.success !== true || result.history_version !== 1 || result.inspection_version !== 1) throw new TypeError('Invalid history inspection'); + if (result.history_epoch !== target.history_epoch) throw changed('history-changed'); + const current = result.current_output; + if (!object(current) || current.history_epoch !== target.history_epoch || current.current_scope !== target.current_scope || current.location_key !== target.location_key || + !object(current.occurrence) || ['run_id', 'output_node_id', 'image_ordinal'].some(key => current.occurrence[key] !== target.occurrence[key]) || !sameReference(current.reference, target.reference)) throw changed('candidate-stale'); + if (current.observation !== 'current-file' || current.evidence_class !== 'locator_candidate' || current.file_identity !== 'unverified' || current.newly_generated !== 'unknown' || current.context_membership !== 'unknown' || current.transient !== (target.reference.folder_type === 'temp')) throw new TypeError('Invalid occurrence evidence'); + const path = [target.reference.subfolder, target.reference.filename].filter(Boolean).join('/'); + const identity = {kind:'reported-location', key:target.location_key, path:target.reference.path_style === 'windows' ? path.replaceAll('\\', '/') : path, + occurrence:{...target.occurrence}, history_epoch:target.history_epoch, current_scope:target.current_scope, reference:{...target.reference}}; + return projectInspectionPayload(result, identity, {url:urlFor(target, 'url', 'media'), filename:target.filename}, extractFacts); +} +export const reportedLocationAdapter = Object.freeze({ + validate, + key(target) {validateTarget(target); return target.location_key;}, + scope(target) {validateTarget(target); return `${target.history_epoch}:${target.current_scope}`;}, + inspectionPath(target) {validate(target); return target.inspection_url;}, + originalDownload(target) {validate(target); return {url:urlFor(target, 'url', 'media'), filename:target.filename};}, + toRecord:toHistoryInspectionRecord, +}); + +/** Only loaded, currently available locations enter a frozen Viewer opening. */ +export function distinctCurrentMedia(receipts, selectedOccurrence) { + if (!Array.isArray(receipts)) throw new TypeError('Invalid loaded receipts'); + const selected = selectedOccurrence === null || selectedOccurrence === undefined ? null : occurrenceKey(selectedOccurrence); + const locations = new Map(); + for (const receipt of receipts) { + if (receipt?.current_candidate?.status !== 'available') continue; + const target = receipt.current_candidate.media; + validate(target); + if (occurrenceKey(receipt.occurrence) !== occurrenceKey(target.occurrence) || !sameReference(receipt.reference, target.reference)) throw new TypeError('Mismatched receipt media'); + if (!locations.has(target.location_key) || occurrenceKey(target.occurrence) === selected) locations.set(target.location_key, target); + } + return [...locations.values()]; +} diff --git a/web/js/workspace/history-state.mjs b/web/js/workspace/history-state.mjs new file mode 100644 index 0000000..7ebfd98 --- /dev/null +++ b/web/js/workspace/history-state.mjs @@ -0,0 +1,304 @@ +import {occurrenceKey, reportedLocationAdapter} from './history-media.mjs'; + +const object = value => value !== null && typeof value === 'object' && !Array.isArray(value); +const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value); +const digest = value => typeof value === 'string' && /^[0-9a-f]{64}$/.test(value); +const id = value => Number.isSafeInteger(value) && value > 0; +const count = value => Number.isSafeInteger(value) && value >= 0; +const fields = ['raw', 'effective', 'prepend', 'append']; +const times = ['queue_item_id', 'observed_dequeue_at', 'observed_callback_at', 'executor_started_at', 'terminal_event_at']; +function requireValue(valid, message = 'Invalid recorded history response') {if (!valid) throw new TypeError(message);} +function text(value, maxBytes, maxChars = Infinity) { + return typeof value === 'string' && [...value].length <= maxChars && !/[\ud800-\udfff]/u.test(value) && new TextEncoder().encode(value).length <= maxBytes; +} +function node(value) {return text(value, 512, 128) && value.length > 0 && !value.includes('\0');} +function decimal(value) {return typeof value === 'string' && /^(?:0|[1-9][0-9]{0,18})$/.test(value) && BigInt(value) <= 9223372036854775807n;} +function freeze(value) { + if (value && typeof value === 'object' && !Object.isFrozen(value)) {Object.values(value).forEach(freeze); Object.freeze(value);} + return value; +} +function envelope(value, epoch = null) { + requireValue(object(value) && value.success === true && value.history_version === 1 && uuid(value.history_epoch)); + if (epoch !== null && value.history_epoch !== epoch) throw Object.assign(new Error('Recorded history changed'), {status:409, code:'history-changed'}); +} +function pagination(value, previousCursor) { + requireValue(typeof value.has_more === 'boolean' && (value.next_cursor === null || (text(value.next_cursor, 4096) && value.next_cursor.length > 0)) && + value.has_more === (value.next_cursor !== null) && (!value.has_more || value.next_cursor !== previousCursor)); +} +function capture(value, output = false) { + requireValue(object(value) && ['complete', 'partial', 'ambiguous', 'unavailable', 'conflicting', 'not_captured'].includes(value.state) && + (value.reason === null || text(value.reason, 128)) && (value.state === 'not_captured' ? value.format_version === null : value.format_version === 1)); + if (output) requireValue(!['ambiguous'].includes(value.state) && (value.state === 'not_captured' ? value.source === null : value.source === 'host_final_history')); +} +function summary(value) { + capture(value, true); + requireValue((value.retained_occurrence_count === null || (count(value.retained_occurrence_count) && value.retained_occurrence_count <= 256)) && + value.list_status === (value.state === 'not_captured' ? 'not_captured' : value.state === 'complete' ? 'complete' : 'incomplete') && + (value.state === 'not_captured') === (value.retained_occurrence_count === null)); +} +function evidence(value) { + requireValue(object(value) && ['recorded', 'inferred', 'unknown'].includes(value.provenance) && + ({recorded:['runtime_return', 'host_cache_output'], inferred:['graph_literal', 'graph_semantics'], unknown:['unknown']})[value.provenance].includes(value.source) && + (value.provenance === 'unknown' ? ['unavailable', 'unsupported', 'ambiguous', 'limit_reached', 'missing'].includes(value.reason) : value.reason === null)); +} +function anchor(value) { + requireValue(object(value) && node(value.anchor_id) && digest(value.context_id) && + (value.positive_snapshot_id === null || digest(value.positive_snapshot_id)) && (value.negative_snapshot_id === null || digest(value.negative_snapshot_id))); +} +function validateRun(value, window = false) { + requireValue(object(value) && ['run_id', 'session_id', 'job_id', 'storage_epoch'].every(key => uuid(value[key])) && value.record_version === 1); + for (const key of times) requireValue((!['queue_item_id', 'observed_dequeue_at'].includes(key) && value[key] === null) || decimal(value[key])); + requireValue(['observed', 'succeeded', 'failed', 'interrupted', 'unknown'].includes(value.outcome) && + (value.reason === null || text(value.reason, 128)) && typeof value.diagnostics_omitted === 'boolean'); + const reasons = {succeeded:['terminal_success'], failed:['terminal_error'], interrupted:['terminal_interrupted'], unknown:['missing_terminal', 'conflicting_terminal', 'invalid_evidence', 'evidence_limit']}; + requireValue(value.outcome === 'observed' ? value.reason === null && value.observed_callback_at === null : value.observed_callback_at !== null && reasons[value.outcome].includes(value.reason)); + if (window) { + requireValue(typeof value.diagnostics_available === 'boolean' && Array.isArray(value.matching_anchors) && value.matching_anchors.length <= 16); + value.matching_anchors.forEach(anchor); capture(value.context_capture); summary(value.output_summary); + requireValue(new Set(value.matching_anchors.map(a => a.anchor_id)).size === value.matching_anchors.length); + } +} +function compareRun(a, b) { + for (const key of ['observed_dequeue_at', 'session_id', 'queue_item_id', 'run_id']) { + const av = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(a[key]) : a[key]; + const bv = key === 'observed_dequeue_at' || key === 'queue_item_id' ? BigInt(b[key]) : b[key]; + if (av !== bv) return av < bv ? -1 : 1; + } + return 0; +} +function validateWindow(value, positiveId, cursor, epoch) { + envelope(value, epoch); pagination(value, cursor); + requireValue(object(value.group) && value.group.positive_id === positiveId && (value.group.raw_text_id === null || digest(value.group.raw_text_id)) && + value.group.relation === 'exact_frozen_raw_text' && ['uncapturable_text', 'no_matches', 'matched'].includes(value.group.scope_status) && + count(value.recorded_run_count) && Array.isArray(value.items) && value.items.length <= 100 && value.recorded_run_count >= value.items.length && Array.isArray(value.context_summaries)); + value.items.forEach(run => validateRun(run, true)); + requireValue(new Set(value.items.map(r => r.run_id)).size === value.items.length && value.items.every((r, i) => i === 0 || compareRun(value.items[i - 1], r) < 0)); + const branches = new Set(); + for (const s of value.context_summaries) { + requireValue(object(s) && digest(s.context_id) && !branches.has(s.context_id) && digest(s.positive_snapshot_id) && (s.negative_snapshot_id === null || digest(s.negative_snapshot_id))); + branches.add(s.context_id); + const raw = s.negative_raw; + requireValue(object(raw) && ['unknown', 'explicit_empty', 'known'].includes(raw.state) && typeof raw.truncated === 'boolean' && Array.isArray(raw.evidence) && + (raw.state === 'unknown' ? raw.preview === null && !raw.truncated : text(raw.preview, 512, 100) && (raw.state === 'explicit_empty' ? raw.preview === '' && !raw.truncated : raw.preview !== ''))); + for (const e of raw.evidence) {requireValue(uuid(e.run_id) && node(e.anchor_id)); evidence(e);} + } + requireValue((!value.has_more || value.items.length > 0) && value.items.every(r => r.matching_anchors.every(a => branches.has(a.context_id)))); + for (const s of value.context_summaries) { + const matching = value.items.flatMap(r => r.matching_anchors.filter(a => a.context_id === s.context_id).map(a => ({...a, run_id:r.run_id}))); + requireValue(matching.length > 0 && matching.every(a => a.positive_snapshot_id === s.positive_snapshot_id && a.negative_snapshot_id === s.negative_snapshot_id) && + s.negative_raw.evidence.length === matching.length && new Set(s.negative_raw.evidence.map(e => JSON.stringify([e.run_id, e.anchor_id]))).size === matching.length && + s.negative_raw.evidence.every(e => matching.some(a => a.run_id === e.run_id && a.anchor_id === e.anchor_id))); + } +} +function validateContext(value) { + anchor(value); + requireValue(uuid(value.reference_epoch) && object(value.evidence) && value.evidence.format_version === 1 && value.evidence.role_provenance === 'inferred'); + for (const role of ['positive', 'negative']) { + const side = value[role], proof = value.evidence[role]; + requireValue((side === null) === (value[`${role}_snapshot_id`] === null)); + if (side === null) {requireValue(proof === null); continue;} + requireValue(object(side) && object(proof)); + for (const field of fields) { + requireValue(side[field] === null || text(side[field], 65536)); evidence(proof[field]); + requireValue((side[field] === null) === (proof[field].provenance === 'unknown')); + } + } + requireValue(['positive_prompt_id', 'negative_prompt_id', 'pair_id'].every(key => value[key] === null || id(value[key])) && object(value.navigation) && + ['positive_available', 'negative_available', 'pair_available'].every(key => typeof value.navigation[key] === 'boolean')); +} +function validateDetail(value, runId, epoch) { + envelope(value, epoch); validateRun(value.run); capture(value.context_capture); summary(value.output_summary); + requireValue(value.run.run_id === runId && Array.isArray(value.contexts) && value.contexts.length <= 16); + value.contexts.forEach(validateContext); + requireValue(new Set(value.contexts.map(c => c.anchor_id)).size === value.contexts.length); +} +export function frozenPromptRoles(context, mode) { + requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); validateContext(context); + return Object.fromEntries(['positive', 'negative'].map(role => { + const value = context[role]?.[mode] ?? null, proof = context.evidence[role]?.[mode] ?? null; + return [role, {status:value === null ? 'unresolved' : 'resolved', text:value, source:proof?.provenance === 'recorded' ? 'execution' : proof?.provenance === 'inferred' ? 'graph-inferred' : null, evidence:proof, + message:value === null ? 'This frozen field was not recorded.' : undefined}]; + })); +} +function validateReference(r) { + requireValue(object(r) && ['output', 'temp'].includes(r.folder_type) && ['posix', 'windows'].includes(r.path_style) && (r.root_key === null || digest(r.root_key)) && + text(r.filename, 1024) && r.filename && !['.', '..'].includes(r.filename) && text(r.subfolder, 2048) && !r.filename.includes('\0') && !r.subfolder.includes('\0')); + const split = r.path_style === 'windows' ? /[/\\]/ : /\//; + requireValue(!split.test(r.filename) && !r.subfolder.startsWith('/') && (!r.subfolder || r.subfolder.split(split).every(p => p && p !== '.' && p !== '..')) && + (r.path_style !== 'windows' || (!r.subfolder.startsWith('\\') && !/:/.test(r.filename + r.subfolder)))); +} +function compareCoordinate(a, b) { + const aa = new TextEncoder().encode(a.output_node_id), bb = new TextEncoder().encode(b.output_node_id); + for (let i = 0; i < Math.min(aa.length, bb.length); i++) if (aa[i] !== bb[i]) return aa[i] - bb[i]; + return aa.length - bb.length || a.image_ordinal - b.image_ordinal; +} +function validateOutputs(value, runId, epoch, cursor, previous) { + envelope(value, epoch); pagination(value, cursor); capture(value.capture, true); + summary({...value.capture, retained_occurrence_count:value.retained_occurrence_count, list_status:value.list_status}); + requireValue(digest(value.capture_fingerprint) && Array.isArray(value.items) && value.items.length <= 100 && (value.retained_occurrence_count ?? 0) >= value.items.length); + if (cursor !== null && previous.capture_fingerprint !== value.capture_fingerprint) throw Object.assign(new Error('Output capture changed'), {status:409, code:'capture-changed'}); + for (const r of value.items) { + occurrenceKey(r.occurrence); validateReference(r.reference); + requireValue(r.occurrence.run_id === runId && r.file_identity === 'unverified' && r.newly_generated === 'unknown' && r.context_membership === 'unknown' && r.transient === (r.reference.folder_type === 'temp')); + const c = r.current_candidate; + requireValue(object(c) && ['available', 'unavailable', 'error'].includes(c.status) && c.evidence_class === 'locator_candidate'); + if (c.status === 'available') { + reportedLocationAdapter.validate(c.media); + requireValue(c.reason === null && c.current_scope === c.media.current_scope && c.location_key === c.media.location_key && c.media.history_epoch === epoch && + occurrenceKey(c.media.occurrence) === occurrenceKey(r.occurrence) && ['folder_type', 'subfolder', 'filename', 'path_style', 'root_key'].every(key => c.media.reference[key] === r.reference[key]) && text(c.relative_path, 4096) && text(c.source_label, 4096) && + ['none', 'unique', 'ambiguous'].includes(c.gallery_match) && (c.gallery_image_id === null || id(c.gallery_image_id))); + } else requireValue(text(c.reason, 128) && c.reason.length > 0 && !['media', 'url', 'thumbnail_url', 'inspection_url'].some(key => Object.hasOwn(c, key))); + } + requireValue(value.items.every((r, i) => i === 0 || compareCoordinate(value.items[i - 1].occurrence, r.occurrence) < 0)); + if (cursor !== null && previous.items.length && value.items.length) requireValue(compareCoordinate(previous.items.at(-1).occurrence, value.items[0].occurrence) < 0); +} +const emptyWindow = () => ({items:[], context_summaries:[], group:null, recorded_run_count:0, next_cursor:null, has_more:false, history_epoch:null, loading:false, error:null}); +const emptyRun = () => ({detail:null, loading:false, error:null}); +const emptyOutputs = () => ({items:[], capture:null, capture_fingerprint:null, retained_occurrence_count:null, list_status:null, next_cursor:null, has_more:false, loading:false, error:null}); +const emptySelection = mode => ({run_id:null, anchor_id:null, context_id:null, mode, receipt:null}); + +export function createHistoryController({api, positiveId, onChange = () => {}, restore}) { + requireValue(id(positiveId), 'Invalid saved positive ID'); + const intent = {search:typeof restore?.search === 'string' ? restore.search : '', scrollTop:typeof restore?.scrollTop === 'number' && Number.isFinite(restore.scrollTop) ? Math.max(0, restore.scrollTop) : 0}; + let state = freeze({window:emptyWindow(), run:emptyRun(), outputs:emptyOutputs(), selection:emptySelection(restore?.mode === 'effective' ? 'effective' : 'raw'), + parent:{detail:null, loading:false, error:null}, intent, refreshRequired:null, refreshLabel:'', validating:false, actionsEnabled:false}); + let disposed = false, failedOutput = null, runOptions = null; + let restoreHint = uuid(restore?.runId) && (restore.anchorId === undefined || restore.anchorId === null || node(restore.anchorId)) && (restore.contextId === undefined || restore.contextId === null || digest(restore.contextId)) ? {runId:restore.runId, anchorId:restore.anchorId ?? null, contextId:restore.contextId ?? null} : null; + const requests = {window:null, run:null, outputs:null, parent:null, validation:null}; + const generations = {window:0, run:0, outputs:0, parent:0, validation:0}; + function cancel(slot) {generations[slot]++; requests[slot]?.abort(); requests[slot] = null;} + function begin(slot) {cancel(slot); const request = requests[slot] = new AbortController(), generation = generations[slot]; return {signal:request.signal, current:() => !disposed && requests[slot] === request && generations[slot] === generation};} + function emit(patch) { + if (disposed) return; + const next = {...state, ...patch}; + next.actionsEnabled = !!next.window.history_epoch && !next.refreshRequired && !next.validating && !next.window.loading && !next.window.error && !next.run.loading && !next.run.error; + state = freeze(next); onChange(state); + } + function clearOutputs() {cancel('outputs'); failedOutput = null; return emptyOutputs();} + function clearDependents() {cancel('run'); return {run:emptyRun(), outputs:clearOutputs(), selection:emptySelection(state.selection.mode)};} + async function validateParent() { + const request = begin('parent'); emit({parent:{detail:null, loading:true, error:null}}); + try { + const detail = await api.get(`/prompt_manager/library/${positiveId}`, {signal:request.signal}); + if (!request.current()) return; + requireValue(object(detail) && object(detail.positive) && detail.positive.id === positiveId && typeof detail.positive.text === 'string'); + emit({parent:{detail:structuredClone(detail), loading:false, error:null}}); + } catch (error) {if (request.current()) emit({parent:{detail:null, loading:false, error}});} + } + function recover(error) { + if (error?.status !== 409 || !['history-changed', 'capture-changed', 'candidate-stale'].includes(error.code)) return false; + if (error.code === 'history-changed') { + restoreHint = null; runOptions = null; + cancel('window'); cancel('validation'); + emit({...clearDependents(), window:emptyWindow(), validating:false, refreshRequired:error.code, refreshLabel:'Reload recorded history'}); + void validateParent(); + } else { + if (state.refreshRequired === error.code) {emit({outputs:{...state.outputs, loading:false, error}}); return true;} + emit({outputs:clearOutputs(), selection:{...state.selection, receipt:null}, refreshRequired:error.code, refreshLabel:'Reload reported outputs'}); + void fetchOutputs(null, 40); + } + return true; + } + function mergeSummaries(earlier, later, items) { + const map = new Map(); + for (const s of [...earlier, ...later]) { + const previous = map.get(s.context_id); + const evidence = new Map([...(previous?.negative_raw.evidence || []), ...s.negative_raw.evidence].map(e => [JSON.stringify([e.run_id, e.anchor_id]), e])); + map.set(s.context_id, {...s, negative_raw:{...s.negative_raw, evidence:[...evidence.values()]}}); + } + const ordered = new Map(); for (const r of items) for (const a of r.matching_anchors) ordered.set(a.context_id, map.get(a.context_id)); + return [...ordered.values()]; + } + async function fetchWindow(earlier = false) { + if (disposed || (earlier && (state.window.loading || !state.window.has_more || state.refreshRequired))) return; + const cursor = earlier ? state.window.next_cursor : null, epoch = earlier ? state.window.history_epoch : null; + const previous = state.window, request = begin('window'); + if (!earlier) {cancel('validation'); emit({...clearDependents(), window:emptyWindow(), validating:false, refreshRequired:null, refreshLabel:''});} + emit({window:{...state.window, loading:true, error:null}}); + const query = new URLSearchParams({limit:'40'}); if (cursor !== null) query.set('cursor', cursor); + try { + const value = await api.get(`/prompt_manager/library/${positiveId}/history?${query}`, {signal:request.signal}); + if (!request.current()) return; + validateWindow(value, positiveId, cursor, epoch); + const page = structuredClone(value), merged = new Map(); + for (const r of [...page.items, ...(earlier ? previous.items : [])]) merged.set(r.run_id, r); + const items = [...merged.values()].sort(compareRun); + emit({window:{...page, items, context_summaries:mergeSummaries(page.context_summaries, earlier ? previous.context_summaries : [], items), loading:false, error:null}}); + const hint = restoreHint; restoreHint = null; + if (hint && items.some(r => r.run_id === hint.runId)) void selectRun(hint.runId, hint); + } catch (error) {if (request.current() && !recover(error)) emit({window:{...state.window, loading:false, error}});} + } + async function selectRun(runId, {anchorId = null, contextId = null} = {}) { + if (disposed) return; + requireValue(uuid(runId) && (anchorId === null || node(anchorId)) && (contextId === null || digest(contextId)), 'Invalid run selection'); + requireValue(state.window.history_epoch !== null && !state.refreshRequired, 'Load recorded history before selecting a run'); + cancel('validation'); + runOptions = {runId, anchorId, contextId}; + const request = begin('run'), epoch = state.window.history_epoch; + emit({validating:false, run:{...emptyRun(), loading:true}, outputs:clearOutputs(), selection:{...emptySelection(state.selection.mode), run_id:runId}}); + try { + const value = await api.get(`/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}`, {signal:request.signal}); + if (!request.current()) return; + validateDetail(value, runId, epoch); + const detail = structuredClone(value); + const selected = detail.contexts.find(c => (anchorId === null || c.anchor_id === anchorId) && (contextId === null || c.context_id === contextId)); + requireValue((anchorId === null && contextId === null) || !!selected, 'Recorded context is unavailable'); + emit({run:{detail, loading:false, error:null}, selection:{...state.selection, anchor_id:selected?.anchor_id ?? null, context_id:selected?.context_id ?? null}}); + } catch (error) {if (request.current() && !recover(error)) emit({run:{detail:null, loading:false, error}});} + } + function cancelDependentRead() {cancel('outputs'); emit({outputs:{...state.outputs, loading:false}});} + async function fetchOutputs(cursor, limit) { + if (disposed || !state.run.detail || state.refreshRequired === 'history-changed') return; + const runId = state.selection.run_id, epoch = state.window.history_epoch, previous = state.outputs, request = begin('outputs'); + const attempt = {cursor, limit}; + emit({outputs:{...previous, loading:true, error:null}}); + const query = new URLSearchParams({epoch, limit:String(limit)}); if (cursor !== null) query.set('cursor', cursor); + try { + const value = await api.get(`/prompt_manager/history/runs/${runId}/outputs?${query}`, {signal:request.signal}); + if (!request.current()) return; + validateOutputs(value, runId, epoch, cursor, previous); + const page = structuredClone(value), items = cursor === null ? page.items : [...previous.items, ...page.items]; + requireValue(items.length <= 256 && (page.retained_occurrence_count ?? 0) >= items.length && (page.has_more ? page.items.length > 0 && (page.retained_occurrence_count ?? 0) > items.length : (page.retained_occurrence_count ?? 0) === items.length)); + failedOutput = null; + emit({outputs:{...page, items, loading:false, error:null}, refreshRequired:null, refreshLabel:''}); + } catch (error) { + if (!request.current() || recover(error)) return; + failedOutput = attempt; + if (error?.status === 422 && error.code === 'response-limit' && error.recovery?.action === 'retry-outputs' && Number.isSafeInteger(error.recovery.limit) && error.recovery.limit >= 1 && error.recovery.limit < limit) failedOutput = {cursor, limit:error.recovery.limit}; + emit({outputs:{...previous, loading:false, error}}); + } + } + async function revalidate() { + if (disposed || !state.window.history_epoch || state.refreshRequired) return; + const request = begin('validation'), epoch = state.window.history_epoch, runId = state.selection.run_id; + cancel('window'); cancel('run'); cancel('outputs'); + emit({validating:true, window:{...state.window, loading:false}, run:{...state.run, loading:false}, outputs:{...state.outputs, loading:false}}); + try { + const value = await api.get(runId ? `/prompt_manager/history/runs/${runId}?${new URLSearchParams({epoch})}` : `/prompt_manager/library/${positiveId}/history?limit=40`, {signal:request.signal}); + if (!request.current()) return; + if (runId) validateDetail(value, runId, epoch); else validateWindow(value, positiveId, null, epoch); + const hadOutputs = state.outputs.capture !== null; + emit({validating:false, ...(runId ? {run:{detail:structuredClone(value), loading:false, error:null}} : {})}); + if (hadOutputs) {emit({outputs:clearOutputs(), selection:{...state.selection, receipt:null}}); return fetchOutputs(null, 40);} + } catch (error) {if (request.current() && !recover(error)) emit({validating:false, refreshRequired:'revalidation-failed', refreshLabel:'Reload recorded history', window:{...state.window, error}});} + } + return { + get state() {return state;}, load:() => fetchWindow(), refresh:() => fetchWindow(), loadEarlier:() => fetchWindow(true), selectRun, + selectContext(anchorId) { + if (disposed) return; + requireValue(node(anchorId), 'Invalid context anchor'); const c = state.run.detail?.contexts.find(c => c.anchor_id === anchorId); requireValue(!!c, 'Recorded context is unavailable'); + cancelDependentRead(); emit({selection:{...state.selection, anchor_id:c.anchor_id, context_id:c.context_id}}); + }, + setMode(mode) {if (disposed) return; requireValue(['raw', 'effective'].includes(mode), 'Invalid frozen prompt mode'); if (mode === state.selection.mode) return; cancelDependentRead(); emit({selection:{...state.selection, mode}});}, + loadOutputs() {if (disposed || state.outputs.loading || (state.outputs.capture !== null && !state.outputs.has_more)) return; return fetchOutputs(state.outputs.next_cursor, 40);}, + selectReceipt(occurrence) { + if (disposed) return; + const key = occurrenceKey(occurrence); requireValue(!state.refreshRequired && !state.validating && state.outputs.items.some(r => occurrenceKey(r.occurrence) === key), 'Reported output is unavailable'); + cancelDependentRead(); emit({selection:{...state.selection, receipt:{...occurrence}}}); + }, + retryRun() {if (!disposed && !state.refreshRequired && runOptions) return selectRun(runOptions.runId, runOptions);}, + retryOutputs() {if (!disposed && !state.refreshRequired && failedOutput) return fetchOutputs(failedOutput.cursor, failedOutput.limit);}, + revalidate, + dispose() {if (disposed) return; disposed = true; Object.keys(requests).forEach(cancel); failedOutput = null; runOptions = null; state = freeze({...state, ...clearDependents(), window:emptyWindow(), parent:{detail:null, loading:false, error:null}, actionsEnabled:false});}, + }; +} diff --git a/web/js/workspace/inspection-state.mjs b/web/js/workspace/inspection-state.mjs index 6efd2af..628aaad 100644 --- a/web/js/workspace/inspection-state.mjs +++ b/web/js/workspace/inspection-state.mjs @@ -151,10 +151,16 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf if (!legacy && result.inspection_version !== 1) throw new TypeError('Unsupported inspection version'); if (!legacy && result.success !== true) throw new TypeError('Invalid inspection success status'); if ((!legacy || result.image !== undefined) && (!object(result.image) || result.image.root_index !== image.root_index || result.image.path !== image.path || result.image.filename !== image.path.split('/').at(-1))) throw new TypeError('Mismatched inspection identity'); + return projectInspectionPayload(result, + { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope }, + downloadFor(image, !legacy || scope !== null), extractFacts, legacy); +} + +/** Shared bounded payload projection; adapters validate identity before entering here. */ +export function projectInspectionPayload(result, identity, download, extractFacts = globalThis.ComfyMetadata?.extractGenerationFacts || globalThis.window?.ComfyMetadata?.extractGenerationFacts, legacy = false) { const record = { - identity: { kind: 'rooted-file', key: imageKey(image), root_index: image.root_index, path: image.path, root_scope: scope }, - file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, - generation: unavailableFacts(), historyTarget: null, download: downloadFor(image, !legacy || scope !== null), + identity, file: null, prompts: promptsFor(result.prompts), library: null, metadata: null, + generation: unavailableFacts(), historyTarget: null, download, }; if (legacy) return record; const resolution = result.resolution; @@ -170,7 +176,12 @@ export function toInspectionRecord(result, image, extractFacts = globalThis.Comf return record; } -export function createInspectionLoader(api, onChange, { extractFacts } = {}) { +export const rootedFileAdapter = Object.freeze({ + validate: validateIdentity, key: imageKey, scope: descriptorScope, + inspectionPath: inspectPath, originalDownload: originalMediaDownload, toRecord: toInspectionRecord, +}); + +export function createInspectionLoader(api, onChange, { extractFacts, adapter = rootedFileAdapter } = {}) { let request = null, disposed = false; return { async load(image) { @@ -179,24 +190,27 @@ export function createInspectionLoader(api, onChange, { extractFacts } = {}) { const current = request = new AbortController(); onChange({ loading: true }); try { - const result = await api.get(inspectPath(image), { signal: current.signal }); + const result = await api.get(adapter.inspectionPath(image), { signal: current.signal }); if (disposed || request !== current) return; - const record = toInspectionRecord(result, image, extractFacts); + const record = adapter.toRecord(result, image, extractFacts); onChange({ prompts: record.prompts, record }); } catch (error) { if (disposed || request !== current) return; if (error?.status === 409 && error.code === 'source-changed') error = new InspectionSourceChangedError(); - const refreshRequired = error instanceof InspectionSourceChangedError; - onChange({ error: true, refreshRequired, message: refreshRequired ? SOURCE_CHANGED : 'Could not read details from this file.' }); + const historyRecovery = adapter !== rootedFileAdapter && error?.status === 409 && ['history-changed', 'candidate-stale', 'capture-changed'].includes(error.code); + const refreshRequired = error instanceof InspectionSourceChangedError || error?.refreshRequired === true || historyRecovery; + onChange({ error: true, refreshRequired, code: error?.code ?? null, recovery: error?.recovery ?? null, + message: error instanceof InspectionSourceChangedError ? SOURCE_CHANGED : refreshRequired ? + error.code === 'history-changed' ? 'Recorded history changed. Reload recorded history.' : 'Current output locations changed. Reload reported outputs.' : 'Could not read details from this file.' }); } }, dispose() { disposed = true; request?.abort(); request = null; }, }; } -const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false }); +const emptyState = () => ({ loading: false, error: false, record: null, prompts: undefined, message: '', refreshRequired: false, code: null, recovery: null }); -export function createInspectionController({ api, extractFacts }) { +export function createInspectionController({ api, extractFacts, adapter = rootedFileAdapter }) { let state = Object.freeze(emptyState()), current = null, pending = null, disposed = false; let attempted, hasAttempt = false; const listeners = new Set(); @@ -205,7 +219,7 @@ export function createInspectionController({ api, extractFacts }) { state = Object.freeze({ ...emptyState(), ...update }); for (const listener of listeners) listener(state); } - const makeLoader = () => createInspectionLoader(api, publish, { extractFacts }); + const makeLoader = () => createInspectionLoader(api, publish, { extractFacts, adapter }); let loader = makeLoader(); function read(image) { pending = loader.load(image); @@ -217,14 +231,15 @@ export function createInspectionController({ api, extractFacts }) { if (disposed) return; let selected = null; try { - const scope = validateIdentity(image), key = imageKey(image); - if (current?.key === key && current.scope === scope) return pending; - selected = { key, scope }; + adapter.validate(image); + const scope = adapter.scope(image), key = adapter.key(image), path = adapter.inspectionPath(image); + if (current?.key === key && current.scope === scope && current.path === path) return pending; + selected = { key, scope, path }; } catch { // The loader publishes validation errors after invalidating the previous source. } loader.dispose(); loader = makeLoader(); current = selected; - attempted = object(image) ? { ...image } : image; hasAttempt = true; + attempted = object(image) ? { ...image, ...(adapter !== rootedFileAdapter ? {occurrence:{...image.occurrence}, reference:{...image.reference}} : {}) } : image; hasAttempt = true; return read(attempted); }, retry() { diff --git a/web/js/workspace/media-viewer.mjs b/web/js/workspace/media-viewer.mjs index f68d072..5155141 100644 --- a/web/js/workspace/media-viewer.mjs +++ b/web/js/workspace/media-viewer.mjs @@ -1,6 +1,6 @@ import {createScope, createButton} from '../shared/toolkit/core.js'; import {createViewerSession} from './viewer-state.mjs'; -import {originalMediaDownload} from './inspection-state.mjs'; +import {rootedFileAdapter} from './inspection-state.mjs'; import {mountFileInspector} from './file-inspector.mjs'; let viewerId = 0; @@ -17,7 +17,7 @@ function element(tag, className, text) { return node; } -export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose}) { +export function createMediaViewer({container,parentScope,inspectionController,sender,onSelect,onRefresh,onRestoreFocus,onClose,adapter = rootedFileAdapter}) { const scope = createScope(); let active = null, retryVersion = 0; const dispose = () => scope.dispose(); @@ -33,7 +33,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se const {items,selectedKey,trigger = document.activeElement} = options; let mounted = false, initial, entry, currentKey, viewer = null, mediaScope = null, closed = false; let originalDownload = null, inspectionState = inspectionController.state; - const session = createViewerSession({items,initialKey:selectedKey,onChange:(state,selected) => { + const session = createViewerSession({adapter,items,initialKey:selectedKey,onChange:(state,selected) => { if (!mounted) { initial = [state,selected]; return; } update(state,selected); }}); @@ -109,7 +109,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se const mediaLocal = mediaScope, key = session.state.key; const current = () => !local.signal.aborted && !mediaLocal.signal.aborted && session.state.key === key; let original; - try { original = originalMediaDownload(selected); } + try { original = adapter.originalDownload(selected); } catch { session.failed(key);return; } const url = new URL(original.url,window.location.href); url.searchParams.set('_pm_viewer',`${Date.now()}-${++retryVersion}`); @@ -193,7 +193,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se if (position.textContent !== label) position.textContent = label; if (currentKey !== state.key) { currentKey = state.key;title.textContent = entry.filename; - try { originalDownload = originalMediaDownload(entry); } catch { originalDownload = null; } + try { originalDownload = adapter.originalDownload(entry); } catch { originalDownload = null; } onSelect?.(entry); rebuild(entry); } @@ -203,7 +203,7 @@ export function createMediaViewer({container,parentScope,inspectionController,se dialog.showModal();mounted = true;update(...initial); local.cleanup(inspectionController.subscribe(state => { inspectionState = state;updateControls(session.state); - if (state.error) session.stop(); + if (state.loading || state.error) session.stop(); inspector.setState(state); if (state.record?.identity.key === currentKey && state.record.library?.title) title.textContent = `${state.record.library.title} ยท ${entry.filename}`; })); diff --git a/web/js/workspace/viewer-state.mjs b/web/js/workspace/viewer-state.mjs index 7a26a16..5a49f7d 100644 --- a/web/js/workspace/viewer-state.mjs +++ b/web/js/workspace/viewer-state.mjs @@ -1,18 +1,17 @@ -import {imageKey} from './images-state.mjs'; -import {inspectPath} from './inspection-state.mjs'; +import {rootedFileAdapter} from './inspection-state.mjs'; /** A viewer visits only the descriptors loaded by its opening query. */ -export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout}) { +export function createViewerSession({items, initialKey, onChange = () => {}, setTimer = setTimeout, clearTimer = clearTimeout, adapter = rootedFileAdapter}) { if (!Array.isArray(items) || !items.length) throw new TypeError('A loaded media collection is required'); const keys = new Set(); const loaded = Object.freeze(items.map(item => { - inspectPath(item); - const key = imageKey(item); + adapter.validate(item); + const key = adapter.key(item); if (keys.has(key)) throw new TypeError('Duplicate viewer identity'); keys.add(key); - return Object.freeze({...item}); + return Object.freeze({...item, ...(adapter !== rootedFileAdapter && item.occurrence ? {occurrence:Object.freeze({...item.occurrence}), reference:Object.freeze({...item.reference})} : {})}); })); - const index = loaded.findIndex(item => imageKey(item) === initialKey); + const index = loaded.findIndex(item => adapter.key(item) === initialKey); if (index < 0) throw new TypeError('Selected media is not loaded'); let state = Object.freeze({key:initialKey,index,total:loaded.length,phase:'stopped',requested:false,mediaReady:false}); let timer = null, deadline = 0; @@ -42,7 +41,7 @@ export function createViewerSession({items, initialKey, onChange = () => {}, set if (loaded.length === 1) return; cancel(); const next = (state.index + direction + loaded.length) % loaded.length; - publish({index:next,key:imageKey(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false}); + publish({index:next,key:adapter.key(loaded[next]),phase:state.requested ? loaded[next].is_video ? 'paused-video' : 'waiting-image' : 'stopped',mediaReady:false}); } publish({}); return {