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
@@ -10,7 +10,9 @@ import {
type ProjectRegistry,
} from "../server/workspace-registry.js";
import {
ProjectIconReader,
readProjectIcon,
readProjectIconSnapshot,
removeProjectCustomIcon,
setProjectCustomIcon,
} from "./project-custom-icon.js";
@@ -52,6 +54,7 @@ async function project() {
return record;
},
} as unknown as ProjectRegistry;
const reader = new ProjectIconReader(paseoHome);
return {
paseoHome,
@@ -59,6 +62,9 @@ async function project() {
set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }),
snapshot: () => readProjectIconSnapshot({ paseoHome, project: record }),
advertisedSnapshot: () => reader.snapshot(record),
readAdvertised: () => reader.read(record),
revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
};
@@ -88,6 +94,40 @@ describe("project custom icon", () => {
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
});
it("fingerprints automatic icons and explicit no-icon results", async () => {
const target = await project();
const missing = await target.snapshot();
expect(missing).toEqual({ icon: null, revision: "automatic:none:v1" });
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const first = await target.snapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
const changed = await target.snapshot();
expect(first.icon).toEqual(PNG_1X1_ICON);
expect(first.revision).toMatch(/^automatic:[a-f0-9]{64}$/);
expect(changed.revision).not.toBe(first.revision);
});
it("serves the bytes that match the advertised revision", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const advertised = await target.advertisedSnapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
await expect(target.readAdvertised()).resolves.toEqual(advertised.icon);
});
it("drops the stored image when the project goes back to automatic", async () => {
const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
@@ -57,12 +57,63 @@ export async function readProjectIcon(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIcon | null> {
if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath);
try {
return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
return null;
return (await readProjectIconSnapshot(input)).icon;
}
export interface ProjectIconSnapshot {
icon: ProjectIcon | null;
revision: string;
}
/** Keeps the icon bytes served by a session aligned with its advertised revision. */
export class ProjectIconReader {
private readonly snapshots = new Map<string, ProjectIconSnapshot>();
constructor(private readonly paseoHome: string) {}
async snapshot(project: PersistedProjectRecord): Promise<ProjectIconSnapshot> {
const snapshot = await readProjectIconSnapshot({ paseoHome: this.paseoHome, project });
this.snapshots.set(project.projectId, snapshot);
return snapshot;
}
async read(project: PersistedProjectRecord): Promise<ProjectIcon | null> {
return (this.snapshots.get(project.projectId) ?? (await this.snapshot(project))).icon;
}
}
/** Reads the effective icon and gives that exact result a stable identity. */
export async function readProjectIconSnapshot(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIconSnapshot> {
if (input.project.customIconRevision) {
let icon: ProjectIcon | null = null;
try {
icon = validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
// A missing/corrupt custom icon is itself a cacheable result until the
// registry revision changes.
}
return {
icon,
revision: icon
? iconRevision("custom", icon)
: `custom:none:${input.project.customIconRevision}`,
};
}
const icon = await getProjectIcon(input.project.rootPath);
if (!icon) return { icon: null, revision: "automatic:none:v1" };
return { icon, revision: iconRevision("automatic", icon) };
}
function iconRevision(source: "automatic" | "custom", icon: ProjectIcon): string {
const fingerprint = createHash("sha256")
.update(icon.mimeType)
.update("\0")
.update(icon.data)
.digest("hex");
return `${source}:${fingerprint}`;
}
export async function removeProjectCustomIcon(input: {