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:
@@ -7,20 +7,14 @@ declare module "express" {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
import multer from "multer";
|
import multer from "multer";
|
||||||
import { createReadStream } from "node:fs";
|
|
||||||
import * as fsPromises from "node:fs/promises";
|
|
||||||
import { resolve, sep, join, isAbsolute } from "node:path";
|
import { resolve, sep, join, isAbsolute } from "node:path";
|
||||||
import * as nodeFs from "node:fs";
|
import * as nodeFs from "node:fs";
|
||||||
|
|
||||||
const {
|
|
||||||
access,
|
|
||||||
} = fsPromises;
|
|
||||||
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
import type { TaskStore, ScheduleType, ActivityEventType, ModelPreset, RoutineTriggerType } from "@fusion/core";
|
||||||
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
import { type Task, type PiExtensionEntry, type PiExtensionSettings, AutomationStore, RoutineStore, isWebhookTrigger, MemoryBackendError, listAgentMemoryFiles, readAgentMemoryFile, writeAgentMemoryFile, discoverPiExtensions, getFusionAgentDir, getLegacyPiAgentDir } from "@fusion/core";
|
||||||
import type { ServerOptions } from "./server.js";
|
import type { ServerOptions } from "./server.js";
|
||||||
import { terminalSessionManager } from "./terminal.js";
|
import { terminalSessionManager } from "./terminal.js";
|
||||||
import { getTerminalService } from "./terminal-service.js";
|
import { getTerminalService } from "./terminal-service.js";
|
||||||
import { listWorkspaceFiles, readWorkspaceFile, writeWorkspaceFile, searchWorkspaceFiles, copyWorkspaceFile, moveWorkspaceFile, deleteWorkspaceFile, renameWorkspaceFile, getWorkspaceFileForDownload, getWorkspaceFolderForZip, listProjectMarkdownFiles, FileServiceError, type MarkdownFileListResponse } from "./file-service.js";
|
|
||||||
import { verifyWebhookSignature } from "./github-webhooks.js";
|
import { verifyWebhookSignature } from "./github-webhooks.js";
|
||||||
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
import { AiSessionStore, SESSION_CLEANUP_DEFAULT_MAX_AGE_MS } from "./ai-session-store.js";
|
||||||
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
import { getSession as getPlanningSession, cleanupSession as cleanupPlanningSession } from "./planning.js";
|
||||||
@@ -47,7 +41,7 @@ import { registerChatRoutes } from "./routes/register-chat-routes.js";
|
|||||||
import { registerSettingsMemoryRoutes } from "./routes/register-settings-memory-routes.js";
|
import { registerSettingsMemoryRoutes } from "./routes/register-settings-memory-routes.js";
|
||||||
import { registerMessagingScriptRoutes } from "./routes/register-messaging-scripts.js";
|
import { registerMessagingScriptRoutes } from "./routes/register-messaging-scripts.js";
|
||||||
import { registerGitGitHubRoutes, runGitCommand } from "./routes/register-git-github.js";
|
import { registerGitGitHubRoutes, runGitCommand } from "./routes/register-git-github.js";
|
||||||
import { registerFilesTerminalWorkspaceRoutes } from "./routes/register-files-terminal-workspaces.js";
|
import { registerFileWorkspaceRoutes } from "./routes/register-file-workspace-routes.js";
|
||||||
import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js";
|
import { registerAgentsProjectsNodesRoutes } from "./routes/register-agents-projects-nodes.js";
|
||||||
import { registerProjectRoutes } from "./routes/register-project-routes.js";
|
import { registerProjectRoutes } from "./routes/register-project-routes.js";
|
||||||
import { registerNodeRoutes } from "./routes/register-node-routes.js";
|
import { registerNodeRoutes } from "./routes/register-node-routes.js";
|
||||||
@@ -939,15 +933,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
rethrowAsApiError,
|
rethrowAsApiError,
|
||||||
};
|
};
|
||||||
|
|
||||||
const sessionFilesCache = new Map<string, { files: string[]; expiresAt: number }>();
|
|
||||||
const fileDiffsCache = new Map<
|
|
||||||
string,
|
|
||||||
{
|
|
||||||
files: Array<{ path: string; status: "added" | "modified" | "deleted" | "renamed"; diff: string; oldPath?: string }>;
|
|
||||||
expiresAt: number;
|
|
||||||
}
|
|
||||||
>();
|
|
||||||
|
|
||||||
// Get GitHub token from options or env
|
// Get GitHub token from options or env
|
||||||
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
const githubToken = options?.githubToken ?? process.env.GITHUB_TOKEN;
|
||||||
const aiSessionStore = options?.aiSessionStore;
|
const aiSessionStore = options?.aiSessionStore;
|
||||||
@@ -970,8 +955,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
runGitCommand,
|
runGitCommand,
|
||||||
resolveDiffBase,
|
resolveDiffBase,
|
||||||
trimTaskDetailActivityLog,
|
trimTaskDetailActivityLog,
|
||||||
sessionFilesCache,
|
|
||||||
fileDiffsCache,
|
|
||||||
triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args),
|
triggerCommentWakeForAssignedAgent: (...args) => triggerCommentWakeForAssignedAgent(...args),
|
||||||
});
|
});
|
||||||
registerPlanningSubtaskRoutes(routeContext, {
|
registerPlanningSubtaskRoutes(routeContext, {
|
||||||
@@ -987,7 +970,10 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
});
|
});
|
||||||
registerMessagingScriptRoutes(routeContext);
|
registerMessagingScriptRoutes(routeContext);
|
||||||
registerGitGitHubRoutes(routeContext);
|
registerGitGitHubRoutes(routeContext);
|
||||||
registerFilesTerminalWorkspaceRoutes(routeContext);
|
registerFileWorkspaceRoutes(routeContext, {
|
||||||
|
runGitCommand,
|
||||||
|
resolveDiffBase,
|
||||||
|
});
|
||||||
registerAgentsProjectsNodesRoutes(routeContext);
|
registerAgentsProjectsNodesRoutes(routeContext);
|
||||||
registerPluginsAutomationRoutes(routeContext);
|
registerPluginsAutomationRoutes(routeContext);
|
||||||
registerProxyRoutes(routeContext);
|
registerProxyRoutes(routeContext);
|
||||||
@@ -1620,452 +1606,6 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// ── Workspace File API Routes ─────────────────────────────────────
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/workspaces
|
|
||||||
* List available file browser workspaces.
|
|
||||||
* Returns: { project: string; tasks: Array<{ id: string; title?: string; worktree: string }> }
|
|
||||||
*/
|
|
||||||
router.get("/workspaces", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { store: scopedStore } = await getProjectContext(req);
|
|
||||||
const tasks = await scopedStore.listTasks({ slim: true, includeArchived: false });
|
|
||||||
|
|
||||||
// Filter to tasks with valid worktrees, checking existence asynchronously
|
|
||||||
// to avoid blocking the event loop
|
|
||||||
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(
|
|
||||||
(t): t is { id: string; title?: string; worktree: string } => t !== null
|
|
||||||
);
|
|
||||||
|
|
||||||
res.json({
|
|
||||||
project: scopedStore.getRootDir(),
|
|
||||||
tasks: workspaceTasks,
|
|
||||||
});
|
|
||||||
} catch (err: unknown) {
|
|
||||||
if (err instanceof ApiError) {
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files
|
|
||||||
* List files in the requested workspace. Defaults to the project root when omitted.
|
|
||||||
* Query params: ?workspace=project|TASK-ID and ?path=relative/path for subdirectory navigation.
|
|
||||||
* Returns: { path: string; entries: FileNode[] }
|
|
||||||
*/
|
|
||||||
router.get("/files", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/markdown-list
|
|
||||||
* Recursively list markdown files in the project workspace.
|
|
||||||
* Returns: { files: MarkdownFileEntry[] }
|
|
||||||
*/
|
|
||||||
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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/search
|
|
||||||
* Search for files matching a query in a workspace.
|
|
||||||
* Query params: q (required, search query), workspace (default "project"), projectId (optional)
|
|
||||||
* Returns: { files: Array<{ path: string; name: string }> }
|
|
||||||
*/
|
|
||||||
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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/{*filepath}
|
|
||||||
* Read file contents from the requested workspace. Defaults to the project root when omitted.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Returns: { content: string; mtime: string; size: number }
|
|
||||||
*/
|
|
||||||
router.get("/files/{*filepath}", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── File Operation Routes ─────────────────────────────────────────────
|
|
||||||
// IMPORTANT: Operation routes must be defined BEFORE the generic write route.
|
|
||||||
// Express matches routes in order, so wildcard routes would shadow specific
|
|
||||||
// operation routes if defined first (e.g., POST /files/somefolder/delete
|
|
||||||
// would match POST /files/{*filepath} with filepath="somefolder/delete").
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper to extract filepath and workspace from request.
|
|
||||||
*/
|
|
||||||
function extractFileParams(req: Request): { filePath: string; workspace: string } {
|
|
||||||
const filePath = Array.isArray(req.params.filepath) ? req.params.filepath[0] : req.params.filepath ?? "";
|
|
||||||
const workspace = typeof req.query.workspace === "string" && req.query.workspace.length > 0
|
|
||||||
? req.query.workspace
|
|
||||||
: "project";
|
|
||||||
return { filePath, workspace };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/files/{*filepath}/copy
|
|
||||||
* Copy a file or directory to a new location within the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Body: { destination: string }
|
|
||||||
* Returns: FileOperationResponse
|
|
||||||
*/
|
|
||||||
router.post("/files/{*filepath}/copy", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/files/{*filepath}/move
|
|
||||||
* Move a file or directory to a new location within the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Body: { destination: string }
|
|
||||||
* Returns: FileOperationResponse
|
|
||||||
*/
|
|
||||||
router.post("/files/{*filepath}/move", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* DELETE /api/files/{*filepath}
|
|
||||||
* Note: This conflicts with the existing GET endpoint for files.
|
|
||||||
* Instead, use POST /api/files/{*filepath}/delete to avoid route collision.
|
|
||||||
* Delete a file or directory within the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Returns: FileOperationResponse
|
|
||||||
*/
|
|
||||||
router.post("/files/{*filepath}/delete", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/files/{*filepath}/rename
|
|
||||||
* Rename a file or directory within the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Body: { newName: string }
|
|
||||||
* Returns: FileOperationResponse
|
|
||||||
*/
|
|
||||||
router.post("/files/{*filepath}/rename", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/{*filepath}/download
|
|
||||||
* Download a single file from the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Streams the file with Content-Disposition header.
|
|
||||||
*/
|
|
||||||
router.get("/files/{*filepath}/download", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* GET /api/files/{*filepath}/download-zip
|
|
||||||
* Download a folder as a ZIP archive from the workspace.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Streams the ZIP archive with Content-Disposition header.
|
|
||||||
*/
|
|
||||||
router.get("/files/{*filepath}/download-zip", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// ── Generic File Write Route ────────────────────────────────────────────
|
|
||||||
// This route must be defined AFTER all operation routes to avoid shadowing.
|
|
||||||
// Express matches routes in order, so this wildcard route would catch
|
|
||||||
// /copy, /move, /delete, /rename, etc. if defined first.
|
|
||||||
|
|
||||||
/**
|
|
||||||
* POST /api/files/{*filepath}
|
|
||||||
* Write file contents to the requested workspace. Defaults to the project root when omitted.
|
|
||||||
* Query param: ?workspace=project|TASK-ID
|
|
||||||
* Body: { content: string }
|
|
||||||
* Returns: { success: true; mtime: string; size: number }
|
|
||||||
*/
|
|
||||||
router.post("/files/{*filepath}", async (req, res) => {
|
|
||||||
try {
|
|
||||||
const { 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 });
|
|
||||||
} else {
|
|
||||||
rethrowAsApiError(err, "Internal server error");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/ai/refine-text
|
* POST /api/ai/refine-text
|
||||||
* AI-powered text refinement for task descriptions.
|
* AI-powered text refinement for task descriptions.
|
||||||
|
|||||||
@@ -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.
|
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:
|
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-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-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-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-planning-subtask-routes.ts` — planning sessions and subtask breakdown routes
|
||||||
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
|
- `register-chat-routes.ts` — chat session/list/mutation/stream routes
|
||||||
- `register-messaging-scripts.ts` — scripts API and mailbox/message 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-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-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-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-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-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
|
- `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:
|
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)
|
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**
|
3. **Do not move proxy/script/message/file wildcards ahead of specific routes**
|
||||||
4. **Project/node/sync/discovery ordering constraints must stay intact**:
|
4. **Project/node/sync/discovery ordering constraints must stay intact**:
|
||||||
- `/projects/across-nodes` and `/projects/detect` must be registered before `/projects/:id`
|
- `/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 { access } from "node:fs/promises";
|
||||||
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
|
import type { TaskStore, Task, TaskDetail, Column } from "@fusion/core";
|
||||||
import { COLUMNS, VALID_TRANSITIONS } 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 { ApiError, badRequest, notFound } from "../api-error.js";
|
||||||
import type { ApiRoutesContext } from "./types.js";
|
import type { ApiRoutesContext } from "./types.js";
|
||||||
|
|
||||||
@@ -15,8 +14,6 @@ interface TaskWorkflowRouteDeps {
|
|||||||
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
|
runGitCommand: (args: string[], cwd: string, timeoutMs: number) => Promise<string>;
|
||||||
resolveDiffBase: (task: Task, cwd: string) => Promise<string | undefined>;
|
resolveDiffBase: (task: Task, cwd: string) => Promise<string | undefined>;
|
||||||
trimTaskDetailActivityLog: (task: TaskDetail) => TaskDetail;
|
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>;
|
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,
|
runGitCommand,
|
||||||
resolveDiffBase,
|
resolveDiffBase,
|
||||||
trimTaskDetailActivityLog,
|
trimTaskDetailActivityLog,
|
||||||
sessionFilesCache,
|
|
||||||
fileDiffsCache,
|
|
||||||
triggerCommentWakeForAssignedAgent,
|
triggerCommentWakeForAssignedAgent,
|
||||||
} = deps;
|
} = deps;
|
||||||
const TASK_DETAIL_ACTIVITY_LOG_LIMIT = taskDetailActivityLogLimit;
|
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 /api/tasks/:id/workflow-results
|
||||||
* Get workflow step execution results for a task.
|
* 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
|
// Add steering comment to task
|
||||||
router.post("/tasks/:id/steer", async (req, res) => {
|
router.post("/tasks/:id/steer", async (req, res) => {
|
||||||
try {
|
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
|
* GET /api/tasks/:id/diff
|
||||||
* Fetch git diff for a task's changes.
|
* 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