commit e815fd32f3c9d1c3c6bf971687c67aba7d516695
parent 453644de25a6f432fdc4e2648d59469f011dccf0
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 6 Oct 2026 11:14:12 -0400
publish: the publish status and the publish plan — one pure builder every surface and the lane read
publish/publishPlan.ts `buildPublishStatus(inputs, now)`: per target the
index / built / deployed / live chips, `builtFromCurrentIndex`, the build
stage's own freshness, `buildStale` {channels | data | config} with the
changed member channels measured against builtCheckedAt, "code newer"
(never stale), the policy's deploy record, the preview URL, the newest
ended stage's refusal in words ("waiting for its build"), `next`; plus the
index, the lane's pass decision and the plan. `planPublishRun(status, opts)`:
update-index when stale; sites by policy (or every stale one) with changed
channels or a signature mismatch; deploys by policy, never for a private or
project-less target; the hub and the homepage by settings.publish; builds
carry indexAfter, deploys builtAfter = the run's start; never forced; the
docker runner as one _all stage. Every freshness question is the stage's
needs(). Pure (no fs, clock or singleton; tested); views/publishStatus.ts is
the view layer's name for it (the publish layer may not import views/).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Diffstat:
3 files changed, 1460 insertions(+), 0 deletions(-)
diff --git a/common/publish/publishPlan.test.ts b/common/publish/publishPlan.test.ts
@@ -0,0 +1,543 @@
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import { defaultPublish, type PublishSettings } from "../lib/settingsSchema";
+import {
+ buildPublishStatus,
+ planPublishRun,
+ publishPassDecision,
+ stepKey,
+ type PublishInputs,
+ type PublishSiteInput,
+ type PublishStatus,
+} from "./publishPlan";
+import { STAGES } from "./stages";
+import type { BuiltStamp, DeployedFile, IndexStamp } from "./stamps";
+
+// Run with: pnpm --filter yt-dlp-transcript-common exec tsx --test publish/publishPlan.test.ts
+//
+// The publish status and plan (release 18) over hand-built inputs: every chip
+// state the plan's tables name, the policies, the preconditions a run carries,
+// and the lane's pass decision. Pure — no disk, no clock.
+
+const HERE = path.dirname(fileURLToPath(import.meta.url));
+const MIN = 60_000;
+const NOW = 2_000_000_000_000;
+
+function stamp(over: Partial<IndexStamp> = {}): IndexStamp {
+ return {
+ v: 1,
+ stampId: "stamp-1",
+ generation: 7,
+ scannedAt: NOW - 60 * MIN,
+ builtAt: NOW - 55 * MIN,
+ templatesAt: NOW - 55 * MIN,
+ commit: "c1",
+ index: { shortCircuited: false, added: 0, changed: 0, removed: 0, heldChannels: [] },
+ stats: { shortCircuited: false, notIndexedYet: 0, notIndexable: 0 },
+ sites: {
+ alpha: { siteFp: null, statsFp: null, inputSig: "sig-alpha" },
+ beta: { siteFp: null, statsFp: null, inputSig: "sig-beta" },
+ },
+ hubSig: "hub-sig",
+ ...over,
+ };
+}
+
+function built(target: string, over: Partial<BuiltStamp> = {}): BuiltStamp {
+ return {
+ v: 1,
+ stampId: `built-${target}`,
+ target,
+ kind: target === "_hub" ? "hub" : target === "_homepage" ? "homepage" : "site",
+ indexStampId: "stamp-1",
+ inputSig: target === "_hub" ? "hub-sig" : `sig-${target}`,
+ builtAt: NOW - 50 * MIN,
+ commit: "c1",
+ branch: "main",
+ runner: "local",
+ audience: "public",
+ corpusGeneratedAt: null,
+ files: 1,
+ bytes: 1,
+ archivesStaged: 0,
+ ...over,
+ };
+}
+
+function deployedTo(target: string, builtStampId: string, kind: "production" | "preview" = "production"): DeployedFile {
+ const rec = {
+ builtStampId,
+ builtAt: NOW - 50 * MIN,
+ kind,
+ url: `https://${target}.example`,
+ at: NOW - 40 * MIN,
+ liveCheck: null,
+ ...(kind === "preview" ? { branch: "preview", alias: `https://preview.${target}.pages.dev` } : {}),
+ };
+ return kind === "production"
+ ? { v: 1, target, production: rec, previews: {} }
+ : { v: 1, target, previews: { preview: rec } };
+}
+
+function site(siteId: string, over: Partial<PublishSiteInput> = {}): PublishSiteInput {
+ return {
+ siteId,
+ title: siteId.toUpperCase(),
+ private: false,
+ listed: true,
+ members: [`${siteId}-ch`, "shared-ch"],
+ built: built(siteId),
+ deployed: deployedTo(siteId, `built-${siteId}`),
+ bundleProblem: null,
+ deployProblem: null,
+ pagesProblem: null,
+ configChangedAt: null,
+ policy: "off",
+ cloudflareProject: siteId,
+ url: `https://${siteId}.pages.dev`,
+ ...over,
+ };
+}
+
+function inputs(over: Partial<PublishInputs> = {}, settings: Partial<PublishSettings> = {}): PublishInputs {
+ return {
+ index: { stamp: stamp(), lastIngestDoneAt: null, configChangedAt: null },
+ ingestByChannel: {},
+ commit: "c1",
+ sites: [site("alpha"), site("beta")],
+ hub: {
+ built: built("_hub"),
+ deployed: deployedTo("_hub", "built-_hub"),
+ bundleProblem: null,
+ deployProblem: null,
+ pagesProblem: null,
+ configChangedAt: null,
+ policy: "off",
+ cloudflareProject: "hub",
+ url: null,
+ },
+ homepage: {
+ built: built("_homepage"),
+ deployed: deployedTo("_homepage", "built-_homepage"),
+ bundleProblem: null,
+ deployProblem: null,
+ pagesProblem: null,
+ configChangedAt: null,
+ policy: "off",
+ cloudflareProject: "archilyzer",
+ url: null,
+ mainHead: null,
+ },
+ settings: { ...defaultPublish(), ...settings },
+ jobs: [],
+ ended: [],
+ lane: {
+ known: true,
+ running: false,
+ jobId: null,
+ passRunning: false,
+ lastCheckAt: null,
+ nextCheckAt: null,
+ lastPassAt: null,
+ lastPassSummary: null,
+ lastDecision: null,
+ },
+ ...over,
+ };
+}
+
+const status = (i: PublishInputs): PublishStatus => buildPublishStatus(i, NOW);
+const siteOf = (s: PublishStatus, id: string) => s.sites.find((t) => t.target === id)!;
+const kinds = (s: PublishStatus["plan"]) => s.steps.map((x) => `${x.kind} ${x.target}`);
+
+// --- purity ------------------------------------------------------------------
+
+test("the builder and the planner are pure: no fs, no clock, no singleton", async () => {
+ const src = await readFile(path.join(HERE, "publishPlan.ts"), "utf8");
+ for (const banned of ["Date.now(", "getSettings(", "getPaths(", "getRegistry(", "getScheduler(", "readJobMeta("]) {
+ assert.equal(src.includes(banned), false, `publishPlan.ts calls ${banned}`);
+ }
+ const specs = [...src.matchAll(/from\s+"([^"]+)"/g)].map((m) => m[1]);
+ for (const spec of specs) {
+ assert.equal(spec.startsWith("node:"), false, `publishPlan.ts imports ${spec}`);
+ assert.ok(spec.startsWith("."), `publishPlan.ts imports a package (${spec})`);
+ }
+ // The same inputs give the same status.
+ assert.deepEqual(status(inputs()), status(inputs()));
+});
+
+// --- the index chip ------------------------------------------------------------
+
+test("index: no stamp is stale and plans the update; every build is then after it", () => {
+ const s = status(inputs({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } }, {}));
+ assert.equal(s.index.fresh, false);
+ assert.equal(s.index.chip.text, "no index yet");
+ assert.deepEqual(kinds(s.plan), ["update-index _index"]);
+ // With a site on "build", it is built after the index's first update.
+ const i = inputs({ index: { stamp: null, lastIngestDoneAt: null, configChangedAt: null } });
+ i.sites[0].policy = "build";
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), ["update-index _index", "build-site alpha"]);
+ assert.equal(p.steps[1].indexAfter, NOW);
+ // The chip a build shows with no stamp is the stage's own "blocked".
+ assert.equal(siteOf(status(i), "alpha").chips.built.text, "update the index first");
+});
+
+test("index: fresh, stale by new data (after scannedAt), stale by a config file", () => {
+ assert.match(status(inputs()).index.chip.text, /^fresh · updated 55 min ago$/);
+ const byData = status(inputs({ index: { stamp: stamp(), lastIngestDoneAt: NOW - 30 * MIN, configChangedAt: null } }));
+ assert.equal(byData.index.fresh, false);
+ assert.equal(byData.index.chip.text, "stale: new data since the last index");
+ // An ingest that ended BEFORE the scan began is in the index.
+ assert.equal(
+ status(inputs({ index: { stamp: stamp(), lastIngestDoneAt: NOW - 61 * MIN, configChangedAt: null } })).index.fresh,
+ true,
+ );
+ const byConfig = status(inputs({ index: { stamp: stamp(), lastIngestDoneAt: null, configChangedAt: NOW - MIN } }));
+ assert.equal(byConfig.index.chip.text, "stale: a config file changed since the last index");
+});
+
+test("index: a running or queued update reads busy", () => {
+ const job = { id: "J1", kind: "update-index" as const, target: "_index", runId: "r", queuedAt: NOW - MIN };
+ assert.equal(status(inputs({ jobs: [{ ...job, status: "running" }] })).index.chip.text, "updating…");
+ const q = status(inputs({ jobs: [{ ...job, status: "queued" }] }));
+ assert.equal(q.index.chip.text, "update queued");
+ assert.equal(q.busy, true);
+});
+
+// --- the built chip ------------------------------------------------------------
+
+test("built: fresh; never built; stale by channels, by data, by config; a bundle problem", () => {
+ assert.match(siteOf(status(inputs()), "alpha").chips.built.text, /^built 50 min ago$/);
+ assert.equal(siteOf(status(inputs()), "alpha").buildFreshness.state, "fresh");
+
+ const never = inputs();
+ never.sites[0].built = null;
+ assert.equal(siteOf(status(never), "alpha").chips.built.text, "never built");
+
+ // Channels: an ingest of a MEMBER after the build.
+ const ch = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "elsewhere": NOW - MIN } });
+ const a = siteOf(status(ch), "alpha");
+ assert.deepEqual(a.buildStale, { reason: "channels", changedChannels: ["alpha-ch"] });
+ assert.equal(a.chips.built.text, "stale: 1 channel changed (alpha-ch)");
+ assert.equal(siteOf(status(ch), "beta").buildStale, undefined, "beta's members did not change");
+
+ // Data: the index says the site's inputs moved.
+ const data = inputs({
+ index: {
+ stamp: stamp({ sites: { ...stamp().sites, alpha: { siteFp: null, statsFp: null, inputSig: "moved" } } }),
+ lastIngestDoneAt: null,
+ configChangedAt: null,
+ },
+ });
+ const d = siteOf(status(data), "alpha");
+ assert.deepEqual(d.buildStale, { reason: "data", changedChannels: [] });
+ assert.equal(d.chips.built.text, "stale: data changed");
+ assert.equal(d.builtFromCurrentIndex, false);
+ assert.equal(siteOf(status(data), "beta").builtFromCurrentIndex, true);
+
+ // Config: its site.json (or tags, aliases, duplicates) after the build.
+ const cfg = inputs();
+ cfg.sites[0].configChangedAt = NOW - MIN;
+ assert.equal(siteOf(status(cfg), "alpha").chips.built.text, "stale: config changed");
+
+ const bundle = inputs();
+ bundle.sites[0].bundleProblem = "no corpus.json";
+ assert.equal(siteOf(status(bundle), "alpha").chips.built.text, "bundle: no corpus.json");
+});
+
+test("built: channels changed is measured against builtCheckedAt — a no-op build clears it", () => {
+ const i = inputs({ ingestByChannel: { "alpha-ch": NOW - 30 * MIN } });
+ assert.equal(siteOf(status(i), "alpha").buildStale?.reason, "channels");
+ // A later no-op build found the bundle current at NOW - 20 min.
+ i.sites[0].built = built("alpha", { checkedAt: NOW - 20 * MIN });
+ const a = siteOf(status(i), "alpha");
+ assert.equal(a.buildStale, undefined);
+ assert.equal(a.buildFreshness.state, "fresh");
+});
+
+test("built: code newer is said, never stale", () => {
+ const s = status(inputs({ commit: "c2" }));
+ const a = siteOf(s, "alpha");
+ assert.equal(a.codeNewer, true);
+ assert.equal(a.buildFreshness.state, "fresh");
+ assert.match(a.chips.built.text, /· code newer$/);
+ assert.equal(a.chips.built.tone, "ok");
+ assert.equal(s.plan.steps.length, 0);
+});
+
+// --- deployed / live ---------------------------------------------------------------
+
+test("deployed: the policy's record; a newer build not deployed; never; private", () => {
+ const ok = siteOf(status(inputs()), "alpha");
+ assert.equal(ok.deployedIsBuilt, true);
+ assert.match(ok.chips.deployed.text, /^production · 40 min ago$/);
+
+ const newer = inputs();
+ newer.sites[0].built = built("alpha", { stampId: "built-alpha-2" });
+ const n = siteOf(status(newer), "alpha");
+ assert.equal(n.deployedIsBuilt, false);
+ assert.equal(n.chips.deployed.text, "production: a newer build is not deployed");
+
+ const preview = inputs();
+ preview.sites[0].policy = "preview";
+ const p = siteOf(status(preview), "alpha");
+ assert.equal(p.deployKind, "preview");
+ assert.equal(p.chips.deployed.text, 'not on preview "preview"');
+ assert.equal(p.previewUrl, "https://preview.alpha.pages.dev");
+
+ const priv = inputs();
+ priv.sites[0] = site("alpha", { private: true, deployed: null, deployProblem: "private site", policy: "build" });
+ assert.equal(siteOf(status(priv), "alpha").chips.deployed.text, "never deployed (private)");
+ assert.equal(siteOf(status(inputs()), "alpha").chips.live.text, "not checked");
+});
+
+test("deployed: a deploy refused for want of its build reads 'waiting for its build'", () => {
+ const i = inputs({
+ ended: [
+ {
+ id: "D1",
+ kind: "deploy-site",
+ target: "alpha",
+ status: "failed",
+ exitCode: 3,
+ endedAt: NOW - MIN,
+ runId: "r",
+ builtAfter: NOW - 2 * MIN,
+ },
+ ],
+ });
+ const a = siteOf(status(i), "alpha");
+ assert.deepEqual(a.chips.deployed, { tone: "blocked", text: "waiting for its build" });
+ i.ended[0] = { ...i.ended[0], builtAfter: undefined };
+ assert.equal(siteOf(status(i), "alpha").chips.deployed.text, "last deploy refused: precondition not met");
+});
+
+test("live: the record's live-check verdict", () => {
+ const i = inputs();
+ const rec = i.sites[0].deployed!.production!;
+ const check = {
+ at: NOW,
+ url: "https://alpha.pages.dev/corpus.json",
+ plain: { status: 200 },
+ busted: { status: 200 },
+ expected: null,
+ };
+ rec.liveCheck = { ...check, verdict: "ok" };
+ assert.equal(siteOf(status(i), "alpha").chips.live.text, "live ok");
+ rec.liveCheck = { ...check, verdict: "stale-edge" };
+ assert.equal(siteOf(status(i), "alpha").chips.live.tone, "warn");
+});
+
+// --- the plan: policies ------------------------------------------------------------
+
+test("Publish now with every policy off is the index update alone", () => {
+ const i = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN }, index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null } });
+ const s = status(i);
+ assert.deepEqual(kinds(s.plan), ["update-index _index"]);
+ assert.deepEqual(
+ s.plan.skipped.map((x) => `${x.kind} ${x.target}: ${x.reason}`),
+ ["build-site alpha: stale, and its policy is off"],
+ );
+ assert.equal(siteOf(s, "alpha").next, "stale, and its policy is off");
+});
+
+test("policies: build builds; preview and production also deploy, after the build, with builtAfter", () => {
+ const i = inputs({
+ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "beta-ch": NOW - 10 * MIN },
+ index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null },
+ });
+ i.sites[0].policy = "preview";
+ i.sites[1].policy = "production";
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), [
+ "update-index _index",
+ "build-site alpha",
+ "deploy-site alpha",
+ "build-site beta",
+ "deploy-site beta",
+ ]);
+ const [, ba, da, , db] = p.steps;
+ assert.equal(ba.indexAfter, NOW, "a build in a run with the index carries indexAfter");
+ assert.equal(da.builtAfter, NOW, "a deploy whose build is in the run carries builtAfter");
+ assert.equal(da.preview, "preview");
+ assert.equal(da.previewUrl, "https://preview.alpha.pages.dev");
+ assert.equal(db.preview, undefined, "production");
+ assert.equal(db.builtAfter, NOW);
+ // The words the targets show.
+ assert.equal(siteOf(status(i), "alpha").next, 'build, then deploy to preview "preview"');
+
+ const buildOnly = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN } });
+ buildOnly.sites[0].policy = "build";
+ const b = status(buildOnly).plan;
+ assert.deepEqual(kinds(b), ["build-site alpha"]);
+ assert.equal(b.steps[0].indexAfter, undefined, "no index update in the run, no indexAfter");
+});
+
+test("plan: a site with neither changed channels nor a signature mismatch is skipped", () => {
+ const i = inputs({ index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null } });
+ for (const s of i.sites) s.policy = "production";
+ assert.deepEqual(kinds(status(i).plan), ["update-index _index"]);
+});
+
+test("plan: a deploy-only step when the build is current but not deployed", () => {
+ const i = inputs();
+ i.sites[0].policy = "production";
+ i.sites[0].deployed = null;
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), ["deploy-site alpha"]);
+ assert.equal(p.steps[0].builtAfter, undefined);
+});
+
+test("private sites never deploy; a project-less site is skipped with the reason", () => {
+ const i = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "beta-ch": NOW - 10 * MIN } });
+ // A private site's policy is clamped on read; even a stray production one
+ // is never planned as a deploy.
+ i.sites[0] = site("alpha", { private: true, policy: "production", deployProblem: "Site \"alpha\" is private" });
+ i.sites[1] = site("beta", { policy: "preview", pagesProblem: "no Pages project", cloudflareProject: null });
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), ["build-site alpha", "build-site beta"]);
+ assert.deepEqual(
+ p.skipped.map((x) => `${x.kind} ${x.target}`).sort(),
+ ["deploy-site alpha", "deploy-site beta"],
+ );
+});
+
+test("plan: builds 'stale' builds every stale site whatever its policy, deploys only by policy", () => {
+ const i = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "beta-ch": NOW - 10 * MIN } });
+ i.sites[1].policy = "production";
+ const p = planPublishRun(status(i), { builds: "stale" });
+ assert.deepEqual(kinds(p), ["build-site alpha", "build-site beta", "deploy-site beta"]);
+ assert.deepEqual(kinds(planPublishRun(status(i), { builds: "stale", deploys: "none" })), [
+ "build-site alpha",
+ "build-site beta",
+ ]);
+});
+
+test("plan: the docker runner builds every stale site in one _all stage", () => {
+ const i = inputs({ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "beta-ch": NOW - 10 * MIN } }, { runner: "docker" });
+ i.sites[0].policy = "production";
+ i.sites[1].policy = "build";
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), ["build-site _all", "deploy-site alpha"]);
+ assert.equal(p.steps[0].runner, "docker");
+ assert.equal(p.steps[1].builtAfter, NOW);
+});
+
+test("plan: the hub and the homepage follow settings.publish's policies", () => {
+ const i = inputs(
+ {
+ ingestByChannel: { "shared-ch": NOW - 10 * MIN },
+ index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null },
+ },
+ { hub: "production", homepage: "build" },
+ );
+ const p = status(i).plan;
+ assert.deepEqual(kinds(p), [
+ "update-index _index",
+ "build-hub _hub",
+ "deploy-hub _hub",
+ "build-homepage _homepage",
+ ]);
+ assert.equal(p.steps[1].indexAfter, NOW);
+ assert.equal(p.steps[2].builtAfter, NOW);
+ // Off (the default): nothing.
+ assert.deepEqual(kinds(status(inputs({ index: i.index, ingestByChannel: i.ingestByChannel })).plan), [
+ "update-index _index",
+ ]);
+ // The homepage is stale when the index moved past its build.
+ const moved = inputs({ index: { stamp: stamp({ stampId: "stamp-2" }), lastIngestDoneAt: null, configChangedAt: null } }, { homepage: "build" });
+ assert.deepEqual(kinds(status(moved).plan), ["build-homepage _homepage"]);
+});
+
+test("plan: never forced, and the lane's exclusions and skipIndex leave steps out", () => {
+ const i = inputs({
+ ingestByChannel: { "alpha-ch": NOW - 10 * MIN },
+ index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null },
+ });
+ i.sites[0].policy = "production";
+ const s = status(i);
+ for (const step of s.plan.steps) assert.equal(step.force, undefined, `${step.kind} is not forced`);
+ const exclude = new Set([stepKey({ kind: "build-site", target: "alpha" })]);
+ assert.deepEqual(kinds(planPublishRun(s, { exclude, skipIndex: true })), ["deploy-site alpha"]);
+ assert.equal(stepKey({ kind: "deploy-site", target: "a", preview: "p" }), "deploy-site:a:preview:p");
+ assert.equal(stepKey({ kind: "deploy-site", target: "a" }), "deploy-site:a:production");
+ assert.equal(stepKey({ kind: "deploy-site", target: "a", to: "local" }), "deploy-site:a:local");
+ // runStart is the caller's when given.
+ assert.equal(planPublishRun(s, { runStart: 5 }).steps[1].indexAfter, 5);
+});
+
+test("every plan step names a real stage and passes the stage's own argv parser", () => {
+ const i = inputs({
+ ingestByChannel: { "alpha-ch": NOW - 10 * MIN, "shared-ch": NOW - 10 * MIN },
+ index: { stamp: stamp(), lastIngestDoneAt: NOW - 10 * MIN, configChangedAt: null },
+ }, { hub: "preview", homepage: "production" });
+ i.sites[0].policy = "preview";
+ for (const step of status(i).plan.steps) {
+ const stage = STAGES[step.kind];
+ assert.ok(stage, step.kind);
+ const argv = stage.argv({ ...step, runId: "r1" });
+ assert.equal(argv[0], "stage");
+ assert.equal(argv[1], step.kind);
+ }
+});
+
+// --- the lane's pass decision -------------------------------------------------------
+
+test("pass decision: off, held, quiet hours and busy never run", () => {
+ const base = {
+ settings: { ...defaultPublish(), enabled: true },
+ stamp: null,
+ indexFresh: false,
+ busy: false,
+ now: NOW,
+ lastPassAt: null,
+ policyWork: false,
+ };
+ assert.deepEqual(publishPassDecision(base), { run: true, reason: "no index stamp yet" });
+ assert.equal(publishPassDecision({ ...base, settings: defaultPublish() }).reason, "the lane is off");
+ assert.equal(publishPassDecision({ ...base, settings: { ...base.settings, held: true } }).reason, "held");
+ assert.equal(publishPassDecision({ ...base, busy: true }).run, false);
+ const hour = new Date(NOW).getHours();
+ const quiet = { start: hour, end: (hour + 1) % 24 };
+ assert.equal(publishPassDecision({ ...base, settings: { ...base.settings, quietHours: quiet } }).reason, "quiet hours");
+ const notQuiet = { start: (hour + 1) % 24, end: (hour + 2) % 24 };
+ assert.equal(publishPassDecision({ ...base, settings: { ...base.settings, quietHours: notQuiet } }).run, true);
+});
+
+test("pass decision: a stale index waits out refreshEveryMinutes from the stamp's builtAt", () => {
+ const settings = { ...defaultPublish(), enabled: true, refreshEveryMinutes: 60 };
+ const a = { settings, indexFresh: false, busy: false, now: NOW, lastPassAt: null, policyWork: false };
+ assert.equal(publishPassDecision({ ...a, stamp: stamp({ builtAt: NOW - 61 * MIN }) }).run, true);
+ const early = publishPassDecision({ ...a, stamp: stamp({ builtAt: NOW - 30 * MIN }) });
+ assert.equal(early.run, false);
+ assert.match(early.reason, /the index is stale; the next update is due 30 min from now/);
+ // 0 = whenever stale.
+ assert.equal(
+ publishPassDecision({ ...a, settings: { ...settings, refreshEveryMinutes: 0 }, stamp: stamp({ builtAt: NOW }) }).run,
+ true,
+ );
+ // A fresh index with nothing to do never runs.
+ assert.equal(publishPassDecision({ ...a, indexFresh: true, stamp: stamp() }).run, false);
+ // Policy work left: once per interval since the last pass.
+ assert.equal(publishPassDecision({ ...a, indexFresh: true, stamp: stamp(), policyWork: true }).run, true);
+ assert.equal(
+ publishPassDecision({ ...a, indexFresh: true, stamp: stamp(), policyWork: true, lastPassAt: NOW - 10 * MIN }).run,
+ false,
+ );
+});
+
+test("the status carries the lane's words and the plan Publish now would run", () => {
+ const s = status(inputs({}, { enabled: true }));
+ assert.equal(s.lane.enabled, true);
+ assert.equal(s.lane.due, false);
+ assert.match(s.lane.reason, /nothing to do/);
+ assert.equal(s.lane.chip.text, "lane on, runner not running");
+ const off = status(inputs());
+ assert.equal(off.lane.chip.text, "lane off");
+ assert.equal(off.lane.blockedReason, "the lane is off");
+ assert.deepEqual(off.plan.steps, []);
+});
diff --git a/common/publish/publishPlan.ts b/common/publish/publishPlan.ts
@@ -0,0 +1,876 @@
+// THE PUBLISH STATUS AND THE PUBLISH PLAN (release 18) — pure.
+//
+// One state builder (the plan's "Surfaces"): `publishState.ts` reads the
+// stamps, the sites, the settings, the config mtimes, the ingest job metas and
+// the live publish jobs into a `PublishInputs`; `buildPublishStatus(inputs,
+// now)` folds them into the `PublishStatus` every surface reads — the /sites
+// Publish panel, `archilyzer publish status`, `GET /api/ops/publish` and the
+// lane; `planPublishRun(status, opts)` says what a run would enqueue — what
+// Publish now enqueues all at once, and what the lane dispatches one stage at
+// a time.
+//
+// PURE: no fs, no clock, no singleton. `now` is an argument. Every freshness
+// question is the stage's own `needs()` (publish/stages.ts) over the
+// `NeedsInput` built here — the status never re-implements a stage's rule; it
+// only adds the two signals a stage child cannot see (`changedChannels` from
+// the job metas, and the config mtimes) and the chips' words.
+//
+// WHY HERE AND NOT IN views/: the lane's runner (publish/publishRunner.ts)
+// plans from it, and the publish layer may not import views/ (common/
+// architecture.test.ts). `views/publishStatus.ts` re-exports it as the view
+// layer's name for it. SERVER-SIDE VALUES: it imports stages.ts, which imports
+// the stamp readers; a client component imports its TYPES only.
+
+import type { PublishPolicy, PublishSettings } from "../lib/settingsSchema";
+import { previewAliasUrl } from "../lib/pagesDeploy";
+import { isInQuietHours } from "../jobs/syncScheduler";
+import {
+ STAGES,
+ builtCheckedAt,
+ deployKindOf,
+ type Freshness,
+ type NeedsInput,
+ type StageKind,
+ type StageRequest,
+ type TargetState,
+} from "./stages";
+import {
+ ALL_TARGET,
+ HOMEPAGE_TARGET,
+ HUB_TARGET,
+ INDEX_TARGET,
+ deployRecordFor,
+ type BuiltStamp,
+ type DeployKind,
+ type DeployRecord,
+ type DeployedFile,
+ type IndexStamp,
+ type LiveCheck,
+} from "./stamps";
+
+// ---------------------------------------------------------------------------
+// Inputs (what publishState.ts reads)
+// ---------------------------------------------------------------------------
+
+/** A publish stage job, queued or running, on the `publish` queue. */
+export type PublishJob = {
+ id: string;
+ kind: StageKind;
+ target: string;
+ status: "queued" | "running";
+ runId: string | null;
+ queuedAt: number;
+ startedAt?: number;
+ preview?: string;
+ to?: "pages" | "local";
+};
+
+/** The newest publish stage job of a kind + target that ENDED. */
+export type PublishJobEnded = {
+ id: string;
+ kind: StageKind;
+ target: string;
+ status: "done" | "failed" | "cancelled";
+ exitCode: number | null;
+ endedAt: number;
+ runId: string | null;
+ // The run's preconditions the job carried (a refusal for want of one reads
+ // "waiting for …").
+ indexAfter?: number;
+ builtAfter?: number;
+ preview?: string;
+ to?: "pages" | "local";
+};
+
+/** The lane runner's live state (publishLaneState.ts), as the editor holds it. */
+export type PublishLaneLive = {
+ // Whether this process can know (the CLI cannot: the lane is the editor's).
+ known: boolean;
+ running: boolean;
+ jobId: string | null;
+ passRunning: boolean;
+ lastCheckAt: number | null;
+ nextCheckAt: number | null;
+ lastPassAt: number | null;
+ lastPassSummary: string | null;
+ lastDecision: string | null;
+};
+
+export type PublishTargetInput = {
+ built: BuiltStamp | null;
+ deployed: DeployedFile | null;
+ bundleProblem: string | null;
+ // Never deployed anywhere (a private site), or null.
+ deployProblem: string | null;
+ // Cannot go to Cloudflare Pages (no project), or null.
+ pagesProblem: string | null;
+ // The newest mtime of a config file this target's build reads.
+ configChangedAt: number | null;
+ policy: PublishPolicy;
+ // The Pages project, for a preview's alias URL.
+ cloudflareProject: string | null;
+ // The public URL the live check reads.
+ url: string | null;
+};
+
+export type PublishSiteInput = PublishTargetInput & {
+ siteId: string;
+ title: string;
+ private: boolean;
+ listed: boolean;
+ // Member channel slugs (site.json `channels`).
+ members: string[];
+};
+
+export type PublishInputs = {
+ index: {
+ stamp: IndexStamp | null;
+ // When the newest ingest ended (ms), or null.
+ lastIngestDoneAt: number | null;
+ // The newest mtime of an index input config file, or null.
+ configChangedAt: number | null;
+ };
+ // Per channel: when its newest ingest ended (a job meta, or its report's
+ // regeneration — see publishState.ts).
+ ingestByChannel: Record<string, number>;
+ // The code this editor (or CLI) runs: a build of another commit reads
+ // "code newer" — never stale.
+ commit: string | null;
+ sites: PublishSiteInput[];
+ hub: PublishTargetInput;
+ homepage: PublishTargetInput & { mainHead: string | null };
+ settings: PublishSettings;
+ jobs: PublishJob[];
+ ended: PublishJobEnded[];
+ lane: PublishLaneLive;
+};
+
+// ---------------------------------------------------------------------------
+// The status
+// ---------------------------------------------------------------------------
+
+export type ChipTone = "ok" | "stale" | "blocked" | "busy" | "warn" | "off";
+export type Chip = { tone: ChipTone; text: string };
+
+export type BuildStale = {
+ reason: "channels" | "data" | "config";
+ changedChannels: string[];
+};
+
+export type PublishTargetStatus = {
+ target: string;
+ kind: "site" | "hub" | "homepage";
+ title: string;
+ indexFresh: boolean;
+ built: BuiltStamp | null;
+ // The bundle is what the CURRENT index would build (same signature).
+ builtFromCurrentIndex: boolean;
+ // The build stage's own answer (needs()).
+ buildFreshness: Freshness;
+ buildStale?: BuildStale;
+ // Built by another commit than the one running: says so, never stale.
+ codeNewer: boolean;
+ deployed: DeployedFile | null;
+ // Where the policy deploys (null: it does not), and that record.
+ deployKind: DeployKind | null;
+ deployRecord: DeployRecord | null;
+ // The record the chips read is of the bundle on disk.
+ deployedIsBuilt: boolean;
+ previewUrl?: string;
+ liveCheck?: LiveCheck;
+ policy: PublishPolicy;
+ // The Pages project (a preview's alias URL is built from it), or null.
+ cloudflareProject: string | null;
+ // The public URL the live check reads, or null.
+ url: string | null;
+ deployProblem?: string;
+ bundleProblem?: string;
+ // What happens next, in words ("build, then deploy to preview", …).
+ next: string;
+ running?: PublishJob;
+ queued: PublishJob[];
+ // The newest ended build / deploy job (for a refusal's words).
+ lastBuild?: PublishJobEnded;
+ lastDeploy?: PublishJobEnded;
+ chips: { index: Chip; built: Chip; deployed: Chip; live: Chip };
+};
+
+export type PublishLaneStatus = {
+ enabled: boolean;
+ held: boolean;
+ quietNow: boolean;
+ busy: boolean;
+ live: PublishLaneLive;
+ // Whether a pass would run now, and why (or why not).
+ due: boolean;
+ reason: string;
+ // Why the lane will not dispatch at all right now (off, held, quiet hours,
+ // a publish job queued or running), or null.
+ blockedReason: string | null;
+ chip: Chip;
+};
+
+export type PlanStep = Omit<StageRequest, "runId"> & {
+ reason: string;
+ previewUrl?: string;
+};
+
+export type PlanSkip = { kind: StageKind; target: string; reason: string };
+
+export type PublishPlan = {
+ runStart: number;
+ steps: PlanStep[];
+ skipped: PlanSkip[];
+};
+
+export type PublishStatus = {
+ now: number;
+ commit: string | null;
+ index: {
+ stamp: IndexStamp | null;
+ freshness: Freshness;
+ fresh: boolean;
+ running?: PublishJob;
+ queued: PublishJob[];
+ lastRun?: PublishJobEnded;
+ chip: Chip;
+ };
+ sites: PublishTargetStatus[];
+ hub: PublishTargetStatus;
+ homepage: PublishTargetStatus;
+ // Any publish stage queued or running.
+ busy: boolean;
+ jobs: PublishJob[];
+ settings: PublishSettings;
+ lane: PublishLaneStatus;
+ // What Publish now would enqueue (deploys by policy).
+ plan: PublishPlan;
+ // What the stages' needs() read.
+ needs: NeedsInput;
+};
+
+const MIN = 60_000;
+
+function namesList(slugs: string[], max = 4): string {
+ const shown = slugs.slice(0, max).join(", ");
+ return slugs.length > max ? `${shown}, …` : shown;
+}
+
+/** "3 min ago", "2 h ago", "4 d ago" — the chips' one clock word. */
+export function agoText(then: number, now: number): string {
+ const ms = Math.max(0, now - then);
+ if (ms < MIN) return "just now";
+ if (ms < 60 * MIN) return `${Math.floor(ms / MIN)} min ago`;
+ if (ms < 48 * 60 * MIN) return `${Math.floor(ms / (60 * MIN))} h ago`;
+ return `${Math.floor(ms / (24 * 60 * MIN))} d ago`;
+}
+
+function changedSince(
+ members: Iterable<string>,
+ byChannel: Record<string, number>,
+ since: number,
+): string[] {
+ const out = new Set<string>();
+ for (const slug of members) {
+ const at = byChannel[slug];
+ if (at !== undefined && at > since) out.add(slug);
+ }
+ return [...out].sort();
+}
+
+function targetState(t: PublishTargetInput, changedChannels: string[]): TargetState {
+ return {
+ built: t.built,
+ deployed: t.deployed,
+ changedChannels,
+ configChangedAt: t.configChangedAt,
+ bundleProblem: t.bundleProblem,
+ deployProblem: t.deployProblem,
+ pagesProblem: t.pagesProblem,
+ };
+}
+
+/** The NeedsInput the stages' needs() read, with the two signals a child cannot see. */
+export function needsInputOf(i: PublishInputs): NeedsInput {
+ const sites: Record<string, TargetState> = {};
+ for (const s of i.sites) {
+ const changed = s.built ? changedSince(s.members, i.ingestByChannel, builtCheckedAt(s.built)) : [];
+ sites[s.siteId] = targetState(s, changed);
+ }
+ const listedMembers = new Set(i.sites.filter((s) => s.listed).flatMap((s) => s.members));
+ const hubChanged = i.hub.built
+ ? changedSince(listedMembers, i.ingestByChannel, builtCheckedAt(i.hub.built))
+ : [];
+ const homeChanged = i.homepage.built
+ ? changedSince(Object.keys(i.ingestByChannel), i.ingestByChannel, builtCheckedAt(i.homepage.built))
+ : [];
+ return {
+ index: { ...i.index },
+ sites,
+ hub: targetState(i.hub, hubChanged),
+ homepage: { ...targetState(i.homepage, homeChanged), mainHead: i.homepage.mainHead },
+ };
+}
+
+function req(kind: StageKind, target: string, extra: Partial<StageRequest> = {}): StageRequest {
+ return { kind, target, runId: "status", ...extra };
+}
+
+/** The build stage that builds `kind`'s target. */
+function buildKindOf(kind: PublishTargetStatus["kind"]): StageKind {
+ return kind === "site" ? "build-site" : kind === "hub" ? "build-hub" : "build-homepage";
+}
+
+function deployStageOf(kind: PublishTargetStatus["kind"]): StageKind {
+ return kind === "site" ? "deploy-site" : kind === "hub" ? "deploy-hub" : "deploy-homepage";
+}
+
+/** Where a policy deploys: production, the preview branch, or nowhere. */
+export function policyDeploy(
+ policy: PublishPolicy,
+ previewBranch: string,
+): { to: DeployKind; preview?: string } | null {
+ if (policy === "production") return { to: "production" };
+ if (policy === "preview") return { to: "preview", preview: previewBranch };
+ return null;
+}
+
+// The deploy record the chips read: the policy's destination when it deploys,
+// else production, else local, else the newest preview.
+function primaryRecord(
+ deployed: DeployedFile | null,
+ dest: { to: DeployKind; preview?: string } | null,
+): DeployRecord | null {
+ if (!deployed) return null;
+ if (dest) return deployRecordFor(deployed, dest.to, dest.preview);
+ if (deployed.production) return deployed.production;
+ if (deployed.local) return deployed.local;
+ const previews = Object.values(deployed.previews).sort((a, b) => b.at - a.at);
+ return previews[0] ?? null;
+}
+
+function previewUrlOf(t: PublishTargetInput, previewBranch: string): string | undefined {
+ const rec = t.deployed?.previews[previewBranch] ?? null;
+ if (rec?.alias) return rec.alias;
+ if (rec?.url) return rec.url;
+ if (t.policy === "preview" && t.cloudflareProject) return previewAliasUrl(t.cloudflareProject, previewBranch);
+ return undefined;
+}
+
+function buildStaleOf(
+ t: PublishTargetInput,
+ state: TargetState,
+ sigMatches: boolean,
+): BuildStale | undefined {
+ if (!t.built) return undefined;
+ if (state.changedChannels.length > 0) return { reason: "channels", changedChannels: state.changedChannels };
+ if (t.configChangedAt !== null && t.configChangedAt > builtCheckedAt(t.built)) {
+ return { reason: "config", changedChannels: [] };
+ }
+ if (!sigMatches) return { reason: "data", changedChannels: [] };
+ return undefined;
+}
+
+function liveChip(check: LiveCheck | undefined): Chip {
+ if (!check) return { tone: "off", text: "not checked" };
+ switch (check.verdict) {
+ case "ok":
+ return { tone: "ok", text: "live ok" };
+ case "stale-edge":
+ return { tone: "warn", text: "live: the edge serves an older build" };
+ case "mismatch":
+ return { tone: "warn", text: "live: a different build answers" };
+ case "unreachable":
+ return { tone: "warn", text: "live: unreachable" };
+ default:
+ return { tone: "off", text: "live check skipped" };
+ }
+}
+
+// The words for a stage job that ended refused (exit 3) or failed.
+function endedWords(e: PublishJobEnded, what: "build" | "deploy"): Chip | null {
+ if (e.status === "done") return null;
+ if (e.status === "cancelled") return { tone: "warn", text: `last ${what} cancelled` };
+ if (e.exitCode === 3) {
+ if (what === "deploy" && e.builtAfter !== undefined) return { tone: "blocked", text: "waiting for its build" };
+ if (what === "build" && e.indexAfter !== undefined) return { tone: "blocked", text: "waiting for the index" };
+ return { tone: "blocked", text: `last ${what} refused: precondition not met` };
+ }
+ return { tone: "warn", text: `last ${what} failed` };
+}
+
+function pickJobs(jobs: PublishJob[], kinds: StageKind[], target: string): { running?: PublishJob; queued: PublishJob[] } {
+ const mine = jobs.filter(
+ (j) => kinds.includes(j.kind) && (j.target === target || (j.target === ALL_TARGET && kinds.includes("build-site"))),
+ );
+ return {
+ running: mine.find((j) => j.status === "running"),
+ queued: mine.filter((j) => j.status === "queued").sort((a, b) => a.queuedAt - b.queuedAt),
+ };
+}
+
+function newestEnded(ended: PublishJobEnded[], kind: StageKind, target: string): PublishJobEnded | undefined {
+ return ended
+ .filter((e) => e.kind === kind && (e.target === target || (kind === "build-site" && e.target === ALL_TARGET)))
+ .sort((a, b) => b.endedAt - a.endedAt)[0];
+}
+
+function targetStatus(
+ i: PublishInputs,
+ needs: NeedsInput,
+ now: number,
+ indexFresh: boolean,
+ indexChip: Chip,
+ kind: PublishTargetStatus["kind"],
+ target: string,
+ title: string,
+ t: PublishTargetInput,
+ state: TargetState,
+): Omit<PublishTargetStatus, "next"> {
+ const stamp = needs.index.stamp;
+ const buildKind = buildKindOf(kind);
+ const buildFreshness = STAGES[buildKind].needs(needs, req(buildKind, target));
+ let sigMatches = true;
+ let builtFromCurrentIndex = false;
+ if (t.built && stamp) {
+ if (kind === "site") sigMatches = stamp.sites[target] !== undefined && t.built.inputSig === stamp.sites[target].inputSig;
+ else if (kind === "hub") sigMatches = t.built.inputSig === stamp.hubSig;
+ else sigMatches = t.built.indexStampId === stamp.stampId;
+ builtFromCurrentIndex = sigMatches;
+ }
+ const buildStale = buildStaleOf(t, state, sigMatches);
+ const codeNewer = Boolean(t.built?.commit && i.commit && t.built.commit !== i.commit);
+ const dest = policyDeploy(t.policy, i.settings.previewBranch);
+ const deployRecord = primaryRecord(t.deployed, dest);
+ const deployedIsBuilt = Boolean(t.built && deployRecord && deployRecord.builtStampId === t.built.stampId);
+ const build = pickJobs(i.jobs, [buildKind], target);
+ const deploy = pickJobs(i.jobs, [deployStageOf(kind)], target);
+ const running = build.running ?? deploy.running;
+ const queued = [...build.queued, ...deploy.queued];
+ const lastBuild = newestEnded(i.ended, buildKind, target);
+ const lastDeploy = newestEnded(i.ended, deployStageOf(kind), target);
+
+ // --- built chip
+ let built: Chip;
+ if (build.running) built = { tone: "busy", text: "building…" };
+ else if (build.queued.length > 0) built = { tone: "busy", text: "build queued" };
+ else if (buildFreshness.state === "blocked") built = { tone: "blocked", text: buildFreshness.reason };
+ else if (!t.built) built = { tone: "stale", text: "never built" };
+ else if (buildStale?.reason === "channels") {
+ const n = buildStale.changedChannels.length;
+ built = { tone: "stale", text: `stale: ${n} channel${n === 1 ? "" : "s"} changed (${namesList(buildStale.changedChannels)})` };
+ } else if (buildStale?.reason === "config") built = { tone: "stale", text: "stale: config changed" };
+ else if (buildStale?.reason === "data") built = { tone: "stale", text: "stale: data changed" };
+ else if (t.bundleProblem) built = { tone: "warn", text: `bundle: ${t.bundleProblem}` };
+ else built = { tone: "ok", text: `built ${agoText(t.built.builtAt, now)}` };
+ if (built.tone !== "busy" && lastBuild && (!t.built || lastBuild.endedAt > t.built.builtAt)) {
+ const w = endedWords(lastBuild, "build");
+ if (w && built.tone !== "ok") built = { tone: w.tone, text: `${built.text} · ${w.text}` };
+ }
+ if (codeNewer && t.built) built = { ...built, text: `${built.text} · code newer` };
+
+ // --- deployed chip
+ let deployed: Chip;
+ if (deploy.running) deployed = { tone: "busy", text: "deploying…" };
+ else if (deploy.queued.length > 0) deployed = { tone: "busy", text: "deploy queued" };
+ else if (t.deployProblem) deployed = { tone: "off", text: "never deployed (private)" };
+ else if (!deployRecord) {
+ deployed = dest
+ ? { tone: "stale", text: dest.to === "preview" ? `not on preview "${dest.preview}"` : "never deployed" }
+ : { tone: "off", text: "not deployed" };
+ } else {
+ const where = deployRecord.kind === "preview" ? `preview ${deployRecord.branch ?? ""}`.trim() : deployRecord.kind;
+ deployed = deployedIsBuilt
+ ? { tone: "ok", text: `${where} · ${agoText(deployRecord.at, now)}` }
+ : { tone: "stale", text: `${where}: a newer build is not deployed` };
+ }
+ if (deployed.tone !== "busy" && lastDeploy && (!deployRecord || lastDeploy.endedAt > deployRecord.at)) {
+ const w = endedWords(lastDeploy, "deploy");
+ if (w) deployed = w.tone === "blocked" ? w : { tone: w.tone, text: `${deployed.text} · ${w.text}` };
+ }
+
+ const liveCheck = deployRecord?.liveCheck ?? undefined;
+ return {
+ target,
+ kind,
+ title,
+ indexFresh,
+ built: t.built,
+ builtFromCurrentIndex,
+ buildFreshness,
+ ...(buildStale ? { buildStale } : {}),
+ codeNewer,
+ deployed: t.deployed,
+ deployKind: dest?.to ?? null,
+ deployRecord,
+ deployedIsBuilt,
+ ...(previewUrlOf(t, i.settings.previewBranch) ? { previewUrl: previewUrlOf(t, i.settings.previewBranch) } : {}),
+ ...(liveCheck ? { liveCheck } : {}),
+ policy: t.policy,
+ cloudflareProject: t.cloudflareProject,
+ url: t.url,
+ ...(t.deployProblem ? { deployProblem: t.deployProblem } : {}),
+ ...(t.bundleProblem ? { bundleProblem: t.bundleProblem } : {}),
+ ...(running ? { running } : {}),
+ queued,
+ ...(lastBuild ? { lastBuild } : {}),
+ ...(lastDeploy ? { lastDeploy } : {}),
+ chips: { index: indexChip, built, deployed, live: liveChip(liveCheck) },
+ };
+}
+
+// ---------------------------------------------------------------------------
+// The lane's decision
+// ---------------------------------------------------------------------------
+
+export type PassDecision = { run: boolean; reason: string };
+
+/**
+ * Whether the lane may dispatch at all now: off, held, quiet hours, or a
+ * publish stage someone else queued. Null when it may.
+ */
+export function laneBlockedReason(
+ settings: PublishSettings,
+ busy: boolean,
+ now: number,
+): string | null {
+ if (!settings.enabled) return "the lane is off";
+ if (settings.held) return "held";
+ if (settings.quietHours && isInQuietHours(now, settings.quietHours.start, settings.quietHours.end)) {
+ return "quiet hours";
+ }
+ if (busy) return "a publish stage is queued or running";
+ return null;
+}
+
+/**
+ * Is a pass due? When the lane may dispatch AND: there is no index stamp; or
+ * the index is stale and its last update is at least `refreshEveryMinutes`
+ * old; or the policies have work left (a target stale since the last update —
+ * a failed stage, a policy just switched on) and the last pass is at least
+ * that old.
+ */
+export function publishPassDecision(a: {
+ settings: PublishSettings;
+ stamp: IndexStamp | null;
+ indexFresh: boolean;
+ busy: boolean;
+ now: number;
+ lastPassAt: number | null;
+ policyWork: boolean;
+}): PassDecision {
+ const blocked = laneBlockedReason(a.settings, a.busy, a.now);
+ if (blocked) return { run: false, reason: blocked };
+ if (!a.stamp) return { run: true, reason: "no index stamp yet" };
+ const refreshMs = a.settings.refreshEveryMinutes * MIN;
+ if (!a.indexFresh) {
+ const age = a.now - a.stamp.builtAt;
+ if (age >= refreshMs) return { run: true, reason: "the index is stale" };
+ return {
+ run: false,
+ reason: `the index is stale; the next update is due ${agoText(a.now - (refreshMs - age), a.now).replace(" ago", "")} from now`,
+ };
+ }
+ if (a.policyWork) {
+ if (a.lastPassAt === null || a.now - a.lastPassAt >= refreshMs) {
+ return { run: true, reason: "a target's policy has work left" };
+ }
+ return { run: false, reason: "a target's policy has work left; waiting out the refresh interval" };
+ }
+ return { run: false, reason: "nothing to do: the index and every policy target are current" };
+}
+
+// ---------------------------------------------------------------------------
+// buildPublishStatus
+// ---------------------------------------------------------------------------
+
+export function buildPublishStatus(i: PublishInputs, now: number): PublishStatus {
+ const needs = needsInputOf(i);
+ const indexFreshness = STAGES["update-index"].needs(needs, req("update-index", INDEX_TARGET));
+ const indexFresh = indexFreshness.state === "fresh";
+ const indexJobs = pickJobs(i.jobs, ["update-index"], INDEX_TARGET);
+ const lastIndex = newestEnded(i.ended, "update-index", INDEX_TARGET);
+ const stamp = i.index.stamp;
+ let indexChip: Chip;
+ if (indexJobs.running) indexChip = { tone: "busy", text: "updating…" };
+ else if (indexJobs.queued.length > 0) indexChip = { tone: "busy", text: "update queued" };
+ else if (!stamp) indexChip = { tone: "stale", text: "no index yet" };
+ else if (indexFreshness.state !== "fresh") indexChip = { tone: "stale", text: `stale: ${indexFreshness.reason}` };
+ else indexChip = { tone: "ok", text: `fresh · updated ${agoText(stamp.builtAt, now)}` };
+ if (indexChip.tone !== "busy" && lastIndex && lastIndex.status !== "done" && (!stamp || lastIndex.endedAt > stamp.builtAt)) {
+ indexChip = { tone: "warn", text: `${indexChip.text} · last update ${lastIndex.status}` };
+ }
+
+ const sites = [...i.sites]
+ .sort((a, b) => a.siteId.localeCompare(b.siteId))
+ .map((s) =>
+ targetStatus(i, needs, now, indexFresh, indexChip, "site", s.siteId, s.title, s, needs.sites[s.siteId]),
+ );
+ const hub = targetStatus(i, needs, now, indexFresh, indexChip, "hub", HUB_TARGET, "Hub", { ...i.hub, policy: i.settings.hub }, needs.hub);
+ const homepage = targetStatus(
+ i,
+ needs,
+ now,
+ indexFresh,
+ indexChip,
+ "homepage",
+ HOMEPAGE_TARGET,
+ "Homepage",
+ { ...i.homepage, policy: i.settings.homepage },
+ needs.homepage,
+ );
+ const busy = i.jobs.length > 0;
+ const partial = {
+ now,
+ commit: i.commit,
+ index: {
+ stamp,
+ freshness: indexFreshness,
+ fresh: indexFresh,
+ ...(indexJobs.running ? { running: indexJobs.running } : {}),
+ queued: indexJobs.queued,
+ ...(lastIndex ? { lastRun: lastIndex } : {}),
+ chip: indexChip,
+ },
+ busy,
+ jobs: i.jobs,
+ settings: i.settings,
+ needs,
+ };
+ // The plan reads the targets without their `next` (it writes it).
+ const draft = {
+ ...partial,
+ sites: sites.map((s) => ({ ...s, next: "" })),
+ hub: { ...hub, next: "" },
+ homepage: { ...homepage, next: "" },
+ lane: undefined as unknown as PublishLaneStatus,
+ plan: { runStart: now, steps: [], skipped: [] } as PublishPlan,
+ } satisfies PublishStatus;
+ const plan = planPublishRun(draft, { deploys: "policy" });
+ const withNext = (t: PublishTargetStatus): PublishTargetStatus => ({ ...t, next: nextWords(t, plan) });
+ const policyWork = plan.steps.some((s) => s.kind !== "update-index");
+ const blockedReason = laneBlockedReason(i.settings, busy, now);
+ const decision = publishPassDecision({
+ settings: i.settings,
+ stamp,
+ indexFresh,
+ busy,
+ now,
+ lastPassAt: i.lane.lastPassAt,
+ policyWork,
+ });
+ const quietNow = Boolean(
+ i.settings.quietHours && isInQuietHours(now, i.settings.quietHours.start, i.settings.quietHours.end),
+ );
+ const lane: PublishLaneStatus = {
+ enabled: i.settings.enabled,
+ held: i.settings.held,
+ quietNow,
+ busy,
+ live: i.lane,
+ due: decision.run,
+ reason: decision.reason,
+ blockedReason,
+ chip: laneChip(i, blockedReason, decision),
+ };
+ return {
+ ...partial,
+ sites: draft.sites.map(withNext),
+ hub: withNext(draft.hub),
+ homepage: withNext(draft.homepage),
+ lane,
+ plan,
+ };
+}
+
+function laneChip(i: PublishInputs, blocked: string | null, d: PassDecision): Chip {
+ if (!i.settings.enabled) return { tone: "off", text: "lane off" };
+ if (i.settings.held) return { tone: "warn", text: "lane held" };
+ if (i.lane.passRunning) return { tone: "busy", text: "lane: pass running" };
+ if (i.lane.known && !i.lane.running) return { tone: "warn", text: "lane on, runner not running" };
+ if (blocked) return { tone: "off", text: `lane waiting: ${blocked}` };
+ return d.run ? { tone: "busy", text: "lane: pass due" } : { tone: "ok", text: "lane idle" };
+}
+
+function nextWords(t: PublishTargetStatus, plan: PublishPlan): string {
+ if (t.running) return `${t.running.kind} running`;
+ if (t.queued.length > 0) return `${t.queued.map((j) => j.kind).join(", ")} queued`;
+ const mine = plan.steps.filter((s) => s.target === t.target || (s.target === ALL_TARGET && t.kind === "site"));
+ if (mine.length > 0) {
+ return mine
+ .map((s) =>
+ s.kind.startsWith("build")
+ ? "build"
+ : s.to === "local"
+ ? "deploy locally"
+ : s.preview
+ ? `deploy to preview "${s.preview}"`
+ : "deploy to production",
+ )
+ .join(", then ");
+ }
+ const skip = plan.skipped.find((s) => s.target === t.target);
+ if (skip) return skip.reason;
+ if (t.buildFreshness.state !== "fresh" && t.policy === "off") return "stale — the policy is off (Build rebuilds it)";
+ return "up to date";
+}
+
+// ---------------------------------------------------------------------------
+// planPublishRun
+// ---------------------------------------------------------------------------
+
+export type PlanOptions = {
+ // "policy": deploy where the target's policy says (Publish now, the lane);
+ // "none": build only.
+ deploys?: "policy" | "none";
+ // "policy": build targets whose policy is not off; "stale": every stale
+ // site, whatever its policy ("Build all stale"). The hub and the homepage
+ // follow their policies either way.
+ builds?: "policy" | "stale";
+ // The run's start (the on-disk preconditions); defaults to status.now.
+ runStart?: number;
+ // Step keys (`stepKey`) to leave out: what the lane already ran this pass.
+ exclude?: ReadonlySet<string>;
+ // Leave the update-index step out (the lane, after its index stage).
+ skipIndex?: boolean;
+};
+
+/** A step's identity within a run: kind, target and destination. */
+export function stepKey(s: Pick<StageRequest, "kind" | "target" | "preview" | "to">): string {
+ const dest = s.kind.startsWith("deploy") ? `:${deployKindOf(s)}${s.preview ? `:${s.preview}` : ""}` : "";
+ return `${s.kind}:${s.target}${dest}`;
+}
+
+/**
+ * What a run enqueues, in order: update-index when the index is stale; then
+ * per site (by id) its build — when it has changed channels, its signature no
+ * longer matches the index, it was never built, its config changed or its
+ * bundle has a problem — and its deploy where the policy says; then the hub;
+ * then the homepage. NEVER forced: a fresh target is not in the plan, and a
+ * stage whose child finds it fresh is a no-op.
+ *
+ * Preconditions are on disk (stages.ts): a build carries `indexAfter =
+ * runStart` when update-index is in the run; a deploy `builtAfter = runStart`
+ * when its build is.
+ */
+export function planPublishRun(status: PublishStatus, opts: PlanOptions = {}): PublishPlan {
+ const runStart = opts.runStart ?? status.now;
+ const exclude = opts.exclude ?? new Set<string>();
+ const needs = status.needs;
+ const settings = status.settings;
+ const steps: PlanStep[] = [];
+ const skipped: PlanSkip[] = [];
+ const add = (s: PlanStep) => {
+ if (!exclude.has(stepKey(s))) steps.push(s);
+ };
+
+ const indexStep =
+ !opts.skipIndex &&
+ !status.index.fresh &&
+ !exclude.has(stepKey({ kind: "update-index", target: INDEX_TARGET }));
+ if (indexStep) {
+ add({
+ kind: "update-index",
+ target: INDEX_TARGET,
+ reason: status.index.freshness.state === "fresh" ? "the index is stale" : status.index.freshness.reason,
+ });
+ }
+ const afterIndex = indexStep ? { indexAfter: runStart } : {};
+ const noStamp = !status.index.stamp;
+
+ // Whether a target's build belongs in the run, and why.
+ const buildWanted = (
+ kind: StageKind,
+ t: PublishTargetStatus,
+ state: TargetState,
+ ): { want: boolean; reason: string } => {
+ const f = STAGES[kind].needs(needs, req(kind, t.target));
+ if (f.state === "stale") return { want: true, reason: f.reason };
+ if (f.state === "blocked") {
+ // No stamp yet: the index this run updates comes first.
+ if (indexStep && noStamp) return { want: true, reason: "built after the index's first update" };
+ return { want: false, reason: f.reason };
+ }
+ return { want: false, reason: "fresh" };
+ };
+
+ // The deploy a policy asks for, after (or without) its build in this run.
+ const deployFor = (kind: StageKind, t: PublishTargetStatus, state: TargetState, building: boolean) => {
+ if (opts.deploys === "none") return;
+ const dest = policyDeploy(t.policy, settings.previewBranch);
+ if (!dest) return;
+ const where = dest.preview ? { preview: dest.preview } : {};
+ const previewUrl =
+ dest.preview && t.cloudflareProject ? { previewUrl: previewAliasUrl(t.cloudflareProject, dest.preview) } : {};
+ if (building) {
+ // Never deployable by configuration: said here, not left to the child.
+ const never = t.deployProblem ?? state.pagesProblem ?? null;
+ if (never) {
+ skipped.push({ kind, target: t.target, reason: never });
+ return;
+ }
+ add({ kind, target: t.target, ...where, builtAfter: runStart, reason: "its build is in this run", ...previewUrl });
+ return;
+ }
+ const f = STAGES[kind].needs(needs, req(kind, t.target, where));
+ if (f.state === "stale") add({ kind, target: t.target, ...where, reason: f.reason, ...previewUrl });
+ else if (f.state === "blocked") skipped.push({ kind, target: t.target, reason: f.reason });
+ };
+
+ // --- sites: each one's build, then its deploy (by id)
+ const rows: { t: PublishTargetStatus; state: TargetState; build: PlanStep | null; deploys: boolean }[] = [];
+ for (const t of status.sites) {
+ const state = needs.sites[t.target];
+ const policyOn = t.policy !== "off";
+ const w = buildWanted("build-site", t, state);
+ const wantByPolicy = opts.builds === "stale" ? true : policyOn;
+ if (!wantByPolicy) {
+ if (w.want) skipped.push({ kind: "build-site", target: t.target, reason: "stale, and its policy is off" });
+ continue;
+ }
+ if (!w.want && w.reason !== "fresh") skipped.push({ kind: "build-site", target: t.target, reason: w.reason });
+ rows.push({
+ t,
+ state,
+ build: w.want ? { kind: "build-site", target: t.target, ...afterIndex, reason: w.reason } : null,
+ deploys: policyOn,
+ });
+ }
+ const builds = rows.filter((r) => r.build && !exclude.has(stepKey(r.build)));
+ if (settings.runner === "docker" && builds.length > 0) {
+ // The fan-out builds every stale site in containers, in ONE stage.
+ add({
+ kind: "build-site",
+ target: ALL_TARGET,
+ runner: "docker",
+ ...afterIndex,
+ reason: `${builds.length} site${builds.length === 1 ? "" : "s"} to build (${namesList(builds.map((r) => r.t.target))}); the docker runner builds every stale site`,
+ });
+ for (const r of rows) if (r.deploys) deployFor("deploy-site", r.t, r.state, r.build !== null);
+ } else {
+ for (const r of rows) {
+ if (r.build) add(r.build);
+ if (r.deploys) deployFor("deploy-site", r.t, r.state, r.build !== null);
+ }
+ }
+
+ // --- the hub, then the homepage: their policies are settings.publish's
+ for (const [kind, deployKind, t, state] of [
+ ["build-hub", "deploy-hub", status.hub, needs.hub],
+ ["build-homepage", "deploy-homepage", status.homepage, needs.homepage],
+ ] as const) {
+ if (t.policy === "off") continue;
+ let w = buildWanted(kind, t, state);
+ // Fresh by the current index, but the index this run updates moves the
+ // hub (its signature carries the stamp id) when a listed site's channels
+ // changed — and the homepage when any did.
+ if (!w.want && w.reason === "fresh" && indexStep && state.changedChannels.length > 0) {
+ const n = state.changedChannels.length;
+ w = { want: true, reason: `${n} channel${n === 1 ? "" : "s"} changed` };
+ }
+ if (w.want) add({ kind, target: t.target, ...afterIndex, reason: w.reason });
+ else if (w.reason !== "fresh") skipped.push({ kind, target: t.target, reason: w.reason });
+ deployFor(deployKind, t, state, w.want);
+ }
+ return { runStart, steps, skipped };
+}
diff --git a/common/views/publishStatus.ts b/common/views/publishStatus.ts
@@ -0,0 +1,41 @@
+// THE PUBLISH STATUS, as the view layer names it (release 18).
+//
+// The builder and the planner are pure and live in the publish layer
+// (publish/publishPlan.ts) because the lane's runner plans from them, and the
+// publish layer may not import views/. This module is the view layer's door to
+// them: the /sites Publish panel, `GET /api/ops/publish` and the CLI's
+// `publish status` import the payload TYPES from here, and the reader that
+// fills it is publish/publishState.ts `readPublishStatus`.
+//
+// A client component imports TYPES only: the builder's module graph reaches
+// the stamp readers.
+
+export {
+ agoText,
+ buildPublishStatus,
+ laneBlockedReason,
+ needsInputOf,
+ planPublishRun,
+ policyDeploy,
+ publishPassDecision,
+ stepKey,
+} from "../publish/publishPlan";
+export type {
+ BuildStale,
+ Chip,
+ ChipTone,
+ PassDecision,
+ PlanOptions,
+ PlanSkip,
+ PlanStep,
+ PublishInputs,
+ PublishJob,
+ PublishJobEnded,
+ PublishLaneLive,
+ PublishLaneStatus,
+ PublishPlan,
+ PublishSiteInput,
+ PublishStatus,
+ PublishTargetInput,
+ PublishTargetStatus,
+} from "../publish/publishPlan";