diff --git a/scripts/task-launch-e2e-config.mjs b/scripts/task-launch-e2e-config.mjs new file mode 100644 index 0000000..ab83b69 --- /dev/null +++ b/scripts/task-launch-e2e-config.mjs @@ -0,0 +1,123 @@ +const DEFAULT_API_BASE_URL_BY_PROTOCOL = { + anthropic: "https://api.anthropic.com", + openai: "https://api.openai.com/v1", + azure: "", + google: "https://generativelanguage.googleapis.com", + ollama: "https://ollama.com", + senseaudio: "https://api.senseaudio.cn", +}; + +const DEFAULT_API_MODEL_BY_PROTOCOL = { + anthropic: "claude-sonnet-4-5", + openai: "gpt-4o", + azure: "gpt-4o", + google: "gemini-2.0-flash", + ollama: "gemma3:4b", + senseaudio: "senseaudio-s2", +}; + +function nonEmpty(value) { + return typeof value === "string" && value.trim() ? value.trim() : null; +} + +function parseMaxTokens(value) { + const parsed = Number(nonEmpty(value)); + return Number.isInteger(parsed) && parsed > 0 ? parsed : 8192; +} + +function parseJsonObject(value) { + const raw = nonEmpty(value); + if (!raw) return null; + try { + const parsed = JSON.parse(raw); + return parsed && typeof parsed === "object" && !Array.isArray(parsed) + ? parsed + : null; + } catch { + return null; + } +} + +function readByokApiKey(env) { + const direct = nonEmpty(env.DEVLOG_E2E_API_KEY); + if (direct) return direct; + + const envVarName = + nonEmpty(env.DEVLOG_E2E_API_KEY_ENV_VAR) ?? + nonEmpty(env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR) ?? + "ANTHROPIC_API_KEY"; + return nonEmpty(env[envVarName]); +} + +export function buildTaskLaunchRuntimePayload(env = process.env) { + const mode = nonEmpty(env.DEVLOG_E2E_AUTH_MODE) ?? "local-cli"; + const model = nonEmpty(env.DEVLOG_E2E_AGENT_MODEL); + + if (mode === "agent-api-key") { + return { + session_auth_mode: "agent-api-key", + agent_api_key_env_var: + nonEmpty(env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR) ?? "ANTHROPIC_API_KEY", + agent_model: model ?? "claude-sonnet-4-6", + }; + } + + if (mode === "anthropic-api-key") { + const protocol = nonEmpty(env.DEVLOG_E2E_API_PROTOCOL) ?? "anthropic"; + const apiKey = readByokApiKey(env); + return { + session_auth_mode: "anthropic-api-key", + agent_api_protocol: protocol, + agent_model: + model ?? + DEFAULT_API_MODEL_BY_PROTOCOL[protocol] ?? + "claude-sonnet-4-5", + agent_base_url: + nonEmpty(env.DEVLOG_E2E_AGENT_BASE_URL) ?? + DEFAULT_API_BASE_URL_BY_PROTOCOL[protocol] ?? + "", + agent_api_version: nonEmpty(env.DEVLOG_E2E_AGENT_API_VERSION) ?? "", + agent_max_tokens: parseMaxTokens(env.DEVLOG_E2E_AGENT_MAX_TOKENS), + ...(apiKey ? { anthropic_api_key: apiKey } : {}), + }; + } + + const localCliEnv = parseJsonObject(env.DEVLOG_E2E_LOCAL_CLI_ENV_JSON); + return { + session_auth_mode: "local-cli", + local_cli_agent_id: + nonEmpty(env.DEVLOG_E2E_LOCAL_CLI_AGENT_ID) ?? + nonEmpty(env.DEVLOG_E2E_LOCAL_CLI_AGENT) ?? + "claude", + agent_model: model ?? "default", + agent_reasoning: nonEmpty(env.DEVLOG_E2E_AGENT_REASONING) ?? "medium", + ...(localCliEnv ? { local_cli_agent_env: localCliEnv } : {}), + }; +} + +export function describeTaskLaunchRuntimePayload(payload) { + if (payload.session_auth_mode === "anthropic-api-key") { + return [ + "mode=anthropic-api-key", + `protocol=${payload.agent_api_protocol}`, + `model=${payload.agent_model}`, + `baseUrl=${payload.agent_base_url}`, + `key=${payload.anthropic_api_key ? "provided" : "missing"}`, + ].join(" "); + } + + if (payload.session_auth_mode === "agent-api-key") { + return [ + "mode=agent-api-key", + `keyEnv=${payload.agent_api_key_env_var ? "configured" : "missing"}`, + `model=${payload.agent_model}`, + ].join(" "); + } + + return [ + "mode=local-cli", + `agent=${payload.local_cli_agent_id}`, + `model=${payload.agent_model}`, + `reasoning=${payload.agent_reasoning}`, + ].join(" "); +} diff --git a/scripts/task-launch-e2e.mjs b/scripts/task-launch-e2e.mjs index 7784870..839f0dd 100644 --- a/scripts/task-launch-e2e.mjs +++ b/scripts/task-launch-e2e.mjs @@ -1,5 +1,10 @@ #!/usr/bin/env node +import { + buildTaskLaunchRuntimePayload, + describeTaskLaunchRuntimePayload, +} from "./task-launch-e2e-config.mjs"; + const baseUrl = (process.env.DEVLOG_E2E_BASE_URL ?? "http://localhost:3000") .replace(/\/$/, ""); const projectId = process.env.DEVLOG_E2E_PROJECT_ID ?? "devlog"; @@ -8,10 +13,7 @@ const codingAgentId = process.env.DEVLOG_E2E_CODING_AGENT_ID ?? "general-coding-agent"; const agentTeamId = process.env.DEVLOG_E2E_AGENT_TEAM_ID ?? "implementation-review-team"; -const sessionAuthMode = - process.env.DEVLOG_E2E_AUTH_MODE ?? "backend-oauth"; -const agentApiKeyEnvVar = - process.env.DEVLOG_E2E_AGENT_API_KEY_ENV_VAR ?? "ANTHROPIC_API_KEY"; +const runtimePayload = buildTaskLaunchRuntimePayload(process.env); const startedAt = Date.now(); const marker = `DEVLOG_E2E_${Date.now()}`; @@ -92,6 +94,7 @@ async function watchSse(sessionId, evidence, signal) { async function main() { console.log(`DevLog task launch E2E against ${baseUrl} project=${projectId}`); + console.log(`runtime ${describeTaskLaunchRuntimePayload(runtimePayload)}`); const task = await request("/api/tasks", { method: "POST", @@ -114,8 +117,7 @@ async function main() { body: JSON.stringify({ coding_agent_id: codingAgentId, agent_team_id: agentTeamId, - session_auth_mode: sessionAuthMode, - agent_api_key_env_var: agentApiKeyEnvVar, + ...runtimePayload, }), }); const session = launched.session; @@ -147,6 +149,7 @@ async function main() { "Human follow-up E2E instruction.", `Reply with exactly ${followupAck}.`, ].join(" "), + ...runtimePayload, }), }); if (!("queue_length" in patched) || !("is_processing" in patched)) { @@ -164,7 +167,7 @@ async function main() { .join("\n"); if (/Failed to authenticate|API Error/i.test(assistantText)) { throw new Error( - "Claude session authentication failed. Use backend OAuth on a logged-in Claude Code runtime or set DEVLOG_E2E_AUTH_MODE=agent-api-key with a backend env var such as ANTHROPIC_API_KEY.", + "Session authentication failed. Use DEVLOG_E2E_AUTH_MODE=local-cli with a usable local coding agent, DEVLOG_E2E_AUTH_MODE=anthropic-api-key with DEVLOG_E2E_API_KEY, or explicitly use DEVLOG_E2E_AUTH_MODE=agent-api-key with a backend env var such as ANTHROPIC_API_KEY.", ); } if (assistantText.includes(followupAck)) return rows; diff --git a/src/app/api/sessions/[id]/stream/route.ts b/src/app/api/sessions/[id]/stream/route.ts index a5962e7..acb80af 100644 --- a/src/app/api/sessions/[id]/stream/route.ts +++ b/src/app/api/sessions/[id]/stream/route.ts @@ -1,72 +1,137 @@ import { NextRequest } from "next/server"; import { getDb } from "@/core/db"; +import { filterBufferedReplayDuplicates } from "@/core/session-stream-dedupe"; +import { buildSessionStreamReplayEvents } from "@/core/session-stream-replay"; import { streamManager } from "@/core/stream-manager"; -import type { ChatMessage } from "@/core/types-dashboard"; +import { resolveProjectId } from "@/lib/api-utils"; +import type { ChatStreamEvent } from "@/core/stream-manager"; export async function GET( _req: NextRequest, { params }: { params: Promise<{ id: string }> } ) { const { id } = await params; + const projectId = resolveProjectId(_req); + const db = getDb(); + const ownedSession = db + .prepare("SELECT 1 FROM sessions WHERE id = ? AND project_id = ? LIMIT 1") + .get(id, projectId); + + if (!ownedSession) { + return new Response("Forbidden", { status: 403 }); + } const encoder = new TextEncoder(); + let cleanupStream = () => {}; const stream = new ReadableStream({ start(controller) { - // Replay persisted messages so the frontend catches up - try { - const db = getDb(); - const messages = db - .prepare( - "SELECT * FROM session_messages WHERE session_id = ? ORDER BY id ASC" - ) - .all(id) as ChatMessage[]; - - for (const msg of messages) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ type: "message", role: msg.role, content: msg.content })}\n\n` - ) - ); - } - } catch { - // DB might not be ready - } + const liveEventBuffer: ChatStreamEvent[] = []; + let bufferingLiveEvents = true; + let heartbeat: ReturnType | undefined; + let unsubscribe = () => {}; + let cleanedUp = false; + let abortHandler: (() => void) | undefined; - // Signal replay complete - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ type: "sync" })}\n\n`) - ); - - // Subscribe to live events - const unsubscribe = streamManager.subscribe(id, (event) => { - try { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify(event)}\n\n`) - ); - } catch { - unsubscribe(); + const cleanup = () => { + if (cleanedUp) { + return; } - }); + cleanedUp = true; + if (heartbeat) { + clearInterval(heartbeat); + } + if (abortHandler) { + _req.signal.removeEventListener("abort", abortHandler); + } + unsubscribe(); + }; + cleanupStream = cleanup; - // Heartbeat every 15s - const heartbeat = setInterval(() => { + const safeEnqueue = (chunk: string) => { try { - controller.enqueue(encoder.encode(`: heartbeat\n\n`)); + controller.enqueue(encoder.encode(chunk)); + return true; } catch { - clearInterval(heartbeat); - unsubscribe(); + cleanup(); + return false; } - }, 15000); + }; - _req.signal.addEventListener("abort", () => { - clearInterval(heartbeat); - unsubscribe(); + const enqueueEvent = (event: ChatStreamEvent) => { + return safeEnqueue(`data: ${JSON.stringify(event)}\n\n`); + }; + + abortHandler = () => { + cleanup(); try { controller.close(); } catch { // already closed } + }; + _req.signal.addEventListener("abort", abortHandler); + + if (_req.signal.aborted) { + cleanup(); + return; + } + + // Subscribe before replay so live events are not lost during catch-up. + unsubscribe = streamManager.subscribe(id, (event) => { + if (bufferingLiveEvents) { + liveEventBuffer.push(event); + return; + } + + enqueueEvent(event); }); + + if (_req.signal.aborted) { + cleanup(); + return; + } + + // Replay persisted messages so the frontend catches up + let replayEvents: ChatStreamEvent[] = []; + try { + replayEvents = buildSessionStreamReplayEvents(db, id); + + for (const event of replayEvents) { + if (!enqueueEvent(event)) { + return; + } + } + } catch (error) { + // DB might not be ready + console.warn("Failed to replay session stream events", { + sessionId: id, + error, + }); + } + + // Signal replay complete + if (!safeEnqueue(`data: ${JSON.stringify({ type: "sync" })}\n\n`)) { + return; + } + + const eventsToDrain = filterBufferedReplayDuplicates( + replayEvents, + liveEventBuffer, + ); + for (const event of eventsToDrain) { + if (!enqueueEvent(event)) { + return; + } + } + bufferingLiveEvents = false; + + // Heartbeat every 15s + heartbeat = setInterval(() => { + safeEnqueue(": heartbeat\n\n"); + }, 15000); + }, + cancel() { + cleanupStream(); }, }); diff --git a/src/app/api/sessions/route.ts b/src/app/api/sessions/route.ts index 94370d8..53a1437 100644 --- a/src/app/api/sessions/route.ts +++ b/src/app/api/sessions/route.ts @@ -14,8 +14,13 @@ import { import { buildSessionRuntimeAuthInstructions, getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, resolveSessionRuntimeAuthConfig, } from "@/core/session-runtime-auth"; +import { + markSessionFailedAndReleaseLinkedTask, + validateTaskSessionLaunch, +} from "@/core/task-lifecycle"; import type { Session } from "@/core/types-dashboard"; export async function GET(req: NextRequest) { @@ -37,35 +42,60 @@ export async function POST(req: NextRequest) { body = {}; } + const bodyRecord = + body && typeof body === "object" && !Array.isArray(body) + ? (body as Record) + : {}; const { task_id, worktree_name, worktree_path, branch_name, prompt, - } = body as Record; - if (!prompt) { + } = bodyRecord; + if (typeof prompt !== "string" || !prompt.trim()) { return NextResponse.json({ error: "prompt is required" }, { status: 400 }); } - if (!worktree_path) { + if (typeof worktree_path !== "string" || !worktree_path.trim()) { return NextResponse.json({ error: "worktree_path is required" }, { status: 400 }); } + const promptText = prompt.trim(); + const worktreePath = worktree_path.trim(); + const taskId = + typeof task_id === "string" && task_id.trim().length > 0 + ? task_id.trim() + : null; + if (task_id != null && !taskId) { + return NextResponse.json( + { error: "task_id must be a non-empty string" }, + { status: 400 }, + ); + } const id = randomBytes(8).toString("hex"); const agentConfig = resolveAgentExecutionConfig( - getAgentExecutionInputFromPayload(body), + getAgentExecutionInputFromPayload(bodyRecord), ); - const runtimeAuthInput = getSessionRuntimeAuthInputFromPayload(body); + const runtimeAuthInput = getSessionRuntimeAuthInputFromPayload(bodyRecord); const runtimeAuthConfig = resolveSessionRuntimeAuthConfig(runtimeAuthInput); const preflight = validateSessionRuntimeProcessLaunch( runtimeAuthConfig, - String(worktree_path), + worktreePath, ); if (!preflight.ok) { return NextResponse.json({ error: preflight.error }, { status: 400 }); } + if (taskId) { + const taskLaunch = validateTaskSessionLaunch(db, taskId, projectId); + if (!taskLaunch.ok) { + return NextResponse.json( + { error: taskLaunch.error }, + { status: taskLaunch.status }, + ); + } + } const sessionPrompt = [ - String(prompt).trim(), + promptText, "", "## Agent Execution", buildAgentExecutionInstructions(agentConfig), @@ -81,16 +111,16 @@ export async function POST(req: NextRequest) { agent_reasoning, agent_api_protocol, agent_api_version, agent_base_url, agent_max_tokens, prompt ) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( id, projectId, - task_id ?? null, - worktree_name ?? null, - worktree_path, - branch_name ?? null, + taskId, + typeof worktree_name === "string" ? worktree_name : null, + worktreePath, + typeof branch_name === "string" ? branch_name : null, agentConfig.codingAgent.id, agentConfig.agentTeam.id, runtimeAuthConfig.mode, @@ -100,17 +130,16 @@ export async function POST(req: NextRequest) { runtimeAuthConfig.reasoning, runtimeAuthConfig.apiProtocol, runtimeAuthConfig.apiVersion, - runtimeAuthConfig.baseUrl, + getPersistedSessionBaseUrl(runtimeAuthConfig), runtimeAuthConfig.maxTokens, sessionPrompt ) as Session; // Link session to task if provided - if (task_id) { - db.prepare("UPDATE tasks SET session_id = ?, status = 'in_progress' WHERE id = ?").run( - id, - task_id - ); + if (taskId) { + db.prepare( + "UPDATE tasks SET session_id = ?, status = 'in_progress', fail_reason = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ? AND project_id = ?", + ).run(id, taskId, projectId); } // Send the initial prompt as the first turn @@ -118,9 +147,11 @@ export async function POST(req: NextRequest) { // Don't await — let it process in the background processManager.sendMessage(id, sessionPrompt, runtimeAuthInput); } catch (err) { - db.prepare( - "UPDATE sessions SET status = 'failed', ended_at = datetime('now') WHERE id = ?" - ).run(id); + markSessionFailedAndReleaseLinkedTask( + db, + id, + `Failed to start: ${(err as Error).message}`, + ); return NextResponse.json( { error: `Failed to start: ${(err as Error).message}` }, { status: 500 } diff --git a/src/app/api/tasks/[id]/execute/route.ts b/src/app/api/tasks/[id]/execute/route.ts index 7b06c87..0fa39ea 100644 --- a/src/app/api/tasks/[id]/execute/route.ts +++ b/src/app/api/tasks/[id]/execute/route.ts @@ -10,13 +10,19 @@ import { } from "@/core/process-manager"; import { fileWatcher } from "@/core/file-watcher"; import { hasTaskPrompt } from "@/core/task-readiness"; -import { slugify, buildPromptTemplate } from "@/core/task-lifecycle"; +import { + markSessionFailedAndReleaseLinkedTask, + slugify, + buildPromptTemplate, +} from "@/core/task-lifecycle"; +import { isTaskExecutableStatus } from "@/core/task-status-flow"; import { getAgentExecutionInputFromPayload, resolveAgentExecutionConfig, } from "@/core/agent-presets"; import { getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, resolveSessionRuntimeAuthConfig, } from "@/core/session-runtime-auth"; import type { Task, Session } from "@/core/types-dashboard"; @@ -54,7 +60,7 @@ export async function POST( { status: 400 } ); } - if (task.status !== "todo" && task.status !== "blocked") { + if (!isTaskExecutableStatus(task.status)) { return NextResponse.json( { error: `Cannot execute task with status '${task.status}'` }, { status: 400 } @@ -122,7 +128,7 @@ export async function POST( agent_reasoning, agent_api_protocol, agent_api_version, agent_base_url, agent_max_tokens, prompt ) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( @@ -141,14 +147,14 @@ export async function POST( runtimeAuthConfig.reasoning, runtimeAuthConfig.apiProtocol, runtimeAuthConfig.apiVersion, - runtimeAuthConfig.baseUrl, + getPersistedSessionBaseUrl(runtimeAuthConfig), runtimeAuthConfig.maxTokens, prompt ) as Session; // 4. Update task db.prepare( - "UPDATE tasks SET status = 'in_progress', worktree_name = ?, session_id = ?, updated_at = datetime('now') WHERE id = ?" + "UPDATE tasks SET status = 'in_progress', worktree_name = ?, session_id = ?, fail_reason = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?" ).run(worktreeName, sessionId, taskId); // 5. Start file watcher @@ -162,9 +168,11 @@ export async function POST( try { processManager.sendMessage(sessionId, prompt, runtimeAuthInput); } catch (err) { - db.prepare( - "UPDATE sessions SET status = 'failed', ended_at = datetime('now') WHERE id = ?" - ).run(sessionId); + markSessionFailedAndReleaseLinkedTask( + db, + sessionId, + `Failed to start agent: ${(err as Error).message}`, + ); return NextResponse.json( { error: `Failed to start agent: ${(err as Error).message}` }, { status: 500 } diff --git a/src/app/api/tasks/[id]/retry/route.ts b/src/app/api/tasks/[id]/retry/route.ts index d8ab488..6e2d28d 100644 --- a/src/app/api/tasks/[id]/retry/route.ts +++ b/src/app/api/tasks/[id]/retry/route.ts @@ -4,9 +4,22 @@ import { getDb } from "@/core/db"; import { resolveProjectId } from "@/lib/api-utils"; import { getProject } from "@/core/project-adapter"; import { listWorktrees } from "@/core/worktree-manager"; -import { processManager } from "@/core/process-manager"; +import { + processManager, + validateSessionRuntimeProcessLaunch, +} from "@/core/process-manager"; import { compileSession } from "@/core/vcc"; -import { buildPromptTemplate } from "@/core/task-lifecycle"; +import { markSessionFailedAndReleaseLinkedTask } from "@/core/task-lifecycle"; +import { buildTaskRetryPrompt } from "@/core/task-retry"; +import { + getAgentExecutionInputFromPayload, + resolveAgentExecutionConfig, +} from "@/core/agent-presets"; +import { + getSessionRuntimeAuthInputFromPayload, + getPersistedSessionBaseUrl, + resolveSessionRuntimeAuthConfig, +} from "@/core/session-runtime-auth"; import type { Task, Session } from "@/core/types-dashboard"; export async function POST( @@ -16,7 +29,17 @@ export async function POST( const { id: taskId } = await params; const db = getDb(); const projectId = resolveProjectId(req); - const { feedback } = (await req.json()) as { feedback: string }; + let payload: unknown; + try { + payload = await req.json(); + } catch { + payload = {}; + } + const record = + payload && typeof payload === "object" && !Array.isArray(payload) + ? (payload as Record) + : {}; + const feedback = typeof record.feedback === "string" ? record.feedback : ""; if (!feedback?.trim()) { return NextResponse.json({ error: "Feedback is required" }, { status: 400 }); @@ -48,6 +71,18 @@ export async function POST( } const project = getProject(projectId); + const agentConfig = resolveAgentExecutionConfig( + getAgentExecutionInputFromPayload(payload), + ); + const runtimeAuthInput = getSessionRuntimeAuthInputFromPayload(payload); + const runtimeAuthConfig = resolveSessionRuntimeAuthConfig(runtimeAuthInput); + const preflight = validateSessionRuntimeProcessLaunch( + runtimeAuthConfig, + wt.path, + ); + if (!preflight.ok) { + return NextResponse.json({ error: preflight.error }, { status: 400 }); + } // 3. Get previous session brief for context let previousBrief = ""; @@ -70,32 +105,29 @@ export async function POST( } // 4. Build retry prompt - const basePrompt = buildPromptTemplate( + const retryPrompt = buildTaskRetryPrompt({ task, project, - wt.path, - wt.branch - ); - const retryPrompt = [ - basePrompt, - "", - "## Previous Attempt Feedback", - feedback, - previousBrief - ? `\n## Previous Session Summary\n\`\`\`\n${previousBrief}\n\`\`\`` - : "", - "", - "Address the feedback above and complete the task.", - ] - .filter(Boolean) - .join("\n"); + worktreePath: wt.path, + branchName: wt.branch, + feedback: feedback.trim(), + previousBrief, + agentConfig, + runtimeAuthConfig, + }); // 5. Create new session const sessionId = randomBytes(8).toString("hex"); const session = db .prepare( - `INSERT INTO sessions (id, project_id, task_id, worktree_name, worktree_path, branch_name, status, prompt) - VALUES (?, ?, ?, ?, ?, ?, 'running', ?) + `INSERT INTO sessions ( + id, project_id, task_id, worktree_name, worktree_path, branch_name, + status, coding_agent_id, agent_team_id, session_auth_mode, + agent_api_key_env_var, local_cli_agent_id, agent_model, + agent_reasoning, agent_api_protocol, agent_api_version, + agent_base_url, agent_max_tokens, prompt + ) + VALUES (?, ?, ?, ?, ?, ?, 'running', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING *` ) .get( @@ -105,21 +137,34 @@ export async function POST( task.worktree_name, wt.path, wt.branch, + agentConfig.codingAgent.id, + agentConfig.agentTeam.id, + runtimeAuthConfig.mode, + runtimeAuthConfig.agentApiKeyEnvVar, + runtimeAuthConfig.localCliAgentId, + runtimeAuthConfig.model, + runtimeAuthConfig.reasoning, + runtimeAuthConfig.apiProtocol, + runtimeAuthConfig.apiVersion, + getPersistedSessionBaseUrl(runtimeAuthConfig), + runtimeAuthConfig.maxTokens, retryPrompt ) as Session; // 6. Update task db.prepare( - "UPDATE tasks SET status = 'in_progress', session_id = ?, updated_at = datetime('now') WHERE id = ?" + "UPDATE tasks SET status = 'in_progress', session_id = ?, fail_reason = NULL, completed_at = NULL, updated_at = datetime('now') WHERE id = ?" ).run(sessionId, taskId); // 7. Spawn agent try { - processManager.sendMessage(sessionId, retryPrompt); + processManager.sendMessage(sessionId, retryPrompt, runtimeAuthInput); } catch (err) { - db.prepare( - "UPDATE sessions SET status = 'failed', ended_at = datetime('now') WHERE id = ?" - ).run(sessionId); + markSessionFailedAndReleaseLinkedTask( + db, + sessionId, + `Failed to start: ${(err as Error).message}`, + ); return NextResponse.json( { error: `Failed to start: ${(err as Error).message}` }, { status: 500 } diff --git a/src/app/sessions/[id]/page.tsx b/src/app/sessions/[id]/page.tsx index 9eaf3fc..70e9688 100644 --- a/src/app/sessions/[id]/page.tsx +++ b/src/app/sessions/[id]/page.tsx @@ -167,7 +167,11 @@ export default function SessionDetailPage() { {/* Tab content */}
- +
diff --git a/src/components/dashboard/task-quick-view.tsx b/src/components/dashboard/task-quick-view.tsx index 1a77617..a05df26 100644 --- a/src/components/dashboard/task-quick-view.tsx +++ b/src/components/dashboard/task-quick-view.tsx @@ -10,6 +10,7 @@ import { CreateTaskDialog } from "@/components/kanban/create-task-dialog"; import { TaskDetailDialog } from "@/components/kanban/task-detail-dialog"; import { useTasks } from "@/hooks/use-tasks"; import type { Task, TaskStatus } from "@/core/types-dashboard"; +import type { SessionRuntimeAuthInput } from "@/core/session-runtime-auth"; import { cn } from "@/core/dashboard-utils"; const QUICK_COLUMNS: TaskStatus[] = ["todo", "in_progress"]; @@ -125,9 +126,15 @@ export function TaskQuickView() { task: Task, worktreePath: string, worktreeName?: string, - branchName?: string + branchName?: string, + agentConfig?: { + coding_agent_id: string; + agent_team_id: string; + } & SessionRuntimeAuthInput, + promptOverride?: string, ): Promise<{ id: string } | null> => { - if (!task.prompt) return null; + const prompt = promptOverride?.trim() || task.prompt; + if (!prompt) return null; const res = await fetch("/api/sessions", { method: "POST", headers: { "Content-Type": "application/json" }, @@ -136,7 +143,8 @@ export function TaskQuickView() { worktree_name: worktreeName, worktree_path: worktreePath, branch_name: branchName, - prompt: task.prompt, + prompt, + ...agentConfig, }), }); if (res.ok) { diff --git a/src/components/kanban/board.tsx b/src/components/kanban/board.tsx index 02f74ac..fdf5d36 100644 --- a/src/components/kanban/board.tsx +++ b/src/components/kanban/board.tsx @@ -5,7 +5,7 @@ import Link from "next/link"; import { useRouter } from "next/navigation"; import { DragDropContext, type DropResult } from "@hello-pangea/dnd"; import { FileText } from "lucide-react"; -import { KanbanColumn } from "./column"; +import { KanbanColumn, KanbanGroupedColumn } from "./column"; import { CreateTaskDialog } from "./create-task-dialog"; import { TaskDetailDialog } from "./task-detail-dialog"; import { useTasks } from "@/hooks/use-tasks"; @@ -13,24 +13,31 @@ import { useTaskSessions } from "@/hooks/use-task-sessions"; import { useAgentSettings } from "@/hooks/use-agent-settings"; import type { Task, TaskStatus } from "@/core/types-dashboard"; import type { SessionRuntimeAuthInput } from "@/core/session-runtime-auth"; +import { getTaskBoardColumns } from "@/core/task-status-flow"; import { Button } from "@/components/ui/button"; import { Skeleton } from "@/components/ui/skeleton"; -const COLUMNS: TaskStatus[] = ["todo", "in_progress", "review", "blocked", "done"]; +const COLUMNS = getTaskBoardColumns(); export function KanbanBoard() { const { loading, tasksByStatus, createTask, updateTask, deleteTask, reorder, executeTask } = useTasks(); const taskSessions = useTaskSessions(); - const { runtimePayload, byokReady } = useAgentSettings(); + const { runtimePayload, byokReady, loaded, settingsReady } = useAgentSettings(); const router = useRouter(); const [selectedTask, setSelectedTask] = useState(null); const [detailOpen, setDetailOpen] = useState(false); const handleExecuteTask = async (taskId: string) => { + if (!loaded) { + return; + } if (!byokReady) { router.push("/settings"); return; } + if (!settingsReady) { + return; + } const result = await executeTask(taskId, runtimePayload); if (result?.session) { router.push(`/sessions/${result.session.id}`); @@ -121,8 +128,8 @@ export function KanbanBoard() { if (loading) { return (
- {COLUMNS.map((col) => ( - + {COLUMNS.map((column) => ( + ))}
); @@ -141,18 +148,34 @@ export function KanbanBoard() {
- {COLUMNS.map((status) => ( - - ))} + {COLUMNS.map((column) => + column.type === "group" ? ( + ({ + status, + tasks: tasksByStatus(status), + }))} + taskSessions={taskSessions} + onDeleteTask={deleteTask} + onClickTask={handleClickTask} + onExecuteTask={handleExecuteTask} + onPauseTask={handlePauseTask} + /> + ) : ( + + ), + )}
diff --git a/src/components/kanban/column.tsx b/src/components/kanban/column.tsx index 641233c..f6a1384 100644 --- a/src/components/kanban/column.tsx +++ b/src/components/kanban/column.tsx @@ -26,7 +26,55 @@ interface KanbanColumnProps { onPauseTask?: (sessionId: string) => void; } -export function KanbanColumn({ status, tasks, taskSessions, onDeleteTask, onClickTask, onExecuteTask, onPauseTask }: KanbanColumnProps) { +interface KanbanGroupedColumnProps + extends Omit { + label: string; + sections: { status: TaskStatus; tasks: Task[] }[]; +} + +function KanbanTaskSection({ + status, + tasks, + taskSessions, + onDeleteTask, + onClickTask, + onExecuteTask, + onPauseTask, + compact = false, +}: KanbanColumnProps & { compact?: boolean }) { + return ( + + {(provided, snapshot) => ( +
+ {tasks.map((task, index) => ( + + ))} + {provided.placeholder} +
+ )} +
+ ); +} + +export function KanbanColumn(props: KanbanColumnProps) { + const { status, tasks } = props; const config = COLUMN_CONFIG[status]; return ( @@ -38,32 +86,59 @@ export function KanbanColumn({ status, tasks, taskSessions, onDeleteTask, onClic {tasks.length} - - {(provided, snapshot) => ( -
- {tasks.map((task, index) => ( - +
+ ); +} + +export function KanbanGroupedColumn({ + label, + sections, + taskSessions, + onDeleteTask, + onClickTask, + onExecuteTask, + onPauseTask, +}: KanbanGroupedColumnProps) { + const totalTasks = sections.reduce((sum, section) => sum + section.tasks.length, 0); + + return ( +
+
+ + +

{label}

+ + {totalTasks} + +
+
+ {sections.map(({ status, tasks }) => { + const config = COLUMN_CONFIG[status]; + + return ( +
+
+ + {config.label} + + {tasks.length} + +
+ - ))} - {provided.placeholder} -
- )} - + + ); + })} +
); } diff --git a/src/components/kanban/task-card-actions.ts b/src/components/kanban/task-card-actions.ts index 6f5cccd..a9ae186 100644 --- a/src/components/kanban/task-card-actions.ts +++ b/src/components/kanban/task-card-actions.ts @@ -1,9 +1,10 @@ import { isActiveSessionStatus } from "@/core/task-readiness"; +import { isTaskExecutableStatus } from "@/core/task-status-flow"; import type { Session, Task } from "@/core/types-dashboard"; export function canExecuteTaskFromCard(task: Task, session?: Session): boolean { return ( - (task.status === "todo" || task.status === "blocked") && + isTaskExecutableStatus(task.status) && !isActiveSessionStatus(session?.status) ); } diff --git a/src/components/kanban/task-detail-dialog.tsx b/src/components/kanban/task-detail-dialog.tsx index 44357b0..896ec33 100644 --- a/src/components/kanban/task-detail-dialog.tsx +++ b/src/components/kanban/task-detail-dialog.tsx @@ -28,6 +28,7 @@ import { Loader2, GitBranch, Terminal, + AlertCircle, } from "lucide-react"; import { DEFAULT_AGENT_TEAM_ID, @@ -35,6 +36,7 @@ import { } from "@/core/agent-presets"; import { AgentSelector } from "@/components/sessions/agent-selector"; import { useAgentSettings } from "@/hooks/use-agent-settings"; +import { isTaskExecutableStatus } from "@/core/task-status-flow"; import type { SessionRuntimeAuthInput } from "@/core/session-runtime-auth"; import type { Task, TaskPriority, TaskStatus, Worktree } from "@/core/types-dashboard"; import { cn } from "@/core/dashboard-utils"; @@ -94,7 +96,8 @@ export function TaskDetailDialog({ const [selectedWorktree, setSelectedWorktree] = useState(""); const [codingAgentId, setCodingAgentId] = useState(DEFAULT_CODING_AGENT_ID); const [agentTeamId, setAgentTeamId] = useState(DEFAULT_AGENT_TEAM_ID); - const { settings, runtimePayload, byokReady } = useAgentSettings(); + const { settings, runtimePayload, byokReady, loaded, settingsReady } = + useAgentSettings(); // Reset form when task changes useEffect(() => { @@ -139,6 +142,8 @@ export function TaskDetailDialog({ }; const handleLaunch = async () => { + if (!settingsReady) return; + const taskPrompt = prompt.trim() || task.prompt; if (!taskPrompt) return; @@ -174,6 +179,7 @@ export function TaskDetailDialog({ if (!dateStr) return null; return new Date(dateStr).toLocaleString(); }; + const canLaunchSession = isTaskExecutableStatus(task.status); return ( @@ -263,11 +269,11 @@ export function TaskDetailDialog({ {/* Prompt */} {editing ? (
- +