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.tsBlame420 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";
bc519aeClaude35import { and, desc, eq, ne, sql, isNotNull, or } from "drizzle-orm";
e6bad81Claude36import { 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;
bc519aeClaude45 /** Full unified-diff patch (migration 0105). Null on pre-0105 rows — those
46 * entries can't be replayed and callers must fall through to the AI tier. */
47 patch: string | null;
e6bad81Claude48 filesChanged: string[];
49 commitSha: string | null;
50 hitCount: number;
51 successRate: number; // 0..1
52 classification: string | null;
53 appliedCount: number;
54}
55
bc519aeClaude56/** Cap on the stored full patch — anything bigger isn't worth replaying. */
57const MAX_PATCH_CHARS = 64 * 1024;
58
59// The full-patch column postdates the locked schema.ts (migration 0105), so
60// it isn't on the drizzle table object — read it with a raw fragment. If the
61// migration hasn't run yet the query throws; callers treat that as a miss.
62const patchColumn = sql<string | null>`${repairFlywheel}."patch"`;
63
e6bad81Claude64// ─────────────────────────────────────────────────────────────────────────
65// Fingerprinting — turn any failure text into a stable signature.
66// ─────────────────────────────────────────────────────────────────────────
67
68/**
69 * Normalise a failure message so two semantically-identical failures collapse
70 * to the same hash. Strips:
71 * - variable line numbers / column numbers (`:42:13`)
72 * - timestamps (any ISO-8601 fragment, any 12-char hex SHA, any UUID)
73 * - absolute paths (`/tmp/...`, `/home/...`, `/opt/...`)
74 * - quoted strings (often contain user data)
75 * - terminal escape sequences
76 * - extra whitespace
77 *
78 * Trade-off: this is intentionally aggressive. Two failures with the same
79 * normalised form ARE considered the same problem. If we get false-collisions
80 * later we narrow the rules. Erring on "match more, share more" wins for the
81 * flywheel's learning.
82 */
83export function normaliseFailure(text: string): string {
84 return text
85 .replace(/\x1b\[[0-9;]*m/g, "") // ANSI colour codes
86 .replace(/\b\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}[\d.+:Z-]*\b/g, "<TS>")
87 .replace(
88 /\b[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\b/gi,
89 "<UUID>",
90 )
91 .replace(/\b[0-9a-f]{40}\b/gi, "<SHA1>")
92 .replace(/\b[0-9a-f]{64}\b/gi, "<SHA256>")
93 .replace(/\b[0-9a-f]{12,16}\b/gi, "<HEX>")
94 .replace(/\/(?:tmp|home|var|opt|root|usr)[^\s'":,\)\]]+/g, "<PATH>")
95 .replace(/[A-Z]:\\[^\s'":,\)\]]+/g, "<PATH>")
96 .replace(/:\d+(:\d+)?\b/g, ":<L>")
97 .replace(/'[^']*'/g, "'<S>'")
98 .replace(/"[^"]*"/g, '"<S>"')
99 .replace(/\s+/g, " ")
100 .trim()
101 .toLowerCase();
102}
103
104/** SHA-256 of the normalised failure text. 32 hex chars used as the signature. */
105export function fingerprint(failureText: string): string {
106 const norm = normaliseFailure(failureText);
107 return createHash("sha256").update(norm).digest("hex").slice(0, 32);
108}
109
110// ─────────────────────────────────────────────────────────────────────────
111// Cache lookup — find a successful past repair for this failure
112// ─────────────────────────────────────────────────────────────────────────
113
114/**
115 * Find the best cached repair for a given failure signature.
116 *
117 * Lookup priority:
118 * 1. Same repo + same signature + outcome=success — strongest signal
119 * 2. Cross-repo with is_public_pattern=true + outcome=success — fallback
120 *
121 * Returns null if no usable cache entry exists.
122 */
123export async function findCachedRepair(
124 repositoryId: string,
125 failureText: string,
126): Promise<CachedRepair | null> {
127 const sig = fingerprint(failureText);
128
129 // First try: same repo, same signature, prefer the most recent success
130 const sameRepo = await db
bc519aeClaude131 .select({ row: repairFlywheel, patch: patchColumn })
e6bad81Claude132 .from(repairFlywheel)
133 .where(
134 and(
135 eq(repairFlywheel.repositoryId, repositoryId),
136 eq(repairFlywheel.failureSignature, sig),
137 eq(repairFlywheel.outcome, "success"),
138 ),
139 )
140 .orderBy(desc(repairFlywheel.appliedAt))
141 .limit(1);
142
143 if (sameRepo.length > 0) {
bc519aeClaude144 return await hydrate(sameRepo[0]!.row, sameRepo[0]!.patch);
e6bad81Claude145 }
146
147 // Fallback: any public pattern with this signature
148 const cross = await db
bc519aeClaude149 .select({ row: repairFlywheel, patch: patchColumn })
e6bad81Claude150 .from(repairFlywheel)
151 .where(
152 and(
153 eq(repairFlywheel.failureSignature, sig),
154 eq(repairFlywheel.outcome, "success"),
155 eq(repairFlywheel.isPublicPattern, true),
156 ),
157 )
158 .orderBy(desc(repairFlywheel.cacheHitCount))
159 .limit(1);
160
161 if (cross.length > 0) {
bc519aeClaude162 return await hydrate(cross[0]!.row, cross[0]!.patch);
e6bad81Claude163 }
164
165 return null;
166}
167
168async function hydrate(
169 row: typeof repairFlywheel.$inferSelect,
bc519aeClaude170 patch: string | null,
e6bad81Claude171): Promise<CachedRepair> {
172 // Confidence: count successes vs failures across all entries that share
173 // this signature (or its cache lineage). Quick computation, sub-ms in
bc519aeClaude174 // typical use; we'll cache later if this becomes hot. Pending rows are
175 // excluded — in-flight repairs haven't settled, and counting them as
176 // non-successes would let queued attempts depress a good pattern's score.
e6bad81Claude177 const stats = await db
178 .select({
179 total: sql<number>`count(*)::int`,
180 successes: sql<number>`sum(case when outcome = 'success' then 1 else 0 end)::int`,
181 })
182 .from(repairFlywheel)
183 .where(
bc519aeClaude184 and(
185 or(
186 eq(repairFlywheel.failureSignature, row.failureSignature),
187 eq(repairFlywheel.parentPatternId, row.id),
188 ),
189 ne(repairFlywheel.outcome, "pending"),
e6bad81Claude190 ),
191 );
192
193 const total = Number(stats[0]?.total ?? 0);
194 const successes = Number(stats[0]?.successes ?? 0);
195 const successRate = total > 0 ? successes / total : 0;
196
197 return {
198 id: row.id,
199 patchSummary: row.patchSummary,
bc519aeClaude200 patch,
e6bad81Claude201 filesChanged: (row.filesChanged as string[]) ?? [],
202 commitSha: row.commitSha,
203 hitCount: row.cacheHitCount,
204 successRate,
205 classification: row.failureClassification,
206 appliedCount: total,
207 };
208}
209
210// ─────────────────────────────────────────────────────────────────────────
211// Recording — write outcomes back to the flywheel
212// ─────────────────────────────────────────────────────────────────────────
213
214export interface RecordRepairInput {
215 repositoryId: string | null;
216 failureText: string;
217 classification: string | null;
218 tier: RepairTier;
219 patchSummary: string;
bc519aeClaude220 /** Full unified-diff patch so Tier-0 cache hits can replay it. Optional —
221 * mechanical repairs have no diff to store. Capped at ~64KB. */
222 patch?: string | null;
e6bad81Claude223 filesChanged: string[];
224 commitSha: string | null;
225 parentPatternId?: string | null;
226 outcome?: RepairOutcome; // defaults to 'pending'
227 isPublicPattern?: boolean;
228}
229
230/**
231 * Append a row to the flywheel. Returns the new entry's id so the caller
232 * can later updateOutcome() once the smoke test settles.
233 *
234 * Caps failureText at ~4KB and patchSummary at 400 chars at the write-site.
235 */
236export async function recordRepair(input: RecordRepairInput): Promise<string> {
237 const sig = fingerprint(input.failureText);
238 const failureText = input.failureText.slice(0, 4096);
239 const patchSummary = input.patchSummary.slice(0, 400);
240
241 const [row] = await db
242 .insert(repairFlywheel)
243 .values({
244 repositoryId: input.repositoryId,
245 failureSignature: sig,
246 failureText,
247 failureClassification: input.classification,
248 repairTier: input.tier,
249 patchSummary,
250 filesChanged: input.filesChanged,
251 commitSha: input.commitSha,
252 outcome: input.outcome ?? "pending",
253 parentPatternId: input.parentPatternId ?? null,
254 isPublicPattern: input.isPublicPattern ?? false,
255 })
256 .returning({ id: repairFlywheel.id });
257
bc519aeClaude258 // The full patch goes in via raw UPDATE: the "patch" column (migration
259 // 0105) postdates the locked schema.ts so it can't ride the drizzle
260 // insert above. Best-effort — if it fails (e.g. migration not yet
261 // applied) the audit row is still intact, the entry just can't be
262 // replayed by the Tier-0 cache.
263 if (input.patch) {
264 const patch = input.patch.slice(0, MAX_PATCH_CHARS);
265 try {
266 await db.execute(
267 sql`update "repair_flywheel" set "patch" = ${patch} where "id" = ${row!.id}`,
268 );
269 } catch (err) {
270 console.warn(
271 "[repair-flywheel] patch write failed (is migration 0105 applied?):",
272 err instanceof Error ? err.message : err,
273 );
274 }
275 }
276
e6bad81Claude277 // If this was a cache hit (Tier 0), bump the parent pattern's hit count.
278 if (input.parentPatternId) {
279 await db
280 .update(repairFlywheel)
281 .set({ cacheHitCount: sql`${repairFlywheel.cacheHitCount} + 1` })
282 .where(eq(repairFlywheel.id, input.parentPatternId));
283 }
284
285 return row!.id;
286}
287
288/**
289 * Update the outcome of a previously-recorded repair. Called after the
290 * smoke test or when a human reverts a repair commit.
291 */
292export async function updateOutcome(
293 id: string,
294 outcome: Exclude<RepairOutcome, "pending">,
295): Promise<void> {
296 await db
297 .update(repairFlywheel)
298 .set({ outcome, outcomeAt: new Date() })
299 .where(eq(repairFlywheel.id, id));
300}
301
302// ─────────────────────────────────────────────────────────────────────────
303// Stats — for /admin/repair-flywheel dashboard
304// ─────────────────────────────────────────────────────────────────────────
305
306export interface FlywheelStats {
307 totalRepairs: number;
308 byTier: Record<RepairTier, number>;
309 byClassification: Record<string, number>;
310 successRate: number;
311 cacheHitRate: number;
312 estimatedAiCallsSaved: number;
313 topPatterns: Array<{
314 signature: string;
315 classification: string | null;
316 hitCount: number;
317 summary: string;
318 }>;
319}
320
321/**
322 * Summary stats across the entire flywheel (or scoped to a single repo).
323 * Used by the admin dashboard. Numbers are computed live; cheap enough that
324 * we don't need a materialised view yet.
325 */
326export async function getFlywheelStats(
327 repositoryId?: string,
328): Promise<FlywheelStats> {
329 const repoFilter = repositoryId
330 ? eq(repairFlywheel.repositoryId, repositoryId)
331 : undefined;
332
333 // Total + per-tier counts in one round trip
334 const tierBreakdown = await db
335 .select({
336 tier: repairFlywheel.repairTier,
337 n: sql<number>`count(*)::int`,
338 })
339 .from(repairFlywheel)
340 .where(repoFilter)
341 .groupBy(repairFlywheel.repairTier);
342
343 const byTier: Record<RepairTier, number> = {
344 cached: 0,
345 mechanical: 0,
346 "ai-sonnet": 0,
347 human: 0,
348 };
349 let total = 0;
350 for (const r of tierBreakdown) {
351 byTier[r.tier as RepairTier] = Number(r.n);
352 total += Number(r.n);
353 }
354
355 // Per-classification counts
356 const classBreakdown = await db
357 .select({
358 cls: repairFlywheel.failureClassification,
359 n: sql<number>`count(*)::int`,
360 })
361 .from(repairFlywheel)
362 .where(repoFilter)
363 .groupBy(repairFlywheel.failureClassification);
364
365 const byClassification: Record<string, number> = {};
366 for (const r of classBreakdown) {
367 byClassification[r.cls ?? "ai-patch"] = Number(r.n);
368 }
369
370 // Success rate
371 const okCount = await db
372 .select({ n: sql<number>`count(*)::int` })
373 .from(repairFlywheel)
374 .where(
375 repoFilter
376 ? and(repoFilter, eq(repairFlywheel.outcome, "success"))
377 : eq(repairFlywheel.outcome, "success"),
378 );
379 const successes = Number(okCount[0]?.n ?? 0);
380 const successRate = total > 0 ? successes / total : 0;
381
382 // Cache hit rate — % of repairs that came from Tier 0
383 const cacheHitRate = total > 0 ? byTier.cached / total : 0;
384
385 // Estimated AI calls saved: each cached + mechanical repair would have
386 // been an AI call in the old world.
387 const estimatedAiCallsSaved = byTier.cached + byTier.mechanical;
388
389 // Top reused patterns (most cache hits)
390 const topPatterns = await db
391 .select({
392 signature: repairFlywheel.failureSignature,
393 classification: repairFlywheel.failureClassification,
394 hitCount: repairFlywheel.cacheHitCount,
395 summary: repairFlywheel.patchSummary,
396 })
397 .from(repairFlywheel)
398 .where(
399 repoFilter
400 ? and(repoFilter, sql`${repairFlywheel.cacheHitCount} > 0`)
401 : sql`${repairFlywheel.cacheHitCount} > 0`,
402 )
403 .orderBy(desc(repairFlywheel.cacheHitCount))
404 .limit(10);
405
406 return {
407 totalRepairs: total,
408 byTier,
409 byClassification,
410 successRate,
411 cacheHitRate,
412 estimatedAiCallsSaved,
413 topPatterns: topPatterns.map((r) => ({
414 signature: r.signature,
415 classification: r.classification,
416 hitCount: r.hitCount,
417 summary: r.summary,
418 })),
419 };
420}