fix(server): keep archived workspaces out of cwd resolution (#564)

Filter archivedAt records out of the prefix-fallback resolver and stop
canonicalizing into archived parents — opening a child of an archived
git ancestor now creates a fresh workspace at the input path instead
of resurfacing the archived parent.
This commit is contained in:
Mohamed Boudra
2026-04-28 11:32:35 +07:00
parent b090b4d237
commit 221b76bf52
2 changed files with 472 additions and 1 deletions
+27 -1
View File
@@ -4,7 +4,7 @@ import { TTLCache } from "@isaacs/ttlcache";
import pMemoize from "p-memoize";
import { realpathSync } from "node:fs";
import type { FSWatcher } from "node:fs";
import { resolve, sep } from "path";
import { basename, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
@@ -6261,6 +6261,7 @@ export class Session {
let bestMatch: PersistedWorkspaceRecord | null = null;
for (const workspace of workspaces) {
if (workspace.cwd === userHome) continue;
if (workspace.archivedAt) continue;
const prefix = workspace.cwd.endsWith(sep) ? workspace.cwd : `${workspace.cwd}${sep}`;
if (!normalizedCwd.startsWith(prefix)) {
continue;
@@ -6562,11 +6563,36 @@ export class Session {
}
private async findOrCreateWorkspaceForDirectory(cwd: string): Promise<PersistedWorkspaceRecord> {
const inputCwd = normalizePersistedWorkspaceId(cwd);
const normalizedCwd = await this.resolveWorkspaceDirectory(cwd);
const existingWorkspace = await this.findExactWorkspaceByDirectory(normalizedCwd, {
refreshGit: false,
});
if (existingWorkspace) {
if (existingWorkspace.archivedAt && inputCwd !== normalizedCwd) {
const timestamp = new Date().toISOString();
const displayName = basename(inputCwd) || inputCwd;
const projectRecord = createPersistedProjectRecord({
projectId: inputCwd,
rootPath: inputCwd,
kind: "non_git",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.projectRegistry.upsert(projectRecord);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId: inputCwd,
projectId: projectRecord.projectId,
cwd: inputCwd,
kind: "directory",
displayName,
createdAt: timestamp,
updatedAt: timestamp,
});
await this.workspaceRegistry.upsert(workspaceRecord);
return workspaceRecord;
}
return this.reclassifyOrUnarchiveWorkspaceForDirectory({
workspace: existingWorkspace,
project: await this.projectRegistry.get(existingWorkspace.projectId),
@@ -0,0 +1,445 @@
// Invariant tests for cwd → workspace resolution on the open_project_request path.
// Each test encodes a default behavior we want from `findOrCreateWorkspaceForDirectory`.
// Run to see which invariants the current code already satisfies (green) and which
// it violates (red).
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { expect, test, vi } from "vitest";
import { Session, type SessionOptions } from "./session.js";
import type { SessionOutboundMessage } from "../shared/messages.js";
import { createNoopWorkspaceGitService } from "./test-utils/workspace-git-service-stub.js";
import {
createPersistedProjectRecord,
createPersistedWorkspaceRecord,
type PersistedProjectRecord,
type PersistedWorkspaceRecord,
} from "./workspace-registry.js";
interface Harness {
session: Session;
emitted: SessionOutboundMessage[];
workspaces: Map<string, PersistedWorkspaceRecord>;
projects: Map<string, PersistedProjectRecord>;
}
function createHarness(input: {
workspaces?: PersistedWorkspaceRecord[];
projects?: PersistedProjectRecord[];
gitRoots?: string[];
}): Harness {
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const projects = new Map<string, PersistedProjectRecord>();
for (const w of input.workspaces ?? []) workspaces.set(w.workspaceId, w);
for (const p of input.projects ?? []) projects.set(p.projectId, p);
const gitRoots = [...(input.gitRoots ?? [])];
function findGitRoot(cwd: string): string | null {
let best: string | null = null;
for (const root of gitRoots) {
if (cwd === root || cwd.startsWith(`${root}${path.sep}`)) {
if (!best || root.length > best.length) best = root;
}
}
return best;
}
const workspaceGitService = createNoopWorkspaceGitService({
getCheckout: async (cwd: string) => {
const root = findGitRoot(cwd);
if (!root) {
return {
cwd,
isGit: false,
currentBranch: null,
remoteUrl: null,
worktreeRoot: null,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
}
return {
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: root,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
};
},
peekSnapshot: () => null,
});
const emitted: SessionOutboundMessage[] = [];
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const session = new Session({
clientId: "test",
appVersion: null,
onMessage: (m) => emitted.push(m),
logger: logger as unknown as SessionOptions["logger"],
downloadTokenStore: {} as unknown as SessionOptions["downloadTokenStore"],
pushTokenStore: {} as unknown as SessionOptions["pushTokenStore"],
paseoHome: mkdtempSync(path.join(tmpdir(), "paseo-invariant-test-")),
agentManager: {
subscribe: () => () => {},
listAgents: () => [],
getAgent: () => null,
archiveAgent: async () => ({ archivedAt: new Date().toISOString() }),
archiveSnapshot: async () => ({}),
clearAgentAttention: async () => {},
notifyAgentState: () => {},
} as unknown as SessionOptions["agentManager"],
agentStorage: {
list: async () => [],
get: async () => null,
} as unknown as SessionOptions["agentStorage"],
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (id: string) => projects.get(id) ?? null,
upsert: async (record: PersistedProjectRecord) => {
projects.set(record.projectId, record);
},
archive: async (id: string, archivedAt: string) => {
const p = projects.get(id);
if (p) projects.set(id, { ...p, archivedAt });
},
remove: async (id: string) => {
projects.delete(id);
},
} as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => Array.from(workspaces.values()),
get: async (id: string) => workspaces.get(id) ?? null,
upsert: async (record: PersistedWorkspaceRecord) => {
workspaces.set(record.workspaceId, record);
},
archive: async (id: string, archivedAt: string) => {
const w = workspaces.get(id);
if (w) workspaces.set(id, { ...w, archivedAt });
},
remove: async (id: string) => {
workspaces.delete(id);
},
} as unknown as SessionOptions["workspaceRegistry"],
chatService: {} as unknown as SessionOptions["chatService"],
scheduleService: {} as unknown as SessionOptions["scheduleService"],
loopService: {} as unknown as SessionOptions["loopService"],
checkoutDiffManager: {
subscribe: async () => ({
initial: { cwd: "/tmp", files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
} as unknown as SessionOptions["checkoutDiffManager"],
workspaceGitService,
daemonConfigStore: {
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
} as unknown as SessionOptions["daemonConfigStore"],
mcpBaseUrl: null,
stt: null,
tts: null,
terminalManager: null,
});
return { session, emitted, workspaces, projects };
}
async function openProject(session: Session, cwd: string, requestId = "req-1") {
await (session as unknown as { handleMessage(m: unknown): Promise<unknown> }).handleMessage({
type: "open_project_request",
cwd,
requestId,
});
}
function getOpenResponse(emitted: SessionOutboundMessage[], requestId: string) {
const m = emitted.find(
(msg) => msg.type === "open_project_response" && msg.payload.requestId === requestId,
);
if (!m || m.type !== "open_project_response") return null;
return m.payload;
}
const T0 = "2026-01-01T00:00:00.000Z";
function gitWorkspace(rootPath: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: rootPath,
projectId: rootPath,
cwd: rootPath,
kind: "local_checkout",
displayName: "main",
createdAt: T0,
updatedAt: T0,
archivedAt,
});
}
function dirWorkspace(cwd: string, archivedAt: string | null = null) {
return createPersistedWorkspaceRecord({
workspaceId: cwd,
projectId: cwd,
cwd,
kind: "directory",
displayName: path.basename(cwd),
createdAt: T0,
updatedAt: T0,
archivedAt,
});
}
function gitProject(rootPath: string, archivedAt: string | null = null) {
return createPersistedProjectRecord({
projectId: rootPath,
rootPath,
kind: "git",
displayName: path.basename(rootPath),
createdAt: T0,
updatedAt: T0,
archivedAt,
});
}
function dirProject(rootPath: string, archivedAt: string | null = null) {
return createPersistedProjectRecord({
projectId: rootPath,
rootPath,
kind: "non_git",
displayName: path.basename(rootPath),
createdAt: T0,
updatedAt: T0,
archivedAt,
});
}
// ─────────────────────────────────────────────────────────────────────────────
// S1. Open a fresh git repo: creates a workspace at the canonical root.
// ─────────────────────────────────────────────────────────────────────────────
test("S1: open fresh git repo creates workspace at canonical root", async () => {
const h = createHarness({ gitRoots: ["/foo"] });
await openProject(h.session, "/foo");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/foo");
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/foo")).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
// S2. Open a fresh non-git directory: creates a directory workspace.
// (Capability we are explicitly keeping.)
// ─────────────────────────────────────────────────────────────────────────────
test("S2: open fresh non-git directory creates a directory workspace at exact path", async () => {
const h = createHarness({});
await openProject(h.session, "/bar");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/bar");
expect(resp?.workspace?.workspaceKind).toBe("directory");
expect(h.workspaces.has("/bar")).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
// S3. Re-open an active workspace by its exact path: returns the same record,
// no archive state change.
// ─────────────────────────────────────────────────────────────────────────────
test("S3: re-open active workspace by exact path returns the same record", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo"],
});
await openProject(h.session, "/foo");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo");
expect(h.workspaces.size).toBe(1);
expect(h.workspaces.get("/foo")?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
// S4. Open a subdir of an active git workspace: canonicalizes UP to the repo
// root, returns the existing workspace. (Per "always go to the nearest git".)
// ─────────────────────────────────────────────────────────────────────────────
test("S4: open subdir of active git workspace returns the repo-root workspace", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo"],
});
await openProject(h.session, "/foo/sub");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.id).toBe("/foo");
expect(h.workspaces.size).toBe(1);
});
// ─────────────────────────────────────────────────────────────────────────────
// S5. Open a subdir of an active non-git directory workspace: I4 says directory
// workspaces do NOT claim their subtree. Subdir gets its own workspace.
// ─────────────────────────────────────────────────────────────────────────────
test("S5: open subdir of active non-git directory creates a SEPARATE workspace", async () => {
const h = createHarness({
workspaces: [dirWorkspace("/bar")],
projects: [dirProject("/bar")],
});
await openProject(h.session, "/bar/baz");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/bar/baz");
expect(h.workspaces.has("/bar")).toBe(true);
expect(h.workspaces.has("/bar/baz")).toBe(true);
expect(h.workspaces.size).toBe(2);
});
// ─────────────────────────────────────────────────────────────────────────────
// S6. Open the EXACT path of an archived git workspace: this IS explicit user
// intent to re-open what they archived. Unarchive is correct here.
// ─────────────────────────────────────────────────────────────────────────────
test("S6: re-opening an archived git workspace by exact path UNARCHIVES it", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
});
await openProject(h.session, "/toolbox");
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
// S7. Nested git: child has its own .git. Innermost wins → separate workspace.
// ─────────────────────────────────────────────────────────────────────────────
test("S7: open nested git repo (own .git) creates a SEPARATE workspace at the inner root", async () => {
const h = createHarness({
workspaces: [gitWorkspace("/foo")],
projects: [gitProject("/foo")],
gitRoots: ["/foo", "/foo/sub"],
});
await openProject(h.session, "/foo/sub");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.workspace?.workspaceDirectory).toBe("/foo/sub");
expect(h.workspaces.has("/foo")).toBe(true);
expect(h.workspaces.has("/foo/sub")).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
// S8. Open a child of an archived NON-GIT ancestor: I4 — ancestor doesn't claim
// subtree. Fresh workspace at child path. Archived ancestor untouched.
// This is the vfonic case from issue #564.
// ─────────────────────────────────────────────────────────────────────────────
test("S8: open child of archived non-git ancestor creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({
workspaces: [dirWorkspace("/Users/me/Developer", archivedAt)],
projects: [dirProject("/Users/me/Developer", archivedAt)],
});
await openProject(h.session, "/Users/me/Developer/projects/foo");
expect(h.workspaces.get("/Users/me/Developer")?.archivedAt).toBe(archivedAt);
expect(h.workspaces.has("/Users/me/Developer/projects/foo")).toBe(true);
});
// ─────────────────────────────────────────────────────────────────────────────
// S9. Open a child of an archived GIT ancestor: canonical resolves UP to the
// archived root. Per "no auto-unarchive", the archived state is sticky.
// This is the headline issue #564 reproduction (Edolce's video).
// ─────────────────────────────────────────────────────────────────────────────
test("S9: opening child of archived git workspace does NOT auto-unarchive the parent", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
});
await openProject(h.session, "/toolbox/flomo-cli");
expect(h.workspaces.get("/toolbox")?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
// S10. The user's exact scenario:
// 1. Open `/projects` as a non-git directory workspace (mistake).
// 2. Archive it.
// 3. Open `/projects/some-git-repo` (a real git repo nested inside).
// Expected: the git repo opens as its own fresh workspace; archived `/projects`
// stays archived and is NOT resurfaced. Tests I4 (non-git ancestor doesn't
// claim subtree) interacting with a git child.
// ─────────────────────────────────────────────────────────────────────────────
test("S10: opening a git repo nested inside an archived non-git directory creates fresh workspace; ancestor stays archived", async () => {
const archivedAt = "2026-04-04T17:15:22.423Z";
const h = createHarness({
workspaces: [dirWorkspace("/projects", archivedAt)],
projects: [dirProject("/projects", archivedAt)],
gitRoots: ["/projects/some-git-repo"],
});
await openProject(h.session, "/projects/some-git-repo");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.workspaceDirectory).toBe("/projects/some-git-repo");
expect(resp?.workspace?.workspaceKind).toBe("local_checkout");
expect(h.workspaces.has("/projects/some-git-repo")).toBe(true);
expect(h.workspaces.get("/projects")?.archivedAt).toBe(archivedAt);
expect(h.projects.get("/projects")?.archivedAt).toBe(archivedAt);
});
// ─────────────────────────────────────────────────────────────────────────────
// S11. Archive then re-add round-trip (project-level): opening the exact path
// of an archived project unarchives both the project and its workspace,
// reusing the same path-derived ids.
// ─────────────────────────────────────────────────────────────────────────────
test("S11: re-opening an archived project by exact path unarchives project + workspace and reuses ids", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [gitWorkspace("/toolbox", archivedAt)],
projects: [gitProject("/toolbox", archivedAt)],
gitRoots: ["/toolbox"],
});
await openProject(h.session, "/toolbox");
const resp = getOpenResponse(h.emitted, "req-1");
expect(resp?.error).toBeNull();
expect(resp?.workspace?.id).toBe("/toolbox");
expect(resp?.workspace?.projectId).toBe("/toolbox");
expect(h.workspaces.size).toBe(1);
expect(h.projects.size).toBe(1);
expect(h.workspaces.get("/toolbox")?.archivedAt).toBeNull();
expect(h.projects.get("/toolbox")?.archivedAt).toBeNull();
});
// ─────────────────────────────────────────────────────────────────────────────
// S12. Prefix-fallback resolver must not surface an archived ancestor: looking
// up a child cwd whose only matching record is an archived parent should
// return null (not the archived parent).
// ─────────────────────────────────────────────────────────────────────────────
test("S12: findWorkspaceByDirectory does not return archived ancestor via prefix fallback", async () => {
const archivedAt = "2026-04-22T13:08:05.400Z";
const h = createHarness({
workspaces: [dirWorkspace("/parent", archivedAt)],
projects: [dirProject("/parent", archivedAt)],
});
const found = await (
h.session as unknown as {
findWorkspaceByDirectory(cwd: string): Promise<unknown>;
}
).findWorkspaceByDirectory("/parent/child");
expect(found).toBeNull();
});