Files
fusion/packages/dashboard/src/routes.ts
gsxdsm 0e50163a1a refactor(FN-1288): consolidate GitHub remote parsing in @fusion/core
- Replace dashboard calls to local getCurrentGitHubRepo helpers with getCurrentRepo from @fusion/core
- Update engine scheduler PR-monitor startup paths to use shared core repo resolution
- Remove duplicated remote parsing implementations from dashboard and engine packages
- Mark gap analysis finding 6.3 as resolved after centralizing parsing logic
2026-04-08 15:53:38 -07:00

11392 lines
370 KiB
TypeScript

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 { 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 { getOrCreateProjectStore } from "./project-store-resolver.js";
import { AiSessionStore } 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";
/**
* 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<string>;
onProgress?: (message: string) => void;
signal?: AbortSignal;
},
): Promise<void>;
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";
}
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<string>();
return value.map((preset, index) => {
if (!preset || typeof preset !== "object") {
throw new Error(`modelPresets[${index}] must be an object`);
}
const candidate = preset as Record<string, unknown>;
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<string>();
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<GitWorktree> = {};
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<string, { fetchUrl: string; pushUrl: string }>();
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<string>();
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<string, number>[] = [];
let batchImportCleanupInterval: ReturnType<typeof setInterval> | 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;
}
export function createApiRoutes(store: TaskStore, options?: ServerOptions): Router {
const router = Router();
function prioritizeProjectsForCurrentDirectory<T extends { path: string }>(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<TaskStore> {
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<string, { files: string[]; expiresAt: number }>();
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);
// 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<string>(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;
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");
}
const 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<string | null> => {
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,
});
await scopedStore.logEntry(req.params.id, "Retry requested from dashboard");
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<string, Awaited<ReturnType<TaskStore["getTask"]>>>();
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<string>();
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");
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");
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 } = 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");
// 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,
});
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);
}
}
});
// 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<string, number>();
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<PrInfo, "lastCheckedAt"> | Omit<import("@fusion/core").IssueInfo, "lastCheckedAt"> | 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<PrInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
const changed = hasPrBadgeFieldsChanged(current, badgeData as Omit<PrInfo, "lastCheckedAt">);
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<import("@fusion/core").IssueInfo, "lastCheckedAt">), lastCheckedAt: checkedAt };
const changed = hasIssueBadgeFieldsChanged(current, badgeData as Omit<import("@fusion/core").IssueInfo, "lastCheckedAt">);
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<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
const prGroups = new Map<string, { owner: string; repo: string; numbers: Set<number>; taskIds: Set<string> }>();
const tasksById = new Map<string, Awaited<ReturnType<TaskStore["getTask"]>>>();
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<number>(),
taskIds: new Set<string>(),
};
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<number>(),
taskIds: new Set<string>(),
};
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<number>; taskIds: Set<string> }) => {
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<number>; taskIds: Set<string> }) => {
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<ReturnType<typeof store.getTask>> | 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<ReturnType<typeof store.createTask>>[];
const tempIdToTaskId = new Map<string, string>();
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 } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
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 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<string, unknown> }
* Returns: { type: "question" | "complete", data: PlanningQuestion | PlanningSummary }
*/
router.post("/planning/respond", async (req, res) => {
try {
const { sessionId, responses } = 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 { 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 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 } = req.body;
if (!sessionId || typeof sessionId !== "string") {
throw badRequest("sessionId is required");
}
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<ReturnType<typeof store.createTask>>[];
const tempIdToTaskId = new Map<string, string>();
// 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();
}
});
/**
* 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<string, unknown> = {};
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<string, unknown>)) {
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<string, boolean>,
});
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);
}
}
});
/**
* 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/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 }
*
* 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 } = req.body || {};
const invocationSource = source ?? "on_demand";
const trigger = triggerDetail ?? "Triggered from dashboard";
// Build structured wake context
const contextSnapshot: Record<string, unknown> = {
wakeReason: invocationSource,
triggerDetail: trigger,
};
if (taskId) {
contextSnapshot.taskId = taskId;
}
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,
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);
}
}
});
/**
* 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/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 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));
// ── AI Session Routes (Background Tasks) ─────────────────────────────────
const aiSessionStore = options?.aiSessionStore;
/**
* 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 });
});
/**
* 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);
});
/**
* 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<import("@fusion/core").RegisteredProject> = {};
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");
}
if (type !== "local" && type !== "remote") {
throw badRequest("type must be 'local' or 'remote'");
}
if (type === "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,
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<Omit<import("@fusion/core").NodeConfig, "id" | "createdAt">> = {};
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.json({ success: true });
} 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.
*/
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");
}
if (node.type === "local") {
res.json({
status: "online",
activeTasks: 0,
maxConcurrent: node.maxConcurrent,
});
return;
}
throw new ApiError(501, "Remote node metrics not yet implemented");
} 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<string, string>();
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<string, { statusCode: string; oldPath?: string }>();
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<string, string> (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<string, string> (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<string, string> (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<string, MessageStore>();
async function getMessageStore(req: Request): Promise<MessageStore> {
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<string, unknown>;
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<import("@fusion/core").AutomationRunResult> {
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<import("@fusion/core").AutomationRunResult> {
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<void> {
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<void> {
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<string, AbortController>();
/**
* 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);
}
});
}