Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

repair-flywheel.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

repair-flywheel.tsBlame380 lines · 1 contributor
e6bad81Claude1/**
2 * Repair Flywheel — the learning loop that makes auto-repair faster + cheaper
3 * with every successful fix.
4 *
5 * ┌─────────── Tier 0: cache hit ────────┐
6 * │ query repair_flywheel by signature │ ← THIS module
7 * │ → reuse the patch that worked │
8 * └──────────┬───────────────────────────┘
9 * │ miss
10 * ▼
11 * ┌─────────── Tier 1: mechanical ───────┐
12 * │ bun install / biome / prettier │
13 * └──────────┬───────────────────────────┘
14 * │ no match / failed
15 * ▼
16 * ┌─────────── Tier 2: Claude Sonnet ────┐
17 * │ AI-generated patches │
18 * └──────────┬───────────────────────────┘
19 * │
20 * ▼
21 * record outcome → flywheel
22 *
23 * After ~5000 entries the cache dominates: most failures hit a known pattern
24 * and get fixed without an AI call. That's the moat — the longer gluecron
25 * runs, the more the platform learns about real-world failures.
26 *
27 * Storage: postgres `repair_flywheel` (drizzle migration 0039).
28 *
29 * Privacy: per-repo by default. `is_public_pattern=true` opts into cross-repo
30 * learning (off by default). Owners can flip the flag per-repo or per-pattern
31 * via /admin/repair-flywheel.
32 */
33
34import { createHash } from "crypto";
35import { and, desc, eq, sql, isNotNull, or } from "drizzle-orm";
36import { db } from "../db";
37import { repairFlywheel } from "../db/schema";
38
39export type RepairTier = "cached" | "mechanical" | "ai-sonnet" | "human";
40export type RepairOutcome = "pending" | "success" | "failed" | "reverted";
41
42export interface CachedRepair {
43 id: string;
44 patchSummary: string;
45 filesChanged: string[];
46 commitSha: string | null;
47 hitCount: number;
48 successRate: number; // 0..1
49 classification: string | null;
50 appliedCount: number;
51}
52
53// ─────────────────────────────────────────────────────────────────────────
54// Fingerprinting — turn any failure text into a stable signature.
55// ─────────────────────────────────────────────────────────────────────────
56
57/**
58 * Normalise a failure message so two semantically-identical failures collapse
59 * to the same hash. Strips:
60 * - variable line numbers / column numbers (`:42:13`)
61 * - timestamps (any ISO-8601 fragment, any 12-char hex SHA, any UUID)
62 * - absolute paths (`/tmp/...`, `/home/...`, `/opt/...`)
63 * - quoted strings (often contain user data)
64 * - terminal escape sequences
65 * - extra whitespace
66 *
67 * Trade-off: this is intentionally aggressive. Two failures with the same
68 * normalised form ARE considered the same problem. If we get false-collisions
69 * later we narrow the rules. Erring on "match more, share more" wins for the
70 * flywheel's learning.
71 */
72export function normaliseFailure(text: string): string {
73 return text
74 .replace(/\x1b\[[0-9;]*m/g, "") // ANSI colour codes
75 .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\d.+:Z-]*\b/g, "<TS>")
76 .replace(
77 /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
78 "<UUID>",
79 )
80 .replace(/\b[0-9a-f]{40}\b/gi, "<SHA1>")
81 .replace(/\b[0-9a-f]{64}\b/gi, "<SHA256>")
82 .replace(/\b[0-9a-f]{12,16}\b/gi, "<HEX>")
83 .replace(/\/(?:tmp|home|var|opt|root|usr)[^\s'":,\)\]]+/g, "<PATH>")
84 .replace(/[A-Z]:\\[^\s'":,\)\]]+/g, "<PATH>")
85 .replace(/:\d+(:\d+)?\b/g, ":<L>")
86 .replace(/'[^']*'/g, "'<S>'")
87 .replace(/"[^"]*"/g, '"<S>"')
88 .replace(/\s+/g, " ")
89 .trim()
90 .toLowerCase();
91}
92
93/** SHA-256 of the normalised failure text. 32 hex chars used as the signature. */
94export function fingerprint(failureText: string): string {
95 const norm = normaliseFailure(failureText);
96 return createHash("sha256").update(norm).digest("hex").slice(0, 32);
97}
98
99// ─────────────────────────────────────────────────────────────────────────
100// Cache lookup — find a successful past repair for this failure
101// ─────────────────────────────────────────────────────────────────────────
102
103/**
104 * Find the best cached repair for a given failure signature.
105 *
106 * Lookup priority:
107 * 1. Same repo + same signature + outcome=success — strongest signal
108 * 2. Cross-repo with is_public_pattern=true + outcome=success — fallback
109 *
110 * Returns null if no usable cache entry exists.
111 */
112export async function findCachedRepair(
113 repositoryId: string,
114 failureText: string,
115): Promise<CachedRepair | null> {
116 const sig = fingerprint(failureText);
117
118 // First try: same repo, same signature, prefer the most recent success
119 const sameRepo = await db
120 .select()
121 .from(repairFlywheel)
122 .where(
123 and(
124 eq(repairFlywheel.repositoryId, repositoryId),
125 eq(repairFlywheel.failureSignature, sig),
126 eq(repairFlywheel.outcome, "success"),
127 ),
128 )
129 .orderBy(desc(repairFlywheel.appliedAt))
130 .limit(1);
131
132 if (sameRepo.length > 0) {
133 return await hydrate(sameRepo[0]!);
134 }
135
136 // Fallback: any public pattern with this signature
137 const cross = await db
138 .select()
139 .from(repairFlywheel)
140 .where(
141 and(
142 eq(repairFlywheel.failureSignature, sig),
143 eq(repairFlywheel.outcome, "success"),
144 eq(repairFlywheel.isPublicPattern, true),
145 ),
146 )
147 .orderBy(desc(repairFlywheel.cacheHitCount))
148 .limit(1);
149
150 if (cross.length > 0) {
151 return await hydrate(cross[0]!);
152 }
153
154 return null;
155}
156
157async function hydrate(
158 row: typeof repairFlywheel.$inferSelect,
159): Promise<CachedRepair> {
160 // Confidence: count successes vs failures across all entries that share
161 // this signature (or its cache lineage). Quick computation, sub-ms in
162 // typical use; we'll cache later if this becomes hot.
163 const stats = await db
164 .select({
165 total: sql<number>`count(*)::int`,
166 successes: sql<number>`sum(case when outcome = 'success' then 1 else 0 end)::int`,
167 })
168 .from(repairFlywheel)
169 .where(
170 or(
171 eq(repairFlywheel.failureSignature, row.failureSignature),
172 eq(repairFlywheel.parentPatternId, row.id),
173 ),
174 );
175
176 const total = Number(stats[0]?.total ?? 0);
177 const successes = Number(stats[0]?.successes ?? 0);
178 const successRate = total > 0 ? successes / total : 0;
179
180 return {
181 id: row.id,
182 patchSummary: row.patchSummary,
183 filesChanged: (row.filesChanged as string[]) ?? [],
184 commitSha: row.commitSha,
185 hitCount: row.cacheHitCount,
186 successRate,
187 classification: row.failureClassification,
188 appliedCount: total,
189 };
190}
191
192// ─────────────────────────────────────────────────────────────────────────
193// Recording — write outcomes back to the flywheel
194// ─────────────────────────────────────────────────────────────────────────
195
196export interface RecordRepairInput {
197 repositoryId: string | null;
198 failureText: string;
199 classification: string | null;
200 tier: RepairTier;
201 patchSummary: string;
202 filesChanged: string[];
203 commitSha: string | null;
204 parentPatternId?: string | null;
205 outcome?: RepairOutcome; // defaults to 'pending'
206 isPublicPattern?: boolean;
207}
208
209/**
210 * Append a row to the flywheel. Returns the new entry's id so the caller
211 * can later updateOutcome() once the smoke test settles.
212 *
213 * Caps failureText at ~4KB and patchSummary at 400 chars at the write-site.
214 */
215export async function recordRepair(input: RecordRepairInput): Promise<string> {
216 const sig = fingerprint(input.failureText);
217 const failureText = input.failureText.slice(0, 4096);
218 const patchSummary = input.patchSummary.slice(0, 400);
219
220 const [row] = await db
221 .insert(repairFlywheel)
222 .values({
223 repositoryId: input.repositoryId,
224 failureSignature: sig,
225 failureText,
226 failureClassification: input.classification,
227 repairTier: input.tier,
228 patchSummary,
229 filesChanged: input.filesChanged,
230 commitSha: input.commitSha,
231 outcome: input.outcome ?? "pending",
232 parentPatternId: input.parentPatternId ?? null,
233 isPublicPattern: input.isPublicPattern ?? false,
234 })
235 .returning({ id: repairFlywheel.id });
236
237 // If this was a cache hit (Tier 0), bump the parent pattern's hit count.
238 if (input.parentPatternId) {
239 await db
240 .update(repairFlywheel)
241 .set({ cacheHitCount: sql`${repairFlywheel.cacheHitCount} + 1` })
242 .where(eq(repairFlywheel.id, input.parentPatternId));
243 }
244
245 return row!.id;
246}
247
248/**
249 * Update the outcome of a previously-recorded repair. Called after the
250 * smoke test or when a human reverts a repair commit.
251 */
252export async function updateOutcome(
253 id: string,
254 outcome: Exclude<RepairOutcome, "pending">,
255): Promise<void> {
256 await db
257 .update(repairFlywheel)
258 .set({ outcome, outcomeAt: new Date() })
259 .where(eq(repairFlywheel.id, id));
260}
261
262// ─────────────────────────────────────────────────────────────────────────
263// Stats — for /admin/repair-flywheel dashboard
264// ─────────────────────────────────────────────────────────────────────────
265
266export interface FlywheelStats {
267 totalRepairs: number;
268 byTier: Record<RepairTier, number>;
269 byClassification: Record<string, number>;
270 successRate: number;
271 cacheHitRate: number;
272 estimatedAiCallsSaved: number;
273 topPatterns: Array<{
274 signature: string;
275 classification: string | null;
276 hitCount: number;
277 summary: string;
278 }>;
279}
280
281/**
282 * Summary stats across the entire flywheel (or scoped to a single repo).
283 * Used by the admin dashboard. Numbers are computed live; cheap enough that
284 * we don't need a materialised view yet.
285 */
286export async function getFlywheelStats(
287 repositoryId?: string,
288): Promise<FlywheelStats> {
289 const repoFilter = repositoryId
290 ? eq(repairFlywheel.repositoryId, repositoryId)
291 : undefined;
292
293 // Total + per-tier counts in one round trip
294 const tierBreakdown = await db
295 .select({
296 tier: repairFlywheel.repairTier,
297 n: sql<number>`count(*)::int`,
298 })
299 .from(repairFlywheel)
300 .where(repoFilter)
301 .groupBy(repairFlywheel.repairTier);
302
303 const byTier: Record<RepairTier, number> = {
304 cached: 0,
305 mechanical: 0,
306 "ai-sonnet": 0,
307 human: 0,
308 };
309 let total = 0;
310 for (const r of tierBreakdown) {
311 byTier[r.tier as RepairTier] = Number(r.n);
312 total += Number(r.n);
313 }
314
315 // Per-classification counts
316 const classBreakdown = await db
317 .select({
318 cls: repairFlywheel.failureClassification,
319 n: sql<number>`count(*)::int`,
320 })
321 .from(repairFlywheel)
322 .where(repoFilter)
323 .groupBy(repairFlywheel.failureClassification);
324
325 const byClassification: Record<string, number> = {};
326 for (const r of classBreakdown) {
327 byClassification[r.cls ?? "ai-patch"] = Number(r.n);
328 }
329
330 // Success rate
331 const okCount = await db
332 .select({ n: sql<number>`count(*)::int` })
333 .from(repairFlywheel)
334 .where(
335 repoFilter
336 ? and(repoFilter, eq(repairFlywheel.outcome, "success"))
337 : eq(repairFlywheel.outcome, "success"),
338 );
339 const successes = Number(okCount[0]?.n ?? 0);
340 const successRate = total > 0 ? successes / total : 0;
341
342 // Cache hit rate — % of repairs that came from Tier 0
343 const cacheHitRate = total > 0 ? byTier.cached / total : 0;
344
345 // Estimated AI calls saved: each cached + mechanical repair would have
346 // been an AI call in the old world.
347 const estimatedAiCallsSaved = byTier.cached + byTier.mechanical;
348
349 // Top reused patterns (most cache hits)
350 const topPatterns = await db
351 .select({
352 signature: repairFlywheel.failureSignature,
353 classification: repairFlywheel.failureClassification,
354 hitCount: repairFlywheel.cacheHitCount,
355 summary: repairFlywheel.patchSummary,
356 })
357 .from(repairFlywheel)
358 .where(
359 repoFilter
360 ? and(repoFilter, sql`${repairFlywheel.cacheHitCount} > 0`)
361 : sql`${repairFlywheel.cacheHitCount} > 0`,
362 )
363 .orderBy(desc(repairFlywheel.cacheHitCount))
364 .limit(10);
365
366 return {
367 totalRepairs: total,
368 byTier,
369 byClassification,
370 successRate,
371 cacheHitRate,
372 estimatedAiCallsSaved,
373 topPatterns: topPatterns.map((r) => ({
374 signature: r.signature,
375 classification: r.classification,
376 hitCount: r.hitCount,
377 summary: r.summary,
378 })),
379 };
380}