Files
paseo/packages/app/src/runtime/directory-sync/workspace-replica.test.ts
T
Stephen ZhouandClaude Opus 5.5 632a7f8859 fix(app): keep live workspace rows when a late creation snapshot arrives (#7758)
A workspace.create.response carries the descriptor captured at provision
time but is sent after the initial agent starts. A pin or title change made
in between reaches the app first as a live update; the late response then
replaced that row with the older snapshot, so a new pin vanished from the
sidebar until the next directory update.

Creation and progress snapshots now only seed rows the replica has not
received; existing rows stay owned by directory updates. Archive rollback
still restores a row that was removed.

Closes #5447

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 01:05:57 +02:00

381 lines
14 KiB
TypeScript

import { expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
} from "@/stores/session-store";
import {
clearWorkspaceArchivePending,
markWorkspaceArchivePending,
} from "@/contexts/session-workspace-upserts";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { WorkspaceDirectoryReplica } from "./workspace-replica";
function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload {
return {
id,
projectId,
projectDisplayName: projectId,
projectRootPath: `/repo/${projectId}`,
workspaceDirectory: `/repo/${projectId}/${id}`,
projectKind: "git",
workspaceKind: "worktree",
name: id,
title: id,
status: "done",
activityAt: null,
statusEnteredAt: null,
archivingAt: null,
diffStat: null,
scripts: [],
};
}
it("commits workspace and project-parent state with filtered removals", () => {
const serverId = "workspace-replica";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const empty = normalizeProjectDescriptor({
projectId: "empty",
projectDisplayName: "Empty",
projectRootPath: "/repo/empty",
projectKind: "git",
});
replica.commitSnapshot(
{
workspaces: new Map([
["kept", normalizeWorkspaceDescriptor(workspace("kept"))],
["filtered", normalizeWorkspaceDescriptor(workspace("filtered", "filtered-project"))],
]),
projects: new Map([[empty.projectId, empty]]),
},
[{ kind: "remove", id: "filtered", removedProjectId: "filtered-project" }],
);
const session = useSessionStore.getState().sessions[serverId];
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["kept"]);
expect(Array.from(session?.projects.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 = normalizeProjectDescriptor({
projectId: "attached",
projectDisplayName: "Stale attached project",
projectRootPath: "/repo/attached",
projectKind: "git",
});
const removedProject = normalizeProjectDescriptor({
projectId: "removed",
projectDisplayName: "Removed project",
projectRootPath: "/repo/removed",
projectKind: "git",
});
const unchangedEmptyProject = normalizeProjectDescriptor({
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],
]),
projects: new Map([
[staleAttachedProject.projectId, staleAttachedProject],
[removedProject.projectId, removedProject],
[unchangedEmptyProject.projectId, unchangedEmptyProject],
]),
},
[
{
kind: "upsert",
project: {
projectId: "attached",
projectKey: "remote:github.com/acme/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?.projects.keys() ?? [])).toEqual([
"attached",
"unchanged-empty",
"new-empty",
]);
expect(session?.projects.get("unchanged-empty")).toBe(unchangedEmptyProject);
expect(session?.projects.get("new-empty")).toMatchObject({
projectDisplayName: "New empty project",
projectRootPath: "/repo/new-empty",
});
store.clearSession(serverId);
});
it("preserves unchanged project identity when another project changes", () => {
const serverId = "project-identity";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const first = normalizeProjectDescriptor({
projectId: "first",
projectDisplayName: "First",
projectRootPath: "/repo/first",
projectKind: "git",
});
const second = normalizeProjectDescriptor({
projectId: "second",
projectDisplayName: "Second",
projectRootPath: "/repo/second",
projectKind: "git",
});
replica.commitSnapshot(
{
workspaces: new Map(),
projects: new Map([
["first", first],
["second", second],
]),
},
[],
);
const previousSecond = useSessionStore.getState().sessions[serverId]?.projects.get("second");
replica.commitSnapshot(
{
workspaces: new Map(),
projects: new Map([
["first", { ...first, projectDisplayName: "Updated" }],
["second", { ...second }],
]),
},
[],
);
expect(useSessionStore.getState().sessions[serverId]?.projects.get("second")).toBe(
previousSecond,
);
store.clearSession(serverId);
});
it("does not invent a null-key project from a workspace update", () => {
const serverId = "workspace-before-project-update";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
replica.applyDelta({ kind: "upsert", workspace: workspace("main", "fresh-project") });
const session = useSessionStore.getState().sessions[serverId];
expect(session?.workspaces.has("main")).toBe(true);
expect(session?.projects.has("fresh-project")).toBe(false);
store.clearSession(serverId);
});
it("does not restore a targeted cached workspace while its archive is pending", () => {
const serverId = "cached-workspace-during-archive";
const workspaceId = "archived-workspace";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
markWorkspaceArchivePending({ serverId, workspaceId });
try {
replica.commitCachedWorkspace(normalizeWorkspaceDescriptor(workspace(workspaceId)), undefined);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(workspaceId)).toBe(false);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId });
store.clearSession(serverId);
}
});
it("setup metadata from directory snapshots and transitions resets failure surfacing without details", () => {
const serverId = "setup-metadata";
useSessionStore.getState().initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const identity = { serverId, workspaceId: "setup" };
const store = useWorkspaceSetupStore.getState();
replica.commitSnapshot(
{
workspaces: new Map([
["setup", normalizeWorkspaceDescriptor({ ...workspace("setup"), setupStatus: "blocked" })],
]),
projects: new Map(),
},
[],
);
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
"blocked",
);
expect(store.claimFailedSetupSurface({ ...identity, status: "blocked" })).toBe(true);
replica.applyDelta({
kind: "upsert",
workspace: { ...workspace("setup"), setupStatus: "running" },
});
replica.applyDelta({
kind: "upsert",
workspace: { ...workspace("setup"), setupStatus: "failed" },
});
expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe(
"failed",
);
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(true);
expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false);
expect(useWorkspaceSetupStore.getState().snapshots).toEqual({});
});
it("keeps a live workspace update when an older creation snapshot arrives", () => {
const serverId = "workspace-create-after-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = workspace("new");
const pinned = {
...initial,
pinnedAt: "2026-09-26T00:00:00.000Z",
title: "Updated title",
};
try {
replica.applyDelta({ kind: "upsert", workspace: pinned });
const accepted = useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id);
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(initial)])).toEqual([]);
expect(replica.snapshot().workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(pinned),
);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toBe(
accepted,
);
} finally {
store.clearSession(serverId);
}
});
it("seeds missing workspaces before live updates and in a mixed creation batch", () => {
const serverId = "workspace-create-before-pin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
const second = normalizeWorkspaceDescriptor(workspace("second"));
const pinned = { ...workspace(initial.id), pinnedAt: "2026-09-26T00:00:00.000Z" };
try {
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
replica.applyDelta({ kind: "upsert", workspace: pinned });
expect(replica.acceptWorkspaces([initial, second])).toEqual([
{ kind: "workspace", type: "upsert", id: second.id, value: second },
]);
expect([...useSessionStore.getState().sessions[serverId]!.workspaces.values()]).toEqual([
normalizeWorkspaceDescriptor(pinned),
second,
]);
} finally {
store.clearSession(serverId);
}
});
it("keeps a manual unpin when a stale pinned snapshot arrives", () => {
const serverId = "workspace-manual-unpin";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const pinned = { ...workspace("new"), pinnedAt: "2026-09-26T00:00:00.000Z" };
const unpinned = { ...pinned, pinnedAt: null };
try {
replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)]);
replica.applyDelta({ kind: "upsert", workspace: unpinned });
expect(replica.acceptWorkspaces([normalizeWorkspaceDescriptor(pinned)])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(pinned.id)).toEqual(
normalizeWorkspaceDescriptor(unpinned),
);
} finally {
store.clearSession(serverId);
}
});
it("restores a missing workspace after an archive failure without replacing a live row", () => {
const serverId = "workspace-archive-rollback";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new WorkspaceDirectoryReplica(serverId);
const initial = normalizeWorkspaceDescriptor(workspace("new"));
try {
replica.acceptWorkspaces([initial]);
markWorkspaceArchivePending({ serverId, workspaceId: initial.id });
replica.removeWorkspaceSnapshot(initial.id);
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(initial.id)).toBe(false);
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
expect(replica.acceptWorkspaces([initial])).toEqual([
{ kind: "workspace", type: "upsert", id: initial.id, value: initial },
]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
initial,
);
const updated = { ...workspace(initial.id), title: "Updated during archive" };
replica.applyDelta({ kind: "upsert", workspace: updated });
expect(replica.acceptWorkspaces([initial])).toEqual([]);
expect(useSessionStore.getState().sessions[serverId]?.workspaces.get(initial.id)).toEqual(
normalizeWorkspaceDescriptor(updated),
);
} finally {
clearWorkspaceArchivePending({ serverId, workspaceId: initial.id });
store.clearSession(serverId);
}
});