@rig-c/review 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +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 +174 -28
- 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
|
|
|
@@ -400,7 +402,24 @@ export function assertNamedFilesHeld(before: unknown, after: unknown): void {
|
|
|
400
402
|
/** The value of a flag in the patch's args, or the producer's default. */
|
|
401
403
|
const flagValue = (args: readonly string[], flag: string, fallback: string) => { const i = args.indexOf(flag); return i >= 0 ? args[i + 1]! : fallback; };
|
|
402
404
|
|
|
403
|
-
export interface RebuildOptions {
|
|
405
|
+
export interface RebuildOptions {
|
|
406
|
+
stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null;
|
|
407
|
+
/** #173: how many strip candidates run at once (default max(1, floor(cores / 2))). */
|
|
408
|
+
stripConcurrency?: number;
|
|
409
|
+
}
|
|
410
|
+
/** #173: a density strip's scratch — one folder per package, holding at most the one strip on screen. */
|
|
411
|
+
export const stripRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'density-strip', sourceHash);
|
|
412
|
+
export const defaultStripConcurrency = () => Math.max(1, Math.floor(availableParallelism() / 2));
|
|
413
|
+
/** Deterministic: the package, the config bytes, every candidate's patch bytes in order and both tool versions. */
|
|
414
|
+
export function stripId(input: { sourceHash: string; configHash: string; patches: readonly ConfigPatch[]; producer: string; compiler: string }): string {
|
|
415
|
+
return sha256(JSON.stringify([DENSITY_STRIP_SCHEMA, input.sourceHash, input.configHash, input.patches.map(patchText), input.producer, input.compiler]));
|
|
416
|
+
}
|
|
417
|
+
/** `GET /api/density-strip/<id>`: the strip, one row per candidate in the request's order, and the two labels. */
|
|
418
|
+
export interface DensityStripView {
|
|
419
|
+
schema: typeof DENSITY_STRIP_SCHEMA; id: string; status: 'running' | 'done'; part: string; field: DensityField;
|
|
420
|
+
concurrency: number; configHash: string; producer: { package: string; version: string }; compiler: { package: string; version: string };
|
|
421
|
+
wallMs: number | null; rows: StripRow[]; labels: { fewest: number | null; widest: number | null };
|
|
422
|
+
}
|
|
404
423
|
export const baselineDir = (stateDir: string, key: string) => join(stateDir, 'baseline', key);
|
|
405
424
|
|
|
406
425
|
const tool = (t: InstalledTool) => ({ package: t.package, version: t.version });
|
|
@@ -431,7 +450,7 @@ interface ApplySteps {
|
|
|
431
450
|
/** #102: check the scratch rig after the compile. */
|
|
432
451
|
check?: (summary: CheckSummary, wallMs: number | null) => void;
|
|
433
452
|
/**
|
|
434
|
-
* #139: the check the producer op already ran on the spec it wrote, read instead of running check again: a summary
|
|
453
|
+
* #139 / #174: the check the producer op (retune, remesh) already ran on the spec it wrote, read instead of running check again: a summary
|
|
435
454
|
* (recorded through `check`, no wall clock), a refusal line (nothing recorded, the build refused), or null — the op
|
|
436
455
|
* carries none, so check runs as for any other Apply.
|
|
437
456
|
*/
|
|
@@ -440,24 +459,31 @@ interface ApplySteps {
|
|
|
440
459
|
hashed?: () => Promise<void>;
|
|
441
460
|
/** #112: the schedule check reads (`--environment environment.json`, written into the scratch directory first). */
|
|
442
461
|
world?: ApplyWorld | null;
|
|
462
|
+
/**
|
|
463
|
+
* #173: a density strip's candidate — built and hashed and its result.json written exactly as an Apply's, but it does
|
|
464
|
+
* not land: no applied record, no stage, no scratch-root arrangement. Its build waits in the strip's own folder.
|
|
465
|
+
*/
|
|
466
|
+
candidate?: boolean;
|
|
443
467
|
}
|
|
444
468
|
|
|
445
469
|
/**
|
|
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`
|
|
470
|
+
* #139 / #174: the check `rig-parts retune` (or `remesh`) ran on the revision it wrote into `spec`, as review's check
|
|
471
|
+
* summary, bound to the revision's files by the provenance's hash rows (`RETUNE_CHECK_STALE` / `REMESH_CHECK_STALE`
|
|
472
|
+
* refuses the Apply). Null — check runs as before — when the Apply checks under an exported world (the op's check read
|
|
473
|
+
* the default one) or the provenance carries no `line` / `status` (a producer before `RETUNE_CHECK_SINCE` /
|
|
474
|
+
* `REMESH_CHECK_SINCE`, both 1.49.0).
|
|
450
475
|
*/
|
|
451
|
-
async function
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
const
|
|
455
|
-
|
|
456
|
-
|
|
476
|
+
async function opCarried(op: CarryingOp, spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
|
|
477
|
+
const file = provenanceFile(op);
|
|
478
|
+
if (world) { push(`review: ${op}'s own check read the default world and this Apply checks under the exported schedule, so check runs on the revision`); return null; }
|
|
479
|
+
const provenance = JSON.parse(await readFile(join(spec, file), 'utf8')) as unknown;
|
|
480
|
+
const carried = carriedCheck(provenance, op);
|
|
481
|
+
if (!carried) { push(`review: ${file} carries no check line or block statuses (@rig-c/parts before ${op === 'retune' ? RETUNE_CHECK_SINCE : REMESH_CHECK_SINCE}), so check runs on the revision`); return null; }
|
|
482
|
+
const stale = await revisionBinding(spec, provenance, op);
|
|
457
483
|
if (stale) return { refused: stale };
|
|
458
|
-
const rows = (
|
|
459
|
-
push(`review: check not run again — ${
|
|
460
|
-
return { summary:
|
|
484
|
+
const rows = (provenance as { revision: unknown[] }).revision.length;
|
|
485
|
+
push(`review: check not run again — ${file} carries ${op}'s check of this revision, bound to it by ${rows} revision hash row(s): ${carried.line}`);
|
|
486
|
+
return { summary: carriedCheckSummary(carried, op) };
|
|
461
487
|
}
|
|
462
488
|
|
|
463
489
|
/** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
|
|
@@ -511,6 +537,11 @@ async function hashBuild(build: string, id: string): Promise<RebuildBuild> {
|
|
|
511
537
|
export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
512
538
|
const jobs = new Map<string, RebuildJob | PatchJob | DeltaJob | RetargetJob>();
|
|
513
539
|
let running: string | null = null;
|
|
540
|
+
/** #173: the density strip on screen (at most one per review): its candidates run beside each other, none of them applied. */
|
|
541
|
+
interface StripCandidate { value: number; id: string; entry: JsonValue; dir: string; job: PatchJob; applied: boolean }
|
|
542
|
+
let strip: { id: string; status: 'running' | 'done'; part: string; field: DensityField; concurrency: number; configHash: string; producer: { package: string; version: string }; compiler: { package: string; version: string }; started: number; wallMs: number | null; candidates: StripCandidate[] } | null = null;
|
|
543
|
+
/** One producer workload at a time per review: an Apply (or undo's re-run) or a strip; the line refusing the other. */
|
|
544
|
+
const busy = (): string | null => (running ? `REBUILD_BUSY: rebuild ${running} is still running` : strip?.status === 'running' ? `REBUILD_BUSY: density strip ${strip.id} is still running` : null);
|
|
514
545
|
/** #166: the session's applied records, in order; the last is on the stage. The request bodies by job id. */
|
|
515
546
|
const stack: AppliedEntry[] = [];
|
|
516
547
|
const bodies = new Map<string, unknown>();
|
|
@@ -694,7 +725,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
694
725
|
const v = object(input, 'undo');
|
|
695
726
|
if (Object.keys(v).join(',') !== 'from' || typeof v.from !== 'string' || !/^[a-f0-9]{64}$/.test(v.from)) bad('expected exactly {from: <the build id on the stage>}');
|
|
696
727
|
assertStateOutside();
|
|
697
|
-
if (
|
|
728
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
698
729
|
const top = stack.at(-1);
|
|
699
730
|
if (!top) throw new RebuildError('UNDO_NOTHING_APPLIED', 'no Apply is on the stage; the inspected build is');
|
|
700
731
|
if (top.id !== v.from) throw new RebuildError('UNDO_STALE', `the stage's last Apply is ${top.id.slice(0, 12)}…, not ${String(v.from).slice(0, 12)}…; reload`);
|
|
@@ -806,13 +837,13 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
806
837
|
job.build = await hashBuild(join(dir, 'build'), job.id);
|
|
807
838
|
if (steps.hashed) await steps.hashed();
|
|
808
839
|
// #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);
|
|
840
|
+
const changed = steps.candidate ? null : restoredDifference(job.id, job.build.files);
|
|
810
841
|
if (changed) { job.producerLine = changed; job.status = 'refused'; push(`review: ${changed}`); return; }
|
|
811
|
-
latestId = job.id;
|
|
842
|
+
if (!steps.candidate) latestId = job.id;
|
|
812
843
|
await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, status: 'built', reused: false }, null, 1) + '\n', { mode: 0o600 });
|
|
813
844
|
// 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' });
|
|
845
|
+
// earlier builds beside it (keptRoot) for undo. #173: a strip candidate lands nowhere until its row is Applied.
|
|
846
|
+
if (!steps.candidate) await landed({ ...job, status: 'built' });
|
|
816
847
|
// "built" is published last: a reader that sees it finds the scratch root already settled (no sibling mid-removal).
|
|
817
848
|
job.status = 'built';
|
|
818
849
|
} catch (error) {
|
|
@@ -845,7 +876,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
845
876
|
* #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
|
|
846
877
|
* inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
|
|
847
878
|
*/
|
|
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> {
|
|
879
|
+
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null, world: ApplyWorld | null = null, cloth: ClothPlan | null = null, candidate = false): Promise<void> {
|
|
849
880
|
const args = job.request.patch.op.args, producer = resolved.producer;
|
|
850
881
|
let counts: RemeshCounts | null = null;
|
|
851
882
|
let built: ProcessResult | null = null;
|
|
@@ -957,9 +988,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
957
988
|
...(building ? {} : { check: (summary: CheckSummary, wallMs: number | null) => { job.wallMs.check = wallMs; job.check = summary; } }),
|
|
958
989
|
// #139: a retune Apply reads the check retune ran on its revision (retune.json, bound by its hash rows) unless the
|
|
959
990
|
// Apply checks under an exported world (retune's check reads the default one) or the producer carries no line.
|
|
960
|
-
|
|
991
|
+
// #174: a remesh patch the same, from remesh.json — remesh checked the revision with the request's pack flags,
|
|
992
|
+
// the ones `flags` reads for review's own check, and the same `--parts`; a patch passes no `--requirements`.
|
|
993
|
+
...(job.op === 'retune' || job.op === 'remesh' ? { carried: (push: (line: string) => void) => opCarried(job.op as CarryingOp, join(dir, 'rig'), world, push) } : {}),
|
|
961
994
|
hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
|
|
962
995
|
world,
|
|
996
|
+
...(candidate ? { candidate: true } : {}),
|
|
963
997
|
});
|
|
964
998
|
}
|
|
965
999
|
|
|
@@ -1024,9 +1058,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1024
1058
|
const id = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}`, op, ...(plan ? { buildInputs: plan.inputsHash } : {}), ...(world ? { environment: world.record.sha256 } : {}), ...(clothPlan ? { cloth: p.handoff!.record.keypoints!.sha256 } : {}) });
|
|
1025
1059
|
const current = jobs.get(id);
|
|
1026
1060
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1027
|
-
if (
|
|
1061
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1028
1062
|
guardRestore(id);
|
|
1029
1063
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1064
|
+
await adopt(id);
|
|
1030
1065
|
await unkeep(id);
|
|
1031
1066
|
const reused = await reuse<PatchJob>(dir, id);
|
|
1032
1067
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1039,6 +1074,114 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1039
1074
|
return { status: 202, body: snapshot(job) };
|
|
1040
1075
|
}
|
|
1041
1076
|
|
|
1077
|
+
/**
|
|
1078
|
+
* #173 · `POST /api/density-strip` with `{patches}`: 2–5 Mesh-panel patches for one part that differ only at its density
|
|
1079
|
+
* field (`src/density-strip.ts`). Each is checked as an Apply checks it (fresh config bytes, names, held files, the
|
|
1080
|
+
* installed producer) and runs through the same patch runner — remesh, the compile, #174's carried check — into its own
|
|
1081
|
+
* folder under `stripRoot`, at most `concurrency` at once. Nothing lands: no applied record, no stage, no undo entry.
|
|
1082
|
+
* A new strip replaces the folder of the one before it; refused while an Apply runs (and an Apply is refused while a
|
|
1083
|
+
* strip runs: `REBUILD_BUSY`).
|
|
1084
|
+
*/
|
|
1085
|
+
async function startStrip(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1086
|
+
let patches: ConfigPatch[];
|
|
1087
|
+
try { patches = stripPatches(input).map(parseConfigPatch); }
|
|
1088
|
+
catch (e) { if (e instanceof ConfigPatchError || e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1089
|
+
let shape: ReturnType<typeof stripShape>;
|
|
1090
|
+
try { shape = stripShape(patches); } catch (e) { if (e instanceof DensityStripError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1091
|
+
assertStateOutside();
|
|
1092
|
+
await options.unchanged();
|
|
1093
|
+
const inputs = await producerInputs(p.handoff, p.handoffReason);
|
|
1094
|
+
const original = JSON.parse(inputs.configText) as JsonValue;
|
|
1095
|
+
const planned: { patch: ConfigPatch; patched: JsonValue }[] = [];
|
|
1096
|
+
for (const patch of patches) {
|
|
1097
|
+
let patched: JsonValue;
|
|
1098
|
+
try { assertPatchFresh(patch, inputs.configHash); assertOpNames(original, patch.ops); patched = applyConfigPatch(original, patch.ops); remeshPart(patch); }
|
|
1099
|
+
catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
1100
|
+
assertNamedFilesHeld(original, patched);
|
|
1101
|
+
planned.push({ patch, patched });
|
|
1102
|
+
}
|
|
1103
|
+
const resolved = resolveProducer(options.starts);
|
|
1104
|
+
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
|
|
1105
|
+
const first = patches[0]!;
|
|
1106
|
+
if (first.producer.name !== resolved.producer.package || first.producer.version !== resolved.producer.version) throw new RebuildError('EDIT_PRODUCER_MISMATCH', `the patch was made for ${first.producer.name} ${first.producer.version}; the installed producer is ${resolved.producer.package} ${resolved.producer.version}`);
|
|
1107
|
+
const producer = `${resolved.producer.package}@${resolved.producer.version}`, compiler = `${resolved.compiler.package}@${resolved.compiler.version}`;
|
|
1108
|
+
const id = stripId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patches, producer, compiler });
|
|
1109
|
+
if (strip?.id === id) return { status: strip.status === 'running' ? 202 : 200, body: stripView() };
|
|
1110
|
+
const refusal = busy();
|
|
1111
|
+
if (refusal) return { status: 200, body: { status: 'refused', error: refusal, producerLine: null } };
|
|
1112
|
+
// Bounded disk: the strip on screen is the only one this package keeps; its predecessor's folder goes first.
|
|
1113
|
+
const root = stripRoot(options.stateDir, p.sourceHash);
|
|
1114
|
+
await rm(root, { recursive: true, force: true });
|
|
1115
|
+
const concurrency = Math.max(1, Math.floor(options.stripConcurrency ?? defaultStripConcurrency()));
|
|
1116
|
+
const candidates: StripCandidate[] = planned.map(({ patch }, i) => {
|
|
1117
|
+
const cid = patchId({ sourceHash: p.sourceHash, configHash: inputs.configHash, patch, producer, compiler, op: 'remesh' });
|
|
1118
|
+
const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id: cid, status: 'running', reused: false, op: 'remesh', request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null };
|
|
1119
|
+
return { value: shape.values[i]!, id: cid, entry: shape.entries[i]!, dir: join(root, id, cid), job, applied: false };
|
|
1120
|
+
});
|
|
1121
|
+
const current = { id, status: 'running' as const, part: shape.part, field: shape.field, concurrency, configHash: inputs.configHash, producer: tool(resolved.producer), compiler: tool(resolved.compiler), started: performance.now(), wallMs: null, candidates };
|
|
1122
|
+
strip = current;
|
|
1123
|
+
// A bounded pool: each worker takes the next candidate in order; the strip is done when every candidate settled.
|
|
1124
|
+
let next = 0;
|
|
1125
|
+
const worker = async () => {
|
|
1126
|
+
for (let i = next++; i < candidates.length; i = next++) {
|
|
1127
|
+
const c = candidates[i]!;
|
|
1128
|
+
await executePatch(c.job, c.dir, inputs, resolved, planned[i]!.patched, shape.part, null, null, null, true).catch(() => {});
|
|
1129
|
+
}
|
|
1130
|
+
};
|
|
1131
|
+
void Promise.all(Array.from({ length: Math.min(concurrency, candidates.length) }, worker)).finally(() => {
|
|
1132
|
+
if (strip === current) { (current as { status: 'running' | 'done' }).status = 'done'; (current as { wallMs: number | null }).wallMs = Math.round(performance.now() - current.started); }
|
|
1133
|
+
});
|
|
1134
|
+
return { status: 202, body: stripView() };
|
|
1135
|
+
}
|
|
1136
|
+
function stripView(): DensityStripView | null {
|
|
1137
|
+
if (!strip) return null;
|
|
1138
|
+
const s = strip;
|
|
1139
|
+
const rows = s.candidates.map(c => candidateRow({ value: c.value, id: c.id, entry: c.entry, job: c.job, part: s.part, modelUrl: c.job.build ? `/density-strip/${s.id}/${c.id}/${c.job.build.model}` : null, applied: c.applied }));
|
|
1140
|
+
return { schema: DENSITY_STRIP_SCHEMA, id: s.id, status: s.status, part: s.part, field: s.field, concurrency: s.concurrency, configHash: s.configHash, producer: s.producer, compiler: s.compiler, wallMs: s.wallMs, rows, labels: s.status === 'done' ? stripLabels(rows) : { fewest: null, widest: null } };
|
|
1141
|
+
}
|
|
1142
|
+
/** `GET /api/density-strip/<id>` (and `GET /api/density-strip`: the strip on screen, or null). */
|
|
1143
|
+
function readStrip(id?: string): DensityStripView | null {
|
|
1144
|
+
if (id !== undefined && strip?.id !== id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', id);
|
|
1145
|
+
return stripView();
|
|
1146
|
+
}
|
|
1147
|
+
/** A candidate's job as it settled (its lines in full), for the row's log. */
|
|
1148
|
+
function stripJob(id: string, cid: string): PatchJob {
|
|
1149
|
+
const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
|
|
1150
|
+
if (!c) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}`);
|
|
1151
|
+
return snapshot(c.job);
|
|
1152
|
+
}
|
|
1153
|
+
/** `POST /api/density-strip/close` with `{id}`: the strip's folder is removed; refused while its candidates run. */
|
|
1154
|
+
async function closeStrip(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
1155
|
+
const v = object(input, 'density strip close');
|
|
1156
|
+
if (Object.keys(v).join(',') !== 'id' || typeof v.id !== 'string' || !/^[a-f0-9]{64}$/.test(v.id)) throw new RebuildError('REBUILD_REQUEST_INVALID', 'expected exactly {id: <the strip id>}');
|
|
1157
|
+
if (strip?.id !== v.id) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${v.id.slice(0, 12)}… is not the strip on screen`);
|
|
1158
|
+
if (strip.status === 'running') return { status: 200, body: { status: 'refused', error: `DENSITY_STRIP_BUSY: strip ${strip.id} is still running; close it when its candidates settle`, producerLine: null } };
|
|
1159
|
+
const applied = strip.candidates.filter(c => c.applied).length;
|
|
1160
|
+
strip = null;
|
|
1161
|
+
await rm(stripRoot(options.stateDir, p.sourceHash), { recursive: true, force: true });
|
|
1162
|
+
return { status: 200, body: { status: 'closed', id: v.id, applied } };
|
|
1163
|
+
}
|
|
1164
|
+
/** A served candidate file: only a built, not-applied candidate's hashed model and pages, read-only. */
|
|
1165
|
+
async function stripFile(id: string, cid: string, name: string): Promise<string> {
|
|
1166
|
+
const c = strip?.id === id ? strip.candidates.find(x => x.id === cid) : undefined;
|
|
1167
|
+
if (!c || c.applied || c.job.status !== 'built' || !c.job.build || !Object.hasOwn(c.job.build.files, name)) throw new RebuildError('DENSITY_STRIP_NOT_FOUND', `${id}/${cid}/${name}`);
|
|
1168
|
+
return safeFile(await realpath(join(c.dir, 'build')), packagePath(name));
|
|
1169
|
+
}
|
|
1170
|
+
/**
|
|
1171
|
+
* #173: an Apply whose build id is a settled strip candidate's takes that candidate's build: the folder moves into the
|
|
1172
|
+
* scratch root, where the ordinary reuse reads and re-hashes it and lands it as any Apply lands. The row then reads
|
|
1173
|
+
* "applied" and is no longer served from the strip.
|
|
1174
|
+
*/
|
|
1175
|
+
async function adopt(id: string): Promise<void> {
|
|
1176
|
+
const c = strip && strip.status === 'done' ? strip.candidates.find(x => x.id === id && !x.applied && x.job.status === 'built') : undefined;
|
|
1177
|
+
if (!c) return;
|
|
1178
|
+
const to = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1179
|
+
if (await exists(to)) return;
|
|
1180
|
+
await mkdir(rebuildRoot(options.stateDir, p.sourceHash), { recursive: true, mode: 0o700 });
|
|
1181
|
+
await rename(c.dir, to);
|
|
1182
|
+
c.applied = true;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1042
1185
|
/** #135: the delta → `rig-parts remesh --delta mesh-edit.json --part <part>` → `<dir>/rig`; the tail adds the per-file diff. */
|
|
1043
1186
|
function executeDelta(job: DeltaJob, dir: string, inputs: ProducerInputs, resolved: Resolved, text: string): Promise<void> {
|
|
1044
1187
|
const producer = resolved.producer, part = job.request.part;
|
|
@@ -1086,9 +1229,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1086
1229
|
const id = deltaId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, deltaText: text, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1087
1230
|
const current = jobs.get(id);
|
|
1088
1231
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1089
|
-
if (
|
|
1232
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1090
1233
|
guardRestore(id);
|
|
1091
1234
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1235
|
+
await adopt(id);
|
|
1092
1236
|
await unkeep(id);
|
|
1093
1237
|
const reused = await reuse<DeltaJob>(dir, id);
|
|
1094
1238
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1200,9 +1344,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1200
1344
|
const id = retargetId({ sourceHash: p.sourceHash, configHash: inputs.configHash, record, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1201
1345
|
const current = jobs.get(id);
|
|
1202
1346
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1203
|
-
if (
|
|
1347
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1204
1348
|
guardRestore(id);
|
|
1205
1349
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1350
|
+
await adopt(id);
|
|
1206
1351
|
await unkeep(id);
|
|
1207
1352
|
const reused = await reuse<RetargetJob>(dir, id);
|
|
1208
1353
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, body); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1237,9 +1382,10 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1237
1382
|
const id = designationId({ sourceHash: p.sourceHash, configHash: inputs.configHash, part: request.part, entry, producer: `${resolved.producer.package}@${resolved.producer.version}`, compiler: `${resolved.compiler.package}@${resolved.compiler.version}` });
|
|
1238
1383
|
const current = jobs.get(id);
|
|
1239
1384
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
1240
|
-
if (
|
|
1385
|
+
if (busy()) return { status: 200, body: { status: 'refused', error: busy(), producerLine: null } };
|
|
1241
1386
|
guardRestore(id);
|
|
1242
1387
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
1388
|
+
await adopt(id);
|
|
1243
1389
|
await unkeep(id);
|
|
1244
1390
|
const reused = await reuse(dir, id);
|
|
1245
1391
|
if (reused) { jobs.set(id, reused); latestId = id; bodies.set(id, input); await landed(reused); return { status: 200, body: snapshot(reused) }; }
|
|
@@ -1264,5 +1410,5 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
1264
1410
|
return safeFile(await realpath(join(rebuildDir(options.stateDir, p.sourceHash, id), 'build')), packagePath(name));
|
|
1265
1411
|
}
|
|
1266
1412
|
|
|
1267
|
-
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history };
|
|
1413
|
+
return { describe, start, read, file, running: () => running, baseline: settledBaseline, inspectedCheck: settledInspectedCheck, latest, undo, history, startStrip, readStrip, stripJob, closeStrip, stripFile };
|
|
1268
1414
|
}
|
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());
|
package/web/cloth-panel.ts
CHANGED
|
@@ -12,6 +12,7 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import {ClothFieldError,fieldText,recipeFromFields,recipeOps,RECIPE_FIELDS,type ClothTagView,type RecipeField} from '../src/cloth-recipe.ts';
|
|
14
14
|
import type {PatchOp} from '../src/config-patch.ts';
|
|
15
|
+
import {itemApplied} from '../src/applied-blocks.ts';
|
|
15
16
|
import type {CropWorldMapping} from '../src/chest-regions.ts';
|
|
16
17
|
import type {OverlayPoint,OverlayProject,OverlayRegion} from './rig-overlays.ts';
|
|
17
18
|
import type {ClothStatusView,PatchJobView,RebuildRefusal} from './rebuild-client.ts';
|
|
@@ -33,6 +34,10 @@ export interface ClothPanel {
|
|
|
33
34
|
links():{tag:string;inspected:ClothTagView|null;drafted:ClothTagView|null};
|
|
34
35
|
apply():Promise<PatchJobView|RebuildRefusal|null>;
|
|
35
36
|
shown(job:PatchJobView|RebuildRefusal|null):void;
|
|
37
|
+
/** #172: the ops of the stage's applied record this block owns (`appliedBlockOps`); a pending op among them is marked applied. */
|
|
38
|
+
applied(ops:readonly PatchOp[]|null):void;
|
|
39
|
+
/** #172: the pending ops marked applied, by their op line. */
|
|
40
|
+
appliedItems():string[];
|
|
36
41
|
status(text:string,error:boolean):void;busy(value:boolean):void;
|
|
37
42
|
restore():void;clear():void;sync():void;dispose():void;
|
|
38
43
|
}
|
|
@@ -43,7 +48,7 @@ const isObject=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==='object'
|
|
|
43
48
|
export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
|
|
44
49
|
const state=node('cloth-panel-state'),toggle=node<HTMLButtonElement>('cloth-links'),picker=node<HTMLSelectElement>('cloth-tag'),declared=node<HTMLInputElement>('cloth-declared'),fields=node('cloth-fields'),pending=node('cloth-pending-list');
|
|
45
50
|
const applyButton=node<HTMLButtonElement>('cloth-apply'),resetButton=node<HTMLButtonElement>('cloth-reset'),statusOut=node('cloth-status'),readout=node('cloth-readout'),draft=node<HTMLTextAreaElement>('cloth-pending');
|
|
46
|
-
let status:ClothStatusView|null=null,reason:string|null=null,selected='',active=false,busy=false;
|
|
51
|
+
let status:ClothStatusView|null=null,reason:string|null=null,selected='',active=false,busy=false,appliedOps:PatchOp[]=[];
|
|
47
52
|
/** Per touched tag: every form field's text, and whether the tag's recipe is declared. */
|
|
48
53
|
let edits=new Map<string,{declared:boolean;values:Record<string,string>}>();
|
|
49
54
|
let drafted:ClothTagView[]=[];
|
|
@@ -78,7 +83,8 @@ export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
|
|
|
78
83
|
function renderPending(){
|
|
79
84
|
let ops:PatchOp[]=[],error:string|null=null;
|
|
80
85
|
try{ops=allOps();}catch(err){if(err instanceof ClothFieldError)error=err.message;else throw err;}
|
|
81
|
-
|
|
86
|
+
// #172: an op is marked applied only while the stage's applied record holds it.
|
|
87
|
+
const items=ops.map(o=>{const on=itemApplied([o],appliedOps);return make('li',{className:on?'applied':''},make('code',{textContent:`${o.op} ${o.path}`}),`${o.op==='remove'?'':` → ${JSON.stringify(o.value)}`}${on?' · applied':''}`);});
|
|
82
88
|
const revert=[...edits.keys()].filter(touched).map(tag=>{const b=make('button',{type:'button',textContent:`Revert ${tag}`,disabled:busy});b.onclick=()=>{edits.delete(tag);change();};return make('li',{},b);});
|
|
83
89
|
pending.replaceChildren(...(error?[make('li',{className:'error',textContent:error})]:[]),...(items.length?items:[make('li',{className:'hint',textContent:'No pending ops.'})]),...revert);
|
|
84
90
|
applyButton.disabled=busy||!items.length||!!error||!!reason||!!status?.reason;resetButton.disabled=busy;
|
|
@@ -156,6 +162,8 @@ export function bindClothPanel(deps:ClothPanelDeps):ClothPanel {
|
|
|
156
162
|
drafted=job.status==='built'&&c?c.links:[];
|
|
157
163
|
readout.replaceChildren(...lines);deps.changed();
|
|
158
164
|
},
|
|
165
|
+
applied(ops){appliedOps=(ops??[]).map(o=>({...o}));renderPending();},
|
|
166
|
+
appliedItems(){return [...pending.querySelectorAll('li.applied code')].map(c=>c.textContent??'');},
|
|
159
167
|
status:panelStatus,
|
|
160
168
|
busy(value){busy=value;render();},
|
|
161
169
|
restore(){restore();render();deps.changed();},
|