Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,9 @@ cascade runs show <run-id>
cascade runs logs <run-id> # Pipe: cascade runs logs ID | grep error
cascade runs llm-calls <run-id>
cascade runs llm-call <run-id> <call-number>
cascade runs debug <run-id>
cascade runs debug <run-id> # View debug analysis
cascade runs debug <run-id> --analyze # Trigger new debug analysis
cascade runs debug <run-id> --analyze --wait # Trigger and wait for completion

# Projects
cascade projects list
Expand Down
2 changes: 1 addition & 1 deletion src/agents/prompts/templates/implementation.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion src/agents/prompts/templates/partials/git.eta
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
81 changes: 81 additions & 0 deletions src/api/routers/runs.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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({
Expand Down Expand Up @@ -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 };
}),
});
8 changes: 6 additions & 2 deletions src/backends/adapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)',
Expand Down Expand Up @@ -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,
Expand Down
65 changes: 62 additions & 3 deletions src/cli/dashboard/runs/debug.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
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 }),
};

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<void> {
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) {
Expand All @@ -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;
}

Expand All @@ -33,4 +46,50 @@ export default class RunsDebug extends DashboardCommand {
this.handleError(err);
}
}

private async triggerAnalysis(
runId: string,
flags: { json?: boolean; wait?: boolean },
): Promise<void> {
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.');
}
}
11 changes: 9 additions & 2 deletions src/cli/github/create-pr.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -26,11 +29,15 @@ export default class CreatePR extends CredentialScopedCommand {

async execute(): Promise<void> {
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'],
Expand Down
5 changes: 5 additions & 0 deletions src/db/repositories/runsRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -209,6 +209,11 @@ export async function getDebugAnalysisByRunId(analyzedRunId: string) {
return row ?? null;
}

export async function deleteDebugAnalysisByRunId(analyzedRunId: string): Promise<void> {
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
Expand Down
6 changes: 5 additions & 1 deletion src/triggers/shared/debug-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -151,6 +152,7 @@ export async function triggerDebugAnalysis(
cardId,
});

markAnalysisRunning(analyzedRunId);
let logDir: string | undefined;
try {
logDir = await extractLogsToTempDir(analyzedRunId);
Expand All @@ -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) {
Expand All @@ -188,6 +191,7 @@ export async function triggerDebugAnalysis(
success: agentResult.success,
});
} finally {
markAnalysisComplete(analyzedRunId);
if (logDir) {
try {
cleanupTempDir(logDir);
Expand Down
13 changes: 13 additions & 0 deletions src/triggers/shared/debug-status.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
const runningAnalyses = new Set<string>();

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);
}
20 changes: 20 additions & 0 deletions tests/unit/api/router.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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', () => {
Expand Down
Loading