返回 CodeWhale
facts-drift.ts
根目录 / web / lib / facts-drift.ts
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 { parseModelCatalog } from "./model-catalog.mjs";
16 import { parseProviderDescriptors } from "./provider-descriptors.mjs";
17 import { fetchBoundedText } from "./bounded-body";
18 import type {
19 PublishedReleaseFact,
20 RepoFacts,
21 } from "./facts.generated";
22 import { FACTS as BUILD_FACTS } from "./facts.generated";
23 import { isRepoFacts } from "./facts";
24
25 const RAW_ROOT = "https://raw.githubusercontent.com/codewhale-hq/CodeWhale";
26 const RELEASE_TAG_ROOT = "https://github.com/codewhale-hq/CodeWhale/releases/tag";
27 const KV_KEY = "facts:current";
28 const LOG_KEY = "facts:drift-log";
29
30 interface KVNamespace {
31 get(k: string): Promise<string | null>;
32 put(k: string, v: string, o?: { expirationTtl?: number }): Promise<void>;
33 }
34
35 interface SourceMarker {
36 revision: string;
37 committedAt: string;
38 }
39
40 async function fetchText(
41 path: string,
42 revision: string,
43 ghToken?: string,
44 ): Promise<string | null> {
45 const headers: Record<string, string> = {
46 "User-Agent": "codewhale-web-drift",
47 };
48 if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
49 try {
50 const r = await fetchBoundedText(`${RAW_ROOT}/${revision}/${path}`, { headers });
51 return r.ok ? r.text : null;
52 } catch {
53 return null;
54 }
55 }
56
57 async function fetchSourceMarker(ghToken?: string): Promise<SourceMarker | null> {
58 const headers: Record<string, string> = {
59 Accept: "application/vnd.github+json",
60 "User-Agent": "codewhale-web-drift",
61 "X-GitHub-Api-Version": "2022-11-28",
62 };
63 if (ghToken) headers.Authorization = `Bearer ${ghToken}`;
64 try {
65 const response = await fetchBoundedText(
66 "https://api.github.com/repos/codewhale-hq/CodeWhale/commits/main",
67 { headers },
68 );
69 if (!response.ok) return null;
70 const json = JSON.parse(response.text) as {
71 sha?: string;
72 commit?: { committer?: { date?: string } };
73 };
74 const revision = json.sha;
75 const committedAt = json.commit?.committer?.date;
76 if (
77 !revision ||
78 !/^[0-9a-f]{40}$/i.test(revision) ||
79 !committedAt ||
80 !Number.isFinite(Date.parse(committedAt))
81 ) {
82 return null;
83 }
84 return { revision, committedAt };
85 } catch {
86 return null;
87 }
88 }
89
90 function deriveVersion(cargo: string): string | null {
91 const m = cargo.match(/^version\s*=\s*"([^"]+)"/m);
92 return m ? m[1] : null;
93 }
94
95 function deriveCrates(cargo: string): string[] {
96 const block = cargo.match(/members\s*=\s*\[([\s\S]*?)\]/);
97 if (!block) return [];
98 return [...block[1].matchAll(/"crates\/([^"]+)"/g)].map((m) => m[1]).sort();
99 }
100
101 function deriveSandboxBackends(source: string): string[] {
102 const marker = source.match(
103 /pub const PUBLIC_SANDBOX_BACKENDS\s*:\s*&\[&str\]\s*=\s*&\[([\s\S]*?)\];/,
104 );
105 if (!marker) return [];
106 return [...marker[1].matchAll(/"([^"]+)"/g)].map((match) => match[1]);
107 }
108
109 async function fetchLatestPublishedRelease(
110 ghToken?: string,
111 ): Promise<PublishedReleaseFact | null> {
112 const headers: Record<string, string> = {
113 Accept: "application/vnd.github+json",
114 "User-Agent": "codewhale-web-drift",
115 "X-GitHub-Api-Version": "2022-11-28",
116 };
117 if (ghToken) headers["Authorization"] = `Bearer ${ghToken}`;
118 try {
119 const r = await fetchBoundedText("https://api.github.com/repos/codewhale-hq/CodeWhale/releases/latest", { headers });
120 if (!r.ok) return null;
121 const j = JSON.parse(r.text) as {
122 tag_name?: string;
123 published_at?: string;
124 };
125 if (
126 !j.tag_name ||
127 !/^v\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?$/.test(j.tag_name) ||
128 !j.published_at ||
129 !Number.isFinite(Date.parse(j.published_at))
130 ) {
131 return null;
132 }
133 return {
134 tag: j.tag_name,
135 version: j.tag_name.slice(1),
136 publishedAt: j.published_at,
137 // Built from the tag, not `html_url`: GitHub answers with the repo's
138 // canonical casing (`codewhale-hq/Codewhale`), which the exact-URL check in
139 // isRepoFacts rejects, invalidating the whole KV snapshot.
140 url: `${RELEASE_TAG_ROOT}/${j.tag_name}`,
141 };
142 } catch {
143 return null;
144 }
145 }
146
147 function deriveLicense(licText: string): string | null {
148 const first = licText.split(/\r?\n/).find((l) => l.trim().length > 0);
149 if (!first) return null;
150 if (/^MIT License/i.test(first)) return "MIT";
151 if (/Apache.*2\.0/i.test(first)) return "Apache-2.0";
152 return first.trim();
153 }
154
155 function parseGeneratedFacts(source: string): Record<string, unknown> | null {
156 const match = source.match(
157 /export\s+const\s+FACTS(?:\s*:\s*RepoFacts)?\s*=\s*(\{[\s\S]*\})\s*;?\s*$/,
158 );
159 if (!match) return null;
160
161 try {
162 const parsed = JSON.parse(match[1]) as unknown;
163 return parsed && typeof parsed === "object" && !Array.isArray(parsed)
164 ? (parsed as Record<string, unknown>)
165 : null;
166 } catch {
167 return null;
168 }
169 }
170
171 function deriveToolCountFromGeneratedFacts(source: string): number | null {
172 const toolCount = parseGeneratedFacts(source)?.toolCount;
173 return typeof toolCount === "number" && Number.isSafeInteger(toolCount) && toolCount >= 0
174 ? toolCount
175 : null;
176 }
177
178 /**
179 * Model presentation comes from the exact revision's reviewed catalog;
180 * tool counts retain the existing generated-source boundary.
181 */
182 export async function deriveFactsFromRemote(ghToken?: string): Promise<RepoFacts | null> {
183 const source = await fetchSourceMarker(ghToken);
184 if (!source) return null;
185
186 const [cargo, providerMetadata, sandboxSource, npmPkg, licText, generatedFacts, modelCatalog, latestPublishedRelease] = await Promise.all([
187 fetchText("Cargo.toml", source.revision, ghToken),
188 fetchText("crates/config/assets/provider_descriptors.json", source.revision, ghToken),
189 fetchText("crates/tui/src/sandbox/mod.rs", source.revision, ghToken),
190 fetchText("npm/codewhale/package.json", source.revision, ghToken),
191 fetchText("LICENSE", source.revision, ghToken),
192 fetchText("web/lib/facts.generated.ts", source.revision, ghToken),
193 fetchText("crates/config/assets/models_dev.bundled.json", source.revision, ghToken),
194 fetchLatestPublishedRelease(ghToken),
195 ]);
196
197 if (!cargo) return null;
198 const providerData = parseProviderDescriptors(providerMetadata);
199 if (!providerData) return null;
200 const toolCount = generatedFacts
201 ? deriveToolCountFromGeneratedFacts(generatedFacts)
202 : null;
203 const models = parseModelCatalog(modelCatalog);
204 // Never attach current-main provenance to build-time tool/model facts. The
205 // checked-in generated snapshot is guarded by the exact revision's CI drift
206 // check, so an absent or malformed value makes the whole derivation fail.
207 if (toolCount === null || models === null) return null;
208
209 const facts: RepoFacts = {
210 generatedAt: new Date().toISOString(),
211 sourceRevision: source.revision,
212 sourceCommittedAt: source.committedAt,
213 version: deriveVersion(cargo),
214 crates: deriveCrates(cargo),
215 sandboxBackends: sandboxSource
216 ? deriveSandboxBackends(sandboxSource)
217 : BUILD_FACTS.sandboxBackends,
218 providers: providerData.providers,
219 models,
220 defaultModel: providerData.defaultModel,
221 nodeEngines: (() => {
222 try { return npmPkg ? JSON.parse(npmPkg).engines?.node ?? null : null; } catch { return null; }
223 })(),
224 toolCount,
225 license: licText ? deriveLicense(licText) : BUILD_FACTS.license,
226 latestPublishedRelease:
227 latestPublishedRelease ?? BUILD_FACTS.latestPublishedRelease,
228 };
229
230 if (!facts.version || facts.crates.length === 0 || facts.providers.length === 0) {
231 return null;
232 }
233 return facts;
234 }
235
236 interface DriftDiff {
237 field: keyof RepoFacts;
238 before: unknown;
239 after: unknown;
240 }
241
242 function diff(a: RepoFacts, b: RepoFacts): DriftDiff[] {
243 const fields: (keyof RepoFacts)[] = [
244 "sourceRevision",
245 "sourceCommittedAt",
246 "version",
247 "crates",
248 "sandboxBackends",
249 "providers",
250 "models",
251 "defaultModel",
252 "nodeEngines",
253 "toolCount",
254 "license",
255 "latestPublishedRelease",
256 ];
257 const out: DriftDiff[] = [];
258 for (const f of fields) {
259 const av = JSON.stringify(a[f]);
260 const bv = JSON.stringify(b[f]);
261 if (av !== bv) out.push({ field: f, before: a[f], after: b[f] });
262 }
263 return out;
264 }
265
266 export interface FactsDriftResult {
267 ok: boolean;
268 changed?: boolean;
269 diffs?: DriftDiff[];
270 reason?: string;
271 }
272
273 export async function runFactsDrift(env: { CURATED_KV?: KVNamespace; GITHUB_TOKEN?: string }): Promise<FactsDriftResult> {
274 if (!env.CURATED_KV) return { ok: false, reason: "CURATED_KV not bound" };
275
276 const remote = await deriveFactsFromRemote(env.GITHUB_TOKEN);
277 if (!remote) return { ok: false, reason: "remote derivation failed" };
278 // getFacts() discards a snapshot isRepoFacts rejects, so never store one.
279 // The scheduled handler drops this result, so say it here: otherwise the
280 // cron stops refreshing KV with no signal at all.
281 const { sourceRevision, sourceCommittedAt, version } = remote;
282 if (!isRepoFacts(remote)) {
283 const reason = "remote facts failed validation";
284 console.warn(
285 `[facts-drift] ${reason}; KV snapshot not refreshed ` +
286 `(sourceRevision=${String(sourceRevision)}, ` +
287 `sourceCommittedAt=${String(sourceCommittedAt)}, version=${String(version)})`,
288 );
289 return { ok: false, reason };
290 }
291
292 const cachedRaw = await env.CURATED_KV.get(KV_KEY);
293 let cached: RepoFacts = BUILD_FACTS;
294 if (cachedRaw) {
295 try {
296 const parsed = JSON.parse(cachedRaw) as unknown;
297 if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
298 cached = parsed as RepoFacts;
299 }
300 } catch {
301 // A truncated or legacy cache is replaced by the newly derived snapshot.
302 }
303 }
304
305 // Overlapping runs can finish out of order; a snapshot from an older source
306 // commit never replaces a newer one (KV has no compare-and-set, so this
307 // narrows the race to the read-to-write window rather than closing it).
308 const cachedAt = Date.parse(String(cached.sourceCommittedAt ?? ""));
309 if (cachedRaw && Number.isFinite(cachedAt) && cachedAt > Date.parse(String(remote.sourceCommittedAt))) {
310 return { ok: true, changed: false };
311 }
312
313 const diffs = diff(cached, remote);
314 if (diffs.length === 0) {
315 return { ok: true, changed: false };
316 }
317
318 // Write new facts. No TTL — they live until next drift overwrites them.
319 await env.CURATED_KV.put(KV_KEY, JSON.stringify(remote));
320
321 // Append to drift log (last 20 entries).
322 try {
323 const logRaw = await env.CURATED_KV.get(LOG_KEY);
324 const log = logRaw ? (JSON.parse(logRaw) as Array<{ at: string; diffs: DriftDiff[] }>) : [];
325 log.unshift({ at: remote.generatedAt, diffs });
326 await env.CURATED_KV.put(LOG_KEY, JSON.stringify(log.slice(0, 20)));
327 } catch {
328 /* non-fatal */
329 }
330
331 return { ok: true, changed: true, diffs };
332 }
333
333 lines TYPESCRIPT