Archilyzer · Source

archilyzer

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

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

publish: the index is judged by the settings it reads (a signature in the stamp), not the settings file's mtime

Every pause click, priority change and drive auto-pause writes settings.json,
and each made the index "stale" — one short-circuited update per refresh
interval. `indexSettingsSig` (inputSig.ts) signs the keys the index and the
builds it signs read (social links, homepage url, buildArchives,
archiveStorage, social.x.visibility, the transcript page size, the storage
locations' roots); update-index records it as `settingsSig`, and needs() says
"the settings the index reads changed" when it differs. The charts config
stays an mtime (its own file).

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

Diffstat:
Mcommon/publish/inputSig.ts | 38++++++++++++++++++++++++++++++++++++++
Mcommon/publish/publishPlan.ts | 2++
Mcommon/publish/publishState.test.ts | 31+++++++++++++++++++++++++++++++
Mcommon/publish/publishState.ts | 11++++++++---
Mcommon/publish/stageBodies.ts | 3++-
Mcommon/publish/stages.test.ts | 23+++++++++++++++++++++++
Mcommon/publish/stages.ts | 8+++++++-
Mcommon/publish/stamps.ts | 4++++
Mplans/release-18.md | 6+++++-
9 files changed, 120 insertions(+), 6 deletions(-)

diff --git a/common/publish/inputSig.ts b/common/publish/inputSig.ts @@ -145,6 +145,44 @@ export async function siteInputSig(i: SiteSigInputs): Promise<string> { return sha1(lines.join("\n")); } +/** The settings the index stage reads (see indexSettingsSig). */ +export type IndexSettingsInputs = Pick< + SiteSettings, + | "socialLinks" + | "homepageUrl" + | "buildArchives" + | "archiveStorage" + | "social" + | "maxTranscriptPageBytes" + | "storage" +>; + +/** + * sha1 over the settings keys the index stage and the builds it signs read — + * NOT the settings file's mtime, which every pause click, priority change and + * drive auto-pause moves (release 18 S4 step 1). The keys: the social links and + * the homepage url (the builds render them), `buildArchives`, `archiveStorage`, + * `social.x.visibility` (which members a site publishes), the transcript page + * size (the index shards by it) and the storage locations' roots (which + * channels the index can reach). A superset is conservative: a short-circuited + * index update, never a missed one. The charts config is its own file, judged + * by its mtime. + */ +export function indexSettingsSig(settings: Partial<IndexSettingsInputs>): string { + return sha1( + JSON.stringify({ + v: 1, + socialLinks: settings.socialLinks ?? null, + homepageUrl: settings.homepageUrl ?? null, + buildArchives: settings.buildArchives ?? null, + archiveStorage: settings.archiveStorage ?? null, + xVisibility: settings.social?.x?.visibility ?? null, + maxTranscriptPageBytes: settings.maxTranscriptPageBytes ?? null, + locations: (settings.storage?.locations ?? []).map((l) => [l.id, l.root]), + }), + ); +} + /** Every member's channel config, read once (unreadable = null). */ export async function readMemberConfigs( paths: Paths, diff --git a/common/publish/publishPlan.ts b/common/publish/publishPlan.ts @@ -129,6 +129,8 @@ export type PublishInputs = { lastIngestDoneAt: number | null; // The newest mtime of an index input config file, or null. configChangedAt: number | null; + // indexSettingsSig of the settings now (stages.ts NeedsInput). + settingsSig?: string; }; // Per channel: when its newest ingest ended (a job meta, or its report's // regeneration — see publishState.ts). diff --git a/common/publish/publishState.test.ts b/common/publish/publishState.test.ts @@ -28,6 +28,10 @@ process.env.CURATED_TAGS_FILE = path.join(ROOT, "transcripts", "tags.json"); const { getPaths } = await import("../lib/paths"); const { readPublishInputs, readPublishStatus, resetPublishStateCache } = await import("./publishState"); const { stageSpec } = await import("./publishStages"); +const { indexSettingsSig } = await import("./inputSig"); +const { writeIndexStamp } = await import("./stamps"); +const { indexStamp } = await import("./__fixtures__/stamps"); +const { getSettings } = await import("../lib/settings"); type JobRecord = import("../jobs/registry").JobRecord; after(() => rm(ROOT, { recursive: true, force: true })); @@ -163,3 +167,30 @@ test("readPublishStatus: the chips a fresh scratch corpus shows", async () => { assert.equal(s.lane.enabled, true); assert.equal(s.lane.due, true, "no stamp: a pass is due"); }); + +test("the index is stale on the settings it reads, not on a settings-file write", async () => { + setup(); + resetPublishStateCache(); + // A stamp newer than every input, signed over the settings as they are. + await writeIndexStamp(paths, indexStamp({ scannedAt: T + 1, builtAt: T + 1, settingsSig: indexSettingsSig(getSettings()) })); + const fresh = async () => (await readPublishStatus(paths, { now: T + 2, live: [], laneKnown: false })).index; + assert.equal((await fresh()).fresh, true, "signed over these settings"); + + // A priority change (a pause click, a drive auto-pause) writes the file NOW. + writeJson(paths.settingsFile, { + publish: { enabled: true, hub: "build", previewBranch: "smoke" }, + channelPriority: { channels: { "a-one": { tier: "high" } } }, + }); + resetPublishStateCache(); + assert.equal((await fresh()).fresh, true, "the file is newer than the stamp; what the index reads is not"); + + // A social link is rendered by every build: the index is stale. + writeJson(paths.settingsFile, { + publish: { enabled: true, hub: "build", previewBranch: "smoke" }, + socialLinks: [{ label: "Site", url: "https://example.com", svg: '<svg viewBox="0 0 24 24"><path d="M0 0h24v24H0z"/></svg>' }], + }); + resetPublishStateCache(); + const after = await fresh(); + assert.equal(after.fresh, false); + assert.deepEqual(after.freshness, { state: "stale", reason: "the settings the index reads changed" }); +}); diff --git a/common/publish/publishState.ts b/common/publish/publishState.ts @@ -7,7 +7,9 @@ // hub's and the homepage's (settings.publish); // - the input files' mtimes the plan lists: for the index tags.json, // search-aliases.json, duplicates*.json, every sites/*/site.json, -// homepage.json, the settings file and the charts config; for a site its +// homepage.json and the charts config — the SETTINGS by the keys the index +// reads (inputSig.ts indexSettingsSig), never the file's mtime, which +// every pause click moves; for a site its // site.json, its tags and aliases and the corpus-wide three; for the hub // homepage.json and every site.json; for the homepage homepage.json; // - the ingest signal, per channel: every job meta of an ingest kind @@ -37,6 +39,7 @@ import { getHomepageConfig } from "../lib/homepage"; import { getPaths, type Paths } from "../lib/paths"; import { PROJECT_URL } from "../lib/project"; import { getSettings, normalizeHomepageUrl } from "../lib/settings"; +import { indexSettingsSig } from "./inputSig"; import { isListedSite, isPrivateSite, @@ -324,9 +327,11 @@ export async function readPublishInputs( corpusWide, ...siteJsonAt.values(), homepageJsonAt, - await mtimeOf(paths.settingsFile), await mtimeOf(paths.chartsConfigFile), ); + // The settings are judged by the keys the index reads, not the file's mtime + // (every pause click writes the file). + const settingsSig = indexSettingsSig(settings); const siteInputs: PublishSiteInput[] = []; for (const s of sites) { @@ -357,7 +362,7 @@ export async function readPublishInputs( const mainHead = await memoized("mainHead", now, () => mainHeadOf(paths)); const commit = await memoized("commit", now, async () => (await checkoutInfo(paths)).commit); return { - index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt }, + index: { stamp: needs.index.stamp, lastIngestDoneAt, configChangedAt: indexConfigAt, settingsSig }, ingestByChannel: byChannel, commit, sites: siteInputs, diff --git a/common/publish/stageBodies.ts b/common/publish/stageBodies.ts @@ -229,7 +229,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> { const { buildIndex } = await import("../controller/buildIndex"); const { buildStats } = await import("../controller/buildStats"); const { syncTemplatesToExport } = await import("../lib/chartsStore"); - const { hubInputSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig"); + const { hubInputSig, indexSettingsSig, readIndexMeta, readMemberConfigs, siteInputSig } = await import("./inputSig"); // A CLI process has no health pass: the drive-health timings the builds' // watchdog runs on are applied here, once (bin/build-index.ts does the same). @@ -311,6 +311,7 @@ async function runUpdateIndex(ctx: StageContext): Promise<StageOutcome> { }, sites: siteEntries, hubSig: await hubInputSig(paths, stampId, sites), + settingsSig: indexSettingsSig(settings), }; const { writeIndexStamp } = await import("./stamps"); await writeIndexStamp(paths, stamp); diff --git a/common/publish/stages.test.ts b/common/publish/stages.test.ts @@ -100,6 +100,29 @@ test("update-index: fresh when a stamp exists and nothing is newer than its scan assert.equal(reason(needs(input(), req("update-index", "_index", { force: true }))), "forced"); }); +test("update-index: the settings it reads, by signature — not judged when the caller read none", () => { + const signed = (stampSig: string | undefined, nowSig: string | undefined) => + input({ + index: { + stamp: indexStamp(stampSig === undefined ? {} : { settingsSig: stampSig }), + lastIngestDoneAt: null, + configChangedAt: null, + ...(nowSig === undefined ? {} : { settingsSig: nowSig }), + }, + }); + assert.equal(state(needs(signed("a", "a"), req("update-index", "_index"))), "fresh"); + assert.equal( + reason(needs(signed("a", "b"), req("update-index", "_index"))), + "the settings the index reads changed", + ); + assert.equal( + reason(needs(signed(undefined, "b"), req("update-index", "_index"))), + "the settings the index reads changed", + "a stamp from before the signature reads as changed", + ); + assert.equal(state(needs(signed("a", undefined), req("update-index", "_index"))), "fresh"); +}); + // --- build-site <id> ---------------------------------------------------------- test("build-site: blocked 'update the index first' with no stamp — even forced", () => { diff --git a/common/publish/stages.ts b/common/publish/stages.ts @@ -125,8 +125,11 @@ export type NeedsInput = { lastIngestDoneAt: number | null; // The newest mtime (ms) of an index input config file (tags.json, // search-aliases.json, duplicates*.json, sites/*/site.json, - // homepage.json, the settings file, the charts config), or null. + // homepage.json, the charts config), or null. configChangedAt: number | null; + // indexSettingsSig over the settings as they are now; compared with the + // stamp's. Absent: not judged (a caller that did not read settings). + settingsSig?: string; }; sites: Record<string, TargetState>; hub: TargetState; @@ -167,6 +170,9 @@ function needsIndex(s: NeedsInput, r: StageRequest): Freshness { if (s.index.configChangedAt !== null && s.index.configChangedAt > stamp.scannedAt) { return stale("a config file changed since the last index"); } + if (s.index.settingsSig !== undefined && s.index.settingsSig !== stamp.settingsSig) { + return stale("the settings the index reads changed"); + } return FRESH; } diff --git a/common/publish/stamps.ts b/common/publish/stamps.ts @@ -48,6 +48,9 @@ export type IndexStamp = { // (null when absent), and the inputSig compose's skip rule is computed from. sites: Record<string, { siteFp: string | null; statsFp: string | null; inputSig: string }>; hubSig: string; + // inputSig.ts indexSettingsSig over the settings the index read (absent on + // a stamp written before release 18's surfaces: it reads as changed). + settingsSig?: string; }; export type BuiltKind = "site" | "hub" | "homepage"; @@ -169,6 +172,7 @@ export function asIndexStamp(v: unknown): IndexStamp | null { return null; } if (!isObj(v.sites) || !isStr(v.hubSig)) return null; + if (v.settingsSig !== undefined && !isStr(v.settingsSig)) return null; for (const s of Object.values(v.sites)) { if (!isObj(s) || !isStr(s.inputSig) || !isStrOrNull(s.siteFp) || !isStrOrNull(s.statsFp)) return null; } diff --git a/plans/release-18.md b/plans/release-18.md @@ -132,7 +132,11 @@ Probe = { status|null; generatedAt?; cfCacheStatus?; age?; cacheControl?; error? (download/transcribe/digest/normalize/import/fetch-posts/tag kinds — the drainable kinds of `jobKinds.ts`) that ENDED `done` after `stamp.scannedAt` — read through the registry's archive reader, no new writer anywhere; (2) a config file newer than the stamp: `tags.json`, `search-aliases.json`, `duplicates*.json`, `sites/*/site.json`, `homepage.json`, the - settings file, the charts config. Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT + charts config; (3) **the settings the index reads, by signature, not the settings file's mtime** (S4 step 1, after S3 + found every pause click, priority change and drive auto-pause staling the index): `indexSettingsSig` (`inputSig.ts`) + over `socialLinks`, `homepageUrl`, `buildArchives`, `archiveStorage`, `social.x.visibility`, + `maxTranscriptPageBytes` and the storage locations' roots, recorded as `settingsSig` in the index stamp and compared + by `needs()` ("the settings the index reads changed"; a stamp without one reads as changed, once). Decided: mtimes/job completions decide WHEN to run, the index child decides WHAT changed (its fingerprints stat every sidecar and are the only correct detector; LMDB `generation` cannot see new files). - **A site is marked stale by its channels, before any index runs** (operator, 2026-10-05): per site, `changedChannels` = the member channels (by `site.json` membership, groups expanded) that have an ingest job meta