@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/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, RETUNE_CHECK_SINCE, RETUNE_FILE, retuneCheckSummary, revisionBinding } from './retune-check.ts';
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 { stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>; bones: readonly string[] | null }
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 summary, bound to the
447
- * revision's files by `retune.json`'s hash rows (`RETUNE_CHECK_STALE` refuses the Apply). Null — check runs as before —
448
- * when the Apply checks under an exported world (retune's check read the default one) or `retune.json` carries no
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 retuneCarried(spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
452
- if (world) { push('review: retune\'s own check read the default world and this Apply checks under the exported schedule, so check runs on the revision'); return null; }
453
- const retune = JSON.parse(await readFile(join(spec, RETUNE_FILE), 'utf8')) as unknown;
454
- const carried = carriedCheck(retune);
455
- if (!carried) { push(`review: ${RETUNE_FILE} carries no check line or block statuses (@rig-c/parts before ${RETUNE_CHECK_SINCE}), so check runs on the revision`); return null; }
456
- const stale = await revisionBinding(spec, retune);
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 = (retune as { revision: unknown[] }).revision.length;
459
- push(`review: check not run again — ${RETUNE_FILE} carries retune's check of this revision, bound to it by ${rows} revision hash row(s): ${carried.line}`);
460
- return { summary: retuneCheckSummary(carried) };
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 (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
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
- ...(job.op === 'retune' ? { carried: (push: (line: string) => void) => retuneCarried(join(dir, 'rig'), world, push) } : {}),
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 (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
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 (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
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 (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
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 (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
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
  }
@@ -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` gates the revision and checks it (`checkStage` over the staged revision, the same `--parts`
4
- * and pack flags review passes) before it writes `retune.json`; from `@rig-c/parts` 1.49.0 (parts#541, its CHANGELOG:
5
- * "remesh,retune: the provenance carries check's final line and every block's status, as check itself reports") each
6
- * check side also carries `line` — check's own last line, composed by the one function check prints it from — and
7
- * `status` — every block's status as that tree's `check.json` records it, in the file's order. The writer
8
- * (`src/retune.ts`): `revision: hashRows(revision)` and `check: { ...sides, scope }` with
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 `retune.json` without `line` and `status` (an
13
- * older producer) is not refused: the caller falls back to running check. Pure but for the binding's reads.
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(retune: unknown): CarriedCheck | null {
40
- const side = isObject(retune) && isObject(retune.check) ? retune.check.revision : undefined;
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(`${RETUNE_FILE}: check.revision.line is not check's last line`);
46
- if (!isObject(side.status) || !Object.values(side.status).every(s => typeof s === 'string' && STATUSES.has(s))) throw new Error(`${RETUNE_FILE}: check.revision.status is not {block: status}`);
47
- if (side.status.gate !== 'PASS' && side.status.gate !== 'FAIL') throw new Error(`${RETUNE_FILE}: check.revision.status.gate is not PASS or FAIL`);
48
- if (typeof side.pass !== 'boolean') throw new Error(`${RETUNE_FILE}: check.revision.pass is not a boolean`);
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 where it came from.
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 retuneCheckSummary(carried: CarriedCheck): CheckSummary {
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: 'retune' };
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(retune: unknown): RetuneRevisionRow[] | string {
68
- const rows = isObject(retune) ? retune.revision : undefined;
69
- if (!Array.isArray(rows) || rows.length === 0) return `${RETUNE_CHECK_STALE}: ${RETUNE_FILE} names no revision hash rows; its check is bound to no files`;
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 `${RETUNE_CHECK_STALE}: ${RETUNE_FILE} has a revision row that is not {path, bytes, sha256}`;
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 `retune.json`. Null when bound; otherwise the first mismatch as
84
- * `RETUNE_CHECK_STALE: <file> sha256 <in retune.json> … <on disk>` (`absent` on the side that has no such file).
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, retune: unknown): Promise<string | null> {
87
- const rows = revisionRows(retune);
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 `${RETUNE_CHECK_STALE}: ${row.path} sha256 ${row.sha256} in ${RETUNE_FILE} … ${actual} on disk; retune's check is not this revision's, so no build is recorded`;
113
+ if (actual !== row.sha256) return stale(row.path, row.sha256, actual);
94
114
  }
95
- for (const path of onDisk) if (path !== RETUNE_FILE && !named.has(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 `${RETUNE_CHECK_STALE}: ${path} sha256 absent in ${RETUNE_FILE} … ${actual} on disk; retune's check is not this revision's, so no build is recorded`;
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());
@@ -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
- const items=ops.map(o=>make('li',{},make('code',{textContent:`${o.op} ${o.path}`}),o.op==='remove'?'':` → ${JSON.stringify(o.value)}`));
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();},