@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/README.md +15 -8
- package/docs/DESIGN.md +1 -1
- package/docs/REVIEW_PACKAGE.md +22 -8
- package/package.json +1 -1
- package/src/applied-blocks.ts +48 -0
- package/src/applied-context.ts +4 -3
- package/src/baseline-reach.ts +177 -0
- package/src/baseline.ts +14 -9
- package/src/cli.ts +2 -2
- package/src/density-strip.ts +201 -0
- package/src/front.ts +7 -0
- package/src/handoff.ts +59 -7
- package/src/manifest.ts +13 -3
- package/src/open.ts +12 -3
- package/src/rebuild.ts +354 -42
- package/src/retune-check.ts +48 -28
- package/src/server.ts +30 -3
- package/web/cloth-panel.ts +10 -2
- package/web/cuts-panel.ts +15 -4
- package/web/density-strip-panel.ts +89 -0
- package/web/index.html +1 -1
- package/web/main.ts +171 -13
- package/web/rebuild-client.ts +38 -8
- package/web/styles.css +2 -0
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 {
|
|
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 {
|
|
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
|
|
421
|
-
* revision's files by
|
|
422
|
-
* when the Apply checks under an exported world (
|
|
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
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
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 = (
|
|
433
|
-
push(`review: check not run again — ${
|
|
434
|
-
return { summary:
|
|
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
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
}
|