Treat every added folder as an independent project (#2098)

* fix(projects): detect when projects become Git repositories

Project identity was coupled to Git placement, while non-Git roots were dropped by session-scoped observation. Keep identity tied to the selected root and observe Git transitions daemon-wide so empty projects update without rehoming workspaces.

* fix(projects): preserve metadata across Git read failures

Refresh archived workspace facts before persistence and treat only confirmed non-repositories as non-Git so transient failures cannot rewrite stored project metadata.

* fix(projects): close project update races

* fix(projects): refresh checkout metadata when reopening folders

Persist worktree ownership separately from workspace kind and gate exact-root project creation on stable host identity. Refresh active and archived records so missed Git transitions cannot return stale checkout descriptors.

* test(projects): accept Windows aliases for repaired roots

* test(server): retry transient Windows cleanup locks

* fix(projects): propagate project metadata refreshes

Project-only Git transitions now fan out workspace updates for legacy clients. Explicit project creation refreshes stored kind, and equivalent cwd spellings reuse existing workspace records.

* fix(projects): refresh worktree source project kind

* fix(projects): preserve exact-folder runtime isolation

* fix(projects): preserve exact cwd across worktree lifecycle

* fix(projects): harden exact-folder worktree flows

* fix(projects): derive exact cwd from matched path identity

* fix(projects): preserve nested worktree lifecycle

* refactor(projects): centralize workspace placement

* test(e2e): verify isolated server ports

* fix(projects): preserve placement reshape compatibility

* fix(projects): validate created worktree placement

* fix(projects): preserve placement through workspace lifecycle

Archive and recovery were rediscovering or guessing checkout roots instead of consuming the persisted workspace placement. Make the record authoritative for exact cwd, backing worktree, and source repository, and classify stale paths before Git reconciliation.

* fix(worktrees): preserve source checkout root

Convert Git's common administrative directory back to the source checkout root when legacy archive placement is reconstructed.

* fix(worktrees): compare filesystem identities

Resolve discovered worktrees and teardown locations through the shared realpath-aware matcher so Windows short and long path spellings cannot split placement identity.

* fix(worktrees): centralize path containment

Route worktree ownership, listing, resolution, and deletion through the realpath-aware containment primitive so Windows path aliases cannot be filtered by an earlier raw prefix check.

* fix(worktrees): remove duplicate ownership discovery

* test(e2e): isolate daemon restart ownership

* fix(worktrees): make lifecycle operations transactional

* fix(workspaces): share git watches by cwd

* test(worktrees): make teardown proof portable

* fix(sync): integrate project updates with directory owner

* fix(workspaces): close reconciliation edge cases

* refactor(sync): remove duplicate project reconciliation

Keep workspace and project deltas behind the host directory transaction, with owner-boundary coverage for snapshot replay and queued full reconciliation.

* fix(sync): buffer project updates before hydration

Start the workspace transaction with the online connection epoch so project broadcasts cannot publish a partial directory before the authoritative snapshot commits.
This commit is contained in:
Mohamed Boudra
2026-07-17 19:30:17 +02:00
committed by GitHub
parent 1977d330ed
commit 39cb3dbb9c
93 changed files with 7426 additions and 2762 deletions
@@ -33,12 +33,12 @@ import type {
AgentStreamEvent,
} from "./agent/agent-sdk-types.js";
import { createWorktree } from "../utils/worktree.js";
import { createRealpathAwarePathMatcher } from "../utils/path.js";
import {
readPaseoWorktreeMetadata,
writePaseoWorktreeFirstAgentBranchAutoNameMetadata,
writePaseoWorktreeMetadata,
} from "../utils/worktree-metadata.js";
import { WorktreeRequestError } from "./worktree-errors.js";
import type { WorkspaceGitRuntimeSnapshot } from "./workspace-git-service.js";
import type { GeneratedWorkspaceName } from "./worktree-branch-name-generator.js";
import { WorkspaceAutoName } from "./workspace-auto-name.js";
@@ -128,7 +128,6 @@ interface SessionTestAccess {
agentUpdates: AgentUpdatesService;
workspaceUpdatesSubscription: unknown;
interruptAgentIfRunning(agentId: string): unknown;
recreateArchivedWorktree(workspace: PersistedWorkspaceRecord): Promise<void>;
reconcileActiveWorkspaceRecords(...args: unknown[]): Promise<Set<string>>;
reconcileWorkspaceRecord(workspaceId: string): Promise<{
changed: boolean;
@@ -154,6 +153,10 @@ interface SessionTestAccess {
clearWorkspaceArchiving(workspaceIds: Iterable<string>): void;
emitWorkspaceUpdateForCwd(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForExternalWorkspaceIds(
workspaceIds: Iterable<string>,
options?: { skipReconcile?: boolean },
): Promise<void>;
emit(message: unknown): void;
onMessage(message: unknown): void;
paseoHome: string;
@@ -652,6 +655,15 @@ function createSessionForWorkspaceTests(
existsOnDisk: async () => true,
list: async () => [],
get: async () => null,
getOrCreateActiveByRoot: async (input) =>
createPersistedProjectRecord({
projectId: "prj_0000000000000000",
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
}),
upsert: async () => {},
archive: async () => {},
remove: async () => {},
@@ -844,7 +856,6 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
terminalManager: null,
}),
);
await session.handleMessage({
type: "create_agent_request",
requestId: "req-create-child",
@@ -857,7 +868,7 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
await expect(
session.buildProjectPlacementForWorkspaceId(createdAgent!.workspaceId!),
).resolves.toMatchObject({
projectKey: parent,
projectKey: expect.stringMatching(/^prj_[0-9a-f]{16}$/),
checkout: { cwd: child },
});
expect(findByType(emitted, "status")?.payload).toMatchObject({
@@ -869,6 +880,163 @@ test("create_agent_request keeps requested child cwd when grouped under an exist
}
});
test("create_agent_request launches from an exact subdirectory in a created worktree", async () => {
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-worktree-cwd-"));
try {
const parent = path.join(workdir, "parent");
const child = path.join(parent, "packages", "app");
mkdirSync(child, { recursive: true });
execFileSync("git", ["init", "-b", "main"], { cwd: parent, stdio: "pipe" });
execFileSync("git", ["config", "user.email", "test@getpaseo.local"], {
cwd: parent,
stdio: "pipe",
});
execFileSync("git", ["config", "user.name", "Paseo Test"], { cwd: parent, stdio: "pipe" });
writeFileSync(path.join(child, "README.md"), "app\n");
execFileSync("git", ["add", "."], { cwd: parent, stdio: "pipe" });
execFileSync("git", ["commit", "-m", "initial"], { cwd: parent, stdio: "pipe" });
const logger = {
child: () => logger,
trace: vi.fn(),
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
};
const agentStorage = new AgentStorage(path.join(workdir, "agents"), asSessionLogger(logger));
const agentManager = new AgentManager({
clients: { codex: new CreateAgentTestClient() },
registry: agentStorage,
logger: asSessionLogger(logger),
idFactory: () => "00000000-0000-4000-8000-000000000552",
});
const projectRegistry = new FileBackedProjectRegistry(
path.join(workdir, "projects.json"),
asSessionLogger(logger),
);
const workspaceRegistry = new FileBackedWorkspaceRegistry(
path.join(workdir, "workspaces.json"),
asSessionLogger(logger),
);
const workspaceGitService = createNoopWorkspaceGitService({
getCheckout: async (cwd: string) => ({
cwd,
isGit: true,
currentBranch: "main",
remoteUrl: null,
worktreeRoot: parent,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
resolveRepoRoot: async () => parent,
resolveDefaultBranch: async () => "main",
});
await projectRegistry.upsert(
createPersistedProjectRecord({
projectId: "proj-parent",
rootPath: parent,
kind: "git",
displayName: "parent",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
await workspaceRegistry.upsert(
createPersistedWorkspaceRecord({
workspaceId: "ws-parent",
projectId: "proj-parent",
cwd: parent,
kind: "local_checkout",
displayName: "parent",
createdAt: "2026-05-07T00:00:00.000Z",
updatedAt: "2026-05-07T00:00:00.000Z",
}),
);
const emitted: SessionOutboundMessage[] = [];
const session = new Session({
clientId: "test-client",
appVersion: null,
onMessage: (message) => emitted.push(message),
logger: asSessionLogger(logger),
downloadTokenStore: asDownloadTokenStore(),
pushTokenStore: asPushTokenStore(),
paseoHome: path.join(workdir, "paseo-home"),
agentManager,
agentStorage,
projectRegistry,
workspaceRegistry,
chatService: asChatService(),
scheduleService: asScheduleService(),
loopService: asLoopService(),
checkoutDiffManager: asCheckoutDiffManager({
subscribe: async () => ({
initial: { cwd: child, files: [], error: null },
unsubscribe: () => {},
}),
scheduleRefreshForCwd: () => {},
onWorkspaceStateMayHaveChanged: () => {},
getMetrics: () => ({
checkoutDiffTargetCount: 0,
checkoutDiffSubscriptionCount: 0,
checkoutDiffWatcherCount: 0,
checkoutDiffFallbackRefreshTargetCount: 0,
}),
dispose: () => {},
}),
workspaceGitService,
workspaceAutoName: new WorkspaceAutoName({
agentManager,
workspaceRegistry,
workspaceGitService,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
readDaemonConfig: () => ({ metadataGeneration: { providers: [] } }),
gitMutation: { notifyGitMutation: async () => {} },
emitWorkspaceUpdateForCwd: async () => {},
emitWorkspaceUpdateForWorkspaceId: async () => {},
logger: asSessionLogger(logger),
}),
daemonConfigStore: asDaemonConfigStore({
get: () => ({ mcp: { injectIntoAgents: false }, providers: {} }),
onChange: () => () => {},
}),
mcpBaseUrl: null,
stt: null,
tts: null,
providerSnapshotManager: createProviderSnapshotManagerStub().manager,
terminalManager: null,
});
await session.handleMessage({
type: "create_agent_request",
requestId: "req-create-worktree-child",
config: { provider: "codex", cwd: child },
attachments: [],
worktree: { mode: "branch-off", newBranch: "feature/created-worktree" },
});
const [createdAgent] = agentManager.listAgents();
const createdWorktreeRoot = execFileSync("git", ["rev-parse", "--show-toplevel"], {
cwd: createdAgent!.cwd,
stdio: "pipe",
})
.toString()
.trim();
expect(
createRealpathAwarePathMatcher(path.join(createdWorktreeRoot, "packages", "app"))(
createdAgent?.cwd ?? "",
),
).toBe(true);
expect(findByType(emitted, "status")?.payload).toMatchObject({
status: "agent_created",
agent: { cwd: createdAgent?.cwd },
});
} finally {
rmSync(workdir, { recursive: true, force: true });
}
});
test("create_agent_request does not title an existing workspace from the agent prompt", async () => {
vi.useFakeTimers();
const workdir = mkdtempSync(path.join(tmpdir(), "paseo-create-agent-existing-title-"));
@@ -2106,6 +2274,93 @@ test("non-git workspace uses deterministic directory name and no unknown branch
expect(result.entries[0]?.name).not.toBe("Unknown branch");
});
test("workspace placements preserve checkout facts independently from the project", async () => {
const session = createSessionForWorkspaceTests();
const manualWorktree = createPersistedWorkspaceRecord({
workspaceId: "ws-manual-worktree",
projectId: "proj-manual-worktree",
cwd: "/tmp/manual-worktree",
kind: "worktree",
displayName: "manual",
isPaseoOwnedWorktree: false,
mainRepoRoot: "/tmp/main-repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const explicitDirectory = createPersistedWorkspaceRecord({
workspaceId: "ws-explicit-directory",
projectId: "proj-manual-worktree",
cwd: "/tmp/plain-directory",
kind: "directory",
displayName: "plain",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const paseoSubdirectory = createPersistedWorkspaceRecord({
workspaceId: "ws-paseo-subdirectory",
projectId: "proj-manual-worktree",
cwd: "/tmp/paseo-worktree/packages/app",
kind: "worktree",
displayName: "app",
isPaseoOwnedWorktree: true,
mainRepoRoot: "/tmp/main-repo",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const project = createPersistedProjectRecord({
projectId: "proj-manual-worktree",
rootPath: "/tmp/main-repo",
kind: "git",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.workspaceRegistry.get = async (workspaceId: string) =>
[manualWorktree, explicitDirectory, paseoSubdirectory].find(
(workspace) => workspace.workspaceId === workspaceId,
) ?? null;
session.projectRegistry.get = async () => project;
session.workspaceGitService.peekSnapshot = (cwd: string) =>
cwd === paseoSubdirectory.cwd
? createWorkspaceRuntimeSnapshot(cwd, {
git: { repoRoot: "/tmp/paseo-worktree" },
})
: null;
await expect(
session.buildProjectPlacementForWorkspaceId(manualWorktree.workspaceId),
).resolves.toEqual(
expect.objectContaining({
checkout: expect.objectContaining({
isGit: true,
isPaseoOwnedWorktree: false,
mainRepoRoot: "/tmp/main-repo",
}),
}),
);
await expect(
session.buildProjectPlacementForWorkspaceId(explicitDirectory.workspaceId),
).resolves.toEqual(
expect.objectContaining({
checkout: expect.objectContaining({
isGit: false,
isPaseoOwnedWorktree: false,
mainRepoRoot: null,
}),
}),
);
await expect(
session.buildProjectPlacementForWorkspaceId(paseoSubdirectory.workspaceId),
).resolves.toEqual(
expect.objectContaining({
checkout: expect.objectContaining({
cwd: paseoSubdirectory.cwd,
worktreeRoot: "/tmp/paseo-worktree",
}),
}),
);
});
test("active-scoped fetch_agents includes only unarchived agents in active workspaces", async () => {
const session = createSessionForWorkspaceTests();
const archivedAt = "2026-03-02T12:00:00.000Z";
@@ -3345,7 +3600,7 @@ test("project.remove.request removes an already-empty project", async () => {
]);
});
test("create paseo worktree request returns a registered workspace descriptor", async () => {
test("create paseo worktree response preserves an explicit non-Git project", async () => {
const emitted: SessionOutboundMessage[] = [];
const createdAt = "2026-05-12T12:00:00.000Z";
vi.setSystemTime(new Date(createdAt));
@@ -3408,7 +3663,15 @@ test("create paseo worktree request returns a registered workspace descriptor",
);
const workspaces = new Map();
const projects = new Map();
const explicitProject = createPersistedProjectRecord({
projectId: "prj_explicit_non_git",
rootPath: path.join(tempDir, "selected-project"),
kind: "non_git",
displayName: "Selected project",
createdAt,
updatedAt: createdAt,
});
const projects = new Map([[explicitProject.projectId, explicitProject]]);
session.paseoHome = paseoHome;
session.workspaceRegistry.get = async (lookupWorkspaceId: string) =>
workspaces.get(lookupWorkspaceId) ?? null;
@@ -3420,6 +3683,22 @@ test("create paseo worktree request returns a registered workspace descriptor",
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.list = async () => Array.from(projects.values());
session.projectRegistry.getOrCreateActiveByRoot = async (input) => {
const existing = Array.from(projects.values()).find(
(project) => !project.archivedAt && project.rootPath === input.rootPath,
);
if (existing) return existing;
const project = createPersistedProjectRecord({
projectId: `prj_${projects.size.toString().padStart(16, "0")}`,
rootPath: input.rootPath,
kind: input.kind,
displayName: input.displayName,
createdAt: input.timestamp,
updatedAt: input.timestamp,
});
projects.set(project.projectId, project);
return project;
};
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
@@ -3432,6 +3711,7 @@ test("create paseo worktree request returns a registered workspace descriptor",
await session.handleCreatePaseoWorktreeRequest({
type: "create_paseo_worktree_request",
cwd: repoDir,
projectId: explicitProject.projectId,
worktreeSlug: "worktree-123",
requestId: "req-worktree",
});
@@ -3444,8 +3724,10 @@ test("create paseo worktree request returns a registered workspace descriptor",
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace).toMatchObject({
projectDisplayName: "repo",
projectKind: "git",
projectId: explicitProject.projectId,
projectDisplayName: explicitProject.displayName,
projectRootPath: explicitProject.rootPath,
projectKind: "non_git",
workspaceKind: "worktree",
name: "worktree-123",
status: "done",
@@ -3454,7 +3736,7 @@ test("create paseo worktree request returns a registered workspace descriptor",
expect(response?.payload.workspace?.id).toMatch(/^wks_[0-9a-f]{16}$/);
expect(response?.payload.workspace?.workspaceDirectory).toContain(path.join("worktree-123"));
expect(workspaces.has(response?.payload.workspace?.id ?? "")).toBe(true);
expect(projects.has(response?.payload.workspace?.projectId ?? "")).toBe(true);
expect(projects.get(explicitProject.projectId)).toEqual(explicitProject);
});
test("workspace update fanout for multiple cwd values is deduplicated", async () => {
@@ -3547,6 +3829,18 @@ test("open_project_request registers a workspace before any agent exists", async
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
const project = createPersistedProjectRecord({
projectId: "prj_githubruntime",
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
};
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
@@ -3860,6 +4154,18 @@ test("open_project_request emits a workspace_update with githubRuntime once the
if (isSessionOutboundMessage(message)) emitted.push(message);
};
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.projectRegistry.getOrCreateActiveByRoot = async (allocation) => {
const project = createPersistedProjectRecord({
projectId: "prj_githubruntime",
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
};
session.projectRegistry.upsert = async (
record: ReturnType<typeof createPersistedProjectRecord>,
) => {
@@ -4085,7 +4391,6 @@ test("open_project_request reclassifies an archived directory workspace when git
"orchestrate",
"desktop-daemon-settings",
);
const remoteProjectId = "remote:github.com/getpaseo/paseo";
const archivedAt = "2026-04-24T09:48:36.168Z";
const workspaceId = "ws-desktop-daemon-settings";
@@ -4163,12 +4468,9 @@ test("open_project_request reclassifies an archived directory workspace when git
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(remoteProjectId);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(projects.get(remoteProjectId)?.kind).toBe("git");
expect(workspaces.get(workspaceId)?.projectId).toBe(remoteProjectId);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
expect(projects.get(cwd)?.archivedAt).toBe(archivedAt);
expect(workspaces.get(workspaceId)?.archivedAt).toBe(archivedAt);
});
test("open_project_request reclassifies an active directory workspace when git metadata becomes available", async () => {
@@ -4282,14 +4584,11 @@ test("open_project_request reclassifies an active directory workspace when git m
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(workspaces.get(workspaceId)?.projectId).toBe(repoRoot);
expect(workspaces.get(workspaceId)?.kind).toBe("worktree");
expect(workspaces.get(workspaceId)?.displayName).toBe("feature/desktop-daemon-settings");
expect(response?.payload.workspace?.projectId).toBe(cwd);
expect(workspaces.get(workspaceId)?.projectId).toBe(cwd);
});
test("open_project_request groups a plain git worktree under an existing repo project", async () => {
test("open_project_request gives a plain git worktree its own exact-root project", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
@@ -4375,16 +4674,14 @@ test("open_project_request groups a plain git worktree under an existing repo pr
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.projectId).toBe(repoRoot);
expect(response?.payload.workspace?.workspaceKind).toBe("worktree");
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
const worktreeWorkspace = Array.from(workspaces.values()).find(
(workspace) => workspace.cwd === cwd,
);
expect(worktreeWorkspace?.projectId).toBe(repoRoot);
expect(worktreeWorkspace?.kind).toBe("worktree");
expect(worktreeWorkspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
});
test("open_project_request unarchives an existing archived workspace and project", async () => {
test("open_project_request keeps archived records and allocates a fresh workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
@@ -4443,14 +4740,15 @@ test("open_project_request unarchives an existing archived workspace and project
requestId: "req-open-unarchive",
});
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)?.archivedAt).toBeNull();
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
expect(projects.get(cwd)?.archivedAt).not.toBeNull();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(workspaceId);
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
});
test("open_project_request recreates a missing project record when unarchiving its workspace", async () => {
test("open_project_request does not repurpose an orphaned archived workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
@@ -4497,18 +4795,12 @@ test("open_project_request recreates a missing project record when unarchiving i
requestId: "req-open-removed-project",
});
expect(projects.get(cwd)).toEqual(
expect.objectContaining({
projectId: cwd,
displayName: "repo",
archivedAt: null,
}),
);
expect(workspaces.get(workspaceId)?.archivedAt).toBeNull();
expect(projects.get(cwd)).toBeUndefined();
expect(workspaces.get(workspaceId)?.archivedAt).not.toBeNull();
const response = findByType(emitted, "open_project_response");
expect(response?.payload.error).toBeNull();
expect(response?.payload.workspace?.id).toBe(workspaceId);
expect(response?.payload.workspace?.projectDisplayName).toBe("repo");
expect(response?.payload.workspace?.id).not.toBe(workspaceId);
expect(response?.payload.workspace?.projectId).toMatch(/^prj_[0-9a-f]{16}$/);
});
test("workspace recovery stays accepted when git observer warming fails", async () => {
@@ -5099,68 +5391,6 @@ test("legacy refresh_agent_request restores a real deleted worktree", async () =
rmSync(tempDir, { recursive: true, force: true });
});
test("recreateArchivedWorktree throws a typed WorktreeRequestError when the project root is missing", async () => {
const { tempDir, repoDir } = createRecreateWorktreeRepo();
const branch = "feature/keep";
execFileSync("git", ["branch", branch], { cwd: repoDir, stdio: "pipe" });
const worktreesRoot = path.join(tempDir, "worktrees");
const paseoHome = path.join(tempDir, "paseo-home");
const created = await createWorktree({
cwd: repoDir,
worktreeSlug: "keep",
source: { kind: "checkout-branch", branchName: branch },
runSetup: false,
paseoHome,
worktreesRoot,
});
const worktreePath = realpathSync(created.worktreePath);
const session = createSessionForWorkspaceTests({ paseoHome, worktreesRoot });
const projects = new Map<string, ReturnType<typeof createPersistedProjectRecord>>();
const workspaces = new Map<string, ReturnType<typeof createPersistedWorkspaceRecord>>();
const workspaceId = "ws-missing-root";
const projectId = repoDir;
const missingRoot = path.join(tempDir, "does-not-exist");
projects.set(
projectId,
createPersistedProjectRecord({
projectId,
rootPath: missingRoot,
kind: "git",
displayName: "worktree-project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
}),
);
const workspaceRecord = createPersistedWorkspaceRecord({
workspaceId,
projectId,
cwd: worktreePath,
kind: "worktree",
branch,
displayName: branch,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-10T00:00:00.000Z",
archivedAt: "2026-03-10T00:00:00.000Z",
});
workspaces.set(workspaceId, workspaceRecord);
session.projectRegistry.get = async (id: string) => projects.get(id) ?? null;
session.workspaceRegistry.get = async (id: string) => workspaces.get(id) ?? null;
session.filesystem.isDirectory = async (target: string) =>
existsSync(target) && statSync(target).isDirectory();
await expect(session.recreateArchivedWorktree(workspaceRecord)).rejects.toBeInstanceOf(
WorktreeRequestError,
);
// Guard fires before createWorktree, so archivedAt is untouched.
expect(workspaces.get(workspaceId)?.archivedAt).toBe("2026-03-10T00:00:00.000Z");
rmSync(tempDir, { recursive: true, force: true });
});
test.skip("open_project_request collapses a git subdirectory onto the repo root workspace", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
@@ -5747,6 +5977,40 @@ test("listWorkspaceDescriptorsSnapshot keeps git workspaces on the baseline desc
expect(descriptors).toEqual([baselineDescriptor]);
});
test("lists Git runtime for a checkout explicitly owned by a non-Git project", async () => {
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-explicit-directory",
rootPath: "/tmp/explicit-directory",
kind: "non_git",
displayName: "directory project",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-explicit-checkout",
projectId: project.projectId,
cwd: REPO_CWD,
kind: "local_checkout",
displayName: "main",
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
session.listAgentPayloads = async () => [];
session.projectRegistry.list = async () => [project];
session.workspaceRegistry.list = async () => [workspace];
session.workspaceGitService.peekSnapshot = () => createWorkspaceRuntimeSnapshot(REPO_CWD);
const descriptors = Array.from(
(await session.buildWorkspaceDescriptorMap({ includeGitData: true })).values(),
) as Array<{ gitRuntime?: { currentBranch: string | null }; githubRuntime?: unknown }>;
expect(descriptors[0]).toMatchObject({
gitRuntime: { currentBranch: "main" },
githubRuntime: expect.any(Object),
});
});
test("buildWorkspaceDescriptorMap computes statusEnteredAt from runtime agent fields", async () => {
const setupSession = () => {
const session = createSessionForWorkspaceTests();
@@ -6188,6 +6452,90 @@ test("emitWorkspaceUpdatesForWorkspaceIds includes archiving state and dedupes u
]);
});
test("external workspace updates emit one deduplicated batch without reconciling", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests();
const project = createPersistedProjectRecord({
projectId: "proj-observer-batch",
rootPath: "/tmp/observer-batch",
kind: "non_git",
displayName: "observer-batch",
createdAt: "2026-07-15T00:00:00.000Z",
updatedAt: "2026-07-15T00:00:00.000Z",
});
const main = createPersistedWorkspaceRecord({
workspaceId: "ws-observer-main",
projectId: project.projectId,
cwd: "/tmp/observer-batch/main",
kind: "directory",
displayName: "main",
createdAt: "2026-07-15T00:00:00.000Z",
updatedAt: "2026-07-15T00:00:00.000Z",
});
const feature = createPersistedWorkspaceRecord({
workspaceId: "ws-observer-feature",
projectId: project.projectId,
cwd: "/tmp/observer-batch/feature",
kind: "directory",
displayName: "feature",
createdAt: "2026-07-15T00:00:00.000Z",
updatedAt: "2026-07-15T00:00:00.000Z",
});
const snapshotReads = { projects: 0, workspaces: 0 };
session.projectRegistry.list = async () => {
snapshotReads.projects += 1;
return [project];
};
session.workspaceRegistry.list = async () => {
snapshotReads.workspaces += 1;
return [main, feature];
};
session.listAgentPayloads = async () => [];
session.workspaceUpdatesSubscription = {
subscriptionId: "sub-observer-batch",
filter: undefined,
isBootstrapping: false,
pendingUpdatesByWorkspaceId: new Map(),
lastEmittedByWorkspaceId: new Map(),
};
session.onMessage = (message) => {
if (isSessionOutboundMessage(message)) emitted.push(message);
};
await session.emitWorkspaceUpdatesForExternalWorkspaceIds(
[main.workspaceId, feature.workspaceId, main.workspaceId],
{ skipReconcile: true },
);
expect(filterByType(emitted, "workspace_update")).toEqual([
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: expect.objectContaining({
id: main.workspaceId,
projectId: project.projectId,
workspaceDirectory: main.cwd,
name: main.displayName,
}),
},
},
{
type: "workspace_update",
payload: {
kind: "upsert",
workspace: expect.objectContaining({
id: feature.workspaceId,
projectId: project.projectId,
workspaceDirectory: feature.cwd,
name: feature.displayName,
}),
},
},
]);
expect(snapshotReads).toEqual({ projects: 1, workspaces: 1 });
});
test("fetch_workspaces_response reads runtime fields from passive workspace git service snapshots", async () => {
const emitted: SessionOutboundMessage[] = [];
const runtimeSnapshot = createWorkspaceRuntimeSnapshot(REPO_CWD, {
@@ -7497,6 +7845,18 @@ test("workspace.create worktree source checks out a GitHub PR from githubPrNumbe
existsOnDisk: async () => true,
list: async () => Array.from(projects.values()),
get: async (projectId: string) => projects.get(projectId) ?? null,
getOrCreateActiveByRoot: async (allocation) => {
const project = createPersistedProjectRecord({
projectId: "prj_worktreefixture",
rootPath: allocation.rootPath,
kind: allocation.kind,
displayName: allocation.displayName,
createdAt: allocation.timestamp,
updatedAt: allocation.timestamp,
});
projects.set(project.projectId, project);
return project;
},
upsert: async (record) => {
projects.set(record.projectId, record);
},
@@ -7691,7 +8051,7 @@ test("workspace auto-name replaces the unchanged prompt title", async () => {
}
});
test("workspace auto-name applies title once when branch auto-name is rejected", async () => {
test("workspace auto-name uses the backing root for a nested worktree", async () => {
vi.useFakeTimers();
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "workspace-auto-name-rejected-")));
const repoDir = path.join(tempDir, "repo");
@@ -7711,15 +8071,19 @@ test("workspace auto-name applies title once when branch auto-name is rejected",
writePaseoWorktreeFirstAgentBranchAutoNameMetadata(repoDir, {
placeholderBranchName: "placeholder-branch",
});
const workspaceCwd = path.join(repoDir, "packages", "app");
mkdirSync(workspaceCwd, { recursive: true });
const workspace = createPersistedWorkspaceRecord({
workspaceId: "ws-rejected-branch-title",
projectId: "proj-rejected-branch-title",
cwd: repoDir,
cwd: workspaceCwd,
kind: "worktree",
displayName: "Fix checkout title",
title: "Fix checkout title",
branch: "placeholder-branch",
worktreeRoot: repoDir,
isPaseoOwnedWorktree: true,
createdAt: "2026-03-01T12:00:00.000Z",
updatedAt: "2026-03-01T12:00:00.000Z",
});
@@ -7779,7 +8143,7 @@ test("workspace auto-name applies title once when branch auto-name is rejected",
},
});
expect(gitMutations).toEqual([]);
expect(emittedCwds).toEqual([repoDir]);
expect(emittedCwds).toEqual([workspaceCwd]);
} finally {
vi.useRealTimers();
rmSync(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
@@ -7957,3 +8321,93 @@ test("workspace create stays out of a non-matching workspace subscription", asyn
expect(filterByType(emitted, "workspace_update")).toEqual([]);
});
test("workspace.create.request attaches a directory workspace to its explicit active project", async () => {
const emitted: SessionOutboundMessage[] = [];
const projects = new Map([
[
"prj_explicit",
createPersistedProjectRecord({
projectId: "prj_explicit",
rootPath: path.join(REPO_CWD, "unrelated"),
kind: "non_git",
displayName: "unrelated",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
}),
],
]);
const workspaces = new Map<string, PersistedWorkspaceRecord>();
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.projectRegistry.get = async (projectId: string) => projects.get(projectId) ?? null;
session.workspaceRegistry.upsert = async (record: unknown) => {
const workspace = record as PersistedWorkspaceRecord;
workspaces.set(workspace.workspaceId, workspace);
};
session.workspaceRegistry.get = async (workspaceId: string) =>
workspaces.get(workspaceId) ?? null;
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-explicit-project",
source: { kind: "directory", path: REPO_CWD, projectId: "prj_explicit" },
});
const response = findByType(emitted, "workspace.create.response");
expect(response?.payload).toMatchObject({
requestId: "req-explicit-project",
error: null,
workspace: { projectId: "prj_explicit" },
});
const workspaceId = response?.payload.workspace?.id;
expect(workspaceId).toEqual(expect.any(String));
expect(workspaces.get(workspaceId as string)).toMatchObject({
cwd: REPO_CWD,
projectId: "prj_explicit",
});
});
test("workspace.create.request reports an unknown explicit project", async () => {
const emitted: SessionOutboundMessage[] = [];
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-unknown-project",
source: { kind: "directory", path: REPO_CWD, projectId: "prj_missing" },
});
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
requestId: "req-unknown-project",
workspace: null,
errorCode: "unknown_project",
});
});
test("workspace.create.request reports an archived explicit project", async () => {
const emitted: SessionOutboundMessage[] = [];
const archivedProject = createPersistedProjectRecord({
projectId: "prj_archived",
rootPath: path.join(REPO_CWD, "unrelated"),
kind: "non_git",
displayName: "unrelated",
createdAt: "2026-03-01T00:00:00.000Z",
updatedAt: "2026-03-01T00:00:00.000Z",
archivedAt: "2026-03-02T00:00:00.000Z",
});
const session = createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) });
session.projectRegistry.get = async (projectId: string) =>
projectId === archivedProject.projectId ? archivedProject : null;
await session.handleMessage({
type: "workspace.create.request",
requestId: "req-archived-project",
source: { kind: "directory", path: REPO_CWD, projectId: "prj_archived" },
});
expect(findByType(emitted, "workspace.create.response")?.payload).toMatchObject({
requestId: "req-archived-project",
workspace: null,
errorCode: "archived_project",
});
});