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:
@@ -5,6 +5,7 @@ import {
|
||||
composeWorkspaceStructure,
|
||||
selectHasHydratedWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
selectHasWorkspaces,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
@@ -79,6 +80,14 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceDirectory(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
composeWorkspaceStructure,
|
||||
selectHasWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
selectWorkspace,
|
||||
@@ -131,20 +132,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("workspace replica authority", () => {
|
||||
it("keeps a cached workspace addressable without publishing it as an authoritative directory", () => {
|
||||
it("publishes a restored directory while keeping remote hydration false", () => {
|
||||
const cachedWorkspace = createWorkspace({ id: "cached-workspace" });
|
||||
initializeWorkspaces([cachedWorkspace]);
|
||||
useSessionStore.getState().restoreSessionReplica(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
|
||||
projects: new Map([
|
||||
[cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)],
|
||||
]),
|
||||
timeline: null,
|
||||
});
|
||||
const cachedSession = useSessionStore.getState().sessions[SERVER_ID];
|
||||
if (!cachedSession) throw new Error("expected initialized session");
|
||||
|
||||
const cachedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
||||
const cachedServerIds = selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [
|
||||
SERVER_ID,
|
||||
]);
|
||||
|
||||
expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe(
|
||||
cachedWorkspace,
|
||||
);
|
||||
expect(selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(cachedSession.hasHydratedWorkspaces).toBe(false);
|
||||
expect(
|
||||
selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds).map(
|
||||
(project) => project.workspaceKeys,
|
||||
),
|
||||
).toEqual([[`${SERVER_ID}:${cachedWorkspace.id}`]]);
|
||||
|
||||
const authoritativeWorkspace = createWorkspace({
|
||||
id: "authoritative-workspace",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SessionsSnapshot {
|
||||
string,
|
||||
{
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
hasWorkspaceDirectorySnapshot?: boolean;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects?: Map<string, ProjectDescriptor>;
|
||||
}
|
||||
@@ -138,6 +139,18 @@ export function selectHydratedWorkspaceServerIds(
|
||||
return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true);
|
||||
}
|
||||
|
||||
export function selectWorkspaceDirectoryServerIds(
|
||||
state: SessionsSnapshot,
|
||||
serverIds: readonly string[],
|
||||
): string[] {
|
||||
return serverIds.filter((serverId) => {
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.hasHydratedWorkspaces === true || session?.hasWorkspaceDirectorySnapshot === true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function selectWorkspaceStructureProjects(
|
||||
state: SessionsSnapshot,
|
||||
serverIds: readonly string[],
|
||||
|
||||
@@ -172,6 +172,7 @@ export interface ProjectDescriptor {
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectCustomIconRevision?: string | null;
|
||||
projectIconRevision?: string;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
}
|
||||
@@ -185,6 +186,7 @@ export function normalizeProjectDescriptor(
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
|
||||
projectIconRevision: payload.projectIconRevision,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
};
|
||||
@@ -391,6 +393,7 @@ export interface SessionState {
|
||||
// Hydration status
|
||||
hasHydratedAgents: boolean;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
hasWorkspaceDirectorySnapshot: boolean;
|
||||
|
||||
// Audio state
|
||||
isPlayingAudio: boolean;
|
||||
@@ -653,6 +656,7 @@ function createInitialSessionState(
|
||||
serverInfo: null,
|
||||
hasHydratedAgents: false,
|
||||
hasHydratedWorkspaces: false,
|
||||
hasWorkspaceDirectorySnapshot: false,
|
||||
isPlayingAudio: false,
|
||||
focusedAgentId: null,
|
||||
focusedTerminalId: null,
|
||||
@@ -799,6 +803,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
||||
workspaces: replica.workspaces,
|
||||
projects: replica.projects,
|
||||
hasWorkspaceDirectorySnapshot: true,
|
||||
agentStreamTail,
|
||||
agentTasks,
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user