@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/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
- const config = await flagPath(root, flags.config, '--config', 'file');
179
- const parts = flags.parts !== undefined ? await flagPath(root, flags.parts, '--parts', 'dir') : join(dirname(config), 'out');
180
- const source = flags.source !== undefined ? await flagPath(root, flags.source, '--source', 'file') : undefined;
181
- const full = flags.full !== undefined ? await flagPath(root, flags.full, '--full', 'dir') : undefined;
182
- const head = flags.head !== undefined ? await flagPath(root, flags.head, '--head', 'dir') : undefined;
183
- const keypoints = flags.keypoints !== undefined ? await flagPath(root, flags.keypoints, '--keypoints', 'file') : undefined;
184
- const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head, keypoints }, flags.parts !== undefined);
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
- return {url,reused:worker.reused,nonce:worker.row.nonce,inputs:p.inputs,selection:p.selection,direct};
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; anything but a built job leaves nothing behind.
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) { job.producerLine = firstRefusal(run.lines) ?? `${op.tool ?? 'rig-parts'} ${op.cmd} exited ${run.exitCode} without a named refusal`; job.status = 'refused'; return; }
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
- for (const sibling of await readdir(rebuildRoot(options.stateDir, p.sourceHash))) if (sibling !== job.id && /^[a-f0-9]{64}$/.test(sibling)) { await rm(join(rebuildRoot(options.stateDir, p.sourceHash), sibling), { recursive: true, force: true }); jobs.delete(sibling); }
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());