Archilyzer · Source

archilyzer

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

commit 63199cc69bf204e1a4b4e8ae8bde9fabc07d4e1e
parent bafc953c31e8555d3182c764418f8d4a2ae5577a
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Mon, 28 Sep 2026 12:47:16 -0400

sites: /sites re-renders when a homepage build lane ends, so "built <when>" is current

JobLane gets `onSettled(outcome)`: called once when the lane reaches its
terminal state (done / failed / cancelled, or "error" when the trigger
refused), through a ref so the one-shot launch effect calls the latest
prop, and guarded so it fires at most once under Strict Mode's
mount-cleanup-mount (release 11 slice O4's JobLane fix). Not called for a
lane already unmounted by a newer launch.

HomepageBuildButtons calls router.refresh() when a lane that BUILDS
(Build homepage, Build & deploy homepage) ends with a job outcome — every
outcome, not only done: a failed or cancelled build may already have
rewritten or emptied homepage/out, and the line says what Deploy homepage
would ship. As StreamActionLog refreshes after any run that started.

e2e sites-homepage.spec:
- new: a build lane cancelled from OUTSIDE the page (the harness reset;
  the lane's own Cancel is a revalidating server action that re-renders by
  itself), passive refresh off — a site written after the page loaded
  appears without a reload. Nothing builds: both queues held, as before.
- the Build homepage test also polls the job's sidecar to `cancelled`
  (the previous commit's fix, seen from the UI path).

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Meditor/app/sites/components/HomepageBuildButtons.tsx | 16+++++++++++++++-
Meditor/app/sites/components/JobLane.tsx | 54++++++++++++++++++++++++++++++++++++++----------------
Meditor/e2e/sites-homepage.spec.ts | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------
3 files changed, 113 insertions(+), 25 deletions(-)

diff --git a/editor/app/sites/components/HomepageBuildButtons.tsx b/editor/app/sites/components/HomepageBuildButtons.tsx @@ -1,6 +1,7 @@ "use client"; import { useState } from "react"; +import { useRouter } from "next/navigation"; import { MAX_PREVIEW_BRANCH, previewAliasUrl, @@ -13,7 +14,7 @@ import { deployHomepageAction, } from "../lib/homepageDeployActions"; import { useHydrated } from "../../operations/components/useOperationsStatus"; -import { JobLane } from "./JobLane"; +import { JobLane, type LaneOutcome } from "./JobLane"; type Lane = { kind: "build" | "build-deploy" | "deploy"; @@ -55,6 +56,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { const [lane, setLane] = useState<Lane | null>(null); const [run, setRun] = useState(0); const hydrated = useHydrated(); + const router = useRouter(); const branch = preview.trim(); const problem = branch ? previewBranchProblem(branch) : null; @@ -67,6 +69,17 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { setLane({ kind, key: next, preview: branch || undefined }); } + // "built <when>" is read from homepage/out when /sites renders, so a lane + // that BUILT has to re-render the page for the line to move (release 13 + // slice W1). Every outcome of a job, not only "done": a failed or cancelled + // build may already have rewritten or emptied homepage/out, and the line — + // which is what Deploy homepage would ship — must say so. "error" started no + // job, and a deploy writes nothing the page reads. StreamActionLog refreshes + // after any run that started, for the same reason. + function settled(l: Lane, outcome: LaneOutcome) { + if (l.kind !== "deploy" && outcome !== "error") router.refresh(); + } + function trigger(l: Lane) { const opts = l.preview ? { previewBranch: l.preview } : undefined; return l.kind === "build" @@ -180,6 +193,7 @@ export function HomepageBuildButtons({ project, builtAt }: Props) { : `homepage/out · ${project}${lane.preview ? ` (preview ${lane.preview})` : " (production)"}` } trigger={() => trigger(lane)} + onSettled={(outcome) => settled(lane, outcome)} /> )} </div> diff --git a/editor/app/sites/components/JobLane.tsx b/editor/app/sites/components/JobLane.tsx @@ -13,11 +13,19 @@ type LaneStatus = | "cancelled" | "error"; +// How a lane ENDS: its job's terminal status, or "error" when the trigger +// refused (or threw) before any job existed. +export type LaneOutcome = "done" | "failed" | "cancelled" | "error"; + type Props = { title: string; subtitle: string; // Launched once on mount. Returns the managed-job descriptor (or an error). trigger: () => Promise<StreamActionResult>; + // Called ONCE, when the lane reaches its terminal state, with that state — + // e.g. to refresh a server-rendered "built <when>" line after a build. Not + // called when the lane is unmounted first (another launch replaced it). + onSettled?: (outcome: LaneOutcome) => void; }; const CHIP: Record<LaneStatus, { label: string; cls: string }> = { @@ -35,7 +43,7 @@ const CHIP: Record<LaneStatus, { label: string; cls: string }> = { // the terminal status. A light poll surfaces queued/running + queue position // before the job reaches a terminal state. Mirrors StreamActionLog's plumbing in // miniature so several lanes can run at once on the batch panel. -export function JobLane({ title, subtitle, trigger }: Props) { +export function JobLane({ title, subtitle, trigger, onSettled }: Props) { const [status, setStatus] = useState<LaneStatus>("starting"); const [log, setLog] = useState(""); const [error, setError] = useState<string | null>(null); @@ -58,6 +66,16 @@ export function JobLane({ title, subtitle, trigger }: Props) { aliveRef.current = false; }; }, []); + // The latest onSettled, read when the lane ends: the launch effect below runs + // once, so a prop captured by its closure would be the first render's. And + // settle() fires it at most once — the launch is one-shot (startedRef), but + // "once" is this prop's contract, so it is guarded here rather than implied + // by the effect's shape (Strict Mode's mount → cleanup → mount included). + const onSettledRef = useRef(onSettled); + useEffect(() => { + onSettledRef.current = onSettled; + }); + const settledRef = useRef(false); useEffect(() => { // Strict-mode mounts effects twice in dev; guard so the job launches once. @@ -66,6 +84,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { const unmounted = () => !aliveRef.current; let stopped = false; let pollTimer: ReturnType<typeof setTimeout> | null = null; + // The lane's terminal state: its chip, then the caller (once). + function settle(outcome: LaneOutcome) { + if (unmounted()) return; + setStatus(outcome); + if (settledRef.current) return; + settledRef.current = true; + onSettledRef.current?.(outcome); + } async function poll(id: string) { try { @@ -103,16 +129,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { try { result = await trigger(); } catch (e) { - if (!unmounted()) { - setStatus("error"); - setError((e as Error).message); - } + if (!unmounted()) setError((e as Error).message); + settle("error"); return; } if (unmounted()) return; if (!result.ok) { - setStatus("error"); setError(result.error); + settle("error"); return; } setJobId(result.jobId); @@ -132,16 +156,14 @@ export function JobLane({ title, subtitle, trigger }: Props) { const term = await result.done; stopped = true; if (pollTimer) clearTimeout(pollTimer); - if (!unmounted()) { - setQueuePos(null); - setStatus( - term.status === "done" - ? "done" - : term.status === "failed" - ? "failed" - : "cancelled", - ); - } + if (!unmounted()) setQueuePos(null); + settle( + term.status === "done" + ? "done" + : term.status === "failed" + ? "failed" + : "cancelled", + ); })(); return () => { diff --git a/editor/e2e/sites-homepage.spec.ts b/editor/e2e/sites-homepage.spec.ts @@ -7,21 +7,28 @@ // from a spec would be a real one. So: // - no spec clicks Deploy homepage, and none ticks Deploy after build and // then clicks Build homepage; -// - the one spec that clicks Build homepage first holds BOTH the `build` and +// - the two specs that click Build homepage first hold BOTH the `build` and // the `deploy` queue with fabricated jobs (/api/test/stuck-job, never -// released here), so the job it starts only ever QUEUES — the +// released here), so the job each starts only ever QUEUES — the // build-homepage it expects on `build`, and equally a build-deploy-homepage // on `deploy` if a regression turned the click into a build-and-deploy — -// and it is cancelled from its own lane while still queued. Its start -// function never runs: no log file, no child, nothing written to -// homepage/public or homepage/out. If the spec fails before its Cancel, the -// next resetData cancels jobs newest first, so the queued job is removed -// before a holder's slot is freed. +// and it is cancelled while still queued: from its own lane, or (the +// re-render spec) by the harness reset. Its start function never runs: no +// log file, no child, nothing written to homepage/public or homepage/out. +// If a spec fails before its cancel, the next resetData cancels jobs newest +// first, so the queued job is removed before a holder's slot is freed. import { readdir } from "node:fs/promises"; import { test, expect, type Page } from "@playwright/test"; import { baseUrl } from "./baseUrl"; -import { pathExists, readJson, resetData, resolvePath } from "./helpers"; +import { + pathExists, + readJson, + resetData, + resolvePath, + writeSettings, + writeSite, +} from "./helpers"; const group = (page: Page) => page.getByRole("group", { name: "Homepage build" }); @@ -205,4 +212,49 @@ test("Build homepage starts a build-homepage job on the build queue (held there, expect(log.status).toBe("cancelled"); expect(log.content).toBe(""); expect(await pathExists(`test-transcripts/.jobs/${job.id}.log`)).toBe(false); + // …and its sidecar says so too (release 13 slice W1). A job cancelled while + // queued used to keep the "queued" its enqueue wrote: the cancel path never + // rewrote the meta, so once the registry forgot the job (a restart, or 100 + // newer ones) /jobs read it back as still queued. + await expect + .poll(async () => (await metasOfKind("build-homepage"))[0]?.status) + .toBe("cancelled"); +}); + +test("a build lane that ends re-renders /sites, so the \"built <when>\" line is current", async ({ + page, + request, +}) => { + // The line reads homepage/out when /sites renders, and nothing here may + // build (see the header) — so the proof is that the page RE-RENDERS when the + // lane ends: a site written to disk after the page loaded appears without a + // reload. Passive refresh is off (it would re-render on the job's status + // change by itself), and the job is cancelled from OUTSIDE the page, through + // the harness reset, because the lane's own Cancel is a server action that + // revalidates, and its response re-renders the page on its own. + await writeSettings({ autoRefreshIntervalSeconds: 0 }); + for (const queue of ["build", "deploy"]) { + const hold = await request.get( + `${baseUrl}/api/test/stuck-job?queue=${encodeURIComponent(queue)}`, + ); + expect(hold.ok(), queue).toBe(true); + } + + await openSites(page); + await buildButton(page).click(); + await expect(group(page).getByText(/^Queued/)).toBeVisible({ timeout: 15_000 }); + + await writeSite("refresh-probe"); + const probe = page.locator('a[href="/sites/refresh-probe"]'); + await expect(probe).toHaveCount(0); + + // Cancels every live job, newest first: the queued build before either + // holder's slot is freed, so nothing is promoted and nothing builds. + const reset = await request.get(`${baseUrl}/api/test/invalidate-cache`); + expect(reset.ok()).toBe(true); + + await expect(group(page).getByText("Cancelled", { exact: true })).toBeVisible({ + timeout: 15_000, + }); + await expect(probe).toBeVisible({ timeout: 15_000 }); });