commit bd4bbe8ec9fec7e33741450000c1190ed8e31a57
parent 50a218193ec36dc01e65333a662ad1112fcb7bf1
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Mon, 21 Sep 2026 01:52:56 -0400
deliver: corrections.md is the artifact, and the panel stops re-reading the project
Two things.
`umtool corrections` is REDIRECTED into corrections.md. finish-sweep.sh opens
with exactly that redirect and the overnight review reads the file, so a job
log somebody has to copy out of a browser is not the same deliverable. The
destination and the command go to `sh` as POSITIONAL arguments -- a project id
or a directory can then contain anything at all without becoming shell -- and
`exec` keeps the command's own exit status.
And the panel's mount no longer asks for the whole deliver state. "Is anything
running" was costing a second read of every cue file, every share folder and
every content module on every project page load, beside the server component
that had just done it. The job registry is process-wide and holds one job, so
`?running=1` answers without a project -- and the client adopts it only when
its `project` matches, because otherwise another project's build would appear
under this one's log.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
4 files changed, 47 insertions(+), 7 deletions(-)
diff --git a/umtool/app/api/report/deliver/route.ts b/umtool/app/api/report/deliver/route.ts
@@ -44,6 +44,17 @@ export async function GET(request: Request) {
return Response.json(jobView(job, Number(url.searchParams.get("since") ?? 0)), { headers });
}
+ // "Is anything running" is the ONLY question the panel asks on mount, and it
+ // is not worth a second full read of the project: the state below re-reads
+ // every cue file, every share folder and every content module, and the
+ // server component beside it has just done exactly that. The job registry is
+ // process-wide, so this needs no project at all -- the caller compares the
+ // job's own `project` field.
+ if (url.searchParams.get("running") === "1") {
+ const r = runningJob();
+ return Response.json({ running: r ? jobView(r) : null }, { headers });
+ }
+
const projectId = url.searchParams.get("project") ?? "";
const project = await projectRef(projectId);
if (!project) return Response.json({ error: "no such project" }, { status: 404, headers });
diff --git a/umtool/components/projects/DeliverActions.tsx b/umtool/components/projects/DeliverActions.tsx
@@ -26,6 +26,7 @@ type StepView = { label: string; argv: string[] };
type JobView = {
id: string;
kind: string;
+ project: string | null;
state: "running" | "done" | "failed";
stepIndex: number;
steps: StepView[];
@@ -71,12 +72,16 @@ export default function DeliverActions({
// Adopt a job already running, so a reload does not lose one. Also how a
// second tab sees the first tab's cut.
useEffect(() => {
- void fetch(`/api/report/deliver?project=${encodeURIComponent(project)}`, { cache: "no-store" })
+ void fetch("/api/report/deliver?running=1", { cache: "no-store" })
.then((r) => r.json())
.then((j) => {
- if (j.running) {
- setJob(j.running as JobView);
- since.current = (j.running as JobView).log.length;
+ // The registry is process-wide and holds one job, which may be another
+ // project's build. Adopting that here would show its log under this
+ // project's panel.
+ const run = j.running as JobView | null;
+ if (run && run.project === project) {
+ setJob(run);
+ since.current = run.log.length;
}
})
.catch(() => {});
diff --git a/umtool/e2e/deliver.spec.ts b/umtool/e2e/deliver.spec.ts
@@ -190,6 +190,12 @@ test("apply rulings is refused while the walk is unfinished, and runs the report
// whole point of running it, and the panel must not paraphrase them.
expect(log).toContain("content.py:6:");
expect(log).toContain("the speaker is the guest, not the host");
+ // And step 2's output is a FILE, because that is what the loop consumes:
+ // finish-sweep.sh opens with the same redirect and the overnight review
+ // reads corrections.md, not a log somebody copies out of a browser.
+ const corrections = readFileSync(path.join(DIR, "corrections.md"), "utf8");
+ expect(corrections).toContain("Corrections for the next pass");
+ expect(corrections).toContain("b02");
});
test("the panel shows the prose lines citing a clip the walk ruled incorrect", async ({
diff --git a/umtool/lib/report/driver.mjs b/umtool/lib/report/driver.mjs
@@ -345,10 +345,28 @@ export function applyRulingsSteps(project) {
timeoutMs: 10 * 60_000,
},
{
- cwd: UMTOOL_DIR,
+ cwd: project.dir,
env: {},
- label: `umtool corrections ${project.id}`,
- argv: ["node", tool("umtool.mjs"), "corrections", project.id],
+ label: `umtool corrections ${project.id} → corrections.md`,
+ // REDIRECTED, because the file is the artifact. finish-sweep.sh opens
+ // with `umtool corrections <id> > corrections.md` and the overnight
+ // review reads that file; a job log somebody has to copy out of a
+ // browser is not the same thing.
+ //
+ // `$0` is the destination and `"$@"` the command, both passed as
+ // POSITIONAL arguments rather than interpolated into the script: a
+ // project id and a directory can then contain anything at all without
+ // becoming shell. `exec` keeps the command's own exit status.
+ argv: [
+ "sh",
+ "-c",
+ 'exec "$@" > "$0"',
+ path.join(project.dir, "corrections.md"),
+ "node",
+ tool("umtool.mjs"),
+ "corrections",
+ project.id,
+ ],
timeoutMs: 5 * 60_000,
},
];