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:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
/**
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
505
packages/dashboard/src/routes/register-session-diff-routes.ts
Normal file
505
packages/dashboard/src/routes/register-session-diff-routes.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
243
packages/dashboard/src/routes/register-terminal-routes.ts
Normal file
243
packages/dashboard/src/routes/register-terminal-routes.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
95
packages/dashboard/src/routes/resolve-diff-base.ts
Normal file
95
packages/dashboard/src/routes/resolve-diff-base.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user