// Takes: alternative renders of one part of a report video, side by side. // // Something ELSE renders them -- an agent trying five endings -- into // // /takes//take.json // /takes//preview.mp4 (whatever take.json names) // /takes//video.manifest.json (what it was built from) // // and this reads them back, serves their previews and records which ones the // operator liked in /takes/verdicts.json, which that agent reads. // So take.json is a contract written by somebody else: every field is checked, // and a take that fails is SKIPPED WITH A REASON rather than dropped or half // shown. A directory with no take.json is not a take (a work dir, a build in // progress) and is not listed at all. // // Plain ESM for the same reason manifest.mjs is: `node --test` runs it with no // TypeScript, and the rules about which file a request may open live here, // tested, rather than in a route. import { readdir, readFile, realpath, rename, stat, writeFile } from "node:fs/promises"; import path from "node:path"; import { inside } from "../paths.mjs"; export const TAKES_DIR = "takes"; export const TAKE_FILE = "take.json"; export const VERDICTS_FILE = "verdicts.json"; /** A take id is its directory's name, and one url-safe segment. */ export const TAKE_ID = /^[a-z0-9][a-z0-9-]{0,63}$/; /** * @param {unknown} v * @returns {v is string} */ export const isTakeId = (v) => typeof v === "string" && TAKE_ID.test(v); export const TAKE_KINDS = ["reference", "similar", "different"]; export const TAKE_VERDICTS = ["like", "maybe", "no"]; /** A note is a sentence or two about one take, not an essay. */ export const TAKE_NOTE_LIMIT = 2000; export const takesDirOf = (projectDir) => path.join(projectDir, TAKES_DIR); export const verdictsFileOf = (projectDir) => path.join(takesDirOf(projectDir), VERDICTS_FILE); const str = (v) => (typeof v === "string" ? v.trim() : ""); /** * One take.json, checked. `{ take }` or `{ error }` -- never a partial take. * * Required: id (== the directory), group, order, label, kind, preview. * Optional: summary (""), changes ([]), seconds (null), builtAt (null). An * optional field of the wrong type is an error too: a `changes` that is a * string would otherwise render as one bullet per character. * * `preview` is RELATIVE to the take's directory and may not climb out of it; * that is checked again against the real path when the file is served. * * @param {string} dirName * @param {unknown} raw */ export function parseTake(dirName, raw) { if (!isTakeId(dirName)) return { error: `directory name is not a take id (${TAKE_ID})` }; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return { error: "take.json is not an object" }; const j = /** @type {Record} */ (raw); if (j.id !== dirName) return { error: `id ${JSON.stringify(j.id ?? null)} is not the directory name` }; const group = str(j.group); if (!group) return { error: "group is missing" }; if (typeof j.order !== "number" || !Number.isFinite(j.order)) return { error: "order is not a number" }; const label = str(j.label); if (!label) return { error: "label is missing" }; if (!TAKE_KINDS.includes(/** @type {string} */ (j.kind))) { return { error: `kind must be one of ${TAKE_KINDS.join(", ")}` }; } const preview = str(j.preview); if (!preview) return { error: "preview is missing" }; if (path.isAbsolute(preview) || /[\\\0]/.test(preview) || preview.split("/").some((s) => s === ".." || s === "")) { return { error: "preview must be a relative path inside the take" }; } if (j.summary !== undefined && typeof j.summary !== "string") return { error: "summary is not a string" }; if (j.changes !== undefined && !(Array.isArray(j.changes) && j.changes.every((c) => typeof c === "string"))) { return { error: "changes is not a list of strings" }; } if (j.seconds !== undefined && j.seconds !== null && (typeof j.seconds !== "number" || !Number.isFinite(j.seconds))) { return { error: "seconds is not a number" }; } if (j.builtAt !== undefined && j.builtAt !== null && typeof j.builtAt !== "string") { return { error: "builtAt is not a string" }; } return { take: { id: dirName, group, order: j.order, label, kind: /** @type {"reference" | "similar" | "different"} */ (j.kind), summary: str(j.summary), changes: /** @type {string[]} */ (j.changes ?? []).map((c) => c.trim()).filter(Boolean), preview, seconds: typeof j.seconds === "number" ? j.seconds : null, builtAt: typeof j.builtAt === "string" ? j.builtAt : null, }, }; } /** * Every take of a project, grouped, plus what was skipped and why. * * Groups are in order of their lowest `order`, then by name; takes within a * group by `order`, then id. Each take carries its preview's size and mtime * (null when the file is not there yet -- an agent may still be rendering it), * and the mtime is what the page passes as `v`, because a re-render writes the * same path. * * @param {string} projectDir */ export async function listTakes(projectDir) { const root = takesDirOf(projectDir); const entries = await readdir(root, { withFileTypes: true }).catch(() => null); if (!entries) return { exists: false, takes: [], groups: [], skipped: [] }; const takes = []; const skipped = []; for (const e of entries) { if (e.name.startsWith(".")) continue; const dir = path.join(root, e.name); const isDir = e.isDirectory() || (e.isSymbolicLink() && (await stat(dir).then((s) => s.isDirectory(), () => false))); if (!isDir) continue; let text; try { text = await readFile(path.join(dir, TAKE_FILE), "utf8"); } catch { continue; // not a take } let raw; try { raw = JSON.parse(text); } catch (err) { skipped.push({ dir: e.name, why: `take.json does not parse: ${err instanceof Error ? err.message : err}` }); continue; } const r = parseTake(e.name, raw); if ("error" in r) { skipped.push({ dir: e.name, why: r.error }); continue; } const file = await previewFile(projectDir, r.take); takes.push({ ...r.take, previewSize: file?.size ?? null, previewMtimeMs: file?.mtimeMs ?? null }); } takes.sort((a, b) => a.order - b.order || a.id.localeCompare(b.id)); const byGroup = new Map(); for (const t of takes) { if (!byGroup.has(t.group)) byGroup.set(t.group, []); byGroup.get(t.group).push(t); } const groups = [...byGroup.entries()] .map(([group, list]) => ({ group, takes: list })) .sort((a, b) => a.takes[0].order - b.takes[0].order || a.group.localeCompare(b.group)); skipped.sort((a, b) => a.dir.localeCompare(b.dir)); return { exists: true, takes, groups, skipped }; } /** * A take's preview, if it is a file whose REAL path is inside that take's own * directory. A symlink out of it, or a take directory that is itself a link * out of `takes/`, is refused, as deckPreviewFile refuses one. * * @param {string} projectDir * @param {{ id: string, preview: string }} take an already-parsed take * @returns {Promise<{ abs: string, size: number, mtimeMs: number } | null>} */ export async function previewFile(projectDir, take) { if (!isTakeId(take?.id)) return null; const root = takesDirOf(projectDir); const dir = path.join(root, take.id); const abs = path.resolve(dir, take.preview); if (!inside(dir, abs) || abs === dir) return null; const [realRoot, realDir, realAbs] = await Promise.all( [root, dir, abs].map((p) => realpath(p).catch(() => null)), ); if (!realRoot || !realDir || !realAbs) return null; if (!inside(realRoot, realDir) || realDir === realRoot) return null; if (!inside(realDir, realAbs) || realAbs === realDir) return null; const st = await stat(realAbs).catch(() => null); if (!st?.isFile()) return null; return { abs: realAbs, size: st.size, mtimeMs: Math.round(st.mtimeMs) }; } // --------------------------------------------------------------------------- // Verdicts. // // { "": { "verdict": "like" | "maybe" | "no" | null, "note": string, "at": ISO } } // // Read back by the agent that made the takes, so the shape is fixed and every // row carries all three keys. A row whose verdict is null and note empty is // REMOVED rather than stored -- it says nothing. // // Its own write queue, as manifest.mjs has its own: lib/state.ts is TypeScript // (and serialises the song state files, an unrelated set). Within a process the // queue serialises read-modify-write; across processes the tmp + rename keeps // a reader from ever seeing half a file. // --------------------------------------------------------------------------- /** @type {Promise} */ let queue = Promise.resolve(); /** * @template T * @param {() => Promise} fn * @returns {Promise} */ function withTakesLock(fn) { const run = queue.then(fn, fn); queue = run.then( () => undefined, () => undefined, ); return run; } /** Rows that are not the contract's shape are dropped on read, never repaired on disk. */ function cleanVerdicts(raw) { const out = {}; if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out; for (const [id, row] of Object.entries(raw)) { if (!isTakeId(id) || !row || typeof row !== "object") continue; const verdict = TAKE_VERDICTS.includes(row.verdict) ? row.verdict : null; const note = typeof row.note === "string" ? row.note : ""; const at = typeof row.at === "string" ? row.at : ""; out[id] = { verdict, note, at }; } return out; } /** * @param {string} projectDir * @returns {Promise>} */ export async function readVerdicts(projectDir) { try { return cleanVerdicts(JSON.parse(await readFile(verdictsFileOf(projectDir), "utf8"))); } catch { return {}; } } /** * Set one take's verdict and/or note. A field left undefined keeps its value; * `verdict: null` clears it. Returns the row as stored (null when removed) and * the whole file. * * A verdicts.json that exists and does not parse is an ERROR, not an empty * map: writing over it would erase every judgement in it. * * @param {string} projectDir * @param {string} takeId * @param {{ verdict?: string | null, note?: string }} patch */ export async function setTakeVerdict(projectDir, takeId, patch) { if (!isTakeId(takeId)) throw new Error("not a take id"); if (patch.verdict !== undefined && patch.verdict !== null && !TAKE_VERDICTS.includes(patch.verdict)) { throw new Error(`verdict must be one of ${TAKE_VERDICTS.join(", ")} or null`); } if (patch.note !== undefined && typeof patch.note !== "string") throw new Error("note must be a string"); const file = verdictsFileOf(projectDir); return withTakesLock(async () => { let text = null; try { text = await readFile(file, "utf8"); } catch (err) { if (/** @type {NodeJS.ErrnoException} */ (err).code !== "ENOENT") throw err; } let map = {}; if (text !== null) { try { map = cleanVerdicts(JSON.parse(text)); } catch { throw new Error(`${VERDICTS_FILE} does not parse; not overwriting it`); } } const prev = map[takeId] ?? { verdict: null, note: "", at: "" }; const row = { verdict: patch.verdict === undefined ? prev.verdict : patch.verdict, note: patch.note === undefined ? prev.note : patch.note.slice(0, TAKE_NOTE_LIMIT).replace(/\s+$/, ""), at: new Date().toISOString(), }; if (row.verdict === null && !row.note) delete map[takeId]; else map[takeId] = row; const tmp = `${file}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`; await writeFile(tmp, JSON.stringify(map, null, 2) + "\n", "utf8"); await rename(tmp, file); return { entry: map[takeId] ?? null, verdicts: map }; }); } /** * Every take with its verdict, for an agent reading the conversation back * (`umtool notes`, the notes digest): label, group, summary and changes from * take.json, and the operator's verdict and note from verdicts.json. A verdict * on a take that no longer exists is kept, with `missing: true`, rather than * dropped -- the agent may have deleted the take it was about. * * @param {string} projectDir * @returns {Promise>} */ export async function takesWithVerdicts(projectDir) { const [listing, verdicts] = await Promise.all([listTakes(projectDir), readVerdicts(projectDir)]); const out = listing.takes.map((t) => ({ id: t.id, group: t.group, label: t.label, summary: t.summary, changes: t.changes, verdict: verdicts[t.id]?.verdict ?? null, note: verdicts[t.id]?.note ?? "", at: verdicts[t.id]?.at ?? "", })); const known = new Set(out.map((t) => t.id)); for (const [id, v] of Object.entries(verdicts)) { if (known.has(id)) continue; out.push({ id, group: null, label: null, summary: "", changes: [], verdict: v.verdict, note: v.note, at: v.at, missing: true }); } return out; }