nyxcore-systems
8 min read

Automating Code Clarity: Building an AI Refactor Pipeline & Conquering Stream Crashes

Join me on a deep dive into building an AI-powered refactoring pipeline, from data models to real-time streaming, and learn how we tackled a tricky Server-Sent Events crash.

Next.jstRPCPrismaLLMRefactoringSSETypeScriptDebuggingSoftware ArchitectureDeveloper Experience

The quest for clean, maintainable code is eternal. As developers, we constantly seek ways to improve our codebase, whether it's by eliminating technical debt, optimizing performance, or simply making things more readable. But manual refactoring across large repositories can be a monumental task. What if we could automate a significant chunk of that process?

That's precisely the challenge we took on in my latest development sprint: building an AI-powered "Refactor Pipeline." This system scans repositories for refactoring opportunities, categorizes them, and then suggests improvements, from concrete code patches to high-level architectural guidance.

This post isn't just about the shiny new feature, though. It's also about the real-world development journey – the architectural decisions, the integration challenges, and the unexpected bugs that inevitably crop up. Let's unpack it.

The Vision: An Intelligent Refactor Pipeline

Our goal was ambitious: create a system that could intelligently identify and suggest improvements for various code smells. This required a multi-stage pipeline, leveraging LLMs for detection and generation, and a robust frontend for user interaction.

Laying the Foundation: Data & Types

Every solid feature starts with its data model. We introduced new Prisma models, RefactorRun and RefactorItem, to track the overall refactoring process and individual opportunities. These link back to User, Tenant, and Repository for proper context and permissions.

prisma
// prisma/schema.prisma
model RefactorRun {
  id           String       @id @default(uuid())
  // ... relations to User, Tenant, Repository
  items        RefactorItem[]
  // ... other run details
}

model RefactorItem {
  id           String       @id @default(uuid())
  runId        String
  run          RefactorRun  @relation(fields: [runId], references: [id])
  category     RefactorCategory
  difficulty   RefactorDifficulty
  impact       RefactorImpact
  outputType   RefactorOutputType
  // ... other item details (e.g., code snippet, improvement suggestion)
}

Alongside the database schema, we defined a comprehensive set of TypeScript types (RefactorPhase, RefactorCategory, RefactorDifficulty, RefactorImpact, RefactorOutputType) to ensure type safety and clarity throughout the application. Our categories span common issues like duplicate-code, dead-code, todo-remnant, unnecessary-code, slow-code, and slow-query.

The Brains: Detection & Generation

The core intelligence lies in two key services:

  1. opportunity-detector.ts: This service orchestrates LLM calls to scan code snippets and identify refactoring opportunities across the defined categories. It's designed for batch processing, deduplicating findings, and then sorting them by a combination of impact and difficulty to prioritize the most valuable improvements.

  2. improvement-generator.ts: Once an opportunity is detected, this service generates the actual improvement. We adopted a tiered approach based on the RefactorDifficulty:

    • Easy: Generates a direct patch (unified diff format) that can be applied automatically.
    • Medium: Provides a prompt – a step-by-step guide for the developer to follow.
    • Hard: Offers a suggestion – a higher-level, architectural recommendation requiring more manual intervention. Crucially, this service supports iteration, allowing developers to regenerate suggestions if the initial one isn't quite right.

The Conductor: pipeline.ts

To manage the asynchronous, multi-stage nature of the refactoring process (scan → detect → improve), we implemented a central orchestrator: src/server/services/refactor/pipeline.ts. This utilizes an AsyncGenerator pattern, similar to our existing auto-fix pipeline, which is perfect for long-running operations that need to stream progress updates back to the client.

typescript
// Conceptual snippet for AsyncGenerator pattern
async function* refactorPipeline(repoId: string, userId: string): AsyncGenerator<PipelineProgress> {
  yield { phase: 'scanning', progress: 0 };
  const files = await scanRepository(repoId);

  yield { phase: 'detecting', progress: 0 };
  for await (const opportunity of detectOpportunities(files)) {
    // Save opportunity, update progress
    yield { phase: 'detecting', progress: calculateProgress() };
  }

  yield { phase: 'improving', progress: 0 };
  for await (const improvement of generateImprovements(opportunities)) {
    // Save improvement, update progress
    yield { phase: 'improving', progress: calculateProgress() };
  }

  yield { phase: 'done', progress: 100 };
}

User Experience: API, UI & Real-time Updates

To expose this functionality, we built a comprehensive tRPC router (src/server/trpc/routers/refactor.ts) with procedures for listing, getting, starting, canceling runs, skipping items, and regenerating improvements.

For real-time updates on the progress of a refactor run, we integrated a Server-Sent Events (SSE) streaming endpoint at src/app/api/v1/events/refactor/[id]/route.ts. This allows the frontend to display live progress without constant polling.

On the UI side, we developed:

  • run-stats.tsx: Cards showing opportunities found, improvements generated, difficulty breakdowns, and success rates.
  • run-progress.tsx: A visual indicator of the pipeline phase (scan → detect → improve → done).
  • opportunity-card.tsx: An expandable component that dynamically renders either a PatchViewer, a prompt, or a suggestion based on the RefactorDifficulty.
  • Dedicated dashboard pages (/dashboard/refactor for run lists, /dashboard/refactor/[id] for detail views with SSE integration and filtering).
  • Integrated "Refactor" links in the sidebar and as a new tab on the project detail page.

Navigating the Trenches: Lessons Learned and Battle Scars

Building a new feature of this complexity rarely goes without a hitch. Here are some of the key lessons we learned and the bugs we squashed along the way.

Lesson 1: Robust SSE Handling is Non-Negotiable

This was a critical one. We had a pre-existing AutoFix feature that also used SSE for streaming progress. When testing an AutoFix run with autoCreatePR: true (which involves navigating away to a PR creation page), the SSE stream would crash with "Invalid state: Controller is already closed".

The Problem: When a user navigates away from a page that initiated an SSE stream, the browser might close the connection before the server-side controller has finished sending all its data or before the server explicitly closes it. Subsequent attempts by the server to enqueue or close the stream would then hit this "controller already closed" error, leading to a server crash.

The Fix: We implemented safeEnqueue and safeClose wrappers around the ReadableStreamDefaultController methods. These wrappers catch the Controller is already closed error, allowing the server-side pipeline to continue processing without crashing, even if the client connection is gone.

typescript
// Conceptual safe wrapper for SSE controllers
interface SafeStreamController extends ReadableStreamDefaultController {
  safeEnqueue: (chunk: any) => void;
  safeClose: () => void;
}

function createSafeController(controller: ReadableStreamDefaultController): SafeStreamController {
  const safeController = controller as SafeStreamController;

  safeController.safeEnqueue = (chunk: any) => {
    try {
      controller.enqueue(chunk);
    } catch (error: any) {
      if (error.message === 'Controller is already closed') {
        console.warn('SSE Controller already closed, skipping enqueue.', error);
      } else {
        throw error; // Re-throw other errors
      }
    }
  };

  safeController.safeClose = () => {
    try {
      controller.close();
    } catch (error: any) {
      if (error.message === 'Controller is already closed') {
        console.warn('SSE Controller already closed, skipping close.', error);
      } else {
        throw error; // Re-throw other errors
      }
    }
  };
  return safeController;
}

Takeaway: Any long-running process that streams data via SSE (or WebSockets) needs defensive programming. Assume the client connection can drop at any moment, and design your server-side stream handling to be resilient to this. This pattern is now being applied to all our SSE endpoints (workflows, code-analysis, discussions, dashboard) for enhanced robustness.

Lesson 2: Next.js CLI Flags Evolve (or Disappear)

During development, I attempted to use npx next dev --turbopack for faster local builds, only to be met with error: unknown option '--turbopack'.

The Problem: The --turbopack flag, while a promising feature, isn't universally supported across all Next.js versions, or its usage might have changed. Our current Next.js 14.2.35 didn't recognize it.

The Fix: Simply reverted to the standard npm run dev or ./scripts/dev-start.sh.

Takeaway: Always verify CLI flags and build commands against your specific framework version. What works in one version might not in the next. Keep your project's package.json scripts and helper scripts (./scripts/dev-start.sh) as the source of truth for development commands.

Lesson 3: Don't Let Linting Block Your Build (Temporarily)

Before a full build, an existing ESLint configuration issue (@typescript-eslint plugin not properly configured) and a useSearchParams() Suspense boundary problem in an unrelated page were blocking npm run build.

The Problem: While linting is crucial for code quality, a misconfigured linter can halt your entire build process, especially in CI/CD environments.

The Fix: For immediate testing and deployment, we used npx next build --no-lint. This allowed the build to complete, bypassing the linting step.

Takeaway: While npx next build --no-lint can be a useful escape hatch in a pinch, it's a temporary workaround. Prioritize fixing your ESLint configuration and any other build-blocking issues. Ignoring them will lead to technical debt and potential quality degradation in the long run. The useSearchParams() issue also highlights the importance of carefully managing Suspense boundaries, especially with client-side hooks in server components.

Current State and Next Steps

Both the Refactor Pipeline and the critical SSE crash fix are now complete and pushed to origin/main (25e038e, 0fcf7d0). Our dev server is humming along on localhost:3000, database tables are correctly provisioned, and the Prisma client is updated.

The old AutoFix run b39ce4dd that triggered the SSE bug has been reset to completed with its stats logged: 220 issues detected, 148 fixes applied. A successful sprint indeed!

Immediate next steps include:

  1. Performing end-to-end testing of the new Refactor Pipeline, verifying SSE streaming.
  2. Applying the safeEnqueue/safeClose pattern to all remaining SSE endpoints.
  3. Adding Row-Level Security (RLS) policies for refactor_runs and refactor_items.
  4. Finally fixing the lingering ESLint configuration and useSearchParams() issues.
  5. Considering a model selection dropdown for the refactor scan dialog, as it currently defaults to Anthropic.

Building powerful tools like this is a journey of continuous learning. Every bug is an opportunity to make the system more robust, and every feature brings us closer to a truly intelligent development environment. Stay tuned for more updates as we continue to refine and expand our AI-powered developer tools!

json
{"thingsDone":["Implemented Refactor Pipeline (scan, detect, improve)","Fixed AutoFix sidebar integration","Fixed SSE stream controller crash with safe wrappers","Created Prisma models for RefactorRun and RefactorItem","Developed LLM-based opportunity detector and improvement generator","Built AsyncGenerator-based refactor pipeline orchestrator","Implemented tRPC procedures and SSE endpoint for refactor runs","Developed frontend components for refactor run stats, progress, and opportunity cards","Integrated refactor feature into dashboard, sidebar, and project pages","Updated active processes display for autofix and refactor"],"pains":["SSE stream controller crashing due to 'Controller is already closed' on client navigation","Next.js CLI '--turbopack' option not recognized","Pre-existing ESLint config issues blocking build","Pre-existing useSearchParams() Suspense boundary issue blocking build"],"successes":["Successful implementation of a complex AI-powered pipeline","Developed a robust solution for SSE stream handling","Integrated new feature seamlessly into existing application architecture","Validated core functionality with existing AutoFix runs"],"techStack":["Next.js","tRPC","Prisma","TypeScript","PostgreSQL","LLM (e.g., Anthropic)","Server-Sent Events (SSE)","React","Zustand (implied for state management)"]}