@rig-c/review 0.8.1 → 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 +15 -8
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +12 -6
- 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/front.ts +3 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +185 -19
- package/src/server.ts +7 -0
- package/web/index.html +1 -1
- package/web/main.ts +58 -2
- package/web/rebuild-client.ts +36 -6
- package/web/styles.css +1 -0
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.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;
|
package/src/rebuild.ts
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import { createHash } from 'node:crypto';
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
|
-
import { copyFile, mkdir, readFile, readdir, realpath, rm, writeFile, lstat } from 'node:fs/promises';
|
|
13
|
+
import { copyFile, mkdir, readFile, readdir, realpath, rename, rm, writeFile, lstat } from 'node:fs/promises';
|
|
14
14
|
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
15
15
|
import { object, packagePath, safeFile, type ReviewPackage } from './manifest.ts';
|
|
16
16
|
import { compileArgs, firstRefusal, modelingProducerTooOld, REMESH_DELTA_SINCE, remeshArgs, remeshDeltaArgs, resolvePose, resolveProducer, runTool, versionAtLeast, type CompileRequest, type InstalledTool, type ProcessResult } from './producer.ts';
|
|
@@ -24,6 +24,7 @@ import { CHEST_REGIONS_MAX } from './chest-regions.ts';
|
|
|
24
24
|
import { bustMeshEntry, deriveBust, parseBustSpec, BustSpecError, type BustSpec } from './chest-landmarks.ts';
|
|
25
25
|
import { modelMeshTriangles, remeshCounts, type RemeshCounts } from './mesh-entry.ts';
|
|
26
26
|
import { patchRoute } from './retune-route.ts';
|
|
27
|
+
import { producerHint } from './baseline-reach.ts';
|
|
27
28
|
import { carriedCheck, RETUNE_CHECK_SINCE, RETUNE_FILE, retuneCheckSummary, revisionBinding } from './retune-check.ts';
|
|
28
29
|
import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, MAX_BUILD_READOUT, needsBuild, newBuildFile, recompositeFigures, type RecompositeFigures } from './build-route.ts';
|
|
29
30
|
import { jsonDiff } from './config-patch.ts';
|
|
@@ -201,6 +202,25 @@ export function designationId(input: { sourceHash: string; configHash: string; p
|
|
|
201
202
|
}
|
|
202
203
|
export const rebuildRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild', sourceHash);
|
|
203
204
|
export const rebuildDir = (stateDir: string, sourceHash: string, id: string) => join(rebuildRoot(stateDir, sourceHash), id);
|
|
205
|
+
/**
|
|
206
|
+
* #166: where the applied records' earlier builds wait for an undo — outside the scratch root, so the scratch root still
|
|
207
|
+
* holds only the build on the stage. At most `MAX_KEPT_BUILDS` builds are on disk per package (the stage's included);
|
|
208
|
+
* an older record's build is evicted, and undoing to it re-runs its producer step from the record.
|
|
209
|
+
*/
|
|
210
|
+
export const keptRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild-kept', sourceHash);
|
|
211
|
+
export const MAX_KEPT_BUILDS = 8;
|
|
212
|
+
/** The undo refusal: the predecessor cannot be restored byte for byte, and review does not guess. */
|
|
213
|
+
export const UNDO_PREDECESSOR_CHANGED = 'UNDO_PREDECESSOR_CHANGED';
|
|
214
|
+
/** One applied record of the session's stack (#166): the request as sent (to re-run it), its inputs and its build by hash. */
|
|
215
|
+
export interface AppliedEntry {
|
|
216
|
+
id: string; body: unknown; configHash: string;
|
|
217
|
+
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
218
|
+
files: Record<string, string>; modelHash: string;
|
|
219
|
+
/** The job as it settled, served back on undo. */
|
|
220
|
+
job: RebuildJob | PatchJob | DeltaJob | RetargetJob;
|
|
221
|
+
}
|
|
222
|
+
/** `GET /api/rebuild/history`: the applied records in order, the last one on the stage; `kept`: its build is still on disk. */
|
|
223
|
+
export interface AppliedHistory { schema: 'rig-review-applied-history/1'; depth: number; records: { id: string; kind: 'regions' | 'patch' | 'delta' | 'retarget'; op: string; what: string; modelHash: string; kept: boolean }[] }
|
|
204
224
|
|
|
205
225
|
export type RebuildStatus = 'running' | 'built' | 'refused' | 'failed';
|
|
206
226
|
export interface RebuildBuild { model: string; modelUrl: string; modelHash: string; files: Record<string, string> }
|
|
@@ -210,6 +230,8 @@ export interface RebuildJob {
|
|
|
210
230
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
211
231
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { remesh: number | null; compile: number | null };
|
|
212
232
|
build: RebuildBuild | null;
|
|
233
|
+
/** #148: review's reading of a producer refusal it can explain (`producerHint`), beside the producer's line; absent otherwise. */
|
|
234
|
+
hint?: string;
|
|
213
235
|
}
|
|
214
236
|
/**
|
|
215
237
|
* A patch Apply (#102): the config patch, its hash and the config bytes it was made against; the op's, the compile's
|
|
@@ -223,6 +245,8 @@ export interface PatchJob {
|
|
|
223
245
|
request: { patch: ConfigPatch; patchSha256: string; configHash: string };
|
|
224
246
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
225
247
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null; check: number | null };
|
|
248
|
+
/** #148: as `RebuildJob.hint`. */
|
|
249
|
+
hint?: string;
|
|
226
250
|
check: CheckSummary | null; diff: { count: number; paths: string[] } | null;
|
|
227
251
|
/** #125: the per-file diff of every producer document (`src/producer-files.ts`) and the images by hash. */
|
|
228
252
|
files: FilesDiff | null;
|
|
@@ -340,6 +364,8 @@ export interface DeltaJob {
|
|
|
340
364
|
request: { part: string; delta: Record<string, unknown>; deltaSha256: string; configHash: string };
|
|
341
365
|
producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
342
366
|
lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null };
|
|
367
|
+
/** #148: as `RebuildJob.hint`. */
|
|
368
|
+
hint?: string;
|
|
343
369
|
files: FilesDiff | null;
|
|
344
370
|
remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
|
|
345
371
|
build: RebuildBuild | null;
|
|
@@ -485,6 +511,11 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
|
|
|
485
511
|
export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
486
512
|
const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
|
|
487
513
|
let running: string | null = null;
|
|
514
|
+
/** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
|
|
515
|
+
const stack: AppliedEntry[] = [];
|
|
516
|
+
const bodies = new Map<string, unknown>();
|
|
517
|
+
/** #166: an undo re-running an evicted predecessor: its id and the build files it must reproduce. */
|
|
518
|
+
let restoring: { id: string; files: Record<string, string> } | null = null;
|
|
488
519
|
const snapshot = <J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(job: J): J => ({ ...job, lines: [...job.lines] });
|
|
489
520
|
|
|
490
521
|
/** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
|
|
@@ -503,7 +534,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
503
534
|
try { assertStateOutside(); } catch { return null; }
|
|
504
535
|
const handoff = p.handoff, key = baselineKey(p.sourceHash, handoff, resolved.producer, resolved.compiler), rig = handoff.rig ?? p.root;
|
|
505
536
|
const show = (rel: string) => relative(p.root, join(rig, rel)).split(sep).join('/');
|
|
506
|
-
baseline = { schema: 'rig-review-baseline/1', key, status: 'running', producer: tool(resolved.producer), firstDifference: null, hashes: null, line: `baseline: running ${resolved.producer.package} ${resolved.producer.version} rig into scratch…`, exitCode: null, wallMs: null };
|
|
537
|
+
baseline = { schema: 'rig-review-baseline/1', key, status: 'running', producer: tool(resolved.producer), firstDifference: null, hashes: null, reach: null, line: `baseline: running ${resolved.producer.package} ${resolved.producer.version} rig into scratch…`, exitCode: null, wallMs: null };
|
|
507
538
|
const started = baseline;
|
|
508
539
|
baselineRun = runBaseline(baselineDir(options.stateDir, key), key, handoff, resolved.producer, show)
|
|
509
540
|
.catch((error): BaselineResult => ({ ...started, status: 'failed', line: `baseline: failed — ${error instanceof Error ? error.message : String(error)}` }))
|
|
@@ -584,6 +615,121 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
584
615
|
const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
|
|
585
616
|
return job && job.status === 'built' && job.build ? { id: job.id, build: join(dir, 'build'), modelHash: job.build.modelHash, spec: join(dir, !('kind' in job) ? 'revision' : job.op === 'build' || (job.kind === 'patch' && job.cloth?.route === 'build') ? 'out/rig' : 'rig') } : null;
|
|
586
617
|
}
|
|
618
|
+
/**
|
|
619
|
+
* #166: the line refusing an undo's re-run whose build is not the one its record names (the first differing file by
|
|
620
|
+
* hash), or null — also null for any job that is not an undo's re-run.
|
|
621
|
+
*/
|
|
622
|
+
function restoredDifference(id: string, files: Record<string, string>): string | null {
|
|
623
|
+
if (restoring?.id !== id) return null;
|
|
624
|
+
const names = [...new Set([...Object.keys(restoring.files), ...Object.keys(files)])].sort();
|
|
625
|
+
const file = names.find(n => restoring!.files[n] !== files[n]);
|
|
626
|
+
if (!file) return null;
|
|
627
|
+
const side = (h: string | undefined) => (h ? `${h.slice(0, 12)}…` : 'absent'), recorded = restoring.files[file];
|
|
628
|
+
restoring = null;
|
|
629
|
+
return `${UNDO_PREDECESSOR_CHANGED}: ${file} — the record's build has sha256 ${side(recorded)}, the re-run wrote ${side(files[file])}; the predecessor cannot be restored byte for byte, so the stage stays on the last Apply`;
|
|
630
|
+
}
|
|
631
|
+
/** #166: an undo's re-run computed another build id than its record's — some input it hashes is not what it was. */
|
|
632
|
+
function guardRestore(id: string): void {
|
|
633
|
+
if (!restoring || restoring.id === id) return;
|
|
634
|
+
const was = restoring.id; restoring = null;
|
|
635
|
+
throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `build id ${was.slice(0, 12)}… → ${id.slice(0, 12)}… — an input the record's build id hashes (the package, the handoff config, the build's painting, runs or named files, the keypoints, the producer or the compiler) is not what it was; the stage stays on the last Apply`);
|
|
636
|
+
}
|
|
637
|
+
/** #166: a kept build asked for again moves back into the scratch root (where reuse reads it). */
|
|
638
|
+
async function unkeep(id: string): Promise<void> {
|
|
639
|
+
const from = join(keptRoot(options.stateDir, p.sourceHash), id), to = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
640
|
+
if (!(await exists(from))) return;
|
|
641
|
+
if (await exists(to)) { await rm(from, { recursive: true, force: true }); return; }
|
|
642
|
+
await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
|
|
643
|
+
await rename(from, to);
|
|
644
|
+
}
|
|
645
|
+
/**
|
|
646
|
+
* #166: a built (or reused) Apply lands on the stack — or, for an undo's re-run or reuse of the predecessor, the undone
|
|
647
|
+
* record leaves it — and the scratch tree is arranged: the stage's build in the scratch root, the other records' most
|
|
648
|
+
* recent `MAX_KEPT_BUILDS - 1` builds in keptRoot, every other build removed.
|
|
649
|
+
*/
|
|
650
|
+
async function landed(job: RebuildJob | PatchJob | DeltaJob | RetargetJob): Promise<void> {
|
|
651
|
+
if (restoring?.id === job.id) { stack.pop(); restoring = null; }
|
|
652
|
+
else if (stack.at(-1)?.id !== job.id && job.build) stack.push({ id: job.id, body: bodies.get(job.id) ?? null, configHash: job.request.configHash, producer: { ...job.producer }, compiler: { ...job.compiler }, files: { ...job.build.files }, modelHash: job.build.modelHash, job: snapshot(job) });
|
|
653
|
+
latestId = stack.at(-1)?.id ?? null;
|
|
654
|
+
await arrange();
|
|
655
|
+
}
|
|
656
|
+
async function arrange(): Promise<void> {
|
|
657
|
+
const root = rebuildRoot(options.stateDir, p.sourceHash), kept = keptRoot(options.stateDir, p.sourceHash);
|
|
658
|
+
const top = stack.at(-1)?.id ?? null, order: string[] = [];
|
|
659
|
+
for (let i = stack.length - 2; i >= 0; i--) { const id = stack[i]!.id; if (id !== top && !order.includes(id)) order.push(id); }
|
|
660
|
+
const keep = new Set(order.slice(0, MAX_KEPT_BUILDS - 1));
|
|
661
|
+
const list = async (dir: string) => { try { return (await readdir(dir)).filter(n => /^[a-f0-9]{64}$/.test(n)); } catch (e) { if ((e as NodeJS.ErrnoException).code === 'ENOENT') return []; throw e; } };
|
|
662
|
+
if (top) await unkeep(top);
|
|
663
|
+
for (const id of await list(root)) {
|
|
664
|
+
if (id === top) continue;
|
|
665
|
+
if (keep.has(id) && !(await exists(join(kept, id)))) { await mkdir(kept, { recursive: true, mode: 0o700 }); await rename(join(root, id), join(kept, id)); }
|
|
666
|
+
else await rm(join(root, id), { recursive: true, force: true });
|
|
667
|
+
jobs.delete(id);
|
|
668
|
+
}
|
|
669
|
+
for (const id of await list(kept)) if (!keep.has(id)) await rm(join(kept, id), { recursive: true, force: true });
|
|
670
|
+
}
|
|
671
|
+
/** `GET /api/rebuild/history` (#166). */
|
|
672
|
+
async function history(): Promise<AppliedHistory> {
|
|
673
|
+
const records: AppliedHistory['records'] = [];
|
|
674
|
+
for (const [i, e] of stack.entries()) {
|
|
675
|
+
const job = e.job, last = i === stack.length - 1;
|
|
676
|
+
const kind = !('kind' in job) ? 'regions' as const : job.kind;
|
|
677
|
+
const what = !('kind' in job) ? `${job.request.bust ? 'contour.bust landmarks' : `${job.request.regions.length} region(s)`} on ${job.request.part}`
|
|
678
|
+
: job.kind === 'patch' ? job.request.patch.ops.map(o => `${o.op} ${o.path}`).slice(0, 3).join(', ') + (job.request.patch.ops.length > 3 ? `, +${job.request.patch.ops.length - 3}` : '')
|
|
679
|
+
: job.kind === 'delta' ? `mesh edit on ${job.request.part}` : `retarget "${job.request.record.flags.animation}"`;
|
|
680
|
+
const kept = await exists(join(last ? rebuildRoot(options.stateDir, p.sourceHash) : keptRoot(options.stateDir, p.sourceHash), e.id));
|
|
681
|
+
records.push({ id: e.id, kind, op: 'op' in job ? job.op : 'remesh', what, modelHash: e.modelHash, kept });
|
|
682
|
+
}
|
|
683
|
+
return { schema: 'rig-review-applied-history/1', depth: stack.length, records };
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* `POST /api/rebuild/undo` with `{from}` (#166): the last applied record leaves the stack and the stage returns to its
|
|
687
|
+
* predecessor's build, or to the inspected build when none is left. `from` is the record the page shows on the stage
|
|
688
|
+
* (`UNDO_STALE` otherwise). A kept predecessor is restored from disk after its files are re-hashed against its record;
|
|
689
|
+
* an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff
|
|
690
|
+
* config or a tool version is not the record's, and refused after when the build is not byte-identical — either way
|
|
691
|
+
* `UNDO_PREDECESSOR_CHANGED: <what>`, the stage left on the last Apply. Undo writes nothing into the package.
|
|
692
|
+
*/
|
|
693
|
+
async function undo(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
694
|
+
const v = object(input, 'undo');
|
|
695
|
+
if (Object.keys(v).join(',') !== 'from' || typeof v.from !== 'string' || !/^[a-f0-9]{64}$/.test(v.from)) bad('expected exactly {from: <the build id on the stage>}');
|
|
696
|
+
assertStateOutside();
|
|
697
|
+
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
698
|
+
const top = stack.at(-1);
|
|
699
|
+
if (!top) throw new RebuildError('UNDO_NOTHING_APPLIED', 'no Apply is on the stage; the inspected build is');
|
|
700
|
+
if (top.id !== v.from) throw new RebuildError('UNDO_STALE', `the stage's last Apply is ${top.id.slice(0, 12)}…, not ${String(v.from).slice(0, 12)}…; reload`);
|
|
701
|
+
const pred = stack.at(-2);
|
|
702
|
+
if (!pred) { stack.pop(); latestId = null; await arrange(); return { status: 200, body: { status: 'inspected', undone: top.id, history: await history() } }; }
|
|
703
|
+
// (i) the kept build: back into the scratch root, re-hashed against result.json and against the record.
|
|
704
|
+
await unkeep(pred.id);
|
|
705
|
+
const dir = rebuildDir(options.stateDir, p.sourceHash, pred.id);
|
|
706
|
+
const kept = await reuse<RebuildJob | PatchJob | DeltaJob | RetargetJob>(dir, pred.id);
|
|
707
|
+
if (kept && JSON.stringify(kept.build!.files) === JSON.stringify(pred.files)) {
|
|
708
|
+
jobs.set(pred.id, kept); stack.pop(); latestId = pred.id; await arrange();
|
|
709
|
+
return { status: 200, body: { status: 'built', undone: top.id, job: snapshot(kept), history: await history() } };
|
|
710
|
+
}
|
|
711
|
+
await rm(dir, { recursive: true, force: true });
|
|
712
|
+
// (ii) evicted (or not its record's bytes): re-run the record's request, only on the inputs it was built from.
|
|
713
|
+
if (pred.body === null) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${pred.id.slice(0, 12)}… — the record carries no request to re-run and its build is gone`);
|
|
714
|
+
const handoff = p.handoff;
|
|
715
|
+
if (handoff) {
|
|
716
|
+
let now = 'unreadable';
|
|
717
|
+
try { now = sha256(await readFile(handoff.config)); } catch { /* named below */ }
|
|
718
|
+
if (now !== pred.configHash) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${handoff.record.config.path} sha256 ${pred.configHash.slice(0, 12)}… → ${now === 'unreadable' ? now : `${now.slice(0, 12)}…`}; the evicted build was made from the config as it was`);
|
|
719
|
+
}
|
|
720
|
+
const retargeting = 'kind' in pred.job && pred.job.kind === 'retarget';
|
|
721
|
+
const resolved = retargeting ? resolvePose(options.starts) : resolveProducer(options.starts);
|
|
722
|
+
if (!resolved.ok) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${pred.producer.package} ${pred.producer.version} → ${resolved.error}`);
|
|
723
|
+
for (const [was, now] of [[pred.producer, resolved.producer], [pred.compiler, resolved.compiler]] as const) {
|
|
724
|
+
if (was.package !== now.package || was.version !== now.version) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${was.package} ${was.version} → ${now.package} ${now.version}; the evicted build was made with the version its record names`);
|
|
725
|
+
}
|
|
726
|
+
restoring = { id: pred.id, files: { ...pred.files } };
|
|
727
|
+
let started: { status: number; body: unknown };
|
|
728
|
+
try { started = await start(pred.body); } catch (e) { restoring = null; throw e; }
|
|
729
|
+
const body = started.body as { id?: string; status?: string };
|
|
730
|
+
if (body.id !== pred.id) { restoring = null; return started; }
|
|
731
|
+
return { status: started.status, body: { status: body.status === 'running' ? 'running' : body.status, undone: top.id, job: body, ...(body.status === 'running' ? {} : { history: await history() }) } };
|
|
732
|
+
}
|
|
587
733
|
/** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
|
|
588
734
|
async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
|
|
589
735
|
|
|
@@ -609,7 +755,8 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
609
755
|
/**
|
|
610
756
|
* The tail every Apply shares (#121), once its producer op has written a rig spec into `<dir>/<op.spec>`: the per-file
|
|
611
757
|
* diff when asked (#125), the compile with the revision's own pack flags, check when asked (#102), the build hashed,
|
|
612
|
-
* result.json written, the package's other scratch builds replaced
|
|
758
|
+
* result.json written, the package's other scratch builds replaced (#166: the applied records' earlier builds kept
|
|
759
|
+
* beside the scratch root for undo); anything but a built job leaves nothing behind.
|
|
613
760
|
* Each runner passes only its producer op and the optional steps it records.
|
|
614
761
|
*/
|
|
615
762
|
async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
|
|
@@ -620,7 +767,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
620
767
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
621
768
|
const run = await op.run(push);
|
|
622
769
|
job.exitCode = run.exitCode; op.wallMs(run.wallMs);
|
|
623
|
-
if (run.exitCode !== 0) {
|
|
770
|
+
if (run.exitCode !== 0) {
|
|
771
|
+
job.producerLine = firstRefusal(run.lines) ?? `${op.tool ?? 'rig-parts'} ${op.cmd} exited ${run.exitCode} without a named refusal`;
|
|
772
|
+
// #148: the producer's line stays as printed; review adds what it means here, when it knows.
|
|
773
|
+
const hint = producerHint(job.producerLine);
|
|
774
|
+
if (hint && !('refusals' in job)) { (job as RebuildJob | PatchJob | DeltaJob).hint = hint; push(`review: ${hint}`); }
|
|
775
|
+
job.status = 'refused'; return;
|
|
776
|
+
}
|
|
624
777
|
job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
|
|
625
778
|
const { pageEdges, packShape, note } = await op.flags(push);
|
|
626
779
|
if (steps.diff) {
|
|
@@ -652,15 +805,20 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
652
805
|
try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the rebuild'); }
|
|
653
806
|
job.build = await hashBuild(join(dir, 'build'), job.id);
|
|
654
807
|
if (steps.hashed) await steps.hashed();
|
|
808
|
+
// #166: an undo's re-run must reproduce the build its record names, file for file; otherwise it is refused, not guessed.
|
|
809
|
+
const changed = restoredDifference(job.id, job.build.files);
|
|
810
|
+
if (changed) { job.producerLine = changed; job.status = 'refused'; push(`review: ${changed}`); return; }
|
|
655
811
|
latestId = job.id;
|
|
656
812
|
await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
|
|
657
|
-
// Each Apply replaces the previous scratch build of this package
|
|
658
|
-
|
|
813
|
+
// Each Apply replaces the previous scratch build of this package in the scratch root; #166 keeps the applied records'
|
|
814
|
+
// earlier builds beside it (keptRoot) for undo.
|
|
815
|
+
await landed({ ...job, status: 'built' });
|
|
659
816
|
// "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
|
|
660
817
|
job.status = 'built';
|
|
661
818
|
} catch (error) {
|
|
662
819
|
job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
|
|
663
820
|
} finally {
|
|
821
|
+
if (restoring?.id === job.id && job.status !== 'built') restoring = null;
|
|
664
822
|
// Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
|
|
665
823
|
if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
|
|
666
824
|
}
|
|
@@ -811,7 +969,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
811
969
|
* on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
|
|
812
970
|
* the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
|
|
813
971
|
*/
|
|
814
|
-
async function startPatch(input: unknown, world: ApplyWorld | null = null): Promise<{ status: number; body: unknown }> {
|
|
972
|
+
async function startPatch(input: unknown, world: ApplyWorld | null = null, body: unknown = { patch: input }): Promise<{ status: number; body: unknown }> {
|
|
815
973
|
let patch: ConfigPatch;
|
|
816
974
|
try { patch = parseConfigPatch(input); } catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
817
975
|
assertStateOutside();
|
|
@@ -867,14 +1025,16 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
867
1025
|
const current = jobs.get(id);
|
|
868
1026
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
869
1027
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1028
|
+
guardRestore(id);
|
|
870
1029
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1030
|
+
await unkeep(id);
|
|
871
1031
|
const reused = await reuse<PatchJob>(dir, id);
|
|
872
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1032
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
873
1033
|
const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id, status: 'running', reused: false, op, request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null,
|
|
874
1034
|
...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
|
|
875
1035
|
...(world ? { environment: world.record } : {}),
|
|
876
1036
|
...(clothPlan ? { cloth: { route: building ? 'build' as const : 'rig' as const, inputs: { source: p.handoff!.record.source?.path ?? '', keypoints: { ...p.handoff!.record.keypoints! } }, refusals: [], line: null, written: null, links: [], wallMs: { propose: null, build: null } } } : {}) };
|
|
877
|
-
jobs.set(id, job); running = id;
|
|
1037
|
+
jobs.set(id, job); running = id; bodies.set(id, body);
|
|
878
1038
|
void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
|
|
879
1039
|
return { status: 202, body: snapshot(job) };
|
|
880
1040
|
}
|
|
@@ -927,11 +1087,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
927
1087
|
const current = jobs.get(id);
|
|
928
1088
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
929
1089
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1090
|
+
guardRestore(id);
|
|
930
1091
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1092
|
+
await unkeep(id);
|
|
931
1093
|
const reused = await reuse<DeltaJob>(dir, id);
|
|
932
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1094
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
933
1095
|
const job: DeltaJob = { schema: REBUILD_SCHEMA, kind: 'delta', id, status: 'running', reused: false, op: 'remesh', request: { part: request.part, delta: request.delta, deltaSha256: sha256(text), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null }, files: null, remesh: null, build: null };
|
|
934
|
-
jobs.set(id, job); running = id;
|
|
1096
|
+
jobs.set(id, job); running = id; bodies.set(id, input);
|
|
935
1097
|
void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
|
|
936
1098
|
return { status: 202, body: snapshot(job) };
|
|
937
1099
|
}
|
|
@@ -1018,7 +1180,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1018
1180
|
* still be the handoff's, the sequence a declared `rig-pose-sequence/1` reference, and `@rig-c/pose` installed. The
|
|
1019
1181
|
* map's resolution against the rig, the flags' combinations and the pose are the producer's to judge, verbatim.
|
|
1020
1182
|
*/
|
|
1021
|
-
async function startRetarget(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1183
|
+
async function startRetarget(input: unknown, body: unknown = { retarget: input }): Promise<{ status: number; body: unknown }> {
|
|
1022
1184
|
let request: ReturnType<typeof parseRetargetRequest>;
|
|
1023
1185
|
try { request = parseRetargetRequest(input); } catch (e) { if (e instanceof RetargetError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1024
1186
|
assertStateOutside();
|
|
@@ -1039,11 +1201,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1039
1201
|
const current = jobs.get(id);
|
|
1040
1202
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1041
1203
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1204
|
+
guardRestore(id);
|
|
1042
1205
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1206
|
+
await unkeep(id);
|
|
1043
1207
|
const reused = await reuse<RetargetJob>(dir, id);
|
|
1044
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1208
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
1045
1209
|
const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], frameTimes: null, build: null };
|
|
1046
|
-
jobs.set(id, job); running = id;
|
|
1210
|
+
jobs.set(id, job); running = id; bodies.set(id, body);
|
|
1047
1211
|
void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
|
|
1048
1212
|
return { status: 202, body: snapshot(job) };
|
|
1049
1213
|
}
|
|
@@ -1052,7 +1216,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1052
1216
|
async function start(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1053
1217
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'retarget')) {
|
|
1054
1218
|
if (Object.keys(input).length !== 1) bad('expected exactly {retarget}');
|
|
1055
|
-
return startRetarget((input as { retarget: unknown }).retarget);
|
|
1219
|
+
return startRetarget((input as { retarget: unknown }).retarget, input);
|
|
1056
1220
|
}
|
|
1057
1221
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
|
|
1058
1222
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
|
|
@@ -1060,7 +1224,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1060
1224
|
const keys = Object.keys(input).sort().join(',');
|
|
1061
1225
|
if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
|
|
1062
1226
|
const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
|
|
1063
|
-
return startPatch((input as { patch: unknown }).patch, world);
|
|
1227
|
+
return startPatch((input as { patch: unknown }).patch, world, input);
|
|
1064
1228
|
}
|
|
1065
1229
|
const request = parseRebuildRequest(input, options.bones);
|
|
1066
1230
|
assertStateOutside();
|
|
@@ -1074,11 +1238,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1074
1238
|
const current = jobs.get(id);
|
|
1075
1239
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1076
1240
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
1241
|
+
guardRestore(id);
|
|
1077
1242
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1243
|
+
await unkeep(id);
|
|
1078
1244
|
const reused = await reuse(dir, id);
|
|
1079
|
-
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
1245
|
+
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
1080
1246
|
const job: RebuildJob = { schema: REBUILD_SCHEMA, id, status: 'running', reused: false, request: { ...request, entrySha256: sha256(entry) }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { remesh: null, compile: null }, build: null };
|
|
1081
|
-
jobs.set(id, job); running = id;
|
|
1247
|
+
jobs.set(id, job); running = id; bodies.set(id, input);
|
|
1082
1248
|
void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
|
|
1083
1249
|
return { status: 202, body: snapshot(job) };
|
|
1084
1250
|
}
|
|
@@ -1098,5 +1264,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1098
1264
|
return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
|
|
1099
1265
|
}
|
|
1100
1266
|
|
|
1101
|
-
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest };
|
|
1267
|
+
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history };
|
|
1102
1268
|
}
|
package/src/server.ts
CHANGED
|
@@ -59,6 +59,12 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
59
59
|
// A refusal is an outcome the page shows verbatim (200), so the managed front passes its text through; only a malformed request is a 400.
|
|
60
60
|
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
61
61
|
}
|
|
62
|
+
// #166: undo the last Apply — {from: <the build id on the stage>}; nothing is read from the client but that id.
|
|
63
|
+
if(req.method==='POST'&&url.pathname==='/api/rebuild/undo'){
|
|
64
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
65
|
+
try{const result=await rebuilds.undo(await bodyJson(req,1024,'REBUILD_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
|
|
66
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
67
|
+
}
|
|
62
68
|
if(req.method==='POST'&&url.pathname==='/api/bones-check'){
|
|
63
69
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
64
70
|
try{return json(await bonesCheck.run(await bodyJson(req,BONES_CHECK_LIMIT,'BONES_CHECK_BODY_LIMIT: maximum 64 KiB')));}
|
|
@@ -88,6 +94,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
88
94
|
if(url.pathname==='/api/review'){await unchanged();return json({manifest:p.manifest,initialAnimation:options.initialAnimation??null,sourceHash:p.sourceHash,reviewedSource:p.reviewedSource,...(referenceBinding(p)?{referenceBinding:referenceBinding(p),explicitReference:{...referenceBinding(p)!.reference,url:'/review-reference'}}:{}),report:p.report,feedbackCatalog:p.feedbackCatalog,assetsBase:'/assets/',capabilities:{playback:!!p.manifest.model,modeling:false,motion:false,feedback:true,audio:true}});}
|
|
89
95
|
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
90
96
|
if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
|
|
97
|
+
if(url.pathname==='/api/rebuild/history')return json(await rebuilds.history());
|
|
91
98
|
const rebuildStatus=/^\/api\/rebuild\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
92
99
|
if(rebuildStatus){try{return json(rebuilds.read(rebuildStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
|
|
93
100
|
if(url.pathname==='/api/requirements')return json(await requirements.describe());
|