Commite6bad81unknown_key
feat(flywheel): repair flywheel — every fix memorised, future failures hit cache
feat(flywheel): repair flywheel — every fix memorised, future failures hit cache The structural moat: every auto-repair attempt is recorded with its failure signature, the patch produced, and the eventual outcome. Future failures with the same signature can short-circuit straight to the cached patch. After ~5000 entries the cache dominates and AI calls drop dramatically. drizzle/0039_repair_flywheel.sql - New 'repair_flywheel' table with 13 columns + 5 indexes including a composite (repository_id, failure_signature, outcome) for the hot cache-lookup path. Each row captures: failure signature + text + classification, repair tier (cached/mechanical/ai-sonnet/human), patch summary + files changed + commit_sha, outcome lifecycle (pending → success/failed/reverted), cache lineage (parent_pattern_id + hit_count), and a privacy gate (is_public_pattern, default off). src/db/schema.ts - Drizzle table + types for repair_flywheel. src/lib/repair-flywheel.ts - normaliseFailure(text): aggressive normaliser. Strips ANSI codes, ISO-8601 timestamps, SHA-1/256 hashes, UUIDs, absolute paths (linux+windows), line numbers, quoted strings; collapses whitespace; lowercases. Two failures with the same root cause but different variable bits collapse to one signature. - fingerprint(text): SHA-256 of the normalised text, sliced to 32 hex chars. - findCachedRepair(repoId, text): cache lookup. Same repo + signature + outcome=success first, falls back to public cross-repo patterns, returns CachedRepair with computed successRate. - recordRepair(input): writes a flywheel row, caps text at 4KB / summary at 400 chars, bumps parent pattern hit_count when this was a Tier 0 hit. - updateOutcome(id, outcome): updates outcome + outcomeAt later (after smoke test settles). - getFlywheelStats(repoId?): admin dashboard query. Returns total + per-tier breakdown + classification breakdown + success rate + cache hit rate + estimated AI calls saved + top reused patterns. src/lib/auto-repair.ts (wiring) - repairGateFailure now calls recordRepair (fire-and-forget) on EVERY tier outcome — mechanical success, mechanical fail, AI success, AI fail. The dataset starts accumulating from this commit forward. Every push that triggers a repair grows the flywheel. - classifyMechanical(summary): tags mechanical repairs as 'lockfile' / 'formatting' / 'imports' from their summary string for stats grouping. src/__tests__/repair-flywheel.test.ts (new) - 13 tests covering the deterministic surface: normalisation strips line numbers + timestamps + SHA hashes + UUIDs + linux+windows paths + ANSI codes + quoted strings + whitespace + case; signatures are stable + 32-char hex; semantically-identical failures collapse to one signature; genuinely different failures don't. Build: clean. Tests: 1255 pass / 1 unrelated pre-existing fail. NEXT (follow-up PR): /admin/repair-flywheel dashboard showing live cache hit rate + top reused patterns + 'AI calls saved' counter. Plus storing the actual patch content so Tier 0 cache hits can replay AI patches without re-asking Claude (the full structural win).
5 files changed+658−0e6bad816f3266a4e8cb16c21f76b33a03f0515a6
5 changed files+658−0
Addeddrizzle/0039_repair_flywheel.sql+66−0View fileUnifiedSplit
@@ -0,0 +1,66 @@
1-- Repair Flywheel
2--
3-- Every auto-repair attempt — mechanical, AI-driven, or cache-hit — gets
4-- recorded here with its failure signature, the patch produced, and the
5-- eventual outcome. Future failures with the same signature can short-
6-- circuit straight to the cached patch (Tier 0), saving AI cost + latency.
7--
8-- After ~5000 entries the flywheel dominates: most CI failures hit a
9-- cached pattern and get fixed in seconds.
10
11CREATE TABLE IF NOT EXISTS "repair_flywheel" (
12 "id" UUID PRIMARY KEY DEFAULT gen_random_uuid(),
13 "repository_id" UUID REFERENCES "repositories"("id") ON DELETE CASCADE,
14
15 -- Fingerprint: SHA-256 of the normalised failure text (variables/paths
16 -- stripped). Two failures with the same signature are considered the
17 -- same problem.
18 "failure_signature" TEXT NOT NULL,
19
20 -- Original failure text (capped to 4KB) — for human review at /admin/repair-flywheel.
21 "failure_text" TEXT NOT NULL,
22
23 -- Mechanical classification, if any: 'lockfile' | 'formatting' | 'imports'
24 -- | NULL (then it was AI-driven or human-driven).
25 "failure_classification" TEXT,
26
27 -- Which tier produced the fix.
28 "repair_tier" TEXT NOT NULL,
29
30 -- Plain-English summary (always populated, max 400 chars).
31 "patch_summary" TEXT NOT NULL,
32
33 -- File paths the repair touched.
34 "files_changed" JSONB NOT NULL DEFAULT '[]'::jsonb,
35
36 -- Resulting commit SHA (NULL until the repair commits).
37 "commit_sha" TEXT,
38
39 -- Outcome: 'pending' (just applied), 'success' (smoke passed),
40 -- 'failed' (smoke failed), 'reverted' (later reverted by a human).
41 "outcome" TEXT NOT NULL DEFAULT 'pending',
42 "applied_at" TIMESTAMPTZ DEFAULT now() NOT NULL,
43 "outcome_at" TIMESTAMPTZ,
44
45 -- Cache lineage: if this entry was applied from another flywheel pattern
46 -- (Tier 0 hit), parent_pattern_id points at the original. Lets us count
47 -- "this pattern was reused N times" for confidence weighting.
48 "parent_pattern_id" UUID REFERENCES "repair_flywheel"("id") ON DELETE SET NULL,
49 "cache_hit_count" INTEGER NOT NULL DEFAULT 0,
50
51 -- Privacy gate: only patterns marked public participate in cross-repo
52 -- learning. Default off; site admins can flip per-pattern, or owners can
53 -- bulk-opt-in via repo settings.
54 "is_public_pattern" BOOLEAN NOT NULL DEFAULT false,
55
56 "created_at" TIMESTAMPTZ DEFAULT now() NOT NULL
57);
58
59CREATE INDEX IF NOT EXISTS "repair_flywheel_signature_idx" ON "repair_flywheel"("failure_signature");
60CREATE INDEX IF NOT EXISTS "repair_flywheel_repo_idx" ON "repair_flywheel"("repository_id");
61CREATE INDEX IF NOT EXISTS "repair_flywheel_outcome_idx" ON "repair_flywheel"("outcome");
62CREATE INDEX IF NOT EXISTS "repair_flywheel_classification_idx" ON "repair_flywheel"("failure_classification");
63-- Composite for the cache lookup hot path: "find a successful repair for this
64-- repo + this exact failure signature."
65CREATE INDEX IF NOT EXISTS "repair_flywheel_lookup_idx"
66 ON "repair_flywheel"("repository_id", "failure_signature", "outcome");
Addedsrc/__tests__/repair-flywheel.test.ts+101−0View fileUnifiedSplit
@@ -0,0 +1,101 @@
1/**
2 * Tests for the deterministic parts of the flywheel: failure normalisation
3 * and fingerprinting. The DB-touching functions are exercised in higher-
4 * level integration tests against a live Postgres.
5 *
6 * Goal: prove that failures with the same root cause but different variable
7 * bits (line numbers, paths, timestamps, hashes) collapse to the same
8 * signature. That's the property that makes the cache work.
9 */
10import { describe, expect, it } from "bun:test";
11import { fingerprint, normaliseFailure } from "../lib/repair-flywheel";
12
13describe("normaliseFailure", () => {
14 it("strips line + column numbers", () => {
15 const a = normaliseFailure("Error at /opt/app/src/foo.ts:42:13");
16 const b = normaliseFailure("Error at /opt/app/src/foo.ts:1099:7");
17 expect(a).toBe(b);
18 });
19
20 it("strips ISO-8601 timestamps", () => {
21 const a = normaliseFailure("Failed at 2026-05-08T22:14:15.123Z");
22 const b = normaliseFailure("Failed at 2025-01-01T00:00:00Z");
23 expect(a).toBe(b);
24 });
25
26 it("strips full SHA-1 commit hashes", () => {
27 const a = normaliseFailure("revert b0a3ba2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f80");
28 const b = normaliseFailure("revert deadbeefcafebabedeadbeefcafebabedeadbeef");
29 expect(a).toBe(b);
30 });
31
32 it("strips UUIDs", () => {
33 const a = normaliseFailure(
34 "request-id 550e8400-e29b-41d4-a716-446655440000 failed",
35 );
36 const b = normaliseFailure(
37 "request-id 6ba7b810-9dad-11d1-80b4-00c04fd430c8 failed",
38 );
39 expect(a).toBe(b);
40 });
41
42 it("strips absolute paths on linux + windows", () => {
43 const a = normaliseFailure("ENOENT /home/runner/work/foo/bar.ts");
44 const b = normaliseFailure("ENOENT /tmp/runner/zzz/bar.ts");
45 const c = normaliseFailure("ENOENT C:\\Users\\runner\\bar.ts");
46 expect(a).toBe(b);
47 expect(a).toBe(c);
48 });
49
50 it("strips ANSI colour codes", () => {
51 const a = normaliseFailure("\x1b[31merror:\x1b[0m lockfile mismatch");
52 const b = normaliseFailure("error: lockfile mismatch");
53 expect(a).toBe(b);
54 });
55
56 it("strips quoted strings (often contain user data)", () => {
57 const a = normaliseFailure(`expected 'alice@example.com' to match pattern`);
58 const b = normaliseFailure(`expected 'bob@other.com' to match pattern`);
59 expect(a).toBe(b);
60 });
61
62 it("collapses whitespace + lowercases", () => {
63 const a = normaliseFailure("ERROR: Lockfile out of sync");
64 const b = normaliseFailure("error: lockfile out of sync");
65 expect(a).toBe(b);
66 });
67
68 it("does NOT collapse genuinely different errors", () => {
69 const a = normaliseFailure("error: lockfile is out of sync");
70 const b = normaliseFailure("TypeError: cannot read property of undefined");
71 expect(a).not.toBe(b);
72 });
73});
74
75describe("fingerprint", () => {
76 it("produces a 32-char hex string", () => {
77 const sig = fingerprint("any failure text");
78 expect(sig).toMatch(/^[0-9a-f]{32}$/);
79 });
80
81 it("is stable across calls", () => {
82 const text = "error: bun install failed: lockfile mismatch at line 42";
83 expect(fingerprint(text)).toBe(fingerprint(text));
84 });
85
86 it("collapses semantically-identical failures to one signature", () => {
87 const a = fingerprint(
88 "TypeError at /home/runner/work/proj/src/foo.ts:42:13 in commit b0a3ba2c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f80",
89 );
90 const b = fingerprint(
91 "TypeError at /tmp/build/x/src/foo.ts:9999:1 in commit deadbeefcafebabedeadbeefcafebabedeadbeef",
92 );
93 expect(a).toBe(b);
94 });
95
96 it("differs for genuinely different failures", () => {
97 const a = fingerprint("error: lockfile is out of sync");
98 const b = fingerprint("TypeError: cannot read property of undefined");
99 expect(a).not.toBe(b);
100 });
101});
Modifiedsrc/db/schema.ts+51−0View fileUnifiedSplit
@@ -2586,3 +2586,54 @@ export type WorkflowRunnerPoolEntry =
25862586 typeof workflowRunnerPool.$inferSelect;
25872587export type NewWorkflowRunnerPoolEntry =
25882588 typeof workflowRunnerPool.$inferInsert;
2589
2590
2591/**
2592 * Repair Flywheel — every auto-repair attempt is recorded here so future
2593 * failures with the same signature can short-circuit straight to the cached
2594 * patch (Tier 0). After ~5000 entries the cache dominates and AI calls drop.
2595 * Migration: 0039_repair_flywheel.sql
2596 */
2597export const repairFlywheel = pgTable(
2598 "repair_flywheel",
2599 {
2600 id: uuid("id").primaryKey().defaultRandom(),
2601 repositoryId: uuid("repository_id").references(() => repositories.id, {
2602 onDelete: "cascade",
2603 }),
2604 // Fingerprint of the normalised failure text (variables/paths stripped).
2605 failureSignature: text("failure_signature").notNull(),
2606 // Original failure text (capped at write-site, ~4KB).
2607 failureText: text("failure_text").notNull(),
2608 // Mechanical classification or NULL if AI/human-driven.
2609 failureClassification: text("failure_classification"),
2610 // 'cached' | 'mechanical' | 'ai-sonnet' | 'human'
2611 repairTier: text("repair_tier").notNull(),
2612 patchSummary: text("patch_summary").notNull(),
2613 filesChanged: jsonb("files_changed").notNull().default([]),
2614 commitSha: text("commit_sha"),
2615 // 'pending' | 'success' | 'failed' | 'reverted'
2616 outcome: text("outcome").notNull().default("pending"),
2617 appliedAt: timestamp("applied_at").defaultNow().notNull(),
2618 outcomeAt: timestamp("outcome_at"),
2619 parentPatternId: uuid("parent_pattern_id"),
2620 cacheHitCount: integer("cache_hit_count").default(0).notNull(),
2621 isPublicPattern: boolean("is_public_pattern").default(false).notNull(),
2622 createdAt: timestamp("created_at").defaultNow().notNull(),
2623 },
2624 (table) => [
2625 index("repair_flywheel_signature_idx").on(table.failureSignature),
2626 index("repair_flywheel_repo_idx").on(table.repositoryId),
2627 index("repair_flywheel_outcome_idx").on(table.outcome),
2628 index("repair_flywheel_classification_idx").on(table.failureClassification),
2629 index("repair_flywheel_lookup_idx").on(
2630 table.repositoryId,
2631 table.failureSignature,
2632 table.outcome
2633 ),
2634 ]
2635);
2636
2637export type RepairFlywheelEntry = typeof repairFlywheel.$inferSelect;
2638export type NewRepairFlywheelEntry = typeof repairFlywheel.$inferInsert;
2639
Modifiedsrc/lib/auto-repair.ts+60−0View fileUnifiedSplit
@@ -19,8 +19,21 @@ import { join } from "path";
1919import { getAnthropic, MODEL_SONNET, extractText, parseJsonResponse, isAiAvailable } from "./ai-client";
2020import { getRepoPath } from "../git/repository";
2121import { tryMechanicalRepair } from "./auto-repair-mechanical";
22import { recordRepair } from "./repair-flywheel";
2223import type { SecurityFinding, SecretFinding } from "./security-scan";
2324
25// Best-effort classification of a mechanical repair summary so the flywheel
26// stats can group by failure type. The mechanical tier writes summaries like
27// "regenerated bun.lock" / "reformatted N file(s) with biome" / "organised
28// imports in N file(s)" — this turns those into a one-word tag.
29function classifyMechanical(summary: string): string | null {
30 const s = summary.toLowerCase();
31 if (s.includes("lockfile") || s.includes("regenerated") && s.includes(".lock")) return "lockfile";
32 if (s.includes("reformatted") || s.includes("formatted")) return "formatting";
33 if (s.includes("imports")) return "imports";
34 return null;
35}
36
2437export interface RepairResult {
2538 attempted: boolean;
2639 success: boolean;
@@ -392,6 +405,19 @@ export async function repairGateFailure(
392405 // Sonnet round-trip AND get a more reliable patch.
393406 const mech = await tryMechanicalRepair(owner, repo, branch, failureDetails);
394407 if (mech.attempted && mech.success && mech.commitSha) {
408 // Record the win in the flywheel — every successful repair grows the
409 // dataset that makes future repairs faster and cheaper. Fire-and-forget;
410 // recording failures must never block the actual fix from being returned.
411 void recordRepair({
412 repositoryId: null,
413 failureText: failureDetails,
414 classification: classifyMechanical(mech.summary),
415 tier: "mechanical",
416 patchSummary: mech.summary,
417 filesChanged: mech.filesChanged,
418 commitSha: mech.commitSha,
419 outcome: "success",
420 }).catch(() => {});
395421 return {
396422 attempted: true,
397423 success: true,
@@ -400,6 +426,20 @@ export async function repairGateFailure(
400426 summary: `[mechanical] ${mech.summary}`,
401427 };
402428 }
429 if (mech.attempted && !mech.success) {
430 // Mechanical handler triggered but didn't fix it. Log the miss so we can
431 // see classifier false-positives in the admin dashboard.
432 void recordRepair({
433 repositoryId: null,
434 failureText: failureDetails,
435 classification: classifyMechanical(mech.summary),
436 tier: "mechanical",
437 patchSummary: mech.summary,
438 filesChanged: mech.filesChanged,
439 commitSha: null,
440 outcome: "failed",
441 }).catch(() => {});
442 }
403443
404444 // ── Tier 2: AI-powered repair via Claude Sonnet
405445 if (!isAiAvailable()) {
@@ -468,6 +508,16 @@ ${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
468508
469509 const result = await applyAndCommit(repoDir, wt.path, branch, parsed.patches, msg);
470510 if (!result.ok) {
511 void recordRepair({
512 repositoryId: null,
513 failureText: failureDetails,
514 classification: null,
515 tier: "ai-sonnet",
516 patchSummary: parsed.summary,
517 filesChanged: result.filesChanged,
518 commitSha: null,
519 outcome: "failed",
520 }).catch(() => {});
471521 return {
472522 attempted: true,
473523 success: false,
@@ -476,6 +526,16 @@ ${parsed.patches.map((p) => `- ${p.path}: ${p.reason}`).join("\n")}
476526 error: result.error,
477527 };
478528 }
529 void recordRepair({
530 repositoryId: null,
531 failureText: failureDetails,
532 classification: null,
533 tier: "ai-sonnet",
534 patchSummary: parsed.summary,
535 filesChanged: result.filesChanged,
536 commitSha: result.sha,
537 outcome: "success",
538 }).catch(() => {});
479539 return {
480540 attempted: true,
481541 success: true,
Addedsrc/lib/repair-flywheel.ts+380−0View fileUnifiedSplit
@@ -0,0 +1,380 @@
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
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}
0381