@rig-c/review 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,11 +24,13 @@ 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';
30
31
  import { CLOTH_KEYPOINTS_UNDECLARED, CLOTH_PATCH_MIXED, CLOTH_SOURCE_UNAVAILABLE, CLOTH_WRITE_SINCE, clothLinks, clothPatchKind, readClothTargets, writtenDiff, type ClothTagView, type WrittenDiff } from './cloth-recipe.ts';
31
32
  import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
33
+ import { samplesClock } from './frame-clock.ts';
32
34
  import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
33
35
 
34
36
  export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
@@ -200,6 +202,25 @@ export function designationId(input: { sourceHash: string; configHash: string; p
200
202
  }
201
203
  export const rebuildRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild', sourceHash);
202
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 }[] }
203
224
 
204
225
  export type RebuildStatus = 'running' | 'built' | 'refused' | 'failed';
205
226
  export interface RebuildBuild { model: string; modelUrl: string; modelHash: string; files: Record<string, string> }
@@ -209,6 +230,8 @@ export interface RebuildJob {
209
230
  producer: { package: string; version: string }; compiler: { package: string; version: string };
210
231
  lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { remesh: number | null; compile: number | null };
211
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;
212
235
  }
213
236
  /**
214
237
  * A patch Apply (#102): the config patch, its hash and the config bytes it was made against; the op's, the compile's
@@ -222,6 +245,8 @@ export interface PatchJob {
222
245
  request: { patch: ConfigPatch; patchSha256: string; configHash: string };
223
246
  producer: { package: string; version: string }; compiler: { package: string; version: string };
224
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;
225
250
  check: CheckSummary | null; diff: { count: number; paths: string[] } | null;
226
251
  /** #125: the per-file diff of every producer document (`src/producer-files.ts`) and the images by hash. */
227
252
  files: FilesDiff | null;
@@ -302,6 +327,8 @@ export interface RetargetJob {
302
327
  lines: string[]; producerLine: string | null; refusals: string[]; exitCode: number | null;
303
328
  wallMs: { clean: number | null; retarget: number | null; compile: number | null };
304
329
  files: FilesDiff | null; motion: MotionSummary | null; readings: RetargetLine[];
330
+ /** The run's report `frameTimes` (`motion.report.json`): the new animation's own clock, null when absent or malformed (#164). */
331
+ frameTimes: number[] | null;
305
332
  build: RebuildBuild | null;
306
333
  }
307
334
  /** Deterministic: the package, the config bytes, the record's bytes and both tool versions. No clock. */
@@ -337,6 +364,8 @@ export interface DeltaJob {
337
364
  request: { part: string; delta: Record<string, unknown>; deltaSha256: string; configHash: string };
338
365
  producer: { package: string; version: string }; compiler: { package: string; version: string };
339
366
  lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null };
367
+ /** #148: as `RebuildJob.hint`. */
368
+ hint?: string;
340
369
  files: FilesDiff | null;
341
370
  remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
342
371
  build: RebuildBuild | null;
@@ -482,6 +511,11 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
482
511
  export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
483
512
  const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
484
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;
485
519
  const snapshot = <J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(job: J): J => ({ ...job, lines: [...job.lines] });
486
520
 
487
521
  /** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
@@ -500,7 +534,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
500
534
  try { assertStateOutside(); } catch { return null; }
501
535
  const handoff = p.handoff, key = baselineKey(p.sourceHash, handoff, resolved.producer, resolved.compiler), rig = handoff.rig ?? p.root;
502
536
  const show = (rel: string) => relative(p.root, join(rig, rel)).split(sep).join('/');
503
- 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 };
504
538
  const started = baseline;
505
539
  baselineRun = runBaseline(baselineDir(options.stateDir, key), key, handoff, resolved.producer, show)
506
540
  .catch((error): BaselineResult => ({ ...started, status: 'failed', line: `baseline: failed — ${error instanceof Error ? error.message : String(error)}` }))
@@ -581,6 +615,121 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
581
615
  const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
582
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;
583
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
+ }
584
733
  /** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
585
734
  async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
586
735
 
@@ -592,14 +741,22 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
592
741
  if (stored.schema !== REBUILD_SCHEMA || stored.id !== id || stored.status !== 'built' || !stored.build) return null;
593
742
  const now = await hashBuild(join(dir, 'build'), id);
594
743
  if (JSON.stringify(now.files) !== JSON.stringify(stored.build.files)) return null;
595
- return { ...stored, reused: true };
744
+ const job = { ...stored, reused: true };
745
+ // A retarget built before the job carried its report's frameTimes (#164): read them from the build's own report.
746
+ if ('kind' in job && job.kind === 'retarget' && (job as RetargetJob).frameTimes === undefined) {
747
+ let times: unknown = null;
748
+ try { times = (JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }).frameTimes; } catch { /* no report: no clock */ }
749
+ const clock = samplesClock(times); (job as RetargetJob).frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
750
+ }
751
+ return job;
596
752
  } catch { return null; }
597
753
  }
598
754
 
599
755
  /**
600
756
  * The tail every Apply shares (#121), once its producer op has written a rig spec into `<dir>/<op.spec>`: the per-file
601
757
  * diff when asked (#125), the compile with the revision's own pack flags, check when asked (#102), the build hashed,
602
- * 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.
603
760
  * Each runner passes only its producer op and the optional steps it records.
604
761
  */
605
762
  async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
@@ -610,7 +767,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
610
767
  await mkdir(dir, { recursive: true, mode: 0o700 });
611
768
  const run = await op.run(push);
612
769
  job.exitCode = run.exitCode; op.wallMs(run.wallMs);
613
- 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
+ }
614
777
  job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
615
778
  const { pageEdges, packShape, note } = await op.flags(push);
616
779
  if (steps.diff) {
@@ -642,15 +805,20 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
642
805
  try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the rebuild'); }
643
806
  job.build = await hashBuild(join(dir, 'build'), job.id);
644
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; }
645
811
  latestId = job.id;
646
812
  await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
647
- // Each Apply replaces the previous scratch build of this package.
648
- 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' });
649
816
  // "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
650
817
  job.status = 'built';
651
818
  } catch (error) {
652
819
  job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
653
820
  } finally {
821
+ if (restoring?.id === job.id && job.status !== 'built') restoring = null;
654
822
  // Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
655
823
  if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
656
824
  }
@@ -801,7 +969,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
801
969
  * on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
802
970
  * the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
803
971
  */
804
- 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 }> {
805
973
  let patch: ConfigPatch;
806
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; }
807
975
  assertStateOutside();
@@ -857,14 +1025,16 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
857
1025
  const current = jobs.get(id);
858
1026
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
859
1027
  if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1028
+ guardRestore(id);
860
1029
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1030
+ await unkeep(id);
861
1031
  const reused = await reuse<PatchJob>(dir, id);
862
- 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) }; }
863
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,
864
1034
  ...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
865
1035
  ...(world ? { environment: world.record } : {}),
866
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 } } } : {}) };
867
- jobs.set(id, job); running = id;
1037
+ jobs.set(id, job); running = id; bodies.set(id, body);
868
1038
  void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
869
1039
  return { status: 202, body: snapshot(job) };
870
1040
  }
@@ -917,11 +1087,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
917
1087
  const current = jobs.get(id);
918
1088
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
919
1089
  if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1090
+ guardRestore(id);
920
1091
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1092
+ await unkeep(id);
921
1093
  const reused = await reuse<DeltaJob>(dir, id);
922
- 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) }; }
923
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 };
924
- jobs.set(id, job); running = id;
1096
+ jobs.set(id, job); running = id; bodies.set(id, input);
925
1097
  void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
926
1098
  return { status: 202, body: snapshot(job) };
927
1099
  }
@@ -991,6 +1163,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
991
1163
  await writeFile(join(out, 'motion.json'), text, { mode: 0o600 });
992
1164
  let frameTimes: number[] | null = null;
993
1165
  try { const report = JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }; if (Array.isArray(report.frameTimes) && report.frameTimes.every(t => typeof t === 'number')) frameTimes = report.frameTimes as number[]; } catch { /* no report: the lines' own t= */ }
1166
+ const clock = samplesClock(frameTimes); job.frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
994
1167
  job.readings = retargetLines(retargetRun?.lines ?? [], frameTimes).slice(0, MAX_RETARGET_LINES);
995
1168
  let inspected: unknown = null;
996
1169
  try { inspected = JSON.parse(await readFile(join(inputs.rig, 'motion.json'), 'utf8')); } catch { /* a rig without motion.json: nothing to keep */ }
@@ -1007,7 +1180,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1007
1180
  * still be the handoff's, the sequence a declared `rig-pose-sequence/1` reference, and `@rig-c/pose` installed. The
1008
1181
  * map's resolution against the rig, the flags' combinations and the pose are the producer's to judge, verbatim.
1009
1182
  */
1010
- 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 }> {
1011
1184
  let request: ReturnType<typeof parseRetargetRequest>;
1012
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; }
1013
1186
  assertStateOutside();
@@ -1028,11 +1201,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1028
1201
  const current = jobs.get(id);
1029
1202
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1030
1203
  if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1204
+ guardRestore(id);
1031
1205
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1206
+ await unkeep(id);
1032
1207
  const reused = await reuse<RetargetJob>(dir, id);
1033
- if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1034
- 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: [], build: null };
1035
- jobs.set(id, job); running = id;
1208
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
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 };
1210
+ jobs.set(id, job); running = id; bodies.set(id, body);
1036
1211
  void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
1037
1212
  return { status: 202, body: snapshot(job) };
1038
1213
  }
@@ -1041,7 +1216,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1041
1216
  async function start(input: unknown): Promise<{ status: number; body: unknown }> {
1042
1217
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'retarget')) {
1043
1218
  if (Object.keys(input).length !== 1) bad('expected exactly {retarget}');
1044
- return startRetarget((input as { retarget: unknown }).retarget);
1219
+ return startRetarget((input as { retarget: unknown }).retarget, input);
1045
1220
  }
1046
1221
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
1047
1222
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
@@ -1049,7 +1224,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1049
1224
  const keys = Object.keys(input).sort().join(',');
1050
1225
  if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
1051
1226
  const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
1052
- return startPatch((input as { patch: unknown }).patch, world);
1227
+ return startPatch((input as { patch: unknown }).patch, world, input);
1053
1228
  }
1054
1229
  const request = parseRebuildRequest(input, options.bones);
1055
1230
  assertStateOutside();
@@ -1063,11 +1238,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1063
1238
  const current = jobs.get(id);
1064
1239
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1065
1240
  if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1241
+ guardRestore(id);
1066
1242
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1243
+ await unkeep(id);
1067
1244
  const reused = await reuse(dir, id);
1068
- 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) }; }
1069
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 };
1070
- jobs.set(id, job); running = id;
1247
+ jobs.set(id, job); running = id; bodies.set(id, input);
1071
1248
  void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
1072
1249
  return { status: 202, body: snapshot(job) };
1073
1250
  }
@@ -1087,5 +1264,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1087
1264
  return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
1088
1265
  }
1089
1266
 
1090
- 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 };
1091
1268
  }
package/src/server.ts CHANGED
@@ -45,7 +45,7 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
45
45
  const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
46
46
  const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
47
47
  if(url.origin!==origin)return json({error:'Loopback host required'},403);
48
- const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
48
+ const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
49
49
  const controlled=await control?.(req,origin);if(controlled)return controlled;
50
50
  options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
51
51
  if(req.method==='PUT'&&url.pathname==='/api/session'){
@@ -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());
package/web/compare.ts CHANGED
@@ -49,6 +49,8 @@ let stripBusy = false;
49
49
  let persistence:PersistenceConnection|null=null,submittedBaseline:CompareSession|null=null,draftConflict=false;
50
50
  // First, before any URL use: take the `#cap=` handoff into memory/session storage and strip it.
51
51
  const access=pageInstanceAccess();let readOnly:string|null=null;
52
+ /** Whether this instance serves agent control (`/api/access`); a plain open is never probed for it (#164). */
53
+ let controlServed=true;
52
54
  let referenceFrames:VideoInfo|null=null;
53
55
  const presentation=new ComparePresentation();
54
56
  const rendererBadges={a:new RendererBadge(),b:new RendererBadge()};
@@ -393,9 +395,9 @@ function bind(): void {
393
395
 
394
396
  async function start(): Promise<void> {
395
397
  try {
396
- let writes: { capabilityRequired: boolean };
397
- [data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean }>('/api/access')]);
398
- readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true);
398
+ let writes: { capabilityRequired: boolean; control?: boolean };
399
+ [data, session, writes] = await Promise.all([api<CompareResponse>('/api/compare'), api<CompareSession>('/api/compare/session'), api<{ capabilityRequired: boolean; control?: boolean }>('/api/access')]);
400
+ readOnly = readOnlyReason(access, writes.capabilityRequired); if (readOnly) message(readOnly, true); controlServed = writes.control !== false;
399
401
  submittedBaseline=structuredClone(session);
400
402
  if (data.id !== session.id || data.sourceHash !== session.sourceHash || JSON.stringify(data.binding) !== JSON.stringify(session.binding)) throw new Error('Comparison and submission session refer to different source identities. Reopen the review.');
401
403
  text('title', data.title); text('animation', `${data.animation} · A/B order and reference video hash are fixed`); document.title = data.title + ' · rig review compare'; select('status').value = session.status; provenance(); bind(); parts=new PartExplorer(el('parts-explorer'),el('selected-part'),previous=>{redrawOverlays(true);const part=parts?.value();if(part){activeCamera=part.side==='b'?'b':'a';workspace?.revealInspector();document.querySelector<HTMLButtonElement>('[data-inspector=rig]')?.click();renderCameraControls();}if(!cameraRestoring&&(previous?.side!==part?.side||previous?.slot!==part?.slot))cameraAction(()=>fitActiveCamera());persistence?.save();},part=>{select('side').value=part.side;select('target-kind').value='slot';renderFeedbackSelection();input('target-name').value=part.slot;document.querySelector<HTMLButtonElement>('[data-inspector=feedback]')?.click();}); updateStripControls(); stripStatus('Choose the entered start–end frames or all feedback ranges to download. Unsubmitted feedback is included; exporting does not submit the review.'); renderFeedbackSelection(); renderFeedback(); savedState();
@@ -410,7 +412,7 @@ async function start(): Promise<void> {
410
412
  input('scrub').max = String(domain.end); input('time-input').max = String(domain.end); text('domain', `Shared range 0–${domain.end} s · Neither side is independently clamped or looped`); ready = true;
411
413
  input('scrub').disabled = false; input('time-input').disabled = false; button('seek').disabled = false; button('play').disabled = domain.end === 0;
412
414
  await seekTo(0);activeCamera='a';if(cameraSync)alignCameras();fit();for(const id of ['layer-images','layer-mesh','layer-bones'])input(id).disabled=false;renderIdle();const restored=await setupPersistence();if(!restored&&canRepeatIdle())void playComparison().catch(error=>message(describe(error),true));
413
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
415
+ if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
414
416
  } catch (error) { failPair(error); for (const side of ['a', 'b'] as const) if (!players[side]) text(`${side}-empty`, `${side.toUpperCase()} Could not open comparison rig.`); text('reference-empty', 'Check the reference video and comparison inputs.'); }
415
417
  }
416
418
  function sharedBox():PoseBounds|null {const a=assets.a?.stage??players.a?.bounds(),b=assets.b?.stage??players.b?.bounds();return a&&b?unionBounds(a,b):a??b??null;}
@@ -431,7 +433,7 @@ function showSurvivors():void {
431
433
  updateFeedbackAvailability();
432
434
  const state=renderPairLayout();activeCamera=state.effective==='b'?'b':state.effective==='reference'?'reference':'a';fit();
433
435
  message(`Comparison unavailable · ${state.reasons.compare??''}${state.effective&&state.effective!=='compare'?` · showing ${state.effective==='reference'?'the reference':state.effective.toUpperCase()} alone`:''}`,true);
434
- if(!readOnly)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
436
+ if(!readOnly&&controlServed)controlDispose=connectControl({sourceHash:data.sourceHash,mode:'compare',route:access.route,headers:()=>access.headers(),observe:controlState,apply:applyControl,onStatus:value=>text('control-state',value)});
435
437
  }
436
438
  function paneFrame(target:CameraTarget,hidden=false):CameraFrame|null {
437
439
  const viewport=el(target+'-viewport');if(viewport.clientWidth<=0||viewport.clientHeight<=0)return hidden?cameraFrames[target]??null:null;