diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b91bef121..dcebc6118 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -157,6 +157,7 @@ function makeFetchAgentsEntry(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/app/src/utils/new-agent-routing.test.ts b/packages/app/src/utils/new-agent-routing.test.ts index cbc3e4f23..b4d0cfa42 100644 --- a/packages/app/src/utils/new-agent-routing.test.ts +++ b/packages/app/src/utils/new-agent-routing.test.ts @@ -39,8 +39,9 @@ describe("resolveNewAgentWorkingDir", () => { it("returns the main repo root for paseo-owned worktrees", () => { const checkout = { isPaseoOwnedWorktree: true, + worktreeRoot: "/repo/.paseo/worktrees/feature", mainRepoRoot: "/repo/main", - } as CheckoutStatusPayload; + } as unknown as CheckoutStatusPayload; expect(resolveNewAgentWorkingDir("/repo/.paseo/worktrees/feature", checkout)).toBe( "/repo/main", diff --git a/packages/app/src/utils/project-placement.test.ts b/packages/app/src/utils/project-placement.test.ts index 27d31f484..28083855f 100644 --- a/packages/app/src/utils/project-placement.test.ts +++ b/packages/app/src/utils/project-placement.test.ts @@ -28,6 +28,7 @@ describe("project-placement", () => { isGit: true as const, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: "/Users/test/repo", isPaseoOwnedWorktree: false as const, mainRepoRoot: null, }, diff --git a/packages/app/src/utils/project-placement.ts b/packages/app/src/utils/project-placement.ts index 61e01420e..08c0fe30b 100644 --- a/packages/app/src/utils/project-placement.ts +++ b/packages/app/src/utils/project-placement.ts @@ -18,6 +18,7 @@ export function deriveProjectPlacementFromCwd(cwd: string): ProjectPlacementPayl isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/server/src/poc-commands/commands-poc.test.ts b/packages/server/src/poc-commands/commands-poc.test.ts index f78c713f2..7ca97cef4 100644 --- a/packages/server/src/poc-commands/commands-poc.test.ts +++ b/packages/server/src/poc-commands/commands-poc.test.ts @@ -15,13 +15,16 @@ * This pattern is used in claude-agent.ts listModels(). */ -import { describe, it, expect } from "vitest"; +import { describe, expect, test } from "vitest"; import { query, - type Query, - type SlashCommand, type SDKUserMessage, } from "@anthropic-ai/claude-agent-sdk"; +import { isCommandAvailable } from "../utils/executable.js"; + +const hasClaudeCredentials = + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; +const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; // Pattern from claude-agent.ts listModels(): // Use an empty async generator when you just need control methods @@ -31,7 +34,7 @@ function createEmptyPrompt(): AsyncGenerator { describe("Claude Agent SDK Commands POC", () => { describe("supportedCommands() API", () => { - it("should return an array of SlashCommand objects", async () => { + test.runIf(canRunClaudeIntegration)("should return an array of SlashCommand objects", async () => { // Use the pattern from claude-agent.ts: // Create a query with empty prompt generator for control methods const emptyPrompt = createEmptyPrompt(); @@ -72,7 +75,7 @@ describe("Claude Agent SDK Commands POC", () => { } }, 30000); - it("should have valid SlashCommand structure for all commands", async () => { + test.runIf(canRunClaudeIntegration)("should have valid SlashCommand structure for all commands", async () => { const emptyPrompt = createEmptyPrompt(); const claudeQuery = query({ @@ -107,7 +110,7 @@ describe("Claude Agent SDK Commands POC", () => { }); describe("Command Execution", () => { - it("should explain that commands are prompts with / prefix", () => { + test("should explain that commands are prompts with / prefix", () => { // This is a documentation test - commands ARE just prompts with / prefix // To execute a command: // 1. Create a user message with content: "/{commandName}" diff --git a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts index 76105eed2..2b330830b 100644 --- a/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts +++ b/packages/server/src/server/agent/providers/claude-sdk-behavior.test.ts @@ -4,8 +4,9 @@ import { mkdtempSync, rmSync, realpathSync } from "node:fs"; import { tmpdir } from "node:os"; import path from "node:path"; -import { describe, it, expect } from "vitest"; +import { beforeAll, describe, expect, test } from "vitest"; import { query, type SDKMessage, type SDKUserMessage } from "@anthropic-ai/claude-agent-sdk"; +import { findExecutable, isCommandAvailable } from "../../../utils/executable.js"; class Pushable implements AsyncIterable { private queue: T[] = []; @@ -52,10 +53,22 @@ function tmpCwd(): string { } } +const hasClaudeCredentials = + !!process.env.CLAUDE_CODE_OAUTH_TOKEN || !!process.env.ANTHROPIC_API_KEY; + describe("Claude SDK direct behavior", () => { - it("shows what happens after interrupt()", async () => { + const canRunClaudeIntegration = isCommandAvailable("claude") && hasClaudeCredentials; + + beforeAll(() => { + if (canRunClaudeIntegration) { + expect(isCommandAvailable("claude")).toBe(true); + } + }); + + test.runIf(canRunClaudeIntegration)("shows what happens after interrupt()", async () => { const cwd = tmpCwd(); const input = new Pushable(); + const claudeBinary = findExecutable("claude"); // Use same options as claude-agent.ts const q = query({ @@ -64,6 +77,7 @@ describe("Claude SDK direct behavior", () => { cwd, includePartialMessages: true, permissionMode: "bypassPermissions", + ...(claudeBinary ? { pathToClaudeCodeExecutable: claudeBinary } : {}), systemPrompt: { type: "preset", preset: "claude_code", diff --git a/packages/server/src/server/loop-service.test.ts b/packages/server/src/server/loop-service.test.ts index 2f9a09e7c..3385584f5 100644 --- a/packages/server/src/server/loop-service.test.ts +++ b/packages/server/src/server/loop-service.test.ts @@ -106,9 +106,9 @@ class ScriptedAgentSession implements AgentSession { const promptText = typeof prompt === "string" ? prompt : JSON.stringify(prompt); const turnId = `turn-${++this.turnCount}`; this.interrupted = false; - setTimeout(() => { + queueMicrotask(() => { void this.runScript(promptText, turnId); - }, 0); + }); return { turnId }; } diff --git a/packages/server/src/server/loop-service.ts b/packages/server/src/server/loop-service.ts index 1d3eccf3d..ead9e2bf0 100644 --- a/packages/server/src/server/loop-service.ts +++ b/packages/server/src/server/loop-service.ts @@ -256,7 +256,7 @@ async function runVerifyCheck(options: { }): Promise { const startedAt = nowIso(); try { - const result = await execFileAsync("/bin/zsh", ["-lc", options.command], { + const result = await execFileAsync("/bin/sh", ["-lc", options.command], { cwd: options.cwd, maxBuffer: MAX_VERIFY_OUTPUT_BYTES, }); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index d8dd0338d..6d2320935 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -109,6 +109,7 @@ import { detectStaleWorkspaces, deriveProjectKind, deriveProjectRootPath, + deriveWorkspaceId, deriveWorkspaceDisplayName, deriveWorkspaceKind, normalizeWorkspaceId as normalizePersistedWorkspaceId, @@ -1298,11 +1299,15 @@ export class Session { private async reconcileWorkspaceRecord(workspaceId: string): Promise<{ workspace: PersistedWorkspaceRecord; changed: boolean; + removedWorkspaceId: string | null; }> { - const normalizedWorkspaceId = normalizePersistedWorkspaceId(workspaceId); - const existing = await this.workspaceRegistry.get(normalizedWorkspaceId); - const placement = await this.buildProjectPlacement(normalizedWorkspaceId); - await this.syncWorkspaceGitWatchTarget(normalizedWorkspaceId, { + const normalizedCwd = normalizePersistedWorkspaceId(workspaceId); + const placement = await this.buildProjectPlacement(normalizedCwd); + const resolvedWorkspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); + const staleWorkspace = + resolvedWorkspaceId === normalizedCwd ? null : await this.workspaceRegistry.get(normalizedCwd); + const existing = (await this.workspaceRegistry.get(resolvedWorkspaceId)) ?? staleWorkspace; + await this.syncWorkspaceGitWatchTarget(resolvedWorkspaceId, { isGit: placement.checkout.isGit, }); const now = new Date().toISOString(); @@ -1310,13 +1315,13 @@ export class Session { const nextWorkspaceCreatedAt = existing?.createdAt ?? now; const currentProjectRecord = await this.projectRegistry.get(placement.projectKey); const nextProjectRecord = this.buildPersistedProjectRecord({ - workspaceId: normalizedWorkspaceId, + workspaceId: resolvedWorkspaceId, placement, createdAt: currentProjectRecord?.createdAt ?? nextProjectCreatedAt, updatedAt: now, }); const nextWorkspaceRecord = this.buildPersistedWorkspaceRecord({ - workspaceId: normalizedWorkspaceId, + workspaceId: resolvedWorkspaceId, placement, createdAt: nextWorkspaceCreatedAt, updatedAt: now, @@ -1335,16 +1340,33 @@ export class Session { currentProjectRecord.rootPath !== nextProjectRecord.rootPath || currentProjectRecord.kind !== nextProjectRecord.kind || currentProjectRecord.displayName !== nextProjectRecord.displayName; + const needsStaleWorkspaceCleanup = + !!staleWorkspace && + !staleWorkspace.archivedAt && + staleWorkspace.workspaceId !== resolvedWorkspaceId; - if (!needsWorkspaceUpdate && !needsProjectUpdate) { + let removedWorkspaceId: string | null = null; + if (needsStaleWorkspaceCleanup) { + await this.workspaceRegistry.archive(staleWorkspace.workspaceId, now); + this.removeWorkspaceGitWatchTarget(staleWorkspace.workspaceId); + removedWorkspaceId = staleWorkspace.workspaceId; + } + + if (!needsWorkspaceUpdate && !needsProjectUpdate && !needsStaleWorkspaceCleanup) { return { workspace: existing!, changed: false, + removedWorkspaceId: null, }; } await this.projectRegistry.upsert(nextProjectRecord); await this.workspaceRegistry.upsert(nextWorkspaceRecord); + if (existing && existing.workspaceId !== resolvedWorkspaceId) { + await this.workspaceRegistry.archive(existing.workspaceId, now); + this.removeWorkspaceGitWatchTarget(existing.workspaceId); + removedWorkspaceId ??= existing.workspaceId; + } if (existing && !existing.archivedAt && existing.projectId !== nextWorkspaceRecord.projectId) { await this.archiveProjectRecordIfEmpty(existing.projectId, now); @@ -1353,6 +1375,7 @@ export class Session { return { workspace: nextWorkspaceRecord, changed: true, + removedWorkspaceId, }; } @@ -1386,6 +1409,9 @@ export class Session { const result = await this.reconcileWorkspaceRecord(workspace.workspaceId); if (result.changed) { changedWorkspaceIds.add(result.workspace.workspaceId); + if (result.removedWorkspaceId) { + changedWorkspaceIds.add(result.removedWorkspaceId); + } } } @@ -5131,7 +5157,7 @@ export class Session { continue; } - const workspaceId = normalizePersistedWorkspaceId(agent.cwd); + const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(agent.cwd, activeRecords); const existing = descriptorsByWorkspaceId.get(workspaceId); if (!existing) { continue; @@ -5147,6 +5173,32 @@ export class Session { return Array.from(descriptorsByWorkspaceId.values()); } + private resolveRegisteredWorkspaceIdForCwd( + cwd: string, + workspaces: PersistedWorkspaceRecord[], + ): string { + const normalizedCwd = normalizePersistedWorkspaceId(cwd); + const exact = workspaces.find((workspace) => workspace.workspaceId === normalizedCwd); + if (exact) { + return exact.workspaceId; + } + + let bestMatch: PersistedWorkspaceRecord | null = null; + for (const workspace of workspaces) { + const prefix = workspace.workspaceId.endsWith(sep) + ? workspace.workspaceId + : `${workspace.workspaceId}${sep}`; + if (!normalizedCwd.startsWith(prefix)) { + continue; + } + if (!bestMatch || workspace.workspaceId.length > bestMatch.workspaceId.length) { + bestMatch = workspace; + } + } + + return bestMatch?.workspaceId ?? normalizedCwd; + } + private async listWorkspaceDescriptors(): Promise { await this.reconcileActiveWorkspaceRecords(); return this.listWorkspaceDescriptorsSnapshot(); @@ -5435,8 +5487,7 @@ export class Session { } private async ensureWorkspaceRegistered(cwd: string): Promise { - const workspaceId = normalizePersistedWorkspaceId(cwd); - return (await this.reconcileWorkspaceRecord(workspaceId)).workspace; + return (await this.reconcileWorkspaceRecord(cwd)).workspace; } private async registerPendingWorktreeWorkspace(options: { @@ -5488,11 +5539,17 @@ export class Session { return; } - const workspaceId = normalizePersistedWorkspaceId(cwd); const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); + const activeWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => !workspace.archivedAt, + ); + const workspaceId = this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces); const all = await this.listWorkspaceDescriptorsSnapshot(); const descriptorsByWorkspaceId = new Map(all.map((entry) => [entry.id, entry] as const)); - const workspaceIdsToEmit = new Set([workspaceId, ...changedWorkspaceIds]); + const workspaceIdsToEmit = new Set([ + workspaceId, + ...changedWorkspaceIds, + ]); for (const nextWorkspaceId of workspaceIdsToEmit) { const workspace = descriptorsByWorkspaceId.get(nextWorkspaceId); @@ -5529,13 +5586,12 @@ export class Session { } const changedWorkspaceIds = await this.reconcileActiveWorkspaceRecords(); + const activeWorkspaces = (await this.workspaceRegistry.list()).filter( + (workspace) => !workspace.archivedAt, + ); const uniqueWorkspaceCwds = new Set(changedWorkspaceIds); for (const cwd of cwds) { - const normalized = normalizePersistedWorkspaceId(cwd); - if (!normalized) { - continue; - } - uniqueWorkspaceCwds.add(normalized); + uniqueWorkspaceCwds.add(this.resolveRegisteredWorkspaceIdForCwd(cwd, activeWorkspaces)); } const subscription = this.workspaceUpdatesSubscription; diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 77818a928..b9b65602d 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -181,6 +181,7 @@ describe("workspace git watch targets", () => { isGit: true, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -267,6 +268,7 @@ describe("workspace git watch targets", () => { isGit: true, currentBranch: "main", remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 04f3b739d..859192291 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -251,6 +251,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -400,6 +401,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -564,6 +566,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -699,6 +702,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -792,6 +796,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: "feature/name-from-server", remoteUrl: "https://github.com/acme/repo-branch.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -849,6 +854,41 @@ describe("workspace aggregation", () => { expect(result.entries[0]?.status).toBe("needs_input"); }); + test("subdirectory agents map to an existing parent workspace descriptor", async () => { + const session = createSessionForWorkspaceTests() as any; + session.workspaceRegistry.list = async () => [ + createPersistedWorkspaceRecord({ + workspaceId: "/tmp/repo", + projectId: "/tmp/repo", + cwd: "/tmp/repo", + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ]; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "a1", + cwd: "/tmp/repo/packages/app", + status: "running", + updatedAt: "2026-03-01T12:03:00.000Z", + }), + ]; + + const result = await session.listFetchWorkspacesEntries({ + type: "fetch_workspaces_request", + requestId: "req-subdir-agent", + }); + + expect(result.entries).toHaveLength(1); + expect(result.entries[0]).toMatchObject({ + id: "/tmp/repo", + status: "running", + activityAt: "2026-03-01T12:03:00.000Z", + }); + }); + test("workspace update stream keeps persisted workspace visible after agents stop", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const logger = { @@ -1114,6 +1154,7 @@ describe("workspace aggregation", () => { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }, @@ -1131,6 +1172,57 @@ describe("workspace aggregation", () => { expect(response?.payload.workspace?.id).toBe("/tmp/repo"); }); + test("open_project_request collapses a git subdirectory onto the repo root workspace", async () => { + const emitted: Array<{ type: string; payload: unknown }> = []; + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + const repoRoot = "/tmp/repo"; + const subdir = "/tmp/repo/packages/app"; + + session.emit = (message: any) => emitted.push(message); + session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null; + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: repoRoot, + projectName: "repo", + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: null, + worktreeRoot: repoRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + await session.handleMessage({ + type: "open_project_request", + cwd: subdir, + requestId: "req-open-subdir", + }); + + expect(workspaces.get(repoRoot)).toBeTruthy(); + expect(workspaces.has(subdir)).toBe(false); + const response = emitted.find((message) => message.type === "open_project_response") as any; + expect(response?.payload.error).toBeNull(); + expect(response?.payload.workspace?.id).toBe(repoRoot); + }); + test("archive_workspace_request hides non-destructive workspace records", async () => { const emitted: Array<{ type: string; payload: unknown }> = []; const session = createSessionForWorkspaceTests() as any; @@ -1239,6 +1331,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", remoteUrl: "https://github.com/zimakki/inkwell.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: cwd !== mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, }, @@ -1345,6 +1438,7 @@ describe("workspace aggregation", () => { isGit: true, currentBranch: cwd === mainWorkspaceId ? "main" : "feature-a", remoteUrl: "https://github.com/new-owner/inkwell.git", + worktreeRoot: cwd, isPaseoOwnedWorktree: cwd !== mainWorkspaceId, mainRepoRoot: cwd === mainWorkspaceId ? null : mainWorkspaceId, }, @@ -1367,4 +1461,99 @@ describe("workspace aggregation", () => { rmSync(tempDir, { recursive: true, force: true }); } }); + + test("reconcile archives stale subdirectory workspace records when collapsing to the repo root", async () => { + const session = createSessionForWorkspaceTests() as any; + const projects = new Map>(); + const workspaces = new Map>(); + + const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-workspace-collapse-"))); + const repoRoot = path.join(tempDir, "repo"); + const subdirWorkspaceId = path.join(repoRoot, "packages", "app"); + const projectId = "remote:github.com/acme/repo"; + + execSync(`mkdir -p ${JSON.stringify(subdirWorkspaceId)}`); + + projects.set( + projectId, + createPersistedProjectRecord({ + projectId, + rootPath: repoRoot, + kind: "git", + displayName: "acme/repo", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + workspaces.set( + repoRoot, + createPersistedWorkspaceRecord({ + workspaceId: repoRoot, + projectId, + cwd: repoRoot, + kind: "local_checkout", + displayName: "main", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + workspaces.set( + subdirWorkspaceId, + createPersistedWorkspaceRecord({ + workspaceId: subdirWorkspaceId, + projectId, + cwd: subdirWorkspaceId, + kind: "directory", + displayName: "app", + createdAt: "2026-03-01T12:00:00.000Z", + updatedAt: "2026-03-01T12:00:00.000Z", + }), + ); + + session.projectRegistry.get = async (nextProjectId: string) => projects.get(nextProjectId) ?? null; + session.projectRegistry.list = async () => Array.from(projects.values()); + session.projectRegistry.upsert = async ( + record: ReturnType, + ) => { + projects.set(record.projectId, record); + }; + session.workspaceRegistry.get = async (workspaceId: string) => + workspaces.get(workspaceId) ?? null; + session.workspaceRegistry.list = async () => Array.from(workspaces.values()); + session.workspaceRegistry.upsert = async ( + record: ReturnType, + ) => { + workspaces.set(record.workspaceId, record); + }; + session.workspaceRegistry.archive = async (workspaceId: string, archivedAt: string) => { + const existing = workspaces.get(workspaceId); + if (!existing) return; + workspaces.set(workspaceId, { ...existing, archivedAt, updatedAt: archivedAt }); + }; + session.buildProjectPlacement = async (cwd: string) => ({ + projectKey: projectId, + projectName: "acme/repo", + checkout: { + cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: repoRoot, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }, + }); + + try { + const result = await session.reconcileWorkspaceRecord(subdirWorkspaceId); + + expect(result.changed).toBe(true); + expect(result.workspace.workspaceId).toBe(repoRoot); + expect(result.removedWorkspaceId).toBe(subdirWorkspaceId); + expect(workspaces.get(repoRoot)?.archivedAt).toBeNull(); + expect(workspaces.get(subdirWorkspaceId)?.archivedAt).toBeTruthy(); + } finally { + rmSync(tempDir, { recursive: true, force: true }); + } + }); }); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index e19dd7d90..fe1b7c53b 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -167,15 +167,16 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu "/tmp/paseo-test", async () => ({}) as any, { allowedOrigins: new Set() }, - undefined, - undefined, - undefined, speechReadiness ? { - getSpeechReadiness: () => speechReadiness, + getReadiness: () => speechReadiness, + onReadinessChange: vi.fn(() => () => {}), } : undefined, undefined, + undefined, + undefined, + undefined, TEST_DAEMON_VERSION, undefined, undefined, diff --git a/packages/server/src/server/workspace-registry-bootstrap.ts b/packages/server/src/server/workspace-registry-bootstrap.ts index 902741339..ae839246c 100644 --- a/packages/server/src/server/workspace-registry-bootstrap.ts +++ b/packages/server/src/server/workspace-registry-bootstrap.ts @@ -6,6 +6,7 @@ import type { StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentStorage } from "./agent/agent-storage.js"; import { buildProjectPlacementForCwd, + deriveWorkspaceId, deriveProjectKind, deriveProjectRootPath, deriveWorkspaceDisplayName, @@ -67,22 +68,26 @@ export async function bootstrapWorkspaceRegistries(options: { const records = await options.agentStorage.list(); const activeRecords = records.filter((record) => !record.archivedAt); - const recordsByWorkspaceId = new Map(); + const recordsByWorkspaceId = new Map< + string, + { placement: Awaited>; records: StoredAgentRecord[] } + >(); for (const record of activeRecords) { - const workspaceId = normalizeWorkspaceId(record.cwd); - const existing = recordsByWorkspaceId.get(workspaceId) ?? []; - existing.push(record); + const normalizedCwd = normalizeWorkspaceId(record.cwd); + const placement = await buildProjectPlacementForCwd({ + cwd: normalizedCwd, + paseoHome: options.paseoHome, + }); + const workspaceId = deriveWorkspaceId(normalizedCwd, placement.checkout); + const existing = recordsByWorkspaceId.get(workspaceId) ?? { placement, records: [] }; + existing.records.push(record); recordsByWorkspaceId.set(workspaceId, existing); } const projectRanges = new Map(); - for (const [workspaceId, workspaceRecords] of recordsByWorkspaceId.entries()) { - const placement = await buildProjectPlacementForCwd({ - cwd: workspaceId, - paseoHome: options.paseoHome, - }); - + for (const [workspaceId, entry] of recordsByWorkspaceId.entries()) { + const { placement, records: workspaceRecords } = entry; let workspaceCreatedAt: string | null = null; let workspaceUpdatedAt: string | null = null; for (const record of workspaceRecords) { diff --git a/packages/server/src/server/workspace-registry-model.test.ts b/packages/server/src/server/workspace-registry-model.test.ts index 29610578b..2005ae2b5 100644 --- a/packages/server/src/server/workspace-registry-model.test.ts +++ b/packages/server/src/server/workspace-registry-model.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test, vi } from "vitest"; -import { detectStaleWorkspaces } from "./workspace-registry-model.js"; +import { deriveWorkspaceId, detectStaleWorkspaces } from "./workspace-registry-model.js"; import { createPersistedWorkspaceRecord } from "./workspace-registry.js"; function createWorkspaceRecord(workspaceId: string) { @@ -52,3 +52,33 @@ describe("detectStaleWorkspaces", () => { expect(Array.from(staleWorkspaceIds)).toEqual([]); }); }); + +describe("deriveWorkspaceId", () => { + test("uses git worktree root when available", () => { + expect( + deriveWorkspaceId("/tmp/repo/packages/app", { + cwd: "/tmp/repo/packages/app", + isGit: true, + currentBranch: "main", + remoteUrl: "https://github.com/acme/repo.git", + worktreeRoot: "/tmp/repo", + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + ).toBe("/tmp/repo"); + }); + + test("falls back to normalized cwd for non-git directories", () => { + expect( + deriveWorkspaceId("/tmp/repo/../repo/scratch", { + cwd: "/tmp/repo/../repo/scratch", + isGit: false, + currentBranch: null, + remoteUrl: null, + worktreeRoot: null, + isPaseoOwnedWorktree: false, + mainRepoRoot: null, + }), + ).toBe("/tmp/repo/scratch"); + }); +}); diff --git a/packages/server/src/server/workspace-registry-model.ts b/packages/server/src/server/workspace-registry-model.ts index 9d1590be0..43dde14fa 100644 --- a/packages/server/src/server/workspace-registry-model.ts +++ b/packages/server/src/server/workspace-registry-model.ts @@ -19,6 +19,10 @@ export function normalizeWorkspaceId(cwd: string): string { return resolve(trimmed); } +export function deriveWorkspaceId(cwd: string, checkout: ProjectCheckoutLitePayload): string { + return checkout.worktreeRoot ?? normalizeWorkspaceId(cwd); +} + function deriveRemoteProjectKey(remoteUrl: string | null): string | null { if (!remoteUrl) { return null; @@ -161,6 +165,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -172,6 +177,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, isPaseoOwnedWorktree: true, mainRepoRoot: status.mainRepoRoot, }; @@ -182,6 +188,7 @@ export async function buildProjectPlacementForCwd(input: { isGit: true, currentBranch: status.currentBranch, remoteUrl: status.remoteUrl, + worktreeRoot: status.worktreeRoot, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -192,13 +199,14 @@ export async function buildProjectPlacementForCwd(input: { isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }), ); const projectKey = deriveProjectGroupingKey({ - cwd: normalizedCwd, + cwd: checkout.worktreeRoot ?? normalizedCwd, remoteUrl: checkout.remoteUrl, isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, mainRepoRoot: checkout.mainRepoRoot, diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 2076c53e9..17d07a54e 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -518,6 +518,7 @@ export async function registerPendingWorktreeWorkspace( isGit: true, currentBranch: options.branchName, remoteUrl: basePlacement.checkout.remoteUrl, + worktreeRoot: options.worktreePath, isPaseoOwnedWorktree: true, mainRepoRoot: options.repoRoot, }, diff --git a/packages/server/src/shared/messages.ts b/packages/server/src/shared/messages.ts index 58416e1c3..4ccb0e657 100644 --- a/packages/server/src/shared/messages.ts +++ b/packages/server/src/shared/messages.ts @@ -1598,6 +1598,7 @@ export const ProjectCheckoutLiteNotGitPayloadSchema = z.object({ isGit: z.literal(false), currentBranch: z.null(), remoteUrl: z.null(), + worktreeRoot: z.null(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), }); @@ -1607,6 +1608,7 @@ export const ProjectCheckoutLiteGitNonPaseoPayloadSchema = z.object({ isGit: z.literal(true), currentBranch: z.string().nullable(), remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), isPaseoOwnedWorktree: z.literal(false), mainRepoRoot: z.null(), }); @@ -1616,6 +1618,7 @@ export const ProjectCheckoutLiteGitPaseoPayloadSchema = z.object({ isGit: z.literal(true), currentBranch: z.string().nullable(), remoteUrl: z.string().nullable(), + worktreeRoot: z.string(), isPaseoOwnedWorktree: z.literal(true), mainRepoRoot: z.string(), }); diff --git a/packages/server/src/utils/checkout-git.test.ts b/packages/server/src/utils/checkout-git.test.ts index 77e416669..cdd7f8ff0 100644 --- a/packages/server/src/utils/checkout-git.test.ts +++ b/packages/server/src/utils/checkout-git.test.ts @@ -151,6 +151,7 @@ const x = 1; const status = await getCheckoutStatusLite(repoDir); expect(status.isGit).toBe(true); expect(status.currentBranch).toBe("main"); + expect(status.worktreeRoot).toBe(repoDir); expect(status.isPaseoOwnedWorktree).toBe(false); expect(status.mainRepoRoot).toBeNull(); }); @@ -373,6 +374,7 @@ const x = 1; const status = await getCheckoutStatusLite(result.worktreePath, { paseoHome }); expect(status.isGit).toBe(true); + expect(status.worktreeRoot).toBe(result.worktreePath); expect(status.isPaseoOwnedWorktree).toBe(true); expect(status.mainRepoRoot).toBe(repoDir); }); diff --git a/packages/server/src/utils/checkout-git.ts b/packages/server/src/utils/checkout-git.ts index 7324e7607..37812924f 100644 --- a/packages/server/src/utils/checkout-git.ts +++ b/packages/server/src/utils/checkout-git.ts @@ -542,6 +542,7 @@ export type CheckoutStatusLiteNotGit = { isGit: false; currentBranch: null; remoteUrl: null; + worktreeRoot: null; isPaseoOwnedWorktree: false; mainRepoRoot: null; }; @@ -550,6 +551,7 @@ export type CheckoutStatusLiteGitNonPaseo = { isGit: true; currentBranch: string | null; remoteUrl: string | null; + worktreeRoot: string; isPaseoOwnedWorktree: false; mainRepoRoot: null; }; @@ -558,6 +560,7 @@ export type CheckoutStatusLiteGitPaseo = { isGit: true; currentBranch: string | null; remoteUrl: string | null; + worktreeRoot: string; isPaseoOwnedWorktree: true; mainRepoRoot: string; }; @@ -1159,6 +1162,7 @@ export async function getCheckoutStatusLite( isGit: false, currentBranch: null, remoteUrl: null, + worktreeRoot: null, isPaseoOwnedWorktree: false, mainRepoRoot: null, }; @@ -1169,6 +1173,7 @@ export async function getCheckoutStatusLite( isGit: true, currentBranch: inspected.currentBranch, remoteUrl: inspected.remoteUrl, + worktreeRoot: inspected.worktreeRoot, isPaseoOwnedWorktree: true, mainRepoRoot: await getMainRepoRoot(cwd), }; @@ -1178,6 +1183,7 @@ export async function getCheckoutStatusLite( isGit: true, currentBranch: inspected.currentBranch, remoteUrl: inspected.remoteUrl, + worktreeRoot: inspected.worktreeRoot, isPaseoOwnedWorktree: false, mainRepoRoot: null, };