import { readSong, resolveRendition, thumbAliasesFor } from "./browse"; import { cachedLoudness } from "./loudness"; import { DEFAULT_TARGET, loudnessVerdict } from "./loudness-types"; import { buildStatus, readManifest } from "./manifest"; import { readSpec, validateSpec } from "./spec"; import { readNotes, type NoteMap } from "./notes"; import { listNotesFiles } from "./annotations/targets.mjs"; import { acceptedFor, readThumbAccepted, readThumbManifest, thumbNamesFor } from "./thumbs"; // --------------------------------------------------------------------------- // Everything undecided, in one worklist. // // The producers all existed already -- a cut with no file, a variant with no // verdict, a recipe that no longer describes its file, a spec that names a trim // set nobody wrote -- but each of them only ever spoke about ONE song, on that // song's page, and songContext() rendered them as prose. So the answer to "what // is actually waiting on me" was: open five pages and read them. // // This is a REDUCER, not a scan. Every call it makes is one the song page // already makes, so the inbox costs what one song page costs, times the number // of songs. // // `project` rather than `song`. It is the one neutral name that matters here: // the second video pipeline in this repo (umtool/report-to-video) has open // decisions of exactly this shape -- a clip nobody judged, a manifest whose // sources have gone -- and when it arrives as a second project kind it should // land in this list rather than beside it. Nothing else in the type mentions a // song, a cut or a plan, and that is deliberate. // --------------------------------------------------------------------------- /** * A decision kind, as a plain string. * * It used to be a closed union of the nine the song reducer emits. It cannot * stay one: a kind's vocabulary belongs to that kind, and a union here would * mean every future kind -- report video, supercut, cover set -- editing this * shared file to say a word only it uses. The real list is assembled from the * registry as `ALL_DECISION_KINDS` in lib/projects.ts, and an e2e spec asserts * the ids are unique across kinds. * * The song kind's own nine are below, for reference and for the registry entry: * unjudged-variant, missing-cut, no-recipe, stale-recipe, spec-problem, * no-plan, unattributed, thumb-unaccepted, and loudness -- which is reported * from the loudness CACHE only, because this reducer must never measure. An * inbox that shells out to ffmpeg once per rendition is an inbox that takes a * minute to open, which is the one thing it cannot afford to be. */ export type DecisionKind = string; /** * `blocking` something downstream would LIE if you acted on it -- a spec error * makes an operation describe a file that is not there. * `open` a real decision nobody has made. * `info` a true fact that is not a decision. A song that never had a * vertical is not waiting on you. * * Severity is EARNED, never decorative. An inbox that marks four missing cuts * per song as blocking is an inbox nobody opens twice. */ export type Severity = "blocking" | "open" | "info"; export type Decision = { kind: DecisionKind; /** The project's id -- a POSIX path relative to REPORTS_ROOT. */ project: string; /** Filled by the dispatcher, so a list can name a project without re-reading it. */ projectTitle?: string; projectKind?: string; /** A rel, a cut name, a spec field -- whatever the decision is about. */ target: string; /** One line, already human. */ why: string; /** Where to go and settle it. */ href: string; severity: Severity; /** Newest evidence first. */ at: number; }; export const SEVERITIES: Severity[] = ["blocking", "open", "info"]; const RANK: Record = { blocking: 0, open: 1, info: 2 }; export function sortDecisions(items: Decision[]): Decision[] { return [...items].sort((a, b) => RANK[a.severity] - RANK[b.severity] || b.at - a.at); } export function countBySeverity(items: Decision[]): Record { const out: Record = { blocking: 0, open: 0, info: 0 }; for (const d of items) out[d.severity] += 1; return out; } /** The one thing worth saying in a header: how much is actually waiting. */ export const openCount = (items: Decision[]): number => items.filter((d) => d.severity !== "info").length; const noteAt = (notes: NoteMap, target: string): number => { const t = notes[target]?.at; const ms = t ? Date.parse(t) : NaN; return Number.isFinite(ms) ? ms : 0; }; /** Every open decision in one song. */ export async function decisionsForSong(id: string): Promise { const song = await readSong(id); if (!song) return []; const spec = await readSpec(id); const [problems, manifest, notes, accepted, generated] = await Promise.all([ validateSpec(spec), readManifest(id), readNotes(id), readThumbAccepted(), readThumbManifest(), ]); const out: Decision[] = []; const songHref = `/browse/${id}`; const cutHref = (cut: string) => `/browse/${id}/${cut}`; const target = { lufs: spec.loudness?.targetLufs ?? DEFAULT_TARGET.lufs, truePeak: spec.loudness?.truePeak ?? DEFAULT_TARGET.truePeak, }; // --- the spec sheet ------------------------------------------------------ for (const p of problems) { out.push({ kind: "spec-problem", project: id, target: p.where, why: p.message, href: songHref, // `pending` is the sheet being right about a file you have not rendered // yet, which is a normal state to save from -- so it is not a decision. severity: p.level === "error" ? "blocking" : "info", at: song.newestMtimeMs, }); } // --- the cuts ------------------------------------------------------------ const recorded = Object.keys(manifest.builds).length; for (const cut of song.cuts) { if (!cut.shipped) { // A hole in the cut set is information; whether it is a DECISION depends // on whether anything says this song was meant to have one. Variants // named for the cut, or a note on the slot, are that evidence. const live = cut.variants.filter((v) => !v.retired); const note = notes[`cut:${cut.name}`]; const why = live.length ? `${live.length} variant${live.length > 1 ? "s" : ""} named for it, nothing shipped` : note ? "a note on the slot, but nothing shipped" : "never built — no variants, no note, so nothing says it was meant to exist"; out.push({ kind: "missing-cut", project: id, target: cut.name, why, href: cutHref(cut.name), severity: live.length || note ? "open" : "info", at: Math.max(...live.map((v) => v.mtimeMs), noteAt(notes, `cut:${cut.name}`), 0), }); continue; } const abs = resolveRendition(id, cut.shipped.rel); const measured = abs ? await cachedLoudness(abs) : null; if (measured) { const v = loudnessVerdict(measured, target); if (!v.ok || Math.abs((measured.integrated ?? target.lufs) - target.lufs) > 1) { out.push({ kind: "loudness", project: id, target: cut.shipped.rel, why: [v.line, ...v.flags].join(" · "), href: cutHref(cut.name), // A reading, not a decision: nothing here is waiting on an answer, // it is waiting on a re-render somebody may or may not want. severity: "info", at: cut.shipped.mtimeMs, }); } } const status = await buildStatus(id, cut.shipped.rel, manifest); if (status.stale) { out.push({ kind: "stale-recipe", project: id, target: cut.shipped.rel, why: `${status.stale} — the recipe is a lead, not a fact`, href: cutHref(cut.name), severity: "open", at: cut.shipped.mtimeMs, }); } else if (!status.entry && recorded > 0) { // Only worth saying when this song records recipes at all. A song where // nothing was ever recorded produces four identical rows that say the // same thing the song page already says. out.push({ kind: "no-recipe", project: id, target: cut.shipped.rel, why: "shipped without a recorded recipe, though other cuts here have one", href: cutHref(cut.name), severity: "info", at: cut.shipped.mtimeMs, }); } for (const v of cut.variants) { if (v.retired) continue; if (v.verdict && v.verdict.verdict !== "undecided") continue; out.push({ kind: "unjudged-variant", project: id, target: v.rel, // A variant COMPETES only when there is something shipped for it to // beat. Otherwise it is a candidate for an empty slot, and the decision // is the missing cut above, not this. why: `\`${v.tag}\` has no verdict against the shipped ${cut.name}`, href: `${cutHref(cut.name)}?v=${encodeURIComponent(v.rel)}`, severity: "open", at: v.mtimeMs, }); } } // --- variants that match no cut ----------------------------------------- for (const v of song.unattributed) { out.push({ kind: "unattributed", project: id, target: v.rel, why: "matches no cut name — attribute it by renaming, never by guessing", href: songHref, severity: "open", at: v.mtimeMs, }); } // --- which arrangement shipped ------------------------------------------ const named = spec.voices?.plan; if (named && !song.plans.some((p) => p.name === named)) { out.push({ kind: "no-plan", project: id, target: named, why: `spec names plan/${named}, which is not there — provenance cannot resolve`, href: songHref, severity: "open", at: song.newestMtimeMs, }); } else if (!named && song.plans.length > 0) { out.push({ kind: "no-plan", project: id, target: "voices.plan", why: `${song.plans.length} plan${song.plans.length > 1 ? "s" : ""} in plan/ and none named — ` + "which one shipped is not recorded, so it is not guessed", href: songHref, // Three of the five songs here have a 2-voice and a 3-voice arrangement // of the same tune. Nothing on disk says which shipped; only a person // can, and until they do every provenance figure is unattributed. severity: "open", at: Math.max(...song.plans.map((p) => p.mtimeMs), 0), }); } // --- cover art ----------------------------------------------------------- const candidates = thumbNamesFor(generated, id, thumbAliasesFor(id)); if (candidates.length && !acceptedFor(accepted, id, thumbAliasesFor(id))) { out.push({ kind: "thumb-unaccepted", project: id, target: candidates.join(", "), why: `${candidates.length} generated cover${candidates.length > 1 ? "s" : ""}, none accepted`, href: songHref, severity: "info", at: song.newestMtimeMs, }); } return sortDecisions(out); } // openDecisions() moved to lib/projects.ts, which is where the enumerator lives // now: it walks every project of every kind and dispatches to that kind's own // provider. This file kept the song reducer, which is what it always was. /** The decisions in one song, as the paste already renders everything else. */ export function decisionsMarkdown(items: Decision[]): string[] { if (!items.length) return []; const out = ["## Open decisions"]; for (const d of items) { out.push(`- ${d.severity === "info" ? "" : `**${d.severity}** · `}${d.kind} \`${d.target}\` — ${d.why}`); } return out; } // --------------------------------------------------------------------------- // Open NOTES -- on an article (sites//reports//notes.json) or on a // report-video project (/notes.json) -- are open decisions: somebody // asked for a change and nobody has answered it. One row per open note, kind // `open-note`, linked to the note on its page. A notes.json that does not // parse is blocking: nothing can write to it until somebody fixes it by hand. // // An ARTICLE is not a project, so its row's `project` is its page's path // (`sites//`), which is also where its href points. // --------------------------------------------------------------------------- const firstLine = (s: string, max = 140) => { const line = s.split("\n").find((l) => l.trim()) ?? ""; return line.length > max ? `${line.slice(0, max - 1)}…` : line; }; function anchorLabel(a: { kind: string; [k: string]: unknown }): string { switch (a.kind) { case "text": return `“${firstLine(String(a.quote ?? ""), 48)}”`; case "cite": return `cite ${a.cite}`; case "section": return `section ${a.section}`; case "moment": return `${a.file} @ ${Number(a.t).toFixed(1)}s`; case "entry": return `entry ${a.entry}`; case "take": return `take ${a.take}`; case "edit": return `edit ${a.field}${a.entry ? ` on ${a.entry}` : ""}`; default: return "whole"; } } export async function noteDecisions(): Promise { const files = await listNotesFiles().catch(() => []); const out: Decision[] = []; for (const f of files) { const article = f.kind === "article"; const project = article ? `sites/${f.id}` : f.id; const page = article ? `/sites/${f.id}` : `/browse/${f.id}`; if (f.error || !f.doc) { out.push({ kind: "unreadable-notes", project, projectKind: article ? "article" : ((f as { projectKind?: string }).projectKind ?? "project"), target: "notes.json", why: `notes.json does not parse (${f.error ?? "unknown"}); nothing will write to it until it is fixed`, href: page, severity: "blocking", at: Date.now(), }); continue; } for (const n of f.doc.notes) { if (n.status !== "open") continue; const replies = n.replies.length ? ` · ${n.replies.length} repl${n.replies.length === 1 ? "y" : "ies"}` : ""; const at = Date.parse(n.updatedAt); out.push({ kind: "open-note", project, projectKind: article ? "article" : ((f as { projectKind?: string }).projectKind ?? "project"), target: anchorLabel(n.anchor as { kind: string }), why: `${firstLine(n.text)}${replies}`, href: `${page}?note=${encodeURIComponent(n.id)}`, severity: "open", at: Number.isFinite(at) ? at : 0, }); } } return out; }