* feat(sync): restore host directories before reconnect Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal. * fix(sync): preserve active turn identity during catch-up Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response. * test(e2e): inspect the real IndexedDB replica Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well. * fix warm reconnect cache paths * feat(sync): persist project icons and deepen directory sync * fix(sync): preserve project update order * fix(cache): preserve validated replica persistence * fix(sync): retain cached agents during catch-up
443 lines
15 KiB
TypeScript
443 lines
15 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
|
|
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
|
import {
|
|
normalizeProjectDescriptor,
|
|
normalizeWorkspaceDescriptor,
|
|
selectAgentTimelineState,
|
|
useSessionStore,
|
|
} from "@/stores/session-store";
|
|
import { createUserMessage, type StreamItem } from "@/types/stream";
|
|
import { ReplicaCache, type ReplicaCacheStorage } from ".";
|
|
|
|
const SERVER_ID = "cached-host";
|
|
const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const;
|
|
|
|
class MemoryStorage implements ReplicaCacheStorage {
|
|
readonly values = new Map<string, string>();
|
|
writes = 0;
|
|
|
|
async getItem(key: string): Promise<string | null> {
|
|
return this.values.get(key) ?? null;
|
|
}
|
|
|
|
async setItem(key: string, value: string): Promise<void> {
|
|
this.writes += 1;
|
|
this.values.set(key, value);
|
|
}
|
|
|
|
async removeItem(key: string): Promise<void> {
|
|
this.values.delete(key);
|
|
}
|
|
}
|
|
|
|
function workspace(
|
|
id = "workspace-1",
|
|
projectId = "project-1",
|
|
workspaceDirectory = "/repo/paseo",
|
|
): WorkspaceDescriptorPayload {
|
|
return {
|
|
id,
|
|
projectId,
|
|
projectDisplayName: "Paseo",
|
|
projectRootPath: workspaceDirectory,
|
|
workspaceDirectory,
|
|
projectKind: "git",
|
|
workspaceKind: "local_checkout",
|
|
name: "main",
|
|
status: "running",
|
|
statusEnteredAt: "2026-07-18T08:00:00.000Z",
|
|
activityAt: null,
|
|
archivingAt: null,
|
|
diffStat: null,
|
|
scripts: [],
|
|
};
|
|
}
|
|
|
|
function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") {
|
|
return {
|
|
...normalizeAgentSnapshot(
|
|
{
|
|
id,
|
|
provider: "codex",
|
|
cwd,
|
|
workspaceId,
|
|
model: null,
|
|
createdAt: "2026-07-18T08:00:00.000Z",
|
|
updatedAt: "2026-07-18T08:01:00.000Z",
|
|
lastUserMessageAt: "2026-07-18T08:01:00.000Z",
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: true,
|
|
supportsSessionPersistence: true,
|
|
supportsDynamicModes: true,
|
|
supportsMcpServers: true,
|
|
supportsReasoningStream: true,
|
|
supportsToolInvocations: true,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: null,
|
|
title: `Agent ${id}`,
|
|
labels: {},
|
|
},
|
|
SERVER_ID,
|
|
),
|
|
projectPlacement: {
|
|
projectKey: cwd,
|
|
projectName: cwd.split("/").at(-1) ?? cwd,
|
|
workspaceName: workspaceId,
|
|
checkout: {
|
|
cwd,
|
|
isGit: false as const,
|
|
currentBranch: null,
|
|
remoteUrl: null,
|
|
worktreeRoot: null,
|
|
isPaseoOwnedWorktree: false as const,
|
|
mainRepoRoot: null,
|
|
},
|
|
},
|
|
};
|
|
}
|
|
|
|
function message(id: string, text: string): StreamItem {
|
|
return {
|
|
kind: "assistant_message",
|
|
id,
|
|
text,
|
|
timestamp: new Date("2026-07-18T08:02:00.000Z"),
|
|
timelineCursor: { epoch: "epoch-1", seq: 12 },
|
|
};
|
|
}
|
|
|
|
function seedSession(): void {
|
|
const store = useSessionStore.getState();
|
|
store.initializeSession(SERVER_ID, null);
|
|
store.setAgents(SERVER_ID, new Map([["agent-1", agent("agent-1")]]));
|
|
store.setWorkspaces(
|
|
SERVER_ID,
|
|
new Map([
|
|
[
|
|
"workspace-1",
|
|
normalizeWorkspaceDescriptor({
|
|
...workspace(),
|
|
workspaceKind: "worktree",
|
|
worktreeSlug: "owned-worktree",
|
|
}),
|
|
],
|
|
]),
|
|
);
|
|
store.setProjects(SERVER_ID, [
|
|
normalizeProjectDescriptor({
|
|
projectId: "project-1",
|
|
projectKey: "remote:github.com/getpaseo/paseo",
|
|
projectDisplayName: "Paseo",
|
|
projectRootPath: "/repo/paseo",
|
|
projectKind: "git",
|
|
}),
|
|
normalizeProjectDescriptor({
|
|
projectId: "empty-project",
|
|
projectDisplayName: "Empty project",
|
|
projectRootPath: "/repo/empty",
|
|
projectKind: "directory",
|
|
}),
|
|
]);
|
|
store.setFocusedAgentId(SERVER_ID, "agent-1");
|
|
store.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("message-1", "Cached")]]]));
|
|
store.setAgentTimelineCursor(
|
|
SERVER_ID,
|
|
new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]),
|
|
);
|
|
store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-1", true]]));
|
|
store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-1", true);
|
|
}
|
|
|
|
function seedTimeline(serverId: string, text: string): void {
|
|
const agentId = `agent-${serverId}`;
|
|
const workspaceId = `workspace-${serverId}`;
|
|
const workspaceDirectory = `/repo/${serverId}`;
|
|
const store = useSessionStore.getState();
|
|
store.initializeSession(serverId, null);
|
|
store.setAgents(serverId, new Map([[agentId, agent(agentId, workspaceId, workspaceDirectory)]]));
|
|
store.setWorkspaces(
|
|
serverId,
|
|
new Map([
|
|
[
|
|
workspaceId,
|
|
normalizeWorkspaceDescriptor(
|
|
workspace(workspaceId, `project-${serverId}`, workspaceDirectory),
|
|
),
|
|
],
|
|
]),
|
|
);
|
|
store.setFocusedAgentId(serverId, agentId);
|
|
store.setAgentStreamTail(serverId, new Map([[agentId, [message(`message-${serverId}`, text)]]]));
|
|
}
|
|
|
|
afterEach(() => {
|
|
vi.useRealTimers();
|
|
const store = useSessionStore.getState();
|
|
store.clearSession(SERVER_ID);
|
|
for (const serverId of LRU_SERVER_IDS) store.clearSession(serverId);
|
|
});
|
|
|
|
describe("ReplicaCache", () => {
|
|
it("persists focused replica changes without writing transient stream head updates", async () => {
|
|
vi.useFakeTimers();
|
|
const storage = new MemoryStorage();
|
|
const cache = new ReplicaCache(storage);
|
|
cache.setHosts([SERVER_ID]);
|
|
seedSession();
|
|
await cache.flush();
|
|
cache.start();
|
|
const writesBeforeStream = storage.writes;
|
|
|
|
useSessionStore
|
|
.getState()
|
|
.setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]]));
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(storage.writes).toBe(writesBeforeStream);
|
|
|
|
useSessionStore
|
|
.getState()
|
|
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]]));
|
|
await vi.advanceTimersByTimeAsync(1_000);
|
|
|
|
expect(storage.writes).toBe(writesBeforeStream + 1);
|
|
cache.setHosts([]);
|
|
});
|
|
|
|
it("restores a displayable stale replica without claiming remote hydration", async () => {
|
|
const storage = new MemoryStorage();
|
|
const writer = new ReplicaCache(storage);
|
|
writer.setHosts([SERVER_ID]);
|
|
seedSession();
|
|
await writer.flush();
|
|
|
|
useSessionStore.getState().clearSession(SERVER_ID);
|
|
|
|
const reader = new ReplicaCache(storage);
|
|
reader.setHosts([SERVER_ID]);
|
|
await reader.restore();
|
|
|
|
const session = useSessionStore.getState().sessions[SERVER_ID];
|
|
expect(session).toBeDefined();
|
|
if (!session) throw new Error("Expected restored session");
|
|
expect(session.client).toBeNull();
|
|
expect(session.hasHydratedAgents).toBe(false);
|
|
expect(session.hasHydratedWorkspaces).toBe(false);
|
|
expect(session.hasWorkspaceDirectorySnapshot).toBe(true);
|
|
expect(Array.from(session.agents.keys())).toEqual(["agent-1"]);
|
|
expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]);
|
|
expect(Array.from(session.projects.keys())).toEqual(["project-1", "empty-project"]);
|
|
expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
|
expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo");
|
|
expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
|
expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree");
|
|
expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
|
|
expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map());
|
|
expect(session.agentTimelineCursor).toEqual(new Map());
|
|
expect(session.agentTimelineHasOlder).toEqual(new Map());
|
|
expect(session.agentTimelineHasNewer).toEqual(new Map());
|
|
expect(session.agentHistorySyncGeneration).toEqual(new Map());
|
|
expect(selectAgentTimelineState(session, "agent-1")).toEqual({
|
|
status: "painted",
|
|
items: [message("message-1", "Cached")],
|
|
});
|
|
});
|
|
|
|
it("persists the complete directory with only the focused timeline tail", async () => {
|
|
const storage = new MemoryStorage();
|
|
const cache = new ReplicaCache(storage);
|
|
cache.setHosts([SERVER_ID]);
|
|
seedSession();
|
|
|
|
const store = useSessionStore.getState();
|
|
store.setAgents(SERVER_ID, (agents) =>
|
|
new Map(agents).set("agent-2", agent("agent-2", "workspace-2", "/repo/other")),
|
|
);
|
|
store.setWorkspaces(SERVER_ID, (workspaces) =>
|
|
new Map(workspaces).set(
|
|
"workspace-2",
|
|
normalizeWorkspaceDescriptor(workspace("workspace-2", "project-2", "/repo/other")),
|
|
),
|
|
);
|
|
const secondTimeline = Array.from({ length: 60 }, (_, index) =>
|
|
message(`message-${index}`, `Second ${index}`),
|
|
);
|
|
store.setAgentStreamTail(
|
|
SERVER_ID,
|
|
new Map([
|
|
["agent-1", [message("message-1", "First")]],
|
|
["agent-2", secondTimeline],
|
|
]),
|
|
);
|
|
store.setFocusedAgentId(SERVER_ID, "agent-2");
|
|
await cache.flush();
|
|
|
|
store.clearSession(SERVER_ID);
|
|
const reader = new ReplicaCache(storage);
|
|
reader.setHosts([SERVER_ID]);
|
|
await reader.restore();
|
|
|
|
const session = useSessionStore.getState().sessions[SERVER_ID];
|
|
const timelines = session?.agentStreamTail;
|
|
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1", "agent-2"]);
|
|
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1", "workspace-2"]);
|
|
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
|
|
"project-1",
|
|
"project-2",
|
|
"empty-project",
|
|
]);
|
|
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
|
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
|
|
|
const persisted = JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null") as {
|
|
version: number;
|
|
hosts: Array<{ timeline: Record<string, unknown> | null }>;
|
|
};
|
|
expect(persisted.version).toBe(5);
|
|
expect(Object.keys(persisted.hosts[0]?.timeline ?? {}).sort()).toEqual(["agentId", "items"]);
|
|
});
|
|
|
|
it("persists reconciled rows without caching unreconciled local presentations", async () => {
|
|
const storage = new MemoryStorage();
|
|
const cache = new ReplicaCache(storage);
|
|
cache.setHosts([SERVER_ID]);
|
|
seedSession();
|
|
const unreconciled = createUserMessage({
|
|
clientMessageId: "client-pending",
|
|
text: "Pending",
|
|
timestamp: new Date("2026-07-18T08:01:00.000Z"),
|
|
});
|
|
const reconciled = createUserMessage({
|
|
clientMessageId: "client-sent",
|
|
messageId: "provider-sent",
|
|
timelineCursor: { epoch: "epoch-1", seq: 11 },
|
|
text: "Sent",
|
|
timestamp: new Date("2026-07-18T08:01:30.000Z"),
|
|
});
|
|
useSessionStore
|
|
.getState()
|
|
.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [unreconciled, reconciled]]]));
|
|
|
|
await cache.flush();
|
|
useSessionStore.getState().clearSession(SERVER_ID);
|
|
await cache.restore();
|
|
|
|
expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1")).toEqual([
|
|
reconciled,
|
|
]);
|
|
});
|
|
|
|
it("persists monotonic directory cursors with the complete host replica", async () => {
|
|
const storage = new MemoryStorage();
|
|
const cache = new ReplicaCache(storage);
|
|
cache.setHosts([SERVER_ID]);
|
|
seedSession();
|
|
await cache.flush();
|
|
|
|
cache.writeDirectoryCheckpoint(SERVER_ID, {
|
|
agents: { generation: "daemon-generation", afterSeq: 7 },
|
|
});
|
|
useSessionStore.getState().setAgents(SERVER_ID, (agents) => {
|
|
const current = agents.get("agent-1");
|
|
if (!current) throw new Error("Expected seeded agent");
|
|
return new Map(agents).set("agent-1", { ...current, title: "Updated agent" });
|
|
});
|
|
await cache.flush();
|
|
|
|
const reader = new ReplicaCache(storage);
|
|
reader.setHosts([SERVER_ID]);
|
|
await reader.restore();
|
|
expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({
|
|
agents: { generation: "daemon-generation", afterSeq: 7 },
|
|
});
|
|
});
|
|
|
|
it("restores every registered host directory before any host reconnects", async () => {
|
|
const storage = new MemoryStorage();
|
|
const writer = new ReplicaCache(storage);
|
|
writer.setHosts(LRU_SERVER_IDS);
|
|
for (const serverId of LRU_SERVER_IDS) seedTimeline(serverId, `cached-${serverId}`);
|
|
await writer.flush();
|
|
for (const serverId of LRU_SERVER_IDS) useSessionStore.getState().clearSession(serverId);
|
|
|
|
const reader = new ReplicaCache(storage);
|
|
reader.setHosts(LRU_SERVER_IDS);
|
|
await reader.restore();
|
|
|
|
for (const serverId of LRU_SERVER_IDS) {
|
|
const session = useSessionStore.getState().sessions[serverId];
|
|
expect(Array.from(session?.agents.keys() ?? [])).toEqual([`agent-${serverId}`]);
|
|
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual([`workspace-${serverId}`]);
|
|
expect(session?.hasHydratedAgents).toBe(false);
|
|
expect(session?.hasHydratedWorkspaces).toBe(false);
|
|
expect(session?.hasWorkspaceDirectorySnapshot).toBe(true);
|
|
}
|
|
});
|
|
|
|
it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
|
|
const storage = new MemoryStorage();
|
|
const cache = new ReplicaCache(storage, { maxBytes: 7_000 });
|
|
cache.setHosts(LRU_SERVER_IDS.slice(0, 2));
|
|
seedTimeline("host-a", "A".repeat(1_200));
|
|
seedTimeline("host-b", "B".repeat(1_200));
|
|
await cache.flush();
|
|
|
|
seedTimeline("host-a", "A".repeat(1_201));
|
|
await cache.flush();
|
|
|
|
cache.setHosts(LRU_SERVER_IDS);
|
|
seedTimeline("host-c", "C".repeat(1_200));
|
|
await cache.flush();
|
|
|
|
for (const serverId of LRU_SERVER_IDS) {
|
|
useSessionStore.getState().clearSession(serverId);
|
|
}
|
|
const reader = new ReplicaCache(storage, { maxBytes: 7_000 });
|
|
reader.setHosts(LRU_SERVER_IDS);
|
|
await reader.restore();
|
|
|
|
expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]);
|
|
});
|
|
|
|
it("rejects and clears version 4 cache data before overwriting it on flush", async () => {
|
|
const storage = new MemoryStorage();
|
|
storage.values.set(
|
|
"@paseo:replica-cache",
|
|
JSON.stringify({
|
|
version: 4,
|
|
hosts: [
|
|
{
|
|
serverId: SERVER_ID,
|
|
agents: [],
|
|
workspaces: [],
|
|
emptyProjects: [],
|
|
timeline: {
|
|
agentId: "agent-1",
|
|
items: [],
|
|
cursor: { epoch: "poisoned", startSeq: 1, endSeq: 100 },
|
|
hasOlder: false,
|
|
},
|
|
},
|
|
],
|
|
}),
|
|
);
|
|
const cache = new ReplicaCache(storage);
|
|
cache.setHosts([SERVER_ID]);
|
|
|
|
await cache.restore();
|
|
expect(storage.values.has("@paseo:replica-cache")).toBe(false);
|
|
await cache.flush();
|
|
|
|
expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined();
|
|
expect(JSON.parse(storage.values.get("@paseo:replica-cache") ?? "null")).toEqual({
|
|
version: 5,
|
|
hosts: [],
|
|
});
|
|
});
|
|
});
|