@rig-c/review 0.1.0 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/front.ts ADDED
@@ -0,0 +1,127 @@
1
+ /** Managed loopback front: one stable origin (127.0.0.1:4318 by default) routing `/instances/<key>/…` to the
2
+ * worker named by that key's private open record. It is a router, not an authority: it never reads packages,
3
+ * never serves or logs a capability, and forwards a mutation (rewriting Origin) only with the page capability. */
4
+ import {randomUUID} from 'node:crypto';
5
+ import {spawn} from 'node:child_process';
6
+ import {lstat,readFile,readdir,realpath,rename,unlink,writeFile,open as openFile} from 'node:fs/promises';
7
+ import {dirname,join} from 'node:path';
8
+ import {fileURLToPath} from 'node:url';
9
+ import {acquireClaim,alive,openFingerprint,readRecord,releaseClaim,running,smallJson,type RecordFile} from './open.ts';
10
+ import {capabilityHeader,openKeyHeader,openNonceHeader,sameSecret} from './open-protocol.ts';
11
+ export const defaultFrontPort=4318; // Foreground `review <dir>` keeps 4317.
12
+ interface FrontRecord {schema:'rig-review-front/1';origin:string;port:number;nonce:string;fingerprint:string;pid:number}
13
+ interface FrontRequest {schema:'rig-review-front-request/1';control:string;request:string;record:string;port:number;nonce:string;fingerprint:string;idleMs:number}
14
+ const appRoot=fileURLToPath(new URL('..',import.meta.url));
15
+ const sleep=(ms:number)=>new Promise(resolveSleep=>setTimeout(resolveSleep,ms));
16
+ const uuid=/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/,hex64=/^[a-f0-9]{64}$/;
17
+ const frontName=(port:number)=>`front-${port}`;
18
+ const noStore={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff','Referrer-Policy':'no-referrer'};
19
+ const refuse=(error:string,status:number)=>Response.json({error},{status,headers:noStore});
20
+ export function frontPort(value?:number):number{const raw=value??(process.env.RIG_REVIEW_FRONT_PORT===undefined?defaultFrontPort:Number(process.env.RIG_REVIEW_FRONT_PORT));if(!Number.isSafeInteger(raw)||raw<1||raw>65535)throw new Error('FRONT_PORT: RIG_REVIEW_FRONT_PORT expects an integer 1..65535');return raw;}
21
+ function frontValue(value:unknown,port:number):FrontRecord|null{if(!value||typeof value!=='object'||Array.isArray(value))return null;const v=value as Partial<FrontRecord>;return v.schema==='rig-review-front/1'&&v.port===port&&v.origin===`http://127.0.0.1:${port}`&&uuid.test(v.nonce??'')&&hex64.test(v.fingerprint??'')&&Number.isSafeInteger(v.pid)&&v.pid!>0?v as FrontRecord:null;}
22
+ async function readFront(file:string,port:number):Promise<FrontRecord|null>{try{return frontValue(await smallJson(file),port);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
23
+ async function frontAlive(row:FrontRecord):Promise<boolean>{try{const response=await fetch(row.origin+'/api/front-status',{headers:{authorization:'Bearer '+row.nonce},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await response.json() as {schema?:string;nonce?:string;fingerprint?:string};return v.schema==='rig-review-front-status/1'&&v.nonce===row.nonce&&v.fingerprint===row.fingerprint;}catch{return false;}}
24
+ /** Ensure this state directory's front on `port`; reuse a verified live one. A front from another tool
25
+ * build is ours (verified nonce) and is replaced. Any other listener is FRONT_PORT_BUSY, never a fallback. */
26
+ export async function ensureFront(options:{control:string;port:number;fingerprint:string;idleMs?:number}):Promise<string>{
27
+ const {control,port,fingerprint}=options,name=frontName(port),recordPath=join(control,name+'.json'),nonce=randomUUID(),deadline=performance.now()+20000,idleMs=options.idleMs??30*60*1000;
28
+ const reuse=async()=>{const row=await readFront(recordPath,port);if(!row||!await frontAlive(row))return null;if(row.fingerprint===fingerprint)return row;
29
+ try{process.kill(row.pid,'SIGTERM');}catch{}for(let n=0;n<100&&await frontAlive(row);n++)await sleep(30);return null;};
30
+ const ready=await reuse();if(ready)return ready.origin;
31
+ const claimed=await acquireClaim(control,name,nonce,reuse,deadline,'FRONT_BUSY: front startup claim did not become ready');if('row' in claimed)return claimed.row.origin;
32
+ let request:string|undefined,child:ReturnType<typeof spawn>|undefined,handedOff=false;
33
+ try{
34
+ const row=await reuse();if(row)return row.origin;
35
+ request=join(control,`${name}.${nonce}.request.json`);const log=join(control,`${name}.${nonce}.log`);
36
+ const spec:FrontRequest={schema:'rig-review-front-request/1',control,request,record:recordPath,port,nonce,fingerprint,idleMs};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
37
+ const logHandle=await openFile(log,'a',0o600);try{child=spawn(process.execPath,[join(appRoot,'src/front-worker.ts'),request],{cwd:control,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});child.unref();}finally{await logHandle.close();}
38
+ let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
39
+ while(performance.now()<deadline){const live=await reuse();if(live){handedOff=true;return live.origin;}
40
+ if(exited){if(/FRONT_PORT_BUSY/.test(await readFile(log,'utf8').catch(()=>'')))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${port} is held by another listener; use --direct (or set RIG_REVIEW_FRONT_PORT)`);throw new Error(`FRONT_START: ${exited}; front log ${log}`);}
41
+ await sleep(30);}
42
+ throw new Error(`FRONT_TIMEOUT: front did not become ready; log ${log}`);
43
+ }finally{if(child&&!handedOff&&child.exitCode===null&&child.signalCode===null)child.kill('SIGTERM');await releaseClaim(join(control,name+'.claim'),nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
44
+ }
45
+ interface Route {mutation:boolean;limit:number;timeoutMs:number;control:boolean}
46
+ /** Closed allowlist per method. Agent control endpoints, open-status and anything unlisted are refused. */
47
+ export function frontRoute(mode:'single'|'compare',method:string,path:string):Route|null{
48
+ const read=method==='GET'||method==='HEAD',get:Route={mutation:false,limit:0,timeoutMs:30000,control:false};
49
+ if(['/','/index.html','/main.js','/styles.css','/api/access'].includes(path))return read?get:null;
50
+ if(path==='/api/control/config')return method==='GET'?{...get,control:true}:null;
51
+ if(['/api/control/register','/api/control/poll','/api/control/ack','/api/control/close'].includes(path))return method==='POST'?{mutation:true,limit:16384,timeoutMs:10000,control:true}:null;
52
+ const session=mode==='single'?'/api/session':'/api/compare/session',strip=mode==='single'?'/api/strip':'/api/compare/strip';
53
+ if(path===session)return read?get:method==='PUT'?{mutation:true,limit:1024*1024,timeoutMs:30000,control:false}:null;
54
+ if(path===strip)return method==='POST'?{mutation:true,limit:1024*1024,timeoutMs:120000,control:false}:null;
55
+ const reads=mode==='single'?['/api/review','/review-reference']:['/api/compare','/api/compare/reference-frames','/compare-reference'];
56
+ if(reads.includes(path))return read?get:null;
57
+ if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
58
+ return null;
59
+ }
60
+ async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{
61
+ if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('FRONT_JSON_REQUIRED');
62
+ const declared=req.headers.get('content-length');if(declared!==null&&(!/^\d+$/.test(declared)||Number(declared)>limit))throw new Error('FRONT_BODY_LIMIT');
63
+ if(!req.body)throw new Error('FRONT_JSON_REQUIRED');const reader=req.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
64
+ try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>limit)throw new Error('FRONT_BODY_LIMIT');chunks.push(next.value);}const bytes=new Uint8Array(size);let at=0;for(const chunk of chunks){bytes.set(chunk,at);at+=chunk.length;}return bytes.buffer;})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('FRONT_BODY_TIMEOUT')),timeoutMs);})]);}
65
+ finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});}
66
+ }
67
+ /** Owner-only regular record for this key, or null. */
68
+ async function instanceRecord(control:string,key:string):Promise<RecordFile|null>{const file=join(control,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)return null;}catch(e){if((e as NodeJS.ErrnoException).code==='ENOENT')return null;throw e;}return readRecord(file,key);}
69
+ /** Worker statuses map to named front codes; worker text never reaches the client. */
70
+ const upstreamFailure:Record<number,string>={400:'FRONT_UPSTREAM_REJECTED',403:'FRONT_UPSTREAM_FORBIDDEN',404:'FRONT_UPSTREAM_NOT_FOUND',405:'FRONT_UPSTREAM_METHOD',409:'FRONT_UPSTREAM_CONFLICT'};
71
+ const responseHeaders=['content-type','content-length','content-range','accept-ranges','content-disposition','cache-control','content-security-policy','x-content-type-options','referrer-policy'];
72
+ export const maximumUpstreamStreams=32;
73
+ export function startFront(options:{control:string;port:number;nonce:string;fingerprint:string}){
74
+ const origin=`http://127.0.0.1:${options.port}`;let active=0,last=performance.now();
75
+ const server=Bun.serve({hostname:'127.0.0.1',port:options.port,async fetch(req){
76
+ last=performance.now();
77
+ try{
78
+ const url=new URL(req.url);if(url.origin!==origin)return refuse('FRONT_LOOPBACK_REQUIRED',403);
79
+ if(url.pathname==='/api/front-status'){if(req.method!=='GET'||!sameSecret(req.headers.get('authorization'),'Bearer '+options.nonce))return refuse('FRONT_STATUS_CAPABILITY_REQUIRED',403);return Response.json({schema:'rig-review-front-status/1',nonce:options.nonce,fingerprint:options.fingerprint},{headers:noStore});}
80
+ const match=/^\/instances\/([a-f0-9]{64})(\/.*)$/.exec(url.pathname);if(!match)return refuse('FRONT_ROUTE_REFUSED',404);
81
+ const key=match[1]!,path=match[2]!,sentOrigin=req.headers.get('origin');
82
+ if(req.headers.get('sec-fetch-site')==='cross-site'||sentOrigin!==null&&sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
83
+ const record=await instanceRecord(options.control,key);if(!record)return refuse('FRONT_INSTANCE_UNKNOWN: reopen with rig-review open',404);
84
+ const route=frontRoute(record.mode,req.method,path);if(!route)return refuse('FRONT_ROUTE_REFUSED',404);
85
+ const landing=path==='/'||path==='/index.html';if(!landing&&url.search)return refuse('FRONT_QUERY_REFUSED',400);
86
+ let body:ArrayBuffer|undefined;
87
+ if(route.mutation){
88
+ // Never rewrite Origin without this instance's capability; the worker compares it again itself.
89
+ if(sentOrigin!==origin)return refuse('FRONT_SAME_ORIGIN_REQUIRED',403);
90
+ if(!sameSecret(req.headers.get(capabilityHeader),record.capability))return refuse('FRONT_CAPABILITY_REQUIRED: reopen with rig-review open to edit',403);
91
+ body=await boundedBody(req,route.limit);
92
+ if(!await alive(record))return refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);
93
+ }
94
+ if(active>=maximumUpstreamStreams)return refuse('FRONT_BUSY: too many concurrent upstream streams',503);
95
+ const headers=new Headers({[openNonceHeader]:record.nonce});for(const name of ['accept','range','if-range',...(route.mutation?['content-type']:[]),...(route.control?['authorization']:[])]){const value=req.headers.get(name);if(value!==null)headers.set(name,value);}
96
+ if(route.mutation){headers.set('origin',record.origin);headers.set(capabilityHeader,record.capability);}
97
+ active++;let released=false;const abort=new AbortController(),timer=setTimeout(()=>abort.abort(),route.timeoutMs);const release=()=>{if(released)return;released=true;active--;clearTimeout(timer);};
98
+ req.signal.addEventListener('abort',()=>abort.abort(),{once:true});
99
+ let upstream:Response;
100
+ try{upstream=await fetch(record.origin+path+(landing?url.search:''),{method:req.method,headers,...(body?{body}:{}),redirect:'manual',signal:abort.signal});}
101
+ catch{release();return abort.signal.aborted&&!req.signal.aborted?refuse('FRONT_UPSTREAM_TIMEOUT',504):refuse('FRONT_INSTANCE_EXPIRED: reopen with rig-review open',410);}
102
+ if(upstream.headers.get(openKeyHeader)!==key){void upstream.body?.cancel().catch(()=>{});release();return refuse('FRONT_INSTANCE_REPLACED: reopen with rig-review open',410);}
103
+ if(upstream.status>=400&&upstream.status!==416){void upstream.body?.cancel().catch(()=>{});release();const code=upstreamFailure[upstream.status];return refuse(code??'FRONT_UPSTREAM_FAILED',code?upstream.status:502);}
104
+ const out=new Headers();for(const name of responseHeaders){const value=upstream.headers.get(name);if(value!==null)out.set(name,value);}
105
+ if(!upstream.body||req.method==='HEAD'){void upstream.body?.cancel().catch(()=>{});release();return new Response(null,{status:upstream.status,headers:out});}
106
+ clearTimeout(timer);const reader=upstream.body.getReader();
107
+ // Media/model streams stay counted until finished or cancelled by the browser.
108
+ const stream=new ReadableStream<Uint8Array>({async pull(controller){try{const next=await reader.read();if(next.done){controller.close();release();}else controller.enqueue(next.value);}catch(error){release();controller.error(error);}},cancel(reason){release();abort.abort();return reader.cancel(reason).catch(()=>{});}});
109
+ return new Response(stream,{status:upstream.status,headers:out});
110
+ }catch(error){const message=error instanceof Error?error.message:'';return refuse(message.startsWith('FRONT_')?message:'FRONT_REQUEST_FAILED',message==='FRONT_BODY_LIMIT'?413:message==='FRONT_BODY_TIMEOUT'?408:400);}
111
+ }});
112
+ return {origin,stop:()=>server.stop(true),idleMs:()=>performance.now()-last,active:()=>active};
113
+ }
114
+ async function liveInstances(control:string):Promise<boolean>{for(const name of await readdir(control))if(/^[a-f0-9]{64}\.json$/.test(name)){const row=await readRecord(join(control,name),name.slice(0,64)).catch(()=>null);if(row&&running(row.pid))return true;}return false;}
115
+ export async function runFront(requestPath:string):Promise<void>{
116
+ const spec=await smallJson(requestPath) as FrontRequest;if(spec?.schema!=='rig-review-front-request/1'||!uuid.test(spec.nonce??'')||!hex64.test(spec.fingerprint??'')||!Number.isSafeInteger(spec.port)||spec.port<1||spec.port>65535||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('front: invalid request');
117
+ const control=await realpath(spec.control);if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.record!==join(control,frontName(spec.port)+'.json'))throw new Error('front: invalid state locations');
118
+ if(await openFingerprint()!==spec.fingerprint)throw new Error('front: tool changed before startup');
119
+ let front:ReturnType<typeof startFront>;
120
+ try{front=startFront({control,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint});}catch(error){if((error as NodeJS.ErrnoException).code==='EADDRINUSE'||/in use/i.test(error instanceof Error?error.message:''))throw new Error(`FRONT_PORT_BUSY: 127.0.0.1:${spec.port}`);throw error;}
121
+ const record:FrontRecord={schema:'rig-review-front/1',origin:front.origin,port:spec.port,nonce:spec.nonce,fingerprint:spec.fingerprint,pid:process.pid},tmp=spec.record+'.'+spec.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.record);
122
+ await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
123
+ let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;clearInterval(timer);front.stop();const current=await readFront(spec.record,spec.port).catch(()=>null);if(current?.nonce===spec.nonce)await unlink(spec.record).catch(()=>{});process.exit(0);};
124
+ // Idle exit only once no request arrived for idleMs and no live worker record remains.
125
+ const timer=setInterval(()=>{if(front.idleMs()>=spec.idleMs&&front.active()===0)void liveInstances(control).then(live=>{if(!live)void stop();}).catch(()=>{});},Math.min(1000,spec.idleMs));
126
+ process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
127
+ }
@@ -1,4 +1,5 @@
1
1
  /** Fixed-instance handshake and enforced landing binding; no filesystem control API. */
2
+ import {timingSafeEqual} from 'node:crypto';
2
3
  export interface OpenIdentity {key:string;contentHash:string;nonce:string;fingerprint:string}
3
4
  export function openGate(req:Request,identity:OpenIdentity|undefined):Response|null{
4
5
  if(!identity)return null;const url=new URL(req.url);
@@ -12,3 +13,19 @@ export function openGate(req:Request,identity:OpenIdentity|undefined):Response|n
12
13
  }
13
14
 
14
15
  export function staleOpenStatus(identity:OpenIdentity):Response{return Response.json({schema:'rig-review-open-status/1',...identity,ready:false,error:'Inputs changed; open a new review binding'},{status:409,headers:{'Cache-Control':'no-store'}});}
16
+ /** Header names shared by the worker, the managed front and the page. */
17
+ export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Open-Nonce',openKeyHeader='X-Review-Open-Key';
18
+ export const capabilityPattern=/^[a-f0-9]{64}$/;
19
+ export function sameSecret(actual:string|null,expected:string):boolean{const a=Buffer.from(actual??''),b=Buffer.from(expected);return a.length===b.length&&timingSafeEqual(a,b);}
20
+ /** Every non-GET request needs this instance's page capability, compared in constant time before any
21
+ * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. */
22
+ export function accessGate(req:Request,capability:string|undefined):Response|null{
23
+ const url=new URL(req.url),headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'};
24
+ if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
+ if(!capability||req.method==='GET'||req.method==='HEAD')return null;
26
+ return sameSecret(req.headers.get(capabilityHeader),capability)?null:Response.json({error:'REVIEW_CAPABILITY_REQUIRED: reopen with rig-review open to edit'},{status:403,headers});
27
+ }
28
+ /** The front forwards the open record's nonce on every request; a replaced worker refuses it. */
29
+ export function openNonceGate(req:Request,identity:OpenIdentity|undefined):Response|null{const sent=req.headers.get(openNonceHeader);if(sent===null||!identity)return null;return sameSecret(sent,identity.nonce)?null:Response.json({error:'OPEN_INSTANCE_REPLACED'},{status:409,headers:{'Cache-Control':'no-store'}});}
30
+ /** Proof for the front that this exact worker answered, not whatever now holds the port. */
31
+ export function stampOpen(req:Request,response:Response,identity:OpenIdentity|undefined):Response{if(identity&&sameSecret(req.headers.get(openNonceHeader),identity.nonce))response.headers.set(openKeyHeader,identity.key);return response;}
package/src/open.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import {parseControlCommand,type ControlCommand,type ControlReceipt} from './control.ts';
2
2
  /** One fixed-package worker per exact binding. There is no general daemon API. */
3
- import {createHash,randomUUID} from 'node:crypto';
3
+ import {createHash,randomBytes,randomUUID} from 'node:crypto';
4
4
  import {spawn} from 'node:child_process';
5
5
  import {homedir} from 'node:os';
6
6
  import {mkdir,readFile,readdir,writeFile,rename,unlink,rm,rmdir,realpath,lstat,open as openFile} from 'node:fs/promises';
@@ -13,11 +13,14 @@ import {loadCompare,assertCompareUnchanged,type ComparePackage} from './compare.
13
13
  import {startServer} from './server.ts';
14
14
  import {startCompareServer} from './compare-server.ts';
15
15
  import {selectAnimation,type AnimationSelection} from './animation-selection.ts';
16
- import type {OpenIdentity} from './open-protocol.ts';
16
+ import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
17
+ import {ensureFront,frontPort} from './front.ts';
17
18
  import {playPackagePath} from './versions.ts';
18
- export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean}
19
+ /** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
20
+ export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean}
19
21
  export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
20
- interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;controlToken?:string}
22
+ /** Private 0600 record. `capability` is the 256-bit page write capability, handed out only in the printed `#cap=` fragment. */
23
+ export interface RecordFile extends OpenIdentity {schema:'rig-review-open-instance/1';pid:number;origin:string;mode:'single'|'compare';capability:string;controlToken?:string}
21
24
  interface WorkerRequest {schema:'rig-review-open-request/1';inputs:OpenInputs;identity:OpenIdentity;stateDir:string;registry:string;request:string;idleMs:number;controlToken?:string}
22
25
  const appRoot=fileURLToPath(new URL('..',import.meta.url));
23
26
  const digest=(value:unknown)=>createHash('sha256').update(JSON.stringify(value)).digest('hex');
@@ -40,61 +43,74 @@ export async function openFingerprint():Promise<string>{
40
43
  hash.update('@rig-c/play/package.json\0');hash.update(await readFile(playPackagePath()));return hash.digest('hex');
41
44
  }
42
45
  export function openKey(p:OpenPackage,fingerprint:string):string {return digest({fingerprint,installation:appRoot,mode:p.mode,inputs:p.inputs,content:p.contentHash,identity:p.mode==='single'?p.p.reviewedSource:p.p.response.binding});}
43
- export function openUrl(record:Pick<RecordFile,'origin'|'key'|'contentHash'>):string {const url=new URL(record.origin);url.searchParams.set('input',record.contentHash);url.searchParams.set('handoff',record.key);return url.href;}
44
- async function smallJson(file:string):Promise<unknown>{const handle=await openFile(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=await handle.stat();if(!before.isFile()||before.size>65536)throw new Error('open state: bounded regular file required');const buffer=Buffer.alloc(before.size+1);let size=0;while(size<buffer.length){const next=await handle.read(buffer,size,buffer.length-size,size);if(!next.bytesRead)break;size+=next.bytesRead;}const after=await handle.stat();if(size!==before.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('open state: changed while reading');return JSON.parse(buffer.subarray(0,size).toString('utf8'));}finally{await handle.close();}}
45
- function recordValue(value:unknown,key:string):RecordFile|null {if(!value||typeof value!=='object')return null;const v=value as Partial<RecordFile>;return v.schema==='rig-review-open-instance/1'&&v.key===key&&/^[a-f0-9]{64}$/.test(v.contentHash??'')&&/^[a-f0-9]{64}$/.test(v.fingerprint??'')&&typeof v.nonce==='string'&&/^[a-f0-9-]{36}$/.test(v.nonce)&&typeof v.origin==='string'&&/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(v.origin)&&Number(v.origin.slice(v.origin.lastIndexOf(':')+1))<=65535&&Number.isSafeInteger(v.pid)&&v.pid!>0?v as RecordFile:null;}
46
- async function readRecord(file:string,key:string):Promise<RecordFile|null>{try{return recordValue(await smallJson(file),key);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
46
+ /** Handoff URL: the managed front's `/instances/<key>/` path (or the worker's own origin when direct). The
47
+ * capability rides in the fragment, which browsers never send to a server; the page stores and strips it. */
48
+ export function openUrl(record:Pick<RecordFile,'origin'|'key'|'contentHash'|'capability'>,front?:string):string {const url=new URL(front?`${front}/instances/${record.key}/`:record.origin);url.searchParams.set('input',record.contentHash);url.searchParams.set('handoff',record.key);url.hash='cap='+record.capability;return url.href;}
49
+ export async function smallJson(file:string):Promise<unknown>{const handle=await openFile(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);try{const before=await handle.stat();if(!before.isFile()||before.size>65536)throw new Error('open state: bounded regular file required');const buffer=Buffer.alloc(before.size+1);let size=0;while(size<buffer.length){const next=await handle.read(buffer,size,buffer.length-size,size);if(!next.bytesRead)break;size+=next.bytesRead;}const after=await handle.stat();if(size!==before.size||before.size!==after.size||before.mtimeMs!==after.mtimeMs)throw new Error('open state: changed while reading');return JSON.parse(buffer.subarray(0,size).toString('utf8'));}finally{await handle.close();}}
50
+ function recordValue(value:unknown,key:string):RecordFile|null {if(!value||typeof value!=='object')return null;const v=value as Partial<RecordFile>;return v.schema==='rig-review-open-instance/1'&&v.key===key&&/^[a-f0-9]{64}$/.test(v.contentHash??'')&&/^[a-f0-9]{64}$/.test(v.fingerprint??'')&&typeof v.nonce==='string'&&/^[a-f0-9-]{36}$/.test(v.nonce)&&typeof v.origin==='string'&&/^http:\/\/127\.0\.0\.1:[1-9]\d{0,4}$/.test(v.origin)&&Number(v.origin.slice(v.origin.lastIndexOf(':')+1))<=65535&&Number.isSafeInteger(v.pid)&&v.pid!>0&&(v.mode==='single'||v.mode==='compare')&&capabilityPattern.test(v.capability??'')?v as RecordFile:null;}
51
+ export async function readRecord(file:string,key:string):Promise<RecordFile|null>{try{return recordValue(await smallJson(file),key);}catch(e){if(['ENOENT','ELOOP'].includes((e as NodeJS.ErrnoException).code??'')||e instanceof SyntaxError)return null;throw e;}}
47
52
  /** Hard byte ceiling and body deadline, including stalled or chunked responses. */
48
53
  export async function readOpenStatus(response:Response,timeoutMs=1500):Promise<unknown>{
49
54
  if(!response.body)throw new Error('open status: body required');const reader=response.body.getReader();let timer:ReturnType<typeof setTimeout>|undefined;
50
55
  try{return await Promise.race([(async()=>{const chunks:Uint8Array[]=[];let size=0;while(true){const next=await reader.read();if(next.done)break;size+=next.value.length;if(size>65536)throw new Error('open status: exceeds 64 KiB');chunks.push(next.value);}const bytes=new Uint8Array(size);let offset=0;for(const chunk of chunks){bytes.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(bytes));})(),new Promise<never>((_,reject)=>{timer=setTimeout(()=>reject(new Error('open status: body timeout')),timeoutMs);})]);}
51
56
  finally{if(timer)clearTimeout(timer);void reader.cancel().catch(()=>{});try{reader.releaseLock();}catch{}}
52
57
  }
53
- async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
58
+ export async function alive(record:RecordFile):Promise<boolean>{try{const response=await fetch(record.origin+'/api/open-status',{headers:{authorization:`Bearer ${record.nonce}`},redirect:'error',signal:AbortSignal.timeout(1500)});if(!response.ok){void response.body?.cancel();return false;}const v=await readOpenStatus(response) as OpenIdentity&{schema:string};return v.schema==='rig-review-open-status/1'&&v.key===record.key&&v.contentHash===record.contentHash&&v.nonce===record.nonce&&v.fingerprint===record.fingerprint;}catch{return false;}}
54
59
 
55
- function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
60
+ export function running(pid:number):boolean{try{process.kill(pid,0);return true;}catch(e){return (e as NodeJS.ErrnoException).code==='EPERM';}}
56
61
  async function claimOwner(claim:string):Promise<{file:string;pid:number;nonce:string}|null>{
57
62
  const info=await lstat(claim);if(info.isSymbolicLink()||!info.isDirectory())throw new Error('open.state: claim must be an owned directory');const names=await readdir(claim);if(!names.length)return null;if(names.length!==1||!/^owner(?:\.[a-f0-9-]{36})?\.json$/.test(names[0]!))throw new Error('open.state: invalid startup claim');const file=join(claim,names[0]!),owner=await smallJson(file) as {pid?:number;nonce?:string};if(!Number.isSafeInteger(owner.pid)||owner.pid!<=0||typeof owner.nonce!=='string'||!/^([a-f0-9-]{36})$/.test(owner.nonce)||names[0]!=='owner.json'&&names[0]!==`owner.${owner.nonce}.json`)throw new Error('open.state: invalid startup claimant');return {file,pid:owner.pid!,nonce:owner.nonce};
58
63
  }
59
64
  async function removeClaimFile(claim:string,owner:{file:string;nonce:string}){try{const current=await smallJson(owner.file) as {nonce?:string};if(current.nonce!==owner.nonce)return;await unlink(owner.file);await rmdir(claim);}catch(e){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
60
- async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
65
+ export async function releaseClaim(claim:string,nonce:string){await removeClaimFile(claim,{file:join(claim,`owner.${nonce}.json`),nonce});}
61
66
  async function canonicalDestination(path:string):Promise<string>{try{return await realpath(path);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;const parent=dirname(path);if(parent===path)throw e;return join(await canonicalDestination(parent),basename(path));}}
62
- export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null}>{
67
+ /** Single-owner startup: publish a populated claim directory atomically (no live owner ever exposes an empty
68
+ * claim; unique owner filenames make stale cleanup safe after replacement). A ready reuse wins while waiting. */
69
+ export async function acquireClaim<T>(control:string,name:string,nonce:string,reuse:()=>Promise<T|null>,deadline:number,busy:string):Promise<{row:T}|{claim:string}>{
70
+ const claim=join(control,name+'.claim'),candidate=join(control,`${name}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
71
+ try{while(true){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);return {claim};}
72
+ catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {row};}
73
+ try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
74
+ catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error(busy);await sleep(30);}}}
75
+ catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
76
+ }
77
+ /** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
78
+ * RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
79
+ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idleMs?:number;direct?:boolean;frontPort?:number;frontIdleMs?:number}={}):Promise<{url:string;reused:boolean;nonce:string;inputs:OpenInputs;selection:AnimationSelection|null;direct:boolean}>{
80
+ const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
63
81
  const p=await loadOpenPackage(inputs),fingerprint=await openFingerprint(),key=openKey(p,fingerprint);
64
82
  const stateDir=await canonicalDestination(resolve(options.stateDir??defaultStateDir())),roots=p.mode==='single'?[p.p.root]:[p.p.a.root,p.p.b.root];
65
83
  for(const root of roots){const rel=relative(root,stateDir);if(!rel||rel!=='..'&&!rel.startsWith('..'+(process.platform==='win32'?'\\':'/'))&&!isAbsolute(rel))throw new Error('open.state: must be outside input packages');}
66
84
  const control=join(stateDir,'open');await mkdir(control,{recursive:true,mode:0o700});const canonicalControl=await realpath(control);if((await lstat(control)).isSymbolicLink())throw new Error('open.state: control directory cannot be a symlink');
85
+ const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
86
+ const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
87
+ return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct};
88
+ }
89
+ async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir:string,canonicalControl:string,idleMs:number):Promise<{row:RecordFile;reused:boolean}>{
67
90
  const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;
68
- const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&await alive(row)?row:null;};
69
- const ready=await reuse();if(ready)return {url:openUrl(ready),reused:true,nonce:ready.nonce,inputs:p.inputs,selection:p.selection};
70
- // Publish a populated directory atomically: no live owner ever exposes an
71
- // empty claim. Unique owner filenames make stale cleanup safe after replacement.
72
- const candidate=join(canonicalControl,`${key}.${nonce}.claiming`);await mkdir(candidate,{mode:0o700});await writeFile(join(candidate,`owner.${nonce}.json`),JSON.stringify({pid:process.pid,nonce}),{mode:0o600,flag:'wx'});
73
- let claimed=false;
74
- try{while(!claimed){try{try{await lstat(claim);const occupied=new Error('claim exists') as NodeJS.ErrnoException;occupied.code='EEXIST';throw occupied;}catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}await rename(candidate,claim);claimed=true;}
75
- catch(e){if(!['EEXIST','ENOTEMPTY'].includes((e as NodeJS.ErrnoException).code??''))throw e;const row=await reuse();if(row){await rm(candidate,{recursive:true});return {url:openUrl(row),reused:true,nonce:row.nonce,inputs:p.inputs,selection:p.selection};}
76
- try{const before=await lstat(claim),owner=await claimOwner(claim);if(owner&&!running(owner.pid)){const again=await claimOwner(claim);if(again?.nonce===owner.nonce&&(await lstat(claim)).ino===before.ino)await removeClaimFile(claim,owner);}else if(!owner&&Date.now()-before.mtimeMs>=250&&(await lstat(claim)).ino===before.ino){try{await rmdir(claim);}catch(error){if(!['ENOENT','ENOTEMPTY','EEXIST'].includes((error as NodeJS.ErrnoException).code??''))throw error;}}}
77
- catch(error){if((error as NodeJS.ErrnoException).code!=='ENOENT')throw error;}if(performance.now()>deadline)throw new Error('OPEN_BUSY: instance startup claim did not become ready');await sleep(30);}}}
78
- catch(error){await rm(candidate,{recursive:true,force:true});throw error;}
91
+ const reuse=async()=>{const row=await readRecord(registry,key);return row&&row.fingerprint===fingerprint&&row.contentHash===p.contentHash&&row.mode===p.mode&&await alive(row)?row:null;};
92
+ const ready=await reuse();if(ready)return {row:ready,reused:true};
93
+ const claimed=await acquireClaim(canonicalControl,key,nonce,reuse,deadline,'OPEN_BUSY: instance startup claim did not become ready');if('row' in claimed)return {row:claimed.row,reused:true};
79
94
  let request:string|undefined,spawned:ReturnType<typeof spawn>|undefined,handedOff=false;
80
95
  try{
81
- const row=await reuse();if(row)return {url:openUrl(row),reused:true,nonce:row.nonce,inputs:p.inputs,selection:p.selection};
96
+ const row=await reuse();if(row)return {row,reused:true};
82
97
  const identity:OpenIdentity={key,contentHash:p.contentHash,nonce,fingerprint};request=join(canonicalControl,`${key}.${nonce}.request.json`);const log=join(canonicalControl,`${key}.${nonce}.log`);
83
- const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs:options.idleMs??30*60*1000,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
98
+ const spec:WorkerRequest={schema:'rig-review-open-request/1',inputs:p.inputs,identity,stateDir,registry,request,idleMs,...(p.inputs.control?{controlToken:randomUUID()}:{} )};await writeFile(request,JSON.stringify(spec),{mode:0o600,flag:'wx'});
84
99
  const logHandle=await openFile(log,'a',0o600);let child;try{child=spawn(process.execPath,[join(appRoot,'src/open-worker.ts'),request],{cwd:canonicalControl,detached:true,stdio:['ignore',logHandle.fd,logHandle.fd],env:process.env});spawned=child;child.unref();}finally{await logHandle.close();}
85
100
  let exited:string|undefined;child.on('error',error=>{exited=error.message;});child.on('exit',(code,signal)=>{exited=`exit ${code} ${signal??''}`;});
86
- while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {url:openUrl(record),reused:false,nonce:record.nonce,inputs:p.inputs,selection:p.selection};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
101
+ while(performance.now()<deadline){const record=await reuse();if(record){handedOff=true;return {row:record,reused:false};}if(exited)throw new Error(`OPEN_START: ${exited}; worker log ${log}`);await sleep(30);}throw new Error(`OPEN_TIMEOUT: worker did not become ready; log ${log}`);
87
102
  }finally{if(spawned&&!handedOff&&spawned.exitCode===null&&spawned.signalCode===null)spawned.kill('SIGTERM');await releaseClaim(claim,nonce);if(request)await unlink(request).catch(e=>{if(e.code!=='ENOENT')throw e;});}
88
103
  }
89
104
  export async function runOpenWorker(requestPath:string):Promise<void>{
90
- const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1'},raw.identity.key))throw new Error('open worker: invalid request');
105
+ const raw=await smallJson(requestPath) as WorkerRequest;if(raw.schema!=='rig-review-open-request/1'||!raw.identity||!recordValue({schema:'rig-review-open-instance/1',...raw.identity,pid:process.pid,origin:'http://127.0.0.1:1',mode:'single',capability:'0'.repeat(64)},raw.identity.key))throw new Error('open worker: invalid request');
91
106
  const spec=raw,control=await realpath(join(spec.stateDir,'open'));if(dirname(await realpath(requestPath))!==control||spec.request!==requestPath||spec.registry!==join(control,spec.identity.key+'.json')||!Number.isSafeInteger(spec.idleMs)||spec.idleMs<100)throw new Error('open worker: invalid state locations or idle limit');
92
107
  const p=await loadOpenPackage(spec.inputs),fingerprint=await openFingerprint();if(fingerprint!==spec.identity.fingerprint||openKey(p,fingerprint)!==spec.identity.key||p.contentHash!==spec.identity.contentHash)throw new Error('open worker: inputs changed before startup');
93
108
  if(spec.inputs.control&&(!spec.controlToken||!/^[a-f0-9-]{36}$/.test(spec.controlToken)))throw new Error('open control: invalid private capability');
94
- let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
109
+ // The worker mints its own page capability; it lives only in this process and the private record.
110
+ const capability=randomBytes(32).toString('hex');let last=performance.now();const options={port:0,stateDir:spec.stateDir,open:spec.identity,capability,activity:()=>{last=performance.now();},...(spec.inputs.control?{control:{token:spec.controlToken!,instanceKey:spec.identity.key}}:{})};
95
111
  const server=p.mode==='single'?await startServer(p.p,{...options,initialAnimation:p.selection?.name}):await startCompareServer(p.p,options);
96
112
  const check=()=>p.mode==='single'?assertReviewUnchanged(p.p):assertCompareUnchanged(p.p);
97
- await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
113
+ await check();const record:RecordFile={schema:'rig-review-open-instance/1',...spec.identity,pid:process.pid,origin:`http://127.0.0.1:${server.port}`,mode:p.mode,capability,...(spec.inputs.control?{controlToken:spec.controlToken}:{})};const tmp=spec.registry+'.'+spec.identity.nonce+'.tmp';await writeFile(tmp,JSON.stringify(record),{mode:0o600,flag:'wx'});await rename(tmp,spec.registry);
98
114
  await unlink(requestPath).catch(e=>{if(e.code!=='ENOENT')throw e;});
99
115
  let stopped=false;const stop=async()=>{if(stopped)return;stopped=true;server.stop(true);clearInterval(timer);const current=await readRecord(spec.registry,spec.identity.key);if(current?.nonce===spec.identity.nonce)await unlink(spec.registry).catch(()=>{});};
100
116
  const timer=setInterval(()=>{if(performance.now()-last>=spec.idleMs)void stop();},Math.min(1000,spec.idleMs));process.on('SIGTERM',()=>{void stop();});process.on('SIGINT',()=>{void stop();});
@@ -109,7 +125,7 @@ export async function controlReview(inputs:OpenInputs,commandOptions:{pageId?:st
109
125
  const file=join(controlDir,key+'.json');try{const info=await lstat(file);if(!info.isFile()||(info.mode&0o077)!==0)throw new Error('CONTROL_STATE: owner-only regular capability record required');}catch(error){if((error as NodeJS.ErrnoException).code==='ENOENT')throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');throw error;}
110
126
  const record=await readRecord(file,key);
111
127
  if(!record||!record.controlToken||!await alive(record))throw new Error('CONTROL_INSTANCE_UNAVAILABLE: open the exact inputs once with --control');
112
- const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json'};
128
+ const headers={Authorization:'Bearer '+record.controlToken,Origin:record.origin,'Content-Type':'application/json',[capabilityHeader]:record.capability};
113
129
  const get=async(path:string,body?:unknown)=>{const response=await fetch(record.origin+path,{method:body===undefined?'GET':'POST',headers,...(body===undefined?{}:{body:JSON.stringify(body)}),redirect:'error',signal:AbortSignal.timeout(5000)});const value=await readOpenStatus(response,5000);if(!response.ok)throw new Error((value as {error?:string}).error??'CONTROL_REQUEST_FAILED');return value;};
114
130
  const state=await get('/api/control') as {sourceHash:string;instanceKey:string;pages:{pageId:string;revision:number}[]};
115
131
  if(state.sourceHash!==p.contentHash||state.instanceKey!==key)throw new Error('CONTROL_STALE_BINDING');
package/src/server.ts CHANGED
@@ -4,7 +4,7 @@ import { appRoot, buildWeb } from './build.ts';
4
4
  import { packagePath, safeFile, type ReviewPackage } from './manifest.ts';
5
5
  import { exportReviewStrip, stripJson } from './strip-export.ts';
6
6
  import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedReference,serveReference} from './reference.ts';
7
- import {openGate,staleOpenStatus,type OpenIdentity} from './open-protocol.ts';
7
+ import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
8
8
  import { readSession, saveSession } from './session.ts';
9
9
  const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
10
10
  function json(data:unknown,status=200){return Response.json(data,{status,headers});}
@@ -15,14 +15,15 @@ async function bodyJson(req:Request):Promise<unknown>{
15
15
  while(true){const {value,done}=await reader.read();if(done)break;bytes+=value.length;if(bytes>1024*1024){await reader.cancel();throw new Error('Session payload exceeds 1 MiB');}chunks.push(value);}
16
16
  const full=new Uint8Array(bytes);let offset=0;for(const chunk of chunks){full.set(chunk,offset);offset+=chunk.length;}return JSON.parse(new TextDecoder().decode(full));
17
17
  }
18
- export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity}={}){
18
+ /** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it. */
19
+ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string}={}){
19
20
  const bundle=await buildWeb();
20
21
  const unchanged=()=>assertReviewUnchanged(p);
21
22
  const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
22
- return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,
23
- async fetch(req,server){try {
24
- const url=new URL(req.url);const origin=`http://127.0.0.1:${server.port}`;
23
+ const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
24
+ const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
25
25
  if(url.origin!==origin)return json({error:'Loopback host required'},403);
26
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
26
27
  const controlled=await control?.(req,origin);if(controlled)return controlled;
27
28
  options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
28
29
  if(req.method==='PUT'&&url.pathname==='/api/session'){
@@ -56,6 +57,6 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
56
57
  if(url.pathname==='/'||url.pathname==='/index.html')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/index.html'),{headers:{...headers,'Content-Type':'text/html; charset=utf-8'}});
57
58
  if(url.pathname==='/styles.css')return new Response(req.method==='HEAD'?null:await readFile(appRoot+'web/styles.css'),{headers:{...headers,'Content-Type':'text/css'}});
58
59
  return json({error:'Not found'},404);
59
- }catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}}
60
- });
60
+ }catch(e){const message=e instanceof Error?e.message:'Request failed';return json({error:message},message.includes('inputs changed')?409:400);}};
61
+ return Bun.serve({hostname:'127.0.0.1',port:options.port??4317,async fetch(req,server){return stampOpen(req,await handle(req,server.port),options.open);}});
61
62
  }
@@ -31,7 +31,7 @@ function command(binary:string,args:string[],kind:string):Promise<Buffer>{
31
31
  export async function referenceFramePng(path:string,index:number,binary:string,width:number,height:number):Promise<Buffer>{
32
32
  // Downsize before native image decode; output cells must not retain hundreds
33
33
  // of full-resolution source rasters merely because the video was 4K.
34
- return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-vsync','0','-frames:v','1','-f','image2pipe','-c:v','png','-threads','1','pipe:1'],'FFMPEG');
34
+ return command(binary,['-v','error','-threads','1','-filter_threads','1','-i',path,'-map','0:v:0','-vf',`select=eq(n\\,${index}),scale=${width}:${height}:force_original_aspect_ratio=decrease`,'-fps_mode','passthrough','-frames:v','1','-f','image2pipe','-c:v','png','-threads','1','pipe:1'],'FFMPEG');
35
35
  }
36
36
 
37
37
  /** Only DOM ownership and canvas/image IO are adapted; all drawing remains native/player code. */
@@ -32,3 +32,13 @@ export function zoomSignature(value:CameraSignature,factor:number):CameraSignatu
32
32
  export function dragSignature(value:CameraSignature,frame:CameraFrame,dx:number,dy:number):CameraSignature {
33
33
  return {zoom:value.zoom,pan:{x:value.pan.x+dx/frame.width,y:value.pan.y+dy/frame.height}};
34
34
  }
35
+
36
+ /** Fit a selected conservative world box using the existing whole-rig camera
37
+ * reference and normalized sync contract. The established zoom range still applies. */
38
+ export function boundsFitSignature(box:{x:number;y:number;width:number;height:number},frame:CameraFrame,marginX=0,marginY=0):CameraSignature {
39
+ if(![box.x,box.y,box.width,box.height].every(Number.isFinite)||box.width<=0||box.height<=0)throw new Error('PART_FIT_UNAVAILABLE: positive finite drawn bounds required');
40
+ const scale=Math.min((frame.width-marginX)/box.width,(frame.height-marginY)/box.height);
41
+ if(!Number.isFinite(scale)||scale<=0)throw new Error('PART_FIT_UNAVAILABLE: viewport has no usable extent');
42
+ const camera={zoom:clampZoom(scale/frame.baseScale),center:{x:box.x+box.width/2,y:box.y+box.height/2}};
43
+ assertCameraView(camera,frame);return cameraSignature(camera,frame);
44
+ }
package/web/compare.css CHANGED
@@ -94,3 +94,47 @@ body{display:grid;grid-template-rows:auto minmax(0,1fr) auto}.layout{grid-templa
94
94
  .numeric-view{font:11px/1.5 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;max-width:100%;padding:8px;background:#14232c;color:#c9dfe7;border-radius:5px}.view-link-controls{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin-bottom:8px}.draft-notice{flex:none;font-size:11px;padding:0 12px;color:#e0c58e}.draft-notice:has(#draft-state:empty){display:none}.draft-notice #draft-recovery{display:inline-flex;gap:8px;margin-left:8px;flex-wrap:wrap}.draft-notice button{font-size:11px;padding:2px 6px}[data-theme=light] .numeric-view{background:#e1eaee;color:#264653}[data-theme=light] .draft-notice{color:#80521a}[data-theme=light] .outcome legend,[data-theme=light] .brand span,[data-theme=light] .toolbar{color:#496774}
95
95
 
96
96
  .camera-sync{display:inline-flex;align-items:center;gap:5px;font-size:11px;white-space:nowrap}.camera-sync input[type=checkbox]{width:auto;flex:none;margin:0}
97
+
98
+ /* Slot explorer shares the stage row; bottom Inspector remains in the main column. */
99
+ .layout{grid-template-columns:150px minmax(0,1fr) 235px!important}.parts-explorer{display:block;grid-template-columns:none;gap:normal;min-width:0;min-height:0;overflow:auto;border-left:1px solid #49616d;background:#182d38;padding:12px;overscroll-behavior:contain}.parts-explorer h2{font-size:12px;margin:0 0 10px}.parts-explorer>label{display:block;font-size:11px;margin-top:12px}.parts-explorer input[type=search]{width:100%;min-width:0}.parts-explorer .part-solo{display:flex;gap:6px;align-items:center}.parts-explorer ul{list-style:none;margin:8px 0;padding:0;display:grid;gap:4px}.parts-explorer li{min-width:0}.parts-explorer li button{display:block;width:100%;text-align:left;overflow-wrap:anywhere;font-size:11px}.parts-explorer button[aria-pressed=true]{background:#355445;border-color:#9cd3b4}.parts-explorer h2,#selected-part h3{font-weight:600}#selected-part{margin-bottom:12px}#selected-part h3{margin:0 0 8px;overflow-wrap:anywhere}#selected-part p{overflow-wrap:anywhere}[data-theme=light] .parts-explorer{background:#edf3f5;border-color:#a6bec8;color:#294552}[data-theme=light] .parts-explorer button[aria-pressed=true]{background:#c5ddcd;color:#183c2c}
100
+ @media(max-width:1050px){.layout{grid-template-columns:110px minmax(0,1fr) 185px!important}.parts-explorer{padding:8px}}
101
+ @media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}.left-panel{display:none!important}}
102
+ @media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}.parts-explorer{padding:6px}.parts-explorer li button{padding:6px}.parts-explorer>.hint{font-size:10px}}
103
+
104
+ .layout{grid-template-columns:minmax(0,1fr) 235px!important}@media(max-width:1050px){.layout{grid-template-columns:minmax(0,1fr) 185px!important}}@media(max-width:700px){.layout{grid-template-columns:minmax(0,1fr) 165px!important}}@media(max-width:460px){.layout{grid-template-columns:minmax(0,1fr) 132px!important}}
105
+
106
+ /* Resizable workspace: one measured shell; workspace-ui clamps the desired browser sizes. */
107
+ .layout.workspace-layout{display:grid;grid-template-columns:var(--workspace-columns)!important;min-height:0!important;overflow:hidden}
108
+ .workspace-layout>main{display:grid;min-width:0;min-height:0;overflow:hidden;grid-column:3;grid-row:1}
109
+ .workspace-stage-stack{grid-row:1;display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden}
110
+ .workspace-stage-stack>#stage-layout{flex:1;min-height:0;height:auto;overflow:hidden}
111
+ .workspace-layout>.left-panel{grid-column:1;grid-row:1;display:flex!important;flex-direction:column;overflow:hidden;min-width:0;padding:0;border-right:1px solid #49616d}
112
+ .workspace-sources{flex:1;min-height:0;overflow:auto;padding:10px;overscroll-behavior:contain}
113
+ .workspace-settings{flex:none;max-height:65%;overflow:auto;padding:10px;border-top:1px solid #49616d}
114
+ .workspace-settings h2{font-size:12px;margin:0 0 8px}
115
+ .workspace-settings label{display:flex;align-items:center;gap:6px;font-size:11px;margin:7px 0}
116
+ .workspace-settings select{width:auto;min-width:0;flex:1}
117
+ .workspace-settings .hint{margin:6px 0}
118
+ .workspace-settings input[type=checkbox]{width:auto;flex:none}
119
+ .workspace-layout>.parts-explorer{display:block;grid-template-columns:none;grid-column:5;grid-row:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
120
+ .workspace-layout .inspector{grid-row:3;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
121
+ .workspace-separator{position:relative;z-index:3;background:#34515e;touch-action:none;cursor:col-resize;min-width:0;min-height:0}
122
+ .workspace-separator::after{content:'';position:absolute;inset:0 -4px}
123
+ .workspace-separator:focus-visible{outline:2px solid #c8e8d6;outline-offset:-2px;background:#719581}
124
+ .workspace-separator[data-resizing=true]{background:#8ac9a3}
125
+ .workspace-leftWidth{grid-column:2;grid-row:1}.workspace-rightWidth{grid-column:4;grid-row:1}
126
+ .workspace-inspectorHeight{grid-row:2;cursor:row-resize}.workspace-inspectorHeight::after{inset:-4px 0}
127
+ .workspace-drawer-triggers{display:flex;gap:6px;flex-wrap:wrap;flex:none;padding:4px 8px;min-height:0}
128
+ .workspace-drawer-triggers:not(:has(button:not([hidden]))){display:none}
129
+ .workspace-drawer-triggers button{padding:4px 7px;font-size:11px;min-height:28px}
130
+ .workspace-drawer{position:fixed;inset:0;width:min(94vw,950px);height:min(92dvh,850px);max-width:94vw;max-height:92dvh;margin:auto;padding:8px;border:1px solid #739383;border-radius:8px;background:#182d38;color:inherit;overflow:hidden;z-index:20}
131
+ .workspace-drawer:not([open]){display:none}.workspace-drawer[open]{display:flex;flex-direction:column;gap:8px}
132
+ .workspace-drawer::backdrop{background:#07141ed0}
133
+ .workspace-drawer-close{align-self:flex-end;flex:none}
134
+ .workspace-drawer>.left-panel{display:flex!important;flex-direction:column;flex:1;overflow:hidden;min-height:0;padding:0}
135
+ .workspace-drawer>.parts-explorer{display:block;grid-template-columns:none;flex:1;width:auto;min-width:0;min-height:0;height:auto;overflow:auto}
136
+ .workspace-drawer>.inspector{flex:1;height:auto!important;min-height:0!important;max-height:none;overflow:hidden}
137
+ [data-theme=light] .workspace-settings,[data-theme=light] .workspace-drawer{background:#edf3f5;color:#294552;border-color:#a6bec8}
138
+ [data-theme=light] .workspace-separator{background:#b2c7cf}[data-theme=light] .workspace-separator:focus-visible{outline-color:#24694d;background:#70a58c}
139
+ @media(pointer:coarse){.workspace-separator::after{inset:0 -7px}.workspace-inspectorHeight::after{inset:-7px 0}.workspace-drawer-triggers button{min-height:38px}}
140
+ #stage-layout[data-panes="1"]{grid-template-columns:minmax(0,1fr);grid-template-rows:minmax(0,1fr)}
package/web/compare.html CHANGED
@@ -1,7 +1,7 @@
1
1
  <!doctype html>
2
- <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review · compare</title><link rel="stylesheet" href="/styles.css"></head><body>
2
+ <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>rig review · compare</title><link rel="stylesheet" href="styles.css"></head><body>
3
3
  <header><div class="brand">rig review <span>COMPARE</span></div><div><h1 id="title">A / B comparison</h1><span id="animation" class="hint"></span></div><div class="actions"><span id="save-state" class="hint">Loading…</span><button id="export-json">Feedback JSON</button><button id="export-markdown">Issue Markdown</button><button id="submit" class="primary" disabled>Submit review</button></div></header>
4
4
  <div class="layout"><main><div class="toolbar"><span>Inspect A and B at the reference video time</span><div class="row"><label class="camera-sync"><input id="camera-sync" type="checkbox" checked>Sync cameras</label><button id="zoom-out" aria-label="Zoom active view out">−</button><output id="zoom-label">100%</output><button id="zoom-in" aria-label="Zoom active view in">+</button><button id="fit">Fit synchronized views</button></div></div>
5
5
  <div id="stage-layout" class="stages"><section class="pane"><div class="pane-head"><strong>REFERENCE</strong><span id="reference-name"></span></div><div id="reference-viewport" class="reference-viewport"><video id="reference-video" preload="auto" playsinline muted></video><p id="reference-empty" class="empty">Loading reference video…</p></div><div class="pane-foot"><span id="reference-time">— s</span><label><input id="reference-audio" type="checkbox">Video audio</label></div></section><section class="pane"><div class="pane-head"><strong class="side-label">A</strong><span id="a-title"></span></div><div id="a-viewport" class="rig-viewport"><div id="a-root" class="rig-root"></div><canvas id="a-overlay" class="rig-overlay" aria-label="A actual rig mesh and bone overlay"></canvas><p id="a-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="a-time">— s</span><span id="a-frame">rate unknown</span><span id="a-renderer" aria-label="A last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section><section class="pane"><div class="pane-head"><strong class="side-label">B</strong><span id="b-title"></span></div><div id="b-viewport" class="rig-viewport"><div id="b-root" class="rig-root"></div><canvas id="b-overlay" class="rig-overlay" aria-label="B actual rig mesh and bone overlay"></canvas><p id="b-empty" class="empty">Loading rig…</p></div><div class="pane-foot"><span id="b-time">— s</span><span id="b-frame">rate unknown</span><span id="b-renderer" aria-label="B last draw backend" title="DOM images for rigid parts; WebGL meshes when available, Canvas2D fallback.">Not rendered</span></div></section></div>
6
- <section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off for independent Reference, A and B views. Toolbar zoom and Fit use the last active pane.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><p class="hint">These controls show actual images, last-drawn mesh edges and named bone points. Both builds use the fixed animation named above. This inspector does not author geometry or motion.</p></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section class="transport"><button id="play" aria-label="Play reference video and both rigs" disabled>▶</button><input id="scrub" type="range" min="0" max="1" step="0.0001" value="0" aria-label="Shared comparison time" disabled><output id="time">— / — s</output><label for="time-input" class="sr-only">Reference video time to inspect</label><input id="time-input" type="number" min="0" step="0.0001" placeholder="Seconds" disabled><button id="seek" disabled>Go to time</button></section><div class="compare-info"><output id="sync-status">Presentation pending</output><span id="sync-warning"></span></div><div class="compare-info"><label for="frame-basis">Review frame</label><select id="frame-basis"><option value="a">A explicit samples</option><option value="b">B explicit samples</option></select><input id="frame-input" type="number" min="0" step="1" aria-label="Explicit producer frame" disabled><button id="frame-seek" type="button" disabled>Inspect frame</button><span id="frame-index-status">Loading reference timestamp index…</span></div><div class="compare-info"><span id="domain">Checking shared playback range…</span><span id="clock-source">Waiting for video time</span></div><details class="trace"><summary>Synchronization trace</summary><pre id="trace">No frame presented yet.</pre></details><p class="boundary">Motion is read only. The reference video FPS is not used to infer the pose frame clock.</p></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">COMPARE REVIEW</div><label for="status">Review status</label><select id="status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance"><h2>Source provenance</h2><div id="provenance-brief"></div><details><summary>Both builds, versions and reference video hash</summary><pre id="provenance-full"></pre></details></section></div><section class="feedback-form"><h2>Frame-range feedback</h2><label for="side">Target build</label><select id="side"><option value="">Select A or B</option><option value="a">A</option><option value="b">B</option></select><div class="frame-range"><label>Start frame<input id="frame-start" type="number" min="0" step="1" inputmode="numeric"></label><label>End frame<input id="frame-end" type="number" min="0" step="1" inputmode="numeric"></label></div><p id="feedback-range-hint" class="hint">Select A or B to check frame bounds.</p><label for="target-kind">Target type</label><select id="target-kind"><option value="">Select</option><option value="bone">Bone</option><option value="slot">Slot</option><option value="rig">Entire rig</option></select><label for="target-name">Actual target name</label><input id="target-name" type="text" list="target-names" autocomplete="off"><datalist id="target-names"></datalist><p id="target-hint" class="hint">Select a target build and type.</p><label for="category">Category</label><select id="category"><option value="">Select</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="summary">One-line summary</label><input id="summary" type="text" maxlength="1000" placeholder="What looks wrong, and over which frames?"><button id="add-feedback" class="block" disabled>Add feedback</button><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="feedback-list"></div></section><details id="strip-panel" class="trace"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Exports Reference / A / B rows without changing inspection time.</p><label for="strip-scope">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-basis">Report defining the range frames</label><select id="strip-basis"><option value="">Select A or B as frame basis</option><option value="a">A report frameTimes</option><option value="b">B report frameTimes</option></select><label for="strip-format">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">The same frame number in A and B is not assumed to mean the same time. Batch export uses each feedback side as its frame basis. Without frameTimes, PNG is refused and JSON preserves time: null.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading comparison provenance…</p></details></section></div></section></main>
7
- </div><footer><span id="message" role="status" aria-live="polite">Loading comparison provenance from the server…</span><span>Source files remain unchanged.</span></footer><script type="module" src="/main.js"></script></body></html>
6
+ <section class="inspector" aria-label="Inspection controls"><div class="inspector-heading"><strong>INSPECTOR</strong><div role="tablist" aria-label="Inspector sections"><button role="tab" id="tab-view" data-inspector="view" aria-controls="inspector-view" aria-selected="true" tabindex="0">View</button><button role="tab" id="tab-rig" data-inspector="rig" aria-controls="inspector-rig" aria-selected="false" tabindex="-1">Rig</button><button role="tab" id="tab-playback" data-inspector="playback" aria-controls="inspector-playback" aria-selected="false" tabindex="-1">Playback</button><button role="tab" id="tab-feedback" data-inspector="feedback" aria-controls="inspector-feedback" aria-selected="false" tabindex="-1">Feedback</button></div><span class="hint">Local view controls · source files stay unchanged</span></div><div class="draft-notice"><span id="draft-state" role="status" aria-live="polite"></span><div id="draft-recovery"></div></div><div class="inspector-body"><section id="inspector-view" role="tabpanel" aria-labelledby="tab-view" data-inspector-panel="view"><div class="view-link-controls"><button id="copy-view" type="button">Copy view link</button><span class="hint">Numeric source region · opening this link restores a paused inspection</span></div><pre id="view-coordinates" class="numeric-view" aria-label="Current numeric inspection region"></pre><div class="inspection-controls" aria-label="Inspection display controls"><label>Appearance <select id="appearance"><option value="system">System</option><option value="dark">Dark</option><option value="light">Light</option></select></label><label><input id="idle-repeat" type="checkbox" checked>Repeat idle</label><span id="idle-state" class="hint" role="status">Idle preview unavailable</span><span id="control-state" class="hint" role="status"></span></div><p class="hint">Sync cameras links fit-relative zoom and viewport-relative pan across visible panes. Turn it off for independent Reference, A and B views. Toolbar zoom and Fit use the last active pane.</p></section><section id="inspector-rig" role="tabpanel" aria-labelledby="tab-rig" data-inspector-panel="rig" hidden><section id="selected-part" aria-label="Selected part" aria-live="polite"></section><div class="rig-layer-controls"><fieldset id="layer-controls"><legend>Rig layers</legend><label><input id="layer-images" type="checkbox" checked disabled>Images</label><label><input id="layer-mesh" type="checkbox" disabled>Mesh edges</label><label><input id="layer-bones" type="checkbox" disabled>Bone points</label></fieldset><span id="layer-state" class="hint">No rig supplied</span></div><p class="hint">These controls show actual images, last-drawn mesh edges and named bone points. Both builds use the fixed animation named above. This inspector does not author geometry or motion.</p></section><section id="inspector-playback" role="tabpanel" aria-labelledby="tab-playback" data-inspector-panel="playback" hidden><section class="transport"><button id="play" aria-label="Play reference video and both rigs" disabled>▶</button><input id="scrub" type="range" min="0" max="1" step="0.0001" value="0" aria-label="Shared comparison time" disabled><output id="time">— / — s</output><label for="time-input" class="sr-only">Reference video time to inspect</label><input id="time-input" type="number" min="0" step="0.0001" placeholder="Seconds" disabled><button id="seek" disabled>Go to time</button></section><div class="compare-info"><output id="sync-status">Presentation pending</output><span id="sync-warning"></span></div><div class="compare-info"><label for="frame-basis">Review frame</label><select id="frame-basis"><option value="a">A explicit samples</option><option value="b">B explicit samples</option></select><input id="frame-input" type="number" min="0" step="1" aria-label="Explicit producer frame" disabled><button id="frame-seek" type="button" disabled>Inspect frame</button><span id="frame-index-status">Loading reference timestamp index…</span></div><div class="compare-info"><span id="domain">Checking shared playback range…</span><span id="clock-source">Waiting for video time</span></div><details class="trace"><summary>Synchronization trace</summary><pre id="trace">No frame presented yet.</pre></details><p class="boundary">Motion is read only. The reference video FPS is not used to infer the pose frame clock.</p></section><section id="inspector-feedback" role="tabpanel" aria-labelledby="tab-feedback" data-inspector-panel="feedback" hidden><div class="review-summary"><div class="eyebrow">COMPARE REVIEW</div><label for="status">Review status</label><select id="status"><option value="pending">Pending</option><option value="approved">Approved</option><option value="changes-requested">Changes requested</option></select><section class="provenance"><h2>Source provenance</h2><div id="provenance-brief"></div><details><summary>Both builds, versions and reference video hash</summary><pre id="provenance-full"></pre></details></section></div><section class="feedback-form"><h2>Frame-range feedback</h2><label for="side">Target build</label><select id="side"><option value="">Select A or B</option><option value="a">A</option><option value="b">B</option></select><div class="frame-range"><label>Start frame<input id="frame-start" type="number" min="0" step="1" inputmode="numeric"></label><label>End frame<input id="frame-end" type="number" min="0" step="1" inputmode="numeric"></label></div><p id="feedback-range-hint" class="hint">Select A or B to check frame bounds.</p><label for="target-kind">Target type</label><select id="target-kind"><option value="">Select</option><option value="bone">Bone</option><option value="slot">Slot</option><option value="rig">Entire rig</option></select><label for="target-name">Actual target name</label><input id="target-name" type="text" list="target-names" autocomplete="off"><datalist id="target-names"></datalist><p id="target-hint" class="hint">Select a target build and type.</p><label for="category">Category</label><select id="category"><option value="">Select</option><option value="defect">Defect</option><option value="quality">Quality</option></select><label for="summary">One-line summary</label><input id="summary" type="text" maxlength="1000" placeholder="What looks wrong, and over which frames?"><button id="add-feedback" class="block" disabled>Add feedback</button><p id="feedback-warnings" class="hint" role="status" hidden></p><div id="feedback-list"></div></section><details id="strip-panel" class="trace"><summary>Export frame strip</summary><p class="hint">Uses the feedback form start and end frames, inclusive. Exports Reference / A / B rows without changing inspection time.</p><label for="strip-scope">Export range</label><select id="strip-scope"><option value="interval">Entered start–end frames</option><option value="feedback">All feedback ranges</option></select><label for="strip-basis">Report defining the range frames</label><select id="strip-basis"><option value="">Select A or B as frame basis</option><option value="a">A report frameTimes</option><option value="b">B report frameTimes</option></select><label for="strip-format">Format</label><select id="strip-format"><option value="png">PNG strip</option><option value="json">JSON frames and provenance</option></select><p class="hint">The same frame number in A and B is not assumed to mean the same time. Batch export uses each feedback side as its frame basis. Without frameTimes, PNG is refused and JSON preserves time: null.</p><button id="strip-export" type="button" disabled>Download strip</button><p id="strip-status" class="hint" role="status" aria-live="polite">Loading comparison provenance…</p></details></section></div></section></main><aside id="parts-explorer" class="parts-explorer" aria-label="Parts explorer"><h2>PARTS</h2><button type="button" data-part-whole aria-pressed="true">Whole rig</button><label for="part-search">Find slot</label><input id="part-search" data-part-search type="search" autocomplete="off" placeholder="Exact slot name or text"><label class="part-solo"><input id="part-solo" data-part-solo type="checkbox" disabled>Solo selected slot</label><p data-part-status class="hint" role="status">Loading actual slots…</p><ul data-part-list aria-label="Actual rig slots"></ul><p class="hint">Select in context or enable Solo. Type icons and main preview represent actual slots; atlas pages are not part thumbnails.</p></aside>
7
+ </div><footer><span id="message" role="status" aria-live="polite">Loading comparison provenance from the server…</span><span>Source files remain unchanged.</span></footer><script type="module" src="main.js"></script></body></html>