diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index a2ec7ec79..6230ae965 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -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 { + 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), diff --git a/packages/server/src/server/session.workspace-resolution-invariants.test.ts b/packages/server/src/server/session.workspace-resolution-invariants.test.ts new file mode 100644 index 000000000..88ca9a6c8 --- /dev/null +++ b/packages/server/src/server/session.workspace-resolution-invariants.test.ts @@ -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; + projects: Map; +} + +function createHarness(input: { + workspaces?: PersistedWorkspaceRecord[]; + projects?: PersistedProjectRecord[]; + gitRoots?: string[]; +}): Harness { + const workspaces = new Map(); + const projects = new Map(); + 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 }).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; + } + ).findWorkspaceByDirectory("/parent/child"); + expect(found).toBeNull(); +});