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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -107,6 +107,10 @@ export class DirectorySync {
|
||||
if (!connection.client || connection.status !== "online") return true;
|
||||
const client = connection.client;
|
||||
const source = connection.source;
|
||||
this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
emptyProjects: new Map(),
|
||||
}));
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
|
||||
@@ -119,6 +123,12 @@ export class DirectorySync {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("project.update", (message) => {
|
||||
if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
|
||||
if (!this.workspaceTransactions.record(source, message.payload)) {
|
||||
this.workspaces.applyDelta(message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("agent_deleted", (message) => {
|
||||
if (message.type === "agent_deleted" && this.isCurrent(client, source)) {
|
||||
this.agents.remove(message.payload.agentId);
|
||||
|
||||
@@ -55,3 +55,91 @@ it("commits workspace and project-parent state with filtered removals", () => {
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["empty"]);
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
it("commits the authoritative snapshot before buffered project updates", () => {
|
||||
const serverId = "project-update-replica";
|
||||
const store = useSessionStore.getState();
|
||||
store.initializeSession(serverId, null as unknown as DaemonClient);
|
||||
const replica = new WorkspaceDirectoryReplica(serverId);
|
||||
const attachedMain = normalizeWorkspaceDescriptor(workspace("attached-main", "attached"));
|
||||
const attachedFeature = normalizeWorkspaceDescriptor(workspace("attached-feature", "attached"));
|
||||
const removed = normalizeWorkspaceDescriptor(workspace("removed", "removed"));
|
||||
const unrelated = normalizeWorkspaceDescriptor(workspace("unrelated", "unrelated"));
|
||||
const staleAttachedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Stale attached project",
|
||||
projectRootPath: "/repo/attached",
|
||||
projectKind: "git",
|
||||
});
|
||||
const removedProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "removed",
|
||||
projectDisplayName: "Removed project",
|
||||
projectRootPath: "/repo/removed",
|
||||
projectKind: "git",
|
||||
});
|
||||
const unchangedEmptyProject = normalizeEmptyProjectDescriptor({
|
||||
projectId: "unchanged-empty",
|
||||
projectDisplayName: "Unchanged empty project",
|
||||
projectRootPath: "/repo/unchanged-empty",
|
||||
projectKind: "git",
|
||||
});
|
||||
|
||||
replica.commitSnapshot(
|
||||
{
|
||||
workspaces: new Map([
|
||||
[attachedMain.id, attachedMain],
|
||||
[attachedFeature.id, attachedFeature],
|
||||
[removed.id, removed],
|
||||
[unrelated.id, unrelated],
|
||||
]),
|
||||
emptyProjects: new Map([
|
||||
[staleAttachedProject.projectId, staleAttachedProject],
|
||||
[removedProject.projectId, removedProject],
|
||||
[unchangedEmptyProject.projectId, unchangedEmptyProject],
|
||||
]),
|
||||
},
|
||||
[
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "attached",
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{
|
||||
kind: "upsert",
|
||||
project: {
|
||||
projectId: "new-empty",
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
projectKind: "directory",
|
||||
},
|
||||
},
|
||||
{ kind: "remove", projectId: "removed" },
|
||||
],
|
||||
);
|
||||
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
expect(session?.workspaces.get(attachedMain.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectCustomName: "Personal name",
|
||||
projectRootPath: "/moved/attached",
|
||||
projectKind: "directory",
|
||||
});
|
||||
expect(session?.workspaces.get(attachedFeature.id)).toMatchObject({
|
||||
projectDisplayName: "Renamed attached project",
|
||||
projectRootPath: "/moved/attached",
|
||||
});
|
||||
expect(session?.workspaces.has(removed.id)).toBe(false);
|
||||
expect(session?.workspaces.get(unrelated.id)).toBe(unrelated);
|
||||
expect(Array.from(session?.emptyProjects.keys() ?? [])).toEqual(["unchanged-empty", "new-empty"]);
|
||||
expect(session?.emptyProjects.get("unchanged-empty")).toBe(unchangedEmptyProject);
|
||||
expect(session?.emptyProjects.get("new-empty")).toMatchObject({
|
||||
projectDisplayName: "New empty project",
|
||||
projectRootPath: "/repo/new-empty",
|
||||
});
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
|
||||
@@ -14,20 +14,50 @@ import {
|
||||
|
||||
export type WorkspaceDirectoryDelta = Extract<
|
||||
SessionOutboundMessage,
|
||||
{ type: "workspace_update" }
|
||||
{ type: "workspace_update" | "project.update" }
|
||||
>["payload"];
|
||||
type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.update" }>["payload"];
|
||||
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
emptyProjects: Map<string, EmptyProjectDescriptor>;
|
||||
}
|
||||
|
||||
function applyProjectDelta(
|
||||
snapshot: WorkspaceDirectorySnapshot,
|
||||
delta: ProjectDirectoryDelta,
|
||||
): void {
|
||||
if (delta.kind === "remove") {
|
||||
snapshot.emptyProjects.delete(delta.projectId);
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId === delta.projectId) snapshot.workspaces.delete(workspaceId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const project = normalizeEmptyProjectDescriptor(delta.project);
|
||||
let hasAttachedWorkspace = false;
|
||||
for (const [workspaceId, workspace] of snapshot.workspaces) {
|
||||
if (workspace.projectId !== project.projectId) continue;
|
||||
hasAttachedWorkspace = true;
|
||||
snapshot.workspaces.set(workspaceId, {
|
||||
...workspace,
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
});
|
||||
}
|
||||
if (hasAttachedWorkspace) snapshot.emptyProjects.delete(project.projectId);
|
||||
else snapshot.emptyProjects.set(project.projectId, project);
|
||||
}
|
||||
|
||||
export class WorkspaceDirectoryReplica {
|
||||
constructor(private readonly serverId: string) {}
|
||||
|
||||
applyDelta(delta: WorkspaceDirectoryDelta): void {
|
||||
const state = this.reconcile(this.read(), [delta]);
|
||||
this.commit(state, delta.kind === "remove" ? [delta.id] : []);
|
||||
this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []);
|
||||
}
|
||||
|
||||
commitSnapshot(
|
||||
@@ -35,9 +65,10 @@ export class WorkspaceDirectoryReplica {
|
||||
deltas: readonly WorkspaceDirectoryDelta[],
|
||||
): void {
|
||||
const removedWorkspaceIds = deltas.flatMap((delta) =>
|
||||
delta.kind === "remove" ? [delta.id] : [],
|
||||
delta.kind === "remove" && "id" in delta ? [delta.id] : [],
|
||||
);
|
||||
this.commit(this.reconcile(snapshot, deltas), removedWorkspaceIds);
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||
}
|
||||
|
||||
private read(): WorkspaceDirectorySnapshot {
|
||||
@@ -60,6 +91,10 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
}
|
||||
for (const delta of deltas) {
|
||||
if ("projectId" in delta || "project" in delta) {
|
||||
applyProjectDelta({ workspaces, emptyProjects }, delta);
|
||||
continue;
|
||||
}
|
||||
if (delta.kind === "remove") {
|
||||
workspaces.delete(delta.id);
|
||||
if (delta.emptyProject) {
|
||||
@@ -84,7 +119,6 @@ export class WorkspaceDirectoryReplica {
|
||||
const store = useSessionStore.getState();
|
||||
store.setWorkspaces(this.serverId, snapshot.workspaces);
|
||||
store.setEmptyProjects(this.serverId, snapshot.emptyProjects.values());
|
||||
store.setHasHydratedWorkspaces(this.serverId, true);
|
||||
for (const workspaceId of removedWorkspaceIds) {
|
||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId });
|
||||
useWorkspaceSetupStore.getState().removeWorkspace({ serverId: this.serverId, workspaceId });
|
||||
|
||||
Reference in New Issue
Block a user