@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/README.md +5 -3
- package/docs/LIVE_FIXTURE.md +93 -0
- package/docs/REVIEW_PACKAGE.md +8 -3
- package/package.json +2 -1
- package/src/animation-selection.ts +2 -0
- package/src/applied-blocks.ts +14 -0
- package/src/baseline-reach.ts +12 -1
- package/src/bones-check.ts +3 -5
- package/src/cloth-recipe.ts +12 -8
- package/src/environment-check.ts +3 -5
- package/src/environment-schedule.ts +3 -1
- package/src/manifest.ts +6 -2
- package/src/open.ts +5 -3
- package/src/readings-run.ts +3 -5
- package/src/readings.ts +8 -0
- package/src/rebuild.ts +140 -80
- package/src/requirements-run.ts +3 -5
- package/src/requirements.ts +6 -2
- package/src/state-dir.ts +48 -0
- package/web/environment-world.ts +15 -0
- package/web/label-placement.ts +4 -0
- package/web/main.ts +51 -27
- package/web/rebuild-client.ts +27 -0
- package/web/retarget-panel.ts +7 -2
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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`;
|
|
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;
|
|
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;
|
|
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
|
-
|
|
898
|
+
outcome = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
|
|
851
899
|
} finally {
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
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
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
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
|
-
//
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
const
|
|
1118
|
-
const
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
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
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
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
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
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
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
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 {
|
package/src/requirements-run.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
|
package/src/requirements.ts
CHANGED
|
@@ -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
|
|
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;
|
package/src/state-dir.ts
ADDED
|
@@ -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
|
+
}
|
package/web/environment-world.ts
CHANGED
|
@@ -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
|
+
}
|
package/web/label-placement.ts
CHANGED
|
@@ -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};}
|