@rig-c/review 0.9.0 → 0.10.1
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 +1 -1
- package/docs/REVIEW_PACKAGE.md +11 -3
- package/package.json +1 -1
- package/src/applied-blocks.ts +48 -0
- package/src/applied-context.ts +4 -3
- package/src/density-strip.ts +201 -0
- package/src/front.ts +4 -0
- package/src/rebuild.ts +188 -36
- package/src/retune-check.ts +48 -28
- package/src/server.ts +23 -3
- package/web/cloth-panel.ts +10 -2
- package/web/cuts-panel.ts +15 -4
- package/web/density-strip-panel.ts +89 -0
- package/web/index.html +1 -1
- package/web/main.ts +114 -12
- package/web/rebuild-client.ts +2 -2
- package/web/styles.css +1 -0
package/src/rebuild.ts
CHANGED
|
@@ -25,11 +25,13 @@ import { bustMeshEntry, deriveBust, parseBustSpec, BustSpecError, type BustSpec
|
|
|
25
25
|
import { modelMeshTriangles, remeshCounts, type RemeshCounts } from './mesh-entry.ts';
|
|
26
26
|
import { patchRoute } from './retune-route.ts';
|
|
27
27
|
import { producerHint } from './baseline-reach.ts';
|
|
28
|
-
import { carriedCheck,
|
|
28
|
+
import { carriedCheck, carriedCheckSummary, type CarryingOp, provenanceFile, REMESH_CHECK_SINCE, RETUNE_CHECK_SINCE, revisionBinding } from './retune-check.ts';
|
|
29
29
|
import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, MAX_BUILD_READOUT, needsBuild, newBuildFile, recompositeFigures, type RecompositeFigures } from './build-route.ts';
|
|
30
30
|
import { jsonDiff } from './config-patch.ts';
|
|
31
31
|
import { CLOTH_KEYPOINTS_UNDECLARED, CLOTH_PATCH_MIXED, CLOTH_SOURCE_UNAVAILABLE, CLOTH_WRITE_SINCE, clothLinks, clothPatchKind, readClothTargets, writtenDiff, type ClothTagView, type WrittenDiff } from './cloth-recipe.ts';
|
|
32
32
|
import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
|
|
33
|
+
import { availableParallelism } from 'node:os';
|
|
34
|
+
import { candidateRow, DENSITY_STRIP_SCHEMA, DensityStripError, stripLabels, stripPatches, stripShape, type DensityField, type StripRow } from './density-strip.ts';
|
|
33
35
|
import { samplesClock } from './frame-clock.ts';
|
|
34
36
|
import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
|
|
35
37
|
|
|
@@ -103,6 +105,8 @@ export function configCuts(configText: string): Record<string, unknown> {
|
|
|
103
105
|
out.assemble = kept;
|
|
104
106
|
}
|
|
105
107
|
if (config.limbs !== undefined) out.limbs = config.limbs;
|
|
108
|
+
// #184: the Cuts panel must see the config's `regions`, or a new part's `add /regions {}` replaces every existing one.
|
|
109
|
+
if (config.regions !== undefined) out.regions = config.regions;
|
|
106
110
|
return out;
|
|
107
111
|
}
|
|
108
112
|
/** #115: why a build-routed Apply cannot run here (the handoff declares no painting and full run), or null. */
|
|
@@ -400,7 +404,24 @@ export function assertNamedFilesHeld(before: unknown, after: unknown): void {
|
|
|
400
404
|
/** The value of a flag in the patch's args, or the producer's default. */
|
|
401
405
|
const flagValue = (args: readonly string[], flag: string, fallback: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1]! : fallback; };
|
|
402
406
|
|
|
403
|
-
export interface RebuildOptions {
|
|
407
|
+
export interface RebuildOptions {
|
|
408
|
+
stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null;
|
|
409
|
+
/** #173: how many strip candidates run at once (default max(1, floor(cores / 2))). */
|
|
410
|
+
stripConcurrency?: number;
|
|
411
|
+
}
|
|
412
|
+
/** #173: a density strip's scratch — one folder per package, holding at most the one strip on screen. */
|
|
413
|
+
export const stripRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'density-strip', sourceHash);
|
|
414
|
+
export const defaultStripConcurrency = () => Math.max(1, Math.floor(availableParallelism() / 2));
|
|
415
|
+
/** Deterministic: the package, the config bytes, every candidate's patch bytes in order and both tool versions. */
|
|
416
|
+
export function stripId(input: { sourceHash: string; configHash: string; patches: readonly ConfigPatch[]; producer: string; compiler: string }): string {
|
|
417
|
+
return sha256(JSON.stringify([DENSITY_STRIP_SCHEMA, input.sourceHash, input.configHash, input.patches.map(patchText), input.producer, input.compiler]));
|
|
418
|
+
}
|
|
419
|
+
/** `GET /api/density-strip/<id>`: the strip, one row per candidate in the request's order, and the two labels. */
|
|
420
|
+
export interface DensityStripView {
|
|
421
|
+
schema: typeof DENSITY_STRIP_SCHEMA; id: string; status: 'running' | 'done'; part: string; field: DensityField;
|
|
422
|
+
concurrency: number; configHash: string; producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
423
|
+
wallMs: number | null; rows: StripRow[]; labels: { fewest: number | null; widest: number | null };
|
|
424
|
+
}
|
|
404
425
|
export const baselineDir = (stateDir: string, key: string) => join(stateDir, 'baseline', key);
|
|
405
426
|
|
|
406
427
|
const tool = (t: InstalledTool) => ({ package: t.package, version: t.version });
|
|
@@ -431,7 +452,7 @@ interface ApplySteps {
|
|
|
431
452
|
/** #102: check the scratch rig after the compile. */
|
|
432
453
|
check?: (summary: CheckSummary, wallMs: number | null) => void;
|
|
433
454
|
/**
|
|
434
|
-
* #139: the check the producer op already ran on the spec it wrote, read instead of running check again: a summary
|
|
455
|
+
* #139 / #174: the check the producer op (retune, remesh) already ran on the spec it wrote, read instead of running check again: a summary
|
|
435
456
|
* (recorded through `check`, no wall clock), a refusal line (nothing recorded, the build refused), or null — the op
|
|
436
457
|
* carries none, so check runs as for any other Apply.
|
|
437
458
|
*/
|
|
@@ -440,24 +461,31 @@ interface ApplySteps {
|
|
|
440
461
|
hashed?: () => Promise<void>;
|
|
441
462
|
/** #112: the schedule check reads (`--environment environment.json`, written into the scratch directory first). */
|
|
442
463
|
world?: ApplyWorld | null;
|
|
464
|
+
/**
|
|
465
|
+
* #173: a density strip's candidate — built and hashed and its result.json written exactly as an Apply's, but it does
|
|
466
|
+
* not land: no applied record, no stage, no scratch-root arrangement. Its build waits in the strip's own folder.
|
|
467
|
+
*/
|
|
468
|
+
candidate?: boolean;
|
|
443
469
|
}
|
|
444
470
|
|
|
445
471
|
/**
|
|
446
|
-
* #139: the check `rig-parts retune` ran on the revision it wrote into `spec`, as review's check
|
|
447
|
-
* revision's files by
|
|
448
|
-
* when the Apply checks under an exported world (
|
|
449
|
-
* `line` / `status` (a producer before `RETUNE_CHECK_SINCE`
|
|
472
|
+
* #139 / #174: the check `rig-parts retune` (or `remesh`) ran on the revision it wrote into `spec`, as review's check
|
|
473
|
+
* summary, bound to the revision's files by the provenance's hash rows (`RETUNE_CHECK_STALE` / `REMESH_CHECK_STALE`
|
|
474
|
+
* refuses the Apply). Null — check runs as before — when the Apply checks under an exported world (the op's check read
|
|
475
|
+
* the default one) or the provenance carries no `line` / `status` (a producer before `RETUNE_CHECK_SINCE` /
|
|
476
|
+
* `REMESH_CHECK_SINCE`, both 1.49.0).
|
|
450
477
|
*/
|
|
451
|
-
async function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
478
|
+
async function opCarried(op: CarryingOp, spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
|
|
479
|
+
const file = provenanceFile(op);
|
|
480
|
+
if (world) { push(`review: ${op}'s own check read the default world and this Apply checks under the exported schedule, so check runs on the revision`); return null; }
|
|
481
|
+
const provenance = JSON.parse(await readFile(join(spec, file), 'utf8')) as unknown;
|
|
482
|
+
const carried = carriedCheck(provenance, op);
|
|
483
|
+
if (!carried) { push(`review: ${file} carries no check line or block statuses (@rig-c/parts before ${op === 'retune' ? RETUNE_CHECK_SINCE : REMESH_CHECK_SINCE}), so check runs on the revision`); return null; }
|
|
484
|
+
const stale = await revisionBinding(spec, provenance, op);
|
|
457
485
|
if (stale) return { refused: stale };
|
|
458
|
-
const rows = (
|
|
459
|
-
push(`review: check not run again — ${
|
|
460
|
-
return { summary:
|
|
486
|
+
const rows = (provenance as { revision: unknown[] }).revision.length;
|
|
487
|
+
push(`review: check not run again — ${file} carries ${op}'s check of this revision, bound to it by ${rows} revision hash row(s): ${carried.line}`);
|
|
488
|
+
return { summary: carriedCheckSummary(carried, op) };
|
|
461
489
|
}
|
|
462
490
|
|
|
463
491
|
/** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
|
|
@@ -511,6 +539,11 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
|
|
|
511
539
|
export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
512
540
|
const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
|
|
513
541
|
let running: string | null = null;
|
|
542
|
+
/** #173: the density strip on screen (at most one per review): its candidates run beside each other, none of them applied. */
|
|
543
|
+
interface StripCandidate { value: number; id: string; entry: JsonValue; dir: string; job: PatchJob; applied: boolean }
|
|
544
|
+
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
|
+
/** One producer workload at a time per review: an Apply (or undo's re-run) or a strip; the line refusing the other. */
|
|
546
|
+
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);
|
|
514
547
|
/** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
|
|
515
548
|
const stack: AppliedEntry[] = [];
|
|
516
549
|
const bodies = new Map<string, unknown>();
|
|
@@ -694,7 +727,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
694
727
|
const v = object(input, 'undo');
|
|
695
728
|
if (Object.keys(v).join(',') !== 'from' || typeof v.from !== 'string' || !/^[a-f0-9]{64}$/.test(v.from)) bad('expected exactly {from: <the build id on the stage>}');
|
|
696
729
|
assertStateOutside();
|
|
697
|
-
if (
|
|
730
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
698
731
|
const top = stack.at(-1);
|
|
699
732
|
if (!top) throw new RebuildError('UNDO_NOTHING_APPLIED', 'no Apply is on the stage; the inspected build is');
|
|
700
733
|
if (top.id !== v.from) throw new RebuildError('UNDO_STALE', `the stage's last Apply is ${top.id.slice(0, 12)}…, not ${String(v.from).slice(0, 12)}…; reload`);
|
|
@@ -762,6 +795,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
762
795
|
async function apply(job: RebuildJob | PatchJob | DeltaJob | RetargetJob, dir: string, inputs: ProducerInputs, resolved: Resolved, op: ProducerOp, steps: ApplySteps = {}): Promise<void> {
|
|
763
796
|
const push = (line: string) => { if (job.lines.length < MAX_LINES) job.lines.push(line); };
|
|
764
797
|
const producer = resolved.producer;
|
|
798
|
+
// #180: a refusal or failure is published only after its scratch is removed (as "built" is published last, #146),
|
|
799
|
+
// so a reader that sees the outcome never finds a folder mid-removal.
|
|
800
|
+
let outcome: RebuildStatus | null = null;
|
|
765
801
|
try {
|
|
766
802
|
await rm(dir, { recursive: true, force: true });
|
|
767
803
|
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
@@ -772,7 +808,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
772
808
|
// #148: the producer's line stays as printed; review adds what it means here, when it knows.
|
|
773
809
|
const hint = producerHint(job.producerLine);
|
|
774
810
|
if (hint && !('refusals' in job)) { (job as RebuildJob | PatchJob | DeltaJob).hint = hint; push(`review: ${hint}`); }
|
|
775
|
-
|
|
811
|
+
outcome = 'refused'; return;
|
|
776
812
|
}
|
|
777
813
|
job.producerLine = run.stdout.filter(l => l.trim()).at(-1)?.trim() ?? null;
|
|
778
814
|
const { pageEdges, packShape, note } = await op.flags(push);
|
|
@@ -787,9 +823,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
787
823
|
push(`review: ${resolved.compiler.package} ${resolved.compiler.version} build --profile rig-play --pack --page-edges ${pageEdges} --pack-shape ${packShape}${note}`);
|
|
788
824
|
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 });
|
|
789
825
|
job.wallMs.compile = compile.wallMs;
|
|
790
|
-
if (compile.exitCode !== 0) { job.exitCode = compile.exitCode; job.producerLine = firstRefusal(compile.lines) ?? `rigc build exited ${compile.exitCode} without a named refusal`;
|
|
826
|
+
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; }
|
|
791
827
|
const carried = steps.check && steps.carried ? await steps.carried(push) : null;
|
|
792
|
-
if (carried && 'refused' in carried) { job.producerLine = carried.refused;
|
|
828
|
+
if (carried && 'refused' in carried) { job.producerLine = carried.refused; outcome = 'refused'; push(`review: ${carried.refused}`); return; }
|
|
793
829
|
if (steps.check && carried) steps.check(carried.summary, null);
|
|
794
830
|
else if (steps.check) {
|
|
795
831
|
// check measures the scratch rig; a FAIL is its judgment, recorded beside the build, not a refusal of the build.
|
|
@@ -806,21 +842,22 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
806
842
|
job.build = await hashBuild(join(dir, 'build'), job.id);
|
|
807
843
|
if (steps.hashed) await steps.hashed();
|
|
808
844
|
// #166: an undo's re-run must reproduce the build its record names, file for file; otherwise it is refused, not guessed.
|
|
809
|
-
const changed = restoredDifference(job.id, job.build.files);
|
|
810
|
-
if (changed) { job.producerLine = changed;
|
|
811
|
-
latestId = job.id;
|
|
845
|
+
const changed = steps.candidate ? null : restoredDifference(job.id, job.build.files);
|
|
846
|
+
if (changed) { job.producerLine = changed; outcome = 'refused'; push(`review: ${changed}`); return; }
|
|
847
|
+
if (!steps.candidate) latestId = job.id;
|
|
812
848
|
await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
|
|
813
849
|
// Each Apply replaces the previous scratch build of this package in the scratch root; #166 keeps the applied records'
|
|
814
|
-
// earlier builds beside it (keptRoot) for undo.
|
|
815
|
-
await landed({ ...job, status: 'built' });
|
|
850
|
+
// earlier builds beside it (keptRoot) for undo. #173: a strip candidate lands nowhere until its row is Applied.
|
|
851
|
+
if (!steps.candidate) await landed({ ...job, status: 'built' });
|
|
816
852
|
// "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
|
|
817
853
|
job.status = 'built';
|
|
818
854
|
} catch (error) {
|
|
819
|
-
|
|
855
|
+
outcome = 'failed'; job.producerLine = error instanceof Error ? error.message : String(error); push(`review: ${job.producerLine}`);
|
|
820
856
|
} finally {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
857
|
+
const built = job.status === 'built';
|
|
858
|
+
if (restoring?.id === job.id && !built) restoring = null;
|
|
859
|
+
// Only a built Apply keeps its scratch directory; a refusal or failure leaves nothing behind — removed before it is published.
|
|
860
|
+
if (!built) { await rm(dir, { recursive: true, force: true }).catch(() => {}); job.status = outcome ?? 'failed'; }
|
|
824
861
|
}
|
|
825
862
|
}
|
|
826
863
|
|
|
@@ -845,7 +882,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
845
882
|
* #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
|
|
846
883
|
* inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
|
|
847
884
|
*/
|
|
848
|
-
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null): Promise<void> {
|
|
885
|
+
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null, candidate = false): Promise<void> {
|
|
849
886
|
const args = job.request.patch.op.args, producer = resolved.producer;
|
|
850
887
|
let counts: RemeshCounts | null = null;
|
|
851
888
|
let built: ProcessResult | null = null;
|
|
@@ -957,9 +994,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
957
994
|
...(building ? {} : { check: (summary: CheckSummary, wallMs: number | null) => { job.wallMs.check = wallMs; job.check = summary; } }),
|
|
958
995
|
// #139: a retune Apply reads the check retune ran on its revision (retune.json, bound by its hash rows) unless the
|
|
959
996
|
// Apply checks under an exported world (retune's check reads the default one) or the producer carries no line.
|
|
960
|
-
|
|
997
|
+
// #174: a remesh patch the same, from remesh.json — remesh checked the revision with the request's pack flags,
|
|
998
|
+
// the ones `flags` reads for review's own check, and the same `--parts`; a patch passes no `--requirements`.
|
|
999
|
+
...(job.op === 'retune' || job.op === 'remesh' ? { carried: (push: (line: string) => void) => opCarried(job.op as CarryingOp, join(dir, 'rig'), world, push) } : {}),
|
|
961
1000
|
hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
|
|
962
1001
|
world,
|
|
1002
|
+
...(candidate ? { candidate: true } : {}),
|
|
963
1003
|
});
|
|
964
1004
|
}
|
|
965
1005
|
|
|
@@ -1024,9 +1064,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1024
1064
|
const id = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}`, op, ...(plan ? { buildInputs: plan.inputsHash } : {}), ...(world ? { environment: world.record.sha256 } : {}), ...(clothPlan ? { cloth: p.handoff!.record.keypoints!.sha256 } : {}) });
|
|
1025
1065
|
const current = jobs.get(id);
|
|
1026
1066
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1027
|
-
if (
|
|
1067
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1028
1068
|
guardRestore(id);
|
|
1029
1069
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1070
|
+
await adopt(id);
|
|
1030
1071
|
await unkeep(id);
|
|
1031
1072
|
const reused = await reuse<PatchJob>(dir, id);
|
|
1032
1073
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1039,6 +1080,114 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1039
1080
|
return { status: 202, body: snapshot(job) };
|
|
1040
1081
|
}
|
|
1041
1082
|
|
|
1083
|
+
/**
|
|
1084
|
+
* #173 · `POST /api/density-strip` with `{patches}`: 2–5 Mesh-panel patches for one part that differ only at its density
|
|
1085
|
+
* field (`src/density-strip.ts`). Each is checked as an Apply checks it (fresh config bytes, names, held files, the
|
|
1086
|
+
* installed producer) and runs through the same patch runner — remesh, the compile, #174's carried check — into its own
|
|
1087
|
+
* folder under `stripRoot`, at most `concurrency` at once. Nothing lands: no applied record, no stage, no undo entry.
|
|
1088
|
+
* A new strip replaces the folder of the one before it; refused while an Apply runs (and an Apply is refused while a
|
|
1089
|
+
* strip runs: `REBUILD_BUSY`).
|
|
1090
|
+
*/
|
|
1091
|
+
async function startStrip(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1092
|
+
let patches: ConfigPatch[];
|
|
1093
|
+
try { patches = stripPatches(input).map(parseConfigPatch); }
|
|
1094
|
+
catch (e) { if (e instanceof ConfigPatchError || e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1095
|
+
let shape: ReturnType<typeof stripShape>;
|
|
1096
|
+
try { shape = stripShape(patches); } catch (e) { if (e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1097
|
+
assertStateOutside();
|
|
1098
|
+
await options.unchanged();
|
|
1099
|
+
const inputs = await producerInputs(p.handoff, p.handoffReason);
|
|
1100
|
+
const original = JSON.parse(inputs.configText) as JsonValue;
|
|
1101
|
+
const planned: { patch: ConfigPatch; patched: JsonValue }[] = [];
|
|
1102
|
+
for (const patch of patches) {
|
|
1103
|
+
let patched: JsonValue;
|
|
1104
|
+
try { assertPatchFresh(patch, inputs.configHash); assertOpNames(original, patch.ops); patched = applyConfigPatch(original, patch.ops); remeshPart(patch); }
|
|
1105
|
+
catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1106
|
+
assertNamedFilesHeld(original, patched);
|
|
1107
|
+
planned.push({ patch, patched });
|
|
1108
|
+
}
|
|
1109
|
+
const resolved = resolveProducer(options.starts);
|
|
1110
|
+
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
|
|
1111
|
+
const first = patches[0]!;
|
|
1112
|
+
if (first.producer.name !== resolved.producer.package || first.producer.version !== resolved.producer.version) throw new RebuildError('EDIT_PRODUCER_MISMATCH', `the patch was made for ${first.producer.name} ${first.producer.version}; the installed producer is ${resolved.producer.package} ${resolved.producer.version}`);
|
|
1113
|
+
const producer = `${resolved.producer.package}@${resolved.producer.version}`, compiler = `${resolved.compiler.package}@${resolved.compiler.version}`;
|
|
1114
|
+
const id = stripId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patches, producer, compiler });
|
|
1115
|
+
if (strip?.id === id) return { status: strip.status === 'running' ? 202 : 200, body: stripView() };
|
|
1116
|
+
const refusal = busy();
|
|
1117
|
+
if (refusal) return { status: 200, body: { status: 'refused', error: refusal, producerLine: null } };
|
|
1118
|
+
// Bounded disk: the strip on screen is the only one this package keeps; its predecessor's folder goes first.
|
|
1119
|
+
const root = stripRoot(options.stateDir, p.sourceHash);
|
|
1120
|
+
await rm(root, { recursive: true, force: true });
|
|
1121
|
+
const concurrency = Math.max(1, Math.floor(options.stripConcurrency ?? defaultStripConcurrency()));
|
|
1122
|
+
const candidates: StripCandidate[] = planned.map(({ patch }, i) => {
|
|
1123
|
+
const cid = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer, compiler, op: 'remesh' });
|
|
1124
|
+
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 };
|
|
1125
|
+
return { value: shape.values[i]!, id: cid, entry: shape.entries[i]!, dir: join(root, id, cid), job, applied: false };
|
|
1126
|
+
});
|
|
1127
|
+
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 };
|
|
1128
|
+
strip = current;
|
|
1129
|
+
// A bounded pool: each worker takes the next candidate in order; the strip is done when every candidate settled.
|
|
1130
|
+
let next = 0;
|
|
1131
|
+
const worker = async () => {
|
|
1132
|
+
for (let i = next++; i < candidates.length; i = next++) {
|
|
1133
|
+
const c = candidates[i]!;
|
|
1134
|
+
await executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true).catch(() => {});
|
|
1135
|
+
}
|
|
1136
|
+
};
|
|
1137
|
+
void Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker)).finally(() => {
|
|
1138
|
+
if (strip === current) { (current as { status: 'running' | 'done' }).status = 'done'; (current as { wallMs: number | null }).wallMs = Math.round(performance.now() - current.started); }
|
|
1139
|
+
});
|
|
1140
|
+
return { status: 202, body: stripView() };
|
|
1141
|
+
}
|
|
1142
|
+
function stripView(): DensityStripView | null {
|
|
1143
|
+
if (!strip) return null;
|
|
1144
|
+
const s = strip;
|
|
1145
|
+
const rows = s.candidates.map(c => candidateRow({ value: c.value, id: c.id, entry: c.entry, job: c.job, part: s.part, modelUrl: c.job.build ? `/density-strip/${s.id}/${c.id}/${c.job.build.model}` : null, applied: c.applied }));
|
|
1146
|
+
return { schema: DENSITY_STRIP_SCHEMA, id: s.id, status: s.status, part: s.part, field: s.field, concurrency: s.concurrency, configHash: s.configHash, producer: s.producer, compiler: s.compiler, wallMs: s.wallMs, rows, labels: s.status === 'done' ? stripLabels(rows) : { fewest: null, widest: null } };
|
|
1147
|
+
}
|
|
1148
|
+
/** `GET /api/density-strip/<id>` (and `GET /api/density-strip`: the strip on screen, or null). */
|
|
1149
|
+
function readStrip(id?: string): DensityStripView | null {
|
|
1150
|
+
if (id !== undefined && strip?.id !== id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', id);
|
|
1151
|
+
return stripView();
|
|
1152
|
+
}
|
|
1153
|
+
/** A candidate's job as it settled (its lines in full), for the row's log. */
|
|
1154
|
+
function stripJob(id: string, cid: string): PatchJob {
|
|
1155
|
+
const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
|
|
1156
|
+
if (!c) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}`);
|
|
1157
|
+
return snapshot(c.job);
|
|
1158
|
+
}
|
|
1159
|
+
/** `POST /api/density-strip/close` with `{id}`: the strip's folder is removed; refused while its candidates run. */
|
|
1160
|
+
async function closeStrip(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1161
|
+
const v = object(input, 'density strip close');
|
|
1162
|
+
if (Object.keys(v).join(',') !== 'id' || typeof v.id !== 'string' || !/^[a-f0-9]{64}$/.test(v.id)) throw new RebuildError('REBUILD_REQUEST_INVALID', 'expected exactly {id: <the strip id>}');
|
|
1163
|
+
if (strip?.id !== v.id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${v.id.slice(0, 12)}… is not the strip on screen`);
|
|
1164
|
+
if (strip.status === 'running') return { status: 200, body: { status: 'refused', error: `DENSITY_STRIP_BUSY: strip ${strip.id} is still running; close it when its candidates settle`, producerLine: null } };
|
|
1165
|
+
const applied = strip.candidates.filter(c => c.applied).length;
|
|
1166
|
+
strip = null;
|
|
1167
|
+
await rm(stripRoot(options.stateDir, p.sourceHash), { recursive: true, force: true });
|
|
1168
|
+
return { status: 200, body: { status: 'closed', id: v.id, applied } };
|
|
1169
|
+
}
|
|
1170
|
+
/** A served candidate file: only a built, not-applied candidate's hashed model and pages, read-only. */
|
|
1171
|
+
async function stripFile(id: string, cid: string, name: string): Promise<string> {
|
|
1172
|
+
const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
|
|
1173
|
+
if (!c || c.applied || c.job.status !== 'built' || !c.job.build || !Object.hasOwn(c.job.build.files, name)) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}/${name}`);
|
|
1174
|
+
return safeFile(await realpath(join(c.dir, 'build')), packagePath(name));
|
|
1175
|
+
}
|
|
1176
|
+
/**
|
|
1177
|
+
* #173: an Apply whose build id is a settled strip candidate's takes that candidate's build: the folder moves into the
|
|
1178
|
+
* scratch root, where the ordinary reuse reads and re-hashes it and lands it as any Apply lands. The row then reads
|
|
1179
|
+
* "applied" and is no longer served from the strip.
|
|
1180
|
+
*/
|
|
1181
|
+
async function adopt(id: string): Promise<void> {
|
|
1182
|
+
const c = strip && strip.status === 'done' ? strip.candidates.find(x => x.id === id && !x.applied && x.job.status === 'built') : undefined;
|
|
1183
|
+
if (!c) return;
|
|
1184
|
+
const to = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1185
|
+
if (await exists(to)) return;
|
|
1186
|
+
await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
|
|
1187
|
+
await rename(c.dir, to);
|
|
1188
|
+
c.applied = true;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1042
1191
|
/** #135: the delta → `rig-parts remesh --delta mesh-edit.json --part <part>` → `<dir>/rig`; the tail adds the per-file diff. */
|
|
1043
1192
|
function executeDelta(job: DeltaJob, dir: string, inputs: ProducerInputs, resolved: Resolved, text: string): Promise<void> {
|
|
1044
1193
|
const producer = resolved.producer, part = job.request.part;
|
|
@@ -1086,9 +1235,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1086
1235
|
const id = deltaId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, deltaText: text, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1087
1236
|
const current = jobs.get(id);
|
|
1088
1237
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1089
|
-
if (
|
|
1238
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1090
1239
|
guardRestore(id);
|
|
1091
1240
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1241
|
+
await adopt(id);
|
|
1092
1242
|
await unkeep(id);
|
|
1093
1243
|
const reused = await reuse<DeltaJob>(dir, id);
|
|
1094
1244
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1200,9 +1350,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1200
1350
|
const id = retargetId({ sourceHash: p.sourceHash, configHash: inputs.configHash, record, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1201
1351
|
const current = jobs.get(id);
|
|
1202
1352
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1203
|
-
if (
|
|
1353
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1204
1354
|
guardRestore(id);
|
|
1205
1355
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1356
|
+
await adopt(id);
|
|
1206
1357
|
await unkeep(id);
|
|
1207
1358
|
const reused = await reuse<RetargetJob>(dir, id);
|
|
1208
1359
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1237,9 +1388,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1237
1388
|
const id = designationId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, entry, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1238
1389
|
const current = jobs.get(id);
|
|
1239
1390
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1240
|
-
if (
|
|
1391
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1241
1392
|
guardRestore(id);
|
|
1242
1393
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1394
|
+
await adopt(id);
|
|
1243
1395
|
await unkeep(id);
|
|
1244
1396
|
const reused = await reuse(dir, id);
|
|
1245
1397
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1264,5 +1416,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1264
1416
|
return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
|
|
1265
1417
|
}
|
|
1266
1418
|
|
|
1267
|
-
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history };
|
|
1419
|
+
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history, startStrip, readStrip, stripJob, closeStrip, stripFile };
|
|
1268
1420
|
}
|
package/src/retune-check.ts
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* #139: a retune Apply reads the check retune already ran on its revision instead of running `rig-parts check` a second
|
|
3
|
-
* time. `rig-parts retune`
|
|
4
|
-
* and
|
|
5
|
-
*
|
|
6
|
-
* check
|
|
7
|
-
*
|
|
8
|
-
*
|
|
3
|
+
* time; #174: a remesh Apply reads remesh's check the same way. `rig-parts retune` and `rig-parts remesh` gate the revision
|
|
4
|
+
* and check it (`checkStage` over the staged revision, the same `--parts` and pack flags review passes) before they
|
|
5
|
+
* write their provenance; from `@rig-c/parts` 1.49.0 (parts#541, its CHANGELOG: "remesh,retune: the provenance carries
|
|
6
|
+
* check's final line and every block's status, as check itself reports") each check side also carries `line` — check's
|
|
7
|
+
* own last line, composed by the one function check prints it from — and `status` — every block's status as that
|
|
8
|
+
* tree's `check.json` records it, in the file's order. The writers (`src/retune.ts`, `src/remesh.ts`):
|
|
9
|
+
* `revision: hashRows(revision)` and `check: { ...sides, scope }` with
|
|
9
10
|
* `sides = { input: checkSide(inCheck), revision: checkSide(revCheck) }`.
|
|
10
11
|
*
|
|
11
12
|
* The carried side is bound to the files by the `revision` hash rows: every row's file in the scratch revision must
|
|
12
|
-
* hash to its row, none missing and none extra (`RETUNE_CHECK_STALE`). A
|
|
13
|
-
* older producer) is not refused: the caller falls back to running check. Pure but for the
|
|
13
|
+
* hash to its row, none missing and none extra (`RETUNE_CHECK_STALE` / `REMESH_CHECK_STALE`). A provenance without
|
|
14
|
+
* `line` and `status` (an older producer) is not refused: the caller falls back to running check. Pure but for the
|
|
15
|
+
* binding's reads.
|
|
14
16
|
*/
|
|
15
17
|
import { createHash } from 'node:crypto';
|
|
16
18
|
import { readdir, readFile } from 'node:fs/promises';
|
|
@@ -19,9 +21,21 @@ import { CHECK_SUMMARY_SCHEMA, type CheckSummary } from './applied-context.ts';
|
|
|
19
21
|
|
|
20
22
|
/** The first `@rig-c/parts` release whose `retune.json` check sides carry `line` and `status` (CHANGELOG 1.49.0, parts#541). */
|
|
21
23
|
export const RETUNE_CHECK_SINCE = '1.49.0';
|
|
24
|
+
/** The same release for `remesh.json` (one change carried both, parts#541). */
|
|
25
|
+
export const REMESH_CHECK_SINCE = '1.49.0';
|
|
22
26
|
/** retune's provenance file, at the revision's root (parts `RETUNE_FILE`). */
|
|
23
27
|
export const RETUNE_FILE = 'retune.json';
|
|
28
|
+
/** remesh's provenance file, at the revision's root (parts `PROVENANCE_FILE`). */
|
|
29
|
+
export const REMESH_FILE = 'remesh.json';
|
|
24
30
|
export const RETUNE_CHECK_STALE = 'RETUNE_CHECK_STALE';
|
|
31
|
+
export const REMESH_CHECK_STALE = 'REMESH_CHECK_STALE';
|
|
32
|
+
|
|
33
|
+
/** The producer ops whose provenance carries the check they ran on their revision. */
|
|
34
|
+
export type CarryingOp = 'retune' | 'remesh';
|
|
35
|
+
const FILE: Record<CarryingOp, string> = { retune: RETUNE_FILE, remesh: REMESH_FILE };
|
|
36
|
+
const STALE: Record<CarryingOp, string> = { retune: RETUNE_CHECK_STALE, remesh: REMESH_CHECK_STALE };
|
|
37
|
+
/** The op's provenance file name. */
|
|
38
|
+
export const provenanceFile = (op: CarryingOp): string => FILE[op];
|
|
25
39
|
|
|
26
40
|
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
27
41
|
/** check.json's bar keys as review's `checkSummary` reads them: an upper-case name. */
|
|
@@ -36,16 +50,17 @@ export interface CarriedCheck { pass: boolean; line: string; status: Record<stri
|
|
|
36
50
|
* {@link RETUNE_CHECK_SINCE}). A side with one of them and not the other, or with a malformed one, throws: that is not
|
|
37
51
|
* an older producer and not a check review can record.
|
|
38
52
|
*/
|
|
39
|
-
export function carriedCheck(
|
|
40
|
-
const
|
|
53
|
+
export function carriedCheck(provenance: unknown, op: CarryingOp = 'retune'): CarriedCheck | null {
|
|
54
|
+
const file = FILE[op];
|
|
55
|
+
const side = isObject(provenance) && isObject(provenance.check) ? provenance.check.revision : undefined;
|
|
41
56
|
// No side at all carries neither field either: check runs, as for an older producer.
|
|
42
57
|
if (!isObject(side)) return null;
|
|
43
58
|
const hasLine = Object.hasOwn(side, 'line'), hasStatus = Object.hasOwn(side, 'status');
|
|
44
59
|
if (!hasLine && !hasStatus) return null;
|
|
45
|
-
if (typeof side.line !== 'string' || !/^check: (PASS|FAIL)/.test(side.line)) throw new Error(`${
|
|
46
|
-
if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${
|
|
47
|
-
if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${
|
|
48
|
-
if (typeof side.pass !== 'boolean') throw new Error(`${
|
|
60
|
+
if (typeof side.line !== 'string' || !/^check: (PASS|FAIL)/.test(side.line)) throw new Error(`${file}: check.revision.line is not check's last line`);
|
|
61
|
+
if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${file}: check.revision.status is not {block: status}`);
|
|
62
|
+
if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${file}: check.revision.status.gate is not PASS or FAIL`);
|
|
63
|
+
if (typeof side.pass !== 'boolean') throw new Error(`${file}: check.revision.pass is not a boolean`);
|
|
49
64
|
return { pass: side.pass, line: side.line, status: side.status as Record<string, string> };
|
|
50
65
|
}
|
|
51
66
|
|
|
@@ -55,19 +70,22 @@ export function carriedCheck(retune: unknown): CarriedCheck | null {
|
|
|
55
70
|
* check` exits `r.figures.PASS ? EXIT_OK : EXIT_REFUSED`); `line` ← `line` (check's last line, byte for byte);
|
|
56
71
|
* `bars` ← every upper-case entry of `status` (check.json's top-level blocks with a `status`, in the file's order; the
|
|
57
72
|
* figure-read `gate`, `loop` and `seam` are lower-case and are not bars in check.json); `gate` ← `status.gate` is
|
|
58
|
-
* `PASS` (`gate_spine_html_green === true`, the one way `barsNotMet` leaves `gate` out). `source` says
|
|
73
|
+
* `PASS` (`gate_spine_html_green === true`, the one way `barsNotMet` leaves `gate` out). `source` says which op's check it is.
|
|
59
74
|
*/
|
|
60
|
-
export function
|
|
75
|
+
export function carriedCheckSummary(carried: CarriedCheck, source: CarryingOp): CheckSummary {
|
|
61
76
|
const bars: Record<string, string> = {};
|
|
62
77
|
for (const [name, status] of Object.entries(carried.status)) if (BAR.test(name)) bars[name] = status;
|
|
63
|
-
return { schema: CHECK_SUMMARY_SCHEMA, pass: carried.pass, exitCode: carried.pass ? 0 : 1, line: carried.line, bars, gate: carried.status.gate === 'PASS', source
|
|
78
|
+
return { schema: CHECK_SUMMARY_SCHEMA, pass: carried.pass, exitCode: carried.pass ? 0 : 1, line: carried.line, bars, gate: carried.status.gate === 'PASS', source };
|
|
64
79
|
}
|
|
65
80
|
|
|
81
|
+
/** #139's name for {@link carriedCheckSummary} of a retune. */
|
|
82
|
+
export const retuneCheckSummary = (carried: CarriedCheck): CheckSummary => carriedCheckSummary(carried, 'retune');
|
|
83
|
+
|
|
66
84
|
/** The `revision` hash rows, or a refusal when they are not rows. */
|
|
67
|
-
function revisionRows(
|
|
68
|
-
const rows = isObject(
|
|
69
|
-
if (!Array.isArray(rows) || rows.length === 0) return `${
|
|
70
|
-
for (const r of rows) if (!isObject(r) || typeof r.path !== 'string' || !r.path || typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) return `${
|
|
85
|
+
function revisionRows(provenance: unknown, op: CarryingOp): RetuneRevisionRow[] | string {
|
|
86
|
+
const rows = isObject(provenance) ? provenance.revision : undefined;
|
|
87
|
+
if (!Array.isArray(rows) || rows.length === 0) return `${STALE[op]}: ${FILE[op]} names no revision hash rows; its check is bound to no files`;
|
|
88
|
+
for (const r of rows) if (!isObject(r) || typeof r.path !== 'string' || !r.path || typeof r.sha256 !== 'string' || !/^[0-9a-f]{64}$/.test(r.sha256)) return `${STALE[op]}: ${FILE[op]} has a revision row that is not {path, bytes, sha256}`;
|
|
71
89
|
return rows as RetuneRevisionRow[];
|
|
72
90
|
}
|
|
73
91
|
|
|
@@ -80,21 +98,23 @@ async function files(root: string): Promise<string[]> {
|
|
|
80
98
|
|
|
81
99
|
/**
|
|
82
100
|
* The binding: every `revision` row's file under `dir` hashes to its row, none is missing, and `dir` holds no file
|
|
83
|
-
* but those rows and
|
|
84
|
-
* `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`
|
|
101
|
+
* but those rows and the op's provenance file. Null when bound; otherwise the first mismatch as
|
|
102
|
+
* `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`REMESH_CHECK_STALE` … `remesh.json` for a remesh;
|
|
103
|
+
* `absent` on the side that has no such file).
|
|
85
104
|
*/
|
|
86
|
-
export async function revisionBinding(dir: string,
|
|
87
|
-
const rows = revisionRows(
|
|
105
|
+
export async function revisionBinding(dir: string, provenance: unknown, op: CarryingOp = 'retune'): Promise<string | null> {
|
|
106
|
+
const rows = revisionRows(provenance, op), file = FILE[op];
|
|
107
|
+
const stale = (path: string, recorded: string, actual: string) => `${STALE[op]}: ${path} sha256 ${recorded} in ${file} … ${actual} on disk; ${op}'s check is not this revision's, so no build is recorded`;
|
|
88
108
|
if (typeof rows === 'string') return rows;
|
|
89
109
|
const onDisk = await files(dir), named = new Set(rows.map(r => r.path));
|
|
90
110
|
for (const row of rows) {
|
|
91
111
|
let actual = 'absent';
|
|
92
112
|
if (onDisk.includes(row.path)) actual = createHash('sha256').update(await readFile(join(dir, ...row.path.split('/')))).digest('hex');
|
|
93
|
-
if (actual !== row.sha256) return
|
|
113
|
+
if (actual !== row.sha256) return stale(row.path, row.sha256, actual);
|
|
94
114
|
}
|
|
95
|
-
for (const path of onDisk) if (path !==
|
|
115
|
+
for (const path of onDisk) if (path !== file && !named.has(path)) {
|
|
96
116
|
const actual = createHash('sha256').update(await readFile(join(dir, ...path.split('/')))).digest('hex');
|
|
97
|
-
return
|
|
117
|
+
return stale(path, 'absent', actual);
|
|
98
118
|
}
|
|
99
119
|
return null;
|
|
100
120
|
}
|
package/src/server.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {assertReviewUnchanged,referenceBinding,selectedReference,safeSelectedRef
|
|
|
7
7
|
import {openGate,staleOpenStatus,accessGate,openNonceGate,stampOpen,type OpenIdentity} from './open-protocol.ts';
|
|
8
8
|
import { readSession, saveSession } from './session.ts';
|
|
9
9
|
import { createRebuilds, RebuildError } from './rebuild.ts';
|
|
10
|
+
import { DENSITY_STRIP_LIMIT } from './density-strip.ts';
|
|
10
11
|
import { createReadings } from './readings-run.ts';
|
|
11
12
|
import { ReadingsError } from './readings.ts';
|
|
12
13
|
import { BONES_CHECK_LIMIT, createBonesCheck } from './bones-check.ts';
|
|
@@ -17,7 +18,7 @@ import { homedir } from 'node:os';
|
|
|
17
18
|
import { join } from 'node:path';
|
|
18
19
|
const headers={ 'X-Content-Type-Options':'nosniff','Cache-Control':'no-store','Content-Security-Policy':"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' blob: data:; media-src 'self'; connect-src 'self'; font-src 'self'; base-uri 'none'; frame-ancestors 'none'; form-action 'none'",'Referrer-Policy':'no-referrer'};
|
|
19
20
|
/** Request shapes the page or an edit card got wrong: 400. Everything else a refusal names is an outcome (200). */
|
|
20
|
-
const MALFORMED=new Set(['REBUILD_REQUEST_INVALID','EDIT_PATCH_INVALID','EDIT_PATCH_OP_UNKNOWN','EDIT_PATCH_POINTER_INVALID','EDIT_PATCH_CMD_UNKNOWN','EDIT_PATCH_ARG_UNKNOWN','BONES_OP_OUT_OF_SCOPE','RETARGET_REQUEST_INVALID']);
|
|
21
|
+
const MALFORMED=new Set(['REBUILD_REQUEST_INVALID','DENSITY_STRIP_REQUEST_INVALID','DENSITY_STRIP_FIELD','MESH_PATCH_SCOPE','EDIT_PATCH_INVALID','EDIT_PATCH_OP_UNKNOWN','EDIT_PATCH_POINTER_INVALID','EDIT_PATCH_CMD_UNKNOWN','EDIT_PATCH_ARG_UNKNOWN','BONES_OP_OUT_OF_SCOPE','RETARGET_REQUEST_INVALID']);
|
|
21
22
|
function json(data:unknown,status=200){return Response.json(data,{status,headers});}
|
|
22
23
|
async function bodyJson(req:Request,limit=1024*1024,what='Session payload exceeds 1 MiB'):Promise<unknown>{
|
|
23
24
|
if(!/^application\/json(?:\s*;|$)/i.test(req.headers.get('content-type')??''))throw new Error('Expected application/json');
|
|
@@ -28,11 +29,11 @@ async function bodyJson(req:Request,limit=1024*1024,what='Session payload exceed
|
|
|
28
29
|
}
|
|
29
30
|
/** `capability` (open workers and foreground CLI serving) gates every non-GET request; tests may omit it.
|
|
30
31
|
* `cwd` is where the producer is looked for after the package's own directories (review's working directory by default). */
|
|
31
|
-
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string;cwd?:string}={}){
|
|
32
|
+
export async function startServer(p:ReviewPackage,options:{port?:number;stateDir?:string;open?:OpenIdentity;initialAnimation?:string;activity?:()=>void;control?:ControlIdentity;capability?:string;cwd?:string;stripConcurrency?:number}={}){
|
|
32
33
|
const bundle=await buildWeb();
|
|
33
34
|
const unchanged=()=>assertReviewUnchanged(p);
|
|
34
35
|
const stateDir=options.stateDir??process.env.RIG_REVIEW_STATE_DIR??join(homedir(),'.local','state','rig-c-review'),starts=[p.root,options.cwd??process.cwd()];
|
|
35
|
-
const rebuilds=createRebuilds(p,{stateDir,starts,unchanged,bones:p.feedbackCatalog.bones});
|
|
36
|
+
const rebuilds=createRebuilds(p,{stateDir,starts,unchanged,bones:p.feedbackCatalog.bones,...(options.stripConcurrency?{stripConcurrency:options.stripConcurrency}:{})});
|
|
36
37
|
// Readings (#108): rigc render's readings on the inspected build and on the latest scratch build, into scratch.
|
|
37
38
|
const readings=createReadings(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
|
|
38
39
|
// Bones check (#109): propose --from-config and compare on a scratch copy of the patched handoff config.
|
|
@@ -65,6 +66,17 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
65
66
|
try{const result=await rebuilds.undo(await bodyJson(req,1024,'REBUILD_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
|
|
66
67
|
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
67
68
|
}
|
|
69
|
+
// #173: a density strip — 2–5 Mesh-panel patches built side by side, none applied; and closing it (its scratch removed).
|
|
70
|
+
if(req.method==='POST'&&url.pathname==='/api/density-strip'){
|
|
71
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
72
|
+
try{const result=await rebuilds.startStrip(await bodyJson(req,DENSITY_STRIP_LIMIT,'DENSITY_STRIP_BODY_LIMIT: maximum 320 KiB'));return json(result.body,result.status);}
|
|
73
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
74
|
+
}
|
|
75
|
+
if(req.method==='POST'&&url.pathname==='/api/density-strip/close'){
|
|
76
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
77
|
+
try{const result=await rebuilds.closeStrip(await bodyJson(req,1024,'DENSITY_STRIP_BODY_LIMIT: maximum 1 KiB'));return json(result.body,result.status);}
|
|
78
|
+
catch(e){if(e instanceof RebuildError)return json({status:'refused',error:e.message,producerLine:null},MALFORMED.has(e.code)?400:200);throw e;}
|
|
79
|
+
}
|
|
68
80
|
if(req.method==='POST'&&url.pathname==='/api/bones-check'){
|
|
69
81
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
70
82
|
try{return json(await bonesCheck.run(await bodyJson(req,BONES_CHECK_LIMIT,'BONES_CHECK_BODY_LIMIT: maximum 64 KiB')));}
|
|
@@ -95,6 +107,14 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
95
107
|
if(url.pathname==='/api/session'){await unchanged();return json(await readSession(p,options.stateDir));}
|
|
96
108
|
if(url.pathname==='/api/rebuild')return json(await rebuilds.describe());
|
|
97
109
|
if(url.pathname==='/api/rebuild/history')return json(await rebuilds.history());
|
|
110
|
+
if(url.pathname==='/api/density-strip')return json({strip:rebuilds.readStrip()});
|
|
111
|
+
const stripStatus=/^\/api\/density-strip\/([a-f0-9]{64})(?:\/([a-f0-9]{64}))?$/.exec(url.pathname);
|
|
112
|
+
if(stripStatus){try{return json(stripStatus[2]?rebuilds.stripJob(stripStatus[1]!,stripStatus[2]):rebuilds.readStrip(stripStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
|
|
113
|
+
const stripFile=/^\/density-strip\/([a-f0-9]{64})\/([a-f0-9]{64})\/(.+)$/.exec(url.pathname);
|
|
114
|
+
if(stripFile){
|
|
115
|
+
let full:string;try{full=await rebuilds.stripFile(stripFile[1]!,stripFile[2]!,decodeURIComponent(stripFile[3]!));}catch(e){return json({error:e instanceof Error?e.message:'Not found'},404);}
|
|
116
|
+
const file=Bun.file(full);return new Response(req.method==='HEAD'?null:file,{headers:{...headers,'Content-Security-Policy':"default-src 'none'; script-src 'none'; sandbox",'Content-Type':file.type||'application/octet-stream','Content-Length':String(file.size)}});
|
|
117
|
+
}
|
|
98
118
|
const rebuildStatus=/^\/api\/rebuild\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
99
119
|
if(rebuildStatus){try{return json(rebuilds.read(rebuildStatus[1]!));}catch(e){if(e instanceof RebuildError)return json({error:e.message},404);throw e;}}
|
|
100
120
|
if(url.pathname==='/api/requirements')return json(await requirements.describe());
|