@rig-c/review 0.8.0 → 0.9.0
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/README.md +21 -11
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +13 -7
- package/package.json +1 -1
- package/src/baseline-reach.ts +177 -0
- package/src/baseline.ts +14 -9
- package/src/cli.ts +2 -2
- package/src/compare-server.ts +1 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +3 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +198 -21
- package/src/server.ts +8 -1
- package/web/compare.ts +7 -5
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +73 -10
- package/web/rebuild-client.ts +37 -7
- package/web/rig-overlays.ts +24 -9
- package/web/styles.css +1 -0
package/src/frame-clock.ts
CHANGED
|
@@ -34,3 +34,33 @@ export function frameAtTime(clock:FrameClock|null|undefined,time:number):number|
|
|
|
34
34
|
while(low<=high){const middle=low+Math.floor((high-low)/2);const sample=timeAtFrame(clock,middle);if(sample===null)return null;if(sample<=time)low=middle+1;else high=middle-1;}
|
|
35
35
|
return high>=0?high:null;
|
|
36
36
|
}
|
|
37
|
+
|
|
38
|
+
/** The most frame times a scratch build's report may state (a bound for the served job, not a timing claim). */
|
|
39
|
+
export const MAX_STAGE_FRAME_TIMES=100_000;
|
|
40
|
+
|
|
41
|
+
/** A producer report's `frameTimes`, kept only when it is a bounded, finite, strictly increasing list; else null. */
|
|
42
|
+
export function samplesClock(times:unknown):FrameClock|null {
|
|
43
|
+
if(!Array.isArray(times)||!times.length||times.length>MAX_STAGE_FRAME_TIMES)return null;
|
|
44
|
+
for(let i=0;i<times.length;i++){const t=times[i];if(typeof t!=='number'||!Number.isFinite(t)||(i>0&&t<=times[i-1]))return null;}
|
|
45
|
+
return {kind:'samples',frameTimes:[...times as number[]]};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** The clock a scratch build states for one of its animations (a retarget's `motion.report.json`), bound to that build. */
|
|
49
|
+
export interface StageClock {animation:string;modelHash:string;clock:FrameClock}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The transport's clock. The package report's clock when it names the active animation; else, with a player, the
|
|
53
|
+
* clock the build on the stage states for the active animation (#164: a retarget's own report frameTimes). The
|
|
54
|
+
* stage clock binds by animation and by the drawn model's hash, so a reset or another rebuild drops it.
|
|
55
|
+
*/
|
|
56
|
+
export function transportClock(report:{clock:FrameClock|null|undefined;animation:string|undefined},active:string,hasPlayer:boolean,stage:StageClock|null,stageModelHash:string|null):{clock:FrameClock;source:'report'|'stage'}|null {
|
|
57
|
+
const own=clockForAnimation(report.clock,report.animation,active,hasPlayer);if(own)return {clock:own,source:'report'};
|
|
58
|
+
if(hasPlayer&&stage&&active&&stage.animation===active&&stage.modelHash===stageModelHash)return {clock:stage.clock,source:'stage'};
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The rig pane's header: the animation, frame and time the drawn pose is at — the transport's clock, the one a seek sets. */
|
|
63
|
+
export function rigPaneLabel(state:{hasPlayer:boolean;animation:string;time:number;frame:number|null}):string {
|
|
64
|
+
if(!state.hasPlayer)return 'Generated rig · read only';
|
|
65
|
+
return ['Generated rig',state.animation||'no animation',...(state.frame===null?[]:[`F${state.frame}`]),`${state.time.toFixed(3)} s`].join(' · ');
|
|
66
|
+
}
|
package/src/front.ts
CHANGED
|
@@ -57,6 +57,9 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
|
|
|
57
57
|
if((mode==='single'?/^\/assets\/[^/]/:/^\/compare-assets\/(a|b)\/[^/]/).test(path))return read?get:null;
|
|
58
58
|
// Apply · Rebuild (single only): status, one typed request, a job's status and its served scratch build.
|
|
59
59
|
if(mode==='single'&&path==='/api/rebuild')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
|
|
60
|
+
// #166 (single only): the applied records, and undo of the last one ({from}).
|
|
61
|
+
if(mode==='single'&&path==='/api/rebuild/history')return read?get:null;
|
|
62
|
+
if(mode==='single'&&path==='/api/rebuild/undo')return method==='POST'?{mutation:true,limit:1024,timeoutMs:30000,control:false}:null;
|
|
60
63
|
if(mode==='single'&&(/^\/api\/rebuild\/[a-f0-9]{64}$/.test(path)||/^\/rebuild\/[a-f0-9]{64}\/[^/]/.test(path)))return read?get:null;
|
|
61
64
|
// Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
|
|
62
65
|
if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
|
package/src/handoff.ts
CHANGED
|
@@ -9,6 +9,13 @@
|
|
|
9
9
|
* package root; for the `../config.json` step, the folder that holds `out/`); a symlink that leaves it is refused by name.
|
|
10
10
|
* The record (paths relative to the package root, hashes, how it was found) is part of `reviewedSource`, so changing a
|
|
11
11
|
* handoff file's bytes starts a new pending session.
|
|
12
|
+
*
|
|
13
|
+
* A rig-parts build root (#149, {@link isBuildRoot}) opens as its `check/` package. For that package the producer's own
|
|
14
|
+
* config for the build is the regular file `config.json` in the build root, else in the folder that holds it — the two
|
|
15
|
+
* places `rig-parts build --config <dir>/config.json --out <dir>/out` leaves it. The build writes no record of its
|
|
16
|
+
* config, so this is a location rule bounded by the build's own files, not a proof: the session baseline (the producer
|
|
17
|
+
* rebuilding the inspected rig from that config) is the check. Discovered there, or declared with `--config` at exactly
|
|
18
|
+
* one of those two files, the handoff's root is that file's folder and the parts directory is the build root.
|
|
12
19
|
*/
|
|
13
20
|
import { createHash } from 'node:crypto';
|
|
14
21
|
import { lstat, readdir, readFile, realpath, stat } from 'node:fs/promises';
|
|
@@ -19,6 +26,9 @@ export const HANDOFF_SCHEMA = 'rig-review-handoff/1';
|
|
|
19
26
|
export const DISCOVERY_PLACES = ['config.json', 'rig/config.json', '../config.json (beside an opened out/)'] as const;
|
|
20
27
|
export const HANDOFF_DECLARATION = 'rig-review open <dir> --config <path> [--parts <dir>] or the review.json "producer" block';
|
|
21
28
|
export const MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${DISCOVERY_PLACES.join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
|
|
29
|
+
/** #149: the places looked at for the check package of a rig-parts build root, after the in-folder ones. */
|
|
30
|
+
export const BUILD_DISCOVERY_PLACES = ['../config.json (in the build root)', '../../config.json (beside the build root)'] as const;
|
|
31
|
+
export const BUILD_MODELING_INPUT_UNAVAILABLE = `MODELING_INPUT_UNAVAILABLE: config.json — looked at ${[...DISCOVERY_PLACES.slice(0, 2), ...BUILD_DISCOVERY_PLACES].join(', ')}; declare it with ${HANDOFF_DECLARATION}`;
|
|
22
32
|
/** Files the producer reads by the config's own naming, resolved against the config's directory (rig-parts `relativeConfigPaths`). */
|
|
23
33
|
export const CONFIG_NAMED_FILES = [['motion', 'animations_from'], ['generation', 'control', 'skeleton']] as const;
|
|
24
34
|
/** What `rig-parts rig` writes into its --out and remesh reads from --rig: the rig spec, its motion, the mesh report and the part images. */
|
|
@@ -56,6 +66,33 @@ const inside = (base: string, path: string, allowSelf = false) => { const rel =
|
|
|
56
66
|
const code = (e: unknown) => (e as NodeJS.ErrnoException).code ?? '';
|
|
57
67
|
async function exists(path: string): Promise<boolean> { try { await lstat(path); return true; } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; } }
|
|
58
68
|
|
|
69
|
+
/** A path's own entry (never followed) is of this kind; absent or another kind is false. */
|
|
70
|
+
async function entryIs(path: string, kind: 'file' | 'dir'): Promise<boolean> {
|
|
71
|
+
try { const info = await lstat(path); return kind === 'file' ? info.isFile() : info.isDirectory(); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return false; throw e; }
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* #149: a rig-parts build root — the folder `rig-parts build --out` wrote — recognised by fixed names the build owns, never
|
|
75
|
+
* by a search: regular `parts.json` and `rig/rig.json`, a real (not symlinked) `check/` directory, and an existing
|
|
76
|
+
* `check/build/skeleton.model.json` entry (its validity is the package adapter's to refuse). A folder lacking any of
|
|
77
|
+
* these is not a build root.
|
|
78
|
+
*/
|
|
79
|
+
export async function isBuildRoot(dir: string): Promise<boolean> {
|
|
80
|
+
if (!(await entryIs(join(dir, 'parts.json'), 'file')) || !(await entryIs(join(dir, 'rig'), 'dir')) || !(await entryIs(join(dir, 'rig', 'rig.json'), 'file')) || !(await entryIs(join(dir, 'check'), 'dir'))) return false;
|
|
81
|
+
return exists(join(dir, 'check', 'build', 'skeleton.model.json'));
|
|
82
|
+
}
|
|
83
|
+
/** The producer's own config places for the build whose check package is `root` (canonical), or null when `root` is not one. */
|
|
84
|
+
interface BuildPlace { config: string; base: string; field: string }
|
|
85
|
+
async function buildPlaces(root: string): Promise<{ build: string; places: BuildPlace[] } | null> {
|
|
86
|
+
if (basename(root) !== 'check' || !(await isBuildRoot(dirname(root)))) return null;
|
|
87
|
+
const build = dirname(root);
|
|
88
|
+
return { build, places: [{ config: join(build, 'config.json'), base: build, field: '../config.json' }, { config: join(dirname(build), 'config.json'), base: dirname(build), field: '../../config.json' }] };
|
|
89
|
+
}
|
|
90
|
+
/** The path as written with its parent chain canonicalized (so /tmp → /private/tmp is not an escape); null when the parent is missing. */
|
|
91
|
+
async function writtenPath(value: string): Promise<string | null> {
|
|
92
|
+
const lexical = resolve(value);
|
|
93
|
+
try { return join(await realpath(dirname(lexical)), basename(lexical)); } catch (e) { if (code(e) === 'ENOENT' || code(e) === 'ENOTDIR') return null; throw e; }
|
|
94
|
+
}
|
|
95
|
+
|
|
59
96
|
/**
|
|
60
97
|
* One handoff path: it must exist, its canonical location must stay inside `base`, and it must be the expected kind.
|
|
61
98
|
* `lexicalInside` says whether the path as written was inside the root, so a canonical escape is named a symlink escape.
|
|
@@ -175,13 +212,17 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
|
|
|
175
212
|
if (flags) {
|
|
176
213
|
const problem = checkHandoffFlags(flags);
|
|
177
214
|
if (problem) throw new HandoffError('HANDOFF_FLAGS_INVALID', problem.split(':')[0]!, problem);
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const
|
|
182
|
-
|
|
183
|
-
const
|
|
184
|
-
const
|
|
215
|
+
// #149: a --config written as exactly one of the build's own config places takes that place's folder as its root.
|
|
216
|
+
const build = await buildPlaces(root), written = build ? await writtenPath(flags.config) : null;
|
|
217
|
+
const place = build?.places.find(p => p.config === written) ?? null, base = place?.base ?? root;
|
|
218
|
+
const config = await flagPath(base, flags.config, '--config', 'file');
|
|
219
|
+
if (place && config !== place.config) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', '--config', `${flags.config} is a symlink; only the build's own ${place.field} is taken`);
|
|
220
|
+
const parts = flags.parts !== undefined ? await flagPath(base, flags.parts, '--parts', 'dir') : place ? build!.build : join(dirname(config), 'out');
|
|
221
|
+
const source = flags.source !== undefined ? await flagPath(base, flags.source, '--source', 'file') : undefined;
|
|
222
|
+
const full = flags.full !== undefined ? await flagPath(base, flags.full, '--full', 'dir') : undefined;
|
|
223
|
+
const head = flags.head !== undefined ? await flagPath(base, flags.head, '--head', 'dir') : undefined;
|
|
224
|
+
const keypoints = flags.keypoints !== undefined ? await flagPath(base, flags.keypoints, '--keypoints', 'file') : undefined;
|
|
225
|
+
const handoff = await record(root, base, 'declared', 'open --config', config, parts, { source, full, head, keypoints }, flags.parts !== undefined);
|
|
185
226
|
// Canonical absolute flags: the background worker and later reloads read exactly these.
|
|
186
227
|
handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}), ...(keypoints ? { keypoints } : {}) };
|
|
187
228
|
return { handoff, reason: null };
|
|
@@ -202,6 +243,17 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
|
|
|
202
243
|
const config = await contained(root, path, name, 'file', true);
|
|
203
244
|
return { handoff: await record(root, root, 'discovered', 'discovery', config, join(dirname(path), 'out'), {}, false), reason: null };
|
|
204
245
|
}
|
|
246
|
+
// #149: the check package of a rig-parts build root — the build's own config, in the build root, else beside it.
|
|
247
|
+
const build = await buildPlaces(root);
|
|
248
|
+
if (build) {
|
|
249
|
+
for (const place of build.places) {
|
|
250
|
+
if (!(await exists(place.config))) continue;
|
|
251
|
+
const config = await contained(place.base, place.config, place.field, 'file', true);
|
|
252
|
+
if (config !== place.config) throw new HandoffError('HANDOFF_SYMLINK_ESCAPE', place.field, `${place.config} is a symlink; only the build's own config file is discovered`);
|
|
253
|
+
return { handoff: await record(root, place.base, 'discovered', 'discovery', config, build.build, {}, false), reason: null };
|
|
254
|
+
}
|
|
255
|
+
return { handoff: null, reason: BUILD_MODELING_INPUT_UNAVAILABLE };
|
|
256
|
+
}
|
|
205
257
|
// The last step: an opened `out/` whose config sits beside it, one level up. Outside the folder, so named.
|
|
206
258
|
if (basename(root) === 'out') {
|
|
207
259
|
const parent = dirname(root), path = join(parent, 'config.json');
|
package/src/manifest.ts
CHANGED
|
@@ -6,7 +6,7 @@ import { parseReport, type ReportSeeds } from './report.ts';
|
|
|
6
6
|
import { readFile, realpath, stat, lstat, readdir } from 'node:fs/promises';
|
|
7
7
|
import { createReadStream } from 'node:fs';
|
|
8
8
|
import { basename, dirname, isAbsolute, relative, resolve, posix } from 'node:path';
|
|
9
|
-
import { parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
|
|
9
|
+
import { isBuildRoot, parseHandoffDeclaration, resolveHandoff, type Handoff, type HandoffDeclaration, type HandoffFlags, type HandoffRecord } from './handoff.ts';
|
|
10
10
|
|
|
11
11
|
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
|
|
12
12
|
export interface Reference { id: string; label: string; type: 'image' | 'video' | 'pose'; path: string; width?: number; height?: number }
|
|
@@ -74,7 +74,9 @@ export function parseManifest(input: unknown): ReviewManifest {
|
|
|
74
74
|
/** `options.handoff` are the explicit `--config …` flags; they win over the manifest block and discovery. */
|
|
75
75
|
export async function loadReview(input: string, options: { handoff?: HandoffFlags } = {}): Promise<ReviewPackage> {
|
|
76
76
|
const inputPath=await realpath(resolve(input));const directory=(await stat(inputPath)).isDirectory();
|
|
77
|
-
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
|
|
77
|
+
if(directory){try{await stat(resolve(inputPath,'review.json'));return loadReview(resolve(inputPath,'review.json'),options);}catch(e){if((e as NodeJS.ErrnoException).code!=='ENOENT')throw e;}
|
|
78
|
+
// #149: a rig-parts build root without a model of its own opens as its check package, exactly as opening check/ does.
|
|
79
|
+
const check=await buildCheckPackage(inputPath);if(check)return loadReview(check,options);}
|
|
78
80
|
const manifestPath=inputPath;const root=directory?inputPath:await realpath(dirname(inputPath));
|
|
79
81
|
const manifest=directory?await folderManifest(root):parseManifest(JSON.parse(await readFile(manifestPath,'utf8')));
|
|
80
82
|
const files = new Set([...(directory?[]:[relative(root,manifestPath)]),...manifest.references.map(v=>v.path),...manifest.authoring.map(v=>v.path),...manifest.audio.clips.map(v=>v.path)]);
|
|
@@ -112,9 +114,17 @@ export async function loadReview(input: string, options: { handoff?: HandoffFlag
|
|
|
112
114
|
return {root,manifestPath,manifest,sourceHash:hash.digest('hex'),reviewedSource,files,report,feedbackCatalog,handoff,handoffReason,...(handoff?.flags?{handoffInputs:handoff.flags}:{})};
|
|
113
115
|
}
|
|
114
116
|
|
|
117
|
+
/**
|
|
118
|
+
* #149: the `check/` package of a rig-parts build root ({@link isBuildRoot}), when the folder has neither of the two
|
|
119
|
+
* adapter model paths itself (a folder that does keeps opening as before). Fixed names, no search.
|
|
120
|
+
*/
|
|
121
|
+
export async function buildCheckPackage(dir:string):Promise<string|null>{
|
|
122
|
+
for(const candidate of ['skeleton.model.json','build/skeleton.model.json']){try{await lstat(resolve(dir,candidate));return null;}catch(e){if(!['ENOENT','ENOTDIR'].includes((e as NodeJS.ErrnoException).code??''))throw e;}}
|
|
123
|
+
return await isBuildRoot(dir)?resolve(dir,'check'):null;
|
|
124
|
+
}
|
|
115
125
|
/** Same text for every command; `root` lets the open launcher name the refused input. */
|
|
116
126
|
export class UnrecognizedOutputError extends Error {
|
|
117
|
-
constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, motion.json, report.json, source video, pose sequence or contact strip');}
|
|
127
|
+
constructor(readonly root:string){super('Output folder: no recognized skeleton.*, build/skeleton.model.json, rig-parts build root (check/build/skeleton.model.json), motion.json, report.json, source video, pose sequence or contact strip');}
|
|
118
128
|
}
|
|
119
129
|
/** An adapter over an existing output directory; never writes a manifest there. */
|
|
120
130
|
async function folderManifest(root:string):Promise<ReviewManifest>{
|
package/src/open-protocol.ts
CHANGED
|
@@ -18,10 +18,11 @@ export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Op
|
|
|
18
18
|
export const capabilityPattern=/^[a-f0-9]{64}$/;
|
|
19
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
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
|
-
|
|
21
|
+
* Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. It also says
|
|
22
|
+
* whether this instance serves agent control, so a page without it never probes `/api/control/config` (#164). */
|
|
23
|
+
export function accessGate(req:Request,capability:string|undefined,control=false):Response|null{
|
|
23
24
|
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(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability,control},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
|
|
25
26
|
if(!capability||req.method==='GET'||req.method==='HEAD')return null;
|
|
26
27
|
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
|
}
|
package/src/open.ts
CHANGED
|
@@ -16,7 +16,14 @@ import {selectAnimation,AnimationSelectionError,type AnimationSelection} from '.
|
|
|
16
16
|
import {capabilityHeader,capabilityPattern,type OpenIdentity} from './open-protocol.ts';
|
|
17
17
|
import {ensureFront,frontPort} from './front.ts';
|
|
18
18
|
import {playPackagePath} from './versions.ts';
|
|
19
|
-
import {HandoffError,HANDOFF_FLAGS,type HandoffFlags} from './handoff.ts';
|
|
19
|
+
import {HandoffError,HANDOFF_FLAGS,isBuildRoot,type HandoffFlags,type HandoffRecord} from './handoff.ts';
|
|
20
|
+
/** #149: an opened rig-parts build check package — its canonical path and the handoff record (or the refusal line). */
|
|
21
|
+
export interface BuildOpen {package:string;handoff:HandoffRecord|null;reason:string|null}
|
|
22
|
+
/** The stderr diagnostic for {@link BuildOpen}: the package opened, then how the config was found, its package-relative path and full SHA-256. */
|
|
23
|
+
export function buildOpenDiagnostic(build:BuildOpen):string{
|
|
24
|
+
const h=build.handoff,how=!h?'':h.how==='declared'?`declared (${h.via})`:h.outside?'discovered outside the opened folder':'discovered';
|
|
25
|
+
return `Package: rig-parts build check package ${build.package}\n${h?`Handoff ${how}: ${h.config.path} sha256 ${h.config.sha256}${h.parts?` · parts ${h.parts}`:''}`:`Handoff: ${build.reason??'none'}`}`;
|
|
26
|
+
}
|
|
20
27
|
/** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
|
|
21
28
|
export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean;config?:string;parts?:string;source?:string;full?:string;head?:string;keypoints?:string}
|
|
22
29
|
/** The producer handoff flags (#101) among the inputs, or undefined when none was given. */
|
|
@@ -97,7 +104,7 @@ async function openRefusal(error:unknown,inputs:OpenInputs):Promise<unknown>{
|
|
|
97
104
|
}
|
|
98
105
|
/** Managed is the default: the printed URL goes through the one loopback front (127.0.0.1:4318, or
|
|
99
106
|
* RIG_REVIEW_FRONT_PORT) so browser storage keeps one origin across reopens. `direct` keeps the worker's own port. */
|
|
100
|
-
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}>{
|
|
107
|
+
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;build:BuildOpen|null}>{
|
|
101
108
|
const direct=options.direct??inputs.direct??false,port=direct?0:frontPort(options.frontPort);
|
|
102
109
|
for(const path of [inputs.input,...(inputs.compare!==undefined?[inputs.compare]:[])])await assertOpenInput(path);
|
|
103
110
|
if(inputs.compare!==undefined&&!inputs.reference)throw new Error('OPEN_REFERENCE_REQUIRED: compare: explicit --reference required');
|
|
@@ -108,7 +115,9 @@ export async function openReview(inputs:OpenInputs,options:{stateDir?:string;idl
|
|
|
108
115
|
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');
|
|
109
116
|
const worker=await ensureWorker(p,key,fingerprint,stateDir,canonicalControl,options.idleMs??30*60*1000);
|
|
110
117
|
const url=direct?openUrl(worker.row):openUrl(worker.row,await ensureFront({control:canonicalControl,port,fingerprint,idleMs:options.frontIdleMs}));
|
|
111
|
-
|
|
118
|
+
// #149: the check package of a rig-parts build root says which package it opened and which config the handoff took.
|
|
119
|
+
const build=p.mode==='single'&&basename(p.p.root)==='check'&&await isBuildRoot(dirname(p.p.root))?{package:p.p.root,handoff:p.p.reviewedSource.handoff??null,reason:p.p.handoffReason}:null;
|
|
120
|
+
return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct,build};
|
|
112
121
|
}
|
|
113
122
|
async function ensureWorker(p:OpenPackage,key:string,fingerprint:string,stateDir:string,canonicalControl:string,idleMs:number):Promise<{row:RecordFile;reused:boolean}>{
|
|
114
123
|
const registry=join(canonicalControl,key+'.json'),claim=join(canonicalControl,key+'.claim'),nonce=randomUUID(),deadline=performance.now()+20000;
|