From 2e201cab098ba6fee9084b04ab3018ad693a7640 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sun, 1 Mar 2026 16:32:02 +0700 Subject: [PATCH] feat(server): workspace-scoped explorer + worktree createdAt --- packages/server/src/client/daemon-client.ts | 8 +- .../server/src/server/agent/agent-storage.ts | 118 ++++++------------ packages/server/src/server/bootstrap.ts | 1 + .../src/server/daemon-client.e2e.test.ts | 14 +-- .../daemon-e2e/file-download.e2e.test.ts | 8 +- .../server/daemon-e2e/filesystem.e2e.test.ts | 7 +- .../src/server/file-download/token-store.ts | 1 - .../src/server/file-explorer/service.ts | 2 +- packages/server/src/server/session.ts | 110 ++++++++-------- packages/server/src/shared/messages.ts | 9 +- packages/server/src/utils/worktree.ts | 29 ++++- 11 files changed, 140 insertions(+), 167 deletions(-) diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index f87852a42..f33a8c6f7 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -2166,7 +2166,7 @@ export class DaemonClient { // ============================================================================ async exploreFileSystem( - agentId: string, + cwd: string, path: string, mode: 'list' | 'file' = 'list', requestId?: string @@ -2175,7 +2175,7 @@ export class DaemonClient { requestId, message: { type: 'file_explorer_request', - agentId, + cwd, path, mode, }, @@ -2185,7 +2185,7 @@ export class DaemonClient { } async requestDownloadToken( - agentId: string, + cwd: string, path: string, requestId?: string ): Promise { @@ -2193,7 +2193,7 @@ export class DaemonClient { requestId, message: { type: 'file_download_token_request', - agentId, + cwd, path, }, responseType: 'file_download_token_response', diff --git a/packages/server/src/server/agent/agent-storage.ts b/packages/server/src/server/agent/agent-storage.ts index 0f7b78dbd..0d2bfd28e 100644 --- a/packages/server/src/server/agent/agent-storage.ts +++ b/packages/server/src/server/agent/agent-storage.ts @@ -79,6 +79,7 @@ export type StoredAgentRecord = z.infer; export class AgentStorage { private cache: Map = new Map(); private pathById: Map = new Map(); + private pathsById: Map> = new Map(); private pendingWrites: Map> = new Map(); private deleting: Set = new Set(); private loaded = false; @@ -91,6 +92,10 @@ export class AgentStorage { this.logger = logger.child({ module: "agent", component: "agent-storage" }); } + async initialize(): Promise { + await this.load(); + } + async list(): Promise { await this.load(); return Array.from(this.cache.values()); @@ -98,24 +103,7 @@ export class AgentStorage { async get(agentId: string): Promise { await this.load(); - const cached = this.cache.get(agentId); - if (cached) { - return cached; - } - - const discoveredPath = await this.findAgentPathById(agentId); - if (!discoveredPath) { - return null; - } - - const record = await this.readRecordFile(discoveredPath); - if (!record) { - return null; - } - - this.cache.set(record.id, record); - this.pathById.set(record.id, discoveredPath); - return record; + return this.cache.get(agentId) ?? null; } async upsert(record: StoredAgentRecord): Promise { @@ -132,6 +120,7 @@ export class AgentStorage { await fs.mkdir(path.dirname(nextPath), { recursive: true }); await writeFileAtomically(nextPath, JSON.stringify(record, null, 2)); + this.addIndexedPath(agentId, nextPath); if (previousPath && previousPath !== nextPath) { try { @@ -139,6 +128,7 @@ export class AgentStorage { } catch { // ignore cleanup errors } + this.removeIndexedPath(agentId, previousPath); } this.cache.set(agentId, record); @@ -165,47 +155,8 @@ export class AgentStorage { await this.load(); this.beginDelete(agentId); await (this.pendingWrites.get(agentId) ?? Promise.resolve()); - const candidates = new Set(); - - const existingPath = - this.pathById.get(agentId) ?? (await this.findAgentPathById(agentId)); - if (existingPath) { - candidates.add(existingPath); - } - - // Remove any stray duplicate record files across project directories. - // This can happen across storage layout migrations or when a record path changes. - try { - const projects = await fs.readdir(this.baseDir, { withFileTypes: true }); - for (const project of projects) { - if (!project.isDirectory()) { - continue; - } - const projectDir = path.join(this.baseDir, project.name); - candidates.add(path.join(projectDir, `${agentId}.json`)); - - // Support one more nesting layer (e.g. provider/version subfolders). - let entries: Array = []; - try { - entries = await fs.readdir(projectDir, { withFileTypes: true }); - } catch { - continue; - } - for (const entry of entries) { - if (!entry.isDirectory()) { - continue; - } - candidates.add(path.join(projectDir, entry.name, `${agentId}.json`)); - } - } - } catch { - // ignore scan errors - } - - // Support legacy flat layouts: baseDir/.json - candidates.add(path.join(this.baseDir, `${agentId}.json`)); - - for (const filePath of candidates) { + const paths = Array.from(this.pathsById.get(agentId) ?? []); + for (const filePath of paths) { try { await fs.unlink(filePath); } catch (error) { @@ -221,6 +172,7 @@ export class AgentStorage { this.cache.delete(agentId); this.pathById.delete(agentId); + this.pathsById.delete(agentId); } async applySnapshot( @@ -282,6 +234,7 @@ export class AgentStorage { private async doLoad(): Promise { this.cache.clear(); this.pathById.clear(); + this.pathsById.clear(); try { const records = await this.scanDisk(); @@ -311,6 +264,19 @@ export class AgentStorage { } for (const entry of entries) { + if (entry.isFile() && entry.name.endsWith(".json")) { + const rootPath = path.join(this.baseDir, entry.name); + const rootRecord = await this.readRecordFile(rootPath); + if (!rootRecord) { + continue; + } + records.push(rootRecord); + this.cache.set(rootRecord.id, rootRecord); + this.pathById.set(rootRecord.id, rootPath); + this.addIndexedPath(rootRecord.id, rootPath); + continue; + } + if (!entry.isDirectory()) { continue; } @@ -334,6 +300,7 @@ export class AgentStorage { records.push(record); this.cache.set(record.id, record); this.pathById.set(record.id, filePath); + this.addIndexedPath(record.id, filePath); } } @@ -356,28 +323,21 @@ export class AgentStorage { return path.join(this.baseDir, projectDir, `${record.id}.json`); } - private async findAgentPathById(agentId: string): Promise { - let projects: Array = []; - try { - projects = await fs.readdir(this.baseDir, { withFileTypes: true }); - } catch { - return null; - } + private addIndexedPath(agentId: string, filePath: string): void { + const paths = this.pathsById.get(agentId) ?? new Set(); + paths.add(filePath); + this.pathsById.set(agentId, paths); + } - for (const project of projects) { - if (!project.isDirectory()) { - continue; - } - const candidate = path.join(this.baseDir, project.name, `${agentId}.json`); - try { - await fs.access(candidate); - return candidate; - } catch { - // not here - } + private removeIndexedPath(agentId: string, filePath: string): void { + const paths = this.pathsById.get(agentId); + if (!paths) { + return; + } + paths.delete(filePath); + if (paths.size === 0) { + this.pathsById.delete(agentId); } - - return null; } private async waitForPendingWrite(agentId: string): Promise { diff --git a/packages/server/src/server/bootstrap.ts b/packages/server/src/server/bootstrap.ts index 4ef8577a1..aef64544f 100644 --- a/packages/server/src/server/bootstrap.ts +++ b/packages/server/src/server/bootstrap.ts @@ -307,6 +307,7 @@ export async function createPaseoDaemon( agentManager, agentStorage ); + await agentStorage.initialize(); const persistedRecords = await agentStorage.list(); logger.info( `Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand` diff --git a/packages/server/src/server/daemon-client.e2e.test.ts b/packages/server/src/server/daemon-client.e2e.test.ts index ebdea38ac..a114f4327 100644 --- a/packages/server/src/server/daemon-client.e2e.test.ts +++ b/packages/server/src/server/daemon-client.e2e.test.ts @@ -1163,7 +1163,7 @@ describe("daemon client E2E", () => { if (message.type !== "file_explorer_response") { return; } - if (message.payload.agentId !== agent.id) { + if (message.payload.cwd !== cwd) { return; } if (message.payload.mode !== "list") { @@ -1179,7 +1179,7 @@ describe("daemon client E2E", () => { }); const listResult = await ctx.client.exploreFileSystem( - agent.id, + cwd, ".", "list", listRequestId @@ -1199,7 +1199,7 @@ describe("daemon client E2E", () => { if (message.type !== "file_explorer_response") { return; } - if (message.payload.agentId !== agent.id) { + if (message.payload.cwd !== cwd) { return; } if (message.payload.mode !== "file") { @@ -1215,7 +1215,7 @@ describe("daemon client E2E", () => { }); const fileResult = await ctx.client.exploreFileSystem( - agent.id, + cwd, "download.txt", "file", fileRequestId @@ -1235,7 +1235,7 @@ describe("daemon client E2E", () => { if (message.type !== "file_download_token_response") { return; } - if (message.payload.agentId !== agent.id) { + if (message.payload.cwd !== cwd) { return; } if (!message.payload.path.endsWith("download.txt")) { @@ -1251,7 +1251,7 @@ describe("daemon client E2E", () => { }); const tokenResponse = await ctx.client.requestDownloadToken( - agent.id, + cwd, "download.txt", tokenRequestId ); @@ -1259,7 +1259,7 @@ describe("daemon client E2E", () => { expect(tokenResponse.error).toBeNull(); expect(tokenResponse.token).toBeTruthy(); expect(tokenResponse.requestId).toBe(tokenRequestId); - expect(tokenMessage.payload.agentId).toBe(agent.id); + expect(tokenMessage.payload.cwd).toBe(cwd); expect(tokenMessage.payload.requestId).toBe(tokenRequestId); const response = await fetch( diff --git a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts index e1425966a..7db5dbad2 100644 --- a/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/file-download.e2e.test.ts @@ -46,7 +46,7 @@ describe("daemon E2E", () => { expect(agent.id).toBeTruthy(); const tokenResponse = await ctx.client.requestDownloadToken( - agent.id, + cwd, "download.txt" ); @@ -102,7 +102,7 @@ describe("daemon E2E", () => { }); const tokenResponse = await ctx.client.requestDownloadToken( - agent.id, + cwd, "expired.txt" ); @@ -123,7 +123,7 @@ describe("daemon E2E", () => { ); test( - "rejects paths outside the agent cwd", + "rejects paths outside the workspace cwd", async () => { const cwd = tmpCwd(); const agent = await ctx.client.createAgent({ @@ -133,7 +133,7 @@ describe("daemon E2E", () => { }); const tokenResponse = await ctx.client.requestDownloadToken( - agent.id, + cwd, "../outside.txt" ); diff --git a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts index 7a8183fff..bb8636657 100644 --- a/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/filesystem.e2e.test.ts @@ -51,7 +51,7 @@ describe("daemon E2E", () => { expect(agent.status).toBe("idle"); // List directory contents - const result = await ctx.client.exploreFileSystem(agent.id, cwd, "list"); + const result = await ctx.client.exploreFileSystem(cwd, ".", "list"); // Verify listing returned without error expect(result.error).toBeNull(); @@ -100,7 +100,7 @@ describe("daemon E2E", () => { expect(agent.id).toBeTruthy(); // Read file contents - const result = await ctx.client.exploreFileSystem(agent.id, testFile, "file"); + const result = await ctx.client.exploreFileSystem(cwd, "readme.txt", "file"); // Verify file read expect(result.error).toBeNull(); @@ -134,8 +134,7 @@ describe("daemon E2E", () => { expect(agent.id).toBeTruthy(); // Try to list non-existent path - const nonExistent = path.join(cwd, "does-not-exist"); - const result = await ctx.client.exploreFileSystem(agent.id, nonExistent, "list"); + const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list"); // Should return error expect(result.error).toBeTruthy(); diff --git a/packages/server/src/server/file-download/token-store.ts b/packages/server/src/server/file-download/token-store.ts index 76e9fabd1..da0fb6c45 100644 --- a/packages/server/src/server/file-download/token-store.ts +++ b/packages/server/src/server/file-download/token-store.ts @@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto"; export type DownloadTokenEntry = { token: string; - agentId: string; path: string; absolutePath: string; fileName: string; diff --git a/packages/server/src/server/file-explorer/service.ts b/packages/server/src/server/file-explorer/service.ts index 9d40c7ff3..6852448ab 100644 --- a/packages/server/src/server/file-explorer/service.ts +++ b/packages/server/src/server/file-explorer/service.ts @@ -234,7 +234,7 @@ async function resolveScopedPath({ return requestedPath; } - throw new Error("Access outside of agent workspace is not allowed"); + throw new Error("Access outside of workspace is not allowed"); } async function buildEntryPayload({ diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 13e28b32b..07e2fc243 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -4380,6 +4380,7 @@ export class Session { payload: { worktrees: worktrees.map((entry) => ({ worktreePath: entry.path, + createdAt: entry.createdAt, branchName: entry.branchName ?? null, head: entry.head ?? null, })), @@ -4604,41 +4605,38 @@ export class Session { } /** - * Handle read-only file explorer requests scoped to an agent's cwd + * Handle read-only file explorer requests scoped to a workspace cwd */ private async handleFileExplorerRequest(request: FileExplorerRequest): Promise { - const { agentId, path: requestedPath = '.', mode, requestId } = request + const { cwd: workspaceCwd, path: requestedPath = '.', mode, requestId } = request + const cwd = workspaceCwd.trim() + if (!cwd) { + this.emit({ + type: 'file_explorer_response', + payload: { + cwd: workspaceCwd, + path: requestedPath, + mode, + directory: null, + file: null, + error: 'cwd is required', + requestId, + }, + }) + return + } try { - const agents = this.agentManager.listAgents() - const agent = agents.find((a) => a.id === agentId) - - if (!agent) { - this.emit({ - type: 'file_explorer_response', - payload: { - agentId, - path: requestedPath, - mode, - directory: null, - file: null, - error: `Agent not found: ${agentId}`, - requestId, - }, - }) - return - } - if (mode === 'list') { const directory = await listDirectoryEntries({ - root: agent.cwd, + root: cwd, relativePath: requestedPath, }) this.emit({ type: 'file_explorer_response', payload: { - agentId, + cwd, path: directory.path, mode, directory, @@ -4649,14 +4647,14 @@ export class Session { }) } else { const file = await readExplorerFile({ - root: agent.cwd, + root: cwd, relativePath: requestedPath, }) this.emit({ type: 'file_explorer_response', payload: { - agentId, + cwd, path: file.path, mode, directory: null, @@ -4668,13 +4666,13 @@ export class Session { } } catch (error: any) { this.sessionLogger.error( - { err: error, agentId, path: requestedPath }, - `Failed to fulfill file explorer request for agent ${agentId}` + { err: error, cwd, path: requestedPath }, + `Failed to fulfill file explorer request for workspace ${cwd}` ) this.emit({ type: 'file_explorer_response', payload: { - agentId, + cwd, path: requestedPath, mode, directory: null, @@ -4719,44 +4717,40 @@ export class Session { } /** - * Handle file download token request scoped to an agent's cwd + * Handle file download token request scoped to a workspace cwd */ private async handleFileDownloadTokenRequest(request: FileDownloadTokenRequest): Promise { - const { agentId, path: requestedPath, requestId } = request + const { cwd: workspaceCwd, path: requestedPath, requestId } = request + const cwd = workspaceCwd.trim() + if (!cwd) { + this.emit({ + type: 'file_download_token_response', + payload: { + cwd: workspaceCwd, + path: requestedPath, + token: null, + fileName: null, + mimeType: null, + size: null, + error: 'cwd is required', + requestId, + }, + }) + return + } this.sessionLogger.debug( - { agentId, path: requestedPath }, - `Handling file download token request for agent ${agentId} (${requestedPath})` + { cwd, path: requestedPath }, + `Handling file download token request for workspace ${cwd} (${requestedPath})` ) try { - const agents = this.agentManager.listAgents() - const agent = agents.find((a) => a.id === agentId) - - if (!agent) { - this.emit({ - type: 'file_download_token_response', - payload: { - agentId, - path: requestedPath, - token: null, - fileName: null, - mimeType: null, - size: null, - error: `Agent not found: ${agentId}`, - requestId, - }, - }) - return - } - const info = await getDownloadableFileInfo({ - root: agent.cwd, + root: cwd, relativePath: requestedPath, }) const entry = this.downloadTokenStore.issueToken({ - agentId, path: info.path, absolutePath: info.absolutePath, fileName: info.fileName, @@ -4767,7 +4761,7 @@ export class Session { this.emit({ type: 'file_download_token_response', payload: { - agentId, + cwd, path: info.path, token: entry.token, fileName: entry.fileName, @@ -4779,13 +4773,13 @@ export class Session { }) } catch (error: any) { this.sessionLogger.error( - { err: error, agentId, path: requestedPath }, - `Failed to issue download token for agent ${agentId}` + { err: error, cwd, path: requestedPath }, + `Failed to issue download token for workspace ${cwd}` ) this.emit({ type: 'file_download_token_response', payload: { - agentId, + cwd, path: requestedPath, token: null, fileName: null, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 9c2df3ebb..f48f1ab66 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -927,7 +927,7 @@ const FileExplorerDirectorySchema = z.object({ export const FileExplorerRequestSchema = z.object({ type: z.literal('file_explorer_request'), - agentId: z.string(), + cwd: z.string(), path: z.string().optional(), mode: z.enum(['list', 'file']), requestId: z.string(), @@ -941,7 +941,7 @@ export const ProjectIconRequestSchema = z.object({ export const FileDownloadTokenRequestSchema = z.object({ type: z.literal('file_download_token_request'), - agentId: z.string(), + cwd: z.string(), path: z.string(), requestId: z.string(), }) @@ -1786,6 +1786,7 @@ export const DirectorySuggestionsResponseSchema = z.object({ const PaseoWorktreeSchema = z.object({ worktreePath: z.string(), + createdAt: z.string(), branchName: z.string().nullable().optional(), head: z.string().nullable().optional(), }) @@ -1812,7 +1813,7 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({ export const FileExplorerResponseSchema = z.object({ type: z.literal('file_explorer_response'), payload: z.object({ - agentId: z.string(), + cwd: z.string(), path: z.string(), mode: z.enum(['list', 'file']), directory: FileExplorerDirectorySchema.nullable(), @@ -1840,7 +1841,7 @@ export const ProjectIconResponseSchema = z.object({ export const FileDownloadTokenResponseSchema = z.object({ type: z.literal('file_download_token_response'), payload: z.object({ - agentId: z.string(), + cwd: z.string(), path: z.string(), token: z.string().nullable(), fileName: z.string().nullable(), diff --git a/packages/server/src/utils/worktree.ts b/packages/server/src/utils/worktree.ts index 856b2ba35..7e59aaa3f 100644 --- a/packages/server/src/utils/worktree.ts +++ b/packages/server/src/utils/worktree.ts @@ -1,6 +1,6 @@ import { exec, spawn } from "child_process"; import { promisify } from "util"; -import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync, statSync } from "fs"; import { join, basename, dirname, resolve, sep } from "path"; import net from "node:net"; import { createHash } from "node:crypto"; @@ -108,6 +108,7 @@ export class WorktreeDestroyError extends Error { export interface PaseoWorktreeInfo { path: string; + createdAt: string; branchName?: string; head?: string; } @@ -728,9 +729,11 @@ export async function isPaseoOwnedWorktreeCwd( }; } -function parseWorktreeList(output: string): PaseoWorktreeInfo[] { - const entries: PaseoWorktreeInfo[] = []; - let current: PaseoWorktreeInfo | null = null; +type ParsedPaseoWorktreeInfo = Omit; + +function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] { + const entries: ParsedPaseoWorktreeInfo[] = []; + let current: ParsedPaseoWorktreeInfo | null = null; for (const line of output.split("\n")) { if (line.startsWith("worktree ")) { @@ -767,6 +770,18 @@ function parseWorktreeList(output: string): PaseoWorktreeInfo[] { return entries; } +function resolveWorktreeCreatedAtIso(worktreePath: string): string { + try { + const stats = statSync(worktreePath); + const birthtimeMs = stats.birthtimeMs; + const createdAtMs = + Number.isFinite(birthtimeMs) && birthtimeMs > 0 ? birthtimeMs : stats.ctimeMs; + return new Date(createdAtMs).toISOString(); + } catch { + return new Date(0).toISOString(); + } +} + export async function listPaseoWorktrees({ cwd, paseoHome, @@ -783,7 +798,11 @@ export async function listPaseoWorktrees({ const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep; return parseWorktreeList(stdout) .map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) })) - .filter((entry) => entry.path.startsWith(rootPrefix)); + .filter((entry) => entry.path.startsWith(rootPrefix)) + .map((entry) => ({ + ...entry, + createdAt: resolveWorktreeCreatedAtIso(entry.path), + })); } export async function resolvePaseoWorktreeRootForCwd(