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
+23
View File
@@ -5,6 +5,7 @@ import {
parseServerInfoStatusPayload,
SessionInboundMessageSchema,
SessionOutboundMessageSchema,
WorkspaceProjectDescriptorPayloadSchema,
} from "./messages.js";
function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
@@ -53,6 +54,28 @@ describe("project icon message security", () => {
});
});
describe("project icon revision compatibility", () => {
const project = {
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
projectKind: "git" as const,
};
test("accepts an old project snapshot without an effective icon revision", () => {
expect(WorkspaceProjectDescriptorPayloadSchema.parse(project)).toEqual(project);
});
test("accepts an effective icon revision on a new project snapshot", () => {
expect(
WorkspaceProjectDescriptorPayloadSchema.parse({
...project,
projectIconRevision: "automatic:none:v1",
}),
).toEqual({ ...project, projectIconRevision: "automatic:none:v1" });
});
});
describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse(
+61 -2
View File
@@ -1104,6 +1104,26 @@ export const SendAgentMessageSchema = z.object({
// Agent RPCs (requestId-correlated)
// ============================================================================
const DirectorySyncRequestSchema = z.object({
generation: z.string().optional(),
afterSeq: z.number().int().nonnegative().optional(),
});
const DirectorySyncRemovalSchema = z.object({
id: z.string(),
seq: z.number().int().positive(),
});
const DirectorySyncMetadataSchema = z.object({
generation: z.string(),
headSeq: z.number().int().nonnegative(),
mode: z.enum(["snapshot", "changes"]),
reason: z
.enum(["no_cursor", "generation_changed", "cursor_expired", "changes_too_large"])
.optional(),
removals: z.array(DirectorySyncRemovalSchema),
});
export const FetchAgentsRequestMessageSchema = z.object({
type: z.literal("fetch_agents_request"),
requestId: z.string(),
@@ -1128,6 +1148,8 @@ export const FetchAgentsRequestMessageSchema = z.object({
subscriptionId: z.string().optional(),
})
.optional(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
});
const WorkspaceStateBucketSchema = z.enum([
@@ -1168,11 +1190,15 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
subscriptionId: z.string().optional(),
})
.optional(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
});
export const ProjectListRequestMessageSchema = z.object({
type: z.literal("project.list.request"),
requestId: z.string(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
});
export const FetchAgentHistoryRequestMessageSchema = z.object({
@@ -3038,6 +3064,8 @@ export const ServerInfoStatusPayloadSchema = z
providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(),
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: z.boolean().optional(),
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: z.boolean().optional(),
@@ -3457,6 +3485,8 @@ export const WorkspaceDescriptorPayloadSchema = z
// Old daemons omit it; absent means the client falls back to GitHub.
forge: z.string().optional(),
project: ProjectPlacementPayloadSchema.optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
})
.transform((workspace) => ({
...workspace,
@@ -3470,10 +3500,14 @@ export const AgentUpdateMessageSchema = z.object({
kind: z.literal("upsert"),
agent: AgentSnapshotPayloadSchema,
project: ProjectPlacementPayloadSchema.nullable().optional(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
z.object({
kind: z.literal("remove"),
agentId: z.string(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
]),
});
@@ -3531,6 +3565,8 @@ const AgentDirectoryResponseEntrySchema = z.object({
// anyway; sending it keeps the client from re-deriving a second opinion that
// could disagree with the ranking it is explaining.
searchMatches: z.array(AgentSearchMatchSchema).optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
});
const AgentDirectoryPageInfoSchema = z.object({
@@ -3546,6 +3582,7 @@ export const FetchAgentsResponseMessageSchema = z.object({
subscriptionId: z.string().nullable().optional(),
entries: z.array(AgentDirectoryResponseEntrySchema),
pageInfo: AgentDirectoryPageInfoSchema,
sync: DirectorySyncMetadataSchema.optional(),
}),
});
@@ -3583,8 +3620,14 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectCustomName: z.string().nullable().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(),
// Fingerprints the effective icon, including automatic discovery and the
// absence of an icon. Clients may persist icon results against this value.
// COMPAT(projectIconCache): added in v0.2.7, remove optional after 2027-02-12.
projectIconRevision: z.string().optional(),
projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
});
export const FetchWorkspacesResponseMessageSchema = z.object({
@@ -3602,6 +3645,7 @@ export const FetchWorkspacesResponseMessageSchema = z.object({
prevCursor: z.string().nullable(),
hasMore: z.boolean(),
}),
sync: DirectorySyncMetadataSchema.optional(),
}),
});
@@ -3611,6 +3655,8 @@ export const WorkspaceUpdateMessageSchema = z.object({
z.object({
kind: z.literal("upsert"),
workspace: WorkspaceDescriptorPayloadSchema,
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
z.object({
kind: z.literal("remove"),
@@ -3625,6 +3671,8 @@ export const WorkspaceUpdateMessageSchema = z.object({
// Project removal is represented on the existing workspace update channel
// so old clients can still parse the message and ignore the extra field.
removedProjectId: z.string().optional(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
]),
});
@@ -3632,8 +3680,18 @@ export const WorkspaceUpdateMessageSchema = z.object({
export const ProjectUpdateMessageSchema = z.object({
type: z.literal("project.update"),
payload: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }),
z.object({ kind: z.literal("remove"), projectId: z.string() }),
z.object({
kind: z.literal("upsert"),
project: WorkspaceProjectDescriptorPayloadSchema,
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
z.object({
kind: z.literal("remove"),
projectId: z.string(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
]),
});
@@ -3642,6 +3700,7 @@ export const ProjectListResponseMessageSchema = z.object({
payload: z.object({
requestId: z.string(),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema),
sync: DirectorySyncMetadataSchema.optional(),
}),
});
@@ -93,6 +93,59 @@ describe("workspace message schemas", () => {
expect(activeScoped.scope).toBe("active");
});
test("parses optional sequenced directory requests and responses", () => {
expect(
SessionInboundMessageSchema.parse({
type: "project.list.request",
requestId: "projects-sync",
sync: { generation: "daemon-generation", afterSeq: 7 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 7 } });
expect(
SessionInboundMessageSchema.parse({
type: "fetch_workspaces_request",
requestId: "workspaces-sync",
sync: { generation: "daemon-generation", afterSeq: 11 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 11 } });
expect(
SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: "agents-sync",
scope: "active",
sync: { generation: "daemon-generation", afterSeq: 13 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 13 } });
const response = SessionOutboundMessageSchema.parse({
type: "project.list.response",
payload: {
requestId: "projects-sync",
projects: [
{
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo",
projectKind: "git",
syncSeq: 12,
},
],
sync: {
generation: "daemon-generation",
headSeq: 12,
mode: "changes",
removals: [{ id: "project-removed", seq: 10 }],
},
},
});
expect(response).toMatchObject({
payload: {
projects: [{ projectId: "project-1", syncSeq: 12 }],
sync: { headSeq: 12, removals: [{ id: "project-removed", seq: 10 }] },
},
});
});
test("parses agent_update without project placement", () => {
const result = SessionOutboundMessageSchema.safeParse({
type: "agent_update",