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.
| e6bad81 | 1 | /** |
| 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 | ||
| 34 | import { createHash } from "crypto"; | |
| bc519ae | 35 | import { and, desc, eq, ne, sql, isNotNull, or } from "drizzle-orm"; |
| e6bad81 | 36 | import { db } from "../db"; |
| 37 | import { repairFlywheel } from "../db/schema"; | |
| 38 | ||
| 39 | export type RepairTier = "cached" | "mechanical" | "ai-sonnet" | "human"; | |
| 40 | export type RepairOutcome = "pending" | "success" | "failed" | "reverted"; | |
| 41 | ||
| 42 | export interface CachedRepair { | |
| 43 | id: string; | |
| 44 | patchSummary: string; | |
| bc519ae | 45 | /** 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; | |
| e6bad81 | 48 | filesChanged: string[]; |
| 49 | commitSha: string | null; | |
| 50 | hitCount: number; | |
| 51 | successRate: number; // 0..1 | |
| 52 | classification: string | null; | |
| 53 | appliedCount: number; | |
| 54 | } | |
| 55 | ||
| bc519ae | 56 | /** Cap on the stored full patch — anything bigger isn't worth replaying. */ |
| 57 | const 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. | |
| 62 | const patchColumn = sql<string | null>`${repairFlywheel}."patch"`; | |
| 63 | ||
| e6bad81 | 64 | // ───────────────────────────────────────────────────────────────────────── |
| 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 | */ | |
| 83 | export 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. */ | |
| 105 | export 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 | */ | |
| 123 | export 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 | |
| bc519ae | 131 | .select({ row: repairFlywheel, patch: patchColumn }) |
| e6bad81 | 132 | .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) { | |
| bc519ae | 144 | return await hydrate(sameRepo[0]!.row, sameRepo[0]!.patch); |
| e6bad81 | 145 | } |
| 146 | ||
| 147 | // Fallback: any public pattern with this signature | |
| 148 | const cross = await db | |
| bc519ae | 149 | .select({ row: repairFlywheel, patch: patchColumn }) |
| e6bad81 | 150 | .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) { | |
| bc519ae | 162 | return await hydrate(cross[0]!.row, cross[0]!.patch); |
| e6bad81 | 163 | } |
| 164 | ||
| 165 | return null; | |
| 166 | } | |
| 167 | ||
| 168 | async function hydrate( | |
| 169 | row: typeof repairFlywheel.$inferSelect, | |
| bc519ae | 170 | patch: string | null, |
| e6bad81 | 171 | ): 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 | |
| bc519ae | 174 | // 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. | |
| e6bad81 | 177 | 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( | |
| bc519ae | 184 | and( |
| 185 | or( | |
| 186 | eq(repairFlywheel.failureSignature, row.failureSignature), | |
| 187 | eq(repairFlywheel.parentPatternId, row.id), | |
| 188 | ), | |
| 189 | ne(repairFlywheel.outcome, "pending"), | |
| e6bad81 | 190 | ), |
| 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, | |
| bc519ae | 200 | patch, |
| e6bad81 | 201 | 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 | ||
| 214 | export interface RecordRepairInput { | |
| 215 | repositoryId: string | null; | |
| 216 | failureText: string; | |
| 217 | classification: string | null; | |
| 218 | tier: RepairTier; | |
| 219 | patchSummary: string; | |
| bc519ae | 220 | /** 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; | |
| e6bad81 | 223 | 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 | */ | |
| 236 | export 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 | ||
| bc519ae | 258 | // 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 | ||
| e6bad81 | 277 | // 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 | */ | |
| 292 | export 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 | ||
| 306 | export 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 | */ | |
| 326 | export 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 | } |