diff --git a/CLAUDE.md b/CLAUDE.md index bf2a5337..90fe9d3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -278,6 +278,8 @@ cascade runs llm-call cascade runs debug # View debug analysis cascade runs debug --analyze # Trigger new debug analysis cascade runs debug --analyze --wait # Trigger and wait for completion +cascade runs trigger --project --agent-type [--card-id ID] [--model MODEL] +cascade runs retry [--model MODEL] # Projects cascade projects list diff --git a/src/api/routers/runs.ts b/src/api/routers/runs.ts index a3fdebfd..60805fde 100644 --- a/src/api/routers/runs.ts +++ b/src/api/routers/runs.ts @@ -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'; @@ -170,6 +171,120 @@ export const runsRouter = router({ }); }); + return { triggered: true }; + }), + + trigger: protectedProcedure + .input( + z.object({ + projectId: z.string(), + agentType: z.string(), + 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 }; }), }); diff --git a/src/cli/dashboard/runs/retry.ts b/src/cli/dashboard/runs/retry.ts new file mode 100644 index 00000000..10ac5ac2 --- /dev/null +++ b/src/cli/dashboard/runs/retry.ts @@ -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 { + 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); + } + } +} diff --git a/src/cli/dashboard/runs/trigger.ts b/src/cli/dashboard/runs/trigger.ts new file mode 100644 index 00000000..cdd71d98 --- /dev/null +++ b/src/cli/dashboard/runs/trigger.ts @@ -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 { + 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); + } + } +} diff --git a/src/triggers/shared/manual-runner.ts b/src/triggers/shared/manual-runner.ts new file mode 100644 index 00000000..df33708a --- /dev/null +++ b/src/triggers/shared/manual-runner.ts @@ -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(); + +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 { + 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, + config: CascadeConfig, + modelOverride?: string, +): Promise { + 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); +} diff --git a/src/types/index.ts b/src/types/index.ts index bd5b5dfd..752956e7 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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; diff --git a/tests/unit/triggers/manual-runner.test.ts b/tests/unit/triggers/manual-runner.test.ts new file mode 100644 index 00000000..f9c75e9c --- /dev/null +++ b/tests/unit/triggers/manual-runner.test.ts @@ -0,0 +1,276 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('../../../src/agents/registry.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../../../src/db/repositories/runsRepository.js', () => ({ + getRunById: vi.fn(), +})); + +vi.mock('../../../src/utils/logging.js', () => ({ + logger: { + info: vi.fn(), + error: vi.fn(), + }, +})); + +import { runAgent } from '../../../src/agents/registry.js'; +import { getRunById } from '../../../src/db/repositories/runsRepository.js'; +import { + clearTriggerTracking, + isTriggerRunning, + triggerManualRun, + triggerRetryRun, +} from '../../../src/triggers/shared/manual-runner.js'; +import type { CascadeConfig, ProjectConfig } from '../../../src/types/index.js'; + +const mockProject: ProjectConfig = { + id: 'test-project', + name: 'Test', + repo: 'owner/repo', + baseBranch: 'main', + branchPrefix: 'feature/', + trello: { + boardId: 'board-1', + lists: { briefing: 'l1', planning: 'l2', todo: 'l3' }, + labels: {}, + }, +} as unknown as ProjectConfig; + +const mockConfig = {} as CascadeConfig; + +describe('triggerManualRun', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearTriggerTracking(); + }); + + it('throws when trigger is already running for same project+agent+card', async () => { + vi.mocked(runAgent).mockImplementation(() => new Promise(() => {})); // Never resolves + + // Start first trigger (fire-and-forget, so no await) + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + }, + mockProject, + mockConfig, + ); + + // Wait a tick for the trigger to mark itself as running + await new Promise((resolve) => setImmediate(resolve)); + + // Try to trigger again + await expect( + triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + }, + mockProject, + mockConfig, + ), + ).rejects.toThrow('Manual trigger already running'); + }); + + it('calls runAgent with correct input including triggerType: manual', async () => { + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-1', + }); + + await triggerManualRun( + { + projectId: 'test-project', + agentType: 'implementation', + cardId: 'card-1', + modelOverride: 'claude-3-5-sonnet-20241022', + }, + mockProject, + mockConfig, + ); + + // Wait for async execution + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'implementation', + expect.objectContaining({ + cardId: 'card-1', + modelOverride: 'claude-3-5-sonnet-20241022', + triggerType: 'manual', + project: mockProject, + config: mockConfig, + }), + ); + }); + + it('calls runAgent with PR fields when provided', async () => { + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-2', + }); + + await triggerManualRun( + { + projectId: 'test-project', + agentType: 'review', + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', + }, + mockProject, + mockConfig, + ); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ + prNumber: 42, + prBranch: 'feature/test', + repoFullName: 'owner/repo', + headSha: 'abc123', + triggerType: 'manual', + }), + ); + }); + + it('marks trigger as complete after runAgent finishes', async () => { + const projectId = 'test-project'; + const agentType = 'implementation'; + const cardId = 'card-complete'; + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Done', + runId: 'run-complete', + }); + + await triggerManualRun({ projectId, agentType, cardId }, mockProject, mockConfig); + + // Immediately after triggering, should be running + const key = `${projectId}:${agentType}:${cardId}:no-pr`; + expect(isTriggerRunning(key)).toBe(true); + + // Wait for completion + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should be marked complete + expect(isTriggerRunning(key)).toBe(false); + }); + + it('marks trigger as complete even when runAgent fails', async () => { + const projectId = 'test-project'; + const agentType = 'implementation'; + const cardId = 'card-fail'; + + vi.mocked(runAgent).mockRejectedValue(new Error('Agent error')); + + await triggerManualRun({ projectId, agentType, cardId }, mockProject, mockConfig); + + // Should be running + const key = `${projectId}:${agentType}:${cardId}:no-pr`; + expect(isTriggerRunning(key)).toBe(true); + + // Wait for failure + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Should be marked complete + expect(isTriggerRunning(key)).toBe(false); + }); +}); + +describe('triggerRetryRun', () => { + beforeEach(() => { + vi.clearAllMocks(); + clearTriggerTracking(); + }); + + it('throws when run is not found', async () => { + vi.mocked(getRunById).mockResolvedValue(null); + + await expect(triggerRetryRun('run-1', mockProject, mockConfig)).rejects.toThrow( + 'Run not found: run-1', + ); + }); + + it('throws when run has no projectId', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + projectId: null, + } as ReturnType extends Promise ? NonNullable : never); + + await expect(triggerRetryRun('run-1', mockProject, mockConfig)).rejects.toThrow( + 'Run run-1 has no associated project', + ); + }); + + it('extracts params from original run and calls triggerManualRun', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'implementation', + projectId: 'test-project', + cardId: 'card-1', + prNumber: null, + model: 'claude-sonnet-4-5-20250929', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Retried', + runId: 'run-2', + }); + + await triggerRetryRun('run-1', mockProject, mockConfig); + + // Wait for async execution + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'implementation', + expect.objectContaining({ + cardId: 'card-1', + modelOverride: 'claude-sonnet-4-5-20250929', + triggerType: 'manual', + }), + ); + }); + + it('uses modelOverride param if provided, otherwise falls back to original run model', async () => { + vi.mocked(getRunById).mockResolvedValue({ + id: 'run-1', + agentType: 'review', + projectId: 'test-project', + cardId: null, + prNumber: 10, + model: 'claude-sonnet-4-5-20250929', + } as ReturnType extends Promise ? NonNullable : never); + + vi.mocked(runAgent).mockResolvedValue({ + success: true, + output: 'Retried', + runId: 'run-3', + }); + + await triggerRetryRun('run-1', mockProject, mockConfig, 'claude-3-5-sonnet-20241022'); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(runAgent).toHaveBeenCalledWith( + 'review', + expect.objectContaining({ + modelOverride: 'claude-3-5-sonnet-20241022', + }), + ); + }); +});