From 02e97b2f066e2589af4c19d67ec82c51a026fd03 Mon Sep 17 00:00:00 2001 From: Zbigniew Sobiecki Date: Mon, 16 Feb 2026 17:47:24 +0000 Subject: [PATCH] feat: manual debug analysis triggering and automatic base branch for PRs Add on-demand debug analysis for any agent run via CLI, web UI, and tRPC API. Previously, debug analysis only ran automatically for failed/timed_out runs. Now users can analyze any run (including successful ones for efficiency review) and re-run analysis on already-analyzed runs. Changes: - Add triggerDebugAnalysis tRPC mutation and getDebugAnalysisStatus query - Add --analyze and --wait flags to `cascade runs debug` CLI command - Add Run Analysis / Re-run Analysis buttons in web UI with auto-polling - Add in-memory analysis status tracker (debug-status.ts) - Add deleteDebugAnalysisByRunId for replace-on-rerun - Fix severity field: use 'manual' for non-failure/non-timeout runs Also makes PR base branch automatic via CASCADE_BASE_BRANCH env var: - create-pr CLI: --base defaults to CASCADE_BASE_BRANCH env var - adapter: injects project.baseBranch as CASCADE_BASE_BRANCH into secrets - Remove --base from CreatePR tool manifest (agents no longer specify it) - Update implementation prompt and git partial to stop specifying --base Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 4 +- .../prompts/templates/implementation.eta | 2 +- src/agents/prompts/templates/partials/git.eta | 2 +- src/api/routers/runs.ts | 81 ++++++ src/backends/adapter.ts | 8 +- src/cli/dashboard/runs/debug.ts | 65 ++++- src/cli/github/create-pr.ts | 11 +- src/db/repositories/runsRepository.ts | 5 + src/triggers/shared/debug-runner.ts | 6 +- src/triggers/shared/debug-status.ts | 13 + tests/unit/api/router.test.ts | 20 ++ tests/unit/api/routers/runs.test.ts | 263 ++++++++++++++++-- tests/unit/backends/adapter.test.ts | 7 +- tests/unit/db/runsRepository.test.ts | 15 + tests/unit/triggers/debug-runner.test.ts | 80 ++++++ tests/unit/triggers/debug-status.test.ts | 55 ++++ web/src/components/debug/debug-analysis.tsx | 86 +++++- 17 files changed, 675 insertions(+), 48 deletions(-) create mode 100644 src/triggers/shared/debug-status.ts create mode 100644 tests/unit/triggers/debug-status.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 5be41f2c..bf2a5337 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -275,7 +275,9 @@ cascade runs show cascade runs logs # Pipe: cascade runs logs ID | grep error cascade runs llm-calls cascade runs llm-call -cascade runs debug +cascade runs debug # View debug analysis +cascade runs debug --analyze # Trigger new debug analysis +cascade runs debug --analyze --wait # Trigger and wait for completion # Projects cascade projects list diff --git a/src/agents/prompts/templates/implementation.eta b/src/agents/prompts/templates/implementation.eta index f38f52a4..1efb01ba 100644 --- a/src/agents/prompts/templates/implementation.eta +++ b/src/agents/prompts/templates/implementation.eta @@ -34,8 +34,8 @@ You are an expert software engineer implementing features and fixing issues base 7. **Run linting** (with fixing first, then without) **and type checking** 8. **Create a PR** using `cascade-tools github create-pr` (it handles commit, push, and PR creation atomically) - - **IMPORTANT: Set `--base` to `<%= it.baseBranch %>` (the project's base branch)** - Do NOT use `gh pr create` or `git push` directly — only `cascade-tools github create-pr` handles the full workflow correctly + - The target base branch is set automatically — do not specify `--base` - IMPORTANT: DO NOT PROCEED FURTHER UNTIL YOU HAVE CONFIRMED the create-pr command output shows `"success": true` with a `prUrl`. 9. **Mark acceptance criteria complete** using UpdateChecklistItem for each criterion you've implemented 10. **Post summary comment** on the <%= it.workItemNoun || 'card' %> describing what was implemented and linking to the PR diff --git a/src/agents/prompts/templates/partials/git.eta b/src/agents/prompts/templates/partials/git.eta index db686a41..400bfdd5 100644 --- a/src/agents/prompts/templates/partials/git.eta +++ b/src/agents/prompts/templates/partials/git.eta @@ -13,7 +13,7 @@ Use the CreatePR gadget to finalize your work. It handles the full workflow auto 2. Push the branch to remote 3. Create the pull request -**IMPORTANT:** Always set the PR `base` branch to `<%= it.baseBranch %>` — this is the project's configured base branch. +The target base branch is set automatically — do not specify the `base` parameter. Set `commit=false` or `push=false` if you've already done those steps manually. diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index a0daea61..a3fdebfd 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -1,8 +1,10 @@ import { TRPCError } from '@trpc/server'; import { eq } from 'drizzle-orm'; import { z } from 'zod'; +import { findProjectById, loadConfig } from '../../config/provider.js'; import { getDb } from '../../db/client.js'; import { + deleteDebugAnalysisByRunId, getDebugAnalysisByRunId, getLlmCallByNumber, getRunById, @@ -11,6 +13,9 @@ import { listRuns, } from '../../db/repositories/runsRepository.js'; import { projects } from '../../db/schema/index.js'; +import { triggerDebugAnalysis } from '../../triggers/shared/debug-runner.js'; +import { isAnalysisRunning } from '../../triggers/shared/debug-status.js'; +import { logger } from '../../utils/logging.js'; import { protectedProcedure, router } from '../trpc.js'; export const runsRouter = router({ @@ -91,4 +96,80 @@ export const runsRouter = router({ const analysis = await getDebugAnalysisByRunId(input.runId); return analysis; }), + + getDebugAnalysisStatus: protectedProcedure + .input(z.object({ runId: z.string().uuid() })) + .query(async ({ input }) => { + if (isAnalysisRunning(input.runId)) { + return { status: 'running' as const }; + } + const analysis = await getDebugAnalysisByRunId(input.runId); + if (analysis) { + return { status: 'completed' as const }; + } + return { status: 'idle' as const }; + }), + + triggerDebugAnalysis: protectedProcedure + .input(z.object({ runId: z.string().uuid() })) + .mutation(async ({ ctx, input }) => { + const run = await getRunById(input.runId); + if (!run) throw new TRPCError({ code: 'NOT_FOUND' }); + + // Verify org access + if (run.projectId) { + const db = getDb(); + const [project] = await db + .select({ orgId: projects.orgId }) + .from(projects) + .where(eq(projects.id, run.projectId)); + if (!project || project.orgId !== ctx.user.orgId) { + throw new TRPCError({ code: 'NOT_FOUND' }); + } + } + + if (run.agentType === 'debug') { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Cannot run debug analysis on a debug run', + }); + } + + if (isAnalysisRunning(input.runId)) { + throw new TRPCError({ + code: 'CONFLICT', + message: 'Debug analysis is already running for this run', + }); + } + + if (!run.projectId) { + throw new TRPCError({ + code: 'BAD_REQUEST', + message: 'Run has no associated project', + }); + } + + const project = await findProjectById(run.projectId); + if (!project) { + throw new TRPCError({ + code: 'NOT_FOUND', + message: 'Project not found for this run', + }); + } + + const config = await loadConfig(); + + // Delete existing analysis before re-running + await deleteDebugAnalysisByRunId(input.runId); + + // Fire-and-forget + triggerDebugAnalysis(input.runId, project, config, run.cardId ?? undefined).catch((err) => { + logger.error('Manual debug analysis failed', { + runId: input.runId, + error: String(err), + }); + }); + + return { triggered: true }; + }), }); diff --git a/src/backends/adapter.ts b/src/backends/adapter.ts index c5ee4792..f376210b 100644 --- a/src/backends/adapter.ts +++ b/src/backends/adapter.ts @@ -98,13 +98,12 @@ function getToolManifests(): ToolManifest[] { { name: 'CreatePR', description: - 'Create a GitHub pull request. Handles the full workflow: stages changes, commits, pushes branch to remote, and creates the PR. ALWAYS use this instead of gh pr create or manual git push. If you have already committed your changes, use --no-commit to skip the commit step.', + 'Create a GitHub pull request. Handles the full workflow: stages changes, commits, pushes branch to remote, and creates the PR. ALWAYS use this instead of gh pr create or manual git push. If you have already committed your changes, use --no-commit to skip the commit step. The target base branch is set automatically — do not specify --base.', cliCommand: 'cascade-tools github create-pr', parameters: { title: { type: 'string', required: true }, body: { type: 'string', required: true }, head: { type: 'string', required: true }, - base: { type: 'string', required: true }, 'no-commit': { type: 'boolean', description: 'Skip staging and committing (use when changes are already committed)', @@ -306,6 +305,11 @@ async function buildBackendInput( // Resolve all per-project secrets for subprocess injection const projectSecrets = await getProjectSecrets(project.id); + // Inject base branch so cascade-tools create-pr uses the correct target automatically + if (project.baseBranch) { + projectSecrets.CASCADE_BASE_BRANCH = project.baseBranch; + } + return { agentType, project, diff --git a/src/cli/dashboard/runs/debug.ts b/src/cli/dashboard/runs/debug.ts index 56a0b3d8..5802e74a 100644 --- a/src/cli/dashboard/runs/debug.ts +++ b/src/cli/dashboard/runs/debug.ts @@ -1,8 +1,8 @@ -import { Args } from '@oclif/core'; +import { Args, Flags } from '@oclif/core'; import { DashboardCommand } from '../_shared/base.js'; export default class RunsDebug extends DashboardCommand { - static override description = 'Show debug analysis for an agent run.'; + static override description = 'Show or trigger debug analysis for an agent run.'; static override args = { id: Args.string({ description: 'Run ID (UUID)', required: true }), @@ -10,12 +10,25 @@ export default class RunsDebug extends DashboardCommand { static override flags = { ...DashboardCommand.baseFlags, + analyze: Flags.boolean({ + description: 'Trigger a new debug analysis', + default: false, + }), + wait: Flags.boolean({ + description: 'Wait for analysis to complete (use with --analyze)', + default: false, + }), }; async run(): Promise { const { args, flags } = await this.parse(RunsDebug); try { + if (flags.analyze) { + await this.triggerAnalysis(args.id, flags); + return; + } + const analysis = await this.client.runs.getDebugAnalysis.query({ runId: args.id }); if (flags.json) { @@ -24,7 +37,7 @@ export default class RunsDebug extends DashboardCommand { } if (!analysis) { - this.log('No debug analysis found for this run.'); + this.log('No debug analysis found for this run. Use --analyze to trigger one.'); return; } @@ -33,4 +46,50 @@ export default class RunsDebug extends DashboardCommand { this.handleError(err); } } + + private async triggerAnalysis( + runId: string, + flags: { json?: boolean; wait?: boolean }, + ): Promise { + const result = await this.client.runs.triggerDebugAnalysis.mutate({ runId }); + + if (!flags.wait) { + if (flags.json) { + this.outputJson(result); + } else { + this.log('Debug analysis triggered.'); + } + return; + } + + this.log('Debug analysis triggered. Waiting for completion...'); + + const timeoutMs = 5 * 60 * 1000; + const pollMs = 5000; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + await new Promise((resolve) => setTimeout(resolve, pollMs)); + const status = await this.client.runs.getDebugAnalysisStatus.query({ runId }); + + if (status.status === 'completed') { + const analysis = await this.client.runs.getDebugAnalysis.query({ runId }); + if (flags.json) { + this.outputJson(analysis); + } else { + this.log('Debug analysis completed.'); + console.log(JSON.stringify(analysis, null, 2)); + } + return; + } + + if (status.status === 'idle') { + // Analysis finished but no result — likely failed + this.log('Debug analysis finished but no result was stored (analysis may have failed).'); + return; + } + } + + this.log('Timed out waiting for debug analysis to complete.'); + } } diff --git a/src/cli/github/create-pr.ts b/src/cli/github/create-pr.ts index c9f3e588..c2eb110b 100644 --- a/src/cli/github/create-pr.ts +++ b/src/cli/github/create-pr.ts @@ -9,7 +9,10 @@ export default class CreatePR extends CredentialScopedCommand { title: Flags.string({ description: 'PR title', required: true }), body: Flags.string({ description: 'PR description (markdown supported)', required: true }), head: Flags.string({ description: 'Source branch name', required: true }), - base: Flags.string({ description: 'Target branch name', required: true }), + base: Flags.string({ + description: 'Target branch name (defaults to CASCADE_BASE_BRANCH env var)', + env: 'CASCADE_BASE_BRANCH', + }), draft: Flags.boolean({ description: 'Create as draft PR', default: false }), commit: Flags.boolean({ description: 'Stage and commit changes before pushing', @@ -26,11 +29,15 @@ export default class CreatePR extends CredentialScopedCommand { async execute(): Promise { const { flags } = await this.parse(CreatePR); + const base = flags.base; + if (!base) { + this.error('--base is required (or set CASCADE_BASE_BRANCH env var)'); + } const result = await createPR({ title: flags.title, body: flags.body, head: flags.head, - base: flags.base, + base, draft: flags.draft, commit: flags.commit, commitMessage: flags['commit-message'], diff --git a/src/db/repositories/runsRepository.ts b/src/db/repositories/runsRepository.ts index f466d630..f359ce49 100644 --- a/src/db/repositories/runsRepository.ts +++ b/src/db/repositories/runsRepository.ts @@ -209,6 +209,11 @@ export async function getDebugAnalysisByRunId(analyzedRunId: string) { return row ?? null; } +export async function deleteDebugAnalysisByRunId(analyzedRunId: string): Promise { + const db = getDb(); + await db.delete(debugAnalyses).where(eq(debugAnalyses.analyzedRunId, analyzedRunId)); +} + export async function getDebugAnalysisByDebugRunId(debugRunId: string) { const db = getDb(); const [row] = await db diff --git a/src/triggers/shared/debug-runner.ts b/src/triggers/shared/debug-runner.ts index 07e5a84e..c4516510 100644 --- a/src/triggers/shared/debug-runner.ts +++ b/src/triggers/shared/debug-runner.ts @@ -12,6 +12,7 @@ import { getPMProvider } from '../../pm/index.js'; import type { AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js'; import { logger } from '../../utils/logging.js'; import { cleanupTempDir } from '../../utils/repo.js'; +import { markAnalysisComplete, markAnalysisRunning } from './debug-status.js'; /** * Extract logs from the database and write them to a temp directory @@ -151,6 +152,7 @@ export async function triggerDebugAnalysis( cardId, }); + markAnalysisRunning(analyzedRunId); let logDir: string | undefined; try { logDir = await extractLogsToTempDir(analyzedRunId); @@ -175,7 +177,8 @@ export async function triggerDebugAnalysis( timeline: parsed.timeline, recommendations: parsed.recommendations, rootCause: parsed.rootCause, - severity: run.status === 'timed_out' ? 'timeout' : 'failure', + severity: + run.status === 'timed_out' ? 'timeout' : run.status === 'failed' ? 'failure' : 'manual', }); if (cardId && parsed.summary) { @@ -188,6 +191,7 @@ export async function triggerDebugAnalysis( success: agentResult.success, }); } finally { + markAnalysisComplete(analyzedRunId); if (logDir) { try { cleanupTempDir(logDir); diff --git a/src/triggers/shared/debug-status.ts b/src/triggers/shared/debug-status.ts new file mode 100644 index 00000000..75cb0463 --- /dev/null +++ b/src/triggers/shared/debug-status.ts @@ -0,0 +1,13 @@ +const runningAnalyses = new Set(); + +export function markAnalysisRunning(runId: string): void { + runningAnalyses.add(runId); +} + +export function markAnalysisComplete(runId: string): void { + runningAnalyses.delete(runId); +} + +export function isAnalysisRunning(runId: string): boolean { + return runningAnalyses.has(runId); +} diff --git a/tests/unit/api/router.test.ts b/tests/unit/api/router.test.ts index 759b7a59..67f5f138 100644 --- a/tests/unit/api/router.test.ts +++ b/tests/unit/api/router.test.ts @@ -18,9 +18,27 @@ vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ listLlmCallsMeta: vi.fn(), getLlmCallByNumber: vi.fn(), getDebugAnalysisByRunId: vi.fn(), + deleteDebugAnalysisByRunId: vi.fn(), listProjectsForOrg: vi.fn(), })); +vi.mock('../../../src/config/provider.js', () => ({ + findProjectById: vi.fn(), + loadConfig: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/debug-status.js', () => ({ + isAnalysisRunning: vi.fn(), +})); + +vi.mock('../../../src/triggers/shared/debug-runner.js', () => ({ + triggerDebugAnalysis: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + vi.mock('../../../src/db/repositories/settingsRepository.js', () => ({ getOrganization: vi.fn(), updateOrganization: vi.fn(), @@ -83,6 +101,8 @@ describe('appRouter', () => { expect(procedures).toContain('runs.listLlmCalls'); expect(procedures).toContain('runs.getLlmCall'); expect(procedures).toContain('runs.getDebugAnalysis'); + expect(procedures).toContain('runs.getDebugAnalysisStatus'); + expect(procedures).toContain('runs.triggerDebugAnalysis'); }); it('has projects sub-router with all procedures', () => { diff --git a/tests/unit/api/routers/runs.test.ts b/tests/unit/api/routers/runs.test.ts index 917fac35..efc88bca 100644 --- a/tests/unit/api/routers/runs.test.ts +++ b/tests/unit/api/routers/runs.test.ts @@ -9,6 +9,7 @@ const mockGetRunLogs = vi.fn(); const mockListLlmCallsMeta = vi.fn(); const mockGetLlmCallByNumber = vi.fn(); const mockGetDebugAnalysisByRunId = vi.fn(); +const mockDeleteDebugAnalysisByRunId = vi.fn(); vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ listRuns: (...args: unknown[]) => mockListRuns(...args), @@ -17,6 +18,7 @@ vi.mock('../../../../src/db/repositories/runsRepository.js', () => ({ listLlmCallsMeta: (...args: unknown[]) => mockListLlmCallsMeta(...args), getLlmCallByNumber: (...args: unknown[]) => mockGetLlmCallByNumber(...args), getDebugAnalysisByRunId: (...args: unknown[]) => mockGetDebugAnalysisByRunId(...args), + deleteDebugAnalysisByRunId: (...args: unknown[]) => mockDeleteDebugAnalysisByRunId(...args), })); // Mock getDb for the inline org-access check in getById @@ -34,6 +36,31 @@ vi.mock('../../../../src/db/schema/index.js', () => ({ projects: { id: 'id', orgId: 'org_id' }, })); +// Mock debug-status tracker +const mockIsAnalysisRunning = vi.fn(); +vi.mock('../../../../src/triggers/shared/debug-status.js', () => ({ + isAnalysisRunning: (...args: unknown[]) => mockIsAnalysisRunning(...args), +})); + +// Mock triggerDebugAnalysis (fire-and-forget) +const mockTriggerDebugAnalysis = vi.fn(); +vi.mock('../../../../src/triggers/shared/debug-runner.js', () => ({ + triggerDebugAnalysis: (...args: unknown[]) => mockTriggerDebugAnalysis(...args), +})); + +// Mock config provider +const mockFindProjectById = vi.fn(); +const mockLoadConfig = vi.fn(); +vi.mock('../../../../src/config/provider.js', () => ({ + findProjectById: (...args: unknown[]) => mockFindProjectById(...args), + loadConfig: (...args: unknown[]) => mockLoadConfig(...args), +})); + +// Mock logger +vi.mock('../../../../src/utils/logging.js', () => ({ + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() }, +})); + import { runsRouter } from '../../../../src/api/routers/runs.js'; function createCaller(ctx: TRPCContext) { @@ -48,12 +75,16 @@ const mockUser = { role: 'admin', }; +const RUN_UUID = 'aaaaaaaa-1111-2222-3333-444444444444'; + describe('runsRouter', () => { beforeEach(() => { vi.clearAllMocks(); // Set up DB chain for getById org check mockDbSelect.mockReturnValue({ from: mockDbFrom }); mockDbFrom.mockReturnValue({ where: mockDbWhere }); + // Default: triggerDebugAnalysis returns a resolved promise (fire-and-forget) + mockTriggerDebugAnalysis.mockReturnValue(Promise.resolve()); }); describe('list', () => { @@ -133,7 +164,7 @@ describe('runsRouter', () => { describe('getById', () => { it('returns run when found and org matches', async () => { const mockRun = { - id: 'aaaaaaaa-1111-2222-3333-444444444444', + id: RUN_UUID, projectId: 'p1', agentType: 'implementation', }; @@ -141,7 +172,7 @@ describe('runsRouter', () => { mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); const caller = createCaller({ user: mockUser }); - const result = await caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }); + const result = await caller.getById({ id: RUN_UUID }); expect(result).toEqual(mockRun); }); @@ -150,47 +181,47 @@ describe('runsRouter', () => { mockGetRunById.mockResolvedValue(null); const caller = createCaller({ user: mockUser }); - await expect( - caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), - ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(caller.getById({ id: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('throws NOT_FOUND when project org does not match user org', async () => { mockGetRunById.mockResolvedValue({ - id: 'aaaaaaaa-1111-2222-3333-444444444444', + id: RUN_UUID, projectId: 'p1', }); mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); const caller = createCaller({ user: mockUser }); - await expect( - caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), - ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(caller.getById({ id: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('throws NOT_FOUND when project not found for run', async () => { mockGetRunById.mockResolvedValue({ - id: 'aaaaaaaa-1111-2222-3333-444444444444', + id: RUN_UUID, projectId: 'p-missing', }); mockDbWhere.mockResolvedValue([]); const caller = createCaller({ user: mockUser }); - await expect( - caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }), - ).rejects.toMatchObject({ code: 'NOT_FOUND' }); + await expect(caller.getById({ id: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); }); it('returns run when run has no projectId', async () => { const mockRun = { - id: 'aaaaaaaa-1111-2222-3333-444444444444', + id: RUN_UUID, projectId: null, agentType: 'debug', }; mockGetRunById.mockResolvedValue(mockRun); const caller = createCaller({ user: mockUser }); - const result = await caller.getById({ id: 'aaaaaaaa-1111-2222-3333-444444444444' }); + const result = await caller.getById({ id: RUN_UUID }); expect(result).toEqual(mockRun); expect(mockDbSelect).not.toHaveBeenCalled(); @@ -208,9 +239,9 @@ describe('runsRouter', () => { mockGetRunLogs.mockResolvedValue(mockLogs); const caller = createCaller({ user: mockUser }); - const result = await caller.getLogs({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + const result = await caller.getLogs({ runId: RUN_UUID }); - expect(mockGetRunLogs).toHaveBeenCalledWith('aaaaaaaa-1111-2222-3333-444444444444'); + expect(mockGetRunLogs).toHaveBeenCalledWith(RUN_UUID); expect(result).toEqual(mockLogs); }); @@ -218,7 +249,7 @@ describe('runsRouter', () => { mockGetRunLogs.mockResolvedValue(null); const caller = createCaller({ user: mockUser }); - const result = await caller.getLogs({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + const result = await caller.getLogs({ runId: RUN_UUID }); expect(result).toBeNull(); }); }); @@ -232,7 +263,7 @@ describe('runsRouter', () => { mockListLlmCallsMeta.mockResolvedValue(mockMeta); const caller = createCaller({ user: mockUser }); - const result = await caller.listLlmCalls({ runId: 'aaaaaaaa-1111-2222-3333-444444444444' }); + const result = await caller.listLlmCalls({ runId: RUN_UUID }); expect(result).toEqual(mockMeta); }); @@ -245,14 +276,11 @@ describe('runsRouter', () => { const caller = createCaller({ user: mockUser }); const result = await caller.getLlmCall({ - runId: 'aaaaaaaa-1111-2222-3333-444444444444', + runId: RUN_UUID, callNumber: 3, }); - expect(mockGetLlmCallByNumber).toHaveBeenCalledWith( - 'aaaaaaaa-1111-2222-3333-444444444444', - 3, - ); + expect(mockGetLlmCallByNumber).toHaveBeenCalledWith(RUN_UUID, 3); expect(result).toEqual(mockCall); }); @@ -262,7 +290,7 @@ describe('runsRouter', () => { await expect( caller.getLlmCall({ - runId: 'aaaaaaaa-1111-2222-3333-444444444444', + runId: RUN_UUID, callNumber: 999, }), ).rejects.toMatchObject({ code: 'NOT_FOUND' }); @@ -276,7 +304,7 @@ describe('runsRouter', () => { const caller = createCaller({ user: mockUser }); const result = await caller.getDebugAnalysis({ - runId: 'aaaaaaaa-1111-2222-3333-444444444444', + runId: RUN_UUID, }); expect(result).toEqual(mockAnalysis); @@ -287,9 +315,190 @@ describe('runsRouter', () => { const caller = createCaller({ user: mockUser }); const result = await caller.getDebugAnalysis({ - runId: 'aaaaaaaa-1111-2222-3333-444444444444', + runId: RUN_UUID, }); expect(result).toBeNull(); }); }); + + describe('getDebugAnalysisStatus', () => { + it('returns running when analysis is in progress', async () => { + mockIsAnalysisRunning.mockReturnValue(true); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getDebugAnalysisStatus({ runId: RUN_UUID }); + + expect(result).toEqual({ status: 'running' }); + // Should not query DB when running + expect(mockGetDebugAnalysisByRunId).not.toHaveBeenCalled(); + }); + + it('returns completed when analysis exists in DB', async () => { + mockIsAnalysisRunning.mockReturnValue(false); + mockGetDebugAnalysisByRunId.mockResolvedValue({ summary: 'done' }); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getDebugAnalysisStatus({ runId: RUN_UUID }); + + expect(result).toEqual({ status: 'completed' }); + }); + + it('returns idle when not running and no analysis exists', async () => { + mockIsAnalysisRunning.mockReturnValue(false); + mockGetDebugAnalysisByRunId.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser }); + const result = await caller.getDebugAnalysisStatus({ runId: RUN_UUID }); + + expect(result).toEqual({ status: 'idle' }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.getDebugAnalysisStatus({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); + + describe('triggerDebugAnalysis', () => { + it('triggers analysis for a valid run', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + cardId: 'card-1', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockIsAnalysisRunning.mockReturnValue(false); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test' }); + mockLoadConfig.mockResolvedValue({}); + mockDeleteDebugAnalysisByRunId.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + const result = await caller.triggerDebugAnalysis({ runId: RUN_UUID }); + + expect(result).toEqual({ triggered: true }); + expect(mockDeleteDebugAnalysisByRunId).toHaveBeenCalledWith(RUN_UUID); + expect(mockTriggerDebugAnalysis).toHaveBeenCalledWith( + RUN_UUID, + { id: 'p1', name: 'Test' }, + {}, + 'card-1', + ); + }); + + it('passes undefined cardId when run has no card', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + cardId: null, + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockIsAnalysisRunning.mockReturnValue(false); + mockFindProjectById.mockResolvedValue({ id: 'p1', name: 'Test' }); + mockLoadConfig.mockResolvedValue({}); + mockDeleteDebugAnalysisByRunId.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await caller.triggerDebugAnalysis({ runId: RUN_UUID }); + + expect(mockTriggerDebugAnalysis).toHaveBeenCalledWith( + RUN_UUID, + expect.anything(), + expect.anything(), + undefined, + ); + }); + + it('throws NOT_FOUND when run does not exist', async () => { + mockGetRunById.mockResolvedValue(null); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws NOT_FOUND when org does not match', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'different-org' }]); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws BAD_REQUEST for debug agent type', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'debug', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('throws CONFLICT when analysis is already running', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p1', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockIsAnalysisRunning.mockReturnValue(true); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'CONFLICT', + }); + }); + + it('throws BAD_REQUEST when run has no projectId', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: null, + agentType: 'implementation', + }); + mockIsAnalysisRunning.mockReturnValue(false); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'BAD_REQUEST', + }); + }); + + it('throws NOT_FOUND when project not found', async () => { + mockGetRunById.mockResolvedValue({ + id: RUN_UUID, + projectId: 'p-missing', + agentType: 'implementation', + }); + mockDbWhere.mockResolvedValue([{ orgId: 'org-1' }]); + mockIsAnalysisRunning.mockReturnValue(false); + mockFindProjectById.mockResolvedValue(undefined); + + const caller = createCaller({ user: mockUser }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'NOT_FOUND', + }); + }); + + it('throws UNAUTHORIZED when unauthenticated', async () => { + const caller = createCaller({ user: null }); + await expect(caller.triggerDebugAnalysis({ runId: RUN_UUID })).rejects.toMatchObject({ + code: 'UNAUTHORIZED', + }); + }); + }); }); diff --git a/tests/unit/backends/adapter.test.ts b/tests/unit/backends/adapter.test.ts index 6f61b7ed..64dfc39f 100644 --- a/tests/unit/backends/adapter.test.ts +++ b/tests/unit/backends/adapter.test.ts @@ -511,10 +511,11 @@ describe('executeWithBackend', () => { expect(backendInput.projectSecrets).toEqual({ GITHUB_TOKEN: 'proj-gh-token', TRELLO_API_KEY: 'proj-trello-key', + CASCADE_BASE_BRANCH: 'main', }); }); - it('omits projectSecrets when no per-project secrets are found', async () => { + it('includes CASCADE_BASE_BRANCH even when no other per-project secrets exist', async () => { setupMocks(); mockGetProjectSecrets.mockResolvedValue({}); @@ -524,6 +525,8 @@ describe('executeWithBackend', () => { await executeWithBackend(backend, 'implementation', input); const backendInput = vi.mocked(backend.execute).mock.calls[0][0]; - expect(backendInput.projectSecrets).toBeUndefined(); + expect(backendInput.projectSecrets).toEqual({ + CASCADE_BASE_BRANCH: 'main', + }); }); }); diff --git a/tests/unit/db/runsRepository.test.ts b/tests/unit/db/runsRepository.test.ts index 8b338be7..372af90a 100644 --- a/tests/unit/db/runsRepository.test.ts +++ b/tests/unit/db/runsRepository.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const mockInsert = vi.fn(); const mockUpdate = vi.fn(); const mockSelect = vi.fn(); +const mockDelete = vi.fn(); const mockValues = vi.fn(); const mockReturning = vi.fn(); const mockSet = vi.fn(); @@ -16,6 +17,7 @@ vi.mock('../../../src/db/client.js', () => ({ insert: mockInsert, update: mockUpdate, select: mockSelect, + delete: mockDelete, }), })); @@ -35,6 +37,7 @@ vi.mock('../../../src/db/schema/index.js', () => ({ import { completeRun, createRun, + deleteDebugAnalysisByRunId, getDebugAnalysisByDebugRunId, getDebugAnalysisByRunId, getLlmCallsByRunId, @@ -59,6 +62,7 @@ describe('runsRepository', () => { mockSelect.mockReturnValue({ from: mockFrom }); mockFrom.mockReturnValue({ where: mockWhere, orderBy: mockOrderBy }); mockWhere.mockReturnValue({ orderBy: mockOrderBy }); + mockDelete.mockReturnValue({ where: mockWhere }); }); describe('createRun', () => { @@ -377,4 +381,15 @@ describe('runsRepository', () => { expect(result).toBeNull(); }); }); + + describe('deleteDebugAnalysisByRunId', () => { + it('calls delete with the correct analyzedRunId', async () => { + mockWhere.mockResolvedValue(undefined); + + await deleteDebugAnalysisByRunId('run-1'); + + expect(mockDelete).toHaveBeenCalled(); + expect(mockWhere).toHaveBeenCalled(); + }); + }); }); diff --git a/tests/unit/triggers/debug-runner.test.ts b/tests/unit/triggers/debug-runner.test.ts index 504f402b..f2cb466a 100644 --- a/tests/unit/triggers/debug-runner.test.ts +++ b/tests/unit/triggers/debug-runner.test.ts @@ -28,6 +28,11 @@ vi.mock('../../../src/utils/repo.js', () => ({ cleanupTempDir: vi.fn(), })); +vi.mock('../../../src/triggers/shared/debug-status.js', () => ({ + markAnalysisRunning: vi.fn(), + markAnalysisComplete: vi.fn(), +})); + import { runAgent } from '../../../src/agents/registry.js'; import { getLlmCallsByRunId, @@ -38,6 +43,10 @@ import { import type { PMProvider } from '../../../src/pm/index.js'; import { getPMProvider } from '../../../src/pm/index.js'; import { triggerDebugAnalysis } from '../../../src/triggers/shared/debug-runner.js'; +import { + markAnalysisComplete, + markAnalysisRunning, +} from '../../../src/triggers/shared/debug-status.js'; const mockPMProvider = { addComment: vi.fn() }; import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; @@ -153,6 +162,77 @@ describe('triggerDebugAnalysis', () => { ); }); + it('calls markAnalysisRunning at start and markAnalysisComplete on success', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + status: 'failed', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(getRunLogs).mockResolvedValue(null); + vi.mocked(getLlmCallsByRunId).mockResolvedValue([]); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: '## Executive Summary\nFailed.\n', + runId: 'debug-run-status', + }); + + vi.mocked(storeDebugAnalysis).mockResolvedValue('da-status'); + + await triggerDebugAnalysis('run-1', mockProject, mockConfig); + + expect(markAnalysisRunning).toHaveBeenCalledWith('run-1'); + expect(markAnalysisComplete).toHaveBeenCalledWith('run-1'); + }); + + it('calls markAnalysisComplete even when agent throws', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + status: 'failed', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(getRunLogs).mockResolvedValue(null); + vi.mocked(getLlmCallsByRunId).mockResolvedValue([]); + + vi.mocked(runAgent).mockRejectedValue(new Error('Agent crashed')); + + await expect(triggerDebugAnalysis('run-1', mockProject, mockConfig)).rejects.toThrow( + 'Agent crashed', + ); + + expect(markAnalysisRunning).toHaveBeenCalledWith('run-1'); + expect(markAnalysisComplete).toHaveBeenCalledWith('run-1'); + }); + + it('sets severity to manual for completed runs', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + status: 'completed', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(getRunLogs).mockResolvedValue(null); + vi.mocked(getLlmCallsByRunId).mockResolvedValue([]); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: '## Executive Summary\nCompleted analysis.\n', + runId: 'debug-run-manual', + }); + + vi.mocked(storeDebugAnalysis).mockResolvedValue('da-manual'); + + await triggerDebugAnalysis('run-1', mockProject, mockConfig); + + expect(storeDebugAnalysis).toHaveBeenCalledWith( + expect.objectContaining({ + severity: 'manual', + }), + ); + }); + it('does not post comment when no cardId', async () => { vi.mocked(getRunById).mockResolvedValue({ id: 'run-1', diff --git a/tests/unit/triggers/debug-status.test.ts b/tests/unit/triggers/debug-status.test.ts new file mode 100644 index 00000000..95efa3c7 --- /dev/null +++ b/tests/unit/triggers/debug-status.test.ts @@ -0,0 +1,55 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + isAnalysisRunning, + markAnalysisComplete, + markAnalysisRunning, +} from '../../../src/triggers/shared/debug-status.js'; + +describe('debug-status tracker', () => { + afterEach(() => { + // Clean up any leftover state + markAnalysisComplete('run-1'); + markAnalysisComplete('run-2'); + }); + + it('reports idle by default', () => { + expect(isAnalysisRunning('run-1')).toBe(false); + }); + + it('marks a run as running', () => { + markAnalysisRunning('run-1'); + expect(isAnalysisRunning('run-1')).toBe(true); + }); + + it('marks a run as complete', () => { + markAnalysisRunning('run-1'); + markAnalysisComplete('run-1'); + expect(isAnalysisRunning('run-1')).toBe(false); + }); + + it('tracks multiple runs independently', () => { + markAnalysisRunning('run-1'); + markAnalysisRunning('run-2'); + + expect(isAnalysisRunning('run-1')).toBe(true); + expect(isAnalysisRunning('run-2')).toBe(true); + + markAnalysisComplete('run-1'); + expect(isAnalysisRunning('run-1')).toBe(false); + expect(isAnalysisRunning('run-2')).toBe(true); + }); + + it('is idempotent for markAnalysisRunning', () => { + markAnalysisRunning('run-1'); + markAnalysisRunning('run-1'); + expect(isAnalysisRunning('run-1')).toBe(true); + + markAnalysisComplete('run-1'); + expect(isAnalysisRunning('run-1')).toBe(false); + }); + + it('is safe to call markAnalysisComplete on non-running run', () => { + markAnalysisComplete('run-1'); + expect(isAnalysisRunning('run-1')).toBe(false); + }); +}); diff --git a/web/src/components/debug/debug-analysis.tsx b/web/src/components/debug/debug-analysis.tsx index 3686af31..e19dee40 100644 --- a/web/src/components/debug/debug-analysis.tsx +++ b/web/src/components/debug/debug-analysis.tsx @@ -1,5 +1,7 @@ -import { trpc } from '@/lib/trpc.js'; -import { useQuery } from '@tanstack/react-query'; +import { Button } from '@/components/ui/button.js'; +import { trpc, trpcClient } from '@/lib/trpc.js'; +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { useEffect, useRef } from 'react'; interface DebugAnalysisProps { runId: string; @@ -16,16 +18,65 @@ function Section({ title, content }: { title: string; content: string | null | u } export function DebugAnalysis({ runId }: DebugAnalysisProps) { + const queryClient = useQueryClient(); + const prevStatusRef = useRef(undefined); + const analysisQuery = useQuery(trpc.runs.getDebugAnalysis.queryOptions({ runId })); - if (analysisQuery.isLoading) { + const statusQuery = useQuery({ + ...trpc.runs.getDebugAnalysisStatus.queryOptions({ runId }), + refetchInterval: (query) => { + const status = query.state.data?.status; + return status === 'running' ? 5000 : false; + }, + }); + + const triggerMutation = useMutation({ + mutationFn: () => trpcClient.runs.triggerDebugAnalysis.mutate({ runId }), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: trpc.runs.getDebugAnalysisStatus.queryOptions({ runId }).queryKey, + }); + }, + }); + + // When status transitions from running → completed, refetch the analysis + useEffect(() => { + const currentStatus = statusQuery.data?.status; + if (prevStatusRef.current === 'running' && currentStatus === 'completed') { + queryClient.invalidateQueries({ + queryKey: trpc.runs.getDebugAnalysis.queryOptions({ runId }).queryKey, + }); + } + prevStatusRef.current = currentStatus; + }, [statusQuery.data?.status, queryClient, runId]); + + const isRunning = triggerMutation.isPending || statusQuery.data?.status === 'running'; + + if (analysisQuery.isLoading || statusQuery.isLoading) { return
Loading analysis...
; } + if (isRunning) { + return ( +
Debug analysis is running...
+ ); + } + if (!analysisQuery.data) { return ( -
- No debug analysis available for this run +
+

No debug analysis available for this run

+ + {triggerMutation.isError && ( +

+ {triggerMutation.error instanceof Error + ? triggerMutation.error.message + : 'Failed to trigger analysis'} +

+ )}
); } @@ -34,11 +85,30 @@ export function DebugAnalysis({ runId }: DebugAnalysisProps) { return (
- {analysis.severity && ( +
- Severity: - {analysis.severity} + {analysis.severity && ( + <> + Severity: + {analysis.severity} + + )}
+ +
+ {triggerMutation.isError && ( +

+ {triggerMutation.error instanceof Error + ? triggerMutation.error.message + : 'Failed to trigger analysis'} +

)}