diff --git a/.gitignore b/.gitignore index 5d04e06..2fbab5d 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ infra/caddy/routes/ bugs apps/api/index docker-compose.yml +docker-compose.override.yml bump.sh scripts/workflow/bump.sh __pycache__ diff --git a/VERSION b/VERSION index 0d91a54..60a2d3e 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.0 +0.4.0 \ No newline at end of file diff --git a/apps/agent/package.json b/apps/agent/package.json index da8553e..b0b2f89 100644 --- a/apps/agent/package.json +++ b/apps/agent/package.json @@ -1,6 +1,6 @@ { "name": "dequel-agent", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/apps/api/Dockerfile.sandbox b/apps/api/Dockerfile.sandbox new file mode 100644 index 0000000..f032e94 --- /dev/null +++ b/apps/api/Dockerfile.sandbox @@ -0,0 +1,23 @@ +FROM oven/bun:1 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + ca-certificates \ + git \ + ripgrep \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /runner +COPY package.sandbox.json ./package.json +RUN bun install --production + +COPY src/fixdiag/sandbox-runner/ ./fixdiag/sandbox-runner/ +COPY src/fixdiag/types.ts ./fixdiag/types.ts +COPY src/fixdiag/llm.ts ./fixdiag/llm.ts +RUN rm -rf ./fixdiag/sandbox-runner/__tests__ + +RUN mkdir -p /srv/jobs /srv/dequel-src /srv/project-src \ + && chown -R bun:bun /srv/jobs /srv/dequel-src /srv/project-src + +USER bun + +CMD ["sleep", "infinity"] diff --git a/apps/api/package.json b/apps/api/package.json index 388f427..58d7fb5 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -1,6 +1,6 @@ { "name": "dequel-api", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { @@ -10,6 +10,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1130.0", + "@ax-llm/ax": "^25.0.0", "@elysiajs/cors": "^1.1.1", "@sinclair/typebox": "^0.34.13", "drizzle-orm": "^0.45.2", diff --git a/apps/api/package.sandbox.json b/apps/api/package.sandbox.json new file mode 100644 index 0000000..3b320e6 --- /dev/null +++ b/apps/api/package.sandbox.json @@ -0,0 +1,7 @@ +{ + "name": "dequel-diag-sandbox", + "private": true, + "dependencies": { + "@ax-llm/ax": "^25.0.0" + } +} diff --git a/apps/api/src/api/index.ts b/apps/api/src/api/index.ts index e94896b..571912f 100644 --- a/apps/api/src/api/index.ts +++ b/apps/api/src/api/index.ts @@ -19,6 +19,8 @@ import { scalingRoutes } from "./scaling"; import { serverInfoRoutes } from "./server-info"; import { serversRoutes } from "./servers"; import { settingsRoutes } from "./settings"; +import { llmSettingsRoutes } from "./settings/llm"; +import { fixdiagRoutes } from "../fixdiag/routes"; import { sharedEnvLinksRoutes, sharedEnvVarsRoutes } from "./shared-env-vars"; import { sshKeysRoutes } from "./ssh-keys"; import { volumesRoutes } from "./volumes"; @@ -114,5 +116,7 @@ export const apiRoutes = new Elysia({ .use(alertsRoutes) .use(githubRoutes) .use(settingsRoutes) + .use(llmSettingsRoutes) + .use(fixdiagRoutes) .use(routesRoutes) .use(backupRoutes); diff --git a/apps/api/src/api/settings/llm.ts b/apps/api/src/api/settings/llm.ts new file mode 100644 index 0000000..d215073 --- /dev/null +++ b/apps/api/src/api/settings/llm.ts @@ -0,0 +1,118 @@ +import { Elysia } from "elysia"; +import { + LLM_PROVIDERS, + deleteLlmKey, + getDecryptedLlmKey, + getLlmKeyStatus, + updateLlmModels, + upsertLlmKey, +} from "../../db/repo"; +import { fetchProviderModels, DEFAULT_PROVIDER_MODELS } from "../../fixdiag/provider-models"; +import { fail, ok } from "../response"; + +export const llmSettingsRoutes = new Elysia({ prefix: "/settings" }) + .get("/llm-keys", async () => ok(await getLlmKeyStatus())) + .get("/llm-default-models", async () => ok(DEFAULT_PROVIDER_MODELS)) + .get("/llm-keys/:provider/models", async ({ params, query, set }: any) => { + const provider = String(params.provider); + if (!(LLM_PROVIDERS as readonly string[]).includes(provider)) { + set.status = 400; + return fail(`provider must be one of: ${LLM_PROVIDERS.join(", ")}`); + } + const existing = await getDecryptedLlmKey(provider); + if (!existing) { + set.status = 404; + return fail("Provider key not configured"); + } + const refresh = query?.refresh === "true" || existing.models.length === 0; + if (refresh) { + const models = await fetchProviderModels({ + provider, + apiKey: existing.apiKey, + baseUrl: existing.baseUrl, + }); + if (models.length > 0) { + await updateLlmModels(provider, models); + return ok({ provider, models, cached: false }); + } + } + return ok({ provider, models: existing.models, cached: true }); + }) + .post("/llm-keys/:provider/sync", async ({ params, set }: any) => { + const provider = String(params.provider); + if (!(LLM_PROVIDERS as readonly string[]).includes(provider)) { + set.status = 400; + return fail(`provider must be one of: ${LLM_PROVIDERS.join(", ")}`); + } + const existing = await getDecryptedLlmKey(provider); + if (!existing) { + set.status = 404; + return fail("Provider key not configured"); + } + const models = await fetchProviderModels({ + provider, + apiKey: existing.apiKey, + baseUrl: existing.baseUrl, + }); + await updateLlmModels(provider, models); + return ok({ provider, models }, `Synced ${models.length} models for ${provider}`); + }) + .put("/llm-keys", async ({ body, set }: any) => { + const provider = String(body?.provider ?? ""); + if (!(LLM_PROVIDERS as readonly string[]).includes(provider)) { + set.status = 400; + return fail(`provider must be one of: ${LLM_PROVIDERS.join(", ")}`); + } + if (body?.apiKey !== undefined && typeof body.apiKey !== "string") { + set.status = 400; + return fail("apiKey must be a string"); + } + const existing = await getDecryptedLlmKey(provider); + if (provider === "custom") { + if (!body?.baseURL && !existing?.baseUrl) { + set.status = 400; + return fail("baseURL is required for the custom provider"); + } + } + const apiKey = + typeof body?.apiKey === "string" && body.apiKey.length > 0 ? body.apiKey : provider === "ollama" ? "ollama" : ""; + const effectiveBaseUrl = body?.baseURL !== undefined ? body.baseURL : existing?.baseUrl; + const baseChanged = body?.baseURL !== undefined && body.baseURL !== existing?.baseUrl; + if (baseChanged && !apiKey && existing?.apiKey) { + set.status = 400; + return fail("apiKey is required when changing baseURL"); + } + const effectiveApiKey = apiKey || existing?.apiKey || ""; + + let models: string[] | undefined = Array.isArray(body?.models) ? body.models.map(String) : undefined; + if (!models || models.length === 0) { + try { + const pulled = await fetchProviderModels({ + provider, + apiKey: effectiveApiKey, + baseUrl: effectiveBaseUrl, + }); + if (pulled.length > 0) { + models = pulled; + } + } catch { + models = existing?.models ?? undefined; + } + } + + const status = await upsertLlmKey({ + provider, + apiKey, + baseURL: body?.baseURL, + models, + }); + return ok(status, "LLM provider key updated"); + }) + .delete("/llm-keys/:provider", async ({ params, set }: any) => { + const deleted = await deleteLlmKey(String(params.provider)); + if (!deleted) { + set.status = 404; + return fail("LLM provider key not found"); + } + return ok(null, "LLM provider key deleted"); + }); diff --git a/apps/api/src/db/__tests__/diag-runs.test.ts b/apps/api/src/db/__tests__/diag-runs.test.ts new file mode 100644 index 0000000..88462a2 --- /dev/null +++ b/apps/api/src/db/__tests__/diag-runs.test.ts @@ -0,0 +1,84 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../db-provider"; +import * as schema from "../schema"; +import { createTestPool, truncateAllTables } from "../test-helper"; +import { + createDiagRun, + finishDiagRun, + getDiagRun, + getStagePayload, + listActiveDiagRuns, + listStageResults, + markInterruptedDiagRuns, + recordStageResult, +} from "../repo/diag-runs"; + +let pool: Pool; + +mock.restore(); + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + await truncateAllTables(pool); + await pool.query( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) + VALUES ('proj-diagruns', 'DiagRuns Project', 'git', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + await pool.query( + `INSERT INTO deployments (id, project_id, source_type, source_ref, status, created_at, updated_at) + VALUES ('dep-x', 'proj-diagruns', 'git', 'https://github.com/test/repo.git', 'failed', NOW(), NOW()), + ('dep-dup', 'proj-diagruns', 'git', 'https://github.com/test/repo.git', 'failed', NOW(), NOW()) + ON CONFLICT DO NOTHING`, + ); +}); + +afterAll(async () => { + try { + await truncateAllTables(pool); + } finally { + await pool.end(); + } +}); + +describe("diag-runs", () => { + it("creates, finds and finishes runs", async () => { + const run = await createDiagRun({ deploymentId: "dep-x", commitSha: "abc", provider: "groq", model: "m" }); + expect(run.status).toBe("running"); + expect(run.currentStage).toBeNull(); + + await recordStageResult(run.id, "triage", { failingStage: "build" }); + expect(await getStagePayload(run.id, "triage")).toEqual({ failingStage: "build" }); + + await recordStageResult(run.id, "triage", { failingStage: "deploy" }); + expect(await getStagePayload(run.id, "triage")).toEqual({ failingStage: "deploy" }); + expect((await listStageResults(run.id)).filter((s) => s.stage === "triage")).toHaveLength(1); + + await finishDiagRun(run.id, "done", "user-source", { cause: "user-source" }, null); + const done = await getDiagRun(run.id); + expect(done?.status).toBe("done"); + expect(done?.cause).toBe("user-source"); + expect(done?.report).toEqual({ cause: "user-source" }); + }); + + it("rejects duplicate runs for the same deployment and commit", async () => { + await createDiagRun({ deploymentId: "dep-dup", commitSha: "", provider: "groq", model: "m" }); + await expect( + createDiagRun({ deploymentId: "dep-dup", commitSha: "", provider: "groq", model: "m" }), + ).rejects.toThrow(); + }); + + it("lists active runs and marks interrupted ones as failed", async () => { + const run = await createDiagRun({ deploymentId: "dep-x", commitSha: "active-1", provider: "ollama", model: "m" }); + const active = await listActiveDiagRuns(); + const found = active.find((r) => r.id === run.id); + expect(found).toMatchObject({ deploymentId: "dep-x", projectName: "DiagRuns Project", provider: "ollama" }); + + expect(await markInterruptedDiagRuns()).toBeGreaterThanOrEqual(1); + expect((await getDiagRun(run.id))?.status).toBe("error"); + expect(await listActiveDiagRuns()).toEqual([]); + }); +}); diff --git a/apps/api/src/db/__tests__/llm-keys.test.ts b/apps/api/src/db/__tests__/llm-keys.test.ts new file mode 100644 index 0000000..2e4bf8b --- /dev/null +++ b/apps/api/src/db/__tests__/llm-keys.test.ts @@ -0,0 +1,73 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../db-provider"; +import * as schema from "../schema"; +import { createTestPool, truncateAllTables } from "../test-helper"; +import { deleteLlmKey, getDecryptedLlmKey, getLlmKeyStatus, updateLlmModels, upsertLlmKey } from "../repo/llm-keys"; + +let pool: Pool; + +mock.restore(); + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + await truncateAllTables(pool); +}); + +afterAll(async () => { + try { + await truncateAllTables(pool); + } finally { + await pool.end(); + } +}); + +describe("llm-keys", () => { + it("stores encrypted and reports status without the secret", async () => { + const status = await upsertLlmKey({ + provider: "groq", + apiKey: "gsk-secret-1", + models: ["llama-3.3-70b-versatile"], + }); + expect(status.provider).toBe("groq"); + expect(status.configured).toBe(true); + expect(status.models).toEqual(["llama-3.3-70b-versatile"]); + expect(JSON.stringify(status)).not.toContain("gsk-secret-1"); + + const all = await getLlmKeyStatus(); + expect(all.find((s) => s.provider === "groq")?.configured).toBe(true); + expect(JSON.stringify(all)).not.toContain("gsk-secret-1"); + }); + + it("round-trips the decrypted key", async () => { + await upsertLlmKey({ provider: "openai", apiKey: "sk-secret-2" }); + const rec = await getDecryptedLlmKey("openai"); + expect(rec?.apiKey).toBe("sk-secret-2"); + expect(await getDecryptedLlmKey("missing")).toBeNull(); + }); + + it("keeps the old key when apiKey is empty", async () => { + await upsertLlmKey({ provider: "groq", apiKey: "gsk-secret-1" }); + const status = await upsertLlmKey({ provider: "groq", apiKey: "", models: ["other-model"] }); + expect(status.configured).toBe(true); + expect(status.models).toEqual(["other-model"]); + expect((await getDecryptedLlmKey("groq"))?.apiKey).toBe("gsk-secret-1"); + }); + + it("updates models for a provider", async () => { + await upsertLlmKey({ provider: "gemini", apiKey: "gem-sec-1" }); + const updated = await updateLlmModels("gemini", ["gemini-2.5-flash", "gemini-2.5-pro"]); + expect(updated?.models).toEqual(["gemini-2.5-flash", "gemini-2.5-pro"]); + expect(await updateLlmModels("nonexistent", ["model"])).toBeNull(); + }); + + it("deletes keys", async () => { + await upsertLlmKey({ provider: "anthropic", apiKey: "sk-ant-1" }); + expect(await deleteLlmKey("anthropic")).toBe(true); + expect(await deleteLlmKey("anthropic")).toBe(false); + expect((await getLlmKeyStatus()).find((s) => s.provider === "anthropic")).toBeUndefined(); + }); +}); diff --git a/apps/api/src/db/migrations/0036_fixdiag.sql b/apps/api/src/db/migrations/0036_fixdiag.sql new file mode 100644 index 0000000..51973af --- /dev/null +++ b/apps/api/src/db/migrations/0036_fixdiag.sql @@ -0,0 +1,47 @@ +CREATE TABLE llm_provider_keys ( + provider text PRIMARY KEY, + key_encrypted text, + key_iv text, + key_tag text, + base_url text, + models jsonb NOT NULL DEFAULT '[]', + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE TABLE diag_runs ( + id text PRIMARY KEY, + deployment_id text NOT NULL REFERENCES deployments(id) ON DELETE CASCADE, + commit_sha text NOT NULL DEFAULT '', + provider text NOT NULL, + model text NOT NULL, + status text NOT NULL DEFAULT 'running', + current_stage text, + cause text, + report jsonb, + error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (deployment_id, commit_sha) +); + +CREATE TABLE diag_stages ( + id text PRIMARY KEY, + run_id text NOT NULL REFERENCES diag_runs(id) ON DELETE CASCADE, + stage text NOT NULL, + payload jsonb, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (run_id, stage) +); + +CREATE TABLE diag_actions ( + key text PRIMARY KEY, + run_id text NOT NULL REFERENCES diag_runs(id) ON DELETE CASCADE, + kind text NOT NULL, + status text NOT NULL DEFAULT 'requested', + result jsonb, + error text, + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE UNIQUE INDEX diag_actions_one_done_per_kind ON diag_actions (run_id, kind) WHERE status = 'done'; diff --git a/apps/api/src/db/migrations/meta/_journal.json b/apps/api/src/db/migrations/meta/_journal.json index c8aa637..850b822 100644 --- a/apps/api/src/db/migrations/meta/_journal.json +++ b/apps/api/src/db/migrations/meta/_journal.json @@ -85,6 +85,13 @@ "when": 1790424535143, "tag": "0035_deployment_fk_cascade", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1790700000000, + "tag": "0036_fixdiag", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/repo/diag-actions.ts b/apps/api/src/db/repo/diag-actions.ts new file mode 100644 index 0000000..3f4b928 --- /dev/null +++ b/apps/api/src/db/repo/diag-actions.ts @@ -0,0 +1,97 @@ +import { and, eq } from "drizzle-orm"; +import { getDb } from "../db-provider"; +import { diagActions } from "../schema"; +import { now } from "./helpers"; + +export type DiagActionKind = "pr" | "slack"; +export type DiagActionStatus = "requested" | "executing" | "done" | "failed"; + +export interface DiagAction { + key: string; + runId: string; + kind: DiagActionKind; + status: DiagActionStatus; + result: unknown; + error: string | null; + updatedAt: string; +} + +const STALE_MS = 5 * 60_000; + +const toAction = (row: typeof diagActions.$inferSelect): DiagAction => ({ + key: row.key, + runId: row.runId, + kind: row.kind as DiagActionKind, + status: row.status as DiagActionStatus, + result: row.result ?? null, + error: row.error, + updatedAt: row.updatedAt?.toISOString() ?? new Date().toISOString(), +}); + +export const getDiagAction = async (key: string): Promise => { + const db = await getDb(); + const [row] = await db.select().from(diagActions).where(eq(diagActions.key, key)).execute(); + return row ? toAction(row) : null; +}; + +export const claimDiagAction = async ( + key: string, + runId: string, + kind: DiagActionKind, +): Promise<{ action: DiagAction; fresh: boolean }> => { + const db = await getDb(); + const timestamp = now(); + const inserted = await db + .insert(diagActions) + .values({ key, runId, kind, status: "executing", updatedAt: timestamp }) + .onConflictDoNothing({ target: diagActions.key }) + .returning() + .execute(); + if (inserted.length === 1) return { action: toAction(inserted[0]), fresh: true }; + const [row] = await db.select().from(diagActions).where(eq(diagActions.key, key)).execute(); + if (!row) throw new Error("Failed to claim diagnosis action"); + if (row.runId !== runId || row.kind !== kind) throw new Error("Action key already used for a different intent"); + const action = toAction(row); + if (action.status === "done") return { action, fresh: false }; + if (action.status === "failed" || Date.now() - new Date(action.updatedAt).getTime() > STALE_MS) { + const [claimed] = await db + .update(diagActions) + .set({ status: "executing", error: null, updatedAt: timestamp }) + .where( + and(eq(diagActions.key, key), eq(diagActions.status, row.status), eq(diagActions.updatedAt, row.updatedAt)), + ) + .returning() + .execute(); + if (!claimed) return { action, fresh: false }; + return { action: toAction(claimed), fresh: true }; + } + return { action, fresh: false }; +}; + +export const completeDiagAction = async (key: string, result: unknown): Promise => { + const db = await getDb(); + await db + .update(diagActions) + .set({ status: "done", result: result as object, error: null, updatedAt: now() }) + .where(eq(diagActions.key, key)) + .execute(); +}; + +export const failDiagAction = async (key: string, error: string): Promise => { + const db = await getDb(); + await db + .update(diagActions) + .set({ status: "failed", error, updatedAt: now() }) + .where(eq(diagActions.key, key)) + .execute(); +}; + +export const findCompletedAction = async (runId: string, kind: DiagActionKind): Promise => { + const db = await getDb(); + const [row] = await db + .select() + .from(diagActions) + .where(and(eq(diagActions.runId, runId), eq(diagActions.kind, kind), eq(diagActions.status, "done"))) + .execute(); + return row ? toAction(row) : null; +}; diff --git a/apps/api/src/db/repo/diag-runs.ts b/apps/api/src/db/repo/diag-runs.ts new file mode 100644 index 0000000..9d57807 --- /dev/null +++ b/apps/api/src/db/repo/diag-runs.ts @@ -0,0 +1,158 @@ +import { randomUUID } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import type { CauseKind, DiagRun, DiagStageName, DiagStatus, Proposal } from "../../fixdiag/types"; +import { getDb } from "../db-provider"; +import { deployments, diagRuns, diagStages, projects } from "../schema"; +import { now } from "./helpers"; + +const toRun = (row: typeof diagRuns.$inferSelect): DiagRun => ({ + id: row.id, + deploymentId: row.deploymentId, + commitSha: row.commitSha, + provider: row.provider as DiagRun["provider"], + model: row.model, + status: row.status as DiagStatus, + currentStage: row.currentStage as DiagStageName | null, + cause: row.cause as CauseKind | null, + report: (row.report as Proposal | null) ?? null, + error: row.error, + createdAt: row.createdAt?.toISOString() ?? new Date().toISOString(), +}); + +export const findDiagRun = async (deploymentId: string, commitSha: string): Promise => { + const db = await getDb(); + const [row] = await db + .select() + .from(diagRuns) + .where(and(eq(diagRuns.deploymentId, deploymentId), eq(diagRuns.commitSha, commitSha))) + .execute(); + return row ? toRun(row) : null; +}; + +export const getDiagRun = async (runId: string): Promise => { + const db = await getDb(); + const [row] = await db.select().from(diagRuns).where(eq(diagRuns.id, runId)).execute(); + return row ? toRun(row) : null; +}; + +export const createDiagRun = async (input: { + deploymentId: string; + commitSha: string; + provider: DiagRun["provider"]; + model: string; +}): Promise => { + const db = await getDb(); + const [inserted] = await db + .insert(diagRuns) + .values({ + id: randomUUID(), + deploymentId: input.deploymentId, + commitSha: input.commitSha, + provider: input.provider, + model: input.model, + }) + .returning() + .execute(); + return toRun(inserted); +}; + +export const deleteDiagRun = async (runId: string): Promise => { + const db = await getDb(); + await db.delete(diagRuns).where(eq(diagRuns.id, runId)).execute(); +}; + +export const recordStageResult = async (runId: string, stage: DiagStageName, payload: unknown): Promise => { + const db = await getDb(); + await db + .insert(diagStages) + .values({ id: randomUUID(), runId, stage, payload: payload as object }) + .onConflictDoUpdate({ target: [diagStages.runId, diagStages.stage], set: { payload: payload as object } }) + .execute(); + await db.update(diagRuns).set({ currentStage: stage, updatedAt: now() }).where(eq(diagRuns.id, runId)).execute(); +}; + +export const getStagePayload = async (runId: string, stage: DiagStageName): Promise => { + const db = await getDb(); + const [row] = await db + .select() + .from(diagStages) + .where(and(eq(diagStages.runId, runId), eq(diagStages.stage, stage))) + .execute(); + return row?.payload ?? null; +}; + +export const listStageResults = async (runId: string): Promise<{ stage: DiagStageName; payload: unknown }[]> => { + const db = await getDb(); + const rows = await db.select().from(diagStages).where(eq(diagStages.runId, runId)).execute(); + return rows.map((r) => ({ stage: r.stage as DiagStageName, payload: r.payload ?? null })); +}; + +export const finishDiagRun = async ( + runId: string, + status: DiagStatus, + cause: CauseKind | null, + report: Proposal | null, + error: string | null, +): Promise => { + const db = await getDb(); + await db + .update(diagRuns) + .set({ status, cause, report: report as object | null, error, updatedAt: now() }) + .where(eq(diagRuns.id, runId)) + .execute(); +}; + +export interface ActiveDiagRun { + id: string; + deploymentId: string; + projectId: string | null; + projectName: string | null; + provider: string; + model: string; + currentStage: DiagStageName | null; + createdAt: string; +} + +export const listActiveDiagRuns = async (): Promise => { + const db = await getDb(); + const rows = await db + .select({ + id: diagRuns.id, + deploymentId: diagRuns.deploymentId, + projectId: deployments.projectId, + projectName: projects.name, + provider: diagRuns.provider, + model: diagRuns.model, + currentStage: diagRuns.currentStage, + createdAt: diagRuns.createdAt, + }) + .from(diagRuns) + .leftJoin(deployments, eq(diagRuns.deploymentId, deployments.id)) + .leftJoin(projects, eq(deployments.projectId, projects.id)) + .where(eq(diagRuns.status, "running")) + .execute(); + return rows.map((row) => ({ + id: row.id, + deploymentId: row.deploymentId, + projectId: row.projectId, + projectName: row.projectName, + provider: row.provider, + model: row.model, + currentStage: row.currentStage as DiagStageName | null, + createdAt: row.createdAt?.toISOString() ?? new Date().toISOString(), + })); +}; + +export const markInterruptedDiagRuns = async (): Promise => { + const active = await listActiveDiagRuns(); + for (const run of active) { + await finishDiagRun( + run.id, + "error", + null, + null, + "Diagnosis interrupted by API restart; start it again from the deployment.", + ); + } + return active.length; +}; diff --git a/apps/api/src/db/repo/github-sessions.ts b/apps/api/src/db/repo/github-sessions.ts index c74e286..0f3154d 100644 --- a/apps/api/src/db/repo/github-sessions.ts +++ b/apps/api/src/db/repo/github-sessions.ts @@ -11,6 +11,13 @@ export const getGithubSession = async (id: string): Promise => { return decryptValue(row.accessTokenEncrypted, row.accessTokenIv, row.accessTokenTag, config.envEncryptionKey); }; +export const getGithubTokenFromCookie = async (cookie: string | null): Promise => { + if (!cookie) return null; + const match = cookie.match(/(?:^|;\s*)github_session=([^;]+)/); + if (!match) return null; + return getGithubSession(match[1]); +}; + export const createGithubSession = async (id: string, accessToken: string): Promise => { const db = await getDb(); const enc = encryptValue(accessToken, config.envEncryptionKey); diff --git a/apps/api/src/db/repo/index.ts b/apps/api/src/db/repo/index.ts index cd908bc..d99287a 100644 --- a/apps/api/src/db/repo/index.ts +++ b/apps/api/src/db/repo/index.ts @@ -50,6 +50,26 @@ export { updateDeploymentCommitSha, updateDeploymentStatus, } from "./deployments"; +export { + createDiagRun, + deleteDiagRun, + findDiagRun, + finishDiagRun, + getDiagRun, + getStagePayload, + listActiveDiagRuns, + listStageResults, + markInterruptedDiagRuns, + recordStageResult, +} from "./diag-runs"; +export type { DiagAction, DiagActionKind, DiagActionStatus } from "./diag-actions"; +export { + claimDiagAction, + completeDiagAction, + failDiagAction, + findCompletedAction, + getDiagAction, +} from "./diag-actions"; export { createDomain, deleteDomain, @@ -68,7 +88,12 @@ export { updateEnvironmentVariable, } from "./env-vars"; export { getGithubIntegration, setGithubIntegration } from "./github"; -export { createGithubSession, deleteGithubSession, getGithubSession } from "./github-sessions"; +export { + createGithubSession, + deleteGithubSession, + getGithubSession, + getGithubTokenFromCookie, +} from "./github-sessions"; export { getPlatformSettings, setIngressServer } from "./platform-settings"; export type { ProjectCleanupInfo } from "./projects"; export { @@ -104,6 +129,15 @@ export { } from "./servers"; export type { SmtpSettingsData } from "./settings"; export { getBackupStorageSettings, getSmtpSettings, upsertBackupStorageSettings, upsertSmtpSettings } from "./settings"; +export type { LlmKeyInput, LlmKeyStatus, LlmProvider } from "./llm-keys"; +export { + LLM_PROVIDERS, + deleteLlmKey, + getDecryptedLlmKey, + getLlmKeyStatus, + updateLlmModels, + upsertLlmKey, +} from "./llm-keys"; export { createSharedEnvVar, deleteSharedEnvVar, diff --git a/apps/api/src/db/repo/llm-keys.ts b/apps/api/src/db/repo/llm-keys.ts new file mode 100644 index 0000000..c60f287 --- /dev/null +++ b/apps/api/src/db/repo/llm-keys.ts @@ -0,0 +1,110 @@ +import { eq } from "drizzle-orm"; +import { config } from "../../utils/config"; +import { decryptValue, encryptValue } from "../../utils/crypto"; +import { getDb } from "../db-provider"; +import { llmProviderKeys } from "../schema"; +import { now } from "./helpers"; + +export const LLM_PROVIDERS = ["openai", "anthropic", "gemini", "groq", "ollama", "custom"] as const; +export type LlmProvider = (typeof LLM_PROVIDERS)[number]; + +export interface LlmKeyStatus { + provider: string; + configured: boolean; + baseUrl: string | null; + models: string[]; +} + +export interface LlmKeyInput { + provider: string; + apiKey: string; + baseURL?: string; + models?: string[]; +} + +const toStatus = (row: typeof llmProviderKeys.$inferSelect): LlmKeyStatus => ({ + provider: row.provider, + configured: !!(row.keyEncrypted && row.keyIv && row.keyTag), + baseUrl: row.baseUrl, + models: (row.models as string[]) ?? [], +}); + +export const getLlmKeyStatus = async (): Promise => { + const db = await getDb(); + const rows = await db.select().from(llmProviderKeys).execute(); + return rows.map(toStatus); +}; + +export const getDecryptedLlmKey = async ( + provider: string, +): Promise<{ apiKey: string; baseUrl: string | null; models: string[] } | null> => { + const db = await getDb(); + const [row] = await db.select().from(llmProviderKeys).where(eq(llmProviderKeys.provider, provider)).execute(); + if (!row?.keyEncrypted || !row.keyIv || !row.keyTag) return null; + return { + apiKey: decryptValue(row.keyEncrypted, row.keyIv, row.keyTag, config.envEncryptionKey), + baseUrl: row.baseUrl, + models: (row.models as string[]) ?? [], + }; +}; + +export const upsertLlmKey = async (input: LlmKeyInput): Promise => { + const db = await getDb(); + const encrypted = input.apiKey ? encryptValue(input.apiKey, config.envEncryptionKey) : null; + const timestamp = now(); + const [existing] = await db + .select() + .from(llmProviderKeys) + .where(eq(llmProviderKeys.provider, input.provider)) + .execute(); + if (existing) { + const [updated] = await db + .update(llmProviderKeys) + .set({ + keyEncrypted: encrypted?.encrypted ?? existing.keyEncrypted, + keyIv: encrypted?.iv ?? existing.keyIv, + keyTag: encrypted?.tag ?? existing.keyTag, + baseUrl: input.baseURL ?? existing.baseUrl, + models: input.models ?? existing.models, + updatedAt: timestamp, + }) + .where(eq(llmProviderKeys.provider, input.provider)) + .returning() + .execute(); + return toStatus(updated); + } + const [inserted] = await db + .insert(llmProviderKeys) + .values({ + provider: input.provider, + keyEncrypted: encrypted?.encrypted ?? null, + keyIv: encrypted?.iv ?? null, + keyTag: encrypted?.tag ?? null, + baseUrl: input.baseURL ?? null, + models: input.models ?? [], + updatedAt: timestamp, + }) + .returning() + .execute(); + return toStatus(inserted); +}; + +export const deleteLlmKey = async (provider: string): Promise => { + const db = await getDb(); + const deleted = await db.delete(llmProviderKeys).where(eq(llmProviderKeys.provider, provider)).returning().execute(); + return deleted.length > 0; +}; + +export const updateLlmModels = async (provider: string, models: string[]): Promise => { + const db = await getDb(); + const [updated] = await db + .update(llmProviderKeys) + .set({ + models, + updatedAt: now(), + }) + .where(eq(llmProviderKeys.provider, provider)) + .returning() + .execute(); + return updated ? toStatus(updated) : null; +}; diff --git a/apps/api/src/db/repo/projects.ts b/apps/api/src/db/repo/projects.ts index 994debf..34d53c1 100644 --- a/apps/api/src/db/repo/projects.ts +++ b/apps/api/src/db/repo/projects.ts @@ -48,9 +48,7 @@ const mapProject = (row: typeof projects.$inferSelect): Project => ({ installCommand: row.installCommand ?? null, outputDir: row.outputDir ?? null, startCommand: row.startCommand ?? null, - githubTokenEncrypted: row.githubTokenEncrypted ?? null, - githubTokenIv: row.githubTokenIv ?? null, - githubTokenTag: row.githubTokenTag ?? null, + hasGithubToken: !!(row.githubTokenEncrypted && row.githubTokenIv && row.githubTokenTag), createdAt: formatTimestamp(row.createdAt), updatedAt: formatTimestamp(row.updatedAt), }); diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index eccf8b7..cea82ef 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -365,6 +365,68 @@ export const backupStorageSettings = pgTable("backup_storage_settings", { updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }); +export const llmProviderKeys = pgTable("llm_provider_keys", { + provider: text().primaryKey(), + keyEncrypted: text("key_encrypted"), + keyIv: text("key_iv"), + keyTag: text("key_tag"), + baseUrl: text("base_url"), + models: jsonb().notNull().default([]), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); + +export const diagRuns = pgTable( + "diag_runs", + { + id: text().primaryKey(), + deploymentId: text("deployment_id").notNull(), + commitSha: text("commit_sha").notNull().default(""), + provider: text().notNull(), + model: text().notNull(), + status: text().notNull().default("running"), + currentStage: text("current_stage"), + cause: text(), + report: jsonb(), + error: text(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.deploymentId], foreignColumns: [deployments.id], onDelete: "cascade" }), + uniqueIndex("diag_runs_deployment_commit").on(table.deploymentId, table.commitSha), + ], +); + +export const diagStages = pgTable( + "diag_stages", + { + id: text().primaryKey(), + runId: text("run_id").notNull(), + stage: text().notNull(), + payload: jsonb(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [ + foreignKey({ columns: [table.runId], foreignColumns: [diagRuns.id], onDelete: "cascade" }), + uniqueIndex("diag_stages_run_stage").on(table.runId, table.stage), + ], +); + +export const diagActions = pgTable( + "diag_actions", + { + key: text().primaryKey(), + runId: text("run_id").notNull(), + kind: text().notNull(), + status: text().notNull().default("requested"), + result: jsonb(), + error: text(), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + }, + (table) => [foreignKey({ columns: [table.runId], foreignColumns: [diagRuns.id], onDelete: "cascade" })], +); + export const sharedEnvVars = pgTable("shared_env_vars", { id: text().primaryKey(), key: text().notNull(), diff --git a/apps/api/src/db/test-helper.ts b/apps/api/src/db/test-helper.ts index aee307f..c56d0e1 100644 --- a/apps/api/src/db/test-helper.ts +++ b/apps/api/src/db/test-helper.ts @@ -15,9 +15,13 @@ const TABLE_NAMES = [ "deployment_logs", "deployments", "databases", + "diag_actions", + "diag_runs", + "diag_stages", "domains", "environment_variables", "github_integrations", + "llm_provider_keys", "platform_settings", "projects", "refresh_tokens", diff --git a/apps/api/src/fixdiag/__tests__/actions.test.ts b/apps/api/src/fixdiag/__tests__/actions.test.ts new file mode 100644 index 0000000..ff2369a --- /dev/null +++ b/apps/api/src/fixdiag/__tests__/actions.test.ts @@ -0,0 +1,134 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../../db/db-provider"; +import * as schema from "../../db/schema"; +import { createTestPool, truncateAllTables } from "../../db/test-helper"; +import { createDiagRun, finishDiagRun } from "../../db/repo/diag-runs"; +import { getDiagAction } from "../../db/repo/diag-actions"; +import { createGithubSession, deleteGithubSession } from "../../db/repo/github-sessions"; +import { approveFixPr, approveSlackPost } from "../actions"; +import * as sandboxHost from "../sandbox-host"; +import type { Proposal } from "../types"; + +mock.restore(); + +mock.module("../sandbox-host", () => ({ + ...sandboxHost, + ensureSandbox: async () => { + throw new Error("sandbox unavailable"); + }, +})); + +let pool: Pool; + +const seed = async () => { + await pool.query( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) + VALUES ('proj-act', 'Act Project', 'git', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + await pool.query( + `INSERT INTO deployments (id, project_id, source_type, source_ref, status, branch, commit_sha, created_at, updated_at) + VALUES ('dep-git-1', 'proj-act', 'git', 'https://github.com/acme/shop.git', 'failed', 'main', 'aaaabbbbcccc', NOW(), NOW()), + ('dep-zip-1', 'proj-act', 'upload', '/tmp/nowhere', 'failed', 'main', '', NOW(), NOW()), + ('dep-run-1', 'proj-act', 'git', 'https://github.com/acme/shop.git', 'running', 'main', NULL, NOW(), NOW()) + ON CONFLICT DO NOTHING`, + ); +}; + +const userReport: Proposal = { + cause: "user-source", + userFix: { title: "Fix it", body: "Do the thing", suggestedDiff: "diff --git a/x b/x\n" }, +}; + +const dequelReport: Proposal = { + cause: "dequel-source", + dequelReport: { problem: "p", cause: "c", proposedFix: "f" }, +}; + +const doneRun = async (deploymentId: string, commitSha: string, report: Proposal) => { + const run = await createDiagRun({ deploymentId, commitSha, provider: "groq", model: "m" }); + await finishDiagRun(run.id, "done", report.cause, report, null); + return run; +}; + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + await truncateAllTables(pool); + await seed(); + await deleteGithubSession("sess-test").catch(() => {}); + await createGithubSession("sess-test", "tok-test"); +}); + +afterEach(async () => { + await truncateAllTables(pool); + await seed(); +}); + +afterAll(async () => { + try { + await truncateAllTables(pool); + } finally { + await pool.end(); + } +}); + +describe("approveFixPr guards", () => { + const cookie = "github_session=sess-test"; + const authed = { validateToken: async () => true }; + + it("requires an idempotency key and configured git credentials first", async () => { + expect(await approveFixPr("nope", "", null)).toMatchObject({ ok: false, status: 400 }); + expect(await approveFixPr("nope", "k-1", null)).toMatchObject({ ok: false, status: 401 }); + expect(await approveFixPr("nope", "k-1", "github_session=missing")).toMatchObject({ ok: false, status: 401 }); + expect(await approveFixPr("nope", "k-unknown-run", cookie, authed)).toMatchObject({ ok: false, status: 404 }); + }); + + it("rejects unfinished runs and non-git projects before touching the sandbox", async () => { + const running = await createDiagRun({ + deploymentId: "dep-git-1", + commitSha: "aaaabbbbcccc", + provider: "groq", + model: "m", + }); + expect(await approveFixPr(running.id, "k-2", cookie, authed)).toMatchObject({ ok: false, status: 409 }); + const zip = await doneRun("dep-zip-1", "", userReport); + expect(await approveFixPr(zip.id, "k-zip", cookie, authed)).toMatchObject({ ok: false, status: 409 }); + }); + + it("fails cleanly when the sandbox is unavailable", async () => { + const noPatch = await doneRun("dep-git-1", "aaaabbbbcccc", { + cause: "user-source", + userFix: { title: "t", body: "b", suggestedDiff: null }, + }); + expect(await approveFixPr(noPatch.id, "k-nopatch", cookie, authed)).toMatchObject({ ok: false, status: 502 }); + }); + + it("rejects stale runs and blocks unauthenticated clicks without side effects", async () => { + const stale = await doneRun("dep-git-1", "oldsameaning", userReport); + expect(await approveFixPr(stale.id, "k-stale", cookie, authed)).toMatchObject({ ok: false, status: 409 }); + const fresh = await doneRun("dep-git-1", "aaaabbbbcccc", userReport); + expect(await approveFixPr(fresh.id, "k-auth", null)).toMatchObject({ ok: false, status: 401 }); + expect(await getDiagAction("k-auth")).toBeNull(); + }); + + it("replays the same key and rejects cross-intent reuse", async () => { + const fresh = await doneRun("dep-git-1", "aaaabbbbcccc", userReport); + const first = await approveFixPr(fresh.id, "k-replay", null); + const second = await approveFixPr(fresh.id, "k-replay", null); + expect(first).toEqual(second); + expect(await approveSlackPost(fresh.id, "k-replay")).toMatchObject({ ok: false, status: 409 }); + }); +}); + +describe("approveSlackPost guards", () => { + it("requires a finished dequel-source run and a configured webhook", async () => { + expect(await approveSlackPost("nope", "s-1")).toMatchObject({ ok: false, status: 404 }); + const user = await doneRun("dep-git-1", "bbbbccccdddd", userReport); + expect(await approveSlackPost(user.id, "s-2")).toMatchObject({ ok: false, status: 409 }); + const deq = await doneRun("dep-git-1", "aaaabbbbcccc", dequelReport); + expect(await approveSlackPost(deq.id, "s-3")).toMatchObject({ ok: false, status: 409 }); + }); +}); diff --git a/apps/api/src/fixdiag/__tests__/context.test.ts b/apps/api/src/fixdiag/__tests__/context.test.ts new file mode 100644 index 0000000..a998aad --- /dev/null +++ b/apps/api/src/fixdiag/__tests__/context.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, mock } from "bun:test"; +import { failureReasonOf, logTextOf, tailLogs } from "../context"; +import type { LogLine } from "../types"; + +mock.restore(); + +const line = (sequence: number, message: string): LogLine => ({ sequence, stage: "build", message }); + +describe("tailLogs", () => { + it("keeps the last lines within limits", () => { + const logs = Array.from({ length: 500 }, (_, i) => line(i + 1, `line ${i + 1}`)); + const tail = tailLogs(logs); + expect(tail).toHaveLength(400); + expect(tail[0].sequence).toBe(101); + }); + + it("caps total characters from the front", () => { + const logs = [line(1, "a".repeat(20000)), line(2, "b".repeat(20000))]; + const tail = tailLogs(logs, 400, 24000); + expect(tail.map((l) => l.sequence)).toEqual([2]); + }); +}); + +describe("logTextOf", () => { + it("prefixes stage names", () => { + expect(logTextOf([line(1, "boom")])).toBe("[build] boom"); + }); + + it("never returns an empty string", () => { + expect(logTextOf([]).trim().length).toBeGreaterThan(0); + }); +}); + +describe("failureReasonOf", () => { + it("falls back to a placeholder", () => { + expect(failureReasonOf("boom")).toBe("boom"); + expect(failureReasonOf(null).trim().length).toBeGreaterThan(0); + expect(failureReasonOf(" ").trim().length).toBeGreaterThan(0); + }); +}); diff --git a/apps/api/src/fixdiag/__tests__/machine.test.ts b/apps/api/src/fixdiag/__tests__/machine.test.ts new file mode 100644 index 0000000..024f231 --- /dev/null +++ b/apps/api/src/fixdiag/__tests__/machine.test.ts @@ -0,0 +1,211 @@ +import { afterAll, afterEach, beforeAll, describe, expect, it, mock } from "bun:test"; +import { drizzle } from "drizzle-orm/node-postgres"; +import type { Pool } from "pg"; +import { setDbProvider } from "../../db/db-provider"; +import * as schema from "../../db/schema"; +import { createTestPool, truncateAllTables } from "../../db/test-helper"; +import { upsertLlmKey } from "../../db/repo/llm-keys"; +import { getDiagRun, listStageResults, recordStageResult } from "../../db/repo/diag-runs"; +import { DiagRunMachine, parseGithubRepo } from "../machine"; +import { diagBus } from "../stream"; +import type { FixdiagPrograms, InvestigateFn, Investigation, StageEvent } from "../types"; + +mock.restore(); + +let pool: Pool; + +const seed = async () => { + await pool.query( + `INSERT INTO projects (id, name, source_type, created_at, updated_at) + VALUES ('proj-diag', 'Diag Project', 'git', NOW(), NOW()) ON CONFLICT DO NOTHING`, + ); + await pool.query( + `INSERT INTO deployments (id, project_id, source_type, source_ref, status, branch, commit_sha, failure_reason, created_at, updated_at) + VALUES ('dep-failed-1', 'proj-diag', 'upload', '/tmp/does-not-exist', 'failed', 'main', 'deadbeef', 'boom', NOW(), NOW()), + ('dep-running-1', 'proj-diag', 'upload', '/tmp/does-not-exist', 'running', 'main', NULL, NULL, NOW(), NOW()) + ON CONFLICT DO NOTHING`, + ); + await pool.query( + `INSERT INTO deployment_logs (deployment_id, sequence, stage, message) + VALUES ('dep-failed-1', 1, 'build', 'Step 1/2'), ('dep-failed-1', 2, 'build', 'boom') ON CONFLICT DO NOTHING`, + ); +}; + +const fakePrograms: FixdiagPrograms = { + triageLogs: async () => ({ failingStage: "build", signalLines: ["boom"], confidence: "high" }), + explainFix: async () => ({ summary: "s", fixSteps: ["do x"], patchHint: null }), + draftProposal: async () => ({ cause: "user-source", userFix: { title: "t", body: "b", suggestedDiff: null } }), +}; + +const fakeInvestigation: Investigation = { + cause: "user-source", + culpritPaths: ["Dockerfile"], + rationale: "r", + keyEvidence: ["boom"], + dequelRev: "v0.3.0 @ abc1234", + dequelStale: false, +}; + +const fakeInvestigator: InvestigateFn = async () => fakeInvestigation; + +beforeAll(async () => { + pool = createTestPool(); + const db = drizzle(pool, { schema }); + setDbProvider(async () => db); + await truncateAllTables(pool); + await seed(); + await upsertLlmKey({ provider: "groq", apiKey: "gsk-test" }); +}); + +afterEach(async () => { + await truncateAllTables(pool); + await seed(); + await upsertLlmKey({ provider: "groq", apiKey: "gsk-test" }); +}); + +afterAll(async () => { + try { + await truncateAllTables(pool); + } finally { + await pool.end(); + } +}); + +describe("DiagRunMachine.start", () => { + it("rejects unknown deployments, non-failed deployments and bad input", async () => { + expect(await DiagRunMachine.start({ deploymentId: "nope", provider: "groq", model: "m" })).toMatchObject({ + ok: false, + status: 404, + }); + expect(await DiagRunMachine.start({ deploymentId: "dep-running-1", provider: "groq", model: "m" })).toMatchObject({ + ok: false, + status: 409, + }); + expect(await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "nope", model: "m" })).toMatchObject({ + ok: false, + status: 400, + }); + expect(await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: " " })).toMatchObject({ + ok: false, + status: 400, + }); + expect(await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "openai", model: "m" })).toMatchObject({ + ok: false, + status: 400, + }); + }); + + it("creates once and returns the existing run on repeat", async () => { + const first = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + expect(first.ok && first.created).toBe(true); + const second = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + expect(second.ok && !second.created && second.run.id === (first.ok && first.run.id)).toBe(true); + }); +}); + +describe("DiagRunMachine.drive", () => { + it("runs all stages, persists the report and emits events", async () => { + const started = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + if (!started.ok) throw new Error("start failed"); + const events: StageEvent[] = []; + const unsub = diagBus.subscribe(started.run.id, (e) => events.push(e)); + try { + await DiagRunMachine.drive(started.run.id, fakePrograms, fakeInvestigator); + } finally { + unsub(); + } + const run = await getDiagRun(started.run.id); + expect(run?.status).toBe("done"); + expect(run?.cause).toBe("user-source"); + expect(run?.report).toEqual({ cause: "user-source", userFix: { title: "t", body: "b", suggestedDiff: null } }); + expect((await listStageResults(started.run.id)).map((s) => s.stage)).toEqual([ + "triage", + "investigate", + "explain", + "propose", + ]); + expect(events.filter((e) => e.type === "stage")).toHaveLength(4); + expect(events.some((e) => e.type === "done")).toBe(true); + }); + + it("resumes after the last completed stage", async () => { + const started = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + if (!started.ok) throw new Error("start failed"); + await recordStageResult(started.run.id, "triage", { failingStage: "build", signalLines: [], confidence: "high" }); + const calls: string[] = []; + const counting: FixdiagPrograms = { + triageLogs: async (...args) => { + calls.push("triage"); + return fakePrograms.triageLogs(...args); + }, + explainFix: async (...args) => { + calls.push("explain"); + return fakePrograms.explainFix(...args); + }, + draftProposal: async (...args) => { + calls.push("propose"); + return fakePrograms.draftProposal(...args); + }, + }; + const investigator: InvestigateFn = async (...args) => { + calls.push("investigate"); + return fakeInvestigator(...args); + }; + await DiagRunMachine.drive(started.run.id, counting, investigator); + expect(calls).toEqual(["investigate", "explain", "propose"]); + expect((await getDiagRun(started.run.id))?.status).toBe("done"); + }); + + it("marks the run errored when the investigator throws", async () => { + const started = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + if (!started.ok) throw new Error("start failed"); + const failing: InvestigateFn = async () => Promise.reject(new Error("sandbox down")); + const events: StageEvent[] = []; + const unsub = diagBus.subscribe(started.run.id, (e) => events.push(e)); + try { + await DiagRunMachine.drive(started.run.id, fakePrograms, failing); + } finally { + unsub(); + } + const run = await getDiagRun(started.run.id); + expect(run?.status).toBe("error"); + expect(run?.error).toBe("investigate failed: sandbox down"); + expect(events.some((e) => e.type === "error")).toBe(true); + }); + + it("auto-posts dequel-source reports to Slack without failing the run", async () => { + const started = await DiagRunMachine.start({ deploymentId: "dep-failed-1", provider: "groq", model: "m" }); + if (!started.ok) throw new Error("start failed"); + const dequelPrograms: FixdiagPrograms = { + ...fakePrograms, + draftProposal: async () => ({ + cause: "dequel-source", + dequelReport: { problem: "p", cause: "c", proposedFix: "f" }, + }), + }; + await DiagRunMachine.drive(started.run.id, dequelPrograms, fakeInvestigator); + const run = await getDiagRun(started.run.id); + expect(run?.status).toBe("done"); + expect(run?.cause).toBe("dequel-source"); + const { rows } = await pool.query("SELECT status FROM diag_actions WHERE run_id = $1 AND kind = 'slack'", [ + started.run.id, + ]); + expect(rows.length).toBe(1); + }); +}); + +describe("parseGithubRepo", () => { + it("parses https and ssh urls", () => { + expect(parseGithubRepo("https://github.com/acme/shop.git", "main")).toEqual({ + owner: "acme", + repo: "shop", + base: "main", + }); + expect(parseGithubRepo("git@github.com:acme/shop.git", null)).toEqual({ + owner: "acme", + repo: "shop", + base: "main", + }); + expect(parseGithubRepo("https://example.com/acme/shop", "dev")).toBeNull(); + }); +}); diff --git a/apps/api/src/fixdiag/__tests__/programs.test.ts b/apps/api/src/fixdiag/__tests__/programs.test.ts new file mode 100644 index 0000000..afe71ba --- /dev/null +++ b/apps/api/src/fixdiag/__tests__/programs.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, mock } from "bun:test"; +import { createPrograms } from "../programs"; +import { heuristicSignalLines } from "../programs"; +import type { DiagLlm } from "../llm"; + +mock.restore(); + +const throwingLlm = (): DiagLlm => + ({ + chat: async () => { + throw new Error("Generate failed: boom"); + }, + }) as unknown as DiagLlm; + +describe("program fallbacks", () => { + it("triage falls back to heuristic signal lines", async () => { + const progs = createPrograms(throwingLlm()); + const out = await progs.triageLogs({ + failureReason: "boom", + logText: "[build] ok\n[build] ERROR: dial tcp: lookup ghcr.io: server misbehaving\n[system] done", + }); + expect(out.confidence).toBe("low"); + expect(out.failingStage).toBe("unknown"); + expect(out.signalLines).toEqual(["[build] ERROR: dial tcp: lookup ghcr.io: server misbehaving"]); + }); + + it("explain falls back to the localization rationale", async () => { + const progs = createPrograms(throwingLlm()); + const out = await progs.explainFix({ + verdict: { failingStage: "x", signalLines: [], confidence: "low" }, + localization: { cause: "dequel-source", culpritPaths: [], rationale: "dns broke" }, + }); + expect(out.summary).toBe("dns broke"); + expect(out.fixSteps).toEqual([]); + expect(out.patchHint).toBeNull(); + }); + + it("propose falls back to a minimal dequel report", async () => { + const progs = createPrograms(throwingLlm()); + const out = await progs.draftProposal({ + localization: { cause: "dequel-source", culpritPaths: [], rationale: "dns broke" }, + explanation: { summary: "s", fixSteps: [], patchHint: null }, + repo: null, + }); + expect(out.cause).toBe("dequel-source"); + expect(out.dequelReport?.problem).toBe("dns broke"); + }); + + it("propose skips the model for unknown cause", async () => { + const progs = createPrograms(throwingLlm()); + const out = await progs.draftProposal({ + localization: { cause: "unknown", culpritPaths: [], rationale: "" }, + explanation: { summary: "s", fixSteps: [], patchHint: null }, + repo: null, + }); + expect(out).toEqual({ cause: "unknown" }); + }); +}); + +describe("heuristicSignalLines", () => { + it("prefers error lines, else the tail", () => { + expect(heuristicSignalLines("[build] ok\n[build] ERROR: x\n[system] done")).toEqual(["[build] ERROR: x"]); + expect(heuristicSignalLines("a\nb")).toEqual(["a", "b"]); + expect(heuristicSignalLines("")).toEqual([]); + }); +}); diff --git a/apps/api/src/fixdiag/actions.ts b/apps/api/src/fixdiag/actions.ts new file mode 100644 index 0000000..b3f5213 --- /dev/null +++ b/apps/api/src/fixdiag/actions.ts @@ -0,0 +1,329 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { config } from "../utils/config"; +import { claimDiagAction, completeDiagAction, failDiagAction, findCompletedAction } from "../db/repo/diag-actions"; +import { getDiagRun, getStagePayload } from "../db/repo/diag-runs"; +import { getDeploymentById } from "../db/repo/deployments"; +import { getGithubTokenFromCookie } from "../db/repo/github-sessions"; +import { getProjectById } from "../db/repo/projects"; +import { parseGithubRepo } from "./repo-url"; +import { + clearProjectSource, + ensureSandbox, + readLocalVersion, + runFixAgent, + syncDequelSource, + syncProjectSource, +} from "./sandbox-host"; + +const execFileAsync = promisify(execFile); + +export type ActionResult = { ok: true; data: unknown } | { ok: false; status: number; message: string }; + +const short = (s: string, n = 500): string => (s.length > n ? `${s.slice(0, n)}…` : s); + +const git = async (args: string[], cwd: string, token: string) => { + const auth = Buffer.from(`x-access-token:${token}`).toString("base64"); + try { + return await execFileAsync("git", args, { + timeout: 120_000, + cwd, + maxBuffer: 4 * 1024 * 1024, + env: { + ...process.env, + GIT_TERMINAL_PROMPT: "0", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "http.https://github.com/.extraHeader", + GIT_CONFIG_VALUE_0: `Authorization: Basic ${auth}`, + }, + }); + } catch (err) { + const stderr = String((err as { stderr?: unknown }).stderr ?? "") + .split(token) + .join("***") + .trim(); + throw new Error(`git ${args.join(" ")} failed: ${short(stderr || "command failed")}`); + } +}; + +const githubFetch = async (token: string, path: string, init?: RequestInit) => { + const res = await fetch(`https://api.github.com${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + Accept: "application/vnd.github.v3+json", + "User-Agent": "dequel", + "Content-Type": "application/json", + ...init?.headers, + }, + signal: AbortSignal.timeout(30_000), + }); + return res; +}; + +const validGithubToken = async (token: string): Promise => { + try { + const res = await githubFetch(token, "/user"); + return res.ok; + } catch { + return false; + } +}; + +export const approveFixPr = async ( + runId: string, + key: string, + cookie: string | null, + opts?: { validateToken?: (token: string) => Promise }, +): Promise => { + if (!key.trim()) return { ok: false, status: 400, message: "idempotencyKey is required" }; + const trimmedKey = key.trim(); + const validate = opts?.validateToken ?? validGithubToken; + const token = await getGithubTokenFromCookie(cookie); + if (!token || !(await validate(token))) { + return { ok: false, status: 401, message: "Connect GitHub first (Settings → GitHub Integration)" }; + } + const run = await getDiagRun(runId); + if (!run) return { ok: false, status: 404, message: "Diagnosis not found" }; + let claim; + try { + claim = await claimDiagAction(trimmedKey, runId, "pr"); + } catch { + return { ok: false, status: 409, message: "Action key already used for a different intent" }; + } + if (!claim.fresh) { + if (claim.action.status === "done") return { ok: true, data: claim.action.result }; + return { ok: false, status: 409, message: "Action already in progress" }; + } + const completed = await findCompletedAction(runId, "pr"); + if (completed) { + await completeDiagAction(trimmedKey, completed.result); + return { ok: true, data: completed.result }; + } + const fail = async (status: number, message: string): Promise => { + await failDiagAction(trimmedKey, message); + return { ok: false, status, message }; + }; + + if (run.status !== "done") return fail(409, "Diagnosis is not complete yet"); + if (run.cause !== "user-source" || !run.report?.userFix) { + return fail(409, "This diagnosis has no user-code fix to apply"); + } + const fix = run.report.userFix; + const dep = await getDeploymentById(run.deploymentId); + if (!dep || dep.sourceType !== "git") return fail(409, "Fix PRs are only available for Git projects"); + if ((dep.commitSha ?? "") !== run.commitSha) { + return fail(409, "The deployment has moved on since this diagnosis (stale)"); + } + const repo = parseGithubRepo(dep.sourceRef, dep.branch); + if (!repo) return fail(422, "Project source is not a GitHub repository"); + + let patch: string; + try { + await ensureSandbox(); + const version = await readLocalVersion(); + const { rev, stale } = await syncDequelSource(version); + const project = await syncProjectSource( + { + deploymentId: dep.id, + projectId: dep.projectId, + sourceType: dep.sourceType, + sourceRef: dep.sourceRef, + branch: dep.branch, + commitSha: dep.commitSha ?? "", + failureReason: dep.failureReason, + }, + runId, + ); + if (!project.available) { + await clearProjectSource(runId); + return fail(422, "Project source could not be pulled into the sandbox"); + } + try { + const investigation = (await getStagePayload(runId, "investigate")) as { + culpritPaths?: unknown; + rationale?: unknown; + } | null; + const explanation = (await getStagePayload(runId, "explain")) as { + summary?: unknown; + fixSteps?: unknown; + } | null; + const lines = [ + `Deployment failed: ${dep.failureReason ?? run.deploymentId}`, + `Diagnosis: ${typeof explanation?.summary === "string" ? explanation.summary : ""}`, + `Culprit files: ${Array.isArray(investigation?.culpritPaths) ? investigation.culpritPaths.filter((p): p is string => typeof p === "string").join(", ") : ""}`, + `Fix steps: ${Array.isArray(explanation?.fixSteps) ? explanation.fixSteps.filter((s): s is string => typeof s === "string").join(" / ") : ""}`, + ]; + if (fix.suggestedDiff?.trim()) { + lines.push( + `Starting suggestion (verify against the files, do not apply blindly):\n${fix.suggestedDiff.slice(0, 8000)}`, + ); + } + const result = await runFixAgent({ + runId, + provider: run.provider, + model: run.model, + fixBrief: lines.join("\n"), + dequelRev: rev, + dequelStale: stale, + onProgress: () => {}, + }); + patch = result.patch; + } finally { + await clearProjectSource(runId); + } + } catch (err) { + return fail(502, `Agent fix failed: ${short(err instanceof Error ? err.message : String(err))}`); + } + + const branch = `dequel-fix/${run.id.slice(0, 8)}`; + const tmp = await mkdtemp(join(tmpdir(), "dequel-fix-")); + const repoDir = join(tmp, "repo"); + try { + const remote = `https://github.com/${repo.owner}/${repo.repo}.git`; + await git(["clone", "--depth", "1", "--branch", repo.base, remote, repoDir], tmpdir(), token); + if (run.commitSha) { + try { + await git(["fetch", "--depth", "1", "origin", run.commitSha], repoDir, token); + await git(["checkout", run.commitSha], repoDir, token); + } catch { + return fail(409, "The diagnosed commit is no longer available (stale)"); + } + } + const branchExists = + (await git(["rev-parse", "--verify", `refs/heads/${branch}`], repoDir, token).catch(() => null)) !== null; + await git(["checkout", ...(branchExists ? [branch] : ["-b", branch])], repoDir, token); + const patchPath = join(tmp, "fix.diff"); + await writeFile(patchPath, patch); + try { + await execFileAsync("git", ["apply", "--check", patchPath], { timeout: 30_000, cwd: repoDir }); + await execFileAsync("git", ["apply", patchPath], { timeout: 30_000, cwd: repoDir }); + } catch (err) { + const detail = short(err instanceof Error ? err.message : String(err)); + return fail(422, `Patch does not apply cleanly: ${detail}`); + } + await git(["add", "-A"], repoDir, token); + await execFileAsync( + "git", + ["-c", "user.name=Dequel", "-c", "user.email=dequel@localhost", "commit", "-m", fix.title, "-m", fix.body], + { + timeout: 30_000, + cwd: repoDir, + }, + ); + try { + await git(["push", "origin", branch], repoDir, token); + } catch (err) { + return fail(422, `Push failed: ${short(err instanceof Error ? err.message : String(err))}`); + } + const prRes = await githubFetch(token, `/repos/${repo.owner}/${repo.repo}/pulls`, { + method: "POST", + body: JSON.stringify({ title: fix.title, body: fix.body, head: branch, base: repo.base }), + }); + if (prRes.status === 201) { + const pr = (await prRes.json()) as { html_url?: string }; + const result = { prUrl: pr.html_url ?? "" }; + await completeDiagAction(trimmedKey, result); + return { ok: true, data: result }; + } + const prBody = short(await prRes.text().catch(() => "")); + if (prRes.status === 422 && prBody.includes("already exists")) { + const existing = await githubFetch( + token, + `/repos/${repo.owner}/${repo.repo}/pulls?head=${repo.owner}:${branch}&state=open`, + ); + const list = (await existing.json().catch(() => [])) as { html_url?: string }[]; + if (list[0]?.html_url) { + const result = { prUrl: list[0].html_url }; + await completeDiagAction(trimmedKey, result); + return { ok: true, data: result }; + } + } + return fail(502, `GitHub rejected the pull request: ${prBody || prRes.status}`); + } catch (err) { + return fail(500, short(err instanceof Error ? err.message : String(err))); + } finally { + await rm(tmp, { recursive: true, force: true }); + } +}; + +export const approveSlackPost = async (runId: string, key: string): Promise => { + if (!key.trim()) return { ok: false, status: 400, message: "idempotencyKey is required" }; + const trimmedKey = key.trim(); + const run = await getDiagRun(runId); + if (!run) return { ok: false, status: 404, message: "Diagnosis not found" }; + let claim; + try { + claim = await claimDiagAction(trimmedKey, runId, "slack"); + } catch { + return { ok: false, status: 409, message: "Action key already used for a different intent" }; + } + if (!claim.fresh) { + if (claim.action.status === "done") return { ok: true, data: claim.action.result }; + return { ok: false, status: 409, message: "Action already in progress" }; + } + const completed = await findCompletedAction(runId, "slack"); + if (completed) { + await completeDiagAction(trimmedKey, completed.result); + return { ok: true, data: completed.result }; + } + const fail = async (status: number, message: string): Promise => { + await failDiagAction(trimmedKey, message); + return { ok: false, status, message }; + }; + + if (run.status !== "done") return fail(409, "Diagnosis is not complete yet"); + if (run.cause !== "dequel-source" || !run.report?.dequelReport) { + return fail(409, "This diagnosis has no Dequel report to post"); + } + if (!config.dequelSlackWebhookUrl) return fail(409, "Dequel Slack is not configured"); + + const report = run.report.dequelReport; + const dep = await getDeploymentById(run.deploymentId); + const project = dep?.projectId ? await getProjectById(dep.projectId).catch(() => null) : null; + const investigation = (await getStagePayload(runId, "investigate")) as { + dequelRev?: unknown; + dequelStale?: unknown; + } | null; + const version = + typeof investigation?.dequelRev === "string" && investigation.dequelRev + ? investigation.dequelRev + (investigation.dequelStale === true ? " (possibly stale)" : "") + : "unknown"; + const payload = { + text: `Dequel diagnosis: ${project?.name ?? run.deploymentId}`, + blocks: [ + { type: "header", text: { type: "plain_text", text: `Dequel diagnosis: ${project?.name ?? "unknown project"}` } }, + { type: "section", text: { type: "mrkdwn", text: `*Problem*\n${report.problem}` } }, + { type: "section", text: { type: "mrkdwn", text: `*Dequel version*\n${version}` } }, + { type: "section", text: { type: "mrkdwn", text: `*Root cause*\n${report.cause}` } }, + { type: "section", text: { type: "mrkdwn", text: `*Proposed fix*\n${report.proposedFix}` } }, + { + type: "context", + elements: [ + { + type: "mrkdwn", + text: `Deployment ${run.deploymentId.slice(0, 8)} · diagnosed with ${run.provider}/${run.model}`, + }, + ], + }, + ], + }; + try { + const res = await fetch(config.dequelSlackWebhookUrl, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + redirect: "manual", + signal: AbortSignal.timeout(10_000), + }); + if (!res.ok) return fail(502, `Slack webhook returned ${res.status}`); + } catch (err) { + return fail(502, short(err instanceof Error ? err.message : String(err))); + } + const result = { posted: true, channel: config.dequelSlackChannel || undefined }; + await completeDiagAction(trimmedKey, result); + return { ok: true, data: result }; +}; diff --git a/apps/api/src/fixdiag/context.ts b/apps/api/src/fixdiag/context.ts new file mode 100644 index 0000000..a49ffee --- /dev/null +++ b/apps/api/src/fixdiag/context.ts @@ -0,0 +1,49 @@ +import { getDeploymentById, getLogs } from "../db/repo/deployments"; +import { getDecryptedLlmKey } from "../db/repo/llm-keys"; +import { buildLlm, type DiagLlm } from "./llm"; +import type { EvidenceBundle, LlmProvider, LogLine } from "./types"; + +export const tailLogs = (logs: LogLine[], maxLines = 400, maxChars = 24000): LogLine[] => { + const tail = logs.slice(-maxLines); + const out: LogLine[] = []; + let chars = 0; + for (let i = tail.length - 1; i >= 0; i--) { + const len = tail[i].message.length; + if (out.length > 0 && chars + len > maxChars) break; + out.unshift(tail[i]); + chars += len; + } + return out; +}; + +export const logTextOf = (logs: LogLine[]): string => { + if (!logs.length) return "(no build logs were recorded for this deployment)"; + return logs.map((l) => `[${l.stage}] ${l.message}`).join("\n"); +}; + +export const failureReasonOf = (reason: string | null): string => + reason?.trim() ? reason : "(no failure reason was recorded)"; + +export const collectFailureContext = async (deploymentId: string): Promise => { + const dep = await getDeploymentById(deploymentId); + if (!dep) throw new Error(`Deployment ${deploymentId} not found`); + const logs = await getLogs(deploymentId); + return { + deployment: { + deploymentId: dep.id, + projectId: dep.projectId, + sourceType: dep.sourceType, + sourceRef: dep.sourceRef, + branch: dep.branch, + commitSha: dep.commitSha ?? "", + failureReason: dep.failureReason, + }, + logs: tailLogs(logs.map((l) => ({ sequence: l.sequence, stage: l.stage, message: l.message }))), + }; +}; + +export const resolveModel = async (provider: LlmProvider, model: string): Promise => { + const rec = await getDecryptedLlmKey(provider); + if (!rec) throw new Error(`LLM provider "${provider}" is not configured`); + return buildLlm(provider, rec.apiKey, rec.baseUrl, model); +}; diff --git a/apps/api/src/fixdiag/llm.ts b/apps/api/src/fixdiag/llm.ts new file mode 100644 index 0000000..951f44a --- /dev/null +++ b/apps/api/src/fixdiag/llm.ts @@ -0,0 +1,37 @@ +import { ai } from "@ax-llm/ax"; +import type { CauseKind, LlmProvider, TriageConfidence } from "./types"; + +export const buildLlm = (provider: LlmProvider, apiKey: string, baseUrl: string | null, model: string) => { + switch (provider) { + case "openai": + return ai({ name: "openai", apiKey, config: { model } }); + case "anthropic": + return ai({ name: "anthropic", apiKey, config: { model } }); + case "gemini": + return ai({ name: "google-gemini", apiKey, config: { model } }); + case "groq": + return ai({ name: "groq", apiKey, config: { model } }); + case "ollama": + return ai({ + name: "openai", + apiKey: apiKey || "ollama", + apiURL: baseUrl ?? "http://host.docker.internal:11435/v1", + config: { model, maxTokens: 8192 }, + }); + case "custom": + return ai({ name: "openai", apiKey, apiURL: baseUrl ?? undefined, config: { model } }); + } +}; + +export type DiagLlm = ReturnType; + +export const asString = (value: unknown, fallback = ""): string => (typeof value === "string" ? value : fallback); + +export const asStringArray = (value: unknown, max = 32): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === "string").slice(0, max) : []; + +export const asConfidence = (value: unknown): TriageConfidence => + value === "low" || value === "medium" || value === "high" ? value : "low"; + +export const asCause = (value: unknown): CauseKind => + value === "user-source" || value === "dequel-source" || value === "unknown" ? value : "unknown"; diff --git a/apps/api/src/fixdiag/machine.ts b/apps/api/src/fixdiag/machine.ts new file mode 100644 index 0000000..f2ec4ce --- /dev/null +++ b/apps/api/src/fixdiag/machine.ts @@ -0,0 +1,192 @@ +import { + createDiagRun, + deleteDiagRun, + findDiagRun, + finishDiagRun, + getDiagRun, + getStagePayload, + recordStageResult, +} from "../db/repo/diag-runs"; +import { getDeploymentById } from "../db/repo/deployments"; +import { getDecryptedLlmKey, LLM_PROVIDERS } from "../db/repo/llm-keys"; +import { collectFailureContext, failureReasonOf, logTextOf, resolveModel } from "./context"; +import { approveSlackPost } from "./actions"; +import { createPrograms } from "./programs"; +import { diagBus } from "./stream"; +import type { + DiagRun, + DiagStageName, + EvidenceBundle, + Explanation, + FixdiagPrograms, + InvestigateFn, + Investigation, + LlmProvider, + Localization, + Proposal, + TriageVerdict, +} from "./types"; +import { + clearProjectSource, + ensureSandbox, + investigateInSandbox, + readLocalVersion, + syncDequelSource, + syncProjectSource, +} from "./sandbox-host"; +import { parseGithubRepo } from "./repo-url"; + +export { parseGithubRepo }; + +const STAGES: DiagStageName[] = ["triage", "investigate", "explain", "propose"]; +const activeDrives = new Set(); + +const defaultInvestigator: InvestigateFn = async ({ run, deployment, logText, verdict, onProgress }) => { + await ensureSandbox(); + const version = await readLocalVersion(); + const { rev, stale } = await syncDequelSource(version); + const project = await syncProjectSource(deployment, run.id); + try { + return await investigateInSandbox({ + runId: run.id, + provider: run.provider, + model: run.model, + deployment, + failureReason: failureReasonOf(deployment.failureReason), + logText, + verdict, + dequelRev: rev, + dequelStale: stale, + projectAvailable: project.available, + onProgress, + }); + } finally { + await clearProjectSource(run.id); + } +}; + +const runStage = async ( + programs: FixdiagPrograms, + investigate: InvestigateFn, + stage: DiagStageName, + evidence: EvidenceBundle, + run: DiagRun, + runId: string, + emitProgress: (line: string) => void, +): Promise => { + switch (stage) { + case "triage": + return programs.triageLogs({ + failureReason: failureReasonOf(evidence.deployment.failureReason), + logText: logTextOf(evidence.logs), + }); + case "investigate": + return investigate({ + run, + deployment: evidence.deployment, + logText: logTextOf(evidence.logs), + verdict: (await getStagePayload(runId, "triage")) as TriageVerdict, + onProgress: emitProgress, + }); + case "explain": { + const verdict = (await getStagePayload(runId, "triage")) as TriageVerdict; + const investigation = (await getStagePayload(runId, "investigate")) as Investigation; + const localization: Localization = { + cause: investigation.cause, + culpritPaths: investigation.culpritPaths, + rationale: investigation.rationale, + }; + return programs.explainFix({ verdict, localization }); + } + case "propose": { + const investigation = (await getStagePayload(runId, "investigate")) as Investigation; + const localization: Localization = { + cause: investigation.cause, + culpritPaths: investigation.culpritPaths, + rationale: investigation.rationale, + }; + const explanation = (await getStagePayload(runId, "explain")) as Explanation; + return programs.draftProposal({ + localization, + explanation, + repo: parseGithubRepo(evidence.deployment.sourceRef, evidence.deployment.branch), + }); + } + } +}; + +export const DiagRunMachine = { + start: async (input: { + deploymentId: string; + provider: string; + model: string; + }): Promise<{ ok: true; run: DiagRun; created: boolean } | { ok: false; status: number; message: string }> => { + if (!(LLM_PROVIDERS as readonly string[]).includes(input.provider)) { + return { ok: false, status: 400, message: `provider must be one of: ${LLM_PROVIDERS.join(", ")}` }; + } + if (!input.model?.trim()) return { ok: false, status: 400, message: "model is required" }; + const dep = await getDeploymentById(input.deploymentId); + if (!dep) return { ok: false, status: 404, message: "Deployment not found" }; + if (dep.status !== "failed") { + return { ok: false, status: 409, message: "Diagnosis is only available for failed deployments" }; + } + const key = await getDecryptedLlmKey(input.provider); + if (!key) return { ok: false, status: 400, message: `LLM provider "${input.provider}" is not configured` }; + const commitSha = dep.commitSha ?? ""; + const existing = await findDiagRun(input.deploymentId, commitSha); + if (existing) { + if (existing.status === "error") await deleteDiagRun(existing.id); + else return { ok: true, run: existing, created: false }; + } + const run = await createDiagRun({ + deploymentId: input.deploymentId, + commitSha, + provider: input.provider as LlmProvider, + model: input.model.trim(), + }); + return { ok: true, run, created: true }; + }, + + drive: async (runId: string, programs?: FixdiagPrograms, investigate?: InvestigateFn): Promise => { + if (activeDrives.has(runId)) return; + activeDrives.add(runId); + try { + const run = await getDiagRun(runId); + if (!run || run.status !== "running") return; + try { + const llm = await resolveModel(run.provider, run.model); + const progs = programs ?? createPrograms(llm); + const investigateFn = investigate ?? defaultInvestigator; + const evidence = await collectFailureContext(run.deploymentId); + const startIdx = run.currentStage ? STAGES.indexOf(run.currentStage) + 1 : 0; + for (let i = Math.max(0, startIdx); i < STAGES.length; i++) { + const stage = STAGES[i]; + let payload: unknown; + try { + payload = await runStage(progs, investigateFn, stage, evidence, run, runId, (line) => + diagBus.emit(runId, { type: "token", runId, stage, delta: line }), + ); + } catch (err) { + throw new Error(`${stage} failed: ${err instanceof Error ? err.message : String(err)}`); + } + await recordStageResult(runId, stage, payload); + diagBus.emit(runId, { type: "stage", runId, stage, payload }); + } + const proposal = (await getStagePayload(runId, "propose")) as Proposal | null; + const finalProposal: Proposal = proposal && proposal.cause ? proposal : { cause: "unknown" }; + await finishDiagRun(runId, "done", finalProposal.cause, finalProposal, null); + if (finalProposal.cause === "dequel-source") { + const posted = await approveSlackPost(runId, `auto-${runId}`).catch(() => null); + if (!posted?.ok) console.log(`[Fixdiag] Slack auto-post skipped for ${runId}: ${posted?.message ?? "error"}`); + } + diagBus.emit(runId, { type: "done", runId }); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + await finishDiagRun(runId, "error", null, null, message).catch(() => {}); + diagBus.emit(runId, { type: "error", runId, message }); + } + } finally { + activeDrives.delete(runId); + } + }, +}; diff --git a/apps/api/src/fixdiag/programs.ts b/apps/api/src/fixdiag/programs.ts new file mode 100644 index 0000000..95a96d5 --- /dev/null +++ b/apps/api/src/fixdiag/programs.ts @@ -0,0 +1,145 @@ +import { ax } from "@ax-llm/ax"; +import { asCause, asConfidence, asString, asStringArray, type DiagLlm } from "./llm"; +import type { + DequelReport, + ExplainInput, + Explanation, + FixdiagPrograms, + Localization, + Proposal, + ProposeInput, + TriageInput, + TriageVerdict, + UserFix, +} from "./types"; + +const nonEmpty = (value: string, label: string): string => + value.trim() ? value : `(${label} unavailable — evidence partial or empty)`; + +const clip = (value: string, max = 12_000): string => + value.length > max ? `${value.slice(0, max)}\n…[truncated ${value.length - max} chars]` : value; + +const ERROR_HINT = + /error|fail|exception|denied|refused|timeout|timed out|not found|missing|unable|cannot|invalid|misbehaving|unrecognized/i; + +export const heuristicSignalLines = (logText: string, max = 5): string[] => { + const lines = logText + .split("\n") + .map((line) => line.trim()) + .filter(Boolean); + const hits = lines.filter((line) => ERROR_HINT.test(line)); + return (hits.length > 0 ? hits : lines).slice(-max); +}; + +const PROGRAM_TIMEOUT_MS = 90_000; + +export const createPrograms = (llm: DiagLlm): FixdiagPrograms => { + const opts = { timeout: PROGRAM_TIMEOUT_MS, maxRetries: 1 } as const; + + const triage = ax( + 'failureReason:string, logText:string -> failingStage:string, signalLines:string[], confidence:class "low,medium,high"', + { + description: + 'You triage a failed Dequel deployment. Identify which build stage failed and quote the 1-5 most telling log lines. Reply with one line per field and no bullet dashes, for example:\nFailing Stage: Docker Build\nSignal Lines: ["[build] ERROR: ..."]\nConfidence: high', + }, + ); + + const explain = ax("verdict:string, localization:string -> summary:string, fixSteps:string[], patchHint:string", { + description: + "You explain a diagnosed build failure to the developer who owns the deployment. summary is 2-4 sentences. fixSteps are concrete actions. patchHint is a unified diff when the fix is a small code change, else the exact text (no diff). Reply with one line per field and no bullet dashes.", + }); + + const proposeUserFix = ax( + 'localization:string, explanation:string, repo:string -> cause:class "user-source,unknown", title:string, body:string, suggestedDiff:string', + { + description: + "You draft a pull request fixing a diagnosed failure in the user's own source. Always fill title, body, and suggestedDiff; write the exact text (no diff) for suggestedDiff when no code change applies. Answer unknown for cause when the evidence does not support a user-source fix. Reply with one line per field and no bullet dashes.", + }, + ); + + const proposeDequelReport = ax( + 'localization:string, explanation:string, repo:string -> cause:class "dequel-source,unknown", problem:string, fixCause:string, proposedFix:string', + { + description: + "You write a bug report for the Dequel platform team about a failure in Dequel's own tooling. Always fill problem, fixCause, and proposedFix with concrete content. Answer unknown for cause when the evidence does not support a Dequel-source report. Reply with one line per field and no bullet dashes.", + }, + ); + + return { + triageLogs: async (input: TriageInput): Promise => { + const failureReason = nonEmpty(input.failureReason ?? "", "failure reason"); + const logText = clip(nonEmpty(input.logText, "build logs")); + try { + const out = await triage.forward(llm, { failureReason, logText }, opts); + return { + failingStage: asString(out.failingStage, "unknown"), + signalLines: asStringArray(out.signalLines, 8), + confidence: asConfidence(out.confidence), + }; + } catch { + return { failingStage: "unknown", signalLines: heuristicSignalLines(logText), confidence: "low" }; + } + }, + + explainFix: async (input: ExplainInput): Promise => { + const verdict = JSON.stringify(input.verdict); + const localization = JSON.stringify(input.localization); + try { + const out = await explain.forward(llm, { verdict, localization }, opts); + const patchHint = asString(out.patchHint); + return { + summary: asString(out.summary), + fixSteps: asStringArray(out.fixSteps, 12), + patchHint: patchHint.trim() ? patchHint : null, + }; + } catch { + return { + summary: asString((input.localization as Localization).rationale, "Explanation unavailable."), + fixSteps: [], + patchHint: null, + }; + } + }, + + draftProposal: async (input: ProposeInput): Promise => { + const base = { + localization: JSON.stringify(input.localization), + explanation: JSON.stringify(input.explanation), + repo: JSON.stringify(input.repo), + }; + if (input.localization.cause === "user-source") { + try { + const out = await proposeUserFix.forward(llm, base, opts); + const userFix: UserFix = { + title: asString(out.title, "Fix build failure"), + body: asString(out.body), + suggestedDiff: asString(out.suggestedDiff).trim() || null, + }; + return { cause: asCause(out.cause) === "unknown" ? "unknown" : "user-source", userFix }; + } catch { + return { + cause: "user-source", + userFix: { title: "Fix build failure", body: input.localization.rationale, suggestedDiff: null }, + }; + } + } + if (input.localization.cause === "dequel-source") { + try { + const out = await proposeDequelReport.forward(llm, base, opts); + const dequelReport: DequelReport = { + problem: asString(out.problem), + cause: asString(out.fixCause), + proposedFix: asString(out.proposedFix), + }; + return { cause: asCause(out.cause) === "unknown" ? "unknown" : "dequel-source", dequelReport }; + } catch { + return { + cause: "dequel-source", + dequelReport: { problem: input.localization.rationale, cause: "", proposedFix: "" }, + }; + } + } + return { cause: "unknown" }; + }, + }; +}; diff --git a/apps/api/src/fixdiag/provider-models.ts b/apps/api/src/fixdiag/provider-models.ts new file mode 100644 index 0000000..308efa0 --- /dev/null +++ b/apps/api/src/fixdiag/provider-models.ts @@ -0,0 +1,251 @@ +export interface FetchModelsOptions { + provider: string; + apiKey?: string; + baseUrl?: string | null; + timeoutMs?: number; +} + +export const DEFAULT_PROVIDER_MODELS: Record = { + openai: ["gpt-4o", "gpt-4o-mini", "o1", "o3-mini", "gpt-4-turbo", "gpt-6.1-sol", "gpt-6-astra", "gpt-5.6-sol"], + anthropic: [ + "claude-3-5-sonnet-latest", + "claude-3-5-haiku-latest", + "claude-3-opus-latest", + "claude-sonnet-5", + "claude-opus-5", + ], + gemini: [ + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-1.5-pro", + "gemini-1.5-flash", + "gemini-3.8-flash", + "gemini-3.7-flash", + ], + groq: [ + "llama-3.3-70b-versatile", + "llama-3.1-8b-instant", + "meta-llama/llama-4-maverick-17b-128e-instruct", + "moonshotai/kimi-k2-instruct", + "openai/gpt-oss-120b", + ], + ollama: ["llama3:latest", "qwen2.5-coder:latest", "mistral:latest", "deepseek-r1:latest", "qwen3.5:0.8b"], + custom: [], +}; + +const EXCLUDED_OPENAI_PATTERNS = + /embedding|whisper|tts|dall-e|moderation|babbage|davinci|realtime|transcription|audio|canary|search/i; + +const fetchOpenAiModels = async (apiKey: string, baseUrl?: string | null, timeoutMs = 8000): Promise => { + const root = baseUrl ? baseUrl.replace(/\/+$/, "") : "https://api.openai.com/v1"; + const url = root.endsWith("/models") ? root : `${root}/models`; + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + throw new Error(`OpenAI models request failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { data?: Array<{ id: string }> }; + const rawList = Array.isArray(body?.data) ? body.data : []; + const filtered = rawList + .map((m) => m.id) + .filter((id): id is string => typeof id === "string" && !EXCLUDED_OPENAI_PATTERNS.test(id)); + + filtered.sort((a, b) => { + const aRank = a.includes("gpt-4o") || a.startsWith("o1") || a.startsWith("o3") ? 0 : 1; + const bRank = b.includes("gpt-4o") || b.startsWith("o1") || b.startsWith("o3") ? 0 : 1; + if (aRank !== bRank) return aRank - bRank; + return a.localeCompare(b); + }); + return filtered; +}; + +const fetchAnthropicModels = async (apiKey: string, timeoutMs = 8000): Promise => { + const res = await fetch("https://api.anthropic.com/v1/models", { + method: "GET", + headers: { + "x-api-key": apiKey, + "anthropic-version": "2023-06-01", + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + throw new Error(`Anthropic models request failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { data?: Array<{ id: string }> }; + const rawList = Array.isArray(body?.data) ? body.data : []; + const filtered = rawList.map((m) => m.id).filter((id): id is string => typeof id === "string"); + + filtered.sort((a, b) => { + const aRank = a.includes("sonnet") ? 0 : a.includes("opus") ? 1 : a.includes("haiku") ? 2 : 3; + const bRank = b.includes("sonnet") ? 0 : b.includes("opus") ? 1 : b.includes("haiku") ? 2 : 3; + if (aRank !== bRank) return aRank - bRank; + return b.localeCompare(a); + }); + return filtered; +}; + +const fetchGeminiModels = async (apiKey: string, timeoutMs = 8000): Promise => { + const url = `https://generativelanguage.googleapis.com/v1beta/models?key=${encodeURIComponent(apiKey)}`; + const res = await fetch(url, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + throw new Error(`Gemini models request failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { + models?: Array<{ name: string; supportedGenerationMethods?: string[] }>; + }; + const rawList = Array.isArray(body?.models) ? body.models : []; + const filtered = rawList + .filter((m) => { + const methods = Array.isArray(m.supportedGenerationMethods) ? m.supportedGenerationMethods : []; + return methods.includes("generateContent"); + }) + .map((m) => (typeof m.name === "string" ? m.name.replace(/^models\//, "") : "")) + .filter((id): id is string => !!id && !/embedding|aqa|bison|gecko/i.test(id)); + + filtered.sort((a, b) => { + const aRank = a.includes("flash") ? 0 : a.includes("pro") ? 1 : 2; + const bRank = b.includes("flash") ? 0 : b.includes("pro") ? 1 : 2; + if (aRank !== bRank) return aRank - bRank; + return a.localeCompare(b); + }); + return filtered; +}; + +const fetchGroqModels = async (apiKey: string, baseUrl?: string | null, timeoutMs = 8000): Promise => { + const root = baseUrl ? baseUrl.replace(/\/+$/, "") : "https://api.groq.com/openai/v1"; + const url = root.endsWith("/models") ? root : `${root}/models`; + const res = await fetch(url, { + method: "GET", + headers: { + Authorization: `Bearer ${apiKey}`, + "Content-Type": "application/json", + }, + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) { + throw new Error(`Groq models request failed: ${res.status} ${res.statusText}`); + } + const body = (await res.json()) as { data?: Array<{ id: string; active?: boolean }> }; + const rawList = Array.isArray(body?.data) ? body.data : []; + const filtered = rawList + .filter((m) => m.active !== false) + .map((m) => m.id) + .filter((id): id is string => typeof id === "string" && !/whisper|tts/i.test(id)); + + filtered.sort((a, b) => a.localeCompare(b)); + return filtered; +}; + +const fetchOllamaModels = async (baseUrl?: string | null, timeoutMs = 8000): Promise => { + const raw = baseUrl || "http://host.docker.internal:11435"; + const root = raw.replace(/\/v1\/?$/, "").replace(/\/+$/, ""); + let models: string[] = []; + + try { + const res = await fetch(`${root}/api/tags`, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) { + const body = (await res.json()) as { models?: Array<{ name: string }> }; + if (Array.isArray(body?.models)) { + models = body.models.map((m) => m.name).filter((n): n is string => typeof n === "string"); + } + } + } catch {} + + if (models.length === 0) { + try { + const res = await fetch(`${root}/v1/models`, { + method: "GET", + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) { + const body = (await res.json()) as { data?: Array<{ id: string }> }; + if (Array.isArray(body?.data)) { + models = body.data.map((m) => m.id).filter((n): n is string => typeof n === "string"); + } + } + } catch {} + } + + return models; +}; + +const fetchCustomModels = async (apiKey?: string, baseUrl?: string | null, timeoutMs = 8000): Promise => { + if (!baseUrl) return []; + const cleanBase = baseUrl.replace(/\/+$/, ""); + const headers: Record = { "Content-Type": "application/json" }; + if (apiKey) headers.Authorization = `Bearer ${apiKey}`; + + const endpoints = cleanBase.endsWith("/models") + ? [cleanBase] + : cleanBase.endsWith("/v1") + ? [`${cleanBase}/models`] + : [`${cleanBase}/models`, `${cleanBase}/v1/models`]; + + for (const endpoint of endpoints) { + try { + const res = await fetch(endpoint, { + method: "GET", + headers, + signal: AbortSignal.timeout(timeoutMs), + }); + if (res.ok) { + const body = (await res.json()) as any; + const list = Array.isArray(body?.data) ? body.data : Array.isArray(body) ? body : []; + const extracted = list + .map((m: any) => (typeof m === "string" ? m : m?.id || m?.name)) + .filter((id: unknown): id is string => typeof id === "string"); + if (extracted.length > 0) return extracted; + } + } catch {} + } + return []; +}; + +export const fetchProviderModels = async (options: FetchModelsOptions): Promise => { + const { provider, apiKey = "", baseUrl, timeoutMs = 8000 } = options; + try { + let models: string[] = []; + switch (provider) { + case "openai": + if (apiKey) models = await fetchOpenAiModels(apiKey, baseUrl, timeoutMs); + break; + case "anthropic": + if (apiKey) models = await fetchAnthropicModels(apiKey, timeoutMs); + break; + case "gemini": + if (apiKey) models = await fetchGeminiModels(apiKey, timeoutMs); + break; + case "groq": + if (apiKey) models = await fetchGroqModels(apiKey, baseUrl, timeoutMs); + break; + case "ollama": + models = await fetchOllamaModels(baseUrl, timeoutMs); + break; + case "custom": + models = await fetchCustomModels(apiKey, baseUrl, timeoutMs); + break; + } + + if (models.length > 0) { + return Array.from(new Set(models)); + } + } catch {} + + return DEFAULT_PROVIDER_MODELS[provider] ?? []; +}; diff --git a/apps/api/src/fixdiag/repo-url.ts b/apps/api/src/fixdiag/repo-url.ts new file mode 100644 index 0000000..ec718eb --- /dev/null +++ b/apps/api/src/fixdiag/repo-url.ts @@ -0,0 +1,8 @@ +export const parseGithubRepo = ( + sourceRef: string, + branch: string | null, +): { owner: string; repo: string; base: string } | null => { + const match = /github\.com[/:]([^/]+)\/([^/]+?)(?:\.git)?\/?$/i.exec(sourceRef.trim()); + if (!match) return null; + return { owner: match[1], repo: match[2], base: branch ?? "main" }; +}; diff --git a/apps/api/src/fixdiag/routes.ts b/apps/api/src/fixdiag/routes.ts new file mode 100644 index 0000000..0b630ab --- /dev/null +++ b/apps/api/src/fixdiag/routes.ts @@ -0,0 +1,136 @@ +import { Elysia } from "elysia"; +import { findCompletedAction } from "../db/repo/diag-actions"; +import { getDiagRun, listActiveDiagRuns, listStageResults } from "../db/repo/diag-runs"; +import { created, fail, ok } from "../api/response"; +import { approveFixPr, approveSlackPost } from "./actions"; +import { DiagRunMachine } from "./machine"; +import { diagBus } from "./stream"; +import type { StageEvent } from "./types"; + +export const fixdiagRoutes = new Elysia() + .post("/deployments/:id/diagnose", async ({ params: { id }, body, set }: any) => { + const started = await DiagRunMachine.start({ + deploymentId: String(id), + provider: String(body?.provider ?? ""), + model: String(body?.model ?? ""), + }); + if (!started.ok) { + set.status = started.status; + return fail(started.message); + } + if (started.created) { + void DiagRunMachine.drive(started.run.id).catch((err) => { + console.error("[Fixdiag] Drive failed:", err); + }); + return created(started.run, "Diagnosis started"); + } + if (started.run.status === "running") { + void DiagRunMachine.drive(started.run.id).catch((err) => { + console.error("[Fixdiag] Drive failed:", err); + }); + } + return ok(started.run, "Diagnosis already exists"); + }) + .get("/diagnoses/active", async () => ok(await listActiveDiagRuns())) + .get("/diagnoses/:id", async ({ params: { id }, set }: any) => { + const run = await getDiagRun(String(id)); + if (!run) { + set.status = 404; + return fail("Diagnosis not found"); + } + const slack = await findCompletedAction(run.id, "slack").catch(() => null); + return ok({ run, stages: await listStageResults(run.id), slackPosted: !!slack }); + }) + .post("/diagnoses/:id/approve-pr", async ({ params: { id }, body, request, set }: any) => { + const res = await approveFixPr(String(id), String(body?.idempotencyKey ?? ""), request.headers.get("cookie")); + if (!res.ok) { + set.status = res.status; + return fail(res.message); + } + return ok(res.data, "Fix PR created"); + }) + .post("/diagnoses/:id/approve-slack", async ({ params: { id }, body, set }: any) => { + const res = await approveSlackPost(String(id), String(body?.idempotencyKey ?? "")); + if (!res.ok) { + set.status = res.status; + return fail(res.message); + } + return ok(res.data, "Report posted to Dequel Slack"); + }) + .get("/diagnoses/:id/stream", async ({ params: { id }, request, set }: any) => { + const run = await getDiagRun(String(id)); + if (!run) { + set.status = 404; + return fail("Diagnosis not found"); + } + const encoder = new TextEncoder(); + let unsubscribe: () => void = () => {}; + let heartbeat: ReturnType | null = null; + let closed = false; + const stop = () => { + if (closed) return; + closed = true; + unsubscribe(); + if (heartbeat) clearInterval(heartbeat); + }; + const runId = run.id; + 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 handle = (event: StageEvent) => { + if (closed) return; + if (event.type === "stage") send("stage", event); + else if (event.type === "done") { + send("done", event); + stop(); + controller.close(); + } else if (event.type === "error") { + send("error", event); + stop(); + controller.close(); + } + }; + send("ready", { runId }); + const buffered: StageEvent[] = []; + let replaying = true; + unsubscribe = diagBus.subscribe(runId, (event) => { + if (event.type === "token") return; + if (replaying) buffered.push(event); + else handle(event); + }); + for (const stage of await listStageResults(runId)) { + send("stage", { runId, stage: stage.stage, payload: stage.payload }); + } + replaying = false; + for (const event of buffered) handle(event); + if (closed) return; + const latest = (await getDiagRun(runId)) ?? run; + if (closed) return; + if (latest.status === "done") { + send("done", { runId }); + stop(); + controller.close(); + return; + } + if (latest.status === "error") { + send("error", { runId, message: latest.error }); + stop(); + controller.close(); + return; + } + heartbeat = setInterval(() => send("heartbeat", { at: new Date().toISOString() }), 15000); + }, + cancel: stop, + }); + request.signal.addEventListener("abort", stop, { once: true }); + return new Response(stream, { + headers: { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + }); diff --git a/apps/api/src/fixdiag/sandbox-host.ts b/apps/api/src/fixdiag/sandbox-host.ts new file mode 100644 index 0000000..0bf5ef3 --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-host.ts @@ -0,0 +1,351 @@ +import { execFile } from "node:child_process"; +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { promisify } from "node:util"; +import { getDecryptedLlmKey } from "../db/repo/llm-keys"; +import { config } from "../utils/config"; +import type { DeploymentFacts, Investigation, TriageVerdict } from "./types"; + +const execFileAsync = promisify(execFile); + +export const SANDBOX_CONTAINER = "dequel-diag-sandbox"; +const RUNNER = "/runner/fixdiag/sandbox-runner/runner.js"; + +const short = (s: string, n = 500): string => (s.length > n ? `${s.slice(0, n)}…` : s); + +export const readLocalVersion = async (): Promise => { + const roots = [resolve(import.meta.dir, "../../../.."), "/app"]; + for (const root of roots) { + try { + const rev = await readFile(join(root, "VERSION"), "utf8").catch(() => null); + const text = (typeof rev === "string" ? rev : (rev as unknown as { toString(): string })?.toString())?.trim(); + if (text) return text; + } catch {} + } + const manifests = [resolve(import.meta.dir, "../../package.json"), "/app/package.json"]; + for (const manifest of manifests) { + try { + const raw = await readFile(manifest, "utf8").catch(() => null); + const text = typeof raw === "string" ? raw : (raw as unknown as { toString(): string })?.toString(); + if (!text) continue; + const version = (JSON.parse(text) as { version?: unknown }).version; + if (typeof version === "string" && version.trim()) return version.trim(); + } catch {} + } + return "unknown"; +}; + +const docker = async (args: string[], timeoutMs = 60_000): Promise => { + try { + const out = await execFileAsync("docker", args, { timeout: timeoutMs, maxBuffer: 8 * 1024 * 1024 }); + return String(out.stdout ?? ""); + } catch (err) { + throw new Error(short(err instanceof Error ? err.message : String(err))); + } +}; + +const OWN_DIRS = "/srv/jobs /srv/dequel-src /srv/project-src"; + +const normalizeOwnership = async (): Promise => { + await docker( + [ + "exec", + "-u", + "0", + SANDBOX_CONTAINER, + "sh", + "-c", + `u=$(id -u bun); for d in ${OWN_DIRS}; do [ "$(stat -c %u "$d" 2>/dev/null)" = "$u" ] || chown -R bun:bun "$d"; done`, + ], + 120_000, + ).catch(() => {}); +}; + +export const ensureSandbox = async (): Promise => { + const running = await docker(["inspect", "-f", "{{.State.Running}}", SANDBOX_CONTAINER], 15_000) + .then((out) => out.trim() === "true") + .catch(() => false); + if (!running) { + const exists = await docker(["inspect", "-f", "{{.Id}}", SANDBOX_CONTAINER], 15_000) + .then((out) => out.trim().length > 0) + .catch(() => false); + if (!exists) throw new Error("Diagnosis sandbox is not running (start the diagnose-sandbox service)"); + await docker(["start", SANDBOX_CONTAINER], 60_000); + } + await normalizeOwnership(); +}; + +export const syncDequelSource = async (version: string): Promise<{ rev: string; stale: boolean }> => { + const tag = version.startsWith("v") ? version : `v${version}`; + const hasRepo = await docker([ + "exec", + SANDBOX_CONTAINER, + "sh", + "-c", + "test -d /srv/dequel-src/.git && echo yes || echo no", + ]).then((out) => out.trim() === "yes"); + if (!hasRepo) { + await docker( + [ + "exec", + SANDBOX_CONTAINER, + "git", + "clone", + "--depth", + "1", + "--branch", + tag, + config.dequelSourceRepoUrl, + "/srv/dequel-src", + ], + 180_000, + ).catch(() => + docker( + ["exec", SANDBOX_CONTAINER, "git", "clone", "--depth", "1", config.dequelSourceRepoUrl, "/srv/dequel-src"], + 180_000, + ), + ); + } + const fetched = await docker([ + "exec", + SANDBOX_CONTAINER, + "sh", + "-c", + `git -C /srv/dequel-src fetch --depth 1 origin ${tag} && git -C /srv/dequel-src checkout -q ${tag}`, + ]) + .then(() => true) + .catch(() => false); + const sha = await docker( + ["exec", SANDBOX_CONTAINER, "git", "-C", "/srv/dequel-src", "rev-parse", "--short", "HEAD"], + 15_000, + ) + .then((out) => out.trim()) + .catch(() => ""); + const rev = sha ? `${tag} @ ${sha}` : tag; + return { rev, stale: !fetched }; +}; + +const PROJECT_SRC_PARENT = "/srv/project-src"; + +const sanitizeRunId = (runId: string): string => runId.replace(/[^A-Za-z0-9_-]/g, "") || "run"; + +export const projectSrcDir = (runId: string): string => `${PROJECT_SRC_PARENT}/${sanitizeRunId(runId)}`; + +export const syncProjectSource = async ( + facts: DeploymentFacts, + runId: string, +): Promise<{ available: boolean; srcDir: string }> => { + const srcDir = projectSrcDir(runId); + await docker( + [ + "exec", + "-u", + "0", + SANDBOX_CONTAINER, + "sh", + "-c", + `rm -rf '${srcDir}' && mkdir -p '${srcDir}' && chown bun:bun '${srcDir}'`, + ], + 30_000, + ); + if (facts.sourceType === "git") { + const base = facts.branch ? ["--branch", facts.branch] : []; + const cloned = await docker( + ["exec", SANDBOX_CONTAINER, "git", "clone", "--depth", "1", ...base, facts.sourceRef, srcDir], + 180_000, + ) + .then(() => true) + .catch(() => false); + if (!cloned) return { available: false, srcDir }; + if (facts.commitSha) { + await docker( + ["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "fetch", "--depth", "1", "origin", facts.commitSha], + 120_000, + ).catch(() => ""); + await docker(["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "checkout", "-q", facts.commitSha], 30_000).catch( + () => "", + ); + } + return { available: true, srcDir }; + } + if (facts.sourceType === "upload") { + const dir = join(config.workspaceRoot, facts.deploymentId); + try { + await docker(["cp", `${dir}/.`, `${SANDBOX_CONTAINER}:${srcDir}/`], 120_000); + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "chown", "-R", "bun:bun", srcDir], 60_000).catch(() => {}); + return { available: true, srcDir }; + } catch { + return { available: false, srcDir }; + } + } + return { available: false, srcDir }; +}; + +export const clearProjectSource = async (runId: string): Promise => { + await docker(["exec", "-u", "0", SANDBOX_CONTAINER, "rm", "-rf", projectSrcDir(runId)], 30_000).catch(() => {}); +}; + +export const investigateInSandbox = async (input: { + runId: string; + provider: string; + model: string; + deployment: DeploymentFacts; + failureReason: string; + logText: string; + verdict: TriageVerdict; + dequelRev: string; + dequelStale: boolean; + projectAvailable: boolean; + onProgress: (line: string) => void; +}): Promise => { + const key = await getDecryptedLlmKey(input.provider); + if (!key) throw new Error(`LLM provider "${input.provider}" is not configured`); + await ensureSandbox(); + const taskBrief = [ + `Dequel platform source is mounted at scope "dequel" (${input.dequelRev}${input.dequelStale ? ", possibly stale" : ""}).`, + input.projectAvailable + ? 'The deployed project\'s source is mounted at scope "project".' + : "No project source is available; diagnose from the logs and the Dequel source only.", + `Triage already read the logs (confidence ${input.verdict.confidence}, failing stage "${input.verdict.failingStage}") and flagged these signal lines: ${input.verdict.signalLines.slice(0, 6).join(" | ")}. Start from them instead of re-discovering the failure.`, + "Use listFiles to discover layout, readFile for targeted reads, searchText to trace error strings.", + ].join(" "); + const raw = await runAgentJob({ + jobDir: `/srv/jobs/${sanitizeRunId(input.runId)}`, + input: { + mode: "investigate", + failureReason: input.failureReason, + logText: input.logText, + taskBrief, + provider: input.provider, + model: input.model, + apiKey: key.apiKey, + baseUrl: key.baseUrl, + hasProjectSource: input.projectAvailable, + projectSrcDir: input.projectAvailable ? projectSrcDir(input.runId) : null, + dequelRev: input.dequelRev, + dequelStale: input.dequelStale, + deadlineMs: 9 * 60_000, + }, + execTimeoutMs: 10 * 60_000, + onProgress: input.onProgress, + }); + return validateInvestigation(raw); +}; + +export interface FixAgentResult { + patch: string; + summary: string; + changedFiles: string[]; +} + +export const runFixAgent = async (input: { + runId: string; + provider: string; + model: string; + fixBrief: string; + dequelRev: string; + dequelStale: boolean; + onProgress: (line: string) => void; +}): Promise => { + const key = await getDecryptedLlmKey(input.provider); + if (!key) throw new Error(`LLM provider "${input.provider}" is not configured`); + await ensureSandbox(); + const srcDir = projectSrcDir(input.runId); + const raw = (await runAgentJob({ + jobDir: `/srv/jobs/fix-${sanitizeRunId(input.runId)}`, + input: { + mode: "fix", + fixBrief: input.fixBrief, + provider: input.provider, + model: input.model, + apiKey: key.apiKey, + baseUrl: key.baseUrl, + hasProjectSource: true, + projectSrcDir: srcDir, + dequelRev: input.dequelRev, + dequelStale: input.dequelStale, + deadlineMs: 5.5 * 60_000, + }, + execTimeoutMs: 6 * 60_000, + onProgress: input.onProgress, + })) as Record; + const summary = typeof raw.summary === "string" ? raw.summary : ""; + const changedFiles = Array.isArray(raw.changedFiles) + ? raw.changedFiles.filter((v): v is string => typeof v === "string").slice(0, 32) + : []; + await docker(["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "add", "-A"], 30_000).catch(() => ""); + const patch = await docker( + ["exec", SANDBOX_CONTAINER, "git", "-C", srcDir, "diff", "--cached", "--no-color", "--binary"], + 30_000, + ) + .then((out) => out) + .catch(() => ""); + if (!patch.trim()) throw new Error("agent made no changes to the project source"); + if (patch.length > 128_000) throw new Error("agent fix is too large to apply as a pull request"); + return { patch, summary, changedFiles }; +}; + +const runAgentJob = async (opts: { + jobDir: string; + input: unknown; + execTimeoutMs: number; + onProgress: (line: string) => void; +}): Promise => { + const hostTmp = await mkdtemp(join(tmpdir(), "dequel-diag-job-")); + try { + await writeFile(join(hostTmp, "input.json"), JSON.stringify(opts.input)); + await docker(["exec", SANDBOX_CONTAINER, "mkdir", "-p", opts.jobDir], 15_000); + await docker(["cp", join(hostTmp, "input.json"), `${SANDBOX_CONTAINER}:${opts.jobDir}/input.json`], 30_000); + const deadline = Date.now() + opts.execTimeoutMs; + let exited = false; + const run = docker(["exec", SANDBOX_CONTAINER, "bun", RUNNER, `${opts.jobDir}/input.json`], opts.execTimeoutMs) + .then(() => { + exited = true; + }) + .catch((err) => { + exited = true; + throw new Error(`sandbox agent failed: ${short(String(err))}`); + }); + run.catch(() => {}); + let seen = 0; + while (Date.now() < deadline && !exited) { + await new Promise((resolve) => setTimeout(resolve, 2000)); + const lines = await docker( + ["exec", SANDBOX_CONTAINER, "sh", "-c", `cat ${opts.jobDir}/progress.jsonl 2>/dev/null || true`], + 15_000, + ) + .then((out) => out.split("\n").filter(Boolean)) + .catch(() => [] as string[]); + for (const line of lines.slice(seen)) opts.onProgress(line); + seen = lines.length; + } + await run; + const report = await docker( + ["exec", SANDBOX_CONTAINER, "sh", "-c", `cat ${opts.jobDir}/report.json 2>/dev/null || true`], + 15_000, + ) + .then((out) => out.trim()) + .catch(() => ""); + if (!report) throw new Error("sandbox agent produced no report"); + return JSON.parse(report); + } finally { + await docker(["exec", SANDBOX_CONTAINER, "rm", "-rf", opts.jobDir], 30_000).catch(() => {}); + await rm(hostTmp, { recursive: true, force: true }).catch(() => {}); + } +}; + +export const validateInvestigation = (raw: unknown): Investigation => { + const record = (raw ?? {}) as Record; + const cause = record.cause === "user-source" || record.cause === "dequel-source" ? record.cause : "unknown"; + const strings = (value: unknown, max = 8): string[] => + Array.isArray(value) ? value.filter((v): v is string => typeof v === "string").slice(0, max) : []; + return { + cause, + culpritPaths: strings(record.culpritPaths), + rationale: typeof record.rationale === "string" ? record.rationale : "", + keyEvidence: strings(record.keyEvidence), + dequelRev: typeof record.dequelRev === "string" ? record.dequelRev : "unknown", + dequelStale: record.dequelStale === true, + }; +}; diff --git a/apps/api/src/fixdiag/sandbox-runner/__tests__/agent-def.test.ts b/apps/api/src/fixdiag/sandbox-runner/__tests__/agent-def.test.ts new file mode 100644 index 0000000..bd35bb8 --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/__tests__/agent-def.test.ts @@ -0,0 +1,11 @@ +import { describe, expect, it } from "bun:test"; +import { createFixer, createInvestigator } from "../agent-def"; + +const ctx = { progressPath: "/tmp/progress.jsonl", projectRoot: "/srv/project-src", dequelRef: "v0.0.0" }; + +describe("agent construction", () => { + it("builds the investigator and fixer without throwing", () => { + expect(() => createInvestigator(ctx)).not.toThrow(); + expect(() => createFixer(ctx)).not.toThrow(); + }); +}); diff --git a/apps/api/src/fixdiag/sandbox-runner/__tests__/forward.test.ts b/apps/api/src/fixdiag/sandbox-runner/__tests__/forward.test.ts new file mode 100644 index 0000000..97fe5bd --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/__tests__/forward.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, mock } from "bun:test"; +import { forwardWithFallback, type Forwardable } from "../forward"; + +mock.restore(); + +const answerOnly = (): Forwardable => ({ + forward: async () => ({ cause: "unknown" }), +}); + +describe("forwardWithFallback", () => { + it("returns the first result when the program finishes in budget", async () => { + const prog: Forwardable = { + forward: async () => ({ cause: "dequel-source" }), + }; + const out = await forwardWithFallback(prog, answerOnly(), {}, { q: "x" }, "/tmp/nonexistent-progress.log"); + expect(out).toEqual({ cause: "dequel-source" }); + }); + + it("retries answer-only on shared memory after max steps", async () => { + const calls: Record[] = []; + let n = 0; + const prog: Forwardable = { + forward: async (_llm, _values, opts) => { + calls.push(opts ?? {}); + n += 1; + if (n === 1) throw new Error("Generate failed: Max steps reached: 20"); + return { cause: "unknown" }; + }, + }; + const answerCalls: Record[] = []; + const fallback: Forwardable = { + forward: async (_llm, _values, opts) => { + answerCalls.push(opts ?? {}); + return { cause: "unknown" }; + }, + }; + const out = await forwardWithFallback(prog, fallback, {}, { q: "x" }, "/tmp/nonexistent-progress.log"); + expect(out).toEqual({ cause: "unknown" }); + expect(calls).toHaveLength(1); + expect(answerCalls).toHaveLength(1); + expect(answerCalls[0]).toMatchObject({ functionCall: "none" }); + expect((answerCalls[0] as Record).mem).toBe((calls[0] as Record).mem); + }); + + it("rethrows errors other than max steps", async () => { + const prog: Forwardable = { + forward: async () => { + throw new Error("Generate failed: HTTP 429 - Too Many Requests"); + }, + }; + await expect( + forwardWithFallback(prog, answerOnly(), {}, { q: "x" }, "/tmp/nonexistent-progress.log"), + ).rejects.toThrow(/429/); + }); + + it("detects max steps buried in a wrapper error chain", async () => { + const prog: Forwardable = { + forward: async () => { + throw new Error("Generate failed", { cause: new Error("Max steps reached: 20") }); + }, + }; + const out = await forwardWithFallback(prog, answerOnly(), {}, { q: "x" }, "/tmp/nonexistent-progress.log"); + expect(out).toEqual({ cause: "unknown" }); + }); +}); diff --git a/apps/api/src/fixdiag/sandbox-runner/__tests__/tools.test.ts b/apps/api/src/fixdiag/sandbox-runner/__tests__/tools.test.ts new file mode 100644 index 0000000..117a065 --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/__tests__/tools.test.ts @@ -0,0 +1,103 @@ +import { afterAll, beforeAll, describe, expect, it, mock } from "bun:test"; +import { mkdtemp, mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { + buildFileTools, + buildFixTools, + editFileAt, + listFilesIn, + readFileAt, + searchInScope, + writeFileAt, + type ToolContext, +} from "../tools"; + +mock.restore(); + +let root = ""; +let ctx: ToolContext; + +beforeAll(async () => { + root = await mkdtemp(join(tmpdir(), "diag-tools-")); + ctx = { + roots: { dequel: join(root, "dequel"), project: join(root, "project") }, + progressPath: join(root, "progress.jsonl"), + }; + await mkdir(join(root, "dequel", "apps/api/src"), { recursive: true }); + await writeFile(join(root, "dequel", "apps/api/src/pipeline.ts"), "export const run = () => { buildImage(); };\n"); + await writeFile(join(root, "dequel", "README.md"), "Dequel\n"); + await mkdir(join(root, "dequel", "node_modules"), { recursive: true }); + await writeFile(join(root, "dequel", "node_modules/blob.js"), "x".repeat(100)); + await mkdir(join(root, "project"), { recursive: true }); + await writeFile(join(root, "project", "Dockerfile"), "FROM node:20\nRUN npm ci\n"); +}); + +afterAll(async () => { + if (root) await rm(root, { recursive: true, force: true }); +}); + +describe("sandbox file tools", () => { + it("lists files while skipping dependency dirs", async () => { + const paths = await listFilesIn(ctx, "dequel"); + expect(paths).toContain("apps/api/src/pipeline.ts"); + expect(paths).toContain("README.md"); + expect(paths.some((p) => p.includes("node_modules"))).toBe(false); + expect(await listFilesIn(ctx, "dequel", "apps/api/src")).toEqual(["apps/api/src/pipeline.ts"]); + }); + + it("reads capped file content", async () => { + const out = await readFileAt(ctx, "project", "Dockerfile"); + expect(out).toContain("FROM node:20"); + await expect(readFileAt(ctx, "project", "missing.txt")).rejects.toThrow(); + }); + + it("rejects paths escaping the roots", async () => { + await expect(readFileAt(ctx, "dequel", "../../etc/passwd")).rejects.toThrow(/sandbox roots/); + await expect(readFileAt(ctx, "dequel", "/srv/jobs/x/input.json")).rejects.toThrow(/sandbox roots/); + await expect(readFileAt(ctx, "nope", "x")).rejects.toThrow(); + await expect(searchInScope(ctx, "dequel", " ")).rejects.toThrow(); + }); + + it("searches contents across the tree", async () => { + const hits = await searchInScope(ctx, "dequel", "buildImage"); + expect(hits).toHaveLength(1); + expect(hits[0]).toContain("apps/api/src/pipeline.ts:1:"); + expect(await searchInScope(ctx, "project", "zzz-no-match")).toEqual([]); + }); + + it("edits files with exact matches only", async () => { + await writeFile(join(root, "project", "app.ts"), "const a = 1;\nconst b = 1;\n"); + await expect(editFileAt(ctx, "app.ts", "missing", "x")).rejects.toThrow(/not found/); + await expect(editFileAt(ctx, "const", "x")).rejects.toThrow(); + await expect(editFileAt(ctx, "app.ts", "= 1;", "= 2;")).rejects.toThrow(/2 times/); + await expect(editFileAt(ctx, "app.ts", "const a = 1;", "const a = 2;")).resolves.toContain("edited"); + await expect(editFileAt(ctx, "app.ts", "const a = 1;", "const a = 1;")).rejects.toThrow(/identical/); + await expect(editFileAt(ctx, "app.ts", "", "x")).rejects.toThrow(/must not be empty/); + }); + + it("creates new files but never overwrites or escapes", async () => { + await expect(writeFileAt(ctx, "new/nested/file.ts", "hello\n")).resolves.toContain("created"); + await expect(writeFileAt(ctx, "new/nested/file.ts", "again")).rejects.toThrow(/already exists/); + await expect(writeFileAt(ctx, "../../evil.ts", "x")).rejects.toThrow(/sandbox roots/); + await expect(writeFileAt(ctx, "big.ts", "x".repeat(100_001))).rejects.toThrow(/exceeds/); + }); + + it("returns handler errors as results instead of throwing", async () => { + const tools = [...buildFileTools(ctx.roots, ctx.progressPath), ...buildFixTools(ctx.roots, ctx.progressPath)]; + const call = async (name: string, args: Record) => { + const tool = tools.find((t) => t.name === name) as unknown as { func: (a: unknown) => Promise }; + return tool.func(args); + }; + await expect(call("readFile", { scope: "dequel", path: "/srv/jobs/x/input.json" })).resolves.toMatch( + /sandbox roots/, + ); + await expect(call("listFiles", { scope: "dequel", prefix: "../../etc" })).resolves.toEqual([ + expect.stringMatching(/sandbox roots/), + ]); + await expect(call("searchText", { scope: "dequel", query: " " })).resolves.toEqual([ + expect.stringMatching(/Error:/), + ]); + await expect(call("readFile", { scope: "nope", path: "x" })).resolves.toMatch(/Error:/); + }); +}); diff --git a/apps/api/src/fixdiag/sandbox-runner/agent-def.ts b/apps/api/src/fixdiag/sandbox-runner/agent-def.ts new file mode 100644 index 0000000..29ccbb5 --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/agent-def.ts @@ -0,0 +1,40 @@ +import { ax } from "@ax-llm/ax"; +import { buildFileTools, buildFixTools } from "./tools"; + +export interface InvestigatorContext { + progressPath: string; + projectRoot: string | null; + dequelRef: string; +} + +const INVESTIGATOR_MAX_STEPS = 20; +const FIXER_MAX_STEPS = 10; + +export const createInvestigator = (ctx: InvestigatorContext, withTools = true) => { + const functions = withTools + ? buildFileTools({ dequel: "/srv/dequel-src", project: ctx.projectRoot }, ctx.progressPath) + : []; + return ax( + 'failureReason:string, logText:string, taskBrief:string -> cause:class "user-source,dequel-source,unknown", culpritPaths:string[], rationale:string, keyEvidence:string[]', + { + functions, + maxSteps: INVESTIGATOR_MAX_STEPS, + description: withTools + ? "Investigate a failed Dequel deployment using the provided read-only file tools. Scope dequel is Dequel's own platform source; scope project is the deployed project's source and may be absent. Decide first whether the cause is in the user's source or Dequel's source, then gather evidence with a few targeted calls. Failures in Dequel's build environment itself (BuildKit, Docker daemon/network/DNS, railpack image pulls) are dequel-source even when no Dequel code file references the failing artifact; reserve unknown for cases where neither side is implicated. Be economical: prefer searchText over broad lists, read only the files that matter, never re-read a file, never repeat a search that returned 0 hits, and after at most 8 tool calls stop calling tools and write your final answer from what you have. You cannot write files, run commands, or reach the network. Prefer unknown over guessing." + : "Write your final investigation answer NOW from the conversation so far. Tool use is disabled: do not emit any tool calls, answer directly with cause, culpritPaths, rationale, and keyEvidence. Prefer unknown over guessing.", + }, + ); +}; + +export const createFixer = (ctx: InvestigatorContext, withTools = true) => { + const functions = withTools + ? buildFixTools({ dequel: "/srv/dequel-src", project: ctx.projectRoot }, ctx.progressPath) + : []; + return ax("fixBrief:string -> summary:string, changedFiles:string[]", { + functions, + maxSteps: FIXER_MAX_STEPS, + description: withTools + ? "Fix a diagnosed build failure in the project source. You have read tools over the dequel scope (reference only, never modify) and read plus edit tools over the project scope. Make the smallest change that fixes the diagnosed failure: prefer editFile with exact matches, create files only when necessary, never touch lockfiles, vendored code, or anything outside the project scope. End by summarizing the change and listing every file you modified." + : "Write your final fix summary NOW from the conversation so far. Tool use is disabled: do not emit any tool calls, answer directly with summary and changedFiles.", + }); +}; diff --git a/apps/api/src/fixdiag/sandbox-runner/forward.ts b/apps/api/src/fixdiag/sandbox-runner/forward.ts new file mode 100644 index 0000000..66bba4d --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/forward.ts @@ -0,0 +1,74 @@ +import { appendFile } from "node:fs/promises"; +import { AxMemory } from "@ax-llm/ax"; + +export const FORWARD_OPTS = { timeout: 120_000, maxRetries: 1 } as const; + +export type Forwardable = { + forward: ( + llm: unknown, + values: Record, + opts?: Record, + ) => Promise>; +}; + +export const chainText = (err: unknown): string => { + const parts: string[] = []; + const seen = new Set(); + const visit = (cur: unknown): void => { + if (!cur || seen.has(cur)) return; + seen.add(cur); + if (typeof cur === "string") { + if (cur.trim()) parts.push(cur.trim()); + return; + } + if (typeof cur !== "object") return; + const e = cur as { message?: unknown; responseBody?: unknown; cause?: unknown; errors?: unknown }; + if (typeof e.message === "string" && e.message.trim()) parts.push(e.message.trim()); + if (e.responseBody != null) { + const text = typeof e.responseBody === "string" ? e.responseBody : JSON.stringify(e.responseBody); + if (text && text.trim() && text.trim() !== "{}") parts.push(text.trim()); + } + if (Array.isArray(e.errors)) for (const sub of e.errors) visit(sub); + visit(e.cause); + }; + visit(err); + return parts.join(" | ").slice(0, 960); +}; + +export const describeError = (err: unknown): string => { + let message = ""; + let body = ""; + const seen = new Set(); + let cur: unknown = err; + while (cur && typeof cur === "object" && !seen.has(cur)) { + seen.add(cur); + const e = cur as { message?: unknown; responseBody?: unknown; cause?: unknown }; + if (!message && typeof e.message === "string" && e.message.trim()) message = e.message.trim(); + if (!body && e.responseBody != null) { + const text = typeof e.responseBody === "string" ? e.responseBody : JSON.stringify(e.responseBody); + if (text && text.trim() && text.trim() !== "{}") body = text.trim(); + } + cur = e.cause; + } + if (body && !message.includes(body)) return `${body} | ${message}`.slice(0, 480); + return (message || String(err)).slice(0, 480); +}; + +export const forwardWithFallback = async ( + prog: Forwardable, + answerOnlyProg: Forwardable, + llm: unknown, + values: Record, + progressPath: string, +): Promise> => { + const mem = new AxMemory(); + try { + return await prog.forward(llm, values, { ...FORWARD_OPTS, mem }); + } catch (err) { + if (!/max steps/i.test(chainText(err))) throw err; + await appendFile(progressPath, "step budget exhausted; writing final answer without further tool calls\n").catch( + () => {}, + ); + return await answerOnlyProg.forward(llm, values, { ...FORWARD_OPTS, mem, functionCall: "none", maxSteps: 6 }); + } +}; diff --git a/apps/api/src/fixdiag/sandbox-runner/runner.ts b/apps/api/src/fixdiag/sandbox-runner/runner.ts new file mode 100644 index 0000000..11d0192 --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/runner.ts @@ -0,0 +1,115 @@ +import { appendFile, readFile, writeFile } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { asCause, asString, asStringArray, buildLlm } from "../llm"; +import { describeError, forwardWithFallback } from "./forward"; +import type { LlmProvider } from "../types"; +import { createFixer, createInvestigator } from "./agent-def"; + +interface BaseInput { + mode: "investigate" | "fix"; + provider: LlmProvider; + model: string; + apiKey: string; + baseUrl: string | null; + hasProjectSource: boolean; + projectSrcDir?: string | null; + dequelRev: string; + dequelStale: boolean; + deadlineMs?: number; +} + +interface InvestigateInput extends BaseInput { + mode: "investigate"; + failureReason: string; + logText: string; + taskBrief: string; +} + +interface FixInput extends BaseInput { + mode: "fix"; + fixBrief: string; +} + +type RunnerInput = InvestigateInput | FixInput; + +const MAX_LOG_CHARS = 3_000; +const FORWARD_OPTS = { timeout: 120_000, maxRetries: 1 } as const; + +const clipTail = (value: string, max: number): string => + value.length > max ? `…[last ${max} of ${value.length} chars]\n${value.slice(-max)}` : value; + +const main = async (): Promise => { + const [inputPath] = Bun.argv.slice(2); + if (!inputPath) { + console.error("usage: runner.js "); + process.exit(2); + } + const dir = dirname(inputPath); + const progressPath = join(dir, "progress.jsonl"); + const raw = JSON.parse(await readFile(inputPath, "utf8")) as RunnerInput; + const deadline = setTimeout( + () => { + console.error("agent deadline exceeded"); + process.exit(2); + }, + raw.deadlineMs ?? 9 * 60_000, + ); + try { + const llm = buildLlm(raw.provider, raw.apiKey, raw.baseUrl ?? null, raw.model); + const ctx = { + progressPath, + projectRoot: raw.hasProjectSource ? (raw.projectSrcDir ?? "/srv/project-src") : null, + dequelRef: raw.dequelRev, + }; + if (raw.mode === "fix") { + await appendFile(progressPath, "fix started\n").catch(() => {}); + const out = await forwardWithFallback( + createFixer(ctx), + createFixer(ctx, false), + llm, + { fixBrief: raw.fixBrief }, + progressPath, + ); + await writeFile( + join(dir, "report.json"), + JSON.stringify({ + summary: asString(out.summary), + changedFiles: asStringArray(out.changedFiles, 32), + }), + ); + await appendFile(progressPath, "fix complete\n").catch(() => {}); + return; + } + await appendFile(progressPath, "investigation started\n").catch(() => {}); + const out = await forwardWithFallback( + createInvestigator(ctx), + createInvestigator(ctx, false), + llm, + { + failureReason: clipTail(raw.failureReason, 2_000), + logText: clipTail(raw.logText, MAX_LOG_CHARS), + taskBrief: raw.taskBrief, + }, + progressPath, + ); + await writeFile( + join(dir, "report.json"), + JSON.stringify({ + cause: asCause(out.cause), + culpritPaths: asStringArray(out.culpritPaths, 8), + rationale: asString(out.rationale), + keyEvidence: asStringArray(out.keyEvidence, 8), + dequelRev: raw.dequelRev, + dequelStale: raw.dequelStale, + }), + ); + await appendFile(progressPath, "investigation complete\n").catch(() => {}); + } finally { + clearTimeout(deadline); + } +}; + +await main().catch((err) => { + console.error(describeError(err)); + process.exit(1); +}); diff --git a/apps/api/src/fixdiag/sandbox-runner/tools.ts b/apps/api/src/fixdiag/sandbox-runner/tools.ts new file mode 100644 index 0000000..bb043ab --- /dev/null +++ b/apps/api/src/fixdiag/sandbox-runner/tools.ts @@ -0,0 +1,302 @@ +import { appendFile, mkdir, readdir, readFile, realpath, stat, writeFile } from "node:fs/promises"; +import { dirname, join, resolve } from "node:path"; +import { f, fn } from "@ax-llm/ax"; +const MAX_LIST = 80; +const MAX_READ_BYTES = 2_400; +const MAX_WRITE_BYTES = 100_000; +const MAX_HITS = 10; +const MAX_SCAN_FILES = 500; +const MAX_SCAN_BYTES = 100_000; +const MAX_LIST_CHARS = 1_800; +const MAX_SEARCH_CHARS = 1_200; + +const SKIP_DIRS = new Set([ + "node_modules", + ".git", + "dist", + "build", + ".next", + "__MACOSX", + ".venv", + "target", + ".cache", + "coverage", +]); + +export interface SandboxRoots { + dequel: string; + project: string | null; +} + +export interface ToolContext { + roots: SandboxRoots; + progressPath: string; + readBytes?: number; +} + +const capByChars = (items: string[], maxChars: number): string[] => { + const out: string[] = []; + let used = 0; + for (const item of items) { + if (used + item.length > maxChars) { + out.push("…[truncated — narrow with prefix or a new query]"); + break; + } + out.push(item); + used += item.length; + } + return out; +}; + +const toBytes = (data: unknown): Buffer | null => { + if (Buffer.isBuffer(data)) return data; + if (typeof data === "string") return Buffer.from(data, "utf8"); + if (data instanceof Uint8Array) return Buffer.from(data.buffer, data.byteOffset, data.byteLength); + return null; +}; + +const noteProgress = async (progressPath: string, message: string): Promise => { + await appendFile(progressPath, `${message}\n`).catch(() => {}); +}; + +const errText = (err: unknown): string => + `Error: ${err instanceof Error ? err.message : String(err)} (adjust the call and try again)`; + +const asTextResult = + (handler: (...args: A) => Promise) => + async (...args: A): Promise => { + try { + return await handler(...args); + } catch (err) { + return errText(err); + } + }; + +const asListResult = + (handler: (...args: A) => Promise) => + async (...args: A): Promise => { + try { + return await handler(...args); + } catch (err) { + return [errText(err)]; + } + }; + +const baseFor = (roots: SandboxRoots, scope: string): string => { + if (scope === "dequel") return roots.dequel; + if (scope === "project") { + if (!roots.project) throw new Error("project source is not available for this diagnosis (logs only)"); + return roots.project; + } + throw new Error(`unknown scope "${scope}" (use "dequel" or "project")`); +}; + +const confinePath = async (roots: SandboxRoots, scope: string, rel: string): Promise => { + const base = baseFor(roots, scope); + const rootResolved = resolve(base); + const abs = resolve(rootResolved, rel); + if (abs !== rootResolved && !abs.startsWith(`${rootResolved}/`)) + throw new Error("path escapes the sandbox roots (use a repo-relative path, not an absolute path or ..)"); + const real = await realpath(abs).catch(() => null); + if (!real || (real !== rootResolved && !real.startsWith(`${rootResolved}/`))) { + throw new Error(`cannot access path: ${rel} (missing, or outside the ${scope} source root)`); + } + return abs; +}; + +const collectPaths = async (dir: string, rootResolved: string, out: string[]): Promise => { + if (out.length >= MAX_LIST) return; + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (out.length >= MAX_LIST) return; + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + if (SKIP_DIRS.has(entry.name)) continue; + out.push(`${abs.slice(rootResolved.length + 1)}/`); + await collectPaths(abs, rootResolved, out); + } else if (entry.isFile()) { + out.push(abs.slice(rootResolved.length + 1)); + } + } +}; + +export const listFilesIn = async (ctx: ToolContext, scope: string, prefix?: string): Promise => { + const abs = await confinePath(ctx.roots, scope, prefix ?? ""); + const st = await stat(abs).catch(() => null); + if (!st?.isDirectory()) throw new Error(`not a directory: ${prefix ?? ""}`); + const out: string[] = []; + await collectPaths(abs, resolve(baseFor(ctx.roots, scope)), out); + const capped = capByChars(out, MAX_LIST_CHARS); + await noteProgress(ctx.progressPath, `list ${scope}/${prefix ?? ""} (${out.length} paths, ${capped.length} shown)`); + return capped; +}; + +export const readFileAt = async (ctx: ToolContext, scope: string, path: string): Promise => { + const abs = await confinePath(ctx.roots, scope, path); + const st = await stat(abs).catch(() => null); + if (!st?.isFile() || st.size > 1024 * 1024) throw new Error(`not a readable file: ${path}`); + const buf = toBytes(await readFile(abs).catch(() => null)); + if (!buf || buf.length === 0) throw new Error(`cannot read file: ${path}`); + if (buf.subarray(0, 8000).includes(0)) return `(binary file, skipped: ${path})`; + const take = Math.min(buf.length, ctx.readBytes ?? MAX_READ_BYTES); + await noteProgress(ctx.progressPath, `read ${scope}/${path} (${take} bytes)`); + return `${buf.length > take ? "(truncated) " : ""}${path}\n---\n${buf.subarray(0, take).toString("utf8")}`; +}; + +export const searchInScope = async ( + ctx: ToolContext, + scope: string, + query: string, + prefix?: string, +): Promise => { + if (!query.trim()) throw new Error("query must not be empty"); + const start = await confinePath(ctx.roots, scope, prefix ?? ""); + const rootResolved = resolve(baseFor(ctx.roots, scope)); + const hits: string[] = []; + let scanned = 0; + const visit = async (dir: string): Promise => { + if (hits.length >= MAX_HITS || scanned >= MAX_SCAN_FILES) return; + const entries = await readdir(dir, { withFileTypes: true }).catch(() => []); + for (const entry of entries) { + if (hits.length >= MAX_HITS || scanned >= MAX_SCAN_FILES) return; + const abs = join(dir, entry.name); + if (entry.isDirectory()) { + if (!SKIP_DIRS.has(entry.name)) await visit(abs); + } else if (entry.isFile()) { + scanned++; + const st = await stat(abs).catch(() => null); + if (!st?.isFile() || st.size > 256 * 1024) continue; + const buf = toBytes(await readFile(abs).catch(() => null)); + if (!buf || buf.length === 0 || buf.subarray(0, 8000).includes(0)) continue; + const text = buf.subarray(0, MAX_SCAN_BYTES).toString("utf8"); + const needle = query.toLowerCase(); + const lines = text.split("\n"); + for (let i = 0; i < lines.length && hits.length < MAX_HITS; i++) { + if (lines[i].toLowerCase().includes(needle)) { + hits.push(`${abs.slice(rootResolved.length + 1)}:${i + 1}: ${lines[i].trim().slice(0, 120)}`); + } + } + } + } + }; + await visit(start); + await noteProgress(ctx.progressPath, `search ${scope} for "${query.slice(0, 60)}" (${hits.length} hits)`); + return capByChars(hits, MAX_SEARCH_CHARS); +}; + +export const buildFileTools = (roots: SandboxRoots, progressPath: string, readBytes?: number) => { + const ctx: ToolContext = { roots, progressPath, readBytes }; + const listFiles = fn("listFiles") + .description( + "List files under a source scope. Scope dequel is Dequel's own platform source; scope project is the deployed project's source. Call with a narrow prefix to discover layout before reading.", + ) + .arg("scope", f.string('Source scope: "dequel" or "project"')) + .arg("prefix", f.string("Optional subdirectory to list, e.g. apps/api/src/orchestrator").optional()) + .returns(f.string("Repo-relative paths, directories end with /").array()) + .handler( + asListResult(async ({ scope, prefix }: { scope: string; prefix?: string }) => listFilesIn(ctx, scope, prefix)), + ) + .build(); + + const readFileTool = fn("readFile") + .description( + `Read one source file from the start, capped at ${((readBytes ?? MAX_READ_BYTES) / 1000).toFixed(1)}KB with a truncation flag. Prefer searchText to locate the exact region before reading.`, + ) + .arg("scope", f.string('Source scope: "dequel" or "project"')) + .arg("path", f.string("Repo-relative file path, e.g. apps/api/src/orchestrator/pipeline.ts")) + .returns(f.string("File content prefixed with a header line")) + .handler(asTextResult(async ({ scope, path }: { scope: string; path: string }) => readFileAt(ctx, scope, path))) + .build(); + + const searchText = fn("searchText") + .description( + "Search file contents for a fixed string (case-insensitive) under a scope. Use this to find error strings, function definitions, or config keys across the tree.", + ) + .arg("scope", f.string('Source scope: "dequel" or "project"')) + .arg("query", f.string("Fixed string to search for")) + .arg("prefix", f.string("Optional subdirectory to search under").optional()) + .returns(f.string('Matching lines as "path:line: text"').array()) + .handler( + asListResult(async ({ scope, query, prefix }: { scope: string; query: string; prefix?: string }) => + searchInScope(ctx, scope, query, prefix), + ), + ) + .build(); + + return [listFiles, readFileTool, searchText]; +}; + +export const editFileAt = async ( + ctx: ToolContext, + path: string, + oldString: string, + newString: string, +): Promise => { + if (!oldString) throw new Error("oldString must not be empty"); + if (newString === oldString) throw new Error("newString is identical to oldString"); + const abs = await confinePath(ctx.roots, "project", path); + const st = await stat(abs).catch(() => null); + if (!st?.isFile()) throw new Error(`not a file: ${path}`); + const buf = toBytes(await readFile(abs).catch(() => null)); + if (!buf) throw new Error(`cannot read file: ${path}`); + const content = buf.toString("utf8"); + const occurrences = content.split(oldString).length - 1; + if (occurrences === 0) throw new Error(`oldString not found in ${path}`); + if (occurrences > 1) throw new Error(`oldString matches ${occurrences} times in ${path}; include more context`); + await writeFile(abs, content.replace(oldString, newString)); + await noteProgress(ctx.progressPath, `edit ${path}`); + return `edited ${path}`; +}; + +export const writeFileAt = async (ctx: ToolContext, path: string, content: string): Promise => { + if (content.length > MAX_WRITE_BYTES) throw new Error(`content exceeds ${MAX_WRITE_BYTES} bytes`); + if (!ctx.roots.project) throw new Error("project source is not available for this diagnosis (logs only)"); + const rootResolved = resolve(ctx.roots.project); + const abs = resolve(rootResolved, path); + if (abs !== rootResolved && !abs.startsWith(`${rootResolved}/`)) + throw new Error("path escapes the sandbox roots (use a repo-relative path, not an absolute path or ..)"); + const parent = dirname(abs); + await mkdir(parent, { recursive: true }); + const realParent = await realpath(parent).catch(() => null); + if (!realParent || (realParent !== rootResolved && !realParent.startsWith(`${rootResolved}/`))) { + throw new Error("path escapes the sandbox roots (use a repo-relative path, not an absolute path or ..)"); + } + const exists = await stat(abs).catch(() => null); + if (exists) throw new Error(`${path} already exists; use editFile to modify it`); + await mkdir(parent, { recursive: true }); + await writeFile(abs, content); + await noteProgress(ctx.progressPath, `create ${path} (${content.length} bytes)`); + return `created ${path}`; +}; + +export const buildFixTools = (roots: SandboxRoots, progressPath: string) => { + const ctx: ToolContext = { roots, progressPath, readBytes: 8_000 }; + const reads = buildFileTools(roots, progressPath, 8_000); + + const editFile = fn("editFile") + .description( + "Replace one exact block of text in a project file. oldString must match exactly once. Use for surgical fixes; prefer this over rewriting whole files.", + ) + .arg("path", f.string("Project-relative file path")) + .arg("oldString", f.string("Exact text to replace (must occur exactly once)")) + .arg("newString", f.string("Replacement text")) + .returns(f.string("Confirmation")) + .handler( + asTextResult(async ({ path, oldString, newString }: { path: string; oldString: string; newString: string }) => + editFileAt(ctx, path, oldString, newString), + ), + ) + .build(); + + const createFile = fn("createFile") + .description("Create a NEW project file. Fails if the file already exists. Keep new files small and necessary.") + .arg("path", f.string("Project-relative file path")) + .arg("content", f.string("Full file content")) + .returns(f.string("Confirmation")) + .handler( + asTextResult(async ({ path, content }: { path: string; content: string }) => writeFileAt(ctx, path, content)), + ) + .build(); + + return [...reads, editFile, createFile]; +}; diff --git a/apps/api/src/fixdiag/stream.ts b/apps/api/src/fixdiag/stream.ts new file mode 100644 index 0000000..c8c9c45 --- /dev/null +++ b/apps/api/src/fixdiag/stream.ts @@ -0,0 +1,30 @@ +import type { StageEvent } from "./types"; + +type Listener = (event: StageEvent) => void; + +class DiagBus { + private listeners = new Map>(); + + subscribe(runId: string, listener: Listener): () => void { + let set = this.listeners.get(runId); + if (!set) { + set = new Set(); + this.listeners.set(runId, set); + } + set.add(listener); + return () => { + set.delete(listener); + if (set.size === 0) this.listeners.delete(runId); + }; + } + + emit(runId: string, event: StageEvent) { + for (const listener of this.listeners.get(runId) ?? []) { + try { + listener(event); + } catch {} + } + } +} + +export const diagBus = new DiagBus(); diff --git a/apps/api/src/fixdiag/types.ts b/apps/api/src/fixdiag/types.ts new file mode 100644 index 0000000..086c84f --- /dev/null +++ b/apps/api/src/fixdiag/types.ts @@ -0,0 +1,127 @@ +export type LlmProvider = "openai" | "anthropic" | "gemini" | "groq" | "ollama" | "custom"; + +export type DiagStageName = "triage" | "investigate" | "explain" | "propose"; + +export type CauseKind = "user-source" | "dequel-source" | "unknown"; + +export type TriageConfidence = "low" | "medium" | "high"; + +export type DiagStatus = "running" | "done" | "error"; + +export interface LogLine { + sequence: number; + stage: string; + message: string; +} + +export interface DeploymentFacts { + deploymentId: string; + projectId: string | null; + sourceType: string; + sourceRef: string; + branch: string | null; + commitSha: string; + failureReason: string | null; +} + +export interface EvidenceBundle { + deployment: DeploymentFacts; + logs: LogLine[]; +} + +export interface TriageVerdict { + failingStage: string; + signalLines: string[]; + confidence: TriageConfidence; +} + +export interface Localization { + cause: CauseKind; + culpritPaths: string[]; + rationale: string; +} + +export interface Explanation { + summary: string; + fixSteps: string[]; + patchHint: string | null; +} + +export interface UserFix { + title: string; + body: string; + suggestedDiff: string | null; +} + +export interface DequelReport { + problem: string; + cause: string; + proposedFix: string; +} + +export interface Proposal { + cause: CauseKind; + userFix?: UserFix; + dequelReport?: DequelReport; +} + +export interface DiagRun { + id: string; + deploymentId: string; + commitSha: string; + provider: LlmProvider; + model: string; + status: DiagStatus; + currentStage: DiagStageName | null; + cause: CauseKind | null; + report: Proposal | null; + error: string | null; + createdAt: string; +} + +export type StageEvent = + | { type: "stage"; runId: string; stage: DiagStageName; payload: unknown } + | { type: "token"; runId: string; stage: DiagStageName; delta: string } + | { type: "done"; runId: string } + | { type: "error"; runId: string; message: string }; + +export interface TriageInput { + failureReason: string | null; + logText: string; +} + +export interface ExplainInput { + verdict: TriageVerdict; + localization: Localization; +} + +export interface ProposeInput { + localization: Localization; + explanation: Explanation; + repo: { owner: string; repo: string; base: string } | null; +} + +export interface Investigation { + cause: CauseKind; + culpritPaths: string[]; + rationale: string; + keyEvidence: string[]; + dequelRev: string; + dequelStale: boolean; +} + +export interface InvestigateInput { + run: DiagRun; + deployment: DeploymentFacts; + logText: string; + verdict: TriageVerdict; + onProgress: (line: string) => void; +} + +export type InvestigateFn = (input: InvestigateInput) => Promise; + +export interface FixdiagPrograms { + triageLogs(input: TriageInput): Promise; + explainFix(input: ExplainInput): Promise; + draftProposal(input: ProposeInput): Promise; +} diff --git a/apps/api/src/index.ts b/apps/api/src/index.ts index 6f0432a..24f8a7d 100644 --- a/apps/api/src/index.ts +++ b/apps/api/src/index.ts @@ -8,6 +8,7 @@ import { startDatabaseMonitoring } from "./databases/manager"; import { getDb } from "./db/db-provider"; import { migrate } from "./db/migrate"; import { ensureLocalServer } from "./db/repo"; +import { markInterruptedDiagRuns } from "./db/repo/diag-runs"; import { deployments } from "./db/schema"; import { alertEvaluator } from "./monitoring/evaluator"; import { startFailureNotifier } from "./monitoring/failure-notifier"; @@ -34,6 +35,8 @@ const bootstrap = async () => { await migrate(); await ensureLocalServer(); + const interrupted = await markInterruptedDiagRuns().catch(() => 0); + if (interrupted > 0) console.log(`[Fixdiag] Marked ${interrupted} interrupted diagnosis run(s) as failed`); await orchestrator.reconcileState(); orchestrator.startWorker(); scalingEngine.start(); diff --git a/apps/api/src/types.ts b/apps/api/src/types.ts index 655e8d6..89f9b99 100644 --- a/apps/api/src/types.ts +++ b/apps/api/src/types.ts @@ -40,9 +40,7 @@ export interface Project { installCommand: string | null; outputDir: string | null; startCommand: string | null; - githubTokenEncrypted: string | null; - githubTokenIv: string | null; - githubTokenTag: string | null; + hasGithubToken: boolean; createdAt: string; updatedAt: string; } diff --git a/apps/api/src/utils/config.ts b/apps/api/src/utils/config.ts index 495caa6..1067759 100644 --- a/apps/api/src/utils/config.ts +++ b/apps/api/src/utils/config.ts @@ -16,6 +16,7 @@ const SYSTEM = { dockerNetwork: "dequel_net", buildkitHost: "tcp://buildkit:1234", redisUrl: "redis://redis:6379", + dequelSourceRepoUrl: "https://github.com/Lftobs/dequel.git", } as const; export const config = { @@ -29,6 +30,8 @@ export const config = { agentTunnelUrl: withFile("AGENT_TUNNEL_URL", ""), appInternalPort: withFile("APP_INTERNAL_PORT", "17476", Number), envEncryptionKey: withFile("ENV_ENCRYPTION_KEY", "dev-env-key-change-me"), + dequelSlackWebhookUrl: withFile("DEQUEL_SLACK_WEBHOOK_URL", ""), + dequelSlackChannel: withFile("DEQUEL_SLACK_CHANNEL", ""), queueConcurrency: withFile("QUEUE_CONCURRENCY", "3", Number), queueRetryMax: withFile("QUEUE_RETRY_MAX", "5", Number), queueRetryBaseMs: withFile("QUEUE_RETRY_BASE_MS", "5000", Number), diff --git a/apps/docs/.astro/astro/content.d.ts b/apps/docs/.astro/astro/content.d.ts index a7e7267..dfde2ca 100644 --- a/apps/docs/.astro/astro/content.d.ts +++ b/apps/docs/.astro/astro/content.d.ts @@ -147,6 +147,13 @@ declare module "astro:content" { collection: "changelogs"; data: any; } & { render(): Render[".md"] }; + "v0.4.0.md": { + id: "v0.4.0.md"; + slug: "v040"; + body: string; + collection: "changelogs"; + data: any; + } & { render(): Render[".md"] }; }; docs: { "agent-caddy-routes.md": { @@ -156,6 +163,13 @@ declare module "astro:content" { collection: "docs"; data: any; } & { render(): Render[".md"] }; + "ai-diagnosis.md": { + id: "ai-diagnosis.md"; + slug: "ai-diagnosis"; + body: string; + collection: "docs"; + data: any; + } & { render(): Render[".md"] }; "auth.md": { id: "auth.md"; slug: "auth"; diff --git a/apps/docs/package.json b/apps/docs/package.json index d5066f1..8fe9783 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -1,7 +1,7 @@ { "name": "dequel-docs", "type": "module", - "version": "0.3.0", + "version": "0.4.0", "scripts": { "dev": "astro dev", "start": "astro dev", diff --git a/apps/docs/src/content/changelogs/v0.4.0.md b/apps/docs/src/content/changelogs/v0.4.0.md new file mode 100644 index 0000000..7a03234 --- /dev/null +++ b/apps/docs/src/content/changelogs/v0.4.0.md @@ -0,0 +1,31 @@ +--- +version: 0.4.0 +date: "2026-10-03" +--- + +## What's Changed +### Features +- Add AI-powered deployment diagnosis ([903d9c9](https://github.com/Lftobs/dequel/commit/903d9c9594152355f6e48668939b79f7d4742cf7)) +- Add automated database backup and restore system ([3b477a9](https://github.com/Lftobs/dequel/commit/3b477a95fbd9c47cb2019929bedb00c84c6cc36b)) +- Database studio data viewer, database settings UI, backup configuration, and capsule tabs ([0fd77c1](https://github.com/Lftobs/dequel/commit/0fd77c186dd9e48ae2cab9687e6ab17afffacc6a)) +- Add shared environment variables and SSH key pool management ([8c7f76b](https://github.com/Lftobs/dequel/commit/8c7f76b82de60db7f7a8a086fe39e1ce50d7819f)) +- Add shared environment variable link support and UI components ([86205cb](https://github.com/Lftobs/dequel/commit/86205cb4f8ab7e5a82f9aa752df30b6da72e6be0)) +- Add project health and failure alerts ([a42c2b3](https://github.com/Lftobs/dequel/commit/a42c2b3e3526dbf98ea83d902971b4e09075739d)) +- Track anonymous usage events ([8abdf74](https://github.com/Lftobs/dequel/commit/8abdf7420877621e10ed277ccb63a70df97a3523)) + + +### Improvements +- Redesign keys and settings pages with tabbed navigation ([765a199](https://github.com/Lftobs/dequel/commit/765a199f6b578149358e1529429260325edf4ad8)) +- Remove delete projects tab and fix tabs overflow behavior ([650e1d8](https://github.com/Lftobs/dequel/commit/650e1d8a7c5f2bedbdec10c162e11368bdb207e4)) +- Add Biome linter + pre-commit hook, format codebase ([2ac7c3f](https://github.com/Lftobs/dequel/commit/2ac7c3f2b24ca146afcc6b9f206cf0ab9ffab7c8)) +- Update database creation engine selection UI ([faef669](https://github.com/Lftobs/dequel/commit/faef6693959dab303340f0245430c48504d08db2)) +- Improve SQL query editor ui ([f38b1a6](https://github.com/Lftobs/dequel/commit/f38b1a68fac5d9af7be44f124e6c879b68bf19ff)) +- Switch compose file to prebuilt images for api and web ([3e6f917](https://github.com/Lftobs/dequel/commit/3e6f917403b233dcc43d60068ed18ddaf77174b6)) +- Make dequel dashboard 100% mobile responsive ([905719f](https://github.com/Lftobs/dequel/commit/905719f336fd79c3818fe45cc023d0df7fdedc95)) +- Make all settings sections, tables, tabs and dialogs fully mobile responsive ([1049120](https://github.com/Lftobs/dequel/commit/1049120cf5c7243be5d75c4d9474a4ab326f9bb4)) + +### Bug Fixes +- Harden alerts and monitoring validation ([1a68e40](https://github.com/Lftobs/dequel/commit/1a68e4066b7d11004b622965aabf2a53767018dd)) +- Secure shared environment variable deletion by project ID ([e146487](https://github.com/Lftobs/dequel/commit/e1464876961f32b421800e708d328a20dc9233c2)) +- Resolve container name, storage type display, and S3 prefix ([3cb6610](https://github.com/Lftobs/dequel/commit/3cb66109d25c238f4011fadeaeb2094240602d9d)) +- Remove duplicate listRoutesByDeployment function ([80d43e5](https://github.com/Lftobs/dequel/commit/80d43e50f5a72dfac4b1908032c33d4b7b26ceed)) diff --git a/apps/docs/src/content/docs/ai-diagnosis.md b/apps/docs/src/content/docs/ai-diagnosis.md new file mode 100644 index 0000000..9ff25d2 --- /dev/null +++ b/apps/docs/src/content/docs/ai-diagnosis.md @@ -0,0 +1,50 @@ +--- +title: AI Diagnosis +category: Deployment +description: Diagnose failed builds with an AI agent, then apply one-click fix PRs or report Dequel bugs. +slug: ai-diagnosis +--- + +When a deployment fails, Dequel lets you hand the failure to an AI agent. The agent runs inside an isolated sandbox container holding a fresh checkout of Dequel's own source and your project's source. It looks up the files it needs itself, decides whether the fault is in your code or in Dequel itself, and proposes what to do next. Every write action requires your explicit click — the one exception is a Dequel bug report, which is posted to Slack automatically once the diagnosis finishes. + +The sandbox persists between diagnoses: Dequel's source is cloned once and updated to your running version on each run, while your project's source is pulled fresh per diagnosis and cleared afterwards. + +## Configure a provider key + +Before diagnosing anything, store an LLM provider key. Open **Settings → AI Diagnosis** and save a key for one of the supported providers (`openai`, `anthropic`, `gemini`, `groq`, `ollama`, or a custom OpenAI-compatible endpoint with a base URL). Keys are encrypted at rest and never shown again after saving. Leaving the key field empty keeps the stored key. + +## Diagnose a failed deployment + +Follow these steps to run a diagnosis. + +1. Open the project and switch to the **Deployments** tab. +2. Select a failed deployment to reveal its build logs. +3. Click **Diagnose** in the log header to open the diagnosis sheet. +4. Pick a provider and model, then click **Diagnose**. +5. Watch the agent work through four stages: reading logs, investigating in the sandbox, explaining the fix, and drafting the proposal. +6. Read the verdict and the explanation. + +The verdict is one of three outcomes. **Your code** means the fault is in your source. **Dequel** means the fault is in Dequel's builder or platform code. **Inconclusive** means the evidence was not sufficient, and no action buttons are shown. + +## Fix your code with one click + +When the verdict is **Your code** and the project deploys from Git, the sheet shows a **Create Fix Pull Request** button. Clicking it sends the agent back into the sandbox to edit the project source, still read-only everywhere else. Dequel takes the resulting diff, applies it to a new `dequel-fix/*` branch, and opens a pull request against your repository, so you only review and merge. If the agent cannot produce a change, or the diff does not apply cleanly, Dequel reports the problem and you apply the fix manually. This action is idempotent: repeated clicks return the same pull request. + +> **Note:** Fix PRs require a connected GitHub account with repository access. Connect it under **Settings → GitHub Integration** first. Without it, Dequel refuses to proceed. + +## Report a Dequel bug + +When the verdict is **Dequel**, Dequel posts the drafted report to the Dequel team's Slack channel automatically as soon as the diagnosis completes — no button, no approval step. The post carries the report in problem, Dequel version, root cause, proposed fix order, with the investigated source revision attached. If the channel is not configured, the run still completes and the sheet says so; copy the report text and share it manually. + +## Limits + +- Diagnosis runs only on deployments with `failed` status. +- One diagnosis exists per deployment and commit; starting it again returns the existing result. +- Fix PRs are available for Git projects only. Zip-upload projects still receive the written explanation. +- The sandbox agent pulls only the files it needs (about 2.4 KB per read during investigation, 8 KB when fixing, and 10 search hits at a time) and can keep working through large repositories across many steps. What bounds it instead is time and cost: a run times out after about 10 minutes, and every file read spends your provider's tokens. +- During investigation the agent has read-only file tools. When fixing, it can edit and create files inside the project source only. Neither mode can run shell commands or read anything outside the two source checkouts, so the provider key stored alongside the job stays out of the agent's reach. + +## Next steps + +- Read [Deployments](/docs/deployments) to understand build sources and rollback. +- Read [Configuration](/docs/configuration) for platform settings. diff --git a/apps/web/package.json b/apps/web/package.json index f74681c..0ca3547 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "dequel-web", - "version": "0.3.0", + "version": "0.4.0", "private": true, "type": "module", "scripts": { diff --git a/apps/web/src/api/client.ts b/apps/web/src/api/client.ts index 37c2a82..5efac02 100644 --- a/apps/web/src/api/client.ts +++ b/apps/web/src/api/client.ts @@ -1,4 +1,5 @@ import type { + ActiveDiagRun, Alert, ApiKey, BackupJob, @@ -6,10 +7,13 @@ import type { CreateProjectInput, Database, Deployment, + DiagRun, + DiagStage, Domain, EnvironmentVariable, GithubIntegrationStatus, GithubRepo, + LlmKeyStatus, Log, Project, QueryExecResult, @@ -463,7 +467,49 @@ export const testSmtpSettings = () => method: "POST", }); -// ─── GitHub Webhook ─────────────────────────────────────── +export const getLlmKeys = () => apiFetch("/settings/llm-keys"); + +export const getLlmDefaultModels = () => apiFetch>("/settings/llm-default-models"); + +export const syncLlmModels = (provider: string) => + apiFetch<{ provider: string; models: string[] }>(`/settings/llm-keys/${provider}/sync`, { + method: "POST", + }); + +export const getLlmModels = (provider: string, refresh = false) => + apiFetch<{ provider: string; models: string[]; cached: boolean }>( + `/settings/llm-keys/${provider}/models${refresh ? "?refresh=true" : ""}`, + ); + +export const setLlmKey = (data: { provider: string; apiKey?: string; baseURL?: string; models?: string[] }) => + apiFetch("/settings/llm-keys", { + method: "PUT", + body: JSON.stringify(data), + }); + +export const deleteLlmKey = (provider: string) => + apiFetch(`/settings/llm-keys/${provider}`, { + method: "DELETE", + }); + +export const startDiagnosis = (deploymentId: string, data: { provider: string; model: string }) => + apiFetch(`/deployments/${deploymentId}/diagnose`, { + method: "POST", + body: JSON.stringify(data), + }); + +export const getDiagnosis = (runId: string) => + apiFetch<{ run: DiagRun; stages: DiagStage[]; slackPosted: boolean }>(`/diagnoses/${runId}`); + +export const getActiveDiagnoses = () => apiFetch("/diagnoses/active"); + +export const streamDiagnosisUrl = (runId: string) => `${BASE}/diagnoses/${runId}/stream`; + +export const approveFixPr = (runId: string, idempotencyKey: string) => + apiFetch<{ prUrl: string }>(`/diagnoses/${runId}/approve-pr`, { + method: "POST", + body: JSON.stringify({ idempotencyKey }), + }); export const getRepoHooks = (owner: string, repo: string) => apiFetch>( diff --git a/apps/web/src/components/DiagNotifier.tsx b/apps/web/src/components/DiagNotifier.tsx new file mode 100644 index 0000000..22e11d2 --- /dev/null +++ b/apps/web/src/components/DiagNotifier.tsx @@ -0,0 +1,63 @@ +import { useQuery } from "@tanstack/react-query"; +import { useEffect, useRef } from "react"; +import * as api from "../api/client"; +import type { DiagRun } from "../types"; + +const causeLabel = (cause: DiagRun["cause"]): string => + cause === "user-source" ? "your code" : cause === "dequel-source" ? "Dequel" : "inconclusive"; + +const runLabel = (run: { projectName: string | null; deploymentId: string }): string => + `${run.projectName ?? "deployment"} ${run.deploymentId.slice(0, 8)}`; + +const notifyDone = (run: DiagRun & { projectName: string | null }) => { + window.dispatchEvent( + new CustomEvent("opencode:notification", { + detail: { + type: "success", + message: `Diagnosis complete for ${runLabel(run)}: ${causeLabel(run.report?.cause ?? run.cause)}`, + }, + }), + ); +}; + +const notifyError = (run: DiagRun & { projectName: string | null }) => { + const reason = (run.error ?? "unknown error").replace(/\s+/g, " ").slice(0, 140); + window.dispatchEvent( + new CustomEvent("opencode:notification", { + detail: { type: "error", message: `Diagnosis failed for ${runLabel(run)}: ${reason}` }, + }), + ); +}; + +export function DiagNotifier({ enabled }: { enabled: boolean }) { + const seen = useRef(new Map()); + const { data } = useQuery({ + queryKey: ["diagnoses", "active"], + queryFn: () => api.getActiveDiagnoses(), + refetchInterval: 10000, + enabled, + retry: false, + }); + + useEffect(() => { + if (!data) return; + const active = new Map(data.map((run) => [run.id, run.projectName])); + for (const run of data) { + if (!seen.current.has(run.id)) seen.current.set(run.id, run.projectName); + } + for (const [id, projectName] of [...seen.current]) { + if (active.has(id)) continue; + seen.current.delete(id); + void api + .getDiagnosis(id) + .then((full) => { + const run = { ...full.run, projectName }; + if (run.status === "done") notifyDone(run); + else if (run.status === "error") notifyError(run); + }) + .catch(() => {}); + } + }, [data]); + + return null; +} diff --git a/apps/web/src/components/Layout.tsx b/apps/web/src/components/Layout.tsx index 7e82556..51c846e 100644 --- a/apps/web/src/components/Layout.tsx +++ b/apps/web/src/components/Layout.tsx @@ -7,6 +7,7 @@ import { parseMetrics } from "../lib/metrics"; import { Header } from "./layout/Header"; import { NotificationBanner } from "./layout/NotificationBanner"; import { Sidebar } from "./layout/Sidebar"; +import { DiagNotifier } from "./DiagNotifier"; export function Layout({ children }: { children: React.ReactNode }) { const location = useLocation(); @@ -127,6 +128,7 @@ export function Layout({ children }: { children: React.ReactNode }) { /> setNotification(null)} /> +
{children}
diff --git a/apps/web/src/components/project/deployments/DiagnoseSheet.tsx b/apps/web/src/components/project/deployments/DiagnoseSheet.tsx new file mode 100644 index 0000000..80f9366 --- /dev/null +++ b/apps/web/src/components/project/deployments/DiagnoseSheet.tsx @@ -0,0 +1,372 @@ +import { useQuery } from "@tanstack/react-query"; +import { AlertCircle, AlertTriangle, HelpCircle, Loader2, RefreshCw, Sparkles } from "lucide-react"; +import { useEffect, useRef, useState } from "react"; +import * as api from "../../../api/client"; +import type { DiagCause, DiagRun, DiagStage } from "../../../types"; +import { Button } from "../../ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../../ui/select"; +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle } from "../../ui/sheet"; +import { DiagnosePipeline, STAGE_ORDER } from "./diagnose/DiagnosePipeline"; +import { DiagnoseVerdict } from "./diagnose/DiagnoseVerdict"; + +interface DiagnoseSheetProps { + deployment: { id: string; status: string; sourceType: string; failureReason: string | null }; + open: boolean; + onOpenChange: (open: boolean) => void; +} + +const payloadText = (payload: unknown, key: string): string => { + if (payload && typeof payload === "object" && typeof (payload as Record)[key] === "string") { + return (payload as Record)[key]; + } + return ""; +}; + +const payloadList = (payload: unknown, key: string): string[] => { + if (payload && typeof payload === "object") { + const value = (payload as Record)[key]; + if (Array.isArray(value)) return value.filter((v): v is string => typeof v === "string"); + } + return []; +}; + +export function DiagnoseSheet({ deployment, open, onOpenChange }: DiagnoseSheetProps) { + const { data: keys, refetch: refetchKeys } = useQuery({ + queryKey: ["llm-keys"], + queryFn: () => api.getLlmKeys(), + }); + const configured = (keys ?? []).filter((k) => k.configured); + const [provider, setProvider] = useState(""); + const [model, setModel] = useState(""); + const [run, setRun] = useState(null); + const [stages, setStages] = useState([]); + const [progress, setProgress] = useState([]); + const [starting, setStarting] = useState(false); + const [running, setRunning] = useState(false); + const [syncingModels, setSyncingModels] = useState(false); + const [error, setError] = useState(null); + const [approving, setApproving] = useState<"pr" | null>(null); + const [prUrl, setPrUrl] = useState(null); + const [slackPosted, setSlackPosted] = useState(false); + const esRef = useRef(null); + + const activeProviderConfig = configured.find((k) => k.provider === provider); + const cachedModels = activeProviderConfig?.models ?? []; + const { data: defaultModels } = useQuery({ + queryKey: ["llm-default-models"], + queryFn: () => api.getLlmDefaultModels(), + }); + const modelOptions = cachedModels.length > 0 ? cachedModels : (defaultModels?.[provider] ?? []); + + useEffect(() => { + if (configured.length > 0 && !provider) { + const first = configured[0].provider; + setProvider(first); + const firstModels = configured[0].models?.length > 0 ? configured[0].models : (defaultModels?.[first] ?? []); + setModel(firstModels[0] ?? ""); + } + }, [configured, provider, defaultModels]); + + const pickProvider = (next: string) => { + setProvider(next); + const target = configured.find((k) => k.provider === next); + const nextModels = target?.models?.length ? target.models : (defaultModels?.[next] ?? []); + setModel(nextModels[0] ?? ""); + }; + + const handleSyncModels = async () => { + if (!provider) return; + setSyncingModels(true); + setError(null); + try { + const res = await api.syncLlmModels(provider); + await refetchKeys(); + if (res.models.length > 0 && (!model || !res.models.includes(model))) { + setModel(res.models[0]); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to sync provider models"); + } finally { + setSyncingModels(false); + } + }; + + useEffect(() => { + if (!open) { + esRef.current?.close(); + esRef.current = null; + setRun(null); + setStages([]); + setProgress([]); + setError(null); + setStarting(false); + setRunning(false); + setPrUrl(null); + setSlackPosted(false); + } + return () => { + esRef.current?.close(); + esRef.current = null; + }; + }, [open]); + + const attach = (runId: string) => { + setRunning(true); + const es = new EventSource(api.streamDiagnosisUrl(runId)); + esRef.current = es; + es.addEventListener("stage", (e) => { + try { + const data = JSON.parse((e as MessageEvent).data) as { stage: string; payload: unknown }; + setStages((prev) => { + const rest = prev.filter((s) => s.stage !== data.stage); + return [...rest, { stage: data.stage, payload: data.payload }].sort( + (a, b) => STAGE_ORDER.indexOf(a.stage) - STAGE_ORDER.indexOf(b.stage), + ); + }); + } catch {} + }); + const finish = async () => { + es.close(); + esRef.current = null; + setRunning(false); + try { + const full = await api.getDiagnosis(runId); + setRun(full.run); + setStages(full.stages); + setSlackPosted(!!full.slackPosted); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load diagnosis results"); + } + }; + es.addEventListener("done", () => void finish()); + es.addEventListener("error", () => void finish()); + es.addEventListener("token", (e) => { + try { + const data = JSON.parse((e as MessageEvent).data) as { stage: string; delta: string }; + if (typeof data.delta === "string" && data.delta.trim()) { + setProgress((prev) => [...prev.slice(-29), data.delta.trim()]); + } + } catch {} + }); + es.onerror = () => {}; + }; + + const start = async () => { + if (!provider || !model.trim()) { + setError("Select an AI provider and model before diagnosing."); + return; + } + setError(null); + setStarting(true); + try { + const started = await api.startDiagnosis(deployment.id, { provider, model: model.trim() }); + setRun(started); + if (started.status === "done" || started.status === "error") { + const full = await api.getDiagnosis(started.id); + setRun(full.run); + setStages(full.stages); + setSlackPosted(!!full.slackPosted); + } else { + attach(started.id); + } + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to start diagnosis"); + } finally { + setStarting(false); + } + }; + + const approvePr = async () => { + if (!run) return; + setApproving("pr"); + setError(null); + try { + const res = await api.approveFixPr(run.id, crypto.randomUUID()); + setPrUrl(res.prUrl); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to create fix PR"); + } finally { + setApproving(null); + } + }; + + const cause: DiagCause | null = run?.report?.cause ?? run?.cause ?? null; + const explainStage = stages.find((s) => s.stage === "explain")?.payload; + const investigateStage = stages.find((s) => s.stage === "investigate")?.payload; + const dequelVersion = + investigateStage && typeof investigateStage === "object" + ? String((investigateStage as Record).dequelRev ?? "") + : ""; + const dequelStale = + investigateStage && typeof investigateStage === "object" + ? (investigateStage as Record).dequelStale === true + : false; + const summary = payloadText(explainStage, "summary"); + const fixSteps = payloadList(explainStage, "fixSteps"); + const userFix = run?.report?.userFix; + const dequelReport = run?.report?.dequelReport; + const isGit = deployment.sourceType === "git"; + + return ( + + + +
+
+ +
+
+ + Diagnose Build Failure — {deployment.id.slice(0, 8)} + + + AI-driven root cause analysis and automated fix generation + +
+
+ + {deployment.failureReason && ( +
+ + {deployment.failureReason} +
+ )} +
+ +
+ {configured.length === 0 ? ( +
+

+ No AI Provider Configured +

+

+ To run build failure diagnoses, configure an API key for OpenAI, Anthropic, Gemini, Groq, or Ollama + under Platform Settings. +

+
+ ) : ( +
+
+
+ + +
+ +
+
+ + {modelOptions.length} available +
+
+ + + +
+
+
+ +
+ + Runs in an isolated read-only sandbox + + +
+
+ )} + + {error && ( +
+ + {error} +
+ )} + + {(running || stages.length > 0) && } + + {run?.status === "done" && cause && ( + + )} + + {run?.status === "error" && ( +
+ + Diagnosis failed: {run.error ?? "unknown error occurred"} +
+ )} +
+
+
+ ); +} diff --git a/apps/web/src/components/project/deployments/deployment-logs.tsx b/apps/web/src/components/project/deployments/deployment-logs.tsx index 0995616..e2e1ea9 100644 --- a/apps/web/src/components/project/deployments/deployment-logs.tsx +++ b/apps/web/src/components/project/deployments/deployment-logs.tsx @@ -2,6 +2,7 @@ import { Terminal } from "lucide-react"; import { useEffect, useRef, useState } from "react"; import { useDeploymentLogs } from "../../../hooks/useDeploymentLogs"; import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { DiagnoseSheet } from "./DiagnoseSheet"; export function formatTimeAgo(dateStr: string) { const diff = Date.now() - new Date(dateStr).getTime(); @@ -78,6 +79,7 @@ function fmtLogTs(raw: string | undefined) { export function DeploymentLogs({ deployment }: { deployment: any }) { const { logs, isLoading } = useDeploymentLogs(deployment.id); const endRef = useRef(null); + const [diagnoseOpen, setDiagnoseOpen] = useState(false); useEffect(() => { endRef.current?.scrollIntoView({ behavior: "smooth", @@ -86,7 +88,9 @@ export function DeploymentLogs({ deployment }: { deployment: any }) { const [copied, setCopied] = useState(false); const copyAllLogs = () => { - const fullText = logs.map(l => `[${l.stage}]-[${fmtLogTs((l as any).timestamp || l.createdAt)}] ${l.message}`).join("\n"); + const fullText = logs + .map((l) => `[${l.stage}]-[${fmtLogTs((l as any).timestamp || l.createdAt)}] ${l.message}`) + .join("\n"); navigator.clipboard.writeText(fullText); setCopied(true); setTimeout(() => setCopied(false), 1500); @@ -105,6 +109,15 @@ export function DeploymentLogs({ deployment }: { deployment: any }) { Duration: + {deployment.status === "failed" && ( + + )} {logs.length > 0 && (
+ ) + ) : ( +

Automated Fix PRs are available for Git repositories.

+ )} + + )} + + {dequelReport && ( +
+
+ Problem +

{dequelReport.problem}

+
+ + {dequelVersion && ( +
+ + Dequel Version + +

+ {dequelVersion} + {dequelStale && (potentially stale build)} +

+
+ )} + +
+ + Root Cause + +

{dequelReport.cause}

+
+ +
+ + Proposed Platform Fix + +

{dequelReport.proposedFix}

+
+ + {slackPosted ? ( +

+ Incident automatically submitted to the Dequel team Slack + channel. +

+ ) : ( +

+ Slack notification not posted (channel not configured or webhook unavailable). +

+ )} +
+ )} + + + ); +} diff --git a/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx b/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx new file mode 100644 index 0000000..8930781 --- /dev/null +++ b/apps/web/src/components/project/deployments/diagnose/DiffViewer.tsx @@ -0,0 +1,63 @@ +import { Check, Copy } from "lucide-react"; +import { useState } from "react"; +import { Button } from "../../../ui/button"; + +interface DiffViewerProps { + diff: string; + title?: string; +} + +export function DiffViewer({ diff, title = "Suggested Fix" }: DiffViewerProps) { + const [copied, setCopied] = useState(false); + + const handleCopy = () => { + navigator.clipboard.writeText(diff); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + }; + + const lines = diff.split("\n"); + + return ( +
+
+ {title} + +
+
+ {lines.map((line, idx) => { + let lineClass = "text-zinc-400"; + if (line.startsWith("+") && !line.startsWith("+++")) { + lineClass = "bg-emerald-950/40 text-emerald-300 px-1 rounded-sm"; + } else if (line.startsWith("-") && !line.startsWith("---")) { + lineClass = "bg-red-950/40 text-red-300 px-1 rounded-sm"; + } else if (line.startsWith("@@")) { + lineClass = "text-cyan-400 font-semibold"; + } else if ( + line.startsWith("diff ") || + line.startsWith("index ") || + line.startsWith("---") || + line.startsWith("+++") + ) { + lineClass = "text-zinc-500 font-medium"; + } + + return ( +
+ {line || " "} +
+ ); + })} +
+
+ ); +} diff --git a/apps/web/src/components/settings/LlmKeysSection.tsx b/apps/web/src/components/settings/LlmKeysSection.tsx new file mode 100644 index 0000000..0ed86e4 --- /dev/null +++ b/apps/web/src/components/settings/LlmKeysSection.tsx @@ -0,0 +1,334 @@ +import { useQuery } from "@tanstack/react-query"; +import { + AlertCircle, + Check, + CheckCircle2, + ExternalLink, + Eye, + EyeOff, + Layers, + RefreshCw, + ShieldCheck, + Sparkles, +} from "lucide-react"; +import { useEffect, useState } from "react"; +import * as api from "../../api/client"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../ui/card"; +import { Input } from "../ui/input"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "../ui/select"; +import { ConfiguredProvidersList } from "./llm/ConfiguredProvidersList"; +import { PROVIDERS } from "./llm/types"; + +export function LlmKeysSection() { + const { data, refetch, isLoading } = useQuery({ + queryKey: ["llm-keys"], + queryFn: () => api.getLlmKeys(), + }); + + const [provider, setProvider] = useState("openai"); + const [apiKey, setApiKey] = useState(""); + const [showKey, setShowKey] = useState(false); + const [baseURL, setBaseURL] = useState(""); + const [saveResult, setSaveResult] = useState(null); + const [saving, setSaving] = useState(false); + const [syncingProvider, setSyncingProvider] = useState(null); + + const configured = data?.filter((k) => k.configured) ?? []; + const currentStatus = data?.find((k) => k.provider === provider); + const currentMeta = PROVIDERS.find((p) => p.id === provider) ?? PROVIDERS[0]; + + useEffect(() => { + setBaseURL(currentStatus?.baseUrl ?? currentMeta.defaultBaseUrl ?? ""); + setApiKey(""); + setSaveResult(null); + }, [provider, currentStatus?.baseUrl, currentMeta.defaultBaseUrl]); + + const save = async (e: React.FormEvent) => { + e.preventDefault(); + setSaveResult(null); + setSaving(true); + try { + const updated = await api.setLlmKey({ + provider, + apiKey: apiKey.trim() || undefined, + baseURL: baseURL.trim() || undefined, + }); + setApiKey(""); + await refetch(); + const modelCount = updated.models?.length ?? 0; + setSaveResult( + modelCount > 0 + ? `Saved successfully. Auto-discovered and cached ${modelCount} models from ${currentMeta.label}.` + : `Saved provider key for ${currentMeta.label}.`, + ); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setSaveResult(`error: ${message}`); + } finally { + setSaving(false); + } + }; + + const handleSync = async (targetProvider: string) => { + setSyncingProvider(targetProvider); + setSaveResult(null); + try { + const res = await api.syncLlmModels(targetProvider); + await refetch(); + const meta = PROVIDERS.find((p) => p.id === targetProvider); + setSaveResult(`Synced ${res.models.length} models from ${meta?.label ?? targetProvider}.`); + } catch (err) { + const message = err instanceof Error ? err.message : "Failed to sync models"; + setSaveResult(`error: ${message}`); + } finally { + setSyncingProvider(null); + } + }; + + const remove = async (p: string) => { + setSaveResult(null); + try { + await api.deleteLlmKey(p); + await refetch(); + const meta = PROVIDERS.find((pr) => pr.id === p); + setSaveResult(`Provider "${meta?.label ?? p}" removed.`); + } catch (err) { + const message = err instanceof Error ? err.message : "Unknown error"; + setSaveResult(`error: ${message}`); + } + }; + + const startEditing = (p: string) => { + setProvider(p); + const target = data?.find((k) => k.provider === p); + if (target) { + setBaseURL(target.baseUrl ?? ""); + } + window.scrollTo({ top: 0, behavior: "smooth" }); + }; + + return ( +
+ {/* Main Settings Card */} + + +
+
+
+ +
+
+
+ AI Diagnosis Providers + {configured.length > 0 ? ( + + {configured.length} Active{" "} + {configured.length === 1 ? "Provider" : "Providers"} + + ) : ( + + Not Configured + + )} +
+

+ Keys used by the automated failure diagnosis agent. Models are auto-pulled directly from each provider + and cached for one-click selection. +

+
+
+ + {currentMeta.docsUrl && ( + + Get API Key + + )} +
+
+ + +
+
+
+ + +

{currentMeta.desc}

+
+ +
+
+ + {currentStatus?.configured && ( + + Key saved + + )} +
+
+ setApiKey(e.target.value)} + className="bg-card border-border/80 text-xs font-mono pr-10 focus:ring-orange-500/50" + /> + +
+

+ {provider === "ollama" + ? "Optional when Ollama runs on your host without authentication." + : "Encrypted at rest using AES-256-GCM."} +

+
+
+ + {(provider === "custom" || provider === "ollama" || currentStatus?.baseUrl) && ( +
+ + setBaseURL(e.target.value)} + placeholder={currentMeta.defaultBaseUrl || "https://api.your-endpoint.com/v1"} + className="bg-card border-border/80 text-xs font-mono focus:ring-orange-500/50" + /> +

+ {provider === "ollama" + ? "Default: http://host.docker.internal:11435/v1 for Docker bridge network access." + : "Base endpoint prefix where /models and /chat/completions are hosted."} +

+
+ )} + + {/* Cached Models Preview for selected provider */} + {currentStatus?.configured && ( +
+
+
+ + Auto-Discovered Models + + {currentStatus.models?.length ?? 0} cached + +
+ +
+ + {currentStatus.models && currentStatus.models.length > 0 ? ( +
+ {currentStatus.models.map((m) => ( + + {m} + + ))} +
+ ) : ( +

+ No models currently cached. Click "Save & Sync Models" or "Sync Models Now" to pull them + automatically. +

+ )} +
+ )} + +
+ {saveResult ? ( +

+ {saveResult.startsWith("error") ? ( + + ) : ( + + )} + {saveResult} +

+ ) : ( + + )} + +
+
+
+
+ + {/* Configured Providers List */} + +
+ ); +} diff --git a/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx b/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx new file mode 100644 index 0000000..bfbf347 --- /dev/null +++ b/apps/web/src/components/settings/llm/ConfiguredProvidersList.tsx @@ -0,0 +1,156 @@ +import { ChevronDown, ChevronUp, RefreshCw, Trash2 } from "lucide-react"; +import { useState } from "react"; +import type { LlmKeyStatus } from "../../../types"; +import { Badge } from "../../ui/badge"; +import { Button } from "../../ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "../../ui/card"; +import { PROVIDERS } from "./types"; + +interface ConfiguredProvidersListProps { + configured: LlmKeyStatus[]; + isLoading: boolean; + syncingProvider: string | null; + onSync: (provider: string) => void; + onEdit: (provider: string) => void; + onRemove: (provider: string) => void; +} + +export function ConfiguredProvidersList({ + configured, + isLoading, + syncingProvider, + onSync, + onEdit, + onRemove, +}: ConfiguredProvidersListProps) { + const [expandedProvider, setExpandedProvider] = useState(null); + + return ( + + +
+
+ Configured Providers +

+ Active providers ready to run failure root-cause analysis +

+
+ + {configured.length} configured + +
+
+ + {isLoading ? ( +
+ Loading providers… +
+ ) : configured.length === 0 ? ( +
+

No providers configured yet

+

Choose a provider above and enter your key to enable automated AI diagnosis.

+
+ ) : ( +
+ {configured.map((item) => { + const meta = PROVIDERS.find((p) => p.id === item.provider); + const isExpanded = expandedProvider === item.provider; + const modelsList = item.models ?? []; + const isSyncing = syncingProvider === item.provider; + + return ( +
+
+
+
+ {meta?.label ?? item.provider} + + + Connected + + + {modelsList.length} {modelsList.length === 1 ? "model" : "models"} cached + +
+ + {item.baseUrl && ( +

{item.baseUrl}

+ )} +
+ +
+ + + +
+
+ + {modelsList.length > 0 && ( +
+
+ {(isExpanded ? modelsList : modelsList.slice(0, 5)).map((m) => ( + + {m} + + ))} + {modelsList.length > 5 && ( + + )} +
+
+ )} +
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/apps/web/src/components/settings/llm/types.ts b/apps/web/src/components/settings/llm/types.ts new file mode 100644 index 0000000..d458d7a --- /dev/null +++ b/apps/web/src/components/settings/llm/types.ts @@ -0,0 +1,53 @@ +export interface ProviderMeta { + id: string; + label: string; + desc: string; + keyPlaceholder: string; + docsUrl?: string; + defaultBaseUrl?: string; +} + +export const PROVIDERS: ProviderMeta[] = [ + { + id: "openai", + label: "OpenAI", + desc: "GPT-4o, o1, o3-mini & reasoning models", + keyPlaceholder: "sk-proj-...", + docsUrl: "https://platform.openai.com/api-keys", + }, + { + id: "anthropic", + label: "Anthropic", + desc: "Claude 3.5 Sonnet, Claude 3 Opus & Haiku", + keyPlaceholder: "sk-ant-api03-...", + docsUrl: "https://console.anthropic.com/settings/keys", + }, + { + id: "gemini", + label: "Google Gemini", + desc: "Gemini 2.5 Flash, 2.5 Pro & 1.5 Pro", + keyPlaceholder: "AIzaSy...", + docsUrl: "https://aistudio.google.com/app/apikey", + }, + { + id: "groq", + label: "Groq", + desc: "Ultra-fast Llama 3.3 70B & open models", + keyPlaceholder: "gsk_...", + docsUrl: "https://console.groq.com/keys", + }, + { + id: "ollama", + label: "Ollama (Self-Hosted)", + desc: "Run local open-weight models via Docker / HTTP", + keyPlaceholder: "(optional for local Ollama)", + defaultBaseUrl: "http://host.docker.internal:11435/v1", + }, + { + id: "custom", + label: "Custom (OpenAI-Compatible)", + desc: "vLLM, LocalAI, OpenRouter, Together, etc.", + keyPlaceholder: "sk-...", + defaultBaseUrl: "https://api.your-endpoint.com/v1", + }, +]; diff --git a/apps/web/src/routes/Settings.tsx b/apps/web/src/routes/Settings.tsx index cf70cb3..1143902 100644 --- a/apps/web/src/routes/Settings.tsx +++ b/apps/web/src/routes/Settings.tsx @@ -1,9 +1,10 @@ import { useQuery } from "@tanstack/react-query"; -import { Database, GitBranch, Mail, Server, Settings2, ShieldCheck } from "lucide-react"; +import { Database, GitBranch, Mail, Server, Settings2, ShieldCheck, Sparkles } from "lucide-react"; import * as api from "../api/client"; import { ConfigWarnings } from "../components/ConfigWarnings"; import { BackupSettingsSection } from "../components/settings/BackupSettingsSection"; import { GithubIntegrationSection } from "../components/settings/GithubIntegrationSection"; +import { LlmKeysSection } from "../components/settings/LlmKeysSection"; import { ServersSection } from "../components/settings/ServersSection"; import { SmtpSection } from "../components/settings/SmtpSection"; import { Badge } from "../components/ui/badge"; @@ -99,6 +100,10 @@ export function Settings() { SMTP Notifications + + + AI Diagnosis + @@ -116,6 +121,9 @@ export function Settings() { + + + ); diff --git a/apps/web/src/routes/SharedEnv.tsx b/apps/web/src/routes/SharedEnv.tsx index 9d7f520..73a300c 100644 --- a/apps/web/src/routes/SharedEnv.tsx +++ b/apps/web/src/routes/SharedEnv.tsx @@ -1,5 +1,5 @@ import { useQuery } from "@tanstack/react-query"; -import { Info, Layers, Share2, Shield } from "lucide-react"; +import { Layers, Share2, Shield } from "lucide-react"; import * as api from "../api/client"; import { SharedEnvVarsSection } from "../components/settings/SharedEnvVarsSection"; import { Badge } from "../components/ui/badge"; @@ -61,21 +61,6 @@ export function SharedEnv() { - {/* Variable Inheritance Tip Callout */} -
-
- -
-
-

Variable Precedence Hierarchy

-

- Shared variables are injected into linked projects during deployment. If a project defines an - environment variable with the exact same key name, the project-level value will take precedence. - Link a shared variable to a project to make it available. -

-
-
-
diff --git a/apps/web/src/types/index.ts b/apps/web/src/types/index.ts index 991d2ec..9042a56 100644 --- a/apps/web/src/types/index.ts +++ b/apps/web/src/types/index.ts @@ -243,6 +243,48 @@ export interface GithubIntegrationStatus { hasWebhookSecret?: boolean; } +export interface LlmKeyStatus { + provider: string; + configured: boolean; + baseUrl: string | null; + models: string[]; +} + +export type DiagCause = "user-source" | "dequel-source" | "unknown"; + +export interface DiagRun { + id: string; + deploymentId: string; + provider: string; + model: string; + status: "running" | "done" | "error"; + currentStage: string | null; + cause: DiagCause | null; + report: { + cause: DiagCause; + userFix?: { title: string; body: string; suggestedDiff: string | null }; + dequelReport?: { problem: string; cause: string; proposedFix: string }; + } | null; + error: string | null; + createdAt: string; +} + +export interface DiagStage { + stage: string; + payload: unknown; +} + +export interface ActiveDiagRun { + id: string; + deploymentId: string; + projectId: string | null; + projectName: string | null; + provider: string; + model: string; + currentStage: string | null; + createdAt: string; +} + export interface BackupJob { id: string; targetId: string; diff --git a/docker-compose.yml b/docker-compose.yml index 1786e8c..80f07f6 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -46,14 +46,18 @@ services: RAILPACK_VERBOSE: "1" RAILPACK_BUILD_TIMEOUT_MS: "1200000" GRAFANA_URL: http://grafana:3000/grafana + POSTGRES_CONTAINER: ${POSTGRES_CONTAINER:-dequel-postgres-1} volumes: - ./workspace:/app/workspace - ./infra/caddy/routes:/caddy/routes - /var/run/docker.sock:/var/run/docker.sock - railpack-cache:/tmp/railpack - api-data:/app/data + - backup-data:/data/backups - /etc/passwd:/etc/passwd:ro - /etc/group:/etc/group:ro + extra_hosts: + - "host.docker.internal:host-gateway" depends_on: buildkit: condition: service_started @@ -77,6 +81,18 @@ services: aliases: - api + diagnose-sandbox: + build: + context: ./apps/api + dockerfile: Dockerfile.sandbox + container_name: dequel-diag-sandbox + restart: unless-stopped + command: ["sleep", "infinity"] + volumes: + - diag-sandbox-data:/srv + extra_hosts: + - "host.docker.internal:host-gateway" + pam-auth: image: python:3-slim restart: unless-stopped @@ -345,6 +361,7 @@ networks: volumes: buildkit-data: + diag-sandbox-data: redis-data: postgres-data: api-data: @@ -353,3 +370,4 @@ volumes: grafana-data: railpack-cache: promtail-data: + backup-data: