| 1 | /** |
| 2 | * facts-drift.ts — runtime version of scripts/derive-facts.mjs. |
| 3 | * |
| 4 | * Fetches source-of-truth files from raw.githubusercontent.com on a schedule, |
| 5 | * re-derives the same RepoFacts shape, compares to the value cached in KV (or |
| 6 | * to the build-time fallback on first run), and if anything changed writes |
| 7 | * the new facts to CURATED_KV under "facts:current". `getFacts()` accepts the |
| 8 | * KV value only when its exact source provenance is at least as new as the |
| 9 | * deployed build; published-release metadata is resolved separately. |
| 10 | * |
| 11 | * Mechanical drift (provider added, sandbox backend renamed, version bumped) |
| 12 | * fixes itself within one cron tick — no redeploy. Semantic drift (a new |
| 13 | * feature should be advertised on the homepage) is still left to humans. |
| 14 | */ |
| 15 | import type { |
| 16 | PublishedReleaseFact, |
| 17 | RepoFacts, |
| 18 | ProviderFact, |
| 19 | ModelFact, |
| 20 | } from "./facts.generated"; |
| 21 | import { FACTS as BUILD_FACTS } from "./facts.generated"; |
| 22 | |
| 23 | const RAW_ROOT = "https://raw.githubusercontent.com/Hmbown/CodeWhale"; |
| 24 | const KV_KEY = "facts:current"; |
| 25 | const LOG_KEY = "facts:drift-log"; |
| 26 | |
| 27 | interface KVNamespace { |
| 28 | get(k: string): Promise<string | null>; |
| 29 | put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>; |
| 30 | } |
| 31 | |
| 32 | interface SourceMarker { |
| 33 | revision: string; |
| 34 | committedAt: string; |
| 35 | } |
| 36 | |
| 37 | async function fetchText( |
| 38 | path: string, |
| 39 | revision: string, |
| 40 | ghToken?: string, |
| 41 | ): Promise<string | null> { |
| 42 | const headers: Record<string, string> = { |
| 43 | "User-Agent": "codewhale-web-drift", |
| 44 | }; |
| 45 | if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`; |
| 46 | try { |
| 47 | const r = await fetch(`${RAW_ROOT}/${revision}/${path}`, { headers }); |
| 48 | if (!r.ok) return null; |
| 49 | return await r.text(); |
| 50 | } catch { |
| 51 | return null; |
| 52 | } |
| 53 | } |
| 54 | |
| 55 | async function fetchSourceMarker(ghToken?: string): Promise<SourceMarker | null> { |
| 56 | const headers: Record<string, string> = { |
| 57 | Accept: "application/vnd.github+json", |
| 58 | "User-Agent": "codewhale-web-drift", |
| 59 | "X-GitHub-Api-Version": "2022-11-28", |
| 60 | }; |
| 61 | if (ghToken) headers.Authorization = `Bearer ${ghToken}`; |
| 62 | try { |
| 63 | const response = await fetch( |
| 64 | "https://api.github.com/repos/Hmbown/CodeWhale/commits/main", |
| 65 | { headers }, |
| 66 | ); |
| 67 | if (!response.ok) return null; |
| 68 | const json = (await response.json()) as { |
| 69 | sha?: string; |
| 70 | commit?: { committer?: { date?: string } }; |
| 71 | }; |
| 72 | const revision = json.sha; |
| 73 | const committedAt = json.commit?.committer?.date; |
| 74 | if ( |
| 75 | !revision || |
| 76 | !/^[0-9a-f]{40}$/i.test(revision) || |
| 77 | !committedAt || |
| 78 | !Number.isFinite(Date.parse(committedAt)) |
| 79 | ) { |
| 80 | return null; |
| 81 | } |
| 82 | return { revision, committedAt }; |
| 83 | } catch { |
| 84 | return null; |
| 85 | } |
| 86 | } |
| 87 | |
| 88 | function deriveVersion(cargo: string): string | null { |
| 89 | const m = cargo.match(/^version\s*=\s*"([^"]+)"/m); |
| 90 | return m ? m[1] : null; |
| 91 | } |
| 92 | |
| 93 | function deriveCrates(cargo: string): string[] { |
| 94 | const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/); |
| 95 | if (!block) return []; |
| 96 | return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort(); |
| 97 | } |
| 98 | |
| 99 | function deriveProvidersFromConfig(cfg: string): ProviderFact[] { |
| 100 | const enumBlock = cfg.match(/pub enum ApiProvider \{([\s\S]*?)\}/); |
| 101 | if (!enumBlock) return []; |
| 102 | const variants = [...enumBlock[1].matchAll(/^\s*(\w+)\s*,\s*$/gm)].map((m) => m[1]); |
| 103 | // Match what the published CLI binary's `--provider` flag accepts |
| 104 | // (ProviderArg in crates/cli/src/lib.rs). DeepseekCN exists in the |
| 105 | // legacy tui ApiProvider enum but is not wired through ProviderKind, |
| 106 | // so the binary rejects it — keep it out of the docs. Issue #1104. |
| 107 | const labelMap: Record<string, ProviderFact> = { |
| 108 | Deepseek: { id: "deepseek", label: "DeepSeek", env: "DEEPSEEK_API_KEY" }, |
| 109 | DeepseekAnthropic: { id: "deepseek-anthropic", label: "DeepSeek Anthropic", env: "DEEPSEEK_API_KEY / ANTHROPIC_API_KEY" }, |
| 110 | NvidiaNim: { id: "nvidia-nim", label: "NVIDIA NIM", env: "NVIDIA_API_KEY / NVIDIA_NIM_API_KEY" }, |
| 111 | Openai: { id: "openai", label: "OpenAI-compatible", env: "OPENAI_API_KEY" }, |
| 112 | Atlascloud: { id: "atlascloud", label: "AtlasCloud", env: "ATLASCLOUD_API_KEY" }, |
| 113 | WanjieArk: { id: "wanjie-ark", label: "Wanjie Ark", env: "WANJIE_ARK_API_KEY / WANJIE_API_KEY / WANJIE_MAAS_API_KEY" }, |
| 114 | Volcengine: { id: "volcengine", label: "Volcengine Ark", env: "VOLCENGINE_API_KEY / VOLCENGINE_ARK_API_KEY / ARK_API_KEY" }, |
| 115 | Openrouter: { id: "openrouter", label: "OpenRouter", env: "OPENROUTER_API_KEY" }, |
| 116 | Orcarouter: { id: "orcarouter", label: "OrcaRouter", env: "ORCAROUTER_API_KEY" }, |
| 117 | XiaomiMimo: { id: "xiaomi-mimo", label: "Xiaomi MiMo", env: "XIAOMI_MIMO_TOKEN_PLAN_API_KEY / MIMO_TOKEN_PLAN_API_KEY / XIAOMI_MIMO_API_KEY / XIAOMI_API_KEY / MIMO_API_KEY" }, |
| 118 | Novita: { id: "novita", label: "Novita AI", env: "NOVITA_API_KEY" }, |
| 119 | Fireworks: { id: "fireworks", label: "Fireworks AI", env: "FIREWORKS_API_KEY" }, |
| 120 | Siliconflow: { id: "siliconflow", label: "SiliconFlow", env: "SILICONFLOW_API_KEY" }, |
| 121 | SiliconflowCn: { id: "siliconflow-CN", label: "SiliconFlow CN", env: "SILICONFLOW_API_KEY" }, |
| 122 | Arcee: { id: "arcee", label: "Arcee AI", env: "ARCEE_API_KEY" }, |
| 123 | Moonshot: { id: "moonshot", label: "Moonshot/Kimi", env: "MOONSHOT_API_KEY / KIMI_API_KEY" }, |
| 124 | Sglang: { id: "sglang", label: "SGLang", env: "SGLANG_API_KEY" }, |
| 125 | Vllm: { id: "vllm", label: "vLLM", env: "VLLM_API_KEY" }, |
| 126 | Ollama: { id: "ollama", label: "Ollama", env: "OLLAMA_API_KEY" }, |
| 127 | OllamaCloud: { id: "ollama-cloud", label: "Ollama Cloud", env: "OLLAMA_CLOUD_API_KEY / OLLAMA_API_KEY" }, |
| 128 | Huggingface: { id: "huggingface", label: "Hugging Face", env: "HUGGINGFACE_API_KEY / HF_TOKEN" }, |
| 129 | Deepinfra: { id: "deepinfra", label: "DeepInfra", env: "DEEPINFRA_API_KEY / DEEPINFRA_TOKEN" }, |
| 130 | Together: { id: "together", label: "Together AI", env: "TOGETHER_API_KEY" }, |
| 131 | Qianfan: { id: "qianfan", label: "Baidu Qianfan", env: "QIANFAN_API_KEY / BAIDU_QIANFAN_API_KEY" }, |
| 132 | OpenaiCodex: { id: "openai-codex", label: "OpenAI Codex", env: "ChatGPT OAuth via `codewhale auth chatgpt`; optional consented Codex CLI credentials (OPENAI_CODEX_ACCESS_TOKEN / CODEX_ACCESS_TOKEN override)" }, |
| 133 | OpencodeGo: { id: "opencode-go", label: "OpenCode Go", env: "OPENCODE_GO_API_KEY" }, |
| 134 | OpencodeZen: { id: "opencode-zen", label: "OpenCode Zen", env: "OPENCODE_ZEN_API_KEY / OPENCODE_API_KEY" }, |
| 135 | Anthropic: { id: "anthropic", label: "Anthropic", env: "ANTHROPIC_API_KEY" }, |
| 136 | Zai: { id: "zai", label: "Z.ai", env: "ZAI_API_KEY / Z_AI_API_KEY" }, |
| 137 | Stepfun: { id: "stepfun", label: "StepFun", env: "STEPFUN_API_KEY / STEP_API_KEY" }, |
| 138 | Minimax: { id: "minimax", label: "MiniMax", env: "MINIMAX_API_KEY" }, |
| 139 | MinimaxAnthropic: { id: "minimax-anthropic", label: "MiniMax (Anthropic-compatible)", env: "MINIMAX_API_KEY" }, |
| 140 | Openmodel: { id: "openmodel", label: "OpenModel", env: "OPENMODEL_API_KEY" }, |
| 141 | Sakana: { id: "sakana", label: "Sakana AI", env: "FUGU_API_KEY / SAKANA_API_KEY" }, |
| 142 | LongCat: { id: "longcat", label: "Meituan LongCat", env: "LONGCAT_API_KEY" }, |
| 143 | Meta: { id: "meta", label: "Meta Model API", env: "META_MODEL_API_KEY / MODEL_API_KEY" }, |
| 144 | Telecomjs: { id: "telecomjs", label: "TelecomJS TokenHub", env: "TELECOMJS_API_KEY" }, |
| 145 | Xai: { id: "xai", label: "xAI", env: "XAI_API_KEY" }, |
| 146 | Mistral: { id: "mistral", label: "Mistral AI", env: "MISTRAL_API_KEY" }, |
| 147 | Google: { id: "google", label: "Google Gemini", env: "GOOGLE_API_KEY / GEMINI_API_KEY" }, |
| 148 | Edenai: { id: "edenai", label: "Eden AI", env: "EDENAI_API_KEY" }, |
| 149 | Concentrate: { id: "concentrate", label: "Concentrate", env: "CONCENTRATE_API_KEY" }, |
| 150 | Codewhale: { id: "codewhale", label: "Codewhale", env: "CODEWHALE_API_KEY" }, |
| 151 | ModelstudioTokenPlan: { id: "modelstudio-token-plan", label: "Model Studio Token Plan", env: "MODELSTUDIO_API_KEY" }, |
| 152 | ModelstudioTokenPlanAnthropic: { id: "modelstudio-token-plan-anthropic", label: "Model Studio Token Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 153 | ModelstudioCodingPlan: { id: "modelstudio-coding-plan", label: "Model Studio Coding Plan", env: "MODELSTUDIO_API_KEY" }, |
| 154 | ModelstudioCodingPlanAnthropic: { id: "modelstudio-coding-plan-anthropic", label: "Model Studio Coding Plan (Anthropic-compatible)", env: "MODELSTUDIO_API_KEY" }, |
| 155 | Zenmux: { id: "zenmux", label: "ZenMux", env: "ZENMUX_API_KEY" }, |
| 156 | Csdn: { id: "csdn", label: "CSDN 星图 (Starmap)", env: "CSDN_API_KEY" }, |
| 157 | }; |
| 158 | // Log loudly on unmapped variants so a new provider can never be silently |
| 159 | // dropped from the drift-derived facts again. DeepseekCN (#1104), the |
| 160 | // dynamic Custom meta-provider (#1519, user-defined endpoints), and |
| 161 | // Antigravity (a non-runnable legacy config tombstone, never a website |
| 162 | // provider) are the deliberate exclusions. |
| 163 | const EXCLUDED = new Set(["DeepseekCN", "Custom", "Antigravity"]); |
| 164 | const unmapped = variants.filter((v) => !EXCLUDED.has(v) && !labelMap[v]); |
| 165 | if (unmapped.length > 0) { |
| 166 | console.warn( |
| 167 | `[facts-drift] ApiProvider variants missing from labelMap: ${unmapped.join(", ")}. ` + |
| 168 | "Add them to labelMap here AND PROVIDER_LABEL_MAP in web/scripts/facts-lib.mjs (or to EXCLUDED if intentionally hidden).", |
| 169 | ); |
| 170 | } |
| 171 | return variants |
| 172 | .filter((v) => !EXCLUDED.has(v)) |
| 173 | .map((v) => labelMap[v]) |
| 174 | .filter(Boolean); |
| 175 | } |
| 176 | |
| 177 | function deriveDefaultModel(cfg: string): string | null { |
| 178 | // Match the const *definition* (`= "..."`); the definition moved to |
| 179 | // config/models.rs in the #3311 split, so callers pass config.rs + models.rs. |
| 180 | const m = cfg.match(/DEFAULT_TEXT_MODEL\s*(?::\s*&str\s*)?=\s*"([^"]+)"/); |
| 181 | return m ? m[1] : null; |
| 182 | } |
| 183 | |
| 184 | function deriveSandboxBackends(source: string): string[] { |
| 185 | const marker = source.match( |
| 186 | /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/, |
| 187 | ); |
| 188 | if (!marker) return []; |
| 189 | return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]); |
| 190 | } |
| 191 | |
| 192 | async function fetchLatestPublishedRelease( |
| 193 | ghToken?: string, |
| 194 | ): Promise<PublishedReleaseFact | null> { |
| 195 | const headers: Record<string, string> = { |
| 196 | Accept: "application/vnd.github+json", |
| 197 | "User-Agent": "codewhale-web-drift", |
| 198 | "X-GitHub-Api-Version": "2022-11-28", |
| 199 | }; |
| 200 | if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`; |
| 201 | try { |
| 202 | const r = await fetch("https://api.github.com/repos/Hmbown/CodeWhale/releases/latest", { headers }); |
| 203 | if (!r.ok) return null; |
| 204 | const j = (await r.json()) as { |
| 205 | tag_name?: string; |
| 206 | published_at?: string; |
| 207 | html_url?: string; |
| 208 | }; |
| 209 | if ( |
| 210 | !j.tag_name || |
| 211 | !/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(j.tag_name) || |
| 212 | !j.published_at || |
| 213 | !Number.isFinite(Date.parse(j.published_at)) || |
| 214 | !j.html_url |
| 215 | ) { |
| 216 | return null; |
| 217 | } |
| 218 | return { |
| 219 | tag: j.tag_name, |
| 220 | version: j.tag_name.slice(1), |
| 221 | publishedAt: j.published_at, |
| 222 | url: j.html_url, |
| 223 | }; |
| 224 | } catch { |
| 225 | return null; |
| 226 | } |
| 227 | } |
| 228 | |
| 229 | function deriveLicense(licText: string): string | null { |
| 230 | const first = licText.split(/\r?\n/).find((l) => l.trim().length > 0); |
| 231 | if (!first) return null; |
| 232 | if (/^MIT License/i.test(first)) return "MIT"; |
| 233 | if (/Apache.*2\.0/i.test(first)) return "Apache-2.0"; |
| 234 | return first.trim(); |
| 235 | } |
| 236 | |
| 237 | function parseGeneratedFacts(source: string): Record<string, unknown> | null { |
| 238 | const match = source.match( |
| 239 | /export\s+const\s+FACTS(?:\s*:\s*RepoFacts)?\s*=\s*(\{[\s\S]*\})\s*;?\s*$/, |
| 240 | ); |
| 241 | if (!match) return null; |
| 242 | |
| 243 | try { |
| 244 | const parsed = JSON.parse(match[1]) as unknown; |
| 245 | return parsed && typeof parsed === "object" && !Array.isArray(parsed) |
| 246 | ? (parsed as Record<string, unknown>) |
| 247 | : null; |
| 248 | } catch { |
| 249 | return null; |
| 250 | } |
| 251 | } |
| 252 | |
| 253 | function deriveToolCountFromGeneratedFacts(source: string): number | null { |
| 254 | const toolCount = parseGeneratedFacts(source)?.toolCount; |
| 255 | return typeof toolCount === "number" && Number.isSafeInteger(toolCount) && toolCount >= 0 |
| 256 | ? toolCount |
| 257 | : null; |
| 258 | } |
| 259 | |
| 260 | /** |
| 261 | * Model rows come through the checked-in generated file, same as toolCount: |
| 262 | * the remote path cannot run `git log` pickaxe passes for `addedAt`, and the |
| 263 | * checked-in snapshot is guarded by the exact revision's CI drift check. |
| 264 | */ |
| 265 | function deriveModelsFromGeneratedFacts(source: string): ModelFact[] | null { |
| 266 | const models = parseGeneratedFacts(source)?.models; |
| 267 | if (!Array.isArray(models)) return null; |
| 268 | const valid = models.every( |
| 269 | (m) => |
| 270 | m && |
| 271 | typeof m === "object" && |
| 272 | !Array.isArray(m) && |
| 273 | typeof (m as ModelFact).id === "string" && |
| 274 | ((m as ModelFact).provider === null || |
| 275 | typeof (m as ModelFact).provider === "string") && |
| 276 | ((m as ModelFact).contextWindow === null || |
| 277 | typeof (m as ModelFact).contextWindow === "number") && |
| 278 | ((m as ModelFact).maxOutput === null || |
| 279 | typeof (m as ModelFact).maxOutput === "number") && |
| 280 | typeof (m as ModelFact).reasoning === "boolean" && |
| 281 | ((m as ModelFact).addedAt === null || |
| 282 | typeof (m as ModelFact).addedAt === "string"), |
| 283 | ); |
| 284 | return valid ? (models as ModelFact[]) : null; |
| 285 | } |
| 286 | |
| 287 | export async function deriveFactsFromRemote(ghToken?: string): Promise<RepoFacts | null> { |
| 288 | const source = await fetchSourceMarker(ghToken); |
| 289 | if (!source) return null; |
| 290 | |
| 291 | const [cargo, configRs, configModels, sandboxSource, npmPkg, licText, generatedFacts, latestPublishedRelease] = await Promise.all([ |
| 292 | fetchText("Cargo.toml", source.revision, ghToken), |
| 293 | fetchText("crates/tui/src/config.rs", source.revision, ghToken), |
| 294 | fetchText("crates/tui/src/config/models.rs", source.revision, ghToken), |
| 295 | fetchText("crates/tui/src/sandbox/mod.rs", source.revision, ghToken), |
| 296 | fetchText("npm/codewhale/package.json", source.revision, ghToken), |
| 297 | fetchText("LICENSE", source.revision, ghToken), |
| 298 | fetchText("web/lib/facts.generated.ts", source.revision, ghToken), |
| 299 | fetchLatestPublishedRelease(ghToken), |
| 300 | ]); |
| 301 | |
| 302 | if (!cargo || !configRs) return null; |
| 303 | const toolCount = generatedFacts |
| 304 | ? deriveToolCountFromGeneratedFacts(generatedFacts) |
| 305 | : null; |
| 306 | const models = generatedFacts |
| 307 | ? deriveModelsFromGeneratedFacts(generatedFacts) |
| 308 | : null; |
| 309 | // Never attach current-main provenance to build-time tool/model facts. The |
| 310 | // checked-in generated snapshot is guarded by the exact revision's CI drift |
| 311 | // check, so an absent or malformed value makes the whole derivation fail. |
| 312 | if (toolCount === null || models === null) return null; |
| 313 | |
| 314 | const facts: RepoFacts = { |
| 315 | generatedAt: new Date().toISOString(), |
| 316 | sourceRevision: source.revision, |
| 317 | sourceCommittedAt: source.committedAt, |
| 318 | version: deriveVersion(cargo), |
| 319 | crates: deriveCrates(cargo), |
| 320 | sandboxBackends: sandboxSource |
| 321 | ? deriveSandboxBackends(sandboxSource) |
| 322 | : BUILD_FACTS.sandboxBackends, |
| 323 | providers: deriveProvidersFromConfig(configRs), |
| 324 | models, |
| 325 | defaultModel: deriveDefaultModel(`${configRs}\n${configModels ?? ""}`), |
| 326 | nodeEngines: (() => { |
| 327 | try { return npmPkg ? JSON.parse(npmPkg).engines?.node ?? null : null; } catch { return null; } |
| 328 | })(), |
| 329 | toolCount, |
| 330 | license: licText ? deriveLicense(licText) : BUILD_FACTS.license, |
| 331 | latestPublishedRelease: |
| 332 | latestPublishedRelease ?? BUILD_FACTS.latestPublishedRelease, |
| 333 | }; |
| 334 | |
| 335 | if (!facts.version || facts.crates.length === 0 || facts.providers.length === 0) { |
| 336 | return null; |
| 337 | } |
| 338 | return facts; |
| 339 | } |
| 340 | |
| 341 | interface DriftDiff { |
| 342 | field: keyof RepoFacts; |
| 343 | before: unknown; |
| 344 | after: unknown; |
| 345 | } |
| 346 | |
| 347 | function diff(a: RepoFacts, b: RepoFacts): DriftDiff[] { |
| 348 | const fields: (keyof RepoFacts)[] = [ |
| 349 | "sourceRevision", |
| 350 | "sourceCommittedAt", |
| 351 | "version", |
| 352 | "crates", |
| 353 | "sandboxBackends", |
| 354 | "providers", |
| 355 | "models", |
| 356 | "defaultModel", |
| 357 | "nodeEngines", |
| 358 | "toolCount", |
| 359 | "license", |
| 360 | "latestPublishedRelease", |
| 361 | ]; |
| 362 | const out: DriftDiff[] = []; |
| 363 | for (const f of fields) { |
| 364 | const av = JSON.stringify(a[f]); |
| 365 | const bv = JSON.stringify(b[f]); |
| 366 | if (av !== bv) out.push({ field: f, before: a[f], after: b[f] }); |
| 367 | } |
| 368 | return out; |
| 369 | } |
| 370 | |
| 371 | export interface FactsDriftResult { |
| 372 | ok: boolean; |
| 373 | changed?: boolean; |
| 374 | diffs?: DriftDiff[]; |
| 375 | reason?: string; |
| 376 | } |
| 377 | |
| 378 | export async function runFactsDrift(env: { CURATED_KV?: KVNamespace; GITHUB_TOKEN?: string }): Promise<FactsDriftResult> { |
| 379 | if (!env.CURATED_KV) return { ok: false, reason: "CURATED_KV not bound" }; |
| 380 | |
| 381 | const remote = await deriveFactsFromRemote(env.GITHUB_TOKEN); |
| 382 | if (!remote) return { ok: false, reason: "remote derivation failed" }; |
| 383 | |
| 384 | const cachedRaw = await env.CURATED_KV.get(KV_KEY); |
| 385 | let cached: RepoFacts = BUILD_FACTS; |
| 386 | if (cachedRaw) { |
| 387 | try { |
| 388 | const parsed = JSON.parse(cachedRaw) as unknown; |
| 389 | if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { |
| 390 | cached = parsed as RepoFacts; |
| 391 | } |
| 392 | } catch { |
| 393 | // A truncated or legacy cache is replaced by the newly derived snapshot. |
| 394 | } |
| 395 | } |
| 396 | |
| 397 | const diffs = diff(cached, remote); |
| 398 | if (diffs.length === 0) { |
| 399 | return { ok: true, changed: false }; |
| 400 | } |
| 401 | |
| 402 | // Write new facts. No TTL — they live until next drift overwrites them. |
| 403 | await env.CURATED_KV.put(KV_KEY, JSON.stringify(remote)); |
| 404 | |
| 405 | // Append to drift log (last 20 entries). |
| 406 | try { |
| 407 | const logRaw = await env.CURATED_KV.get(LOG_KEY); |
| 408 | const log = logRaw ? (JSON.parse(logRaw) as Array<{ at: string; diffs: DriftDiff[] }>) : []; |
| 409 | log.unshift({ at: remote.generatedAt, diffs }); |
| 410 | await env.CURATED_KV.put(LOG_KEY, JSON.stringify(log.slice(0, 20))); |
| 411 | } catch { |
| 412 | /* non-fatal */ |
| 413 | } |
| 414 | |
| 415 | return { ok: true, changed: true, diffs }; |
| 416 | } |
| 417 |