feat(FN-2561): extract file and workspace routes into dedicated registrar

- Add register-file-workspace-routes.ts with task file, workspace discovery, file operation, and markdown/search endpoints
- Mount the new file workspace registrar from routes.ts using shared route context and injected helpers
- Remove file/workspace route implementations from routes.ts and register-task-workflow-routes.ts to keep domain boundaries clear
- Update routes/README.md with registrar responsibilities and ordering constraints for file wildcard routes
This commit is contained in:
Fusion
2026-04-26 10:47:41 -07:00
committed by gsxdsm
parent 4d9c924f81
commit cdffd7d080
4 changed files with 787 additions and 902 deletions

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`) also take a narrow dependency-injection object for non-context helpers that must stay source-of-truth in `routes.ts` (cache maps, 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` 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.
The context provides core cross-cutting plumbing:
@@ -32,7 +32,7 @@ The context provides core cross-cutting plumbing:
- `register-discovery-routes.ts` — discovery routes (`/discovery/status|start|stop|nodes|connect`) with `options?.centralCore` reuse
- `register-settings-sync-inbound-routes.ts` — inbound sync/auth endpoints (`/settings/sync-receive`, `/settings/auth-receive`, `/settings/auth-export`)
- `register-settings-sync-helpers.ts` — shared sync-domain helpers (`fetchFromRemoteNode`, `readStoredAuthProvidersFromDisk`)
- `register-task-workflow-routes.ts` — task/workflow domain (`/tasks*`, `/documents`, task comments/docs/checkout/spec/attachments, PR+issue status, task file/diff endpoints)
- `register-task-workflow-routes.ts` — task/workflow domain (`/tasks*`, `/documents`, task comments/docs/checkout/spec/attachments, PR+issue status, task lifecycle/workflow endpoints)
- `register-planning-subtask-routes.ts` — planning sessions and subtask breakdown routes
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
- `register-messaging-scripts.ts` — scripts API and mailbox/message routes
@@ -43,7 +43,14 @@ The context provides core cross-cutting plumbing:
- `register-model-routes.ts``/models` endpoint, favorites projection, and `useClaudeCli` filtering for `pi-claude-cli` entries
- `register-auth-routes.ts` — auth/provider domain (`/auth/status`, `/auth/login`, `/auth/logout`, `/auth/api-key`, `/auth/claude-cli`, `/providers/claude-cli/status`)
- `register-usage-routes.ts``/usage` endpoint with `fetchAllProviderUsage(options?.authStorage)` integration
- `register-files-terminal-workspaces.ts`files, terminal, workspace file operations
- `register-file-workspace-routes.ts`task/workspace file domain:
- Task files: `/tasks/:id/files`, `/tasks/:id/files/{*filepath}` (read/write)
- 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-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
@@ -57,7 +64,8 @@ The context provides core cross-cutting plumbing:
Express matches in registration order. Keep registrar and in-registrar route ordering stable:
1. **Specific operation routes before generic parameterized routes** (`/runs`, `/runs/:id`, `/copy`, `/delete` before `/:id` style handlers)
2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete` before catch-all file write routes)
2. **Specific operation routes before wildcard paths** (`/files/{*filepath}/copy|move|delete|rename|download|download-zip` before `POST /files/{*filepath}`)
- Why: Express route matching is first-win. If the wildcard write route is registered first, paths like `/files/somefolder/delete` will be treated as file writes instead of delete operations.
3. **Do not move proxy/script/message/file wildcards ahead of specific routes**
4. **Project/node/sync/discovery ordering constraints must stay intact**:
- `/projects/across-nodes` and `/projects/detect` must be registered before `/projects/:id`

View File

@@ -0,0 +1,770 @@
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 {
copyWorkspaceFile,
deleteWorkspaceFile,
FileServiceError,
getWorkspaceFileForDownload,
getWorkspaceFolderForZip,
listFiles,
listProjectMarkdownFiles,
listWorkspaceFiles,
moveWorkspaceFile,
readFile,
readWorkspaceFile,
renameWorkspaceFile,
scanMarkdownFiles,
searchWorkspaceFiles,
type MarkdownFileListResponse,
writeFile,
writeWorkspaceFile,
} 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 ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
return { filePath, workspace };
}
/**
* Registers task-file, workspace-file, and changed-file routes.
*
* Ordering is critical: operation routes (copy/move/delete/rename/download)
* must be registered before the generic wildcard write route
* (`POST /files/{*filepath}`), otherwise Express will route operation suffixes
* as a generic filepath.
*/
export function registerFileWorkspaceRoutes(ctx: ApiRoutesContext, deps: FileWorkspaceRouteDeps): void {
const { router, getProjectContext, rethrowAsApiError } = ctx;
const { runGitCommand, resolveDiffBase } = deps;
// ── Task file routes ──────────────────────────────────────────────
router.get("/tasks/:id/files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { path: subPath } = req.query;
const result = await listFiles(scopedStore, req.params.id, typeof subPath === "string" ? subPath : undefined);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const result = await readFile(scopedStore, req.params.id, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const errorWithCode = err as NodeJS.ErrnoException;
const status = errorWithCode.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.post("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const { content } = req.body;
if (typeof content !== "string") {
throw badRequest("content is required and must be a string");
}
const result = await writeFile(scopedStore, req.params.id, filePath, content);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const errorWithCode = err as NodeJS.ErrnoException;
const status = errorWithCode.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// ── Workspace discovery routes ────────────────────────────────────
router.get("/workspaces", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
const worktreeCheckPromises = tasks.map(async (task): Promise<{ id: string; title?: string; worktree: string } | null> => {
if (typeof task.worktree !== "string" || task.worktree.length === 0) {
return null;
}
try {
await access(task.worktree);
return {
id: task.id,
title: task.title,
worktree: task.worktree,
};
} catch {
return null;
}
});
const workspaceTasks = (await Promise.all(worktreeCheckPromises)).filter(
(task): task is { id: string; title?: string; worktree: string } => task !== null,
);
res.json({
project: scopedStore.getRootDir(),
tasks: workspaceTasks,
});
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Internal server error");
}
});
// ── Workspace file routes ─────────────────────────────────────────
router.get("/files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { path: subPath, workspace } = req.query;
const workspaceId = typeof workspace === "string" && workspace.length > 0 ? workspace : "project";
const result = await listWorkspaceFiles(scopedStore, workspaceId, typeof subPath === "string" ? subPath : undefined);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/files/markdown-list", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const showHiddenQuery = req.query.showHidden;
const showHidden = showHiddenQuery === "1" || showHiddenQuery === "true";
const result: MarkdownFileListResponse = await listProjectMarkdownFiles(scopedStore, { showHidden });
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/files/search", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const q = req.query.q;
if (!q || typeof q !== "string" || q.trim().length === 0) {
throw new ApiError(400, "Query parameter 'q' is required and must be a non-empty string");
}
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
const result = await searchWorkspaceFiles(scopedStore, workspace, q);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
const result = await readWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// MUST be before generic wildcard write route.
router.post("/files/{*filepath}/copy", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
throw badRequest("destination is required and must be a string");
}
const result = await copyWorkspaceFile(scopedStore, workspace, filePath, destination);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.post("/files/{*filepath}/move", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { destination } = req.body;
if (!destination || typeof destination !== "string") {
throw badRequest("destination is required and must be a string");
}
const result = await moveWorkspaceFile(scopedStore, workspace, filePath, destination);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.post("/files/{*filepath}/delete", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const result = await deleteWorkspaceFile(scopedStore, workspace, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.post("/files/{*filepath}/rename", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { newName } = req.body;
if (!newName || typeof newName !== "string") {
throw badRequest("newName is required and must be a string");
}
const result = await renameWorkspaceFile(scopedStore, workspace, filePath, newName);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EEXIST" ? 409
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/files/{*filepath}/download", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, stats, fileName } = await getWorkspaceFileForDownload(scopedStore, workspace, filePath);
res.setHeader("Content-Type", "application/octet-stream");
res.setHeader("Content-Disposition", `attachment; filename="${fileName}"`);
res.setHeader("Content-Length", stats.size);
res.setHeader("Last-Modified", stats.mtime.toUTCString());
const stream = createReadStream(absolutePath);
stream.pipe(res);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EISDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
router.get("/files/{*filepath}/download-zip", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { filePath, workspace } = extractFileParams(req);
const { absolutePath, dirName } = await getWorkspaceFolderForZip(scopedStore, workspace, filePath);
const archiver = await import("archiver");
const archive = archiver.default("zip", { zlib: { level: 6 } });
res.setHeader("Content-Type", "application/zip");
res.setHeader("Content-Disposition", `attachment; filename="${dirName}.zip"`);
archive.pipe(res);
archive.directory(absolutePath, dirName);
await archive.finalize();
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "ENOTDIR" ? 400
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// Must remain after copy/move/delete/rename/download routes.
router.post("/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const { content } = req.body;
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
? req.query.workspace
: "project";
if (typeof content !== "string") {
throw badRequest("content is required and must be a string");
}
const result = await writeWorkspaceFile(scopedStore, workspace, filePath, content);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: 400;
throw new ApiError(status, err.message, { code: err.code });
}
rethrowAsApiError(err, "Internal server error");
}
});
// ── 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);
const files = await scanMarkdownFiles(scopedStore);
const query = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
const filteredFiles = query.length > 0
? files.filter((file) => file.name.toLowerCase().includes(query) || file.contentPreview.toLowerCase().includes(query))
: files;
res.json(filteredFiles);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Internal server error");
}
});
}

View File

@@ -2,7 +2,6 @@ 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 { listFiles, readFile, writeFile, scanMarkdownFiles, FileServiceError } from "../file-service.js";
import { ApiError, badRequest, notFound } from "../api-error.js";
import type { ApiRoutesContext } from "./types.js";
@@ -15,8 +14,6 @@ interface TaskWorkflowRouteDeps {
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
resolveDiffBase: (task: Task, cwd: string) => Promise<string | undefined>;
trimTaskDetailActivityLog: (task: TaskDetail) => TaskDetail;
sessionFilesCache: Map<string, { files: string[]; expiresAt: number }>;
fileDiffsCache: Map<string, { files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>; expiresAt: number }>;
triggerCommentWakeForAssignedAgent: (scopedStore: TaskStore, task: Task, wake: { triggeringCommentType: "steering" | "task" | "pr"; triggeringCommentIds?: string[]; triggerDetail: string }) => Promise<void>;
}
@@ -31,8 +28,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
runGitCommand,
resolveDiffBase,
trimTaskDetailActivityLog,
sessionFilesCache,
fileDiffsCache,
triggerCommentWakeForAssignedAgent,
} = deps;
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = taskDetailActivityLogLimit;
@@ -665,93 +660,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
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;
}
// Check worktree existence asynchronously to avoid blocking event loop
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; // Capture after check
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) {
// Committed changes since 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);
}
}
// Staged changes (in git index)
const stagedOutput = (await runGitCommand(["diff", "--cached", "--name-only"], worktree, 5000)).trim();
for (const file of stagedOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
// Unstaged working tree changes
const workingTreeOutput = (await runGitCommand(["diff", "--name-only"], worktree, 5000)).trim();
for (const file of workingTreeOutput.split("\n").filter(Boolean)) {
fileSet.add(file);
}
// Untracked files (new files not yet staged)
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`);
} else {
rethrowAsApiError(err, "Internal server error");
}
}
});
/**
* GET /api/tasks/:id/workflow-results
* Get workflow step execution results for a task.
@@ -1154,26 +1062,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// GET /project-files/md — List Markdown files in the project directory
router.get("/project-files/md", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const files = await scanMarkdownFiles(scopedStore);
const query = typeof req.query.q === "string" ? req.query.q.trim().toLowerCase() : "";
const filteredFiles = query.length > 0
? files.filter((file) => file.name.toLowerCase().includes(query) || file.contentPreview.toLowerCase().includes(query))
: files;
res.json(filteredFiles);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
rethrowAsApiError(err, "Internal server error");
}
});
// Add steering comment to task
router.post("/tasks/:id/steer", async (req, res) => {
try {
@@ -1737,102 +1625,6 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
// ── File API Routes ───────────────────────────────────────────────
/**
* GET /api/tasks/:id/files
* List files in task directory (or worktree if available).
* Query param: ?path=relative/path for subdirectory navigation.
* Returns: { path: string; entries: FileNode[] }
*/
router.get("/tasks/:id/files", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const { path: subPath } = req.query;
const result = await listFiles(scopedStore, req.params.id, typeof subPath === "string" ? subPath : undefined);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const status = err.code === "ENOTASK" ? 404
: err.code === "ENOENT" ? 404
: err.code === "EACCES" ? 403
: 400;
throw new ApiError(status, err.message, { code: err.code });
} else {
rethrowAsApiError(err, "Internal server error");
}
}
});
/**
* GET /api/tasks/:id/files/:filepath
* Read file contents.
* Returns: { content: string; mtime: string; size: number }
*/
router.get("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const result = await readFile(scopedStore, req.params.id, filePath);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const errorWithCode = err as NodeJS.ErrnoException;
const status = errorWithCode.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: err.code === "EINVAL" && (err instanceof Error ? err.message : String(err)).includes("Binary file") ? 415
: 400;
throw new ApiError(status, err.message, { code: err.code });
} else {
rethrowAsApiError(err, "Internal server error");
}
}
});
/**
* POST /api/tasks/:id/files/:filepath
* Write file contents.
* Body: { content: string }
* Returns: { success: true; mtime: string; size: number }
*/
router.post("/tasks/:id/files/{*filepath}", async (req, res) => {
try {
const { store: scopedStore } = await getProjectContext(req);
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
const { content } = req.body;
if (typeof content !== "string") {
throw badRequest("content is required and must be a string");
}
const result = await writeFile(scopedStore, req.params.id, filePath, content);
res.json(result);
} catch (err: unknown) {
if (err instanceof ApiError) {
throw err;
}
if (err instanceof FileServiceError) {
const errorWithCode = err as NodeJS.ErrnoException;
const status = errorWithCode.code === "ENOENT" ? 404
: err.code === "ENOTASK" ? 404
: err.code === "EACCES" ? 403
: err.code === "ETOOLARGE" ? 413
: 400;
throw new ApiError(status, err.message, { code: err.code });
} else {
rethrowAsApiError(err, "Internal server error");
}
}
});
/**
* GET /api/tasks/:id/diff
* Fetch git diff for a task's changes.
@@ -2051,229 +1843,4 @@ export function registerTaskWorkflowRoutes(ctx: ApiRoutesContext, deps: TaskWork
}
});
/**
* GET /api/tasks/:id/file-diffs
* Fetch changed files with individual git diffs for a task worktree.
* Uses the shared resolveDiffBase() helper so the board card count and the
* changed-files viewer always agree. Prefers live branch merge-base first,
* then falls back to task.baseCommitSha / HEAD~1.
* Returns: Array<{ path, status, diff, oldPath? }>
*/
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;
}
// 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 {
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 */ }
doneFiles.push({ path: filePath, status, diff });
}
res.json(doneFiles);
} catch {
res.json([]);
}
return;
}
// Done tasks without a commit SHA: return safe, empty response.
// Do NOT fall through to worktree-based logic that could scan the
// entire repository when the worktree has been cleaned up.
if (task.column === "done") {
res.json([]);
return;
}
// Check worktree existence asynchronously to avoid blocking event loop
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; // Capture after check
const cached = fileDiffsCache.get(task.id);
if (cached && cached.expiresAt > Date.now()) {
res.json(cached.files);
return;
}
const cwd = worktree;
// Resolve a diff base using the shared strategy so both endpoints
// always agree on which files have changed. Prefer live branch
// merge-base first, then task-scoped baseCommitSha when needed.
const diffBase = await resolveDiffBase(task, cwd);
// Collect file statuses from committed, staged, unstaged, and untracked changes.
// Deduplicate by path to match session-files.
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 {
// committed diff failed — continue with working-tree only
}
}
// Staged changes (in git index)
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] ?? "";
// Only add if not already in map (committed takes precedence)
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// staged diff failed
}
// Unstaged working tree changes
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] ?? "";
// Only add if not already in map (committed/staged takes precedence)
if (filePath && !fileMap.has(filePath)) {
if (statusCode.startsWith("R")) {
fileMap.set(filePath, { statusCode, oldPath: parts[2] });
} else {
fileMap.set(filePath, { statusCode });
}
}
}
} catch {
// working tree diff failed
}
// Untracked files (new files not yet staged)
try {
const untrackedOutput = (await runGitCommand(["ls-files", "--others", "--exclude-standard"], cwd, 5000)).trim();
for (const line of untrackedOutput.split("\n").filter(Boolean)) {
// Only add if not already tracked (committed/staged/unstaged)
if (line && !fileMap.has(line)) {
fileMap.set(line, { statusCode: "U", isUntracked: true });
}
}
} catch {
// untracked listing failed
}
// Build the result array with per-file diffs using the two-dot range
// against the resolved merge-base.
const diffRange = diffBase ? `${diffBase}..HEAD` : "HEAD";
const files = [];
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 {
// For untracked files, generate synthetic diff against /dev/null
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 = "";
}
// Skip files with empty diffs (mode-only changes, binary files)
// BUT keep untracked files which have synthetic diffs
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`);
} else {
rethrowAsApiError(err, "Internal server error");
}
}
});
}