@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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * `POST /api/environment-check` (issue #112): "Check under this world". The Environment block's exported
3
+ * `rigc-environment/1` schedule is written by review into `<state>/environment-check/<package hash>/<id>/environment.json`,
4
+ * then `rig-parts check --rig <rig> --out out --parts <handoff parts> --page-edges free --pack-shape polygon
5
+ * --environment environment.json` runs with the installed producer (@rig-c/parts 1.48.0 or later, parts#538), on the
6
+ * inspected rig spec (the handoff's, read only) or on the session's latest scratch revision. The pack flags are the
7
+ * inspected build's own background check's (`src/baseline.ts`), so the default-world line beside it differs only by the
8
+ * world. The job keeps what check said: its `environment:` line, check.json's `environment` record (path, sha256, steps),
9
+ * the ANIMATIONS_DO_NOT_FOLD and TEXTURE_STRETCH lines, the summary, and a `CHECK_ENVIRONMENT` refusal verbatim. The
10
+ * check's output directory is removed after the run; the result is stored so the same schedule on the same build is read
11
+ * back. The client sends `{build, schedule}` only — never a path or a flag. Nothing is written into the package.
12
+ */
13
+ import { createHash } from 'node:crypto';
14
+ import { isAbsolute, join, relative } from 'node:path';
15
+ import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
16
+ import type { ReviewPackage } from './manifest.ts';
17
+ import { firstRefusal, modelingProducerTooOld, resolveProducer, runTool, versionAtLeast } from './producer.ts';
18
+ import { producerInputs, RebuildError } from './rebuild.ts';
19
+ import { checkSummary, type CheckSummary } from './applied-context.ts';
20
+ import { barLine, READOUT_BARS } from './physics-tuning.ts';
21
+ import { CHECK_ENVIRONMENT_SINCE, parseSchedule, ScheduleError, scheduleText } from './environment-schedule.ts';
22
+
23
+ export const ENVIRONMENT_CHECK_SCHEMA = 'rig-review-environment-check/1';
24
+ export const ENVIRONMENT_CHECK_LIMIT = 64 * 1024;
25
+ const MAX_LINES = 4000;
26
+ const sha256 = (text: string) => createHash('sha256').update(text).digest('hex');
27
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
28
+
29
+ export class EnvironmentCheckError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
30
+ export type EnvironmentCheckBuild = 'inspected' | 'scratch';
31
+ export interface EnvironmentCheckRequest { build: EnvironmentCheckBuild; text: string }
32
+ /** `{build, schedule}` only: the schedule as data (re-serialized here, so its hash is the exported bytes'), never a path. */
33
+ export function parseEnvironmentCheckRequest(input: unknown): EnvironmentCheckRequest {
34
+ if (!isRecord(input) || Object.keys(input).sort().join(',') !== 'build,schedule') throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', 'expected exactly {build, schedule}');
35
+ if (input.build !== 'inspected' && input.build !== 'scratch') throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', 'build must be inspected or scratch');
36
+ try { return { build: input.build, text: scheduleText(parseSchedule(input.schedule)) }; }
37
+ catch (e) { if (e instanceof ScheduleError) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_REQUEST_INVALID', e.message); throw e; }
38
+ }
39
+ /** check's argv as `rig-parts check --help` states it (1.48.0); every path the server's. */
40
+ export const environmentCheckArgs = (r: { rig: string; parts: string; out: string; environment: string }): string[] =>
41
+ ['check', '--rig', r.rig, '--out', r.out, '--parts', r.parts, '--page-edges', 'free', '--pack-shape', 'polygon', '--environment', r.environment];
42
+
43
+ /** What check.json records under `--environment` (parts#538): the path as given, the bytes' sha256, the step count. */
44
+ export interface CheckEnvironmentRecord { path: string; sha256: string; steps: number }
45
+ export function checkEnvironmentRecord(checkJson: unknown): CheckEnvironmentRecord | null {
46
+ const e = isRecord(checkJson) && isRecord(checkJson.environment) ? checkJson.environment : null;
47
+ return e && typeof e.path === 'string' && typeof e.sha256 === 'string' && Number.isSafeInteger(e.steps) ? { path: e.path, sha256: e.sha256, steps: e.steps as number } : null;
48
+ }
49
+ /** check's `environment: <path>, rigc-environment/1, <n> step(s), sha256 <16 hex> — …` line, verbatim (trimmed). */
50
+ export const environmentLineOf = (lines: readonly string[]) => lines.map(l => l.trim()).find(l => /^environment: \S.*rigc-environment\/1, \d+ step\(s\), sha256 [0-9a-f]{16}/.test(l)) ?? null;
51
+
52
+ export interface EnvironmentCheckJob {
53
+ schema: typeof ENVIRONMENT_CHECK_SCHEMA; id: string; status: 'running' | 'checked' | 'refused' | 'failed';
54
+ build: EnvironmentCheckBuild; buildId: string | null;
55
+ /** The schedule as written: its sha256, byte length, step count. */
56
+ schedule: { sha256: string; size: number; steps: number };
57
+ producer: { package: string; version: string };
58
+ exitCode: number | null; wallMs: number | null;
59
+ check: CheckSummary | null;
60
+ /** check.json's `environment`; its sha256 must be the schedule's (a mismatch fails the job). */
61
+ environment: CheckEnvironmentRecord | null;
62
+ environmentLine: string | null;
63
+ /** The readout bars' lines (ANIMATIONS_DO_NOT_FOLD, TEXTURE_STRETCH) as check printed them. */
64
+ bars: { name: string; line: string }[];
65
+ /** A named refusal verbatim (`FAIL CHECK_ENVIRONMENT: …`), or why the run failed. */
66
+ error: string | null;
67
+ lines: string[];
68
+ }
69
+ export interface EnvironmentCheckOptions {
70
+ stateDir: string; starts: readonly string[]; unchanged: () => Promise<void>;
71
+ latest: () => { id: string; spec: string } | null;
72
+ }
73
+ export const environmentCheckRoot = (stateDir: string, sourceHash: string) => join(stateDir, 'environment-check', sourceHash);
74
+
75
+ /** One check at a time per review instance; finished jobs stay readable by id, and a stored result is read back. */
76
+ export function createEnvironmentCheck(p: ReviewPackage, options: EnvironmentCheckOptions) {
77
+ const jobs = new Map<string, EnvironmentCheckJob>();
78
+ let running: string | null = null;
79
+ const snapshot = (job: EnvironmentCheckJob): EnvironmentCheckJob => JSON.parse(JSON.stringify(job)) as EnvironmentCheckJob;
80
+ function assertStateOutside(): void {
81
+ for (const root of [p.root, ...(p.handoff ? [p.handoff.root] : [])]) {
82
+ const inside = relative(root, options.stateDir);
83
+ if (inside === '' || !inside.startsWith('..') && !isAbsolute(inside)) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_STATE_INSIDE_PACKAGE', 'the review state directory is inside the inspected package; set RIG_REVIEW_STATE_DIR outside it');
84
+ }
85
+ }
86
+
87
+ async function execute(job: EnvironmentCheckJob, dir: string, bin: string, rig: string, parts: string, text: string): Promise<void> {
88
+ try {
89
+ await rm(dir, { recursive: true, force: true });
90
+ await mkdir(dir, { recursive: true, mode: 0o700 });
91
+ await writeFile(join(dir, 'environment.json'), text, { mode: 0o600 });
92
+ const run = await runTool(bin, environmentCheckArgs({ rig, parts, out: 'out', environment: 'environment.json' }), { cwd: dir, onLine: line => { if (job.lines.length < MAX_LINES) job.lines.push(line); } });
93
+ let checkJson: unknown = null;
94
+ try { checkJson = JSON.parse(await readFile(join(dir, 'out', 'check.json'), 'utf8')); } catch { /* refused before check.json; the lines say why */ }
95
+ const refusal = firstRefusal(run.lines);
96
+ Object.assign(job, { exitCode: run.exitCode, wallMs: run.wallMs, environmentLine: environmentLineOf(run.lines), environment: checkEnvironmentRecord(checkJson) });
97
+ try { await options.unchanged(); } catch { throw new EnvironmentCheckError('ENVIRONMENT_CHECK_INPUTS_CHANGED', 'the package or its producer handoff changed during the check'); }
98
+ if (checkJson === null) { job.status = 'refused'; job.error = refusal ?? `rig-parts check exited ${run.exitCode} without a named refusal`; }
99
+ else {
100
+ job.check = checkSummary(checkJson, run.exitCode, run.lines, refusal);
101
+ job.bars = READOUT_BARS.map(name => ({ name, line: barLine(name, run.lines, job.check) }));
102
+ if (!job.environment || job.environment.sha256 !== job.schedule.sha256) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_UNRECORDED', `check.json's environment ${job.environment ? `sha256 ${job.environment.sha256}` : 'is absent'}; the schedule written was sha256 ${job.schedule.sha256}`);
103
+ job.status = 'checked';
104
+ }
105
+ await writeFile(join(dir, 'result.json'), JSON.stringify({ ...job, lines: job.lines.slice(-MAX_LINES) }, null, 1) + '\n', { mode: 0o600 });
106
+ } catch (error) { job.status = 'failed'; job.error = error instanceof Error ? error.message : String(error); }
107
+ finally { await rm(join(dir, 'out'), { recursive: true, force: true }).catch(() => {}); if (job.status === 'failed') await rm(dir, { recursive: true, force: true }).catch(() => {}); }
108
+ }
109
+
110
+ /** Refusals before anything runs; a malformed request is ENVIRONMENT_CHECK_REQUEST_INVALID (400 at the route). */
111
+ async function start(input: unknown): Promise<{ status: number; body: unknown }> {
112
+ const request = parseEnvironmentCheckRequest(input);
113
+ assertStateOutside();
114
+ await options.unchanged();
115
+ let inputs: Awaited<ReturnType<typeof producerInputs>>;
116
+ try { inputs = await producerInputs(p.handoff, p.handoffReason); } catch (e) { if (e instanceof RebuildError) return { status: 200, body: { status: 'refused', error: e.message } }; throw e; }
117
+ let rig = inputs.rig, buildId: string | null = null;
118
+ if (request.build === 'scratch') {
119
+ const latest = options.latest();
120
+ if (!latest) return { status: 200, body: { status: 'refused', error: 'ENVIRONMENT_CHECK_NO_SCRATCH: no scratch build in this session yet; Apply builds one, or check the inspected rig' } };
121
+ rig = latest.spec; buildId = latest.id;
122
+ }
123
+ const resolved = resolveProducer(options.starts);
124
+ if (!resolved.ok) return { status: 200, body: { status: 'refused', error: resolved.error } };
125
+ const producer = { package: resolved.producer.package, version: resolved.producer.version };
126
+ if (!versionAtLeast(producer.version, CHECK_ENVIRONMENT_SINCE)) return { status: 200, body: { status: 'refused', error: modelingProducerTooOld(producer.version || 'unknown', CHECK_ENVIRONMENT_SINCE, 'rig-parts check --environment') } };
127
+ const fileSha = sha256(request.text);
128
+ const id = sha256(JSON.stringify([ENVIRONMENT_CHECK_SCHEMA, p.sourceHash, fileSha, request.build, buildId, inputs.configHash, `${producer.package}@${producer.version}`]));
129
+ const current = jobs.get(id);
130
+ if (current && current.status !== 'failed') return { status: current.status === 'running' ? 202 : 200, body: snapshot(current) };
131
+ const dir = join(environmentCheckRoot(options.stateDir, p.sourceHash), id);
132
+ try { const stored = JSON.parse(await readFile(join(dir, 'result.json'), 'utf8')) as EnvironmentCheckJob; if (stored.schema === ENVIRONMENT_CHECK_SCHEMA && stored.id === id) { jobs.set(id, stored); return { status: 200, body: snapshot(stored) }; } } catch { /* not run yet */ }
133
+ if (running) return { status: 200, body: { status: 'refused', error: `ENVIRONMENT_CHECK_BUSY: check ${running} is still running` } };
134
+ const job: EnvironmentCheckJob = {
135
+ schema: ENVIRONMENT_CHECK_SCHEMA, id, status: 'running', build: request.build, buildId,
136
+ schedule: { sha256: fileSha, size: Buffer.byteLength(request.text), steps: (JSON.parse(request.text) as { steps: unknown[] }).steps.length }, producer,
137
+ exitCode: null, wallMs: null, check: null, environment: null, environmentLine: null, bars: [], error: null, lines: [],
138
+ };
139
+ jobs.set(id, job); running = id;
140
+ void execute(job, dir, resolved.producer.bin, rig, inputs.parts, request.text).finally(() => { if (running === id) running = null; });
141
+ return { status: 202, body: snapshot(job) };
142
+ }
143
+ function read(id: string): EnvironmentCheckJob {
144
+ const job = jobs.get(id);
145
+ if (!job) throw new EnvironmentCheckError('ENVIRONMENT_CHECK_NOT_FOUND', id);
146
+ return snapshot(job);
147
+ }
148
+ return { start, read, running: () => running };
149
+ }
@@ -0,0 +1,183 @@
1
+ /**
2
+ * The Environment block's wind as a `rigc-environment/1` schedule (issue #112): what `rigc render --environment` and
3
+ * `rig-parts check --environment` (@rig-c/parts 1.48.0, parts#538) read — `{spec, steps: [{t, wind?, gravity?}]}`, each
4
+ * step holding from its `t` until the next, x right and y down, the runtime's wind [1, 0] and gravity [0, 1] before the
5
+ * first. Pure: no DOM, no IO, no clock.
6
+ *
7
+ * The panel's wind (`./environment.ts`) is `base + breeze(t) + gust(t)` on a clock the player's steps advance. From a
8
+ * fresh clock (`restore`, which starts a repeating gust at 0) that is a function of time alone, so a schedule can carry
9
+ * it with t = 0 at the animation's first frame:
10
+ * - the step form (no breeze, no repeating gust): one step, the standing world — exact;
11
+ * - the sampled form (breeze and/or repeat on): one step per frame of the render's grid (`SCHEDULE_FPS`), each the
12
+ * panel's wind at that frame's time. rigc sets a step before the physics step that poses its frame, as the panel sets
13
+ * the wind before the player's step, so the two agree at every frame time; between frames rigc holds the frame's
14
+ * value, within `scheduleTolerance` of the panel's.
15
+ * A one-off Gust (Repeat off) is an event on the live clock, not part of the settings, and is not in the schedule.
16
+ */
17
+ import { breezeAt, gustEnvelope, gustPeriod, type WindSnapshot } from './environment.ts';
18
+
19
+ export const ENVIRONMENT_SPEC = 'rigc-environment/1';
20
+ /** The grid the schedule is sampled on: `rigc render`'s default fps and `rig-parts check`'s IDLE_FPS. */
21
+ export const SCHEDULE_FPS = 12;
22
+ /** At most this many steps (50 s at 12 fps, ~36 KiB of JSON); a longer clip is refused by name. */
23
+ export const MAX_SCHEDULE_STEPS = 600;
24
+ /** The first `@rig-c/parts` release whose `check` reads `--environment` (its CHANGELOG: 1.48.0, parts#538). */
25
+ export const CHECK_ENVIRONMENT_SINCE = '1.48.0';
26
+ /** The runtime's world before the first step (rig-c `src/environment.ts`). */
27
+ export const RUNTIME_WORLD = { wind: [1, 0] as [number, number], gravity: [0, 1] as [number, number] };
28
+
29
+ export interface EnvironmentStep { t: number; wind?: [number, number]; gravity?: [number, number] }
30
+ export interface EnvironmentSchedule { spec: typeof ENVIRONMENT_SPEC; steps: EnvironmentStep[] }
31
+ export class ScheduleError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
32
+
33
+ /** The standing world as one step from t = 0 (#108's readings schedule; the step form below is this). */
34
+ export const standingSchedule = (e: { wind: [number, number]; gravity: [number, number] }): EnvironmentSchedule => ({ spec: ENVIRONMENT_SPEC, steps: [{ t: 0, wind: e.wind, gravity: e.gravity }] });
35
+
36
+ /** The panel's wind at clock time `t` from a fresh clock (`createWindModel().restore(snapshot)`, then advanced to `t`). */
37
+ export function windAt(s: WindSnapshot, t: number): number {
38
+ const gentle = s.breeze ? breezeAt(t) : 0;
39
+ let gust = 0;
40
+ if (s.repeat) {
41
+ const period = gustPeriod(s.gustDuration), age = t - Math.floor(t / period) * period;
42
+ gust = (s.base < 0 ? -1 : 1) * gustEnvelope(age, s.gustPeak, s.gustDuration);
43
+ }
44
+ return s.base + gentle + gust;
45
+ }
46
+ /** Whether the wind changes in time (breeze or a repeating gust): the sampled form. */
47
+ export const windVaries = (s: WindSnapshot) => s.breeze || (s.repeat && s.gustPeak > 0);
48
+ /**
49
+ * The largest difference between the panel's wind and the schedule's held value inside one frame interval:
50
+ * max |dw/dt| / fps, with |breeze'| <= 2·2.2 + 0.8·5.1 and |gust'| <= peak·π / duration. 0 for the step form.
51
+ */
52
+ export function scheduleTolerance(s: WindSnapshot, fps = SCHEDULE_FPS): number {
53
+ if (!windVaries(s)) return 0;
54
+ return ((s.breeze ? 2 * 2.2 + 0.8 * 5.1 : 0) + (s.repeat ? s.gustPeak * Math.PI / s.gustDuration : 0)) / fps;
55
+ }
56
+
57
+ export type ScheduleForm = 'step' | 'sampled';
58
+ export interface WindScheduleExport { schedule: EnvironmentSchedule; form: ScheduleForm; tolerance: number; fps: number; duration: number }
59
+ /**
60
+ * The panel's settings and gravity (the slider's value, applied as [0, g]) as a schedule over a clip of `duration`
61
+ * seconds: the step form when nothing varies, else one step per frame of `fps` up to the clip's last frame (rigc refuses a
62
+ * step after it), a step whose wind equals the one before left out.
63
+ */
64
+ export function windSchedule(s: WindSnapshot, gravity: number, duration: number, fps = SCHEDULE_FPS): WindScheduleExport {
65
+ const g: [number, number] = [0, gravity];
66
+ if (!windVaries(s)) return { schedule: standingSchedule({ wind: [s.base, 0], gravity: g }), form: 'step', tolerance: 0, fps, duration };
67
+ if (!(duration > 0) || !Number.isFinite(duration)) throw new ScheduleError('ENVIRONMENT_SCHEDULE_NO_CLIP', 'a breeze or a repeating gust is sampled over a clip; no clip with a duration is drawn');
68
+ const last = Math.round(duration * fps);
69
+ if (last + 1 > MAX_SCHEDULE_STEPS) throw new ScheduleError('ENVIRONMENT_SCHEDULE_TOO_LONG', `${duration} s at ${fps} fps is ${last + 1} steps; at most ${MAX_SCHEDULE_STEPS}`);
70
+ const steps: EnvironmentStep[] = [];
71
+ let previous = NaN;
72
+ for (let k = 0; k <= last; k++) {
73
+ const t = k / fps, w = windAt(s, t);
74
+ if (k === 0) steps.push({ t, wind: [w, 0], gravity: g });
75
+ else if (w !== previous) steps.push({ t, wind: [w, 0] });
76
+ previous = w;
77
+ }
78
+ return { schedule: { spec: ENVIRONMENT_SPEC, steps }, form: 'sampled', tolerance: scheduleTolerance(s, fps), fps, duration };
79
+ }
80
+
81
+ /** The exported bytes: what is downloaded, written for rigc and rig-parts, and hashed into feedback. */
82
+ export const scheduleText = (s: EnvironmentSchedule) => JSON.stringify(s, null, 1) + '\n';
83
+
84
+ const bad = (detail: string): never => { throw new ScheduleError('ENVIRONMENT_SCHEDULE_INVALID', detail); };
85
+ const isRecord = (v: unknown): v is Record<string, unknown> => !!v && typeof v === 'object' && !Array.isArray(v);
86
+ const pair = (v: unknown, field: string): [number, number] => {
87
+ if (!Array.isArray(v) || v.length !== 2 || !v.every(n => typeof n === 'number' && Number.isFinite(n) && Math.abs(n) <= 1e4)) bad(`${field} must be [x, y], finite`);
88
+ return [(v as number[])[0]!, (v as number[])[1]!];
89
+ };
90
+ /** A schedule as rig-c's reader takes it (exact keys, ascending t from 0, a half stated per step), bounded; data only. */
91
+ export function parseSchedule(value: unknown): EnvironmentSchedule {
92
+ if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'spec,steps') return bad('expected exactly {spec, steps}');
93
+ if (value.spec !== ENVIRONMENT_SPEC) bad(`spec must be ${ENVIRONMENT_SPEC}`);
94
+ if (!Array.isArray(value.steps) || value.steps.length < 1 || value.steps.length > MAX_SCHEDULE_STEPS) bad(`steps must hold 1–${MAX_SCHEDULE_STEPS} entries`);
95
+ const steps: EnvironmentStep[] = [];
96
+ (value.steps as unknown[]).forEach((raw, i) => {
97
+ if (!isRecord(raw) || !Object.keys(raw).every(k => k === 't' || k === 'wind' || k === 'gravity')) bad(`steps[${i}] holds only t, wind, gravity`);
98
+ const step = raw as Record<string, unknown>, t = step.t;
99
+ if (typeof t !== 'number' || !Number.isFinite(t) || t < 0 || t > 3600) bad(`steps[${i}].t must be seconds in [0, 3600]`);
100
+ if (i > 0 && !((t as number) > steps[i - 1]!.t)) bad(`steps[${i}].t must come after steps[${i - 1}].t`);
101
+ if (step.wind === undefined && step.gravity === undefined) bad(`steps[${i}] states neither wind nor gravity`);
102
+ steps.push({ t: t as number, ...(step.wind === undefined ? {} : { wind: pair(step.wind, `steps[${i}].wind`) }), ...(step.gravity === undefined ? {} : { gravity: pair(step.gravity, `steps[${i}].gravity`) }) });
103
+ });
104
+ return { spec: ENVIRONMENT_SPEC, steps };
105
+ }
106
+
107
+ /** rig-c's `environmentFrame`: the first frame `i` whose time `i / fps` is at or after `t`. */
108
+ export function environmentFrame(t: number, fps: number): number {
109
+ let i = Math.max(0, Math.ceil(t * fps));
110
+ while (i > 0 && (i - 1) / fps >= t) i--;
111
+ while (i / fps < t) i++;
112
+ return i;
113
+ }
114
+ /**
115
+ * The world as rigc reads the schedule at time `t` on the `fps` grid: the physics step that ends on frame
116
+ * `environmentFrame(t)` runs under every step that lands on that frame or before (the runtime's world before the first).
117
+ */
118
+ export function scheduleWorldAt(s: EnvironmentSchedule, t: number, fps = SCHEDULE_FPS): { wind: [number, number]; gravity: [number, number] } {
119
+ const frame = environmentFrame(t, fps);
120
+ let wind = RUNTIME_WORLD.wind, gravity = RUNTIME_WORLD.gravity;
121
+ for (const step of s.steps) { if (environmentFrame(step.t, fps) > frame) break; if (step.wind) wind = step.wind; if (step.gravity) gravity = step.gravity; }
122
+ return { wind, gravity };
123
+ }
124
+
125
+ /** One line: form, steps, the hash's first 16 hex (as check's `environment:` line prints it), the tolerance. */
126
+ export function scheduleSummary(e: WindScheduleExport, sha256: string): string {
127
+ const form = e.form === 'step' ? 'step form (exact)' : `sampled at ${e.fps} fps over ${e.duration.toFixed(3)} s (held between frames, within ${e.tolerance.toFixed(3)} of the panel)`;
128
+ return `${ENVIRONMENT_SPEC}, ${e.schedule.steps.length} step(s), sha256 ${sha256.slice(0, 16)} · ${form}`;
129
+ }
130
+
131
+ /** `context.environmentSchedule` (#112): the exported bytes by hash and content, and whether readings and Apply used them. */
132
+ export const SCHEDULE_CONTEXT_SCHEMA = 'rig-review-environment-schedule/1';
133
+ export interface ScheduleContext { schema: typeof SCHEDULE_CONTEXT_SCHEMA; sha256: string; size: number; text: string; usedForReadings: boolean }
134
+ const ctx = (detail: string): never => { throw new Error(`feedback.context.environmentSchedule: ${detail}`); };
135
+ /** The context's exact shape, its sha256 recomputed over `text` (`hash` is the caller's) and `text` a schedule. */
136
+ export function parseScheduleContext(value: unknown, hash: (text: string) => string): ScheduleContext | null {
137
+ if (value === null) return null;
138
+ if (!isRecord(value) || Object.keys(value).sort().join(',') !== 'schema,sha256,size,text,usedForReadings') return ctx('expected exactly {schema, sha256, size, text, usedForReadings}');
139
+ if (value.schema !== SCHEDULE_CONTEXT_SCHEMA) ctx(`schema must be ${SCHEDULE_CONTEXT_SCHEMA}`);
140
+ if (typeof value.text !== 'string' || value.text.length > 128 * 1024) ctx('text must be the exported schedule, at most 128 KiB');
141
+ const text = value.text as string;
142
+ if (typeof value.sha256 !== 'string' || value.sha256 !== hash(text)) ctx('sha256 is not the sha256 of text');
143
+ if (value.size !== new TextEncoder().encode(text).length) ctx('size is not the byte length of text');
144
+ if (typeof value.usedForReadings !== 'boolean') ctx('usedForReadings must be a boolean');
145
+ let doc: unknown;
146
+ try { doc = JSON.parse(text); } catch { return ctx('text does not parse as JSON'); }
147
+ try { parseSchedule(doc); } catch (e) { ctx(`text is not a ${ENVIRONMENT_SPEC} schedule (${e instanceof Error ? e.message : String(e)})`); }
148
+ return value as unknown as ScheduleContext;
149
+ }
150
+ /** "41 step(s) · sha256 … · used for readings": one line for the issue export. */
151
+ export function scheduleContextLine(value: unknown): string | null {
152
+ if (!isRecord(value) || value.schema !== SCHEDULE_CONTEXT_SCHEMA || typeof value.sha256 !== 'string' || typeof value.text !== 'string') return null;
153
+ let steps = 0;
154
+ try { const doc = JSON.parse(value.text) as { steps?: unknown[] }; steps = Array.isArray(doc.steps) ? doc.steps.length : 0; } catch { return null; }
155
+ return `${ENVIRONMENT_SPEC}, ${steps} step(s) · schedule sha256 ${value.sha256}${value.usedForReadings ? ' · used for readings and Apply' : ' · not used for readings'}`;
156
+ }
157
+
158
+ /** SHA-256 of a string's UTF-8 bytes, hex — synchronous, so the page can hash the exported bytes where it records them. */
159
+ export function sha256Hex(text: string): string {
160
+ const bytes = new TextEncoder().encode(text), K = new Uint32Array([
161
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
162
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7, 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
163
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
164
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3, 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2]);
165
+ const h = new Uint32Array([0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab, 0x5be0cd19]);
166
+ const length = Math.ceil((bytes.length + 9) / 64) * 64, m = new Uint8Array(length);
167
+ m.set(bytes); m[bytes.length] = 0x80;
168
+ const view = new DataView(m.buffer), bits = bytes.length * 8;
169
+ view.setUint32(length - 8, Math.floor(bits / 0x100000000)); view.setUint32(length - 4, bits >>> 0);
170
+ const w = new Uint32Array(64), rot = (x: number, n: number) => (x >>> n) | (x << (32 - n));
171
+ for (let o = 0; o < length; o += 64) {
172
+ for (let i = 0; i < 16; i++) w[i] = view.getUint32(o + i * 4);
173
+ for (let i = 16; i < 64; i++) { const a = w[i - 15]!, b = w[i - 2]!; w[i] = (w[i - 16]! + (rot(a, 7) ^ rot(a, 18) ^ (a >>> 3)) + w[i - 7]! + (rot(b, 17) ^ rot(b, 19) ^ (b >>> 10))) >>> 0; }
174
+ let [a, b, c, d, e, f, g, hh] = h as unknown as number[];
175
+ for (let i = 0; i < 64; i++) {
176
+ const t1 = (hh! + (rot(e!, 6) ^ rot(e!, 11) ^ rot(e!, 25)) + ((e! & f!) ^ (~e! & g!)) + K[i]! + w[i]!) >>> 0;
177
+ const t2 = ((rot(a!, 2) ^ rot(a!, 13) ^ rot(a!, 22)) + ((a! & b!) ^ (a! & c!) ^ (b! & c!))) >>> 0;
178
+ hh = g; g = f; f = e; e = (d! + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
179
+ }
180
+ h[0] += a!; h[1] += b!; h[2] += c!; h[3] += d!; h[4] += e!; h[5] += f!; h[6] += g!; h[7] += hh!;
181
+ }
182
+ return [...h].map(x => x.toString(16).padStart(8, '0')).join('');
183
+ }
@@ -7,6 +7,7 @@ import type { ReferenceBinding } from './reference.ts';
7
7
  import { environmentSummary, parseEnvironmentContext } from './environment.ts';
8
8
  import { appliedSummary } from './applied-context.ts';
9
9
  import { requirementsSummaryLine } from './requirements.ts';
10
+ import { scheduleContextLine } from './environment-schedule.ts';
10
11
  type Category = Feedback['category'];
11
12
  type Side = 'single' | 'a' | 'b';
12
13
  type ParsedFeedbackSession = FeedbackHistory<ReviewSession | CompareSession>;
@@ -176,6 +177,12 @@ function requirementsLine(context: Json | undefined): string[] {
176
177
  const text = requirementsSummaryLine(context.requirements);
177
178
  return text === null ? [] : [`- Requirements: ${feedbackMarkdownText(text)}`];
178
179
  }
180
+ /** "Environment schedule: rigc-environment/1, 41 step(s) · schedule sha256 …": the exported world the note carries (#112). */
181
+ function scheduleLine(context: Json | undefined): string[] {
182
+ if (context === undefined || context === null || typeof context !== 'object' || Array.isArray(context) || !('environmentSchedule' in context)) return [];
183
+ const text = scheduleContextLine(context.environmentSchedule);
184
+ return text === null ? [] : [`- Environment schedule: ${feedbackMarkdownText(text)}`];
185
+ }
179
186
  /** Local issue-ready text only. No issue creation, clock inference or source IO. */
180
187
  export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
181
188
  const model = groupFeedbackIssues(input), md = feedbackMarkdownText;
@@ -209,7 +216,7 @@ export function formatFeedbackIssues(input: ParsedFeedbackSession): string {
209
216
  `- Summary: ${md(item.summary)}`,
210
217
  ...environmentLine(item.context),
211
218
  ...appliedLine(item.context),
212
- ...requirementsLine(item.context), '',
219
+ ...requirementsLine(item.context), ...scheduleLine(item.context), '',
213
220
  'Selected build identity:', '', jsonBlock(item.selectedSource));
214
221
  if (model.pairedBinding) lines.push('', 'Paired comparison binding:', '', jsonBlock(model.pairedBinding));
215
222
  if (item.warnings.length) lines.push('', 'Validation warnings (record retained):', '', jsonBlock(item.warnings));
package/src/front.ts CHANGED
@@ -61,11 +61,14 @@ export function frontRoute(mode:'single'|'compare',method:string,path:string):Ro
61
61
  // Bones check (#109, single only): one typed request {ops, configHash}, answered in the response.
62
62
  if(mode==='single'&&path==='/api/bones-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:60000,control:false}:null;
63
63
  // Readings (#108, single only): one typed request, then its job's status.
64
- if(mode==='single'&&path==='/api/readings')return method==='POST'?{mutation:true,limit:16*1024,timeoutMs:30000,control:false}:null;
64
+ if(mode==='single'&&path==='/api/readings')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
65
65
  if(mode==='single'&&/^\/api\/readings\/[a-f0-9]{64}$/.test(path))return read?get:null;
66
66
  // Declared requirements (#113, single only): the stage's catalog, one typed request, then its job's status.
67
67
  if(mode==='single'&&path==='/api/requirements')return read?get:method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
68
68
  if(mode==='single'&&/^\/api\/requirements\/[a-f0-9]{64}$/.test(path))return read?get:null;
69
+ // Check under this world (#112, single only): one typed request {build, schedule}, then its job's status.
70
+ if(mode==='single'&&path==='/api/environment-check')return method==='POST'?{mutation:true,limit:64*1024,timeoutMs:30000,control:false}:null;
71
+ if(mode==='single'&&/^\/api\/environment-check\/[a-f0-9]{64}$/.test(path))return read?get:null;
69
72
  return null;
70
73
  }
71
74
  async function boundedBody(req:Request,limit:number,timeoutMs=10000):Promise<ArrayBuffer>{
package/src/handoff.ts CHANGED
@@ -25,9 +25,9 @@ export const CONFIG_NAMED_FILES = [['motion', 'animations_from'], ['generation',
25
25
  export const RIG_FILES = ['rig.json', 'motion.json', 'mesh_report.json'] as const;
26
26
  const MAX_LISTED = 4096;
27
27
 
28
- export interface HandoffDeclaration { config: string; parts?: string; source?: string; full?: string; head?: string }
28
+ export interface HandoffDeclaration { config: string; parts?: string; source?: string; full?: string; head?: string; keypoints?: string }
29
29
  export type HandoffFlags = HandoffDeclaration;
30
- export const HANDOFF_FLAGS = ['config', 'parts', 'source', 'full', 'head'] as const;
30
+ export const HANDOFF_FLAGS = ['config', 'parts', 'source', 'full', 'head', 'keypoints'] as const;
31
31
  export interface HandoffFile { path: string; sha256: string }
32
32
  /** The identity record: paths relative to the package root (`../` only for the discovered parent config), every hashed file. */
33
33
  export interface HandoffRecord {
@@ -37,13 +37,16 @@ export interface HandoffRecord {
37
37
  outside: boolean;
38
38
  config: HandoffFile;
39
39
  parts: string | null; rig: string | null; source: HandoffFile | null; full: string | null; head: string | null;
40
+ /** #111: the declared body-18 keypoint file `rig-parts propose --keypoints` reads; the key is absent when none is declared, so an undeclared handoff's identity is unchanged. */
41
+ keypoints?: HandoffFile;
40
42
  files: HandoffFile[];
41
43
  missing: string[];
42
44
  }
43
45
  /** The record plus the absolute paths the producer is run with (never served, never in the identity). */
44
46
  /** `source` (#109): the declared source painting's absolute path, which `propose --from-config` reads; null when none was declared. */
45
47
  /** `full` / `head` (#115): the declared See-through runs' absolute paths, which `rig-parts build` reads; null when none was declared. */
46
- export interface Handoff { record: HandoffRecord; config: string; parts: string | null; rig: string | null; root: string; flags?: HandoffFlags; source?: string | null; full?: string | null; head?: string | null }
48
+ /** `keypoints` (#111): the declared keypoint file's absolute path, which a cloth recipe Apply's `propose --keypoints` reads; null when none was declared. */
49
+ export interface Handoff { record: HandoffRecord; config: string; parts: string | null; rig: string | null; root: string; flags?: HandoffFlags; source?: string | null; full?: string | null; head?: string | null; keypoints?: string | null }
47
50
 
48
51
  export class HandoffError extends Error {
49
52
  constructor(readonly code: string, readonly field: string, readonly detail: string) { super(`${code} ${field}: ${detail}`); }
@@ -87,15 +90,15 @@ async function blockPath(root: string, value: unknown, field: string, kind: 'fil
87
90
  return contained(root, join(root, value), field, kind, true);
88
91
  }
89
92
 
90
- /** `producer: {config, parts?, source?, full?, head?}` — strings only, no other key. Paths are checked at load. */
93
+ /** `producer: {config, parts?, source?, full?, head?, keypoints?}` — strings only, no other key. Paths are checked at load. */
91
94
  export function parseHandoffDeclaration(value: unknown): HandoffDeclaration | undefined {
92
95
  if (value === undefined) return undefined;
93
- if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HandoffError('HANDOFF_PATH_INVALID', 'producer', 'expected an object {config, parts?, source?, full?, head?}');
96
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new HandoffError('HANDOFF_PATH_INVALID', 'producer', 'expected an object {config, parts?, source?, full?, head?, keypoints?}');
94
97
  const v = value as Record<string, unknown>;
95
- for (const key of Object.keys(v)) if (!(HANDOFF_FLAGS as readonly string[]).includes(key)) throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'unknown key; expected config, parts, source, full, head');
98
+ for (const key of Object.keys(v)) if (!(HANDOFF_FLAGS as readonly string[]).includes(key)) throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'unknown key; expected config, parts, source, full, head, keypoints');
96
99
  if (typeof v.config !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', 'producer.config', 'required: the producer config path');
97
100
  const out: HandoffDeclaration = { config: v.config };
98
- for (const key of ['parts', 'source', 'full', 'head'] as const) if (v[key] !== undefined) { if (typeof v[key] !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'expected a string path'); out[key] = v[key] as string; }
101
+ for (const key of ['parts', 'source', 'full', 'head', 'keypoints'] as const) if (v[key] !== undefined) { if (typeof v[key] !== 'string') throw new HandoffError('HANDOFF_PATH_INVALID', `producer.${key}`, 'expected a string path'); out[key] = v[key] as string; }
99
102
  return out;
100
103
  }
101
104
  /** `--source` and `--full` travel together, `--head` only with them, and everything else only with `--config`. */
@@ -119,7 +122,7 @@ async function listed(base: string, dir: string, field: string): Promise<string[
119
122
  }
120
123
 
121
124
  /** Hash every handoff file and build the record. `root` is the package root (display paths are relative to it). */
122
- async function record(root: string, base: string, how: HandoffRecord['how'], via: HandoffRecord['via'], config: string, partsPath: string | null, extra: { source?: string; full?: string; head?: string }, partsDeclared: boolean): Promise<Handoff> {
125
+ async function record(root: string, base: string, how: HandoffRecord['how'], via: HandoffRecord['via'], config: string, partsPath: string | null, extra: { source?: string; full?: string; head?: string; keypoints?: string }, partsDeclared: boolean): Promise<Handoff> {
123
126
  const show = (path: string) => relative(root, path).split(sep).join('/') || '.';
124
127
  const files = new Map<string, string>(), missing: string[] = [];
125
128
  const add = async (path: string) => { files.set(show(path), await hashFile(path)); };
@@ -154,10 +157,12 @@ async function record(root: string, base: string, how: HandoffRecord['how'], via
154
157
  let source: HandoffFile | null = null;
155
158
  if (extra.source) { await add(extra.source); source = { path: show(extra.source), sha256: files.get(show(extra.source))! }; }
156
159
  for (const dir of [extra.full, extra.head]) if (dir) for (const file of await listed(base, dir, show(dir))) await add(file);
160
+ // #111: hashed only when declared — an undeclared handoff's record (and so its identity) holds no keypoints key at all.
161
+ if (extra.keypoints) await add(extra.keypoints);
157
162
  const sorted = [...files].sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)).map(([path, hash]) => ({ path, sha256: hash }));
158
163
  return {
159
- record: { schema: HANDOFF_SCHEMA, how, via, outside: !inside(root, config), config: { path: show(config), sha256: files.get(show(config))! }, parts: parts ? show(parts) : null, rig: rig ? show(rig) : null, source, full: extra.full ? show(extra.full) : null, head: extra.head ? show(extra.head) : null, files: sorted, missing },
160
- config, parts, rig, root: base, source: extra.source ?? null, full: extra.full ?? null, head: extra.head ?? null,
164
+ record: { schema: HANDOFF_SCHEMA, how, via, outside: !inside(root, config), config: { path: show(config), sha256: files.get(show(config))! }, parts: parts ? show(parts) : null, rig: rig ? show(rig) : null, source, full: extra.full ? show(extra.full) : null, head: extra.head ? show(extra.head) : null, ...(extra.keypoints ? { keypoints: { path: show(extra.keypoints), sha256: files.get(show(extra.keypoints))! } } : {}), files: sorted, missing },
165
+ config, parts, rig, root: base, source: extra.source ?? null, full: extra.full ?? null, head: extra.head ?? null, keypoints: extra.keypoints ?? null,
161
166
  };
162
167
  }
163
168
 
@@ -175,9 +180,10 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
175
180
  const source = flags.source !== undefined ? await flagPath(root, flags.source, '--source', 'file') : undefined;
176
181
  const full = flags.full !== undefined ? await flagPath(root, flags.full, '--full', 'dir') : undefined;
177
182
  const head = flags.head !== undefined ? await flagPath(root, flags.head, '--head', 'dir') : undefined;
178
- const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head }, flags.parts !== undefined);
183
+ const keypoints = flags.keypoints !== undefined ? await flagPath(root, flags.keypoints, '--keypoints', 'file') : undefined;
184
+ const handoff = await record(root, root, 'declared', 'open --config', config, parts, { source, full, head, keypoints }, flags.parts !== undefined);
179
185
  // Canonical absolute flags: the background worker and later reloads read exactly these.
180
- handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}) };
186
+ handoff.flags = { config, ...(flags.parts !== undefined ? { parts } : {}), ...(source ? { source } : {}), ...(full ? { full } : {}), ...(head ? { head } : {}), ...(keypoints ? { keypoints } : {}) };
181
187
  return { handoff, reason: null };
182
188
  }
183
189
  if (declared) {
@@ -186,8 +192,9 @@ export async function resolveHandoff(root: string, declared: HandoffDeclaration
186
192
  const source = declared.source !== undefined ? await blockPath(root, declared.source, 'producer.source', 'file') : undefined;
187
193
  const full = declared.full !== undefined ? await blockPath(root, declared.full, 'producer.full', 'dir') : undefined;
188
194
  const head = declared.head !== undefined ? await blockPath(root, declared.head, 'producer.head', 'dir') : undefined;
195
+ const keypoints = declared.keypoints !== undefined ? await blockPath(root, declared.keypoints, 'producer.keypoints', 'file') : undefined;
189
196
  if ((source === undefined) !== (full === undefined) || (head !== undefined && full === undefined)) throw new HandoffError('HANDOFF_FLAGS_INVALID', 'producer', 'source and full are given together, head only with them');
190
- return { handoff: await record(root, root, 'declared', 'review.json producer', config, parts, { source, full, head }, declared.parts !== undefined), reason: null };
197
+ return { handoff: await record(root, root, 'declared', 'review.json producer', config, parts, { source, full, head, keypoints }, declared.parts !== undefined), reason: null };
191
198
  }
192
199
  for (const name of ['config.json', 'rig/config.json']) {
193
200
  const path = join(root, name);
package/src/open.ts CHANGED
@@ -18,7 +18,7 @@ import {ensureFront,frontPort} from './front.ts';
18
18
  import {playPackagePath} from './versions.ts';
19
19
  import {HandoffError,HANDOFF_FLAGS,type HandoffFlags} from './handoff.ts';
20
20
  /** `direct` chooses how the page is reached (own port instead of the managed front); it is not part of the instance identity. */
21
- export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean;config?:string;parts?:string;source?:string;full?:string;head?:string}
21
+ export interface OpenInputs {input:string;compare?:string;reference?:string;animation?:string;control?:boolean;direct?:boolean;config?:string;parts?:string;source?:string;full?:string;head?:string;keypoints?:string}
22
22
  /** The producer handoff flags (#101) among the inputs, or undefined when none was given. */
23
23
  export function handoffFlags(inputs:OpenInputs):HandoffFlags|undefined{const present=HANDOFF_FLAGS.filter(f=>inputs[f]!==undefined);if(!present.length)return undefined;return Object.fromEntries(present.map(f=>[f,inputs[f]])) as unknown as HandoffFlags;}
24
24
  export type OpenPackage=({mode:'single';p:ReviewPackage;inputs:OpenInputs;contentHash:string}|{mode:'compare';p:ComparePackage;inputs:OpenInputs;contentHash:string})&{selection:AnimationSelection|null};
@@ -10,6 +10,7 @@ import { copyFile, lstat, mkdir, readFile, rm, writeFile } from 'node:fs/promise
10
10
  import { dirname, isAbsolute, join, relative } from 'node:path';
11
11
  import { safeFile, type ReviewPackage } from './manifest.ts';
12
12
  import { resolveCompiler, runTool, type InstalledTool } from './producer.ts';
13
+ import { scheduleText, type EnvironmentSchedule } from './environment-schedule.ts';
13
14
  import { environmentSchedule, helpHasEnvironment, parseReadingsRequest, READING_FILE, readingLabel, readingResult, READINGS_SCHEMA, READINGS_SINCE, ReadingsError, renderArgs, type ReadingBuild, type ReadingRequest, type ReadingResult, type ReadingsRequest } from './readings.ts';
14
15
 
15
16
  const sha256 = (bytes: string | Uint8Array) => createHash('sha256').update(bytes).digest('hex');
@@ -39,7 +40,7 @@ export async function runReading(r: ReadingRequest, animation: string, candidate
39
40
  try { const stored = JSON.parse(await readFile(resultFile, 'utf8')) as { key: string; result: ReadingResult }; if (stored.key === key) return stored.result; } catch { /* not run yet */ }
40
41
  await rm(dir, { recursive: true, force: true });
41
42
  await mkdir(dir, { recursive: true, mode: 0o700 });
42
- if (schedule) await writeFile(join(dir, 'environment.json'), JSON.stringify(schedule, null, 1) + '\n', { mode: 0o600 });
43
+ if (schedule) await writeFile(join(dir, 'environment.json'), scheduleText(schedule as EnvironmentSchedule), { mode: 0o600 });
43
44
  const run = await runTool(compiler.bin, renderArgs(r, animation, { candidate, out: 'render', environment: schedule ? 'environment.json' : null }), { cwd: dir });
44
45
  let json: unknown = null, fps: number | null = null;
45
46
  if (run.exitCode === 0) {
package/src/readings.ts CHANGED
@@ -7,6 +7,7 @@
7
7
  * Review judges nothing here: a reading is not a gate, and an unknown slot or bone is rigc's to refuse.
8
8
  */
9
9
  import { firstRefusal } from './producer.ts';
10
+ import { parseSchedule, ScheduleError, standingSchedule, type EnvironmentSchedule } from './environment-schedule.ts';
10
11
 
11
12
  export const READINGS_SCHEMA = 'rig-review-readings/1';
12
13
  /** The first `rig-c` release with all five readings (its CHANGELOG: 2.63.0, "render: --overlaps names which slot covers which"). */
@@ -24,7 +25,8 @@ export type ReadingKind = ReadingRequest['kind'];
24
25
  export const READING_KINDS: readonly ReadingKind[] = ['folds', 'pass-through', 'seam', 'overlaps', 'angle-range'];
25
26
  /** The world the physics answers while the readings are posed: the Environment block's wind and gravity as they stand (x right, y down). */
26
27
  export interface ReadingEnvironment { wind: [number, number]; gravity: [number, number] }
27
- export interface ReadingsRequest { animation: string; readings: ReadingRequest[]; environment: ReadingEnvironment | null }
28
+ /** #112: with "Use for readings" on, the Environment block's exported `rigc-environment/1` schedule goes in its place. */
29
+ export interface ReadingsRequest { animation: string; readings: ReadingRequest[]; environment: ReadingEnvironment | EnvironmentSchedule | null }
28
30
 
29
31
  export class ReadingsError extends Error { constructor(readonly code: string, detail: string) { super(`${code}: ${detail}`); } }
30
32
  const bad = (detail: string): never => { throw new ReadingsError('READINGS_REQUEST_INVALID', detail); };
@@ -57,8 +59,12 @@ export function parseReadingsRequest(input: unknown): ReadingsRequest {
57
59
  const readings = (v.readings as unknown[]).map((r, i) => parseReading(r, `readings[${i}]`));
58
60
  const labels = readings.map(readingLabel);
59
61
  if (new Set(labels).size !== labels.length) bad('readings: one entry named twice');
60
- let environment: ReadingEnvironment | null = null;
61
- if (v.environment !== null) { const e = record(v.environment, 'environment'); exactly(e, ['wind', 'gravity'], 'environment'); environment = { wind: pair(e.wind, 'environment.wind'), gravity: pair(e.gravity, 'environment.gravity') }; }
62
+ let environment: ReadingsRequest['environment'] = null;
63
+ if (v.environment !== null) {
64
+ const e = record(v.environment, 'environment');
65
+ if (Object.hasOwn(e, 'spec')) { try { environment = parseSchedule(e); } catch (error) { if (error instanceof ScheduleError) bad(`environment: ${error.message}`); throw error; } }
66
+ else { exactly(e, ['wind', 'gravity'], 'environment'); environment = { wind: pair(e.wind, 'environment.wind'), gravity: pair(e.gravity, 'environment.gravity') }; }
67
+ }
62
68
  return { animation, readings, environment };
63
69
  }
64
70
 
@@ -88,8 +94,8 @@ export interface RenderPaths { candidate: string; out: string; environment: stri
88
94
  export function renderArgs(r: ReadingRequest, animation: string, paths: RenderPaths): string[] {
89
95
  return ['render', '--candidate', paths.candidate, '--animation', animation, ...(paths.environment ? ['--environment', paths.environment] : []), ...readingFlag(r), '--out', paths.out];
90
96
  }
91
- /** The schedule file `--environment` reads: the world as it stands, from t = 0. */
92
- export const environmentSchedule = (e: ReadingEnvironment) => ({ spec: 'rigc-environment/1', steps: [{ t: 0, wind: e.wind, gravity: e.gravity }] });
97
+ /** The schedule file `--environment` reads: the world as it stands, from t = 0 — or, #112, the exported schedule as sent. */
98
+ export const environmentSchedule = (e: ReadingEnvironment | EnvironmentSchedule): EnvironmentSchedule => ('spec' in e ? e : standingSchedule(e));
93
99
  /** Whether this rigc's `render --help` lists `--environment` (the schedule is passed only then). */
94
100
  export const helpHasEnvironment = (lines: readonly string[]) => lines.some(l => /^\s*--environment\s/.test(l));
95
101
 
@@ -216,7 +222,7 @@ export function ticksOnClip(ticks: readonly ReadingTick[], jobAnimation: string,
216
222
 
217
223
  /** `context.readings`: the pairs chosen and where they ran (the builds' identities), never the results' bytes. */
218
224
  export interface ReadingsContext {
219
- schema: typeof READINGS_SCHEMA; animation: string; requests: ReadingRequest[]; environment: ReadingEnvironment | null; environmentPassed: boolean;
225
+ schema: typeof READINGS_SCHEMA; animation: string; requests: ReadingRequest[]; environment: ReadingsRequest['environment']; environmentPassed: boolean;
220
226
  builds: { inspected: string; scratch: string | null }; compiler: { package: string; version: string };
221
227
  ticks: { inspected: number; scratch: number | null };
222
228
  }