Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion src/workspace-store.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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)
Expand Down
44 changes: 43 additions & 1 deletion src/workspaces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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);
Expand All @@ -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();
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (platform() !== "win32") {
const aliasRoot = join(root, "alias-root");
await symlink(root, aliasRoot, "dir");
Expand All @@ -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<void> {
Expand Down
16 changes: 15 additions & 1 deletion src/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -174,6 +179,15 @@ export class WorkspaceRegistry {
}

private async openCheckoutWorkspace(path: string): Promise<WorkspaceContext> {
const restoredWorktree = this.store?.getSessionByRoot(resolve(expandHomePath(path)));
if (restoredWorktree?.mode === "worktree" && restoredWorktree.managed) {
const workspace = this.getWorkspace(restoredWorktree.id);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Stale worktree roots are restored

When a persisted managed worktree has been deleted or moved, this branch reconstructs and returns it without checking that its root still exists, causing subsequent file operations or process startup to fail against a nonexistent workspace.

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 };
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

const root = assertAllowedPath(path, this.config.allowedRoots);
const rootStats = await ensureCheckoutWorkspaceRoot(root);
if (!rootStats.isDirectory()) {
Expand Down
Loading