@rig-c/review 0.10.0 → 0.10.2

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
@@ -34,6 +34,7 @@ import { availableParallelism } from 'node:os';
34
34
  import { candidateRow, DENSITY_STRIP_SCHEMA, DensityStripError, stripLabels, stripPatches, stripShape, type DensityField, type StripRow } from './density-strip.ts';
35
35
  import { samplesClock } from './frame-clock.ts';
36
36
  import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
37
+ import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
37
38
 
38
39
  export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
39
40
  const MAX_REGIONS = CHEST_REGIONS_MAX, MAX_POINTS = 256, MAX_LINES = 4000, GRID = 256;
@@ -105,6 +106,8 @@ export function configCuts(configText: string): Record<string, unknown> {
105
106
  out.assemble = kept;
106
107
  }
107
108
  if (config.limbs !== undefined) out.limbs = config.limbs;
109
+ // #184: the Cuts panel must see the config's `regions`, or a new part's `add /regions {}` replaces every existing one.
110
+ if (config.regions !== undefined) out.regions = config.regions;
108
111
  return out;
109
112
  }
110
113
  /** #115: why a build-routed Apply cannot run here (the handoff declares no painting and full run), or null. */
@@ -486,16 +489,50 @@ async function opCarried(op: CarryingOp, spec: string, world: ApplyWorld | null,
486
489
  return { summary: carriedCheckSummary(carried, op) };
487
490
  }
488
491
 
492
+ /** #185: the most files, and the deepest nesting, a config-named run directory may hold to be hashed into the build inputs. */
493
+ export const MAX_NAMED_DIR_FILES = 4096, MAX_NAMED_DIR_DEPTH = 8;
494
+ /**
495
+ * #185: a config-named run directory (an alternate's `full` / `head`) as one digest: every regular file under it, at most
496
+ * `MAX_NAMED_DIR_DEPTH` levels down and `MAX_NAMED_DIR_FILES` in all, each resolved and held inside the package root and
497
+ * hashed, in path order. Over the bound refuses (`BUILD_INPUT_TOO_LARGE`) rather than hash part of it.
498
+ */
499
+ async function namedDirDigest(root: string, dir: string, field: string): Promise<string> {
500
+ const rows: [string, string][] = [];
501
+ const walk = async (at: string, depth: number): Promise<void> => {
502
+ const entries = (await readdir(at, { withFileTypes: true })).map(e => e.name).sort();
503
+ for (const name of entries) {
504
+ const lexical = join(at, name);
505
+ const real = await realpath(lexical);
506
+ const rel = relative(root, real);
507
+ if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${field} holds ${relative(dir, lexical).split(sep).join('/')}, which resolves outside the package root`);
508
+ const st = await lstat(real);
509
+ if (st.isDirectory()) {
510
+ if (depth >= MAX_NAMED_DIR_DEPTH) throw new RebuildError('BUILD_INPUT_TOO_LARGE', `config ${field} nests deeper than ${MAX_NAMED_DIR_DEPTH} levels`);
511
+ await walk(lexical, depth + 1);
512
+ } else if (st.isFile()) {
513
+ if (rows.length >= MAX_NAMED_DIR_FILES) throw new RebuildError('BUILD_INPUT_TOO_LARGE', `config ${field} holds more than ${MAX_NAMED_DIR_FILES} files`);
514
+ rows.push([relative(dir, lexical).split(sep).join('/'), sha256(await readFile(real))]);
515
+ }
516
+ }
517
+ };
518
+ await walk(dir, 1);
519
+ return sha256(JSON.stringify(rows));
520
+ }
521
+
489
522
  /** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
490
523
  export interface BuildPlan { source: string; full: string; head: string | null; inputsHash: string; inputs: AssembledView['inputs'] }
491
524
  /**
492
525
  * The build's inputs for a patch (`null` refusal line when the handoff declares no painting and full run): the painting
493
526
  * and the runs, and every config-relative file the patched config names for assemble, each resolved against the handoff
494
- * config's directory and held inside the handoff root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and hashed.
527
+ * config's directory and held inside the handoff root (`BUILD_INPUT_OUTSIDE_ROOT`, `BUILD_INPUT_MISSING`) and hashed — a named
528
+ * directory outside the declared runs by its contents (`namedDirDigest`, #185); a declared run is in the record already.
495
529
  */
496
530
  export async function buildPlan(handoff: Handoff, patched: JsonValue, show: (abs: string) => string): Promise<BuildPlan> {
497
531
  if (!handoff.source || !handoff.full) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', BUILD_SOURCE_UNAVAILABLE.slice('MODELING_INPUT_UNAVAILABLE: '.length));
498
532
  const root = await realpath(handoff.root), configDir = dirname(handoff.config), named: AssembledView['inputs']['named'] = [];
533
+ // The declared runs' files are already in the handoff record (and so in the hash below); any other named directory is hashed here.
534
+ const declaredRuns = new Set<string>();
535
+ for (const run of [handoff.full, handoff.head]) if (run) declaredRuns.add(await realpath(run).catch(() => resolve(run)));
499
536
  for (const f of buildNamedFiles(patched)) {
500
537
  if (isAbsolute(f.value)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${f.field} is an absolute path; the handoff config names its files relative to its own directory`);
501
538
  const lexical = resolve(configDir, f.value);
@@ -503,7 +540,7 @@ export async function buildPlan(handoff: Handoff, patched: JsonValue, show: (abs
503
540
  try { real = await realpath(lexical); } catch { throw new RebuildError('BUILD_INPUT_MISSING', `config ${f.field} names ${show(lexical)}, which does not exist`); }
504
541
  const rel = relative(root, real);
505
542
  if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${f.field} resolves outside the package root`);
506
- named.push({ field: f.field, path: show(real), sha256: f.kind === 'file' ? sha256(await readFile(real)) : null });
543
+ named.push({ field: f.field, path: show(real), sha256: f.kind === 'file' ? sha256(await readFile(real)) : declaredRuns.has(real) ? null : await namedDirDigest(root, real, f.field) });
507
544
  }
508
545
  const inputs = { source: handoff.record.source?.path ?? show(handoff.source), full: handoff.record.full ?? show(handoff.full), head: handoff.head ? handoff.record.head ?? show(handoff.head) : null, named };
509
546
  // The painting, the runs' hashed files (the handoff record's) and the named files: an identical build reuses its scratch build.
@@ -541,7 +578,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
541
578
  interface StripCandidate { value: number; id: string; entry: JsonValue; dir: string; job: PatchJob; applied: boolean }
542
579
  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
580
  /** 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);
581
+ /**
582
+ * #183: a workload that passed `busy()` and has not yet set `running` or `strip` (its awaits — adopt, unkeep, reuse, the
583
+ * strip's scratch removal — come between). Taken synchronously right after `busy()`, so no second workload passes in that
584
+ * window; released when the workload is set or refused. Holds the line `busy()` refuses others with.
585
+ */
586
+ let reserved: string | null = null;
587
+ const busy = (): string | null => (running ? `REBUILD_BUSY: rebuild ${running} is still running` : reserved ?? (strip?.status === 'running' ? `REBUILD_BUSY: density strip ${strip.id} is still running` : null));
588
+ /** #183: run `body` holding the reservation; `body` sets `running` / `strip` synchronously before it returns. */
589
+ async function reserve<T>(line: string, body: () => Promise<T>): Promise<T> {
590
+ reserved = line;
591
+ try { return await body(); } finally { if (reserved === line) reserved = null; }
592
+ }
545
593
  /** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
546
594
  const stack: AppliedEntry[] = [];
547
595
  const bodies = new Map<string, unknown>();
@@ -551,10 +599,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
551
599
 
552
600
  /** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
553
601
  function assertStateOutside(): void {
554
- for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
555
- const inside = relative(root, options.stateDir);
556
- if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
557
- }
602
+ assertStateDirOutside(options.stateDir, p, message => new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', message));
558
603
  }
559
604
  /** Once per session: the first time the handoff and the producer both resolve, the baseline starts (or is reused). */
560
605
  let baseline: BaselineResult | null = null, baselineRun: Promise<BaselineResult> | null = null;
@@ -793,6 +838,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
793
838
  async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
794
839
  const push = (line: string) => { if (job.lines.length < MAX_LINES) job.lines.push(line); };
795
840
  const producer = resolved.producer;
841
+ // #180: a refusal or failure is published only after its scratch is removed (as "built" is published last, #146),
842
+ // so a reader that sees the outcome never finds a folder mid-removal.
843
+ let outcome: RebuildStatus | null = null;
796
844
  try {
797
845
  await rm(dir, { recursive: true, force: true });
798
846
  await mkdir(dir, { recursive: true, mode: 0o700 });
@@ -803,7 +851,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
803
851
  // #148: the producer's line stays as printed; review adds what it means here, when it knows.
804
852
  const hint = producerHint(job.producerLine);
805
853
  if (hint && !('refusals' in job)) { (job as RebuildJob | PatchJob | DeltaJob).hint = hint; push(`review: ${hint}`); }
806
- job.status = 'refused'; return;
854
+ outcome = 'refused'; return;
807
855
  }
808
856
  job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
809
857
  const { pageEdges, packShape, note } = await op.flags(push);
@@ -818,9 +866,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
818
866
  push(`review: ${resolved.compiler.package} ${resolved.compiler.version} build --profile rig-play --pack --page-edges ${pageEdges} --pack-shape ${packShape}${note}`);
819
867
  const compile = await runTool(resolved.compiler.bin, compileArgs({ rig: `${op.spec}/rig.json`, motion: `${op.spec}/motion.json`, out: 'build', pageEdges, packShape }), { cwd: dir, onLine: push });
820
868
  job.wallMs.compile = compile.wallMs;
821
- if (compile.exitCode !== 0) { job.exitCode = compile.exitCode; job.producerLine = firstRefusal(compile.lines) ?? `rigc build exited ${compile.exitCode} without a named refusal`; job.status = 'refused'; return; }
869
+ if (compile.exitCode !== 0) { job.exitCode = compile.exitCode; job.producerLine = firstRefusal(compile.lines) ?? `rigc build exited ${compile.exitCode} without a named refusal`; outcome = 'refused'; return; }
822
870
  const carried = steps.check && steps.carried ? await steps.carried(push) : null;
823
- if (carried && 'refused' in carried) { job.producerLine = carried.refused; job.status = 'refused'; push(`review: ${carried.refused}`); return; }
871
+ if (carried && 'refused' in carried) { job.producerLine = carried.refused; outcome = 'refused'; push(`review: ${carried.refused}`); return; }
824
872
  if (steps.check && carried) steps.check(carried.summary, null);
825
873
  else if (steps.check) {
826
874
  // check measures the scratch rig; a FAIL is its judgment, recorded beside the build, not a refusal of the build.
@@ -838,7 +886,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
838
886
  if (steps.hashed) await steps.hashed();
839
887
  // #166: an undo's re-run must reproduce the build its record names, file for file; otherwise it is refused, not guessed.
840
888
  const changed = steps.candidate ? null : restoredDifference(job.id, job.build.files);
841
- if (changed) { job.producerLine = changed; job.status = 'refused'; push(`review: ${changed}`); return; }
889
+ if (changed) { job.producerLine = changed; outcome = 'refused'; push(`review: ${changed}`); return; }
842
890
  if (!steps.candidate) latestId = job.id;
843
891
  await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
844
892
  // Each Apply replaces the previous scratch build of this package in the scratch root; #166 keeps the applied records'
@@ -847,11 +895,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
847
895
  // "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
848
896
  job.status = 'built';
849
897
  } catch (error) {
850
- job.status = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
898
+ outcome = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
851
899
  } finally {
852
- if (restoring?.id === job.id && job.status !== 'built') restoring = null;
853
- // Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind.
854
- if (job.status !== 'built') await rm(dir, { recursive: true, force: true }).catch(() => {});
900
+ const built = job.status === 'built';
901
+ if (restoring?.id === job.id && !built) restoring = null;
902
+ // Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind — removed before it is published.
903
+ if (!built) { await rm(dir, { recursive: true, force: true }).catch(() => {}); job.status = outcome ?? 'failed'; }
855
904
  }
856
905
  }
857
906
 
@@ -1059,19 +1108,21 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1059
1108
  const current = jobs.get(id);
1060
1109
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1061
1110
  if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1062
- guardRestore(id);
1063
- const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1064
- await adopt(id);
1065
- await unkeep(id);
1066
- const reused = await reuse<PatchJob>(dir, id);
1067
- if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
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,
1069
- ...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
1070
- ...(world ? { environment: world.record } : {}),
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 } } } : {}) };
1072
- jobs.set(id, job); running = id; bodies.set(id, body);
1073
- void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
1074
- return { status: 202, body: snapshot(job) };
1111
+ return reserve(`REBUILD_BUSY: rebuild ${id} is still running`, async () => {
1112
+ guardRestore(id);
1113
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1114
+ await adopt(id);
1115
+ await unkeep(id);
1116
+ const reused = await reuse<PatchJob>(dir, id);
1117
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1118
+ 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,
1119
+ ...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
1120
+ ...(world ? { environment: world.record } : {}),
1121
+ ...(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 } } } : {}) };
1122
+ jobs.set(id, job); running = id; bodies.set(id, body);
1123
+ void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
1124
+ return { status: 202, body: snapshot(job) };
1125
+ });
1075
1126
  }
1076
1127
 
1077
1128
  /**
@@ -1109,29 +1160,32 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1109
1160
  if (strip?.id === id) return { status: strip.status === 'running' ? 202 : 200, body: stripView() };
1110
1161
  const refusal = busy();
1111
1162
  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); }
1163
+ // #183: own the workload before the first await below; `strip = current` takes over before the reservation ends.
1164
+ return reserve(`REBUILD_BUSY: density strip ${id} is still running`, async () => {
1165
+ // Bounded disk: the strip on screen is the only one this package keeps; its predecessor's folder goes first.
1166
+ const root = stripRoot(options.stateDir, p.sourceHash);
1167
+ await rm(root, { recursive: true, force: true });
1168
+ const concurrency = Math.max(1, Math.floor(options.stripConcurrency ?? defaultStripConcurrency()));
1169
+ const candidates: StripCandidate[] = planned.map(({ patch }, i) => {
1170
+ const cid = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer, compiler, op: 'remesh' });
1171
+ 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 };
1172
+ return { value: shape.values[i]!, id: cid, entry: shape.entries[i]!, dir: join(root, id, cid), job, applied: false };
1173
+ });
1174
+ 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 };
1175
+ strip = current;
1176
+ // A bounded pool: each worker takes the next candidate in order; the strip is done when every candidate settled.
1177
+ let next = 0;
1178
+ const worker = async () => {
1179
+ for (let i = next++; i < candidates.length; i = next++) {
1180
+ const c = candidates[i]!;
1181
+ await executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true).catch(() => {});
1182
+ }
1183
+ };
1184
+ void Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker)).finally(() => {
1185
+ if (strip === current) { (current as { status: 'running' | 'done' }).status = 'done'; (current as { wallMs: number | null }).wallMs = Math.round(performance.now() - current.started); }
1186
+ });
1187
+ return { status: 202, body: stripView() };
1133
1188
  });
1134
- return { status: 202, body: stripView() };
1135
1189
  }
1136
1190
  function stripView(): DensityStripView | null {
1137
1191
  if (!strip) return null;
@@ -1230,16 +1284,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1230
1284
  const current = jobs.get(id);
1231
1285
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1232
1286
  if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1233
- guardRestore(id);
1234
- const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1235
- await adopt(id);
1236
- await unkeep(id);
1237
- const reused = await reuse<DeltaJob>(dir, id);
1238
- if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
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 };
1240
- jobs.set(id, job); running = id; bodies.set(id, input);
1241
- void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
1242
- return { status: 202, body: snapshot(job) };
1287
+ return reserve(`REBUILD_BUSY: rebuild ${id} is still running`, async () => {
1288
+ guardRestore(id);
1289
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1290
+ await adopt(id);
1291
+ await unkeep(id);
1292
+ const reused = await reuse<DeltaJob>(dir, id);
1293
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1294
+ 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 };
1295
+ jobs.set(id, job); running = id; bodies.set(id, input);
1296
+ void executeDelta(job, dir, inputs, resolved, text).finally(() => { if (running === id) running = null; });
1297
+ return { status: 202, body: snapshot(job) };
1298
+ });
1243
1299
  }
1244
1300
 
1245
1301
  /** The package's declared `rig-pose-sequence/1` references (validated at load), with whether clean's record is on them. */
@@ -1345,16 +1401,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1345
1401
  const current = jobs.get(id);
1346
1402
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1347
1403
  if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1348
- guardRestore(id);
1349
- const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1350
- await adopt(id);
1351
- await unkeep(id);
1352
- const reused = await reuse<RetargetJob>(dir, id);
1353
- if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
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 };
1355
- jobs.set(id, job); running = id; bodies.set(id, body);
1356
- void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
1357
- return { status: 202, body: snapshot(job) };
1404
+ return reserve(`REBUILD_BUSY: rebuild ${id} is still running`, async () => {
1405
+ guardRestore(id);
1406
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1407
+ await adopt(id);
1408
+ await unkeep(id);
1409
+ const reused = await reuse<RetargetJob>(dir, id);
1410
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1411
+ 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 };
1412
+ jobs.set(id, job); running = id; bodies.set(id, body);
1413
+ void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
1414
+ return { status: 202, body: snapshot(job) };
1415
+ });
1358
1416
  }
1359
1417
 
1360
1418
  /** `POST /api/rebuild`: refusals are returned before anything is written or run. */
@@ -1383,16 +1441,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
1383
1441
  const current = jobs.get(id);
1384
1442
  if (current?.status === 'running') return { status: 202, body: snapshot(current) };
1385
1443
  if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
1386
- guardRestore(id);
1387
- const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1388
- await adopt(id);
1389
- await unkeep(id);
1390
- const reused = await reuse(dir, id);
1391
- if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
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 };
1393
- jobs.set(id, job); running = id; bodies.set(id, input);
1394
- void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
1395
- return { status: 202, body: snapshot(job) };
1444
+ return reserve(`REBUILD_BUSY: rebuild ${id} is still running`, async () => {
1445
+ guardRestore(id);
1446
+ const dir = rebuildDir(options.stateDir, p.sourceHash, id);
1447
+ await adopt(id);
1448
+ await unkeep(id);
1449
+ const reused = await reuse(dir, id);
1450
+ if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
1451
+ 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 };
1452
+ jobs.set(id, job); running = id; bodies.set(id, input);
1453
+ void execute(job, dir, inputs, resolved, entry).finally(() => { if (running === id) running = null; });
1454
+ return { status: 202, body: snapshot(job) };
1455
+ });
1396
1456
  }
1397
1457
 
1398
1458
  function read(id: string): RebuildJob | PatchJob | DeltaJob | RetargetJob {
@@ -9,12 +9,13 @@
9
9
  * The client sends typed requirements only — never a path or a flag. Nothing is written into the package.
10
10
  */
11
11
  import { createHash } from 'node:crypto';
12
- import { isAbsolute, join, relative } from 'node:path';
12
+ import { join } from 'node:path';
13
13
  import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
14
14
  import type { ReviewPackage } from './manifest.ts';
15
15
  import { resolveProducer, runTool } from './producer.ts';
16
16
  import { producerInputs, RebuildError } from './rebuild.ts';
17
17
  import { parseRequirementsRequest, readRequirements, requirementsCheckArgs, requirementsDocument, requirementsFileText, RequirementsError, REQUIREMENTS_RUN_SCHEMA, type RequirementRow, type RequirementsBuild } from './requirements.ts';
18
+ import { assertStateOutside as assertStateDirOutside } from './state-dir.ts';
18
19
 
19
20
  const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
20
21
  const MAX_LINES = 4000;
@@ -89,10 +90,7 @@ export function createRequirementsCheck(p: ReviewPackage, options: RequirementsO
89
90
  let running: string | null = null;
90
91
  const snapshot = (job: RequirementsJob): RequirementsJob => JSON.parse(JSON.stringify(job)) as RequirementsJob;
91
92
  function assertStateOutside(): void {
92
- for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
93
- const inside = relative(root, options.stateDir);
94
- if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new RequirementsError('REQUIREMENTS_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
95
- }
93
+ assertStateDirOutside(options.stateDir, p, message => new RequirementsError('REQUIREMENTS_STATE_INSIDE_PACKAGE', message));
96
94
  }
97
95
  const asRequirements = (e: unknown): never => { if (e instanceof RebuildError) throw new RequirementsError(e.code, e.message.slice(e.code.length + 2)); throw e; };
98
96
 
@@ -134,8 +134,12 @@ export function rowOf(name: string, line: Record<string, unknown>, printed: stri
134
134
  // The producer prints the bar as `range_degrees: [lo, hi]`.
135
135
  const bar = Array.isArray(line.range_degrees) ? line.range_degrees : [line.lo_degrees, line.hi_degrees];
136
136
  const lo = num(bar[0]), hi = num(bar[1]);
137
- // The end that breaks its bar, else the end with less room to its bar.
138
- const lowSide = least !== null && lo !== null && (least < lo || (greatest === null || hi === null || least - lo < hi - greatest));
137
+ // The end that breaks its bar by more (either may, or both), else the end with less room to its bar.
138
+ const lowBreak = least !== null && lo !== null && least < lo ? lo - least : 0;
139
+ const highBreak = greatest !== null && hi !== null && greatest > hi ? greatest - hi : 0;
140
+ const lowSide = least !== null && lo !== null && (lowBreak > 0 || highBreak > 0
141
+ ? lowBreak >= highBreak
142
+ : greatest === null || hi === null || least - lo < hi - greatest);
139
143
  worst = worstFrame(lowSide ? line.least_at : line.greatest_at) ?? worstFrame(line.least_at);
140
144
  } else if (kind === 'follow') { measured = num(line.fraction_measured); worst = worstFrame(line.largest_residual_at) ?? worstFrame(line.largest_drive_at); }
141
145
  const reason = typeof line.reason === 'string' ? line.reason : null;
@@ -0,0 +1,48 @@
1
+ /**
2
+ * #187: where review state may live. The state directory (`RIG_REVIEW_STATE_DIR` or the default) is judged by its
3
+ * canonical location — the realpath of its longest existing prefix plus the not-yet-created remainder — so a symlink
4
+ * (the directory itself or any ancestor) that lands inside a supplied folder is refused, not followed. It may not sit
5
+ * inside the rig-parts build root the check package belongs to (opening `out` redirects the package to `out/check`;
6
+ * the build is still the supplied folder), the package root, or the producer handoff's root.
7
+ */
8
+ import { realpathSync } from 'node:fs';
9
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
10
+
11
+ export type StateRootKind = 'build root' | 'package' | 'handoff root';
12
+ export interface StateRoot { kind: StateRootKind; path: string }
13
+ /** The fields of a loaded package that name supplied folders. */
14
+ export interface StateRootsSource { root: string; buildRoot?: string; handoff?: { root: string } | null }
15
+
16
+ const missing = (e: unknown) => ['ENOENT', 'ENOTDIR'].includes((e as NodeJS.ErrnoException).code ?? '');
17
+
18
+ /** The canonical location of `path`: realpath of the longest existing prefix joined with the remainder. */
19
+ export function canonicalStatePath(path: string): string {
20
+ const absolute = resolve(path), rest: string[] = [];
21
+ for (let current = absolute; ; current = dirname(current)) {
22
+ try { const real = realpathSync(current); return rest.length ? join(real, ...rest.reverse()) : real; }
23
+ catch (e) { if (!missing(e) || dirname(current) === current) throw e; rest.push(basename(current)); }
24
+ }
25
+ }
26
+
27
+ /** The supplied folders of one package, innermost first (a refusal names the first that holds the state). */
28
+ export function stateRoots(p: StateRootsSource): StateRoot[] {
29
+ return [{ kind: 'package', path: p.root }, ...(p.buildRoot ? [{ kind: 'build root' as const, path: p.buildRoot }] : []), ...(p.handoff ? [{ kind: 'handoff root' as const, path: p.handoff.root }] : [])];
30
+ }
31
+
32
+ const within = (root: string, path: string) => { const rel = relative(root, path); return rel === '' || rel !== '..' && !rel.startsWith('..' + sep) && !isAbsolute(rel); };
33
+
34
+ /** The first supplied folder the state directory sits inside (both sides canonical), or null when it is outside all. */
35
+ export function stateDirInside(stateDir: string, roots: readonly StateRoot[]): StateRoot | null {
36
+ const state = canonicalStatePath(stateDir);
37
+ for (const root of roots) if (within(canonicalStatePath(root.path), state)) return root;
38
+ return null;
39
+ }
40
+
41
+ /** The refusal text after the code: which supplied folder holds the state directory. */
42
+ export const stateInsideMessage = (root: StateRoot) => `the review state directory is inside the inspected ${root.kind}; set RIG_REVIEW_STATE_DIR outside it`;
43
+
44
+ /** Throws `make(code-less message)` when the state directory sits inside one of the package's supplied folders. */
45
+ export function assertStateOutside(stateDir: string, p: StateRootsSource, make: (message: string, root: StateRoot) => Error): void {
46
+ const root = stateDirInside(stateDir, stateRoots(p));
47
+ if (root) throw make(stateInsideMessage(root), root);
48
+ }
@@ -40,3 +40,18 @@ export function environmentCheckRows(job: EnvironmentCheckView, defaultLine: (na
40
40
  if (job.check?.line) rows.push({ kind: 'label', text: `this world · ${job.check.line}` });
41
41
  return rows;
42
42
  }
43
+
44
+ /**
45
+ * #183: a move of the drawn clip, as the page makes it — another animation chosen, a seek (scrub, cue, reading, control),
46
+ * a replay from the end (Play after the clip ended, idle repeat's loop), or an Apply's reload, which keeps the animation
47
+ * and time and resumes the world it had.
48
+ */
49
+ export type ClipMove = { kind: 'choose' } | { kind: 'seek'; to: number } | { kind: 'replay' } | { kind: 'reload' };
50
+ /**
51
+ * Whether the move restarts the clip and so, while the schedule is watched (Use for readings), the wind clock restarts
52
+ * with it: the exported schedule starts at t = 0 on the clip's first frame, so the stage must too.
53
+ */
54
+ export function restartsWindClock(move: ClipMove, watched: boolean): boolean {
55
+ if (!watched) return false;
56
+ return move.kind === 'choose' || move.kind === 'replay' || (move.kind === 'seek' && move.to <= 0);
57
+ }
@@ -6,6 +6,8 @@
6
6
  * tries its home box (right of the point, rising from it), then the box left of the point, then both sides shifted up
7
7
  * and down by one row at a time; it takes the first box that intersects no box already placed nor an obstacle (the point
8
8
  * glyphs) and stays inside the bounds. A label with no free box within `rows` is hidden; a pinned label is never hidden and falls back to its home box.
9
+ * With bounds, a label whose point is outside them (a joint panned or zoomed off the canvas, its glyph clipped) is not
10
+ * placed at all, pinned or not (#186): a tag and leader for a joint that is not drawn would be a phantom annotation.
9
11
  */
10
12
  export interface LabelPoint {x:number;y:number}
11
13
  export interface LabelInput {at:LabelPoint;width:number;pinned?:boolean}
@@ -34,9 +36,11 @@ export function labelCandidates(input:LabelInput,options:LabelPlacementOptions={
34
36
  export function placeLabels(inputs:readonly LabelInput[],options:LabelPlacementOptions={}):PlacedLabel[]{
35
37
  const order=inputs.map((_,i)=>i).sort((a,b)=>Number(!!inputs[b]!.pinned)-Number(!!inputs[a]!.pinned)||a-b);
36
38
  const inside=(b:LabelBox)=>!options.bounds||(b.x>=0&&b.y>=0&&b.x+b.w<=options.bounds.width&&b.y+b.h<=options.bounds.height);
39
+ const onCanvas=(p:LabelPoint)=>!options.bounds||(p.x>=0&&p.y>=0&&p.x<=options.bounds.width&&p.y<=options.bounds.height);
37
40
  const placed:LabelBox[]=[...(options.obstacles??[])],out:PlacedLabel[]=new Array(inputs.length);
38
41
  for(const i of order){
39
42
  const input=inputs[i]!,candidates=labelCandidates(input,options),home=candidates[0]!;
43
+ if(!onCanvas(input.at)){out[i]={box:home,visible:false,moved:false};continue;}
40
44
  const index=candidates.findIndex(b=>inside(b)&&!placed.some(p=>boxesIntersect(b,p)));
41
45
  if(index>=0){const box=candidates[index]!;placed.push(box);out[i]={box,visible:true,moved:index>0};}
42
46
  else if(input.pinned){placed.push(home);out[i]={box:home,visible:true,moved:false};}