@rig-c/review 0.7.1 → 0.8.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -7
- package/docs/DESIGN.md +2 -1
- package/docs/REVIEW_PACKAGE.md +17 -7
- package/package.json +1 -1
- package/src/applied-context.ts +35 -7
- package/src/cli.ts +1 -1
- package/src/cloth-recipe.ts +199 -0
- package/src/compare-server.ts +1 -1
- package/src/environment-check.ts +149 -0
- package/src/environment-schedule.ts +183 -0
- package/src/feedback-export.ts +8 -1
- package/src/frame-clock.ts +30 -0
- package/src/front.ts +4 -1
- package/src/handoff.ts +20 -13
- package/src/open-protocol.ts +4 -3
- package/src/open.ts +1 -1
- package/src/readings-run.ts +2 -1
- package/src/readings.ts +12 -6
- package/src/rebuild.ts +198 -34
- package/src/retune-check.ts +100 -0
- package/src/server.ts +12 -2
- package/src/session.ts +3 -0
- package/web/cloth-panel.ts +168 -0
- package/web/compare.ts +7 -5
- package/web/environment-world.ts +42 -0
- package/web/index.html +1 -1
- package/web/label-placement.ts +46 -0
- package/web/main.ts +127 -17
- package/web/readings-panel.ts +1 -1
- package/web/rebuild-client.ts +14 -9
- package/web/rig-overlays.ts +26 -10
- package/web/styles.css +2 -0
package/src/rebuild.ts
CHANGED
|
@@ -24,8 +24,12 @@ import { CHEST_REGIONS_MAX } from './chest-regions.ts';
|
|
|
24
24
|
import { bustMeshEntry, deriveBust, parseBustSpec, BustSpecError, type BustSpec } from './chest-landmarks.ts';
|
|
25
25
|
import { modelMeshTriangles, remeshCounts, type RemeshCounts } from './mesh-entry.ts';
|
|
26
26
|
import { patchRoute } from './retune-route.ts';
|
|
27
|
-
import {
|
|
27
|
+
import { carriedCheck, RETUNE_CHECK_SINCE, RETUNE_FILE, retuneCheckSummary, revisionBinding } from './retune-check.ts';
|
|
28
|
+
import { absolutizeBuildFiles, buildLines, buildNamedFiles, buildReadout, MAX_BUILD_READOUT, needsBuild, newBuildFile, recompositeFigures, type RecompositeFigures } from './build-route.ts';
|
|
28
29
|
import { jsonDiff } from './config-patch.ts';
|
|
30
|
+
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';
|
|
31
|
+
import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
|
|
32
|
+
import { samplesClock } from './frame-clock.ts';
|
|
29
33
|
import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
|
|
30
34
|
|
|
31
35
|
export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
|
|
@@ -227,9 +231,46 @@ export interface PatchJob {
|
|
|
227
231
|
build: RebuildBuild | null;
|
|
228
232
|
/** #115, a `build` job only: what the assemble stage read and wrote (absent on every other op, so their jobs are unchanged). */
|
|
229
233
|
assembled?: AssembledView;
|
|
234
|
+
/** #112: the `rigc-environment/1` schedule check read (`--environment`), by hash; absent when Apply ran under the default world. */
|
|
235
|
+
environment?: ApplyWorld['record'];
|
|
236
|
+
/** #111, a `cloth` job only: what `propose --cloth --write-config` wrote and how the written config was built. */
|
|
237
|
+
cloth?: ClothJob;
|
|
238
|
+
}
|
|
239
|
+
/**
|
|
240
|
+
* #111: a cloth recipe Apply — `rig-parts propose --cloth cloth.json --write-config config.json` on the handoff's parts,
|
|
241
|
+
* painting and keypoints, then the written config built as `build` (the config declares `limbs`) or `rig --config`.
|
|
242
|
+
* `refusals`: every FAIL line propose printed, verbatim; `line`: propose's own `wrote config.json: …` line; `written`:
|
|
243
|
+
* every JSON Pointer at which the written config differs from cloth.json (the patched handoff config), and those outside
|
|
244
|
+
* the sections propose splices (none, or the Apply is refused); `links`: the written config's chains per tag.
|
|
245
|
+
*/
|
|
246
|
+
export interface ClothJob {
|
|
247
|
+
route: 'build' | 'rig';
|
|
248
|
+
inputs: { source: string; keypoints: { path: string; sha256: string } };
|
|
249
|
+
refusals: string[]; line: string | null; written: WrittenDiff | null; links: ClothTagView[];
|
|
250
|
+
wallMs: { propose: number | null; build: number | null };
|
|
251
|
+
}
|
|
252
|
+
/** #111: what a cloth Apply runs with — the handoff's painting and keypoints (absolute), parts.json and the reader's tags; never from the client. */
|
|
253
|
+
export interface ClothPlan { source: string; keypoints: string; partsJson: unknown; targets: string[] }
|
|
254
|
+
/**
|
|
255
|
+
* The tags the installed reader accepts, read off its own source (`src/cloth.ts`'s `CLOTH_TARGETS`); when that text
|
|
256
|
+
* does not state them, the tags the config's `cloth` block already declares (and `read: false`).
|
|
257
|
+
*/
|
|
258
|
+
export async function clothTargets(producerRoot: string | null, configText: string): Promise<{ tags: string[]; read: boolean }> {
|
|
259
|
+
if (producerRoot) { try { const tags = readClothTargets(await readFile(join(producerRoot, 'src', 'cloth.ts'), 'utf8')); if (tags) return { tags, read: true }; } catch { /* not readable: fall back */ } }
|
|
260
|
+
const cloth = object(JSON.parse(configText), 'config').cloth;
|
|
261
|
+
return { tags: cloth && typeof cloth === 'object' && !Array.isArray(cloth) ? Object.keys(cloth).filter(k => k !== 'note' && !k.endsWith('_note') && !k.startsWith('x-')) : [], read: false };
|
|
262
|
+
}
|
|
263
|
+
/** A written config that differs from its input outside the drafted sections is not built (bar 1, #111). */
|
|
264
|
+
export const CLOTH_WRITTEN_OUTSIDE_DRAFTED = 'CLOTH_WRITTEN_OUTSIDE_DRAFTED';
|
|
265
|
+
/** #112: the exported schedule an Apply's check reads, as written into the scratch directory, and what the job records of it. */
|
|
266
|
+
export interface ApplyWorld { text: string; record: { sha256: string; steps: number } }
|
|
267
|
+
/** `{patch, environment}`'s schedule: parsed as data and re-serialized, so its hash is the exported bytes'. */
|
|
268
|
+
export function parseApplyWorld(value: unknown): ApplyWorld {
|
|
269
|
+
try { const schedule = parseSchedule(value), text = scheduleText(schedule); return { text, record: { sha256: sha256(text), steps: schedule.steps.length } }; }
|
|
270
|
+
catch (e) { if (e instanceof ScheduleError) throw new RebuildError('REBUILD_REQUEST_INVALID', `environment: ${e.message}`); throw e; }
|
|
230
271
|
}
|
|
231
272
|
/** The producer op a patch job ran (#136; `build` since #115). */
|
|
232
|
-
export type AppliedOp = 'rig' | 'retune' | 'remesh' | 'build';
|
|
273
|
+
export type AppliedOp = 'rig' | 'retune' | 'remesh' | 'build' | 'cloth';
|
|
233
274
|
/**
|
|
234
275
|
* #115: a `rig-parts build` job's assemble readout — the inputs it was run with (paths relative to the package root, the
|
|
235
276
|
* assemble-read config files by hash), every FAIL line verbatim, the `cut:` / `limb cut:` / `limb cap:` / uncovered-hole
|
|
@@ -262,6 +303,8 @@ export interface RetargetJob {
|
|
|
262
303
|
lines: string[]; producerLine: string | null; refusals: string[]; exitCode: number | null;
|
|
263
304
|
wallMs: { clean: number | null; retarget: number | null; compile: number | null };
|
|
264
305
|
files: FilesDiff | null; motion: MotionSummary | null; readings: RetargetLine[];
|
|
306
|
+
/** The run's report `frameTimes` (`motion.report.json`): the new animation's own clock, null when absent or malformed (#164). */
|
|
307
|
+
frameTimes: number[] | null;
|
|
265
308
|
build: RebuildBuild | null;
|
|
266
309
|
}
|
|
267
310
|
/** Deterministic: the package, the config bytes, the record's bytes and both tool versions. No clock. */
|
|
@@ -305,9 +348,10 @@ export interface DeltaJob {
|
|
|
305
348
|
* Deterministic: the package, the config bytes, the patch's own bytes and both tool versions. No clock. A retune-routed
|
|
306
349
|
* patch adds its op (#136), so a scratch build the whole-rig path left for the same patch is never reused as retune's.
|
|
307
350
|
*/
|
|
308
|
-
export function patchId(input: { sourceHash: string; configHash: string; patch: ConfigPatch; producer: string; compiler: string; op?: AppliedOp; buildInputs?: string }): string {
|
|
351
|
+
export function patchId(input: { sourceHash: string; configHash: string; patch: ConfigPatch; producer: string; compiler: string; op?: AppliedOp; buildInputs?: string; environment?: string; cloth?: string }): string {
|
|
309
352
|
// #115: a build-routed patch adds its op and the hash of the files assemble reads (painting, runs, named files).
|
|
310
|
-
|
|
353
|
+
// #112: an Apply whose check reads a schedule adds the schedule's hash, so a default-world build is never reused for it.
|
|
354
|
+
return sha256(JSON.stringify([REBUILD_SCHEMA, 'patch', input.sourceHash, input.configHash, patchText(input.patch), input.producer, input.compiler, ...(input.op === 'retune' ? ['retune'] : []), ...(input.op === 'build' ? ['build', input.buildInputs ?? ''] : []), ...(input.op === 'cloth' ? ['cloth', input.cloth ?? '', input.buildInputs ?? ''] : []), ...(input.environment ? ['environment', input.environment] : [])]));
|
|
311
355
|
}
|
|
312
356
|
const namedFile = (doc: unknown, keys: readonly string[]): unknown => keys.reduce<unknown>((v, k) => (v && typeof v === 'object' && !Array.isArray(v) ? (v as Record<string, unknown>)[k] : undefined), doc);
|
|
313
357
|
/**
|
|
@@ -359,9 +403,35 @@ interface ApplySteps {
|
|
|
359
403
|
/** #125: diff every producer document against the inspected rig, before the compile. */
|
|
360
404
|
diff?: (files: FilesDiff) => void;
|
|
361
405
|
/** #102: check the scratch rig after the compile. */
|
|
362
|
-
check?: (summary: CheckSummary, wallMs: number) => void;
|
|
406
|
+
check?: (summary: CheckSummary, wallMs: number | null) => void;
|
|
407
|
+
/**
|
|
408
|
+
* #139: the check the producer op already ran on the spec it wrote, read instead of running check again: a summary
|
|
409
|
+
* (recorded through `check`, no wall clock), a refusal line (nothing recorded, the build refused), or null — the op
|
|
410
|
+
* carries none, so check runs as for any other Apply.
|
|
411
|
+
*/
|
|
412
|
+
carried?: (push: (line: string) => void) => Promise<{ summary: CheckSummary } | { refused: string } | null>;
|
|
363
413
|
/** After the build is hashed, before result.json is written. */
|
|
364
414
|
hashed?: () => Promise<void>;
|
|
415
|
+
/** #112: the schedule check reads (`--environment environment.json`, written into the scratch directory first). */
|
|
416
|
+
world?: ApplyWorld | null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* #139: the check `rig-parts retune` ran on the revision it wrote into `spec`, as review's check summary, bound to the
|
|
421
|
+
* revision's files by `retune.json`'s hash rows (`RETUNE_CHECK_STALE` refuses the Apply). Null — check runs as before —
|
|
422
|
+
* when the Apply checks under an exported world (retune's check read the default one) or `retune.json` carries no
|
|
423
|
+
* `line` / `status` (a producer before `RETUNE_CHECK_SINCE`).
|
|
424
|
+
*/
|
|
425
|
+
async function retuneCarried(spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
|
|
426
|
+
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; }
|
|
427
|
+
const retune = JSON.parse(await readFile(join(spec, RETUNE_FILE), 'utf8')) as unknown;
|
|
428
|
+
const carried = carriedCheck(retune);
|
|
429
|
+
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; }
|
|
430
|
+
const stale = await revisionBinding(spec, retune);
|
|
431
|
+
if (stale) return { refused: stale };
|
|
432
|
+
const rows = (retune as { revision: unknown[] }).revision.length;
|
|
433
|
+
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}`);
|
|
434
|
+
return { summary: retuneCheckSummary(carried) };
|
|
365
435
|
}
|
|
366
436
|
|
|
367
437
|
/** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
|
|
@@ -481,6 +551,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
481
551
|
cuts: inputs ? { config: configCuts(inputs.configText), build: buildGap(handoff) } : null,
|
|
482
552
|
// #104: the config's idle (tracks, blink, bones, chains) and where the inspected idle keys each track.
|
|
483
553
|
idle: inputs ? await idleStatus(inputs) : null,
|
|
554
|
+
// #111: the cloth recipe form — the reader's tags, the config's recipes as written, each tag's parts and chains (the
|
|
555
|
+
// link preview) and why a cloth Apply cannot run here (no keypoints, no painting, an older producer), or null.
|
|
556
|
+
cloth: inputs && handoff ? await clothStatus(handoff, inputs, resolved) : null,
|
|
484
557
|
producer: resolved.ok ? tool(resolved.producer) : null, compiler: resolved.ok ? tool(resolved.compiler) : null,
|
|
485
558
|
// #114: what the retarget editor offers — the pose producer, the declared sequences, the rig's bones and animations.
|
|
486
559
|
retarget: await retargetStatus(inputs),
|
|
@@ -488,6 +561,19 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
488
561
|
running,
|
|
489
562
|
};
|
|
490
563
|
}
|
|
564
|
+
/** #111: what the cloth recipe form starts from. */
|
|
565
|
+
async function clothStatus(handoff: Handoff, inputs: ProducerInputs, resolved: ReturnType<typeof resolveProducer>) {
|
|
566
|
+
const config = object(JSON.parse(inputs.configText), 'config');
|
|
567
|
+
let partsJson: unknown = null;
|
|
568
|
+
try { partsJson = JSON.parse(await readFile(join(inputs.parts, 'parts.json'), 'utf8')); } catch { /* none: no tag lists its parts */ }
|
|
569
|
+
const targets = await clothTargets(resolved.ok ? resolved.producer.root : null, inputs.configText);
|
|
570
|
+
const reason = !handoff.keypoints || !handoff.record.keypoints ? CLOTH_KEYPOINTS_UNDECLARED
|
|
571
|
+
: !resolved.ok ? resolved.error
|
|
572
|
+
: !versionAtLeast(resolved.producer.version, CLOTH_WRITE_SINCE) ? modelingProducerTooOld(resolved.producer.version || 'unknown', CLOTH_WRITE_SINCE, 'rig-parts propose --cloth --write-config (entry by entry)')
|
|
573
|
+
: !handoff.source ? CLOTH_SOURCE_UNAVAILABLE
|
|
574
|
+
: config.limbs !== undefined && !handoff.full ? BUILD_SOURCE_UNAVAILABLE : null;
|
|
575
|
+
return { targets: targets.tags, targetsRead: targets.read, recipes: config.cloth ?? null, tags: clothLinks(config, partsJson, targets.tags), keypoints: handoff.record.keypoints ?? null, route: config.limbs !== undefined ? 'build' : 'rig', reason };
|
|
576
|
+
}
|
|
491
577
|
/** The inspected build's check once it settles (tests); null when the baseline did not reproduce. */
|
|
492
578
|
async function settledInspectedCheck(): Promise<InspectedCheck | null> { await settledBaseline(); return inspectedCheckRun ? await inspectedCheckRun : inspectedCheck; }
|
|
493
579
|
/** The most recent built scratch build of this session (#108 readings run on it), or null. */
|
|
@@ -496,7 +582,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
496
582
|
const job = latestId ? jobs.get(latestId) : undefined;
|
|
497
583
|
// `spec`: the rig spec directory the job's producer op wrote (#113 checks requirements on it) — a designation's `revision`, a patch's or delta's `rig`.
|
|
498
584
|
const dir = job ? rebuildDir(options.stateDir, p.sourceHash, job.id) : '';
|
|
499
|
-
return job && job.status === 'built' && job.build ? { id: job.id, build: join(dir, 'build'), modelHash: job.build.modelHash, spec: join(dir, !('kind' in job) ? 'revision' : job.op === 'build' ? 'out/rig' : 'rig') } : null;
|
|
585
|
+
return job && job.status === 'built' && job.build ? { id: job.id, build: join(dir, 'build'), modelHash: job.build.modelHash, spec: join(dir, !('kind' in job) ? 'revision' : job.op === 'build' || (job.kind === 'patch' && job.cloth?.route === 'build') ? 'out/rig' : 'rig') } : null;
|
|
500
586
|
}
|
|
501
587
|
/** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
|
|
502
588
|
async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
|
|
@@ -509,7 +595,14 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
509
595
|
if (stored.schema !== REBUILD_SCHEMA || stored.id !== id || stored.status !== 'built' || !stored.build) return null;
|
|
510
596
|
const now = await hashBuild(join(dir, 'build'), id);
|
|
511
597
|
if (JSON.stringify(now.files) !== JSON.stringify(stored.build.files)) return null;
|
|
512
|
-
|
|
598
|
+
const job = { ...stored, reused: true };
|
|
599
|
+
// A retarget built before the job carried its report's frameTimes (#164): read them from the build's own report.
|
|
600
|
+
if ('kind' in job && job.kind === 'retarget' && (job as RetargetJob).frameTimes === undefined) {
|
|
601
|
+
let times: unknown = null;
|
|
602
|
+
try { times = (JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }).frameTimes; } catch { /* no report: no clock */ }
|
|
603
|
+
const clock = samplesClock(times); (job as RetargetJob).frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
|
|
604
|
+
}
|
|
605
|
+
return job;
|
|
513
606
|
} catch { return null; }
|
|
514
607
|
}
|
|
515
608
|
|
|
@@ -542,10 +635,14 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
542
635
|
const compile = await runTool(resolved.compiler.bin, compileArgs({ rig: `${op.spec}/rig.json`, motion: `${op.spec}/motion.json`, out: 'build', pageEdges, packShape }), { cwd: dir, onLine: push });
|
|
543
636
|
job.wallMs.compile = compile.wallMs;
|
|
544
637
|
if (compile.exitCode !== 0) { job.exitCode = compile.exitCode; job.producerLine = firstRefusal(compile.lines) ?? `rigc build exited ${compile.exitCode} without a named refusal`; job.status = 'refused'; return; }
|
|
545
|
-
|
|
638
|
+
const carried = steps.check && steps.carried ? await steps.carried(push) : null;
|
|
639
|
+
if (carried && 'refused' in carried) { job.producerLine = carried.refused; job.status = 'refused'; push(`review: ${carried.refused}`); return; }
|
|
640
|
+
if (steps.check && carried) steps.check(carried.summary, null);
|
|
641
|
+
else if (steps.check) {
|
|
546
642
|
// check measures the scratch rig; a FAIL is its judgment, recorded beside the build, not a refusal of the build.
|
|
547
|
-
|
|
548
|
-
|
|
643
|
+
if (steps.world) await writeFile(join(dir, 'environment.json'), steps.world.text, { mode: 0o600 });
|
|
644
|
+
push(`review: ${producer.package} ${producer.version} check --rig ${op.spec} --out check --parts <handoff parts>${steps.world ? ` --environment environment.json (the exported schedule, ${steps.world.record.steps} step(s), sha256 ${steps.world.record.sha256.slice(0, 16)})` : ''}`);
|
|
645
|
+
const check = await runTool(producer.bin, ['check', '--rig', op.spec, '--out', 'check', '--parts', inputs.parts, '--page-edges', pageEdges, '--pack-shape', packShape, ...(steps.world ? ['--environment', 'environment.json'] : [])], { cwd: dir, onLine: push });
|
|
549
646
|
let checkJson: unknown = null;
|
|
550
647
|
try { checkJson = JSON.parse(await readFile(join(dir, 'check', 'check.json'), 'utf8')); } catch { /* check refused before writing it; its line says why */ }
|
|
551
648
|
steps.check(checkSummary(checkJson, check.exitCode, check.lines, firstRefusal(check.lines)), check.wallMs);
|
|
@@ -590,28 +687,68 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
590
687
|
* #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
|
|
591
688
|
* inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
|
|
592
689
|
*/
|
|
593
|
-
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null): Promise<void> {
|
|
690
|
+
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> {
|
|
594
691
|
const args = job.request.patch.op.args, producer = resolved.producer;
|
|
595
692
|
let counts: RemeshCounts | null = null;
|
|
596
693
|
let built: ProcessResult | null = null;
|
|
694
|
+
/** #111: a cloth job whose written config declares limbs is built as `build` does; otherwise as `rig --config`. */
|
|
695
|
+
const building = job.op === 'build' || (job.op === 'cloth' && job.cloth?.route === 'build');
|
|
696
|
+
/** #115 (and #111's build route): assemble → rig → check on `config.json`, already written into the scratch directory. */
|
|
697
|
+
async function runBuild(push: (line: string) => void, what: string): Promise<ProcessResult> {
|
|
698
|
+
// #112: build forwards `--environment` to its check stage (rig-parts build --help, 1.48.0).
|
|
699
|
+
if (world) await writeFile(join(dir, 'environment.json'), world.text, { mode: 0o600 });
|
|
700
|
+
push(`review: ${producer.package} ${producer.version} build --config config.json (${what}) --source <handoff source> --full <handoff full>${plan!.head ? ' --head <handoff head>' : ''} --seam-pairs${world ? ` --environment environment.json (the exported schedule, ${world.record.steps} step(s), sha256 ${world.record.sha256.slice(0, 16)})` : ''} → scratch out (assemble → rig → check)`);
|
|
701
|
+
// `--seam-pairs` (forwarded to check): the setup-pose seam split by pair on a pass too, so every Apply shows its `seam pair` lines.
|
|
702
|
+
built = await runTool(producer.bin, ['build', '--config', 'config.json', '--source', plan!.source, '--full', plan!.full, ...(plan!.head ? ['--head', plan!.head] : []), '--out', 'out', '--seam-pairs', ...(world ? ['--environment', 'environment.json'] : []), ...args], { cwd: dir, onLine: push });
|
|
703
|
+
const readout = buildReadout(built.lines);
|
|
704
|
+
// A check that ran and found a bar not met is check's judgment, kept beside the build (as for a `rig` patch):
|
|
705
|
+
// build prints "stopped at check; N bar(s) not met" (a check that refused its input says "no later stage ran").
|
|
706
|
+
const stoppedAtCheck = built.exitCode !== 0 && built.lines.some(l => /^build: stopped at check; \d+ bar\(s\) not met/.test(l.trim())) && (await exists(join(dir, 'out', 'rig', 'rig.json')));
|
|
707
|
+
job.assembled = { ...job.assembled!, refusals: stoppedAtCheck ? [] : readout.refusals, checkFails: stoppedAtCheck ? readout.refusals : [], lines: readout.assemble, seam: readout.seam, seamPairs: readout.seamPairs, recomposite: { ...job.assembled!.recomposite, line: readout.recomposite } };
|
|
708
|
+
const lines = buildLines(built.lines);
|
|
709
|
+
return { ...built, exitCode: stoppedAtCheck ? 0 : built.exitCode, lines, stdout: lines };
|
|
710
|
+
}
|
|
711
|
+
/** #111: propose writes the config, review checks the written diff, then the written config is built. */
|
|
712
|
+
async function runCloth(push: (line: string) => void): Promise<ProcessResult> {
|
|
713
|
+
const c = job.cloth!, plan2 = cloth!;
|
|
714
|
+
// cloth.json: the patched handoff config with every path it names made absolute (the scratch copy reads the
|
|
715
|
+
// handoff's files), so the config propose writes beside it builds from the same files.
|
|
716
|
+
const input = JSON.parse(scratchConfigText(inputs.config, patched)) as Record<string, unknown>;
|
|
717
|
+
absolutizeBuildFiles(input, value => (isAbsolute(value) ? value : resolve(dirname(inputs.config), value)));
|
|
718
|
+
await writeFile(join(dir, 'cloth.json'), JSON.stringify(input, null, 2) + '\n', { mode: 0o600 });
|
|
719
|
+
push(`review: ${producer.package} ${producer.version} propose --parts <handoff parts> --source <handoff source> --keypoints <handoff keypoints> --out propose --cloth cloth.json (the handoff config with ${job.request.patch.ops.length} patch op(s)) --write-config config.json`);
|
|
720
|
+
const proposed = await runTool(producer.bin, ['propose', '--parts', inputs.parts, '--source', plan2.source, '--keypoints', plan2.keypoints, '--out', 'propose', '--cloth', 'cloth.json', '--write-config', 'config.json'], { cwd: dir, onLine: push });
|
|
721
|
+
c.wallMs.propose = proposed.wallMs;
|
|
722
|
+
if (proposed.exitCode !== 0) { c.refusals = proposed.lines.filter(l => /^\s*FAIL\s/.test(l)).map(l => l.trim()).slice(0, MAX_BUILD_READOUT); return proposed; }
|
|
723
|
+
c.line = proposed.stdout.map(l => l.trim()).find(l => l.startsWith('wrote config.json:')) ?? null;
|
|
724
|
+
const written = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) as unknown;
|
|
725
|
+
c.written = writtenDiff(input, written);
|
|
726
|
+
c.links = clothLinks(written, plan2.partsJson, plan2.targets);
|
|
727
|
+
if (c.written.outside.length) {
|
|
728
|
+
const line = `${CLOTH_WRITTEN_OUTSIDE_DRAFTED}: the config propose wrote differs from cloth.json outside the sections it splices, at ${c.written.outside.slice(0, 8).join(', ')}${c.written.outside.length > 8 ? ', …' : ''}; not built`;
|
|
729
|
+
push(`review: ${line}`);
|
|
730
|
+
return { exitCode: 1, lines: [line], stdout: [], wallMs: proposed.wallMs };
|
|
731
|
+
}
|
|
732
|
+
push(`review: the written config differs from cloth.json at ${c.written.count} path(s), every one under the sections propose splices${c.written.sections.length ? ` (${c.written.sections.join(', ')})` : ''}`);
|
|
733
|
+
let op: ProcessResult;
|
|
734
|
+
if (c.route === 'build') op = await runBuild(push, 'the config propose wrote');
|
|
735
|
+
else {
|
|
736
|
+
push(`review: ${producer.package} ${producer.version} rig --config config.json (the config propose wrote) → scratch rig`);
|
|
737
|
+
op = await runTool(producer.bin, ['rig', '--config', 'config.json', '--parts', inputs.parts, '--out', 'rig', ...args], { cwd: dir, onLine: push });
|
|
738
|
+
}
|
|
739
|
+
c.wallMs.build = op.wallMs;
|
|
740
|
+
return { ...op, wallMs: proposed.wallMs + op.wallMs };
|
|
741
|
+
}
|
|
597
742
|
return apply(job, dir, inputs, resolved, {
|
|
598
|
-
cmd: job.op
|
|
743
|
+
cmd: job.op === 'cloth' ? 'propose' : job.op, spec: building ? 'out/rig' : 'rig',
|
|
599
744
|
async run(push) {
|
|
745
|
+
if (job.op === 'cloth' && cloth) return runCloth(push);
|
|
600
746
|
if (job.op === 'build' && plan) {
|
|
601
747
|
// #115: assemble → rig → check from the painting and the runs, the patched config's assemble-read files absolute.
|
|
602
748
|
const doc = JSON.parse(scratchConfigText(inputs.config, patched)) as Record<string, unknown>;
|
|
603
749
|
absolutizeBuildFiles(doc, value => (isAbsolute(value) ? value : resolve(dirname(inputs.config), value)));
|
|
604
750
|
await writeFile(join(dir, 'config.json'), JSON.stringify(doc, null, 2) + '\n', { mode: 0o600 });
|
|
605
|
-
push
|
|
606
|
-
// `--seam-pairs` (forwarded to check): the setup-pose seam split by pair on a pass too, so every Apply shows its `seam pair` lines.
|
|
607
|
-
built = await runTool(producer.bin, ['build', '--config', 'config.json', '--source', plan.source, '--full', plan.full, ...(plan.head ? ['--head', plan.head] : []), '--out', 'out', '--seam-pairs', ...args], { cwd: dir, onLine: push });
|
|
608
|
-
const readout = buildReadout(built.lines);
|
|
609
|
-
// A check that ran and found a bar not met is check's judgment, kept beside the build (as for a `rig` patch):
|
|
610
|
-
// build prints "stopped at check; N bar(s) not met" (a check that refused its input says "no later stage ran").
|
|
611
|
-
const stoppedAtCheck = built.exitCode !== 0 && built.lines.some(l => /^build: stopped at check; \d+ bar\(s\) not met/.test(l.trim())) && (await exists(join(dir, 'out', 'rig', 'rig.json')));
|
|
612
|
-
job.assembled = { ...job.assembled!, refusals: stoppedAtCheck ? [] : readout.refusals, checkFails: stoppedAtCheck ? readout.refusals : [], lines: readout.assemble, seam: readout.seam, seamPairs: readout.seamPairs, recomposite: { ...job.assembled!.recomposite, line: readout.recomposite } };
|
|
613
|
-
const lines = buildLines(built.lines);
|
|
614
|
-
return { ...built, exitCode: stoppedAtCheck ? 0 : built.exitCode, lines, stdout: lines };
|
|
751
|
+
return runBuild(push, `the handoff config with ${job.request.patch.ops.length} patch op(s)`);
|
|
615
752
|
}
|
|
616
753
|
if (job.op === 'retune') {
|
|
617
754
|
// retune reads the inspected rig and writes only --out; what it holds it refuses by name (RETUNE_REQUEST_HELD).
|
|
@@ -634,7 +771,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
634
771
|
},
|
|
635
772
|
wallMs: ms => { job.wallMs.op = ms; },
|
|
636
773
|
async flags(push) {
|
|
637
|
-
if (
|
|
774
|
+
if (building && built) {
|
|
638
775
|
job.exitCode = built.exitCode;
|
|
639
776
|
const lines = buildLines(built.lines);
|
|
640
777
|
job.producerLine = lines.filter(l => /^build:\s/.test(l)).at(-1) ?? job.producerLine;
|
|
@@ -659,8 +796,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
659
796
|
}, {
|
|
660
797
|
diff: files => { const rigJson = files.documents.find(d => d.file === 'rig.json'); job.files = files; job.diff = rigJson ? { count: rigJson.count, paths: rigJson.paths } : null; },
|
|
661
798
|
// #115: build runs check itself, on the parts it assembled; its check.json is read after the op (flags).
|
|
662
|
-
...(
|
|
799
|
+
...(building ? {} : { check: (summary: CheckSummary, wallMs: number | null) => { job.wallMs.check = wallMs; job.check = summary; } }),
|
|
800
|
+
// #139: a retune Apply reads the check retune ran on its revision (retune.json, bound by its hash rows) unless the
|
|
801
|
+
// Apply checks under an exported world (retune's check reads the default one) or the producer carries no line.
|
|
802
|
+
...(job.op === 'retune' ? { carried: (push: (line: string) => void) => retuneCarried(join(dir, 'rig'), world, push) } : {}),
|
|
663
803
|
hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
|
|
804
|
+
world,
|
|
664
805
|
});
|
|
665
806
|
}
|
|
666
807
|
|
|
@@ -670,7 +811,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
670
811
|
* on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
|
|
671
812
|
* the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
|
|
672
813
|
*/
|
|
673
|
-
async function startPatch(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
814
|
+
async function startPatch(input: unknown, world: ApplyWorld | null = null): Promise<{ status: number; body: unknown }> {
|
|
674
815
|
let patch: ConfigPatch;
|
|
675
816
|
try { patch = parseConfigPatch(input); } catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
676
817
|
assertStateOutside();
|
|
@@ -685,27 +826,44 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
685
826
|
if (patch.op.cmd === 'remesh') part = remeshPart(patch);
|
|
686
827
|
} catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
687
828
|
assertNamedFilesHeld(original, patched);
|
|
829
|
+
// #111: a cloth recipe patch (every op under /cloth) runs propose --cloth --write-config, then builds what it wrote.
|
|
830
|
+
// Decision 1: without the declared keypoints the draft moves the body bones, so it is refused before anything else.
|
|
831
|
+
const clothKind = clothPatchKind(patch.ops);
|
|
832
|
+
if (clothKind === 'mixed') throw new RebuildError('CLOTH_PATCH_MIXED', CLOTH_PATCH_MIXED.slice('CLOTH_PATCH_MIXED: '.length));
|
|
833
|
+
const clothing = clothKind === 'cloth' && patch.op.cmd === 'rig';
|
|
834
|
+
if (clothing && (!p.handoff!.keypoints || !p.handoff!.record.keypoints)) throw new RebuildError('CLOTH_KEYPOINTS_UNDECLARED', CLOTH_KEYPOINTS_UNDECLARED.slice('CLOTH_KEYPOINTS_UNDECLARED: '.length));
|
|
688
835
|
const resolved = resolveProducer(options.starts);
|
|
689
836
|
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
|
|
690
837
|
if (patch.producer.name !== resolved.producer.package || patch.producer.version !== resolved.producer.version) throw new RebuildError('EDIT_PRODUCER_MISMATCH', `the patch was made for ${patch.producer.name} ${patch.producer.version}; the installed producer is ${resolved.producer.package} ${resolved.producer.version}`);
|
|
838
|
+
// #112: `check --environment` is @rig-c/parts 1.48.0's (parts#538); an older producer is refused by name, nothing run.
|
|
839
|
+
if (world && !versionAtLeast(resolved.producer.version, CHECK_ENVIRONMENT_SINCE)) { const error = modelingProducerTooOld(resolved.producer.version || 'unknown', CHECK_ENVIRONMENT_SINCE, 'rig-parts check --environment'); return { status: 200, body: { status: 'refused', error, producerLine: error } }; }
|
|
691
840
|
// #136: the whole-rig rebuild is the fast path (~18 s) and gives the same bytes when the baseline is reproduced,
|
|
692
841
|
// so it stays the default. Only when the config cannot reproduce the inspected rig (a repaired build) does a
|
|
693
842
|
// patch inside retune's scope run retune, which holds the rest of the inspected rig itself (~52 s).
|
|
694
843
|
// #115: a cut, patch or limb edit (or a bone edit on a config with limbs) is read by assemble, so it runs `build`.
|
|
695
|
-
|
|
844
|
+
let clothPlan: ClothPlan | null = null;
|
|
845
|
+
if (clothing) {
|
|
846
|
+
if (!versionAtLeast(resolved.producer.version, CLOTH_WRITE_SINCE)) { const error = modelingProducerTooOld(resolved.producer.version || 'unknown', CLOTH_WRITE_SINCE, 'rig-parts propose --cloth --write-config (entry by entry)'); return { status: 200, body: { status: 'refused', error, producerLine: error } }; }
|
|
847
|
+
if (!p.handoff!.source) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', CLOTH_SOURCE_UNAVAILABLE.slice('MODELING_INPUT_UNAVAILABLE: '.length));
|
|
848
|
+
let partsJson: unknown = null;
|
|
849
|
+
try { partsJson = JSON.parse(await readFile(join(inputs.parts, 'parts.json'), 'utf8')); } catch { /* propose reads parts.json itself and refuses by name */ }
|
|
850
|
+
clothPlan = { source: p.handoff!.source, keypoints: p.handoff!.keypoints!, partsJson, targets: (await clothTargets(resolved.producer.root, inputs.configText)).tags };
|
|
851
|
+
}
|
|
852
|
+
// Decision 2 (#111): the written config is built as #115 builds when it declares limbs, else by `rig --config`.
|
|
853
|
+
const building = patch.op.cmd === 'rig' && (needsBuild(patch.ops, original) || (clothing && !!original && typeof original === 'object' && !Array.isArray(original) && (original as Record<string, unknown>).limbs !== undefined));
|
|
696
854
|
let plan: BuildPlan | null = null;
|
|
697
855
|
if (building) {
|
|
698
856
|
const held = newBuildFile(original, patched);
|
|
699
857
|
if (held) throw new RebuildError('EDIT_PATCH_PATH_HELD', `${held} names a file the handoff config does not; a patch may not name a new file (review sends no path)`);
|
|
700
858
|
plan = await buildPlan(p.handoff!, patched, abs => relative(p.root, abs).split(sep).join('/'));
|
|
701
859
|
}
|
|
702
|
-
const route = patch.op.cmd === 'remesh' ? 'remesh' : building ? 'build' : patchRoute(patch.ops, resolved.producer.version);
|
|
860
|
+
const route = patch.op.cmd === 'remesh' ? 'remesh' : clothing ? 'cloth' : building ? 'build' : patchRoute(patch.ops, resolved.producer.version);
|
|
703
861
|
const baselineNow = route === 'retune' ? await settledBaseline() : null;
|
|
704
862
|
const op: AppliedOp = route === 'retune' && baselineNow?.status === 'reproduced' ? 'rig' : route;
|
|
705
|
-
if (op === 'rig' || op === 'build') {
|
|
863
|
+
if (op === 'rig' || op === 'build' || op === 'cloth') {
|
|
706
864
|
try { assertWholeRigBaseline(await settledBaseline()); } catch (e) { if (e instanceof BaselineError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
707
865
|
}
|
|
708
|
-
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 } : {}) });
|
|
866
|
+
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 } : {}) });
|
|
709
867
|
const current = jobs.get(id);
|
|
710
868
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
711
869
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
@@ -713,9 +871,11 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
713
871
|
const reused = await reuse<PatchJob>(dir, id);
|
|
714
872
|
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
715
873
|
const job: PatchJob = { schema: REBUILD_SCHEMA, kind: 'patch', id, status: 'running', reused: false, op, request: { patch, patchSha256: patchSha256(patch), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, exitCode: null, wallMs: { op: null, compile: null, check: null }, check: null, diff: null, files: null, remesh: null, build: null,
|
|
716
|
-
...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {})
|
|
874
|
+
...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
|
|
875
|
+
...(world ? { environment: world.record } : {}),
|
|
876
|
+
...(clothPlan ? { cloth: { route: building ? 'build' as const : 'rig' as const, inputs: { source: p.handoff!.record.source?.path ?? '', keypoints: { ...p.handoff!.record.keypoints! } }, refusals: [], line: null, written: null, links: [], wallMs: { propose: null, build: null } } } : {}) };
|
|
717
877
|
jobs.set(id, job); running = id;
|
|
718
|
-
void executePatch(job, dir, inputs, resolved, patched, part, plan).finally(() => { if (running === id) running = null; });
|
|
878
|
+
void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
|
|
719
879
|
return { status: 202, body: snapshot(job) };
|
|
720
880
|
}
|
|
721
881
|
|
|
@@ -841,6 +1001,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
841
1001
|
await writeFile(join(out, 'motion.json'), text, { mode: 0o600 });
|
|
842
1002
|
let frameTimes: number[] | null = null;
|
|
843
1003
|
try { const report = JSON.parse(await readFile(join(dir, 'motion.report.json'), 'utf8')) as { frameTimes?: unknown }; if (Array.isArray(report.frameTimes) && report.frameTimes.every(t => typeof t === 'number')) frameTimes = report.frameTimes as number[]; } catch { /* no report: the lines' own t= */ }
|
|
1004
|
+
const clock = samplesClock(frameTimes); job.frameTimes = clock?.kind === 'samples' ? clock.frameTimes : null;
|
|
844
1005
|
job.readings = retargetLines(retargetRun?.lines ?? [], frameTimes).slice(0, MAX_RETARGET_LINES);
|
|
845
1006
|
let inspected: unknown = null;
|
|
846
1007
|
try { inspected = JSON.parse(await readFile(join(inputs.rig, 'motion.json'), 'utf8')); } catch { /* a rig without motion.json: nothing to keep */ }
|
|
@@ -881,7 +1042,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
881
1042
|
const dir = rebuildDir(options.stateDir, p.sourceHash, id);
|
|
882
1043
|
const reused = await reuse<RetargetJob>(dir, id);
|
|
883
1044
|
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
884
|
-
const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], build: null };
|
|
1045
|
+
const job: RetargetJob = { schema: REBUILD_SCHEMA, kind: 'retarget', id, status: 'running', reused: false, op: 'retarget', request: { record, recordSha256: sha256(retargetRecordText(record)), configHash: inputs.configHash }, producer: tool(resolved.producer), compiler: tool(resolved.compiler), lines: [], producerLine: null, refusals: [], exitCode: null, wallMs: { clean: null, retarget: null, compile: null }, files: null, motion: null, readings: [], frameTimes: null, build: null };
|
|
885
1046
|
jobs.set(id, job); running = id;
|
|
886
1047
|
void executeRetarget(job, dir, inputs, resolved, sequence).finally(() => { if (running === id) running = null; });
|
|
887
1048
|
return { status: 202, body: snapshot(job) };
|
|
@@ -895,8 +1056,11 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
895
1056
|
}
|
|
896
1057
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
|
|
897
1058
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
|
|
898
|
-
|
|
899
|
-
|
|
1059
|
+
// #112: `{patch, environment}` — the exported schedule Apply's check reads.
|
|
1060
|
+
const keys = Object.keys(input).sort().join(',');
|
|
1061
|
+
if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
|
|
1062
|
+
const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
|
|
1063
|
+
return startPatch((input as { patch: unknown }).patch, world);
|
|
900
1064
|
}
|
|
901
1065
|
const request = parseRebuildRequest(input, options.bones);
|
|
902
1066
|
assertStateOutside();
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
/**
|
|
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
|
|
9
|
+
* `sides = { input: checkSide(inCheck), revision: checkSide(revCheck) }`.
|
|
10
|
+
*
|
|
11
|
+
* 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.
|
|
14
|
+
*/
|
|
15
|
+
import { createHash } from 'node:crypto';
|
|
16
|
+
import { readdir, readFile } from 'node:fs/promises';
|
|
17
|
+
import { join, relative, sep } from 'node:path';
|
|
18
|
+
import { CHECK_SUMMARY_SCHEMA, type CheckSummary } from './applied-context.ts';
|
|
19
|
+
|
|
20
|
+
/** The first `@rig-c/parts` release whose `retune.json` check sides carry `line` and `status` (CHANGELOG 1.49.0, parts#541). */
|
|
21
|
+
export const RETUNE_CHECK_SINCE = '1.49.0';
|
|
22
|
+
/** retune's provenance file, at the revision's root (parts `RETUNE_FILE`). */
|
|
23
|
+
export const RETUNE_FILE = 'retune.json';
|
|
24
|
+
export const RETUNE_CHECK_STALE = 'RETUNE_CHECK_STALE';
|
|
25
|
+
|
|
26
|
+
const isObject = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
27
|
+
/** check.json's bar keys as review's `checkSummary` reads them: an upper-case name. */
|
|
28
|
+
const BAR = /^[A-Z][A-Z0-9_]+$/;
|
|
29
|
+
const STATUSES = new Set(['PASS', 'FAIL', 'SKIP', 'WARN', 'CLEAR', 'REPORTED']);
|
|
30
|
+
|
|
31
|
+
export interface RetuneRevisionRow { path: string; bytes: number; sha256: string }
|
|
32
|
+
export interface CarriedCheck { pass: boolean; line: string; status: Record<string, string> }
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* `check.revision`'s carried fields, or null when there is no such side or it carries neither `line` nor `status` (a producer before
|
|
36
|
+
* {@link RETUNE_CHECK_SINCE}). A side with one of them and not the other, or with a malformed one, throws: that is not
|
|
37
|
+
* an older producer and not a check review can record.
|
|
38
|
+
*/
|
|
39
|
+
export function carriedCheck(retune: unknown): CarriedCheck | null {
|
|
40
|
+
const side = isObject(retune) && isObject(retune.check) ? retune.check.revision : undefined;
|
|
41
|
+
// No side at all carries neither field either: check runs, as for an older producer.
|
|
42
|
+
if (!isObject(side)) return null;
|
|
43
|
+
const hasLine = Object.hasOwn(side, 'line'), hasStatus = Object.hasOwn(side, 'status');
|
|
44
|
+
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`);
|
|
49
|
+
return { pass: side.pass, line: side.line, status: side.status as Record<string, string> };
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The carried side in the summary shape review records for a check it ran (`checkSummary`), field by field:
|
|
54
|
+
* `pass` ← `pass` (`figures.PASS === true`, check.json's `PASS`); `exitCode` ← 0 when it passes, else 1 (`rig-parts
|
|
55
|
+
* check` exits `r.figures.PASS ? EXIT_OK : EXIT_REFUSED`); `line` ← `line` (check's last line, byte for byte);
|
|
56
|
+
* `bars` ← every upper-case entry of `status` (check.json's top-level blocks with a `status`, in the file's order; the
|
|
57
|
+
* 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.
|
|
59
|
+
*/
|
|
60
|
+
export function retuneCheckSummary(carried: CarriedCheck): CheckSummary {
|
|
61
|
+
const bars: Record<string, string> = {};
|
|
62
|
+
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' };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** 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}`;
|
|
71
|
+
return rows as RetuneRevisionRow[];
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function files(root: string): Promise<string[]> {
|
|
75
|
+
const out: string[] = [];
|
|
76
|
+
const walk = async (dir: string) => { for (const e of await readdir(dir, { withFileTypes: true })) { const full = join(dir, e.name); if (e.isDirectory()) await walk(full); else out.push(relative(root, full).split(sep).join('/')); } };
|
|
77
|
+
await walk(root);
|
|
78
|
+
return out.sort();
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 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).
|
|
85
|
+
*/
|
|
86
|
+
export async function revisionBinding(dir: string, retune: unknown): Promise<string | null> {
|
|
87
|
+
const rows = revisionRows(retune);
|
|
88
|
+
if (typeof rows === 'string') return rows;
|
|
89
|
+
const onDisk = await files(dir), named = new Set(rows.map(r => r.path));
|
|
90
|
+
for (const row of rows) {
|
|
91
|
+
let actual = 'absent';
|
|
92
|
+
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`;
|
|
94
|
+
}
|
|
95
|
+
for (const path of onDisk) if (path !== RETUNE_FILE && !named.has(path)) {
|
|
96
|
+
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`;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -12,6 +12,7 @@ import { ReadingsError } from './readings.ts';
|
|
|
12
12
|
import { BONES_CHECK_LIMIT, createBonesCheck } from './bones-check.ts';
|
|
13
13
|
import { createRequirementsCheck } from './requirements-run.ts';
|
|
14
14
|
import { REQUIREMENTS_LIMIT, RequirementsError } from './requirements.ts';
|
|
15
|
+
import { createEnvironmentCheck, ENVIRONMENT_CHECK_LIMIT, EnvironmentCheckError } from './environment-check.ts';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
16
17
|
import { join } from 'node:path';
|
|
17
18
|
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'};
|
|
@@ -38,11 +39,13 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
38
39
|
const bonesCheck=createBonesCheck(p,{stateDir,starts,unchanged});
|
|
39
40
|
// Declared requirements (#113): review writes the file into scratch; the installed producer's check measures it.
|
|
40
41
|
const requirements=createRequirementsCheck(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
|
|
42
|
+
// Check under this world (#112): the exported schedule written into scratch; the installed producer's check --environment reads it.
|
|
43
|
+
const environmentCheck=createEnvironmentCheck(p,{stateDir,starts,unchanged,latest:rebuilds.latest});
|
|
41
44
|
const control=options.control?createControlServer({identity:options.control,sourceHash:p.sourceHash,mode:'single',unchanged,activity:options.activity}):null;
|
|
42
45
|
const handle=async(req:Request,port:number|undefined):Promise<Response>=>{try {
|
|
43
46
|
const url=new URL(req.url);const origin=`http://127.0.0.1:${port}`;
|
|
44
47
|
if(url.origin!==origin)return json({error:'Loopback host required'},403);
|
|
45
|
-
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability);if(refused)return refused;
|
|
48
|
+
const refused=openNonceGate(req,options.open)??accessGate(req,options.capability,!!control);if(refused)return refused;
|
|
46
49
|
const controlled=await control?.(req,origin);if(controlled)return controlled;
|
|
47
50
|
options.activity?.();const gate=openGate(req,options.open);if(gate){if(gate.status===200){try{await unchanged();}catch{return staleOpenStatus(options.open!);}}return gate;}
|
|
48
51
|
if(req.method==='PUT'&&url.pathname==='/api/session'){
|
|
@@ -66,9 +69,14 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
66
69
|
try{const result=await requirements.start(await bodyJson(req,REQUIREMENTS_LIMIT,'REQUIREMENTS_BODY_LIMIT: maximum 64 KiB'));return json(result.body,result.status);}
|
|
67
70
|
catch(e){if(e instanceof RequirementsError)return json({status:'refused',error:e.message},e.code==='REQUIREMENTS_REQUEST_INVALID'?400:200);throw e;}
|
|
68
71
|
}
|
|
72
|
+
if(req.method==='POST'&&url.pathname==='/api/environment-check'){
|
|
73
|
+
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
74
|
+
try{const result=await environmentCheck.start(await bodyJson(req,ENVIRONMENT_CHECK_LIMIT,'ENVIRONMENT_CHECK_BODY_LIMIT: maximum 64 KiB'));return json(result.body,result.status);}
|
|
75
|
+
catch(e){if(e instanceof EnvironmentCheckError)return json({status:'refused',error:e.message},e.code==='ENVIRONMENT_CHECK_REQUEST_INVALID'?400:200);throw e;}
|
|
76
|
+
}
|
|
69
77
|
if(req.method==='POST'&&url.pathname==='/api/readings'){
|
|
70
78
|
if(req.headers.get('origin')!==origin)return json({error:'Same-origin Origin required'},403);
|
|
71
|
-
try{const result=await readings.start(await bodyJson(req,
|
|
79
|
+
try{const result=await readings.start(await bodyJson(req,64*1024,'READINGS_BODY_LIMIT: maximum 64 KiB'));return json(result.body,result.status);}
|
|
72
80
|
catch(e){if(e instanceof ReadingsError)return json({status:'refused',error:e.message},e.code==='READINGS_REQUEST_INVALID'?400:200);throw e;}
|
|
73
81
|
}
|
|
74
82
|
if(req.method==='POST'&&url.pathname==='/api/strip'){
|
|
@@ -85,6 +93,8 @@ export async function startServer(p:ReviewPackage,options:{port?:number;stateDir
|
|
|
85
93
|
if(url.pathname==='/api/requirements')return json(await requirements.describe());
|
|
86
94
|
const requirementsStatus=/^\/api\/requirements\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
87
95
|
if(requirementsStatus){try{return json(requirements.read(requirementsStatus[1]!));}catch(e){if(e instanceof RequirementsError)return json({error:e.message},404);throw e;}}
|
|
96
|
+
const environmentCheckStatus=/^\/api\/environment-check\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
97
|
+
if(environmentCheckStatus){try{return json(environmentCheck.read(environmentCheckStatus[1]!));}catch(e){if(e instanceof EnvironmentCheckError)return json({error:e.message},404);throw e;}}
|
|
88
98
|
const readingsStatus=/^\/api\/readings\/([a-f0-9]{64})$/.exec(url.pathname);
|
|
89
99
|
if(readingsStatus){try{return json(readings.read(readingsStatus[1]!));}catch(e){if(e instanceof ReadingsError)return json({error:e.message},404);throw e;}}
|
|
90
100
|
const rebuildFile=/^\/rebuild\/([a-f0-9]{64})\/(.+)$/.exec(url.pathname);
|
package/src/session.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { parseEnvironmentContext } from './environment.ts';
|
|
|
9
9
|
import { appliedCheckRefusal, parseAppliedContext } from './applied-context.ts';
|
|
10
10
|
import { parseReadingsContext } from './readings.ts';
|
|
11
11
|
import { parseRequirementsContext } from './requirements.ts';
|
|
12
|
+
import { parseScheduleContext } from './environment-schedule.ts';
|
|
12
13
|
import { choice, object, string, type Json, type ReviewPackage, type ReviewedSource } from './manifest.ts';
|
|
13
14
|
export interface Feedback {id:string;frameStart:number;frameEnd:number;target:{kind:'bone'|'slot'|'rig';name:string};category:'defect'|'quality';summary:string;context?:Json}
|
|
14
15
|
export function parseFeedback(item:unknown,strict=true):Feedback {
|
|
@@ -37,6 +38,8 @@ export function validatePackageFeedback(feedback:Feedback,p:ReviewPackage,animat
|
|
|
37
38
|
if(context.readings!==undefined)parseReadingsContext(context.readings);
|
|
38
39
|
// The declared requirements (#113): absent or null stays valid; a present record carries the file's text and its sha256, recomputed here.
|
|
39
40
|
if(context.requirements!==undefined)parseRequirementsContext(context.requirements,text=>createHash('sha256').update(text).digest('hex'));
|
|
41
|
+
// The exported environment schedule (#112): absent or null stays valid; a present record carries its bytes and their sha256, recomputed here.
|
|
42
|
+
if(context.environmentSchedule!==undefined)parseScheduleContext(context.environmentSchedule,text=>createHash('sha256').update(text).digest('hex'));
|
|
40
43
|
const selected=animation??(typeof context.animation==='string'?context.animation:null);
|
|
41
44
|
validateFeedbackSelection(p.feedbackCatalog,{frameStart:feedback.frameStart,frameEnd:feedback.frameEnd,target:feedback.target,animation:selected});
|
|
42
45
|
return {...feedback,context:{...context,...(animation===undefined?{}:{animation}),interval} as Json};
|