@rig-c/review 0.10.1 → 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 +126 -72
- 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;
|
|
@@ -488,16 +489,50 @@ async function opCarried(op: CarryingOp, spec: string, world: ApplyWorld | null,
|
|
|
488
489
|
return { summary: carriedCheckSummary(carried, op) };
|
|
489
490
|
}
|
|
490
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
|
+
|
|
491
522
|
/** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
|
|
492
523
|
export interface BuildPlan { source: string; full: string; head: string | null; inputsHash: string; inputs: AssembledView['inputs'] }
|
|
493
524
|
/**
|
|
494
525
|
* The build's inputs for a patch (`null` refusal line when the handoff declares no painting and full run): the painting
|
|
495
526
|
* and the runs, and every config-relative file the patched config names for assemble, each resolved against the handoff
|
|
496
|
-
* 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.
|
|
497
529
|
*/
|
|
498
530
|
export async function buildPlan(handoff: Handoff, patched: JsonValue, show: (abs: string) => string): Promise<BuildPlan> {
|
|
499
531
|
if (!handoff.source || !handoff.full) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', BUILD_SOURCE_UNAVAILABLE.slice('MODELING_INPUT_UNAVAILABLE: '.length));
|
|
500
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)));
|
|
501
536
|
for (const f of buildNamedFiles(patched)) {
|
|
502
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`);
|
|
503
538
|
const lexical = resolve(configDir, f.value);
|
|
@@ -505,7 +540,7 @@ export async function buildPlan(handoff: Handoff, patched: JsonValue, show: (abs
|
|
|
505
540
|
try { real = await realpath(lexical); } catch { throw new RebuildError('BUILD_INPUT_MISSING', `config ${f.field} names ${show(lexical)}, which does not exist`); }
|
|
506
541
|
const rel = relative(root, real);
|
|
507
542
|
if (rel === '' || rel.startsWith('..') || isAbsolute(rel)) throw new RebuildError('BUILD_INPUT_OUTSIDE_ROOT', `config ${f.field} resolves outside the package root`);
|
|
508
|
-
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) });
|
|
509
544
|
}
|
|
510
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 };
|
|
511
546
|
// The painting, the runs' hashed files (the handoff record's) and the named files: an identical build reuses its scratch build.
|
|
@@ -543,7 +578,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
543
578
|
interface StripCandidate { value: number; id: string; entry: JsonValue; dir: string; job: PatchJob; applied: boolean }
|
|
544
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;
|
|
545
580
|
/** One producer workload at a time per review: an Apply (or undo's re-run) or a strip; the line refusing the other. */
|
|
546
|
-
|
|
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
|
+
}
|
|
547
593
|
/** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
|
|
548
594
|
const stack: AppliedEntry[] = [];
|
|
549
595
|
const bodies = new Map<string, unknown>();
|
|
@@ -553,10 +599,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
553
599
|
|
|
554
600
|
/** The state directory may not sit inside the package or the handoff's root (the parent of an opened out/). */
|
|
555
601
|
function assertStateOutside(): void {
|
|
556
|
-
|
|
557
|
-
const inside = relative(root, options.stateDir);
|
|
558
|
-
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');
|
|
559
|
-
}
|
|
602
|
+
assertStateDirOutside(options.stateDir, p, message => new RebuildError('REBUILD_STATE_INSIDE_PACKAGE', message));
|
|
560
603
|
}
|
|
561
604
|
/** Once per session: the first time the handoff and the producer both resolve, the baseline starts (or is reused). */
|
|
562
605
|
let baseline: BaselineResult | null = null, baselineRun: Promise<BaselineResult> | null = null;
|
|
@@ -1065,19 +1108,21 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1065
1108
|
const current = jobs.get(id);
|
|
1066
1109
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1067
1110
|
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
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
|
+
});
|
|
1081
1126
|
}
|
|
1082
1127
|
|
|
1083
1128
|
/**
|
|
@@ -1115,29 +1160,32 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1115
1160
|
if (strip?.id === id) return { status: strip.status === 'running' ? 202 : 200, body: stripView() };
|
|
1116
1161
|
const refusal = busy();
|
|
1117
1162
|
if (refusal) return { status: 200, body: { status: 'refused', error: refusal, producerLine: null } };
|
|
1118
|
-
//
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
const
|
|
1124
|
-
const
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
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() };
|
|
1139
1188
|
});
|
|
1140
|
-
return { status: 202, body: stripView() };
|
|
1141
1189
|
}
|
|
1142
1190
|
function stripView(): DensityStripView | null {
|
|
1143
1191
|
if (!strip) return null;
|
|
@@ -1236,16 +1284,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1236
1284
|
const current = jobs.get(id);
|
|
1237
1285
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1238
1286
|
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
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
|
+
});
|
|
1249
1299
|
}
|
|
1250
1300
|
|
|
1251
1301
|
/** The package's declared `rig-pose-sequence/1` references (validated at load), with whether clean's record is on them. */
|
|
@@ -1351,16 +1401,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1351
1401
|
const current = jobs.get(id);
|
|
1352
1402
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1353
1403
|
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
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
|
+
});
|
|
1364
1416
|
}
|
|
1365
1417
|
|
|
1366
1418
|
/** `POST /api/rebuild`: refusals are returned before anything is written or run. */
|
|
@@ -1389,16 +1441,18 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1389
1441
|
const current = jobs.get(id);
|
|
1390
1442
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1391
1443
|
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
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
|
+
});
|
|
1402
1456
|
}
|
|
1403
1457
|
|
|
1404
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};}
|