feat(FN-2562): merge fusion/fn-2562 (auto-resolved)

- docs(FN-2562): document extracted terminal and session-diff registrars
- fix(FN-2562): complete Step 5 — restore lint green
- feat(FN-2562): complete Step 3 — extract session diff registrar
- feat(FN-2562): complete Step 2 — create terminal route registrar
- feat(FN-2562): complete Step 1 — extract diff-base helper module
This commit is contained in:
Fusion
2026-04-26 11:54:54 -07:00
committed by gsxdsm
parent 7a0efbc6df
commit 05d6ebfb8e
9 changed files with 883 additions and 891 deletions

View File

@@ -100,3 +100,15 @@ export function rateLimited(message: string, retryAfter?: number): ApiError {
export function internalError(message: string): ApiError {
return new ApiError(500, message);
}
export 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);
}

View File

@@ -29,6 +29,7 @@ import {
internalError,
notFound,
rateLimited,
rethrowAsApiError,
sendErrorResponse,
unauthorized,
} from "./api-error.js";
@@ -40,7 +41,7 @@ import { registerPlanningSubtaskRoutes } from "./routes/register-planning-subtas
import { registerChatRoutes } from "./routes/register-chat-routes.js";
import { registerSettingsMemoryRoutes } from "./routes/register-settings-memory-routes.js";
import { registerMessagingScriptRoutes } from "./routes/register-messaging-scripts.js";
import { registerGitGitHubRoutes, runGitCommand } from "./routes/register-git-github.js";
import { registerGitGitHubRoutes } from "./routes/register-git-github.js";
import { registerFileWorkspaceRoutes } from "./routes/register-file-workspace-routes.js";
import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js";
import { registerProjectRoutes } from "./routes/register-project-routes.js";
@@ -60,6 +61,9 @@ import { registerModelRoutes } from "./routes/register-model-routes.js";
import { registerUsageRoutes } from "./routes/register-usage-routes.js";
import { registerAuthRoutes } from "./routes/register-auth-routes.js";
import { registerIntegratedRouters, registerIntegratedDevServerRouter } from "./routes/register-integrated-routers.js";
import { registerTerminalRoutes } from "./routes/register-terminal-routes.js";
import { registerSessionDiffRoutes } from "./routes/register-session-diff-routes.js";
import { runGitCommand } from "./routes/resolve-diff-base.js";
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = 500;
@@ -298,82 +302,7 @@ function assertConsistentOptionalPair(
};
}
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);
}
export interface ResolveDiffBaseTaskInput {
baseCommitSha?: string;
baseBranch?: string;
}
/**
* Resolve the diff base ref for a task worktree.
*
* IMPORTANT: `packages/engine/src/merger.ts` mirrors this exact ordering for
* merge-time scope warnings. Keep both implementations in sync so dashboard
* changed-files views and merger scope enforcement evaluate the same range.
*
* Strategy (in priority order):
* 1. **Branch merge-base** — Prefer the live merge-base between `headRef` and
* local `{baseBranch}` (fallback: `origin/{baseBranch}`).
* 2. **Task-scoped baseCommitSha** — If merge-base is unavailable or equals
* `headRef`, use `baseCommitSha` when still an ancestor of `headRef`.
* 3. **headRef~1** — Last-resort fallback.
*/
export async function resolveDiffBase(
task: ResolveDiffBaseTaskInput,
cwd: string,
headRef = "HEAD",
runGit: (args: string[], cwd?: string, timeout?: number) => Promise<string> = runGitCommand,
): Promise<string | undefined> {
const baseBranch = task.baseBranch ?? "main";
let mergeBase: string | undefined;
try {
try {
mergeBase = (await runGit(["merge-base", headRef, baseBranch], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGit(["merge-base", headRef, `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
}
} catch {
// base branch may no longer exist locally/remotely
}
// If merge-base equals headRef, the live merge-base would produce an empty
// diff. Prefer task.baseCommitSha when still valid.
if (mergeBase) {
try {
const head = (await runGit(["rev-parse", headRef], cwd, 5000)).trim();
if (head && head !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
if (task.baseCommitSha) {
try {
await runGit(["merge-base", "--is-ancestor", task.baseCommitSha, headRef], cwd, 5000);
return task.baseCommitSha;
} catch {
// stale or unreachable — fall through
}
}
try {
return (await runGit(["rev-parse", `${headRef}~1`], cwd, 5000)).trim() || undefined;
} catch {
return undefined;
}
}
export { resolveDiffBase, type ResolveDiffBaseTaskInput, runGitCommand } from "./routes/resolve-diff-base.js";
function slugifyPresetName(name: string): string {
const slug = name
@@ -953,7 +882,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
validateOptionalModelField,
normalizeModelSelectionPair,
runGitCommand,
resolveDiffBase,
trimTaskDetailActivityLog,
triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args),
});
@@ -970,10 +898,8 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
});
registerMessagingScriptRoutes(routeContext);
registerGitGitHubRoutes(routeContext);
registerFileWorkspaceRoutes(routeContext, {
runGitCommand,
resolveDiffBase,
});
registerSessionDiffRoutes(router, { getProjectContext });
registerFileWorkspaceRoutes(routeContext);
registerAgentsProjectsNodesRoutes(routeContext);
registerPluginsAutomationRoutes(routeContext);
registerProxyRoutes(routeContext);
@@ -1340,270 +1266,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
// ---------- Auth routes ----------
registerAuthRoutes(routeContext);
// ── 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 { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.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: unknown) {
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");
}
throw badRequest("Session is not running");
}
res.json({ killed: true, sessionId: id });
} catch (err: unknown) {
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: unknown) {
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: unknown) {
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 { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.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: unknown) {
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 { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.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: unknown) {
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", async (req, res) => {
try {
const { id } = req.params;
const { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const killed = terminalService.killSession(id);
if (!killed) {
const session = terminalService.getSession(id);
if (!session) {
throw notFound("Session not found");
}
throw badRequest("Failed to kill session");
}
res.json({ killed: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
registerTerminalRoutes(router, {
getProjectContext,
terminalSessionManager,
getTerminalService,
});
/**

View File

@@ -10,7 +10,7 @@ Registrars should be typed as `ApiRouteRegistrar` so modules share one explicit
The context centralizes cross-cutting dependencies so registrars preserve behavior without re-implementing plumbing.
Some registrars (for example `register-task-workflow-routes.ts` and `register-file-workspace-routes.ts`) also take a narrow dependency-injection object for non-context helpers that must stay source-of-truth in `routes.ts` (git diff helpers, background refresh helpers, multer upload middleware). This avoids helper duplication while preserving runtime parity.
Some registrars (for example `register-task-workflow-routes.ts`) also take a narrow dependency-injection object for non-context helpers that must stay source-of-truth in `routes.ts` (git helpers, background refresh helpers, multer upload middleware). This avoids helper duplication while preserving runtime parity.
The context provides core cross-cutting plumbing:
@@ -48,9 +48,18 @@ The context provides core cross-cutting plumbing:
- Workspace discovery/files: `/workspaces`, `/files`, `/files/markdown-list`, `/files/search`, `/files/{*filepath}`
- File operations: `/files/{*filepath}/copy|move|delete|rename`, `/files/{*filepath}/download`, `/files/{*filepath}/download-zip`
- Generic wildcard write: `/files/{*filepath}` (must remain after operation routes)
- Changed-file helpers: `/tasks/:id/session-files`, `/tasks/:id/file-diffs`
- Project markdown search: `/project-files/md`
- Caches: local `sessionFilesCache` and `fileDiffsCache` (10-second TTL)
- `register-session-diff-routes.ts` — task session/diff domain:
- Session changed-file list: `/tasks/:id/session-files`
- Aggregate task diff: `/tasks/:id/diff`
- Per-file diffs: `/tasks/:id/file-diffs`
- Caches: module-level `sessionFilesCache` and `fileDiffsCache` (10-second TTL)
- `resolve-diff-base.ts` — shared git diff-base utilities:
- `runGitCommand(args, cwd, timeoutMs)`
- `resolveDiffBase(task, cwd)` + `ResolveDiffBaseTaskInput` type
- `register-terminal-routes.ts` — terminal execution and PTY endpoints:
- Command execution + streaming: `/terminal/exec`, `/terminal/sessions/:id`, `/terminal/sessions/:id/stream`, `/terminal/sessions/:id/kill`
- PTY lifecycle: `/terminal/sessions` (create/list) and `/terminal/sessions/:id` (delete)
- `register-agent-core-routes.ts` — core agent CRUD, lookups, stats/org-tree, hierarchy aliases (`/agents/:id/children|employees`)
- `register-agent-runtime-routes.ts` — agent runtime/control-plane, heartbeats/runs, access/permissions, soul/memory, revisions/budget/keys, task/inbox surfaces
- `register-agent-reflection-rating-routes.ts` — reflection/performance/context endpoints and ratings APIs

View File

@@ -1,8 +1,7 @@
import { access } from "node:fs/promises";
import { createReadStream } from "node:fs";
import type { Request } from "express";
import type { Task } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
import { ApiError, badRequest } from "../api-error.js";
import {
copyWorkspaceFile,
deleteWorkspaceFile,
@@ -24,19 +23,6 @@ import {
} from "../file-service.js";
import type { ApiRoutesContext } from "./types.js";
interface FileWorkspaceRouteDeps {
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
resolveDiffBase: (task: Task, cwd: string) => Promise<string | undefined>;
}
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;
}
>();
function extractFileParams(req: Request): { filePath: string; workspace: string } {
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
@@ -54,9 +40,8 @@ function extractFileParams(req: Request): { filePath: string; workspace: string
* (`POST /files/{*filepath}`), otherwise Express will route operation suffixes
* as a generic filepath.
*/
export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext, deps: FileWorkspaceRouteDeps): void {
export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const { runGitCommand, resolveDiffBase } = deps;
// ── Task file routes ──────────────────────────────────────────────
router.get("/tasks/:id/files", async (req, res) => {
@@ -470,285 +455,6 @@ export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext, deps: FileWor
}
});
// ── Session/changed-file routes ────────────────────────────────────
router.get("/tasks/:id/session-files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree;
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 = await resolveDiffBase(task, worktree);
if (baseRef) {
const committedOutput = (await runGitCommand(["diff", "--name-only", `${baseRef}..HEAD`], worktree, 5000)).trim();
for (const file of committedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
}
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-only"], worktree, 5000)).trim();
for (const file of stagedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
const workingTreeOutput = (await runGitCommand(["diff", "--name-only"], worktree, 5000)).trim();
for (const file of workingTreeOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], worktree, 5000)).trim();
for (const file of untrackedOutput.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: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (task.column === "done" && task.mergeDetails?.commitSha) {
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
res.json([]);
return;
}
try {
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 5000)).trim();
const doneFiles = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
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 = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 5000);
} catch {
// ignore per-file diff failures
}
doneFiles.push({ path: filePath, status, diff });
}
res.json(doneFiles);
} catch {
res.json([]);
}
return;
}
if (task.column === "done") {
res.json([]);
return;
}
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree;
const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
return;
}
const cwd = worktree;
const diffBase = await resolveDiffBase(task, cwd);
const fileMap = new Map<string, { statusCode: string; oldPath?: string; isUntracked?: boolean }>();
if (diffBase) {
try {
const committedOutput = (await runGitCommand(["diff", "--name-status", `${diffBase}..HEAD`], cwd, 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 {
// continue with working-tree-only changes
}
}
try {
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status"], cwd, 5000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// ignore staged diff failures
}
try {
const workingTreeOutput = (await runGitCommand(["diff", "--name-status"], cwd, 5000)).trim();
for (const line of workingTreeOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// ignore unstaged diff failures
}
try {
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], cwd, 5000)).trim();
for (const line of untrackedOutput.split("\n").filter(Boolean)) {
if (line && !fileMap.has(line)) {
fileMap.set(line, { statusCode: "U", isUntracked: true });
}
}
} catch {
// ignore untracked listing failures
}
const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD";
const files = [];
for (const [filePath, { statusCode, oldPath, isUntracked }] of fileMap.entries()) {
let status: "added" | "modified" | "deleted" | "renamed" = "modified";
if (statusCode.startsWith("A") || statusCode === "U") {
status = "added";
} else if (statusCode.startsWith("D")) {
status = "deleted";
} else if (statusCode.startsWith("R")) {
status = "renamed";
}
let diff = "";
try {
if (isUntracked) {
diff = await runGitCommand(["diff", "--no-index", "/dev/null", filePath], cwd, 5000).catch(() => "");
} else {
diff = await runGitCommand(["diff", diffRange, "--", filePath], cwd, 5000);
}
} catch {
diff = "";
}
if (!diff && !isUntracked) {
continue;
}
files.push(oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff });
}
fileDiffsCache.set(task.id, {
files,
expiresAt: Date.now() + 10000,
});
res.json(files);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/project-files/md", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);

View File

@@ -1,7 +1,5 @@
import { type NextFunction, type Request, type Response } from "express";
import { execFile } from "node:child_process";
import { isAbsolute } from "node:path";
import { promisify } from "node:util";
import type { BatchStatusEntry, BatchStatusResponse, BatchStatusResult, IssueInfo, PrInfo, TaskStore } from "@fusion/core";
import { getCurrentRepo, isGhAuthenticated } from "@fusion/core";
import {
@@ -23,8 +21,7 @@ import {
verifyWebhookSignature,
} from "../github-webhooks.js";
import type { ApiRoutesContext } from "./types.js";
const execFileAsync = promisify(execFile);
import { runGitCommand } from "./resolve-diff-base.js";
function getCommandErrorMessage(error: unknown): string {
if (error instanceof Error) {
@@ -34,28 +31,7 @@ function getCommandErrorMessage(error: unknown): string {
return String(error);
}
export async function runGitCommand(args: string[], cwd?: string, timeout = 10000): Promise<string> {
const result = await execFileAsync("git", args, {
cwd,
timeout,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
});
if (typeof result === "string") {
return result;
}
if (Array.isArray(result)) {
return String(result[0] ?? "");
}
if (result && typeof result === "object" && "stdout" in result) {
return String((result as { stdout?: unknown }).stdout ?? "");
}
return "";
}
export { runGitCommand };
/** Git remote info returned by the remotes endpoint */
export interface GitRemote {

View File

@@ -0,0 +1,505 @@
import { access } from "node:fs/promises";
import type { Request, Router } from "express";
import { ApiError, notFound, rethrowAsApiError } from "../api-error.js";
import { resolveDiffBase, runGitCommand } from "./resolve-diff-base.js";
import type { ProjectContext } from "./types.js";
export interface SessionDiffRouteDeps {
getProjectContext: (req: Request) => Promise<ProjectContext>;
}
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;
}
>();
/**
* Registers task session-file and diff routes.
*
* Endpoints:
* - GET /tasks/:id/session-files
* - GET /tasks/:id/diff
* - GET /tasks/:id/file-diffs
*/
export function registerSessionDiffRoutes(router: Router, deps: SessionDiffRouteDeps): void {
const { getProjectContext } = deps;
router.get("/tasks/:id/session-files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree;
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 = await resolveDiffBase(task, worktree);
if (baseRef) {
const committedOutput = (await runGitCommand(["diff", "--name-only", `${baseRef}..HEAD`], worktree, 5000)).trim();
for (const file of committedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
}
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-only"], worktree, 5000)).trim();
for (const file of stagedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
const workingTreeOutput = (await runGitCommand(["diff", "--name-only"], worktree, 5000)).trim();
for (const file of workingTreeOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], worktree, 5000)).trim();
for (const file of untrackedOutput.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: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/tasks/:id/diff", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (task.column === "done" && task.mergeDetails?.commitSha) {
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 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 = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 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;
}
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 resolvedWorktree = worktree || task.worktree;
if (!resolvedWorktree) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
let worktreeExists = false;
try {
await access(resolvedWorktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const cwd = resolvedWorktree;
const diffBase = await resolveDiffBase(task, cwd);
const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD";
const fileMap = new Map<string, string>();
if (diffBase) {
try {
const committedOutput = (await runGitCommand(["diff", "--name-status", `${diffBase}..HEAD`], cwd, 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 stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status"], cwd, 10000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
fileMap.set(filePath, parts[0] ?? "M");
}
}
} catch {
// staged diff failed
}
try {
const workingTreeOutput = (await runGitCommand(["diff", "--name-status"], cwd, 10000)).trim();
for (const line of workingTreeOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
fileMap.set(filePath, parts[0] ?? "M");
}
}
} catch {
// working tree diff failed
}
try {
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], cwd, 10000)).trim();
for (const line of untrackedOutput.split("\n").filter(Boolean)) {
fileMap.set(line, "U");
}
} catch {
// untracked listing 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") || statusCode === "U") status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
let patch = "";
try {
if (statusCode === "U") {
patch = await runGitCommand(["diff", "--no-index", "/dev/null", filePath], cwd, 10000).catch(() => "");
} else {
patch = await runGitCommand(["diff", diffRange, "--", filePath], cwd, 10000);
}
} catch {
// ignore individual file errors
}
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: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.get("/tasks/:id/file-diffs", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
if (task.column === "done" && task.mergeDetails?.commitSha) {
const rootDir = scopedStore.getRootDir();
const sha = task.mergeDetails.commitSha;
let mergeBase: string | undefined;
try {
mergeBase = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
res.json([]);
return;
}
try {
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 5000)).trim();
const doneFiles = [];
for (const line of nameStatus.split("\n").filter(Boolean)) {
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 = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 5000);
} catch {
// ignore per-file diff failures
}
doneFiles.push({ path: filePath, status, diff });
}
res.json(doneFiles);
} catch {
res.json([]);
}
return;
}
if (task.column === "done") {
res.json([]);
return;
}
if (!task.worktree) {
res.json([]);
return;
}
let worktreeExists = false;
try {
await access(task.worktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json([]);
return;
}
const worktree = task.worktree;
const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
return;
}
const cwd = worktree;
const diffBase = await resolveDiffBase(task, cwd);
const fileMap = new Map<string, { statusCode: string; oldPath?: string; isUntracked?: boolean }>();
if (diffBase) {
try {
const committedOutput = (await runGitCommand(["diff", "--name-status", `${diffBase}..HEAD`], cwd, 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 {
// continue with working-tree-only changes
}
}
try {
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status"], cwd, 5000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// ignore staged diff failures
}
try {
const workingTreeOutput = (await runGitCommand(["diff", "--name-status"], cwd, 5000)).trim();
for (const line of workingTreeOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const statusCode = parts[0] ?? "M";
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// ignore unstaged diff failures
}
try {
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], cwd, 5000)).trim();
for (const line of untrackedOutput.split("\n").filter(Boolean)) {
if (line && !fileMap.has(line)) {
fileMap.set(line, { statusCode: "U", isUntracked: true });
}
}
} catch {
// ignore untracked listing failures
}
const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD";
const files = [];
for (const [filePath, { statusCode, oldPath, isUntracked }] of fileMap.entries()) {
let status: "added" | "modified" | "deleted" | "renamed" = "modified";
if (statusCode.startsWith("A") || statusCode === "U") {
status = "added";
} else if (statusCode.startsWith("D")) {
status = "deleted";
} else if (statusCode.startsWith("R")) {
status = "renamed";
}
let diff = "";
try {
if (isUntracked) {
diff = await runGitCommand(["diff", "--no-index", "/dev/null", filePath], cwd, 5000).catch(() => "");
} else {
diff = await runGitCommand(["diff", diffRange, "--", filePath], cwd, 5000);
}
} catch {
diff = "";
}
if (!diff && !isUntracked) {
continue;
}
files.push(oldPath ? { path: filePath, status, diff, oldPath } : { path: filePath, status, diff });
}
fileDiffsCache.set(task.id, {
files,
expiresAt: Date.now() + 10000,
});
res.json(files);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
throw notFound(`Task ${req.params.id} not found`);
}
rethrowAsApiError(err, "Internal server error");
}
});
}

View File

@@ -1,5 +1,4 @@
import { createReadStream } from "node:fs";
import { access } from "node:fs/promises";
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
import { COLUMNS, VALID_TRANSITIONS } from "@fusion/core";
import { ApiError, badRequest, notFound } from "../api-error.js";
@@ -12,7 +11,6 @@ interface TaskWorkflowRouteDeps {
validateOptionalModelField: (value: unknown, name: string) => string | undefined;
normalizeModelSelectionPair: (provider: string | undefined, modelId: string | undefined) => { provider?: string | null; modelId?: string | null };
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
resolveDiffBase: (task: Task, cwd: string) => Promise<string | undefined>;
trimTaskDetailActivityLog: (task: TaskDetail) => TaskDetail;
triggerCommentWakeForAssignedAgent: (scopedStore: TaskStore, task: Task, wake: { triggeringCommentType: "steering" | "task" | "pr"; triggeringCommentIds?: string[]; triggerDetail: string }) => Promise<void>;
}
@@ -26,7 +24,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
validateOptionalModelField,
normalizeModelSelectionPair,
runGitCommand,
resolveDiffBase,
trimTaskDetailActivityLog,
triggerCommentWakeForAssignedAgent,
} = deps;
@@ -1625,222 +1622,5 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
/**
* 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 { store: scopedStore } = await getProjectContext(req);
const task = await scopedStore.getTask(req.params.id);
if (!task) {
res.status(404).json({ error: "Task not found" });
return;
}
// 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 = (await runGitCommand(["rev-parse", `${sha}^`], rootDir, 5000)).trim();
} catch {
// Last resort: no diff available
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const nameStatus = (await runGitCommand(["diff", "--name-status", `${mergeBase}..${sha}`], rootDir, 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 = await runGitCommand(["diff", `${mergeBase}..${sha}`, "--", filePath], rootDir, 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 resolvedWorktree = worktree || task.worktree;
// Check worktree existence asynchronously to avoid blocking event loop
if (!resolvedWorktree) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
let worktreeExists = false;
try {
await access(resolvedWorktree);
worktreeExists = true;
} catch {
worktreeExists = false;
}
if (!worktreeExists) {
res.json({ files: [], stats: { filesChanged: 0, additions: 0, deletions: 0 } });
return;
}
const cwd = resolvedWorktree;
// Use resolveDiffBase for consistent diff base across all endpoints
const diffBase = await resolveDiffBase(task, cwd);
const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD";
// Get list of changed files — include committed, staged, unstaged, and untracked
const fileMap = new Map<string, string>();
if (diffBase) {
try {
const committedOutput = (await runGitCommand(["diff", "--name-status", `${diffBase}..HEAD`], cwd, 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
}
}
// Staged changes (in git index)
try {
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-status"], cwd, 10000)).trim();
for (const line of stagedOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
// Use staged status (don't overwrite committed status)
const filePath = parts[1] ?? "";
if (filePath && !fileMap.has(filePath)) {
fileMap.set(filePath, parts[0] ?? "M");
}
}
} catch {
// staged diff failed
}
try {
const workingTreeOutput = (await runGitCommand(["diff", "--name-status"], cwd, 10000)).trim();
for (const line of workingTreeOutput.split("\n").filter(Boolean)) {
const parts = line.split("\t");
const filePath = parts[1] ?? "";
// Unstaged changes only affect files not already in map (to avoid overwrite)
if (filePath && !fileMap.has(filePath)) {
fileMap.set(filePath, parts[0] ?? "M");
}
}
} catch {
// working tree diff failed
}
// Untracked files (new files not yet staged)
try {
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], cwd, 10000)).trim();
for (const line of untrackedOutput.split("\n").filter(Boolean)) {
// Mark as untracked with special status "U" - will be converted to "added"
fileMap.set(line, "U");
}
} catch {
// untracked listing 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") || statusCode === "U") status = "added";
else if (statusCode.startsWith("D")) status = "deleted";
else status = "modified";
// Get patch for this file
let patch = "";
try {
// For untracked files, generate synthetic diff against /dev/null
if (statusCode === "U") {
patch = await runGitCommand(["diff", "--no-index", "/dev/null", filePath], cwd, 10000).catch(() => "");
} else {
patch = await runGitCommand(["diff", diffRange, "--", filePath], cwd, 10000);
}
} catch {
// Ignore errors for individual files
}
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: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
}

View File

@@ -0,0 +1,243 @@
import type { Request, Router } from "express";
import { ApiError, badRequest, notFound, rethrowAsApiError } from "../api-error.js";
import type { ProjectContext } from "./types.js";
import type { TerminalOutputEvent, terminalSessionManager as terminalSessionManagerType } from "../terminal.js";
import type { getTerminalService as getTerminalServiceType } from "../terminal-service.js";
export interface TerminalRouteDeps {
getProjectContext: (req: Request) => Promise<ProjectContext>;
terminalSessionManager: typeof terminalSessionManagerType;
getTerminalService: typeof getTerminalServiceType;
}
/**
* Registers terminal execution and PTY management routes.
*
* Endpoints:
* - POST /terminal/exec
* - POST /terminal/sessions/:id/kill
* - GET /terminal/sessions/:id
* - GET /terminal/sessions/:id/stream
* - POST /terminal/sessions
* - GET /terminal/sessions
* - DELETE /terminal/sessions/:id
*/
export function registerTerminalRoutes(router: Router, deps: TerminalRouteDeps): void {
const { getProjectContext, terminalSessionManager, getTerminalService } = deps;
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 { store: scopedStore } = await getProjectContext(req);
const rootDir = scopedStore.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: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to execute command");
}
});
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");
}
throw badRequest("Session is not running");
}
res.json({ killed: true, sessionId: id });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
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: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
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");
}
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.write(`event: connected\ndata: ${JSON.stringify({ sessionId: id })}\n\n`);
const onOutput = (event: 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`);
if (event.type === "exit") {
setTimeout(() => {
res.end();
}, 100);
}
};
terminalSessionManager.on("output", onOutput);
req.on("close", () => {
terminalSessionManager.off("output", onOutput);
});
req.on("error", () => {
terminalSessionManager.off("output", onOutput);
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
router.post("/terminal/sessions", async (req, res) => {
try {
const { cwd, cols, rows } = req.body;
const { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.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: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to create terminal session");
}
});
router.get("/terminal/sessions", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const sessions = terminalService.getAllSessions();
res.json(
sessions.map((session) => ({
id: session.id,
cwd: session.cwd,
shell: session.shell,
createdAt: session.createdAt.toISOString(),
lastActivityAt: session.lastActivityAt.toISOString(),
})),
);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Failed to list sessions");
}
});
router.delete("/terminal/sessions/:id", async (req, res) => {
try {
const { id } = req.params;
const { store: scopedStore } = await getProjectContext(req);
const terminalService = getTerminalService(scopedStore.getRootDir());
const killed = terminalService.killSession(id);
if (!killed) {
const session = terminalService.getSession(id);
if (!session) {
throw notFound("Session not found");
}
throw badRequest("Failed to kill session");
}
res.json({ killed: true });
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err);
}
});
}

View File

@@ -0,0 +1,95 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
const execFileAsync = promisify(execFile);
/**
* Execute a git command and return stdout as text.
*/
export async function runGitCommand(args: string[], cwd?: string, timeout = 10000): Promise<string> {
const result = await execFileAsync("git", args, {
cwd,
timeout,
maxBuffer: 10 * 1024 * 1024,
encoding: "utf-8",
});
if (typeof result === "string") {
return result;
}
if (Array.isArray(result)) {
return String(result[0] ?? "");
}
if (result && typeof result === "object" && "stdout" in result) {
return String((result as { stdout?: unknown }).stdout ?? "");
}
return "";
}
export interface ResolveDiffBaseTaskInput {
baseCommitSha?: string;
baseBranch?: string;
}
/**
* Resolve the diff base ref for a task worktree.
*
* IMPORTANT: `packages/engine/src/merger.ts` mirrors this exact ordering for
* merge-time scope warnings. Keep both implementations in sync so dashboard
* changed-files views and merger scope enforcement evaluate the same range.
*
* Strategy (in priority order):
* 1. **Branch merge-base** — Prefer the live merge-base between `headRef` and
* local `{baseBranch}` (fallback: `origin/{baseBranch}`).
* 2. **Task-scoped baseCommitSha** — If merge-base is unavailable or equals
* `headRef`, use `baseCommitSha` when still an ancestor of `headRef`.
* 3. **headRef~1** — Last-resort fallback.
*/
export async function resolveDiffBase(
task: ResolveDiffBaseTaskInput,
cwd: string,
headRef = "HEAD",
runGit: (args: string[], cwd?: string, timeout?: number) => Promise<string> = runGitCommand,
): Promise<string | undefined> {
const baseBranch = task.baseBranch ?? "main";
let mergeBase: string | undefined;
try {
try {
mergeBase = (await runGit(["merge-base", headRef, baseBranch], cwd, 5000)).trim() || undefined;
} catch {
mergeBase = (await runGit(["merge-base", headRef, `origin/${baseBranch}`], cwd, 5000)).trim() || undefined;
}
} catch {
// base branch may no longer exist locally/remotely
}
// If merge-base equals headRef, the live merge-base would produce an empty
// diff. Prefer task.baseCommitSha when still valid.
if (mergeBase) {
try {
const head = (await runGit(["rev-parse", headRef], cwd, 5000)).trim();
if (head && head !== mergeBase) return mergeBase;
} catch {
return mergeBase;
}
}
if (task.baseCommitSha) {
try {
await runGit(["merge-base", "--is-ancestor", task.baseCommitSha, headRef], cwd, 5000);
return task.baseCommitSha;
} catch {
// stale or unreachable — fall through
}
}
try {
return (await runGit(["rev-parse", `${headRef}~1`], cwd, 5000)).trim() || undefined;
} catch {
return undefined;
}
}