feat(server): workspace-scoped explorer + worktree createdAt
This commit is contained in:
@@ -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> {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user