Restore host directories instantly before reconnect (#3259)

* 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
This commit is contained in:
Mohamed Boudra
2026-08-13 23:10:44 +02:00
committed by GitHub
parent 599846650c
commit 373a98c64d
51 changed files with 2428 additions and 400 deletions
@@ -55,34 +55,50 @@ function workspace(
}
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,
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,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: `Agent ${id}`,
labels: {},
},
SERVER_ID,
);
};
}
function message(id: string, text: string): StreamItem {
@@ -212,10 +228,12 @@ describe("ReplicaCache", () => {
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"]);
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")]);
@@ -230,7 +248,7 @@ describe("ReplicaCache", () => {
});
});
it("persists only the focused agent view with a short timeline tail", async () => {
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]);
@@ -266,9 +284,13 @@ describe("ReplicaCache", () => {
const session = useSessionStore.getState().sessions[SERVER_ID];
const timelines = session?.agentStreamTail;
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
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));
@@ -310,6 +332,53 @@ describe("ReplicaCache", () => {
]);
});
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 });
+90 -28
View File
@@ -8,7 +8,6 @@ import {
selectAgentTimelineState,
useSessionStore,
type Agent,
type AgentTimelineState,
type SessionReplica,
type SessionState,
type ProjectDescriptor,
@@ -21,7 +20,7 @@ const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 5;
const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024;
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
const IsoDateSchema = z.iso.datetime();
const TimelinePositionSchema = z.strictObject({
epoch: z.string(),
@@ -107,6 +106,43 @@ const AgentCapabilitiesSchema = z.strictObject({
supportsRewindBoth: z.boolean().optional(),
});
const StoredProjectCheckoutSchema = z.union([
z.strictObject({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
}),
z.strictObject({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.string().nullable(),
}),
z.strictObject({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
}),
]);
const StoredProjectPlacementSchema = z.strictObject({
projectKey: z.string(),
projectName: z.string(),
workspaceName: z.string().nullable().optional(),
checkout: StoredProjectCheckoutSchema,
});
const StoredAgentSnapshotSchema = z.strictObject({
id: z.string(),
provider: AgentProviderSchema,
@@ -141,7 +177,7 @@ const StoredAgentSnapshotSchema = z.strictObject({
const StoredAgentSchema = z.strictObject({
snapshot: StoredAgentSnapshotSchema,
projectPlacement: z.null(),
projectPlacement: StoredProjectPlacementSchema.nullable(),
lastActivityAt: IsoDateSchema,
});
@@ -201,6 +237,8 @@ const StoredProjectSchema = z.strictObject({
projectKey: z.string().optional(),
projectDisplayName: z.string(),
projectCustomName: z.string().nullable(),
projectCustomIconRevision: z.string().nullable(),
projectIconRevision: z.string().optional(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
});
@@ -217,6 +255,7 @@ const StoredHostSchema = z.strictObject({
projects: z.array(StoredProjectSchema),
emptyProjects: z.array(StoredProjectSchema),
timeline: StoredTimelineSchema.nullable(),
directorySync: z.unknown().optional(),
});
const StoredCacheSchema = z.strictObject({
@@ -231,9 +270,10 @@ type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
type StoredProject = z.infer<typeof StoredProjectSchema>;
interface ReplicaInput {
agent: Agent | undefined;
workspace: WorkspaceDescriptor | undefined;
project: ProjectDescriptor | undefined;
agents: ReadonlyMap<string, Agent>;
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgent: Agent | undefined;
timelineItems: StreamItem[] | undefined;
}
@@ -367,6 +407,10 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
}
}
function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement"] {
return agent.projectPlacement ?? null;
}
function serializeAgent(agent: Agent): StoredAgent {
const snapshot = {
id: agent.id,
@@ -421,7 +465,7 @@ function serializeAgent(agent: Agent): StoredAgent {
};
return {
snapshot,
projectPlacement: null,
projectPlacement: serializeProjectPlacement(agent),
lastActivityAt: agent.lastActivityAt.toISOString(),
};
}
@@ -478,6 +522,8 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName,
projectCustomIconRevision: project.projectCustomIconRevision ?? null,
projectIconRevision: project.projectIconRevision,
projectRootPath: project.projectRootPath,
projectKind: project.projectKind,
};
@@ -485,50 +531,47 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean {
return (
left.agent === right.agent &&
left.workspace === right.workspace &&
left.project === right.project &&
left.agents === right.agents &&
left.workspaces === right.workspaces &&
left.projects === right.projects &&
left.focusedAgent === right.focusedAgent &&
left.timelineItems === right.timelineItems
);
}
function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput {
const agent = agentId ? session.agents.get(agentId) : undefined;
const workspace = agent
? ((agent.workspaceId ? session.workspaces.get(agent.workspaceId) : undefined) ??
Array.from(session.workspaces.values()).find(
(candidate) => candidate.workspaceDirectory === agent.cwd,
))
: undefined;
const timeline: AgentTimelineState = agentId
const timeline = agentId
? selectAgentTimelineState(session, agentId)
: { status: "cold" };
: { status: "cold" as const };
return {
agent,
workspace,
project: workspace ? session.projects.get(workspace.projectId) : undefined,
agents: session.agents,
workspaces: session.workspaces,
projects: session.projects,
focusedAgent: agent,
timelineItems: timeline.status === "cold" ? undefined : timeline.items,
};
}
function serializeHost(serverId: string, input: ReplicaInput): StoredHost {
function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost {
const items = input.timelineItems
?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item))
.map(serializeTimelineItem)
.filter((item) => item !== null);
return {
serverId,
agents: input.agent ? [serializeAgent(input.agent)] : [],
workspaces: input.workspace ? [serializeWorkspace(input.workspace)] : [],
projects: input.project ? [serializeProject(input.project)] : [],
agents: Array.from(input.agents.values(), serializeAgent),
workspaces: Array.from(input.workspaces.values(), serializeWorkspace),
projects: Array.from(input.projects.values(), serializeProject),
emptyProjects: [],
timeline:
input.agent && items
input.focusedAgent && items
? {
agentId: input.agent.id,
agentId: input.focusedAgent.id,
items: items.slice(-MAX_TIMELINE_ITEMS),
}
: null,
...(directorySync ? { directorySync } : {}),
};
}
@@ -688,6 +731,24 @@ export class ReplicaCache {
this.schedulePersist();
}
readDirectoryCheckpoint(serverId: string): unknown {
return this.storedHosts.get(serverId)?.directorySync;
}
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void {
let stored = this.storedHosts.get(serverId);
if (!stored) {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null);
this.capturedInputs.set(serverId, input);
stored = serializeHost(serverId, input);
}
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint });
this.schedulePersist();
}
async flush(): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
@@ -727,8 +788,9 @@ export class ReplicaCache {
if (previous && replicaInputsEqual(previous, input)) return false;
this.capturedInputs.set(serverId, input);
const directorySync = this.storedHosts.get(serverId)?.directorySync;
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, serializeHost(serverId, input));
this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync));
return true;
}
@@ -0,0 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ReplicaCacheStorage } from "./index";
export const replicaCacheStorage: ReplicaCacheStorage = AsyncStorage;
@@ -0,0 +1,70 @@
import type { ReplicaCacheStorage } from "./index";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
databasePromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
});
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to open replica cache")),
);
request.addEventListener("blocked", () =>
reject(new Error("Replica cache database upgrade was blocked")),
);
});
return databasePromise;
}
async function getItem(key: string): Promise<string | null> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to read replica cache")),
);
});
}
async function setItem(key: string, value: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put(value, key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to write replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache write was aborted")),
);
});
}
async function removeItem(key: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).delete(key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to remove replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache removal was aborted")),
);
});
}
export const replicaCacheStorage: ReplicaCacheStorage = { getItem, setItem, removeItem };