Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

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:
Aumtool/app/api/notes/route.ts | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/e2e/fixtures/make-fixture.mjs | 4++++
Aumtool/e2e/fixtures/sites-fixture.mjs | 18++++++++++++++++++
Aumtool/lib/annotations/server.ts | 27+++++++++++++++++++++++++++
Mumtool/lib/annotations/store.mjs | 4+++-
Mumtool/lib/annotations/targets.mjs | 18+++++++++++++++---
Aumtool/lib/annotations/useNotes.ts | 92+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mumtool/playwright.config.ts | 4++++
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.