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:
@@ -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`
|
||||
|
||||
770
packages/dashboard/src/routes/register-file-workspace-routes.ts
Normal file
770
packages/dashboard/src/routes/register-file-workspace-routes.ts
Normal 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");
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user