feat(server): workspace-scoped explorer + worktree createdAt

This commit is contained in:
Mohamed Boudra
2026-03-01 16:32:02 +07:00
parent ec5b416b8a
commit 2e201cab09
11 changed files with 140 additions and 167 deletions
+4 -4
View File
@@ -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<FileDownloadTokenPayload> {
@@ -2193,7 +2193,7 @@ export class DaemonClient {
requestId,
message: {
type: 'file_download_token_request',
agentId,
cwd,
path,
},
responseType: 'file_download_token_response',
@@ -79,6 +79,7 @@ export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
export class AgentStorage {
private cache: Map<string, StoredAgentRecord> = new Map();
private pathById: Map<string, string> = new Map();
private pathsById: Map<string, Set<string>> = new Map();
private pendingWrites: Map<string, Promise<void>> = new Map();
private deleting: Set<string> = new Set();
private loaded = false;
@@ -91,6 +92,10 @@ export class AgentStorage {
this.logger = logger.child({ module: "agent", component: "agent-storage" });
}
async initialize(): Promise<void> {
await this.load();
}
async list(): Promise<StoredAgentRecord[]> {
await this.load();
return Array.from(this.cache.values());
@@ -98,24 +103,7 @@ export class AgentStorage {
async get(agentId: string): Promise<StoredAgentRecord | null> {
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<void> {
@@ -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<string>();
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<import("node:fs").Dirent> = [];
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/<agentId>.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<StoredAgentRecord[]> {
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<string | null> {
let projects: Array<import("node:fs").Dirent> = [];
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<string>();
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<void> {
+1
View File
@@ -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`
@@ -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(
@@ -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"
);
@@ -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();
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
export type DownloadTokenEntry = {
token: string;
agentId: string;
path: string;
absolutePath: string;
fileName: string;
@@ -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({
+52 -58
View File
@@ -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<void> {
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<void> {
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,
+5 -4
View File
@@ -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(),
+24 -5
View File
@@ -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<PaseoWorktreeInfo, "createdAt">;
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(