feat(server): workspace-scoped explorer + worktree createdAt
This commit is contained in:
@@ -2166,7 +2166,7 @@ export class DaemonClient {
|
|||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
async exploreFileSystem(
|
async exploreFileSystem(
|
||||||
agentId: string,
|
cwd: string,
|
||||||
path: string,
|
path: string,
|
||||||
mode: 'list' | 'file' = 'list',
|
mode: 'list' | 'file' = 'list',
|
||||||
requestId?: string
|
requestId?: string
|
||||||
@@ -2175,7 +2175,7 @@ export class DaemonClient {
|
|||||||
requestId,
|
requestId,
|
||||||
message: {
|
message: {
|
||||||
type: 'file_explorer_request',
|
type: 'file_explorer_request',
|
||||||
agentId,
|
cwd,
|
||||||
path,
|
path,
|
||||||
mode,
|
mode,
|
||||||
},
|
},
|
||||||
@@ -2185,7 +2185,7 @@ export class DaemonClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async requestDownloadToken(
|
async requestDownloadToken(
|
||||||
agentId: string,
|
cwd: string,
|
||||||
path: string,
|
path: string,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
): Promise<FileDownloadTokenPayload> {
|
): Promise<FileDownloadTokenPayload> {
|
||||||
@@ -2193,7 +2193,7 @@ export class DaemonClient {
|
|||||||
requestId,
|
requestId,
|
||||||
message: {
|
message: {
|
||||||
type: 'file_download_token_request',
|
type: 'file_download_token_request',
|
||||||
agentId,
|
cwd,
|
||||||
path,
|
path,
|
||||||
},
|
},
|
||||||
responseType: 'file_download_token_response',
|
responseType: 'file_download_token_response',
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ export type StoredAgentRecord = z.infer<typeof STORED_AGENT_SCHEMA>;
|
|||||||
export class AgentStorage {
|
export class AgentStorage {
|
||||||
private cache: Map<string, StoredAgentRecord> = new Map();
|
private cache: Map<string, StoredAgentRecord> = new Map();
|
||||||
private pathById: Map<string, string> = 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 pendingWrites: Map<string, Promise<void>> = new Map();
|
||||||
private deleting: Set<string> = new Set();
|
private deleting: Set<string> = new Set();
|
||||||
private loaded = false;
|
private loaded = false;
|
||||||
@@ -91,6 +92,10 @@ export class AgentStorage {
|
|||||||
this.logger = logger.child({ module: "agent", component: "agent-storage" });
|
this.logger = logger.child({ module: "agent", component: "agent-storage" });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async initialize(): Promise<void> {
|
||||||
|
await this.load();
|
||||||
|
}
|
||||||
|
|
||||||
async list(): Promise<StoredAgentRecord[]> {
|
async list(): Promise<StoredAgentRecord[]> {
|
||||||
await this.load();
|
await this.load();
|
||||||
return Array.from(this.cache.values());
|
return Array.from(this.cache.values());
|
||||||
@@ -98,24 +103,7 @@ export class AgentStorage {
|
|||||||
|
|
||||||
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
async get(agentId: string): Promise<StoredAgentRecord | null> {
|
||||||
await this.load();
|
await this.load();
|
||||||
const cached = this.cache.get(agentId);
|
return this.cache.get(agentId) ?? null;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsert(record: StoredAgentRecord): Promise<void> {
|
async upsert(record: StoredAgentRecord): Promise<void> {
|
||||||
@@ -132,6 +120,7 @@ export class AgentStorage {
|
|||||||
|
|
||||||
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
await fs.mkdir(path.dirname(nextPath), { recursive: true });
|
||||||
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
await writeFileAtomically(nextPath, JSON.stringify(record, null, 2));
|
||||||
|
this.addIndexedPath(agentId, nextPath);
|
||||||
|
|
||||||
if (previousPath && previousPath !== nextPath) {
|
if (previousPath && previousPath !== nextPath) {
|
||||||
try {
|
try {
|
||||||
@@ -139,6 +128,7 @@ export class AgentStorage {
|
|||||||
} catch {
|
} catch {
|
||||||
// ignore cleanup errors
|
// ignore cleanup errors
|
||||||
}
|
}
|
||||||
|
this.removeIndexedPath(agentId, previousPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.cache.set(agentId, record);
|
this.cache.set(agentId, record);
|
||||||
@@ -165,47 +155,8 @@ export class AgentStorage {
|
|||||||
await this.load();
|
await this.load();
|
||||||
this.beginDelete(agentId);
|
this.beginDelete(agentId);
|
||||||
await (this.pendingWrites.get(agentId) ?? Promise.resolve());
|
await (this.pendingWrites.get(agentId) ?? Promise.resolve());
|
||||||
const candidates = new Set<string>();
|
const paths = Array.from(this.pathsById.get(agentId) ?? []);
|
||||||
|
for (const filePath of paths) {
|
||||||
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) {
|
|
||||||
try {
|
try {
|
||||||
await fs.unlink(filePath);
|
await fs.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -221,6 +172,7 @@ export class AgentStorage {
|
|||||||
|
|
||||||
this.cache.delete(agentId);
|
this.cache.delete(agentId);
|
||||||
this.pathById.delete(agentId);
|
this.pathById.delete(agentId);
|
||||||
|
this.pathsById.delete(agentId);
|
||||||
}
|
}
|
||||||
|
|
||||||
async applySnapshot(
|
async applySnapshot(
|
||||||
@@ -282,6 +234,7 @@ export class AgentStorage {
|
|||||||
private async doLoad(): Promise<StoredAgentRecord[]> {
|
private async doLoad(): Promise<StoredAgentRecord[]> {
|
||||||
this.cache.clear();
|
this.cache.clear();
|
||||||
this.pathById.clear();
|
this.pathById.clear();
|
||||||
|
this.pathsById.clear();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const records = await this.scanDisk();
|
const records = await this.scanDisk();
|
||||||
@@ -311,6 +264,19 @@ export class AgentStorage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for (const entry of entries) {
|
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()) {
|
if (!entry.isDirectory()) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
@@ -334,6 +300,7 @@ export class AgentStorage {
|
|||||||
records.push(record);
|
records.push(record);
|
||||||
this.cache.set(record.id, record);
|
this.cache.set(record.id, record);
|
||||||
this.pathById.set(record.id, filePath);
|
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`);
|
return path.join(this.baseDir, projectDir, `${record.id}.json`);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findAgentPathById(agentId: string): Promise<string | null> {
|
private addIndexedPath(agentId: string, filePath: string): void {
|
||||||
let projects: Array<import("node:fs").Dirent> = [];
|
const paths = this.pathsById.get(agentId) ?? new Set<string>();
|
||||||
try {
|
paths.add(filePath);
|
||||||
projects = await fs.readdir(this.baseDir, { withFileTypes: true });
|
this.pathsById.set(agentId, paths);
|
||||||
} catch {
|
}
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const project of projects) {
|
private removeIndexedPath(agentId: string, filePath: string): void {
|
||||||
if (!project.isDirectory()) {
|
const paths = this.pathsById.get(agentId);
|
||||||
continue;
|
if (!paths) {
|
||||||
}
|
return;
|
||||||
const candidate = path.join(this.baseDir, project.name, `${agentId}.json`);
|
}
|
||||||
try {
|
paths.delete(filePath);
|
||||||
await fs.access(candidate);
|
if (paths.size === 0) {
|
||||||
return candidate;
|
this.pathsById.delete(agentId);
|
||||||
} catch {
|
|
||||||
// not here
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async waitForPendingWrite(agentId: string): Promise<void> {
|
private async waitForPendingWrite(agentId: string): Promise<void> {
|
||||||
|
|||||||
@@ -307,6 +307,7 @@ export async function createPaseoDaemon(
|
|||||||
agentManager,
|
agentManager,
|
||||||
agentStorage
|
agentStorage
|
||||||
);
|
);
|
||||||
|
await agentStorage.initialize();
|
||||||
const persistedRecords = await agentStorage.list();
|
const persistedRecords = await agentStorage.list();
|
||||||
logger.info(
|
logger.info(
|
||||||
`Agent registry loaded (${persistedRecords.length} record${persistedRecords.length === 1 ? "" : "s"}); agents will initialize on demand`
|
`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") {
|
if (message.type !== "file_explorer_response") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.agentId !== agent.id) {
|
if (message.payload.cwd !== cwd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.mode !== "list") {
|
if (message.payload.mode !== "list") {
|
||||||
@@ -1179,7 +1179,7 @@ describe("daemon client E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const listResult = await ctx.client.exploreFileSystem(
|
const listResult = await ctx.client.exploreFileSystem(
|
||||||
agent.id,
|
cwd,
|
||||||
".",
|
".",
|
||||||
"list",
|
"list",
|
||||||
listRequestId
|
listRequestId
|
||||||
@@ -1199,7 +1199,7 @@ describe("daemon client E2E", () => {
|
|||||||
if (message.type !== "file_explorer_response") {
|
if (message.type !== "file_explorer_response") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.agentId !== agent.id) {
|
if (message.payload.cwd !== cwd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.mode !== "file") {
|
if (message.payload.mode !== "file") {
|
||||||
@@ -1215,7 +1215,7 @@ describe("daemon client E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const fileResult = await ctx.client.exploreFileSystem(
|
const fileResult = await ctx.client.exploreFileSystem(
|
||||||
agent.id,
|
cwd,
|
||||||
"download.txt",
|
"download.txt",
|
||||||
"file",
|
"file",
|
||||||
fileRequestId
|
fileRequestId
|
||||||
@@ -1235,7 +1235,7 @@ describe("daemon client E2E", () => {
|
|||||||
if (message.type !== "file_download_token_response") {
|
if (message.type !== "file_download_token_response") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.payload.agentId !== agent.id) {
|
if (message.payload.cwd !== cwd) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!message.payload.path.endsWith("download.txt")) {
|
if (!message.payload.path.endsWith("download.txt")) {
|
||||||
@@ -1251,7 +1251,7 @@ describe("daemon client E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
agent.id,
|
cwd,
|
||||||
"download.txt",
|
"download.txt",
|
||||||
tokenRequestId
|
tokenRequestId
|
||||||
);
|
);
|
||||||
@@ -1259,7 +1259,7 @@ describe("daemon client E2E", () => {
|
|||||||
expect(tokenResponse.error).toBeNull();
|
expect(tokenResponse.error).toBeNull();
|
||||||
expect(tokenResponse.token).toBeTruthy();
|
expect(tokenResponse.token).toBeTruthy();
|
||||||
expect(tokenResponse.requestId).toBe(tokenRequestId);
|
expect(tokenResponse.requestId).toBe(tokenRequestId);
|
||||||
expect(tokenMessage.payload.agentId).toBe(agent.id);
|
expect(tokenMessage.payload.cwd).toBe(cwd);
|
||||||
expect(tokenMessage.payload.requestId).toBe(tokenRequestId);
|
expect(tokenMessage.payload.requestId).toBe(tokenRequestId);
|
||||||
|
|
||||||
const response = await fetch(
|
const response = await fetch(
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ describe("daemon E2E", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
agent.id,
|
cwd,
|
||||||
"download.txt"
|
"download.txt"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -102,7 +102,7 @@ describe("daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
agent.id,
|
cwd,
|
||||||
"expired.txt"
|
"expired.txt"
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -123,7 +123,7 @@ describe("daemon E2E", () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
test(
|
test(
|
||||||
"rejects paths outside the agent cwd",
|
"rejects paths outside the workspace cwd",
|
||||||
async () => {
|
async () => {
|
||||||
const cwd = tmpCwd();
|
const cwd = tmpCwd();
|
||||||
const agent = await ctx.client.createAgent({
|
const agent = await ctx.client.createAgent({
|
||||||
@@ -133,7 +133,7 @@ describe("daemon E2E", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const tokenResponse = await ctx.client.requestDownloadToken(
|
const tokenResponse = await ctx.client.requestDownloadToken(
|
||||||
agent.id,
|
cwd,
|
||||||
"../outside.txt"
|
"../outside.txt"
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ describe("daemon E2E", () => {
|
|||||||
expect(agent.status).toBe("idle");
|
expect(agent.status).toBe("idle");
|
||||||
|
|
||||||
// List directory contents
|
// 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
|
// Verify listing returned without error
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
@@ -100,7 +100,7 @@ describe("daemon E2E", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Read file contents
|
// 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
|
// Verify file read
|
||||||
expect(result.error).toBeNull();
|
expect(result.error).toBeNull();
|
||||||
@@ -134,8 +134,7 @@ describe("daemon E2E", () => {
|
|||||||
expect(agent.id).toBeTruthy();
|
expect(agent.id).toBeTruthy();
|
||||||
|
|
||||||
// Try to list non-existent path
|
// Try to list non-existent path
|
||||||
const nonExistent = path.join(cwd, "does-not-exist");
|
const result = await ctx.client.exploreFileSystem(cwd, "does-not-exist", "list");
|
||||||
const result = await ctx.client.exploreFileSystem(agent.id, nonExistent, "list");
|
|
||||||
|
|
||||||
// Should return error
|
// Should return error
|
||||||
expect(result.error).toBeTruthy();
|
expect(result.error).toBeTruthy();
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { randomUUID } from "node:crypto";
|
|||||||
|
|
||||||
export type DownloadTokenEntry = {
|
export type DownloadTokenEntry = {
|
||||||
token: string;
|
token: string;
|
||||||
agentId: string;
|
|
||||||
path: string;
|
path: string;
|
||||||
absolutePath: string;
|
absolutePath: string;
|
||||||
fileName: string;
|
fileName: string;
|
||||||
|
|||||||
@@ -234,7 +234,7 @@ async function resolveScopedPath({
|
|||||||
return requestedPath;
|
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({
|
async function buildEntryPayload({
|
||||||
|
|||||||
@@ -4380,6 +4380,7 @@ export class Session {
|
|||||||
payload: {
|
payload: {
|
||||||
worktrees: worktrees.map((entry) => ({
|
worktrees: worktrees.map((entry) => ({
|
||||||
worktreePath: entry.path,
|
worktreePath: entry.path,
|
||||||
|
createdAt: entry.createdAt,
|
||||||
branchName: entry.branchName ?? null,
|
branchName: entry.branchName ?? null,
|
||||||
head: entry.head ?? 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> {
|
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 {
|
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') {
|
if (mode === 'list') {
|
||||||
const directory = await listDirectoryEntries({
|
const directory = await listDirectoryEntries({
|
||||||
root: agent.cwd,
|
root: cwd,
|
||||||
relativePath: requestedPath,
|
relativePath: requestedPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'file_explorer_response',
|
type: 'file_explorer_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
cwd,
|
||||||
path: directory.path,
|
path: directory.path,
|
||||||
mode,
|
mode,
|
||||||
directory,
|
directory,
|
||||||
@@ -4649,14 +4647,14 @@ export class Session {
|
|||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
const file = await readExplorerFile({
|
const file = await readExplorerFile({
|
||||||
root: agent.cwd,
|
root: cwd,
|
||||||
relativePath: requestedPath,
|
relativePath: requestedPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'file_explorer_response',
|
type: 'file_explorer_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
cwd,
|
||||||
path: file.path,
|
path: file.path,
|
||||||
mode,
|
mode,
|
||||||
directory: null,
|
directory: null,
|
||||||
@@ -4668,13 +4666,13 @@ export class Session {
|
|||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId, path: requestedPath },
|
{ err: error, cwd, path: requestedPath },
|
||||||
`Failed to fulfill file explorer request for agent ${agentId}`
|
`Failed to fulfill file explorer request for workspace ${cwd}`
|
||||||
)
|
)
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'file_explorer_response',
|
type: 'file_explorer_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
cwd,
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
mode,
|
mode,
|
||||||
directory: null,
|
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> {
|
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(
|
this.sessionLogger.debug(
|
||||||
{ agentId, path: requestedPath },
|
{ cwd, path: requestedPath },
|
||||||
`Handling file download token request for agent ${agentId} (${requestedPath})`
|
`Handling file download token request for workspace ${cwd} (${requestedPath})`
|
||||||
)
|
)
|
||||||
|
|
||||||
try {
|
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({
|
const info = await getDownloadableFileInfo({
|
||||||
root: agent.cwd,
|
root: cwd,
|
||||||
relativePath: requestedPath,
|
relativePath: requestedPath,
|
||||||
})
|
})
|
||||||
|
|
||||||
const entry = this.downloadTokenStore.issueToken({
|
const entry = this.downloadTokenStore.issueToken({
|
||||||
agentId,
|
|
||||||
path: info.path,
|
path: info.path,
|
||||||
absolutePath: info.absolutePath,
|
absolutePath: info.absolutePath,
|
||||||
fileName: info.fileName,
|
fileName: info.fileName,
|
||||||
@@ -4767,7 +4761,7 @@ export class Session {
|
|||||||
this.emit({
|
this.emit({
|
||||||
type: 'file_download_token_response',
|
type: 'file_download_token_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
cwd,
|
||||||
path: info.path,
|
path: info.path,
|
||||||
token: entry.token,
|
token: entry.token,
|
||||||
fileName: entry.fileName,
|
fileName: entry.fileName,
|
||||||
@@ -4779,13 +4773,13 @@ export class Session {
|
|||||||
})
|
})
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.sessionLogger.error(
|
this.sessionLogger.error(
|
||||||
{ err: error, agentId, path: requestedPath },
|
{ err: error, cwd, path: requestedPath },
|
||||||
`Failed to issue download token for agent ${agentId}`
|
`Failed to issue download token for workspace ${cwd}`
|
||||||
)
|
)
|
||||||
this.emit({
|
this.emit({
|
||||||
type: 'file_download_token_response',
|
type: 'file_download_token_response',
|
||||||
payload: {
|
payload: {
|
||||||
agentId,
|
cwd,
|
||||||
path: requestedPath,
|
path: requestedPath,
|
||||||
token: null,
|
token: null,
|
||||||
fileName: null,
|
fileName: null,
|
||||||
|
|||||||
@@ -927,7 +927,7 @@ const FileExplorerDirectorySchema = z.object({
|
|||||||
|
|
||||||
export const FileExplorerRequestSchema = z.object({
|
export const FileExplorerRequestSchema = z.object({
|
||||||
type: z.literal('file_explorer_request'),
|
type: z.literal('file_explorer_request'),
|
||||||
agentId: z.string(),
|
cwd: z.string(),
|
||||||
path: z.string().optional(),
|
path: z.string().optional(),
|
||||||
mode: z.enum(['list', 'file']),
|
mode: z.enum(['list', 'file']),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
@@ -941,7 +941,7 @@ export const ProjectIconRequestSchema = z.object({
|
|||||||
|
|
||||||
export const FileDownloadTokenRequestSchema = z.object({
|
export const FileDownloadTokenRequestSchema = z.object({
|
||||||
type: z.literal('file_download_token_request'),
|
type: z.literal('file_download_token_request'),
|
||||||
agentId: z.string(),
|
cwd: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
requestId: z.string(),
|
requestId: z.string(),
|
||||||
})
|
})
|
||||||
@@ -1786,6 +1786,7 @@ export const DirectorySuggestionsResponseSchema = z.object({
|
|||||||
|
|
||||||
const PaseoWorktreeSchema = z.object({
|
const PaseoWorktreeSchema = z.object({
|
||||||
worktreePath: z.string(),
|
worktreePath: z.string(),
|
||||||
|
createdAt: z.string(),
|
||||||
branchName: z.string().nullable().optional(),
|
branchName: z.string().nullable().optional(),
|
||||||
head: z.string().nullable().optional(),
|
head: z.string().nullable().optional(),
|
||||||
})
|
})
|
||||||
@@ -1812,7 +1813,7 @@ export const PaseoWorktreeArchiveResponseSchema = z.object({
|
|||||||
export const FileExplorerResponseSchema = z.object({
|
export const FileExplorerResponseSchema = z.object({
|
||||||
type: z.literal('file_explorer_response'),
|
type: z.literal('file_explorer_response'),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
agentId: z.string(),
|
cwd: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
mode: z.enum(['list', 'file']),
|
mode: z.enum(['list', 'file']),
|
||||||
directory: FileExplorerDirectorySchema.nullable(),
|
directory: FileExplorerDirectorySchema.nullable(),
|
||||||
@@ -1840,7 +1841,7 @@ export const ProjectIconResponseSchema = z.object({
|
|||||||
export const FileDownloadTokenResponseSchema = z.object({
|
export const FileDownloadTokenResponseSchema = z.object({
|
||||||
type: z.literal('file_download_token_response'),
|
type: z.literal('file_download_token_response'),
|
||||||
payload: z.object({
|
payload: z.object({
|
||||||
agentId: z.string(),
|
cwd: z.string(),
|
||||||
path: z.string(),
|
path: z.string(),
|
||||||
token: z.string().nullable(),
|
token: z.string().nullable(),
|
||||||
fileName: z.string().nullable(),
|
fileName: z.string().nullable(),
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { exec, spawn } from "child_process";
|
import { exec, spawn } from "child_process";
|
||||||
import { promisify } from "util";
|
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 { join, basename, dirname, resolve, sep } from "path";
|
||||||
import net from "node:net";
|
import net from "node:net";
|
||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
@@ -108,6 +108,7 @@ export class WorktreeDestroyError extends Error {
|
|||||||
|
|
||||||
export interface PaseoWorktreeInfo {
|
export interface PaseoWorktreeInfo {
|
||||||
path: string;
|
path: string;
|
||||||
|
createdAt: string;
|
||||||
branchName?: string;
|
branchName?: string;
|
||||||
head?: string;
|
head?: string;
|
||||||
}
|
}
|
||||||
@@ -728,9 +729,11 @@ export async function isPaseoOwnedWorktreeCwd(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseWorktreeList(output: string): PaseoWorktreeInfo[] {
|
type ParsedPaseoWorktreeInfo = Omit<PaseoWorktreeInfo, "createdAt">;
|
||||||
const entries: PaseoWorktreeInfo[] = [];
|
|
||||||
let current: PaseoWorktreeInfo | null = null;
|
function parseWorktreeList(output: string): ParsedPaseoWorktreeInfo[] {
|
||||||
|
const entries: ParsedPaseoWorktreeInfo[] = [];
|
||||||
|
let current: ParsedPaseoWorktreeInfo | null = null;
|
||||||
|
|
||||||
for (const line of output.split("\n")) {
|
for (const line of output.split("\n")) {
|
||||||
if (line.startsWith("worktree ")) {
|
if (line.startsWith("worktree ")) {
|
||||||
@@ -767,6 +770,18 @@ function parseWorktreeList(output: string): PaseoWorktreeInfo[] {
|
|||||||
return entries;
|
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({
|
export async function listPaseoWorktrees({
|
||||||
cwd,
|
cwd,
|
||||||
paseoHome,
|
paseoHome,
|
||||||
@@ -783,7 +798,11 @@ export async function listPaseoWorktrees({
|
|||||||
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
|
const rootPrefix = normalizePathForOwnership(worktreesRoot) + sep;
|
||||||
return parseWorktreeList(stdout)
|
return parseWorktreeList(stdout)
|
||||||
.map((entry) => ({ ...entry, path: normalizePathForOwnership(entry.path) }))
|
.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(
|
export async function resolvePaseoWorktreeRootForCwd(
|
||||||
|
|||||||
Reference in New Issue
Block a user