@rig-c/review 0.7.1 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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);
@@ -18,10 +18,11 @@ export const capabilityHeader='X-Review-Capability',openNonceHeader='X-Review-Op
18
18
  export const capabilityPattern=/^[a-f0-9]{64}$/;
19
19
  export function sameSecret(actual:string|null,expected:string):boolean{const a=Buffer.from(actual??''),b=Buffer.from(expected);return a.length===b.length&&timingSafeEqual(a,b);}
20
20
  /** Every non-GET request needs this instance's page capability, compared in constant time before any
21
- * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. */
22
- export function accessGate(req:Request,capability:string|undefined):Response|null{
21
+ * Origin check. GET /api/access only says whether one is required; no endpoint ever returns it. It also says
22
+ * whether this instance serves agent control, so a page without it never probes `/api/control/config` (#164). */
23
+ export function accessGate(req:Request,capability:string|undefined,control=false):Response|null{
23
24
  const url=new URL(req.url),headers={'Cache-Control':'no-store','X-Content-Type-Options':'nosniff'};
24
- if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
+ if(url.pathname==='/api/access')return req.method==='GET'||req.method==='HEAD'?Response.json({schema:'rig-review-access/1',capabilityRequired:!!capability,control},{headers}):Response.json({error:'Method not allowed'},{status:405,headers});
25
26
  if(!capability||req.method==='GET'||req.method==='HEAD')return null;
26
27
  return sameSecret(req.headers.get(capabilityHeader),capability)?null:Response.json({error:'REVIEW_CAPABILITY_REQUIRED: reopen with rig-review open to edit'},{status:403,headers});
27
28
  }
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
  }