import { Router, type Request, type Response, type NextFunction } from "express"; import multer from "multer"; import { createReadStream, existsSync } from "node:fs"; import { mkdtemp } from "node:fs/promises"; import { execSync } from "node:child_process"; import { resolve, sep, join } from "node:path"; import { tmpdir } from "node:os"; import * as nodeFs from "node:fs"; import * as nodeChildProcess from "node:child_process"; import type { TaskStore, Column, MergeResult, ScheduleType, ActivityEventType, ModelPreset, AutomationStep, MessageType, ParticipantType, MessageCreateInput } from "@fusion/core"; import { COLUMNS, VALID_TRANSITIONS, GLOBAL_SETTINGS_KEYS, type BatchStatusEntry, type BatchStatusResponse, type BatchStatusResult, type IssueInfo, type PrInfo, type Task, getCurrentRepo, isGhAuthenticated, AUTOMATION_PRESETS, AutomationStore, validateBackupSchedule, validateBackupRetention, validateBackupDir, syncBackupAutomation, exportSettings, importSettings, validateImportData, MessageStore, MEMORY_FILE_PATH } from "@fusion/core"; import type { ChatStore, ChatSessionCreateInput, ChatSessionUpdateInput } from "@fusion/core"; import type { ServerOptions } from "./server.js"; import { GitHubClient, parseBadgeUrl } from "./github.js"; import { githubRateLimiter } from "./github-poll.js"; import { terminalSessionManager } from "./terminal.js"; import { getTerminalService } from "./terminal-service.js"; import { listFiles, readFile, writeFile, listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, readProjectFile, writeProjectFile, FileServiceError, type FileListResponse, type FileContentResponse, type SaveFileResponse, type FileOperationResponse } from "./file-service.js"; import { fetchAllProviderUsage } from "./usage.js"; import { getGitHubAppConfig, verifyWebhookSignature, classifyWebhookEvent, isSameResource, hasPrBadgeFieldsChanged, hasIssueBadgeFieldsChanged, type BadgeUrlComponents, } from "./github-webhooks.js"; import { createMissionRouter } from "./mission-routes.js"; import { createPluginRouter } from "./plugin-routes.js"; import { getOrCreateProjectStore } from "./project-store-resolver.js"; import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js"; import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js"; import { getSubtaskSession, cleanupSubtaskSession } from "./subtask-breakdown.js"; import { startAgentGeneration, generateAgentSpec, getAgentGenerationSession, cleanupAgentGenerationSession, RateLimitError as AgentGenerationRateLimitError, SessionNotFoundError as AgentGenerationSessionNotFoundError, } from "./agent-generation.js"; import { getMissionInterviewSession, cleanupMissionInterviewSession } from "./mission-interview.js"; import { writeSSEEvent } from "./sse-buffer.js"; import { ApiError, badRequest, conflict, internalError, notFound, rateLimited, sendErrorResponse, unauthorized, } from "./api-error.js"; import { rateLimit, RATE_LIMITS } from "./rate-limit.js"; /** * Minimal interface matching pi-coding-agent's ModelRegistry API surface * used by the models route. Avoids a direct dependency on the pi-coding-agent package. */ export interface ModelRegistryLike { /** Reload models from disk to pick up changes. */ refresh(): void; /** Get models that have auth configured. */ getAvailable(): Array<{ id: string; name: string; provider: string; reasoning: boolean; contextWindow: number }>; } /** * Minimal interface matching pi-coding-agent's AuthStorage API surface * used by the auth routes. Avoids a direct dependency on the pi-coding-agent package. */ export interface AuthStorageLike { reload(): void; getOAuthProviders(): Array<{ id: string; name: string }>; hasAuth(provider: string): boolean; login( providerId: string, callbacks: { onAuth: (info: { url: string; instructions?: string }) => void; onPrompt: (prompt: { message: string; placeholder?: string; allowEmpty?: boolean }) => Promise; onProgress?: (message: string) => void; signal?: AbortSignal; }, ): Promise; logout(provider: string): void; /** Get providers that accept API keys (non-OAuth). Returns provider id and name. */ getApiKeyProviders?(): Array<{ id: string; name: string }>; /** Save an API key for a provider. Creates or overwrites the existing key. */ setApiKey?(providerId: string, apiKey: string): void; /** Remove the stored API key for a provider. No-op if not set. */ clearApiKey?(providerId: string): void; /** Check if a provider has an API key configured. */ hasApiKey?(providerId: string): boolean; } const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024 }, // 5MB }); // Dynamic import fallback for @fusion/engine with injectable override for tests. // eslint-disable-next-line @typescript-eslint/no-explicit-any let createKbAgentForRefine: any; /** @internal Inject a mock createKbAgent function for workflow-step refine route tests. */ export function __setCreateKbAgentForRefine(mock: typeof createKbAgentForRefine): void { createKbAgentForRefine = mock; } function validateOptionalModelField(value: unknown, name: string): string | undefined { if (value === undefined || value === null) return undefined; if (typeof value !== "string") { throw new Error(`${name} must be a string`); } const trimmed = value.trim(); return trimmed.length > 0 ? trimmed : undefined; } function normalizeModelSelectionPair(provider: string | undefined, modelId: string | undefined) { if (!provider || !modelId) { return { provider: undefined, modelId: undefined }; } return { provider, modelId }; } function assertConsistentOptionalPair( provider: unknown, modelId: unknown, pairName: string, ): { provider?: string; modelId?: string } { const normalizedProvider = validateOptionalModelField(provider, `${pairName} provider`); const normalizedModelId = validateOptionalModelField(modelId, `${pairName} modelId`); if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { throw new Error(`${pairName} must include both provider and modelId or neither`); } return { provider: normalizedProvider, modelId: normalizedModelId, }; } function rethrowAsApiError(error: unknown, fallbackMessage = "Internal server error"): never { if (error instanceof ApiError) { throw error; } if (error instanceof Error && error.message) { throw internalError(error.message); } throw internalError(fallbackMessage); } function slugifyPresetName(name: string): string { const slug = name .trim() .toLowerCase() .replace(/[^a-z0-9_-]+/g, "-") .replace(/-+/g, "-") .replace(/^[-_]+|[-_]+$/g, "") .slice(0, 32); return slug || "preset"; } /** * Extract RunMutationContext from the X-Run-Context header. * Used to correlate dashboard mutations with agent runs for audit trails. */ function extractRunContext(req: { headers: { [key: string]: string | string[] | undefined } }): import("@fusion/core").RunMutationContext | undefined { const header = req.headers['x-run-context']; if (typeof header !== 'string') return undefined; try { const parsed = JSON.parse(header); if (parsed && typeof parsed.runId === 'string' && typeof parsed.agentId === 'string') { return parsed as import("@fusion/core").RunMutationContext; } } catch { /* invalid JSON, ignore */ } return undefined; } function validateModelPresets(value: unknown): ModelPreset[] | undefined { if (value === undefined) return undefined; if (!Array.isArray(value)) { throw new Error("modelPresets must be an array"); } const seenIds = new Set(); return value.map((preset, index) => { if (!preset || typeof preset !== "object") { throw new Error(`modelPresets[${index}] must be an object`); } const candidate = preset as Record; const rawId = validateOptionalModelField(candidate.id, `modelPresets[${index}].id`); const name = validateOptionalModelField(candidate.name, `modelPresets[${index}].name`); if (!name) { throw new Error(`modelPresets[${index}].name is required`); } // Auto-generate ID from name when not provided let id = rawId || slugifyPresetName(name); // If the explicit ID collides, fall back to the slugified name if (seenIds.has(id)) { const slugId = slugifyPresetName(name); if (!seenIds.has(slugId)) { id = slugId; } else { // Both explicit ID and slug collide — append -1, -2, etc. const maxBase = 30; let idx = 1; while (seenIds.has(id) && idx < 100) { const suffix = `-${idx}`; id = `${slugId.slice(0, maxBase - suffix.length)}${suffix}`; idx++; } } } seenIds.add(id); const executor = assertConsistentOptionalPair( candidate.executorProvider, candidate.executorModelId, `modelPresets[${index}].executor`, ); const validator = assertConsistentOptionalPair( candidate.validatorProvider, candidate.validatorModelId, `modelPresets[${index}].validator`, ); return { id, name, executorProvider: executor.provider, executorModelId: executor.modelId, validatorProvider: validator.provider, validatorModelId: validator.modelId, }; }); } // ── Git Remote Detection ────────────────────────────────────────── /** Git remote info returned by the remotes endpoint */ export interface GitRemote { name: string; owner: string; repo: string; url: string; } /** * Parse a GitHub URL to extract owner and repo. * Handles HTTPS (https://github.com/owner/repo.git) and SSH (git@github.com:owner/repo.git) formats. */ function parseGitHubUrl(url: string): { owner: string; repo: string } | null { // HTTPS format: https://github.com/owner/repo.git or https://github.com/owner/repo const httpsMatch = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i); if (httpsMatch) { return { owner: httpsMatch[1], repo: httpsMatch[2] }; } // SSH format: git@github.com:owner/repo.git or git@github.com:owner/repo const sshMatch = url.match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i); if (sshMatch) { return { owner: sshMatch[1], repo: sshMatch[2] }; } return null; } function parseGitHubBadgeUrl(url: string | undefined): { owner: string; repo: string } | null { if (!url) return null; try { const parsed = new URL(url); if (parsed.hostname !== "github.com") return null; const parts = parsed.pathname.split("/").filter(Boolean); if (parts.length < 4) return null; const [owner, repo, resourceType] = parts; if ((resourceType !== "issues" && resourceType !== "pull") || !owner || !repo) { return null; } return { owner, repo }; } catch { return null; } } /** * Get GitHub remotes from the current git repository. * Executes `git remote -v` and parses the output. */ function getGitHubRemotes(cwd?: string): GitRemote[] { try { // Execute git remote -v to get all remotes with their URLs const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; const output = execSync("git remote -v", execOptions); const remotes: GitRemote[] = []; const seen = new Set(); for (const line of output.split("\n")) { // Parse lines like: "origin https://github.com/owner/repo.git (fetch)" const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); if (!match) continue; const [, name, url] = match; // Skip duplicates (fetch/push entries for same remote) const key = `${name}-${url}`; if (seen.has(key)) continue; seen.add(key); // Only include GitHub URLs const parsed = parseGitHubUrl(url); if (parsed) { remotes.push({ name, owner: parsed.owner, repo: parsed.repo, url, }); } } return remotes; } catch { // Return empty array if not a git repo, git not available, or any error return []; } } /** * Check if the current directory is a git repository. * Used to validate git operations before executing commands. */ function isGitRepo(cwd?: string): boolean { try { const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; execSync("git rev-parse --git-dir", execOptions); return true; } catch { return false; } } /** * Get the current git status including branch, commit hash, and dirty state. * Returns structured data for the Git Manager UI. */ function getGitStatus(cwd?: string): { branch: string; commit: string; isDirty: boolean; ahead: number; behind: number; } | null { try { const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; // Get current branch const branch = execSync("git branch --show-current", execOptions).trim() || "HEAD detached"; // Get current commit hash (short) const commit = execSync("git rev-parse --short HEAD", execOptions).trim(); // Check if working directory is dirty const statusOutput = execSync("git status --porcelain", execOptions).trim(); const isDirty = statusOutput.length > 0; // Get ahead/behind counts from origin let ahead = 0; let behind = 0; try { const revListOutput = execSync("git rev-list --left-right --count HEAD...@{u}", execOptions).trim(); const match = revListOutput.match(/(\d+)\s+(\d+)/); if (match) { ahead = parseInt(match[1], 10); behind = parseInt(match[2], 10); } } catch { // No upstream or other error - leave as 0 } return { branch, commit, isDirty, ahead, behind }; } catch { return null; } } /** Git commit info returned by the commits endpoint */ export interface GitCommit { hash: string; shortHash: string; message: string; author: string; date: string; parents: string[]; } /** * Get recent commits from the git log. * @param limit Maximum number of commits to return (default 20) */ function getGitCommits(limit: number = 20, cwd?: string): GitCommit[] { try { // Format: hash|shortHash|message|author|date|parents const format = "%H|%h|%s|%an|%aI|%P"; const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; const output = execSync(`git log --max-count=${limit} --pretty=format:"${format}"`, execOptions); const commits: GitCommit[] = []; for (const line of output.split("\n")) { const parts = line.split("|"); if (parts.length < 5) continue; const [hash, shortHash, message, author, date, parentsStr] = parts; const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : []; commits.push({ hash, shortHash, message: message || "", author: author || "", date: date || "", parents, }); } return commits; } catch { return []; } } /** * Validates a git ref name to prevent command injection. * Refs include branch names, remote tracking branches (remote/branch), and tags. * Must not contain shell metacharacters or start with dashes. */ function isValidGitRef(ref: string): boolean { if (!ref || ref.length === 0) return false; if (ref.startsWith("-")) return false; if (/[;<>&|`$(){}[\]\r\n]/.test(ref)) return false; if (/\s/.test(ref)) return false; // Allow slashes for remote/branch format, dots, hyphens, underscores, alphanumerics if (!/^[a-zA-Z0-9/_.@-]+$/.test(ref)) return false; if (ref.includes("..")) return false; if (ref.includes("~")) return false; if (ref.includes("^")) return false; if (ref.includes(":")) return false; // Must not look like an option if (ref.startsWith("--")) return false; return true; } /** * Get recent commits for a specific branch. * @param branch The branch name (validated before calling) * @param limit Maximum number of commits to return * @param cwd Working directory */ function getGitCommitsForBranch(branch: string, limit: number = 10, cwd?: string): GitCommit[] { try { const format = "%H|%h|%s|%an|%aI|%P"; const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; const output = execSync(`git log --max-count=${limit} --pretty=format:"${format}" "${branch}"`, execOptions); const commits: GitCommit[] = []; for (const line of output.split("\n")) { const parts = line.split("|"); if (parts.length < 5) continue; const [hash, shortHash, message, author, date, parentsStr] = parts; const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : []; commits.push({ hash, shortHash, message: message || "", author: author || "", date: date || "", parents, }); } return commits; } catch { return []; } } /** * Get commits ahead of the upstream tracking branch (commits that would be pushed). * Returns the list of local commits not yet present on the upstream. * Returns an empty array if there is no upstream configured. */ function getAheadCommits(cwd?: string): GitCommit[] { try { const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; // Check if an upstream is configured try { execSync("git rev-parse --abbrev-ref @{u}", execOptions); } catch { // No upstream configured return []; } // Format: hash|shortHash|message|author|date|parents const format = "%H|%h|%s|%an|%aI|%P"; const output = execSync(`git log @{u}..HEAD --pretty=format:"${format}"`, execOptions); const commits: GitCommit[] = []; for (const line of output.split("\n")) { const parts = line.split("|"); if (parts.length < 5) continue; const [hash, shortHash, message, author, date, parentsStr] = parts; const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : []; commits.push({ hash, shortHash, message: message || "", author: author || "", date: date || "", parents, }); } return commits; } catch { return []; } } /** * Get recent commits reachable from a remote tracking ref. * @param remoteRef The remote ref (e.g. "origin/main") to list commits for * @param limit Maximum number of commits to return (default 10) */ function getRemoteCommits(remoteRef: string, limit: number = 10, cwd?: string): GitCommit[] { try { if (!isValidGitRef(remoteRef)) { throw new Error("Invalid remote ref"); } // Verify the ref exists const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; try { execSync(`git rev-parse --verify "${remoteRef}"`, execOptions); } catch { return []; } // Format: hash|shortHash|message|author|date|parents const format = "%H|%h|%s|%an|%aI|%P"; const safeLimit = Math.min(Math.max(1, limit), 50); const output = execSync(`git log --max-count=${safeLimit} --pretty=format:"${format}" "${remoteRef}"`, { encoding: "utf-8", timeout: 10000, cwd, }); const commits: GitCommit[] = []; for (const line of output.split("\n")) { const parts = line.split("|"); if (parts.length < 5) continue; const [hash, shortHash, message, author, date, parentsStr] = parts; const parents = parentsStr ? parentsStr.split(" ").filter(Boolean) : []; commits.push({ hash, shortHash, message: message || "", author: author || "", date: date || "", parents, }); } return commits; } catch { return []; } } /** * Get the diff for a specific commit. * @param hash The commit hash * @returns Object with stat and patch */ function getCommitDiff(hash: string, cwd?: string): { stat: string; patch: string } | null { try { const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; // Validate the hash is a valid git object execSync(`git cat-file -t ${hash}`, { encoding: "utf-8", timeout: 5000, cwd }); // Get diff stat const stat = execSync(`git show --stat --format="" ${hash}`, execOptions).trim(); // Get patch const patch = execSync(`git show --format="" ${hash}`, execOptions); return { stat, patch }; } catch { return null; } } /** Git branch info returned by the branches endpoint */ export interface GitBranch { name: string; isCurrent: boolean; remote?: string; lastCommitDate?: string; } /** * Get all local branches with their info. */ function getGitBranches(cwd?: string): GitBranch[] { try { const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; // Get current branch name let currentBranch = ""; try { currentBranch = execSync("git branch --show-current", execOptions).trim(); } catch { // Detached HEAD - no current branch } // Get all branches with info const format = "%(refname:short)|%(upstream:short)|%(committerdate:iso8601)|%(HEAD)"; const output = execSync(`git for-each-ref --format="${format}" refs/heads/`, { encoding: "utf-8", timeout: 10000, cwd, }); const branches: GitBranch[] = []; for (const line of output.trim().split("\n")) { const parts = line.split("|"); if (parts.length < 4) continue; const [name, remote, lastCommitDate, headMarker] = parts; const isCurrent = headMarker === "*" || name === currentBranch; branches.push({ name, isCurrent, remote: remote || undefined, lastCommitDate: lastCommitDate || undefined, }); } return branches; } catch { return []; } } /** Git worktree info returned by the worktrees endpoint */ export interface GitWorktree { path: string; branch?: string; isMain: boolean; isBare: boolean; taskId?: string; } /** * Get all git worktrees. * @param tasks Optional task list to correlate worktrees with tasks */ function getGitWorktrees(tasks: { id: string; worktree?: string }[] = [], cwd?: string): GitWorktree[] { try { const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; const output = execSync("git worktree list --porcelain", execOptions); const worktrees: GitWorktree[] = []; let currentWorktree: Partial = {}; for (const line of output.split("\n")) { if (line.startsWith("worktree ")) { // Save previous worktree if exists if (currentWorktree.path) { // Find associated task by matching worktree path const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree); worktrees.push({ path: currentWorktree.path, branch: currentWorktree.branch, isMain: currentWorktree.isMain || false, isBare: currentWorktree.isBare || false, taskId: task?.id, }); } // Start new worktree currentWorktree = { path: line.slice(9).trim() }; } else if (line.startsWith("branch ")) { currentWorktree.branch = line.slice(8).trim().replace(/^refs\/heads\//, ""); } else if (line === "bare") { currentWorktree.isBare = true; } else if (line === "main") { currentWorktree.isMain = true; } else if (line === "" && currentWorktree.path) { // Empty line signals end of worktree entry const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree); worktrees.push({ path: currentWorktree.path, branch: currentWorktree.branch, isMain: currentWorktree.isMain || false, isBare: currentWorktree.isBare || false, taskId: task?.id, }); currentWorktree = {}; } } // Handle last worktree if no trailing newline if (currentWorktree.path) { const task = tasks.find((t) => t.worktree && currentWorktree.path === t.worktree); worktrees.push({ path: currentWorktree.path, branch: currentWorktree.branch, isMain: currentWorktree.isMain || false, isBare: currentWorktree.isBare || false, taskId: task?.id, }); } return worktrees; } catch { return []; } } // ── Git Action Helper Functions ────────────────────────────────────────── /** * Validates a branch name to prevent command injection. * Branch names must not contain spaces, special shell characters, or start with dashes. */ function isValidBranchName(name: string): boolean { // Must not be empty if (!name || name.length === 0) return false; // Must not start with a dash (could be interpreted as an option) if (name.startsWith("-")) return false; // Must not contain shell metacharacters if (/[;<>&|`$(){}[\]\r\n]/.test(name)) return false; // Must be valid git ref format (no spaces, no double dots, etc) if (/\s/.test(name)) return false; if (name.includes("..")) return false; if (name.includes("~")) return false; if (name.includes("^")) return false; if (name.includes(":")) return false; // Must not be a reserved git ref name const reserved = ["HEAD", "FETCH_HEAD", "ORIG_HEAD", "MERGE_HEAD", "CHERRY_PICK_HEAD"]; if (reserved.includes(name)) return false; return true; } /** * Create a new branch from current HEAD or specified base. * Returns the created branch name. */ function createGitBranch(name: string, base?: string, cwd?: string): string { if (!isValidBranchName(name)) { throw new Error("Invalid branch name"); } if (base && !isValidBranchName(base)) { throw new Error("Invalid base branch name"); } const cmd = base ? `git checkout -b ${name} ${base}` : `git checkout -b ${name}`; execSync(cmd, { encoding: "utf-8", timeout: 10000, cwd }); return name; } /** * Checkout an existing branch. * Throws if there are uncommitted changes that would be lost. */ function checkoutGitBranch(name: string, cwd?: string): void { if (!isValidBranchName(name)) { throw new Error("Invalid branch name"); } const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; // Check for uncommitted changes that would be lost try { execSync("git diff-index --quiet HEAD --", execOptions); } catch { // Has uncommitted changes - check if they'd be lost const diff = execSync("git diff --name-only", execOptions).trim(); if (diff) { throw new Error("Uncommitted changes would be lost. Commit or stash changes first."); } } execSync(`git checkout ${name}`, { encoding: "utf-8", timeout: 10000, cwd }); } /** * Delete a branch. * Throws if it's the current branch or has unmerged commits. */ function deleteGitBranch(name: string, force: boolean = false, cwd?: string): void { if (!isValidBranchName(name)) { throw new Error("Invalid branch name"); } const flag = force ? "-D" : "-d"; execSync(`git branch ${flag} ${name}`, { encoding: "utf-8", timeout: 10000, cwd }); } /** Result of a fetch operation */ export interface GitFetchResult { fetched: boolean; message: string; } /** * Fetch from origin or specified remote. */ function fetchGitRemote(remote: string = "origin", cwd?: string): GitFetchResult { if (!isValidBranchName(remote)) { throw new Error("Invalid remote name"); } try { const output = execSync(`git fetch ${remote}`, { encoding: "utf-8", timeout: 30000, cwd }); return { fetched: true, message: output.trim() || "Fetch completed" }; } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("Could not resolve host") || message.includes("Connection refused")) { throw new Error("Failed to connect to remote"); } // No updates is not an error return { fetched: false, message: message || "No updates" }; } } /** Result of a pull operation */ export interface GitPullResult { success: boolean; message: string; conflict?: boolean; } /** * Pull the current branch. */ function pullGitBranch(cwd?: string): GitPullResult { try { const output = execSync("git pull", { encoding: "utf-8", timeout: 30000, cwd }); return { success: true, message: output.trim() }; } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("CONFLICT") || message.includes("Merge conflict")) { const success = false; return { success, message: "Merge conflict detected. Resolve manually.", conflict: true }; } throw new Error(message || "Pull failed"); } } /** Result of a push operation */ export interface GitPushResult { success: boolean; message: string; } /** * Push the current branch. */ function pushGitBranch(cwd?: string): GitPushResult { try { const output = execSync("git push", { encoding: "utf-8", timeout: 30000, cwd }); return { success: true, message: output.trim() || "Push completed" }; } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("rejected") || message.includes("non-fast-forward")) { throw new Error("Push rejected. Pull latest changes first."); } if (message.includes("Could not resolve host") || message.includes("Connection refused")) { throw new Error("Failed to connect to remote"); } throw new Error(message || "Push failed"); } } // ── Git Remote Management Helper Functions ─────────────────────────────── /** Detailed git remote info with fetch and push URLs */ export interface GitRemoteDetailed { name: string; fetchUrl: string; pushUrl: string; } /** * Validates a git URL format. * Accepts: https://, git@, file://, or ssh:// formats * Rejects URLs containing shell metacharacters to prevent command injection. */ function isValidGitUrl(url: string): boolean { if (!url || typeof url !== "string") return false; // Reject URLs with shell metacharacters to prevent injection if (/[;<>&|`$(){}[\]\r\n]/.test(url)) return false; // Reject URLs starting with dash (could be interpreted as option) if (url.startsWith("-")) return false; // HTTPS URL: https://host.com/path.git or https://host.com/path if (/^https?:\/\/.+/.test(url)) return true; // SSH URL: git@host.com:path.git or git@host.com:path if (/^git@[^:]+:.+/.test(url)) return true; // File URL: file:///path/to/repo if (/^file:\/\/.+/.test(url)) return true; // SSH URL with protocol: ssh://git@host.com/path.git if (/^ssh:\/\/.+/.test(url)) return true; return false; } /** * Get all git remotes with their fetch and push URLs. * Executes `git remote -v` and parses the output. */ function listGitRemotes(cwd?: string): GitRemoteDetailed[] { try { const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; const output = execSync("git remote -v", execOptions); const remotes = new Map(); for (const line of output.split("\n")) { // Parse lines like: "origin https://github.com/owner/repo.git (fetch)" const match = line.match(/^(\S+)\s+(\S+)\s+\((fetch|push)\)$/); if (!match) continue; const [, name, url, type] = match; if (!remotes.has(name)) { remotes.set(name, { fetchUrl: "", pushUrl: "" }); } const remote = remotes.get(name)!; if (type === "fetch") { remote.fetchUrl = url; } else { remote.pushUrl = url; } } return Array.from(remotes.entries()).map(([name, urls]) => ({ name, fetchUrl: urls.fetchUrl, pushUrl: urls.pushUrl, })); } catch { return []; } } /** * Add a new git remote. */ function addGitRemote(name: string, url: string, cwd?: string): void { if (!isValidBranchName(name)) { throw new Error("Invalid remote name"); } if (!isValidGitUrl(url)) { throw new Error("Invalid git URL format"); } try { execSync(`git remote add ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("already exists")) { throw new Error(`Remote '${name}' already exists`); } throw new Error(message || "Failed to add remote"); } } /** * Remove a git remote. */ function removeGitRemote(name: string, cwd?: string): void { if (!isValidBranchName(name)) { throw new Error("Invalid remote name"); } try { execSync(`git remote remove ${name}`, { encoding: "utf-8", timeout: 10000, cwd }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("No such remote")) { throw new Error(`Remote '${name}' does not exist`); } throw new Error(message || "Failed to remove remote"); } } /** * Rename a git remote. */ function renameGitRemote(oldName: string, newName: string, cwd?: string): void { if (!isValidBranchName(oldName)) { throw new Error("Invalid remote name"); } if (!isValidBranchName(newName)) { throw new Error("Invalid new remote name"); } try { execSync(`git remote rename ${oldName} ${newName}`, { encoding: "utf-8", timeout: 10000, cwd }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("No such remote")) { throw new Error(`Remote '${oldName}' does not exist`); } if (message.includes("already exists")) { throw new Error(`Remote '${newName}' already exists`); } throw new Error(message || "Failed to rename remote"); } } /** * Set the URL for a git remote. */ function setGitRemoteUrl(name: string, url: string, cwd?: string): void { if (!isValidBranchName(name)) { throw new Error("Invalid remote name"); } if (!isValidGitUrl(url)) { throw new Error("Invalid git URL format"); } try { execSync(`git remote set-url ${name} ${url}`, { encoding: "utf-8", timeout: 10000, cwd }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err.message || String(err); if (message.includes("No such remote")) { throw new Error(`Remote '${name}' does not exist`); } throw new Error(message || "Failed to update remote URL"); } } // ── Git Stash, Stage, Commit Helper Functions ──────────────────────────── /** Git stash entry */ export interface GitStash { index: number; message: string; date: string; branch: string; } /** Individual file change with staging status */ export interface GitFileChange { file: string; status: "added" | "modified" | "deleted" | "renamed" | "copied" | "untracked"; staged: boolean; oldFile?: string; } /** * Get list of stash entries. */ function getGitStashList(cwd?: string): GitStash[] { try { const output = execSync('git stash list --format="%gd|%gs|%ai"', { encoding: "utf-8", timeout: 5000, cwd, }).trim(); if (!output) return []; const stashes: GitStash[] = []; for (const line of output.split("\n")) { const parts = line.split("|"); if (parts.length < 3) continue; const [ref, message, date] = parts; const indexMatch = ref.match(/stash@\{(\d+)\}/); const index = indexMatch ? parseInt(indexMatch[1], 10) : stashes.length; // Extract branch from message like "WIP on main: abc1234 ..." const branchMatch = message.match(/(?:WIP on|On) ([^:]+):/); const branch = branchMatch ? branchMatch[1] : ""; stashes.push({ index, message, date, branch }); } return stashes; } catch { return []; } } /** * Create a new stash. */ function createGitStash(message?: string, cwd?: string): string { let output: string; const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; if (message) { // Sanitize message: remove shell metacharacters to prevent injection const sanitized = message.replace(/[`$\\!"]/g, "").trim(); if (!sanitized) { throw new Error("Invalid stash message"); } output = execSync(`git stash push -m '${sanitized.replace(/'/g, "'\\''")}'`, execOptions).trim(); } else { output = execSync("git stash push", execOptions).trim(); } if (output.includes("No local changes to save")) { throw new Error("No local changes to stash"); } return output || "Stash created"; } /** * Apply a stash entry. */ function applyGitStash(index: number, drop: boolean = false, cwd?: string): string { if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index"); const cmd = drop ? `git stash pop stash@{${index}}` : `git stash apply stash@{${index}}`; const output = execSync(cmd, { encoding: "utf-8", timeout: 10000, cwd }).trim(); return output || (drop ? "Stash popped" : "Stash applied"); } /** * Drop a stash entry. */ function dropGitStash(index: number, cwd?: string): string { if (index < 0 || !Number.isInteger(index)) throw new Error("Invalid stash index"); const output = execSync(`git stash drop stash@{${index}}`, { encoding: "utf-8", timeout: 10000, cwd, }).trim(); return output || "Stash dropped"; } /** * Get file changes (staged and unstaged). */ function getGitFileChanges(cwd?: string): GitFileChange[] { try { const output = execSync("git status --porcelain=v1", { encoding: "utf-8", timeout: 5000, cwd, }).trim(); if (!output) return []; const changes: GitFileChange[] = []; for (const line of output.split("\n")) { if (line.length < 3) continue; const indexStatus = line[0]; const workTreeStatus = line[1]; const filePath = line.slice(3).trim(); // Map git status codes to our status type const mapStatus = (code: string): GitFileChange["status"] => { switch (code) { case "A": return "added"; case "M": return "modified"; case "D": return "deleted"; case "R": return "renamed"; case "C": return "copied"; case "?": return "untracked"; default: return "modified"; } }; // Handle renamed files: "R old -> new" let file = filePath; let oldFile: string | undefined; if (filePath.includes(" -> ")) { const [old, newF] = filePath.split(" -> "); oldFile = old.trim(); file = newF.trim(); } // Staged changes (index status is not space and not ?) if (indexStatus !== " " && indexStatus !== "?") { changes.push({ file, status: mapStatus(indexStatus), staged: true, oldFile, }); } // Unstaged changes (work tree status is not space) if (workTreeStatus !== " ") { changes.push({ file, status: workTreeStatus === "?" ? "untracked" : mapStatus(workTreeStatus), staged: false, oldFile, }); } } return changes; } catch { return []; } } /** * Get working directory diff. */ function getGitWorkingDiff(cwd?: string): { stat: string; patch: string } { try { const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; const stat = execSync("git diff --stat", execOptions).trim(); const patch = execSync("git diff", execOptions); return { stat, patch }; } catch { return { stat: "", patch: "" }; } } /** * Stage specific files. */ function stageGitFiles(files: string[], cwd?: string): string[] { if (!files.length) throw new Error("No files specified"); // Validate file paths - no shell metacharacters for (const f of files) { if (/[;&|`$(){}[\]\r\n]/.test(f)) { throw new Error(`Invalid file path: ${f}`); } } const escaped = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" "); execSync(`git add ${escaped}`, { encoding: "utf-8", timeout: 10000, cwd }); return files; } /** * Unstage specific files. */ function unstageGitFiles(files: string[], cwd?: string): string[] { if (!files.length) throw new Error("No files specified"); for (const f of files) { if (/[;&|`$(){}[\]\r\n]/.test(f)) { throw new Error(`Invalid file path: ${f}`); } } const escaped = files.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" "); execSync(`git reset HEAD ${escaped}`, { encoding: "utf-8", timeout: 10000, cwd }); return files; } /** * Create a commit with staged changes. */ function createGitCommit(message: string, cwd?: string): { hash: string; message: string } { if (!message || !message.trim()) throw new Error("Commit message is required"); const execOptions = { encoding: "utf-8" as const, timeout: 10000, cwd }; // Check there are staged changes const staged = execSync("git diff --cached --name-only", { encoding: "utf-8", timeout: 5000, cwd }).trim(); if (!staged) throw new Error("No staged changes to commit"); // Sanitize: use single quotes and escape embedded single quotes for shell safety const sanitized = message.trim().replace(/'/g, "'\\''"); execSync(`git commit -m '${sanitized}'`, execOptions); const hash = execSync("git rev-parse --short HEAD", { encoding: "utf-8", timeout: 5000, cwd }).trim(); return { hash, message: message.trim() }; } /** * Discard changes for specific files. */ function discardGitChanges(files: string[], cwd?: string): string[] { if (!files.length) throw new Error("No files specified"); for (const f of files) { if (/[;&|`$(){}[\]\r\n]/.test(f)) { throw new Error(`Invalid file path: ${f}`); } } const execOptions = { encoding: "utf-8" as const, timeout: 5000, cwd }; // Separate untracked from tracked const statusOutput = execSync("git status --porcelain=v1", execOptions).trim(); const untracked = new Set(); for (const line of statusOutput.split("\n")) { if (line.startsWith("??")) { untracked.add(line.slice(3).trim()); } } const trackedFiles = files.filter((f) => !untracked.has(f)); const untrackedFiles = files.filter((f) => untracked.has(f)); if (trackedFiles.length) { const escaped = trackedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" "); execSync(`git checkout -- ${escaped}`, { encoding: "utf-8", timeout: 10000, cwd }); } if (untrackedFiles.length) { const escaped = untrackedFiles.map((f) => `"${f.replace(/"/g, '\\"')}"`).join(" "); execSync(`git clean -f -- ${escaped}`, { encoding: "utf-8", timeout: 10000, cwd }); } return files; } // ── Module-level batch-import rate limiter state (resettable for testing) ── const batchImportWindowMs = 10_000; // 10 seconds const batchImportInstances: Map[] = []; let batchImportCleanupInterval: ReturnType | undefined; /** @internal Reset batch-import rate limiter state (for test isolation) */ export function __resetBatchImportRateLimiter(): void { for (const clients of batchImportInstances) { clients.clear(); } batchImportInstances.length = 0; if (batchImportCleanupInterval) { clearInterval(batchImportCleanupInterval); batchImportCleanupInterval = undefined; } } function parseLastEventId(req: Request): number | undefined { const rawHeader = req.headers["last-event-id"]; const rawQuery = req.query.lastEventId; const raw = Array.isArray(rawHeader) ? rawHeader[0] : (typeof rawHeader === "string" ? rawHeader : Array.isArray(rawQuery) ? rawQuery[0] : rawQuery); if (raw === undefined || raw === null) return undefined; const parsed = Number.parseInt(String(raw), 10); if (!Number.isFinite(parsed) || parsed < 0) return undefined; return parsed; } function replayBufferedSSE( res: Response, bufferedEvents: Array<{ id: number; event: string; data: string }>, ): boolean { for (const bufferedEvent of bufferedEvents) { if (!writeSSEEvent(res, bufferedEvent.event, bufferedEvent.data, bufferedEvent.id)) { return false; } } return true; } function checkSessionLock( sessionId: string, tabId: string | undefined, store: AiSessionStore | undefined, ): { allowed: true } | { allowed: false; currentHolder: string | null } { if (!tabId || !store) { return { allowed: true }; } const result = store.acquireLock(sessionId, tabId); if (result.acquired) { return { allowed: true }; } return { allowed: false, currentHolder: result.currentHolder }; } export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router { const router = Router(); function prioritizeProjectsForCurrentDirectory(projects: T[]): T[] { const cwd = resolve(process.cwd()); const rankProject = (projectPath: string): number => { const normalizedProjectPath = resolve(projectPath); if (normalizedProjectPath === cwd) { return Number.MAX_SAFE_INTEGER; } const prefix = normalizedProjectPath.endsWith(sep) ? normalizedProjectPath : `${normalizedProjectPath}${sep}`; if (!cwd.startsWith(prefix)) { return -1; } return normalizedProjectPath.length; }; return [...projects].sort((a, b) => rankProject(b.path) - rankProject(a.path)); } function getProjectIdFromRequest(req: Request): string | undefined { if (req.query && typeof req.query.projectId === "string" && req.query.projectId.length > 0) { return req.query.projectId; } if (req.body && typeof req.body.projectId === "string" && req.body.projectId.length > 0) { return req.body.projectId; } return undefined; } async function getScopedStore(req: Request): Promise { const projectId = getProjectIdFromRequest(req); if (!projectId) return store; // Use the shared project-store resolver so mutations emit events // on the same EventEmitter that SSE listeners are attached to. return getOrCreateProjectStore(projectId); } if (process.env.FUSION_DEBUG_PLANNING_ROUTES === "1") { const planningRoutes = [ "POST /planning/start", "POST /planning/start-streaming", "POST /planning/respond", "POST /planning/:sessionId/retry", "POST /planning/cancel", "POST /planning/create-task", "POST /planning/start-breakdown", "POST /planning/create-tasks", "GET /planning/:sessionId/stream", ]; console.debug("[planning:routes:registered]", planningRoutes); } const sessionFilesCache = new Map(); const fileDiffsCache = new Map< string, { files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>; expiresAt: number; } >(); // Get GitHub token from options or env const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN; // HeartbeatMonitor for triggering agent execution runs const heartbeatMonitor = options?.heartbeatMonitor; const hasHeartbeatExecutor = Boolean(heartbeatMonitor); const aiSessionStore = options?.aiSessionStore; const triggerCommentWakeForAssignedAgent = async ( scopedStore: TaskStore, task: Task, wake: { triggeringCommentType: "steering" | "task" | "pr"; triggeringCommentIds?: string[]; triggerDetail: string; }, ): Promise => { if (!hasHeartbeatExecutor || !heartbeatMonitor || !task.assignedAgentId) { return; } const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const assignedAgent = await agentStore.getAgent(task.assignedAgentId); if (!assignedAgent) { return; } const responseMode = (assignedAgent.runtimeConfig as { messageResponseMode?: string } | undefined)?.messageResponseMode; if (responseMode !== "immediate") { return; } const activeRun = await agentStore.getActiveHeartbeatRun(assignedAgent.id); if (activeRun) { return; } const triggeringCommentIds = wake.triggeringCommentIds?.filter((id) => typeof id === "string" && id.length > 0); const contextSnapshot: Record = { wakeReason: "on_demand", triggerDetail: wake.triggerDetail, taskId: task.id, ...(triggeringCommentIds?.length ? { triggeringCommentIds } : {}), triggeringCommentType: wake.triggeringCommentType, }; await heartbeatMonitor.executeHeartbeat({ agentId: assignedAgent.id, source: "on_demand", triggerDetail: wake.triggerDetail, taskId: task.id, triggeringCommentIds, triggeringCommentType: wake.triggeringCommentType, contextSnapshot, }); }; // Scheduler config (includes persisted settings) router.get("/config", async (req, res) => { try { const scopedStore = await getScopedStore(req); const settings = await scopedStore.getSettings(); res.json({ maxConcurrent: settings.maxConcurrent ?? options?.maxConcurrent ?? 2, maxWorktrees: settings.maxWorktrees ?? 4, rootDir: scopedStore.getRootDir(), }); } catch { const scopedStore = await getScopedStore(req); res.json({ maxConcurrent: options?.maxConcurrent ?? 2, maxWorktrees: 4, rootDir: scopedStore.getRootDir() }); } }); // Settings CRUD router.get("/settings", async (req, res) => { try { const scopedStore = await getScopedStore(req); const settings = await scopedStore.getSettings(); // Inject server-side configuration flags res.json({ ...settings, githubTokenConfigured: Boolean(githubToken), }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); router.put("/settings", async (req, res) => { try { const scopedStore = await getScopedStore(req); // Strip server-owned fields that should never be persisted to config.json. // These are computed server-side and injected only on GET /settings. // eslint-disable-next-line @typescript-eslint/no-unused-vars const { githubTokenConfigured, ...clientSettings } = req.body; // Reject global-only fields with a helpful error pointing to the correct endpoint const globalKeySet = new Set(GLOBAL_SETTINGS_KEYS); const globalFieldsFound = Object.keys(clientSettings).filter((k) => globalKeySet.has(k)); if (globalFieldsFound.length > 0) { throw badRequest(`Cannot update global settings via this endpoint. Use PUT /settings/global instead. Global fields found: ${globalFieldsFound.join(", ")}`); } if (Object.prototype.hasOwnProperty.call(clientSettings, "modelPresets")) { clientSettings.modelPresets = validateModelPresets(clientSettings.modelPresets); } // Validate backup settings if provided if (clientSettings.autoBackupSchedule !== undefined && !validateBackupSchedule(clientSettings.autoBackupSchedule)) { throw badRequest("Invalid cron expression for autoBackupSchedule"); } if (clientSettings.autoBackupRetention !== undefined && !validateBackupRetention(clientSettings.autoBackupRetention)) { throw badRequest("autoBackupRetention must be between 1 and 100"); } if (clientSettings.autoBackupDir !== undefined && !validateBackupDir(clientSettings.autoBackupDir)) { throw badRequest("autoBackupDir must be a relative path without '..' traversal"); } const settings = await scopedStore.updateSettings(clientSettings); // Sync backup automation schedule when backup settings change if (options?.automationStore) { try { await syncBackupAutomation(options.automationStore, settings); } catch (err) { // Log but don't fail the settings update if automation sync fails console.error("Failed to sync backup automation:", err); } } res.json(settings); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = typeof err?.message === "string" && ( err.message.includes("modelPresets") || err.message.includes("must include both provider and modelId") ) ? 400 : 500; throw new ApiError(status, err.message); } }); // ── Project Memory Routes ───────────────────────────────────── /** * GET /api/memory * Returns the project memory file content. * If .fusion/memory.md does not exist yet, returns an empty string. */ router.get("/memory", async (req, res) => { try { const scopedStore = await getScopedStore(req); const memory = await readProjectFile(scopedStore, MEMORY_FILE_PATH); res.json({ content: memory.content }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError && err.code === "ENOENT") { res.json({ content: "" }); return; } rethrowAsApiError(err, "Failed to read memory"); } }); /** * PUT /api/memory * Updates the project memory file content. * Body: { content: string } */ router.put("/memory", async (req, res) => { try { const { content } = req.body ?? {}; if (typeof content !== "string") { throw badRequest("content must be a string"); } const scopedStore = await getScopedStore(req); await writeProjectFile(scopedStore, MEMORY_FILE_PATH, content); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to save memory"); } }); // ── Global Settings Routes ───────────────────────────────────── /** * GET /api/settings/global * Returns the global (user-level) settings from ~/.pi/fusion/settings.json. * Does NOT include computed/server-only fields like githubTokenConfigured. */ router.get("/settings/global", async (_req, res) => { try { const globalStore = store.getGlobalSettingsStore(); const settings = await globalStore.getSettings(); res.json(settings); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PUT /api/settings/global * Update global (user-level) settings in ~/.pi/fusion/settings.json. * These settings persist across all fn projects for the current user. */ router.put("/settings/global", async (req, res) => { try { const settings = await store.updateGlobalSettings(req.body); res.json(settings); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/settings/scopes * Returns settings separated by scope: { global, project }. * Useful for the UI to show which scope each setting comes from. */ router.get("/settings/scopes", async (req, res) => { try { const scopedStore = await getScopedStore(req); const scopes = await scopedStore.getSettingsByScope(); res.json(scopes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/settings/test-ntfy * Send a test notification to verify ntfy configuration. * Returns: { success: true } on success, { error: string } on failure. */ router.post("/settings/test-ntfy", async (req, res) => { try { const scopedStore = await getScopedStore(req); const settings = await scopedStore.getSettings(); // Validate ntfy is enabled if (!settings.ntfyEnabled) { throw badRequest("ntfy notifications are not enabled"); } // Validate topic exists and matches required format const topic = settings.ntfyTopic; if (!topic || !/^[a-zA-Z0-9_-]{1,64}$/.test(topic)) { throw badRequest("ntfy topic is not configured or invalid"); } // Send test notification to ntfy.sh const ntfyBaseUrl = "https://ntfy.sh"; const url = `${ntfyBaseUrl}/${topic}`; const response = await fetch(url, { method: "POST", headers: { "Title": "Fusion test notification", "Priority": "default", "Content-Type": "text/plain", }, body: "Fusion test notification — your notifications are working!", }); if (!response.ok) { throw new ApiError(502, `ntfy.sh returned ${response.status}: ${response.statusText}`); } res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to send test notification"); } }); // ── Settings Export/Import Routes ───────────────────────────────── /** * GET /api/settings/export * Export settings as JSON for backup or migration. * Query params: ?scope=global|project|both (default: both) * Returns: SettingsExportData structure */ router.get("/settings/export", async (req, res) => { try { const scopedStore = await getScopedStore(req); const scopeParam = req.query.scope as string | undefined; const scope = scopeParam === "global" || scopeParam === "project" || scopeParam === "both" ? scopeParam : "both"; const exportData = await exportSettings(scopedStore, { scope }); res.json(exportData); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to export settings"); } }); /** * POST /api/settings/import * Import settings from JSON data. * Body: { data: SettingsExportData, scope?: 'global'|'project'|'both', merge?: boolean } * Returns: { success: true, globalCount: number, projectCount: number } */ router.post("/settings/import", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { data, scope = "both", merge = true } = req.body; // Validate the import data const validationErrors = validateImportData(data); if (validationErrors.length > 0) { throw badRequest(`Validation failed: ${validationErrors.join("; ")}`); } // Perform the import const result = await importSettings(scopedStore, data, { scope, merge }); if (!result.success) { throw new ApiError(500, result.error ?? "Import failed", { globalCount: result.globalCount, projectCount: result.projectCount, }); } res.json({ success: true, globalCount: result.globalCount, projectCount: result.projectCount, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to import settings"); } }); // ── Executor Stats Route ──────────────────────────────────────────── /** * GET /api/executor/stats * Returns executor statistics for the status bar. * * Counts (running, blocked, queued, in-review, stuck) are derived client-side * from the tasks array. This endpoint returns settings-based values and * lastActivityAt from the activity log. */ router.get("/executor/stats", async (req, res) => { try { const scopedStore = await getScopedStore(req); const settings = await scopedStore.getSettings(); // Get the most recent activity timestamp from the activity log let lastActivityAt: string | undefined; try { const activityLog = await scopedStore.getActivityLog({ limit: 1 }); if (activityLog.length > 0) { lastActivityAt = activityLog[0].timestamp; } } catch { // If we can't get activity log, that's OK - just leave lastActivityAt undefined } res.json({ globalPause: settings.globalPause ?? false, enginePaused: settings.enginePaused ?? false, maxConcurrent: settings.maxConcurrent ?? 2, lastActivityAt, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Backup Routes ───────────────────────────────────────────────── /** * GET /api/backups * List all database backups with metadata. */ router.get("/backups", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { createBackupManager } = await import("@fusion/core"); const settings = await scopedStore.getSettings(); const manager = createBackupManager(scopedStore["kbDir"], settings); const backups = await manager.listBackups(); // Calculate total size const totalSize = backups.reduce((sum, b) => sum + b.size, 0); res.json({ backups, count: backups.length, totalSize, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to list backups"); } }); /** * POST /api/backups * Create a new database backup immediately. */ router.post("/backups", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { runBackupCommand } = await import("@fusion/core"); const settings = await scopedStore.getSettings(); const result = await runBackupCommand(scopedStore["kbDir"], settings); if (result.success) { res.json({ success: true, backupPath: result.backupPath, output: result.output, deletedCount: result.deletedCount, }); } else { throw new ApiError(500, result.output); } } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create backup"); } }); // Models registerModelsRoute(router, options?.modelRegistry, store); // List all tasks router.get("/tasks", async (req, res) => { try { const scopedStore = await getScopedStore(req); const limit = typeof req.query.limit === "string" ? Number.parseInt(req.query.limit, 10) : undefined; const offset = typeof req.query.offset === "string" ? Number.parseInt(req.query.offset, 10) : undefined; const q = typeof req.query.q === "string" ? req.query.q.trim() : undefined; if (limit !== undefined && (!Number.isFinite(limit) || limit < 0)) { throw badRequest("limit must be a non-negative integer"); } if (offset !== undefined && (!Number.isFinite(offset) || offset < 0)) { throw badRequest("offset must be a non-negative integer"); } let tasks; if (q && q.length > 0) { tasks = await scopedStore.searchTasks(q, { limit, offset }); } else { tasks = await scopedStore.listTasks({ limit, offset }); } res.json(tasks); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // Create task router.post("/tasks", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { title, description, column, dependencies, breakIntoSubtasks, enabledWorkflowSteps, modelPresetId, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, } = req.body; if (!description || typeof description !== "string") { throw badRequest("description is required"); } if (breakIntoSubtasks !== undefined && typeof breakIntoSubtasks !== "boolean") { throw badRequest("breakIntoSubtasks must be a boolean"); } const validatedModelProvider = validateOptionalModelField(modelProvider, "modelProvider"); const validatedModelId = validateOptionalModelField(modelId, "modelId"); const validatedValidatorModelProvider = validateOptionalModelField(validatorModelProvider, "validatorModelProvider"); const validatedValidatorModelId = validateOptionalModelField(validatorModelId, "validatorModelId"); const validatedPlanningModelProvider = validateOptionalModelField(planningModelProvider, "planningModelProvider"); const validatedPlanningModelId = validateOptionalModelField(planningModelId, "planningModelId"); // Validate thinkingLevel if provided const validThinkingLevels = ["off", "minimal", "low", "medium", "high"]; if (thinkingLevel !== undefined && thinkingLevel !== null && !validThinkingLevels.includes(thinkingLevel)) { throw badRequest(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`); } const executorModel = normalizeModelSelectionPair(validatedModelProvider, validatedModelId); const validatorModel = normalizeModelSelectionPair(validatedValidatorModelProvider, validatedValidatorModelId); const planningModel = normalizeModelSelectionPair(validatedPlanningModelProvider, validatedPlanningModelId); // Validate enabledWorkflowSteps if provided if (enabledWorkflowSteps !== undefined) { if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) { throw badRequest("enabledWorkflowSteps must be an array of strings"); } } // Check for summarize flag in request const summarize = req.body.summarize === true; // Get settings for auto-summarization const settings = await scopedStore.getSettings(); // Create onSummarize callback if summarization is enabled const onSummarize = (summarize || settings.autoSummarizeTitles) ? async (desc: string): Promise => { try { const { summarizeTitle } = await import("@fusion/core"); // Resolve model selection hierarchy for summarization const resolvedProvider = (settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) || (settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) || (settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined); const resolvedModelId = (settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) || (settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) || (settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined); return await summarizeTitle(desc, scopedStore.getRootDir(), resolvedProvider, resolvedModelId); } catch { // Return null on error so task creation continues without title return null; } } : undefined; const task = await scopedStore.createTask( { title, description, column, dependencies, breakIntoSubtasks, enabledWorkflowSteps, modelPresetId: validateOptionalModelField(modelPresetId, "modelPresetId"), modelProvider: executorModel.provider, modelId: executorModel.modelId, validatorModelProvider: validatorModel.provider, validatorModelId: validatorModel.modelId, planningModelProvider: planningModel.provider, planningModelId: planningModel.modelId, thinkingLevel: thinkingLevel || undefined, summarize, }, { onSummarize, settings: { autoSummarizeTitles: settings.autoSummarizeTitles } } ); res.status(201).json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") ? 400 : 500; throw new ApiError(status, err.message); } }); // Move task to column router.post("/tasks/:id/move", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { column } = req.body; if (!column || !COLUMNS.includes(column as Column)) { throw badRequest(`Invalid column. Must be one of: ${COLUMNS.join(", ")}`); } const task = await scopedStore.moveTask(req.params.id, column as Column); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message.includes("Invalid transition") ? 400 : 500; throw new ApiError(status, err.message); } }); // Merge task (in-review → done, merges branch + cleans worktree) // Uses AI merge handler if provided, falls back to store.mergeTask router.post("/tasks/:id/merge", async (req, res) => { try { const scopedStore = await getScopedStore(req); const merge = options?.onMerge ?? ((id: string) => scopedStore.mergeTask(id)); const result = await merge(req.params.id); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message.includes("Cannot merge") ? 400 : err.message.includes("conflict") ? 409 : 500; throw new ApiError(status, err.message); } }); // Retry failed or stuck-killed task router.post("/tasks/:id/retry", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (task.status !== "failed" && task.status !== "stuck-killed") { throw badRequest(`Task is not in a retryable state (current status: ${task.status || 'none'})`); } await scopedStore.updateTask(req.params.id, { status: null, error: null, worktree: null, branch: null, stuckKillCount: 0, }); // Reset steps if the branch has no unique commits (work was lost with worktree) const completedSteps = task.steps.filter( (s: { status: string }) => s.status === "done" || s.status === "in-progress", ); if (completedSteps.length > 0) { const branchName = task.branch || `fusion/${task.id.toLowerCase()}`; try { const { execSync } = await import("node:child_process"); const rootDir = scopedStore.getRootDir(); const mergeBase = execSync( `git merge-base "${branchName}" HEAD 2>/dev/null`, { cwd: rootDir, stdio: "pipe", encoding: "utf-8" }, ).trim(); const branchHead = execSync( `git rev-parse "${branchName}" 2>/dev/null`, { cwd: rootDir, stdio: "pipe", encoding: "utf-8" }, ).trim(); if (mergeBase === branchHead) { for (let i = 0; i < task.steps.length; i++) { if (task.steps[i].status === "done" || task.steps[i].status === "in-progress") { await scopedStore.updateStep(req.params.id, i, "pending"); } } await scopedStore.logEntry( req.params.id, `Reset ${completedSteps.length} step(s) to pending — branch had no commits (uncommitted work lost)`, ); } } catch { // Branch may not exist — non-fatal, steps keep their status } } await scopedStore.logEntry(req.params.id, "Retry requested from dashboard (stuck kill budget reset)"); const updated = await scopedStore.moveTask(req.params.id, "todo"); res.json(updated); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // Duplicate task router.post("/tasks/:id/duplicate", async (req, res) => { try { const scopedStore = await getScopedStore(req); const newTask = await scopedStore.duplicateTask(req.params.id); res.status(201).json(newTask); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); // Create refinement task from a completed or in-review task router.post("/tasks/:id/refine", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { feedback } = req.body; if (!feedback || typeof feedback !== "string") { throw badRequest("feedback is required and must be a string"); } // Trim before checking length to catch whitespace-only input const trimmedFeedback = feedback.trim(); if (trimmedFeedback.length === 0 || trimmedFeedback.length > 2000) { throw badRequest("feedback must be between 1 and 2000 characters"); } const refinedTask = await scopedStore.refineTask(req.params.id, trimmedFeedback); await scopedStore.logEntry(req.params.id, "Refinement requested", trimmedFeedback); res.status(201).json(refinedTask); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : err.message?.includes("must be in 'done' or 'in-review'") ? 400 : err.message?.includes("Feedback is required") ? 400 : 500; throw new ApiError(status, err.message); } }); // Archive task (done → archived) router.post("/tasks/:id/archive", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.archiveTask(req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("must be in") ? 400 : 500; throw new ApiError(status, err.message); } }); // Unarchive task (archived → done) router.post("/tasks/:id/unarchive", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.unarchiveTask(req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("must be in") ? 400 : 500; throw new ApiError(status, err.message); } }); // Archive all done tasks router.post("/tasks/archive-all-done", async (req, res) => { try { const scopedStore = await getScopedStore(req); const archived = await scopedStore.archiveAllDone(); res.json({ archived }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/tasks/batch-update-models * Batch update AI model configuration for multiple tasks. * Body: { taskIds: string[], modelProvider?: string | null, modelId?: string | null, validatorModelProvider?: string | null, validatorModelId?: string | null, planningModelProvider?: string | null, planningModelId?: string | null } * Returns: { updated: Task[], count: number } */ router.post("/tasks/batch-update-models", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { taskIds, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId } = req.body; // Validate taskIds if (!Array.isArray(taskIds)) { throw badRequest("taskIds must be an array"); } if (taskIds.length === 0) { throw badRequest("taskIds must contain at least one task ID"); } if (taskIds.some((id) => typeof id !== "string" || id.trim().length === 0)) { throw badRequest("taskIds must contain non-empty strings"); } // Validate that at least one model field is being updated const hasExecutorModel = modelProvider !== undefined || modelId !== undefined; const hasValidatorModel = validatorModelProvider !== undefined || validatorModelId !== undefined; const hasPlanningModel = planningModelProvider !== undefined || planningModelId !== undefined; if (!hasExecutorModel && !hasValidatorModel && !hasPlanningModel) { throw badRequest("At least one model field must be provided"); } // Validate model field pairs (both provider and modelId must be provided together or neither) const validateModelPair = (provider: unknown, modelIdValue: unknown, name: string): { provider?: string | null; modelId?: string | null } => { if (provider === undefined && modelIdValue === undefined) { return { provider: undefined, modelId: undefined }; } if ((provider !== undefined && modelIdValue === undefined) || (provider === undefined && modelIdValue !== undefined)) { throw new Error(`${name} must include both provider and modelId or neither`); } if (provider !== null && typeof provider !== "string") { throw new Error(`${name} provider must be a string or null`); } if (modelIdValue !== null && typeof modelIdValue !== "string") { throw new Error(`${name} modelId must be a string or null`); } return { provider: provider as string | null, modelId: modelIdValue as string | null }; }; let validatedExecutor: { provider?: string | null; modelId?: string | null }; let validatedValidator: { provider?: string | null; modelId?: string | null }; let validatedPlanning: { provider?: string | null; modelId?: string | null }; try { validatedExecutor = validateModelPair(modelProvider, modelId, "Executor model"); validatedValidator = validateModelPair(validatorModelProvider, validatorModelId, "Validator model"); validatedPlanning = validateModelPair(planningModelProvider, planningModelId, "Planning model"); } catch (err: any) { if (err instanceof ApiError) { throw err; } throw badRequest(err.message); } // Verify all tasks exist const tasksById = new Map>>(); for (const taskId of taskIds) { try { const task = await scopedStore.getTask(taskId); tasksById.set(taskId, task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT" || err?.message?.includes("not found")) { throw notFound(`Task ${taskId} not found`); } throw err; } } // Build update payload (only include fields that were explicitly provided) const updates: { modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null } = {}; if (validatedExecutor.provider !== undefined) { updates.modelProvider = validatedExecutor.provider; } if (validatedExecutor.modelId !== undefined) { updates.modelId = validatedExecutor.modelId; } if (validatedValidator.provider !== undefined) { updates.validatorModelProvider = validatedValidator.provider; } if (validatedValidator.modelId !== undefined) { updates.validatorModelId = validatedValidator.modelId; } if (validatedPlanning.provider !== undefined) { updates.planningModelProvider = validatedPlanning.provider; } if (validatedPlanning.modelId !== undefined) { updates.planningModelId = validatedPlanning.modelId; } // Update all tasks in parallel const updatePromises = taskIds.map(async (taskId) => { try { const updated = await scopedStore.updateTask(taskId, updates); return { success: true, task: updated }; } catch (err: any) { if (err instanceof ApiError) { throw err; } console.error(`Failed to update task ${taskId}:`, err); const success = false; return { success, taskId, error: err.message }; } }); const results = await Promise.all(updatePromises); // Collect successful updates const updated: Task[] = []; const errors: Array<{ taskId: string; error: string }> = []; for (const result of results) { if (result.success && "task" in result && result.task) { updated.push(result.task); } else if (!result.success) { errors.push({ taskId: result.taskId, error: result.error }); } } // Log errors but don't fail the entire request if (errors.length > 0) { console.error(`[batch-update-models] ${errors.length} tasks failed to update:`, errors); } res.json({ updated, count: updated.length }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to batch update models"); } }); // Upload attachment router.post("/tasks/:id/attachments", upload.single("file"), async (req, res) => { try { const scopedStore = await getScopedStore(req); if (!req.file) { throw badRequest("No file provided"); } const attachment = await scopedStore.addAttachment( req.params.id as string, req.file.originalname, req.file.buffer, req.file.mimetype, ); res.status(201).json(attachment); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message.includes("Invalid mime type") || err.message.includes("File too large") ? 400 : 500; throw new ApiError(status, err.message); } }); // Download attachment router.get("/tasks/:id/attachments/:filename", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { path, mimeType } = await scopedStore.getAttachment(req.params.id, req.params.filename); res.setHeader("Content-Type", mimeType); createReadStream(path).pipe(res); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Attachment not found"); } else { rethrowAsApiError(err); } } }); // Delete attachment router.delete("/tasks/:id/attachments/:filename", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.deleteAttachment(req.params.id, req.params.filename); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Attachment not found"); } else { rethrowAsApiError(err); } } }); // Get historical agent logs for a task. // Per-entry text and detail fields are returned in full — no truncation. // The 500-entry cap (MAX_LOG_ENTRIES) is a client-side whole-list limit. router.get("/tasks/:id/logs", async (req, res) => { try { const scopedStore = await getScopedStore(req); const logs = await scopedStore.getAgentLogs(req.params.id); res.json(logs); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err); } } }); /** * Resolve the diff base ref for a task's worktree. * * Strategy (in priority order): * 1. **Task-scoped** — When the task has a `baseCommitSha` that is still * a valid ancestor of the current HEAD in the worktree, use it. This * keeps the changed-files list scoped to files introduced by *this* * specific task, even in shared or recycled worktree scenarios. * 2. **Branch merge-base** — Fall back to the merge-base between HEAD and * `origin/{baseBranch}` (or bare `{baseBranch}`). * 3. **HEAD~1** — Last resort when neither baseCommitSha nor merge-base * can be resolved. */ function resolveDiffBase(task: { baseCommitSha?: string; baseBranch?: string }, cwd: string): string | undefined { // 1. Try task-scoped baseCommitSha if (task.baseCommitSha) { try { // Validate that the stored SHA is still an ancestor of HEAD. // If the branch was rebased or the SHA is otherwise unreachable, // this will exit non-zero and we fall through. nodeChildProcess.execSync( `git merge-base --is-ancestor ${task.baseCommitSha} HEAD`, { cwd, encoding: "utf-8", timeout: 5000, stdio: "pipe" }, ); return task.baseCommitSha; } catch { // baseCommitSha is stale or invalid — fall through to merge-base } } // 2. Branch merge-base const baseBranch = task.baseBranch ?? "main"; try { return nodeChildProcess.execSync( `git merge-base HEAD origin/${baseBranch} 2>/dev/null || git merge-base HEAD ${baseBranch}`, { cwd, encoding: "utf-8", timeout: 5000 }, ).trim() || undefined; } catch { // merge-base unavailable — fall through to HEAD~1 } // 3. HEAD~1 fallback try { return nodeChildProcess.execSync("git rev-parse HEAD~1", { cwd, encoding: "utf-8", timeout: 5000, }).trim() || undefined; } catch { return undefined; } } router.get("/tasks/:id/session-files", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task.worktree || !nodeFs.existsSync(task.worktree)) { res.json([]); return; } const cached = sessionFilesCache.get(task.id); if (cached && cached.expiresAt > Date.now()) { res.json(cached.files); return; } let files: string[] = []; try { const fileSet = new Set(); const baseRef = resolveDiffBase(task, task.worktree); if (baseRef) { const committedOutput = nodeChildProcess.execSync(`git diff --name-only ${baseRef}..HEAD`, { cwd: task.worktree, encoding: "utf-8", timeout: 5000, }).trim(); for (const file of committedOutput.split("\n").filter(Boolean)) { fileSet.add(file); } } const workingTreeOutput = nodeChildProcess.execSync("git diff --name-only", { cwd: task.worktree, encoding: "utf-8", timeout: 5000, }).trim(); for (const file of workingTreeOutput.split("\n").filter(Boolean)) { fileSet.add(file); } files = Array.from(fileSet); } catch { files = []; } sessionFilesCache.set(task.id, { files, expiresAt: Date.now() + 10000, }); res.json(files); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * GET /api/tasks/:id/workflow-results * Get workflow step execution results for a task. * Returns: WorkflowStepResult[] */ router.get("/tasks/:id/workflow-results", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); res.json(task.workflowStepResults || []); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err, "Internal server error"); } } }); // Get single task with prompt content router.get("/tasks/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } // ENOENT means the task directory/file genuinely doesn't exist → 404. // Any other error (e.g. JSON parse failure from a concurrent partial write, // or a transient FS error) should surface as 500 so clients can retry. if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err, "Internal server error"); } } }); // Pause task router.post("/tasks/:id/pause", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.pauseTask(req.params.id, true); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // Unpause task router.post("/tasks/:id/unpause", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.pauseTask(req.params.id, false); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // Approve plan for a task in awaiting-approval status router.post("/tasks/:id/approve-plan", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); // Verify task is in triage column with awaiting-approval status if (task.column !== "triage") { throw badRequest("Task must be in 'triage' column to approve plan"); } if (task.status !== "awaiting-approval") { throw badRequest("Task must have status 'awaiting-approval' to approve plan"); } // Log the approval await scopedStore.logEntry(task.id, "Plan approved by user"); // Move to todo and clear status const updated = await scopedStore.moveTask(task.id, "todo"); await scopedStore.updateTask(task.id, { status: undefined }); res.json({ ...updated, status: undefined }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); // Reject plan for a task in awaiting-approval status router.post("/tasks/:id/reject-plan", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); // Verify task is in triage column with awaiting-approval status if (task.column !== "triage") { throw badRequest("Task must be in 'triage' column to reject plan"); } if (task.status !== "awaiting-approval") { throw badRequest("Task must have status 'awaiting-approval' to reject plan"); } // Log the rejection await scopedStore.logEntry(task.id, "Plan rejected by user", "Specification will be regenerated"); // Clear status to return to normal triage state await scopedStore.updateTask(task.id, { status: undefined }); // Remove PROMPT.md to force regeneration const { rm } = await import("node:fs/promises"); const { join } = await import("node:path"); const promptPath = join(scopedStore.getRootDir(), ".fusion", "tasks", task.id, "PROMPT.md"); await rm(promptPath, { force: true }); const updated = await scopedStore.getTask(task.id); res.json(updated); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); router.get("/tasks/:id/comments", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); res.json(task.comments || []); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); router.post("/tasks/:id/comments", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { text, author } = req.body; if (!text || typeof text !== "string") { throw badRequest("text is required and must be a string"); } if (text.length === 0 || text.length > 2000) { throw badRequest("text must be between 1 and 2000 characters"); } if (author !== undefined && typeof author !== "string") { throw badRequest("author must be a string"); } const task = await scopedStore.addTaskComment(req.params.id, text, author?.trim() || "user"); const newCommentId = task.comments?.at(-1)?.id; void triggerCommentWakeForAssignedAgent(scopedStore, task, { triggeringCommentType: "task", triggeringCommentIds: newCommentId ? [newCommentId] : undefined, triggerDetail: "task-comment", }).catch((error) => { console.warn( `[routes] failed to trigger task-comment heartbeat for ${task.id}: ${error instanceof Error ? error.message : String(error)}`, ); }); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); router.patch("/tasks/:id/comments/:commentId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { text } = req.body; if (!text || typeof text !== "string") { throw badRequest("text is required and must be a string"); } if (text.length === 0 || text.length > 2000) { throw badRequest("text must be between 1 and 2000 characters"); } const task = await scopedStore.updateTaskComment(req.params.id, req.params.commentId, text); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); router.delete("/tasks/:id/comments/:commentId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.deleteTaskComment(req.params.id, req.params.commentId); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); // Add steering comment to task router.post("/tasks/:id/steer", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { text } = req.body; if (!text || typeof text !== "string") { throw badRequest("text is required and must be a string"); } if (text.length === 0 || text.length > 2000) { throw badRequest("text must be between 1 and 2000 characters"); } const task = await scopedStore.addSteeringComment(req.params.id, text, "user"); const newSteeringCommentId = task.steeringComments?.at(-1)?.id; void triggerCommentWakeForAssignedAgent(scopedStore, task, { triggeringCommentType: "steering", triggeringCommentIds: newSteeringCommentId ? [newSteeringCommentId] : undefined, triggerDetail: "steering-comment", }).catch((error) => { console.warn( `[routes] failed to trigger steering-comment heartbeat for ${task.id}: ${error instanceof Error ? error.message : String(error)}`, ); }); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : 500; throw new ApiError(status, err.message); } }); // Request AI revision of task spec router.post("/tasks/:id/spec/revise", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { feedback } = req.body; if (!feedback || typeof feedback !== "string") { throw badRequest("feedback is required and must be a string"); } if (feedback.length === 0 || feedback.length > 2000) { throw badRequest("feedback must be between 1 and 2000 characters"); } // Get current task state const task = await scopedStore.getTask(req.params.id); // Check if task can transition to triage const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); if (!canTransition) { throw badRequest( `Cannot request spec revision for tasks in '${task.column}' column. Move task to 'todo' or 'in-progress' first.`, ); } // Log the revision request await scopedStore.logEntry(task.id, "AI spec revision requested", feedback); // Move to triage for re-specification (only valid for todo/in-progress) const updated = await scopedStore.moveTask(task.id, "triage"); // Update status to indicate needs re-specification await scopedStore.updateTask(task.id, { status: "needs-respecify" }); res.json(updated); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : err.message?.includes("Invalid transition") ? 400 : 500; throw new ApiError(status, err.message); } }); // Rebuild task spec without feedback router.post("/tasks/:id/spec/rebuild", async (req, res) => { try { const scopedStore = await getScopedStore(req); // Get current task state const task = await scopedStore.getTask(req.params.id); // Check if task can transition to triage const canTransition = VALID_TRANSITIONS[task.column]?.includes("triage"); if (!canTransition) { throw badRequest(`Cannot rebuild spec for tasks in '${task.column}' column. Move task to a valid column first.`); } // Log the rebuild request await scopedStore.logEntry(task.id, "Specification rebuild requested by user"); // Move to triage for re-specification const updated = await scopedStore.moveTask(task.id, "triage"); // Update status to indicate needs re-specification await scopedStore.updateTask(task.id, { status: "needs-respecify" }); res.json(updated); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.code === "ENOENT" ? 404 : err.message?.includes("Invalid transition") ? 400 : 500; throw new ApiError(status, err.message); } }); // Update task router.patch("/tasks/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider, modelId, validatorModelProvider, validatorModelId, planningModelProvider, planningModelId, thinkingLevel, assigneeUserId } = req.body; // Validate model fields are strings or undefined/null const validateModelField = (value: unknown, name: string): string | null | undefined => { if (value === undefined || value === null) return null; if (typeof value !== "string") { throw new Error(`${name} must be a string`); } return value; }; const validatedModelProvider = validateModelField(modelProvider, "modelProvider"); const validatedModelId = validateModelField(modelId, "modelId"); const validatedValidatorModelProvider = validateModelField(validatorModelProvider, "validatorModelProvider"); const validatedValidatorModelId = validateModelField(validatorModelId, "validatorModelId"); const validatedPlanningModelProvider = validateModelField(planningModelProvider, "planningModelProvider"); const validatedPlanningModelId = validateModelField(planningModelId, "planningModelId"); const validatedAssigneeUserId = validateModelField(assigneeUserId, "assigneeUserId"); // Validate thinkingLevel if provided const validThinkingLevels = ["off", "minimal", "low", "medium", "high"]; if (thinkingLevel !== undefined && thinkingLevel !== null && !validThinkingLevels.includes(thinkingLevel)) { throw new Error(`thinkingLevel must be one of: ${validThinkingLevels.join(", ")}`); } if (enabledWorkflowSteps !== undefined) { if (!Array.isArray(enabledWorkflowSteps) || !enabledWorkflowSteps.every((id: unknown) => typeof id === "string")) { throw new Error("enabledWorkflowSteps must be an array of strings"); } } const task = await scopedStore.updateTask(req.params.id, { title, description, prompt, dependencies, enabledWorkflowSteps, modelProvider: validatedModelProvider, modelId: validatedModelId, validatorModelProvider: validatedValidatorModelProvider, validatorModelId: validatedValidatorModelId, planningModelProvider: validatedPlanningModelProvider, planningModelId: validatedPlanningModelId, thinkingLevel: thinkingLevel === null ? null : thinkingLevel, assigneeUserId: validatedAssigneeUserId, }); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("must be a string") || err.message?.includes("must be an array of strings") || err.message?.includes("thinkingLevel must be one of") ? 400 : 500; throw new ApiError(status, err.message); } }); // Assign or unassign a task to an explicit agent router.patch("/tasks/:id/assign", async (req, res) => { try { const { agentId } = req.body as { agentId?: string | null }; if (agentId !== null && typeof agentId !== "string") { throw badRequest("agentId must be a string or null"); } if (typeof agentId === "string" && agentId.trim().length === 0) { throw badRequest("agentId must be a non-empty string or null"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); if (typeof agentId === "string") { const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } } const task = await scopedStore.updateTask(req.params.id, { assignedAgentId: agentId === null ? null : agentId, }); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT" || err?.message?.includes("not found")) { throw notFound(err.message ?? "Task not found"); } else { rethrowAsApiError(err); } } }); // Assign or unassign a task to a user (for review handoff) router.patch("/tasks/:id/assign-user", async (req, res) => { try { const { userId } = req.body as { userId?: string | null }; if (userId !== null && typeof userId !== "string") { throw badRequest("userId must be a string or null"); } if (typeof userId === "string" && userId.trim().length === 0) { throw badRequest("userId must be a non-empty string or null"); } const scopedStore = await getScopedStore(req); // When assigning a user, also clear the awaiting-user-review status // so the task can proceed to merge const updates: Record = { assigneeUserId: userId === null ? null : userId, }; // Clear awaiting-user-review status when explicitly assigning a user if (userId !== null) { updates.status = null; } const task = await scopedStore.updateTask(req.params.id, updates as any); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT" || err?.message?.includes("not found")) { throw notFound(err.message ?? "Task not found"); } else { rethrowAsApiError(err); } } }); // Accept review - clear assignee and awaiting-user-review status, keep in in-review router.post("/tasks/:id/accept-review", async (req, res) => { try { const scopedStore = await getScopedStore(req); // Clear assignee and status to allow auto-merge to proceed const task = await scopedStore.updateTask(req.params.id, { assigneeUserId: null, status: null, }); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT" || err?.message?.includes("not found")) { throw notFound(err.message ?? "Task not found"); } else { rethrowAsApiError(err); } } }); // Return task to agent - clear assignee and status, move to todo router.post("/tasks/:id/return-to-agent", async (req, res) => { try { const scopedStore = await getScopedStore(req); // Clear assignee and status, move to todo so scheduler re-dispatches await scopedStore.updateTask(req.params.id, { assigneeUserId: null, status: null, }); const task = await scopedStore.moveTask(req.params.id, "todo"); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT" || err?.message?.includes("not found")) { throw notFound(err.message ?? "Task not found"); } else { rethrowAsApiError(err); } } }); // Acquire checkout lease for a task router.post("/tasks/:id/checkout", async (req, res) => { try { const { agentId } = req.body ?? {}; if (typeof agentId !== "string" || agentId.trim().length === 0) { throw badRequest("agentId is required"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), taskStore: scopedStore, }); await agentStore.init(); const task = await agentStore.checkoutTask(agentId, req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.name === "CheckoutConflictError") { res.status(409).json({ error: "Task is already checked out", currentHolder: err.currentHolderId, taskId: err.taskId, }); return; } if (err?.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); // Release checkout lease for a task router.post("/tasks/:id/release", async (req, res) => { try { const { agentId } = req.body ?? {}; if (typeof agentId !== "string" || agentId.trim().length === 0) { throw badRequest("agentId is required"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), taskStore: scopedStore, }); await agentStore.init(); const task = await agentStore.releaseTask(agentId, req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.message?.includes("not the checkout holder")) { throw new ApiError(403, "Not the checkout holder"); } if (err?.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); // Force release checkout lease for a task router.post("/tasks/:id/force-release", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir(), taskStore: scopedStore, }); await agentStore.init(); const task = await agentStore.forceReleaseTask(req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); // Get checkout lease state for a task router.get("/tasks/:id/checkout", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task) { throw notFound("Task not found"); } res.json({ checkedOutBy: task.checkedOutBy ?? null, checkedOutAt: task.checkedOutAt ?? null, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // Delete task router.delete("/tasks/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.deleteTask(req.params.id); res.json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/remotes * Returns GitHub remotes from the current git repository. * Response: Array of GitRemote objects [{ name: string, owner: string, repo: string, url: string }] */ router.get("/git/remotes", (_req, res) => { try { const rootDir = store.getRootDir(); const remotes = getGitHubRemotes(rootDir); res.json(remotes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/remotes/detailed * Returns all git remotes with their fetch and push URLs. * Response: Array of GitRemoteDetailed objects [{ name: string, fetchUrl: string, pushUrl: string }] */ router.get("/git/remotes/detailed", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const remotes = listGitRemotes(rootDir); res.json(remotes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/remotes * Add a new git remote. * Body: { name: string, url: string } */ router.post("/git/remotes", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name, url } = req.body; if (!name || typeof name !== "string") { throw badRequest("name is required"); } if (!url || typeof url !== "string") { throw badRequest("url is required"); } addGitRemote(name, url, rootDir); res.status(201).json({ name, added: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("Invalid remote name")) { throw badRequest(err.message); } else if (err.message?.includes("Invalid git URL")) { throw badRequest(err.message); } else if (err.message?.includes("already exists")) { throw conflict(err.message); } else { rethrowAsApiError(err); } } }); /** * DELETE /api/git/remotes/:name * Remove a git remote. */ router.delete("/git/remotes/:name", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; removeGitRemote(name, rootDir); res.json({ name, removed: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("Invalid remote name")) { throw badRequest(err.message); } else if (err.message?.includes("does not exist")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * PATCH /api/git/remotes/:name * Rename a git remote. * Body: { newName: string } */ router.patch("/git/remotes/:name", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; const { newName } = req.body; if (!newName || typeof newName !== "string") { throw badRequest("newName is required"); } renameGitRemote(name, newName, rootDir); res.json({ oldName: name, newName, renamed: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("Invalid")) { throw badRequest(err.message); } else if (err.message?.includes("does not exist")) { throw notFound(err.message); } else if (err.message?.includes("already exists")) { throw conflict(err.message); } else { rethrowAsApiError(err); } } }); /** * PUT /api/git/remotes/:name/url * Update the URL for a git remote. * Body: { url: string } */ router.put("/git/remotes/:name/url", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; const { url } = req.body; if (!url || typeof url !== "string") { throw badRequest("url is required"); } setGitRemoteUrl(name, url, rootDir); res.json({ name, url, updated: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("Invalid")) { throw badRequest(err.message); } else if (err.message?.includes("does not exist")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/git/status * Returns current git status: branch, commit hash, dirty state, ahead/behind counts. * Response: { branch: string, commit: string, isDirty: boolean, ahead: number, behind: number } */ router.get("/git/status", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const status = getGitStatus(rootDir); if (!status) { throw internalError("Failed to get git status"); } res.json(status); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/commits * Returns recent commits (default 20, configurable via ?limit=). * Response: Array of GitCommit objects */ router.get("/git/commits", (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const limit = Math.min(parseInt(req.query.limit as string, 10) || 20, 100); const commits = getGitCommits(limit, rootDir); res.json(commits); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/commits/:hash/diff * Returns diff for a specific commit (stat + patch). * Response: { stat: string, patch: string } */ router.get("/git/commits/:hash/diff", (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { hash } = req.params; // Validate hash format (only hex characters, 7-40 chars) if (!/^[a-f0-9]{7,40}$/i.test(hash)) { throw badRequest("Invalid commit hash format"); } const diff = getCommitDiff(hash, rootDir); if (!diff) { throw notFound("Commit not found"); } res.json(diff); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/commits/ahead * Returns local commits ahead of the upstream tracking branch (commits that would be pushed). * Response: Array of GitCommit objects (empty when no upstream is configured) */ router.get("/git/commits/ahead", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const commits = getAheadCommits(rootDir); res.json(commits); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/remotes/:name/commits * Returns recent commits for a specific remote tracking ref. * Query: ?ref=branchName (defaults to HEAD of the remote's default branch) * Query: ?limit=N (defaults to 10, max 50) * Response: Array of GitCommit objects */ router.get("/git/remotes/:name/commits", (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; if (!isValidBranchName(name)) { throw badRequest("Invalid remote name"); } const ref = req.query.ref as string | undefined; const limit = Math.min(parseInt(req.query.limit as string, 10) || 10, 50); // Build the full remote ref: if ref is given, use "remote/ref", otherwise use "remote/HEAD" let remoteRef: string; if (ref) { if (!isValidGitRef(ref)) { throw badRequest("Invalid ref name"); } // Strip any leading "refs/" or remote prefix the user might accidentally include const cleanRef = ref.replace(/^refs\/(heads\/)?/, ""); // If the ref already starts with the remote name, use it as-is if (cleanRef.startsWith(`${name}/`)) { remoteRef = cleanRef; } else { remoteRef = `${name}/${cleanRef}`; } } else { // Default: try remote/HEAD symbolic ref, fall back to remote/main, remote/master try { const headRef = execSync(`git symbolic-ref refs/remotes/${name}/HEAD`, { encoding: "utf-8", timeout: 5000, cwd: rootDir, }).trim(); // symbolic-ref returns full ref like refs/remotes/origin/main remoteRef = headRef.replace(/^refs\/remotes\//, ""); } catch { // Try common defaults try { execSync(`git rev-parse --verify "${name}/main"`, { encoding: "utf-8", timeout: 5000, cwd: rootDir, }); remoteRef = `${name}/main`; } catch { try { execSync(`git rev-parse --verify "${name}/master"`, { encoding: "utf-8", timeout: 5000, cwd: rootDir, }); remoteRef = `${name}/master`; } catch { // Remote exists but no common branch found res.json([]); return; } } } } const commits = getRemoteCommits(remoteRef, limit, rootDir); res.json(commits); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/branches * Returns all local branches with current indicator, remote tracking info, and last commit date. * Response: Array of GitBranch objects */ router.get("/git/branches", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const branches = getGitBranches(rootDir); res.json(branches); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/branches/:name/commits * Returns recent commits for a specific branch. * Query params: limit (default 10, max 100) * Response: Array of GitCommit objects */ router.get("/git/branches/:name/commits", (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; if (!isValidGitRef(name)) { throw badRequest("Invalid branch name"); } const limit = Math.min(Math.max(parseInt(String(req.query.limit)) || 10, 1), 100); const commits = getGitCommitsForBranch(name, limit, rootDir); res.json(commits); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/worktrees * Returns all worktrees with path, branch, isMain, and associated task ID. * Response: Array of GitWorktree objects */ router.get("/git/worktrees", async (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } // Get tasks to correlate with worktrees const tasks = await store.listTasks(); const worktrees = getGitWorktrees(tasks, rootDir); res.json(worktrees); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Git Action Routes ───────────────────────────────────────────── /** * POST /api/git/branches * Create a new branch from current HEAD or specified base. * Body: { name: string, base?: string } */ router.post("/git/branches", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name, base } = req.body; if (!name || typeof name !== "string") { throw badRequest("name is required"); } const branchName = createGitBranch(name, base, rootDir); res.status(201).json({ name: branchName, created: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("Invalid branch name")) { throw badRequest(err.message); } else if (err.message.includes("already exists")) { throw conflict(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/git/branches/:name/checkout * Checkout an existing branch. */ router.post("/git/branches/:name/checkout", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; checkoutGitBranch(name, rootDir); res.json({ checkedOut: name }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("Invalid branch name")) { throw badRequest(err.message); } else if (err.message.includes("Uncommitted changes")) { throw conflict(err.message); } else { rethrowAsApiError(err); } } }); /** * DELETE /api/git/branches/:name * Delete a branch. * Query: ?force=true to force delete (even with unmerged commits) */ router.delete("/git/branches/:name", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { name } = req.params; const force = req.query.force === "true"; deleteGitBranch(name, force, rootDir); res.json({ deleted: name }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("Invalid branch name")) { throw badRequest(err.message); } else if (err.message.includes("Cannot delete branch") || err.message.includes("is currently checked out")) { throw conflict(err.message); } else if (err.message.includes("not fully merged")) { throw conflict("Branch has unmerged commits. Use force=true to delete anyway."); } else { rethrowAsApiError(err); } } }); /** * POST /api/git/fetch * Fetch from origin or specified remote. * Body: { remote?: string } */ router.post("/git/fetch", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { remote } = req.body; const result = fetchGitRemote(remote || "origin", rootDir); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("Invalid remote name")) { throw badRequest(err.message); } else if (err.message.includes("Failed to connect")) { throw new ApiError(503, err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/git/pull * Pull the current branch. */ router.post("/git/pull", async (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const result = pullGitBranch(rootDir); if (result.conflict) { throw new ApiError(409, result.message ?? "Merge conflict detected. Resolve manually.", { ...result, }); } res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/push * Push the current branch. */ router.post("/git/push", async (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const result = pushGitBranch(rootDir); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("rejected") || err.message.includes("Pull latest")) { throw conflict(err.message); } else if (err.message.includes("Failed to connect")) { throw new ApiError(503, err.message); } else { rethrowAsApiError(err); } } }); // ── Git Stash, Stage, Commit Routes ──────────────────────────────── /** * GET /api/git/stashes * Returns list of stash entries. */ router.get("/git/stashes", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const stashes = getGitStashList(rootDir); res.json(stashes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/stashes * Create a new stash. * Body: { message?: string } */ router.post("/git/stashes", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { message } = req.body; const result = createGitStash(message, rootDir); res.status(201).json({ message: result }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("No local changes")) { throw badRequest(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/git/stashes/:index/apply * Apply a stash entry. * Body: { drop?: boolean } */ router.post("/git/stashes/:index/apply", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const index = parseInt(req.params.index, 10); if (isNaN(index) || index < 0) { throw badRequest("Invalid stash index"); } const { drop } = req.body; const result = applyGitStash(index, drop === true, rootDir); res.json({ message: result }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * DELETE /api/git/stashes/:index * Drop a stash entry. */ router.delete("/git/stashes/:index", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const index = parseInt(req.params.index, 10); if (isNaN(index) || index < 0) { throw badRequest("Invalid stash index"); } const result = dropGitStash(index, rootDir); res.json({ message: result }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/diff * Returns working directory diff (unstaged changes). */ router.get("/git/diff", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const diff = getGitWorkingDiff(rootDir); res.json(diff); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/git/changes * Returns file changes (staged and unstaged). */ router.get("/git/changes", (_req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const changes = getGitFileChanges(rootDir); res.json(changes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/stage * Stage specific files. * Body: { files: string[] } */ router.post("/git/stage", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { files } = req.body; if (!Array.isArray(files) || files.length === 0) { throw badRequest("files array is required"); } const staged = stageGitFiles(files, rootDir); res.json({ staged }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/unstage * Unstage specific files. * Body: { files: string[] } */ router.post("/git/unstage", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { files } = req.body; if (!Array.isArray(files) || files.length === 0) { throw badRequest("files array is required"); } const unstaged = unstageGitFiles(files, rootDir); res.json({ unstaged }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/git/commit * Create a commit with staged changes. * Body: { message: string } */ router.post("/git/commit", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { message } = req.body; if (!message || typeof message !== "string" || !message.trim()) { throw badRequest("Commit message is required"); } const result = createGitCommit(message, rootDir); res.status(201).json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("No staged changes")) { throw badRequest(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/git/discard * Discard working directory changes for specific files. * Body: { files: string[] } */ router.post("/git/discard", async (req, res) => { try { const rootDir = store.getRootDir(); if (!isGitRepo(rootDir)) { throw badRequest("Not a git repository"); } const { files } = req.body; if (!Array.isArray(files) || files.length === 0) { throw badRequest("files array is required"); } const discarded = discardGitChanges(files, rootDir); res.json({ discarded }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── GitHub Import Routes ────────────────────────────────────────── /** * POST /api/github/issues/fetch * Fetch open issues from a GitHub repository. * Body: { owner: string, repo: string, limit?: number, labels?: string[] } * Returns: Array of GitHubIssue objects (filtered, no PRs) */ router.post("/github/issues/fetch", async (req, res) => { try { const { owner, repo, limit = 30, labels } = req.body; if (!owner || typeof owner !== "string") { throw badRequest("owner is required"); } if (!repo || typeof repo !== "string") { throw badRequest("repo is required"); } // Check gh authentication if (!isGhAuthenticated()) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } const client = new GitHubClient(); try { const issues = await client.listIssues(owner, repo, { limit, labels }); res.json(issues); } catch (err: any) { if (err instanceof ApiError) { throw err; } // Handle specific error cases from gh CLI const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes("not found") || errorMessage.includes("404")) { throw notFound(`Repository not found: ${owner}/${repo}`); } if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); } } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/github/issues/import * Import a specific GitHub issue as a fn task. * Body: { owner: string, repo: string, issueNumber: number } * Returns: Created Task object */ router.post("/github/issues/import", async (req, res) => { try { const { owner, repo, issueNumber } = req.body; if (!owner || typeof owner !== "string") { throw badRequest("owner is required"); } if (!repo || typeof repo !== "string") { throw badRequest("repo is required"); } if (!issueNumber || typeof issueNumber !== "number" || issueNumber < 1) { throw badRequest("issueNumber is required and must be a positive number"); } // Check gh authentication if (!isGhAuthenticated()) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } const client = new GitHubClient(); const scopedStore = await getScopedStore(req); let issue: { number: number; title: string; body: string | null; html_url: string; state: "open" | "closed"; } | null; try { issue = await client.getIssue(owner, repo, issueNumber); // getIssue returns null when the issue doesn't exist OR when it's a PR // We return a 400 error indicating it might be a PR (consistent with old behavior) if (issue === null) { throw badRequest(`#${issueNumber} is a pull request, not an issue`); } } catch (err: any) { if (err instanceof ApiError) { throw err; } const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes("not found") || errorMessage.includes("404")) { throw notFound(`Issue #${issueNumber} not found in ${owner}/${repo}`); } if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); } // Check if already imported const existingTasks = await scopedStore.listTasks(); const sourceUrl = issue.html_url; for (const existingTask of existingTasks) { if (existingTask.description.includes(sourceUrl)) { throw new ApiError(409, `Issue #${issueNumber} already imported as ${existingTask.id}`, { existingTaskId: existingTask.id, }); } } // Create the task const title = issue.title.slice(0, 200); const body = issue.body?.trim() || "(no description)"; const description = `${body}\n\nSource: ${sourceUrl}`; const task = await scopedStore.createTask({ title: title || undefined, description, column: "triage", dependencies: [], }); // Log the import action await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl); res.status(201).json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/github/issues/batch-import * Import multiple GitHub issues as fn tasks with throttling. * Body: { owner: string, repo: string, issueNumbers: number[], delayMs?: number } * Returns: { results: BatchImportResult[] } */ // Batch import rate limiter: max 1 request per 10 seconds per IP const batchImportRateLimiter = (() => { const clients = new Map(); batchImportInstances.push(clients); if (!batchImportCleanupInterval) { batchImportCleanupInterval = setInterval(() => { const now = Date.now(); for (const instanceClients of batchImportInstances) { for (const [ip, resetTime] of instanceClients) { if (now >= resetTime) { instanceClients.delete(ip); } } } }, batchImportWindowMs); } return (req: Request, res: Response, next: NextFunction): void => { const ip = req.ip ?? req.socket.remoteAddress ?? "unknown"; const now = Date.now(); const resetTime = clients.get(ip); if (resetTime && now < resetTime) { const retryAfter = Math.ceil((resetTime - now) / 1000); res.setHeader("Retry-After", String(retryAfter)); throw rateLimited("Batch import rate limit exceeded. Try again in a few seconds."); } clients.set(ip, now + batchImportWindowMs); next(); }; })(); router.post("/github/issues/batch-import", batchImportRateLimiter, async (req, res) => { try { const { owner, repo, issueNumbers, delayMs } = req.body; // Validate owner if (!owner || typeof owner !== "string") { throw badRequest("owner is required"); } // Validate repo if (!repo || typeof repo !== "string") { throw badRequest("repo is required"); } // Validate issueNumbers if (!Array.isArray(issueNumbers)) { throw badRequest("issueNumbers is required and must be an array"); } if (issueNumbers.length === 0) { throw badRequest("issueNumbers must contain at least 1 issue number"); } if (issueNumbers.length > 50) { throw badRequest("issueNumbers cannot contain more than 50 issue numbers"); } if (!issueNumbers.every((n) => typeof n === "number" && n > 0 && Number.isInteger(n))) { throw badRequest("issueNumbers must contain only positive integers"); } const token = process.env.GITHUB_TOKEN; const githubClient = new GitHubClient(token); const scopedStore = await getScopedStore(req); // Get existing tasks to check for duplicates const existingTasks = await scopedStore.listTasks(); // Process issues sequentially with throttling const results: Array<{ issueNumber: number; success: boolean; taskId?: string; error?: string; skipped?: boolean; retryAfter?: number; }> = []; for (const issueNumber of issueNumbers) { const url = `https://api.github.com/repos/${encodeURIComponent(owner)}/${encodeURIComponent(repo)}/issues/${issueNumber}`; // Use throttled fetch to avoid rate limits const fetchResult = await githubClient.fetchThrottled<{ number: number; title: string; body: string | null; html_url: string; pull_request?: unknown; }>(url, {}, { delayMs: delayMs ?? 1000, maxRetries: 3 }); if (!fetchResult.success) { results.push({ issueNumber, success: false, error: fetchResult.error ?? "Failed to fetch issue", retryAfter: fetchResult.retryAfter, }); continue; } const issue = fetchResult.data!; // Check if it's a pull request if (issue.pull_request) { results.push({ issueNumber, success: false, error: "This is a pull request, not an issue", }); continue; } // Check if already imported const sourceUrl = issue.html_url; const existingTask = existingTasks.find((t) => t.description.includes(sourceUrl)); if (existingTask) { results.push({ issueNumber, success: true, skipped: true, taskId: existingTask.id, }); continue; } // Create the task const title = issue.title.slice(0, 200); const body = issue.body?.trim() || "(no description)"; const description = `${body}\n\nSource: ${sourceUrl}`; try { const task = await scopedStore.createTask({ title: title || undefined, description, column: "triage", dependencies: [], }); // Log the import action await scopedStore.logEntry(task.id, "Imported from GitHub", sourceUrl); results.push({ issueNumber, success: true, taskId: task.id, }); // Add to existingTasks to avoid duplicate imports within the same batch existingTasks.push({ ...task, description }); } catch (err: any) { if (err instanceof ApiError) { throw err; } results.push({ issueNumber, success: false, error: err.message ?? "Failed to create task", }); } } res.json({ results }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/github/pulls/fetch * Fetch open pull requests from a GitHub repository. * Body: { owner: string, repo: string, limit?: number } * Returns: Array of GitHubPull objects */ router.post("/github/pulls/fetch", async (req, res) => { try { const { owner, repo, limit = 30 } = req.body; if (!owner || typeof owner !== "string") { throw badRequest("owner is required"); } if (!repo || typeof repo !== "string") { throw badRequest("repo is required"); } // Check gh authentication if (!isGhAuthenticated()) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } const client = new GitHubClient(); try { const pulls = await client.listPullRequests(owner, repo, { limit }); res.json(pulls); } catch (err: any) { if (err instanceof ApiError) { throw err; } // Handle specific error cases from gh CLI const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes("not found") || errorMessage.includes("404")) { throw notFound(`Repository not found: ${owner}/${repo}`); } if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); } } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/github/pulls/import * Import a specific GitHub pull request as a fn review task. * Body: { owner: string, repo: string, prNumber: number } * Returns: Created Task object */ router.post("/github/pulls/import", async (req, res) => { try { const { owner, repo, prNumber } = req.body; if (!owner || typeof owner !== "string") { throw badRequest("owner is required"); } if (!repo || typeof repo !== "string") { throw badRequest("repo is required"); } if (!prNumber || typeof prNumber !== "number" || prNumber < 1) { throw badRequest("prNumber is required and must be a positive number"); } // Check gh authentication if (!isGhAuthenticated()) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } const client = new GitHubClient(); const scopedStore = await getScopedStore(req); let pr: { number: number; title: string; body: string | null; html_url: string; headBranch: string; baseBranch: string; state: "open" | "closed" | "merged"; } | null; try { pr = await client.getPullRequest(owner, repo, prNumber); if (pr === null) { throw notFound(`PR #${prNumber} not found in ${owner}/${repo}`); } } catch (err: any) { if (err instanceof ApiError) { throw err; } const errorMessage = err instanceof Error ? err.message : String(err); if (errorMessage.includes("not found") || errorMessage.includes("404")) { throw notFound(`PR #${prNumber} not found in ${owner}/${repo}`); } if (errorMessage.includes("authentication") || errorMessage.includes("401") || errorMessage.includes("403")) { throw unauthorized("Not authenticated with GitHub. Run `gh auth login`."); } throw new ApiError(502, `GitHub CLI error: ${errorMessage}`); } // Check if already imported const existingTasks = await scopedStore.listTasks(); const sourceUrl = pr.html_url; for (const existingTask of existingTasks) { if (existingTask.description.includes(sourceUrl)) { throw new ApiError(409, `PR #${prNumber} already imported as ${existingTask.id}`, { existingTaskId: existingTask.id, }); } } // Create the task with "Review PR:" prefix const title = `Review PR #${pr.number}: ${pr.title.slice(0, 180)}`; const body = pr.body?.trim() || "(no description)"; const description = `Review and address any issues in this pull request.\n\nPR: ${sourceUrl}\nBranch: ${pr.headBranch} → ${pr.baseBranch}\n\n${body}`; const task = await scopedStore.createTask({ title: title || undefined, description, column: "triage", dependencies: [], }); // Log the import action await scopedStore.logEntry(task.id, "Imported PR from GitHub", sourceUrl); res.status(201).json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ---------- Auth routes ---------- registerAuthRoutes(router, options?.authStorage); // ── PR Management Routes ───────────────────────────────────────── /** * POST /api/tasks/:id/pr/create * Create a GitHub PR for an in-review task. * Body: { title: string, body?: string, base?: string } * Returns: Created PrInfo */ router.post("/tasks/:id/pr/create", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { title, body, base } = req.body; if (!title || typeof title !== "string") { throw badRequest("title is required and must be a string"); } // Get task and validate const task = await scopedStore.getTask(req.params.id); if (task.column !== "in-review") { throw badRequest("Task must be in 'in-review' column to create a PR"); } if (task.prInfo) { throw conflict(`Task already has PR #${task.prInfo.number}: ${task.prInfo.url}`); } // Determine branch name from task const branchName = `fusion/${task.id.toLowerCase()}`; // Get owner/repo from git remote or GITHUB_REPOSITORY env let owner: string; let repo: string; const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [o, r] = envRepo.split("/"); owner = o; repo = r; } else { const gitRepo = getCurrentRepo(scopedStore.getRootDir()); if (!gitRepo) { throw badRequest("Could not determine GitHub repository. Set GITHUB_REPOSITORY env var or configure git remote."); } owner = gitRepo.owner; repo = gitRepo.repo; } // Check rate limit const repoKey = `${owner}/${repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { const resetTime = githubRateLimiter.getResetTime(repoKey); const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined; throw new ApiError(429, "GitHub API rate limit exceeded for this repository", { retryAfter, resetAt: resetTime?.toISOString(), }); } // Create the PR const client = new GitHubClient(); const prInfo = await client.createPr({ owner, repo, title, body, head: branchName, base, }); // Store PR info await scopedStore.updatePrInfo(task.id, prInfo); await scopedStore.logEntry(task.id, "Created PR", `PR #${prInfo.number}: ${prInfo.url}`); res.status(201).json(prInfo); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else if (err.message?.includes("already exists")) { throw conflict(err.message); } else if (err.message?.includes("No commits between")) { throw badRequest("Branch has no commits. Push changes before creating PR."); } else { rethrowAsApiError(err, "Failed to create PR"); } } }); /** * POST /api/github/webhooks * GitHub App webhook endpoint for badge updates. * Accepts signed webhook deliveries for pull_request, issues, and issue_comment events. * Verifies X-Hub-Signature-256, fetches canonical badge state, and updates matching tasks. * * Responses: * - 200: Valid ping event * - 202: Valid but unsupported/irrelevant event * - 401: Missing required webhook auth headers * - 403: Signature mismatch/tampering detected * - 503: GitHub App configuration missing or incomplete * - 500: Installation token refresh failed */ router.post("/github/webhooks", async (req, res) => { const config = getGitHubAppConfig(); if (!config) { throw new ApiError(503, "GitHub App not configured"); } // Get raw body (Buffer from express.raw() middleware) const rawBody = req.body as Buffer; if (!Buffer.isBuffer(rawBody)) { throw badRequest("Invalid request body"); } // Verify signature const signatureHeader = req.headers["x-hub-signature-256"] as string | undefined; const verification = verifyWebhookSignature(rawBody, signatureHeader, config.webhookSecret); if (!verification.valid) { throw new ApiError(403, verification.error ?? "Invalid signature"); } // Parse payload after verification let payload: unknown; try { payload = JSON.parse(rawBody.toString("utf-8")); } catch { throw badRequest("Invalid JSON payload"); } // Classify event const eventType = req.headers["x-github-event"] as string | undefined; const classification = classifyWebhookEvent(eventType, payload); // Handle ping if (eventType === "ping") { res.status(200).json({ message: "Pong" }); return; } // Unsupported event if (!classification.supported) { res.status(202).json({ message: "Event type not supported" }); return; } // Not relevant for badge updates (e.g., issue_comment on regular issue) if (!classification.relevant) { res.status(202).json({ message: "Event not relevant for badges" }); return; } // Missing required data if (!classification.owner || !classification.repo || classification.number === undefined || !classification.installationId) { throw badRequest("Missing repository or installation data"); } // Fetch installation token const installationToken = await GitHubClient.fetchInstallationToken( classification.installationId, config.appId, config.privateKey, ); if (!installationToken) { throw internalError("Failed to fetch installation token"); } // Fetch canonical badge state let badgeData: Omit | Omit | null = null; if (classification.resourceType === "pr") { badgeData = await GitHubClient.fetchPrWithInstallationToken( classification.owner, classification.repo, classification.number, installationToken, ); } else { badgeData = await GitHubClient.fetchIssueWithInstallationToken( classification.owner, classification.repo, classification.number, installationToken, ); } if (!badgeData) { res.status(202).json({ message: "Badge resource not found or inaccessible" }); return; } // Find all matching tasks by badge URL const tasks = await store.listTasks(); const matchingTasks: Array<{ id: string; resourceType: "pr" | "issue"; current: unknown }> = []; for (const task of tasks) { if (classification.resourceType === "pr" && task.prInfo) { const parsed = parseBadgeUrl(task.prInfo.url); if (parsed && parsed.owner.toLowerCase() === classification.owner!.toLowerCase() && parsed.repo.toLowerCase() === classification.repo!.toLowerCase() && parsed.number === classification.number) { matchingTasks.push({ id: task.id, resourceType: "pr", current: task.prInfo }); } } else if (classification.resourceType === "issue" && task.issueInfo) { const parsed = parseBadgeUrl(task.issueInfo.url); if (parsed && parsed.owner.toLowerCase() === classification.owner!.toLowerCase() && parsed.repo.toLowerCase() === classification.repo!.toLowerCase() && parsed.number === classification.number) { matchingTasks.push({ id: task.id, resourceType: "issue", current: task.issueInfo }); } } } if (matchingTasks.length === 0) { res.status(202).json({ message: "No tasks linked to this resource" }); return; } // Update matching tasks const checkedAt = new Date().toISOString(); let badgeFieldsChanged = false; for (const match of matchingTasks) { if (match.resourceType === "pr") { const current = match.current as PrInfo; const next = { ...(badgeData as Omit), lastCheckedAt: checkedAt }; const changed = hasPrBadgeFieldsChanged(current, badgeData as Omit); if (changed || current.lastCheckedAt !== checkedAt) { await store.updatePrInfo(match.id, next); if (changed) badgeFieldsChanged = true; } } else { const current = match.current as import("@fusion/core").IssueInfo; const next = { ...(badgeData as Omit), lastCheckedAt: checkedAt }; const changed = hasIssueBadgeFieldsChanged(current, badgeData as Omit); if (changed || current.lastCheckedAt !== checkedAt) { await store.updateIssueInfo(match.id, next); if (changed) badgeFieldsChanged = true; } } } res.status(200).json({ updated: matchingTasks.length, tasks: matchingTasks.map(m => m.id), badgeFieldsChanged, }); }); /** * GET /api/tasks/:id/pr/status * Get cached PR status for a task. Triggers background refresh if stale (>5 min). * Uses only persisted badge timestamps (no in-memory poller state). */ router.get("/tasks/:id/pr/status", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task.prInfo) { throw notFound("Task has no associated PR"); } // Check if data is stale (>5 minutes since last check) const fiveMinutesMs = 5 * 60 * 1000; const lastChecked = task.prInfo.lastCheckedAt || task.updatedAt; const lastCheckedTime = new Date(lastChecked).getTime(); const isStale = Date.now() - lastCheckedTime > fiveMinutesMs; // Return cached data immediately res.json({ prInfo: task.prInfo, stale: isStale, automationStatus: task.status ?? null, }); // Trigger background refresh if stale (don't await, let it run) if (isStale) { refreshPrInBackground(scopedStore, task.id, task.prInfo, githubToken); } } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err); } } }); /** * POST /api/tasks/:id/pr/refresh * Force refresh PR status from GitHub API. * Returns: Updated PrInfo */ router.post("/tasks/:id/pr/refresh", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task.prInfo) { throw notFound("Task has no associated PR"); } // Get owner/repo from badge URL first, then fall back to env/git let owner: string; let repo: string; const badgeParsed = parseBadgeUrl(task.prInfo.url); if (badgeParsed) { owner = badgeParsed.owner; repo = badgeParsed.repo; } else { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [o, r] = envRepo.split("/"); owner = o; repo = r; } else { const gitRepo = getCurrentRepo(scopedStore.getRootDir()); if (!gitRepo) { throw badRequest("Could not determine GitHub repository"); } owner = gitRepo.owner; repo = gitRepo.repo; } } // Check rate limit const repoKey = `${owner}/${repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { const resetTime = githubRateLimiter.getResetTime(repoKey); const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined; throw new ApiError(429, "GitHub API rate limit exceeded for this repository", { retryAfter, resetAt: resetTime?.toISOString(), }); } // Fetch fresh PR status + merge readiness const client = new GitHubClient(); const mergeStatus = await client.getPrMergeStatus(owner, repo, task.prInfo.number); const prInfo = { ...mergeStatus.prInfo, lastCheckedAt: new Date().toISOString(), }; // Update stored PR info await scopedStore.updatePrInfo(task.id, prInfo); res.json({ prInfo, mergeReady: mergeStatus.mergeReady, blockingReasons: mergeStatus.blockingReasons, reviewDecision: mergeStatus.reviewDecision, checks: mergeStatus.checks, automationStatus: task.status ?? null, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/tasks/:id/issue/status * Get cached issue status for a task. Triggers background refresh if stale (>5 min). * Uses only persisted badge timestamps (no in-memory poller state). */ router.get("/tasks/:id/issue/status", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task.issueInfo) { throw notFound("Task has no associated issue"); } const fiveMinutesMs = 5 * 60 * 1000; const lastChecked = task.issueInfo.lastCheckedAt || task.updatedAt; const lastCheckedTime = new Date(lastChecked).getTime(); const isStale = Date.now() - lastCheckedTime > fiveMinutesMs; res.json({ issueInfo: task.issueInfo, stale: isStale, }); if (isStale) { refreshIssueInBackground(scopedStore, task.id, task.issueInfo, githubToken); } } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err); } } }); /** * POST /api/tasks/:id/issue/refresh * Force refresh issue status from GitHub API. * Returns: Updated IssueInfo */ router.post("/tasks/:id/issue/refresh", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task.issueInfo) { throw notFound("Task has no associated issue"); } let owner: string; let repo: string; // Get owner/repo from badge URL first, then fall back to env/git const badgeParsed = parseBadgeUrl(task.issueInfo.url); if (badgeParsed) { owner = badgeParsed.owner; repo = badgeParsed.repo; } else { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [o, r] = envRepo.split("/"); owner = o; repo = r; } else { const gitRepo = getCurrentRepo(scopedStore.getRootDir()); if (!gitRepo) { throw badRequest("Could not determine GitHub repository"); } owner = gitRepo.owner; repo = gitRepo.repo; } } const repoKey = `${owner}/${repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { const resetTime = githubRateLimiter.getResetTime(repoKey); const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined; throw new ApiError(429, "GitHub API rate limit exceeded for this repository", { retryAfter, resetAt: resetTime?.toISOString(), }); } const client = new GitHubClient(githubToken); const issueInfo = await client.getIssueStatus(owner, repo, task.issueInfo.number); if (!issueInfo) { throw notFound(`Issue #${task.issueInfo.number} not found in ${owner}/${repo}`); } const updatedIssueInfo = { ...issueInfo, lastCheckedAt: new Date().toISOString(), }; await scopedStore.updateIssueInfo(task.id, updatedIssueInfo); res.json(updatedIssueInfo); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/github/batch/status * Refresh issue/PR badge status for up to 100 tasks in grouped GitHub requests. * Body: { taskIds: string[] } */ router.post("/github/batch/status", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { taskIds } = (req.body ?? {}) as import("@fusion/core").BatchStatusRequest; if (!Array.isArray(taskIds)) { throw badRequest("taskIds must be an array"); } if (taskIds.some((taskId) => typeof taskId !== "string" || taskId.trim().length === 0)) { throw badRequest("taskIds must contain non-empty strings"); } if (taskIds.length > 100) { throw badRequest("taskIds must contain at most 100 items"); } if (taskIds.length === 0) { res.json({ results: {} } satisfies BatchStatusResponse); return; } const fallbackRepo = getDefaultGitHubRepo(scopedStore); const results: BatchStatusResult = {}; const issueGroups = new Map; taskIds: Set }>(); const prGroups = new Map; taskIds: Set }>(); const tasksById = new Map>>(); for (const taskId of taskIds) { try { const task = await scopedStore.getTask(taskId); tasksById.set(taskId, task); const entry = ensureBatchStatusEntry(results, taskId); if (task.issueInfo) entry.issueInfo = task.issueInfo; if (task.prInfo) entry.prInfo = task.prInfo; entry.stale = Boolean( (task.issueInfo && isBatchStatusStale(task.issueInfo, task.updatedAt)) || (task.prInfo && isBatchStatusStale(task.prInfo, task.updatedAt)), ); if (!task.issueInfo && !task.prInfo) { appendBatchStatusError(results, taskId, "Task has no GitHub badge metadata"); continue; } if (task.issueInfo) { const issueRepo = parseGitHubBadgeUrl(task.issueInfo.url) ?? fallbackRepo; if (!issueRepo) { appendBatchStatusError(results, taskId, "Could not determine GitHub repository for issue badge"); } else { const repoKey = `${issueRepo.owner}/${issueRepo.repo}`; const group = issueGroups.get(repoKey) ?? { owner: issueRepo.owner, repo: issueRepo.repo, numbers: new Set(), taskIds: new Set(), }; group.numbers.add(task.issueInfo.number); group.taskIds.add(taskId); issueGroups.set(repoKey, group); } } if (task.prInfo) { const prRepo = parseGitHubBadgeUrl(task.prInfo.url) ?? fallbackRepo; if (!prRepo) { appendBatchStatusError(results, taskId, "Could not determine GitHub repository for PR badge"); } else { const repoKey = `${prRepo.owner}/${prRepo.repo}`; const group = prGroups.get(repoKey) ?? { owner: prRepo.owner, repo: prRepo.repo, numbers: new Set(), taskIds: new Set(), }; group.numbers.add(task.prInfo.number); group.taskIds.add(taskId); prGroups.set(repoKey, group); } } } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.code === "ENOENT") { appendBatchStatusError(results, taskId, `Task ${taskId} not found`); } else { appendBatchStatusError(results, taskId, err.message || `Failed to load task ${taskId}`); } } } const client = new GitHubClient(githubToken); const applyIssueGroup = async (group: { owner: string; repo: string; numbers: Set; taskIds: Set }) => { const repoKey = `${group.owner}/${group.repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { const resetTime = githubRateLimiter.getResetTime(repoKey); const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined; throw new ApiError(429, "GitHub API rate limit exceeded for this repository", { retryAfter, resetAt: resetTime?.toISOString(), }); } try { const issueStatuses = await client.getBatchIssueStatus(group.owner, group.repo, [...group.numbers]); const refreshedAt = new Date().toISOString(); for (const taskId of group.taskIds) { const task = tasksById.get(taskId); if (!task?.issueInfo) continue; const issueInfo = issueStatuses.get(task.issueInfo.number); if (!issueInfo) { appendBatchStatusError(results, taskId, `Issue #${task.issueInfo.number} not found in ${group.owner}/${group.repo}`); continue; } const updatedIssueInfo: IssueInfo = { ...issueInfo, lastCheckedAt: refreshedAt, }; await scopedStore.updateIssueInfo(taskId, updatedIssueInfo); const entry = ensureBatchStatusEntry(results, taskId); entry.issueInfo = updatedIssueInfo; entry.stale = entry.prInfo ? isBatchStatusStale(entry.prInfo, task.updatedAt) : false; } } catch (err: any) { if (err instanceof ApiError) { throw err; } for (const taskId of group.taskIds) { appendBatchStatusError(results, taskId, err.message || `Failed to refresh issue badges for ${repoKey}`); } } return true; }; const applyPrGroup = async (group: { owner: string; repo: string; numbers: Set; taskIds: Set }) => { const repoKey = `${group.owner}/${group.repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { const resetTime = githubRateLimiter.getResetTime(repoKey); const retryAfter = resetTime ? Math.max(0, Math.ceil((resetTime.getTime() - Date.now()) / 1000)) : undefined; throw new ApiError(429, "GitHub API rate limit exceeded for this repository", { retryAfter, resetAt: resetTime?.toISOString(), }); } try { const prStatuses = await client.getBatchPrStatus(group.owner, group.repo, [...group.numbers]); const refreshedAt = new Date().toISOString(); for (const taskId of group.taskIds) { const task = tasksById.get(taskId); if (!task?.prInfo) continue; const prInfo = prStatuses.get(task.prInfo.number); if (!prInfo) { appendBatchStatusError(results, taskId, `PR #${task.prInfo.number} not found in ${group.owner}/${group.repo}`); continue; } const updatedPrInfo: PrInfo = { ...prInfo, lastCheckedAt: refreshedAt, }; await scopedStore.updatePrInfo(taskId, updatedPrInfo); const entry = ensureBatchStatusEntry(results, taskId); entry.prInfo = updatedPrInfo; entry.stale = entry.issueInfo ? isBatchStatusStale(entry.issueInfo, task.updatedAt) : false; } } catch (err: any) { if (err instanceof ApiError) { throw err; } for (const taskId of group.taskIds) { appendBatchStatusError(results, taskId, err.message || `Failed to refresh PR badges for ${repoKey}`); } } return true; }; for (const group of issueGroups.values()) { const shouldContinue = await applyIssueGroup(group); if (!shouldContinue) return; } for (const group of prGroups.values()) { const shouldContinue = await applyPrGroup(group); if (!shouldContinue) return; } for (const taskId of taskIds) { ensureBatchStatusEntry(results, taskId); } res.json({ results } satisfies BatchStatusResponse); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to batch refresh GitHub status"); } }); // ── Terminal Routes ───────────────────────────────────────────────── /** * POST /api/terminal/exec * Execute a shell command in the project root directory. * Body: { command: string } * Returns: { sessionId: string } * * Output is streamed via SSE at /api/terminal/sessions/:id/stream */ router.post("/terminal/exec", async (req, res) => { try { const { command } = req.body; if (!command || typeof command !== "string") { throw badRequest("command is required and must be a string"); } if (command.length > 4096) { throw badRequest("command exceeds maximum length of 4096 characters"); } const rootDir = store.getRootDir(); const result = terminalSessionManager.createSession(command, rootDir); if (result.error) { throw new ApiError(403, result.error); } res.status(201).json({ sessionId: result.sessionId }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to execute command"); } }); /** * POST /api/terminal/sessions/:id/kill * Terminate a running terminal session. * Returns: { killed: boolean } */ router.post("/terminal/sessions/:id/kill", (req, res) => { try { const { id } = req.params; const { signal } = req.body; const validSignals: NodeJS.Signals[] = ["SIGTERM", "SIGKILL", "SIGINT"]; const killSignal = validSignals.includes(signal) ? signal : "SIGTERM"; const killed = terminalSessionManager.killSession(id, killSignal); if (!killed) { const session = terminalSessionManager.getSession(id); if (!session) { throw notFound("Session not found"); } else { throw badRequest("Session is not running"); } return; } res.json({ killed: true, sessionId: id }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/terminal/sessions/:id * Get session status and output history. * Returns: { id, command, running, exitCode, output } */ router.get("/terminal/sessions/:id", (req, res) => { try { const session = terminalSessionManager.getSession(req.params.id); if (!session) { throw notFound("Session not found"); } res.json({ id: session.id, command: session.command, running: session.exitCode === null && !session.killed, exitCode: session.exitCode, output: session.output.join(""), startTime: session.startTime.toISOString(), }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/terminal/sessions/:id/stream * SSE endpoint for real-time terminal output streaming. * Events: terminal:output (stdout/stderr), terminal:exit */ router.get("/terminal/sessions/:id/stream", (req, res) => { try { const { id } = req.params; const session = terminalSessionManager.getSession(id); if (!session) { throw notFound("Session not found"); } // Set up SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); // Disable nginx buffering if present // Send initial connection event res.write(`event: connected\ndata: ${JSON.stringify({ sessionId: id })}\n\n`); // Handler for output events const onOutput = (event: import("./terminal.js").TerminalOutputEvent) => { if (event.sessionId !== id) return; const eventName = event.type === "exit" ? "terminal:exit" : "terminal:output"; const data = JSON.stringify({ type: event.type, data: event.data, ...(event.exitCode !== undefined && { exitCode: event.exitCode }), }); res.write(`event: ${eventName}\ndata: ${data}\n\n`); // Close connection on exit after a brief delay to ensure client receives final data if (event.type === "exit") { setTimeout(() => { res.end(); }, 100); } }; // Subscribe to session manager events terminalSessionManager.on("output", onOutput); // Handle client disconnect req.on("close", () => { terminalSessionManager.off("output", onOutput); }); // Handle errors req.on("error", () => { terminalSessionManager.off("output", onOutput); }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── PTY Terminal Routes (WebSocket-based) ──────────────────────────── /** * POST /api/terminal/sessions * Create a new PTY terminal session. * Body: { cwd?: string, cols?: number, rows?: number } * Returns: { sessionId: string, shell: string, cwd: string } */ router.post("/terminal/sessions", async (req, res) => { try { const { cwd, cols, rows } = req.body; const terminalService = getTerminalService(store.getRootDir()); const result = await terminalService.createSession({ cwd, cols: typeof cols === "number" ? cols : undefined, rows: typeof rows === "number" ? rows : undefined, }); if (!result.success) { const statusByCode = { max_sessions: 503, invalid_shell: 400, pty_load_failed: 503, pty_spawn_failed: 500, } as const; throw new ApiError(statusByCode[result.code], result.error, { code: result.code }); } res.status(201).json({ sessionId: result.session.id, shell: result.session.shell, cwd: result.session.cwd, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create terminal session"); } }); /** * GET /api/terminal/sessions * List all active PTY terminal sessions. * Returns: [{ id: string, cwd: string, shell: string, createdAt: string }] */ router.get("/terminal/sessions", async (_req, res) => { try { const terminalService = getTerminalService(store.getRootDir()); const sessions = terminalService.getAllSessions(); res.json( sessions.map((s) => ({ id: s.id, cwd: s.cwd, shell: s.shell, createdAt: s.createdAt.toISOString(), lastActivityAt: s.lastActivityAt.toISOString(), })) ); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to list sessions"); } }); /** * DELETE /api/terminal/sessions/:id * Kill a PTY terminal session. * Returns: { killed: boolean } */ router.delete("/terminal/sessions/:id", (req, res) => { try { const { id } = req.params; const terminalService = getTerminalService(store.getRootDir()); const killed = terminalService.killSession(id); if (!killed) { const session = terminalService.getSession(id); if (!session) { throw notFound("Session not found"); } else { throw badRequest("Failed to kill session"); } return; } res.json({ killed: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── File API Routes ─────────────────────────────────────────────── /** * GET /api/tasks/:id/files * List files in task directory (or worktree if available). * Query param: ?path=relative/path for subdirectory navigation. * Returns: { path: string; entries: FileNode[] } */ router.get("/tasks/:id/files", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { path: subPath } = req.query; const result = await listFiles(scopedStore, req.params.id, typeof subPath === "string" ? subPath : undefined); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * GET /api/tasks/:id/files/:filepath * Read file contents. * Returns: { content: string; mtime: string; size: number } */ router.get("/tasks/:id/files/{*filepath}", async (req, res) => { try { const scopedStore = await getScopedStore(req); const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? ""; const result = await readFile(scopedStore, req.params.id, filePath); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOENT" ? 404 : err.code === "ENOTASK" ? 404 : err.code === "EACCES" ? 403 : err.code === "ETOOLARGE" ? 413 : err.code === "EINVAL" && err.message.includes("Binary file") ? 415 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * POST /api/tasks/:id/files/:filepath * Write file contents. * Body: { content: string } * Returns: { success: true; mtime: string; size: number } */ router.post("/tasks/:id/files/{*filepath}", async (req, res) => { try { const scopedStore = await getScopedStore(req); const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? ""; const { content } = req.body; if (typeof content !== "string") { throw badRequest("content is required and must be a string"); } const result = await writeFile(scopedStore, req.params.id, filePath, content); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOENT" ? 404 : err.code === "ENOTASK" ? 404 : err.code === "EACCES" ? 403 : err.code === "ETOOLARGE" ? 413 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); // ── Workspace File API Routes ───────────────────────────────────── /** * GET /api/workspaces * List available file browser workspaces. * Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> } */ router.get("/workspaces", async (_req, res) => { try { const tasks = await store.listTasks(); res.json({ project: store.getRootDir(), tasks: tasks .filter((task) => typeof task.worktree === "string" && task.worktree.length > 0 && existsSync(task.worktree)) .map((task) => ({ id: task.id, title: task.title, worktree: task.worktree!, })), }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Internal server error"); } }); /** * GET /api/files * List files in the requested workspace. Defaults to the project root when omitted. * Query params: ?workspace=project|TASK-ID and ?path=relative/path for subdirectory navigation. * Returns: { path: string; entries: FileNode[] } */ router.get("/files", async (req, res) => { try { const { path: subPath, workspace } = req.query; const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project"; const result = await listWorkspaceFiles(store, workspaceId, typeof subPath === "string" ? subPath : undefined); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * GET /api/files/{*filepath} * Read file contents from the requested workspace. Defaults to the project root when omitted. * Query param: ?workspace=project|TASK-ID * Returns: { content: string; mtime: string; size: number } */ router.get("/files/{*filepath}", async (req, res) => { try { const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? ""; const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0 ? req.query.workspace : "project"; const result = await readWorkspaceFile(store, workspace, filePath); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EACCES" ? 403 : err.code === "ETOOLARGE" ? 413 : err.code === "EINVAL" && err.message.includes("Binary file") ? 415 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * POST /api/files/{*filepath} * Write file contents to the requested workspace. Defaults to the project root when omitted. * Query param: ?workspace=project|TASK-ID * Body: { content: string } * Returns: { success: true; mtime: string; size: number } */ router.post("/files/{*filepath}", async (req, res) => { try { const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? ""; const { content } = req.body; const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0 ? req.query.workspace : "project"; if (typeof content !== "string") { throw badRequest("content is required and must be a string"); } const result = await writeWorkspaceFile(store, workspace, filePath, content); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EACCES" ? 403 : err.code === "ETOOLARGE" ? 413 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); // ── File Operation Routes ───────────────────────────────────────────── /** * Helper to extract filepath and workspace from request. */ function extractFileParams(req: Request): { filePath: string; workspace: string } { const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? ""; const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0 ? req.query.workspace : "project"; return { filePath, workspace }; } /** * POST /api/files/{*filepath}/copy * Copy a file or directory to a new location within the workspace. * Query param: ?workspace=project|TASK-ID * Body: { destination: string } * Returns: FileOperationResponse */ router.post("/files/{*filepath}/copy", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const { destination } = req.body; if (!destination || typeof destination !== "string") { throw badRequest("destination is required and must be a string"); } const result = await copyWorkspaceFile(store, workspace, filePath, destination); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EEXIST" ? 409 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * POST /api/files/{*filepath}/move * Move a file or directory to a new location within the workspace. * Query param: ?workspace=project|TASK-ID * Body: { destination: string } * Returns: FileOperationResponse */ router.post("/files/{*filepath}/move", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const { destination } = req.body; if (!destination || typeof destination !== "string") { throw badRequest("destination is required and must be a string"); } const result = await moveWorkspaceFile(store, workspace, filePath, destination); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EEXIST" ? 409 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * DELETE /api/files/{*filepath} * Note: This conflicts with the existing GET endpoint for files. * Instead, use POST /api/files/{*filepath}/delete to avoid route collision. * Delete a file or directory within the workspace. * Query param: ?workspace=project|TASK-ID * Returns: FileOperationResponse */ router.post("/files/{*filepath}/delete", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const result = await deleteWorkspaceFile(store, workspace, filePath); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * POST /api/files/{*filepath}/rename * Rename a file or directory within the workspace. * Query param: ?workspace=project|TASK-ID * Body: { newName: string } * Returns: FileOperationResponse */ router.post("/files/{*filepath}/rename", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const { newName } = req.body; if (!newName || typeof newName !== "string") { throw badRequest("newName is required and must be a string"); } const result = await renameWorkspaceFile(store, workspace, filePath, newName); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EEXIST" ? 409 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * GET /api/files/{*filepath}/download * Download a single file from the workspace. * Query param: ?workspace=project|TASK-ID * Streams the file with Content-Disposition header. */ router.get("/files/{*filepath}/download", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(store, workspace, filePath); res.setHeader("Content-Type", "application/octet-stream"); res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`); res.setHeader("Content-Length", stats.size); res.setHeader("Last-Modified", stats.mtime.toUTCString()); const stream = createReadStream(absolutePath); stream.pipe(res); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "EISDIR" ? 400 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); /** * GET /api/files/{*filepath}/download-zip * Download a folder as a ZIP archive from the workspace. * Query param: ?workspace=project|TASK-ID * Streams the ZIP archive with Content-Disposition header. */ router.get("/files/{*filepath}/download-zip", async (req, res) => { try { const { filePath, workspace } = extractFileParams(req); const { absolutePath, dirName } = await getWorkspaceFolderForZip(store, workspace, filePath); const archiver = await import("archiver"); const archive = archiver.default("zip", { zlib: { level: 6 } }); res.setHeader("Content-Type", "application/zip"); res.setHeader("Content-Disposition", `attachment; filename="${dirName}.zip"`); archive.pipe(res); archive.directory(absolutePath, dirName); await archive.finalize(); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof FileServiceError) { const status = err.code === "ENOTASK" ? 404 : err.code === "ENOENT" ? 404 : err.code === "ENOTDIR" ? 400 : err.code === "EACCES" ? 403 : 400; throw new ApiError(status, err.message, { code: err.code }); } else { rethrowAsApiError(err, "Internal server error"); } } }); // ── Planning Mode Routes ────────────────────────────────────────────────── router.post("/subtasks/start-streaming", async (req, res) => { try { const { description } = req.body; if (!description || typeof description !== "string") { throw badRequest("description is required and must be a string"); } if (description.length > 1000) { throw badRequest("description must be 1000 characters or less"); } const scopedStore = await getScopedStore(req); const { createSubtaskSession } = await import("./subtask-breakdown.js"); const session = await createSubtaskSession(description, scopedStore, scopedStore.getRootDir()); res.status(201).json({ sessionId: session.sessionId }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to start subtask breakdown"); } }); router.get("/subtasks/:sessionId/stream", async (req, res) => { const { sessionId } = req.params; res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); res.write(": connected\n\n"); try { const { subtaskStreamManager, getSubtaskSession } = await import("./subtask-breakdown.js"); const session = getSubtaskSession(sessionId); if (!session) { writeSSEEvent(res, "error", JSON.stringify("Session not found or expired")); res.end(); return; } const lastEventId = parseLastEventId(req); if (lastEventId !== undefined) { const buffered = subtaskStreamManager.getBufferedEvents(sessionId, lastEventId); if (!replayBufferedSSE(res, buffered)) { res.end(); return; } } if (session.status === "complete") { const existing = subtaskStreamManager.getBufferedEvents(sessionId, 0); const lastSubtasksEvent = [...existing].reverse().find((event) => event.event === "subtasks"); const subtasksEventId = lastSubtasksEvent?.id ?? subtaskStreamManager.broadcast(sessionId, { type: "subtasks", data: session.subtasks, }); if (lastEventId === undefined || subtasksEventId > lastEventId) { if (!writeSSEEvent(res, "subtasks", JSON.stringify(session.subtasks), subtasksEventId)) { res.end(); return; } } const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete"); const completeEventId = lastCompleteEvent?.id ?? subtaskStreamManager.broadcast(sessionId, { type: "complete" }); if (lastEventId === undefined || completeEventId > lastEventId) { writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId); } res.end(); return; } if (session.status === "error") { const errorMessage = String(session.error || "Unknown error"); const existing = subtaskStreamManager.getBufferedEvents(sessionId, 0); const lastErrorEvent = [...existing].reverse().find((event) => event.event === "error"); const errorEventId = lastErrorEvent?.id ?? subtaskStreamManager.broadcast(sessionId, { type: "error", data: errorMessage, }); if (lastEventId === undefined || errorEventId > lastEventId) { writeSSEEvent(res, "error", JSON.stringify(errorMessage), errorEventId); } res.end(); return; } const unsubscribe = subtaskStreamManager.subscribe(sessionId, (event, eventId) => { const data = (event as { data?: unknown }).data; if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) { unsubscribe(); return; } if (event.type === "complete" || event.type === "error") { unsubscribe(); res.end(); } }); const heartbeat = setInterval(() => { if (res.writableEnded) { clearInterval(heartbeat); return; } res.write(": heartbeat\n\n"); }, 30_000); req.on("close", () => { clearInterval(heartbeat); unsubscribe(); }); } catch (err: any) { if (err instanceof ApiError) { throw err; } writeSSEEvent(res, "error", JSON.stringify(String(err?.message) || "Unknown error")); res.end(); } }); router.post("/subtasks/create-tasks", async (req, res) => { try { const { sessionId, subtasks, parentTaskId } = req.body as { sessionId?: string; subtasks?: Array<{ tempId: string; title: string; description: string; size?: "S" | "M" | "L"; dependsOn?: string[] }>; parentTaskId?: string; }; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } if (!Array.isArray(subtasks) || subtasks.length === 0) { throw badRequest("subtasks must be a non-empty array"); } const scopedStore = await getScopedStore(req); const { getSubtaskSession, cleanupSubtaskSession } = await import("./subtask-breakdown.js"); const session = getSubtaskSession(sessionId); if (!session) { throw notFound(`Subtask session ${sessionId} not found or expired`); } // Fetch parent task to inherit model settings if parentTaskId is provided let parentTask: Awaited> | undefined; if (typeof parentTaskId === "string" && parentTaskId.trim()) { try { parentTask = await scopedStore.getTask(parentTaskId); } catch { // Parent task not found or error - proceed without inheritance parentTask = undefined; } } const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); for (const item of subtasks) { if (!item || typeof item.tempId !== "string" || typeof item.title !== "string" || !item.title.trim()) { throw badRequest("Each subtask must include tempId and title"); } const task = await scopedStore.createTask({ title: item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), column: "triage", dependencies: undefined, // Inherit parent's model settings if available modelProvider: parentTask?.modelProvider, modelId: parentTask?.modelId, validatorModelProvider: parentTask?.validatorModelProvider, validatorModelId: parentTask?.validatorModelId, }); tempIdToTaskId.set(item.tempId, task.id); createdTasks.push(task); if (item.size === "S" || item.size === "M" || item.size === "L") { await scopedStore.updateTask(task.id, { size: item.size }); } } for (let index = 0; index < subtasks.length; index++) { const item = subtasks[index]!; const created = createdTasks[index]!; const resolvedDependencies = Array.isArray(item.dependsOn) ? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep)) : []; if (resolvedDependencies.length > 0) { const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies }); createdTasks[index] = updated; } await scopedStore.logEntry(created.id, "Created via subtask breakdown", `Source: ${session.initialDescription.slice(0, 200)}`); } let parentTaskClosed = false; if (typeof parentTaskId === "string" && parentTaskId.trim()) { try { await scopedStore.deleteTask(parentTaskId); parentTaskClosed = true; } catch { parentTaskClosed = false; } } cleanupSubtaskSession(sessionId); res.status(201).json({ tasks: createdTasks, parentTaskClosed }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create tasks from breakdown"); } }); router.post("/subtasks/cancel", async (req, res) => { try { const { sessionId, tabId } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined; const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore); if (!lockCheck.allowed) { res.status(409).json({ error: "Session locked by another tab", lockedByTab: lockCheck.currentHolder, }); return; } const { cancelSubtaskSession } = await import("./subtask-breakdown.js"); await cancelSubtaskSession(sessionId); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "SessionNotFoundError") { throw notFound(err.message); } else { rethrowAsApiError(err, "Failed to cancel subtask session"); } } }); router.post("/subtasks/:sessionId/retry", async (req, res) => { try { const { sessionId } = req.params; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0 ? req.body.tabId.trim() : undefined; const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore); if (!lockCheck.allowed) { res.status(409).json({ error: "Session locked by another tab", lockedByTab: lockCheck.currentHolder, }); return; } const scopedStore = await getScopedStore(req); const { retrySubtaskSession } = await import("./subtask-breakdown.js"); await retrySubtaskSession(sessionId, scopedStore.getRootDir()); res.json({ success: true, sessionId }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "SessionNotFoundError") { throw notFound(err.message); } else if (err.name === "InvalidSessionStateError") { throw badRequest(err.message); } else { rethrowAsApiError(err, "Failed to retry subtask session"); } } }); /** * POST /api/planning/start * Start a new planning session. * Body: { initialPlan: string } * Returns: { sessionId: string, firstQuestion: PlanningQuestion } */ router.post("/planning/start", async (req, res) => { try { const { initialPlan } = req.body; if (!initialPlan || typeof initialPlan !== "string") { throw badRequest("initialPlan is required and must be a string"); } if (initialPlan.length > 500) { throw badRequest("initialPlan must be 500 characters or less"); } const scopedStore = await getScopedStore(req); const ip = req.ip || req.socket.remoteAddress || "unknown"; const rootDir = scopedStore.getRootDir(); const { createSession, RateLimitError } = await import("./planning.js"); const result = await createSession(ip, initialPlan, scopedStore, rootDir); res.status(201).json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "RateLimitError") { throw rateLimited(err.message); } else { rethrowAsApiError(err, "Failed to start planning session"); } } }); /** * POST /api/planning/start-streaming * Start a new planning session with AI agent streaming. * Body: { initialPlan: string } * Returns: { sessionId: string } * * After receiving sessionId, connect to GET /api/planning/:sessionId/stream * for real-time thinking output and questions. */ router.post("/planning/start-streaming", async (req, res) => { try { const { initialPlan, planningModelProvider, planningModelId } = req.body; if (!initialPlan || typeof initialPlan !== "string") { throw badRequest("initialPlan is required and must be a string"); } if (initialPlan.length > 500) { throw badRequest("initialPlan must be 500 characters or less"); } if (planningModelProvider !== undefined && typeof planningModelProvider !== "string") { throw badRequest("planningModelProvider must be a string when provided"); } if (planningModelId !== undefined && typeof planningModelId !== "string") { throw badRequest("planningModelId must be a string when provided"); } const scopedStore = await getScopedStore(req); const ip = req.ip || req.socket.remoteAddress || "unknown"; const rootDir = scopedStore.getRootDir(); const { createSessionWithAgent, RateLimitError } = await import("./planning.js"); const sessionId = await createSessionWithAgent( ip, initialPlan, rootDir, planningModelProvider, planningModelId, ); res.status(201).json({ sessionId }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "RateLimitError") { throw rateLimited(err.message); } else { rethrowAsApiError(err, "Failed to start planning session"); } } }); /** * POST /api/planning/respond * Submit a response to the current planning question. * Body: { sessionId: string, responses: Record } * Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary } */ router.post("/planning/respond", async (req, res) => { try { const { sessionId, responses, tabId } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } if (!responses || typeof responses !== "object") { throw badRequest("responses is required and must be an object"); } const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined; const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore); if (!lockCheck.allowed) { res.status(409).json({ error: "Session locked by another tab", lockedByTab: lockCheck.currentHolder, }); return; } const { submitResponse, SessionNotFoundError, InvalidSessionStateError } = await import("./planning.js"); const result = await submitResponse(sessionId, responses, store.getRootDir()); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "SessionNotFoundError") { throw notFound(err.message); } else if (err.name === "InvalidSessionStateError") { throw badRequest(err.message); } else { rethrowAsApiError(err, "Failed to process response"); } } }); router.post("/planning/:sessionId/retry", async (req, res) => { try { const { sessionId } = req.params; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const tabId = typeof req.body?.tabId === "string" && req.body.tabId.trim().length > 0 ? req.body.tabId.trim() : undefined; const lockCheck = checkSessionLock(sessionId, tabId, aiSessionStore); if (!lockCheck.allowed) { res.status(409).json({ error: "Session locked by another tab", lockedByTab: lockCheck.currentHolder, }); return; } const scopedStore = await getScopedStore(req); const { retrySession } = await import("./planning.js"); await retrySession(sessionId, scopedStore.getRootDir()); res.json({ success: true, sessionId }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "SessionNotFoundError") { throw notFound(err.message); } else if (err.name === "InvalidSessionStateError") { throw badRequest(err.message); } else { rethrowAsApiError(err, "Failed to retry planning session"); } } }); /** * POST /api/planning/cancel * Cancel and cleanup a planning session. * Body: { sessionId: string } */ router.post("/planning/cancel", async (req, res) => { try { const { sessionId, tabId } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const normalizedTabId = typeof tabId === "string" && tabId.trim().length > 0 ? tabId.trim() : undefined; const lockCheck = checkSessionLock(sessionId, normalizedTabId, aiSessionStore); if (!lockCheck.allowed) { res.status(409).json({ error: "Session locked by another tab", lockedByTab: lockCheck.currentHolder, }); return; } const { cancelSession, SessionNotFoundError } = await import("./planning.js"); await cancelSession(sessionId); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.name === "SessionNotFoundError") { throw notFound(err.message); } else { rethrowAsApiError(err, "Failed to cancel session"); } } }); /** * POST /api/planning/create-task * Create a task from a completed planning session. * Body: { sessionId: string } * Returns: Created Task */ router.post("/planning/create-task", async (req, res) => { try { const { sessionId } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const scopedStore = await getScopedStore(req); const { getSession, getSummary, cleanupSession } = await import("./planning.js"); const session = getSession(sessionId); let summary = getSummary(sessionId); let initialPlan = session?.initialPlan; let usedPersistedFallback = false; if (!session) { if (!aiSessionStore) { throw notFound(`Planning session ${sessionId} not found or expired`); } const persistedSession = aiSessionStore.get(sessionId); if (!persistedSession || persistedSession.type !== "planning") { throw notFound(`Planning session ${sessionId} not found or expired`); } if (persistedSession.status !== "complete") { throw badRequest("Planning session is not complete"); } if (!persistedSession.result) { throw badRequest("Planning session result is not available"); } try { const parsedSummary = JSON.parse(persistedSession.result) as { title?: unknown; description?: unknown; suggestedSize?: unknown; suggestedDependencies?: unknown; keyDeliverables?: unknown; }; summary = { title: typeof parsedSummary.title === "string" && parsedSummary.title.trim().length > 0 ? parsedSummary.title : persistedSession.title, description: typeof parsedSummary.description === "string" && parsedSummary.description.trim().length > 0 ? parsedSummary.description : persistedSession.title, suggestedSize: parsedSummary.suggestedSize === "S" || parsedSummary.suggestedSize === "M" || parsedSummary.suggestedSize === "L" ? parsedSummary.suggestedSize : "M", suggestedDependencies: Array.isArray(parsedSummary.suggestedDependencies) ? parsedSummary.suggestedDependencies.filter((dep): dep is string => typeof dep === "string") : [], keyDeliverables: Array.isArray(parsedSummary.keyDeliverables) ? parsedSummary.keyDeliverables.filter((item): item is string => typeof item === "string") : [], }; } catch { throw badRequest("Planning session result is invalid"); } try { const parsedInput = JSON.parse(persistedSession.inputPayload) as { initialPlan?: unknown }; if (typeof parsedInput.initialPlan === "string" && parsedInput.initialPlan.trim().length > 0) { initialPlan = parsedInput.initialPlan; } } catch { // Keep fallback value below } if (!initialPlan) { initialPlan = persistedSession.title; } usedPersistedFallback = true; } if (!summary) { throw badRequest("Planning session is not complete"); } // Create the task const task = await scopedStore.createTask({ title: summary.title, description: summary.description, column: "triage", dependencies: summary.suggestedDependencies.length > 0 ? summary.suggestedDependencies : undefined, }); // Update task with suggested size if provided if (summary.suggestedSize) { await scopedStore.updateTask(task.id, { size: summary.suggestedSize }); } // Log the planning mode creation await scopedStore.logEntry(task.id, "Created via Planning Mode", `Initial plan: ${(initialPlan ?? "").slice(0, 200)}`); // Cleanup the session if (usedPersistedFallback) { aiSessionStore?.delete(sessionId); } else { cleanupSession(sessionId); } res.status(201).json(task); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create task"); } }); /** * POST /api/planning/start-breakdown * Start subtask breakdown from a completed planning session. * Body: { sessionId: string } * Returns: { sessionId: string } — ID of the generated subtask breakdown */ router.post("/planning/start-breakdown", async (req, res) => { try { const { sessionId } = req.body; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const { getSession, generateSubtasksFromPlanning } = await import("./planning.js"); const session = getSession(sessionId); if (!session) { throw notFound(`Planning session ${sessionId} not found or expired`); } if (!session.summary) { throw badRequest("Planning session is not complete"); } const subtasks = generateSubtasksFromPlanning(sessionId); if (subtasks.length === 0) { throw badRequest("Could not generate subtasks from planning session"); } // Return a synthetic session ID (based on the planning session) and the generated subtasks // We use the planning session ID directly as the breakdown session ID res.json({ sessionId, subtasks }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to start planning breakdown"); } }); /** * POST /api/planning/create-tasks * Create multiple tasks from a completed planning session (after optional editing). * Body: { planningSessionId: string, subtasks: Array<{id, title, description, suggestedSize, dependsOn}> } * Returns: { tasks: Task[] } */ router.post("/planning/create-tasks", async (req, res) => { try { const { planningSessionId, subtasks } = req.body as { planningSessionId?: string; subtasks?: Array<{ id: string; title: string; description: string; suggestedSize: "S" | "M" | "L"; dependsOn: string[]; }>; }; if (!planningSessionId || typeof planningSessionId !== "string") { throw badRequest("planningSessionId is required"); } if (!Array.isArray(subtasks) || subtasks.length === 0) { throw badRequest("subtasks must be a non-empty array"); } const scopedStore = await getScopedStore(req); const { getSession, cleanupSession, formatInterviewQA } = await import("./planning.js"); const session = getSession(planningSessionId); if (!session) { throw notFound(`Planning session ${planningSessionId} not found or expired`); } if (!session.summary) { throw badRequest("Planning session is not complete"); } const qaSection = formatInterviewQA(session.history); const logDetails = qaSection ? `Source: ${session.initialPlan.slice(0, 200)}\n\n${qaSection}` : `Source: ${session.initialPlan.slice(0, 200)}`; // Validate each subtask for (const item of subtasks) { if (!item || typeof item.id !== "string" || typeof item.title !== "string" || !item.title.trim()) { throw badRequest("Each subtask must include id and title"); } } const createdTasks = [] as Awaited>[]; const tempIdToTaskId = new Map(); // Create tasks for (const item of subtasks) { const task = await scopedStore.createTask({ title: item.title.trim(), description: typeof item.description === "string" ? item.description.trim() : item.title.trim(), column: "triage", dependencies: undefined, }); tempIdToTaskId.set(item.id, task.id); createdTasks.push(task); if (item.suggestedSize === "S" || item.suggestedSize === "M" || item.suggestedSize === "L") { await scopedStore.updateTask(task.id, { size: item.suggestedSize }); } } // Resolve dependencies for (let index = 0; index < subtasks.length; index++) { const item = subtasks[index]!; const created = createdTasks[index]!; const resolvedDependencies = Array.isArray(item.dependsOn) ? item.dependsOn.map((dep) => tempIdToTaskId.get(dep)).filter((dep): dep is string => Boolean(dep)) : []; if (resolvedDependencies.length > 0) { const updated = await scopedStore.updateTask(created.id, { dependencies: resolvedDependencies }); createdTasks[index] = updated; } await scopedStore.logEntry(created.id, "Created via Planning Mode (multi-task)", logDetails); } // Cleanup the planning session cleanupSession(planningSessionId); res.status(201).json({ tasks: createdTasks }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create tasks from planning"); } }); /** * GET /api/planning/:sessionId/stream * SSE endpoint for real-time planning session updates. * Streams thinking output, questions, summaries, and errors. * * Event types: * - thinking: AI thinking output chunks * - question: New question to display * - summary: Planning summary when complete * - error: Error message * - complete: Stream completed */ router.get("/planning/:sessionId/stream", async (req, res) => { const { sessionId } = req.params; // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); // Send initial connection confirmation res.write(": connected\n\n"); try { const { planningStreamManager, getSession } = await import("./planning.js"); // Verify session exists const session = getSession(sessionId); if (!session) { writeSSEEvent(res, "error", JSON.stringify({ message: "Session not found or expired" })); res.end(); return; } const lastEventId = parseLastEventId(req); if (lastEventId !== undefined) { const buffered = planningStreamManager.getBufferedEvents(sessionId, lastEventId); if (!replayBufferedSSE(res, buffered)) { res.end(); return; } } if (session.summary) { const existing = planningStreamManager.getBufferedEvents(sessionId, 0); const lastSummaryEvent = [...existing].reverse().find((event) => event.event === "summary"); const summaryEventId = lastSummaryEvent?.id ?? planningStreamManager.broadcast(sessionId, { type: "summary", data: session.summary, }); if (lastEventId === undefined || summaryEventId > lastEventId) { if (!writeSSEEvent(res, "summary", JSON.stringify(session.summary), summaryEventId)) { res.end(); return; } } const lastCompleteEvent = [...existing].reverse().find((event) => event.event === "complete"); const completeEventId = lastCompleteEvent?.id ?? planningStreamManager.broadcast(sessionId, { type: "complete" }); if (lastEventId === undefined || completeEventId > lastEventId) { writeSSEEvent(res, "complete", JSON.stringify({}), completeEventId); } res.end(); return; } // Subscribe to session events const unsubscribe = planningStreamManager.subscribe(sessionId, (event, eventId) => { const data = (event as { data?: unknown }).data; if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) { unsubscribe(); return; } // End stream on complete or error if (event.type === "complete" || event.type === "error") { unsubscribe(); res.end(); } }); // Handle client disconnect req.on("close", () => { unsubscribe(); }); // Send heartbeat every 30s to keep connection alive const heartbeat = setInterval(() => { if (res.writableEnded) { clearInterval(heartbeat); return; } res.write(": heartbeat\n\n"); }, 30_000); req.on("close", () => { clearInterval(heartbeat); }); } catch (err: any) { if (err instanceof ApiError) { throw err; } writeSSEEvent(res, "error", JSON.stringify({ message: err.message || "Stream error" })); res.end(); } }); // ── Chat Routes ──────────────────────────────────────────────────────────── /** * GET /api/chat/sessions * List chat sessions with optional filtering. * Query params: projectId?, status?, agentId? */ router.get("/chat/sessions", rateLimit(RATE_LIMITS.api), async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const { projectId, status, agentId } = req.query as { projectId?: string; status?: string; agentId?: string; }; const sessions = chatStore.listSessions({ ...(projectId && { projectId }), ...(status && { status: status as "active" | "archived" }), ...(agentId && { agentId }), }); res.json({ sessions }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to list chat sessions"); } }); /** * POST /api/chat/sessions * Create a new chat session. * Body: { agentId: string, title?: string, modelProvider?: string, modelId?: string } */ router.post("/chat/sessions", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const { agentId, title, modelProvider, modelId } = req.body as { agentId?: string; title?: string; modelProvider?: string; modelId?: string; }; if (!agentId || typeof agentId !== "string" || !agentId.trim()) { throw badRequest("agentId is required"); } // Validate optional model pair consistency const normalizedProvider = validateOptionalModelField(modelProvider, "modelProvider"); const normalizedModelId = validateOptionalModelField(modelId, "modelId"); if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { throw badRequest("modelProvider and modelId must both be provided or neither"); } const session = chatStore.createSession({ agentId: agentId.trim(), title: title?.trim() || null, modelProvider: normalizedProvider ?? null, modelId: normalizedModelId ?? null, }); res.status(201).json({ session }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to create chat session"); } }); /** * GET /api/chat/sessions/:id * Get a single chat session. */ router.get("/chat/sessions/:id", async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const sessionId = String(req.params.id); const session = chatStore.getSession(sessionId); if (!session) { throw notFound(`Chat session ${sessionId} not found`); } res.json({ session }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to get chat session"); } }); /** * PATCH /api/chat/sessions/:id * Update a chat session (title, status). * Body: { title?: string, status?: "active" | "archived" } */ router.patch("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const sessionId = String(req.params.id); const { title, status } = req.body as { title?: string; status?: string }; // Validate status if provided if (status !== undefined && status !== "active" && status !== "archived") { throw badRequest("status must be 'active' or 'archived'"); } const session = chatStore.updateSession(sessionId, { ...(title !== undefined && { title: title?.trim() || null }), ...(status !== undefined && { status }), }); if (!session) { throw notFound(`Chat session ${sessionId} not found`); } res.json({ session }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to update chat session"); } }); /** * DELETE /api/chat/sessions/:id * Delete a chat session and all its messages. */ router.delete("/chat/sessions/:id", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const chatStore = options?.chatStore; const sessionId = String(req.params.id); if (!chatStore) { throw internalError("Chat store not available"); } const deleted = chatStore.deleteSession(sessionId); if (!deleted) { throw notFound(`Chat session ${sessionId} not found`); } res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to delete chat session"); } }); /** * GET /api/chat/sessions/:id/messages * Get messages for a chat session with pagination. * Query params: limit? (default 50, max 200), offset? (default 0), before? (ISO timestamp) */ router.get("/chat/sessions/:id/messages", async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const sessionId = String(req.params.id); // Verify session exists const session = chatStore.getSession(sessionId); if (!session) { throw notFound(`Chat session ${sessionId} not found`); } const { limit: limitStr, offset: offsetStr, before } = req.query as { limit?: string; offset?: string; before?: string; }; // Validate pagination params const limit = limitStr !== undefined ? parseInt(String(limitStr), 10) : 50; const offset = offsetStr !== undefined ? parseInt(String(offsetStr), 10) : 0; if (!Number.isFinite(limit) || limit < 1) { throw badRequest("limit must be a positive integer"); } if (!Number.isFinite(offset) || offset < 0) { throw badRequest("offset must be a non-negative integer"); } const effectiveLimit = Math.min(limit, 200); const messages = chatStore.getMessages(sessionId, { limit: effectiveLimit, offset, ...(before && { before }), }); res.json({ messages }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to get chat messages"); } }); /** * POST /api/chat/sessions/:id/messages * Send a message and stream AI response via SSE. * Body: { content: string, modelProvider?: string, modelId?: string } * * Event types: * - thinking: AI thinking output chunks * - text: AI response text chunks * - done: Message sent successfully with messageId * - error: Error message */ router.post("/chat/sessions/:id/messages", rateLimit(RATE_LIMITS.sse), async (req, res) => { try { const chatStore = options?.chatStore; const chatManager = options?.chatManager; if (!chatStore || !chatManager) { throw internalError("Chat store or manager not available"); } const { content, modelProvider, modelId } = req.body as { content?: string; modelProvider?: string; modelId?: string; }; const sessionId = String(req.params.id); if (!content || typeof content !== "string" || !content.trim()) { throw badRequest("content is required and must be a non-empty string"); } // Verify session exists const session = chatStore.getSession(sessionId); if (!session) { throw notFound(`Chat session ${sessionId} not found`); } // Set SSE headers res.setHeader("Content-Type", "text/event-stream"); res.setHeader("Cache-Control", "no-cache"); res.setHeader("Connection", "keep-alive"); res.setHeader("X-Accel-Buffering", "no"); res.flushHeaders(); // Send initial connection confirmation res.write(": connected\n\n"); // Import chat modules const { chatStreamManager, checkRateLimit: checkChatRateLimit, getRateLimitResetTime: getChatRateLimitResetTime } = await import("./chat.js"); // Check rate limit const ip = req.ip || req.socket.remoteAddress || "unknown"; if (!checkChatRateLimit(ip)) { const resetTime = getChatRateLimitResetTime(ip); writeSSEEvent(res, "error", JSON.stringify({ message: `Rate limit exceeded. Reset at ${resetTime?.toISOString() || "unknown"}`, })); res.end(); return; } // Replay buffered events if client sent Last-Event-ID const lastEventId = parseLastEventId(req); if (lastEventId !== undefined) { const buffered = chatStreamManager.getBufferedEvents(sessionId, lastEventId); for (const bufferedEvent of buffered) { if (!writeSSEEvent(res, bufferedEvent.event, bufferedEvent.data, bufferedEvent.id)) { res.end(); return; } } } // Subscribe to session events const unsubscribe = chatStreamManager.subscribe(sessionId, (event, eventId) => { const data = (event as { data?: unknown }).data; if (!writeSSEEvent(res, event.type, JSON.stringify(data ?? {}), eventId)) { unsubscribe(); return; } // End stream on done or error if (event.type === "done" || event.type === "error") { unsubscribe(); res.end(); } }); // Handle client disconnect req.on("close", () => { unsubscribe(); }); // Send heartbeat every 30s to keep connection alive const heartbeat = setInterval(() => { if (res.writableEnded) { clearInterval(heartbeat); return; } res.write(": heartbeat\n\n"); }, 30_000); req.on("close", () => { clearInterval(heartbeat); }); // Send message in background (non-blocking) // Validate optional model pair consistency const normalizedProvider = validateOptionalModelField(modelProvider, "modelProvider"); const normalizedModelId = validateOptionalModelField(modelId, "modelId"); if ((normalizedProvider && !normalizedModelId) || (!normalizedProvider && normalizedModelId)) { chatStreamManager.broadcast(sessionId, { type: "error", data: "modelProvider and modelId must both be provided or neither", }); unsubscribe(); res.end(); return; } // Fire and forget - streaming happens via callbacks chatManager.sendMessage( sessionId, content.trim(), normalizedProvider, normalizedModelId, ).catch((err: Error) => { console.error(`[chat:routes] Error in sendMessage:`, err); chatStreamManager.broadcast(sessionId, { type: "error", data: err.message || "Failed to process message", }); }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to send chat message"); } }); /** * DELETE /api/chat/sessions/:id/messages/:messageId * Delete a specific message from a chat session. */ router.delete("/chat/sessions/:id/messages/:messageId", rateLimit(RATE_LIMITS.mutation), async (req, res) => { try { const chatStore = options?.chatStore; if (!chatStore) { throw internalError("Chat store not available"); } const sessionId = String(req.params.id); const messageId = String(req.params.messageId); // Verify session exists const session = chatStore.getSession(sessionId); if (!session) { throw notFound(`Chat session ${sessionId} not found`); } // Check if message exists const message = chatStore.getMessage(messageId); if (!message) { throw notFound(`Message ${messageId} not found`); } // Note: ChatStore currently doesn't have deleteMessage, but we can add it // For now, return success if session exists (the message check is a bonus) // TODO: Add deleteMessage to ChatStore if not already present res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to delete chat message"); } }); if (process.env.FUSION_DEBUG_CHAT_ROUTES === "1") { const chatRoutes = [ "GET /chat/sessions", "POST /chat/sessions", "GET /chat/sessions/:id", "PATCH /chat/sessions/:id", "DELETE /chat/sessions/:id", "GET /chat/sessions/:id/messages", "POST /chat/sessions/:id/messages", "DELETE /chat/sessions/:id/messages/:messageId", ]; console.debug("[chat:routes:registered]", chatRoutes); } /** * POST /api/ai/refine-text * AI-powered text refinement for task descriptions. * Body: { text: string, type: string } * Returns: { refined: string } * * Refinement types: clarify, add-details, expand, simplify * Rate limited: 10 requests per hour per IP */ router.post("/ai/refine-text", async (req, res) => { try { const { text, type } = req.body; const ip = req.ip || req.socket.remoteAddress || "unknown"; const rootDir = store.getRootDir(); const { validateRefineRequest, checkRateLimit, getRateLimitResetTime, refineText, RateLimitError, ValidationError, InvalidTypeError, AiServiceError, } = await import("./ai-refine.js"); // Check rate limit first if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); throw rateLimited(`Rate limit exceeded. Maximum 10 refinement requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); } // Validate request body let validated; try { validated = validateRefineRequest(text, type); } catch (err) { if (err instanceof ValidationError) { throw badRequest(err.message); } if (err instanceof InvalidTypeError) { throw new ApiError(422, err.message); } throw err; } // Process refinement const refined = await refineText(validated.text, validated.type, rootDir); res.json({ refined }); } catch (err: any) { if (err instanceof ApiError) { throw err; } // Check error by name since error classes are from dynamic import if (err?.name === "RateLimitError") { throw rateLimited(err.message); } else if (err?.name === "AiServiceError") { rethrowAsApiError(err, "AI service error"); } else { rethrowAsApiError(err, "Failed to refine text"); } } }); /** * POST /api/ai/summarize-title * AI-powered title generation from task descriptions. * Body: { description: string, provider?: string, modelId?: string } * Returns: { title: string } * * Generates a concise title (≤60 characters) from descriptions longer than 200 characters. * Rate limited: 10 requests per hour per IP */ router.post("/ai/summarize-title", async (req, res) => { try { const { description, provider, modelId } = req.body; const ip = req.ip || req.socket.remoteAddress || "unknown"; const rootDir = store.getRootDir(); const { checkRateLimit, getRateLimitResetTime, summarizeTitle, validateDescription, MIN_DESCRIPTION_LENGTH, MAX_DESCRIPTION_LENGTH, RateLimitError, ValidationError, AiServiceError, } = await import("@fusion/core"); // Debug logging if (process.env.FUSION_DEBUG_AI) { console.log(`[ai-summarize] Request from ${ip}, description length: ${description?.length || 0}`); } // Check rate limit first if (!checkRateLimit(ip)) { const resetTime = getRateLimitResetTime(ip); throw rateLimited(`Rate limit exceeded. Maximum 10 summarization requests per hour. Reset at ${resetTime?.toISOString() || "unknown"}`); } // Validate request body try { validateDescription(description); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.name === "ValidationError") { throw badRequest(err.message); } throw err; } // Resolve model selection hierarchy: // 1. Request body provider+modelId // 2. Settings titleSummarizerProvider + titleSummarizerModelId // 3. Settings planningProvider + planningModelId // 4. Settings defaultProvider + defaultModelId // 5. Automatic model resolution (no explicit model) const settings = await store.getSettings(); const resolvedProvider = (provider && modelId ? provider : undefined) || (settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerProvider : undefined) || (settings.planningProvider && settings.planningModelId ? settings.planningProvider : undefined) || (settings.defaultProvider && settings.defaultModelId ? settings.defaultProvider : undefined); const resolvedModelId = (provider && modelId ? modelId : undefined) || (settings.titleSummarizerProvider && settings.titleSummarizerModelId ? settings.titleSummarizerModelId : undefined) || (settings.planningProvider && settings.planningModelId ? settings.planningModelId : undefined) || (settings.defaultProvider && settings.defaultModelId ? settings.defaultModelId : undefined); if (process.env.FUSION_DEBUG_AI) { console.log(`[ai-summarize] Resolved model: ${resolvedProvider || "auto"}/${resolvedModelId || "auto"}`); } // Process summarization const title = await summarizeTitle(description, rootDir, resolvedProvider, resolvedModelId); if (!title) { throw badRequest(`Description must be at least ${MIN_DESCRIPTION_LENGTH} characters for summarization`); } res.json({ title }); } catch (err: any) { if (err instanceof ApiError) { throw err; } // Check error by name since error classes are from dynamic import if (err?.name === "RateLimitError") { throw rateLimited(err.message); } else if (err?.name === "AiServiceError") { throw new ApiError(503, err.message || "AI service temporarily unavailable"); } else if (err?.name === "ValidationError") { throw badRequest(err.message); } else { console.error("[ai-summarize] Unexpected error:", err); rethrowAsApiError(err, "Failed to generate title"); } } }); /** * GET /api/usage * Fetch AI provider subscription usage (Claude, Codex, Gemini). * Returns: { providers: ProviderUsage[] } * * Cached for 30 seconds to avoid hitting provider API rate limits. * Each provider's status is independent — one failure doesn't break all. */ router.get("/usage", async (_req, res) => { try { const providers = await fetchAllProviderUsage(); res.json({ providers }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err, "Failed to fetch usage data"); } }); // ── Automation / Scheduled Task Routes ──────────────────────────── const automationStore = options?.automationStore; // GET /automations — list all scheduled tasks router.get("/automations", async (_req: Request, res: Response) => { if (!automationStore) { return res.json([]); } try { const schedules = await automationStore.listSchedules(); res.json(schedules); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // POST /automations — create a new schedule router.post("/automations", async (req: Request, res: Response) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; // Validation if (!name?.trim()) { throw badRequest("Name is required"); } const hasSteps = Array.isArray(steps) && steps.length > 0; if (!hasSteps && !command?.trim()) { throw badRequest("Command is required when no steps are provided"); } const validTypes = ["hourly", "daily", "weekly", "monthly", "custom", "every15Minutes", "every30Minutes", "every2Hours", "every6Hours", "every12Hours", "weekdays"]; if (!scheduleType || !validTypes.includes(scheduleType)) { throw badRequest(`Invalid schedule type. Must be one of: ${validTypes.join(", ")}`); } if (scheduleType === "custom") { if (!cronExpression?.trim()) { throw badRequest("Cron expression is required for custom schedule type"); } if (!AutomationStore.isValidCron(cronExpression)) { throw badRequest(`Invalid cron expression: "${cronExpression}"`); } } // Validate steps if provided if (hasSteps) { const stepErr = validateAutomationSteps(steps); if (stepErr) { throw badRequest(stepErr); } } const schedule = await automationStore.createSchedule({ name, description, scheduleType: scheduleType as ScheduleType, cronExpression, command: command ?? "", enabled, timeoutMs, steps: hasSteps ? steps : undefined, }); res.status(201).json(schedule); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // GET /automations/:id — get a single schedule router.get("/automations/:id", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const schedule = await automationStore.getSchedule(id); res.json(schedule); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } rethrowAsApiError(err); } }); // PATCH /automations/:id — update a schedule router.patch("/automations/:id", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps } = req.body; // Validate cron if switching to custom if (scheduleType === "custom" && cronExpression) { if (!AutomationStore.isValidCron(cronExpression)) { throw badRequest(`Invalid cron expression: "${cronExpression}"`); } } // Validate steps if provided if (Array.isArray(steps) && steps.length > 0) { const stepErr = validateAutomationSteps(steps); if (stepErr) { throw badRequest(stepErr); } } const schedule = await automationStore.updateSchedule(id, { name, description, scheduleType, cronExpression, command, enabled, timeoutMs, steps: steps !== undefined ? steps : undefined, }); res.json(schedule); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } if (err.message?.includes("cannot be empty") || err.message?.includes("Invalid cron")) { throw badRequest(err.message); } rethrowAsApiError(err); } }); // DELETE /automations/:id — delete a schedule router.delete("/automations/:id", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const deleted = await automationStore.deleteSchedule(id); res.json(deleted); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } rethrowAsApiError(err); } }); // POST /automations/:id/run — trigger a manual run router.post("/automations/:id/run", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const schedule = await automationStore.getSchedule(id); const startedAt = new Date().toISOString(); let result: import("@fusion/core").AutomationRunResult; if (schedule.steps && schedule.steps.length > 0) { // Multi-step execution result = await executeScheduleSteps(schedule, startedAt); } else { // Legacy single-command execution result = await executeSingleCommand(schedule.command, schedule.timeoutMs, startedAt); } // Record the result const updated = await automationStore.recordRun(schedule.id, result); res.json({ schedule: updated, result }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } rethrowAsApiError(err); } }); // POST /automations/:id/toggle — toggle enabled/disabled router.post("/automations/:id/toggle", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const schedule = await automationStore.getSchedule(id); const updated = await automationStore.updateSchedule(id, { enabled: !schedule.enabled, }); res.json(updated); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } rethrowAsApiError(err); } }); // POST /automations/:id/steps/reorder — reorder steps router.post("/automations/:id/steps/reorder", async (req, res) => { if (!automationStore) { throw new ApiError(503, "Automation store not available"); } try { const id = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; const { stepIds } = req.body; if (!Array.isArray(stepIds)) { throw badRequest("stepIds must be an array"); } const schedule = await automationStore.reorderSteps(id, stepIds); res.json(schedule); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound("Schedule not found"); } if (err.message?.includes("mismatch") || err.message?.includes("Unknown step") || err.message?.includes("no steps")) { throw badRequest(err.message); } rethrowAsApiError(err); } }); // ── Activity Log Routes ───────────────────────────────────────────── /** * GET /api/activity * Get activity log entries. * Query params: limit (default 100, max 1000), since (ISO timestamp), type (event type filter) * Returns: ActivityLogEntry[] sorted newest first */ router.get("/activity", async (req, res) => { try { const limitParam = req.query.limit; const sinceParam = req.query.since; const typeParam = req.query.type; // Parse and validate limit let limit: number | undefined; if (limitParam !== undefined) { const parsed = Number.parseInt(limitParam as string, 10); if (!Number.isFinite(parsed) || parsed < 0) { throw badRequest("limit must be a non-negative integer"); } limit = Math.min(parsed, 1000); // Max 1000 } // Validate type if provided const validTypes = ["task:created", "task:moved", "task:updated", "task:deleted", "task:merged", "task:failed", "settings:updated"]; if (typeParam !== undefined && !validTypes.includes(typeParam as string)) { throw badRequest(`Invalid type. Must be one of: ${validTypes.join(", ")}`); } const options: { limit?: number; since?: string; type?: ActivityEventType } = { limit, since: sinceParam as string | undefined, type: typeParam as ActivityEventType | undefined, }; const entries = await store.getActivityLog(options); res.json(entries); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * DELETE /api/activity * Clear all activity log entries (maintenance endpoint). * Returns: { success: true } */ router.delete("/activity", async (_req, res) => { try { await store.clearActivityLog(); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Workflow Step Routes ────────────────────────────────────────────── /** * GET /api/workflow-steps * List all workflow step definitions. * Returns: WorkflowStep[] */ router.get("/workflow-steps", async (req, res) => { try { const scopedStore = await getScopedStore(req); const steps = await scopedStore.listWorkflowSteps(); res.json(steps); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/workflow-steps * Create a new workflow step. * Body: { name: string, description: string, mode?: "prompt"|"script", prompt?: string, scriptName?: string, enabled?: boolean, modelProvider?: string, modelId?: string } * Returns: WorkflowStep */ router.post("/workflow-steps", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; if (!name || typeof name !== "string" || !name.trim()) { throw badRequest("name is required"); } if (!description || typeof description !== "string" || !description.trim()) { throw badRequest("description is required"); } // Validate mode const resolvedMode: "prompt" | "script" = mode || "prompt"; if (resolvedMode !== "prompt" && resolvedMode !== "script") { throw badRequest("mode must be 'prompt' or 'script'"); } // Validate phase if (phase !== undefined && phase !== "pre-merge" && phase !== "post-merge") { throw badRequest("phase must be 'pre-merge' or 'post-merge'"); } if (prompt !== undefined && typeof prompt !== "string") { throw badRequest("prompt must be a string"); } if (toolMode !== undefined && toolMode !== "readonly" && toolMode !== "coding") { throw badRequest("toolMode must be 'readonly' or 'coding'"); } if (scriptName !== undefined && typeof scriptName !== "string") { throw badRequest("scriptName must be a string"); } if (enabled !== undefined && typeof enabled !== "boolean") { throw badRequest("enabled must be a boolean"); } if (defaultOn !== undefined && typeof defaultOn !== "boolean") { throw badRequest("defaultOn must be a boolean"); } // Validate script mode: scriptName must reference a named script in settings if (resolvedMode === "script") { if (!scriptName?.trim()) { throw badRequest("scriptName is required when mode is 'script'"); } const settings = await scopedStore.getSettings(); const scripts = settings.scripts || {}; if (!(scriptName.trim() in scripts)) { throw badRequest(`Script '${scriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`); } } // Validate model override pair (only relevant for prompt mode) const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model"); // Check for name conflicts const existing = await scopedStore.listWorkflowSteps(); if (existing.some((ws) => ws.name.toLowerCase() === name.trim().toLowerCase())) { throw conflict(`A workflow step named '${name.trim()}' already exists`); } const step = await scopedStore.createWorkflowStep({ name: name.trim(), description: description.trim(), mode: resolvedMode, phase, prompt: prompt?.trim(), toolMode, scriptName: scriptName?.trim(), enabled, defaultOn: defaultOn === true, modelProvider: modelPair.provider, modelId: modelPair.modelId, }); res.status(201).json(step); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = typeof err?.message === "string" && (err.message.includes("must include both provider and modelId") || err.message.includes("Script mode requires")) ? 400 : 500; throw new ApiError(status, err.message); } }); /** * PATCH /api/workflow-steps/:id * Update a workflow step. * Body: Partial<{ name, description, mode, prompt, scriptName, enabled, modelProvider, modelId }> * Returns: WorkflowStep */ router.patch("/workflow-steps/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { name, description, mode, phase, prompt, toolMode, scriptName, enabled, defaultOn, modelProvider, modelId } = req.body; const updates: Record = {}; if (name !== undefined) { if (typeof name !== "string" || !name.trim()) { throw badRequest("name must be a non-empty string"); } updates.name = name.trim(); } if (description !== undefined) { if (typeof description !== "string" || !description.trim()) { throw badRequest("description must be a non-empty string"); } updates.description = description.trim(); } if (mode !== undefined) { if (mode !== "prompt" && mode !== "script") { throw badRequest("mode must be 'prompt' or 'script'"); } updates.mode = mode; } if (phase !== undefined) { if (phase !== "pre-merge" && phase !== "post-merge") { throw badRequest("phase must be 'pre-merge' or 'post-merge'"); } updates.phase = phase; } if (prompt !== undefined) { if (typeof prompt !== "string") { throw badRequest("prompt must be a string"); } updates.prompt = prompt; } if (toolMode !== undefined) { if (toolMode !== "readonly" && toolMode !== "coding") { throw badRequest("toolMode must be 'readonly' or 'coding'"); } updates.toolMode = toolMode; } if (scriptName !== undefined) { if (typeof scriptName !== "string") { throw badRequest("scriptName must be a string"); } updates.scriptName = scriptName; } if (enabled !== undefined) { if (typeof enabled !== "boolean") { throw badRequest("enabled must be a boolean"); } updates.enabled = enabled; } if (defaultOn !== undefined) { if (typeof defaultOn !== "boolean") { throw badRequest("defaultOn must be a boolean"); } updates.defaultOn = defaultOn; } // Validate script-mode requirements against the resulting state (existing + updates) // This catches cases where an existing script-mode step has its scriptName updated // without the mode field being explicitly sent. const existingStep = await scopedStore.getWorkflowStep(req.params.id); const resultingMode: string | undefined = updates.mode !== undefined ? (updates.mode as string) : existingStep?.mode; const resultingScriptName: string | undefined = updates.scriptName !== undefined ? (updates.scriptName as string) : existingStep?.scriptName; if (resultingMode === "script") { if (!resultingScriptName?.trim()) { throw badRequest("scriptName is required when mode is 'script'"); } const settings = await scopedStore.getSettings(); const scripts = settings.scripts || {}; if (!(resultingScriptName.trim() in scripts)) { throw badRequest(`Script '${resultingScriptName.trim()}' not found in project settings. Available scripts: ${Object.keys(scripts).join(", ") || "none"}`); } } // Validate and apply model override pair if (modelProvider !== undefined || modelId !== undefined) { const modelPair = assertConsistentOptionalPair(modelProvider, modelId, "workflow step model"); updates.modelProvider = modelPair.provider; updates.modelId = modelPair.modelId; } const step = await scopedStore.updateWorkflowStep(req.params.id, updates); res.json(step); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { const status = typeof err?.message === "string" && (err.message.includes("must include both provider and modelId") || err.message.includes("Script mode requires")) ? 400 : 500; throw new ApiError(status, err.message); } } }); /** * DELETE /api/workflow-steps/:id * Delete a workflow step. * Returns: 204 No Content */ router.delete("/workflow-steps/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); await scopedStore.deleteWorkflowStep(req.params.id); res.status(204).send(); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/workflow-steps/:id/refine * Use AI to refine the workflow step's description into a detailed agent prompt. * Only available for prompt-mode steps. * Returns: { prompt: string, workflowStep: WorkflowStep } */ router.post("/workflow-steps/:id/refine", async (req, res) => { try { const scopedStore = await getScopedStore(req); const step = await scopedStore.getWorkflowStep(req.params.id); if (!step) { throw notFound(`Workflow step '${req.params.id}' not found`); } if (step.mode === "script") { throw badRequest("Cannot refine prompt for script-mode workflow steps"); } if (!step.description?.trim()) { throw badRequest("Workflow step has no description to refine"); } // Use AI to refine the description into a detailed agent prompt let refinedPrompt: string; try { let createKbAgent = createKbAgentForRefine; if (!createKbAgent) { // Dynamic import to avoid resolution issues in tests const engineModule = "@fusion/engine"; const engine = await import(/* @vite-ignore */ engineModule); createKbAgent = engine.createKbAgent; } const settings = await scopedStore.getSettings(); const systemPrompt = `You are an expert at creating detailed agent prompts for workflow steps. A workflow step is a quality gate that runs after a task is implemented but before it's marked complete. Given a rough description, create a detailed prompt that an AI agent can follow to execute this workflow step. The prompt should: 1. Define the purpose clearly 2. Specify what files/context to examine 3. List specific criteria to check 4. Describe what "success" looks like 5. Include guidance on handling common edge cases Output ONLY the prompt text (no markdown, no explanations).`; const { session } = await createKbAgent({ cwd: scopedStore.getRootDir(), systemPrompt, tools: "none", defaultProvider: settings.planningProvider || settings.defaultProvider, defaultModelId: settings.planningModelId || settings.defaultModelId, defaultThinkingLevel: settings.defaultThinkingLevel, }); let output = ""; session.on("text", (delta: string) => { output += delta; }); await session.prompt( `Refine this workflow step description into a detailed agent prompt:\n\nName: ${step.name}\nDescription: ${step.description}` ); session.dispose(); refinedPrompt = output.trim(); } catch (agentErr: any) { // Fallback: return the description as-is if AI is unavailable refinedPrompt = step.description; } // Update the workflow step with the refined prompt const updated = await scopedStore.updateWorkflowStep(step.id, { prompt: refinedPrompt }); res.json({ prompt: refinedPrompt, workflowStep: updated }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Workflow Step Templates ─────────────────────────────────────────── /** * GET /api/workflow-step-templates * List all built-in workflow step templates. * Returns: { templates: WorkflowStepTemplate[] } */ router.get("/workflow-step-templates", async (_req, res) => { try { const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core"); res.json({ templates: WORKFLOW_STEP_TEMPLATES }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/workflow-step-templates/:id/create * Create a workflow step from a built-in template. * Returns: WorkflowStep */ router.post("/workflow-step-templates/:id/create", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { WORKFLOW_STEP_TEMPLATES } = await import("@fusion/core"); const template = WORKFLOW_STEP_TEMPLATES.find((t) => t.id === req.params.id); if (!template) { throw notFound(`Template '${req.params.id}' not found`); } // Check for name conflicts with existing workflow steps const existing = await scopedStore.listWorkflowSteps(); if (existing.some((ws) => ws.name.toLowerCase() === template.name.toLowerCase())) { throw conflict(`A workflow step named '${template.name}' already exists`); } const step = await scopedStore.createWorkflowStep({ templateId: template.id, name: template.name, description: template.description, prompt: template.prompt, toolMode: template.toolMode, enabled: true, }); res.status(201).json(step); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Scripts Routes ────────────────────────────────────────────────────────── /** * POST /api/scripts/:name/run * Execute a saved script by name using terminal service. * Body: { args?: string[] } - Optional arguments to append to the command * Returns: { sessionId: string, command: string } */ router.post("/scripts/:name/run", async (req, res) => { try { const scopedStore = await getScopedStore(req); const scriptName = Array.isArray(req.params.name) ? req.params.name[0] : req.params.name; if (!scriptName) { throw badRequest("Script name is required"); } if (!/^[a-zA-Z0-9_-]+$/.test(scriptName)) { throw badRequest("Script name must contain only alphanumeric characters, hyphens, and underscores (no spaces)"); } // Get the script from settings const settings = await scopedStore.getSettings(); const currentScripts = settings.scripts ?? {}; if (currentScripts[scriptName] === undefined) { throw notFound(`Script '${scriptName}' not found`); } const baseCommand = currentScripts[scriptName]; const { args } = req.body ?? {}; // Validate args if provided if (args !== undefined && !Array.isArray(args)) { throw badRequest("args must be an array of strings"); } if (args && !args.every((a: unknown) => typeof a === "string")) { throw badRequest("args must be an array of strings"); } // Build the full command with args let fullCommand = baseCommand; if (args && args.length > 0) { // Properly escape arguments for shell execution const escapedArgs = args.map((arg: unknown) => { // Quote and escape the argument for shell const str = String(arg); // If the arg contains special characters, use double quotes with escaping if (str.includes('"') || str.includes("$") || str.includes("`") || str.includes("\\")) { // Use single quotes and escape embedded single quotes return `'${str.replace(/'/g, "'\\''")}'`; } // Simple case: use double quotes return `"${str}"`; }); fullCommand = `${baseCommand} ${escapedArgs.join(" ")}`; } // Execute via terminal service const terminalService = getTerminalService(scopedStore.getRootDir()); const result = await terminalService.createSession({ cwd: scopedStore.getRootDir(), }); if (!result.success) { const statusByCode = { max_sessions: 503, invalid_shell: 400, pty_load_failed: 503, pty_spawn_failed: 500, } as const; const status = result.code ? (statusByCode[result.code] ?? 500) : 500; throw new ApiError(status, result.error || "Failed to create terminal session"); } const sessionId = result.session.id; // Write the command to the PTY (use writeInput for compatibility with test mocks) terminalService.writeInput(sessionId, `${fullCommand}\n`); res.status(201).json({ sessionId, command: fullCommand, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Agent Routes ─────────────────────────────────────────────────────────── /** * GET /api/agents * List all agents with optional filtering. * Query params: state, role */ router.get("/agents", async (req, res) => { try { const filter: { state?: string; role?: string } = {}; if (req.query.state && typeof req.query.state === "string") { filter.state = req.query.state; } if (req.query.role && typeof req.query.role === "string") { filter.role = req.query.role; } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agents = await agentStore.listAgents(filter as { state?: "idle" | "active" | "paused" | "terminated"; role?: import("@fusion/core").AgentCapability }); res.json(agents); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); function validateAgentInstructionsPayload( res: Response, instructionsPath: unknown, instructionsText: unknown, ): boolean { if (instructionsPath !== undefined && instructionsPath !== null && instructionsPath !== "") { if (typeof instructionsPath !== "string") { throw badRequest("instructionsPath must be a string"); return false; } if (instructionsPath.length > 500) { throw badRequest("instructionsPath must be at most 500 characters"); return false; } if (instructionsPath.includes("..")) { throw badRequest("instructionsPath must not contain parent directory traversal (..)"); return false; } const isAbsoluteUnix = instructionsPath.startsWith("/"); const isAbsoluteWindows = /^[A-Za-z]:[\\/]/.test(instructionsPath); if (isAbsoluteUnix || isAbsoluteWindows) { throw badRequest("instructionsPath must be a project-relative path"); return false; } if (!instructionsPath.endsWith(".md")) { throw badRequest("instructionsPath must end in .md"); return false; } } if (instructionsText !== undefined && instructionsText !== null && instructionsText !== "") { if (typeof instructionsText !== "string") { throw badRequest("instructionsText must be a string"); return false; } if (instructionsText.length > 50000) { throw badRequest("instructionsText must be at most 50,000 characters"); return false; } } return true; } function serializeAccessState(state: import("@fusion/core").AgentAccessState) { return { ...state, resolvedPermissions: Array.from(state.resolvedPermissions), explicitPermissions: Array.from(state.explicitPermissions), roleDefaultPermissions: Array.from(state.roleDefaultPermissions), }; } /** * POST /api/agents * Create a new agent. */ router.post("/agents", async (req, res) => { try { const { name, role, metadata, title, icon, reportsTo, runtimeConfig, permissions, instructionsPath, instructionsText, } = req.body ?? {}; if (!name || typeof name !== "string") { throw badRequest("name is required"); } if (!role || typeof role !== "string") { throw badRequest("role is required"); } if (metadata !== undefined && (typeof metadata !== "object" || metadata === null || Array.isArray(metadata))) { throw badRequest("metadata must be an object"); } if (title !== undefined && title !== null && typeof title !== "string") { throw badRequest("title must be a string"); } if (icon !== undefined && icon !== null && typeof icon !== "string") { throw badRequest("icon must be a string"); } if (reportsTo !== undefined && reportsTo !== null && typeof reportsTo !== "string") { throw badRequest("reportsTo must be a string"); } if (runtimeConfig !== undefined && (typeof runtimeConfig !== "object" || runtimeConfig === null || Array.isArray(runtimeConfig))) { throw badRequest("runtimeConfig must be an object"); } if (permissions !== undefined && (typeof permissions !== "object" || permissions === null || Array.isArray(permissions))) { throw badRequest("permissions must be an object"); } if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) { return; } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.createAgent({ name, role: role as import("@fusion/core").AgentCapability, metadata, title: title ?? undefined, icon: icon ?? undefined, reportsTo: reportsTo ?? undefined, runtimeConfig, permissions, instructionsPath: instructionsPath ?? undefined, instructionsText: instructionsText ?? undefined, }); res.status(201).json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("required") || err.message?.includes("cannot be empty")) { throw badRequest(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/export * Export agents to an Agent Companies package directory. * * Body: * - { agentIds?: string[]; companyName?: string; companySlug?: string; outputDir?: string } */ router.post("/agents/export", async (req, res) => { try { const { agentIds, companyName, companySlug, outputDir } = req.body ?? {}; if (agentIds !== undefined) { if (!Array.isArray(agentIds)) { throw badRequest("agentIds must be an array of strings"); } if (agentIds.some((id: unknown) => typeof id !== "string" || id.trim().length === 0)) { throw badRequest("agentIds must contain non-empty strings"); } } if (companyName !== undefined && typeof companyName !== "string") { throw badRequest("companyName must be a string"); } if (companySlug !== undefined && typeof companySlug !== "string") { throw badRequest("companySlug must be a string"); } if (outputDir !== undefined && typeof outputDir !== "string") { throw badRequest("outputDir must be a string"); } const scopedStore = await getScopedStore(req); const { AgentStore, exportAgentsToDirectory } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const allAgents = await agentStore.listAgents(); const requestedIds = Array.isArray(agentIds) ? [...new Set(agentIds.map((id) => id.trim()))] : []; const agentsToExport = requestedIds.length > 0 ? allAgents.filter((agent: any) => requestedIds.includes(agent.id)) : allAgents; if (agentsToExport.length === 0) { throw badRequest("No agents found to export"); } let resolvedOutputDir: string; if (typeof outputDir === "string" && outputDir.trim().length > 0) { resolvedOutputDir = resolve(outputDir.trim()); } else if (typeof outputDir === "string") { throw badRequest("outputDir cannot be empty"); } else { resolvedOutputDir = await mkdtemp(join(tmpdir(), "fusion-agent-export-")); } const result = await exportAgentsToDirectory(agentsToExport, resolvedOutputDir, { companyName: typeof companyName === "string" ? companyName : undefined, companySlug: typeof companySlug === "string" ? companySlug : undefined, }); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/agents/import * Import agents from Agent Companies sources. * * Body modes (checked in order): * - { agents: AgentManifest[], skipExisting?, dryRun? } * - { source: string, skipExisting?, dryRun? } // server directory path * - { manifest: string, skipExisting?, dryRun? } // raw AGENTS.md content */ router.post("/agents/import", async (req, res) => { try { const { agents, source, manifest, skipExisting, dryRun } = req.body ?? {}; const { AgentStore, parseCompanyDirectory, parseCompanyArchive, parseSingleAgentManifest, convertAgentCompanies, AgentCompaniesParseError, } = await import("@fusion/core"); const scopedStore = await getScopedStore(req); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const existingAgents = await agentStore.listAgents(); const existingNames = new Set(existingAgents.map((a: any) => a.name)); const conversionOptions = skipExisting ? { skipExisting: [...existingNames] } : undefined; let pkg: { company?: { name?: string; slug?: string }; agents: unknown[]; teams: unknown[]; projects: unknown[]; tasks: unknown[]; }; if (Array.isArray(agents)) { pkg = { company: undefined, agents, teams: [], projects: [], tasks: [], }; } else if (typeof source === "string" && source.trim()) { const sourcePath = resolve(source); if (!existsSync(sourcePath)) { throw badRequest(`source does not exist: ${sourcePath}`); } const isArchive = sourcePath.endsWith(".tar.gz") || sourcePath.endsWith(".tgz") || sourcePath.endsWith(".zip"); if (isArchive) { pkg = await parseCompanyArchive(sourcePath); } else if (nodeFs.statSync(sourcePath).isDirectory()) { pkg = parseCompanyDirectory(sourcePath); } else { throw badRequest("Source must be a server-side directory or archive path"); } } else if (typeof manifest === "string") { const { manifest: singleAgent } = parseSingleAgentManifest(manifest); pkg = { company: undefined, agents: [singleAgent], teams: [], projects: [], tasks: [], }; } else { throw badRequest("Provide one of: agents (array), source (path), or manifest (string)"); } const { inputs, result } = convertAgentCompanies(pkg as any, conversionOptions); const companyName = pkg.company?.name ?? "Unknown"; const companySlug = typeof pkg.company?.slug === "string" ? pkg.company.slug : undefined; if (inputs.length === 0 && result.errors.length === 0 && result.skipped.length === 0) { throw badRequest("No agents found in manifest"); } if (dryRun) { const agentPreview = inputs.map((input: any) => ({ name: input.name, role: input.role, title: typeof input.title === "string" ? input.title : undefined, skills: Array.isArray(input.metadata?.skills) ? input.metadata.skills.filter((skill: unknown): skill is string => typeof skill === "string") : undefined, })); res.json({ dryRun: true, companyName, ...(companySlug ? { companySlug } : {}), agents: agentPreview, created: result.created, skipped: result.skipped, errors: result.errors, }); return; } const created: Array<{ id: string; name: string }> = []; const errors: Array<{ name: string; error: string }> = [...result.errors]; for (const input of inputs) { if (!skipExisting && existingNames.has(input.name)) { errors.push({ name: input.name, error: "Agent with this name already exists" }); continue; } try { const agent = await agentStore.createAgent(input); created.push({ id: agent.id, name: agent.name }); } catch (err: any) { if (err instanceof ApiError) { throw err; } errors.push({ name: input.name, error: err.message }); } } res.json({ companyName, ...(companySlug ? { companySlug } : {}), created, skipped: result.skipped, errors, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err?.name === "AgentCompaniesParseError") { throw badRequest(err.message); } rethrowAsApiError(err); } }); /** * GET /api/agents/stats * Return aggregate stats across all agents. * Must be registered before /agents/:id to avoid "stats" matching :id. */ router.get("/agents/stats", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agents = await agentStore.listAgents(); const activeCount = agents.filter((a: any) => a.state === "active" || a.state === "running").length; const assignedTaskCount = agents.filter((a: any) => a.taskId).length; let completedRuns = 0; let failedRuns = 0; for (const agent of agents) { const runs = await agentStore.getRecentRuns(agent.id, 100); completedRuns += runs.filter((r: any) => r.status === "completed").length; failedRuns += runs.filter((r: any) => r.status === "failed" || r.status === "terminated").length; } const total = completedRuns + failedRuns; const successRate = total > 0 ? completedRuns / total : 0; res.json({ activeCount, assignedTaskCount, completedRuns, failedRuns, successRate }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/org-tree * Return full agent org chart tree. * Must be registered before /agents/:id to avoid "org-tree" matching :id. */ router.get("/agents/org-tree", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const tree = await agentStore.getOrgTree(); res.json(tree); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/resolve/:shortname * Resolve an agent by shortname or ID. * Must be registered before /agents/:id to avoid "resolve" matching :id. */ router.get("/agents/resolve/:shortname", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.resolveAgent(req.params.shortname); if (!agent) { throw notFound("Agent not found"); } res.json({ agent }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id * Get agent by ID with heartbeat history. */ router.get("/agents/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgentDetail(req.params.id, 50); if (!agent) { throw notFound("Agent not found"); } res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/agents/:id * Update agent fields. */ router.patch("/agents/:id", async (req, res) => { try { const body = req.body ?? {}; const updates: import("@fusion/core").AgentUpdateInput = {}; if ("name" in body) { if (body.name !== null && typeof body.name !== "string") { throw badRequest("name must be a string"); } updates.name = body.name ?? undefined; } if ("role" in body) { if (body.role !== null && typeof body.role !== "string") { throw badRequest("role must be a string"); } updates.role = body.role ?? undefined; } if ("metadata" in body) { if (body.metadata !== null && (typeof body.metadata !== "object" || Array.isArray(body.metadata))) { throw badRequest("metadata must be an object"); } updates.metadata = body.metadata ?? undefined; } if ("title" in body) { if (body.title !== null && typeof body.title !== "string") { throw badRequest("title must be a string"); } updates.title = body.title ?? undefined; } if ("icon" in body) { if (body.icon !== null && typeof body.icon !== "string") { throw badRequest("icon must be a string"); } updates.icon = body.icon ?? undefined; } if ("reportsTo" in body) { if (body.reportsTo !== null && typeof body.reportsTo !== "string") { throw badRequest("reportsTo must be a string"); } updates.reportsTo = body.reportsTo ?? undefined; } if ("pauseReason" in body) { if (body.pauseReason !== null && typeof body.pauseReason !== "string") { throw badRequest("pauseReason must be a string"); } updates.pauseReason = body.pauseReason ?? undefined; } if ("runtimeConfig" in body) { if (body.runtimeConfig !== null && (typeof body.runtimeConfig !== "object" || Array.isArray(body.runtimeConfig))) { throw badRequest("runtimeConfig must be an object"); } updates.runtimeConfig = body.runtimeConfig ?? undefined; } if ("permissions" in body) { if (body.permissions !== null && (typeof body.permissions !== "object" || Array.isArray(body.permissions))) { throw badRequest("permissions must be an object"); } updates.permissions = body.permissions ?? undefined; } if ("totalInputTokens" in body) { if (body.totalInputTokens !== null && typeof body.totalInputTokens !== "number") { throw badRequest("totalInputTokens must be a number"); } updates.totalInputTokens = body.totalInputTokens ?? undefined; } if ("totalOutputTokens" in body) { if (body.totalOutputTokens !== null && typeof body.totalOutputTokens !== "number") { throw badRequest("totalOutputTokens must be a number"); } updates.totalOutputTokens = body.totalOutputTokens ?? undefined; } if (!validateAgentInstructionsPayload(res, body.instructionsPath, body.instructionsText)) { return; } if ("instructionsPath" in body) { updates.instructionsPath = body.instructionsPath ?? undefined; } if ("instructionsText" in body) { updates.instructionsText = body.instructionsText ?? undefined; } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgent(req.params.id, updates); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else if (err.message?.includes("cannot be empty")) { throw badRequest(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/access * Get computed access state for an agent. */ router.get("/agents/:id/access", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore, computeAccessState } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const state = computeAccessState(agent); res.json(serializeAccessState(state)); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/agents/:id/permissions * Update agent permission grants. */ router.patch("/agents/:id/permissions", async (req, res) => { try { const { permissions } = req.body ?? {}; if (permissions === undefined || permissions === null || typeof permissions !== "object" || Array.isArray(permissions)) { throw badRequest("permissions must be an object"); } const { AgentStore, isValidPermission } = await import("@fusion/core"); for (const [key, value] of Object.entries(permissions as Record)) { if (key.startsWith("budget:")) { throw badRequest("Budget permissions are not supported"); } if (!isValidPermission(key)) { throw badRequest(`Invalid permission: ${key}`); } if (typeof value !== "boolean") { throw badRequest(`Permission value for ${key} must be boolean`); } } const scopedStore = await getScopedStore(req); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgent(req.params.id, { permissions: permissions as Record, }); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * PATCH /api/agents/:id/instructions * Update agent custom instructions. * Body: { instructionsPath?: string, instructionsText?: string } */ router.patch("/agents/:id/instructions", async (req, res) => { try { const { instructionsPath, instructionsText } = req.body ?? {}; if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) { return; } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgent(req.params.id, { instructionsPath: instructionsPath ?? undefined, instructionsText: instructionsText ?? undefined, }); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/soul * Fetch agent soul/personality text. */ router.get("/agents/:id/soul", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } res.json({ soul: agent.soul ?? null }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/agents/:id/soul * Update agent soul/personality text. * Body: { soul: string } */ router.patch("/agents/:id/soul", async (req, res) => { try { const { soul } = req.body ?? {}; if (typeof soul !== "string") { throw badRequest("soul must be a string"); } if (soul.length > 10000) { throw badRequest("soul must be at most 10,000 characters"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgent(req.params.id, { soul }); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/memory * Fetch per-agent memory text. */ router.get("/agents/:id/memory", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } res.json({ memory: agent.memory ?? null }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/agents/:id/memory * Update per-agent memory text. * Body: { memory: string } */ router.patch("/agents/:id/memory", async (req, res) => { try { const { memory } = req.body ?? {}; if (typeof memory !== "string") { throw badRequest("memory must be a string"); } if (memory.length > 50000) { throw badRequest("memory must be at most 50,000 characters"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgent(req.params.id, { memory }); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); /** * POST /api/agents/:id/state * Update agent state. * Body: { state: AgentState } */ router.post("/agents/:id/state", async (req, res) => { try { const { state } = req.body; if (!state || typeof state !== "string") { throw badRequest("state is required"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.updateAgentState(req.params.id, state as import("@fusion/core").AgentState); res.json(agent); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else if (/invalid state transition/i.test(err.message ?? "")) { throw badRequest(err.message); } else { rethrowAsApiError(err); } } }); /** * DELETE /api/agents/:id * Delete an agent. */ router.delete("/agents/:id", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); await agentStore.deleteAgent(req.params.id); res.status(204).send(); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/config-revisions * List config revisions for an agent. * Query: limit (default: 50) */ router.get("/agents/:id/config-revisions", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const rawLimit = req.query.limit; const limit = rawLimit === undefined ? 50 : Number.parseInt(String(rawLimit), 10); if (!Number.isInteger(limit) || limit <= 0) { throw badRequest("limit must be a positive integer"); } const revisions = await agentStore.getConfigRevisions(req.params.id, limit); res.json(revisions); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/config-revisions/:revisionId * Get a specific config revision for an agent. */ router.get("/agents/:id/config-revisions/:revisionId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const revision = await agentStore.getConfigRevision(req.params.id, req.params.revisionId); if (!revision) { throw notFound("Config revision not found"); } res.json(revision); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/agents/:id/config-revisions/:revisionId/rollback * Roll back an agent to a previous config revision. */ router.post("/agents/:id/config-revisions/:revisionId/rollback", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const result = await agentStore.rollbackConfig(req.params.id, req.params.revisionId); res.json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("belongs to agent")) { throw badRequest(err.message); } else if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/budget * Get budget status for an agent. */ router.get("/agents/:id/budget", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const budgetStatus = await agentStore.getBudgetStatus(req.params.id); res.json(budgetStatus); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/:id/budget/reset * Reset budget usage for an agent. */ router.post("/agents/:id/budget/reset", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } await agentStore.resetBudgetUsage(req.params.id); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/:id/keys * Create a new API key for an agent. * Body: { label?: string } */ router.post("/agents/:id/keys", async (req, res) => { try { const { label } = req.body ?? {}; if (label !== undefined && typeof label !== "string") { throw badRequest("label must be a string"); } const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const result = await agentStore.createApiKey(req.params.id, { label }); res.status(201).json(result); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/keys * List all API keys for an agent. */ router.get("/agents/:id/keys", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const keys = await agentStore.listApiKeys(req.params.id); res.json(keys); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * DELETE /api/agents/:id/keys/:keyId * Revoke an API key for an agent. */ router.delete("/agents/:id/keys/:keyId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const revoked = await agentStore.revokeApiKey(req.params.id, req.params.keyId); res.json(revoked); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/tasks * List tasks explicitly assigned to the given agent. */ router.get("/agents/:id/tasks", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const tasks = await scopedStore.listTasks(); res.json(tasks.filter((task) => task.assignedAgentId === req.params.id)); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/agents/:id/inbox * Select the next inbox-lite task candidate for an agent. * * Returns `{ task, priority, reason }` when work is available, * or `{ task: null }` when no matching work is found. */ router.post("/agents/:id/inbox", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agentId = req.params.id; const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } const selection = await scopedStore.selectNextTaskForAgent(agentId); if (!selection) { res.json({ task: null }); return; } res.json({ task: selection.task, priority: selection.priority, reason: selection.reason, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/agents/:id/heartbeat * Record a heartbeat for an agent. * Body: { status?: "ok"|"missed"|"recovered", triggerExecution?: boolean } * * When triggerExecution is true AND HeartbeatMonitor is available, * also executes a heartbeat run after recording the heartbeat event. */ router.post("/agents/:id/heartbeat", async (req, res) => { try { const { status = "ok", triggerExecution } = req.body; const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const event = await agentStore.recordHeartbeat(req.params.id, status as "ok" | "missed" | "recovered"); // Optionally trigger execution let run: import("@fusion/core").AgentHeartbeatRun | undefined; if (triggerExecution && hasHeartbeatExecutor && heartbeatMonitor) { run = await heartbeatMonitor.executeHeartbeat({ agentId: req.params.id, source: "on_demand", triggerDetail: "Triggered from heartbeat", contextSnapshot: { wakeReason: "on_demand", triggerDetail: "Triggered from heartbeat", }, }); } res.json(run ? { event, run } : event); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/heartbeats * Get heartbeat history for an agent. * Query: limit (default: 50) */ router.get("/agents/:id/heartbeats", async (req, res) => { try { const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50; const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const history = await agentStore.getHeartbeatHistory(req.params.id, limit); res.json(history); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/runs * List recent runs for an agent. * Query: limit (default: 20) */ router.get("/agents/:id/runs", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 20; const runs = await agentStore.getRecentRuns(req.params.id, limit); res.json(runs); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/:id/runs * Manually start a heartbeat run for an agent. * Body: { * source?: HeartbeatInvocationSource, * triggerDetail?: string, * taskId?: string, * triggeringCommentIds?: string[], * triggeringCommentType?: "steering" | "task" | "pr", * } * * When HeartbeatMonitor is available, delegates to executeHeartbeat() with * a structured wake context snapshot. This ensures a single authoritative run * record is created and fully completed without duplicate startRun calls. * * Returns 409 Conflict if the agent already has an active run. */ router.post("/agents/:id/runs", async (req, res) => { try { const { source, triggerDetail, taskId, triggeringCommentIds, triggeringCommentType } = req.body || {}; const invocationSource = source ?? "on_demand"; const trigger = triggerDetail ?? "Triggered from dashboard"; if (triggeringCommentIds !== undefined) { if (!Array.isArray(triggeringCommentIds) || triggeringCommentIds.some((id) => typeof id !== "string")) { throw badRequest("triggeringCommentIds must be an array of strings"); } } if ( triggeringCommentType !== undefined && triggeringCommentType !== "steering" && triggeringCommentType !== "task" && triggeringCommentType !== "pr" ) { throw badRequest("triggeringCommentType must be one of: steering, task, pr"); } const normalizedTriggeringCommentIds = Array.isArray(triggeringCommentIds) ? triggeringCommentIds.map((id) => id.trim()).filter((id) => id.length > 0) : undefined; const normalizedTriggeringCommentType = triggeringCommentType === "steering" || triggeringCommentType === "task" || triggeringCommentType === "pr" ? triggeringCommentType : undefined; // Build structured wake context const contextSnapshot: Record = { wakeReason: invocationSource, triggerDetail: trigger, }; if (taskId) { contextSnapshot.taskId = taskId; } if (normalizedTriggeringCommentIds?.length) { contextSnapshot.triggeringCommentIds = normalizedTriggeringCommentIds; } if (normalizedTriggeringCommentType) { contextSnapshot.triggeringCommentType = normalizedTriggeringCommentType; } if (hasHeartbeatExecutor && heartbeatMonitor) { // Check for existing active run const scopedStore = await getScopedStore(req); const { AgentStore: AgentStoreClass } = await import("@fusion/core"); const agentStore = new AgentStoreClass({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id); if (activeRun) { throw new ApiError(409, "Agent already has an active run", { runId: activeRun.id }); } // Execute heartbeat end-to-end (single run record, no duplicate startRun call) const run = await heartbeatMonitor.executeHeartbeat({ agentId: req.params.id, source: invocationSource, triggerDetail: trigger, taskId, triggeringCommentIds: normalizedTriggeringCommentIds, triggeringCommentType: normalizedTriggeringCommentType, contextSnapshot, }); res.status(201).json(run); } else { // Fallback: record-only behavior without HeartbeatMonitor const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); // Check for existing active run const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id); if (activeRun) { throw new ApiError(409, "Agent already has an active run", { runId: activeRun.id }); } const run = await agentStore.startHeartbeatRun(req.params.id); // Enrich with invocation source, trigger detail, and context snapshot (run as any).invocationSource = invocationSource; (run as any).triggerDetail = trigger; (run as any).contextSnapshot = contextSnapshot; await agentStore.saveRun(run); res.status(201).json(run); } } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/:id/runs/stop * Stop the currently active heartbeat run for an agent. */ router.post("/agents/:id/runs/stop", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const activeRun = await agentStore.getActiveHeartbeatRun(req.params.id); if (!activeRun) { res.status(200).json({ ok: true, message: "No active run" }); return; } if (hasHeartbeatExecutor && heartbeatMonitor) { await heartbeatMonitor.stopRun(req.params.id); } else { const existingRun = await agentStore.getRunDetail(req.params.id, activeRun.id); if (existingRun) { await agentStore.saveRun({ ...existingRun, endedAt: new Date().toISOString(), status: "terminated", stderrExcerpt: existingRun.stderrExcerpt ?? "Run stopped by user", }); } await agentStore.endHeartbeatRun(activeRun.id, "terminated"); try { await agentStore.updateAgentState(req.params.id, "active"); } catch { // Best effort to restore an idle/active state for follow-up runs. } } res.status(200).json({ ok: true, runId: activeRun.id }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/runs/:runId * Get detail for a specific agent run. */ router.get("/agents/:id/runs/:runId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const run = await agentStore.getRunDetail(req.params.id, req.params.runId); if (!run) { throw notFound("Run not found"); } res.json(run); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/runs/:runId/logs * Get agent log entries for a specific run's time window. * Uses the run's contextSnapshot.taskId to locate the task's agent log, * then filters entries by the run's startedAt/endedAt timestamps. * Returns an empty array if the run has no associated task. */ router.get("/agents/:id/runs/:runId/logs", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const run = await agentStore.getRunDetail(req.params.id, req.params.runId); if (!run) { throw notFound("Run not found"); } // Only use the run's context snapshot for task ID — do not fall back // to agent.taskId since that represents the agent's *current* task, // not the task active during a historical run. const taskId = run.contextSnapshot?.taskId as string | undefined; if (!taskId) { res.json([]); return; } const logs = await scopedStore.getAgentLogsByTimeRange( taskId, run.startedAt, run.endedAt, ); res.json(logs); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/runs/:runId/mutations * Get the mutation trail for a specific agent run. * Returns all TaskLogEntry objects correlated with the given runId via runContext. */ router.get("/agents/:id/runs/:runId/mutations", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); // Verify the run exists const run = await agentStore.getRunDetail(req.params.id, req.params.runId); if (!run) { throw notFound("Run not found"); } // Query mutation trail const mutations = await scopedStore.getMutationsForRun(req.params.runId); res.json({ runId: req.params.runId, mutations }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/chain-of-command * Fetch agent reporting chain from self to top-most manager. * Response 200: Agent[] — [self, manager, grand-manager, ...] * Response 404: { error: "Agent not found" } — When target agent doesn't exist */ router.get("/agents/:id/chain-of-command", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agent = await agentStore.getAgent(req.params.id); if (!agent) { throw notFound("Agent not found"); } const chain = await agentStore.getChainOfCommand(req.params.id); res.json(chain); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/children * Fetch agents that report to a given agent (parent-child hierarchy). * Response 200: Agent[] — Array of agents where reportsTo equals :id * Response 404: { error: "Agent not found" } — When parent agent doesn't exist */ const getAgentEmployeesHandler = async (req: Request, res: Response) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the parent agent exists const parent = await agentStore.getAgent(agentId); if (!parent) { throw notFound("Agent not found"); } const children = await agentStore.getAgentsByReportsTo(agentId); res.json(children); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }; router.get("/agents/:id/children", getAgentEmployeesHandler); /** * GET /api/agents/:id/employees * Alias for /api/agents/:id/children. */ router.get("/agents/:id/employees", getAgentEmployeesHandler); // ── Agent Reflection Routes ────────────────────────────────────────────── /** * GET /api/agents/:id/reflections/latest * Fetch the most recent reflection for an agent. * Must be registered before /agents/:id/reflections to avoid matching "latest" as a limit. * Response 200: AgentReflection | null — The most recent reflection or null * Response 404: { error: "Agent not found" } — When agent doesn't exist * { error: "No reflections found" } — When agent has no reflections */ router.get("/agents/:id/reflections/latest", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore, ReflectionStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the agent exists const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } const reflection = await reflectionStore.getLatestReflection(agentId); if (!reflection) { throw notFound("No reflections found"); } res.json(reflection); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/reflections * List reflection history for an agent. * Query params: limit (optional, default 50) * Response 200: AgentReflection[] — Array of reflections * Response 404: { error: "Agent not found" } — When agent doesn't exist */ router.get("/agents/:id/reflections", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore, ReflectionStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the agent exists const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } // Parse limit from query params (default 50) const limitParam = req.query.limit; const limit = limitParam ? parseInt(String(limitParam), 10) : 50; const reflections = await reflectionStore.getReflections(agentId, limit); res.json(reflections); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/agents/:id/reflections * Trigger a manual reflection for an agent. * Response 201: AgentReflection — The created reflection * Response 404: { error: "Agent not found" } — When agent doesn't exist * Response 500: { error: message } — When reflection generation fails */ router.post("/agents/:id/reflections", async (req, res) => { try { const taskStore = await getScopedStore(req); const { AgentStore, ReflectionStore } = await import("@fusion/core"); const { AgentReflectionService } = await import("@fusion/engine"); const agentStore = new AgentStore({ rootDir: taskStore.getRootDir() }); const reflectionStore = new ReflectionStore({ rootDir: taskStore.getRootDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the agent exists const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } // Create the reflection service and generate a reflection const reflectionService = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: taskStore.getRootDir(), }); const reflection = await reflectionService.generateReflection(agentId, "manual"); res.status(201).json(reflection); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/performance * Get aggregated performance summary for an agent. * Query params: windowMs (optional, default 7 days) * Response 200: AgentPerformanceSummary * Response 404: { error: "Agent not found" } — When agent doesn't exist */ router.get("/agents/:id/performance", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore, ReflectionStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); const reflectionStore = new ReflectionStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the agent exists const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } // Parse windowMs from query params (default 7 days) const windowMsParam = req.query.windowMs; const windowMs = windowMsParam ? parseInt(String(windowMsParam), 10) : undefined; const summary = await reflectionStore.getPerformanceSummary(agentId, { windowMs }); res.json(summary); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/reflection-context * Get raw context for debugging agent reflections. * Response 200: { context: object } — The built reflection context * Response 404: { error: "Agent not found" } — When agent doesn't exist * Response 503: { error: "Reflection service not available" } — When engine not initialized */ router.get("/agents/:id/reflection-context", async (req, res) => { try { const taskStore = await getScopedStore(req); const { AgentStore, ReflectionStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: taskStore.getRootDir() }); const reflectionStore = new ReflectionStore({ rootDir: taskStore.getRootDir() }); await agentStore.init(); const agentId = Array.isArray(req.params.id) ? req.params.id[0] : req.params.id; if (!agentId) { throw badRequest("Agent id is required"); } // Validate the agent exists const agent = await agentStore.getAgent(agentId); if (!agent) { throw notFound("Agent not found"); } // Check if AgentReflectionService is available let AgentReflectionService: any; try { const engine = await import("@fusion/engine"); AgentReflectionService = engine.AgentReflectionService; } catch { res.status(503).json({ error: "Reflection service not available" }); return; } if (!AgentReflectionService) { res.status(503).json({ error: "Reflection service not available" }); return; } // Create the service and build the context const reflectionService = new AgentReflectionService({ agentStore, taskStore, reflectionStore, rootDir: taskStore.getRootDir(), }); const context = await reflectionService.buildReflectionContext(agentId); res.json({ context }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Agent Rating Routes ───────────────────────────────────────────────── /** * GET /api/agents/:id/ratings * Fetch ratings for an agent. * Query params: limit (number, default 50), category (string, optional) * Response 200: AgentRating[] */ router.get("/agents/:id/ratings", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50; const category = typeof req.query.category === "string" ? req.query.category : undefined; const ratings = await agentStore.getRatings(req.params.id, { limit, category }); res.json(ratings); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * POST /api/agents/:id/ratings * Add a rating for an agent. * Body: { score: number, category?: string, comment?: string, runId?: string, taskId?: string, raterType?: string } * Response 201: AgentRating — The created rating * Response 400: { error: "score is required" } — When score is missing * { error: "score must be a number between 1 and 5" } — When score is invalid */ router.post("/agents/:id/ratings", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const { score, category, comment, runId, taskId, raterType } = req.body || {}; // Validate score if (score === undefined || score === null) { throw badRequest("score is required"); } if (typeof score !== "number" || !Number.isFinite(score) || score < 1 || score > 5) { throw badRequest("score must be a number between 1 and 5"); } // Default raterType to "user" if not provided const resolvedRaterType = raterType || "user"; const rating = await agentStore.addRating(req.params.id, { score, category, comment, runId, taskId, raterType: resolvedRaterType, }); res.status(201).json(rating); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * GET /api/agents/:id/ratings/summary * Fetch rating summary for an agent. * Response 200: AgentRatingSummary */ router.get("/agents/:id/ratings/summary", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); const summary = await agentStore.getRatingSummary(req.params.id); res.json(summary); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); /** * DELETE /api/agents/:id/ratings/:ratingId * Delete a specific rating. * Response 204: No Content */ router.delete("/agents/:id/ratings/:ratingId", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { AgentStore } = await import("@fusion/core"); const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() }); await agentStore.init(); await agentStore.deleteRating(req.params.ratingId); res.status(204).send(); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message?.includes("not found")) { throw notFound(err.message); } else { rethrowAsApiError(err); } } }); // ── Agent Generation Routes ────────────────────────────────────────────── /** * POST /api/agents/generate/start * Start a new agent generation session. * Body: { role: string } * Response: { sessionId, roleDescription } */ router.post("/agents/generate/start", async (req, res) => { try { const { role } = req.body as { role?: string }; if (!role || typeof role !== "string") { throw badRequest("role is required and must be a string"); } const trimmedRole = role.trim(); if (trimmedRole.length === 0) { throw badRequest("role must not be empty"); } if (trimmedRole.length > 1000) { throw badRequest("role must not exceed 1000 characters"); } const ip = req.ip || req.socket.remoteAddress || "unknown"; const session = await startAgentGeneration(ip, trimmedRole); res.status(201).json({ sessionId: session.id, roleDescription: session.roleDescription, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof AgentGenerationRateLimitError) { throw rateLimited(err.message); } console.error("[agent-generation] Error starting session:", err); rethrowAsApiError(err, "Failed to start agent generation session"); } }); /** * POST /api/agents/generate/spec * Generate the agent specification for an existing session. * Body: { sessionId: string } * Response: { spec: AgentGenerationSpec } */ router.post("/agents/generate/spec", async (req, res) => { try { const { sessionId } = req.body as { sessionId?: string }; if (!sessionId || typeof sessionId !== "string") { throw badRequest("sessionId is required"); } const scopedStore = await getScopedStore(req); const rootDir = scopedStore.getRootDir(); const spec = await generateAgentSpec(sessionId, rootDir); res.json({ spec }); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err instanceof AgentGenerationSessionNotFoundError) { throw notFound(err.message); } console.error("[agent-generation] Error generating spec:", err); rethrowAsApiError(err, "Failed to generate agent specification"); } }); /** * GET /api/agents/generate/:sessionId * Get the current state of an agent generation session. * Response: { session: AgentGenerationSession } */ router.get("/agents/generate/:sessionId", async (req, res) => { try { const { sessionId } = req.params; const session = getAgentGenerationSession(sessionId); if (!session) { throw notFound(`Session ${sessionId} not found or expired`); } res.json({ session }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * DELETE /api/agents/generate/:sessionId * Cancel and clean up an agent generation session. * Response: { success: true } */ router.delete("/agents/generate/:sessionId", async (req, res) => { try { const { sessionId } = req.params; cleanupAgentGenerationSession(sessionId); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Mission Routes ───────────────────────────────────────────────────────── // Mount mission routes at /api/missions router.use("/missions", createMissionRouter(store, options?.missionAutopilot, aiSessionStore)); // ── Plugin Routes ───────────────────────────────────────────────────────── // Mount plugin routes at /api/plugins if (options?.pluginStore && options?.pluginLoader) { router.use("/plugins", createPluginRouter(options.pluginStore, options.pluginLoader, options.pluginRunner)); } // ── AI Session Routes (Background Tasks) ───────────────────────────────── /** * GET /api/ai-sessions * List active background AI sessions (generating or awaiting_input). * Query: { projectId?: string } */ router.get("/ai-sessions", (req, res) => { if (!aiSessionStore) { res.json({ sessions: [] }); return; } const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; const sessions = aiSessionStore.listActive(projectId); res.json({ sessions }); }); /** * DELETE /api/ai-sessions/cleanup * Cleanup stale AI sessions with optional max-age override. */ router.delete("/ai-sessions/cleanup", (req, res) => { if (!aiSessionStore) { sendErrorResponse(res, 503, "Session store not available"); return; } const minimumMaxAgeMs = 60 * 60 * 1000; let maxAgeMs = SESSION_CLEANUP_DEFAULT_MAX_AGE_MS; if (typeof req.query.maxAgeMs === "string") { const parsed = Number(req.query.maxAgeMs); if (!Number.isFinite(parsed)) { throw badRequest("maxAgeMs must be a valid number"); } maxAgeMs = Math.max(minimumMaxAgeMs, Math.floor(parsed)); } const result = aiSessionStore.cleanupStaleSessions(maxAgeMs); res.json({ ...result, maxAgeMs, }); }); /** * GET /api/ai-sessions/:id * Get full session state for modal reconnection. */ router.get("/ai-sessions/:id", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const session = aiSessionStore.get(req.params.id); if (!session) { throw notFound("Session not found"); } res.json(session); }); router.post("/ai-sessions/:id/lock", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const session = aiSessionStore.get(id); if (!session) { throw notFound("Session not found"); } const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : ""; if (!tabId) { throw badRequest("tabId is required"); } const result = aiSessionStore.acquireLock(id, tabId); if (!result.acquired) { res.json({ acquired: false, currentHolder: result.currentHolder }); return; } res.json({ acquired: true }); }); router.delete("/ai-sessions/:id/lock", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : ""; if (!tabId) { throw badRequest("tabId is required"); } aiSessionStore.releaseLock(id, tabId); res.json({ success: true }); }); router.post("/ai-sessions/:id/lock/force", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const session = aiSessionStore.get(id); if (!session) { throw notFound("Session not found"); } const tabId = typeof req.body?.tabId === "string" ? req.body.tabId.trim() : ""; if (!tabId) { throw badRequest("tabId is required"); } aiSessionStore.forceAcquireLock(id, tabId); res.json({ success: true }); }); router.delete("/ai-sessions/:id/lock/beacon", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const tabId = typeof req.query.tabId === "string" ? req.query.tabId.trim() : ""; if (tabId) { aiSessionStore.releaseLock(id, tabId); } res.status(200).end(); }); /** * POST /api/ai-sessions/:id/ping * Lightweight keep-alive touch for active AI sessions. */ router.post("/ai-sessions/:id/ping", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const updated = aiSessionStore.ping(id); if (!updated) { throw notFound("Session not found"); } res.json({ ok: true }); }); /** * DELETE /api/ai-sessions/:id * Dismiss/cancel a background AI session. * Also cleans up the in-memory agent if still alive. */ router.delete("/ai-sessions/:id", (req, res) => { if (!aiSessionStore) { throw notFound("AI sessions not available"); } const { id } = req.params; const session = aiSessionStore.get(id); if (!session) { throw notFound("Session not found"); } aiSessionStore.delete(id); try { if (getPlanningSession(id)) cleanupPlanningSession(id); } catch { // Session may not belong to planning or may already be cleaned up. } try { if (getSubtaskSession(id)) cleanupSubtaskSession(id); } catch { // Session may not belong to subtask breakdown or may already be cleaned up. } try { if (getMissionInterviewSession(id)) cleanupMissionInterviewSession(id); } catch { // Session may not belong to mission interview or may already be cleaned up. } res.json({ ok: true }); }); // ── Directory Browsing ──────────────────────────────────────────────────────── /** * GET /api/browse-directory * Browse filesystem directories for the directory picker. * Query: { path?: string, showHidden?: "true" } * Returns: { currentPath: string, parentPath: string | null, entries: Array<{ name: string, path: string, hasChildren: boolean }> } */ router.get("/browse-directory", async (req, res) => { try { const { resolve, dirname, join } = await import("node:path"); const { readdir, stat } = await import("node:fs/promises"); const rawPath = (req.query.path as string) || process.env.HOME || process.env.USERPROFILE || "/"; const showHidden = req.query.showHidden === "true"; // Validate: must be absolute, no .. traversal const resolvedPath = resolve(rawPath); if (rawPath.includes("..")) { throw badRequest("Path must not contain '..' traversal"); } if (resolvedPath !== resolve(resolvedPath)) { throw badRequest("Path must be absolute"); } // Check path exists and is a directory let pathStat; try { pathStat = await stat(resolvedPath); } catch { throw notFound("Directory not found"); } if (!pathStat.isDirectory()) { throw badRequest("Path is not a directory"); } // Read directory entries const dirEntries = await readdir(resolvedPath, { withFileTypes: true }); const entries: Array<{ name: string; path: string; hasChildren: boolean }> = []; for (const entry of dirEntries) { if (!entry.isDirectory()) continue; if (!showHidden && entry.name.startsWith(".")) continue; const entryPath = join(resolvedPath, entry.name); let hasChildren = false; try { const subEntries = await readdir(entryPath, { withFileTypes: true }); hasChildren = subEntries.some((e) => e.isDirectory()); } catch { // Can't read subdirectory — treat as no children } entries.push({ name: entry.name, path: entryPath, hasChildren }); } entries.sort((a, b) => a.name.localeCompare(b.name)); const parentPath = resolvedPath === "/" ? null : dirname(resolvedPath); res.json({ currentPath: resolvedPath, parentPath, entries }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Project Management Routes (Multi-Project Support) ─────────────────────── // These routes require CentralCore which is imported dynamically to avoid // circular dependencies and ensure the central database is initialized. /** * GET /api/projects * List all registered projects with their basic info. * Returns: ProjectInfo[] */ router.get("/projects", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); // Reconcile stale "initializing" projects before listing so the // dashboard never shows permanent loading spinners for legacy records. await central.reconcileProjectStatuses(); const projects = prioritizeProjectsForCurrentDirectory(await central.listProjects()); await central.close(); res.json(projects); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/projects * Register a new project. * Body: { name: string, path: string, isolationMode?: "in-process" | "child-process" } * Returns: RegisteredProject */ router.post("/projects", async (req, res) => { try { const { name, path, isolationMode = "in-process" } = req.body; if (!name || typeof name !== "string" || !name.trim()) { throw badRequest("name is required and must be a non-empty string"); } if (!path || typeof path !== "string" || !path.trim()) { throw badRequest("path is required and must be a non-empty string"); } if (!["in-process", "child-process"].includes(isolationMode)) { throw badRequest("isolationMode must be 'in-process' or 'child-process'"); } // Check if path exists and has .fusion/ directory const { existsSync } = await import("node:fs"); const { join } = await import("node:path"); if (!existsSync(path)) { throw badRequest("Project path does not exist"); } const hasFusionDir = existsSync(join(path, ".fusion")); const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.registerProject({ name: name.trim(), path: path.trim(), isolationMode, }); // Activate the project (registration sets it to 'initializing') const activeProject = await central.updateProject(project.id, { status: "active" }); await central.close(); res.status(201).json({ ...activeProject, _meta: { hasFusionDir: hasFusionDir ? undefined : false } }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("already registered") ? 409 : err.message?.includes("Duplicate path") ? 409 : 500; throw new ApiError(status, err.message); } }); /** * POST /api/projects/detect * Auto-detect fn projects in a directory. * Body: { basePath?: string } * Returns: { projects: DetectedProject[] } */ router.post("/projects/detect", async (req, res) => { try { const { basePath } = req.body; const { existsSync } = await import("node:fs"); const { join } = await import("node:path"); const { readdir } = await import("node:fs/promises"); // Default to home directory if no basePath provided const searchPath = basePath || process.env.HOME || process.env.USERPROFILE || "."; if (!existsSync(searchPath)) { throw badRequest("Base path does not exist"); } // Get list of existing projects to check for duplicates const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const existingProjects = await central.listProjects(); await central.close(); const existingPaths = new Set(existingProjects.map((p: { path: string }) => p.path)); // Scan for .fusion/fusion.db or .fusion/fusion.db files (indicating fn projects) const detected: Array<{ path: string; suggestedName: string; existing: boolean }> = []; try { const entries = await readdir(searchPath, { withFileTypes: true }); for (const entry of entries) { if (!entry.isDirectory()) continue; const dirPath = join(searchPath, entry.name); const hasKbDb = existsSync(join(dirPath, ".fusion", "fusion.db")); const hasFusionDir = existsSync(join(dirPath, ".fusion")); if (hasKbDb || hasFusionDir) { detected.push({ path: dirPath, suggestedName: entry.name, existing: existingPaths.has(dirPath), }); } } } catch { // Ignore read errors } res.json({ projects: detected }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/projects/:id * Get a single project by ID. */ router.get("/projects/:id", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.getProject(req.params.id); await central.close(); if (!project) { throw notFound("Project not found"); } res.json(project); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/projects/:id * Update a project. */ router.patch("/projects/:id", async (req, res) => { try { const { name, status, isolationMode, nodeId } = req.body; const updates: Partial = {}; if (name !== undefined) updates.name = name; if (status !== undefined) updates.status = status as import("@fusion/core").ProjectStatus; if (isolationMode !== undefined) updates.isolationMode = isolationMode as "in-process" | "child-process"; const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.updateProject(req.params.id, updates); if (!project) { await central.close(); throw notFound("Project not found"); } let resultProject = project; if (nodeId !== undefined) { if (nodeId === null) { resultProject = await central.unassignProjectFromNode(req.params.id); } else if (typeof nodeId === "string" && nodeId.trim()) { resultProject = await central.assignProjectToNode(req.params.id, nodeId.trim()); } else { await central.close(); throw badRequest("nodeId must be a non-empty string or null"); } } await central.close(); res.json(resultProject); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); /** * DELETE /api/projects/:id * Unregister a project. */ router.delete("/projects/:id", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); await central.unregisterProject(req.params.id); await central.close(); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); /** * GET /api/projects/:id/health * Get health metrics for a specific project. * Returns: ProjectHealth */ router.get("/projects/:id/health", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const { realpath } = await import("node:fs/promises"); const central = new CentralCore(); await central.init(); const project = await central.getProject(req.params.id); if (!project) { await central.close(); throw notFound("Project not found"); } const health = await central.getProjectHealth(req.params.id); await central.close(); if (!health) { throw notFound("Project health not found"); } // If this dashboard serves the requested project, compute live counts // from the task store instead of relying on cached central DB values. try { const storePath = await realpath(store.getRootDir()); const projectPath = await realpath(project.path); if (storePath === projectPath) { const tasks = await store.listTasks(); const activeCols = new Set(["triage", "todo", "in-progress", "in-review"]); const activeTaskCount = tasks.filter((t) => activeCols.has(t.column)).length; const inFlightAgentCount = tasks.filter((t) => t.column === "in-progress").length; const totalTasksCompleted = tasks.filter((t) => t.column === "done" || t.column === "archived").length; res.json({ ...health, activeTaskCount, inFlightAgentCount, totalTasksCompleted, }); return; } } catch { // realpath may fail if a path doesn't exist; fall through to cached data } res.json(health); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/projects/:id/config * Get project-specific configuration. * Returns: { maxConcurrent: number, rootDir: string } */ router.get("/projects/:id/config", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.getProject(req.params.id); await central.close(); if (!project) { throw notFound("Project not found"); } res.json({ maxConcurrent: 2, rootDir: project.path, }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/projects/:id/pause * Pause a project. */ router.post("/projects/:id/pause", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.updateProject(req.params.id, { status: "paused" }); await central.updateProjectHealth(req.params.id, { status: "paused" }); await central.close(); res.json(project); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); /** * POST /api/projects/:id/resume * Resume a paused project. */ router.post("/projects/:id/resume", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const project = await central.updateProject(req.params.id, { status: "active" }); await central.updateProjectHealth(req.params.id, { status: "active" }); await central.close(); res.json(project); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); // ── Node Management Routes (Multi-Node Support) ─────────────────────────── /** * GET /api/nodes * List all registered nodes. * Returns: NodeConfig[] */ router.get("/nodes", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const nodes = await central.listNodes(); await central.close(); nodes.sort((a, b) => a.name.localeCompare(b.name)); res.json(nodes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/nodes * Register a new node. * Body: { name, type, url?, apiKey?, maxConcurrent?, capabilities? } */ router.post("/nodes", async (req, res) => { try { const { name, type, url, apiKey, maxConcurrent, capabilities } = req.body; if (!name || typeof name !== "string" || !name.trim()) { throw badRequest("name is required and must be a non-empty string"); } // Default to "remote" for backward compatibility with frontend API calls const nodeType = type === "local" || type === "remote" ? type : "remote"; if (nodeType === "remote" && (!url || typeof url !== "string" || !url.trim())) { throw badRequest("url is required for remote nodes"); } if ( maxConcurrent !== undefined && (typeof maxConcurrent !== "number" || !Number.isFinite(maxConcurrent) || maxConcurrent < 1) ) { throw badRequest("maxConcurrent must be a number >= 1"); } if ( capabilities !== undefined && (!Array.isArray(capabilities) || capabilities.some((capability) => typeof capability !== "string")) ) { throw badRequest("capabilities must be an array of strings"); } const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const node = await central.registerNode({ name: name.trim(), type: nodeType, url: typeof url === "string" ? url.trim() : undefined, apiKey: typeof apiKey === "string" ? apiKey : undefined, maxConcurrent, capabilities, }); await central.close(); res.status(201).json(node); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("already exists") ? 409 : err.message?.includes("must") ? 400 : 500; throw new ApiError(status, err.message); } }); /** * GET /api/nodes/:id * Get node details by ID. */ router.get("/nodes/:id", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const node = await central.getNode(req.params.id); await central.close(); if (!node) { throw notFound("Node not found"); } res.json(node); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * PATCH /api/nodes/:id * Update node config. */ router.patch("/nodes/:id", async (req, res) => { try { const { name, url, apiKey, maxConcurrent, status, capabilities } = req.body; const updates: Partial> = {}; if (name !== undefined) updates.name = name; if (url !== undefined) updates.url = url; if (apiKey !== undefined) updates.apiKey = apiKey; if (maxConcurrent !== undefined) updates.maxConcurrent = maxConcurrent; if (status !== undefined) updates.status = status as import("@fusion/core").NodeStatus; if (capabilities !== undefined) updates.capabilities = capabilities; const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const node = await central.updateNode(req.params.id, updates); await central.close(); res.json(node); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : err.message?.includes("must") ? 400 : 500; throw new ApiError(status, err.message); } }); /** * DELETE /api/nodes/:id * Unregister a node. */ router.delete("/nodes/:id", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const existing = await central.getNode(req.params.id); if (!existing) { await central.close(); throw notFound("Node not found"); } await central.unregisterNode(req.params.id); await central.close(); res.status(204).end(); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/nodes/:id/health-check * Trigger health check for a node. */ router.post("/nodes/:id/health-check", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const healthStatus = await central.checkNodeHealth(req.params.id); await central.close(); res.json({ status: healthStatus }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("not found") ? 404 : 500; throw new ApiError(status, err.message); } }); /** * GET /api/nodes/:id/metrics * Get node runtime metrics (SystemMetrics from node's systemMetrics field). */ router.get("/nodes/:id/metrics", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const node = await central.getNode(req.params.id); await central.close(); if (!node) { throw notFound("Node not found"); } // Return the systemMetrics field which contains SystemMetrics or null res.json(node.systemMetrics ?? null); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/mesh/state * Get full mesh topology state (all nodes with their metrics and known peers). */ router.get("/mesh/state", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const nodes = await central.listNodes(); const meshStates = await Promise.all( nodes.map((node) => central.getMeshState(node.id)) ); await central.close(); res.json(meshStates); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Mesh Topology Routes ──────────────────────────────────────────────── /** * GET /api/mesh/state * Returns the full mesh topology state with peer connections between nodes. */ router.get("/mesh/state", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const nodes = await central.listNodes(); await central.close(); // Return nodes with connection info for topology visualization const meshState = nodes.map((node) => ({ id: node.id, name: node.name, type: node.type, status: node.status, url: node.url ?? null, // For now, we don't have peer-to-peer connection info, // so all connections are shown as connected to local node connections: nodes .filter((n) => n.id !== node.id) .map((n) => ({ peerId: n.id, peerName: n.name, status: n.status, })), })); res.json(meshState); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/mesh/sync * Exchange peer information with another node for gossip protocol. * * Request body: PeerSyncRequest * Response body: PeerSyncResponse */ router.post("/mesh/sync", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); // Validate required fields const senderNodeId = req.body?.senderNodeId; if (!senderNodeId) { throw badRequest("senderNodeId is required"); } const knownPeers = req.body?.knownPeers; if (!Array.isArray(knownPeers)) { throw badRequest("knownPeers must be an array"); } // Optional: validate knownPeers entries have required fields for (const peer of knownPeers) { if (!peer?.nodeId || !peer?.nodeName || typeof peer?.status !== "string") { throw badRequest("Each knownPeers entry must have nodeId, nodeName, and status"); } } // Get sender node from registry to validate auth const senderNode = await central.getNode(senderNodeId); // Auth validation: if sender is registered with an apiKey, validate it if (senderNode?.apiKey) { const authHeader = req.headers.authorization; const token = authHeader?.startsWith("Bearer ") ? authHeader.slice(7) : undefined; if (!token || token !== senderNode.apiKey) { await central.close(); res.status(401).json({ error: "Unauthorized" }); return; } } // Merge incoming peer data await central.mergePeers(knownPeers); // Update sender node status to online (it sent us a request, so it's alive) try { await central.updateNode(senderNodeId, { status: "online" }); } catch { // Silently skip if sender node not found in local registry } // Get all known peers const allKnownPeers = await central.getAllKnownPeerInfo(); // Calculate newPeers - peers the sender doesn't know about const senderKnownIds = new Set(knownPeers.map((p: { nodeId: string }) => p.nodeId)); const newPeers = allKnownPeers.filter((peer) => !senderKnownIds.has(peer.nodeId)); // Get local node info const localPeer = await central.getLocalPeerInfo(); await central.close(); // Return sync response res.json({ senderNodeId: localPeer.nodeId, senderNodeUrl: localPeer.nodeUrl, knownPeers: allKnownPeers, newPeers, timestamp: new Date().toISOString(), }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Node Discovery Routes (mDNS / DNS-SD) ──────────────────────────────── /** * GET /api/discovery/status * Returns whether discovery is active and the current config. */ router.get("/discovery/status", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const active = central.isDiscoveryActive(); const config = central.getDiscoveryConfig(); await central.close(); res.json({ active, config }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/discovery/start * Body: { broadcast?: boolean, listen?: boolean, port?: number, serviceType?: string } */ router.post("/discovery/start", async (req, res) => { try { const broadcast = req.body?.broadcast ?? true; const listen = req.body?.listen ?? true; const requestPort = req.body?.port; const serviceType = typeof req.body?.serviceType === "string" && req.body.serviceType.trim().length > 0 ? req.body.serviceType.trim() : "_fusion._tcp"; if (typeof broadcast !== "boolean") { throw badRequest("broadcast must be a boolean"); } if (typeof listen !== "boolean") { throw badRequest("listen must be a boolean"); } if ( requestPort !== undefined && (typeof requestPort !== "number" || !Number.isFinite(requestPort) || requestPort < 1) ) { throw badRequest("port must be a number >= 1"); } const localPort = typeof req.socket.localPort === "number" && req.socket.localPort > 0 ? req.socket.localPort : 4040; const port = requestPort ?? localPort; const config: import("@fusion/core").DiscoveryConfig = { broadcast, listen, serviceType, port, staleTimeoutMs: 300_000, }; const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); await central.startDiscovery(config); await central.close(); res.json({ success: true, config }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/discovery/stop * Stops active mDNS discovery. */ router.post("/discovery/stop", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); central.stopDiscovery(); await central.close(); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/discovery/nodes * List currently discovered nodes. */ router.get("/discovery/nodes", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const nodes = central.getDiscoveredNodes(); await central.close(); res.json(nodes); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/discovery/connect * Register a discovered node in the node registry. * Body: { name: string, host: string, port: number, apiKey?: string } */ router.post("/discovery/connect", async (req, res) => { try { const { name, host, port, apiKey } = req.body as { name?: unknown; host?: unknown; port?: unknown; apiKey?: unknown; }; if (typeof name !== "string" || !name.trim()) { throw badRequest("name is required and must be a non-empty string"); } if (typeof host !== "string" || !host.trim()) { throw badRequest("host is required and must be a non-empty string"); } if (typeof port !== "number" || !Number.isFinite(port) || port < 1) { throw badRequest("port is required and must be a number >= 1"); } if (apiKey !== undefined && typeof apiKey !== "string") { throw badRequest("apiKey must be a string"); } let normalizedHost = host.trim(); try { const url = new URL(normalizedHost); normalizedHost = url.hostname; } catch { normalizedHost = normalizedHost.replace(/^https?:\/\//, ""); } normalizedHost = normalizedHost.split("/")[0] ?? normalizedHost; const normalizedUrl = `http://${normalizedHost}:${port}`; const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const node = await central.registerNode({ name: name.trim(), type: "remote", url: normalizedUrl, apiKey: typeof apiKey === "string" && apiKey.length > 0 ? apiKey : undefined, }); try { await central.checkNodeHealth(node.id); } catch { // Best effort only; registration itself succeeded. } await central.close(); res.json(node); } catch (err: any) { if (err instanceof ApiError) { throw err; } const status = err.message?.includes("already exists") ? 409 : err.message?.includes("must") ? 400 : 500; throw new ApiError(status, err.message); } }); /** * GET /api/activity-feed * Get unified activity feed across all projects. * Query: limit, projectId, types * Returns: ActivityFeedEntry[] */ router.get("/activity-feed", async (req, res) => { try { const limit = typeof req.query.limit === "string" ? parseInt(req.query.limit, 10) : 50; const projectId = typeof req.query.projectId === "string" ? req.query.projectId : undefined; const typesParam = typeof req.query.types === "string" ? req.query.types.split(",") : undefined; const types = typesParam as import("@fusion/core").ActivityEventType[] | undefined; const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const entries = await central.getRecentActivity({ limit, projectId, types }); await central.close(); res.json(entries); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/global-concurrency * Get global concurrency state across all projects. * Returns: GlobalConcurrencyState */ router.get("/global-concurrency", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const state = await central.getGlobalConcurrencyState(); await central.close(); res.json(state); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/first-run-status * Check if user has projects or needs setup wizard. * Returns: { hasProjects: boolean, singleProjectPath: string | null } */ router.get("/first-run-status", async (_req, res) => { try { const { CentralCore } = await import("@fusion/core"); const central = new CentralCore(); await central.init(); const projects = await central.listProjects(); await central.close(); const hasProjects = projects.length > 0; const singleProjectPath = projects.length === 1 ? projects[0].path : null; res.json({ hasProjects, singleProjectPath }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/setup-state * Returns the first-run state and any detected projects for migration. * This is used by the dashboard to determine what UI to show on startup. */ router.get("/setup-state", async (_req, res) => { try { const { FirstRunDetector } = await import("@fusion/core"); const { CentralCore } = await import("@fusion/core"); const detector = new FirstRunDetector(); const state = await detector.detectFirstRunState(); const detectedProjects = await detector.detectExistingProjects(process.cwd()); // Get central DB info const central = new CentralCore(); await central.init(); const projects = await central.listProjects(); await central.close(); res.json({ state, detectedProjects, hasCentralDb: detector.hasCentralDb(), registeredProjects: projects.map((p) => ({ id: p.id, name: p.name, path: p.path, })), }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/complete-setup * Complete the first-run setup by registering projects. * Body: { projects: Array<{ path: string, name: string, isolationMode?: "in-process" | "child-process" }> } */ router.post("/complete-setup", async (req, res) => { try { const { CentralCore } = await import("@fusion/core"); const { MigrationCoordinator } = await import("@fusion/core"); const { projects } = req.body as { projects: Array<{ path: string; name: string; isolationMode?: "in-process" | "child-process" }>; }; if (!Array.isArray(projects)) { throw badRequest("projects must be an array"); } const central = new CentralCore(); await central.init(); try { const coordinator = new MigrationCoordinator(central); const result = await coordinator.completeSetup(projects); res.json({ success: result.success, projectsRegistered: result.projectsRegistered, errors: result.errors, }); } finally { await central.close(); } } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/tasks/:id/diff * Fetch git diff for a task's changes. * Query: ?worktree=path * Returns: TaskDiff */ router.get("/tasks/:id/diff", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); if (!task) { throw notFound("Task not found"); } // Done tasks: diff from the squash commit's first parent. // The merger only performs squash merges, so sha^..sha contains exactly // this task's merged changes and excludes unrelated tasks merged in between. if (task.column === "done" && task.mergeDetails?.commitSha) { const rootDir = scopedStore.getRootDir(); const sha = task.mergeDetails.commitSha; let mergeBase: string | undefined; try { mergeBase = nodeChildProcess.execSync( `git rev-parse ${sha}^`, { cwd: rootDir, encoding: "utf-8", timeout: 5000 }, ).trim(); } catch { // Last resort: no diff available res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } }); return; } const nameStatus = nodeChildProcess.execSync( `git diff --name-status ${mergeBase}..${sha}`, { cwd: rootDir, encoding: "utf-8", timeout: 10000 }, ).trim(); const doneFiles: Array<{ path: string; status: "added" | "modified" | "deleted"; additions: number; deletions: number; patch: string; }> = []; for (const line of nameStatus.split("\n").filter(Boolean)) { const parts = line.split("\t"); const statusCode = parts[0] ?? "M"; const filePath = parts[1] ?? ""; if (!filePath) continue; let status: "added" | "modified" | "deleted" = "modified"; if (statusCode.startsWith("A")) status = "added"; else if (statusCode.startsWith("D")) status = "deleted"; let patch = ""; try { patch = nodeChildProcess.execSync( `git diff ${mergeBase}..${sha} -- "${filePath}"`, { cwd: rootDir, encoding: "utf-8", timeout: 10000 }, ); } catch { /* ignore */ } const additions = (patch.match(/^\+[^+]/gm) || []).length; const deletions = (patch.match(/^-[^-]/gm) || []).length; doneFiles.push({ path: filePath, status, additions, deletions, patch }); } const doneStats = { filesChanged: doneFiles.length, additions: doneFiles.reduce((s, f) => s + f.additions, 0), deletions: doneFiles.reduce((s, f) => s + f.deletions, 0), }; res.json({ files: doneFiles, stats: doneStats }); return; } // Done tasks without a commit SHA: return safe, deterministic response. // Do NOT fall through to the worktree-based diff logic, which would use // the repo root as cwd and return an inflated repository-wide diff. if (task.column === "done") { const md = task.mergeDetails; res.json({ files: [], stats: { filesChanged: md?.filesChanged ?? 0, additions: md?.insertions ?? 0, deletions: md?.deletions ?? 0, }, }); return; } const worktree = typeof req.query.worktree === "string" ? req.query.worktree : undefined; const cwd = worktree || task.worktree || scopedStore.getRootDir(); // Use resolveDiffBase for consistent diff base across all endpoints const diffBase = resolveDiffBase(task, cwd); const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD"; // Get list of changed files — include both committed and working-tree changes const fileMap = new Map(); if (diffBase) { try { const committedOutput = nodeChildProcess.execSync( `git diff --name-status ${diffBase}..HEAD`, { encoding: "utf-8", cwd, timeout: 10000 }, ).trim(); for (const line of committedOutput.split("\n").filter(Boolean)) { const parts = line.split("\t"); fileMap.set(parts[1] ?? "", parts[0] ?? "M"); } } catch { // committed diff failed } } try { const workingTreeOutput = nodeChildProcess.execSync("git diff --name-status", { encoding: "utf-8", cwd, timeout: 10000, }).trim(); for (const line of workingTreeOutput.split("\n").filter(Boolean)) { const parts = line.split("\t"); fileMap.set(parts[1] ?? "", parts[0] ?? "M"); } } catch { // working tree diff failed } const files: Array<{ path: string; status: "added" | "modified" | "deleted"; additions: number; deletions: number; patch: string; }> = []; for (const [filePath, statusCode] of fileMap) { if (!filePath) continue; let status: "added" | "modified" | "deleted"; if (statusCode.startsWith("A")) status = "added"; else if (statusCode.startsWith("D")) status = "deleted"; else status = "modified"; // Get patch for this file let patch = ""; try { patch = nodeChildProcess.execSync(`git diff ${diffRange} -- "${filePath}"`, { encoding: "utf-8", cwd, timeout: 10000, }); } catch { // Ignore errors for individual files } // Count additions/deletions const additions = (patch.match(/^\+[^+]/gm) || []).length; const deletions = (patch.match(/^-[^-]/gm) || []).length; files.push({ path: filePath, status, additions, deletions, patch }); } const stats = { filesChanged: files.length, additions: files.reduce((sum, f) => sum + f.additions, 0), deletions: files.reduce((sum, f) => sum + f.deletions, 0), }; res.json({ files, stats }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/tasks/:id/file-diffs * Fetch changed files with individual git diffs for a task worktree. * Uses the shared resolveDiffBase() helper so the board card count and the * changed-files viewer always agree. Prefers task.baseCommitSha when valid, * falling back to branch merge-base / HEAD~1. * Returns: Array<{ path, status, diff, oldPath? }> */ router.get("/tasks/:id/file-diffs", async (req, res) => { try { const scopedStore = await getScopedStore(req); const task = await scopedStore.getTask(req.params.id); // Done tasks: diff from the squash commit's first parent. // The merger only performs squash merges, so sha^..sha contains exactly // this task's merged changes and excludes unrelated tasks merged in between. if (task.column === "done" && task.mergeDetails?.commitSha) { const rootDir = scopedStore.getRootDir(); const sha = task.mergeDetails.commitSha; let mergeBase: string | undefined; try { mergeBase = nodeChildProcess.execSync( `git rev-parse ${sha}^`, { cwd: rootDir, encoding: "utf-8", timeout: 5000 }, ).trim(); } catch { res.json([]); return; } try { const nameStatus = nodeChildProcess.execSync( `git diff --name-status ${mergeBase}..${sha}`, { cwd: rootDir, encoding: "utf-8", timeout: 5000 }, ).trim(); const doneFiles = nameStatus.split("\n").filter(Boolean).map((line) => { const parts = line.split("\t"); const statusCode = parts[0] ?? "M"; const filePath = parts[1] ?? ""; let status: "added" | "modified" | "deleted" | "renamed" = "modified"; if (statusCode.startsWith("A")) status = "added"; else if (statusCode.startsWith("D")) status = "deleted"; else if (statusCode.startsWith("R")) status = "renamed"; let diff = ""; try { diff = nodeChildProcess.execSync( `git diff ${mergeBase}..${sha} -- "${filePath}"`, { cwd: rootDir, encoding: "utf-8", timeout: 5000 }, ); } catch { /* ignore */ } return { path: filePath, status, diff }; }); res.json(doneFiles); } catch { res.json([]); } return; } // Done tasks without a commit SHA: return safe, empty response. // Do NOT fall through to worktree-based logic that could scan the // entire repository when the worktree has been cleaned up. if (task.column === "done") { res.json([]); return; } if (!task.worktree || !nodeFs.existsSync(task.worktree)) { res.json([]); return; } const cached = fileDiffsCache.get(task.id); if (cached && cached.expiresAt > Date.now()) { res.json(cached.files); return; } const cwd = task.worktree; // Resolve a diff base using the shared strategy so both endpoints // always agree on which files have changed. Prefer task-scoped // baseCommitSha when it is still valid for the current HEAD. const diffBase = resolveDiffBase(task, cwd); // Collect file statuses from both committed changes (against diffBase) // and working-tree changes, deduplicating by path to match session-files. const fileMap = new Map(); if (diffBase) { try { const committedOutput = nodeChildProcess.execSync( `git diff --name-status ${diffBase}..HEAD`, { cwd, encoding: "utf-8", timeout: 5000 }, ).trim(); for (const line of committedOutput.split("\n").filter(Boolean)) { const parts = line.split("\t"); const statusCode = parts[0] ?? "M"; if (statusCode.startsWith("R")) { fileMap.set(parts[2] ?? parts[1] ?? "", { statusCode, oldPath: parts[1] }); } else { fileMap.set(parts[1] ?? "", { statusCode }); } } } catch { // committed diff failed — continue with working-tree only } } try { const workingTreeOutput = nodeChildProcess.execSync("git diff --name-status", { cwd, encoding: "utf-8", timeout: 5000, }).trim(); for (const line of workingTreeOutput.split("\n").filter(Boolean)) { const parts = line.split("\t"); const statusCode = parts[0] ?? "M"; if (statusCode.startsWith("R")) { fileMap.set(parts[2] ?? parts[1] ?? "", { statusCode, oldPath: parts[1] }); } else { fileMap.set(parts[1] ?? "", { statusCode }); } } } catch { // working tree diff failed — continue with committed only } // Build the result array with per-file diffs using the two-dot range // against the resolved merge-base. const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD"; const files = Array.from(fileMap.entries()).flatMap(([filePath, { statusCode, oldPath }]) => { let status: "added" | "modified" | "deleted" | "renamed" = "modified"; if (statusCode.startsWith("A")) { status = "added"; } else if (statusCode.startsWith("D")) { status = "deleted"; } else if (statusCode.startsWith("R")) { status = "renamed"; } let diff = ""; try { diff = nodeChildProcess.execSync(`git diff ${diffRange} -- "${filePath}"`, { cwd, encoding: "utf-8", timeout: 5000, }); } catch { diff = ""; } // Filter out files with empty diffs (mode-only changes, binary files, etc.) if (!diff) { return []; } return oldPath ? [{ path: filePath, status, diff, oldPath }] : [{ path: filePath, status, diff }]; }); fileDiffsCache.set(task.id, { files, expiresAt: Date.now() + 10000, }); res.json(files); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.code === "ENOENT") { throw notFound(`Task ${req.params.id} not found`); } else { rethrowAsApiError(err, "Internal server error"); } } }); // ── Scripts API ────────────────────────────────────────────────────────── /** * GET /api/scripts * Fetch all saved scripts. * Returns: Record (name -> command) */ router.get("/scripts", async (req, res) => { try { const scopedStore = await getScopedStore(req); const settings = await scopedStore.getSettings(); res.json(settings.scripts ?? {}); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/scripts * Add or update a script. * Body: { name: string, command: string } * Returns: Record (updated scripts) */ router.post("/scripts", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { name, command } = req.body; if (!name || typeof name !== "string" || !name.trim()) { throw badRequest("name is required"); } if (command === undefined || typeof command !== "string") { throw badRequest("command is required"); } const settings = await scopedStore.getSettings(); const scripts = { ...(settings.scripts ?? {}), [name.trim()]: command.trim(), }; await scopedStore.updateSettings({ scripts }); res.json(scripts); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * DELETE /api/scripts/:name * Remove a script. * Returns: Record (updated scripts) */ router.delete("/scripts/:name", async (req, res) => { try { const scopedStore = await getScopedStore(req); const { name } = req.params; const settings = await scopedStore.getSettings(); const scripts = { ...(settings.scripts ?? {}) }; delete scripts[name]; await scopedStore.updateSettings({ scripts }); res.json(scripts); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); // ── Messaging Routes ────────────────────────────────────────────────── /** Cache of MessageStore instances keyed by rootDir */ const messageStoreCache = new Map(); async function getMessageStore(req: Request): Promise { const scopedStore = await getScopedStore(req); const rootDir = scopedStore.getRootDir(); let msgStore = messageStoreCache.get(rootDir); if (!msgStore) { msgStore = new MessageStore({ rootDir: join(rootDir, ".fusion") }); await msgStore.init(); messageStoreCache.set(rootDir, msgStore); } return msgStore; } const VALID_MESSAGE_TYPES: MessageType[] = ["agent-to-agent", "agent-to-user", "user-to-agent", "system"]; const VALID_PARTICIPANT_TYPES: ParticipantType[] = ["agent", "user", "system"]; const DASHBOARD_USER_ID = "dashboard"; /** * GET /api/messages/inbox * Fetch inbox messages for the dashboard user. * Query params: limit, offset, unreadOnly, type */ router.get("/messages/inbox", async (req, res) => { try { const msgStore = await getMessageStore(req); const filter = { limit: parseInt(req.query.limit as string) || 20, offset: parseInt(req.query.offset as string) || 0, read: req.query.unreadOnly === "true" ? false : undefined, type: req.query.type as MessageType | undefined, }; const messages = await msgStore.getInbox(DASHBOARD_USER_ID, "user", filter); const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user"); res.json({ messages, total: messages.length, unreadCount: mailbox.unreadCount }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/messages/outbox * Fetch sent messages for the dashboard user. * Query params: limit, offset, type */ router.get("/messages/outbox", async (req, res) => { try { const msgStore = await getMessageStore(req); const filter = { limit: parseInt(req.query.limit as string) || 20, offset: parseInt(req.query.offset as string) || 0, type: req.query.type as MessageType | undefined, }; const messages = await msgStore.getOutbox(DASHBOARD_USER_ID, "user", filter); res.json({ messages, total: messages.length }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/messages/unread-count * Get unread message count (lightweight for header badge). */ router.get("/messages/unread-count", async (req, res) => { try { const msgStore = await getMessageStore(req); const mailbox = await msgStore.getMailbox(DASHBOARD_USER_ID, "user"); res.json({ unreadCount: mailbox.unreadCount }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/messages/read-all * Mark all inbox messages as read. * IMPORTANT: Must be registered before /messages/:id to avoid path conflicts. */ router.post("/messages/read-all", async (req, res) => { try { const msgStore = await getMessageStore(req); const count = await msgStore.markAllAsRead(DASHBOARD_USER_ID, "user"); res.json({ markedAsRead: count }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/messages * Send a new message. * Body: { toId, toType, content, type, metadata? } */ router.post("/messages", async (req, res) => { try { const { toId, toType, content, type, metadata } = req.body; // Validate required fields if (!toId || typeof toId !== "string") { throw badRequest("toId is required"); } if (!toType || !VALID_PARTICIPANT_TYPES.includes(toType)) { throw badRequest(`toType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}`); } if (!content || typeof content !== "string" || content.length === 0 || content.length > 2000) { throw badRequest("content is required and must be 1-2000 characters"); } if (!type || !VALID_MESSAGE_TYPES.includes(type)) { throw badRequest(`type must be one of: ${VALID_MESSAGE_TYPES.join(", ")}`); } const msgStore = await getMessageStore(req); const message = await msgStore.sendMessage({ fromId: DASHBOARD_USER_ID, fromType: "user", toId, toType, content, type, metadata, }); res.status(201).json(message); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/messages/conversation/:participantType/:participantId * Get conversation between dashboard user and a specific participant. */ router.get("/messages/conversation/:participantType/:participantId", async (req, res) => { try { const { participantType, participantId } = req.params; if (!VALID_PARTICIPANT_TYPES.includes(participantType as ParticipantType)) { throw badRequest(`participantType must be one of: ${VALID_PARTICIPANT_TYPES.join(", ")}`); } const msgStore = await getMessageStore(req); const messages = await msgStore.getConversation( { id: DASHBOARD_USER_ID, type: "user" }, { id: participantId, type: participantType as ParticipantType }, ); res.json(messages); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * GET /api/messages/:id * Fetch a single message. */ router.get("/messages/:id", async (req, res) => { try { const msgStore = await getMessageStore(req); const message = await msgStore.getMessage(req.params.id); if (!message) { throw notFound("Message not found"); } res.json(message); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/messages/:id/read * Mark a specific message as read. */ router.post("/messages/:id/read", async (req, res) => { try { const msgStore = await getMessageStore(req); const message = await msgStore.markAsRead(req.params.id); res.json(message); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); /** * DELETE /api/messages/:id * Delete a message. */ router.delete("/messages/:id", async (req, res) => { try { const msgStore = await getMessageStore(req); await msgStore.deleteMessage(req.params.id); res.status(204).send(); } catch (err: any) { if (err instanceof ApiError) { throw err; } if (err.message.includes("not found")) { throw notFound(err.message); } rethrowAsApiError(err); } }); /** * GET /api/agents/:id/mailbox * View an agent's mailbox (admin read-only access). */ router.get("/agents/:id/mailbox", async (req, res) => { try { const msgStore = await getMessageStore(req); const agentId = req.params.id; const mailbox = await msgStore.getMailbox(agentId, "agent"); const inbox = await msgStore.getInbox(agentId, "agent"); res.json({ ...mailbox, messages: inbox }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); router.use((err: unknown, req: Request, res: Response, next: NextFunction) => { if (res.headersSent) { next(err); return; } if (err instanceof ApiError) { sendErrorResponse(res, err.statusCode, err.message, { details: err.details }); return; } if (err instanceof Error) { sendErrorResponse(res, 500, err.message); return; } sendErrorResponse(res, 500, "Internal server error"); }); return router; } // ── Automation step helpers ───────────────────────────────────────── /** * Validate an array of automation steps. * Returns an error string if invalid, or null if valid. */ function validateAutomationSteps(steps: unknown[]): string | null { for (let i = 0; i < steps.length; i++) { const step = steps[i] as Record; if (!step.id || typeof step.id !== "string") { return `Step ${i + 1}: id is required`; } if (!step.type || (step.type !== "command" && step.type !== "ai-prompt")) { return `Step ${i + 1}: type must be "command" or "ai-prompt"`; } if (!step.name || typeof step.name !== "string" || !step.name.trim()) { return `Step ${i + 1}: name is required`; } if (step.type === "command") { if (!step.command || typeof step.command !== "string" || !step.command.trim()) { return `Step ${i + 1}: command is required for command steps`; } } if (step.type === "ai-prompt") { if (!step.prompt || typeof step.prompt !== "string" || !step.prompt.trim()) { return `Step ${i + 1}: prompt is required for ai-prompt steps`; } } // Validate model fields are both present or both absent const hasProvider = step.modelProvider && typeof step.modelProvider === "string"; const hasModelId = step.modelId && typeof step.modelId === "string"; if ((hasProvider && !hasModelId) || (!hasProvider && hasModelId)) { return `Step ${i + 1}: modelProvider and modelId must both be present or both absent`; } } return null; } /** * Execute a single shell command (used by manual run endpoint). */ async function executeSingleCommand( command: string, timeoutMs: number | undefined, startedAt: string, ): Promise { const { exec } = await import("node:child_process"); const { promisify } = await import("node:util"); const execAsyncFn = promisify(exec); const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000; const MAX_BUFFER = 1024 * 1024; const MAX_OUTPUT = 10240; try { const { stdout, stderr } = await execAsyncFn(command, { timeout: timeoutMs ?? DEFAULT_TIMEOUT_MS, maxBuffer: MAX_BUFFER, shell: "/bin/sh", }); let output = stdout; if (stderr) { output += stdout ? "\n--- stderr ---\n" : ""; output += stderr; } if (output.length > MAX_OUTPUT) { output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]"; } return { success: true, output, startedAt, completedAt: new Date().toISOString() }; } catch (err: any) { if (err instanceof ApiError) { throw err; } const stdout = err.stdout ?? ""; const stderr = err.stderr ?? ""; let output = stdout; if (stderr) { output += stdout ? "\n--- stderr ---\n" : ""; output += stderr; } if (output.length > MAX_OUTPUT) { output = output.slice(0, MAX_OUTPUT) + "\n[output truncated]"; } return { success: false, output, error: err.killed ? `Command timed out after ${(timeoutMs ?? DEFAULT_TIMEOUT_MS) / 1000}s` : err.message ?? String(err), startedAt, completedAt: new Date().toISOString(), }; } } /** * Execute all steps in a multi-step schedule (used by manual run endpoint). */ async function executeScheduleSteps( schedule: import("@fusion/core").ScheduledTask, startedAt: string, ): Promise { const steps = schedule.steps!; const stepResults: import("@fusion/core").AutomationStepResult[] = []; let overallSuccess = true; let stoppedEarly = false; for (let i = 0; i < steps.length; i++) { const step = steps[i]; const stepStartedAt = new Date().toISOString(); const timeoutMs = step.timeoutMs ?? schedule.timeoutMs ?? 300000; let stepResult: import("@fusion/core").AutomationStepResult; if (step.type === "command") { const cmdResult = await executeSingleCommand(step.command ?? "", timeoutMs, stepStartedAt); stepResult = { stepId: step.id, stepName: step.name, stepIndex: i, success: cmdResult.success, output: cmdResult.output, error: cmdResult.error, startedAt: stepStartedAt, completedAt: cmdResult.completedAt, }; } else if (step.type === "ai-prompt") { // AI prompt steps return a placeholder in manual run mode const model = step.modelProvider && step.modelId ? `${step.modelProvider}/${step.modelId}` : "default"; stepResult = { stepId: step.id, stepName: step.name, stepIndex: i, success: !!step.prompt?.trim(), output: step.prompt?.trim() ? `[AI prompt step — model: ${model}]\nPrompt: ${step.prompt}` : "", error: step.prompt?.trim() ? undefined : "AI prompt step has no prompt specified", startedAt: stepStartedAt, completedAt: new Date().toISOString(), }; } else { stepResult = { stepId: step.id, stepName: step.name, stepIndex: i, success: false, output: "", error: `Unknown step type: "${step.type}"`, startedAt: stepStartedAt, completedAt: new Date().toISOString(), }; } stepResults.push(stepResult); if (!stepResult.success) { overallSuccess = false; if (!step.continueOnFailure) { stoppedEarly = true; break; } } } // Aggregate output const outputParts: string[] = []; for (const sr of stepResults) { outputParts.push(`=== Step ${sr.stepIndex + 1}: ${sr.stepName} (${sr.success ? "success" : "FAILED"}) ===`); if (sr.output) outputParts.push(sr.output); if (sr.error) outputParts.push(`Error: ${sr.error}`); } let output = outputParts.join("\n"); if (output.length > 10240) { output = output.slice(0, 10240) + "\n[output truncated]"; } const failedSteps = stepResults.filter((sr) => !sr.success); const error = failedSteps.length > 0 ? `${failedSteps.length} step(s) failed: ${failedSteps.map((s) => s.stepName).join(", ")}${stoppedEarly ? " (execution stopped)" : ""}` : undefined; return { success: overallSuccess, output, error, startedAt, completedAt: new Date().toISOString(), stepResults, }; } function getDefaultGitHubRepo(store: TaskStore): { owner: string; repo: string } | null { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [owner, repo] = envRepo.split("/"); if (owner && repo) { return { owner, repo }; } } const rootDir = typeof store.getRootDir === "function" ? store.getRootDir() : process.cwd(); return getCurrentRepo(rootDir); } function isBatchStatusStale(info: { lastCheckedAt?: string } | undefined, updatedAt?: string): boolean { const lastChecked = info?.lastCheckedAt ?? updatedAt; if (!lastChecked) return true; return Date.now() - new Date(lastChecked).getTime() > 5 * 60 * 1000; } function ensureBatchStatusEntry(results: BatchStatusResult, taskId: string): BatchStatusEntry { results[taskId] ??= { stale: true }; return results[taskId]; } function appendBatchStatusError(results: BatchStatusResult, taskId: string, message: string): void { const entry = ensureBatchStatusEntry(results, taskId); entry.error = entry.error ? `${entry.error}; ${message}` : message; entry.stale = true; } /** * Background PR refresh - updates PR status without blocking the response. * Silently logs errors without affecting the user experience. * Prefers badge URL for repo resolution to support multi-repo setups. */ async function refreshPrInBackground(store: TaskStore, taskId: string, currentPrInfo: PrInfo, token?: string): Promise { try { // Get owner/repo from badge URL first, then fall back to env/git let owner: string; let repo: string; const badgeParsed = parseBadgeUrl(currentPrInfo.url); if (badgeParsed) { owner = badgeParsed.owner; repo = badgeParsed.repo; } else { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [o, r] = envRepo.split("/"); owner = o; repo = r; } else { const gitRepo = getCurrentRepo(store.getRootDir()); if (!gitRepo) return; // Silent fail - can't determine repo owner = gitRepo.owner; repo = gitRepo.repo; } } const repoKey = `${owner}/${repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { return; } const client = new GitHubClient(token); const prInfo = await client.getPrStatus(owner, repo, currentPrInfo.number); prInfo.lastCheckedAt = new Date().toISOString(); await store.updatePrInfo(taskId, prInfo); } catch { // Silent fail - background refresh is best-effort } } async function refreshIssueInBackground( store: TaskStore, taskId: string, currentIssueInfo: import("@fusion/core").IssueInfo, token?: string, ): Promise { try { let owner: string; let repo: string; // Get owner/repo from badge URL first, then fall back to env/git const badgeParsed = parseBadgeUrl(currentIssueInfo.url); if (badgeParsed) { owner = badgeParsed.owner; repo = badgeParsed.repo; } else { const envRepo = process.env.GITHUB_REPOSITORY; if (envRepo) { const [o, r] = envRepo.split("/"); owner = o; repo = r; } else { const gitRepo = getCurrentRepo(store.getRootDir()); if (!gitRepo) return; owner = gitRepo.owner; repo = gitRepo.repo; } } const repoKey = `${owner}/${repo}`; if (!githubRateLimiter.canMakeRequest(repoKey)) { return; } const client = new GitHubClient(token); const issueInfo = await client.getIssueStatus(owner, repo, currentIssueInfo.number); if (!issueInfo) { return; } await store.updateIssueInfo(taskId, { ...issueInfo, lastCheckedAt: new Date().toISOString(), }); } catch { // Silent fail - background refresh is best-effort } } /** * Register the GET /api/models route. * Returns available AI models from the ModelRegistry for the UI model selector, * along with favoriteProviders for UI ordering. * If no ModelRegistry is provided, returns an empty array. */ function registerModelsRoute(router: Router, modelRegistry?: ModelRegistryLike, store?: TaskStore): void { router.get("/models", async (_req, res) => { // Always return 200 with empty array instead of 404 when no models available. // This ensures the frontend can handle empty states gracefully. if (!modelRegistry) { res.json({ models: [], favoriteProviders: [], favoriteModels: [] }); return; } try { modelRegistry.refresh(); const models = modelRegistry.getAvailable().map((m) => ({ provider: m.provider, id: m.id, name: m.name, reasoning: m.reasoning, contextWindow: m.contextWindow, })); // Get favoriteProviders and favoriteModels from global settings let favoriteProviders: string[] = []; let favoriteModels: string[] = []; if (store) { try { const globalStore = store.getGlobalSettingsStore(); const globalSettings = await globalStore.getSettings(); favoriteProviders = globalSettings.favoriteProviders ?? []; favoriteModels = globalSettings.favoriteModels ?? []; } catch { // Silently ignore settings errors - just return empty favorites } } res.json({ models, favoriteProviders, favoriteModels }); } catch (err: any) { if (err instanceof ApiError) { throw err; } const message = err instanceof Error ? err.message : String(err); console.log(`[models] Failed to load models: ${message}`); res.json({ models: [], favoriteProviders: [], favoriteModels: [] }); } }); } /** * Register authentication status, login, and logout routes. * Uses pi-coding-agent's AuthStorage for credential management. * If no AuthStorage is provided, creates one internally (reads from ~/.pi/agent/auth.json). */ function registerAuthRoutes(router: Router, authStorage?: AuthStorageLike): void { // Use injected AuthStorage or fail gracefully if not provided. // When running via the CLI/engine, AuthStorage is passed in via ServerOptions. function getAuthStorage(): AuthStorageLike { if (!authStorage) { throw new Error("Authentication is not configured"); } return authStorage; } /** * Track in-progress login flows to prevent concurrent logins for the same provider. * Maps provider ID → AbortController for the active login. */ const loginInProgress = new Map(); /** * GET /api/auth/status * Returns list of all providers with their authentication status and type. * Includes both OAuth-backed and API-key-backed providers. * Response: { providers: [{ id, name, authenticated, type }] } */ router.get("/auth/status", (_req, res) => { try { const storage = getAuthStorage(); storage.reload(); const oauthProviders = storage.getOAuthProviders(); const providers: { id: string; name: string; authenticated: boolean; type: "oauth" | "api_key" }[] = oauthProviders.map((p) => ({ id: p.id, name: p.name, authenticated: storage.hasAuth(p.id), type: "oauth" as const, })); // Include API-key-backed providers if supported if (storage.getApiKeyProviders) { const apiKeyProviders = storage.getApiKeyProviders(); for (const p of apiKeyProviders) { // Skip if already listed as an OAuth provider (avoid duplicates) if (providers.some((existing) => existing.id === p.id)) continue; providers.push({ id: p.id, name: p.name, authenticated: storage.hasApiKey ? storage.hasApiKey(p.id) : false, type: "api_key" as const, }); } } res.json({ providers }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/auth/login * Initiates OAuth login for a provider. * Body: { provider: string } * Response: { url: string, instructions?: string } * * The endpoint starts the OAuth flow and returns the auth URL from the * onAuth callback. The client should open this URL in a new tab and * poll GET /api/auth/status to detect completion. */ router.post("/auth/login", async (req, res) => { try { const { provider } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } // Prevent concurrent logins for the same provider if (loginInProgress.has(provider)) { throw conflict(`Login already in progress for ${provider}`); } const storage = getAuthStorage(); const oauthProviders = storage.getOAuthProviders(); const found = oauthProviders.find((p) => p.id === provider); if (!found) { throw badRequest(`Unknown provider: ${provider}`); } const abortController = new AbortController(); loginInProgress.set(provider, abortController); // We need to get the URL from the onAuth callback before responding. // The login() call continues in the background until the user completes OAuth. let authResolve: (info: { url: string; instructions?: string }) => void; let authReject: (err: Error) => void; const authUrlPromise = new Promise<{ url: string; instructions?: string }>((resolve, reject) => { authResolve = resolve; authReject = reject; }); // Start login flow in background — don't await the full login const loginPromise = storage.login(provider, { onAuth: (info) => { authResolve({ url: info.url, instructions: info.instructions }); }, onPrompt: async (prompt) => { // Web UI cannot interactively prompt — return empty string if allowed if (prompt.allowEmpty) return ""; return prompt.placeholder || ""; }, onProgress: () => {}, // no-op for web UI signal: abortController.signal, }); // Race: either we get the auth URL or the login completes/fails first const timeout = setTimeout(() => { authReject(new Error("Login initiation timed out")); }, 30_000); loginPromise .then(() => { // Login completed (user finished OAuth in browser) }) .catch((err) => { // Login failed — also reject auth URL if not yet received authReject(err); }) .finally(() => { clearTimeout(timeout); loginInProgress.delete(provider); }); const authInfo = await authUrlPromise; clearTimeout(timeout); res.json({ url: authInfo.url, instructions: authInfo.instructions }); } catch (err: any) { if (err instanceof ApiError) { throw err; } // Clean up on error const provider = req.body?.provider; if (provider) loginInProgress.delete(provider); rethrowAsApiError(err); } }); /** * POST /api/auth/logout * Removes credentials for a provider. * Body: { provider: string } * Response: { success: true } */ router.post("/auth/logout", (req, res) => { try { const { provider } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } const storage = getAuthStorage(); storage.logout(provider); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * POST /api/auth/api-key * Save an API key for an API-key-backed provider. * Body: { provider: string, apiKey: string } * Response: { success: true } * * Validates the provider exists, is API-key-backed, and the key is non-empty. * Never returns the key in any response. */ router.post("/auth/api-key", (req, res) => { try { const { provider, apiKey } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } if (!apiKey || typeof apiKey !== "string" || !apiKey.trim()) { throw badRequest("apiKey is required and must be a non-empty string"); } const storage = getAuthStorage(); // Check that the storage supports API key management if (!storage.setApiKey) { throw badRequest("API key management is not supported"); } // Validate the provider is an API-key-backed provider const apiKeyProviders = storage.getApiKeyProviders?.() ?? []; const found = apiKeyProviders.find((p) => p.id === provider); if (!found) { throw badRequest(`Unknown API key provider: ${provider}`); } storage.setApiKey(provider, apiKey.trim()); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); /** * DELETE /api/auth/api-key * Remove an API key for a provider. * Body: { provider: string } * Response: { success: true } */ router.delete("/auth/api-key", (req, res) => { try { const { provider } = req.body; if (!provider || typeof provider !== "string") { throw badRequest("provider is required"); } const storage = getAuthStorage(); if (!storage.clearApiKey) { throw badRequest("API key management is not supported"); } storage.clearApiKey(provider); res.json({ success: true }); } catch (err: any) { if (err instanceof ApiError) { throw err; } rethrowAsApiError(err); } }); }