diff --git a/src/workspace-store.ts b/src/workspace-store.ts index 39c2ed09..6e0a5b68 100644 --- a/src/workspace-store.ts +++ b/src/workspace-store.ts @@ -1,4 +1,4 @@ -import { eq } from "drizzle-orm"; +import { desc, eq } from "drizzle-orm"; import { openDatabase, type DatabaseHandle } from "./db/client.js"; import { workspaceSessions, @@ -31,6 +31,7 @@ export interface WorkspaceStore { managed?: boolean; }): WorkspaceSession; getSession(id: string): WorkspaceSession | undefined; + getSessionByRoot(root: string): WorkspaceSession | undefined; touchSession(id: string): void; close?(): void; } @@ -94,6 +95,17 @@ export class SqliteWorkspaceStore implements WorkspaceStore { return row ? rowToWorkspaceSession(row) : undefined; } + getSessionByRoot(root: string): WorkspaceSession | undefined { + const row = this.database.db + .select() + .from(workspaceSessions) + .where(eq(workspaceSessions.root, root)) + .orderBy(desc(workspaceSessions.lastUsedAt)) + .get(); + + return row ? rowToWorkspaceSession(row) : undefined; + } + touchSession(id: string): void { this.database.db .update(workspaceSessions) diff --git a/src/workspaces.test.ts b/src/workspaces.test.ts index 4f3eb769..f19534c5 100644 --- a/src/workspaces.test.ts +++ b/src/workspaces.test.ts @@ -12,6 +12,7 @@ import { ensureCheckoutWorkspaceRoot, WorkspaceRegistry } from "./workspaces.js" const execFileAsync = promisify(execFile); const root = await mkdtemp(join(tmpdir(), "devspace-workspace-test-")); const outsideRoot = await mkdtemp(join(tmpdir(), "devspace-workspace-outside-test-")); +const worktreeRoot = await mkdtemp(join(tmpdir(), "devspace-worktree-test-")); try { const agentDir = join(root, ".pi", "agent"); @@ -45,7 +46,7 @@ try { const config = loadConfig({ DEVSPACE_CONFIG_DIR: join(root, ".devspace-home"), DEVSPACE_ALLOWED_ROOTS: root, - DEVSPACE_WORKTREE_ROOT: join(root, ".devspace", "worktrees"), + DEVSPACE_WORKTREE_ROOT: worktreeRoot, DEVSPACE_AGENT_DIR: agentDir, DEVSPACE_SUBAGENTS: "1", DEVSPACE_OAUTH_OWNER_TOKEN: "test-owner-token-that-is-long-enough", @@ -164,6 +165,25 @@ try { path: gitRoot, mode: "worktree", }); + const persistentWorktreeAgentsDir = join( + persistentWorktree.workspace.root, + ".devspace", + "agents", + ); + await mkdir(persistentWorktreeAgentsDir, { recursive: true }); + await writeFile( + join(persistentWorktreeAgentsDir, "restored.md"), + [ + "---", + "name: restored", + "description: Available after persisted workspace restoration.", + "provider: codex", + "---", + "", + "Restore worktree context.", + "", + ].join("\n"), + ); firstStore.close(); const secondStore = new SqliteWorkspaceStore(stateDir); @@ -179,6 +199,27 @@ try { assert.equal(restoredWorktree.worktree?.managed, true); secondStore.close(); + const reopenedStore = new SqliteWorkspaceStore(stateDir); + const reopenedRegistry = new WorkspaceRegistry(config, reopenedStore); + try { + const reopenedWorktree = await reopenedRegistry.openWorkspace(persistentWorktree.workspace.root); + assert.equal(reopenedWorktree.workspace.id, persistentWorktree.workspace.id); + assert.equal(reopenedWorktree.workspace.mode, "worktree"); + assert.equal(reopenedWorktree.workspace.sourceRoot, gitRoot); + assert.equal(reopenedWorktree.workspace.root, persistentWorktree.workspace.root); + assert.equal(reopenedWorktree.workspace.worktree?.managed, true); + assert.deepEqual( + reopenedWorktree.workspace.agentProfiles.map((profile) => profile.name), + ["restored"], + ); + await assert.rejects( + () => reopenedRegistry.openWorkspace(join(worktreeRoot, "unregistered-worktree")), + /Path is outside allowed roots/, + ); + } finally { + reopenedStore.close(); + } + if (platform() !== "win32") { const aliasRoot = join(root, "alias-root"); await symlink(root, aliasRoot, "dir"); @@ -204,6 +245,7 @@ try { } finally { await rm(root, { recursive: true, force: true }); await rm(outsideRoot, { recursive: true, force: true }); + await rm(worktreeRoot, { recursive: true, force: true }); } async function git(cwd: string, args: string[]): Promise { diff --git a/src/workspaces.ts b/src/workspaces.ts index e3c252f4..22744e69 100644 --- a/src/workspaces.ts +++ b/src/workspaces.ts @@ -6,7 +6,12 @@ import { dirname, join, relative, resolve, sep } from "node:path"; import { loadProjectContextFiles } from "@earendil-works/pi-coding-agent"; import type { ServerConfig } from "./config.js"; import { createManagedWorktree } from "./git-worktrees.js"; -import { assertAllowedPath, isPathInsideRoot, resolveAllowedPath } from "./roots.js"; +import { + assertAllowedPath, + expandHomePath, + isPathInsideRoot, + resolveAllowedPath, +} from "./roots.js"; import { loadWorkspaceSkills, markSkillActivated, @@ -174,6 +179,15 @@ export class WorkspaceRegistry { } private async openCheckoutWorkspace(path: string): Promise { + const restoredWorktree = this.store?.getSessionByRoot(resolve(expandHomePath(path))); + if (restoredWorktree?.mode === "worktree" && restoredWorktree.managed) { + const workspace = this.getWorkspace(restoredWorktree.id); + workspace.agentProfiles = await loadLocalAgentProfiles(this.config, workspace.root); + const agentsFiles = await this.loadInitialAgentsFiles(workspace.root); + const availableAgentsFiles = await this.findAvailableAgentsFiles(workspace.root, agentsFiles); + return { workspace, agentsFiles, availableAgentsFiles }; + } + const root = assertAllowedPath(path, this.config.allowedRoots); const rootStats = await ensureCheckoutWorkspaceRoot(root); if (!rootStats.isDirectory()) {