Archilyzer · Source

archilyzer

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

commit af8948946c67afbf8f6e10f11f40b81445d4b315
parent ef3fc94e99f3810b6b3009d284da24b54a93e630
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 12:29:15 -0400

editor: /operations/publish (the lane's runner, hold, settings and plan) and a site's publish policy on its form

A static route beside operations/[id]: the runner pill and Start / Drain /
Stop (startPublishLaneAction & co. in publishActions.ts), the hold
(pauseLaneControl, lane "publish"), last check / next check / last pass /
last decision, the plan a pass would run, and settings.publish's form
(savePublishSettingsAction: enabled, check/refresh minutes, quiet hours,
runner, preview branch, hub and homepage policies). setLaneHeld revalidates
/operations/publish and /sites for the publish lane; the operations board
links the page. SiteForm gains "Publish policy" (site.json publish.auto) —
writeSite's refusal of a deploying policy without a Pages project is the
form's error, and a save no longer drops the key.

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

Diffstat:
Meditor/app/operations/actions.ts | 4++++
Meditor/app/operations/components/OperationsBoard.tsx | 12++++++++++++
Aeditor/app/operations/components/PublishLaneView.tsx | 190+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/components/settings/PublishSettingsForm.tsx | 164+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aeditor/app/operations/publish/page.tsx | 44++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/operations/settingsActions.ts | 47+++++++++++++++++++++++++++++++++++++++++++++++
Meditor/app/sites/actions.ts | 10++++++++++
Meditor/app/sites/components/SiteForm.tsx | 20++++++++++++++++++++
8 files changed, 491 insertions(+), 0 deletions(-)

diff --git a/editor/app/operations/actions.ts b/editor/app/operations/actions.ts @@ -215,6 +215,10 @@ async function setLaneHeld( revalidatePath("/jobs"); revalidatePath("/workers"); revalidatePath("/operations/[id]", "page"); + if (lane === "publish") { + revalidatePath("/operations/publish"); + revalidatePath("/sites"); + } return { ok: true }; } diff --git a/editor/app/operations/components/OperationsBoard.tsx b/editor/app/operations/components/OperationsBoard.tsx @@ -1,6 +1,7 @@ "use client"; import type { AutoQueueStatusPayload } from "yt-dlp-transcript-common/views/autoQueueStatus"; +import Link from "next/link"; import type { SyncRowView } from "../syncRow"; import { OperationRail } from "./OperationRail"; import { railStates } from "./railStates"; @@ -52,6 +53,17 @@ export function OperationsBoard({ selectedId={null} sync={sync} /> + {/* THE PUBLISH LANE (release 18) is a pipeline lane, not an operation of + the catalog: a plain row below the rail, to its own page. */} + <Link + href="/operations/publish" + className="flex items-baseline justify-between gap-3 rounded-md border border-border px-3 py-2 text-sm hover:bg-muted" + > + <span className="font-medium">Publishing</span> + <span className="text-muted-foreground"> + the publish lane — index, builds, deploys + </span> + </Link> </div> ); } diff --git a/editor/app/operations/components/PublishLaneView.tsx b/editor/app/operations/components/PublishLaneView.tsx @@ -0,0 +1,190 @@ +"use client"; + +import Link from "next/link"; +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "yt-dlp-transcript-common/components/ui/button"; +import type { PublishLaneStatus, PublishPlan } from "yt-dlp-transcript-common/views/publishStatus"; +import { PauseLaneButton } from "../../components/lanes/pauseControl"; +import { + drainPublishLaneAction, + startPublishLaneAction, + stopPublishLaneAction, + type LaneControlResult, +} from "../../sites/lib/publishActions"; +import { Chip } from "../../sites/components/PublishPanel"; +import { useHydrated } from "./useOperationsStatus"; + +// The publish lane's console (release 18): the runner and its three controls +// (Start / Drain / Stop — the runner, as on every lane), the hold (the LANE's +// gate: a hold stops dispatching between stages and never kills one), what the +// runner last did and when it looks next, and the plan a pass would run now. +// +// The page is server-rendered from readPublishStatus; this refreshes it every +// few seconds while it is open, and after every control. + +const REFRESH_MS = 5_000; + +function at(ms: number | null): string { + return ms === null ? "—" : new Date(ms).toLocaleString(); +} + +export function PublishLaneView({ + lane, + plan, +}: { + lane: PublishLaneStatus; + plan: Pick<PublishPlan, "steps" | "skipped">; +}) { + const router = useRouter(); + const hydrated = useHydrated(); + const [busy, setBusy] = useState(false); + const [error, setError] = useState<string | null>(null); + const running = lane.live.running; + + useEffect(() => { + const t = setInterval(() => router.refresh(), REFRESH_MS); + return () => clearInterval(t); + }, [router]); + + async function control(action: () => Promise<LaneControlResult>) { + setBusy(true); + setError(null); + try { + const r = await action(); + if (!r.ok) setError(r.error ?? "The control failed."); + } finally { + setBusy(false); + router.refresh(); + } + } + + return ( + <div className="flex flex-col gap-4" data-hydrated={hydrated ? "true" : undefined}> + <div className="flex flex-wrap items-center gap-x-3 gap-y-2"> + <h2 className="font-display text-lg font-semibold tracking-tight">Publish lane</h2> + <span + className={`rounded-full border px-3 py-1 text-sm ${ + running ? "border-success/30 bg-success-soft text-success" : "border-border bg-card text-muted-foreground" + }`} + > + {running ? "Runner running" : "Runner stopped"} + </span> + <Chip name="lane" chip={lane.chip} /> + <span className="ml-auto flex flex-wrap gap-2"> + <Button + type="button" + size="sm" + aria-label="Start publish lane" + onClick={() => control(startPublishLaneAction)} + disabled={busy || running} + > + Start + </Button> + <Button + type="button" + size="sm" + variant="outline" + aria-label="Drain publish lane" + title="Finish the stage in flight, start no next one, then stop" + onClick={() => control(drainPublishLaneAction)} + disabled={busy || !running} + className="border-warning/30 text-warning hover:bg-warning-soft hover:text-warning" + > + Drain + </Button> + <Button + type="button" + size="sm" + variant="outline" + aria-label="Stop publish lane" + title="Stop the runner now; a stage in flight finishes as its own job (Cancel it on /jobs)" + onClick={() => control(stopPublishLaneAction)} + disabled={busy || !running} + > + Stop + </Button> + <PauseLaneButton lane="publish" held={lane.held} onChange={() => router.refresh()} /> + </span> + </div> + {error && ( + <p role="alert" className="text-sm text-destructive"> + {error} + </p> + )} + + <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1 text-sm" data-testid="publish-lane-facts"> + <dt className="text-muted-foreground">Lane</dt> + <dd> + {lane.enabled ? "on" : "off"} + {lane.held ? " · held" : ""} + {lane.quietNow ? " · quiet hours" : ""} + {lane.blockedReason ? ` — ${lane.blockedReason}` : ""} + </dd> + <dt className="text-muted-foreground">A pass now</dt> + <dd> + {lane.due ? "due" : "not due"} — {lane.reason} + </dd> + <dt className="text-muted-foreground">Last check</dt> + <dd>{at(lane.live.lastCheckAt)}</dd> + <dt className="text-muted-foreground">Next check</dt> + <dd>{at(lane.live.nextCheckAt)}</dd> + <dt className="text-muted-foreground">Last pass</dt> + <dd> + {at(lane.live.lastPassAt)} + {lane.live.lastPassSummary ? ` — ${lane.live.lastPassSummary}` : ""} + </dd> + <dt className="text-muted-foreground">Last decision</dt> + <dd>{lane.live.lastDecision ?? "—"}</dd> + {lane.live.jobId && ( + <> + <dt className="text-muted-foreground">Runner</dt> + <dd> + <Link href={`/jobs/${lane.live.jobId}`} className="underline underline-offset-2"> + job log + </Link> + </dd> + </> + )} + </dl> + + <div className="text-sm" data-testid="publish-lane-plan"> + <h3 className="font-semibold">What a pass would run now</h3> + {plan.steps.length === 0 ? ( + <p className="text-muted-foreground">Nothing: every target its policies cover is current.</p> + ) : ( + <ol className="ml-5 list-decimal"> + {plan.steps.map((s) => ( + <li key={`${s.kind}:${s.target}:${s.preview ?? ""}`}> + <code> + {s.kind} {s.target} + {s.preview ? ` --preview ${s.preview}` : ""} + </code>{" "} + <span className="text-muted-foreground">— {s.reason}</span> + </li> + ))} + </ol> + )} + {plan.skipped.length > 0 && ( + <ul className="mt-1 ml-5 list-disc text-xs text-muted-foreground"> + {plan.skipped.map((s) => ( + <li key={`${s.kind}:${s.target}`}> + <code> + {s.kind} {s.target} + </code> + : {s.reason} + </li> + ))} + </ul> + )} + <p className="mt-2 text-xs text-muted-foreground"> + The same plan is <strong>Publish now</strong> on{" "} + <Link href="/sites" className="underline"> + /sites + </Link> + ; a site&rsquo;s policy is set on its own page. + </p> + </div> + </div> + ); +} diff --git a/editor/app/operations/components/settings/PublishSettingsForm.tsx b/editor/app/operations/components/settings/PublishSettingsForm.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useActionState } from "react"; +import type { PublishSettings } from "yt-dlp-transcript-common/lib/settings"; +import { Field } from "../../../components/forms/Field"; +import { seedChecked, seedValue } from "../../../lib/formState"; +import { savePublishSettingsAction } from "../../settingsActions"; +import type { SaveResult } from "../../../settings/actions"; + +// `settings.publish` (release 18), every field but `held` — the hold button +// beside the runner writes that one. The policies of the hub and the homepage +// live here; a SITE's policy is its own (site.json `publish.auto`, set on the +// site's page). + +const POLICIES = [ + ["off", "Off"], + ["build", "Build"], + ["preview", "Preview"], + ["production", "Production"], +] as const; + +function PolicySelect({ + label, + name, + initial, + state, +}: { + label: string; + name: string; + initial: string; + state: SaveResult | undefined; +}) { + return ( + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">{label}</span> + <select + name={name} + defaultValue={seedValue(state, name, initial)} + className="w-48 rounded border border-border bg-card px-2 py-1 text-sm" + > + {POLICIES.map(([v, l]) => ( + <option key={v} value={v}> + {l} + </option> + ))} + </select> + </label> + ); +} + +export function PublishSettingsForm({ initial }: { initial: PublishSettings }) { + const [state, formAction, pending] = useActionState<SaveResult | undefined, FormData>( + savePublishSettingsAction, + undefined, + ); + + return ( + <form action={formAction} data-settings-block="publish" className="flex flex-col gap-3"> + <fieldset className="flex flex-col gap-3 rounded border border-border p-3"> + <legend className="px-1 text-sm font-medium">Publish lane settings</legend> + <label className="flex items-start gap-2 text-sm"> + <input + type="checkbox" + name="publishEnabled" + defaultChecked={seedChecked(state, "publishEnabled", initial.enabled)} + className="mt-1" + /> + <span className="flex flex-col gap-1"> + <span className="font-medium">Run the publish lane</span> + <span className="text-xs text-muted-foreground"> + The runner wakes, and when the index is stale (or a policy target is left stale) it updates + the index, then builds and deploys what each policy says — one stage at a time on the publish + queue. Off, the buttons on /sites still run. + </span> + </span> + </label> + <Field + label="Check every (minutes)" + name="publishCheckEveryMinutes" + type="number" + min="1" + state={state} + defaultValue={String(initial.checkEveryMinutes)} + hint="How often the runner asks whether a pass is due. 1–1440." + /> + <Field + label="Refresh every (minutes)" + name="publishRefreshEveryMinutes" + type="number" + min="0" + state={state} + defaultValue={String(initial.refreshEveryMinutes)} + hint="The least time between two index updates the lane starts; 0 = whenever the index is stale." + /> + <div className="flex flex-wrap gap-3"> + <Field + label="Quiet hours: start (0–23)" + name="publishQuietStart" + type="number" + min="0" + state={state} + defaultValue={initial.quietHours ? String(initial.quietHours.start) : ""} + placeholder="none" + /> + <Field + label="Quiet hours: end (0–23)" + name="publishQuietEnd" + type="number" + min="0" + state={state} + defaultValue={initial.quietHours ? String(initial.quietHours.end) : ""} + placeholder="none" + hint="No pass starts in [start, end) on this machine's clock (may wrap midnight). Both empty: none." + /> + </div> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Build runner</span> + <select + name="publishRunner" + defaultValue={seedValue(state, "publishRunner", initial.runner)} + className="w-48 rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="local">Local (one site at a time)</option> + <option value="docker">Docker (host only)</option> + </select> + <span className="text-xs text-muted-foreground"> + Docker builds every stale site in containers — a host with a container engine only; refused in + a container. + </span> + </label> + <Field + label="Preview branch" + name="publishPreviewBranch" + state={state} + defaultValue={initial.previewBranch} + hint="Where a “preview” policy deploys (wrangler --branch). Lowercase letters, digits and dashes; never main." + /> + <div className="flex flex-wrap gap-4"> + <PolicySelect label="Hub policy" name="publishHub" initial={initial.hub} state={state} /> + <PolicySelect label="Homepage policy" name="publishHomepage" initial={initial.homepage} state={state} /> + </div> + <div className="flex items-center gap-3"> + <button + type="submit" + disabled={pending} + className="px-3 py-1.5 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90 disabled:opacity-50" + > + {pending ? "Saving…" : "Save publish settings"} + </button> + {state?.ok === true && ( + <span role="status" className="text-sm text-success"> + Saved. + </span> + )} + {state?.ok === false && ( + <span role="alert" className="text-sm text-destructive"> + {state.error} + </span> + )} + </div> + </fieldset> + </form> + ); +} diff --git a/editor/app/operations/publish/page.tsx b/editor/app/operations/publish/page.tsx @@ -0,0 +1,44 @@ +import type { Metadata } from "next"; +import Link from "next/link"; +import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { getSettings } from "yt-dlp-transcript-common/lib/settings"; +import { readPublishStatus } from "yt-dlp-transcript-common/publish/publishState"; +import { PublishLaneView } from "../components/PublishLaneView"; +import { PublishSettingsForm } from "../components/settings/PublishSettingsForm"; + +export const dynamic = "force-dynamic"; +export const metadata: Metadata = { title: "Publish lane" }; + +// THE PUBLISH LANE (release 18) — a static route beside `operations/[id]` +// (Next prefers it): the publish lane is not an operation of the catalog +// (lib/operations.ts), whose settings switch and lane keys are the ingest +// lanes', so it has its own page rather than a catalog entry. +// +// Everything on it is read through readPublishStatus — the same status the +// /sites Publish panel, `archilyzer publish status` and `GET /api/ops/publish` +// read. +export default async function PublishLanePage() { + const status = await readPublishStatus(getPaths()); + const settings = getSettings().publish; + return ( + <div className="flex max-w-4xl flex-col gap-6"> + <div> + <h1 className="text-2xl font-semibold">Publishing</h1> + <p className="text-sm text-muted-foreground"> + The lane keeps the published sites current: when the index is stale it updates it, then builds + what changed and deploys where each target&rsquo;s policy says. Every stage runs on the{" "} + <code>publish</code> queue, one at a time. The rows and their buttons are on{" "} + <Link href="/sites" className="underline"> + /sites + </Link> + . + </p> + </div> + <PublishLaneView + lane={status.lane} + plan={{ steps: status.plan.steps, skipped: status.plan.skipped }} + /> + <PublishSettingsForm initial={settings} /> + </div> + ); +} diff --git a/editor/app/operations/settingsActions.ts b/editor/app/operations/settingsActions.ts @@ -24,6 +24,8 @@ import { isDigestTimestampMode, } from "yt-dlp-transcript-common/lib/digest"; import { withGateHeld } from "yt-dlp-transcript-common/lib/pauseGates"; +import { previewBranchProblem } from "yt-dlp-transcript-common/lib/pagesDeploy"; +import { sanitizePublish } from "yt-dlp-transcript-common/lib/settingsSchema"; import type { SaveResult } from "../settings/actions"; import { formValues } from "../lib/formState"; @@ -280,3 +282,48 @@ export async function saveAttributionSettingsAction( revalidateOperations(); return { ok: true }; } + +// The publish lane (release 18): `settings.publish`, saved by +// PublishSettingsForm on /operations/publish. `held` is not on the form — the +// hold button writes it (operations/actions.ts setLaneHeld) — so it is carried +// from the stored block. A preview branch name the sanitizer would silently +// replace by the default is refused instead, with the sentence the deploy +// buttons use. +export async function savePublishSettingsAction( + _prev: SaveResult | undefined, + formData: FormData, +): Promise<SaveResult> { + const values = formValues(formData); + const current = getSettings().publish; + const text = (key: string) => String(formData.get(key) ?? "").trim(); + const previewBranch = text("publishPreviewBranch") || current.previewBranch; + const branchProblem = previewBranchProblem(previewBranch); + if (branchProblem) return { ok: false, error: branchProblem, values }; + const quietStart = text("publishQuietStart"); + const quietEnd = text("publishQuietEnd"); + if ((quietStart === "") !== (quietEnd === "")) { + return { ok: false, error: "Quiet hours need both a start and an end hour (or neither).", values }; + } + const next = sanitizePublish({ + ...current, + enabled: formData.get("publishEnabled") === "on", + checkEveryMinutes: num(formData, "publishCheckEveryMinutes", current.checkEveryMinutes), + refreshEveryMinutes: num(formData, "publishRefreshEveryMinutes", current.refreshEveryMinutes), + quietHours: quietStart === "" ? null : { start: Number(quietStart), end: Number(quietEnd) }, + runner: text("publishRunner") || current.runner, + previewBranch, + hub: text("publishHub") || current.hub, + homepage: text("publishHomepage") || current.homepage, + }); + if (quietStart !== "" && next.quietHours === null) { + return { ok: false, error: "Quiet hours are two different hours from 0 to 23.", values }; + } + try { + await saveSettings({ publish: next }); + } catch (e) { + return { ok: false, error: (e as Error).message, values }; + } + revalidatePath("/operations/publish"); + revalidatePath("/sites"); + return { ok: true }; +} diff --git a/editor/app/sites/actions.ts b/editor/app/sites/actions.ts @@ -1,5 +1,6 @@ "use server"; +import { PUBLISH_POLICIES, type PublishPolicy } from "yt-dlp-transcript-common/lib/settingsSchema"; import { revalidatePath } from "next/cache"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { @@ -118,6 +119,14 @@ export async function saveSiteAction( return { ok: false, error: `Unknown audience "${audienceRaw}".`, values }; } const isPrivate = audienceRaw === "private"; + // The publish lane's policy for this site (release 18). writeSite refuses a + // deploying policy with no Pages project (sitePublishProblem) and stores a + // private site's as "build". + const publishAutoRaw = String(formData.get("publishAuto") ?? "off"); + if (!(PUBLISH_POLICIES as readonly string[]).includes(publishAutoRaw)) { + return { ok: false, error: `Unknown publish policy "${publishAutoRaw}".`, values }; + } + const publishAuto = publishAutoRaw as PublishPolicy; // Search (opt-out; off = report-only) and the report list, which is not on // this form: the stored one is carried over. let storedReports: Pick<Site, "reports"> | undefined; @@ -265,6 +274,7 @@ export async function saveSiteAction( // The Site is rebuilt from the form: a key missing here is dropped on save. ...(listed ? {} : { listed: false }), ...(isPrivate ? { audience: "private" as const } : {}), + ...(publishAuto !== "off" ? { publish: { auto: publishAuto } } : {}), ...reportKeys, ...(hubUrl ? { hubUrl } : {}), ...(pwa ? { pwa: true } : {}), diff --git a/editor/app/sites/components/SiteForm.tsx b/editor/app/sites/components/SiteForm.tsx @@ -361,6 +361,26 @@ export function SiteForm({ initial, channels, allSites, isNew }: Props) { into while Settings keeps X posts private. </span> </label> + <label className="flex flex-col gap-1 text-sm"> + <span className="font-medium">Publish policy</span> + <SeededSelect + state={state} + name="publishAuto" + aria-label="Publish policy" + initial={initial.publish?.auto ?? "off"} + className="w-fit rounded border border-border bg-card px-2 py-1 text-sm" + > + <option value="off">Off — built and deployed only when you press a button</option> + <option value="build">Build — Publish now and the publish lane rebuild it when it is stale</option> + <option value="preview">Preview — and deploy it to the preview branch</option> + <option value="production">Production — and deploy it to production</option> + </SeededSelect> + <span className="text-xs text-muted-foreground"> + What Publish now and the publish lane do for this site (site.json{" "} + <code>publish.auto</code>). Preview and Production need a Cloudflare + Pages project; a private site is built, never deployed. + </span> + </label> <label className="flex items-center gap-2 text-sm"> <input type="checkbox"