Archilyzer · Source

archilyzer

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

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:
Acommon/publish/publishPlan.test.ts | 543+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/publish/publishPlan.ts | 876+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acommon/views/publishStatus.ts | 41+++++++++++++++++++++++++++++++++++++++++
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";