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
@@ -1,14 +1,43 @@
import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
class FakeDirectoryClient {
fetchAgentsCalls = 0;
fetchWorkspacesCalls = 0;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private readonly handlers = new Map<
SessionOutboundMessage["type"],
Set<(message: SessionOutboundMessage) => void>
>();
on(): () => void {
return () => undefined;
on<TType extends SessionOutboundMessage["type"]>(
type: TType,
handler: (message: Extract<SessionOutboundMessage, { type: TType }>) => void,
): () => void {
const handlers = this.handlers.get(type) ?? new Set();
const registered = handler as unknown as (message: SessionOutboundMessage) => void;
handlers.add(registered);
this.handlers.set(type, handlers);
return () => handlers.delete(registered);
}
emit<TType extends SessionOutboundMessage["type"]>(
message: Extract<SessionOutboundMessage, { type: TType }>,
): void {
for (const handler of this.handlers.get(message.type) ?? []) handler(message);
}
holdWorkspaceFetch(): (result: WorkspaceFetchResult) => void {
let complete!: (result: WorkspaceFetchResult) => void;
this.pendingWorkspaceFetch = new Promise((resolve) => {
complete = resolve;
});
return complete;
}
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
@@ -20,8 +49,13 @@ class FakeDirectoryClient {
};
}
async fetchWorkspaces(): Promise<Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>> {
async fetchWorkspaces(): Promise<WorkspaceFetchResult> {
this.fetchWorkspacesCalls += 1;
if (this.pendingWorkspaceFetch) {
const pending = this.pendingWorkspaceFetch;
this.pendingWorkspaceFetch = null;
return pending;
}
return {
requestId: "workspaces",
entries: [],
@@ -110,4 +144,114 @@ describe("DirectorySync session readiness", () => {
expect(client.fetchAgentsCalls).toBe(1);
directory.dispose();
});
it("buffers workspace and project updates in the same hydration transaction", async () => {
const serverId = "workspace-project-transaction";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
const completeFetch = client.holdWorkspaceFetch();
const refresh = directory.refreshWorkspaces({ subscribe: true });
await Promise.resolve();
client.emit({
type: "workspace_update",
payload: {
kind: "remove",
id: "removed-workspace",
emptyProject: {
projectId: "workspace-project",
projectDisplayName: "Project from workspace update",
projectRootPath: "/repo/workspace-project",
projectKind: "git",
},
},
});
client.emit({
type: "project.update",
payload: {
kind: "upsert",
project: {
projectId: "snapshot-project",
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
},
},
});
completeFetch({
requestId: "workspaces",
entries: [],
emptyProjects: [
{
projectId: "snapshot-project",
projectDisplayName: "Stale snapshot project",
projectRootPath: "/repo/snapshot-project",
projectKind: "git",
},
],
pageInfo: { hasMore: false, nextCursor: null, prevCursor: null },
});
await refresh;
const emptyProjects = useSessionStore.getState().sessions[serverId]?.emptyProjects;
expect(Array.from(emptyProjects?.keys() ?? [])).toEqual([
"snapshot-project",
"workspace-project",
]);
expect(emptyProjects?.get("snapshot-project")).toMatchObject({
projectDisplayName: "Renamed during hydration",
projectRootPath: "/moved/snapshot-project",
projectKind: "directory",
});
expect(emptyProjects?.get("workspace-project")).toMatchObject({
projectDisplayName: "Project from workspace update",
});
directory.dispose();
});
it("buffers project updates from the online epoch before workspace hydration starts", async () => {
const serverId = "project-before-workspace-hydration";
const { client, directory } = createDirectory(serverId);
const store = useSessionStore.getState();
store.initializeSession(serverId, client as unknown as DaemonClient, 1);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true },
});
client.emit({
type: "project.update",
payload: {
kind: "upsert",
project: {
projectId: "early-project",
projectDisplayName: "Early project",
projectRootPath: "/repo/early-project",
projectKind: "git",
},
},
});
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(false);
await directory.refreshWorkspaces({ subscribe: true });
expect(useSessionStore.getState().sessions[serverId]?.hasHydratedWorkspaces).toBe(true);
expect(
useSessionStore.getState().sessions[serverId]?.emptyProjects.get("early-project"),
).toMatchObject({
projectDisplayName: "Early project",
projectRootPath: "/repo/early-project",
});
directory.dispose();
});
});