diff --git a/docs/architecture.md b/docs/architecture.md index 6599fa673e..a1288d0e91 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -104,8 +104,8 @@ Cross-platform React Native app that connects to one or more daemons. - Expo Router navigation (`/h/[serverId]/workspace/[workspaceId]`, `/h/[serverId]/agent/[agentId]`, etc.). The `workspaceId` URL segment is an opaque workspace id, not a directly meaningful filesystem path. - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state -- `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in a row store. It restores before navigation becomes ready and leaves remote hydration flags false. -- `runtime/directory-sync` owns directory reconciliation. On reconnect it passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. +- `runtime/replica-cache` is typed storage behind the directory and timeline owners. It never observes or mutates `SessionStore`. +- `runtime/directory-sync` owns directory cache selection and network reconciliation. On demand it paints accepted rows for one host, then passes the persisted per-entity cursor through `project.list`, `fetch_workspaces`, and `fetch_agents`; the daemon returns each entity's latest projection when its sequence is newer, plus tombstones. - `workspace-labels` owns one sequenced catalog replica per connected host, the deterministic cross-host projection that surfaces spanning hosts use (the filter page, the manager), and the per-host resolution a workspace row's chips use. Two hosts may give one name different colors, so a row resolves against its own host's catalog and a merged answer would be wrong there. Catalogs never synchronize between hosts; assignment creates a missing definition only on the target host. On the daemon, catalog and assignment rewrites share a journaled commit boundary. Startup recovery completes that commit before workspace or catalog publication. - `SessionContext` wraps the daemon client for the active session - Composer UI and submit/draft behavior live in `packages/app/src/composer/`; screens and panels should integrate it from there instead of dropping composer internals into `components/`, `hooks/`, or `screens/workspace/` @@ -113,17 +113,21 @@ Cross-platform React Native app that connects to one or more daemons. - Timeline sync correctness is documented in [docs/timeline-sync.md](timeline-sync.md): live streams are for immediacy, `fetch_agent_timeline_request` is authoritative, and catch-up is paged but complete. - Voice features: dictation (STT) and voice agent (realtime) -The replica cache paints stale data immediately while the host connects. Directory cursors are -reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers. -Pending permission requests are not restored from it. The row store is not encrypted, so the cached -timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a -separate product/security decision. Agents, workspaces, and projects have individual rows; the -focused timeline tail and directory checkpoint each have one row per host. Writes serialize only -rows whose in-memory identities changed. Restore validates every row before hydration; one invalid -row clears the cache and starts cold. The 32 MiB byte budget is tracked incrementally from row -payload lengths and evicts whole hosts in least-recently-written order. After restart, that order is -an approximation rebuilt from the row store's `readAll()` order. Browser and Electron builds use -IndexedDB. Native builds use expo-sqlite. +Consumers request directory or timeline data without choosing memory, cache, or network. The owner +publishes an accepted cache hit and then reconciles it over the existing network path. A miss or an +invalid row uses that same path. Offline demand still publishes an accepted cache hit and defers +network reconciliation. Cache loading is demand-driven: opening a chat reads its agent row and +focused timeline row, plus the workspace and project rows needed by the route; opening a directory +reads directory rows for that host and establishes its live subscription when connected. Accepted +cached rows satisfy the same consumer-readiness projection as network rows. Host registry startup and +host connection do not create directory demand or install replicas. The directory owner retains +declared surface demand and re-establishes network reconciliation after reconnect; React does not +track connection generations. Late cache reads cannot replace state already advanced by live or +authoritative network data. Owners explicitly persist accepted commits; directory rows and their +checkpoint share one storage transaction. See +[data-model.md](data-model.md#replica-row-store) +for the storage shape and [timeline-sync.md](timeline-sync.md#client-replica-lifetime) for timeline +resume behavior. The three directory entity types have independent monotonic sequences and share one daemon generation. The daemon retains only the latest projection per entity and bounded tombstones, not an diff --git a/docs/data-model.md b/docs/data-model.md index 4da3abb251..edf1ebb657 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -521,6 +521,26 @@ Right-sidebar client state splits on whether it is determined by the directory o - **Directory-backed** (shared by same-`cwd` workspaces): keyed by `(serverId, cwd)`. Git status/diff, GitHub PR status, PR timeline, file preview content. These are TanStack Query caches, not persisted stores. - **Workspace-owned** (independent per workspace): keyed by `workspaceId`, with `cwd` used only as a fallback when no `workspaceId` is present. Review draft comments (`@paseo:review-draft-store`), diff-mode overrides (in-memory), workspace composer attachments, and file-explorer nav/expand state. The `workspaceId` part of these keys is **opaque** — never parse it back into a path. +### Replica row store + +The durable client replica uses IndexedDB on browser/Electron and expo-sqlite on native. Rows use the +compound key `(serverId, kind, id)`; kinds are `agent`, `workspace`, `project`, `timeline`, and +`checkpoint`. Directory entities have individual rows. Timeline and checkpoint use the singleton id +and have at most one row per host. + +The store is a typed persistence boundary. It returns values to directory and timeline owners and +accepts their explicit commits; it never reads or writes UI state. Reads are scoped to the requested +host, kinds, and ids. Opening a cached workspace uses exact workspace and project keys rather than a +directory scan. One invalid row is deleted and returned as a miss without affecting other rows. +An invalid directory row and its affected checkpoint cursor are repaired in one transaction, so a +later launch cannot accept a checkpoint for a partial baseline. Directory changes and their +checkpoint are also applied in one transaction. + +The cache is capped at 32 MiB and evicts whole hosts in least-recently-written order. Budget +bookkeeping may scan opaque row sizes during a deferred write, never during host registry startup or +before a requested cache row can paint. The row store is not encrypted. A cached timeline can contain +source code, prompts, and tool output; encrypted-at-rest storage is a separate security decision. + ### Draft Store **AsyncStorage key:** `paseo-drafts` (version 2) diff --git a/docs/timeline-sync.md b/docs/timeline-sync.md index d645ff6833..3f0b6e790a 100644 --- a/docs/timeline-sync.md +++ b/docs/timeline-sync.md @@ -90,26 +90,28 @@ history start; a response requested from a pre-replacement range is stale and is ## Client replica lifetime -The host runtime owns each session replica for as long as the host remains registered. React -providers attach message handlers and UI integrations to that replica, but mounting or unmounting a -provider must not create or clear it. A provider can remount during Fast Refresh or ordinary UI -recomposition while the runtime still owns the same directory snapshot and timeline cursors. +The session projection remains host-scoped for as long as the host is registered. The viewed-timeline +owner wraps cached preparation, network catch-up, accepted timeline application, and persistence +behind one interface. React supplies transport and projection operations without selecting a cache +path or issuing a separate persistence notification. Removing the host from the registry is the destructive boundary: it stops the runtime and clears the session and host-scoped setup state together. -The durable replica cache persists synchronization authority only when it can store the complete -current canonical window losslessly. The stored range describes those exact items: `startSeq` drives -older pagination and `endSeq` drives forward catch-up. Restore paints the items immediately, requests -`after endSeq`, and requests `before startSeq` when the user loads older history. +The timeline owner asks durable replica storage for an agent when that agent becomes visible. An +accepted row paints immediately before subscription acknowledgement or timeline fetch. The stored +range describes those exact items: `startSeq` drives older pagination and `endSeq` drives forward +catch-up. The owner requests `after endSeq`, and requests `before startSeq` when the user loads older +history. Code outside the owner does not distinguish cached and network timelines. The first resume request is bounded. If it reports more newer history, fetch one latest bounded tail instead of replaying every missed page. Live gap recovery still pages forward until current. If the canonical window exceeds the cache item limit, contains a discontiguous retained range, has a live head, or includes presentation data the cache cannot encode losslessly, persistence drops the -range and keeps a display-only tail. Restore then uses the ordinary bounded `tail` bootstrap. Never -slice items while retaining the pre-slice range; that falsely certifies discarded source rows. +range and keeps a display-only tail. Never slice items while retaining the pre-slice range; that +falsely certifies discarded source rows. A display-only row paints without granting synchronization +authority, so the owner uses the ordinary bounded `tail` bootstrap. Live rows received between cache paint and catch-up stay in the separate live head and reconcile with the authoritative range through the existing forward-page path. The cache does not persist sync diff --git a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts index f04a0626ed..6ae16b62a7 100644 --- a/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts +++ b/packages/app/e2e/browser/rewind-menu.ui-contract.spec.ts @@ -26,15 +26,35 @@ async function expectUserMessageVisible(page: Page, text: string): Promise await expect(userMessage(page, text)).toBeVisible(); } -async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise { +async function rewriteCachedMessageAsLegacyRow( + page: Page, + input: { prompt: string; agentId: string; workspaceId: string }, +): Promise { await expect .poll(async () => { const cache = await readReplicaCache(page); if (!cache) return false; for (const host of cache.hosts ?? []) { - for (const item of host.timeline?.items ?? []) { - if (item.kind === "user_message" && item.text === prompt && item.messageId) { - return true; + const hasAgent = host.agents.some( + (agent) => + agent.snapshot && + typeof agent.snapshot === "object" && + Reflect.get(agent.snapshot, "id") === input.agentId, + ); + const hasWorkspace = host.workspaces.some( + (workspace) => workspace.id === input.workspaceId, + ); + if (!hasAgent || !hasWorkspace) continue; + for (const timeline of host.timelines) { + for (const item of timeline.items ?? []) { + if ( + timeline.agentId === input.agentId && + item.kind === "user_message" && + item.text === input.prompt && + item.messageId + ) { + return true; + } } } } @@ -45,8 +65,8 @@ async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Prom const cache = await readReplicaCache(page); if (!cache) throw new Error("Replica cache was not persisted"); const cachedMessage = cache.hosts - ?.flatMap((host) => host.timeline?.items ?? []) - .find((item) => item.kind === "user_message" && item.text === prompt); + ?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? [])) + .find((item) => item.kind === "user_message" && item.text === input.prompt); if (!cachedMessage) throw new Error("Cached user message was not found"); delete cachedMessage.messageId; await writeReplicaCache(page, cache); @@ -61,7 +81,7 @@ async function waitForCurrentSubmissionExcludedFromCache( const cache = await readReplicaCache(page); if (!cache) return false; return !cache.hosts - ?.flatMap((host) => host.timeline?.items ?? []) + ?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? [])) .some( (item) => item.kind === "user_message" && @@ -79,7 +99,7 @@ async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string) const cache = await readReplicaCache(page); if (!cache) return false; return cache.hosts - ?.flatMap((host) => host.timeline?.items ?? []) + ?.flatMap((host) => host.timelines.flatMap((timeline) => timeline.items ?? [])) .some( (item) => item.kind === "user_message" && item.text === prompt && item.messageId === undefined, @@ -131,14 +151,18 @@ test.describe("Rewind sheet", () => { await openAgentRoute(page, session); await expectUserMessageVisible(page, prompt); await gate.drop(); - await rewriteCachedMessageAsLegacyRow(page, prompt); + await rewriteCachedMessageAsLegacyRow(page, { + prompt, + agentId: session.agentId, + workspaceId: session.workspaceId, + }); await page.reload(); + await waitForCachedMessageWithoutProviderId(page, prompt); const restoredMessage = userMessage(page, prompt); await expect(restoredMessage).toBeVisible(); await restoredMessage.hover(); await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0); - await waitForCachedMessageWithoutProviderId(page, prompt); } finally { gate.restore(); await session.cleanup(); diff --git a/packages/app/e2e/support/helpers/replica-cache-storage.ts b/packages/app/e2e/support/helpers/replica-cache-storage.ts index dc06de8dd0..d1957880fe 100644 --- a/packages/app/e2e/support/helpers/replica-cache-storage.ts +++ b/packages/app/e2e/support/helpers/replica-cache-storage.ts @@ -18,7 +18,7 @@ interface ReplicaCacheHostRecord { agents: Array>; workspaces: Array>; projects: Array>; - timeline?: { + timelines: Array<{ agentId?: string; items?: Array>; range?: { @@ -26,7 +26,7 @@ interface ReplicaCacheHostRecord { epoch?: string; startSeq?: number; } | null; - } | null; + }>; directorySync?: unknown; } @@ -83,7 +83,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord { agents: [], workspaces: [], projects: [], - timeline: null, + timelines: [], }; const payload = JSON.parse(row.payload) as Record; switch (row.kind) { @@ -97,7 +97,7 @@ function assembleCache(rows: ReplicaRowRecord[]): ReplicaCacheRecord { host.projects.push(payload); break; case "timeline": - host.timeline = payload; + host.timelines.push(payload); break; case "checkpoint": host.directorySync = payload; @@ -147,12 +147,13 @@ export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): }); } } - if (host.timeline) { + for (const timeline of host.timelines) { + if (typeof timeline.agentId !== "string") continue; rows.push({ serverId: host.serverId, kind: "timeline", - id: SINGLETON_ID, - payload: JSON.stringify(host.timeline), + id: timeline.agentId, + payload: JSON.stringify(timeline), }); } if (host.directorySync !== undefined) { diff --git a/packages/app/e2e/support/helpers/timeline-pagination.ts b/packages/app/e2e/support/helpers/timeline-pagination.ts index 79848da4ba..0cdd34857c 100644 --- a/packages/app/e2e/support/helpers/timeline-pagination.ts +++ b/packages/app/e2e/support/helpers/timeline-pagination.ts @@ -293,9 +293,9 @@ export async function reloadAgentTimelineFromPersistedReplica( await expect .poll(async () => { const cache = await readReplicaCache(page); - const timeline = cache?.hosts?.find( - (host) => host.timeline?.agentId === agent.agentId, - )?.timeline; + const timeline = cache?.hosts + ?.flatMap((host) => host.timelines) + .find((candidate) => candidate.agentId === agent.agentId); return timeline?.items?.length === 50; }) .toBe(true); @@ -311,7 +311,9 @@ export async function waitForPersistedCanonicalTimelineRange( const readRange = async () => { const cache = await readReplicaCache(page); if (cache?.version !== 6) return null; - const range = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline?.range; + const range = cache.hosts + ?.flatMap((host) => host.timelines) + .find((timeline) => timeline.agentId === agentId)?.range; if ( typeof range?.epoch !== "string" || typeof range.startSeq !== "number" || diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index b54470d50a..884bde3558 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -961,18 +961,9 @@ function RootProviders({ children }: { children: ReactNode }) { ); } -function recordUserActivity(): void { - getHostRuntimeStore().recordUserActivity(); -} - function RootAppTree() { return ( - + diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 0ad0394143..7f66a07b25 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -38,6 +38,12 @@ function HostAgentReadyRouteContent() { } return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); + useEffect(() => { + if (!serverId || !agentId || agentWorkspaceId) return; + void getHostRuntimeStore() + .prepareAgentRoute(serverId, agentId) + .catch(() => undefined); + }, [agentId, agentWorkspaceId, serverId]); const shouldLookupAgent = Boolean( serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); diff --git a/packages/app/src/command-center/command-center.tsx b/packages/app/src/command-center/command-center.tsx index 956c47b385..d61ffa2e54 100644 --- a/packages/app/src/command-center/command-center.tsx +++ b/packages/app/src/command-center/command-center.tsx @@ -138,7 +138,7 @@ function useBuiltInRows(open: boolean): { agents: CommandCenterAgentResult[]; } { const { t } = useTranslation(); - const { agents } = useAggregatedAgents(); + const { agents } = useAggregatedAgents({ demand: open }); const { projects } = useProjects({ enabled: open }); const showHost = useHosts().length > 1; diff --git a/packages/app/src/components/sidebar-workspace-list.test.tsx b/packages/app/src/components/sidebar-workspace-list.test.tsx index ae8bf82777..94e6f36466 100644 --- a/packages/app/src/components/sidebar-workspace-list.test.tsx +++ b/packages/app/src/components/sidebar-workspace-list.test.tsx @@ -31,7 +31,6 @@ import { type SidebarProjectEntry, } from "@/hooks/use-sidebar-workspaces-list"; import { useSidebarWorkspacesList } from "@/hooks/use-sidebar-workspaces-list"; -import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { getHostRuntimeStore, type HostRuntimeController, @@ -404,35 +403,6 @@ describe("sidebar workspace render isolation", () => { }); }); - it("does not re-render for a deep-equal scripts patch", async () => { - const counts: RenderCounts = { - frame: 0, - headers: {}, - rows: {}, - projectSelection: {}, - rowSelection: {}, - }; - ({ root, container } = await renderProbe(counts)); - - const applyRunningScript = (current: Parameters[0]) => - patchWorkspaceScripts(current, { - workspaceId: "a-main", - scripts: [{ ...runningScript }], - }); - - act(() => { - useSessionStore.getState().setWorkspaces(SERVER_ID, applyRunningScript); - }); - - expect(counts).toEqual({ - frame: 0, - headers: {}, - rows: {}, - projectSelection: {}, - rowSelection: {}, - }); - }); - it("updates active selection probes from the active workspace route", async () => { const counts: RenderCounts = { frame: 0, diff --git a/packages/app/src/components/sidebar/sidebar-model.tsx b/packages/app/src/components/sidebar/sidebar-model.tsx index a09c9c4526..9399a3bd13 100644 --- a/packages/app/src/components/sidebar/sidebar-model.tsx +++ b/packages/app/src/components/sidebar/sidebar-model.tsx @@ -54,7 +54,7 @@ export function SidebarModelProvider({ active?: boolean; children: ReactNode; }) { - const list = useSidebarWorkspacesList(); + const list = useSidebarWorkspacesList({ enabled: active }); const groupMode = useSidebarViewStore((state) => state.groupMode); const labelFilter = useSidebarViewStore((state) => state.labelFilter); const projectFilters = useSidebarViewStore((state) => state.projectFilters); diff --git a/packages/app/src/contexts/session-context.service-status.test.ts b/packages/app/src/contexts/session-context.service-status.test.ts deleted file mode 100644 index 2c57c744cc..0000000000 --- a/packages/app/src/contexts/session-context.service-status.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { WorkspaceScriptPayload } from "@getpaseo/protocol/messages"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { patchWorkspaceScripts } from "./session-workspace-scripts"; - -function workspace(input: { - id: string; - workspaceDirectory?: string; - scripts?: WorkspaceDescriptor["scripts"]; -}): WorkspaceDescriptor { - return { - id: input.id, - projectId: "project-1", - projectDisplayName: "Project 1", - projectRootPath: "/repo", - workspaceDirectory: input.workspaceDirectory ?? "/repo/main", - projectKind: "git", - workspaceKind: "checkout", - name: "main", - status: "running", - archivingAt: null, - statusEnteredAt: null, - diffStat: null, - scripts: input.scripts ?? [], - }; -} - -const runningScript: WorkspaceScriptPayload = { - scriptName: "web", - type: "service", - hostname: "web.paseo.localhost", - port: 3000, - proxyUrl: "http://web.paseo.localhost:6767", - lifecycle: "running", - health: "healthy", - exitCode: null, - terminalId: null, -}; - -describe("patchWorkspaceScripts", () => { - it("patches only the matching workspace scripts", () => { - const other = workspace({ id: "ws-other", workspaceDirectory: "/repo/other", scripts: [] }); - const current = new Map([ - ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], - [other.id, other], - ]); - - const next = patchWorkspaceScripts(current, { - workspaceId: "ws-main", - scripts: [runningScript], - }); - - expect(next).not.toBe(current); - expect(next.get("ws-main")?.scripts).toEqual([runningScript]); - expect(next.get("ws-other")).toBe(other); - }); - - it("patches the matching workspace when the map key differs from the workspace id", () => { - const current = new Map([ - [ - "workspace-record-42", - workspace({ - id: "ws-main", - workspaceDirectory: "C:\\repo\\main\\", - scripts: [], - }), - ], - ]); - - const next = patchWorkspaceScripts(current, { - workspaceId: "ws-main", - scripts: [runningScript], - }); - - expect(next).not.toBe(current); - expect(next.get("workspace-record-42")?.scripts).toEqual([runningScript]); - }); - - it("ignores updates for unknown workspaces", () => { - const current = new Map([ - ["ws-main", workspace({ id: "ws-main", workspaceDirectory: "/repo/main", scripts: [] })], - ]); - - const next = patchWorkspaceScripts(current, { - workspaceId: "ws-missing", - scripts: [runningScript], - }); - - expect(next).toBe(current); - expect(next.get("ws-main")?.scripts).toEqual([]); - }); -}); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 14a0589a8b..f19224eee8 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -11,23 +11,13 @@ import { refreshAgentInitializationTimeout, } from "@/hooks/use-agent-initialization"; import type { StreamItem } from "@/types/stream"; -import { - createSessionAgentStreamReducerQueue, - deriveAgentStreamTurnLiveness, - processTimelineResponse, - type ProcessTimelineResponseOutput, - type TimelineReducerSideEffect, -} from "@/timeline/session-stream-reducers"; -import { useCreateFlowStore } from "@/stores/create-flow-store"; -import { - isTimelineResumeSnapshotAuthoritative, - planTimelineTailFetch, -} from "@/timeline/timeline-sync-plan"; +import { deriveAgentStreamTurnLiveness } from "@/timeline/session-stream-reducers"; +import { planTimelineTailFetch } from "@/timeline/timeline-sync-plan"; import { requestTimelineReplacement } from "@/timeline/timeline-replacement"; import { - createViewedTimelineSync, type TimelineDeliveryMode, - type ViewedTimelineSync, + type TimelineResponsePayload, + type ViewedTimelineOwner, } from "@/timeline/viewed-timeline-sync"; import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol/messages"; import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; @@ -57,14 +47,11 @@ import { getInitKey, getInitDeferred, createInitDeferred, - resolveInitDeferred, rejectInitDeferred, } from "@/utils/agent-initialization"; import { encodeImages } from "@/utils/encode-images"; import { derivePendingPermissionKey } from "@/utils/agent-snapshots"; -import { getSendingClientMessageIds } from "@/composer/submission/model"; import type { AttachmentMetadata } from "@/attachments/types"; -import { patchWorkspaceScripts } from "@/contexts/session-workspace-scripts"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { showProviderNoticeToast } from "@/utils/provider-notice-toast"; @@ -72,11 +59,6 @@ import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache" import { useProviderSubagentStore } from "@/subagents/provider-store"; import { revalidateSessionAfterResume } from "@/contexts/session-resume-revalidation"; -type TimelineResponsePayload = Extract< - SessionOutboundMessage, - { type: "fetch_agent_timeline_response" } ->["payload"]; - function consumeForcedTimelineTailReplacement( payload: TimelineResponsePayload, replacements: Set, @@ -212,86 +194,6 @@ type WorkspaceSetupProgressPayload = Extract< { type: "workspace_setup_progress" } >["payload"]; -type SessionStoreActions = ReturnType; -type SetInitializingAgents = SessionStoreActions["setInitializingAgents"]; - -function clearAgentInitializingFlag( - setInitializingAgents: SetInitializingAgents, - serverId: string, - agentId: string, -): void { - setInitializingAgents(serverId, (prev) => { - if (prev.get(agentId) !== true) { - return prev; - } - const next = new Map(prev); - next.set(agentId, false); - return next; - }); -} - -function handleTimelineError(input: { - result: ProcessTimelineResponseOutput; - agentId: string; - initKey: string; - serverId: string; - setInitializingAgents: SetInitializingAgents; -}): void { - const { result, agentId, initKey, serverId, setInitializingAgents } = input; - if (result.clearInitializing) { - clearAgentInitializingFlag(setInitializingAgents, serverId, agentId); - } - if (result.initResolution === "reject" && result.error) { - rejectInitDeferred(initKey, new Error(result.error)); - } -} - -function executeTimelineSideEffects(input: { - sideEffects: TimelineReducerSideEffect[]; - agentId: string; - recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; -}): void { - const { sideEffects, agentId, recoverTimelineGap } = input; - for (const effect of sideEffects) { - if (effect.type === "catch_up") { - recoverTimelineGap(agentId, effect.cursor); - } - } -} - -function finalizeTimelineApplication(input: { - result: ProcessTimelineResponseOutput; - agentId: string; - initKey: string; - serverId: string; - shouldMarkAuthoritativeHistoryApplied: boolean; - setInitializingAgents: SetInitializingAgents; -}): void { - const { - result, - agentId, - initKey, - serverId, - shouldMarkAuthoritativeHistoryApplied, - setInitializingAgents, - } = input; - - if (result.clearInitializing) { - clearAgentInitializingFlag(setInitializingAgents, serverId, agentId); - } - if (shouldMarkAuthoritativeHistoryApplied) { - useCreateFlowStore.getState().clearByAgent({ serverId, agentId }); - const session = useSessionStore.getState().sessions[serverId]; - const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId); - if (agent && agent.status !== "running") { - getHostRuntimeStore().drainQueuedAgentMessage(serverId, agentId); - } - } - if (result.initResolution === "resolve") { - resolveInitDeferred(initKey); - } -} - function notifyVoiceAbortFailure( data: Extract["payload"], notifyError: (message: string) => void, @@ -337,22 +239,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setIsPlayingAudio = useSessionStore((state) => state.setIsPlayingAudio); const setAgentStreamTail = useSessionStore((state) => state.setAgentStreamTail); const setAgentStreamHead = useSessionStore((state) => state.setAgentStreamHead); - const setAgentStreamState = useSessionStore((state) => state.setAgentStreamState); const applyAgentTurnLiveness = useSessionStore((state) => state.applyAgentTurnLiveness); const clearAgentTurnLiveness = useSessionStore((state) => state.clearAgentTurnLiveness); const clearAgentStreamHead = useSessionStore((state) => state.clearAgentStreamHead); - const setAgentTimelineCursor = useSessionStore((state) => state.setAgentTimelineCursor); - const setAgentTimelineHasNewer = useSessionStore((state) => state.setAgentTimelineHasNewer); const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents); const bumpHistorySyncGeneration = useSessionStore((state) => state.bumpHistorySyncGeneration); - const markAgentHistorySynchronized = useSessionStore( - (state) => state.markAgentHistorySynchronized, - ); - const applyAgentTimelineResponseState = useSessionStore( - (state) => state.applyAgentTimelineResponseState, - ); const setAgents = useSessionStore((state) => state.setAgents); - const setWorkspaces = useSessionStore((state) => state.setWorkspaces); const flushAgentLastActivity = useSessionStore((state) => state.flushAgentLastActivity); const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); @@ -370,7 +262,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const attentionNotifiedRef = useRef>(new Map()); const appStateRef = useRef(AppState.currentState); const forcedTimelineTailReplacements = useRef(new Set()); - const viewedTimelineSyncRef = useRef(null); + const viewedTimelineSyncRef = useRef(null); const audioOutputBuffersRef = useRef>(new Map()); const activeAudioGroupsRef = useRef>(new Set()); const isAppVisible = useAppVisible(); @@ -389,20 +281,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider viewedTimelineSyncRef.current?.setActive(isAppVisible); }, [isAppVisible]); - const recoverTimelineGap = useCallback( - (agentId: string, cursor: { epoch: string; endSeq: number }) => { - viewedTimelineSyncRef.current?.recoverGap(agentId, cursor); - }, - [], - ); - const handleAppResumed = useCallback( (awayMs: number) => { void revalidateSessionAfterResume({ awayMs, serverId, bumpHistorySyncGeneration, - refreshDirectories: () => getHostRuntimeStore().refreshDirectories(serverId), }); }, [bumpHistorySyncGeneration, serverId], @@ -413,7 +297,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider client, focusedAgentId, focusedTerminalId, - onUserActivity: () => getHostRuntimeStore().recordUserActivity(), onAppResumed: handleAppResumed, }); useEffect(() => startPushNotifications({ client, serverId }), [client, serverId]); @@ -553,118 +436,22 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider [serverId, upsertWorkspaceSetupProgress], ); - const applyTimelineResponse = useCallback( - (receivedPayload: TimelineResponsePayload) => { - const payload = consumeForcedTimelineTailReplacement( - receivedPayload, - forcedTimelineTailReplacements.current, - ); - const agentId = payload.agentId; - const initKey = getInitKey(serverId, agentId); - const shouldMarkAuthoritativeHistoryApplied = isTimelineResumeSnapshotAuthoritative({ - direction: payload.direction, - hasNewer: payload.hasNewer, - error: payload.error, - }); - - // Read current store state - const session = useSessionStore.getState().sessions[serverId]; - const isInitializing = session?.initializingAgents.get(agentId) === true; - const activeInitDeferred = getInitDeferred(initKey); - const hasActiveInitDeferred = Boolean(activeInitDeferred); - const timeline = selectAgentTimelineState(session, agentId); - const currentCursor = - timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; - const currentTail = timeline.status === "cold" ? [] : timeline.items; - const currentHead = session?.agentStreamHead.get(agentId) ?? []; - const sendingClientMessageIds = getSendingClientMessageIds( - session?.messageSubmissions.get(agentId), - ); - - // Call pure reducer - const result = processTimelineResponse({ - payload, - currentTail, - currentHead, - currentCursor, - isInitializing, - hasActiveInitDeferred, - initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", - sendingClientMessageIds, - }); - - if (result.error) { - handleTimelineError({ - result, - agentId, - initKey, - serverId, - setInitializingAgents, - }); - return; - } - - if (result.commit === "discard") { - if (result.acknowledgedClientMessageIds.length > 0) { - setAgentStreamState(serverId, agentId, { - acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, - }); - } - if (payload.direction !== "before") { - setAgentTimelineHasNewer(serverId, (current) => { - const next = new Map(current); - next.set(agentId, payload.hasNewer); - return next; - }); - } - markAgentHistorySynchronized(serverId, agentId); - } else { - applyAgentTimelineResponseState(serverId, agentId, { - items: result.tail, - head: result.head, - range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), - older: result.older, - newer: - payload.direction === "before" - ? timeline.status === "synced" && timeline.newer === "available" - : payload.hasNewer, - synchronized: shouldMarkAuthoritativeHistoryApplied, - acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, - }); - } - - executeTimelineSideEffects({ - sideEffects: result.sideEffects, - agentId, - recoverTimelineGap, - }); - - finalizeTimelineApplication({ - result, - agentId, - initKey, - serverId, - shouldMarkAuthoritativeHistoryApplied, - setInitializingAgents, - }); - }, - [ - applyAgentTimelineResponseState, - markAgentHistorySynchronized, - recoverTimelineGap, - serverId, - setAgentStreamState, - setAgentTimelineHasNewer, - setInitializingAgents, - ], - ); + const applyTimelineResponse = useCallback((receivedPayload: TimelineResponsePayload) => { + const payload = consumeForcedTimelineTailReplacement( + receivedPayload, + forcedTimelineTailReplacements.current, + ); + const owner = viewedTimelineSyncRef.current; + if (!owner) throw new Error("Viewed timeline owner is unavailable"); + owner.applyTimelineResponse(payload); + }, []); useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); const initialDeliveryMode = getTimelineDeliveryMode( client.getLastServerInfoMessage()?.features?.selectiveAgentTimeline, ); - const sync = createViewedTimelineSync({ + const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, { initialDeliveryMode, setSubscription: (agentIds) => client.setAgentTimelineSubscription(agentIds), readCursor: (agentId) => { @@ -745,12 +532,8 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider // Daemon message handlers - directly update Zustand store useEffect(() => { - const agentStreamReducerQueue = createSessionAgentStreamReducerQueue({ - serverId, - setAgentStreamState, - setAgentTimelineCursor, - recoverTimelineGap, - }); + const owner = viewedTimelineSyncRef.current; + if (!owner) throw new Error("Viewed timeline owner is unavailable"); const unsubAgentStream = client.on("agent_stream", (message) => { if (message.type !== "agent_stream") return; @@ -771,7 +554,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (turnLiveness.length > 0) { applyAgentTurnLiveness(serverId, agentId, turnLiveness); } - agentStreamReducerQueue.enqueue(agentId, { + owner.enqueueStreamEvent(agentId, { event: streamEvent, seq, epoch, @@ -791,7 +574,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const unsubAgentTimeline = client.on("fetch_agent_timeline_response", (message) => { if (message.type !== "fetch_agent_timeline_response") return; - agentStreamReducerQueue.flushAgent(message.payload.agentId); + owner.flushStreamAgent(message.payload.agentId); applyTimelineResponse(message.payload); }); @@ -813,11 +596,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider useProviderSubagentStore.getState().applyUpdate(serverId, message.payload); }); - const unsubScriptStatusUpdate = client.on("script_status_update", (message) => { - if (message.type !== "script_status_update") return; - setWorkspaces(serverId, (prev) => patchWorkspaceScripts(prev, message.payload)); - }); - const unsubCheckoutStatusUpdate = client.on("checkout_status_update", (message) => { if (message.type !== "checkout_status_update") return; applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); @@ -1008,7 +786,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubAgentTimeline(); unsubProviderSubagentUpdate(); unsubAgentAttention(); - unsubScriptStatusUpdate(); unsubCheckoutStatusUpdate(); unsubWorkspaceSetupProgress(); unsubWorkspaceSetupStatusResponse(); @@ -1020,7 +797,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubTranscription(); unsubVoiceInputState(); unsubTerminalAttention(); - agentStreamReducerQueue.dispose({ flush: true }); }; }, [ client, @@ -1029,16 +805,12 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setIsPlayingAudio, setAgentStreamTail, setAgentStreamHead, - setAgentStreamState, applyAgentTurnLiveness, clearAgentStreamHead, - setAgentTimelineCursor, setInitializingAgents, setAgents, - setWorkspaces, setPendingPermissions, notifyAgentAttention, - recoverTimelineGap, applyWorkspaceSetupProgress, applyTimelineResponse, updateSessionServerInfo, diff --git a/packages/app/src/contexts/session-resume-revalidation.test.ts b/packages/app/src/contexts/session-resume-revalidation.test.ts index 8f3cc858cd..4c32bcf115 100644 --- a/packages/app/src/contexts/session-resume-revalidation.test.ts +++ b/packages/app/src/contexts/session-resume-revalidation.test.ts @@ -5,18 +5,17 @@ import { } from "./session-resume-revalidation"; describe("session resume revalidation", () => { - it("refreshes both directories and timeline history after a stale resume", async () => { + it("refreshes demanded timeline history without manufacturing directory demand", async () => { const calls: string[] = []; const revalidated = await revalidateSessionAfterResume({ awayMs: SESSION_STALE_AFTER_MS, serverId: "server", bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), - refreshDirectories: async () => calls.push("directories"), }); expect(revalidated).toBe(true); - expect(calls).toEqual(["history:server", "directories"]); + expect(calls).toEqual(["history:server"]); }); it("does nothing after a brief background interval", async () => { @@ -26,27 +25,9 @@ describe("session resume revalidation", () => { awayMs: SESSION_STALE_AFTER_MS - 1, serverId: "server", bumpHistorySyncGeneration: () => calls.push("history"), - refreshDirectories: async () => calls.push("directories"), }); expect(revalidated).toBe(false); expect(calls).toEqual([]); }); - - it("defers stale resume revalidation while the host is disconnected", async () => { - const calls: string[] = []; - - const revalidated = await revalidateSessionAfterResume({ - awayMs: SESSION_STALE_AFTER_MS, - serverId: "server", - bumpHistorySyncGeneration: (serverId) => calls.push(`history:${serverId}`), - refreshDirectories: async () => { - calls.push("directories"); - throw new Error("Host server is not connected"); - }, - }); - - expect(revalidated).toBe(false); - expect(calls).toEqual(["history:server", "directories"]); - }); }); diff --git a/packages/app/src/contexts/session-resume-revalidation.ts b/packages/app/src/contexts/session-resume-revalidation.ts index 139bf3e01c..8766ce4cc7 100644 --- a/packages/app/src/contexts/session-resume-revalidation.ts +++ b/packages/app/src/contexts/session-resume-revalidation.ts @@ -4,17 +4,11 @@ export async function revalidateSessionAfterResume(input: { awayMs: number; serverId: string; bumpHistorySyncGeneration: (serverId: string) => void; - refreshDirectories: () => Promise; }): Promise { if (input.awayMs < SESSION_STALE_AFTER_MS) { return false; } - try { - input.bumpHistorySyncGeneration(input.serverId); - await input.refreshDirectories(); - return true; - } catch { - return false; - } + input.bumpHistorySyncGeneration(input.serverId); + return true; } diff --git a/packages/app/src/contexts/session-workspace-scripts.ts b/packages/app/src/contexts/session-workspace-scripts.ts deleted file mode 100644 index 30d600ec9d..0000000000 --- a/packages/app/src/contexts/session-workspace-scripts.ts +++ /dev/null @@ -1,33 +0,0 @@ -import equal from "fast-deep-equal"; -import type { ScriptStatusUpdateMessage } from "@getpaseo/protocol/messages"; -import type { WorkspaceDescriptor } from "@/stores/session-store"; -import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; - -export function patchWorkspaceScripts( - workspaces: Map, - update: ScriptStatusUpdateMessage["payload"], -): Map { - const workspaceKey = resolveWorkspaceMapKeyByIdentity({ - workspaces, - workspaceId: update.workspaceId, - }); - if (!workspaceKey) { - return workspaces; - } - - const existing = workspaces.get(workspaceKey); - if (!existing) { - return workspaces; - } - - if (equal(existing.scripts, update.scripts)) { - return workspaces; - } - - const next = new Map(workspaces); - next.set(workspaceKey, { - ...existing, - scripts: update.scripts.map((s) => ({ ...s })), - }); - return next; -} diff --git a/packages/app/src/hooks/client-activity-tracker.test.ts b/packages/app/src/hooks/client-activity-tracker.test.ts index a919258cbd..db6f2e5476 100644 --- a/packages/app/src/hooks/client-activity-tracker.test.ts +++ b/packages/app/src/hooks/client-activity-tracker.test.ts @@ -68,26 +68,12 @@ function buildTracker( initialFocusedTerminalId: overrides.initialFocusedTerminalId ?? null, initialAppVisible: overrides.initialAppVisible ?? true, now: clock.now, - onUserActivity: overrides.onUserActivity ?? (() => undefined), onAppResumed: overrides.onAppResumed, }); return { tracker, client, clock }; } describe("client activity tracker", () => { - it("notifies local consumers about user activity", () => { - let notifications = 0; - const { tracker } = buildTracker({ - onUserActivity: () => { - notifications += 1; - }, - }); - - tracker.recordUserActivity(); - - expect(notifications).toBe(1); - }); - it("includes the latest user-activity time in the next heartbeat", () => { const { tracker, client, clock } = buildTracker(); diff --git a/packages/app/src/hooks/client-activity-tracker.ts b/packages/app/src/hooks/client-activity-tracker.ts index c33e83246a..d00b92c111 100644 --- a/packages/app/src/hooks/client-activity-tracker.ts +++ b/packages/app/src/hooks/client-activity-tracker.ts @@ -23,7 +23,6 @@ export interface ClientActivityTrackerInput { initialFocusedTerminalId: string | null; initialAppVisible: boolean; now: () => number; - onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -40,7 +39,7 @@ export interface ClientActivityTracker { export function createClientActivityTracker( input: ClientActivityTrackerInput, ): ClientActivityTracker { - const { client, deviceType, now, onUserActivity, onAppResumed } = input; + const { client, deviceType, now, onAppResumed } = input; let lastActivityAtMs = now(); let appVisible = input.initialAppVisible; let appVisibilityChangedAtMs = now(); @@ -63,7 +62,6 @@ export function createClientActivityTracker( function recordUserActivity(): void { lastActivityAtMs = now(); - onUserActivity(); } function maybeSendImmediateHeartbeat(): void { diff --git a/packages/app/src/hooks/use-agent-initialization.test.ts b/packages/app/src/hooks/use-agent-initialization.test.ts index 8786e3ac07..f2726fba02 100644 --- a/packages/app/src/hooks/use-agent-initialization.test.ts +++ b/packages/app/src/hooks/use-agent-initialization.test.ts @@ -114,23 +114,22 @@ describe("ensureAgentIsInitialized", () => { it("requests a bounded projected tail after restoring painted replica items", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); - useSessionStore.getState().restoreSessionReplica(serverId, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - timeline: { - agentId, - items: [ - { - kind: "assistant_message", - id: "painted-item", - text: "Painted before hydration", - timestamp: new Date("2026-07-27T10:00:00.000Z"), - }, - ], - range: null, - hasOlder: false, - }, + useSessionStore.getState().initializeSession(serverId, null); + useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, { + items: [ + { + kind: "assistant_message", + id: "painted-item", + text: "Painted before hydration", + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }, + ], + head: [], + range: null, + older: "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], }); void ensureAgentIsInitialized({ @@ -157,24 +156,23 @@ describe("ensureAgentIsInitialized", () => { it("catches up after the restored canonical replica range", () => { const client = new FakeDaemonClient(); const runtime = new FakeTimelineRuntime(); - useSessionStore.getState().restoreSessionReplica(serverId, { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - timeline: { - agentId, - items: [ - { - kind: "assistant_message", - id: "canonical-item", - text: "Canonical before restart", - timelineCursor: { epoch: "epoch-1", seq: 12 }, - timestamp: new Date("2026-07-27T10:00:00.000Z"), - }, - ], - range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 }, - hasOlder: true, - }, + useSessionStore.getState().initializeSession(serverId, null); + useSessionStore.getState().applyAgentTimelineResponseState(serverId, agentId, { + items: [ + { + kind: "assistant_message", + id: "canonical-item", + text: "Canonical before restart", + timelineCursor: { epoch: "epoch-1", seq: 12 }, + timestamp: new Date("2026-07-27T10:00:00.000Z"), + }, + ], + head: [], + range: { epoch: "epoch-1", startSeq: 10, endSeq: 12 }, + older: "available", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], }); void ensureAgentIsInitialized({ diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts index d76f603622..a7ed05e906 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.test.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.test.ts @@ -66,6 +66,7 @@ function createBaseInput(): AgentScreenMachineInput { isHistorySyncing: false, needsAuthoritativeSync: false, visibilityCatchUpStatus: "ready", + visibilityCatchUpError: null, hasHydratedHistoryBefore: false, }; } @@ -248,6 +249,24 @@ describe("deriveAgentScreenViewState", () => { expectSyncErrorSync(ready); }); + it("shows the owner error when the first timeline load fails", () => { + const result = deriveAgentScreenViewState({ + input: { + ...createBaseInput(), + agent: createAgent("agent-1"), + visibilityCatchUpStatus: "error", + visibilityCatchUpError: "already has an active writer", + }, + memory: createBaseMemory(), + }); + + expect(result.state).toEqual({ + tag: "error", + message: "already has an active writer", + }); + expect(result.memory.hadInitialSyncFailure).toBe(true); + }); + it("keeps sync errors non-blocking once the screen was ready", () => { const memory = createBaseMemory({ hasRenderedReady: true, diff --git a/packages/app/src/hooks/use-agent-screen-state-machine.ts b/packages/app/src/hooks/use-agent-screen-state-machine.ts index 09a4c907c5..df128e4f2f 100644 --- a/packages/app/src/hooks/use-agent-screen-state-machine.ts +++ b/packages/app/src/hooks/use-agent-screen-state-machine.ts @@ -49,6 +49,7 @@ export interface AgentScreenMachineInput { isHistorySyncing: boolean; needsAuthoritativeSync: boolean; visibilityCatchUpStatus: ViewedTimelineStatus; + visibilityCatchUpError: string | null; continuity: AgentScreenContinuity; hasHydratedHistoryBefore: boolean; } @@ -117,6 +118,13 @@ function updateInitialSyncFailureMemory(args: { if (args.input.missingAgentState.kind === "error" && !args.input.hasHydratedHistoryBefore) { args.nextMemory.hadInitialSyncFailure = true; } + if ( + args.input.visibilityCatchUpStatus === "error" && + args.input.visibilityCatchUpError && + !args.input.hasHydratedHistoryBefore + ) { + args.nextMemory.hadInitialSyncFailure = true; + } } function shouldUseOptimisticCreateFlowAgent(input: AgentScreenMachineInput): boolean { @@ -236,6 +244,21 @@ export function deriveAgentScreenViewState({ }; } + if ( + input.visibilityCatchUpStatus === "error" && + input.visibilityCatchUpError && + !input.hasHydratedHistoryBefore && + !nextMemory.hasRenderedReady + ) { + return { + state: { + tag: "error", + message: input.visibilityCatchUpError, + }, + memory: nextMemory, + }; + } + if (candidateAgent && shouldBlockReadyState) { return { state: { diff --git a/packages/app/src/hooks/use-aggregated-agents.ts b/packages/app/src/hooks/use-aggregated-agents.ts index 5d013db5ee..1a016e9b13 100644 --- a/packages/app/src/hooks/use-aggregated-agents.ts +++ b/packages/app/src/hooks/use-aggregated-agents.ts @@ -1,4 +1,4 @@ -import { useMemo, useCallback, useRef, useSyncExternalStore } from "react"; +import { useMemo, useCallback, useEffect, useRef, useSyncExternalStore } from "react"; import equal from "fast-deep-equal"; import { useShallow } from "zustand/shallow"; import { useSessionStore } from "@/stores/session-store"; @@ -21,10 +21,18 @@ export interface AggregatedAgentsResult { export function useAggregatedAgents(options?: { includeArchived?: boolean; + demand?: boolean; }): AggregatedAgentsResult { const daemons = useHosts(); const runtime = getHostRuntimeStore(); const includeArchived = options?.includeArchived ?? false; + const demand = options?.demand ?? true; + const serverIds = useMemo(() => daemons.map((daemon) => daemon.serverId), [daemons]); + useEffect(() => { + if (!demand) return; + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + return () => releases.forEach((release) => release()); + }, [demand, runtime, serverIds]); const runtimeVersion = useSyncExternalStore( (onStoreChange) => runtime.subscribeAll(onStoreChange), () => runtime.getVersion(), @@ -42,8 +50,9 @@ export function useAggregatedAgents(options?: { ); const refreshAll = useCallback(() => { - runtime.refreshAllAgentDirectories(); - }, [runtime]); + if (!demand) return; + for (const serverId of serverIds) void runtime.refreshDirectories(serverId); + }, [demand, runtime, serverIds]); // Keyed by "serverId:agentId" — reuse the previous AggregatedAgent object when // none of its fields changed, so downstream memo/shallow comparisons can bail early. diff --git a/packages/app/src/hooks/use-client-activity.ts b/packages/app/src/hooks/use-client-activity.ts index 2307ec5cb3..65d8bf4067 100644 --- a/packages/app/src/hooks/use-client-activity.ts +++ b/packages/app/src/hooks/use-client-activity.ts @@ -15,7 +15,6 @@ interface ClientActivityOptions { client: DaemonClient; focusedAgentId: string | null; focusedTerminalId: string | null; - onUserActivity: () => void; onAppResumed?: (awayMs: number) => void; } @@ -29,7 +28,6 @@ export function useClientActivity({ client, focusedAgentId, focusedTerminalId, - onUserActivity, onAppResumed, }: ClientActivityOptions): void { const onAppResumedRef = useRef(onAppResumed); @@ -44,7 +42,6 @@ export function useClientActivity({ initialFocusedTerminalId: focusedTerminalId, initialAppVisible: AppState.currentState === "active", now: () => Date.now(), - onUserActivity, onAppResumed: (awayMs) => onAppResumedRef.current?.(awayMs), }); } diff --git a/packages/app/src/hooks/use-favicon-status.ts b/packages/app/src/hooks/use-favicon-status.ts index 0f054ee7fa..5fcce9a24c 100644 --- a/packages/app/src/hooks/use-favicon-status.ts +++ b/packages/app/src/hooks/use-favicon-status.ts @@ -94,7 +94,7 @@ async function updateMacDockBadge(count?: number) { } export function useFaviconStatus() { - const { agents } = useAggregatedAgents(); + const { agents } = useAggregatedAgents({ demand: !isNative }); const workspaceStatuses = useWorkspaceStatusesForBadges(); const [colorScheme, setColorScheme] = useState(getSystemColorScheme); const lastDockBadgeCountRef = useRef(undefined); diff --git a/packages/app/src/hooks/use-projects.ts b/packages/app/src/hooks/use-projects.ts index c5d0359a5e..ecf44a91b2 100644 --- a/packages/app/src/hooks/use-projects.ts +++ b/packages/app/src/hooks/use-projects.ts @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useRef, useSyncExternalStore } from "react"; +import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react"; import equal from "fast-deep-equal"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { @@ -168,6 +168,10 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult () => (enabled ? hosts.map((host) => host.serverId) : []), [enabled, hosts], ); + useEffect(() => { + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + return () => releases.forEach((release) => release()); + }, [runtime, serverIds]); const replicaSelector = useMemo( () => selectProjectHostReplicas(hosts, enabled), [enabled, hosts], @@ -180,7 +184,7 @@ export function useProjects(options: UseProjectsOptions = {}): UseProjectsResult ); const refetch = useCallback(() => { if (!enabled) return; - runtime.refreshAllAgentDirectories({ serverIds }); + for (const serverId of serverIds) void runtime.refreshDirectories(serverId); }, [enabled, runtime, serverIds]); return { diff --git a/packages/app/src/hooks/use-sidebar-workspaces-list.ts b/packages/app/src/hooks/use-sidebar-workspaces-list.ts index 279d838eab..d8cc3383e5 100644 --- a/packages/app/src/hooks/use-sidebar-workspaces-list.ts +++ b/packages/app/src/hooks/use-sidebar-workspaces-list.ts @@ -2,10 +2,7 @@ import { useCallback, useEffect, useMemo } from "react"; import { useStoreWithEqualityFn } from "zustand/traditional"; import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useSessionStore } from "@/stores/session-store"; -import { - useHydratedWorkspaceServerIds, - useWorkspaceDirectoryServerIds, -} from "@/stores/session-store-hooks"; +import { useWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { useHostProjects } from "@/projects/host-projects"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; @@ -124,6 +121,11 @@ export function useSidebarWorkspacesList(options?: { } return matched; }, [allServerIds, hostFilters, hostRegistryLoaded]); + useEffect(() => { + if (!isActive) return; + const releases = serverIds.map((serverId) => runtime.acquireDirectoryDemand(serverId)); + return () => releases.forEach((release) => release()); + }, [isActive, runtime, serverIds]); useEffect(() => { if (!hostRegistryLoaded) { @@ -134,7 +136,6 @@ export function useSidebarWorkspacesList(options?: { const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER); - const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds); const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds); const hostProjects = useHostProjects(directoryServerIds); @@ -175,8 +176,6 @@ export function useSidebarWorkspacesList(options?: { const refreshAll = useCallback(() => { if (!isActive) return; for (const serverId of serverIds) { - const snapshot = runtime.getSnapshot(serverId); - if (snapshot?.connectionStatus !== "online") continue; void runtime.refreshDirectories(serverId).catch((error) => { console.error("[WorkspaceFetch][sidebar-refresh] failed", { serverId, @@ -189,7 +188,7 @@ export function useSidebarWorkspacesList(options?: { const loadingState = deriveSidebarLoadingState({ isActive, serverIds, - hydratedServerIds, + hydratedServerIds: directoryServerIds, hasProjects: projects.length > 0, }); diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index 515262bb70..abb1d8685f 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -48,7 +48,6 @@ import { } from "@/constants/layout"; import { isWeb } from "@/constants/platform"; import { useAgentAttentionClear } from "@/hooks/use-agent-attention-clear"; -import { useAgentInitialization } from "@/hooks/use-agent-initialization"; import { useAgentInputDraft, type AgentInputDraft } from "@/composer/draft/input-draft"; import { type AgentScreenAgent, @@ -99,6 +98,7 @@ import { useSettings } from "@/hooks/use-settings"; import type { Theme } from "@/styles/theme"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem, TodoEntry } from "@/types/stream"; +import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync"; import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents"; import { getInitDeferred, getInitKey } from "@/utils/agent-initialization"; import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots"; @@ -143,6 +143,15 @@ function resolveChatAgentFromSession( return session?.agents?.get(agentId) ?? session?.agentDetails?.get(agentId) ?? null; } +function readViewedTimelineError(input: { + agentId: string | undefined; + status: ViewedTimelineStatus; + sync: ViewedTimelineUiBridge | null; +}): string | null { + if (!input.agentId || input.status !== "error" || !input.sync) return null; + return input.sync.getAgentTimelineError(input.agentId); +} + const EMPTY_CHAT_AGENT_STATE: ChatAgentSelectedState = { serverId: null, id: null, @@ -847,14 +856,15 @@ function ChatAgentContent({ readTimelineStatus, ); const visibilityCatchUpStatus = isPaneVisible ? timelineStatus : "ready"; + const visibilityCatchUpError = readViewedTimelineError({ + agentId, + status: visibilityCatchUpStatus, + sync: viewedTimelineSync, + }); const hasActiveCreateHandoff = useCreateFlowStore((state) => findActiveCreateHandoff({ pendingByDraftId: state.pendingByDraftId, serverId, agentId }), ); const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId])); - const { ensureAgentIsInitialized } = useAgentInitialization({ - serverId, - client: hasSession ? client : null, - }); const [missingAgentState, setMissingAgentState] = useState({ kind: "idle", }); @@ -1007,6 +1017,7 @@ function ChatAgentContent({ isHistorySyncing, needsAuthoritativeSync, visibilityCatchUpStatus, + visibilityCatchUpError, continuity, hasHydratedHistoryBefore, }, @@ -1049,7 +1060,11 @@ function ChatAgentContent({ streamViewRef.current?.scrollToBottom("rewind"); }, []); - const retryAgentLoad = useCallback(() => setMissingAgentState({ kind: "idle" }), []); + const retryAgentLoad = useCallback(() => { + setMissingAgentState({ kind: "idle" }); + if (!agentId || !viewedTimelineSync) return; + viewedTimelineSync.retryVisibleAgentTimeline(agentId); + }, [agentId, viewedTimelineSync]); const retryTimelineSync = useCallback(() => { if (!agentId || !viewedTimelineSync) return; @@ -1078,7 +1093,14 @@ function ChatAgentContent({ } return; } - if (!client || !isPaneVisible || !isConnected || !hasSession) { + if ( + !client || + !viewedTimelineSync || + !isPaneVisible || + !isConnected || + !hasSession || + visibilityCatchUpStatus !== "ready" + ) { return; } if ( @@ -1092,7 +1114,7 @@ function ChatAgentContent({ setMissingAgentState({ kind: "resolving" }); const attemptToken = ++initAttemptTokenRef.current; - ensureAgentIsInitialized(agentId) + Promise.resolve() .then(async () => { if (attemptToken !== initAttemptTokenRef.current) { return; @@ -1137,12 +1159,13 @@ function ChatAgentContent({ hasAppliedAuthoritativeHistory, agentId, client, - ensureAgentIsInitialized, hasSession, isConnected, isPaneVisible, missingAgentState.kind, serverId, + viewedTimelineSync, + visibilityCatchUpStatus, ]); const animatedContentStyle = useMemo( diff --git a/packages/app/src/runtime/directory-sync/agent-replica.test.ts b/packages/app/src/runtime/directory-sync/agent-replica.test.ts index d798282aa8..c791949827 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.test.ts @@ -51,6 +51,21 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry { } describe("AgentDirectoryReplica", () => { + it("accepts the requested agent from the authoritative timeline after a cache miss", () => { + const serverId = "agent-replica-cache-miss"; + const store = useSessionStore.getState(); + store.initializeSession(serverId, null as unknown as DaemonClient); + const replica = new AgentDirectoryReplica(serverId, () => undefined); + + expect(replica.submitTimelineAgent(replica.captureTimeline("agent"), payload("network"))).toBe( + true, + ); + expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.title).toBe( + "network", + ); + store.clearSession(serverId); + }); + it("preserves an unchanged running agent's turn identity during catch-up", () => { const serverId = "agent-replica-catch-up"; const store = useSessionStore.getState(); diff --git a/packages/app/src/runtime/directory-sync/agent-replica.ts b/packages/app/src/runtime/directory-sync/agent-replica.ts index 7f90998e5b..c9ff7424a4 100644 --- a/packages/app/src/runtime/directory-sync/agent-replica.ts +++ b/packages/app/src/runtime/directory-sync/agent-replica.ts @@ -29,6 +29,7 @@ export interface AgentLifecycleToken { export class AgentDirectoryReplica { private readonly lifecycleVersions = new Map(); private readonly members = new Set(); + private readonly pendingCacheReads = new Set(); constructor( private readonly serverId: string, @@ -39,11 +40,37 @@ export class AgentDirectoryReplica { return { agentId, version: this.lifecycleVersions.get(agentId) ?? 0 }; } + captureCache(agentId: string): AgentLifecycleToken { + this.pendingCacheReads.add(agentId); + return this.captureTimeline(agentId); + } + + commitCached(agents: Map): void { + this.members.clear(); + for (const [agentId, agent] of agents) { + this.members.add(agentId); + useSessionStore.getState().setAgentLastActivity(agentId, agent.lastActivityAt); + } + useSessionStore.getState().setAgents(this.serverId, agents); + } + + commitCachedAgent(token: AgentLifecycleToken, agent: Agent): boolean { + this.pendingCacheReads.delete(token.agentId); + if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) return false; + if (this.members.has(agent.id)) return false; + this.members.add(agent.id); + useSessionStore.getState().setAgents(this.serverId, (current) => { + if (current.has(agent.id)) return current; + const next = new Map(current); + next.set(agent.id, agent); + return next; + }); + useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt); + return true; + } + submitTimelineAgent(token: AgentLifecycleToken, payload: AgentSnapshotPayload): boolean { - if ( - !this.members.has(token.agentId) || - token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0) - ) { + if (token.version !== (this.lifecycleVersions.get(token.agentId) ?? 0)) { return false; } const existing = useSessionStore.getState().sessions[this.serverId]?.agents.get(token.agentId); @@ -56,6 +83,7 @@ export class AgentDirectoryReplica { projectPlacement: timelineAgent.projectPlacement ?? existing?.projectPlacement, }; const accepted = upsertAgentReplica(this.serverId, normalized); + this.members.add(accepted.id); replaceAgentPendingPermissions(this.serverId, accepted); useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt); if (accepted.archivedAt) { @@ -84,6 +112,9 @@ export class AgentDirectoryReplica { const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map(); const reconciled = reconcileAgentDirectory({ previous, snapshot: entries, deltas }); const nextIds = new Set(reconciled.entries.map((entry) => entry.agent.id)); + for (const agentId of this.pendingCacheReads) { + if (!nextIds.has(agentId)) this.advance(agentId); + } for (const agentId of this.members) { if (!nextIds.has(agentId)) this.advance(agentId); } @@ -94,6 +125,7 @@ export class AgentDirectoryReplica { if (!nextIds.has(agentId)) removeAgentDirectoryReplica(this.serverId, agentId); } this.members.clear(); + this.pendingCacheReads.clear(); for (const agentId of nextIds) this.members.add(agentId); const { agents } = replaceFetchedAgentDirectory({ serverId: this.serverId, diff --git a/packages/app/src/runtime/directory-sync/index.test.ts b/packages/app/src/runtime/directory-sync/index.test.ts index 9725170d9f..2008abb6a1 100644 --- a/packages/app/src/runtime/directory-sync/index.test.ts +++ b/packages/app/src/runtime/directory-sync/index.test.ts @@ -1,19 +1,33 @@ import { afterEach, describe, expect, it } from "vitest"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; -import { normalizeProjectDescriptor, useSessionStore } from "@/stores/session-store"; -import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; +import { + normalizeProjectDescriptor, + normalizeWorkspaceDescriptor, + useSessionStore, +} from "@/stores/session-store"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { selectWorkspaceDirectoryServerIds } from "@/stores/session-store-hooks/selectors"; +import type { CachedDirectory } from "@/runtime/replica-cache"; +import { + DirectoryRefreshSupersededError, + DirectorySync, + type DirectoryCheckpointStorage, +} from "./index"; type WorkspaceFetchResult = Awaited>; type ProjectListResult = Awaited>; +type AgentFetchResult = Awaited>; class FakeDirectoryClient { fetchAgentsCalls = 0; lastAgentOptions: unknown; fetchWorkspacesCalls = 0; + lastWorkspaceOptions: unknown; listProjectsCalls = 0; lastProjectOptions: unknown; projectResult: ProjectListResult | null = null; + private pendingAgentFetch: Promise | null = null; private pendingWorkspaceFetch: Promise | null = null; private readonly handlers = new Map< SessionOutboundMessage["type"], @@ -45,9 +59,22 @@ class FakeDirectoryClient { return complete; } + holdAgentFetch(): (result: AgentFetchResult) => void { + let complete!: (result: AgentFetchResult) => void; + this.pendingAgentFetch = new Promise((resolve) => { + complete = resolve; + }); + return complete; + } + async fetchAgents(options?: unknown): Promise>> { this.fetchAgentsCalls += 1; this.lastAgentOptions = options; + if (this.pendingAgentFetch) { + const pending = this.pendingAgentFetch; + this.pendingAgentFetch = null; + return pending; + } return { requestId: "agents", entries: [], @@ -55,8 +82,9 @@ class FakeDirectoryClient { }; } - async fetchWorkspaces(): Promise { + async fetchWorkspaces(options?: unknown): Promise { this.fetchWorkspacesCalls += 1; + this.lastWorkspaceOptions = options; if (this.pendingWorkspaceFetch) { const pending = this.pendingWorkspaceFetch; this.pendingWorkspaceFetch = null; @@ -87,6 +115,10 @@ class FakeDirectoryClient { ], }; } + + getLastServerInfoMessage(): null { + return null; + } } const serverIds = new Set(); @@ -111,12 +143,649 @@ function createDirectory(serverId: string): { return { client, directory }; } +function createAgent(serverId: string, id: string) { + return { + ...normalizeAgentSnapshot( + { + id, + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Cached", + labels: {}, + }, + serverId, + ), + projectPlacement: null, + }; +} + afterEach(() => { for (const serverId of serverIds) useSessionStore.getState().clearSession(serverId); serverIds.clear(); }); describe("DirectorySync session readiness", () => { + it("paints a demanded cached directory while offline", async () => { + const serverId = "offline-cached-directory"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "cached-workspace", + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + workspaceDirectory: "/repo/cached", + projectKind: "git", + workspaceKind: "local_checkout", + name: "cached", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + const cachedProject = normalizeProjectDescriptor({ + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + projectKind: "git", + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + }), + commitDirectory: () => undefined, + }, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + + await directory.refreshAll(); + + expect( + useSessionStore.getState().sessions[serverId]?.workspaces.get(cachedWorkspace.id), + ).toEqual(cachedWorkspace); + expect(selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [serverId])).toEqual([ + serverId, + ]); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + directory.dispose(); + }); + + it("prepares a cached agent workspace route without sidebar demand", async () => { + const serverId = "cached-agent-route"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "agent-1"); + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + cachedAgent.workspaceId = cachedWorkspace.id; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => cachedAgent, + readWorkspace: async () => ({ workspace: cachedWorkspace }), + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectory: () => undefined, + }, + ); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + + await directory.prepareAgentRoute(cachedAgent.id); + + const session = useSessionStore.getState().sessions[serverId]; + expect(session?.hasHydratedAgents).toBe(false); + expect(session?.hasWorkspaceDirectorySnapshot).toBe(false); + expect(session?.agents.get(cachedAgent.id)).toEqual(cachedAgent); + expect(session?.workspaces.get(cachedWorkspace.id)).toEqual(cachedWorkspace); + expect(client.fetchAgentsCalls).toBe(0); + expect(client.fetchWorkspacesCalls).toBe(0); + directory.dispose(); + }); + + it("persists accepted script status updates through the directory owner", () => { + const serverId = "script-status-owner"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const commits: CachedDirectory[] = []; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectory: (_serverId, value) => commits.push(value), + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const workspace = normalizeWorkspaceDescriptor({ + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo", + workspaceDirectory: "/repo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.setWorkspaces(serverId, new Map([[workspace.id, workspace]])); + + client.emit({ + type: "script_status_update", + payload: { + workspaceId: workspace.id, + scripts: [ + { + scriptName: "web", + type: "service", + hostname: "web.paseo.localhost", + port: 3000, + proxyUrl: "http://web.paseo.localhost:6767", + lifecycle: "running", + health: "healthy", + exitCode: null, + terminalId: null, + }, + ], + }, + }); + + expect( + useSessionStore.getState().sessions[serverId]?.workspaces.get(workspace.id)?.scripts[0] + ?.lifecycle, + ).toBe("running"); + expect(commits.at(-1)?.workspaces.get(workspace.id)?.scripts[0]?.lifecycle).toBe("running"); + directory.dispose(); + }); + + it("subscribes to live agent updates when the directory is demanded", async () => { + const serverId = "demanded-directory-subscription"; + const { client, directory } = createDirectory(serverId); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + + await directory.refreshAll(); + + expect(client.lastAgentOptions).toMatchObject({ subscribe: {} }); + directory.dispose(); + }); + + it("coalesces overlapping route and full-directory demand", async () => { + const serverId = "coalesced-directory-demand"; + const { client, directory } = createDirectory(serverId); + const releaseAgents = client.holdAgentFetch(); + const releaseWorkspaces = client.holdWorkspaceFetch(); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + + directory.setAgentRouteDemand(["agent-1"]); + directory.setDemand({}, true); + await expect.poll(() => client.fetchAgentsCalls).toBe(1); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + + releaseAgents({ + requestId: "agents", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + releaseWorkspaces({ + requestId: "workspaces", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + await directory.refreshDemand(); + + expect(client.fetchAgentsCalls).toBe(1); + expect(client.fetchWorkspacesCalls).toBe(1); + directory.dispose(); + }); + + it("publishes cached directory data before the authoritative request completes", async () => { + const serverId = "cached-directory"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const releaseNetwork = client.holdWorkspaceFetch(); + let cacheReads = 0; + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "cached-workspace", + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + workspaceDirectory: "/repo/cached", + projectKind: "git", + workspaceKind: "local_checkout", + name: "cached", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + const cachedProject = normalizeProjectDescriptor({ + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + projectKind: "git", + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => { + cacheReads += 1; + return { + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + }; + }, + commitDirectory: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true }, + }); + + const refresh = directory.refreshWorkspaces(); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + + expect(cacheReads).toBe(1); + expect( + useSessionStore.getState().sessions[serverId]?.workspaces.get("cached-workspace")?.name, + ).toBe("cached"); + + releaseNetwork({ + requestId: "workspaces", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + await refresh; + expect(useSessionStore.getState().sessions[serverId]?.workspaces.size).toBe(0); + directory.dispose(); + }); + + it("reconciles workspace changes on top of the accepted cached baseline", async () => { + const serverId = "cached-workspace-changes"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedWorkspace = normalizeWorkspaceDescriptor({ + id: "cached-workspace", + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + workspaceDirectory: "/repo/cached", + projectKind: "git", + workspaceKind: "local_checkout", + name: "cached", + status: "done", + statusEnteredAt: null, + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }); + const cachedProject = normalizeProjectDescriptor({ + projectId: "cached-project", + projectDisplayName: "Cached project", + projectRootPath: "/repo/cached", + projectKind: "git", + }); + const releaseNetwork = client.holdWorkspaceFetch(); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), + projects: new Map([[cachedProject.projectId, cachedProject]]), + checkpoint: { workspaces: { generation: "g", afterSeq: 7 } }, + }), + commitDirectory: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + + const refresh = directory.refreshWorkspaces(); + await expect.poll(() => client.fetchWorkspacesCalls).toBe(1); + releaseNetwork({ + requestId: "workspaces", + entries: [], + emptyProjects: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + sync: { generation: "g", headSeq: 7, mode: "changes", removals: [] }, + }); + await refresh; + + expect(client.lastWorkspaceOptions).toMatchObject({ + sync: { generation: "g", afterSeq: 7 }, + }); + expect(useSessionStore.getState().sessions[serverId]?.workspaces.has(cachedWorkspace.id)).toBe( + true, + ); + directory.dispose(); + }); + + it("does not use a cached checkpoint when the corresponding cache read loses its race", async () => { + const serverId = "late-directory-cache"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + let releaseCache!: ( + value: Awaited>, + ) => void; + const cacheRead = new Promise>>( + (resolve) => { + releaseCache = resolve; + }, + ); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: () => cacheRead, + commitDirectory: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: true, directorySync: true }, + }); + + const refresh = directory.refreshWorkspaces(); + await Promise.resolve(); + client.emit({ + type: "workspace_update", + payload: { kind: "remove", id: "deleted-live" }, + }); + releaseCache({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + checkpoint: { workspaces: { generation: "stale", afterSeq: 99 } }, + }); + await refresh; + + expect(client.lastWorkspaceOptions).not.toHaveProperty("sync.generation"); + directory.dispose(); + }); + + it("does not resurrect an agent deleted while its targeted cache row is loading", async () => { + const serverId = "late-agent-cache"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = { + ...normalizeAgentSnapshot( + { + id: "agent-1", + provider: "codex", + cwd: "/repo", + model: null, + createdAt: "2026-08-26T00:00:00.000Z", + updatedAt: "2026-08-26T00:00:00.000Z", + lastUserMessageAt: null, + status: "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: "Cached", + labels: {}, + }, + serverId, + ), + projectPlacement: null, + }; + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectory: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + useSessionStore.getState().initializeSession(serverId, client as unknown as DaemonClient, 1); + + const load = directory.loadCachedAgent(cachedAgent.id); + client.emit({ + type: "agent_deleted", + payload: { agentId: cachedAgent.id, requestId: "delete-live" }, + }); + releaseAgent(cachedAgent); + await load; + + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); + directory.dispose(); + }); + + it("does not resurrect an agent removed by an authoritative snapshot while cache loads", async () => { + const serverId = "late-agent-authoritative-snapshot"; + serverIds.add(serverId); + const client = new FakeDirectoryClient(); + const cachedAgent = createAgent(serverId, "agent-1"); + const releaseNetwork = client.holdAgentFetch(); + let releaseAgent!: (agent: typeof cachedAgent) => void; + const agentRead = new Promise((resolve) => { + releaseAgent = resolve; + }); + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: () => agentRead, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }), + commitDirectory: () => undefined, + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); + store.updateSessionServerInfo(serverId, { + serverId, + hostname: null, + version: "test", + features: { directorySync: true, workspaceMultiplicity: true }, + }); + + const refresh = directory.refreshAgents(); + const load = directory.loadCachedAgent(cachedAgent.id); + releaseNetwork({ + requestId: "authoritative-snapshot", + entries: [], + pageInfo: { hasMore: false, nextCursor: null, prevCursor: null }, + }); + await refresh; + releaseAgent(cachedAgent); + await load; + + expect(useSessionStore.getState().sessions[serverId]?.agents.has(cachedAgent.id)).toBe(false); + directory.dispose(); + }); + it("uses the saved agent sequence while bounding the bootstrap page", async () => { const serverId = "agent-list-sequence-page"; serverIds.add(serverId); @@ -130,8 +799,15 @@ describe("DirectorySync session readiness", () => { markAgentError: () => undefined, }, { - readDirectoryCheckpoint: () => ({ agents: { generation: "generation", afterSeq: 12 } }), - writeDirectoryCheckpoint: () => undefined, + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + checkpoint: { agents: { generation: "generation", afterSeq: 12 } }, + }), + commitDirectory: () => undefined, }, ); directory.connectionChanged({ @@ -216,27 +892,7 @@ describe("DirectorySync session readiness", () => { serverIds.add(serverId); const client = new FakeDirectoryClient(); const writes: unknown[] = []; - const directory = new DirectorySync( - serverId, - { - onAgentStoppedRunning: () => undefined, - markAgentLoading: () => undefined, - markAgentReady: () => undefined, - markAgentError: () => undefined, - }, - { - readDirectoryCheckpoint: () => ({ projects: { generation: "generation", afterSeq: 4 } }), - writeDirectoryCheckpoint: (_serverId, checkpoint) => writes.push(checkpoint), - }, - ); - directory.connectionChanged({ - client: client as unknown as DaemonClient, - status: "online", - source: { clientGeneration: 1, connectionEpoch: 1 }, - }); - const store = useSessionStore.getState(); - store.initializeSession(serverId, client as unknown as DaemonClient, 1); - store.setProjects(serverId, [ + const cachedProjects = [ normalizeProjectDescriptor({ projectId: "project-1", projectDisplayName: "Old name", @@ -249,7 +905,34 @@ describe("DirectorySync session readiness", () => { projectRootPath: "/repo/two", projectKind: "git", }), - ]); + ]; + const directory = new DirectorySync( + serverId, + { + onAgentStoppedRunning: () => undefined, + markAgentLoading: () => undefined, + markAgentReady: () => undefined, + markAgentError: () => undefined, + }, + { + readAgent: async () => undefined, + readWorkspace: async () => undefined, + readDirectory: async () => ({ + agents: new Map(), + workspaces: new Map(), + projects: new Map(cachedProjects.map((project) => [project.projectId, project])), + checkpoint: { projects: { generation: "generation", afterSeq: 4 } }, + }), + commitDirectory: (_serverId, value) => writes.push(value.checkpoint), + }, + ); + directory.connectionChanged({ + client: client as unknown as DaemonClient, + status: "online", + source: { clientGeneration: 1, connectionEpoch: 1 }, + }); + const store = useSessionStore.getState(); + store.initializeSession(serverId, client as unknown as DaemonClient, 1); store.updateSessionServerInfo(serverId, { serverId, hostname: null, diff --git a/packages/app/src/runtime/directory-sync/index.ts b/packages/app/src/runtime/directory-sync/index.ts index 096d4a60cd..90648ffd38 100644 --- a/packages/app/src/runtime/directory-sync/index.ts +++ b/packages/app/src/runtime/directory-sync/index.ts @@ -31,6 +31,12 @@ import { type DirectoryTransaction, } from "./transaction"; import { workspaceLabels } from "@/workspace-labels"; +import type { + CachedDirectory, + CachedWorkspace, + DirectoryCheckpoint, + DirectoryCursor, +} from "@/runtime/replica-cache"; const PAGE_LIMIT = 200; const AGENT_SORT: NonNullable = [ @@ -63,17 +69,6 @@ interface AgentPageInfo { afterCursor?: string | null; } -interface DirectoryCursor { - generation: string; - afterSeq: number; -} - -interface DirectoryCursors { - projects?: DirectoryCursor; - workspaces?: DirectoryCursor; - agents?: DirectoryCursor; -} - export interface DirectoryConnection { client: DaemonClient | null; status: "online" | "offline"; @@ -81,8 +76,18 @@ export interface DirectoryConnection { } export interface DirectoryCheckpointStorage { - readDirectoryCheckpoint(serverId: string): unknown; - writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void; + readAgent(serverId: string, agentId: string): Promise; + readWorkspace(serverId: string, workspaceId: string): Promise; + readDirectory(serverId: string): Promise; + commitDirectory( + serverId: string, + directory: { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; + }, + ): void; } export interface RefreshAgentDirectoryInput { @@ -114,6 +119,15 @@ export class DirectorySync { }; private unsubscribe: (() => void) | null = null; private readonly abortSessionWaits = new Set<() => void>(); + private cacheLoad: Promise | null = null; + private cacheAccepted = false; + private revision = 0; + private workspaceRevision = 0; + private readonly routeDemandIds = new Set(); + private readonly fullDemandSources = new Set(); + private demandRefresh: Promise | null = null; + private satisfiedDemandSource: DirectorySourceToken | null = null; + private cursors: DirectoryCheckpoint = {}; constructor( private readonly serverId: string, @@ -148,60 +162,222 @@ export class DirectorySync { if (!connection.client || connection.status !== "online") return true; const client = connection.client; const source = connection.source; - this.workspaceTransactions.begin(source, () => ({ - workspaces: new Map(), - projects: new Map(), - })); const subscriptions = [ client.on("agent_update", (message) => { if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; + this.revision += 1; const recorded = this.agentTransactions.record(source, message.payload); if (!recorded) { this.agents.applyDelta(message.payload); this.noteLiveCursor("agents", message.payload); + this.persistDirectory(); } }), client.on("workspace_update", (message) => { if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; + this.revision += 1; + this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { this.workspaces.applyDelta(message.payload); this.noteLiveCursor("workspaces", message.payload); + this.persistDirectory(); } }), client.on("project.update", (message) => { if (message.type !== "project.update" || !this.isCurrent(client, source)) return; + this.revision += 1; + this.workspaceRevision += 1; const recorded = this.workspaceTransactions.record(source, message.payload); if (!recorded) { this.workspaces.applyDelta(message.payload); this.noteLiveCursor("projects", message.payload); + this.persistDirectory(); + } + }), + client.on("script_status_update", (message) => { + if (message.type !== "script_status_update" || !this.isCurrent(client, source)) return; + this.revision += 1; + this.workspaceRevision += 1; + const delta: WorkspaceDirectoryDelta = { + kind: "script_status", + update: message.payload, + }; + const recorded = this.workspaceTransactions.record(source, delta); + if (!recorded) { + this.workspaces.applyDelta(delta); + this.persistDirectory(); } }), client.on("agent_deleted", (message) => { if (message.type === "agent_deleted" && this.isCurrent(client, source)) { + this.revision += 1; this.agents.remove(message.payload.agentId); + this.persistDirectory(); } }), client.on("agent_archived", (message) => { if (message.type === "agent_archived" && this.isCurrent(client, source)) { + this.revision += 1; this.agents.archive(message.payload.agentId, message.payload.archivedAt); + this.persistDirectory(); } }), ]; this.unsubscribe = () => { for (const unsubscribe of subscriptions) unsubscribe(); }; + if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined); return true; } + setDemand(source: object, demanded: boolean): void { + const wasDemanded = this.fullDemandSources.size > 0; + if (demanded) this.fullDemandSources.add(source); + else this.fullDemandSources.delete(source); + if (!wasDemanded && this.fullDemandSources.size > 0) { + void this.loadCachedDirectory().catch(() => undefined); + if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined); + } + } + + setAgentRouteDemand(agentIds: readonly string[]): void { + const next = new Set(agentIds); + if ( + next.size === this.routeDemandIds.size && + [...next].every((agentId) => this.routeDemandIds.has(agentId)) + ) { + return; + } + this.routeDemandIds.clear(); + for (const agentId of next) this.routeDemandIds.add(agentId); + if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) { + void this.requestDemandRefresh().catch(() => undefined); + } + } + dispose(): void { this.flushAbortedTransactions(); this.abortPendingSessionWaits(); this.unsubscribe?.(); this.unsubscribe = null; + this.fullDemandSources.clear(); + this.routeDemandIds.clear(); workspaceLabels.disconnect(this.serverId); } + private hasDemand(): boolean { + return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0; + } + + private requestDemandRefresh(force = false): Promise { + if (this.demandRefresh) return this.demandRefresh; + if (!this.hasDemand()) return Promise.resolve(); + if (!this.getOnlineConnection()) { + return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve(); + } + const source = this.connection.source; + if ( + !force && + this.satisfiedDemandSource?.clientGeneration === source.clientGeneration && + this.satisfiedDemandSource.connectionEpoch === source.connectionEpoch + ) { + return Promise.resolve(); + } + const refresh = + this.fullDemandSources.size > 0 + ? this.refreshAll() + : Promise.all([ + this.refreshAgentsInternal({ subscribe: {} }, false), + this.refreshWorkspacesInternal({ subscribe: true }, false), + ]).then(() => undefined); + this.demandRefresh = refresh + .then(() => { + this.satisfiedDemandSource = source; + return undefined; + }) + .finally(() => { + this.demandRefresh = null; + const current = this.connection.source; + if ( + this.hasDemand() && + (current.clientGeneration !== source.clientGeneration || + current.connectionEpoch !== source.connectionEpoch) + ) { + void this.requestDemandRefresh().catch(() => undefined); + } + }); + return this.demandRefresh; + } + + refreshDemand(): Promise { + return this.requestDemandRefresh(true); + } + + async loadCachedAgent(agentId: string): Promise { + if (!this.checkpoints) return; + if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return; + const token = this.agents.captureCache(agentId); + const agent = await this.checkpoints.readAgent(this.serverId, agentId); + if (!agent) return; + const session = useSessionStore.getState().sessions[this.serverId]; + if (!session || session.agents.has(agentId)) return; + this.agents.commitCachedAgent(token, agent); + } + + async prepareAgentRoute(agentId: string): Promise { + await this.loadCachedAgent(agentId); + const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId); + if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId); + } + + async prepareWorkspaceRoute(workspaceId: string): Promise { + await this.loadCachedWorkspace(workspaceId); + } + + private async loadCachedWorkspace(workspaceId: string): Promise { + if (!this.checkpoints) return; + if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return; + const revision = this.workspaceRevision; + const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId); + if (!cached) return; + if (this.workspaceRevision !== revision) return; + const session = useSessionStore.getState().sessions[this.serverId]; + if (!session) return; + this.workspaces.commitCachedWorkspace(cached.workspace, cached.project); + } + + private loadCachedDirectory(): Promise { + const checkpoints = this.checkpoints; + if (!checkpoints) return Promise.resolve(); + this.cacheLoad ??= (async () => { + const revision = this.revision; + const initial = useSessionStore.getState().sessions[this.serverId]; + if (!initial) return; + const initialAgents = initial.agents; + const initialWorkspaces = initial.workspaces; + const initialProjects = initial.projects; + const pristine = + initialAgents.size === 0 && initialWorkspaces.size === 0 && initialProjects.size === 0; + const cached = await checkpoints.readDirectory(this.serverId); + if (this.cacheAccepted || !pristine || this.revision !== revision) return; + const session = useSessionStore.getState().sessions[this.serverId]; + if ( + !session || + session.agents !== initialAgents || + session.workspaces !== initialWorkspaces || + session.projects !== initialProjects + ) { + return; + } + this.agents.commitCached(cached.agents); + this.workspaces.commitCached(cached); + this.cursors = cached.checkpoint ?? {}; + this.cacheAccepted = true; + })(); + return this.cacheLoad; + } + async fetchTimeline( agentId: string, request: Parameters[1], @@ -209,14 +385,36 @@ export class DirectorySync { const { client } = this.requireOnline(); const token = this.agents.captureTimeline(agentId); const page = await fetchAgentTimelineOnce(client, agentId, request); - if (page.agent) this.agents.submitTimelineAgent(token, page.agent); + if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) { + this.revision += 1; + this.persistDirectory(); + } return page; } async refreshAgents( input: RefreshAgentDirectoryInput = {}, ): Promise { - const { client, source } = this.requireOnline(); + return this.refreshAgentsInternal(input, true); + } + + private async refreshAgentsInternal( + input: RefreshAgentDirectoryInput, + loadDirectoryCache: boolean, + ): Promise { + if (loadDirectoryCache) { + this.primeAgentTransactionForCacheLoad(); + await this.loadCachedDirectory(); + } + const onlineConnection = this.getOnlineConnection(); + if (!onlineConnection) { + this.callbacks.markAgentReady(); + return { + agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents), + subscriptionId: null, + }; + } + const { client, source } = onlineConnection; const transaction = this.agentTransactions.begin(source, () => ({ entries: [], subscriptionId: null, @@ -270,8 +468,10 @@ export class DirectorySync { : delta, ) : completion.deltas; + this.revision += 1; const agents = this.commitAgentSnapshot(completion.snapshot, deltas); this.persistAgentCursors(completion.snapshot, completion.deltas); + this.persistDirectory(); this.callbacks.markAgentReady(); return { agents, subscriptionId: completion.snapshot.subscriptionId }; } catch (error) { @@ -285,7 +485,17 @@ export class DirectorySync { } async refreshWorkspaces(input?: { subscribe?: boolean }): Promise { - const { client, source } = this.requireOnline(); + return this.refreshWorkspacesInternal(input, true); + } + + private async refreshWorkspacesInternal( + input: { subscribe?: boolean } | undefined, + loadDirectoryCache: boolean, + ): Promise { + if (loadDirectoryCache) await this.loadCachedDirectory(); + const onlineConnection = this.getOnlineConnection(); + if (!onlineConnection) return; + const { client, source } = onlineConnection; const transaction = this.workspaceTransactions.begin(source, () => ({ workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces), projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects), @@ -337,7 +547,9 @@ export class DirectorySync { ...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}), }); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear(); + if (cursor === null && payload.sync?.mode !== "changes") { + transaction.snapshot.workspaces.clear(); + } for (const entry of payload.entries) { const workspace = normalizeWorkspaceDescriptor(entry); transaction.snapshot.workspaces.set(workspace.id, workspace); @@ -371,8 +583,11 @@ export class DirectorySync { } async refreshAll(): Promise { - await Promise.all([this.refreshAgents(), this.refreshWorkspaces({ subscribe: true })]); - await this.connectWorkspaceLabels(); + await Promise.all([ + this.refreshAgents({ subscribe: {} }), + this.refreshWorkspaces({ subscribe: true }), + ]); + if (this.getOnlineConnection()) await this.connectWorkspaceLabels(); } async connectWorkspaceLabels(): Promise { @@ -436,12 +651,29 @@ export class DirectorySync { } private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } { - if (!this.connection.client || this.connection.status !== "online") { + const connection = this.getOnlineConnection(); + if (!connection) { throw new Error(`Host ${this.serverId} is not connected`); } + return connection; + } + + private getOnlineConnection(): { client: DaemonClient; source: DirectorySourceToken } | null { + if (!this.connection.client || this.connection.status !== "online") return null; return { client: this.connection.client, source: this.connection.source }; } + private primeAgentTransactionForCacheLoad(): void { + const connection = this.getOnlineConnection(); + if (!connection) return; + this.agentTransactions.begin(connection.source, () => ({ + entries: [], + subscriptionId: null, + legacy: false, + syncRemovals: [], + })); + } + private supportsDirectorySync(): boolean { return ( useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === @@ -476,7 +708,7 @@ export class DirectorySync { supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined, ); this.assertWorkspaceTransactionCurrent(client, source, transaction); - if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear(); + if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear(); for (const entry of payload.projects) { const project = normalizeProjectDescriptor(entry); transaction.snapshot.projects.set(project.projectId, project); @@ -504,14 +736,30 @@ export class DirectorySync { if (completion.kind === "stale") { throw new DirectoryRefreshSupersededError("workspace completion was superseded"); } + this.revision += 1; + this.workspaceRevision += 1; this.workspaces.commitSnapshot(completion.snapshot, completion.deltas); for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) { if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor); } for (const delta of completion.deltas) { + if (delta.kind === "script_status") continue; const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces"; this.noteLiveCursor(entity, delta); } + this.persistDirectory(); + } + + private persistDirectory(): void { + if (!this.checkpoints) return; + const session = useSessionStore.getState().sessions[this.serverId]; + if (!session) return; + this.checkpoints.commitDirectory(this.serverId, { + agents: session.agents, + workspaces: session.workspaces, + projects: session.projects, + checkpoint: this.cursors, + }); } private recordAgentSync( @@ -525,7 +773,7 @@ export class DirectorySync { } private noteLiveCursor( - entity: keyof DirectoryCursors, + entity: keyof DirectoryCheckpoint, payload: { generation?: string; seq?: number }, ): void { if (!payload.generation || payload.seq === undefined) return; @@ -535,27 +783,18 @@ export class DirectorySync { }); } - private readCursors(): DirectoryCursors { - const value = this.checkpoints?.readDirectoryCheckpoint(this.serverId); - if (!value || typeof value !== "object" || Array.isArray(value)) return {}; - const cursors: DirectoryCursors = {}; - for (const entity of ["projects", "workspaces", "agents"] as const) { - const cursor = (value as Record)[entity]; - if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) continue; - const { generation, afterSeq } = cursor as Record; - if (typeof generation === "string" && typeof afterSeq === "number" && afterSeq >= 0) { - cursors[entity] = { generation, afterSeq }; - } - } - return cursors; + private readCursors(): DirectoryCheckpoint { + return this.cursors; } - private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void { - if (!this.checkpoints) return; + private writeCursor( + entity: keyof DirectoryCheckpoint, + cursor: { generation: string; afterSeq: number }, + ): void { const current = this.readCursors(); const previous = current[entity]; if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return; - this.checkpoints.writeDirectoryCheckpoint(this.serverId, { ...current, [entity]: cursor }); + this.cursors = { ...current, [entity]: cursor }; } private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean { diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index b64b5b14c1..79e9fd5100 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -1,4 +1,8 @@ -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import equal from "fast-deep-equal"; +import type { + ScriptStatusUpdateMessage, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, @@ -11,11 +15,11 @@ import { clearWorkspaceArchivePending, shouldSuppressWorkspaceForLocalArchive, } from "@/contexts/session-workspace-upserts"; +import { resolveWorkspaceMapKeyByIdentity } from "@/utils/workspace-identity"; -export type WorkspaceDirectoryDelta = Extract< - SessionOutboundMessage, - { type: "workspace_update" | "project.update" } ->["payload"]; +export type WorkspaceDirectoryDelta = + | Extract["payload"] + | { kind: "script_status"; update: ScriptStatusUpdateMessage["payload"] }; type ProjectDirectoryDelta = Extract["payload"]; export interface WorkspaceDirectorySnapshot { @@ -26,6 +30,25 @@ export interface WorkspaceDirectorySnapshot { >; } +function patchWorkspaceScripts( + workspaces: Map, + update: ScriptStatusUpdateMessage["payload"], +): Map { + const workspaceKey = resolveWorkspaceMapKeyByIdentity({ + workspaces, + workspaceId: update.workspaceId, + }); + if (!workspaceKey) return workspaces; + const existing = workspaces.get(workspaceKey); + if (!existing || equal(existing.scripts, update.scripts)) return workspaces; + const next = new Map(workspaces); + next.set(workspaceKey, { + ...existing, + scripts: update.scripts.map((script) => ({ ...script })), + }); + return next; +} + function applyProjectDelta( snapshot: WorkspaceDirectorySnapshot, delta: ProjectDirectoryDelta, @@ -61,6 +84,24 @@ export class WorkspaceDirectoryReplica { this.commit(state, delta.kind === "remove" && "id" in delta ? [delta.id] : []); } + commitCached(input: { + workspaces: Map; + projects: Map; + }): void { + this.commit(input, []); + useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true); + } + + commitCachedWorkspace( + workspace: WorkspaceDescriptor, + project: ProjectDescriptor | undefined, + ): void { + const snapshot = this.read(); + snapshot.workspaces.set(workspace.id, workspace); + if (project) snapshot.projects.set(project.projectId, project); + this.commit(snapshot, []); + } + commitSnapshot( snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], @@ -84,7 +125,7 @@ export class WorkspaceDirectoryReplica { snapshot: WorkspaceDirectorySnapshot, deltas: readonly WorkspaceDirectoryDelta[], ): WorkspaceDirectorySnapshot { - const workspaces = new Map(snapshot.workspaces); + let workspaces = new Map(snapshot.workspaces); const projects = new Map(snapshot.projects); for (const [workspaceId, workspace] of workspaces) { if (shouldSuppressWorkspaceForLocalArchive({ serverId: this.serverId, workspace })) { @@ -92,6 +133,10 @@ export class WorkspaceDirectoryReplica { } } for (const delta of deltas) { + if (delta.kind === "script_status") { + workspaces = patchWorkspaceScripts(workspaces, delta.update); + continue; + } if ("projectId" in delta || "project" in delta) { applyProjectDelta({ workspaces, projects }, delta); continue; diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index 0548cf853f..431b806aae 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -21,7 +21,6 @@ import { type HostRuntimeControllerDeps, type HostRuntimeStorage, } from "./host-runtime"; -import { ReplicaCache } from "./replica-cache"; import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store"; class FakeDaemonClient { @@ -115,6 +114,10 @@ class FakeDaemonClient { return this.state; } + getLastServerInfoMessage(): null { + return null; + } + subscribeConnectionStatus(listener: (status: ConnectionState) => void): () => void { this.listeners.add(listener); listener(this.state); @@ -255,11 +258,11 @@ class Deferred { } } -async function waitForDirectoryReady(store: HostRuntimeStore, serverId: string): Promise { - if (store.getSnapshot(serverId)?.agentDirectoryStatus === "ready") return; +async function waitForHostOnline(store: HostRuntimeStore, serverId: string): Promise { + if (store.getSnapshot(serverId)?.connectionStatus === "online") return; await new Promise((resolve) => { const unsubscribe = store.subscribe(serverId, () => { - if (store.getSnapshot(serverId)?.agentDirectoryStatus !== "ready") return; + if (store.getSnapshot(serverId)?.connectionStatus !== "online") return; unsubscribe(); resolve(); }); @@ -458,6 +461,16 @@ function createMemoryReplicaRowStore(): ReplicaRowStore { `${row.serverId}:${row.kind}:${row.id}`; return { open: async () => undefined, + read: async (serverId, kinds, ids) => { + const acceptedKinds = new Set(kinds); + const acceptedIds = ids ? new Set(ids) : null; + return [...rows.values()].filter( + (row) => + row.serverId === serverId && + acceptedKinds.has(row.kind) && + (!acceptedIds || acceptedIds.has(row.id)), + ); + }, readAll: async () => { const hosts = new Map(); for (const row of rows.values()) { @@ -1470,46 +1483,21 @@ describe("HostRuntimeStore", () => { expect(store.getHosts()).toEqual([]); }); - it("restores the display replica before declaring the host registry loaded", async () => { + it("loads the host registry without scanning or installing replica rows", async () => { const host = makeHost(); const storage = createMemoryHostRuntimeStorage(); - const replicaRowStore = createMemoryReplicaRowStore(); + const backingStore = createMemoryReplicaRowStore(); + let fullScans = 0; + const replicaRowStore: ReplicaRowStore = { + ...backingStore, + readAll: async () => { + fullScans += 1; + return backingStore.readAll(); + }, + }; await storage.setItem("@paseo:daemon-registry", JSON.stringify([host])); await storage.setItem("@paseo:e2e", "1"); - - const cachedAgent = replicaAgent( - makeFetchAgentsEntry({ - id: "cached-agent", - cwd: "/repo/paseo", - updatedAt: "2026-07-18T08:00:00.000Z", - title: "Cached agent", - }).agent, - host.serverId, - ); const session = useSessionStore.getState(); - session.initializeSession(host.serverId, null); - session.setAgents(host.serverId, new Map([[cachedAgent.id, cachedAgent]])); - session.setFocusedAgentId(host.serverId, cachedAgent.id); - session.setAgentStreamTail( - host.serverId, - new Map([ - [ - cachedAgent.id, - [ - { - kind: "assistant_message", - id: "cached-message", - text: "Already painted", - timestamp: new Date("2026-07-18T08:01:00.000Z"), - }, - ], - ], - ]), - ); - const cache = new ReplicaCache(replicaRowStore, { clearLegacyCache: async () => undefined }); - cache.setHosts([host.serverId]); - await cache.flush(); - session.clearSession(host.serverId); const store = new HostRuntimeStore({ storage, @@ -1528,13 +1516,14 @@ describe("HostRuntimeStore", () => { store.boot(); await registryLoaded; - const restored = useSessionStore.getState().sessions[host.serverId]; - expect(restored?.agents.get(cachedAgent.id)?.title).toBe("Cached agent"); - expect(restored?.agentStreamTail.get(cachedAgent.id)?.[0]).toMatchObject({ - text: "Already painted", - timestamp: new Date("2026-07-18T08:01:00.000Z"), + expect(fullScans).toBe(0); + expect(useSessionStore.getState().sessions[host.serverId]).toMatchObject({ + client: null, + hasHydratedAgents: false, + hasHydratedWorkspaces: false, }); - expect(restored?.hasHydratedAgents).toBe(false); + expect(useSessionStore.getState().sessions[host.serverId]?.agents.size).toBe(0); + expect(useSessionStore.getState().sessions[host.serverId]?.workspaces.size).toBe(0); store.syncHosts([]); session.clearSession(host.serverId); @@ -1753,8 +1742,7 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await waitForHostOnline(store, host.serverId); await vi.advanceTimersByTimeAsync(1_500); const outageStartedAt = Date.now(); @@ -1771,7 +1759,7 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); - it("bootstraps agent directory subscription when host transitions online", async () => { + it("loads an agent directory only after a consumer requests it", async () => { const host = makeHost({ connections: [ { @@ -1798,16 +1786,25 @@ describe("HostRuntimeStore", () => { useSessionStore .getState() .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); + useSessionStore.getState().updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "test", + features: { workspaceMultiplicity: false }, + }); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + expect(fakeClient.fetchAgentsCalls).toHaveLength(0); + const releaseDemand = store.acquireDirectoryDemand(host.serverId); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await vi.waitFor(() => { + expect(store.getSnapshot(host.serverId)?.agentDirectoryStatus).toBe("ready"); + }); expect(fakeClient.fetchAgentsCalls).toHaveLength(1); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ - scope: "active", sort: [{ key: "updated_at", direction: "desc" }], - subscribe: { subscriptionId: "app:srv_test" }, + subscribe: {}, page: { limit: 200 }, }); @@ -1815,13 +1812,18 @@ describe("HostRuntimeStore", () => { expect(snapshot?.agentDirectoryStatus).toBe("ready"); expect(snapshot?.hasEverLoadedAgentDirectory).toBe(true); + fakeClient.setConnectionState({ status: "disconnected", reason: "transport closed" }); + fakeClient.setConnectionState({ status: "connected" }); + await fakeClient.waitForFetches(2); + expect(fakeClient.fetchAgentsCalls[1]).toMatchObject({ subscribe: {} }); + await store.refreshAgentDirectory({ serverId: host.serverId }); - expect(fakeClient.fetchAgentsCalls[1]).toEqual({ - scope: "active", + expect(fakeClient.fetchAgentsCalls[2]).toEqual({ sort: [{ key: "updated_at", direction: "desc" }], page: { limit: 200 }, }); + releaseDemand(); store.syncHosts([]); useSessionStore.getState().clearSession(host.serverId); }); @@ -1852,9 +1854,14 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_no_session" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await load; const session = useSessionStore.getState().sessions[host.serverId]; expect(session?.client).toBe(fakeClient); @@ -1917,9 +1924,14 @@ describe("HostRuntimeStore", () => { version: "0.1.96", }); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await load; expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -2010,6 +2022,12 @@ describe("HostRuntimeStore", () => { ]), ); store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: `app:${host.serverId}` }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -2017,7 +2035,7 @@ describe("HostRuntimeStore", () => { project: bufferedAgent.project, }); pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); - await waitForDirectoryReady(store, host.serverId); + await load; await fakeClient.waitForSentMessages(2); expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ @@ -2094,9 +2112,14 @@ describe("HostRuntimeStore", () => { .getState() .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_paged" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(2); - await waitForDirectoryReady(store, host.serverId); + await load; expect(fakeClient.fetchAgentsCalls).toHaveLength(2); expect(fakeClient.fetchAgentsCalls[0]).toEqual({ @@ -2179,6 +2202,12 @@ describe("HostRuntimeStore", () => { new Map([[recoveredAgent.agent.id, { epoch: "epoch", startSeq: 10, endSeq: 20 }]]), ); store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_paged_delta" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ @@ -2202,7 +2231,7 @@ describe("HostRuntimeStore", () => { ], }), ); - await waitForDirectoryReady(store, host.serverId); + await load; expect( Array.from(useSessionStore.getState().sessions[host.serverId]?.agents.values() ?? []).map( @@ -2264,6 +2293,11 @@ describe("HostRuntimeStore", () => { store.syncHosts([host]); await fakeClient.waitForAgentUpdates(); + const load = store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: `app:${host.serverId}` }, + page: { limit: 200 }, + }); fakeClient.agentUpdate({ kind: "upsert", agent: { ...snapshotEntry.agent, title: "before-session" }, @@ -2275,7 +2309,7 @@ describe("HostRuntimeStore", () => { version: "test", }); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await load; expect( useSessionStore.getState().sessions[host.serverId]?.agents.get("agent-pre-session")?.title, @@ -2308,8 +2342,8 @@ describe("HostRuntimeStore", () => { .getState() .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ serverId: host.serverId }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); const olderRefresh = store.refreshAgentDirectory({ serverId: host.serverId }); @@ -2376,8 +2410,8 @@ describe("HostRuntimeStore", () => { .getState() .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ serverId: host.serverId }); const olderPage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(olderPage.promise); @@ -2435,8 +2469,8 @@ describe("HostRuntimeStore", () => { const sessionStore = useSessionStore.getState(); sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ serverId: host.serverId }); const stalePage = new Deferred>>(); fakeClient.fetchAgentsResponses.push(stalePage.promise); @@ -2533,6 +2567,8 @@ describe("HostRuntimeStore", () => { ]), ); store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ serverId: host.serverId }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -2540,7 +2576,7 @@ describe("HostRuntimeStore", () => { project: bufferedAgent.project, }); pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); - await waitForDirectoryReady(store, host.serverId); + await load; await fakeClient.waitForSentMessages(2); expect(fakeClient.sentAgentMessages.map(([agentId, text]) => [agentId, text])).toEqual([ @@ -2833,6 +2869,8 @@ describe("HostRuntimeStore", () => { isArchiving: true, }); store.syncHosts([host]); + await waitForHostOnline(store, host.serverId); + const load = store.refreshAgentDirectory({ serverId: host.serverId }); await fakeClient.waitForFetches(2); fakeClient.agentUpdate({ kind: "upsert", @@ -2848,7 +2886,7 @@ describe("HostRuntimeStore", () => { project: base.project, }); pageTwo.resolve(makeFetchAgentsPayload({ entries: [] })); - await waitForDirectoryReady(store, host.serverId); + await load; sessionStore.flushAgentLastActivity(); const state = useSessionStore.getState(); @@ -2881,7 +2919,7 @@ describe("HostRuntimeStore", () => { useSessionStore.getState().clearSession(host.serverId); }); - it("re-subscribes agent directory updates after reconnect", async () => { + it("re-subscribes agent directory updates when the surface requests after reconnect", async () => { const host = makeHost({ serverId: "srv_resubscribe", connections: [ @@ -2910,9 +2948,13 @@ describe("HostRuntimeStore", () => { .getState() .initializeSession(host.serverId, fakeClient as unknown as DaemonClient, 1); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); fakeClient.setConnectionState({ status: "connected" }); await Promise.resolve(); @@ -2924,9 +2966,13 @@ describe("HostRuntimeStore", () => { reason: "client_closed", }); fakeClient.setConnectionState({ status: "connected" }); - + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_resubscribe" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(2); - await waitForDirectoryReady(store, host.serverId); expect(fakeClient.fetchAgentsCalls).toEqual([ { @@ -3010,9 +3056,13 @@ describe("HostRuntimeStore", () => { }); store.syncHosts([host]); - + await waitForHostOnline(store, host.serverId); + await store.refreshAgentDirectory({ + serverId: host.serverId, + subscribe: { subscriptionId: "app:srv_archived_rehydrate" }, + page: { limit: 200 }, + }); await fakeClient.waitForFetches(1); - await waitForDirectoryReady(store, host.serverId); expect(useSessionStore.getState().sessions[host.serverId]?.agents.has("agent-archived")).toBe( false, diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 684b5e6227..8fef25f5fa 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -62,6 +62,13 @@ import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/direc import { ReplicaCache } from "@/runtime/replica-cache"; import type { ReplicaRowStore } from "@/runtime/replica-cache/row-store"; import { createReplicaRowStore } from "@/runtime/replica-cache/row-store-factory"; +import { + createTimelineReplica, + createViewedTimelineOwner, + type TimelineReplica, + type ViewedTimelineOwner, + type ViewedTimelineOwnerPorts, +} from "@/timeline/viewed-timeline-sync"; import { projectIconCache } from "@/projects/icon-cache"; import { nativePerformanceTrace } from "@/performance/native-trace"; import { revokePushNotifications } from "@/push-notifications"; @@ -187,7 +194,6 @@ const PROBE_MAX_BACKOFF_MS = 30_000; const PROBE_INACTIVE_WHILE_ONLINE_MS = 120_000; const ADAPTIVE_SWITCH_THRESHOLD_MS = 40; const ADAPTIVE_SWITCH_CONSECUTIVE_PROBES = 3; -const DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT = 200; const CONFIGURED_OVERRIDE_BOOTSTRAP_RETRY_MS = 1_000; function toActiveConnection(connection: HostConnection): ActiveConnection { @@ -1362,9 +1368,9 @@ export class HostRuntimeStore { private deps: HostRuntimeControllerDeps; private lastConnectionStatusByServer = new Map(); private connectionStatusStartedAtByServer = new Map(); - private directoryBootstrapInFlight = new Map>(); private queuedAgentDrainInFlight = new Set(); private directorySyncByServer = new Map(); + private timelineReplicaByServer = new Map(); private configuredOverrideBootstrapInFlight: Promise | null = null; private bootPromise: Promise | null = null; private storage: HostRuntimeStorage; @@ -1393,10 +1399,6 @@ export class HostRuntimeStore { return this.hostRegistryStatus; } - recordUserActivity(): void { - this.replicaCache.recordUserActivity(); - } - subscribeHostList(listener: () => void): () => void { this.hostListListeners.add(listener); return () => { @@ -1483,12 +1485,11 @@ export class HostRuntimeStore { this.hosts = profiles; this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); projectIconCache.setHosts(profiles.map((profile) => profile.serverId)); - await Promise.all([this.replicaCache.restore(), projectIconCache.restore()]); + await projectIconCache.restore(); this.syncHosts(profiles); } catch (error) { console.error("[HostRuntime] Failed to load host registry from storage", error); } finally { - this.replicaCache.start(); this.hostRegistryStatus = "ready"; this.emitHostList(); if (shouldPersistHosts) { @@ -1618,11 +1619,11 @@ export class HostRuntimeStore { rekeyMap(this.controllers, oldServerId, newServerId); rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId); rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId); - rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId); this.replicaCache.reconcileServerId(oldServerId, newServerId); projectIconCache.reconcileServerId(oldServerId, newServerId); this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.delete(oldServerId); + this.timelineReplicaByServer.delete(oldServerId); const directory = new DirectorySync( newServerId, { @@ -1634,6 +1635,14 @@ export class HostRuntimeStore { this.replicaCache, ); this.directorySyncByServer.set(newServerId, directory); + this.timelineReplicaByServer.set( + newServerId, + createTimelineReplica({ + serverId: newServerId, + storage: this.replicaCache, + prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), + }), + ); controller.adoptReconciledServerId(newServerId); const snapshot = controller.getSnapshot(); this.clearHostReplica(oldServerId); @@ -1994,9 +2003,9 @@ export class HostRuntimeStore { this.controllers.delete(serverId); this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); - this.directoryBootstrapInFlight.delete(serverId); this.directorySyncByServer.get(serverId)?.dispose(); this.directorySyncByServer.delete(serverId); + this.timelineReplicaByServer.delete(serverId); this.clearHostReplica(serverId); void controller.stop(); this.emit(serverId); @@ -2020,19 +2029,25 @@ export class HostRuntimeStore { onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId), }); this.controllers.set(host.serverId, controller); - this.directorySyncByServer.set( + useSessionStore.getState().initializeSession(host.serverId, null); + const directory = new DirectorySync( host.serverId, - new DirectorySync( - host.serverId, - { - onAgentStoppedRunning: (agentId) => - this.drainQueuedAgentMessage(host.serverId, agentId), - markAgentLoading: () => controller.markAgentDirectorySyncLoading(), - markAgentReady: () => controller.markAgentDirectorySyncReady(), - markAgentError: (error) => controller.markAgentDirectorySyncError(error), - }, - this.replicaCache, - ), + { + onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), + markAgentLoading: () => controller.markAgentDirectorySyncLoading(), + markAgentReady: () => controller.markAgentDirectorySyncReady(), + markAgentError: (error) => controller.markAgentDirectorySyncError(error), + }, + this.replicaCache, + ); + this.directorySyncByServer.set(host.serverId, directory); + this.timelineReplicaByServer.set( + host.serverId, + createTimelineReplica({ + serverId: host.serverId, + storage: this.replicaCache, + prepareAgent: (agentId) => directory.prepareAgentRoute(agentId), + }), ); const initialSnapshot = controller.getSnapshot(); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); @@ -2040,7 +2055,7 @@ export class HostRuntimeStore { controller.subscribe(() => { const snapshot = controller.getSnapshot(); this.syncSessionReplica(snapshot.serverId, snapshot); - this.maybeAutoBootstrapDirectories(snapshot.serverId); + this.syncDirectoryConnection(snapshot.serverId); this.emit(snapshot.serverId); }); void controller @@ -2073,25 +2088,23 @@ export class HostRuntimeStore { useWorkspaceSetupStore.getState().clearServer(serverId); } - private maybeAutoBootstrapDirectories(serverId: string): void { + private syncDirectoryConnection(serverId: string): void { const controller = this.controllers.get(serverId); if (!controller) { this.lastConnectionStatusByServer.delete(serverId); this.connectionStatusStartedAtByServer.delete(serverId); - this.directoryBootstrapInFlight.delete(serverId); return; } const snapshot = controller.getSnapshot(); const directory = this.directorySyncByServer.get(serverId); - const directorySourceChanged = - directory?.connectionChanged({ - client: snapshot.client, - status: snapshot.connectionStatus === "online" ? "online" : "offline", - source: { - clientGeneration: snapshot.clientGeneration, - connectionEpoch: snapshot.connectionEpoch, - }, - }) ?? false; + directory?.connectionChanged({ + client: snapshot.client, + status: snapshot.connectionStatus === "online" ? "online" : "offline", + source: { + clientGeneration: snapshot.clientGeneration, + connectionEpoch: snapshot.connectionEpoch, + }, + }); const previousStatus = this.lastConnectionStatusByServer.get(serverId); const statusChanged = previousStatus !== snapshot.connectionStatus; const isUnavailable = @@ -2113,55 +2126,6 @@ export class HostRuntimeStore { invalidateServerDataQueriesAfterReconnect({ queryClient, serverId }); void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey }); } - - // Runtime owns directory bootstrap policy, including reconnect and delayed - // session initialization races. - if (snapshot.connectionStatus !== "online") { - return; - } - if (!didTransitionOnline && snapshot.hasEverLoadedAgentDirectory) { - return; - } - if (this.directoryBootstrapInFlight.has(serverId) && !directorySourceChanged) { - return; - } - - const bootstrap = Promise.resolve() - .then(() => - Promise.all([ - this.refreshAgentDirectory({ - serverId, - subscribe: { subscriptionId: `app:${serverId}` }, - page: { limit: DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT }, - }).catch((error) => { - console.error("[HostRuntime] agent directory bootstrap failed", { - serverId, - error: toErrorMessage(error), - }); - }), - this.refreshWorkspaceDirectory({ serverId, subscribe: true }).catch((error) => { - console.error("[HostRuntime] workspace directory bootstrap failed", { - serverId, - error: toErrorMessage(error), - }); - }), - ]).then(() => - directory?.connectWorkspaceLabels().catch((error) => { - console.error("[HostRuntime] workspace label bootstrap failed", { - serverId, - error: toErrorMessage(error), - }); - }), - ), - ) - .finally(() => { - const inFlight = this.directoryBootstrapInFlight.get(serverId); - if (inFlight === bootstrap) { - this.directoryBootstrapInFlight.delete(serverId); - } - }); - - this.directoryBootstrapInFlight.set(serverId, bootstrap); } drainQueuedAgentMessage(serverId: string, agentId: string): void { @@ -2300,7 +2264,33 @@ export class HostRuntimeStore { async refreshDirectories(serverId: string): Promise { const directory = this.directorySyncByServer.get(serverId); if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); - await directory.refreshAll(); + await directory.refreshDemand(); + } + + acquireDirectoryDemand(serverId: string): () => void { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) return () => undefined; + const source = {}; + directory.setDemand(source, true); + return () => directory.setDemand(source, false); + } + + async prepareAgentRoute(serverId: string, agentId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.prepareAgentRoute(agentId); + } + + async prepareWorkspaceRoute(serverId: string, workspaceId: string): Promise { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await directory.prepareWorkspaceRoute(workspaceId); + } + + async prepareAgentTimeline(serverId: string, agentId: string): Promise { + const replica = this.timelineReplicaByServer.get(serverId); + if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); + await replica.prepare(agentId); } fetchAgentTimeline( @@ -2313,14 +2303,21 @@ export class HostRuntimeStore { return directory.fetchTimeline(agentId, request); } - refreshAllAgentDirectories(input?: { serverIds?: string[] }): void { - const targetServerIds = input?.serverIds ? new Set(input.serverIds) : null; - for (const [serverId] of this.controllers) { - if (targetServerIds && !targetServerIds.has(serverId)) { - continue; - } - void this.refreshAgentDirectory({ serverId }).catch(() => undefined); - } + createViewedTimelineOwner( + serverId: string, + ports: ViewedTimelineOwnerPorts, + ): ViewedTimelineOwner { + const directory = this.directorySyncByServer.get(serverId); + if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`); + const replica = this.timelineReplicaByServer.get(serverId); + if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`); + return createViewedTimelineOwner({ + serverId, + replica, + replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds), + drainQueuedAgentMessage: (agentId) => this.drainQueuedAgentMessage(serverId, agentId), + ports, + }); } private emit(serverId: string): void { diff --git a/packages/app/src/runtime/replica-cache/diff.test.ts b/packages/app/src/runtime/replica-cache/diff.test.ts deleted file mode 100644 index 36c8f41659..0000000000 --- a/packages/app/src/runtime/replica-cache/diff.test.ts +++ /dev/null @@ -1,63 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { StreamItem } from "@/types/stream"; -import { diffMap, diffReplicaInput, type ReplicaInput } from "./diff"; - -function input(overrides: Partial = {}): ReplicaInput { - return { - agents: new Map(), - workspaces: new Map(), - projects: new Map(), - focusedAgentId: undefined, - timelineItems: undefined, - timelineRange: null, - timelineHasOlder: false, - ...overrides, - }; -} - -describe("diffReplicaInput", () => { - it("upserts only values whose map identity changed and deletes missing ids", () => { - const retained = { value: "retained" }; - const previousValue = { value: "previous" }; - const changed = { value: "changed" }; - const removed = { value: "removed" }; - const previous = new Map([ - ["retained", retained], - ["changed", previousValue], - ["removed", removed], - ]); - const next = new Map([ - ["retained", retained], - ["changed", changed], - ]); - - expect(diffMap(previous, next)).toEqual({ - upserts: [changed], - deletes: ["removed"], - }); - }); - - it("does not rewrite the timeline when its inputs retain identity", () => { - const timelineItems: StreamItem[] = []; - const range = { epoch: "epoch", startSeq: 1, endSeq: 1 }; - const previous = input({ - focusedAgentId: "agent", - timelineItems, - timelineRange: range, - }); - const next = input({ - focusedAgentId: "agent", - timelineItems, - timelineRange: range, - }); - - expect(diffReplicaInput(previous, next).timelineChanged).toBe(false); - }); - - it("rewrites the timeline when one of its inputs changes identity", () => { - const previous = input({ focusedAgentId: "agent", timelineItems: [] }); - const next = input({ focusedAgentId: "agent", timelineItems: [] }); - - expect(diffReplicaInput(previous, next).timelineChanged).toBe(true); - }); -}); diff --git a/packages/app/src/runtime/replica-cache/diff.ts b/packages/app/src/runtime/replica-cache/diff.ts deleted file mode 100644 index 8cfc4b0cb9..0000000000 --- a/packages/app/src/runtime/replica-cache/diff.ts +++ /dev/null @@ -1,77 +0,0 @@ -import type { - Agent, - ProjectDescriptor, - SessionReplicaTimeline, - WorkspaceDescriptor, -} from "@/stores/session-store"; -import type { StreamItem } from "@/types/stream"; - -export interface ReplicaInput { - agents: ReadonlyMap; - workspaces: ReadonlyMap; - projects: ReadonlyMap; - focusedAgentId: string | undefined; - timelineItems: StreamItem[] | undefined; - timelineRange: SessionReplicaTimeline["range"]; - timelineHasOlder: boolean; -} - -interface EntityChanges { - upserts: Entity[]; - deletes: string[]; -} - -export interface ReplicaInputChanges { - agents: EntityChanges; - workspaces: EntityChanges; - projects: EntityChanges; - timelineChanged: boolean; -} - -export function diffMap( - previous: ReadonlyMap | undefined, - next: ReadonlyMap, -): EntityChanges { - if (previous === next) return { upserts: [], deletes: [] }; - const upserts: Entity[] = []; - const deletes: string[] = []; - for (const [id, value] of next) { - if (previous?.get(id) !== value) upserts.push(value); - } - if (previous) { - for (const id of previous.keys()) { - if (!next.has(id)) deletes.push(id); - } - } - return { upserts, deletes }; -} - -export function diffReplicaInput( - previous: ReplicaInput | undefined, - next: ReplicaInput, -): ReplicaInputChanges { - const timelineChanged = - previous === undefined || - previous.focusedAgentId !== next.focusedAgentId || - previous.timelineItems !== next.timelineItems || - previous.timelineRange !== next.timelineRange || - previous.timelineHasOlder !== next.timelineHasOlder; - return { - agents: diffMap(previous?.agents, next.agents), - workspaces: diffMap(previous?.workspaces, next.workspaces), - projects: diffMap(previous?.projects, next.projects), - timelineChanged, - }; -} - -export function hasReplicaInputChanges(changes: ReplicaInputChanges): boolean { - return ( - changes.agents.upserts.length > 0 || - changes.agents.deletes.length > 0 || - changes.workspaces.upserts.length > 0 || - changes.workspaces.deletes.length > 0 || - changes.projects.upserts.length > 0 || - changes.projects.deletes.length > 0 || - changes.timelineChanged - ); -} diff --git a/packages/app/src/runtime/replica-cache/index.test.ts b/packages/app/src/runtime/replica-cache/index.test.ts index 12497e8a37..fd4e2b366d 100644 --- a/packages/app/src/runtime/replica-cache/index.test.ts +++ b/packages/app/src/runtime/replica-cache/index.test.ts @@ -1,30 +1,28 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; +import { describe, expect, it } from "vitest"; import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages"; -import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { projectAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, - selectAgentTimelineState, - useSessionStore, + type Agent, } from "@/stores/session-store"; -import { createUserMessage, type StreamItem } from "@/types/stream"; -import { ReplicaCache } from "."; -import { AgentDirectoryReplica } from "../directory-sync/agent-replica"; +import type { StreamItem } from "@/types/stream"; +import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { ReplicaCache, type DirectoryCheckpoint } from "."; import type { ReplicaHostRows, ReplicaRow, ReplicaRowChanges, ReplicaRowStore } from "./row-store"; const SERVER_ID = "cached-host"; -const LRU_SERVER_IDS = ["host-a", "host-b", "host-c"] as const; class MemoryStorage implements ReplicaRowStore { readonly rows = new Map(); readonly changes: ReplicaRowChanges[] = []; - readonly deletedHosts: string[] = []; - readonly renamedHosts: Array<{ oldServerId: string; newServerId: string }> = []; + readonly reads: Array<{ + serverId: string; + kinds: readonly ReplicaRow["kind"][]; + ids?: readonly string[]; + }> = []; writes = 0; - clears = 0; - applyResults: Promise[] = []; + cleanups = 0; + nextWriteFailure: Error | null = null; private key(row: Pick): string { return `${row.serverId}:${row.kind}:${row.id}`; @@ -32,6 +30,22 @@ class MemoryStorage implements ReplicaRowStore { async open(): Promise {} + async read( + serverId: string, + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + this.reads.push({ serverId, kinds, ...(ids ? { ids } : {}) }); + const acceptedKinds = new Set(kinds); + const acceptedIds = ids ? new Set(ids) : null; + return [...this.rows.values()].filter( + (row) => + row.serverId === serverId && + acceptedKinds.has(row.kind) && + (!acceptedIds || acceptedIds.has(row.id)), + ); + } + async readAll(): Promise { const hosts = new Map(); for (const row of this.rows.values()) { @@ -39,25 +53,26 @@ class MemoryStorage implements ReplicaRowStore { rows.push(row); hosts.set(row.serverId, rows); } - return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows })); + return [...hosts].map(([serverId, rows]) => ({ serverId, rows })); } async apply(changes: ReplicaRowChanges): Promise { this.writes += 1; - const result = this.applyResults.shift(); - if (result) await result; + if (this.nextWriteFailure) { + const error = this.nextWriteFailure; + this.nextWriteFailure = null; + throw error; + } this.changes.push(changes); for (const key of changes.deletes) this.rows.delete(this.key(key)); for (const row of changes.upserts) this.rows.set(this.key(row), row); } async deleteHost(serverId: string): Promise { - this.deletedHosts.push(serverId); for (const [key, row] of this.rows) if (row.serverId === serverId) this.rows.delete(key); } async renameHost(oldServerId: string, newServerId: string): Promise { - this.renamedHosts.push({ oldServerId, newServerId }); for (const [key, row] of this.rows) { if (row.serverId !== oldServerId) continue; this.rows.delete(key); @@ -67,66 +82,29 @@ class MemoryStorage implements ReplicaRowStore { } async clear(): Promise { - this.clears += 1; this.rows.clear(); } } -function deferred(): { - promise: Promise; - reject: (error: Error) => void; -} { - let reject = (_error: Error): void => undefined; - const promise = new Promise((_resolve, rejectPromise) => { - reject = rejectPromise; +const noLegacyCleanup = { clearLegacyCache: async () => undefined }; + +function createCache(storage: MemoryStorage, maxBytes?: number): ReplicaCache { + const cache = new ReplicaCache(storage, { + ...noLegacyCleanup, + ...(maxBytes ? { maxBytes } : {}), }); - return { promise, reject }; + cache.setHosts([SERVER_ID]); + return cache; } -async function waitForWrites(storage: MemoryStorage, expected: number): Promise { - for (let attempt = 0; attempt < 20 && storage.writes < expected; attempt += 1) { - await Promise.resolve(); - } - expect(storage.writes).toBe(expected); -} - -const NO_LEGACY_CLEANUP = { clearLegacyCache: async () => undefined }; - -function cache(storage: MemoryStorage, options: { maxBytes?: number } = {}): ReplicaCache { - return new ReplicaCache(storage, { ...options, ...NO_LEGACY_CLEANUP }); -} - -function workspace( - id = "workspace-1", - projectId = "project-1", - workspaceDirectory = "/repo/paseo", -): WorkspaceDescriptorPayload { - return { - id, - projectId, - projectDisplayName: "Paseo", - projectRootPath: workspaceDirectory, - workspaceDirectory, - projectKind: "git", - workspaceKind: "local_checkout", - name: "main", - status: "running", - statusEnteredAt: "2026-07-18T08:00:00.000Z", - activityAt: null, - archivingAt: null, - diffStat: null, - scripts: [], - }; -} - -function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") { +function agent(id = "agent-1"): Agent { return { ...normalizeAgentSnapshot( { id, provider: "codex", - cwd, - workspaceId, + cwd: "/repo/paseo", + workspaceId: "workspace-1", model: null, createdAt: "2026-07-18T08:00:00.000Z", updatedAt: "2026-07-18T08:01:00.000Z", @@ -144,892 +122,135 @@ function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") { availableModes: [], pendingPermissions: [], persistence: null, - title: `Agent ${id}`, + title: "Cached agent", 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, - }, - }, + projectPlacement: null, }; } -function message(id: string, text: string): StreamItem { +function workspacePayload(): WorkspaceDescriptorPayload { + return { + id: "workspace-1", + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + workspaceDirectory: "/repo/paseo", + projectKind: "git", + workspaceKind: "local_checkout", + name: "main", + status: "running", + statusEnteredAt: "2026-07-18T08:00:00.000Z", + activityAt: null, + archivingAt: null, + diffStat: null, + scripts: [], + }; +} + +function timelineItem(text = "Cached"): StreamItem { return { kind: "assistant_message", - id, + id: "message-1", text, timestamp: new Date("2026-07-18T08:02:00.000Z"), timelineCursor: { epoch: "epoch-1", seq: 12 }, }; } -function toolCall(): StreamItem { +function directory( + checkpoint: DirectoryCheckpoint = { agents: { generation: "g", afterSeq: 12 } }, +) { + const cachedAgent = agent(); + const workspace = normalizeWorkspaceDescriptor(workspacePayload()); + const project = normalizeProjectDescriptor({ + projectId: "project-1", + projectDisplayName: "Paseo", + projectRootPath: "/repo/paseo", + projectKind: "git", + }); return { - kind: "tool_call", - id: "tool-1", - timelineCursor: { epoch: "epoch-1", seq: 12 }, - timestamp: new Date("2026-07-18T08:02:00.000Z"), - payload: { - source: "agent", - data: { - provider: "codex", - callId: "call-1", - name: "shell", - status: "completed", - error: null, - detail: { - type: "shell", - command: "npm test", - output: "passed", - exitCode: 0, - }, - }, - }, + agents: new Map([[cachedAgent.id, cachedAgent]]), + workspaces: new Map([[workspace.id, workspace]]), + projects: new Map([[project.projectId, project]]), + checkpoint, }; } -function seedSession(): void { - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null); - store.setAgents( - SERVER_ID, - new Map([ - [ - "agent-1", - { - ...agent("agent-1"), - pendingPermissions: [ - { - id: "pending-permission", - provider: "codex", - name: "shell", - kind: "tool", - title: "Approve shell command", - }, - ], - }, - ], - ]), - ); - store.setWorkspaces( - SERVER_ID, - new Map([ - [ - "workspace-1", - normalizeWorkspaceDescriptor({ - ...workspace(), - workspaceKind: "worktree", - worktreeSlug: "owned-worktree", - labels: ["backend"], - }), - ], - ]), - ); - store.setProjects(SERVER_ID, [ - normalizeProjectDescriptor({ - projectId: "project-1", - projectKey: "remote:github.com/getpaseo/paseo", - projectDisplayName: "Paseo", - projectRootPath: "/repo/paseo", - projectKind: "git", - }), - normalizeProjectDescriptor({ - projectId: "empty-project", - projectDisplayName: "Empty project", - projectRootPath: "/repo/empty", - projectKind: "directory", - }), - ]); - store.setFocusedAgentId(SERVER_ID, "agent-1"); - store.setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("message-1", "Cached")]]])); - store.setAgentTimelineCursor( - SERVER_ID, - new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), - ); - store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-1", true]])); - store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-1", true); +function timeline(text = "Cached") { + return { + agentId: "agent-1", + items: [timelineItem(text)], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 12 }, + hasOlder: true, + }; } -function seedTimeline(serverId: string, text: string): void { - const agentId = `agent-${serverId}`; - const workspaceId = `workspace-${serverId}`; - const workspaceDirectory = `/repo/${serverId}`; - const store = useSessionStore.getState(); - store.initializeSession(serverId, null); - store.setAgents(serverId, new Map([[agentId, agent(agentId, workspaceId, workspaceDirectory)]])); - store.setWorkspaces( - serverId, - new Map([ - [ - workspaceId, - normalizeWorkspaceDescriptor( - workspace(workspaceId, `project-${serverId}`, workspaceDirectory), - ), - ], - ]), - ); - store.setFocusedAgentId(serverId, agentId); - store.setAgentStreamTail(serverId, new Map([[agentId, [message(`message-${serverId}`, text)]]])); -} - -afterEach(() => { - vi.useRealTimers(); - const store = useSessionStore.getState(); - store.clearSession(SERVER_ID); - for (const serverId of LRU_SERVER_IDS) store.clearSession(serverId); -}); - describe("ReplicaCache", () => { - it("persists after user inactivity even while replica changes continue", async () => { - vi.useFakeTimers(); + it("does nothing until an owner explicitly commits data", async () => { const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - replicaCache.start(); - const writesBeforeChange = storage.writes; + const cache = createCache(storage); - useSessionStore - .getState() - .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("first", "First")]]])); - await vi.advanceTimersByTimeAsync(4_000); - replicaCache.recordUserActivity(); - await vi.advanceTimersByTimeAsync(1_000); - useSessionStore - .getState() - .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("second", "Second")]]])); - await vi.advanceTimersByTimeAsync(3_999); + await cache.flush(); - expect(storage.writes).toBe(writesBeforeChange); - - await vi.advanceTimersByTimeAsync(1); - - expect(storage.writes).toBe(writesBeforeChange + 1); - replicaCache.setHosts([]); - }); - - it("persists focused replica changes without writing transient stream head updates", async () => { - vi.useFakeTimers(); - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - replicaCache.start(); - const writesBeforeStream = storage.writes; - - useSessionStore - .getState() - .setAgentStreamHead(SERVER_ID, new Map([["agent-1", [message("live", "Streaming")]]])); - await vi.advanceTimersByTimeAsync(5_000); - - expect(storage.writes).toBe(writesBeforeStream); - - useSessionStore - .getState() - .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [message("saved", "Committed")]]])); - await vi.advanceTimersByTimeAsync(5_000); - - expect(storage.writes).toBe(writesBeforeStream + 1); - replicaCache.setHosts([]); - }); - - it("restores the exact persisted canonical timeline window", async () => { - const storage = new MemoryStorage(); - const writer = cache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - await writer.flush(); - - useSessionStore.getState().clearSession(SERVER_ID); - - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(session).toBeDefined(); - if (!session) throw new Error("Expected restored session"); - 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", "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.agents.get("agent-1")?.pendingPermissions).toEqual([]); - expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); - expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); - // A restored row draws its label chips. The reconnect cursor is current, so nothing re-sends - // them and a cache that dropped them would leave the sidebar unlabelled until the next edit. - expect(session.workspaces.get("workspace-1")?.labels).toEqual(["backend"]); - expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); - expect(session.agentAuthoritativeHistoryApplied).toEqual(new Map([["agent-1", true]])); - expect(session.agentTimelineCursor).toEqual( - new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), - ); - expect(session.agentTimelineHasOlder).toEqual(new Map([["agent-1", true]])); - expect(session.agentTimelineHasNewer).toEqual(new Map([["agent-1", false]])); - expect(session.agentHistorySyncGeneration).toEqual(new Map()); - expect(selectAgentTimelineState(session, "agent-1")).toEqual({ - status: "synced", - items: [message("message-1", "Cached")], - range: { epoch: "epoch-1", startSeq: 1, endSeq: 12 }, - older: "available", - newer: "none", - }); - }); - - it("restores canonical turn membership without downgrading tagged rows", async () => { - const storage = new MemoryStorage(); - const writer = cache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - const initial: StreamItem = { - kind: "user_message", - id: "initial", - text: "initial", - timestamp: new Date(1), - turnId: "turn-1", - }; - const hello: StreamItem = { - kind: "user_message", - id: "hello", - text: "hello", - timestamp: new Date(2), - turnId: "turn-1", - clientMessageId: "hello-client", - messageId: "hello-client", - }; - useSessionStore - .getState() - .setAgentStreamTail( - SERVER_ID, - new Map([["agent-1", [initial, message("assistant", "done"), hello]]]), - ); - await writer.flush(); - useSessionStore.getState().clearSession(SERVER_ID); - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - const tail = - useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1") ?? []; - expect(tail.find((item) => item.id === "hello")?.turnId).toBe("turn-1"); - }); - - it("restores tool calls inside an authoritative cached window", async () => { - const storage = new MemoryStorage(); - const writer = cache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - useSessionStore.getState().setAgentStreamTail(SERVER_ID, new Map([["agent-1", [toolCall()]]])); - await writer.flush(); - - useSessionStore.getState().clearSession(SERVER_ID); - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(session?.agentStreamTail.get("agent-1")).toEqual([toolCall()]); - expect(session?.agentTimelineCursor.get("agent-1")).toEqual({ - epoch: "epoch-1", - startSeq: 1, - endSeq: 12, - }); - }); - - it("restores display-only when retained items do not reach the stored range end", async () => { - const storage = new MemoryStorage(); - const writer = cache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - useSessionStore - .getState() - .setAgentTimelineCursor( - SERVER_ID, - new Map([["agent-1", { epoch: "epoch-1", startSeq: 1, endSeq: 13 }]]), - ); - await writer.flush(); - - useSessionStore.getState().clearSession(SERVER_ID); - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - expect(session?.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); - expect(session?.agentTimelineCursor).toEqual(new Map()); - expect(selectAgentTimelineState(session, "agent-1")).toEqual({ - status: "painted", - items: [message("message-1", "Cached")], - }); - }); - - it("persists the complete directory with only the focused timeline tail", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - - const store = useSessionStore.getState(); - store.setAgents(SERVER_ID, (agents) => - new Map(agents).set("agent-2", agent("agent-2", "workspace-2", "/repo/other")), - ); - store.setWorkspaces(SERVER_ID, (workspaces) => - new Map(workspaces).set( - "workspace-2", - normalizeWorkspaceDescriptor(workspace("workspace-2", "project-2", "/repo/other")), - ), - ); - const secondTimeline = Array.from({ length: 60 }, (_, index) => ({ - ...message(`message-${index}`, `Second ${index}`), - timelineCursor: { epoch: "epoch-2", seq: index + 1 }, - })); - store.setAgentStreamTail( - SERVER_ID, - new Map([ - ["agent-1", [message("message-1", "First")]], - ["agent-2", secondTimeline], - ]), - ); - store.setAgentTimelineCursor( - SERVER_ID, - new Map([["agent-2", { epoch: "epoch-2", startSeq: 1, endSeq: 60 }]]), - ); - store.setAgentTimelineHasOlder(SERVER_ID, new Map([["agent-2", true]])); - store.setAgentAuthoritativeHistoryApplied(SERVER_ID, "agent-2", true); - store.setFocusedAgentId(SERVER_ID, "agent-2"); - await replicaCache.flush(); - - store.clearSession(SERVER_ID); - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - const session = useSessionStore.getState().sessions[SERVER_ID]; - const timelines = session?.agentStreamTail; - 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)); - expect(session?.agentTimelineCursor.has("agent-2")).toBe(false); - expect(selectAgentTimelineState(session, "agent-2")).toEqual({ - status: "painted", - items: secondTimeline.slice(-50), - }); - - const timelineRow = [...storage.rows.values()].find((row) => row.kind === "timeline"); - expect(timelineRow).toBeDefined(); - if (!timelineRow) throw new Error("Expected persisted timeline row"); - const persisted = JSON.parse(timelineRow.payload) as Record; - expect(Object.keys(persisted).sort()).toEqual(["agentId", "hasOlder", "items", "range"]); - }); - - it("persists reconciled rows without caching unreconciled local presentations", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - const unreconciled = createUserMessage({ - clientMessageId: "client-pending", - text: "Pending", - timestamp: new Date("2026-07-18T08:01:00.000Z"), - }); - const reconciled = createUserMessage({ - clientMessageId: "client-sent", - messageId: "provider-sent", - timelineCursor: { epoch: "epoch-1", seq: 11 }, - text: "Sent", - timestamp: new Date("2026-07-18T08:01:30.000Z"), - }); - useSessionStore - .getState() - .setAgentStreamTail(SERVER_ID, new Map([["agent-1", [unreconciled, reconciled]]])); - - await replicaCache.flush(); - useSessionStore.getState().clearSession(SERVER_ID); - await replicaCache.restore(); - - expect(useSessionStore.getState().sessions[SERVER_ID]?.agentStreamTail.get("agent-1")).toEqual([ - reconciled, - ]); - }); - - it("persists monotonic directory cursors with the complete host replica", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - - replicaCache.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 replicaCache.flush(); - - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({ - agents: { generation: "daemon-generation", afterSeq: 7 }, - }); - const finalWrite = storage.changes.at(-1); - expect(finalWrite?.upserts.map((row) => row.kind).sort()).toEqual(["agent", "checkpoint"]); - }); - - it("persists one agent update as exactly one row upsert", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - storage.changes.length = 0; - - 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(current.id, { ...current, title: "Updated" }); - }); - await replicaCache.flush(); - - expect(storage.changes).toHaveLength(1); - expect(storage.changes[0]).toMatchObject({ - upserts: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], - deletes: [], - }); - }); - - it("persists the focused synced timeline produced by the session store", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - const focused = agent("agent-1"); - store.setAgents(SERVER_ID, new Map([[focused.id, focused]])); - store.setFocusedAgentId(SERVER_ID, focused.id); - store.setAgentStreamState(SERVER_ID, focused.id, { - tail: [message("synced-message", "Synced from daemon")], - }); - store.setAgentTimelineCursor( - SERVER_ID, - new Map([[focused.id, { epoch: "epoch-1", startSeq: 1, endSeq: 12 }]]), - ); - store.setAgentAuthoritativeHistoryApplied(SERVER_ID, focused.id, true); - - await replicaCache.flush(); - - expect(storage.changes).toHaveLength(1); - expect(storage.changes[0]?.upserts).toContainEqual( - expect.objectContaining({ - serverId: SERVER_ID, - kind: "timeline", - id: "singleton", - }), - ); - }); - - it("does not persist unchanged agents after a changes-mode reconciliation", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - const directory = new AgentDirectoryReplica(SERVER_ID, () => undefined); - const agents = [agent("agent-1"), agent("agent-2")]; - directory.commitSnapshot( - agents.map((value) => ({ - agent: projectAgentSnapshot(value), - project: value.projectPlacement!, - })), - [], - ); - await replicaCache.flush(); - storage.changes.length = 0; - - directory.commitChanges([], [], []); - await replicaCache.flush(); - - expect(storage.changes).toEqual([]); - }); - - it("persists only the changed agent after a changes-mode reconciliation", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - const store = useSessionStore.getState(); - store.initializeSession(SERVER_ID, null as unknown as DaemonClient); - const directory = new AgentDirectoryReplica(SERVER_ID, () => undefined); - const agents = [agent("agent-1"), agent("agent-2")]; - directory.commitSnapshot( - agents.map((value) => ({ - agent: projectAgentSnapshot(value), - project: value.projectPlacement!, - })), - [], - ); - await replicaCache.flush(); - storage.changes.length = 0; - - directory.commitChanges( - [ - { - agent: { ...projectAgentSnapshot(agents[0]), title: "Updated" }, - project: agents[0].projectPlacement!, - }, - ], - [], - [], - ); - await replicaCache.flush(); - - expect(storage.changes).toHaveLength(1); - expect(storage.changes[0]).toMatchObject({ - upserts: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], - deletes: [], - }); - }); - - it("persists a checkpoint without serializing host entity rows", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - storage.changes.length = 0; - - replicaCache.writeDirectoryCheckpoint(SERVER_ID, { - agents: { generation: "generation", afterSeq: 4 }, - }); - await replicaCache.flush(); - - expect(storage.changes).toHaveLength(1); - expect(storage.changes[0]?.upserts).toEqual([ - expect.objectContaining({ kind: "checkpoint", id: "singleton" }), - ]); - }); - - it("maps host removal and reconciliation to row-store host operations", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - - replicaCache.reconcileServerId(SERVER_ID, "reconciled-host"); - await replicaCache.flush(); - replicaCache.setHosts([]); - await replicaCache.flush(); - - expect(storage.renamedHosts).toEqual([ - { oldServerId: SERVER_ID, newServerId: "reconciled-host" }, - ]); - expect(storage.deletedHosts).toContain("reconciled-host"); - }); - - it("restores workspace change request checks beside the directory cursor", async () => { - const githubRuntime = { - featuresEnabled: true, - pullRequest: { - number: 824, - url: "https://github.com/blank-dot-page/editor/pull/824", - title: "Cut realistic editor typing latency by two thirds", - state: "OPEN", - baseRefName: "main", - headRefName: "perf-editor-typing-latency", - isMerged: false, - checksStatus: "success" as const, - checks: [ - { - name: "Check", - status: "success" as const, - url: "https://github.com/blank-dot-page/editor/actions/runs/824", - }, - ], - }, - error: null, - }; - const storage = new MemoryStorage(); - const writer = cache(storage); - writer.setHosts([SERVER_ID]); - seedSession(); - useSessionStore.getState().setWorkspaces( - SERVER_ID, - new Map([ - [ - "workspace-1", - normalizeWorkspaceDescriptor({ - ...workspace(), - forge: "github", - githubRuntime, - }), - ], - ]), - ); - writer.writeDirectoryCheckpoint(SERVER_ID, { - workspaces: { generation: "daemon-generation", afterSeq: 9 }, - }); - await writer.flush(); - - useSessionStore.getState().clearSession(SERVER_ID); - const reader = cache(storage); - reader.setHosts([SERVER_ID]); - await reader.restore(); - - expect( - useSessionStore.getState().sessions[SERVER_ID]?.workspaces.get("workspace-1")?.githubRuntime, - ).toEqual(githubRuntime); - expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({ - workspaces: { generation: "daemon-generation", afterSeq: 9 }, - }); - }); - - it("restores every registered host directory before any host reconnects", async () => { - const storage = new MemoryStorage(); - const writer = cache(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 = cache(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 replicaCache = cache(storage, { maxBytes: 7_000 }); - replicaCache.setHosts(LRU_SERVER_IDS.slice(0, 2)); - seedTimeline("host-a", "A".repeat(1_200)); - seedTimeline("host-b", "B".repeat(1_200)); - await replicaCache.flush(); - - seedTimeline("host-a", "A".repeat(1_201)); - await replicaCache.flush(); - - replicaCache.setHosts(LRU_SERVER_IDS); - seedTimeline("host-c", "C".repeat(1_200)); - await replicaCache.flush(); - - for (const serverId of LRU_SERVER_IDS) { - useSessionStore.getState().clearSession(serverId); - } - const reader = cache(storage, { maxBytes: 7_000 }); - reader.setHosts(LRU_SERVER_IDS); - await reader.restore(); - - expect(Object.keys(useSessionStore.getState().sessions).sort()).toEqual(["host-a", "host-c"]); - }); - - it("never writes an oversized host and does not retry it on every flush", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage, { maxBytes: 1_000 }); - replicaCache.setHosts([SERVER_ID]); - seedTimeline(SERVER_ID, "oversized".repeat(500)); - - await replicaCache.flush(); - await replicaCache.flush(); - - expect(storage.changes).toEqual([]); - expect(storage.deletedHosts).toEqual([]); - }); - - it("lets a changed evicted host displace the oldest resident host", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage, { maxBytes: 7_000 }); - replicaCache.setHosts(LRU_SERVER_IDS.slice(0, 2)); - seedTimeline("host-a", "A".repeat(1_200)); - seedTimeline("host-b", "B".repeat(1_200)); - await replicaCache.flush(); - - seedTimeline("host-a", "A".repeat(1_201)); - await replicaCache.flush(); - replicaCache.setHosts(LRU_SERVER_IDS); - seedTimeline("host-c", "C".repeat(1_200)); - await replicaCache.flush(); - - seedTimeline("host-b", "B".repeat(1_201)); - await replicaCache.flush(); - - expect(new Set([...storage.rows.values()].map((row) => row.serverId))).toEqual( - new Set(["host-b", "host-c"]), - ); - }); - - it("persists an oversized host after its current input shrinks below the budget", async () => { - const storage = new MemoryStorage(); - const replicaCache = cache(storage, { maxBytes: 3_000 }); - replicaCache.setHosts([SERVER_ID]); - seedTimeline(SERVER_ID, "oversized".repeat(500)); - await replicaCache.flush(); + expect(storage.writes).toBe(0); expect(storage.rows.size).toBe(0); - - seedTimeline(SERVER_ID, "small"); - await replicaCache.flush(); - - expect([...storage.rows.values()]).toEqual( - expect.arrayContaining([ - expect.objectContaining({ serverId: SERVER_ID, kind: "timeline", id: "singleton" }), - ]), - ); }); - it("keeps the newest row after two ordered write failures", async () => { + it("round-trips explicit directory and timeline commits", async () => { const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - storage.changes.length = 0; - const firstFailure = deferred(); - const secondFailure = deferred(); - storage.applyResults.push(firstFailure.promise, secondFailure.promise); + const writer = createCache(storage); + writer.commitDirectory(SERVER_ID, directory()); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); - 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(current.id, { ...current, title: "v1" }); - }); - const firstWrite = replicaCache.flush(); - await waitForWrites(storage, 2); - 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(current.id, { ...current, title: "v2" }); - }); - const secondWrite = replicaCache.flush(); + const reader = createCache(storage); + const restoredDirectory = await reader.readDirectory(SERVER_ID); + const restoredTimeline = await reader.readTimeline(SERVER_ID, "agent-1"); - firstFailure.reject(new Error("first failure")); - await waitForWrites(storage, 3); - secondFailure.reject(new Error("second failure")); - await Promise.all([firstWrite, secondWrite]); - await replicaCache.flush(); - - const persisted = [...storage.rows.values()].find( - (row) => row.serverId === SERVER_ID && row.kind === "agent" && row.id === "agent-1", - ); - expect(JSON.parse(persisted?.payload ?? "null").snapshot.title).toBe("v2"); + expect(restoredDirectory.agents.get("agent-1")?.title).toBe("Cached agent"); + expect(restoredDirectory.workspaces.get("workspace-1")?.name).toBe("main"); + expect(restoredDirectory.projects.get("project-1")?.projectDisplayName).toBe("Paseo"); + expect(restoredDirectory.checkpoint).toEqual({ agents: { generation: "g", afterSeq: 12 } }); + expect(restoredTimeline).toEqual(timeline()); }); - it("retries restored changes after a transient write failure", async () => { - vi.useFakeTimers(); + it("reads one requested agent and the focused timeline without scanning directory rows", async () => { const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - const failure = deferred(); - storage.applyResults.push(failure.promise); + const writer = createCache(storage); + writer.commitDirectory(SERVER_ID, directory()); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + await writer.flush(); - 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(current.id, { ...current, title: "retried" }); - }); - const write = replicaCache.flush(); - await waitForWrites(storage, 2); - failure.reject(new Error("transient failure")); - await write; - - await vi.advanceTimersByTimeAsync(5_000); - await waitForWrites(storage, 3); - - const persisted = [...storage.rows.values()].find( - (row) => row.serverId === SERVER_ID && row.kind === "agent" && row.id === "agent-1", - ); - expect(JSON.parse(persisted?.payload ?? "null").snapshot.title).toBe("retried"); + const reader = createCache(storage); + expect((await reader.readAgent(SERVER_ID, "agent-1"))?.id).toBe("agent-1"); + expect((await reader.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([timelineItem()]); + expect(storage.reads).toEqual([ + { serverId: SERVER_ID, kinds: ["agent"], ids: ["agent-1"] }, + { serverId: SERVER_ID, kinds: ["timeline"], ids: ["agent-1"] }, + ]); }); - it("does not restore a failed row write after its host was removed", async () => { - vi.useFakeTimers(); + it("reads one requested workspace and its project without scanning the directory", async () => { const storage = new MemoryStorage(); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - seedSession(); - await replicaCache.flush(); - storage.changes.length = 0; - const failure = deferred(); - storage.applyResults.push(failure.promise); + const writer = createCache(storage); + writer.commitDirectory(SERVER_ID, directory()); + await writer.flush(); - 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(current.id, { ...current, title: "removed" }); - }); - const write = replicaCache.flush(); - await waitForWrites(storage, 2); - replicaCache.setHosts([]); - failure.reject(new Error("failed after removal")); - await write; - await vi.advanceTimersByTimeAsync(5_000); - await replicaCache.flush(); + const reader = createCache(storage); + const restored = await reader.readWorkspace(SERVER_ID, "workspace-1"); - expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); - expect(storage.changes).toEqual([]); + expect(restored?.workspace.id).toBe("workspace-1"); + expect(restored?.project?.projectId).toBe("project-1"); + expect(storage.reads).toEqual([ + { serverId: SERVER_ID, kinds: ["workspace"], ids: ["workspace-1"] }, + { serverId: SERVER_ID, kinds: ["project"], ids: ["project-1"] }, + ]); }); - it("clears the whole cache when one row payload is corrupt", async () => { - const storage = new MemoryStorage(); - storage.rows.set(`${SERVER_ID}:agent:agent-1`, { - serverId: SERVER_ID, - kind: "agent", - id: "agent-1", - payload: "{not json", - }); - storage.rows.set(`${SERVER_ID}:project:project-1`, { - serverId: SERVER_ID, - kind: "project", - id: "project-1", - payload: JSON.stringify({ projectId: "project-1" }), - }); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); - - await replicaCache.restore(); - - expect(useSessionStore.getState().sessions[SERVER_ID]).toBeUndefined(); - expect(storage.rows.size).toBe(0); - expect(storage.clears).toBe(1); - }); - - it("clears every host when one host contains a corrupt row", async () => { + it("treats a corrupt row as a scoped miss", async () => { const storage = new MemoryStorage(); storage.rows.set(`${SERVER_ID}:agent:agent-1`, { serverId: SERVER_ID, @@ -1037,51 +258,188 @@ describe("ReplicaCache", () => { id: "agent-1", payload: "{bad", }); - storage.rows.set("second-host:checkpoint:singleton", { - serverId: "second-host", - kind: "checkpoint", - id: "singleton", - payload: JSON.stringify({ agents: { generation: "g", afterSeq: 1 } }), - }); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID, "second-host"]); - - await replicaCache.restore(); - - expect(storage.rows.size).toBe(0); - expect(storage.clears).toBe(1); - }); - - it("clears the whole cache when a valid JSON row has an unknown shape", async () => { - const storage = new MemoryStorage(); storage.rows.set(`${SERVER_ID}:project:project-1`, { serverId: SERVER_ID, kind: "project", id: "project-1", - payload: JSON.stringify({ projectId: "project-1", surprise: true }), + payload: JSON.stringify({ + projectId: "project-1", + projectDisplayName: "Paseo", + projectCustomName: null, + projectCustomIconRevision: null, + projectRootPath: "/repo/paseo", + projectKind: "git", + }), }); - const replicaCache = cache(storage); - replicaCache.setHosts([SERVER_ID]); + const cache = createCache(storage); - await replicaCache.restore(); + const restored = await cache.readDirectory(SERVER_ID); - expect(storage.rows.size).toBe(0); - expect(storage.clears).toBe(1); + expect(restored.agents.size).toBe(0); + expect(restored.projects.get("project-1")?.projectDisplayName).toBe("Paseo"); + expect(storage.rows.has(`${SERVER_ID}:agent:agent-1`)).toBe(false); + expect(storage.rows.has(`${SERVER_ID}:project:project-1`)).toBe(true); }); - it("runs legacy blob cleanup when the row store is first prepared", async () => { + it("removes a targeted corrupt row from eviction bookkeeping", async () => { + const otherServerId = "other-host"; const storage = new MemoryStorage(); - let cleanups = 0; - const replicaCache = new ReplicaCache(storage, { + const writer = new ReplicaCache(storage, noLegacyCleanup); + writer.setHosts([SERVER_ID, otherServerId]); + writer.commitDirectory(SERVER_ID, directory()); + writer.commitTimeline(SERVER_ID, "agent-1", timeline()); + writer.commitDirectory(otherServerId, directory()); + await writer.flush(); + storage.rows.set(`${SERVER_ID}:agent:agent-1`, { + serverId: SERVER_ID, + kind: "agent", + id: "agent-1", + payload: `{${"x".repeat(5_000)}`, + }); + const initialBytes = [...storage.rows.values()].reduce( + (total, row) => total + Buffer.byteLength(row.payload), + 0, + ); + const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: initialBytes + 100 }); + cache.setHosts([SERVER_ID, otherServerId]); + cache.commitDirectory(otherServerId, directory()); + await cache.flush(); + + expect(await cache.readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + cache.commitTimeline(otherServerId, "agent-1", timeline("x".repeat(1_000))); + await cache.flush(); + + expect(storage.rows.has(`${SERVER_ID}:timeline:agent-1`)).toBe(true); + }); + + it("atomically removes a targeted corrupt row and its matching persisted cursor", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitDirectory( + SERVER_ID, + directory({ + agents: { generation: "g", afterSeq: 12 }, + projects: { generation: "g", afterSeq: 4 }, + }), + ); + await writer.flush(); + storage.rows.set(`${SERVER_ID}:agent:agent-1`, { + serverId: SERVER_ID, + kind: "agent", + id: "agent-1", + payload: "{bad", + }); + + expect(await createCache(storage).readAgent(SERVER_ID, "agent-1")).toBeUndefined(); + + expect(storage.changes.at(-1)).toMatchObject({ + deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], + upserts: [{ serverId: SERVER_ID, kind: "checkpoint", id: "singleton" }], + }); + expect((await createCache(storage).readDirectory(SERVER_ID)).checkpoint).toEqual({ + projects: { generation: "g", afterSeq: 4 }, + }); + }); + + it("drops only the cursor whose cached entity baseline is corrupt", async () => { + const storage = new MemoryStorage(); + const writer = createCache(storage); + writer.commitDirectory( + SERVER_ID, + directory({ + agents: { generation: "g", afterSeq: 12 }, + projects: { generation: "g", afterSeq: 4 }, + }), + ); + await writer.flush(); + storage.rows.set(`${SERVER_ID}:agent:agent-1`, { + serverId: SERVER_ID, + kind: "agent", + id: "agent-1", + payload: "{bad", + }); + + const restored = await createCache(storage).readDirectory(SERVER_ID); + + expect(restored.checkpoint).toEqual({ + projects: { generation: "g", afterSeq: 4 }, + }); + expect(storage.changes.at(-1)).toMatchObject({ + deletes: [{ serverId: SERVER_ID, kind: "agent", id: "agent-1" }], + upserts: [{ serverId: SERVER_ID, kind: "checkpoint", id: "singleton" }], + }); + const reopened = await createCache(storage).readDirectory(SERVER_ID); + expect(reopened.checkpoint).toEqual({ + projects: { generation: "g", afterSeq: 4 }, + }); + }); + + it("commits directory rows and their checkpoint in one storage transaction", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + + cache.commitDirectory(SERVER_ID, directory()); + await cache.flush(); + + expect(storage.changes).toHaveLength(1); + expect(storage.changes[0]?.upserts.map((row) => row.kind).sort()).toEqual([ + "agent", + "checkpoint", + "project", + "workspace", + ]); + }); + + it("retries an explicit commit after a storage failure", async () => { + const storage = new MemoryStorage(); + const cache = createCache(storage); + storage.nextWriteFailure = new Error("disk busy"); + cache.commitTimeline(SERVER_ID, "agent-1", timeline("Retry me")); + + await cache.flush(); + expect(storage.rows.size).toBe(0); + await cache.flush(); + + expect((await cache.readTimeline(SERVER_ID, "agent-1"))?.items).toEqual([ + timelineItem("Retry me"), + ]); + }); + + it("rebuilds every directory row before restoring its checkpoint after eviction", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage, { ...noLegacyCleanup, maxBytes: 2_500 }); + cache.setHosts([SERVER_ID, "other-host"]); + const cachedDirectory = directory(); + cache.commitDirectory(SERVER_ID, cachedDirectory); + await cache.flush(); + cache.commitDirectory("other-host", cachedDirectory); + await cache.flush(); + expect([...storage.rows.values()].some((row) => row.serverId === SERVER_ID)).toBe(false); + + cache.commitDirectory(SERVER_ID, cachedDirectory); + await cache.flush(); + + expect( + [...storage.rows.values()] + .filter((row) => row.serverId === SERVER_ID) + .map((row) => row.kind) + .sort(), + ).toEqual(["agent", "checkpoint", "project", "workspace"]); + }); + + it("runs legacy cleanup once when storage is first used", async () => { + const storage = new MemoryStorage(); + const cache = new ReplicaCache(storage, { clearLegacyCache: async () => { - cleanups += 1; + storage.cleanups += 1; }, }); - replicaCache.setHosts([SERVER_ID]); + cache.setHosts([SERVER_ID]); - await replicaCache.restore(); - await replicaCache.flush(); + await cache.readAgent(SERVER_ID, "missing"); + cache.commitDirectory(SERVER_ID, directory()); + await cache.flush(); - expect(cleanups).toBe(1); + expect(storage.cleanups).toBe(1); }); }); diff --git a/packages/app/src/runtime/replica-cache/index.ts b/packages/app/src/runtime/replica-cache/index.ts index faa5c25081..72a37f54c9 100644 --- a/packages/app/src/runtime/replica-cache/index.ts +++ b/packages/app/src/runtime/replica-cache/index.ts @@ -1,5 +1,4 @@ import { Buffer } from "buffer"; -import { Keyboard } from "react-native"; import { z } from "zod"; import { AgentStatusSchema, @@ -10,11 +9,8 @@ import { AgentProviderSchema } from "@getpaseo/protocol/provider-manifest"; import { normalizeProjectDescriptor, normalizeWorkspaceDescriptor, - selectAgentTimelineState, - useSessionStore, type Agent, - type SessionReplica, - type SessionState, + type AgentTimelineCursorState, type ProjectDescriptor, type WorkspaceDescriptor, } from "@/stores/session-store"; @@ -24,16 +20,9 @@ import { type StreamItem, } from "@/types/stream"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; -import { - diffReplicaInput, - hasReplicaInputChanges, - type ReplicaInput, - type ReplicaInputChanges, -} from "./diff"; import { clearLegacyReplicaCache } from "./legacy-cleanup"; import { REPLICA_SINGLETON_ROW_ID, - type ReplicaHostRows, type ReplicaRow, type ReplicaRowChanges, type ReplicaRowKey, @@ -41,7 +30,7 @@ import { type ReplicaRowStore, } from "./row-store"; -const PERSIST_AFTER_USER_INACTIVITY_MS = 5_000; +const PERSIST_DELAY_MS = 5_000; const MAX_TIMELINE_ITEMS = 50; const MAX_CACHE_BYTES = 32 * 1024 * 1024; const IsoDateSchema = z.iso.datetime(); @@ -292,18 +281,7 @@ const StoredTimelineSchema = z.strictObject({ hasOlder: z.boolean(), }); -const StoredHostSchema = z.strictObject({ - serverId: z.string(), - agents: z.array(StoredAgentSchema), - workspaces: z.array(StoredWorkspaceSchema), - projects: z.array(StoredProjectSchema), - emptyProjects: z.array(StoredProjectSchema), - timeline: StoredTimelineSchema.nullable(), - directorySync: z.unknown().optional(), -}); - type StoredAgent = z.infer; -type StoredHost = z.infer; type StoredTimeline = z.infer; type StoredTimelineItem = z.infer; type StoredToolCall = Extract["item"]; @@ -315,7 +293,48 @@ interface ReplicaCacheOptions { clearLegacyCache?: () => Promise; } -function deserializeTimeline(stored: StoredHost["timeline"]): SessionReplica["timeline"] { +export interface CachedDirectory { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +export interface CachedWorkspace { + workspace: WorkspaceDescriptor; + project?: ProjectDescriptor; +} + +export interface DirectoryCursor { + generation: string; + afterSeq: number; +} + +export interface DirectoryCheckpoint { + projects?: DirectoryCursor; + workspaces?: DirectoryCursor; + agents?: DirectoryCursor; +} + +const DirectoryCursorSchema = z.strictObject({ + generation: z.string(), + afterSeq: z.number().int().nonnegative(), +}); + +const DirectoryCheckpointSchema = z.strictObject({ + projects: DirectoryCursorSchema.optional(), + workspaces: DirectoryCursorSchema.optional(), + agents: DirectoryCursorSchema.optional(), +}); + +export interface CachedTimeline { + agentId: string; + items: StreamItem[]; + range: AgentTimelineCursorState | null; + hasOlder: boolean; +} + +function deserializeTimeline(stored: StoredTimeline | null): CachedTimeline | null { if (!stored) { return null; } @@ -622,36 +641,17 @@ function isTimelineItemStoredLosslessly(item: StreamItem): boolean { } } -function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput { - const agent = agentId ? session.agents.get(agentId) : undefined; - const timeline = agentId - ? selectAgentTimelineState(session, agentId) - : { status: "cold" as const }; - return { - agents: session.agents, - workspaces: session.workspaces, - projects: session.projects, - focusedAgentId: agent?.id, - timelineItems: timeline.status === "cold" ? undefined : timeline.items, - timelineRange: timeline.status === "synced" ? timeline.range : null, - timelineHasOlder: timeline.status === "synced" && timeline.older === "available", - }; -} - -function serializeTimeline(input: ReplicaInput): StoredTimeline | null { - const canonicalItems = input.timelineItems?.filter( +function serializeTimeline(timeline: CachedTimeline): StoredTimeline | null { + const canonicalItems = timeline.items.filter( (item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item), ); - const items = canonicalItems - ? canonicalItems.map(serializeTimelineItem).filter((item) => item !== null) - : undefined; - const range = input.timelineRange; + const items = canonicalItems.map(serializeTimelineItem).filter((item) => item !== null); + const range = timeline.range; const canPersistCoverage = range !== null && range.retainedRanges === undefined && - canonicalItems !== undefined && canonicalItems.length <= MAX_TIMELINE_ITEMS && - items?.length === canonicalItems.length && + items.length === canonicalItems.length && canonicalItems.every( (item) => isTimelineItemStoredLosslessly(item) && @@ -660,44 +660,13 @@ function serializeTimeline(input: ReplicaInput): StoredTimeline | null { item.timelineCursor.seq <= range.endSeq, ) && canonicalItems.some((item) => item.timelineCursor?.seq === range.endSeq); - if (!input.focusedAgentId || !items) return null; return { - agentId: input.focusedAgentId, + agentId: timeline.agentId, items: items.slice(-MAX_TIMELINE_ITEMS), range: canPersistCoverage ? { epoch: range.epoch, startSeq: range.startSeq, endSeq: range.endSeq } : null, - hasOlder: canPersistCoverage ? input.timelineHasOlder : false, - }; -} - -function deserializeHost(stored: StoredHost): SessionReplica { - const agents = stored.agents.map((entry) => deserializeAgent(stored.serverId, entry)); - const workspaces = stored.workspaces.map(normalizeWorkspaceDescriptor); - const listedProjects = stored.projects.map(normalizeProjectDescriptor); - const legacyProjects = [ - ...stored.emptyProjects.map(normalizeProjectDescriptor), - ...workspaces.map(legacyProjectDescriptorFromWorkspace), - ]; - const projects = new Map( - [...legacyProjects, ...listedProjects].map((project) => [project.projectId, project]), - ); - return { - agents: new Map(agents.map((agent) => [agent.id, agent])), - workspaces: new Map(workspaces.map((workspace) => [workspace.id, workspace])), - projects, - timeline: deserializeTimeline(stored.timeline), - }; -} - -function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor { - return { - projectId: workspace.projectId, - projectKey: null, - projectDisplayName: workspace.projectDisplayName, - projectCustomName: workspace.projectCustomName ?? null, - projectRootPath: workspace.projectRootPath, - projectKind: workspace.projectKind, + hasOlder: canPersistCoverage ? timeline.hasOlder : false, }; } @@ -723,24 +692,73 @@ function parseStoredPayload(schema: z.ZodType, payload: string): V return parsed.data; } +interface DirectoryReadAccumulator { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; +} + +interface PendingReplicaChanges extends ReplicaRowChanges { + directoryReplacements: Map>; +} + +function applyDirectoryRow( + serverId: string, + row: ReplicaRow, + result: DirectoryReadAccumulator, +): void { + switch (row.kind) { + case "agent": { + const stored = parseStoredPayload(StoredAgentSchema, row.payload); + if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); + result.agents.set(row.id, deserializeAgent(serverId, stored)); + return; + } + case "workspace": { + const stored = parseStoredPayload(StoredWorkspaceSchema, row.payload); + if (stored.id !== row.id) throw new Error("Replica workspace row id mismatch"); + result.workspaces.set(row.id, normalizeWorkspaceDescriptor(stored)); + return; + } + case "project": { + const stored = parseStoredPayload(StoredProjectSchema, row.payload); + if (stored.projectId !== row.id) throw new Error("Replica project row id mismatch"); + result.projects.set(row.id, normalizeProjectDescriptor(stored)); + return; + } + case "checkpoint": + if (row.id !== REPLICA_SINGLETON_ROW_ID) { + throw new Error("Replica checkpoint row id mismatch"); + } + result.checkpoint = parseStoredPayload(DirectoryCheckpointSchema, row.payload); + return; + default: + return; + } +} + +function directoryEntityForRow(row: ReplicaRow): keyof DirectoryCheckpoint | undefined { + if (row.kind === "agent") return "agents"; + if (row.kind === "workspace") return "workspaces"; + if (row.kind === "project") return "projects"; + return undefined; +} + export class ReplicaCache { private readonly activeServerIds = new Set(); private readonly storedRows = new Map>(); private readonly hostBytes = new Map(); private readonly hostWriteOrder = new Map(); - private readonly evictedHostBytes = new Map(); - private readonly lastFocusedAgentIds = new Map(); - private readonly capturedInputs = new Map(); - private readonly directoryCheckpoints = new Map(); - private readonly checkpointInputs = new Map(); private pendingUpserts = new Map(); private pendingDeletes = new Map(); + private pendingDirectoryReplacements = new Map>(); private readonly maxBytes: number; private totalBytes = 0; - private unsubscribe: (() => void) | null = null; private persistTimer: ReturnType | null = null; private writeQueue: Promise = Promise.resolve(); private preparePromise: Promise | null = null; + private storedIndexPromise: Promise | null = null; constructor( private readonly rowStore: ReplicaRowStore, @@ -752,6 +770,218 @@ export class ReplicaCache { private readonly clearLegacyCache: () => Promise; + async readAgent(serverId: string, agentId: string): Promise { + const rows = await this.readRows(serverId, ["agent"], [agentId]); + const row = rows[0]; + if (!row) return undefined; + try { + const stored = parseStoredPayload(StoredAgentSchema, row.payload); + if (stored.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); + return deserializeAgent(serverId, stored); + } catch { + await this.deleteInvalidRow(row); + return undefined; + } + } + + async readWorkspace(serverId: string, workspaceId: string): Promise { + const workspaceRow = (await this.readRows(serverId, ["workspace"], [workspaceId]))[0]; + if (!workspaceRow) return undefined; + let workspace: WorkspaceDescriptor; + try { + const stored = parseStoredPayload(StoredWorkspaceSchema, workspaceRow.payload); + if (stored.id !== workspaceRow.id) throw new Error("Replica workspace row id mismatch"); + workspace = normalizeWorkspaceDescriptor(stored); + } catch { + await this.deleteInvalidRow(workspaceRow); + return undefined; + } + + let project: ProjectDescriptor | undefined; + const projectRow = (await this.readRows(serverId, ["project"], [workspace.projectId]))[0]; + if (projectRow) { + try { + const stored = parseStoredPayload(StoredProjectSchema, projectRow.payload); + if (stored.projectId !== projectRow.id) throw new Error("Replica project row id mismatch"); + project = normalizeProjectDescriptor(stored); + } catch { + await this.deleteInvalidRow(projectRow); + } + } + + return { workspace, ...(project ? { project } : {}) }; + } + + async readDirectory(serverId: string): Promise { + const rows = await this.readRows(serverId, ["agent", "workspace", "project", "checkpoint"]); + const result: DirectoryReadAccumulator = { + agents: new Map(), + workspaces: new Map(), + projects: new Map(), + }; + const invalidEntities = new Set(); + const invalidRows: ReplicaRow[] = []; + for (const row of rows) { + try { + applyDirectoryRow(serverId, row, result); + } catch { + const invalidEntity = directoryEntityForRow(row); + if (invalidEntity) invalidEntities.add(invalidEntity); + invalidRows.push(row); + } + } + if (result.checkpoint && invalidEntities.size > 0) { + result.checkpoint = { ...result.checkpoint }; + for (const entity of invalidEntities) delete result.checkpoint[entity]; + } + if (invalidRows.length > 0) { + await this.repairInvalidDirectoryRows(serverId, invalidRows, result.checkpoint); + } + return result; + } + + async readTimeline(serverId: string, agentId: string): Promise { + const rows = await this.readRows(serverId, ["timeline"], [agentId]); + const row = rows[0]; + if (!row) return undefined; + try { + const stored = parseStoredPayload(StoredTimelineSchema, row.payload); + if (stored.agentId !== agentId) return undefined; + return deserializeTimeline(stored) ?? undefined; + } catch { + await this.deleteInvalidRow(row); + return undefined; + } + } + + private async readRows( + serverId: string, + kinds: readonly ReplicaRowKind[], + ids?: readonly string[], + ): Promise { + if (!this.activeServerIds.has(serverId)) return []; + try { + await this.prepareStore(); + return await this.rowStore.read(serverId, kinds, ids); + } catch { + return []; + } + } + + private async deleteInvalidRow(row: ReplicaRow): Promise { + const invalidEntity = directoryEntityForRow(row); + if (!invalidEntity) { + const changes = { upserts: [], deletes: [row] } satisfies ReplicaRowChanges; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + return; + } + + const checkpointRow = ( + await this.readRows(row.serverId, ["checkpoint"], [REPLICA_SINGLETON_ROW_ID]) + )[0]; + let checkpoint: DirectoryCheckpoint | undefined; + if (checkpointRow) { + try { + checkpoint = parseStoredPayload(DirectoryCheckpointSchema, checkpointRow.payload); + checkpoint = { ...checkpoint }; + delete checkpoint[invalidEntity]; + } catch { + checkpoint = undefined; + } + } + const changes: ReplicaRowChanges = { + deletes: [row, ...(checkpointRow && !checkpoint ? [checkpointRow] : [])], + upserts: + checkpointRow && checkpoint + ? [ + { + serverId: row.serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + } + + private async repairInvalidDirectoryRows( + serverId: string, + invalidRows: ReplicaRow[], + checkpoint: DirectoryCheckpoint | undefined, + ): Promise { + const checkpointWasInvalid = invalidRows.some((row) => row.kind === "checkpoint"); + const changes: ReplicaRowChanges = { + deletes: invalidRows, + upserts: + checkpoint !== undefined && !checkpointWasInvalid + ? [ + { + serverId, + kind: "checkpoint", + id: REPLICA_SINGLETON_ROW_ID, + payload: JSON.stringify(checkpoint), + }, + ] + : [], + }; + await this.queueOperation(async () => { + await this.rowStore.apply(changes); + this.applyStoredChanges(changes); + }); + } + + commitDirectory( + serverId: string, + directory: { + agents: Map; + workspaces: Map; + projects: Map; + checkpoint?: DirectoryCheckpoint; + }, + ): void { + if (!this.activeServerIds.has(serverId)) return; + this.clearPendingDirectoryChanges(serverId); + const desiredRows: ReplicaRow[] = []; + for (const agent of directory.agents.values()) { + desiredRows.push(this.entityRow(serverId, "agent", agent.id, serializeAgent(agent))); + } + for (const workspace of directory.workspaces.values()) { + desiredRows.push( + this.entityRow(serverId, "workspace", workspace.id, serializeWorkspace(workspace)), + ); + } + for (const project of directory.projects.values()) { + desiredRows.push( + this.entityRow(serverId, "project", project.projectId, serializeProject(project)), + ); + } + if (directory.checkpoint) { + desiredRows.push( + this.entityRow(serverId, "checkpoint", REPLICA_SINGLETON_ROW_ID, directory.checkpoint), + ); + } + for (const row of desiredRows) this.queueUpsert(row); + this.pendingDirectoryReplacements.set(serverId, new Set(desiredRows.map((row) => rowKey(row)))); + this.schedulePersist(); + } + + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void { + if (!this.activeServerIds.has(serverId)) return; + if (timeline.agentId !== agentId) throw new Error("Timeline cache key does not match payload"); + const stored = serializeTimeline(timeline); + if (stored) this.queueEntityUpsert(serverId, "timeline", agentId, stored); + else this.queueEntityDelete(serverId, "timeline", agentId); + this.schedulePersist(); + } + setHosts(serverIds: Iterable): void { const next = new Set(serverIds); const removed = [...this.activeServerIds].filter((serverId) => !next.has(serverId)); @@ -762,84 +992,6 @@ export class ReplicaCache { this.dropPendingHostChanges(serverId); this.queueOperation(() => this.rowStore.deleteHost(serverId)); } - for (const serverId of this.lastFocusedAgentIds.keys()) { - if (!next.has(serverId)) this.lastFocusedAgentIds.delete(serverId); - } - for (const serverId of this.capturedInputs.keys()) { - if (!next.has(serverId)) this.capturedInputs.delete(serverId); - } - for (const serverId of this.directoryCheckpoints.keys()) { - if (!next.has(serverId)) this.directoryCheckpoints.delete(serverId); - } - for (const serverId of this.checkpointInputs.keys()) { - if (!next.has(serverId)) this.checkpointInputs.delete(serverId); - } - for (const serverId of this.evictedHostBytes.keys()) { - if (!next.has(serverId)) this.evictedHostBytes.delete(serverId); - } - } - - async restore(): Promise { - try { - await this.prepareStore(); - } catch { - return; - } - let hosts: ReplicaHostRows[]; - try { - hosts = await this.rowStore.readAll(); - } catch { - return; - } - this.storedRows.clear(); - this.hostBytes.clear(); - this.hostWriteOrder.clear(); - this.directoryCheckpoints.clear(); - this.checkpointInputs.clear(); - this.totalBytes = 0; - const restoredHosts: StoredHost[] = []; - try { - for (const hostRows of hosts) { - if (!this.activeServerIds.has(hostRows.serverId)) { - await this.rowStore.deleteHost(hostRows.serverId); - continue; - } - restoredHosts.push(this.restoreHostRows(hostRows)); - } - } catch { - await this.clearInvalidCache(); - return; - } - await this.evictOverBudget(); - for (const host of restoredHosts) { - if (!this.storedRows.has(host.serverId)) continue; - if (host.timeline) this.lastFocusedAgentIds.set(host.serverId, host.timeline.agentId); - useSessionStore.getState().restoreSessionReplica(host.serverId, deserializeHost(host)); - const session = useSessionStore.getState().sessions[host.serverId]; - if (session) { - this.capturedInputs.set( - host.serverId, - selectReplicaInput(session, this.lastFocusedAgentIds.get(host.serverId) ?? null), - ); - } - } - } - - recordUserActivity(): void { - if (!this.persistTimer) return; - clearTimeout(this.persistTimer); - this.persistTimer = null; - this.schedulePersist(); - } - - start(): void { - if (this.unsubscribe) return; - const changedBeforeSubscription = this.captureSessions(); - this.unsubscribe = useSessionStore.subscribe(() => { - if (this.activeServerIds.size === 0) return; - this.schedulePersist(); - }); - if (changedBeforeSubscription || this.hasPendingChanges()) this.schedulePersist(); } reconcileServerId(oldServerId: string, newServerId: string): void { @@ -858,101 +1010,41 @@ export class ReplicaCache { this.hostWriteOrder.delete(oldServerId); this.touchHost(newServerId); } - const focusedAgentId = this.lastFocusedAgentIds.get(oldServerId); - if (focusedAgentId) { - this.lastFocusedAgentIds.delete(oldServerId); - this.lastFocusedAgentIds.set(newServerId, focusedAgentId); - } - const capturedInput = this.capturedInputs.get(oldServerId); - if (capturedInput) { - this.capturedInputs.delete(oldServerId); - this.capturedInputs.set(newServerId, capturedInput); - } - if (this.directoryCheckpoints.has(oldServerId)) { - const checkpoint = this.directoryCheckpoints.get(oldServerId); - this.directoryCheckpoints.delete(oldServerId); - this.directoryCheckpoints.set(newServerId, checkpoint); - } - if (this.checkpointInputs.has(oldServerId)) { - const checkpoint = this.checkpointInputs.get(oldServerId); - this.checkpointInputs.delete(oldServerId); - this.checkpointInputs.set(newServerId, checkpoint); - } - const evictedBytes = this.evictedHostBytes.get(oldServerId); - if (evictedBytes !== undefined) { - this.evictedHostBytes.delete(oldServerId); - this.evictedHostBytes.set(newServerId, evictedBytes); - } this.renamePendingHostChanges(oldServerId, newServerId); if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId); this.queueOperation(() => this.rowStore.renameHost(oldServerId, newServerId)); } - readDirectoryCheckpoint(serverId: string): unknown { - return this.directoryCheckpoints.get(serverId); - } - - writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void { - if (this.checkpointInputs.get(serverId) === checkpoint) return; - this.checkpointInputs.set(serverId, checkpoint); - this.directoryCheckpoints.set(serverId, checkpoint); - if (this.evictedHostBytes.has(serverId)) { - const session = useSessionStore.getState().sessions[serverId]; - if (!session) return; - this.captureHost(serverId, session, true); - } - const payload = JSON.stringify(checkpoint); - if (payload === undefined) { - this.queueDelete({ - serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - }); - } else { - this.queueUpsert({ - serverId, - kind: "checkpoint", - id: REPLICA_SINGLETON_ROW_ID, - payload, - }); - } - this.schedulePersist(); - } - async flush(): Promise { - await this.persist(false); + await this.persist(); await this.writeQueue.catch(() => undefined); } private async flushPending(): Promise { - if (Keyboard.isVisible()) { - this.schedulePersist(); - return; - } - await this.persist(true); + await this.persist(); } - private async persist(skipUnchanged: boolean): Promise { + private async persist(): Promise { if (this.persistTimer) { clearTimeout(this.persistTimer); this.persistTimer = null; } - const changed = this.captureSessions(); - if (skipUnchanged && !changed && !this.hasPendingChanges()) return; + if (!this.hasPendingChanges()) return; const write = this.writeQueue .catch(() => undefined) .then(async () => { - const changes = this.drainPendingChanges(); - if (changes.upserts.length === 0 && changes.deletes.length === 0) return; + const pending = this.drainPendingChanges(); try { await this.prepareStore(); + await this.ensureStoredIndex(); + const changes = this.materializeDirectoryReplacements(pending); const boundedChanges = await this.fitChangesToBudget(changes); if (boundedChanges.upserts.length > 0 || boundedChanges.deletes.length > 0) { await this.rowStore.apply(boundedChanges); this.applyStoredChanges(boundedChanges); } } catch { - this.restorePendingChanges(changes); + this.restorePendingChanges(pending); if (this.hasPendingChanges()) this.schedulePersist(); } return undefined; @@ -961,163 +1053,32 @@ export class ReplicaCache { await write; } - private captureSessions(): boolean { - const sessions = useSessionStore.getState().sessions; - let changed = false; - for (const serverId of this.activeServerIds) { - const session = sessions[serverId]; - if (!session) continue; - if (this.captureHost(serverId, session)) changed = true; - } - return changed; - } - - private captureHost(serverId: string, session: SessionState, force = false): boolean { - if (session.focusedAgentId) { - this.lastFocusedAgentIds.set(serverId, session.focusedAgentId); - } - const focusedAgentId = this.lastFocusedAgentIds.get(serverId) ?? null; - const previous = this.capturedInputs.get(serverId); - const selected = selectReplicaInput(session, focusedAgentId); - const hasTimelineHead = - focusedAgentId !== null && (session.agentStreamHead.get(focusedAgentId)?.length ?? 0) > 0; - let input = selected; - if (hasTimelineHead && previous && selected.timelineItems === previous.timelineItems) { - input = { - ...selected, - timelineRange: previous.timelineRange, - timelineHasOlder: previous.timelineHasOlder, - }; - } else if (hasTimelineHead) { - input = { ...selected, timelineRange: null, timelineHasOlder: false }; - } - const changes = diffReplicaInput(previous, input); - this.capturedInputs.set(serverId, input); - const isEvicted = this.evictedHostBytes.has(serverId); - if (!hasReplicaInputChanges(changes) && !(force && isEvicted)) return false; - const persistedChanges = isEvicted ? diffReplicaInput(undefined, input) : changes; - this.queueReplicaChanges(serverId, input, persistedChanges); - return true; - } - - private queueReplicaChanges( - serverId: string, - input: ReplicaInput, - changes: ReplicaInputChanges, - ): void { - for (const agent of changes.agents.upserts) { - this.queueEntityUpsert(serverId, "agent", agent.id, serializeAgent(agent)); - } - for (const id of changes.agents.deletes) this.queueEntityDelete(serverId, "agent", id); - for (const workspace of changes.workspaces.upserts) { - this.queueEntityUpsert(serverId, "workspace", workspace.id, serializeWorkspace(workspace)); - } - for (const id of changes.workspaces.deletes) this.queueEntityDelete(serverId, "workspace", id); - for (const project of changes.projects.upserts) { - this.queueEntityUpsert(serverId, "project", project.projectId, serializeProject(project)); - } - for (const id of changes.projects.deletes) this.queueEntityDelete(serverId, "project", id); - if (!changes.timelineChanged) return; - const timeline = serializeTimeline(input); - if (timeline) { - this.queueEntityUpsert(serverId, "timeline", REPLICA_SINGLETON_ROW_ID, timeline); - } else { - this.queueEntityDelete(serverId, "timeline", REPLICA_SINGLETON_ROW_ID); - } - } - private queueEntityUpsert( serverId: string, kind: ReplicaRowKind, id: string, value: unknown, ): void { - this.queueUpsert({ serverId, kind, id, payload: JSON.stringify(value) }); + this.queueUpsert(this.entityRow(serverId, kind, id, value)); } - private async clearInvalidCache(): Promise { - try { - await this.rowStore.clear(); - } catch { - // A storage failure must not turn invalid cached data into application state. - } - this.storedRows.clear(); - this.hostBytes.clear(); - this.hostWriteOrder.clear(); - this.evictedHostBytes.clear(); - this.directoryCheckpoints.clear(); - this.checkpointInputs.clear(); - this.capturedInputs.clear(); - this.lastFocusedAgentIds.clear(); - this.pendingUpserts.clear(); - this.pendingDeletes.clear(); - this.totalBytes = 0; + private entityRow( + serverId: string, + kind: ReplicaRowKind, + id: string, + value: unknown, + ): ReplicaRow { + return { serverId, kind, id, payload: JSON.stringify(value) }; } - private restoreHostRows(hostRows: ReplicaHostRows): StoredHost { - const agents: StoredAgent[] = []; - const workspaces: StoredWorkspace[] = []; - const projects: StoredProject[] = []; - let timeline: StoredTimeline | null = null; - let checkpoint: unknown; - const rows = new Map(); - let bytes = 0; - for (const row of hostRows.rows) { - if (row.serverId !== hostRows.serverId) throw new Error("Replica row host key mismatch"); - switch (row.kind) { - case "agent": { - const agent = parseStoredPayload(StoredAgentSchema, row.payload); - if (agent.snapshot.id !== row.id) throw new Error("Replica agent row id mismatch"); - agents.push(agent); - break; - } - case "workspace": { - const workspace = parseStoredPayload(StoredWorkspaceSchema, row.payload); - if (workspace.id !== row.id) throw new Error("Replica workspace row id mismatch"); - workspaces.push(workspace); - break; - } - case "project": { - const project = parseStoredPayload(StoredProjectSchema, row.payload); - if (project.projectId !== row.id) throw new Error("Replica project row id mismatch"); - projects.push(project); - break; - } - case "timeline": - if (row.id !== REPLICA_SINGLETON_ROW_ID) { - throw new Error("Replica timeline row id mismatch"); - } - timeline = parseStoredPayload(StoredTimelineSchema, row.payload); - break; - case "checkpoint": - if (row.id !== REPLICA_SINGLETON_ROW_ID) { - throw new Error("Replica checkpoint row id mismatch"); - } - checkpoint = parseJsonPayload(row.payload); - break; - default: - throw new Error("Unknown replica row kind"); - } - rows.set(rowKey(row), row); - bytes += payloadBytes(row.payload); + private clearPendingDirectoryChanges(serverId: string): void { + const isDirectoryRow = (row: ReplicaRowKey) => row.kind !== "timeline"; + for (const [key, row] of this.pendingUpserts) { + if (row.serverId === serverId && isDirectoryRow(row)) this.pendingUpserts.delete(key); } - this.storedRows.set(hostRows.serverId, rows); - this.hostBytes.set(hostRows.serverId, bytes); - this.totalBytes += bytes; - this.touchHost(hostRows.serverId); - if (checkpoint !== undefined) { - this.directoryCheckpoints.set(hostRows.serverId, checkpoint); - this.checkpointInputs.set(hostRows.serverId, checkpoint); + for (const [key, row] of this.pendingDeletes) { + if (row.serverId === serverId && isDirectoryRow(row)) this.pendingDeletes.delete(key); } - return { - serverId: hostRows.serverId, - agents, - workspaces, - projects, - emptyProjects: [], - timeline, - ...(checkpoint !== undefined ? { directorySync: checkpoint } : {}), - }; } private queueEntityDelete(serverId: string, kind: ReplicaRowKind, id: string): void { @@ -1137,20 +1098,43 @@ export class ReplicaCache { } private hasPendingChanges(): boolean { - return this.pendingUpserts.size > 0 || this.pendingDeletes.size > 0; + return ( + this.pendingUpserts.size > 0 || + this.pendingDeletes.size > 0 || + this.pendingDirectoryReplacements.size > 0 + ); } - private drainPendingChanges(): ReplicaRowChanges { + private drainPendingChanges(): PendingReplicaChanges { const changes = { upserts: [...this.pendingUpserts.values()], deletes: [...this.pendingDeletes.values()], + directoryReplacements: this.pendingDirectoryReplacements, }; this.pendingUpserts = new Map(); this.pendingDeletes = new Map(); + this.pendingDirectoryReplacements = new Map(); return changes; } - private restorePendingChanges(changes: ReplicaRowChanges): void { + private materializeDirectoryReplacements(pending: PendingReplicaChanges): ReplicaRowChanges { + const deletes = new Map(pending.deletes.map((row) => [pendingRowKey(row), row])); + for (const [serverId, desiredRows] of pending.directoryReplacements) { + for (const row of this.storedRows.get(serverId)?.values() ?? []) { + if (row.kind !== "timeline" && !desiredRows.has(rowKey(row))) { + deletes.set(pendingRowKey(row), row); + } + } + } + return { upserts: pending.upserts, deletes: [...deletes.values()] }; + } + + private restorePendingChanges(changes: PendingReplicaChanges): void { + for (const [serverId, desiredRows] of changes.directoryReplacements) { + if (this.activeServerIds.has(serverId) && !this.pendingDirectoryReplacements.has(serverId)) { + this.pendingDirectoryReplacements.set(serverId, desiredRows); + } + } for (const key of changes.deletes) { const pendingKey = pendingRowKey(key); if ( @@ -1191,7 +1175,6 @@ export class ReplicaCache { rows.set(rowKey(row), row); this.storedRows.set(row.serverId, rows); this.adjustHostBytes(row.serverId, payloadBytes(row.payload) - previousBytes); - this.evictedHostBytes.delete(row.serverId); touchedServerIds.add(row.serverId); } for (const serverId of touchedServerIds) { @@ -1213,19 +1196,6 @@ export class ReplicaCache { this.hostWriteOrder.set(serverId, true); } - private async evictOverBudget(): Promise { - while (this.totalBytes > this.maxBytes) { - const oldestServerId = this.hostWriteOrder.keys().next().value; - if (oldestServerId === undefined) return; - const bytes = this.hostBytes.get(oldestServerId) ?? 0; - await this.rowStore.deleteHost(oldestServerId); - this.removeStoredHost(oldestServerId); - this.evictedHostBytes.set(oldestServerId, bytes); - this.directoryCheckpoints.delete(oldestServerId); - this.checkpointInputs.delete(oldestServerId); - } - } - private async fitChangesToBudget(changes: ReplicaRowChanges): Promise { const touchedServerIds = new Set(); for (const key of changes.deletes) touchedServerIds.add(key.serverId); @@ -1262,11 +1232,10 @@ export class ReplicaCache { projectedTotal -= bytes; projectedBytes.delete(serverId); evicted.add(serverId); - this.evictedHostBytes.set(serverId, bytes); if (this.storedRows.has(serverId)) await this.rowStore.deleteHost(serverId); this.removeStoredHost(serverId); - this.directoryCheckpoints.delete(serverId); - this.checkpointInputs.delete(serverId); + // The next accepted owner commit must rebuild the complete host replica. Retaining the + // directory replacement marker ensures its checkpoint and rows stay atomic. } return { @@ -1283,6 +1252,7 @@ export class ReplicaCache { } private dropPendingHostChanges(serverId: string): void { + this.pendingDirectoryReplacements.delete(serverId); for (const [key, row] of this.pendingUpserts) { if (row.serverId === serverId) this.pendingUpserts.delete(key); } @@ -1293,6 +1263,12 @@ export class ReplicaCache { private renamePendingHostChanges(oldServerId: string, newServerId: string): void { const changes = this.drainPendingChanges(); + for (const [serverId, desiredRows] of changes.directoryReplacements) { + this.pendingDirectoryReplacements.set( + serverId === oldServerId ? newServerId : serverId, + desiredRows, + ); + } for (const key of changes.deletes) { this.queueDelete(key.serverId === oldServerId ? { ...key, serverId: newServerId } : key); } @@ -1310,7 +1286,30 @@ export class ReplicaCache { return this.preparePromise; } - private queueOperation(operation: () => Promise): void { + private ensureStoredIndex(): Promise { + this.storedIndexPromise ??= this.rowStore.readAll().then(async (hosts) => { + this.storedRows.clear(); + this.hostBytes.clear(); + this.hostWriteOrder.clear(); + this.totalBytes = 0; + for (const host of hosts) { + if (!this.activeServerIds.has(host.serverId)) { + await this.rowStore.deleteHost(host.serverId); + continue; + } + const rows = new Map(host.rows.map((row) => [rowKey(row), row])); + const bytes = host.rows.reduce((sum, row) => sum + payloadBytes(row.payload), 0); + this.storedRows.set(host.serverId, rows); + this.hostBytes.set(host.serverId, bytes); + this.totalBytes += bytes; + this.touchHost(host.serverId); + } + return undefined; + }); + return this.storedIndexPromise; + } + + private queueOperation(operation: () => Promise): Promise { this.writeQueue = this.writeQueue .catch(() => undefined) .then(async () => { @@ -1319,6 +1318,7 @@ export class ReplicaCache { return undefined; }) .catch(() => undefined); + return this.writeQueue; } private schedulePersist(): void { @@ -1326,6 +1326,6 @@ export class ReplicaCache { this.persistTimer = setTimeout(() => { this.persistTimer = null; void this.flushPending(); - }, PERSIST_AFTER_USER_INACTIVITY_MS); + }, PERSIST_DELAY_MS); } } diff --git a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts index deedc2c912..daa8ee28ef 100644 --- a/packages/app/src/runtime/replica-cache/row-store-sqlite.ts +++ b/packages/app/src/runtime/replica-cache/row-store-sqlite.ts @@ -107,6 +107,28 @@ export function createSqliteReplicaRowStore( return Array.from(hosts, ([serverId, rows]) => ({ serverId, rows })); } + async function read( + serverId: string, + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + if (kinds.length === 0 || ids?.length === 0) return []; + const placeholders = kinds.map(() => "?").join(", "); + const idClause = ids ? ` AND id IN (${ids.map(() => "?").join(", ")})` : ""; + const storedRows = await getConnection().all( + `SELECT server_id, kind, id, payload FROM rows + WHERE server_id = ? AND kind IN (${placeholders})${idClause} + ORDER BY kind, id`, + [serverId, ...kinds, ...(ids ?? [])], + ); + return storedRows.map((row) => ({ + serverId: row.server_id, + kind: row.kind, + id: row.id, + payload: row.payload, + })); + } + async function apply(changes: ReplicaRowChanges): Promise { await getConnection().transaction(async (transaction) => { for (const key of changes.deletes) { @@ -147,5 +169,5 @@ export function createSqliteReplicaRowStore( await getConnection().run("DELETE FROM rows"); } - return { open, readAll, apply, deleteHost, renameHost, clear }; + return { open, read, readAll, apply, deleteHost, renameHost, clear }; } diff --git a/packages/app/src/runtime/replica-cache/row-store.contract.ts b/packages/app/src/runtime/replica-cache/row-store.contract.ts index ed8e57c766..2e267e812d 100644 --- a/packages/app/src/runtime/replica-cache/row-store.contract.ts +++ b/packages/app/src/runtime/replica-cache/row-store.contract.ts @@ -55,6 +55,22 @@ export function runReplicaRowStoreContract( ]); }); + it("reads only requested kinds for one host", async () => { + await store.apply({ + upserts: [ + row("server-a", "agent", "agent-1", "agent"), + row("server-a", "timeline", "singleton", "timeline"), + row("server-b", "agent", "agent-2", "other host"), + ], + deletes: [], + }); + + expect(await store.read("server-a", ["timeline"])).toEqual([ + row("server-a", "timeline", "singleton", "timeline"), + ]); + expect(await store.read("server-a", ["agent"], ["missing"])).toEqual([]); + }); + it("overwrites an existing row on upsert", async () => { await store.apply({ upserts: [row("server-a", "agent", "agent-1", "old")], diff --git a/packages/app/src/runtime/replica-cache/row-store.ts b/packages/app/src/runtime/replica-cache/row-store.ts index 7600f0c368..b9cd02d99c 100644 --- a/packages/app/src/runtime/replica-cache/row-store.ts +++ b/packages/app/src/runtime/replica-cache/row-store.ts @@ -24,6 +24,11 @@ export interface ReplicaHostRows { export interface ReplicaRowStore { open(): Promise; + read( + serverId: string, + kinds: readonly ReplicaRowKind[], + ids?: readonly string[], + ): Promise; readAll(): Promise; apply(changes: ReplicaRowChanges): Promise; deleteHost(serverId: string): Promise; diff --git a/packages/app/src/runtime/replica-cache/row-store.web.test.ts b/packages/app/src/runtime/replica-cache/row-store.web.test.ts index ef91a322cb..472143ee34 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.test.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.test.ts @@ -1,4 +1,9 @@ -import { IDBKeyRange as FakeIDBKeyRange, indexedDB as fakeIndexedDb } from "fake-indexeddb"; +import { + IDBKeyRange as FakeIDBKeyRange, + IDBObjectStore as FakeIDBObjectStore, + indexedDB as fakeIndexedDb, +} from "fake-indexeddb"; +import { expect, it, vi } from "vitest"; import { runReplicaRowStoreContract } from "./row-store.contract"; import { createIndexedDbReplicaRowStore } from "./row-store.web"; @@ -18,3 +23,29 @@ runReplicaRowStoreContract("IndexedDB", async () => { }, }; }); + +it("uses exact IndexedDB keys for targeted rows instead of scanning the host", async () => { + const store = createIndexedDbReplicaRowStore({ + databaseName: `replica-row-store-targeted-${databaseSequence++}`, + schemaVersion: 1, + }); + await store.open(); + await store.apply({ + upserts: [ + { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, + { serverId: "server-a", kind: "workspace", id: "workspace-1", payload: "workspace" }, + ], + deletes: [], + }); + const get = vi.spyOn(FakeIDBObjectStore.prototype, "get"); + const getAll = vi.spyOn(FakeIDBObjectStore.prototype, "getAll"); + + expect(await store.read("server-a", ["agent"], ["agent-1"])).toEqual([ + { serverId: "server-a", kind: "agent", id: "agent-1", payload: "agent" }, + ]); + expect(get).toHaveBeenCalledWith(["server-a", "agent", "agent-1"]); + expect(getAll).not.toHaveBeenCalled(); + + get.mockRestore(); + getAll.mockRestore(); +}); diff --git a/packages/app/src/runtime/replica-cache/row-store.web.ts b/packages/app/src/runtime/replica-cache/row-store.web.ts index c740283706..12f484bd91 100644 --- a/packages/app/src/runtime/replica-cache/row-store.web.ts +++ b/packages/app/src/runtime/replica-cache/row-store.web.ts @@ -136,6 +136,38 @@ export function createIndexedDbReplicaRowStore( return Array.from(hosts, ([serverId, hostRows]) => ({ serverId, rows: hostRows })); } + async function read( + serverId: string, + kinds: readonly ReplicaRow["kind"][], + ids?: readonly string[], + ): Promise { + if (kinds.length === 0 || ids?.length === 0) return []; + const transaction = getDatabase().transaction(ROWS_STORE, "readonly"); + const completion = transactionComplete(transaction); + const store = transaction.objectStore(ROWS_STORE); + const requests = ids + ? kinds.flatMap((kind) => + ids.map(async (id) => { + const row = await requestResult( + store.get([serverId, kind, id]), + ); + return row ? [row] : []; + }), + ) + : kinds.map((kind) => + requestResult( + store.getAll(IDBKeyRange.bound([serverId, kind], [serverId, kind, []])), + ), + ); + const rows = (await Promise.all(requests)).flat(); + await completion; + return rows.sort((left, right) => + left.kind === right.kind + ? left.id.localeCompare(right.id) + : left.kind.localeCompare(right.kind), + ); + } + async function apply(changes: ReplicaRowChanges): Promise { await runTransaction(getDatabase(), ROWS_STORE, async (transaction) => { const rows = transaction.objectStore(ROWS_STORE); @@ -175,7 +207,7 @@ export function createIndexedDbReplicaRowStore( }); } - return { open, readAll, apply, deleteHost, renameHost, clear }; + return { open, read, readAll, apply, deleteHost, renameHost, clear }; } export function createReplicaRowStore(): ReplicaRowStore { diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 7ff4917dc3..8b7036234c 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1106,7 +1106,7 @@ function renderWorkspaceContent(input: RenderWorkspaceContentInput): React.React ); } - if (!hasHydratedAgents || !hasLoadedTerminals) { + if (!activeTabDescriptor && (!hasHydratedAgents || !hasLoadedTerminals)) { return ( @@ -1545,6 +1545,12 @@ function WorkspaceScreenContent({ [workspaceId], ); const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId); + useEffect(() => { + if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return; + void getHostRuntimeStore() + .prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId) + .catch(() => undefined); + }, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]); const workspaceScripts = getWorkspaceScripts(workspaceDescriptor); const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } = useWorkspaceRouteActions(normalizedServerId); @@ -1907,6 +1913,13 @@ function WorkspaceScreenContent({ routeFocused: isRouteFocused, focusedPaneOnly: syncFocusedPaneOnly, }); + useEffect(() => { + for (const agentId of visibleAgentIds) { + void getHostRuntimeStore() + .prepareAgentTimeline(normalizedServerId, agentId) + .catch(() => undefined); + } + }, [normalizedServerId, visibleAgentIds]); useLayoutEffect(() => { if (!persistenceKey || !viewedTimelineSync) { return; diff --git a/packages/app/src/stores/session-store-hooks/selectors.test.ts b/packages/app/src/stores/session-store-hooks/selectors.test.ts index a95d2e9fc5..a79d30fe0d 100644 --- a/packages/app/src/stores/session-store-hooks/selectors.test.ts +++ b/packages/app/src/stores/session-store-hooks/selectors.test.ts @@ -135,14 +135,11 @@ afterEach(() => { describe("workspace replica authority", () => { it("publishes a restored directory while keeping remote hydration false", () => { const cachedWorkspace = createWorkspace({ id: "cached-workspace" }); - useSessionStore.getState().restoreSessionReplica(SERVER_ID, { - agents: new Map(), - workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]), - projects: new Map([ - [cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)], - ]), - timeline: null, - }); + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null); + store.setWorkspaces(SERVER_ID, new Map([[cachedWorkspace.id, cachedWorkspace]])); + store.setProjects(SERVER_ID, [projectDescriptorFromTestWorkspace(cachedWorkspace)]); + store.setHasWorkspaceDirectorySnapshot(SERVER_ID, true); const cachedSession = useSessionStore.getState().sessions[SERVER_ID]; if (!cachedSession) throw new Error("expected initialized session"); diff --git a/packages/app/src/stores/session-store.test.ts b/packages/app/src/stores/session-store.test.ts index 201dddaf46..3475f4779c 100644 --- a/packages/app/src/stores/session-store.test.ts +++ b/packages/app/src/stores/session-store.test.ts @@ -11,7 +11,6 @@ import { type WorkspaceDescriptor, } from "./session-store"; import type { StreamItem } from "../types/stream"; -import { patchWorkspaceScripts } from "../contexts/session-workspace-scripts"; function createWorkspace( input: Partial & Pick, @@ -750,29 +749,3 @@ describe("removeWorkspace", () => { expect(after.workspaces).toBe(before.workspaces); }); }); - -describe("patchWorkspaceScripts", () => { - it("preserves workspace entry identity when scripts are content-equal", () => { - const script = { - scriptName: "web", - type: "service" as const, - hostname: "web.paseo.localhost", - port: 3000, - proxyUrl: "http://web.paseo.localhost:6767", - lifecycle: "running" as const, - health: "healthy" as const, - exitCode: null, - terminalId: null, - }; - const workspace = createWorkspace({ id: "/repo/main", scripts: [script] }); - const current = new Map([[workspace.id, workspace]]); - - const next = patchWorkspaceScripts(current, { - workspaceId: workspace.id, - scripts: [{ ...script }], - }); - - expect(next).toBe(current); - expect(next.get(workspace.id)).toBe(workspace); - }); -}); diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index d408525c15..c2328a5b62 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -298,20 +298,6 @@ export interface AgentTimelineCursorState { }>; } -export interface SessionReplicaTimeline { - agentId: string; - items: StreamItem[]; - range: AgentTimelineCursorState | null; - hasOlder: boolean; -} - -export interface SessionReplica { - agents: Map; - workspaces: Map; - projects: Map; - timeline: SessionReplicaTimeline | null; -} - export type AgentTimelineState = | { status: "cold" } | { status: "painted"; items: StreamItem[] } @@ -454,7 +440,6 @@ interface SessionStoreActions { client: DaemonClient | null, clientGeneration?: number, ) => void; - restoreSessionReplica: (serverId: string, replica: SessionReplica) => void; clearSession: (serverId: string) => void; getSession: (serverId: string) => SessionState | undefined; updateSessionClient: (serverId: string, client: DaemonClient, clientGeneration?: number) => void; @@ -628,6 +613,7 @@ interface SessionStoreActions { // Hydration setHasHydratedAgents: (serverId: string, hydrated: boolean) => void; setHasHydratedWorkspaces: (serverId: string, hydrated: boolean) => void; + setHasWorkspaceDirectorySnapshot: (serverId: string, available: boolean) => void; // Agent directory (derived from agents) getAgentDirectory: (serverId: string) => AgentDirectoryEntry[] | undefined; @@ -770,58 +756,6 @@ export const useSessionStore = create()( }); }, - restoreSessionReplica: (serverId, replica) => { - set((prev) => { - if (prev.sessions[serverId]) { - return prev; - } - const session = createInitialSessionState(serverId, null); - const timeline = replica.timeline; - const agentStreamTail = new Map(); - const agentTasks = new Map(); - if (timeline) { - agentStreamTail.set(timeline.agentId, timeline.items); - const tasks = latestTasksFromStream(timeline.items); - if (tasks.length > 0) agentTasks.set(timeline.agentId, tasks); - } - const agentTimelineCursor = new Map(); - const agentTimelineHasOlder = new Map(); - const agentTimelineHasNewer = new Map(); - const agentAuthoritativeHistoryApplied = new Map(); - if (timeline?.range) { - agentTimelineCursor.set(timeline.agentId, timeline.range); - agentTimelineHasOlder.set(timeline.agentId, timeline.hasOlder); - agentTimelineHasNewer.set(timeline.agentId, false); - agentAuthoritativeHistoryApplied.set(timeline.agentId, true); - } - const agentLastActivity = new Map(prev.agentLastActivity); - for (const agent of replica.agents.values()) { - agentLastActivity.set(agent.id, agent.lastActivityAt); - } - return { - ...prev, - sessions: { - ...prev.sessions, - [serverId]: { - ...session, - agents: replica.agents, - workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents), - workspaces: replica.workspaces, - projects: replica.projects, - hasWorkspaceDirectorySnapshot: true, - agentStreamTail, - agentTasks, - agentTimelineCursor, - agentTimelineHasOlder, - agentTimelineHasNewer, - agentAuthoritativeHistoryApplied, - }, - }, - agentLastActivity, - }; - }); - }, - clearSession: (serverId) => { agentLastActivityCoalescer.flushNow(); set((prev) => { @@ -1959,6 +1893,20 @@ export const useSessionStore = create()( }); }, + setHasWorkspaceDirectorySnapshot: (serverId, available) => { + set((prev) => { + const session = prev.sessions[serverId]; + if (!session || session.hasWorkspaceDirectorySnapshot === available) return prev; + return { + ...prev, + sessions: { + ...prev.sessions, + [serverId]: { ...session, hasWorkspaceDirectorySnapshot: available }, + }, + }; + }); + }, + // Agent directory - derived from agents (computed on-demand) getAgentDirectory: (serverId) => { const state = get(); diff --git a/packages/app/src/timeline/replica.test.ts b/packages/app/src/timeline/replica.test.ts new file mode 100644 index 0000000000..63f52f5668 --- /dev/null +++ b/packages/app/src/timeline/replica.test.ts @@ -0,0 +1,230 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages"; +import type { CachedTimeline } from "@/runtime/replica-cache"; +import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store"; +import type { StreamItem } from "@/types/stream"; +import { + createTimelineReplica, + createViewedTimelineOwner, + type TimelineReplicaStorage, + type ViewedTimelineOwner, +} from "./viewed-timeline-sync"; + +const SERVER_ID = "timeline-replica-host"; +const AGENT_ID = "agent-1"; + +function item(id: string, text: string, seq: number): StreamItem { + return { + kind: "assistant_message", + id, + text, + timestamp: new Date("2026-08-26T10:00:00.000Z"), + timelineCursor: { epoch: "epoch-1", seq }, + }; +} + +function cachedTimeline(): CachedTimeline { + return { + agentId: AGENT_ID, + items: [item("cached", "cached", 4)], + range: { epoch: "epoch-1", startSeq: 1, endSeq: 4 }, + hasOlder: true, + }; +} + +function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner { + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage, + prepareAgent: async () => undefined, + }); + return createViewedTimelineOwner({ + serverId: SERVER_ID, + replica, + replaceDemandedAgentIds: () => undefined, + drainQueuedAgentMessage: () => undefined, + ports: { + initialDeliveryMode: "legacy", + setSubscription: async () => undefined, + readCursor: () => undefined, + fetchPage: async () => ({ hasNewer: false, endCursor: null }), + fetchLatestTail: async () => ({ hasNewer: false, endCursor: null }), + reportError: () => undefined, + schedule: () => () => undefined, + }, + }); +} + +function applySynced(agentId: string, seq: number): void { + useSessionStore.getState().applyAgentTimelineResponseState(SERVER_ID, agentId, { + items: [item(`network-${agentId}`, "network", seq)], + head: [], + range: { epoch: "epoch-1", startSeq: 1, endSeq: seq }, + older: "available", + newer: false, + synchronized: true, + acknowledgedClientMessageIds: [], + }); +} + +afterEach(() => useSessionStore.getState().clearSession(SERVER_ID)); + +describe("viewed timeline persistence", () => { + it("shares an in-flight cache preparation with the viewed owner", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + let reads = 0; + const read = new Promise((resolve) => { + release = resolve; + }); + const replica = createTimelineReplica({ + serverId: SERVER_ID, + storage: { + readTimeline: () => { + reads += 1; + return read; + }, + commitTimeline: () => undefined, + }, + prepareAgent: async () => undefined, + }); + + const routePreparation = replica.prepare(AGENT_ID); + const ownerPreparation = replica.prepare(AGENT_ID); + release(cachedTimeline()); + await Promise.all([routePreparation, ownerPreparation]); + + expect(reads).toBe(1); + expect(replica.readCursor(AGENT_ID)).toEqual({ epoch: "epoch-1", endSeq: 4 }); + }); + + it("paints cached history without claiming authoritative synchronization", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const owner = createOwner({ + readTimeline: async () => cachedTimeline(), + commitTimeline: () => undefined, + }); + + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toEqual({ status: "painted", items: cachedTimeline().items }); + owner.dispose(); + }); + + it("does not let a late cache read overwrite newer network state", async () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + let release!: (value: CachedTimeline) => void; + const read = new Promise((resolve) => { + release = resolve; + }); + const owner = createOwner({ + readTimeline: () => read, + commitTimeline: () => undefined, + }); + + owner.replaceVisibleAgentIds("test", [AGENT_ID]); + applySynced(AGENT_ID, 8); + release(cachedTimeline()); + + await expect + .poll(() => + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ) + .toMatchObject({ status: "synced", range: { endSeq: 8 } }); + owner.dispose(); + }); + + it("persists accepted live stream commits through the owner", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + applySynced(AGENT_ID, 8); + const commits: CachedTimeline[] = []; + const owner = createOwner({ + readTimeline: async () => undefined, + commitTimeline: (_serverId, _agentId, timeline) => commits.push(timeline), + }); + owner.enqueueStreamEvent(AGENT_ID, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: "live", messageId: "live" }, + } as AgentStreamEventPayload, + seq: 9, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + owner.flushStreamAgent(AGENT_ID); + + expect(commits.at(-1)?.items.at(-1)).toMatchObject({ text: "live" }); + expect(commits.at(-1)?.range?.endSeq).toBe(9); + owner.dispose(); + }); + + it("applies and persists authoritative pages inside the owner", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const keys: string[] = []; + const owner = createOwner({ + readTimeline: async () => undefined, + commitTimeline: (_serverId, agentId) => keys.push(agentId), + }); + + owner.applyTimelineResponse({ + requestId: "page-1", + agentId: AGENT_ID, + agent: null, + direction: "tail", + projection: "projected", + reset: false, + epoch: "epoch-1", + window: { minSeq: 1, maxSeq: 0, nextSeq: 1 }, + startCursor: null, + endCursor: null, + entries: [], + error: null, + hasNewer: false, + hasOlder: false, + staleCursor: false, + gap: false, + }); + + expect( + selectAgentTimelineState(useSessionStore.getState().sessions[SERVER_ID], AGENT_ID), + ).toMatchObject({ status: "synced" }); + expect(keys).toEqual([AGENT_ID]); + owner.dispose(); + }); + + it("persists demanded agents independently", () => { + useSessionStore.getState().initializeSession(SERVER_ID, null); + const keys: string[] = []; + const owner = createOwner({ + readTimeline: async () => undefined, + commitTimeline: (_serverId, agentId) => keys.push(agentId), + }); + + applySynced(AGENT_ID, 8); + applySynced("agent-2", 3); + for (const [agentId, seq] of [ + [AGENT_ID, 9], + ["agent-2", 4], + ] as const) { + owner.enqueueStreamEvent(agentId, { + event: { + type: "timeline", + provider: "codex", + item: { type: "assistant_message", text: agentId, messageId: `live-${agentId}` }, + } as AgentStreamEventPayload, + seq, + epoch: "epoch-1", + timestamp: new Date("2026-08-26T10:00:01.000Z"), + }); + owner.flushStreamAgent(agentId); + } + + expect(keys).toEqual([AGENT_ID, "agent-2"]); + owner.dispose(); + }); +}); diff --git a/packages/app/src/timeline/session-stream-reducers.ts b/packages/app/src/timeline/session-stream-reducers.ts index 70e005ee26..bd4c8b1db5 100644 --- a/packages/app/src/timeline/session-stream-reducers.ts +++ b/packages/app/src/timeline/session-stream-reducers.ts @@ -1833,6 +1833,7 @@ export interface CreateSessionAgentStreamReducerQueueInput { state: (prev: Map) => Map, ) => void; recoverTimelineGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + onCommitted?: (agentId: string) => void; } interface ScheduledReducerFlush { @@ -1886,7 +1887,8 @@ function cancelAgentStreamReducerFlush(id: number) { export function createSessionAgentStreamReducerQueue( input: CreateSessionAgentStreamReducerQueueInput, ): AgentStreamReducerQueue { - const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap } = input; + const { serverId, setAgentStreamState, setAgentTimelineCursor, recoverTimelineGap, onCommitted } = + input; return createAgentStreamReducerQueue({ getSnapshot: (agentId) => { @@ -1945,6 +1947,7 @@ export function createSessionAgentStreamReducerQueue( return next; }); } + onCommitted?.(agentId); }, handleSideEffects: (agentId, sideEffects) => { for (const effect of sideEffects) { diff --git a/packages/app/src/timeline/viewed-timeline-sync.test.ts b/packages/app/src/timeline/viewed-timeline-sync.test.ts index 2b189e7faf..2c9b65dd7a 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.test.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.test.ts @@ -34,7 +34,15 @@ interface TimelineFetch { class TimelineWorld { readonly errors: string[] = []; readonly cursors = new Map(); + readonly cacheRequests: string[] = []; + cacheGate: Deferred | null = null; readonly sync = createViewedTimelineSync({ + replaceDemandedAgentIds: () => undefined, + prepare: async (agentId) => { + this.cacheRequests.push(agentId); + this.cacheRequestWaiters.shift()?.(agentId); + await this.cacheGate?.promise; + }, initialDeliveryMode: "selective", setSubscription: async (agentIds) => { const result = deferred(); @@ -100,12 +108,23 @@ class TimelineWorld { resolve(fetch: TimelineFetch): void; }> = []; private readonly errorWaiters: Array<(message: string) => void> = []; + private readonly cacheRequestWaiters: Array<(agentId: string) => void> = []; private readonly scheduled: Array<{ task: () => void; delayMs: number }> = []; private readonly retryWaiters: Array<{ delayMs: number; resolve(retry: () => void): void; }> = []; + get pendingFetchCount(): number { + return this.fetches.length; + } + + nextCacheRequest(): Promise { + const request = this.cacheRequests.at(-1); + if (request) return Promise.resolve(request); + return new Promise((resolve) => this.cacheRequestWaiters.push(resolve)); + } + private fetchTimeline( agentId: string, request: ProjectedTimelineForwardFetchPlan, @@ -197,6 +216,31 @@ test("uses a tail fetch when an agent becomes visible", async () => { fetch.respond({ hasNewer: false }); }); +test("loads the cache before choosing the authoritative network request", async () => { + const world = new TimelineWorld(); + world.cacheGate = deferred(); + world.sync.setConnected(true); + world.sync.replaceVisibleAgentIds("workspace", ["agent-a"]); + const membership = await world.nextMembership(); + membership.succeed(); + const cacheRequest = await world.nextCacheRequest(); + + expect(cacheRequest).toBe("agent-a"); + expect(world.pendingFetchCount).toBe(0); + + world.cursors.set("agent-a", { epoch: "cached-epoch", endSeq: 17 }); + world.cacheGate.resolve(); + const fetch = await world.nextFetch("agent-a"); + + expect(fetch.request).toEqual({ + direction: "after", + cursor: { epoch: "cached-epoch", seq: 17 }, + limit: 40, + projection: "projected", + }); + fetch.respond({ hasNewer: false }); +}); + test("catches up after the restored cursor when an agent becomes visible", async () => { const world = new TimelineWorld(); world.cursors.set("agent-a", { epoch: "epoch-agent-a", endSeq: 42 }); diff --git a/packages/app/src/timeline/viewed-timeline-sync.ts b/packages/app/src/timeline/viewed-timeline-sync.ts index cf90918c11..1cf2a1b269 100644 --- a/packages/app/src/timeline/viewed-timeline-sync.ts +++ b/packages/app/src/timeline/viewed-timeline-sync.ts @@ -3,14 +3,277 @@ import { planTimelineResumeFetch, type ProjectedTimelineForwardFetchPlan, } from "./timeline-sync-plan"; +import type { CachedTimeline } from "@/runtime/replica-cache"; +import { + selectAgentTimelineState, + useSessionStore, + type AgentTimelineCursorState, + type AgentTimelineState, +} from "@/stores/session-store"; +import { useCreateFlowStore } from "@/stores/create-flow-store"; +import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import { getSendingClientMessageIds } from "@/composer/submission/model"; +import { + getInitDeferred, + getInitKey, + rejectInitDeferred, + resolveInitDeferred, +} from "@/utils/agent-initialization"; +import { + createSessionAgentStreamReducerQueue, + type AgentStreamReducerEvent, + type ProcessTimelineResponseOutput, + processTimelineResponse, +} from "./session-stream-reducers"; +import { isTimelineResumeSnapshotAuthoritative } from "./timeline-sync-plan"; -interface TimelinePageResult { +export interface TimelineReplicaStorage { + readTimeline(serverId: string, agentId: string): Promise; + commitTimeline(serverId: string, agentId: string, timeline: CachedTimeline): void; +} + +async function prepareCachedTimeline(input: { + serverId: string; + agentId: string; + storage: TimelineReplicaStorage; + prepareAgent: (agentId: string) => Promise; +}): Promise { + const before = useSessionStore.getState().sessions[input.serverId]; + const beforeTimeline = selectAgentTimelineState(before, input.agentId); + if (beforeTimeline.status === "synced") return undefined; + const beforeHead = before?.agentStreamHead.get(input.agentId); + await input.prepareAgent(input.agentId); + const stored = await input.storage.readTimeline(input.serverId, input.agentId); + if (!stored) return undefined; + const session = useSessionStore.getState().sessions[input.serverId]; + const currentTimeline = selectAgentTimelineState(session, input.agentId); + if (session?.agentStreamHead.get(input.agentId) !== beforeHead) return undefined; + if (beforeTimeline.status === "painted") { + return currentTimeline.status === "painted" && currentTimeline.items === beforeTimeline.items + ? stored + : undefined; + } + if (currentTimeline.status !== "cold") return undefined; + useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, { + items: stored.items, + head: [], + range: stored.range, + older: stored.hasOlder ? "available" : "none", + newer: false, + synchronized: false, + acknowledgedClientMessageIds: [], + }); + return stored; +} + +export interface TimelineReplica { + prepare(agentId: string): Promise; + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; + timelineUpdated(agentId: string): void; +} + +class TimelineReplicaOwner implements TimelineReplica { + private readonly cachedCursors = new Map(); + private readonly preparations = new Map>(); + + constructor( + private readonly serverId: string, + private readonly storage: TimelineReplicaStorage, + private readonly prepareAgent: (agentId: string) => Promise, + ) {} + + async prepare(agentId: string): Promise { + const existing = this.preparations.get(agentId); + if (existing) return existing; + const preparation = this.load(agentId).finally(() => { + if (this.preparations.get(agentId) === preparation) { + this.preparations.delete(agentId); + } + }); + this.preparations.set(agentId, preparation); + return preparation; + } + + private async load(agentId: string): Promise { + const stored = await prepareCachedTimeline({ + serverId: this.serverId, + agentId, + storage: this.storage, + prepareAgent: this.prepareAgent, + }); + if (!stored) return; + if (stored.range) { + this.cachedCursors.set(agentId, { + epoch: stored.range.epoch, + endSeq: stored.range.endSeq, + }); + } + } + + readCursor(agentId: string): { epoch: string; endSeq: number } | undefined { + return this.cachedCursors.get(agentId); + } + + timelineUpdated(agentId: string): void { + const session = useSessionStore.getState().sessions[this.serverId]; + const timeline = selectAgentTimelineState(session, agentId); + if (timeline.status !== "synced") return; + this.cachedCursors.delete(agentId); + this.storage.commitTimeline(this.serverId, agentId, { + agentId, + items: [...timeline.items, ...(session?.agentStreamHead.get(agentId) ?? [])], + range: timeline.range, + hasOlder: timeline.older === "available", + }); + } +} + +export function createTimelineReplica(input: { + serverId: string; + storage: TimelineReplicaStorage; + prepareAgent: (agentId: string) => Promise; +}): TimelineReplica { + return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent); +} + +export interface TimelinePageResult { hasNewer: boolean; endCursor: { epoch: string; seq: number } | null; } -interface ViewedTimelineSyncPorts { +export type TimelineResponsePayload = Extract< + SessionOutboundMessage, + { type: "fetch_agent_timeline_response" } +>["payload"]; + +function clearAgentInitializingFlag(serverId: string, agentId: string): void { + useSessionStore.getState().setInitializingAgents(serverId, (previous) => { + if (previous.get(agentId) !== true) return previous; + const next = new Map(previous); + next.set(agentId, false); + return next; + }); +} + +function commitProcessedTimeline(input: { + serverId: string; + payload: TimelineResponsePayload; + result: ProcessTimelineResponseOutput; + timeline: AgentTimelineState; + currentCursor: AgentTimelineCursorState | undefined; + synchronized: boolean; +}): void { + const { serverId, payload, result, timeline, currentCursor, synchronized } = input; + const agentId = payload.agentId; + const store = useSessionStore.getState(); + if (result.commit !== "discard") { + store.applyAgentTimelineResponseState(serverId, agentId, { + items: result.tail, + head: result.head, + range: result.cursorChanged ? (result.cursor ?? null) : (currentCursor ?? null), + older: result.older, + newer: + payload.direction === "before" + ? timeline.status === "synced" && timeline.newer === "available" + : payload.hasNewer, + synchronized, + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + return; + } + if (result.acknowledgedClientMessageIds.length > 0) { + store.setAgentStreamState(serverId, agentId, { + acknowledgedClientMessageIds: result.acknowledgedClientMessageIds, + }); + } + if (payload.direction !== "before") { + store.setAgentTimelineHasNewer(serverId, (current) => { + const next = new Map(current); + next.set(agentId, payload.hasNewer); + return next; + }); + } + store.markAgentHistorySynchronized(serverId, agentId); +} + +function finalizeProcessedTimeline(input: { + serverId: string; + agentId: string; + initKey: string; + result: ProcessTimelineResponseOutput; + synchronized: boolean; + recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + drainQueuedAgentMessage: (agentId: string) => void; +}): void { + for (const effect of input.result.sideEffects) { + if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor); + } + if (input.result.clearInitializing) { + clearAgentInitializingFlag(input.serverId, input.agentId); + } + if (input.synchronized) { + useCreateFlowStore + .getState() + .clearByAgent({ serverId: input.serverId, agentId: input.agentId }); + const session = useSessionStore.getState().sessions[input.serverId]; + const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId); + if (agent && agent.status !== "running") input.drainQueuedAgentMessage(input.agentId); + } + if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey); +} + +function applyAuthoritativeTimelineResponse(input: { + serverId: string; + payload: TimelineResponsePayload; + recoverGap: (agentId: string, cursor: { epoch: string; endSeq: number }) => void; + drainQueuedAgentMessage: (agentId: string) => void; +}): boolean { + const { serverId, payload } = input; + const agentId = payload.agentId; + const initKey = getInitKey(serverId, agentId); + const session = useSessionStore.getState().sessions[serverId]; + const timeline = selectAgentTimelineState(session, agentId); + const activeInitDeferred = getInitDeferred(initKey); + const currentCursor = timeline.status === "synced" ? (timeline.range ?? undefined) : undefined; + const result = processTimelineResponse({ + payload, + currentTail: timeline.status === "cold" ? [] : timeline.items, + currentHead: session?.agentStreamHead.get(agentId) ?? [], + currentCursor, + isInitializing: session?.initializingAgents.get(agentId) === true, + hasActiveInitDeferred: Boolean(activeInitDeferred), + initRequestDirection: activeInitDeferred?.requestDirection ?? "tail", + sendingClientMessageIds: getSendingClientMessageIds(session?.messageSubmissions.get(agentId)), + }); + + if (result.error) { + if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId); + if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error)); + return false; + } + + const synchronized = isTimelineResumeSnapshotAuthoritative({ + direction: payload.direction, + hasNewer: payload.hasNewer, + error: payload.error, + }); + commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized }); + finalizeProcessedTimeline({ + serverId, + agentId, + initKey, + result, + synchronized, + recoverGap: input.recoverGap, + drainQueuedAgentMessage: input.drainQueuedAgentMessage, + }); + return true; +} + +export interface ViewedTimelineSyncPorts { initialDeliveryMode: TimelineDeliveryMode; + prepare(agentId: string): Promise; + replaceDemandedAgentIds(agentIds: string[]): void; setSubscription(agentIds: string[]): Promise; readCursor(agentId: string): { epoch: string; endSeq: number } | undefined; fetchPage( @@ -29,6 +292,7 @@ export interface ViewedTimelineUiBridge { replaceVisibleAgentIds(sourceId: string, agentIds: string[]): void; subscribe(listener: () => void): () => void; getAgentTimelineStatus(agentId: string): ViewedTimelineStatus; + getAgentTimelineError(agentId: string): string | null; retryVisibleAgentTimeline(agentId: string): void; } @@ -40,6 +304,61 @@ export interface ViewedTimelineSync extends ViewedTimelineUiBridge { dispose(): void; } +export type ViewedTimelineOwnerPorts = Omit< + ViewedTimelineSyncPorts, + "prepare" | "replaceDemandedAgentIds" +>; + +export interface ViewedTimelineOwner extends ViewedTimelineSync { + applyTimelineResponse(payload: TimelineResponsePayload): void; + enqueueStreamEvent(agentId: string, event: AgentStreamReducerEvent): void; + flushStreamAgent(agentId: string): void; +} + +export function createViewedTimelineOwner(input: { + serverId: string; + replica: TimelineReplica; + replaceDemandedAgentIds: (agentIds: string[]) => void; + drainQueuedAgentMessage: (agentId: string) => void; + ports: ViewedTimelineOwnerPorts; +}): ViewedTimelineOwner { + const sync = createViewedTimelineSync({ + ...input.ports, + prepare: (agentId) => input.replica.prepare(agentId), + readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId), + replaceDemandedAgentIds: input.replaceDemandedAgentIds, + }); + const streamQueue = createSessionAgentStreamReducerQueue({ + serverId: input.serverId, + setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args), + setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args), + recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), + onCommitted: (agentId) => input.replica.timelineUpdated(agentId), + }); + return { + ...sync, + applyTimelineResponse(payload) { + const accepted = applyAuthoritativeTimelineResponse({ + serverId: input.serverId, + payload, + recoverGap: (agentId, cursor) => sync.recoverGap(agentId, cursor), + drainQueuedAgentMessage: input.drainQueuedAgentMessage, + }); + if (accepted) input.replica.timelineUpdated(payload.agentId); + }, + enqueueStreamEvent(agentId, event) { + streamQueue.enqueue(agentId, event); + }, + flushStreamAgent(agentId) { + streamQueue.flushAgent(agentId); + }, + dispose() { + streamQueue.dispose({ flush: true }); + sync.dispose(); + }, + }; +} + const RETRY_DELAY_MS = 1_000; const MAX_RETRY_DELAY_MS = 30_000; const VIEWED_TIMELINE_HOT_AGENT_LIMIT = 5; @@ -111,10 +430,12 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed // Acknowledgement and tail completion are the only drain points. const pendingCatchUps = new Map(); const visibilityCatchUpPending = new Set(); - const visibilityCatchUpErrors = new Set(); + const visibilityCatchUpErrors = new Map(); // User-initiated retries only. Background retries stay silent; a retry the user asked for // owes them a pending state until it settles. const manualRetries = new Set(); + const loadedCache = new Set(); + const cacheLoads = new Map>(); const listeners = new Set<() => void>(); let active = true; let connected = false; @@ -170,13 +491,16 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (wasPending || hadError || wasRetrying) notifyListeners(); }; - const setVisibilityCatchUpError = (agentIds: string[]) => { + const setVisibilityCatchUpError = (agentIds: string[], error: unknown) => { + const message = error instanceof Error ? error.message : String(error); let changed = false; for (const agentId of agentIds) { if (manualRetries.delete(agentId)) changed = true; - if (!visibilityCatchUpPending.delete(agentId)) continue; - visibilityCatchUpErrors.add(agentId); - changed = true; + if (visibilityCatchUpPending.delete(agentId)) changed = true; + if (visibilityCatchUpErrors.get(agentId) !== message) { + visibilityCatchUpErrors.set(agentId, message); + changed = true; + } } if (changed) notifyListeners(); }; @@ -239,12 +563,26 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed cancelRetry, retryDelayMs: nextRetryDelayMs, }); - setVisibilityCatchUpError([agentId]); + setVisibilityCatchUpError([agentId], error); ports.reportError(error); } } }; + const ensureCacheLoaded = (agentId: string): void => { + if (loadedCache.has(agentId) || cacheLoads.has(agentId)) return; + const load = ports + .prepare(agentId) + .catch((error) => ports.reportError(error)) + .finally(() => { + cacheLoads.delete(agentId); + loadedCache.add(agentId); + const pending = pendingCatchUps.get(agentId); + startCatchUp(agentId, { request: pending, supersede: Boolean(pending) }); + }); + cacheLoads.set(agentId, load); + }; + const startCatchUp = ( agentId: string, options: { @@ -257,6 +595,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (request) pendingCatchUps.set(agentId, request); return; } + if (!loadedCache.has(agentId)) { + if (request) pendingCatchUps.set(agentId, request); + ensureCacheLoaded(agentId); + return; + } const nextRequest = request ?? planTimelineResumeFetch(ports.readCursor(agentId)); const current = catchUps.get(agentId); const decision = decideCatchUp({ current, request: nextRequest, supersede }); @@ -307,7 +650,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed await ports.setSubscription(requested); } catch (error) { membershipNeedsRetry = true; - setVisibilityCatchUpError(requested); + setVisibilityCatchUpError(requested, error); cancelMembershipRetry?.(); const nextRetryDelayMs = getNextRetryDelayMs(membershipRetryDelayMs); cancelMembershipRetry = ports.schedule(() => { @@ -417,11 +760,13 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed visibilityCatchUpPending.add(agentId); visibilityCatchUpErrors.delete(agentId); manualRetries.delete(agentId); + ensureCacheLoaded(agentId); } } cancelMembershipRetry?.(); cancelMembershipRetry = null; desired = nextDesired; + ports.replaceDemandedAgentIds(desired); membershipGeneration += 1; notifyListeners(); if (deliveryMode === "legacy") { @@ -455,6 +800,9 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed if (!isDesired(agentId) || visibilityCatchUpPending.has(agentId)) return "pending"; return "ready"; }, + getAgentTimelineError(agentId) { + return visibilityCatchUpErrors.get(agentId) ?? null; + }, replaceVisibleAgentIds(sourceId, agentIds) { const normalized = normalizeAgentIds(agentIds); if (normalized.length === 0) sources.delete(sourceId); @@ -501,6 +849,7 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed const visible = active ? visibleAgentIds() : []; recentlyViewedAgentIds = visible; desired = visible; + ports.replaceDemandedAgentIds(desired); visibilityCatchUpPending.clear(); visibilityCatchUpErrors.clear(); manualRetries.clear(); @@ -527,8 +876,11 @@ export function createViewedTimelineSync(ports: ViewedTimelineSyncPorts): Viewed membershipGeneration += 1; for (const agentId of desired) cancelCatchUp(agentId); desired = []; + ports.replaceDemandedAgentIds([]); acknowledged = []; recentlyViewedAgentIds = []; + loadedCache.clear(); + cacheLoads.clear(); visibilityCatchUpPending.clear(); visibilityCatchUpErrors.clear(); manualRetries.clear();