commit 5a717dd1901e8277854225d48f61c207305890e7
parent 44787dc1489f30ba075958844f095a9a046573e3
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Thu, 8 Oct 2026 22:27:59 -0400
umtool S0: /api/notes, useNotes, e2e SITES_DIR fixture
One route for both tracks (article and video-project targets, operator-stamped
writes, 409 on a stale token); the e2e server reads a fixture SITES_DIR.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
8 files changed, 214 insertions(+), 4 deletions(-)
diff --git a/umtool/app/api/notes/route.ts b/umtool/app/api/notes/route.ts
@@ -0,0 +1,51 @@
+import { errorResponse, targetFrom } from "@/lib/annotations/server";
+import { readNotes } from "@/lib/annotations/store.mjs";
+import { writeNote } from "@/lib/annotations/targets.mjs";
+
+export const dynamic = "force-dynamic";
+
+// Notes on an article or a report-video project (lib/annotations/, docs/notes.md).
+//
+// GET ?article=<site>/<report> | ?project=<id>
+// { subject, file, token, doc, source, error? } -- doc null when
+// there are no notes; `source` is what a first write would record.
+// POST same params, body { token, op: { op: "add" | "edit" | "status" |
+// "reply" | "delete" | "delete-reply" | "source", ... } }
+// { doc, token, note }. 409 when `token` is stale or the file on
+// disk is not a notes doc; 400 for a refused op or target.
+//
+// Every write from here is the OPERATOR's. An agent writes through
+// `umtool notes`, which stamps "agent"; there is no way to claim to be one here.
+const NO_STORE = { "cache-control": "no-store" };
+
+function params(request: Request) {
+ const url = new URL(request.url);
+ return { article: url.searchParams.get("article"), project: url.searchParams.get("project") };
+}
+
+export async function GET(request: Request) {
+ try {
+ const target = await targetFrom(params(request));
+ const read = await readNotes(target.file);
+ const source = read.doc?.source ?? (await target.source());
+ return Response.json(
+ { subject: target.subject, file: target.file, token: read.token, doc: read.doc, source: source ?? null, ...(read.error ? { error: read.error } : {}) },
+ { headers: NO_STORE },
+ );
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
+
+export async function POST(request: Request) {
+ try {
+ const target = await targetFrom(params(request));
+ const body = (await request.json().catch(() => null)) as { token?: unknown; op?: unknown } | null;
+ if (!body || typeof body.op !== "object" || body.op === null) return Response.json({ error: "body needs an op" }, { status: 400 });
+ const token = typeof body.token === "string" ? body.token : null;
+ const out = await writeNote(target, body.op as Record<string, unknown>, { by: "operator", token });
+ return Response.json(out, { headers: NO_STORE });
+ } catch (err) {
+ return errorResponse(err);
+ }
+}
diff --git a/umtool/e2e/fixtures/make-fixture.mjs b/umtool/e2e/fixtures/make-fixture.mjs
@@ -22,6 +22,7 @@ import { spawnSync } from "node:child_process";
import path from "node:path";
import { SONG_DATA } from "../../song/paths.mjs";
import { songCapabilities } from "./song-capabilities.mjs";
+import { makeSitesFixture } from "./sites-fixture.mjs";
const CODE = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..", "..", "song");
const dest = path.resolve(process.argv[2] ?? path.join(process.cwd(), ".e2e-song"));
@@ -1814,7 +1815,10 @@ take("intro-a", { group: "opening", order: 5, label: "Cold open", kind: "similar
take("bad-kind", { group: "finale", order: 4, label: "Bad", kind: "maybe" });
mkdirSync(path.join(TAKES, "takes", "current", "out"), { recursive: true });
+const { sites: SITES } = makeSitesFixture({ dest, reports, channels: CHANNELS });
+
console.log(`fixture at ${dest}`);
+console.log(` SITES_DIR=${SITES}`);
if (planned) console.log(` planned clip (used in a build): ${planned}`);
console.log(` videos/: alpha (4 cuts, 3 variants), beta (2 cuts), deck (1 cut, 2 variants)`);
console.log(` deck: 1 spec error, 1 stale recipe, 1 unjudged variant, 1 judged one`);
diff --git a/umtool/e2e/fixtures/sites-fixture.mjs b/umtool/e2e/fixtures/sites-fixture.mjs
@@ -0,0 +1,18 @@
+// The fixture's SITES_DIR (`<dest>/sites`), which the e2e server reads instead
+// of the real transcripts/sites (playwright.config.ts). The suite WRITES notes
+// there -- a report's notes.json is the one corpus file umtool writes -- so it
+// must never be the real one.
+//
+// Called by make-fixture.mjs after the projects and the channels exist, so a
+// report here can cite the fixture's channels and link its projects.
+import { mkdirSync } from "node:fs";
+import path from "node:path";
+
+/**
+ * @param {{ dest: string, reports: string, channels: string }} at
+ */
+export function makeSitesFixture({ dest }) {
+ const sites = path.join(dest, "sites");
+ mkdirSync(sites, { recursive: true });
+ return { sites };
+}
diff --git a/umtool/lib/annotations/server.ts b/umtool/lib/annotations/server.ts
@@ -0,0 +1,27 @@
+import { projectRef } from "@/lib/projects";
+import { articleTarget, projectTargetFor, TargetError } from "./targets.mjs";
+
+// The app's side of lib/annotations/targets.mjs: a request's `article` or
+// `project` parameter to a target, using the app's memoised project walk.
+// Everything about WHERE a note may be written is decided in targets.mjs.
+
+export type Target = Awaited<ReturnType<typeof articleTarget>> | Awaited<ReturnType<typeof projectTargetFor>>;
+
+export async function targetFrom(params: { article?: string | null; project?: string | null }): Promise<Target> {
+ if (params.article && params.project) throw new TargetError("pass article or project, not both");
+ if (params.article) return articleTarget(params.article);
+ if (params.project) {
+ const p = await projectRef(params.project);
+ if (!p) throw new TargetError(`no project ${params.project}`, 404);
+ return projectTargetFor(p);
+ }
+ throw new TargetError("pass ?article=<site>/<report> or ?project=<id>");
+}
+
+/** A thrown error as a response: typed refusals keep their status, the rest are 500s. */
+export function errorResponse(err: unknown): Response {
+ const status = typeof (err as { status?: unknown })?.status === "number" ? (err as { status: number }).status : null;
+ const name = (err as Error)?.name;
+ const code = status ?? (name === "NoteError" ? 400 : 500);
+ return Response.json({ error: err instanceof Error ? err.message : String(err) }, { status: code });
+}
diff --git a/umtool/lib/annotations/store.mjs b/umtool/lib/annotations/store.mjs
@@ -57,6 +57,7 @@ export async function notesToken(file) {
* file is there and is not a notes doc (the page shows it; writes refuse).
*
* @param {string} file
+ * @returns {Promise<{ doc: import("./types").NotesDoc | null, token: string, error?: string }>}
*/
export async function readNotes(file) {
const token = await notesToken(file);
@@ -133,13 +134,14 @@ export async function withNotesLock(file, fn, { waitMs = LOCK_WAIT_MS, staleMs =
* @param {{ subject: Record<string, string>, source?: Record<string, string> | null }} init
* @param {Record<string, any>} op
* @param {{ by: "operator" | "agent", token?: string | null }} opts
+ * @returns {Promise<{ doc: import("./types").NotesDoc | null, token: string, note: import("./types").Note | null }>}
*/
export async function writeOp(file, init, op, { by, token = null }) {
return withNotesLock(file, async () => {
const current = await readNotes(file);
if (current.error) throw new NotesUnreadable(file, current.error);
if (token !== null && token !== undefined && token !== current.token) throw new StaleNotes(token, current.token);
- const doc = current.doc ?? emptyDoc(init.subject, init.source ?? undefined);
+ const doc = /** @type {any} */ (current.doc ?? emptyDoc(init.subject, init.source ?? undefined));
const note = applyOp(doc, op, by);
if (doc.notes.length === 0) {
await unlink(/* turbopackIgnore: true */ file).catch((err) => {
diff --git a/umtool/lib/annotations/targets.mjs b/umtool/lib/annotations/targets.mjs
@@ -65,10 +65,22 @@ export async function articleTarget(spec, { sitesDir = SITES_DIR, reportsRoot =
export async function projectTarget(spec, { reportsRoot = REPORTS_ROOT } = {}) {
const r = await resolveProject(String(spec ?? ""), reportsRoot);
if (r.ambiguous) throw new TargetError(`${spec} names ${r.ambiguous.length} projects: ${r.ambiguous.map((p) => p.id).join(", ")}`);
- const p = r.project;
- if (!p) throw new TargetError(`no project ${spec}`, 404);
+ if (!r.project) throw new TargetError(`no project ${spec}`, 404);
+ return projectTargetFor(r.project, { reportsRoot });
+}
+
+/**
+ * The target for a project ref already in hand (the app's memoised walk).
+ *
+ * @param {{ id: string, dir: string, kind: string }} p
+ * @param {{ reportsRoot?: string }} [opts]
+ */
+export async function projectTargetFor(p, { reportsRoot = REPORTS_ROOT } = {}) {
if (p.kind !== "report-video") throw new TargetError(`${p.id} is a ${p.kind} project; notes are for report videos`);
- const [realRoot, realDir] = await Promise.all([realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null), realpath(/* turbopackIgnore: true */ p.dir).catch(() => null)]);
+ const [realRoot, realDir] = await Promise.all([
+ realpath(/* turbopackIgnore: true */ reportsRoot).catch(() => null),
+ realpath(/* turbopackIgnore: true */ p.dir).catch(() => null),
+ ]);
if (!realRoot || !realDir || !inside(realRoot, realDir) || realRoot === realDir) {
throw new TargetError(`${p.id} is not under the reports root`, 403);
}
diff --git a/umtool/lib/annotations/useNotes.ts b/umtool/lib/annotations/useNotes.ts
@@ -0,0 +1,92 @@
+"use client";
+
+import { useCallback, useEffect, useRef, useState } from "react";
+import type { NoteOp, NotesRead, Note, NotesDoc } from "./types";
+
+// The page's handle on one notes.json: read it, write one op at a time with the
+// token it read, and on a 409 re-read and say so rather than retrying blind --
+// an agent may have replied in between, and the operator should see that reply
+// before their edit lands on top of it.
+
+export type NotesTarget = { article: string } | { project: string };
+
+export function notesQuery(target: NotesTarget): string {
+ return "article" in target ? `article=${encodeURIComponent(target.article)}` : `project=${encodeURIComponent(target.project)}`;
+}
+
+export type UseNotes = {
+ doc: NotesDoc | null;
+ notes: Note[];
+ source: NotesRead["source"];
+ error: string | null;
+ loading: boolean;
+ busy: boolean;
+ /** Apply one op. Resolves to the note touched (null on delete), or null after an error (shown in `error`). */
+ write: (op: NoteOp) => Promise<Note | null>;
+ reload: () => Promise<void>;
+};
+
+export function useNotes(target: NotesTarget | null, { initial }: { initial?: NotesRead | null } = {}): UseNotes {
+ const [read, setRead] = useState<NotesRead | null>(initial ?? null);
+ const [error, setError] = useState<string | null>(initial?.error ?? null);
+ const [loading, setLoading] = useState(!initial && !!target);
+ const [busy, setBusy] = useState(false);
+ const token = useRef<string | null>(initial?.token ?? null);
+ const query = target ? notesQuery(target) : null;
+
+ const reload = useCallback(async () => {
+ if (!query) return;
+ setLoading(true);
+ try {
+ const res = await fetch(`/api/notes?${query}`, { cache: "no-store" });
+ const j = await res.json();
+ if (!res.ok) throw new Error(j.error ?? res.statusText);
+ token.current = j.token;
+ setRead(j);
+ setError(j.error ?? null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ }, [query]);
+
+ useEffect(() => {
+ if (!initial) void reload();
+ // `initial` is the server render's read; only a changed target re-reads.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [reload]);
+
+ const write = useCallback(
+ async (op: NoteOp): Promise<Note | null> => {
+ if (!query) return null;
+ setBusy(true);
+ try {
+ const res = await fetch(`/api/notes?${query}`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ token: token.current, op }),
+ });
+ const j = await res.json();
+ if (res.status === 409) {
+ await reload();
+ setError(`${j.error ?? "the notes changed"} -- reloaded; check and try again`);
+ return null;
+ }
+ if (!res.ok) throw new Error(j.error ?? res.statusText);
+ token.current = j.token;
+ setRead((r) => (r ? { ...r, doc: j.doc, token: j.token, source: j.doc?.source ?? r.source } : r));
+ setError(null);
+ return j.note ?? null;
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ return null;
+ } finally {
+ setBusy(false);
+ }
+ },
+ [query, reload],
+ );
+
+ return { doc: read?.doc ?? null, notes: read?.doc?.notes ?? [], source: read?.source ?? null, error, loading, busy, write, reload };
+}
diff --git a/umtool/playwright.config.ts b/umtool/playwright.config.ts
@@ -76,6 +76,10 @@ export default defineConfig({
// var. CHANNELS_DIR has to be said explicitly: it is where a report
// video's cue files live, and its default is the real 3 GB corpus.
`CHANNELS_DIR=${FIXTURE}/channels ` +
+ // The sites (/sites, article notes): the fixture's own, never the real
+ // transcripts/sites -- the one corpus file umtool writes is a report's
+ // notes.json, and the suite writes them.
+ `SITES_DIR=${FIXTURE}/sites ` +
// The cache (the project index, posters, analyses) is no longer under
// SONG_DIR (release 17): its default is the user's ~/.cache, which a
// suite must never write. The fixture's own, rebuilt with it every run.