@rig-c/review 0.8.1 → 0.10.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,14 @@ 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 { carriedCheck, RETUNE_CHECK_SINCE, RETUNE_FILE, retuneCheckSummary, revisionBinding } from './retune-check.ts';
27
+ import { producerHint } from './baseline-reach.ts';
28
+ import { carriedCheck, carriedCheckSummary, type CarryingOp, provenanceFile, REMESH_CHECK_SINCE, RETUNE_CHECK_SINCE, 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 { availableParallelism } from 'node:os';
34
+ import { candidateRow, DENSITY_STRIP_SCHEMA, DensityStripError, stripLabels, stripPatches, stripShape, type DensityField, type StripRow } from './density-strip.ts';
32
35
  import { samplesClock } from './frame-clock.ts';
33
36
  import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
34
37
 
@@ -201,6 +204,25 @@ export function designationId(input: { sourceHash: string; configHash: string; p
201
204
  }
202
205
  export const rebuildRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild', sourceHash);
203
206
  export const rebuildDir = (stateDir: string, sourceHash: string, id: string) => join(rebuildRoot(stateDir, sourceHash), id);
207
+ /**
208
+ * #166: where the applied records' earlier builds wait for an undo — outside the scratch root, so the scratch root still
209
+ * holds only the build on the stage. At most `MAX_KEPT_BUILDS` builds are on disk per package (the stage's included);
210
+ * an older record's build is evicted, and undoing to it re-runs its producer step from the record.
211
+ */
212
+ export const keptRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'rebuild-kept', sourceHash);
213
+ export const MAX_KEPT_BUILDS = 8;
214
+ /** The undo refusal: the predecessor cannot be restored byte for byte, and review does not guess. */
215
+ export const UNDO_PREDECESSOR_CHANGED = 'UNDO_PREDECESSOR_CHANGED';
216
+ /** One applied record of the session's stack (#166): the request as sent (to re-run it), its inputs and its build by hash. */
217
+ export interface AppliedEntry {
218
+ id: string; body: unknown; configHash: string;
219
+ producer: { package: string; version: string }; compiler: { package: string; version: string };
220
+ files: Record<string, string>; modelHash: string;
221
+ /** The job as it settled, served back on undo. */
222
+ job: RebuildJob | PatchJob | DeltaJob | RetargetJob;
223
+ }
224
+ /** `GET /api/rebuild/history`: the applied records in order, the last one on the stage; `kept`: its build is still on disk. */
225
+ 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
226
 
205
227
  export type RebuildStatus = 'running' | 'built' | 'refused' | 'failed';
206
228
  export interface RebuildBuild { model: string; modelUrl: string; modelHash: string; files: Record<string, string> }
@@ -210,6 +232,8 @@ export interface RebuildJob {
210
232
  producer: { package: string; version: string }; compiler: { package: string; version: string };
211
233
  lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { remesh: number | null; compile: number | null };
212
234
  build: RebuildBuild | null;
235
+ /** #148: review's reading of a producer refusal it can explain (`producerHint`), beside the producer's line; absent otherwise. */
236
+ hint?: string;
213
237
  }
214
238
  /**
215
239
  * 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 +247,8 @@ export interface PatchJob {
223
247
  request: { patch: ConfigPatch; patchSha256: string; configHash: string };
224
248
  producer: { package: string; version: string }; compiler: { package: string; version: string };
225
249
  lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null; check: number | null };
250
+ /** #148: as `RebuildJob.hint`. */
251
+ hint?: string;
226
252
  check: CheckSummary | null; diff: { count: number; paths: string[] } | null;
227
253
  /** #125: the per-file diff of every producer document (`src/producer-files.ts`) and the images by hash. */
228
254
  files: FilesDiff | null;
@@ -340,6 +366,8 @@ export interface DeltaJob {
340
366
  request: { part: string; delta: Record<string, unknown>; deltaSha256: string; configHash: string };
341
367
  producer: { package: string; version: string }; compiler: { package: string; version: string };
342
368
  lines: string[]; producerLine: string | null; exitCode: number | null; wallMs: { op: number | null; compile: number | null };
369
+ /** #148: as `RebuildJob.hint`. */
370
+ hint?: string;
343
371
  files: FilesDiff | null;
344
372
  remesh: (RemeshCounts & { modelTriangles: number | null }) | null;
345
373
  build: RebuildBuild | null;
@@ -374,7 +402,24 @@ export function assertNamedFilesHeld(before: unknown, after: unknown): void {
374
402
  /** The value of a flag in the patch's args, or the producer's default. */
375
403
  const flagValue = (args: readonly string[], flag: string, fallback: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1]! : fallback; };
376
404
 
377
- export interface RebuildOptions { stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null }
405
+ export interface RebuildOptions {
406
+ stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null;
407
+ /** #173: how many strip candidates run at once (default max(1, floor(cores / 2))). */
408
+ stripConcurrency?: number;
409
+ }
410
+ /** #173: a density strip's scratch — one folder per package, holding at most the one strip on screen. */
411
+ export const stripRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'density-strip', sourceHash);
412
+ export const defaultStripConcurrency = () => Math.max(1, Math.floor(availableParallelism() / 2));
413
+ /** Deterministic: the package, the config bytes, every candidate's patch bytes in order and both tool versions. */
414
+ export function stripId(input: { sourceHash: string; configHash: string; patches: readonly ConfigPatch[]; producer: string; compiler: string }): string {
415
+ return sha256(JSON.stringify([DENSITY_STRIP_SCHEMA, input.sourceHash, input.configHash, input.patches.map(patchText), input.producer, input.compiler]));
416
+ }
417
+ /** `GET /api/density-strip/<id>`: the strip, one row per candidate in the request's order, and the two labels. */
418
+ export interface DensityStripView {
419
+ schema: typeof DENSITY_STRIP_SCHEMA; id: string; status: 'running' | 'done'; part: string; field: DensityField;
420
+ concurrency: number; configHash: string; producer: { package: string; version: string }; compiler: { package: string; version: string };
421
+ wallMs: number | null; rows: StripRow[]; labels: { fewest: number | null; widest: number | null };
422
+ }
378
423
  export const baselineDir = (stateDir: string, key: string) => join(stateDir, 'baseline', key);
379
424
 
380
425
  const tool = (t: InstalledTool) => ({ package: t.package, version: t.version });
@@ -405,7 +450,7 @@ interface ApplySteps {
405
450
  /** #102: check the scratch rig after the compile. */
406
451
  check?: (summary: CheckSummary, wallMs: number | null) => void;
407
452
  /**
408
- * #139: the check the producer op already ran on the spec it wrote, read instead of running check again: a summary
453
+ * #139 / #174: the check the producer op (retune, remesh) already ran on the spec it wrote, read instead of running check again: a summary
409
454
  * (recorded through `check`, no wall clock), a refusal line (nothing recorded, the build refused), or null — the op
410
455
  * carries none, so check runs as for any other Apply.
411
456
  */
@@ -414,24 +459,31 @@ interface ApplySteps {
414
459
  hashed?: () => Promise<void>;
415
460
  /** #112: the schedule check reads (`--environment environment.json`, written into the scratch directory first). */
416
461
  world?: ApplyWorld | null;
462
+ /**
463
+ * #173: a density strip's candidate — built and hashed and its result.json written exactly as an Apply's, but it does
464
+ * not land: no applied record, no stage, no scratch-root arrangement. Its build waits in the strip's own folder.
465
+ */
466
+ candidate?: boolean;
417
467
  }
418
468
 
419
469
  /**
420
- * #139: the check `rig-parts retune` ran on the revision it wrote into `spec`, as review's check summary, bound to the
421
- * revision's files by `retune.json`'s hash rows (`RETUNE_CHECK_STALE` refuses the Apply). Null — check runs as before —
422
- * when the Apply checks under an exported world (retune's check read the default one) or `retune.json` carries no
423
- * `line` / `status` (a producer before `RETUNE_CHECK_SINCE`).
470
+ * #139 / #174: the check `rig-parts retune` (or `remesh`) ran on the revision it wrote into `spec`, as review's check
471
+ * summary, bound to the revision's files by the provenance's hash rows (`RETUNE_CHECK_STALE` / `REMESH_CHECK_STALE`
472
+ * refuses the Apply). Null — check runs as before — when the Apply checks under an exported world (the op's check read
473
+ * the default one) or the provenance carries no `line` / `status` (a producer before `RETUNE_CHECK_SINCE` /
474
+ * `REMESH_CHECK_SINCE`, both 1.49.0).
424
475
  */
425
- async function retuneCarried(spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
426
- if (world) { push('review: retune\'s own check read the default world and this Apply checks under the exported schedule, so check runs on the revision'); return null; }
427
- const retune = JSON.parse(await readFile(join(spec, RETUNE_FILE), 'utf8')) as unknown;
428
- const carried = carriedCheck(retune);
429
- if (!carried) { push(`review: ${RETUNE_FILE} carries no check line or block statuses (@rig-c/parts before ${RETUNE_CHECK_SINCE}), so check runs on the revision`); return null; }
430
- const stale = await revisionBinding(spec, retune);
476
+ async function opCarried(op: CarryingOp, spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
477
+ const file = provenanceFile(op);
478
+ if (world) { push(`review: ${op}'s own check read the default world and this Apply checks under the exported schedule, so check runs on the revision`); return null; }
479
+ const provenance = JSON.parse(await readFile(join(spec, file), 'utf8')) as unknown;
480
+ const carried = carriedCheck(provenance, op);
481
+ if (!carried) { push(`review: ${file} carries no check line or block statuses (@rig-c/parts before ${op === 'retune' ? RETUNE_CHECK_SINCE : REMESH_CHECK_SINCE}), so check runs on the revision`); return null; }
482
+ const stale = await revisionBinding(spec, provenance, op);
431
483
  if (stale) return { refused: stale };
432
- const rows = (retune as { revision: unknown[] }).revision.length;
433
- push(`review: check not run again — ${RETUNE_FILE} carries retune's check of this revision, bound to it by ${rows} revision hash row(s): ${carried.line}`);
434
- return { summary: retuneCheckSummary(carried) };
484
+ const rows = (provenance as { revision: unknown[] }).revision.length;
485
+ push(`review: check not run again — ${file} carries ${op}'s check of this revision, bound to it by ${rows} revision hash row(s): ${carried.line}`);
486
+ return { summary: carriedCheckSummary(carried, op) };
435
487
  }
436
488
 
437
489
  /** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
@@ -485,6 +537,16 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
485
537
  export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
486
538
  const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
487
539
  let running: string | null = null;
540
+ /** #173: the density strip on screen (at most one per review): its candidates run beside each other, none of them applied. */
541
+ interface StripCandidate { value: number; id: string; entry: JsonValue; dir: string; job: PatchJob; applied: boolean }
542
+ let strip: { id: string; status: 'running' | 'done'; part: string; field: DensityField; concurrency: number; configHash: string; producer: { package: string; version: string }; compiler: { package: string; version: string }; started: number; wallMs: number | null; candidates: StripCandidate[] } | null = null;
543
+ /** One producer workload at a time per review: an Apply (or undo's re-run) or a strip; the line refusing the other. */
544
+ const busy = (): string | null => (running ? `REBUILD_BUSY: rebuild ${running} is still running` : strip?.status === 'running' ? `REBUILD_BUSY: density strip ${strip.id} is still running` : null);
545
+ /** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
546
+ const stack: AppliedEntry[] = [];
547
+ const bodies = new Map<string, unknown>();
548
+ /** #166: an undo re-running an evicted predecessor: its id and the build files it must reproduce. */
549
+ let restoring: { id: string; files: Record<string, string> } | null = null;
488
550
  const snapshot = <J extends RebuildJob | PatchJob | DeltaJob | RetargetJob>(job: J): J => ({ ...job, lines: [...job.lines] });
489
551
 
490
552
  /** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
@@ -503,7 +565,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
503
565
  try { assertStateOutside(); } catch { return null; }
504
566
  const handoff = p.handoff, key = baselineKey(p.sourceHash, handoff, resolved.producer, resolved.compiler), rig = handoff.rig ?? p.root;
505
567
  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 };
568
+ 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
569
  const started = baseline;
508
570
  baselineRun = runBaseline(baselineDir(options.stateDir, key), key, handoff, resolved.producer, show)
509
571
  .catch((error): BaselineResult => ({ ...started, status: 'failed', line: `baseline: failed — ${error instanceof Error ? error.message : String(error)}` }))
@@ -584,6 +646,121 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
584
646
  const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
585
647
  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
648
  }
649
+ /**
650
+ * #166: the line refusing an undo's re-run whose build is not the one its record names (the first differing file by
651
+ * hash), or null — also null for any job that is not an undo's re-run.
652
+ */
653
+ function restoredDifference(id: string, files: Record<string, string>): string | null {
654
+ if (restoring?.id !== id) return null;
655
+ const names = [...new Set([...Object.keys(restoring.files), ...Object.keys(files)])].sort();
656
+ const file = names.find(n => restoring!.files[n] !== files[n]);
657
+ if (!file) return null;
658
+ const side = (h: string | undefined) => (h ? `${h.slice(0, 12)}…` : 'absent'), recorded = restoring.files[file];
659
+ restoring = null;
660
+ 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`;
661
+ }
662
+ /** #166: an undo's re-run computed another build id than its record's — some input it hashes is not what it was. */
663
+ function guardRestore(id: string): void {
664
+ if (!restoring || restoring.id === id) return;
665
+ const was = restoring.id; restoring = null;
666
+ 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`);
667
+ }
668
+ /** #166: a kept build asked for again moves back into the scratch root (where reuse reads it). */
669
+ async function unkeep(id: string): Promise<void> {
670
+ const from = join(keptRoot(options.stateDir, p.sourceHash), id), to = rebuildDir(options.stateDir, p.sourceHash, id);
671
+ if (!(await exists(from))) return;
672
+ if (await exists(to)) { await rm(from, { recursive: true, force: true }); return; }
673
+ await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
674
+ await rename(from, to);
675
+ }
676
+ /**
677
+ * #166: a built (or reused) Apply lands on the stack — or, for an undo's re-run or reuse of the predecessor, the undone
678
+ * record leaves it — and the scratch tree is arranged: the stage's build in the scratch root, the other records' most
679
+ * recent `MAX_KEPT_BUILDS - 1` builds in keptRoot, every other build removed.
680
+ */
681
+ async function landed(job: RebuildJob | PatchJob | DeltaJob | RetargetJob): Promise<void> {
682
+ if (restoring?.id === job.id) { stack.pop(); restoring = null; }
683
+ 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) });
684
+ latestId = stack.at(-1)?.id ?? null;
685
+ await arrange();
686
+ }
687
+ async function arrange(): Promise<void> {
688
+ const root = rebuildRoot(options.stateDir, p.sourceHash), kept = keptRoot(options.stateDir, p.sourceHash);
689
+ const top = stack.at(-1)?.id ?? null, order: string[] = [];
690
+ for (let i = stack.length - 2; i >= 0; i--) { const id = stack[i]!.id; if (id !== top && !order.includes(id)) order.push(id); }
691
+ const keep = new Set(order.slice(0, MAX_KEPT_BUILDS - 1));
692
+ 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; } };
693
+ if (top) await unkeep(top);
694
+ for (const id of await list(root)) {
695
+ if (id === top) continue;
696
+ if (keep.has(id) && !(await exists(join(kept, id)))) { await mkdir(kept, { recursive: true, mode: 0o700 }); await rename(join(root, id), join(kept, id)); }
697
+ else await rm(join(root, id), { recursive: true, force: true });
698
+ jobs.delete(id);
699
+ }
700
+ for (const id of await list(kept)) if (!keep.has(id)) await rm(join(kept, id), { recursive: true, force: true });
701
+ }
702
+ /** `GET /api/rebuild/history` (#166). */
703
+ async function history(): Promise<AppliedHistory> {
704
+ const records: AppliedHistory['records'] = [];
705
+ for (const [i, e] of stack.entries()) {
706
+ const job = e.job, last = i === stack.length - 1;
707
+ const kind = !('kind' in job) ? 'regions' as const : job.kind;
708
+ const what = !('kind' in job) ? `${job.request.bust ? 'contour.bust landmarks' : `${job.request.regions.length} region(s)`} on ${job.request.part}`
709
+ : 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}` : '')
710
+ : job.kind === 'delta' ? `mesh edit on ${job.request.part}` : `retarget "${job.request.record.flags.animation}"`;
711
+ const kept = await exists(join(last ? rebuildRoot(options.stateDir, p.sourceHash) : keptRoot(options.stateDir, p.sourceHash), e.id));
712
+ records.push({ id: e.id, kind, op: 'op' in job ? job.op : 'remesh', what, modelHash: e.modelHash, kept });
713
+ }
714
+ return { schema: 'rig-review-applied-history/1', depth: stack.length, records };
715
+ }
716
+ /**
717
+ * `POST /api/rebuild/undo` with `{from}` (#166): the last applied record leaves the stack and the stage returns to its
718
+ * predecessor's build, or to the inspected build when none is left. `from` is the record the page shows on the stage
719
+ * (`UNDO_STALE` otherwise). A kept predecessor is restored from disk after its files are re-hashed against its record;
720
+ * an evicted one re-runs its recorded request through the same runner, refused before anything runs when the handoff
721
+ * config or a tool version is not the record's, and refused after when the build is not byte-identical — either way
722
+ * `UNDO_PREDECESSOR_CHANGED: <what>`, the stage left on the last Apply. Undo writes nothing into the package.
723
+ */
724
+ async function undo(input: unknown): Promise<{ status: number; body: unknown }> {
725
+ const v = object(input, 'undo');
726
+ 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>}');
727
+ assertStateOutside();
728
+ if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
729
+ const top = stack.at(-1);
730
+ if (!top) throw new RebuildError('UNDO_NOTHING_APPLIED', 'no Apply is on the stage; the inspected build is');
731
+ 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`);
732
+ const pred = stack.at(-2);
733
+ if (!pred) { stack.pop(); latestId = null; await arrange(); return { status: 200, body: { status: 'inspected', undone: top.id, history: await history() } }; }
734
+ // (i) the kept build: back into the scratch root, re-hashed against result.json and against the record.
735
+ await unkeep(pred.id);
736
+ const dir = rebuildDir(options.stateDir, p.sourceHash, pred.id);
737
+ const kept = await reuse<RebuildJob | PatchJob | DeltaJob | RetargetJob>(dir, pred.id);
738
+ if (kept && JSON.stringify(kept.build!.files) === JSON.stringify(pred.files)) {
739
+ jobs.set(pred.id, kept); stack.pop(); latestId = pred.id; await arrange();
740
+ return { status: 200, body: { status: 'built', undone: top.id, job: snapshot(kept), history: await history() } };
741
+ }
742
+ await rm(dir, { recursive: true, force: true });
743
+ // (ii) evicted (or not its record's bytes): re-run the record's request, only on the inputs it was built from.
744
+ 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`);
745
+ const handoff = p.handoff;
746
+ if (handoff) {
747
+ let now = 'unreadable';
748
+ try { now = sha256(await readFile(handoff.config)); } catch { /* named below */ }
749
+ 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`);
750
+ }
751
+ const retargeting = 'kind' in pred.job && pred.job.kind === 'retarget';
752
+ const resolved = retargeting ? resolvePose(options.starts) : resolveProducer(options.starts);
753
+ if (!resolved.ok) throw new RebuildError(UNDO_PREDECESSOR_CHANGED, `${pred.producer.package} ${pred.producer.version} → ${resolved.error}`);
754
+ for (const [was, now] of [[pred.producer, resolved.producer], [pred.compiler, resolved.compiler]] as const) {
755
+ 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`);
756
+ }
757
+ restoring = { id: pred.id, files: { ...pred.files } };
758
+ let started: { status: number; body: unknown };
759
+ try { started = await start(pred.body); } catch (e) { restoring = null; throw e; }
760
+ const body = started.body as { id?: string; status?: string };
761
+ if (body.id !== pred.id) { restoring = null; return started; }
762
+ return { status: started.status, body: { status: body.status === 'running' ? 'running' : body.status, undone: top.id, job: body, ...(body.status === 'running' ? {} : { history: await history() }) } };
763
+ }
587
764
  /** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
588
765
  async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
589
766
 
@@ -609,7 +786,8 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
609
786
  /**
610
787
  * The tail every Apply shares (#121), once its producer op has written a rig spec into `<dir>/<op.spec>`: the per-file
611
788
  * 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.
789
+ * result.json written, the package's other scratch builds replaced (#166: the applied records' earlier builds kept
790
+ * beside the scratch root for undo); anything but a built job leaves nothing behind.
613
791
  * Each runner passes only its producer op and the optional steps it records.
614
792
  */
615
793
  async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
@@ -620,7 +798,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
620
798
  await mkdir(dir, { recursive: true, mode: 0o700 });
621
799
  const run = await op.run(push);
622
800
  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; }
801
+ if (run.exitCode !== 0) {
802
+ job.producerLine = firstRefusal(run.lines) ?? `${op.tool ?? 'rig-parts'} ${op.cmd} exited ${run.exitCode} without a named refusal`;
803
+ // #148: the producer's line stays as printed; review adds what it means here, when it knows.
804
+ const hint = producerHint(job.producerLine);
805
+ if (hint && !('refusals' in job)) { (job as RebuildJob | PatchJob | DeltaJob).hint = hint; push(`review: ${hint}`); }
806
+ job.status = 'refused'; return;
807
+ }
624
808
  job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
625
809
  const { pageEdges, packShape, note } = await op.flags(push);
626
810
  if (steps.diff) {
@@ -652,15 +836,20 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
652
836
  try { await options.unchanged(); } catch { throw new RebuildError('REBUILD_INPUTS_CHANGED', 'the package or its producer handoff changed during the rebuild'); }
653
837
  job.build = await hashBuild(join(dir, 'build'), job.id);
654
838
  if (steps.hashed) await steps.hashed();
655
- latestId = job.id;
839
+ // #166: an undo's re-run must reproduce the build its record names, file for file; otherwise it is refused, not guessed.
840
+ const changed = steps.candidate ? null : restoredDifference(job.id, job.build.files);
841
+ if (changed) { job.producerLine = changed; job.status = 'refused'; push(`review: ${changed}`); return; }
842
+ if (!steps.candidate) latestId = job.id;
656
843
  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); }
844
+ // Each Apply replaces the previous scratch build of this package in the scratch root; #166 keeps the applied records'
845
+ // earlier builds beside it (keptRoot) for undo. #173: a strip candidate lands nowhere until its row is Applied.
846
+ if (!steps.candidate) await landed({ ...job, status: 'built' });
659
847
  // "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
660
848
  job.status = 'built';
661
849
  } catch (error) {
662
850
  job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
663
851
  } finally {
852
+ if (restoring?.id === job.id && job.status !== 'built') restoring = null;
664
853
  // Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
665
854
  if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
666
855
  }
@@ -687,7 +876,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
687
876
  * #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
688
877
  * inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
689
878
  */
690
- function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null): Promise<void> {
879
+ function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null, candidate = false): Promise<void> {
691
880
  const args = job.request.patch.op.args, producer = resolved.producer;
692
881
  let counts: RemeshCounts | null = null;
693
882
  let built: ProcessResult | null = null;
@@ -799,9 +988,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
799
988
  ...(building ? {} : { check: (summary: CheckSummary, wallMs: number | null) => { job.wallMs.check = wallMs; job.check = summary; } }),
800
989
  // #139: a retune Apply reads the check retune ran on its revision (retune.json, bound by its hash rows) unless the
801
990
  // Apply checks under an exported world (retune's check reads the default one) or the producer carries no line.
802
- ...(job.op === 'retune' ? { carried: (push: (line: string) => void) => retuneCarried(join(dir, 'rig'), world, push) } : {}),
991
+ // #174: a remesh patch the same, from remesh.json — remesh checked the revision with the request's pack flags,
992
+ // the ones `flags` reads for review's own check, and the same `--parts`; a patch passes no `--requirements`.
993
+ ...(job.op === 'retune' || job.op === 'remesh' ? { carried: (push: (line: string) => void) => opCarried(job.op as CarryingOp, join(dir, 'rig'), world, push) } : {}),
803
994
  hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
804
995
  world,
996
+ ...(candidate ? { candidate: true } : {}),
805
997
  });
806
998
  }
807
999
 
@@ -811,7 +1003,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
811
1003
  * on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
812
1004
  * the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
813
1005
  */
814
- async function startPatch(input: unknown, world: ApplyWorld | null = null): Promise<{ status: number; body: unknown }> {
1006
+ async function startPatch(input: unknown, world: ApplyWorld | null = null, body: unknown = { patch: input }): Promise<{ status: number; body: unknown }> {
815
1007
  let patch: ConfigPatch;
816
1008
  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
1009
  assertStateOutside();
@@ -866,19 +1058,130 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
866
1058
  const id = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}`, op, ...(plan ? { buildInputs: plan.inputsHash } : {}), ...(world ? { environment: world.record.sha256 } : {}), ...(clothPlan ? { cloth: p.handoff!.record.keypoints!.sha256 } : {}) });
867
1059
  const current = jobs.get(id);
868
1060
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
869
- if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1061
+ if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1062
+ guardRestore(id);
870
1063
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1064
+ await adopt(id);
1065
+ await unkeep(id);
871
1066
  const reused = await reuse<PatchJob>(dir, id);
872
- if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1067
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
873
1068
  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
1069
  ...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
875
1070
  ...(world ? { environment: world.record } : {}),
876
1071
  ...(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;
1072
+ jobs.set(id, job); running = id; bodies.set(id, body);
878
1073
  void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
879
1074
  return { status: 202, body: snapshot(job) };
880
1075
  }
881
1076
 
1077
+ /**
1078
+ * #173 · `POST /api/density-strip` with `{patches}`: 2–5 Mesh-panel patches for one part that differ only at its density
1079
+ * field (`src/density-strip.ts`). Each is checked as an Apply checks it (fresh config bytes, names, held files, the
1080
+ * installed producer) and runs through the same patch runner — remesh, the compile, #174's carried check — into its own
1081
+ * folder under `stripRoot`, at most `concurrency` at once. Nothing lands: no applied record, no stage, no undo entry.
1082
+ * A new strip replaces the folder of the one before it; refused while an Apply runs (and an Apply is refused while a
1083
+ * strip runs: `REBUILD_BUSY`).
1084
+ */
1085
+ async function startStrip(input: unknown): Promise<{ status: number; body: unknown }> {
1086
+ let patches: ConfigPatch[];
1087
+ try { patches = stripPatches(input).map(parseConfigPatch); }
1088
+ catch (e) { if (e instanceof ConfigPatchError || e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
1089
+ let shape: ReturnType<typeof stripShape>;
1090
+ try { shape = stripShape(patches); } catch (e) { if (e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
1091
+ assertStateOutside();
1092
+ await options.unchanged();
1093
+ const inputs = await producerInputs(p.handoff, p.handoffReason);
1094
+ const original = JSON.parse(inputs.configText) as JsonValue;
1095
+ const planned: { patch: ConfigPatch; patched: JsonValue }[] = [];
1096
+ for (const patch of patches) {
1097
+ let patched: JsonValue;
1098
+ try { assertPatchFresh(patch, inputs.configHash); assertOpNames(original, patch.ops); patched = applyConfigPatch(original, patch.ops); remeshPart(patch); }
1099
+ catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
1100
+ assertNamedFilesHeld(original, patched);
1101
+ planned.push({ patch, patched });
1102
+ }
1103
+ const resolved = resolveProducer(options.starts);
1104
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
1105
+ const first = patches[0]!;
1106
+ if (first.producer.name !== resolved.producer.package || first.producer.version !== resolved.producer.version) throw new RebuildError('EDIT_PRODUCER_MISMATCH', `the patch was made for ${first.producer.name} ${first.producer.version}; the installed producer is ${resolved.producer.package} ${resolved.producer.version}`);
1107
+ const producer = `${resolved.producer.package}@${resolved.producer.version}`, compiler = `${resolved.compiler.package}@${resolved.compiler.version}`;
1108
+ const id = stripId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patches, producer, compiler });
1109
+ if (strip?.id === id) return { status: strip.status === 'running' ? 202 : 200, body: stripView() };
1110
+ const refusal = busy();
1111
+ if (refusal) return { status: 200, body: { status: 'refused', error: refusal, producerLine: null } };
1112
+ // Bounded disk: the strip on screen is the only one this package keeps; its predecessor's folder goes first.
1113
+ const root = stripRoot(options.stateDir, p.sourceHash);
1114
+ await rm(root, { recursive: true, force: true });
1115
+ const concurrency = Math.max(1, Math.floor(options.stripConcurrency ?? defaultStripConcurrency()));
1116
+ const candidates: StripCandidate[] = planned.map(({ patch }, i) => {
1117
+ const cid = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer, compiler, op: 'remesh' });
1118
+ const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id: cid, status: 'running', reused: false, op: 'remesh', 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 };
1119
+ return { value: shape.values[i]!, id: cid, entry: shape.entries[i]!, dir: join(root, id, cid), job, applied: false };
1120
+ });
1121
+ const current = { id, status: 'running' as const, part: shape.part, field: shape.field, concurrency, configHash: inputs.configHash, producer: tool(resolved.producer), compiler: tool(resolved.compiler), started: performance.now(), wallMs: null, candidates };
1122
+ strip = current;
1123
+ // A bounded pool: each worker takes the next candidate in order; the strip is done when every candidate settled.
1124
+ let next = 0;
1125
+ const worker = async () => {
1126
+ for (let i = next++; i < candidates.length; i = next++) {
1127
+ const c = candidates[i]!;
1128
+ await executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true).catch(() => {});
1129
+ }
1130
+ };
1131
+ void Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker)).finally(() => {
1132
+ if (strip === current) { (current as { status: 'running' | 'done' }).status = 'done'; (current as { wallMs: number | null }).wallMs = Math.round(performance.now() - current.started); }
1133
+ });
1134
+ return { status: 202, body: stripView() };
1135
+ }
1136
+ function stripView(): DensityStripView | null {
1137
+ if (!strip) return null;
1138
+ const s = strip;
1139
+ const rows = s.candidates.map(c => candidateRow({ value: c.value, id: c.id, entry: c.entry, job: c.job, part: s.part, modelUrl: c.job.build ? `/density-strip/${s.id}/${c.id}/${c.job.build.model}` : null, applied: c.applied }));
1140
+ return { schema: DENSITY_STRIP_SCHEMA, id: s.id, status: s.status, part: s.part, field: s.field, concurrency: s.concurrency, configHash: s.configHash, producer: s.producer, compiler: s.compiler, wallMs: s.wallMs, rows, labels: s.status === 'done' ? stripLabels(rows) : { fewest: null, widest: null } };
1141
+ }
1142
+ /** `GET /api/density-strip/<id>` (and `GET /api/density-strip`: the strip on screen, or null). */
1143
+ function readStrip(id?: string): DensityStripView | null {
1144
+ if (id !== undefined && strip?.id !== id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', id);
1145
+ return stripView();
1146
+ }
1147
+ /** A candidate's job as it settled (its lines in full), for the row's log. */
1148
+ function stripJob(id: string, cid: string): PatchJob {
1149
+ const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
1150
+ if (!c) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}`);
1151
+ return snapshot(c.job);
1152
+ }
1153
+ /** `POST /api/density-strip/close` with `{id}`: the strip's folder is removed; refused while its candidates run. */
1154
+ async function closeStrip(input: unknown): Promise<{ status: number; body: unknown }> {
1155
+ const v = object(input, 'density strip close');
1156
+ if (Object.keys(v).join(',') !== 'id' || typeof v.id !== 'string' || !/^[a-f0-9]{64}$/.test(v.id)) throw new RebuildError('REBUILD_REQUEST_INVALID', 'expected exactly {id: <the strip id>}');
1157
+ if (strip?.id !== v.id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${v.id.slice(0, 12)}… is not the strip on screen`);
1158
+ if (strip.status === 'running') return { status: 200, body: { status: 'refused', error: `DENSITY_STRIP_BUSY: strip ${strip.id} is still running; close it when its candidates settle`, producerLine: null } };
1159
+ const applied = strip.candidates.filter(c => c.applied).length;
1160
+ strip = null;
1161
+ await rm(stripRoot(options.stateDir, p.sourceHash), { recursive: true, force: true });
1162
+ return { status: 200, body: { status: 'closed', id: v.id, applied } };
1163
+ }
1164
+ /** A served candidate file: only a built, not-applied candidate's hashed model and pages, read-only. */
1165
+ async function stripFile(id: string, cid: string, name: string): Promise<string> {
1166
+ const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
1167
+ if (!c || c.applied || c.job.status !== 'built' || !c.job.build || !Object.hasOwn(c.job.build.files, name)) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}/${name}`);
1168
+ return safeFile(await realpath(join(c.dir, 'build')), packagePath(name));
1169
+ }
1170
+ /**
1171
+ * #173: an Apply whose build id is a settled strip candidate's takes that candidate's build: the folder moves into the
1172
+ * scratch root, where the ordinary reuse reads and re-hashes it and lands it as any Apply lands. The row then reads
1173
+ * "applied" and is no longer served from the strip.
1174
+ */
1175
+ async function adopt(id: string): Promise<void> {
1176
+ const c = strip && strip.status === 'done' ? strip.candidates.find(x => x.id === id && !x.applied && x.job.status === 'built') : undefined;
1177
+ if (!c) return;
1178
+ const to = rebuildDir(options.stateDir, p.sourceHash, id);
1179
+ if (await exists(to)) return;
1180
+ await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
1181
+ await rename(c.dir, to);
1182
+ c.applied = true;
1183
+ }
1184
+
882
1185
  /** #135: the delta → `rig-parts remesh --delta mesh-edit.json --part <part>` → `<dir>/rig`; the tail adds the per-file diff. */
883
1186
  function executeDelta(job: DeltaJob, dir: string, inputs: ProducerInputs, resolved: Resolved, text: string): Promise<void> {
884
1187
  const producer = resolved.producer, part = job.request.part;
@@ -926,12 +1229,15 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
926
1229
  const id = deltaId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, deltaText: text, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
927
1230
  const current = jobs.get(id);
928
1231
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
929
- if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1232
+ if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1233
+ guardRestore(id);
930
1234
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1235
+ await adopt(id);
1236
+ await unkeep(id);
931
1237
  const reused = await reuse<DeltaJob>(dir, id);
932
- if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1238
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
933
1239
  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;
1240
+ jobs.set(id, job); running = id; bodies.set(id, input);
935
1241
  void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
936
1242
  return { status: 202, body: snapshot(job) };
937
1243
  }
@@ -1018,7 +1324,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1018
1324
  * still be the handoff's, the sequence a declared `rig-pose-sequence/1` reference, and `@rig-c/pose` installed. The
1019
1325
  * map's resolution against the rig, the flags' combinations and the pose are the producer's to judge, verbatim.
1020
1326
  */
1021
- async function startRetarget(input: unknown): Promise<{ status: number; body: unknown }> {
1327
+ async function startRetarget(input: unknown, body: unknown = { retarget: input }): Promise<{ status: number; body: unknown }> {
1022
1328
  let request: ReturnType<typeof parseRetargetRequest>;
1023
1329
  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
1330
  assertStateOutside();
@@ -1038,12 +1344,15 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1038
1344
  const id = retargetId({ sourceHash: p.sourceHash, configHash: inputs.configHash, record, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
1039
1345
  const current = jobs.get(id);
1040
1346
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1041
- if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1347
+ if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1348
+ guardRestore(id);
1042
1349
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1350
+ await adopt(id);
1351
+ await unkeep(id);
1043
1352
  const reused = await reuse<RetargetJob>(dir, id);
1044
- if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1353
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1045
1354
  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;
1355
+ jobs.set(id, job); running = id; bodies.set(id, body);
1047
1356
  void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
1048
1357
  return { status: 202, body: snapshot(job) };
1049
1358
  }
@@ -1052,7 +1361,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1052
1361
  async function start(input: unknown): Promise<{ status: number; body: unknown }> {
1053
1362
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'retarget')) {
1054
1363
  if (Object.keys(input).length !== 1) bad('expected exactly {retarget}');
1055
- return startRetarget((input as { retarget: unknown }).retarget);
1364
+ return startRetarget((input as { retarget: unknown }).retarget, input);
1056
1365
  }
1057
1366
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
1058
1367
  if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
@@ -1060,7 +1369,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1060
1369
  const keys = Object.keys(input).sort().join(',');
1061
1370
  if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
1062
1371
  const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
1063
- return startPatch((input as { patch: unknown }).patch, world);
1372
+ return startPatch((input as { patch: unknown }).patch, world, input);
1064
1373
  }
1065
1374
  const request = parseRebuildRequest(input, options.bones);
1066
1375
  assertStateOutside();
@@ -1073,12 +1382,15 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1073
1382
  const id = designationId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, entry, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
1074
1383
  const current = jobs.get(id);
1075
1384
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1076
- if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
1385
+ if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1386
+ guardRestore(id);
1077
1387
  const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1388
+ await adopt(id);
1389
+ await unkeep(id);
1078
1390
  const reused = await reuse(dir, id);
1079
- if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
1391
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1080
1392
  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;
1393
+ jobs.set(id, job); running = id; bodies.set(id, input);
1082
1394
  void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
1083
1395
  return { status: 202, body: snapshot(job) };
1084
1396
  }
@@ -1098,5 +1410,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1098
1410
  return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
1099
1411
  }
1100
1412
 
1101
- return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest };
1413
+ return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history, startStrip, readStrip, stripJob, closeStrip, stripFile };
1102
1414
  }