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"; | |
| 35 | import { and, desc, eq, sql, isNotNull, or } from "drizzle-orm"; | |
| 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; | |
| 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 | */ | |
| 72 | export 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. */ | |
| 94 | export 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 | */ | |
| 112 | export 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 | ||
| 157 | async 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 | ||
| 196 | export 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 | */ | |
| 215 | export 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 | */ | |
| 252 | export 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 | ||
| 266 | export 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 | */ | |
| 286 | export 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 | } |