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
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache";
class MemoryStorage implements ProjectIconCacheStorage {
readonly values = new Map<string, string>();
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
}
const target = {
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/project-a",
iconRevision: "revision-a",
};
const icon = { data: "aWNvbg==", mimeType: "image/png" };
function clientWith(iconResult: typeof icon | null) {
return {
getProjectIcon: async () => ({ requestId: "icon", icon: iconResult }),
} as unknown as DaemonClient;
}
describe("ProjectIconCache", () => {
it("resolves, persists, and hydrates an icon before a host connects", async () => {
const storage = new MemoryStorage();
const writer = new ProjectIconCache(storage);
writer.setHosts([target.serverId]);
await writer.query(target, true, () => clientWith(icon), true).queryFn();
await writer.flush();
const reader = new ProjectIconCache(storage);
reader.setHosts([target.serverId]);
await reader.restore();
expect(reader.query(target, true, () => null, false).initialData).toEqual(icon);
});
it("persists an explicit no-icon result", async () => {
const storage = new MemoryStorage();
const writer = new ProjectIconCache(storage);
writer.setHosts([target.serverId]);
await writer.query(target, true, () => clientWith(null), true).queryFn();
await writer.flush();
const reader = new ProjectIconCache(storage);
reader.setHosts([target.serverId]);
await reader.restore();
expect(reader.query(target, true, () => null, false)).toHaveProperty("initialData", null);
});
it("misses only the project whose revision changed", async () => {
const cache = new ProjectIconCache(new MemoryStorage());
const other = { ...target, projectId: "project-b" };
await cache.query(target, true, () => clientWith(icon), true).queryFn();
await cache.query(other, true, () => clientWith(null), true).queryFn();
expect(
cache.query({ ...target, iconRevision: "revision-b" }, true, () => null, false),
).not.toHaveProperty("initialData");
expect(cache.query(other, true, () => null, false)).toHaveProperty("initialData", null);
});
});
+217
View File
@@ -0,0 +1,217 @@
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
const STORAGE_KEY = "@paseo:project-icon-cache";
const CACHE_VERSION = 1;
const PERSIST_DELAY_MS = 250;
const MAX_ENTRIES = 512;
export interface ProjectIconCacheStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
interface ProjectIconIdentity {
serverId: string;
projectId: string;
revision: string;
}
type ProjectIconCacheRead = { hit: true; icon: ProjectIcon | null } | { hit: false };
interface ProjectIconTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
interface StoredIcon extends ProjectIconIdentity {
icon: ProjectIcon | null;
}
function keyOf(identity: ProjectIconIdentity): string {
return JSON.stringify([identity.serverId, identity.projectId, identity.revision]);
}
function parseStoredIcon(value: unknown): StoredIcon | null {
if (!value || typeof value !== "object") return null;
const entry = value as Record<string, unknown>;
if (
typeof entry.serverId !== "string" ||
typeof entry.projectId !== "string" ||
typeof entry.revision !== "string"
) {
return null;
}
if (entry.icon === null) {
return {
serverId: entry.serverId,
projectId: entry.projectId,
revision: entry.revision,
icon: null,
};
}
if (!entry.icon || typeof entry.icon !== "object") return null;
const icon = entry.icon as Record<string, unknown>;
if (typeof icon.data !== "string" || typeof icon.mimeType !== "string") return null;
return {
serverId: entry.serverId,
projectId: entry.projectId,
revision: entry.revision,
icon: { data: icon.data, mimeType: icon.mimeType },
};
}
/** Owns project-icon persistence, revision matching, and negative results. */
export class ProjectIconCache {
private readonly entries = new Map<string, StoredIcon>();
private activeServerIds = new Set<string>();
private persistTimer: ReturnType<typeof setTimeout> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: ProjectIconCacheStorage) {}
setHosts(serverIds: Iterable<string>): void {
this.activeServerIds = new Set(serverIds);
let changed = false;
for (const [key, entry] of this.entries) {
if (!this.activeServerIds.has(entry.serverId)) {
this.entries.delete(key);
changed = true;
}
}
if (changed) this.schedulePersist();
}
async restore(): Promise<void> {
let raw: string | null;
try {
raw = await this.storage.getItem(STORAGE_KEY);
} catch {
return;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { version?: unknown; entries?: unknown };
if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.entries)) return;
for (const value of parsed.entries) {
const entry = parseStoredIcon(value);
if (entry && this.activeServerIds.has(entry.serverId)) {
this.entries.set(keyOf(entry), entry);
}
}
} catch {
// A corrupt cache is disposable.
}
}
private read(identity: ProjectIconIdentity): ProjectIconCacheRead {
const entry = this.entries.get(keyOf(identity));
return entry ? { hit: true, icon: entry.icon } : { hit: false };
}
private write(identity: ProjectIconIdentity, icon: ProjectIcon | null): void {
const key = keyOf(identity);
this.entries.delete(key);
this.entries.set(key, { ...identity, icon });
while (this.entries.size > MAX_ENTRIES) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
this.schedulePersist();
}
query(
target: ProjectIconTarget,
supportsCustomIcons: boolean | null,
getClient: () => DaemonClient | null,
connected: boolean,
) {
// COMPAT(projectIconCache): daemons before v0.2.7 do not expose an effective revision, so
// their icon results remain memory-only.
const revision = target.iconRevision ?? target.customIconRevision ?? "automatic";
const identity: ProjectIconIdentity | null = target.iconRevision
? { serverId: target.serverId, projectId: target.projectId, revision: target.iconRevision }
: null;
const cached = identity ? this.read(identity) : { hit: false as const };
const lookup = resolveLookup(target, supportsCustomIcons);
let queryKey: readonly string[];
if (!lookup) {
queryKey = ["projectIcon", target.serverId, "pending", target.projectId];
} else if (lookup.kind === "project") {
queryKey = ["projectIcon", target.serverId, target.projectId, revision];
} else {
queryKey = ["projectIcon", target.serverId, "legacy", lookup.cwd];
}
return {
queryKey,
queryFn: async () => {
if (!lookup) return null;
const client = getClient();
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
if (result.error) throw new Error(result.error);
if (identity) this.write(identity, result.icon);
return result.icon;
},
enabled: Boolean(lookup && getClient() && connected),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false as const,
refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const,
...(cached.hit ? { initialData: cached.icon } : {}),
};
}
reconcileServerId(oldServerId: string, newServerId: string): void {
for (const [key, entry] of this.entries) {
if (entry.serverId !== oldServerId) continue;
this.entries.delete(key);
const reconciled = { ...entry, serverId: newServerId };
this.entries.set(keyOf(reconciled), reconciled);
}
if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId);
this.schedulePersist();
}
async flush(): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
const payload = JSON.stringify({ version: CACHE_VERSION, entries: [...this.entries.values()] });
const write = this.writeQueue
.catch(() => undefined)
.then(() => this.storage.setItem(STORAGE_KEY, payload));
this.writeQueue = write;
await write.catch(() => undefined);
}
private schedulePersist(): void {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
void this.flush();
}, PERSIST_DELAY_MS);
}
}
export const projectIconCache = new ProjectIconCache(replicaCacheStorage);
function resolveLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean | null,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null {
if (supportsCustomIcons === null) return null;
return supportsCustomIcons
? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir };
}
+4
View File
@@ -20,4 +20,8 @@ describe("project icon lookup", () => {
cwd: "/projects/paseo",
});
});
it("waits while custom-icon capability is unknown", () => {
expect(resolveProjectIconLookup(target, null)).toBeNull();
});
});
+12 -34
View File
@@ -2,7 +2,8 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next";
import { useQueries, useQuery } from "@tanstack/react-query";
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import { useHostFeatureMap } from "@/runtime/host-features";
import { useHostFeatureAvailabilityMap } from "@/runtime/host-features";
import { projectIconCache } from "@/projects/icon-cache";
import {
getHostRuntimeStore,
isHostRuntimeConnected,
@@ -16,6 +17,7 @@ interface ProjectIconTarget {
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
/**
@@ -24,8 +26,9 @@ interface ProjectIconTarget {
*/
export function resolveProjectIconLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } {
supportsCustomIcons: boolean | null,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null {
if (supportsCustomIcons === null) return null;
return supportsCustomIcons
? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir };
@@ -35,10 +38,6 @@ function legacyIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, "legacy", cwd] as const;
}
function iconQueryKey(serverId: string, projectId: string, revision: string) {
return ["projectIcon", serverId, projectId, revision] as const;
}
function iconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) return null;
return `data:${icon.mimeType};base64,${icon.data}`;
@@ -88,7 +87,7 @@ export function useProjectIcons(input: {
() => [...new Set(input.projects.map((project) => project.serverId))],
[input.projects],
);
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon");
const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon");
const requests = useMemo(() => {
const unique = new Map<string, ProjectIconTarget>();
for (const project of input.projects) {
@@ -100,35 +99,14 @@ export function useProjectIcons(input: {
const queries = useQueries({
queries: requests.map((request) => {
const revision = request.customIconRevision ?? "automatic";
const lookup = resolveProjectIconLookup(
request,
supportsCustomIcons.get(request.serverId) === true,
);
return {
queryKey:
lookup.kind === "project"
? iconQueryKey(request.serverId, lookup.projectId, revision)
: legacyIconQueryKey(request.serverId, lookup.cwd),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
return result.icon;
},
select: iconDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
...projectIconCache.query(
request,
supportsCustomIcons.get(request.serverId) ?? null,
() => getHostRuntimeStore().getClient(request.serverId),
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
select: iconDataUri,
};
}),
});
@@ -7,6 +7,7 @@ export interface WorkspaceStructureHostPlacement {
iconWorkingDir: string;
worktreeSupport: "supported" | "unsupported" | "unknown";
customIconRevision?: string | null;
iconRevision?: string;
}
export interface WorkspaceStructureProject {
@@ -160,6 +161,7 @@ function addProjectToView(input: {
iconWorkingDir: project.projectRootPath,
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
customIconRevision: project.projectCustomIconRevision,
iconRevision: project.projectIconRevision,
};
const draft = byProject.get(viewKey);
if (!draft) {