@rig-c/review 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +21 -4
- package/docs/DESIGN.md +2 -1
- package/docs/REVIEW_PACKAGE.md +16 -6
- 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/environment-check.ts +149 -0
- package/src/environment-schedule.ts +183 -0
- package/src/feedback-export.ts +8 -1
- package/src/front.ts +4 -1
- package/src/handoff.ts +20 -13
- package/src/open.ts +1 -1
- package/src/readings-run.ts +2 -1
- package/src/readings.ts +12 -6
- package/src/rebuild.ts +185 -32
- package/src/retune-check.ts +100 -0
- package/src/server.ts +11 -1
- package/src/session.ts +3 -0
- package/web/cloth-panel.ts +168 -0
- package/web/environment-world.ts +42 -0
- package/web/index.html +1 -1
- package/web/main.ts +112 -9
- package/web/readings-panel.ts +1 -1
- package/web/rebuild-client.ts +13 -8
- package/web/rig-overlays.ts +2 -1
- package/web/styles.css +2 -0
package/src/rebuild.ts
CHANGED
|
@@ -24,8 +24,11 @@ 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';
|
|
29
32
|
import { cleanArgs, mapText, motionSummary, parseRetargetRequest, retargetArgs, retargetLines, retargetRecord, retargetRecordText, RetargetError, type MotionSummary, type RetargetLine, type RetargetRecord } from './retarget.ts';
|
|
30
33
|
|
|
31
34
|
export const REBUILD_SCHEMA = 'rig-review-rebuild/1';
|
|
@@ -227,9 +230,46 @@ export interface PatchJob {
|
|
|
227
230
|
build: RebuildBuild | null;
|
|
228
231
|
/** #115, a `build` job only: what the assemble stage read and wrote (absent on every other op, so their jobs are unchanged). */
|
|
229
232
|
assembled?: AssembledView;
|
|
233
|
+
/** #112: the `rigc-environment/1` schedule check read (`--environment`), by hash; absent when Apply ran under the default world. */
|
|
234
|
+
environment?: ApplyWorld['record'];
|
|
235
|
+
/** #111, a `cloth` job only: what `propose --cloth --write-config` wrote and how the written config was built. */
|
|
236
|
+
cloth?: ClothJob;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* #111: a cloth recipe Apply — `rig-parts propose --cloth cloth.json --write-config config.json` on the handoff's parts,
|
|
240
|
+
* painting and keypoints, then the written config built as `build` (the config declares `limbs`) or `rig --config`.
|
|
241
|
+
* `refusals`: every FAIL line propose printed, verbatim; `line`: propose's own `wrote config.json: …` line; `written`:
|
|
242
|
+
* every JSON Pointer at which the written config differs from cloth.json (the patched handoff config), and those outside
|
|
243
|
+
* the sections propose splices (none, or the Apply is refused); `links`: the written config's chains per tag.
|
|
244
|
+
*/
|
|
245
|
+
export interface ClothJob {
|
|
246
|
+
route: 'build' | 'rig';
|
|
247
|
+
inputs: { source: string; keypoints: { path: string; sha256: string } };
|
|
248
|
+
refusals: string[]; line: string | null; written: WrittenDiff | null; links: ClothTagView[];
|
|
249
|
+
wallMs: { propose: number | null; build: number | null };
|
|
250
|
+
}
|
|
251
|
+
/** #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. */
|
|
252
|
+
export interface ClothPlan { source: string; keypoints: string; partsJson: unknown; targets: string[] }
|
|
253
|
+
/**
|
|
254
|
+
* The tags the installed reader accepts, read off its own source (`src/cloth.ts`'s `CLOTH_TARGETS`); when that text
|
|
255
|
+
* does not state them, the tags the config's `cloth` block already declares (and `read: false`).
|
|
256
|
+
*/
|
|
257
|
+
export async function clothTargets(producerRoot: string | null, configText: string): Promise<{ tags: string[]; read: boolean }> {
|
|
258
|
+
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 */ } }
|
|
259
|
+
const cloth = object(JSON.parse(configText), 'config').cloth;
|
|
260
|
+
return { tags: cloth && typeof cloth === 'object' && !Array.isArray(cloth) ? Object.keys(cloth).filter(k => k !== 'note' && !k.endsWith('_note') && !k.startsWith('x-')) : [], read: false };
|
|
261
|
+
}
|
|
262
|
+
/** A written config that differs from its input outside the drafted sections is not built (bar 1, #111). */
|
|
263
|
+
export const CLOTH_WRITTEN_OUTSIDE_DRAFTED = 'CLOTH_WRITTEN_OUTSIDE_DRAFTED';
|
|
264
|
+
/** #112: the exported schedule an Apply's check reads, as written into the scratch directory, and what the job records of it. */
|
|
265
|
+
export interface ApplyWorld { text: string; record: { sha256: string; steps: number } }
|
|
266
|
+
/** `{patch, environment}`'s schedule: parsed as data and re-serialized, so its hash is the exported bytes'. */
|
|
267
|
+
export function parseApplyWorld(value: unknown): ApplyWorld {
|
|
268
|
+
try { const schedule = parseSchedule(value), text = scheduleText(schedule); return { text, record: { sha256: sha256(text), steps: schedule.steps.length } }; }
|
|
269
|
+
catch (e) { if (e instanceof ScheduleError) throw new RebuildError('REBUILD_REQUEST_INVALID', `environment: ${e.message}`); throw e; }
|
|
230
270
|
}
|
|
231
271
|
/** The producer op a patch job ran (#136; `build` since #115). */
|
|
232
|
-
export type AppliedOp = 'rig' | 'retune' | 'remesh' | 'build';
|
|
272
|
+
export type AppliedOp = 'rig' | 'retune' | 'remesh' | 'build' | 'cloth';
|
|
233
273
|
/**
|
|
234
274
|
* #115: a `rig-parts build` job's assemble readout — the inputs it was run with (paths relative to the package root, the
|
|
235
275
|
* assemble-read config files by hash), every FAIL line verbatim, the `cut:` / `limb cut:` / `limb cap:` / uncovered-hole
|
|
@@ -305,9 +345,10 @@ export interface DeltaJob {
|
|
|
305
345
|
* Deterministic: the package, the config bytes, the patch's own bytes and both tool versions. No clock. A retune-routed
|
|
306
346
|
* 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
347
|
*/
|
|
308
|
-
export function patchId(input: { sourceHash: string; configHash: string; patch: ConfigPatch; producer: string; compiler: string; op?: AppliedOp; buildInputs?: string }): string {
|
|
348
|
+
export function patchId(input: { sourceHash: string; configHash: string; patch: ConfigPatch; producer: string; compiler: string; op?: AppliedOp; buildInputs?: string; environment?: string; cloth?: string }): string {
|
|
309
349
|
// #115: a build-routed patch adds its op and the hash of the files assemble reads (painting, runs, named files).
|
|
310
|
-
|
|
350
|
+
// #112: an Apply whose check reads a schedule adds the schedule's hash, so a default-world build is never reused for it.
|
|
351
|
+
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
352
|
}
|
|
312
353
|
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
354
|
/**
|
|
@@ -359,9 +400,35 @@ interface ApplySteps {
|
|
|
359
400
|
/** #125: diff every producer document against the inspected rig, before the compile. */
|
|
360
401
|
diff?: (files: FilesDiff) => void;
|
|
361
402
|
/** #102: check the scratch rig after the compile. */
|
|
362
|
-
check?: (summary: CheckSummary, wallMs: number) => void;
|
|
403
|
+
check?: (summary: CheckSummary, wallMs: number | null) => void;
|
|
404
|
+
/**
|
|
405
|
+
* #139: the check the producer op already ran on the spec it wrote, read instead of running check again: a summary
|
|
406
|
+
* (recorded through `check`, no wall clock), a refusal line (nothing recorded, the build refused), or null — the op
|
|
407
|
+
* carries none, so check runs as for any other Apply.
|
|
408
|
+
*/
|
|
409
|
+
carried?: (push: (line: string) => void) => Promise<{ summary: CheckSummary } | { refused: string } | null>;
|
|
363
410
|
/** After the build is hashed, before result.json is written. */
|
|
364
411
|
hashed?: () => Promise<void>;
|
|
412
|
+
/** #112: the schedule check reads (`--environment environment.json`, written into the scratch directory first). */
|
|
413
|
+
world?: ApplyWorld | null;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* #139: the check `rig-parts retune` ran on the revision it wrote into `spec`, as review's check summary, bound to the
|
|
418
|
+
* revision's files by `retune.json`'s hash rows (`RETUNE_CHECK_STALE` refuses the Apply). Null — check runs as before —
|
|
419
|
+
* when the Apply checks under an exported world (retune's check read the default one) or `retune.json` carries no
|
|
420
|
+
* `line` / `status` (a producer before `RETUNE_CHECK_SINCE`).
|
|
421
|
+
*/
|
|
422
|
+
async function retuneCarried(spec: string, world: ApplyWorld | null, push: (line: string) => void): Promise<{ summary: CheckSummary } | { refused: string } | null> {
|
|
423
|
+
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; }
|
|
424
|
+
const retune = JSON.parse(await readFile(join(spec, RETUNE_FILE), 'utf8')) as unknown;
|
|
425
|
+
const carried = carriedCheck(retune);
|
|
426
|
+
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; }
|
|
427
|
+
const stale = await revisionBinding(spec, retune);
|
|
428
|
+
if (stale) return { refused: stale };
|
|
429
|
+
const rows = (retune as { revision: unknown[] }).revision.length;
|
|
430
|
+
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}`);
|
|
431
|
+
return { summary: retuneCheckSummary(carried) };
|
|
365
432
|
}
|
|
366
433
|
|
|
367
434
|
/** #115: what a build-routed patch runs with — the handoff's painting and runs, absolute; never from the client. */
|
|
@@ -481,6 +548,9 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
481
548
|
cuts: inputs ? { config: configCuts(inputs.configText), build: buildGap(handoff) } : null,
|
|
482
549
|
// #104: the config's idle (tracks, blink, bones, chains) and where the inspected idle keys each track.
|
|
483
550
|
idle: inputs ? await idleStatus(inputs) : null,
|
|
551
|
+
// #111: the cloth recipe form — the reader's tags, the config's recipes as written, each tag's parts and chains (the
|
|
552
|
+
// link preview) and why a cloth Apply cannot run here (no keypoints, no painting, an older producer), or null.
|
|
553
|
+
cloth: inputs && handoff ? await clothStatus(handoff, inputs, resolved) : null,
|
|
484
554
|
producer: resolved.ok ? tool(resolved.producer) : null, compiler: resolved.ok ? tool(resolved.compiler) : null,
|
|
485
555
|
// #114: what the retarget editor offers — the pose producer, the declared sequences, the rig's bones and animations.
|
|
486
556
|
retarget: await retargetStatus(inputs),
|
|
@@ -488,6 +558,19 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
488
558
|
running,
|
|
489
559
|
};
|
|
490
560
|
}
|
|
561
|
+
/** #111: what the cloth recipe form starts from. */
|
|
562
|
+
async function clothStatus(handoff: Handoff, inputs: ProducerInputs, resolved: ReturnType<typeof resolveProducer>) {
|
|
563
|
+
const config = object(JSON.parse(inputs.configText), 'config');
|
|
564
|
+
let partsJson: unknown = null;
|
|
565
|
+
try { partsJson = JSON.parse(await readFile(join(inputs.parts, 'parts.json'), 'utf8')); } catch { /* none: no tag lists its parts */ }
|
|
566
|
+
const targets = await clothTargets(resolved.ok ? resolved.producer.root : null, inputs.configText);
|
|
567
|
+
const reason = !handoff.keypoints || !handoff.record.keypoints ? CLOTH_KEYPOINTS_UNDECLARED
|
|
568
|
+
: !resolved.ok ? resolved.error
|
|
569
|
+
: !versionAtLeast(resolved.producer.version, CLOTH_WRITE_SINCE) ? modelingProducerTooOld(resolved.producer.version || 'unknown', CLOTH_WRITE_SINCE, 'rig-parts propose --cloth --write-config (entry by entry)')
|
|
570
|
+
: !handoff.source ? CLOTH_SOURCE_UNAVAILABLE
|
|
571
|
+
: config.limbs !== undefined && !handoff.full ? BUILD_SOURCE_UNAVAILABLE : null;
|
|
572
|
+
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 };
|
|
573
|
+
}
|
|
491
574
|
/** The inspected build's check once it settles (tests); null when the baseline did not reproduce. */
|
|
492
575
|
async function settledInspectedCheck(): Promise<InspectedCheck | null> { await settledBaseline(); return inspectedCheckRun ? await inspectedCheckRun : inspectedCheck; }
|
|
493
576
|
/** The most recent built scratch build of this session (#108 readings run on it), or null. */
|
|
@@ -496,7 +579,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
496
579
|
const job = latestId ? jobs.get(latestId) : undefined;
|
|
497
580
|
// `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
581
|
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;
|
|
582
|
+
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
583
|
}
|
|
501
584
|
/** The baseline once it settles (tests, and the whole-rig ops of #102/#103 through assertWholeRigBaseline). */
|
|
502
585
|
async function settledBaseline(): Promise<BaselineResult | null> { if (!baselineRun) ensureBaseline(resolveProducer(options.starts)); return baselineRun ? await baselineRun : baseline; }
|
|
@@ -542,10 +625,14 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
542
625
|
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
626
|
job.wallMs.compile = compile.wallMs;
|
|
544
627
|
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
|
-
|
|
628
|
+
const carried = steps.check && steps.carried ? await steps.carried(push) : null;
|
|
629
|
+
if (carried && 'refused' in carried) { job.producerLine = carried.refused; job.status = 'refused'; push(`review: ${carried.refused}`); return; }
|
|
630
|
+
if (steps.check && carried) steps.check(carried.summary, null);
|
|
631
|
+
else if (steps.check) {
|
|
546
632
|
// check measures the scratch rig; a FAIL is its judgment, recorded beside the build, not a refusal of the build.
|
|
547
|
-
|
|
548
|
-
|
|
633
|
+
if (steps.world) await writeFile(join(dir, 'environment.json'), steps.world.text, { mode: 0o600 });
|
|
634
|
+
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)})` : ''}`);
|
|
635
|
+
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
636
|
let checkJson: unknown = null;
|
|
550
637
|
try { checkJson = JSON.parse(await readFile(join(dir, 'check', 'check.json'), 'utf8')); } catch { /* check refused before writing it; its line says why */ }
|
|
551
638
|
steps.check(checkSummary(checkJson, check.exitCode, check.lines, firstRefusal(check.lines)), check.wallMs);
|
|
@@ -590,28 +677,68 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
590
677
|
* #102: the patched config → `rig-parts rig` (whole rig), `retune` (#136: the constraints and the idle spliced onto the
|
|
591
678
|
* inspected rig) or `remesh` (one part's patched entry) → `<dir>/rig`; the tail adds the diff and check.
|
|
592
679
|
*/
|
|
593
|
-
function executePatch(job: PatchJob, dir: string, inputs: ProducerInputs, resolved: Resolved, patched: JsonValue, part: string | null, plan: BuildPlan | null = null): Promise<void> {
|
|
680
|
+
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
681
|
const args = job.request.patch.op.args, producer = resolved.producer;
|
|
595
682
|
let counts: RemeshCounts | null = null;
|
|
596
683
|
let built: ProcessResult | null = null;
|
|
684
|
+
/** #111: a cloth job whose written config declares limbs is built as `build` does; otherwise as `rig --config`. */
|
|
685
|
+
const building = job.op === 'build' || (job.op === 'cloth' && job.cloth?.route === 'build');
|
|
686
|
+
/** #115 (and #111's build route): assemble → rig → check on `config.json`, already written into the scratch directory. */
|
|
687
|
+
async function runBuild(push: (line: string) => void, what: string): Promise<ProcessResult> {
|
|
688
|
+
// #112: build forwards `--environment` to its check stage (rig-parts build --help, 1.48.0).
|
|
689
|
+
if (world) await writeFile(join(dir, 'environment.json'), world.text, { mode: 0o600 });
|
|
690
|
+
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)`);
|
|
691
|
+
// `--seam-pairs` (forwarded to check): the setup-pose seam split by pair on a pass too, so every Apply shows its `seam pair` lines.
|
|
692
|
+
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 });
|
|
693
|
+
const readout = buildReadout(built.lines);
|
|
694
|
+
// A check that ran and found a bar not met is check's judgment, kept beside the build (as for a `rig` patch):
|
|
695
|
+
// build prints "stopped at check; N bar(s) not met" (a check that refused its input says "no later stage ran").
|
|
696
|
+
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')));
|
|
697
|
+
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 } };
|
|
698
|
+
const lines = buildLines(built.lines);
|
|
699
|
+
return { ...built, exitCode: stoppedAtCheck ? 0 : built.exitCode, lines, stdout: lines };
|
|
700
|
+
}
|
|
701
|
+
/** #111: propose writes the config, review checks the written diff, then the written config is built. */
|
|
702
|
+
async function runCloth(push: (line: string) => void): Promise<ProcessResult> {
|
|
703
|
+
const c = job.cloth!, plan2 = cloth!;
|
|
704
|
+
// cloth.json: the patched handoff config with every path it names made absolute (the scratch copy reads the
|
|
705
|
+
// handoff's files), so the config propose writes beside it builds from the same files.
|
|
706
|
+
const input = JSON.parse(scratchConfigText(inputs.config, patched)) as Record<string, unknown>;
|
|
707
|
+
absolutizeBuildFiles(input, value => (isAbsolute(value) ? value : resolve(dirname(inputs.config), value)));
|
|
708
|
+
await writeFile(join(dir, 'cloth.json'), JSON.stringify(input, null, 2) + '\n', { mode: 0o600 });
|
|
709
|
+
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`);
|
|
710
|
+
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 });
|
|
711
|
+
c.wallMs.propose = proposed.wallMs;
|
|
712
|
+
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; }
|
|
713
|
+
c.line = proposed.stdout.map(l => l.trim()).find(l => l.startsWith('wrote config.json:')) ?? null;
|
|
714
|
+
const written = JSON.parse(await readFile(join(dir, 'config.json'), 'utf8')) as unknown;
|
|
715
|
+
c.written = writtenDiff(input, written);
|
|
716
|
+
c.links = clothLinks(written, plan2.partsJson, plan2.targets);
|
|
717
|
+
if (c.written.outside.length) {
|
|
718
|
+
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`;
|
|
719
|
+
push(`review: ${line}`);
|
|
720
|
+
return { exitCode: 1, lines: [line], stdout: [], wallMs: proposed.wallMs };
|
|
721
|
+
}
|
|
722
|
+
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(', ')})` : ''}`);
|
|
723
|
+
let op: ProcessResult;
|
|
724
|
+
if (c.route === 'build') op = await runBuild(push, 'the config propose wrote');
|
|
725
|
+
else {
|
|
726
|
+
push(`review: ${producer.package} ${producer.version} rig --config config.json (the config propose wrote) → scratch rig`);
|
|
727
|
+
op = await runTool(producer.bin, ['rig', '--config', 'config.json', '--parts', inputs.parts, '--out', 'rig', ...args], { cwd: dir, onLine: push });
|
|
728
|
+
}
|
|
729
|
+
c.wallMs.build = op.wallMs;
|
|
730
|
+
return { ...op, wallMs: proposed.wallMs + op.wallMs };
|
|
731
|
+
}
|
|
597
732
|
return apply(job, dir, inputs, resolved, {
|
|
598
|
-
cmd: job.op
|
|
733
|
+
cmd: job.op === 'cloth' ? 'propose' : job.op, spec: building ? 'out/rig' : 'rig',
|
|
599
734
|
async run(push) {
|
|
735
|
+
if (job.op === 'cloth' && cloth) return runCloth(push);
|
|
600
736
|
if (job.op === 'build' && plan) {
|
|
601
737
|
// #115: assemble → rig → check from the painting and the runs, the patched config's assemble-read files absolute.
|
|
602
738
|
const doc = JSON.parse(scratchConfigText(inputs.config, patched)) as Record<string, unknown>;
|
|
603
739
|
absolutizeBuildFiles(doc, value => (isAbsolute(value) ? value : resolve(dirname(inputs.config), value)));
|
|
604
740
|
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 };
|
|
741
|
+
return runBuild(push, `the handoff config with ${job.request.patch.ops.length} patch op(s)`);
|
|
615
742
|
}
|
|
616
743
|
if (job.op === 'retune') {
|
|
617
744
|
// retune reads the inspected rig and writes only --out; what it holds it refuses by name (RETUNE_REQUEST_HELD).
|
|
@@ -634,7 +761,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
634
761
|
},
|
|
635
762
|
wallMs: ms => { job.wallMs.op = ms; },
|
|
636
763
|
async flags(push) {
|
|
637
|
-
if (
|
|
764
|
+
if (building && built) {
|
|
638
765
|
job.exitCode = built.exitCode;
|
|
639
766
|
const lines = buildLines(built.lines);
|
|
640
767
|
job.producerLine = lines.filter(l => /^build:\s/.test(l)).at(-1) ?? job.producerLine;
|
|
@@ -659,8 +786,12 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
659
786
|
}, {
|
|
660
787
|
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
788
|
// #115: build runs check itself, on the parts it assembled; its check.json is read after the op (flags).
|
|
662
|
-
...(
|
|
789
|
+
...(building ? {} : { check: (summary: CheckSummary, wallMs: number | null) => { job.wallMs.check = wallMs; job.check = summary; } }),
|
|
790
|
+
// #139: a retune Apply reads the check retune ran on its revision (retune.json, bound by its hash rows) unless the
|
|
791
|
+
// Apply checks under an exported world (retune's check reads the default one) or the producer carries no line.
|
|
792
|
+
...(job.op === 'retune' ? { carried: (push: (line: string) => void) => retuneCarried(join(dir, 'rig'), world, push) } : {}),
|
|
663
793
|
hashed: async () => { if (counts) job.remesh = { ...counts, modelTriangles: modelMeshTriangles(JSON.parse(await readFile(join(dir, 'build', 'skeleton.model.json'), 'utf8')), counts.part) }; },
|
|
794
|
+
world,
|
|
664
795
|
});
|
|
665
796
|
}
|
|
666
797
|
|
|
@@ -670,7 +801,7 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
670
801
|
* on a producer that has it (#136, `src/retune-route.ts`); any other `rig` patch rebuilds the whole rig and first needs
|
|
671
802
|
* the baseline to have reproduced the inspected rig (`EDIT_BASELINE_MISMATCH` naming the path). Refusals come before anything is run.
|
|
672
803
|
*/
|
|
673
|
-
async function startPatch(input: unknown): Promise<{ status: number; body: unknown }> {
|
|
804
|
+
async function startPatch(input: unknown, world: ApplyWorld | null = null): Promise<{ status: number; body: unknown }> {
|
|
674
805
|
let patch: ConfigPatch;
|
|
675
806
|
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
807
|
assertStateOutside();
|
|
@@ -685,27 +816,44 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
685
816
|
if (patch.op.cmd === 'remesh') part = remeshPart(patch);
|
|
686
817
|
} catch (e) { if (e instanceof ConfigPatchError) throw new RebuildError(e.code, e.message.slice(e.code.length + 2)); throw e; }
|
|
687
818
|
assertNamedFilesHeld(original, patched);
|
|
819
|
+
// #111: a cloth recipe patch (every op under /cloth) runs propose --cloth --write-config, then builds what it wrote.
|
|
820
|
+
// Decision 1: without the declared keypoints the draft moves the body bones, so it is refused before anything else.
|
|
821
|
+
const clothKind = clothPatchKind(patch.ops);
|
|
822
|
+
if (clothKind === 'mixed') throw new RebuildError('CLOTH_PATCH_MIXED', CLOTH_PATCH_MIXED.slice('CLOTH_PATCH_MIXED: '.length));
|
|
823
|
+
const clothing = clothKind === 'cloth' && patch.op.cmd === 'rig';
|
|
824
|
+
if (clothing && (!p.handoff!.keypoints || !p.handoff!.record.keypoints)) throw new RebuildError('CLOTH_KEYPOINTS_UNDECLARED', CLOTH_KEYPOINTS_UNDECLARED.slice('CLOTH_KEYPOINTS_UNDECLARED: '.length));
|
|
688
825
|
const resolved = resolveProducer(options.starts);
|
|
689
826
|
if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error, producerLine: resolved.error } };
|
|
690
827
|
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}`);
|
|
828
|
+
// #112: `check --environment` is @rig-c/parts 1.48.0's (parts#538); an older producer is refused by name, nothing run.
|
|
829
|
+
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
830
|
// #136: the whole-rig rebuild is the fast path (~18 s) and gives the same bytes when the baseline is reproduced,
|
|
692
831
|
// so it stays the default. Only when the config cannot reproduce the inspected rig (a repaired build) does a
|
|
693
832
|
// patch inside retune's scope run retune, which holds the rest of the inspected rig itself (~52 s).
|
|
694
833
|
// #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
|
-
|
|
834
|
+
let clothPlan: ClothPlan | null = null;
|
|
835
|
+
if (clothing) {
|
|
836
|
+
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 } }; }
|
|
837
|
+
if (!p.handoff!.source) throw new RebuildError('MODELING_INPUT_UNAVAILABLE', CLOTH_SOURCE_UNAVAILABLE.slice('MODELING_INPUT_UNAVAILABLE: '.length));
|
|
838
|
+
let partsJson: unknown = null;
|
|
839
|
+
try { partsJson = JSON.parse(await readFile(join(inputs.parts, 'parts.json'), 'utf8')); } catch { /* propose reads parts.json itself and refuses by name */ }
|
|
840
|
+
clothPlan = { source: p.handoff!.source, keypoints: p.handoff!.keypoints!, partsJson, targets: (await clothTargets(resolved.producer.root, inputs.configText)).tags };
|
|
841
|
+
}
|
|
842
|
+
// Decision 2 (#111): the written config is built as #115 builds when it declares limbs, else by `rig --config`.
|
|
843
|
+
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
844
|
let plan: BuildPlan | null = null;
|
|
697
845
|
if (building) {
|
|
698
846
|
const held = newBuildFile(original, patched);
|
|
699
847
|
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
848
|
plan = await buildPlan(p.handoff!, patched, abs => relative(p.root, abs).split(sep).join('/'));
|
|
701
849
|
}
|
|
702
|
-
const route = patch.op.cmd === 'remesh' ? 'remesh' : building ? 'build' : patchRoute(patch.ops, resolved.producer.version);
|
|
850
|
+
const route = patch.op.cmd === 'remesh' ? 'remesh' : clothing ? 'cloth' : building ? 'build' : patchRoute(patch.ops, resolved.producer.version);
|
|
703
851
|
const baselineNow = route === 'retune' ? await settledBaseline() : null;
|
|
704
852
|
const op: AppliedOp = route === 'retune' && baselineNow?.status === 'reproduced' ? 'rig' : route;
|
|
705
|
-
if (op === 'rig' || op === 'build') {
|
|
853
|
+
if (op === 'rig' || op === 'build' || op === 'cloth') {
|
|
706
854
|
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
855
|
}
|
|
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 } : {}) });
|
|
856
|
+
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
857
|
const current = jobs.get(id);
|
|
710
858
|
if (current?.status === 'running') return { status: 202, body: snapshot(current) };
|
|
711
859
|
if (running) return { status: 200, body: { status: 'refused', error: `REBUILD_BUSY: rebuild ${running} is still running`, producerLine: null } };
|
|
@@ -713,9 +861,11 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
713
861
|
const reused = await reuse<PatchJob>(dir, id);
|
|
714
862
|
if (reused) { jobs.set(id, reused); latestId = id; return { status: 200, body: snapshot(reused) }; }
|
|
715
863
|
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 } } : {})
|
|
864
|
+
...(plan ? { assembled: { inputs: plan.inputs, refusals: [], checkFails: [], lines: [], seam: [], seamPairs: [], recomposite: { line: null, before: null, after: null, errorMap: null }, parts: null } } : {}),
|
|
865
|
+
...(world ? { environment: world.record } : {}),
|
|
866
|
+
...(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
867
|
jobs.set(id, job); running = id;
|
|
718
|
-
void executePatch(job, dir, inputs, resolved, patched, part, plan).finally(() => { if (running === id) running = null; });
|
|
868
|
+
void executePatch(job, dir, inputs, resolved, patched, part, plan, world, clothPlan).finally(() => { if (running === id) running = null; });
|
|
719
869
|
return { status: 202, body: snapshot(job) };
|
|
720
870
|
}
|
|
721
871
|
|
|
@@ -895,8 +1045,11 @@ export function createRebuilds(p: ReviewPackage, options: RebuildOptions) {
|
|
|
895
1045
|
}
|
|
896
1046
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'delta')) return startDelta(input);
|
|
897
1047
|
if (input && typeof input === 'object' && !Array.isArray(input) && Object.hasOwn(input, 'patch')) {
|
|
898
|
-
|
|
899
|
-
|
|
1048
|
+
// #112: `{patch, environment}` — the exported schedule Apply's check reads.
|
|
1049
|
+
const keys = Object.keys(input).sort().join(',');
|
|
1050
|
+
if (keys !== 'patch' && keys !== 'environment,patch') bad('expected exactly {patch}, {patch, environment} or {part, regions, configHash}');
|
|
1051
|
+
const world = Object.hasOwn(input, 'environment') ? parseApplyWorld((input as { environment: unknown }).environment) : null;
|
|
1052
|
+
return startPatch((input as { patch: unknown }).patch, world);
|
|
900
1053
|
}
|
|
901
1054
|
const request = parseRebuildRequest(input, options.bones);
|
|
902
1055
|
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,6 +39,8 @@ 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}`;
|
|
@@ -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};
|