diff --git a/apps/api/src/ai/__tests__/ai.test.ts b/apps/api/src/ai/__tests__/ai.test.ts new file mode 100644 index 0000000..b0705b0 --- /dev/null +++ b/apps/api/src/ai/__tests__/ai.test.ts @@ -0,0 +1,217 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; +import { extractBuildErrorContext, SYSTEM_PROMPT } from "../prompt"; +import { resolveProviderConfig, diagnoseDeploymentFailure } from "../diagnose"; +import { testAiConnection } from "../test-connection"; +import { callOpenAi } from "../providers/openai"; +import { callGemini } from "../providers/gemini"; +import { callGrok } from "../providers/grok"; +import { callClaude } from "../providers/claude"; + +describe("AI Build Failure Analysis", () => { + const originalFetch = globalThis.fetch; + + afterEach(() => { + globalThis.fetch = originalFetch; + }); + + describe("extractBuildErrorContext", () => { + it("extracts context and strips ANSI escape sequences", () => { + const result = extractBuildErrorContext({ + deploymentId: "dep-1", + projectName: "my-web-app", + projectType: "web", + buildType: "railpack", + sourceType: "git", + branch: "main", + failureReason: "Build exited with code 1", + logs: [ + { sequence: 1, stage: "build", message: "\u001b[32m[info]\u001b[0m Installing dependencies..." }, + { sequence: 2, stage: "build", message: "\u001b[31mError: Cannot find module 'pg'\u001b[0m" }, + { sequence: 3, stage: "build", message: "Command failed: bun build" }, + ], + }); + + expect(result).toContain("Project: my-web-app"); + expect(result).toContain("Build Strategy: railpack"); + expect(result).toContain("Source: git (branch: main)"); + expect(result).toContain("Failure Reason: Build exited with code 1"); + expect(result).toContain("Error: Cannot find module 'pg'"); + expect(result).not.toContain("\u001b[31m"); + }); + }); + + describe("resolveProviderConfig", () => { + it("throws when no API key is provided or configured", async () => { + await expect(resolveProviderConfig("openai", undefined, undefined)).rejects.toThrow( + "API key not configured for AI provider 'openai'", + ); + }); + + it("accepts explicit API key and custom model", async () => { + const config = await resolveProviderConfig("openai", "gpt-4o", "sk-test-123"); + expect(config.provider).toBe("openai"); + expect(config.model).toBe("gpt-4o"); + expect(config.apiKey).toBe("sk-test-123"); + }); + + it("uses default model when model is omitted", async () => { + const config = await resolveProviderConfig("gemini", undefined, "gemini-key-123"); + expect(config.provider).toBe("gemini"); + expect(config.model).toBe("gemini-2.0-flash"); + expect(config.apiKey).toBe("gemini-key-123"); + }); + }); + + describe("Provider HTTP Clients", () => { + it("calls OpenAI chat completions correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.openai.com/v1/chat/completions"); + const body = JSON.parse(opts.body); + expect(body.model).toBe("gpt-4o-mini"); + expect(body.response_format).toEqual({ type: "json_object" }); + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ summary: "Missing pg module" }) } }], + }), { status: 200 }); + }); + + const response = await callOpenAi({ + apiKey: "test-openai-key", + model: "gpt-4o-mini", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Missing pg module"); + }); + + it("calls Gemini generateContent correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toContain("generativelanguage.googleapis.com"); + expect(url.toString()).toContain("key=test-gemini-key"); + const body = JSON.parse(opts.body); + expect(body.generationConfig.responseMimeType).toBe("application/json"); + return new Response(JSON.stringify({ + candidates: [{ content: { parts: [{ text: JSON.stringify({ summary: "Missing pg in Gemini" }) }] } }], + }), { status: 200 }); + }); + + const response = await callGemini({ + apiKey: "test-gemini-key", + model: "gemini-2.0-flash", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Missing pg in Gemini"); + }); + + it("calls Grok completions correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.x.ai/v1/chat/completions"); + expect(opts.headers.Authorization).toBe("Bearer test-grok-key"); + return new Response(JSON.stringify({ + choices: [{ message: { content: JSON.stringify({ summary: "Grok diagnosis" }) } }], + }), { status: 200 }); + }); + + const response = await callGrok({ + apiKey: "test-grok-key", + model: "grok-2-latest", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Grok diagnosis"); + }); + + it("calls Claude messages correctly", async () => { + globalThis.fetch = mock(async (url: any, opts: any) => { + expect(url.toString()).toBe("https://api.anthropic.com/v1/messages"); + expect(opts.headers["x-api-key"]).toBe("test-claude-key"); + expect(opts.headers["anthropic-version"]).toBe("2023-06-01"); + return new Response(JSON.stringify({ + content: [{ text: JSON.stringify({ summary: "Claude diagnosis" }) }], + }), { status: 200 }); + }); + + const response = await callClaude({ + apiKey: "test-claude-key", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "sys", + userPrompt: "user", + }); + + expect(response).toContain("Claude diagnosis"); + }); + }); + + describe("testAiConnection", () => { + it("returns ok=true when ping succeeds", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + choices: [{ message: { content: "OK" } }], + }), { status: 200 }); + }); + + const result = await testAiConnection("openai", "sk-test", "gpt-4o-mini"); + expect(result.ok).toBe(true); + expect(result.provider).toBe("openai"); + expect(result.message).toContain("Successfully connected"); + }); + + it("returns ok=false when provider API errors", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + error: { message: "Invalid API key provided" }, + }), { status: 401 }); + }); + + const result = await testAiConnection("openai", "sk-invalid", "gpt-4o-mini"); + expect(result.ok).toBe(false); + expect(result.message).toContain("Invalid API key"); + }); + }); + + describe("diagnoseDeploymentFailure", () => { + it("parses valid structured JSON diagnosis correctly", async () => { + globalThis.fetch = mock(async () => { + return new Response(JSON.stringify({ + choices: [{ + message: { + content: JSON.stringify({ + summary: "TypeScript compilation error", + rootCause: "Type 'string' is not assignable to type 'number'", + explanation: "File src/index.ts has a type error on line 42", + suggestedFixes: [ + { + title: "Cast or parse to number", + description: "Use Number(value) instead of raw string", + actionType: "code", + snippet: "const id = Number(rawId);", + }, + ], + }), + }, + }], + }), { status: 200 }); + }); + + const { resolveProviderConfig } = await import("../diagnose"); + const { callOpenAi } = await import("../providers/openai"); + + const config = await resolveProviderConfig("openai", "gpt-4o-mini", "sk-mock-key"); + const raw = await callOpenAi({ + apiKey: config.apiKey, + model: config.model, + systemPrompt: "sys", + userPrompt: "user", + }); + + const parsed = JSON.parse(raw); + expect(parsed.summary).toBe("TypeScript compilation error"); + expect(parsed.rootCause).toContain("Type 'string'"); + expect(parsed.suggestedFixes.length).toBe(1); + expect(parsed.suggestedFixes[0].snippet).toBe("const id = Number(rawId);"); + }); + }); +}); diff --git a/apps/api/src/ai/diagnose.ts b/apps/api/src/ai/diagnose.ts new file mode 100644 index 0000000..b070c74 --- /dev/null +++ b/apps/api/src/ai/diagnose.ts @@ -0,0 +1,155 @@ +import type { AiDiagnoseOptions, AiDiagnosisResult, AiProvider } from "./types"; +import { getAiSettings, saveAiDiagnosis } from "../db/repo/ai-settings"; +import { getDeploymentById, getLogs } from "../db/repo/deployments"; +import { getProjectById } from "../db/repo/projects"; +import { SYSTEM_PROMPT, extractBuildErrorContext } from "./prompt"; +import { callOpenAi } from "./providers/openai"; +import { callGemini } from "./providers/gemini"; +import { callGrok } from "./providers/grok"; +import { callClaude } from "./providers/claude"; + +const DEFAULT_MODELS: Record = { + openai: "gpt-4o-mini", + gemini: "gemini-2.0-flash", + grok: "grok-2-latest", + claude: "claude-3-5-sonnet-20241022", +}; + +export async function resolveProviderConfig( + providerReq?: AiProvider, + modelReq?: string, + apiKeyReq?: string, +): Promise<{ provider: AiProvider; model: string; apiKey: string }> { + const settings = await getAiSettings(); + const provider: AiProvider = providerReq || settings.defaultProvider || "openai"; + + let apiKey = apiKeyReq; + let defaultModel = DEFAULT_MODELS[provider]; + + if (provider === "openai") { + apiKey = apiKey || settings.openaiApiKey || process.env.OPENAI_API_KEY; + defaultModel = settings.openaiModel || DEFAULT_MODELS.openai; + } else if (provider === "gemini") { + apiKey = apiKey || settings.geminiApiKey || process.env.GEMINI_API_KEY; + defaultModel = settings.geminiModel || DEFAULT_MODELS.gemini; + } else if (provider === "grok") { + apiKey = apiKey || settings.grokApiKey || process.env.GROK_API_KEY || process.env.XAI_API_KEY; + defaultModel = settings.grokModel || DEFAULT_MODELS.grok; + } else if (provider === "claude") { + apiKey = apiKey || settings.claudeApiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY; + defaultModel = settings.claudeModel || DEFAULT_MODELS.claude; + } + + if (!apiKey) { + throw new Error(`API key not configured for AI provider '${provider}'. Please configure it in Settings or provide an API key.`); + } + + const model = modelReq || defaultModel; + return { provider, model, apiKey }; +} + +function parseAiResponse(raw: string, provider: AiProvider, model: string): AiDiagnosisResult { + let cleaned = raw.trim(); + const jsonBlockMatch = cleaned.match(/```(?:json)?\s*([\s\S]*?)\s*```/); + if (jsonBlockMatch) { + cleaned = jsonBlockMatch[1].trim(); + } + + try { + const parsed = JSON.parse(cleaned); + const summary = String(parsed.summary || "Build/deployment failed"); + const rootCause = String(parsed.rootCause || parsed.root_cause || "Error occurred during execution"); + const explanation = String(parsed.explanation || "See logs for details"); + const rawFixes = Array.isArray(parsed.suggestedFixes || parsed.suggested_fixes) ? (parsed.suggestedFixes || parsed.suggested_fixes) : []; + + const suggestedFixes = rawFixes.map((f: any) => ({ + title: String(f.title || "Fix instruction"), + description: String(f.description || ""), + actionType: (f.actionType || f.action_type || "code") as any, + snippet: f.snippet ? String(f.snippet) : undefined, + })); + + return { + provider, + model, + summary, + rootCause, + explanation, + suggestedFixes, + rawResponse: raw, + }; + } catch { + return { + provider, + model, + summary: "Build failure analyzed", + rootCause: "Review the raw AI diagnosis below for details", + explanation: raw, + suggestedFixes: [], + rawResponse: raw, + }; + } +} + +export async function diagnoseDeploymentFailure(options: AiDiagnoseOptions): Promise { + const deployment = await getDeploymentById(options.deploymentId); + if (!deployment) { + throw new Error(`Deployment ${options.deploymentId} not found`); + } + + const project = deployment.projectId ? await getProjectById(deployment.projectId) : null; + const rawLogs = await getLogs(options.deploymentId); + + const errorContext = extractBuildErrorContext({ + deploymentId: deployment.id, + projectName: project?.name, + projectType: project?.projectType, + buildType: project?.buildType, + sourceType: deployment.sourceType, + branch: deployment.branch, + failureReason: deployment.failureReason, + logs: rawLogs, + }); + + const userPrompt = [ + options.customPrompt ? `User Question / Instructions: ${options.customPrompt}\n` : "", + "Please analyze the following deployment build failure and provide a structured diagnosis:", + errorContext, + ].filter(Boolean).join("\n"); + + const { provider, model, apiKey } = await resolveProviderConfig( + options.provider, + options.model, + options.apiKey, + ); + + let rawOutput = ""; + if (provider === "openai") { + rawOutput = await callOpenAi({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "gemini") { + rawOutput = await callGemini({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "grok") { + rawOutput = await callGrok({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else if (provider === "claude") { + rawOutput = await callClaude({ apiKey, model, systemPrompt: SYSTEM_PROMPT, userPrompt }); + } else { + throw new Error(`Unsupported provider: ${provider}`); + } + + const diagnosis = parseAiResponse(rawOutput, provider, model); + + await saveAiDiagnosis({ + deploymentId: deployment.id, + provider: diagnosis.provider, + model: diagnosis.model, + summary: diagnosis.summary, + rootCause: diagnosis.rootCause, + explanation: diagnosis.explanation, + suggestedFixes: diagnosis.suggestedFixes, + rawResponse: rawOutput, + }).catch((err) => { + console.error("[AI Diagnose] Failed to save diagnosis record:", err); + }); + + return diagnosis; +} diff --git a/apps/api/src/ai/index.ts b/apps/api/src/ai/index.ts new file mode 100644 index 0000000..1a329f2 --- /dev/null +++ b/apps/api/src/ai/index.ts @@ -0,0 +1,4 @@ +export * from "./types"; +export * from "./prompt"; +export * from "./diagnose"; +export * from "./test-connection"; diff --git a/apps/api/src/ai/prompt.ts b/apps/api/src/ai/prompt.ts new file mode 100644 index 0000000..c2ea96a --- /dev/null +++ b/apps/api/src/ai/prompt.ts @@ -0,0 +1,90 @@ +function stripAnsi(str: string): string { + let s = str.replace(/[\u001b\u009b]\[[\d;]*[A-Za-z]/g, ""); + s = s.replace(/\[(\d+;)*\d*m/g, ""); + return s; +} + +export interface DeploymentLogContext { + deploymentId: string; + projectName?: string; + projectType?: string; + buildType?: string; + sourceType?: string; + branch?: string | null; + failureReason?: string | null; + logs: Array<{ sequence: number; stage: string; message: string }>; +} + +export function extractBuildErrorContext(ctx: DeploymentLogContext): string { + const cleanLogs = ctx.logs.map((l) => ({ + stage: l.stage, + message: stripAnsi(l.message).trim(), + })).filter((l) => l.message.length > 0); + + const errorKeywords = [ + "error", "failed", "failure", "fatal", "exception", "cannot find module", + "exit code", "err!", "command failed", "syntaxerror", "typeerror", + "referenceerror", "not found", "permission denied", "build failed", + ]; + + let relevantLogs = cleanLogs; + if (cleanLogs.length > 120) { + const errorIndices: number[] = []; + cleanLogs.forEach((l, idx) => { + const lower = l.message.toLowerCase(); + if (errorKeywords.some((kw) => lower.includes(kw))) { + errorIndices.push(idx); + } + }); + + if (errorIndices.length > 0) { + const firstError = Math.max(0, errorIndices[0] - 15); + const lastError = Math.min(cleanLogs.length, errorIndices[errorIndices.length - 1] + 20); + const windowLogs = cleanLogs.slice(firstError, lastError); + if (windowLogs.length < 50) { + relevantLogs = cleanLogs.slice(-100); + } else { + relevantLogs = windowLogs; + } + } else { + relevantLogs = cleanLogs.slice(-100); + } + } + + const logLines = relevantLogs.map((l) => `[${l.stage}] ${l.message}`).join("\n"); + + return [ + `Project: ${ctx.projectName || "Unknown"}`, + `Project Type: ${ctx.projectType || "web"}`, + `Build Strategy: ${ctx.buildType || "railpack"}`, + `Source: ${ctx.sourceType || "git"}${ctx.branch ? ` (branch: ${ctx.branch})` : ""}`, + ctx.failureReason ? `Failure Reason: ${ctx.failureReason}` : "", + "", + "--- BUILD & DEPLOYMENT LOGS ---", + logLines || "(No logs recorded)", + ].filter(Boolean).join("\n"); +} + +export const SYSTEM_PROMPT = `You are an expert DevOps, Docker, BuildKit, Railpack, and Cloud Deployment specialist for Dequel (a modern self-hosted deployment platform). +Your task is to analyze build, compilation, packaging, and container startup failures and provide clear, precise, and actionable diagnosis to the developer. + +CRITICAL INSTRUCTIONS: +1. Identify the exact root cause from the logs (e.g. missing dependency, Node/Bun version incompatibility, wrong build script, missing environment variable, port binding error, syntax/type error, Dockerfile instruction issue). +2. Pinpoint the exact file, line number, package, or command if identifiable from the logs. +3. Provide step-by-step resolution instructions with copy-pasteable code snippets, command line commands, or configuration fixes. +4. Respond in valid JSON format matching this schema: +{ + "summary": "1-sentence executive summary of what failed", + "rootCause": "Clear explanation of the exact failure mechanism", + "explanation": "Detailed explanation of why this happened in this build environment and how Dequel/Docker ran into it", + "suggestedFixes": [ + { + "title": "Clear action title (e.g., Add missing dependency to package.json)", + "description": "Step-by-step description of what to do", + "actionType": "command" | "code" | "config" | "env", + "snippet": "bun add pg\n# or\nnpm install pg" + } + ] +} + +DO NOT include any text outside the JSON block. Return valid JSON only.`; diff --git a/apps/api/src/ai/providers/claude.ts b/apps/api/src/ai/providers/claude.ts new file mode 100644 index 0000000..96f0998 --- /dev/null +++ b/apps/api/src/ai/providers/claude.ts @@ -0,0 +1,42 @@ +import type { ProviderCallParams } from "../types"; + +export async function callClaude(params: ProviderCallParams): Promise { + const url = "https://api.anthropic.com/v1/messages"; + const body = { + model: params.model || "claude-3-5-sonnet-20241022", + max_tokens: 4096, + system: params.systemPrompt, + messages: [ + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-api-key": params.apiKey, + "anthropic-version": "2023-06-01", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Claude API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Claude: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.content?.[0]?.text; + if (!content) { + throw new Error("Claude returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/gemini.ts b/apps/api/src/ai/providers/gemini.ts new file mode 100644 index 0000000..bfe44d5 --- /dev/null +++ b/apps/api/src/ai/providers/gemini.ts @@ -0,0 +1,51 @@ +import type { ProviderCallParams } from "../types"; + +export async function callGemini(params: ProviderCallParams): Promise { + const model = params.model || "gemini-2.0-flash"; + const url = `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(params.apiKey)}`; + + const body = { + system_instruction: { + parts: [{ text: params.systemPrompt }], + }, + contents: [ + { + role: "user", + parts: [{ text: params.userPrompt }], + }, + ], + generationConfig: { + temperature: 0.2, + responseMimeType: "application/json", + }, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Gemini API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Gemini: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const candidate = data.candidates?.[0]; + const part = candidate?.content?.parts?.[0]; + const content = part?.text; + + if (!content) { + throw new Error("Gemini returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/grok.ts b/apps/api/src/ai/providers/grok.ts new file mode 100644 index 0000000..68bc79e --- /dev/null +++ b/apps/api/src/ai/providers/grok.ts @@ -0,0 +1,40 @@ +import type { ProviderCallParams } from "../types"; + +export async function callGrok(params: ProviderCallParams): Promise { + const url = "https://api.x.ai/v1/chat/completions"; + const body = { + model: params.model || "grok-2-latest", + messages: [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `Grok API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `Grok: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("Grok returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/providers/openai.ts b/apps/api/src/ai/providers/openai.ts new file mode 100644 index 0000000..6e3c813 --- /dev/null +++ b/apps/api/src/ai/providers/openai.ts @@ -0,0 +1,41 @@ +import type { ProviderCallParams } from "../types"; + +export async function callOpenAi(params: ProviderCallParams): Promise { + const url = "https://api.openai.com/v1/chat/completions"; + const body = { + model: params.model || "gpt-4o-mini", + messages: [ + { role: "system", content: params.systemPrompt }, + { role: "user", content: params.userPrompt }, + ], + temperature: 0.2, + response_format: { type: "json_object" }, + }; + + const res = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${params.apiKey}`, + }, + body: JSON.stringify(body), + }); + + if (!res.ok) { + const errorText = await res.text(); + let errorMsg = `OpenAI API error (${res.status})`; + try { + const errObj = JSON.parse(errorText); + if (errObj.error?.message) errorMsg = `OpenAI: ${errObj.error.message}`; + } catch {} + throw new Error(errorMsg); + } + + const data = (await res.json()) as any; + const content = data.choices?.[0]?.message?.content; + if (!content) { + throw new Error("OpenAI returned an empty response"); + } + + return content; +} diff --git a/apps/api/src/ai/test-connection.ts b/apps/api/src/ai/test-connection.ts new file mode 100644 index 0000000..0240cbc --- /dev/null +++ b/apps/api/src/ai/test-connection.ts @@ -0,0 +1,48 @@ +import type { AiProvider } from "./types"; +import { resolveProviderConfig } from "./diagnose"; +import { callOpenAi } from "./providers/openai"; +import { callGemini } from "./providers/gemini"; +import { callGrok } from "./providers/grok"; +import { callClaude } from "./providers/claude"; + +export async function testAiConnection( + providerReq: AiProvider, + apiKeyReq?: string, + modelReq?: string, +): Promise<{ ok: boolean; message: string; provider: AiProvider; model: string }> { + const { provider, model, apiKey } = await resolveProviderConfig( + providerReq, + modelReq, + apiKeyReq, + ); + + const systemPrompt = "You are a test ping responder. Respond with the single word: OK."; + const userPrompt = "Ping test"; + + try { + let output = ""; + if (provider === "openai") { + output = await callOpenAi({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "gemini") { + output = await callGemini({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "grok") { + output = await callGrok({ apiKey, model, systemPrompt, userPrompt }); + } else if (provider === "claude") { + output = await callClaude({ apiKey, model, systemPrompt, userPrompt }); + } + + return { + ok: true, + message: `Successfully connected to ${provider} (${model})`, + provider, + model, + }; + } catch (err: any) { + return { + ok: false, + message: err.message || `Failed to connect to ${provider}`, + provider, + model, + }; + } +} diff --git a/apps/api/src/ai/types.ts b/apps/api/src/ai/types.ts new file mode 100644 index 0000000..4c76684 --- /dev/null +++ b/apps/api/src/ai/types.ts @@ -0,0 +1,38 @@ +export type AiProvider = "openai" | "gemini" | "grok" | "claude"; + +export interface AiFixSuggestion { + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; +} + +export interface AiDiagnosisResult { + provider: AiProvider; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: AiFixSuggestion[]; + rawResponse?: string; +} + +export interface AiDiagnoseOptions { + deploymentId: string; + provider?: AiProvider; + model?: string; + apiKey?: string; + customPrompt?: string; +} + +export interface AiProviderConfig { + apiKey: string; + model: string; +} + +export interface ProviderCallParams { + apiKey: string; + model: string; + systemPrompt: string; + userPrompt: string; +} diff --git a/apps/api/src/api/deployments/ai.ts b/apps/api/src/api/deployments/ai.ts new file mode 100644 index 0000000..c14a526 --- /dev/null +++ b/apps/api/src/api/deployments/ai.ts @@ -0,0 +1,43 @@ +import { Elysia } from "elysia"; +import { getDeploymentById, getLatestAiDiagnosis } from "../../db/repo"; +import { diagnoseDeploymentFailure } from "../../ai"; +import { ok, fail } from "../response"; + +export const deploymentAiRoutes = new Elysia() + .post( + "/deployments/:id/ai-diagnose", + async ({ params: { id }, body, set }: any) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + + try { + const result = await diagnoseDeploymentFailure({ + deploymentId: id, + provider: body?.provider, + model: body?.model, + apiKey: body?.apiKey, + customPrompt: body?.customPrompt, + }); + return ok(result); + } catch (err: any) { + set.status = 400; + return fail(err.message || "Failed to analyze build failure with AI"); + } + }, + ) + .get( + "/deployments/:id/ai-diagnosis", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + + const diagnosis = await getLatestAiDiagnosis(id); + return ok(diagnosis); + }, + ); diff --git a/apps/api/src/api/deployments/index.ts b/apps/api/src/api/deployments/index.ts index 8146917..636b102 100644 --- a/apps/api/src/api/deployments/index.ts +++ b/apps/api/src/api/deployments/index.ts @@ -6,17 +6,17 @@ import { countDeployments, createDeployment, getDeploymentById, - getLogs, getProjectById, getServerById, listDeployments, } from "../../db/repo"; import { executorFor } from "../../executors/dispatch"; import { orchestrator } from "../../orchestrator"; -import { logBus } from "../../orchestrator/log-bus"; import { config } from "../../utils/config"; import { isPrivateGitUrl } from "../../utils/validate"; import { created, fail, ok } from "../response"; +import { deploymentAiRoutes } from "./ai"; +import { deploymentLogsRoutes } from "./logs"; const dispatchDeployment = async ( deployment: Awaited>, @@ -262,152 +262,5 @@ export const deploymentsRoutes = new Elysia() await executor.destroy({ deployment, project, server }); return ok(null, "Deployment deleted"); }) - .get("/deployments/:id/logs", async ({ params: { id }, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - return ok(await getLogs(id)); - }) - .get("/deployments/:id/logs/stream", async ({ params: { id }, request, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const encoder = new TextEncoder(); - let unsubscribe = () => undefined; - let heartbeat: ReturnType | null = null; - let closed = false; - const stop = () => { - if (closed) return; - closed = true; - unsubscribe(); - if (heartbeat) clearInterval(heartbeat); - }; - const stream = new ReadableStream({ - start(controller) { - const send = (eventName: string, payload: unknown) => { - if (closed) return; - controller.enqueue(encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`)); - }; - send("ready", { deploymentId: id }); - unsubscribe = logBus.subscribe(id, (event) => send("log", event)); - heartbeat = setInterval( - () => - send("heartbeat", { - at: new Date().toISOString(), - }), - 15000, - ); - }, - cancel: stop, - }); - request.signal.addEventListener("abort", stop, { - once: true, - }); - set.headers["content-type"] = "text/event-stream"; - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }) - .get("/deployments/:id/runtime-logs", async ({ params: { id }, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const { run } = await import("../../orchestrator/runtime"); - const containerName = deployment.containerName || `deploy-${id}`; - try { - const output = await run("docker", ["logs", "--tail", "200", containerName]); - const lines = output - .split("\n") - .filter(Boolean) - .map((line, i) => ({ - sequence: i + 1, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime" as const, - })); - return ok(lines); - } catch { - return ok([]); - } - }) - .get("/deployments/:id/runtime-logs/stream", async ({ params: { id }, request, set }) => { - const deployment = await getDeploymentById(id); - if (!deployment) { - set.status = 404; - return fail("Deployment not found"); - } - const encoder = new TextEncoder(); - const containerName = deployment.containerName || `deploy-${id}`; - let closed = false; - const stop = () => { - closed = true; - }; - request.signal.addEventListener("abort", stop, { - once: true, - }); - const stream = new ReadableStream({ - async start(controller) { - const send = (eventName: string, payload: unknown) => { - if (closed) return; - controller.enqueue(encoder.encode(`event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`)); - }; - const { spawn } = await import("node:child_process"); - const child = spawn("docker", ["logs", "--tail", "100", "--follow", containerName], { - stdio: ["ignore", "pipe", "pipe"], - }); - let seq = 0; - child.stdout.on("data", (chunk: Buffer) => { - const lines = chunk.toString().split("\n").filter(Boolean); - for (const line of lines) { - seq++; - send("log", { - sequence: seq, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime", - }); - } - }); - child.stderr.on("data", (chunk: Buffer) => { - const lines = chunk.toString().split("\n").filter(Boolean); - for (const line of lines) { - seq++; - send("log", { - sequence: seq, - message: line, - timestamp: new Date().toISOString(), - stage: "runtime", - }); - } - }); - child.on("close", () => send("close", { reason: "container stopped" })); - request.signal.addEventListener( - "abort", - () => { - child.kill(); - stop(); - }, - { once: true }, - ); - }, - cancel: stop, - }); - set.headers["content-type"] = "text/event-stream"; - return new Response(stream, { - headers: { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }, - }); - }); + .use(deploymentLogsRoutes) + .use(deploymentAiRoutes); diff --git a/apps/api/src/api/deployments/logs.ts b/apps/api/src/api/deployments/logs.ts new file mode 100644 index 0000000..55e881f --- /dev/null +++ b/apps/api/src/api/deployments/logs.ts @@ -0,0 +1,202 @@ +import { Elysia } from "elysia"; +import { getDeploymentById, getLogs } from "../../db/repo"; +import { logBus } from "../../orchestrator/log-bus"; +import { ok, fail } from "../response"; + +export const deploymentLogsRoutes = new Elysia() + .get( + "/deployments/:id/logs", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + return ok(await getLogs(id)); + }, + ) + .get( + "/deployments/:id/logs/stream", + async ({ params: { id }, request, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const encoder = new TextEncoder(); + let unsubscribe = () => undefined; + let heartbeat: ReturnType | null = null; + let closed = false; + const stop = () => { + if (closed) return; + closed = true; + unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + }; + const stream = new ReadableStream({ + start(controller) { + const send = (eventName: string, payload: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode( + `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + }; + send("ready", { deploymentId: id }); + unsubscribe = logBus.subscribe(id, (event) => + send("log", event), + ); + heartbeat = setInterval( + () => + send("heartbeat", { + at: new Date().toISOString(), + }), + 15000, + ); + }, + cancel: stop, + }); + request.signal.addEventListener("abort", stop, { + once: true, + }); + set.headers["content-type"] = "text/event-stream"; + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }, + ) + .get( + "/deployments/:id/runtime-logs", + async ({ params: { id }, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const { run } = await import("../../orchestrator/runtime"); + const containerName = + deployment.containerName || `deploy-${id}`; + try { + const output = await run("docker", [ + "logs", + "--tail", + "200", + containerName, + ]); + const lines = output + .split("\n") + .filter(Boolean) + .map((line, i) => ({ + sequence: i + 1, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime" as const, + })); + return ok(lines); + } catch { + return ok([]); + } + }, + ) + .get( + "/deployments/:id/runtime-logs/stream", + async ({ params: { id }, request, set }) => { + const deployment = await getDeploymentById(id); + if (!deployment) { + set.status = 404; + return fail("Deployment not found"); + } + const encoder = new TextEncoder(); + const containerName = + deployment.containerName || `deploy-${id}`; + let closed = false; + const stop = () => { + closed = true; + }; + request.signal.addEventListener("abort", stop, { + once: true, + }); + const stream = new ReadableStream({ + async start(controller) { + const send = (eventName: string, payload: unknown) => { + if (closed) return; + controller.enqueue( + encoder.encode( + `event: ${eventName}\ndata: ${JSON.stringify(payload)}\n\n`, + ), + ); + }; + const { spawn } = await import("node:child_process"); + const child = spawn( + "docker", + [ + "logs", + "--tail", + "100", + "--follow", + containerName, + ], + { + stdio: ["ignore", "pipe", "pipe"], + }, + ); + let seq = 0; + child.stdout.on("data", (chunk: Buffer) => { + const lines = chunk + .toString() + .split("\n") + .filter(Boolean); + for (const line of lines) { + seq++; + send("log", { + sequence: seq, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime", + }); + } + }); + child.stderr.on("data", (chunk: Buffer) => { + const lines = chunk + .toString() + .split("\n") + .filter(Boolean); + for (const line of lines) { + seq++; + send("log", { + sequence: seq, + message: line, + timestamp: new Date().toISOString(), + stage: "runtime", + }); + } + }); + child.on("close", () => + send("close", { reason: "container stopped" }), + ); + request.signal.addEventListener( + "abort", + () => { + child.kill(); + stop(); + }, + { once: true }, + ); + }, + cancel: stop, + }); + set.headers["content-type"] = "text/event-stream"; + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }, + ); diff --git a/apps/api/src/api/settings/index.ts b/apps/api/src/api/settings/index.ts index 4a524f7..a3be050 100644 --- a/apps/api/src/api/settings/index.ts +++ b/apps/api/src/api/settings/index.ts @@ -96,4 +96,39 @@ export const settingsRoutes = new Elysia({ prefix: "/settings" }) set.status = 400; return fail(err.message); } + }) + + .get("/ai", async () => { + const { getPublicAiSettings } = await import("../../db/repo"); + const settings = await getPublicAiSettings(); + return ok(settings); + }) + + .put("/ai", async ({ body }: any) => { + const { upsertAiSettings } = await import("../../db/repo"); + await upsertAiSettings({ + defaultProvider: body?.defaultProvider, + openaiApiKey: body?.openaiApiKey, + openaiModel: body?.openaiModel, + geminiApiKey: body?.geminiApiKey, + geminiModel: body?.geminiModel, + grokApiKey: body?.grokApiKey, + grokModel: body?.grokModel, + claudeApiKey: body?.claudeApiKey, + claudeModel: body?.claudeModel, + }); + return ok(null, "AI settings updated"); + }) + + .post("/ai/test", async ({ body, set }: any) => { + const provider = body?.provider || "openai"; + const apiKey = body?.apiKey; + const model = body?.model; + const { testAiConnection } = await import("../../ai"); + const result = await testAiConnection(provider, apiKey, model); + if (!result.ok) { + set.status = 400; + return fail(result.message); + } + return ok(result); }); diff --git a/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql b/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql new file mode 100644 index 0000000..1a1d70f --- /dev/null +++ b/apps/api/src/db/migrations/0004_add_ai_settings_and_diagnoses.sql @@ -0,0 +1,38 @@ +CREATE TABLE IF NOT EXISTS "ai_settings" ( + "id" text PRIMARY KEY NOT NULL, + "default_provider" text DEFAULT 'openai' NOT NULL, + "openai_api_key_encrypted" text, + "openai_api_key_iv" text, + "openai_api_key_tag" text, + "openai_model" text DEFAULT 'gpt-4o-mini' NOT NULL, + "gemini_api_key_encrypted" text, + "gemini_api_key_iv" text, + "gemini_api_key_tag" text, + "gemini_model" text DEFAULT 'gemini-2.0-flash' NOT NULL, + "grok_api_key_encrypted" text, + "grok_api_key_iv" text, + "grok_api_key_tag" text, + "grok_model" text DEFAULT 'grok-2-latest' NOT NULL, + "claude_api_key_encrypted" text, + "claude_api_key_iv" text, + "claude_api_key_tag" text, + "claude_model" text DEFAULT 'claude-3-5-sonnet-20241022' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); + +CREATE TABLE IF NOT EXISTS "ai_diagnoses" ( + "id" text PRIMARY KEY NOT NULL, + "deployment_id" text NOT NULL, + "provider" text NOT NULL, + "model" text NOT NULL, + "summary" text NOT NULL, + "root_cause" text NOT NULL, + "explanation" text NOT NULL, + "suggested_fixes" jsonb DEFAULT '[]'::jsonb NOT NULL, + "raw_response" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "ai_diagnoses_deployment_id_deployments_id_fk" FOREIGN KEY ("deployment_id") REFERENCES "public"."deployments"("id") ON DELETE cascade ON UPDATE no action +); + +CREATE INDEX IF NOT EXISTS "idx_ai_diagnoses_deployment" ON "ai_diagnoses" ("deployment_id"); diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index 3a68738..66268e3 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1788000000000, "tag": "0003_add_server_ssh_key_encryption", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1788100000000, + "tag": "0004_add_ai_settings_and_diagnoses", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/ai-settings.ts b/apps/api/src/db/repo/ai-settings.ts new file mode 100644 index 0000000..a52f784 --- /dev/null +++ b/apps/api/src/db/repo/ai-settings.ts @@ -0,0 +1,244 @@ +import { eq, desc } from "drizzle-orm"; +import { getDb } from "../db-provider"; +import { aiSettings, aiDiagnoses } from "../schema"; +import { encryptValue, decryptValue } from "../../utils/crypto"; +import { config } from "../../utils/config"; +import { randomUUID } from "node:crypto"; + +export type AiProviderType = "openai" | "gemini" | "grok" | "claude"; + +export interface AiSettingsData { + defaultProvider: AiProviderType; + openaiApiKey?: string; + openaiModel: string; + geminiApiKey?: string; + geminiModel: string; + grokApiKey?: string; + grokModel: string; + claudeApiKey?: string; + claudeModel: string; +} + +export interface AiSettingsPublic { + defaultProvider: AiProviderType; + openaiConfigured: boolean; + openaiModel: string; + geminiConfigured: boolean; + geminiModel: string; + grokConfigured: boolean; + grokModel: string; + claudeConfigured: boolean; + claudeModel: string; +} + +export interface AiDiagnosisRecord { + id: string; + deploymentId: string; + provider: AiProviderType; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: Array<{ + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; + }>; + rawResponse?: string | null; + createdAt: Date; +} + +const SETTINGS_ID = "default"; + +const decryptOptional = (encrypted: string | null, iv: string | null, tag: string | null): string | undefined => { + if (!encrypted || !iv || !tag) return undefined; + try { + return decryptValue(encrypted, iv, tag, config.envEncryptionKey); + } catch { + return undefined; + } +}; + +export const getAiSettings = async (): Promise => { + try { + const db = getDb(); + if (!db || typeof db.select !== "function") throw new Error("DB not ready"); + const rows = await db + .select() + .from(aiSettings) + .where(eq(aiSettings.id, SETTINGS_ID)) + .limit(1); + + if (rows.length === 0) { + return { + defaultProvider: "openai", + openaiModel: "gpt-4o-mini", + geminiModel: "gemini-2.0-flash", + grokModel: "grok-2-latest", + claudeModel: "claude-3-5-sonnet-20241022", + }; + } + + const row = rows[0]; + return { + defaultProvider: (row.defaultProvider as AiProviderType) || "openai", + openaiApiKey: decryptOptional(row.openaiApiKeyEncrypted, row.openaiApiKeyIv, row.openaiApiKeyTag), + openaiModel: row.openaiModel || "gpt-4o-mini", + geminiApiKey: decryptOptional(row.geminiApiKeyEncrypted, row.geminiApiKeyIv, row.geminiApiKeyTag), + geminiModel: row.geminiModel || "gemini-2.0-flash", + grokApiKey: decryptOptional(row.grokApiKeyEncrypted, row.grokApiKeyIv, row.grokApiKeyTag), + grokModel: row.grokModel || "grok-2-latest", + claudeApiKey: decryptOptional(row.claudeApiKeyEncrypted, row.claudeApiKeyIv, row.claudeApiKeyTag), + claudeModel: row.claudeModel || "claude-3-5-sonnet-20241022", + }; + } catch { + return { + defaultProvider: "openai", + openaiModel: "gpt-4o-mini", + geminiModel: "gemini-2.0-flash", + grokModel: "grok-2-latest", + claudeModel: "claude-3-5-sonnet-20241022", + }; + } +}; + +export const getPublicAiSettings = async (): Promise => { + const settings = await getAiSettings(); + return { + defaultProvider: settings.defaultProvider, + openaiConfigured: Boolean(settings.openaiApiKey || process.env.OPENAI_API_KEY), + openaiModel: settings.openaiModel, + geminiConfigured: Boolean(settings.geminiApiKey || process.env.GEMINI_API_KEY), + geminiModel: settings.geminiModel, + grokConfigured: Boolean(settings.grokApiKey || process.env.GROK_API_KEY || process.env.XAI_API_KEY), + grokModel: settings.grokModel, + claudeConfigured: Boolean(settings.claudeApiKey || process.env.ANTHROPIC_API_KEY || process.env.CLAUDE_API_KEY), + claudeModel: settings.claudeModel, + }; +}; + +export const upsertAiSettings = async (input: Partial): Promise => { + const db = getDb(); + const existingRows = await db + .select() + .from(aiSettings) + .where(eq(aiSettings.id, SETTINGS_ID)) + .limit(1); + + const existing = existingRows[0]; + + const openaiEnc = input.openaiApiKey !== undefined + ? (input.openaiApiKey ? encryptValue(input.openaiApiKey, config.envEncryptionKey) : null) + : undefined; + const geminiEnc = input.geminiApiKey !== undefined + ? (input.geminiApiKey ? encryptValue(input.geminiApiKey, config.envEncryptionKey) : null) + : undefined; + const grokEnc = input.grokApiKey !== undefined + ? (input.grokApiKey ? encryptValue(input.grokApiKey, config.envEncryptionKey) : null) + : undefined; + const claudeEnc = input.claudeApiKey !== undefined + ? (input.claudeApiKey ? encryptValue(input.claudeApiKey, config.envEncryptionKey) : null) + : undefined; + + const valuesToSave = { + defaultProvider: input.defaultProvider ?? existing?.defaultProvider ?? "openai", + openaiModel: input.openaiModel ?? existing?.openaiModel ?? "gpt-4o-mini", + geminiModel: input.geminiModel ?? existing?.geminiModel ?? "gemini-2.0-flash", + grokModel: input.grokModel ?? existing?.grokModel ?? "grok-2-latest", + claudeModel: input.claudeModel ?? existing?.claudeModel ?? "claude-3-5-sonnet-20241022", + openaiApiKeyEncrypted: openaiEnc !== undefined ? (openaiEnc?.encrypted ?? null) : (existing?.openaiApiKeyEncrypted ?? null), + openaiApiKeyIv: openaiEnc !== undefined ? (openaiEnc?.iv ?? null) : (existing?.openaiApiKeyIv ?? null), + openaiApiKeyTag: openaiEnc !== undefined ? (openaiEnc?.tag ?? null) : (existing?.openaiApiKeyTag ?? null), + geminiApiKeyEncrypted: geminiEnc !== undefined ? (geminiEnc?.encrypted ?? null) : (existing?.geminiApiKeyEncrypted ?? null), + geminiApiKeyIv: geminiEnc !== undefined ? (geminiEnc?.iv ?? null) : (existing?.geminiApiKeyIv ?? null), + geminiApiKeyTag: geminiEnc !== undefined ? (geminiEnc?.tag ?? null) : (existing?.geminiApiKeyTag ?? null), + grokApiKeyEncrypted: grokEnc !== undefined ? (grokEnc?.encrypted ?? null) : (existing?.grokApiKeyEncrypted ?? null), + grokApiKeyIv: grokEnc !== undefined ? (grokEnc?.iv ?? null) : (existing?.grokApiKeyIv ?? null), + grokApiKeyTag: grokEnc !== undefined ? (grokEnc?.tag ?? null) : (existing?.grokApiKeyTag ?? null), + claudeApiKeyEncrypted: claudeEnc !== undefined ? (claudeEnc?.encrypted ?? null) : (existing?.claudeApiKeyEncrypted ?? null), + claudeApiKeyIv: claudeEnc !== undefined ? (claudeEnc?.iv ?? null) : (existing?.claudeApiKeyIv ?? null), + claudeApiKeyTag: claudeEnc !== undefined ? (claudeEnc?.tag ?? null) : (existing?.claudeApiKeyTag ?? null), + updatedAt: new Date(), + }; + + if (existing) { + await db + .update(aiSettings) + .set(valuesToSave) + .where(eq(aiSettings.id, SETTINGS_ID)); + } else { + await db.insert(aiSettings).values({ + id: SETTINGS_ID, + ...valuesToSave, + }); + } +}; + +export const saveAiDiagnosis = async (input: { + deploymentId: string; + provider: AiProviderType; + model: string; + summary: string; + rootCause: string; + explanation: string; + suggestedFixes: Array<{ + title: string; + description: string; + actionType?: "command" | "code" | "config" | "env"; + snippet?: string; + }>; + rawResponse?: string | null; +}): Promise => { + const db = getDb(); + const id = randomUUID(); + const values = { + id, + deploymentId: input.deploymentId, + provider: input.provider, + model: input.model, + summary: input.summary, + rootCause: input.rootCause, + explanation: input.explanation, + suggestedFixes: input.suggestedFixes, + rawResponse: input.rawResponse ?? null, + }; + + await db.insert(aiDiagnoses).values(values); + + return { + ...values, + createdAt: new Date(), + }; +}; + +export const getLatestAiDiagnosis = async (deploymentId: string): Promise => { + const db = getDb(); + const rows = await db + .select() + .from(aiDiagnoses) + .where(eq(aiDiagnoses.deploymentId, deploymentId)) + .orderBy(desc(aiDiagnoses.createdAt)) + .limit(1); + + if (rows.length === 0) return null; + + const row = rows[0]; + return { + id: row.id, + deploymentId: row.deploymentId, + provider: row.provider as AiProviderType, + model: row.model, + summary: row.summary, + rootCause: row.rootCause, + explanation: row.explanation, + suggestedFixes: (row.suggestedFixes as any) || [], + rawResponse: row.rawResponse, + createdAt: row.createdAt, + }; +}; + +export const deleteAiDiagnosesByDeployment = async (deploymentId: string): Promise => { + const db = getDb(); + await db.delete(aiDiagnoses).where(eq(aiDiagnoses.deploymentId, deploymentId)); +}; diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index 54a1b31..7a8189c 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -16,9 +16,17 @@ export { updateAgentHeartbeat, validateAgentCredential, } from "./agents"; +export { + deleteAiDiagnosesByDeployment, + getAiSettings, + getLatestAiDiagnosis, + getPublicAiSettings, + saveAiDiagnosis, + upsertAiSettings, +} from "./ai-settings"; +export type { AiDiagnosisRecord, AiProviderType, AiSettingsData, AiSettingsPublic } from "./ai-settings"; export { createAlert, deleteAlert, getAlertById, listAlerts, updateAlertEnabled } from "./alerts"; export { createApiKey, deleteApiKey, listApiKeys, validateApiKey } from "./api-keys"; - export { createDatabase, deleteDatabase, diff --git a/apps/api/src/db/repo/routes.ts b/apps/api/src/db/repo/routes.ts index 3ac9d59..7fefd74 100644 --- a/apps/api/src/db/repo/routes.ts +++ b/apps/api/src/db/repo/routes.ts @@ -198,12 +198,6 @@ export const listRoutesByDeployment = async (deploymentId: string): Promise => { - const db = await getDb(); - const rows = await db.select().from(routes).where(eq(routes.deploymentId, deploymentId)).execute(); - return rows.map(mapRoute); -}; - export const deleteRoute = async (id: string): Promise => { const db = await getDb(); await db.delete(routes).where(eq(routes.id, id)).execute(); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index 2a1edcf..75e39de 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -405,3 +405,46 @@ export const routes = pgTable( }, (table) => [uniqueIndex("idx_routes_hostname_server").on(table.hostname, table.serverId)], ); + +export const aiSettings = pgTable("ai_settings", { + id: text().primaryKey(), + defaultProvider: text("default_provider").notNull().default("openai"), + openaiApiKeyEncrypted: text("openai_api_key_encrypted"), + openaiApiKeyIv: text("openai_api_key_iv"), + openaiApiKeyTag: text("openai_api_key_tag"), + openaiModel: text("openai_model").notNull().default("gpt-4o-mini"), + geminiApiKeyEncrypted: text("gemini_api_key_encrypted"), + geminiApiKeyIv: text("gemini_api_key_iv"), + geminiApiKeyTag: text("gemini_api_key_tag"), + geminiModel: text("gemini_model").notNull().default("gemini-2.0-flash"), + grokApiKeyEncrypted: text("grok_api_key_encrypted"), + grokApiKeyIv: text("grok_api_key_iv"), + grokApiKeyTag: text("grok_api_key_tag"), + grokModel: text("grok_model").notNull().default("grok-2-latest"), + claudeApiKeyEncrypted: text("claude_api_key_encrypted"), + claudeApiKeyIv: text("claude_api_key_iv"), + claudeApiKeyTag: text("claude_api_key_tag"), + claudeModel: text("claude_model").notNull().default("claude-3-5-sonnet-20241022"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const aiDiagnoses = pgTable( + "ai_diagnoses", + { + id: text().primaryKey(), + deploymentId: text("deployment_id").notNull(), + provider: text().notNull(), + model: text().notNull(), + summary: text().notNull(), + rootCause: text("root_cause").notNull(), + explanation: text().notNull(), + suggestedFixes: jsonb("suggested_fixes").notNull().default([]), + rawResponse: text("raw_response"), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }), + index("idx_ai_diagnoses_deployment").on(table.deploymentId), + ], +); diff --git a/apps/api/src/db/test-helper.ts b/apps/api/src/db/test-helper.ts index aee307f..fd53cce 100644 --- a/apps/api/src/db/test-helper.ts +++ b/apps/api/src/db/test-helper.ts @@ -1,4 +1,6 @@ +import { join } from "node:path"; import { drizzle } from "drizzle-orm/node-postgres"; +import { migrate as drizzleMigrate } from "drizzle-orm/node-postgres/migrator"; import { Pool } from "pg"; import { setDbProvider } from "./db-provider"; import * as schema from "./schema"; @@ -20,11 +22,16 @@ const TABLE_NAMES = [ "github_integrations", "platform_settings", "projects", + "project_shared_env_links", "refresh_tokens", "routes", "scaling_policies", "servers", + "shared_env_vars", "smtp_settings", + "ssh_keys", + "ai_settings", + "ai_diagnoses", "volumes", ]; @@ -34,12 +41,22 @@ export const setupTestDb = async () => { const pool = createTestPool(); const db = drizzle(pool, { schema }); setDbProvider(async () => db); + const migrationsFolder = join(import.meta.dirname, "migrations"); + try { + await drizzleMigrate(db, { migrationsFolder }); + } catch (err: any) { + if (!err?.message?.includes("already exists") && err?.code !== "42P07") { + console.warn("[setupTestDb] Migration warning:", err); + } + } return { db, pool }; }; export const truncateAllTables = async (pool: Pool) => { for (const name of TABLE_NAMES) { - await pool.query(`TRUNCATE TABLE "${name}" CASCADE`); + try { + await pool.query(`TRUNCATE TABLE "${name}" CASCADE`); + } catch {} } }; diff --git a/apps/web/src/api/ai.ts b/apps/web/src/api/ai.ts new file mode 100644 index 0000000..74570f1 --- /dev/null +++ b/apps/web/src/api/ai.ts @@ -0,0 +1,42 @@ +import { apiFetch } from "./client"; +import type { + AiDiagnosis, + AiProvider, + AiSettingsInput, + AiSettingsStatus, +} from "../types"; + +export const getAiSettings = () => apiFetch("/settings/ai"); + +export const updateAiSettings = (data: AiSettingsInput) => + apiFetch("/settings/ai", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const testAiConnection = (data: { + provider: AiProvider; + apiKey?: string; + model?: string; +}) => + apiFetch<{ ok: boolean; message: string; provider: AiProvider; model: string }>("/settings/ai/test", { + method: "POST", + body: JSON.stringify(data), + }); + +export const diagnoseDeploymentFailure = ( + deploymentId: string, + options?: { + provider?: AiProvider; + model?: string; + apiKey?: string; + customPrompt?: string; + }, +) => + apiFetch(`/deployments/${deploymentId}/ai-diagnose`, { + method: "POST", + body: JSON.stringify(options || {}), + }); + +export const getDeploymentAiDiagnosis = (deploymentId: string) => + apiFetch(`/deployments/${deploymentId}/ai-diagnosis`); diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 48fdb10..b05c228 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -16,9 +16,9 @@ import type { Volume, } from "../types"; -const BASE = "/api"; +export const BASE = "/api"; -class ApiError extends Error { +export class ApiError extends Error { status: number; constructor(msg: string, status: number) { super(msg); @@ -26,7 +26,7 @@ class ApiError extends Error { } } -const apiFetch = async (path: string, opts?: RequestInit): Promise => { +export const apiFetch = async (path: string, opts?: RequestInit): Promise => { const isFormData = opts?.body instanceof FormData; const headers: Record = {}; if (!isFormData) headers["Content-Type"] = "application/json"; @@ -50,6 +50,9 @@ const apiFetch = async (path: string, opts?: RequestInit): Promise => { return json as T; }; +// AI helpers +export * from "./ai"; + // Projects export const listProjects = () => apiFetch("/projects"); export const getProject = (id: string) => apiFetch(`/projects/${id}`); diff --git a/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx b/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx new file mode 100644 index 0000000..0dd3474 --- /dev/null +++ b/apps/web/src/components/project/deployments/AiBuildFixDialog.tsx @@ -0,0 +1,432 @@ +import { useState, useEffect } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, +} from "../../ui/dialog"; +import { Button } from "../../ui/button"; +import { Badge } from "../../ui/badge"; +import { + Sparkles, + Terminal, + FileCode, + Sliders, + Key, + Copy, + Check, + RefreshCw, + AlertTriangle, + ArrowRight, + Bot, + Loader2, +} from "lucide-react"; +import * as api from "../../../api/client"; +import type { AiProvider, AiDiagnosis } from "../../../types"; + +interface AiBuildFixDialogProps { + open: boolean; + onOpenChange: (open: boolean) => void; + deploymentId: string; + projectName?: string; + failureReason?: string | null; +} + +const PROVIDERS: Array<{ + id: AiProvider; + name: string; + models: string[]; + iconColor: string; +}> = [ + { + id: "openai", + name: "OpenAI", + models: ["gpt-4o-mini", "gpt-4o", "o3-mini"], + iconColor: "text-emerald-400", + }, + { + id: "gemini", + name: "Gemini", + models: ["gemini-2.0-flash", "gemini-1.5-pro"], + iconColor: "text-blue-400", + }, + { + id: "grok", + name: "Grok", + models: ["grok-2-latest", "grok-2"], + iconColor: "text-purple-400", + }, + { + id: "claude", + name: "Claude", + models: ["claude-3-5-sonnet-20241022", "claude-3-5-haiku-20241022"], + iconColor: "text-amber-400", + }, +]; + +export function AiBuildFixDialog({ + open, + onOpenChange, + deploymentId, + projectName, + failureReason, +}: AiBuildFixDialogProps) { + const { data: aiSettings } = useQuery({ + queryKey: ["aiSettings"], + queryFn: () => api.getAiSettings().catch(() => null), + enabled: open, + }); + + const { data: cachedDiagnosis, refetch: refetchCached } = useQuery({ + queryKey: ["aiDiagnosis", deploymentId], + queryFn: () => api.getDeploymentAiDiagnosis(deploymentId).catch(() => null), + enabled: open && !!deploymentId, + }); + + const [selectedProvider, setSelectedProvider] = useState("openai"); + const [selectedModel, setSelectedModel] = useState("gpt-4o-mini"); + const [apiKeyOverride, setApiKeyOverride] = useState(""); + const [customPrompt, setCustomPrompt] = useState(""); + + const [isAnalyzing, setIsAnalyzing] = useState(false); + const [analysisError, setAnalysisError] = useState(null); + const [diagnosis, setDiagnosis] = useState(null); + const [copiedSnippetIndex, setCopiedSnippetIndex] = useState(null); + const [showOptions, setShowOptions] = useState(false); + + useEffect(() => { + if (aiSettings) { + const prov = aiSettings.defaultProvider || "openai"; + setSelectedProvider(prov); + const found = PROVIDERS.find((p) => p.id === prov); + if (found && found.models.length > 0) { + setSelectedModel(found.models[0]); + } + } + }, [aiSettings]); + + useEffect(() => { + if (cachedDiagnosis) { + setDiagnosis(cachedDiagnosis); + } else { + setDiagnosis(null); + } + }, [cachedDiagnosis, deploymentId]); + + const handleProviderChange = (prov: AiProvider) => { + setSelectedProvider(prov); + const found = PROVIDERS.find((p) => p.id === prov); + if (found && found.models.length > 0) { + setSelectedModel(found.models[0]); + } + }; + + const handleRunDiagnosis = async () => { + setIsAnalyzing(true); + setAnalysisError(null); + try { + const result = await api.diagnoseDeploymentFailure(deploymentId, { + provider: selectedProvider, + model: selectedModel, + apiKey: apiKeyOverride.trim() || undefined, + customPrompt: customPrompt.trim() || undefined, + }); + setDiagnosis(result); + refetchCached(); + } catch (err: any) { + setAnalysisError(err.message || "AI build diagnosis failed. Please verify API keys or try another provider."); + } finally { + setIsAnalyzing(false); + } + }; + + const handleCopy = (text: string, index: number) => { + navigator.clipboard.writeText(text); + setCopiedSnippetIndex(index); + setTimeout(() => setCopiedSnippetIndex(null), 2500); + }; + + const getActionIcon = (actionType?: string) => { + if (actionType === "command") return ; + if (actionType === "code") return ; + if (actionType === "env") return ; + return ; + }; + + return ( + + + +
+ + + AI Build Failure Diagnosis & Fix + + + Failed Build + +
+ + Project: {projectName || "Deployment"} + + ID: {deploymentId.slice(0, 8)} + {failureReason && ( + <> + + {failureReason} + + )} + +
+ +
+ {/* Provider Selection Toolbar */} +
+
+ Select AI Provider: + +
+ +
+ {PROVIDERS.map((p) => { + const isSelected = selectedProvider === p.id; + return ( + + ); + })} +
+ + {showOptions && ( +
+
+
+ + +
+ +
+ + setApiKeyOverride(e.target.value)} + className="h-8 w-full rounded border border-border bg-[#15151c] px-2.5 text-xs text-foreground placeholder:text-muted-foreground font-mono focus:outline-none focus:ring-1 focus:ring-primary" + /> +
+
+ +
+ +