commit ce7eb8cf5c2614bafb9c062ee6bf2474d63bf9de
parent d3f1d2532024e74a41ed34ac36418e1c6e36add8
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Wed, 19 Aug 2026 00:01:37 -0400
umtool: a timeline entry is not card-or-clip — the vocabulary is open
/browse/quartering-employee-count 500'd with "The path argument must be of type
string. Received undefined".
readClipDetail() treated every entry that was not a `card` as a clip, so it read
`e.video` off entries that have no source and handed `undefined` to path.join().
That manifest's timeline is 19 clips, 10 cards, one `scroll` and one `chart` —
and it is the only one of the six that has anything but clips, which is exactly
why this survived every test and every hand-check against ferret-rescue.
The rule is the one the pipeline itself uses and the one clipsOf() already had: a
CLIP is `type === "clip"`. Everything else is a non-clip entry with no window and
no source, and it renders generically — so a third type does not crash the page,
and a fourth will not either.
The same assumption was in two more places, and the fixture found the second one
the moment it was written:
- components/projects/ReportProject.tsx rendered card-or-clip rows.
- `umtool show` crashed on `e.start.toFixed(2)` for the same reason.
Also fixed a related lie by omission: the card said "19 clips · 10 cards" about a
31-entry timeline. It now counts the rest ("2 chart/scroll"), and the project
page header says "19 clips · 12 other".
The fixture carries an entry of type `zz-unknown` — a type nothing in the code
knows about — plus a card, and a spec asserts the page is 200, that both render
with their real `data-kind`, and that all six entries are counted.
One test correctness fix on top, from the same run: build.spec's orphan check
asserted the MACHINE holds no build-video process. On a shared box that is an
assertion about somebody else's work — a concurrent agent's waiter shell, whose
command line contains `pgrep -f build-video.mjs`, matched it. It is now scoped to
this suite's own children: an actual `node …/build-video.mjs` invocation against
this fixture.
Verified against the real tree: all six report projects, both song URLs and the
decisions page are 200, all 31 entries render with their real types, and the clip
bench opens on that project. e2e 142 passed; the one red is triage.spec:180,
untouched by this, which times out a 5-second poll at load 25 and passes in
isolation.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Diffstat:
7 files changed, 116 insertions(+), 27 deletions(-)
diff --git a/umtool/bin/umtool.mjs b/umtool/bin/umtool.mjs
@@ -171,8 +171,14 @@ async function cmdShow() {
console.log(`\ncues from ${detail.channelsDir}${detail.shadowExists ? " (this project's shadow tree)" : ""}`);
console.log("\nthe cut:");
for (const e of detail.entries) {
- if (e.kind === "card") {
- console.log(` ${e.id.padEnd(5)} card ${e.seconds}s ${e.heading ?? e.title ?? ""}`);
+ // A CLIP is `type === "clip"`. Everything else -- a card, and the `scroll`
+ // and `chart` entries one real manifest carries -- has no window and no
+ // source, and the vocabulary is open, so it is printed generically rather
+ // than assumed to be one of two things.
+ if (e.kind !== "clip") {
+ const label = e.heading ?? e.title ?? e.label ?? "";
+ const secs = e.seconds != null ? `${e.seconds}s` : "";
+ console.log(` ${e.id.padEnd(5)} ${String(e.kind).padEnd(9)} ${secs.padStart(4)} ${label}`);
continue;
}
const marks = [
diff --git a/umtool/components/projects/ReportProject.tsx b/umtool/components/projects/ReportProject.tsx
@@ -64,6 +64,7 @@ export default async function ReportProject({
const { manifest: m, build, entries, channelsDir, shadowExists } = detail;
const clips = entries.filter((e) => e.kind === "clip");
+ const nonClips = entries.filter((e) => e.kind !== "clip");
const runtime = clips.reduce((n, e) => n + Math.max(0, e.end - e.start), 0);
const showAll = search.all === "1";
const p = m.provenance ?? {};
@@ -105,7 +106,11 @@ export default async function ReportProject({
<div className="flex h-full flex-col">
<BrowseHeader
crumbs={[{ href: "/browse", label: "projects" }, { label: project.id }]}
- note={`${clips.length} clips · ${hms(runtime)} · ${build.built ? "built" : "not built"}`}
+ note={
+ `${clips.length} clips` +
+ (nonClips.length ? ` · ${nonClips.length} other` : "") +
+ ` · ${hms(runtime)} · ${build.built ? "built" : "not built"}`
+ }
/>
<div className="flex flex-wrap items-start gap-3 border-b border-[var(--color-line)] px-4 py-2">
@@ -197,18 +202,24 @@ export default async function ReportProject({
</h2>
<ul className="space-y-1">
{entries.map((e) => {
- if (e.kind === "card") {
+ // Anything that is not a CLIP renders generically. The timeline's
+ // vocabulary is open -- one real manifest carries `scroll` and
+ // `chart` beside its cards -- and a page that only knows two words
+ // either crashes on the third or silently drops it.
+ if (e.kind !== "clip") {
return (
<li
key={e.id}
data-entry={e.id}
- data-kind="card"
+ data-kind={e.kind}
className="flex flex-wrap items-baseline gap-2 rounded border border-dashed border-[var(--color-line)] px-3 py-1.5 text-[12px]"
>
<span className="font-mono text-[var(--color-dim)]">{e.id}</span>
- <Pill>card · {e.style}</Pill>
- <span className="text-[var(--color-text)]">{e.heading ?? e.title ?? ""}</span>
- <span className="num micro ml-auto">{e.seconds}s</span>
+ <Pill>{e.style ? `${e.kind} · ${e.style}` : e.kind}</Pill>
+ <span className="text-[var(--color-text)]">
+ {e.heading ?? e.title ?? e.label ?? ""}
+ </span>
+ {e.seconds != null && <span className="num micro ml-auto">{e.seconds}s</span>}
</li>
);
}
diff --git a/umtool/docs/report-video.md b/umtool/docs/report-video.md
@@ -51,6 +51,16 @@ travel. umtool never reorders as a side effect of a window edit.
A card entry is `{"type":"card", "id", "style", "seconds", …}` — see the pipeline
README for the styles. Cards have no window and no source.
+**The entry vocabulary is OPEN, and code must treat it that way.** A CLIP is
+`type === "clip"`; everything else is a non-clip entry with no window and no
+source, and it is *not* necessarily a card. One real manifest
+(`quartering-employee-count`) carries `scroll` and `chart` entries beside its
+ten cards. Anything that branches on card-or-clip will send `undefined` into a
+path join the first time it meets a third type — which is exactly what 500'd the
+project page and crashed `umtool show`, on the one manifest of six that has any.
+The fixture now carries an entry of a type nothing in the code knows about, so
+this cannot come back.
+
## The three-stage window model
A clip's window passes through three different notions of "where the cut is", and
@@ -156,5 +166,8 @@ not assumed to share a clock), or convert the clip to a quote card.
over overlapping windows.
- **Mirrors are not assumed to share a clock.** Timestamps mapped from one mirror to
another have to be verified against the mirror's own cue text, not assumed.
+- **Every entry is a chapter, so counts must agree.** `verify-build.mjs` compares
+ chapter count to *timeline* length, not clip count — a mismatch means the file
+ and the timeline disagree about what is in it.
- **`--chapters-only` is cheap and separate.** Retitling chapters does not need a
re-encode; the per-clip segments on disk are all the offsets need.
diff --git a/umtool/e2e/build.spec.ts b/umtool/e2e/build.spec.ts
@@ -162,27 +162,41 @@ test("a second build is refused while one is running, and cancel leaves no orpha
// reaps the node process and leaves yt-dlp and ffmpeg running -- the same
// failure the diarize backfill had. Killing the process GROUP is what stops it.
//
- // pgrep is run WITHOUT a shell on purpose. Going through `bash -lc` puts the
- // pattern into bash's own command line, so pgrep matches the shell that is
- // asking -- a self-match that reads exactly like the orphan it is looking for.
+ // Scoped to THIS suite's own children, two ways, and both are needed.
+ //
+ // It runs pgrep without a shell: going through `bash -lc` puts the pattern
+ // into bash's own command line and pgrep matches the shell that is asking.
+ // And it keeps only lines that are an actual `node …/build-video.mjs`
+ // invocation against THIS FIXTURE -- because this is a shared machine, and a
+ // concurrent agent running its own builds (or merely waiting on one with
+ // `pgrep -f build-video.mjs` in its command line) is not an orphan of ours.
+ // Asserting the machine holds no build-video process at all is an assertion
+ // about somebody else's work.
//
// Polled rather than asserted once: the kill is SIGTERM to the group and
// SIGKILL five seconds later, so "gone" is a state it reaches rather than one
- // it is in the instant the job reports failed. Racing that produced a red on
- // one run and a green on the next.
- const gone = async () => {
- for (let i = 0; i < 30; i += 1) {
- let out = "";
- try {
- out = execFileSync("pgrep", ["-af", "build-video.mjs"]).toString().trim();
- } catch {
- return ""; // pgrep exits 1 when nothing matches, which is the good case
- }
- if (!out) return "";
- await new Promise((r) => setTimeout(r, 400));
+ // it is in the instant the job reports failed.
+ const ours = () => {
+ let out = "";
+ try {
+ out = execFileSync("pgrep", ["-af", "build-video.mjs"]).toString();
+ } catch {
+ return ""; // pgrep exits 1 when nothing matches, which is the good case
}
- return execFileSync("pgrep", ["-af", "build-video.mjs"]).toString().trim();
+ return out
+ .split("\n")
+ .filter(Boolean)
+ .filter((line) => {
+ const cmd = line.slice(line.indexOf(" ") + 1);
+ return /^\S*node\b/.test(cmd) && cmd.includes(FIXTURE);
+ })
+ .join("\n");
};
- const strays = await gone();
+ let strays = "";
+ for (let i = 0; i < 30; i += 1) {
+ strays = ours();
+ if (!strays) break;
+ await new Promise((r) => setTimeout(r, 400));
+ }
expect(strays, `left running: ${strays}`).toBe("");
});
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -742,6 +742,15 @@ const REPORT = writeProject(
{ type: "clip", id: "c03", video: "vid2", start: 1.0, end: 4.0, cite: 1, section: 0, quote: "no punctuation" },
// Also ends mid-cue, but lockEnd ACKNOWLEDGES it, so it must stay silent.
{ type: "clip", id: "c04", video: "vid1", start: 15.0, end: 18.0, cite: 15, section: 0, lockEnd: true, quote: "trailing off" },
+ // A card, and an entry of a type NOTHING IN THE CODE KNOWS ABOUT.
+ //
+ // The timeline's vocabulary is open: quartering-employee-count carries
+ // `scroll` and `chart` entries beside its cards, and code that treated
+ // anything-not-a-card as a clip sent `undefined` into path.join() and 500'd
+ // the whole project page. Every other real manifest is clips only, which is
+ // exactly why that survived testing. `zz-unknown` is here so it cannot again.
+ { type: "card", id: "k01", style: "chapter", seconds: 3, heading: "A card" },
+ { type: "zz-unknown", id: "z01", seconds: 5, heading: "An entry type from the future" },
]),
);
diff --git a/umtool/e2e/projects.spec.ts b/umtool/e2e/projects.spec.ts
@@ -473,3 +473,23 @@ test("umtool new scaffolds a project that check immediately blocks", () => {
expect(code).toBe(1);
rmSync(dir, { recursive: true, force: true });
});
+
+test("a timeline entry of an unknown type renders, rather than crashing the page", async ({
+ page,
+}) => {
+ // The manifest's vocabulary is OPEN. A real one carries `scroll` and `chart`
+ // beside its cards; code that assumed card-or-clip put `undefined` into
+ // path.join() and 500'd the project page. The rule is that a CLIP is
+ // `type === "clip"` and everything else renders generically.
+ const res = await page.goto("/browse/reports/report-fixture");
+ expect(res?.status()).toBe(200);
+
+ await expect(page.locator("[data-entry=z01]")).toHaveAttribute("data-kind", "zz-unknown");
+ await expect(page.locator("[data-entry=z01]")).toContainText("An entry type from the future");
+ await expect(page.locator("[data-entry=k01]")).toHaveAttribute("data-kind", "card");
+
+ // And it is COUNTED, not silently dropped: a card saying "4 clips · 1 card"
+ // about a 6-entry timeline would be lying by omission.
+ await expect(page.locator("[data-entry]")).toHaveCount(6);
+ await expect(page.locator("[data-entry=c01]")).toHaveAttribute("data-kind", "clip");
+});
diff --git a/umtool/lib/projects/report.mjs b/umtool/lib/projects/report.mjs
@@ -220,6 +220,9 @@ export async function summariseReport(ctx) {
const m = await readManifest(dir);
const clips = clipsOf(m);
const cards = cardsOf(m);
+ // Anything that is neither. The vocabulary is open, so a card that says
+ // "19 clips · 10 cards" about a 31-entry timeline is lying by omission.
+ const others = (m?.timeline ?? []).filter((e) => e?.type !== "clip" && e?.type !== "card");
const build = await buildStateOf(dir, m);
const runtime = clips.reduce((n, e) => n + Math.max(0, (e.end ?? 0) - (e.start ?? 0)), 0);
@@ -241,6 +244,10 @@ export async function summariseReport(ctx) {
const facts = [];
if (clips.length) facts.push(`${clips.length} clip${clips.length === 1 ? "" : "s"}`);
if (cards.length) facts.push(`${cards.length} card${cards.length === 1 ? "" : "s"}`);
+ if (others.length) {
+ const kinds = [...new Set(others.map((e) => e.type ?? "entry"))].sort();
+ facts.push(`${others.length} ${kinds.join("/")}`);
+ }
if (runtime > 0) facts.push(fmtDur(runtime));
if (sources) facts.push(`${sources} source${sources === 1 ? "" : "s"}`);
if (locked) facts.push(`${locked} locked`);
@@ -279,6 +286,7 @@ export async function summariseReport(ctx) {
attrs: {
clips: String(clips.length),
cards: String(cards.length),
+ other: String(others.length),
sources: String(sources),
locked: String(locked),
...(build.built ? { built: "1" } : {}),
@@ -527,8 +535,16 @@ export async function readClipDetail(dir, { manifest = null } = {}) {
const entries = [];
for (const e of m.timeline ?? []) {
- if (e.type === "card") {
- entries.push({ ...e, kind: "card" });
+ // A CLIP is `type === "clip"`. Everything else is a non-clip entry with no
+ // window and no source.
+ //
+ // Not `!== "card"`: the timeline's vocabulary is OPEN. quartering-employee-
+ // count carries `scroll` and `chart` entries beside its cards, and treating
+ // anything-that-is-not-a-card as a clip sent `undefined` into path.join()
+ // and 500'd the whole project page. Every other real manifest is clips only,
+ // which is exactly why this survived testing.
+ if (e.type !== "clip") {
+ entries.push({ ...e, kind: e.type ?? "entry" });
continue;
}
const chan = channelFor(m, e);