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
2 changes: 2 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,8 @@ cascade runs llm-call <run-id> <call-number>
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
cascade runs trigger --project <id> --agent-type <type> [--card-id ID] [--model MODEL]
cascade runs retry <run-id> [--model MODEL]

# Projects
cascade projects list
Expand Down
115 changes: 115 additions & 0 deletions src/api/routers/runs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { projects } from '../../db/schema/index.js';
import { triggerDebugAnalysis } from '../../triggers/shared/debug-runner.js';
import { isAnalysisRunning } from '../../triggers/shared/debug-status.js';
import { triggerManualRun, triggerRetryRun } from '../../triggers/shared/manual-runner.js';
import { logger } from '../../utils/logging.js';
import { protectedProcedure, router } from '../trpc.js';

Expand Down Expand Up @@ -170,6 +171,120 @@ export const runsRouter = router({
});
});

return { triggered: true };
}),

trigger: protectedProcedure
.input(
z.object({
projectId: z.string(),
agentType: z.string(),
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: agentType: z.string() — other routers use z.string().min(1) for agent type validation. Without it, an empty string silently fails in the fire-and-forget path.

cardId: z.string().optional(),
prNumber: z.number().optional(),
prBranch: z.string().optional(),
repoFullName: z.string().optional(),
headSha: z.string().optional(),
model: z.string().optional(),
}),
)
.mutation(async ({ ctx, input }) => {
// Verify org ownership of project
const db = getDb();
const [project] = await db
.select({ orgId: projects.orgId })
.from(projects)
.where(eq(projects.id, input.projectId));

if (!project || project.orgId !== ctx.user.orgId) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project not found',
});
}

const projectConfig = await findProjectById(input.projectId);
if (!projectConfig) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project configuration not found',
});
}

const config = await loadConfig();

// Fire-and-forget
triggerManualRun(
{
projectId: input.projectId,
agentType: input.agentType,
cardId: input.cardId,
prNumber: input.prNumber,
prBranch: input.prBranch,
repoFullName: input.repoFullName,
headSha: input.headSha,
modelOverride: input.model,
},
projectConfig,
config,
).catch((err) => {
logger.error('Manual trigger failed', {
projectId: input.projectId,
agentType: input.agentType,
error: String(err),
});
});

return { triggered: true };
}),

retry: protectedProcedure
.input(
z.object({
runId: z.string().uuid(),
model: z.string().optional(),
}),
)
.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.projectId) {
throw new TRPCError({
code: 'BAD_REQUEST',
message: 'Run has no associated project',
});
}

const projectConfig = await findProjectById(run.projectId);
if (!projectConfig) {
throw new TRPCError({
code: 'NOT_FOUND',
message: 'Project configuration not found',
});
}

const config = await loadConfig();

// Fire-and-forget
triggerRetryRun(input.runId, projectConfig, config, input.model).catch((err) => {
logger.error('Retry run failed', {
runId: input.runId,
error: String(err),
});
});

return { triggered: true };
}),
});
34 changes: 34 additions & 0 deletions src/cli/dashboard/runs/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { Args, Flags } from '@oclif/core';
import { DashboardCommand } from '../_shared/base.js';

export default class RunsRetry extends DashboardCommand {
static override description = 'Retry a previous agent run.';

static override args = {
id: Args.string({ description: 'Run ID (UUID)', required: true }),
};

static override flags = {
...DashboardCommand.baseFlags,
model: Flags.string({ description: 'Override model (optional)' }),
};

async run(): Promise<void> {
const { args, flags } = await this.parse(RunsRetry);

try {
const result = await this.client.runs.retry.mutate({
runId: args.id,
model: flags.model,
});

if (flags.json) {
this.outputJson(result);
} else {
this.log('Run retry triggered successfully.');
}
} catch (err) {
this.handleError(err);
}
}
}
43 changes: 43 additions & 0 deletions src/cli/dashboard/runs/trigger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import { Flags } from '@oclif/core';
import { DashboardCommand } from '../_shared/base.js';

export default class RunsTrigger extends DashboardCommand {
static override description = 'Manually trigger an agent run.';

static override flags = {
...DashboardCommand.baseFlags,
project: Flags.string({ description: 'Project ID', required: true }),
'agent-type': Flags.string({ description: 'Agent type to run', required: true }),
'card-id': Flags.string({ description: 'Card ID (optional)' }),
'pr-number': Flags.integer({ description: 'PR number (optional)' }),
'pr-branch': Flags.string({ description: 'PR branch (optional)' }),
'repo-full-name': Flags.string({ description: 'Repository full name (optional)' }),
'head-sha': Flags.string({ description: 'Git SHA (optional)' }),
model: Flags.string({ description: 'Override model (optional)' }),
};

async run(): Promise<void> {
const { flags } = await this.parse(RunsTrigger);

try {
const result = await this.client.runs.trigger.mutate({
projectId: flags.project,
agentType: flags['agent-type'],
cardId: flags['card-id'],
prNumber: flags['pr-number'],
prBranch: flags['pr-branch'],
repoFullName: flags['repo-full-name'],
headSha: flags['head-sha'],
model: flags.model,
});

if (flags.json) {
this.outputJson(result);
} else {
this.log('Agent run triggered successfully.');
}
} catch (err) {
this.handleError(err);
}
}
}
161 changes: 161 additions & 0 deletions src/triggers/shared/manual-runner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { runAgent } from '../../agents/registry.js';
import { getRunById } from '../../db/repositories/runsRepository.js';
import type { AgentInput, AgentResult, CascadeConfig, ProjectConfig } from '../../types/index.js';
import { logger } from '../../utils/logging.js';

/**
* In-memory tracking to prevent duplicate concurrent manual triggers.
*/
const runningTriggers = new Map<string, boolean>();

function generateTriggerKey(
projectId: string,
agentType: string,
cardId?: string,
prNumber?: number,
): string {
return `${projectId}:${agentType}:${cardId ?? 'no-card'}:${prNumber ?? 'no-pr'}`;
}

function markTriggerRunning(key: string): void {
runningTriggers.set(key, true);
}

function markTriggerComplete(key: string): void {
runningTriggers.delete(key);
}

export function isTriggerRunning(key: string): boolean {
return runningTriggers.has(key);
}

/**
* Clear all trigger tracking (test utility).
*/
export function clearTriggerTracking(): void {
runningTriggers.clear();
}

/**
* Input for manual agent triggers.
*/
export interface ManualTriggerInput {
projectId: string;
agentType: string;
cardId?: string;
prNumber?: number;
prBranch?: string;
repoFullName?: string;
headSha?: string;
modelOverride?: string;
}

/**
* Trigger a manual agent run.
*
* This runs fire-and-forget (does not await runAgent completion).
* Status tracking is handled via in-memory map to prevent duplicates.
*/
export async function triggerManualRun(
input: ManualTriggerInput,
project: ProjectConfig,
config: CascadeConfig,
): Promise<void> {
const triggerKey = generateTriggerKey(
input.projectId,
input.agentType,
input.cardId,
input.prNumber,
);

if (isTriggerRunning(triggerKey)) {
throw new Error(
`Manual trigger already running for project=${input.projectId}, agent=${input.agentType}, card=${input.cardId ?? 'N/A'}, pr=${input.prNumber ?? 'N/A'}`,
);
}

logger.info('Triggering manual agent run', {
projectId: input.projectId,
agentType: input.agentType,
cardId: input.cardId,
prNumber: input.prNumber,
modelOverride: input.modelOverride,
});

markTriggerRunning(triggerKey);

const agentInput: AgentInput & { project: ProjectConfig; config: CascadeConfig } = {
cardId: input.cardId,
prNumber: input.prNumber,
prBranch: input.prBranch,
repoFullName: input.repoFullName,
headSha: input.headSha,
modelOverride: input.modelOverride,
triggerType: 'manual',
project,
config,
};

// Fire-and-forget execution
runAgent(input.agentType, agentInput)
.then((result: AgentResult) => {
logger.info('Manual agent run completed', {
projectId: input.projectId,
agentType: input.agentType,
success: result.success,
runId: result.runId,
});
})
.catch((err) => {
logger.error('Manual agent run failed', {
projectId: input.projectId,
agentType: input.agentType,
error: String(err),
});
})
.finally(() => {
markTriggerComplete(triggerKey);
});
}

/**
* Retry a previous agent run.
*
* Reads the original run from DB, extracts parameters, and triggers a new manual run.
*/
export async function triggerRetryRun(
runId: string,
project: ProjectConfig,
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This getRunById call duplicates the fetch already done in the retry tRPC mutation. Consider accepting the run data as a parameter (similar to how triggerDebugAnalysis receives its inputs from the router) to avoid the extra DB round-trip.

config: CascadeConfig,
modelOverride?: string,
): Promise<void> {
const run = await getRunById(runId);
if (!run) {
throw new Error(`Run not found: ${runId}`);
}

if (!run.projectId) {
throw new Error(`Run ${runId} has no associated project`);
}

logger.info('Retrying agent run', {
originalRunId: runId,
agentType: run.agentType,
projectId: run.projectId,
modelOverride,
});

// Extract params from original run
const triggerInput: ManualTriggerInput = {
projectId: run.projectId,
agentType: run.agentType,
cardId: run.cardId ?? undefined,
prNumber: run.prNumber ?? undefined,
modelOverride: modelOverride ?? run.model ?? undefined,
};

// For PR-based agents, we don't store branch/SHA in DB, so we can't restore them.
// The retry will fetch fresh data from GitHub if needed.

await triggerManualRun(triggerInput, project, config);
}
2 changes: 1 addition & 1 deletion src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ export interface AgentInput {
prBranch?: string;
repoFullName?: string;
headSha?: string;
triggerType?: 'check-failure' | 'feature-implementation' | 'ci-success';
triggerType?: 'check-failure' | 'feature-implementation' | 'ci-success' | 'manual';

// Debug agent fields
logDir?: string;
Expand Down
Loading