Restore host directories instantly before reconnect (#3259)

* feat(sync): restore host directories before reconnect

Persist complete project, workspace, and active-agent replicas so every registered host can paint immediately, including offline hosts. Reconcile those replicas through monotonic per-entity cursors on the existing directory RPCs without retaining an event journal.

* fix(sync): preserve active turn identity during catch-up

Own Agent-to-wire projection beside the existing snapshot normalizer so replica persistence does not reach through directory-sync internals. This also keeps identified active turns intact when unchanged agents are folded into an incremental directory response.

* test(e2e): inspect the real IndexedDB replica

Keep cache setup, reload, and hydration coverage on the production browser storage backend after the replica moved off localStorage. Measure writes at the actual IndexedDB put boundary as well.

* fix warm reconnect cache paths

* feat(sync): persist project icons and deepen directory sync

* fix(sync): preserve project update order

* fix(cache): preserve validated replica persistence

* fix(sync): retain cached agents during catch-up
This commit is contained in:
Mohamed Boudra
2026-08-13 23:10:44 +02:00
committed by GitHub
parent 599846650c
commit 373a98c64d
51 changed files with 2428 additions and 400 deletions
+16 -7
View File
@@ -69,6 +69,7 @@ not retain non-Git directories.
| `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay | | `server/bootstrap.ts` | Daemon initialization: HTTP server, WS server, agent manager, storage, relay |
| `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing | | `server/websocket-server.ts` | WebSocket connection management, hello handshake, binary frame routing |
| `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations | | `server/session.ts` | Per-client session state, timeline subscriptions, terminal operations |
| `server/directory-sync/` | Daemon-global latest-state sequences for projects, workspaces, and agents |
| `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management | | `server/agent/agent-manager.ts` | Agent lifecycle state machine, timeline tracking, subscriber management |
| `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` | | `server/agent/agent-storage.ts` | File-backed JSON persistence at `$PASEO_HOME/agents/` |
| `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation | | `server/agent/tools/` | Transport-neutral catalog for workspaces, agents, permissions, and automation |
@@ -97,19 +98,27 @@ 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. - 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 - `HostRuntimeController` manages saved host connections, reconnection, and per-host runtime state
- `runtime/replica-cache` keeps a non-authoritative per-host display replica in AsyncStorage: only the last focused agent, its workspace, and a short timeline tail. It restores before navigation becomes ready, leaves remote hydration flags false, and is atomically replaced by the normal snapshot-plus-delta synchronization path. - `runtime/replica-cache` keeps the complete project, workspace, and active-agent directory plus one short focused timeline tail in AsyncStorage. 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.
- `SessionContext` wraps the daemon client for the active session - `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/` - 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/`
- Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication - Timeline reducers in `timeline/session-stream-reducers.ts` handle compaction, gap detection, sequence-based deduplication
- 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. - 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) - Voice features: dictation (STT) and voice agent (realtime)
The replica cache exists only to paint stale data immediately while the host connects. It does not The replica cache paints stale data immediately while the host connects. Directory cursors are
own mutations, infer deletions, or replace daemon reconciliation. Pending permission requests are reconciliation checkpoints; cached entities remain non-authoritative until the daemon answers.
not restored from it. AsyncStorage is not encrypted, so the cached timeline tail may contain source Pending permission requests are not restored from it. AsyncStorage is not encrypted, so the cached
code, prompts, and tool output; encrypted-at-rest storage is a separate product/security decision. timeline tail may contain source code, prompts, and tool output; encrypted-at-rest storage is a
Its serialized payload has a 1 MiB byte budget and evicts whole host snapshots in least-recently- separate product/security decision. Its serialized payload has a 32 MiB byte budget and evicts whole
written order; a single oversized host is omitted rather than partially restored. host snapshots in least-recently-written order; a single oversized host is omitted rather than
partially restored. Browser and Electron builds store it in IndexedDB. Native builds use
AsyncStorage, and Android reserves 64 MiB for that database.
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
event log. A missing, expired, or previous-generation cursor receives a full snapshot. Projects are
independent records; a project with no workspaces does not need a workspace placeholder.
### `packages/cli` — Command-line client ### `packages/cli` — Command-line client
+1 -1
View File
@@ -139,7 +139,7 @@ export default {
}, },
plugins: [ plugins: [
"expo-router", "expo-router",
[withAndroidAsyncStorageSize, 10], [withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins, ...buildProfile.cameraPlugins,
[ [
"expo-splash-screen", "expo-splash-screen",
@@ -10,6 +10,7 @@ import {
fillComposerDraft, fillComposerDraft,
submitMessage, submitMessage,
} from "../support/helpers/composer"; } from "../support/helpers/composer";
import { readReplicaCache, writeReplicaCache } from "../support/helpers/replica-cache-storage";
// UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`. // UI plumbing contract against the dev mock provider. Real-provider behavior is tested in `daemon-e2e/*-rewind.real.e2e.test.ts`.
@@ -27,39 +28,28 @@ async function expectUserMessageVisible(page: Page, text: string): Promise<void>
async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> { async function rewriteCachedMessageAsLegacyRow(page: Page, prompt: string): Promise<void> {
await expect await expect
.poll(() => .poll(async () => {
page.evaluate((messageText) => { const cache = await readReplicaCache(page);
const raw = localStorage.getItem("@paseo:replica-cache"); if (!cache) return false;
if (!raw) return false; for (const host of cache.hosts ?? []) {
const cache = JSON.parse(raw) as { for (const item of host.timeline?.items ?? []) {
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>; if (item.kind === "user_message" && item.text === prompt && item.messageId) {
}; return true;
for (const host of cache.hosts ?? []) {
for (const item of host.timeline?.items ?? []) {
if (item.kind === "user_message" && item.text === messageText && item.messageId) {
return true;
}
} }
} }
return false; }
}, prompt), return false;
) })
.toBe(true); .toBe(true);
await page.evaluate((messageText) => { const cache = await readReplicaCache(page);
const key = "@paseo:replica-cache"; if (!cache) throw new Error("Replica cache was not persisted");
const raw = localStorage.getItem(key); const cachedMessage = cache.hosts
if (!raw) throw new Error("Replica cache was not persisted"); ?.flatMap((host) => host.timeline?.items ?? [])
const cache = JSON.parse(raw) as { .find((item) => item.kind === "user_message" && item.text === prompt);
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>; if (!cachedMessage) throw new Error("Cached user message was not found");
}; delete cachedMessage.messageId;
const cachedMessage = cache.hosts await writeReplicaCache(page, cache);
?.flatMap((host) => host.timeline?.items ?? [])
.find((item) => item.kind === "user_message" && item.text === messageText);
if (!cachedMessage) throw new Error("Cached user message was not found");
delete cachedMessage.messageId;
localStorage.setItem(key, JSON.stringify(cache));
}, prompt);
} }
async function waitForCurrentSubmissionExcludedFromCache( async function waitForCurrentSubmissionExcludedFromCache(
@@ -67,46 +57,34 @@ async function waitForCurrentSubmissionExcludedFromCache(
prompt: string, prompt: string,
): Promise<void> { ): Promise<void> {
await expect await expect
.poll(() => .poll(async () => {
page.evaluate((messageText) => { const cache = await readReplicaCache(page);
const raw = localStorage.getItem("@paseo:replica-cache"); if (!cache) return false;
if (!raw) return false; return !cache.hosts
const cache = JSON.parse(raw) as { ?.flatMap((host) => host.timeline?.items ?? [])
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>; .some(
}; (item) =>
return !cache.hosts item.kind === "user_message" &&
?.flatMap((host) => host.timeline?.items ?? []) item.text === prompt &&
.some( typeof item.clientMessageId === "string" &&
(item) => item.messageId === undefined,
item.kind === "user_message" && );
item.text === messageText && })
typeof item.clientMessageId === "string" &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true); .toBe(true);
} }
async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> { async function waitForCachedMessageWithoutProviderId(page: Page, prompt: string): Promise<void> {
await expect await expect
.poll(() => .poll(async () => {
page.evaluate((messageText) => { const cache = await readReplicaCache(page);
const raw = localStorage.getItem("@paseo:replica-cache"); if (!cache) return false;
if (!raw) return false; return cache.hosts
const cache = JSON.parse(raw) as { ?.flatMap((host) => host.timeline?.items ?? [])
hosts?: Array<{ timeline?: { items?: Array<Record<string, unknown>> } | null }>; .some(
}; (item) =>
return cache.hosts item.kind === "user_message" && item.text === prompt && item.messageId === undefined,
?.flatMap((host) => host.timeline?.items ?? []) );
.some( })
(item) =>
item.kind === "user_message" &&
item.text === messageText &&
item.messageId === undefined,
);
}, prompt),
)
.toBe(true); .toBe(true);
} }
@@ -148,16 +126,13 @@ test.describe("Rewind sheet", () => {
title: "Rewind cache upgrade e2e", title: "Rewind cache upgrade e2e",
initialPrompt: prompt, initialPrompt: prompt,
}); });
let heldTimelineRequest = false;
try { try {
await openAgentRoute(page, session); await openAgentRoute(page, session);
await expectUserMessageVisible(page, prompt); await expectUserMessageVisible(page, prompt);
await gate.drop();
await rewriteCachedMessageAsLegacyRow(page, prompt); await rewriteCachedMessageAsLegacyRow(page, prompt);
gate.holdNextClientRequest("fetch_agent_timeline_request");
await page.reload(); await page.reload();
await gate.waitForHeldClientRequest();
heldTimelineRequest = true;
const restoredMessage = userMessage(page, prompt); const restoredMessage = userMessage(page, prompt);
await expect(restoredMessage).toBeVisible(); await expect(restoredMessage).toBeVisible();
@@ -165,7 +140,6 @@ test.describe("Rewind sheet", () => {
await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0); await expect(restoredMessage.getByTestId("rewind-menu-trigger")).toHaveCount(0);
await waitForCachedMessageWithoutProviderId(page, prompt); await waitForCachedMessageWithoutProviderId(page, prompt);
} finally { } finally {
if (heldTimelineRequest) gate.releaseHeldClientRequest();
gate.restore(); gate.restore();
await session.cleanup(); await session.cleanup();
} }
@@ -1,6 +1,9 @@
import type { Page } from "@playwright/test"; import type { Page } from "@playwright/test";
import {
const REPLICA_CACHE_STORAGE_KEY = "@paseo:replica-cache"; observeReplicaCacheStorageWrites,
readReplicaCacheStorageWriteObserver,
resetReplicaCacheStorageWriteObserver,
} from "./replica-cache-storage";
export interface ReplicaCachePerfReport { export interface ReplicaCachePerfReport {
elapsedMs: number; elapsedMs: number;
@@ -11,12 +14,7 @@ export interface ReplicaCachePerfReport {
} }
interface ReplicaCachePerfState { interface ReplicaCachePerfState {
lastValue: string | null;
resetAt: number; resetAt: number;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
} }
declare global { declare global {
@@ -26,55 +24,25 @@ declare global {
} }
export async function observeReplicaCacheWrites(page: Page): Promise<void> { export async function observeReplicaCacheWrites(page: Page): Promise<void> {
await page.addInitScript((storageKey) => { await observeReplicaCacheStorageWrites(page);
const originalSetItem = Storage.prototype.setItem; await page.addInitScript(() => {
window.__replicaCachePerf = { window.__replicaCachePerf = { resetAt: performance.now() };
lastValue: localStorage.getItem(storageKey), });
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
Storage.prototype.setItem = function measuredSetItem(key: string, value: string): void {
const startedAt = performance.now();
originalSetItem.call(this, key, value);
if (key !== storageKey) return;
const state = window.__replicaCachePerf;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
};
}, REPLICA_CACHE_STORAGE_KEY);
} }
export async function beginReplicaCacheMeasurement(page: Page): Promise<void> { export async function beginReplicaCacheMeasurement(page: Page): Promise<void> {
await resetReplicaCacheStorageWriteObserver(page);
await page.evaluate(() => { await page.evaluate(() => {
const lastValue = window.__replicaCachePerf?.lastValue ?? null; window.__replicaCachePerf = { resetAt: performance.now() };
window.__replicaCachePerf = {
lastValue,
resetAt: performance.now(),
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}); });
} }
export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> { export async function readReplicaCacheMeasurement(page: Page): Promise<ReplicaCachePerfReport> {
return page.evaluate(() => { const storage = await readReplicaCacheStorageWriteObserver(page);
const elapsedMs = await page.evaluate(() => {
const state = window.__replicaCachePerf; const state = window.__replicaCachePerf;
if (!state) throw new Error("Replica cache performance observer is not installed"); if (!state) throw new Error("Replica cache performance observer is not installed");
return { return performance.now() - state.resetAt;
elapsedMs: performance.now() - state.resetAt,
redundantWrites: state.redundantWrites,
serializedChars: state.serializedChars,
storageDurationMs: state.storageDurationMs,
writes: state.writes,
};
}); });
return { elapsedMs, ...storage };
} }
@@ -0,0 +1,150 @@
import type { Page } from "@playwright/test";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
const STORAGE_KEY = "@paseo:replica-cache";
export interface ReplicaCacheRecord {
hosts?: Array<{
timeline?: {
agentId?: string;
items?: Array<Record<string, unknown>>;
} | null;
}>;
}
interface ReplicaCacheWriteObserverState {
lastValue: string | null;
redundantWrites: number;
serializedChars: number;
storageDurationMs: number;
writes: number;
}
declare global {
interface Window {
__replicaCacheWriteObserver?: ReplicaCacheWriteObserverState;
}
}
async function readStoredValue(input: {
databaseName: string;
storeName: string;
storageKey: string;
}): Promise<string | null> {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(input.databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
return new Promise((resolve, reject) => {
const request = database
.transaction(input.storeName, "readonly")
.objectStore(input.storeName)
.get(input.storageKey);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () => reject(request.error));
});
}
export async function readReplicaCache(page: Page): Promise<ReplicaCacheRecord | null> {
const raw = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
return raw ? (JSON.parse(raw) as ReplicaCacheRecord) : null;
}
export async function writeReplicaCache(page: Page, value: ReplicaCacheRecord): Promise<void> {
await page.evaluate(
async ({ databaseName, storeName, storageKey, raw }) => {
const database = await new Promise<IDBDatabase>((resolve, reject) => {
const request = indexedDB.open(databaseName, 1);
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () => reject(request.error));
});
await new Promise<void>((resolve, reject) => {
const transaction = database.transaction(storeName, "readwrite");
transaction.objectStore(storeName).put(raw, storageKey);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () => reject(transaction.error));
transaction.addEventListener("abort", () => reject(transaction.error));
});
},
{
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
raw: JSON.stringify(value),
},
);
}
export async function observeReplicaCacheStorageWrites(page: Page): Promise<void> {
await page.addInitScript(
({ databaseName, storeName, storageKey }) => {
window.__replicaCacheWriteObserver = {
lastValue: null,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function measuredPut(value: unknown, key?: IDBValidKey) {
const request =
key === undefined ? originalPut.call(this, value) : originalPut.call(this, value, key);
if (
this.transaction.db.name !== databaseName ||
this.name !== storeName ||
key !== storageKey ||
typeof value !== "string"
) {
return request;
}
const startedAt = performance.now();
request.addEventListener("success", () => {
const state = window.__replicaCacheWriteObserver;
if (!state) return;
if (state.lastValue === value) state.redundantWrites += 1;
state.lastValue = value;
state.writes += 1;
state.serializedChars += value.length;
state.storageDurationMs += performance.now() - startedAt;
});
return request;
};
},
{ databaseName: DATABASE_NAME, storeName: STORE_NAME, storageKey: STORAGE_KEY },
);
}
export async function resetReplicaCacheStorageWriteObserver(page: Page): Promise<void> {
const lastValue = await page.evaluate(readStoredValue, {
databaseName: DATABASE_NAME,
storeName: STORE_NAME,
storageKey: STORAGE_KEY,
});
await page.evaluate((value) => {
window.__replicaCacheWriteObserver = {
lastValue: value,
redundantWrites: 0,
serializedChars: 0,
storageDurationMs: 0,
writes: 0,
};
}, lastValue);
}
export async function readReplicaCacheStorageWriteObserver(
page: Page,
): Promise<ReplicaCacheWriteObserverState> {
return page.evaluate(() => {
const state = window.__replicaCacheWriteObserver;
if (!state) throw new Error("Replica cache write observer is not installed");
return state;
});
}
@@ -1,5 +1,6 @@
import { expect, type Page } from "@playwright/test"; import { expect, type Page } from "@playwright/test";
import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent"; import { buildAgentRoute, seedMockAgentWorkspace, type MockAgentWorkspace } from "./mock-agent";
import { readReplicaCache } from "./replica-cache-storage";
import { import {
delayAgentBootstrapTailResponse, delayAgentBootstrapTailResponse,
delayAgentOlderTimelineResponse, delayAgentOlderTimelineResponse,
@@ -284,22 +285,13 @@ export async function reloadAgentTimelineFromPersistedReplica(
agent: LongTimelineAgent, agent: LongTimelineAgent,
): Promise<void> { ): Promise<void> {
await expect await expect
.poll(() => .poll(async () => {
page.evaluate((agentId) => { const cache = await readReplicaCache(page);
const raw = localStorage.getItem("@paseo:replica-cache"); const timeline = cache?.hosts?.find(
if (!raw) return false; (host) => host.timeline?.agentId === agent.agentId,
const cache = JSON.parse(raw) as { )?.timeline;
hosts?: Array<{ return timeline?.items?.length === 50;
timeline?: { })
agentId?: string;
items?: unknown[];
} | null;
}>;
};
const timeline = cache.hosts?.find((host) => host.timeline?.agentId === agentId)?.timeline;
return timeline?.items?.length === 50;
}, agent.agentId),
)
.toBe(true); .toBe(true);
await page.reload(); await page.reload();
@@ -3,7 +3,7 @@ import path from "node:path";
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
describe("Android AsyncStorage configuration", () => { describe("Android AsyncStorage configuration", () => {
it("writes a 10 MiB database limit into Gradle properties", () => { it("writes a 64 MiB database limit into Gradle properties", () => {
const appDirectory = path.resolve(import.meta.dirname, ".."); const appDirectory = path.resolve(import.meta.dirname, "..");
const output = execFileSync( const output = execFileSync(
process.execPath, process.execPath,
@@ -11,7 +11,7 @@ describe("Android AsyncStorage configuration", () => {
"-e", "-e",
` `
const plugin = require("./plugins/with-android-async-storage-size"); const plugin = require("./plugins/with-android-async-storage-size");
const config = plugin({ name: "Paseo", slug: "paseo" }, 10); const config = plugin({ name: "Paseo", slug: "paseo" }, 64);
const mod = config.mods.android.gradleProperties; const mod = config.mods.android.gradleProperties;
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config }) mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
.then((result) => process.stdout.write(JSON.stringify(result.modResults))); .then((result) => process.stdout.write(JSON.stringify(result.modResults)));
@@ -21,7 +21,7 @@ describe("Android AsyncStorage configuration", () => {
); );
expect(JSON.parse(output)).toEqual([ expect(JSON.parse(output)).toEqual([
{ type: "property", key: "AsyncStorage_db_size_in_MB", value: "10" }, { type: "property", key: "AsyncStorage_db_size_in_MB", value: "64" },
]); ]);
}); });
}); });
@@ -40,7 +40,6 @@ export interface ProjectEditSheetProps {
export function ProjectEditSheet({ export function ProjectEditSheet({
visible, visible,
onClose, onClose,
serverId,
projectId, projectId,
projectViewKey, projectViewKey,
client, client,
@@ -60,7 +59,6 @@ export function ProjectEditSheet({
submitProjectEdit({ client, projectId, submission }), submitProjectEdit({ client, projectId, submission }),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["projects"] }); queryClient.invalidateQueries({ queryKey: ["projects"] });
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
toast.show(t("settings.project.edit.savedToast"), { variant: "success" }); toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
onClose(); onClose();
}, },
@@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo } from "react";
import { useStoreWithEqualityFn } from "zustand/traditional"; import { useStoreWithEqualityFn } from "zustand/traditional";
import { useCreateFlowStore } from "@/stores/create-flow-store"; import { useCreateFlowStore } from "@/stores/create-flow-store";
import { useSessionStore } from "@/stores/session-store"; import { useSessionStore } from "@/stores/session-store";
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks"; import {
useHydratedWorkspaceServerIds,
useWorkspaceDirectoryServerIds,
} from "@/stores/session-store-hooks";
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors"; import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
import { useHostProjects } from "@/projects/host-projects"; import { useHostProjects } from "@/projects/host-projects";
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime"; import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
@@ -132,8 +135,9 @@ export function useSidebarWorkspacesList(options?: {
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER); const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds); const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
const hostProjects = useHostProjects(hydratedServerIds); const hostProjects = useHostProjects(directoryServerIds);
const sidebarModel = useMemo( const sidebarModel = useMemo(
() => () =>
@@ -0,0 +1,68 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { ProjectIconCache, type ProjectIconCacheStorage } from "./icon-cache";
class MemoryStorage implements ProjectIconCacheStorage {
readonly values = new Map<string, string>();
async getItem(key: string): Promise<string | null> {
return this.values.get(key) ?? null;
}
async setItem(key: string, value: string): Promise<void> {
this.values.set(key, value);
}
}
const target = {
serverId: "host-a",
projectId: "project-a",
iconWorkingDir: "/project-a",
iconRevision: "revision-a",
};
const icon = { data: "aWNvbg==", mimeType: "image/png" };
function clientWith(iconResult: typeof icon | null) {
return {
getProjectIcon: async () => ({ requestId: "icon", icon: iconResult }),
} as unknown as DaemonClient;
}
describe("ProjectIconCache", () => {
it("resolves, persists, and hydrates an icon before a host connects", async () => {
const storage = new MemoryStorage();
const writer = new ProjectIconCache(storage);
writer.setHosts([target.serverId]);
await writer.query(target, true, () => clientWith(icon), true).queryFn();
await writer.flush();
const reader = new ProjectIconCache(storage);
reader.setHosts([target.serverId]);
await reader.restore();
expect(reader.query(target, true, () => null, false).initialData).toEqual(icon);
});
it("persists an explicit no-icon result", async () => {
const storage = new MemoryStorage();
const writer = new ProjectIconCache(storage);
writer.setHosts([target.serverId]);
await writer.query(target, true, () => clientWith(null), true).queryFn();
await writer.flush();
const reader = new ProjectIconCache(storage);
reader.setHosts([target.serverId]);
await reader.restore();
expect(reader.query(target, true, () => null, false)).toHaveProperty("initialData", null);
});
it("misses only the project whose revision changed", async () => {
const cache = new ProjectIconCache(new MemoryStorage());
const other = { ...target, projectId: "project-b" };
await cache.query(target, true, () => clientWith(icon), true).queryFn();
await cache.query(other, true, () => clientWith(null), true).queryFn();
expect(
cache.query({ ...target, iconRevision: "revision-b" }, true, () => null, false),
).not.toHaveProperty("initialData");
expect(cache.query(other, true, () => null, false)).toHaveProperty("initialData", null);
});
});
+217
View File
@@ -0,0 +1,217 @@
import type { ProjectIcon } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
const STORAGE_KEY = "@paseo:project-icon-cache";
const CACHE_VERSION = 1;
const PERSIST_DELAY_MS = 250;
const MAX_ENTRIES = 512;
export interface ProjectIconCacheStorage {
getItem(key: string): Promise<string | null>;
setItem(key: string, value: string): Promise<void>;
}
interface ProjectIconIdentity {
serverId: string;
projectId: string;
revision: string;
}
type ProjectIconCacheRead = { hit: true; icon: ProjectIcon | null } | { hit: false };
interface ProjectIconTarget {
serverId: string;
projectId: string;
iconWorkingDir: string;
customIconRevision?: string | null;
iconRevision?: string;
}
interface StoredIcon extends ProjectIconIdentity {
icon: ProjectIcon | null;
}
function keyOf(identity: ProjectIconIdentity): string {
return JSON.stringify([identity.serverId, identity.projectId, identity.revision]);
}
function parseStoredIcon(value: unknown): StoredIcon | null {
if (!value || typeof value !== "object") return null;
const entry = value as Record<string, unknown>;
if (
typeof entry.serverId !== "string" ||
typeof entry.projectId !== "string" ||
typeof entry.revision !== "string"
) {
return null;
}
if (entry.icon === null) {
return {
serverId: entry.serverId,
projectId: entry.projectId,
revision: entry.revision,
icon: null,
};
}
if (!entry.icon || typeof entry.icon !== "object") return null;
const icon = entry.icon as Record<string, unknown>;
if (typeof icon.data !== "string" || typeof icon.mimeType !== "string") return null;
return {
serverId: entry.serverId,
projectId: entry.projectId,
revision: entry.revision,
icon: { data: icon.data, mimeType: icon.mimeType },
};
}
/** Owns project-icon persistence, revision matching, and negative results. */
export class ProjectIconCache {
private readonly entries = new Map<string, StoredIcon>();
private activeServerIds = new Set<string>();
private persistTimer: ReturnType<typeof setTimeout> | null = null;
private writeQueue: Promise<void> = Promise.resolve();
constructor(private readonly storage: ProjectIconCacheStorage) {}
setHosts(serverIds: Iterable<string>): void {
this.activeServerIds = new Set(serverIds);
let changed = false;
for (const [key, entry] of this.entries) {
if (!this.activeServerIds.has(entry.serverId)) {
this.entries.delete(key);
changed = true;
}
}
if (changed) this.schedulePersist();
}
async restore(): Promise<void> {
let raw: string | null;
try {
raw = await this.storage.getItem(STORAGE_KEY);
} catch {
return;
}
if (!raw) return;
try {
const parsed = JSON.parse(raw) as { version?: unknown; entries?: unknown };
if (parsed.version !== CACHE_VERSION || !Array.isArray(parsed.entries)) return;
for (const value of parsed.entries) {
const entry = parseStoredIcon(value);
if (entry && this.activeServerIds.has(entry.serverId)) {
this.entries.set(keyOf(entry), entry);
}
}
} catch {
// A corrupt cache is disposable.
}
}
private read(identity: ProjectIconIdentity): ProjectIconCacheRead {
const entry = this.entries.get(keyOf(identity));
return entry ? { hit: true, icon: entry.icon } : { hit: false };
}
private write(identity: ProjectIconIdentity, icon: ProjectIcon | null): void {
const key = keyOf(identity);
this.entries.delete(key);
this.entries.set(key, { ...identity, icon });
while (this.entries.size > MAX_ENTRIES) {
const oldest = this.entries.keys().next().value;
if (oldest === undefined) break;
this.entries.delete(oldest);
}
this.schedulePersist();
}
query(
target: ProjectIconTarget,
supportsCustomIcons: boolean | null,
getClient: () => DaemonClient | null,
connected: boolean,
) {
// COMPAT(projectIconCache): daemons before v0.2.7 do not expose an effective revision, so
// their icon results remain memory-only.
const revision = target.iconRevision ?? target.customIconRevision ?? "automatic";
const identity: ProjectIconIdentity | null = target.iconRevision
? { serverId: target.serverId, projectId: target.projectId, revision: target.iconRevision }
: null;
const cached = identity ? this.read(identity) : { hit: false as const };
const lookup = resolveLookup(target, supportsCustomIcons);
let queryKey: readonly string[];
if (!lookup) {
queryKey = ["projectIcon", target.serverId, "pending", target.projectId];
} else if (lookup.kind === "project") {
queryKey = ["projectIcon", target.serverId, target.projectId, revision];
} else {
queryKey = ["projectIcon", target.serverId, "legacy", lookup.cwd];
}
return {
queryKey,
queryFn: async () => {
if (!lookup) return null;
const client = getClient();
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
if (result.error) throw new Error(result.error);
if (identity) this.write(identity, result.icon);
return result.icon;
},
enabled: Boolean(lookup && getClient() && connected),
staleTime: Infinity,
gcTime: 1000 * 60 * 60,
refetchOnMount: false as const,
refetchOnWindowFocus: false as const,
refetchOnReconnect: false as const,
...(cached.hit ? { initialData: cached.icon } : {}),
};
}
reconcileServerId(oldServerId: string, newServerId: string): void {
for (const [key, entry] of this.entries) {
if (entry.serverId !== oldServerId) continue;
this.entries.delete(key);
const reconciled = { ...entry, serverId: newServerId };
this.entries.set(keyOf(reconciled), reconciled);
}
if (this.activeServerIds.delete(oldServerId)) this.activeServerIds.add(newServerId);
this.schedulePersist();
}
async flush(): Promise<void> {
if (this.persistTimer) {
clearTimeout(this.persistTimer);
this.persistTimer = null;
}
const payload = JSON.stringify({ version: CACHE_VERSION, entries: [...this.entries.values()] });
const write = this.writeQueue
.catch(() => undefined)
.then(() => this.storage.setItem(STORAGE_KEY, payload));
this.writeQueue = write;
await write.catch(() => undefined);
}
private schedulePersist(): void {
if (this.persistTimer) return;
this.persistTimer = setTimeout(() => {
this.persistTimer = null;
void this.flush();
}, PERSIST_DELAY_MS);
}
}
export const projectIconCache = new ProjectIconCache(replicaCacheStorage);
function resolveLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean | null,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null {
if (supportsCustomIcons === null) return null;
return supportsCustomIcons
? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir };
}
+4
View File
@@ -20,4 +20,8 @@ describe("project icon lookup", () => {
cwd: "/projects/paseo", cwd: "/projects/paseo",
}); });
}); });
it("waits while custom-icon capability is unknown", () => {
expect(resolveProjectIconLookup(target, null)).toBeNull();
});
}); });
+12 -34
View File
@@ -2,7 +2,8 @@ import { useMemo, useRef } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQueries, useQuery } from "@tanstack/react-query"; import { useQueries, useQuery } from "@tanstack/react-query";
import type { ProjectIcon } from "@getpaseo/protocol/messages"; import type { ProjectIcon } from "@getpaseo/protocol/messages";
import { useHostFeatureMap } from "@/runtime/host-features"; import { useHostFeatureAvailabilityMap } from "@/runtime/host-features";
import { projectIconCache } from "@/projects/icon-cache";
import { import {
getHostRuntimeStore, getHostRuntimeStore,
isHostRuntimeConnected, isHostRuntimeConnected,
@@ -16,6 +17,7 @@ interface ProjectIconTarget {
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
} }
/** /**
@@ -24,8 +26,9 @@ interface ProjectIconTarget {
*/ */
export function resolveProjectIconLookup( export function resolveProjectIconLookup(
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">, target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
supportsCustomIcons: boolean, supportsCustomIcons: boolean | null,
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } { ): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } | null {
if (supportsCustomIcons === null) return null;
return supportsCustomIcons return supportsCustomIcons
? { kind: "project", projectId: target.projectId } ? { kind: "project", projectId: target.projectId }
: { kind: "legacy", cwd: target.iconWorkingDir }; : { kind: "legacy", cwd: target.iconWorkingDir };
@@ -35,10 +38,6 @@ function legacyIconQueryKey(serverId: string, cwd: string) {
return ["projectIcon", serverId, "legacy", cwd] as const; return ["projectIcon", serverId, "legacy", cwd] as const;
} }
function iconQueryKey(serverId: string, projectId: string, revision: string) {
return ["projectIcon", serverId, projectId, revision] as const;
}
function iconDataUri(icon: ProjectIcon | null): string | null { function iconDataUri(icon: ProjectIcon | null): string | null {
if (!icon) return null; if (!icon) return null;
return `data:${icon.mimeType};base64,${icon.data}`; return `data:${icon.mimeType};base64,${icon.data}`;
@@ -88,7 +87,7 @@ export function useProjectIcons(input: {
() => [...new Set(input.projects.map((project) => project.serverId))], () => [...new Set(input.projects.map((project) => project.serverId))],
[input.projects], [input.projects],
); );
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon"); const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon");
const requests = useMemo(() => { const requests = useMemo(() => {
const unique = new Map<string, ProjectIconTarget>(); const unique = new Map<string, ProjectIconTarget>();
for (const project of input.projects) { for (const project of input.projects) {
@@ -100,35 +99,14 @@ export function useProjectIcons(input: {
const queries = useQueries({ const queries = useQueries({
queries: requests.map((request) => { queries: requests.map((request) => {
const revision = request.customIconRevision ?? "automatic";
const lookup = resolveProjectIconLookup(
request,
supportsCustomIcons.get(request.serverId) === true,
);
return { return {
queryKey: ...projectIconCache.query(
lookup.kind === "project" request,
? iconQueryKey(request.serverId, lookup.projectId, revision) supportsCustomIcons.get(request.serverId) ?? null,
: legacyIconQueryKey(request.serverId, lookup.cwd), () => getHostRuntimeStore().getClient(request.serverId),
queryFn: async () => {
const client = getHostRuntimeStore().getClient(request.serverId);
if (!client) return null;
const result =
lookup.kind === "project"
? await client.getProjectIcon(lookup.projectId)
: await client.requestProjectIcon(lookup.cwd);
return result.icon;
},
select: iconDataUri,
enabled: Boolean(
getHostRuntimeStore().getClient(request.serverId) &&
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)), isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
), ),
staleTime: Infinity, select: iconDataUri,
gcTime: 1000 * 60 * 60,
refetchOnMount: false,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
}; };
}), }),
}); });
@@ -7,6 +7,7 @@ export interface WorkspaceStructureHostPlacement {
iconWorkingDir: string; iconWorkingDir: string;
worktreeSupport: "supported" | "unsupported" | "unknown"; worktreeSupport: "supported" | "unsupported" | "unknown";
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
} }
export interface WorkspaceStructureProject { export interface WorkspaceStructureProject {
@@ -160,6 +161,7 @@ function addProjectToView(input: {
iconWorkingDir: project.projectRootPath, iconWorkingDir: project.projectRootPath,
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported", worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
customIconRevision: project.projectCustomIconRevision, customIconRevision: project.projectCustomIconRevision,
iconRevision: project.projectIconRevision,
}; };
const draft = byProject.get(viewKey); const draft = byProject.get(viewKey);
if (!draft) { if (!draft) {
@@ -51,6 +51,31 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
} }
describe("AgentDirectoryReplica", () => { describe("AgentDirectoryReplica", () => {
it("preserves an unchanged running agent's turn identity during catch-up", () => {
const serverId = "agent-replica-catch-up";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
const replica = new AgentDirectoryReplica(serverId, () => undefined);
replica.commitSnapshot(
[
entry({
...payload("running"),
status: "running",
activeTurn: { turnId: "turn-1", startedAt: "2026-07-17T00:01:00.000Z" },
}),
],
[],
);
replica.commitChanges([], [], []);
expect(useSessionStore.getState().sessions[serverId]?.agents.get("agent")?.activeTurn).toEqual({
turnId: "turn-1",
startedAt: new Date("2026-07-17T00:01:00.000Z"),
});
store.clearSession(serverId);
});
it("keeps membership authoritative across remove, stale timeline, and re-add", () => { it("keeps membership authoritative across remove, stale timeline, and re-add", () => {
const serverId = "agent-replica"; const serverId = "agent-replica";
const store = useSessionStore.getState(); const store = useSessionStore.getState();
@@ -3,7 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent"; import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client"; import { queryClient } from "@/data/query-client";
import { useSessionStore, type Agent } from "@/stores/session-store"; import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { import {
applyAgentDirectoryDelta, applyAgentDirectoryDelta,
type AgentDirectoryDelta, type AgentDirectoryDelta,
@@ -15,6 +15,12 @@ import {
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation"; import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
function projectAgentDirectoryEntry(agent: Agent): FetchAgentsEntry | null {
return agent.projectPlacement
? { agent: projectAgentSnapshot(agent), project: agent.projectPlacement }
: null;
}
export interface AgentLifecycleToken { export interface AgentLifecycleToken {
readonly agentId: string; readonly agentId: string;
readonly version: number; readonly version: number;
@@ -97,6 +103,25 @@ export class AgentDirectoryReplica {
return agents; return agents;
} }
commitChanges(
entries: FetchAgentsEntry[],
removals: readonly { id: string }[],
deltas: readonly AgentDirectoryDelta[],
): Map<string, Agent> {
const previous = useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
const merged = new Map<string, FetchAgentsEntry>();
for (const agent of previous.values()) {
const entry = projectAgentDirectoryEntry(agent);
if (entry) merged.set(agent.id, entry);
}
for (const entry of entries) merged.set(entry.agent.id, entry);
const removalsAsDeltas: AgentDirectoryDelta[] = removals.map(({ id }) => ({
kind: "remove",
agentId: id,
}));
return this.commitSnapshot(Array.from(merged.values()), [...removalsAsDeltas, ...deltas]);
}
archive(agentId: string, archivedAt: string): void { archive(agentId: string, archivedAt: string): void {
this.advance(agentId); this.advance(agentId);
useSessionStore.getState().setAgents(this.serverId, (current) => { useSessionStore.getState().setAgents(this.serverId, (current) => {
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it } from "vitest"; import { afterEach, describe, expect, it } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client"; import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { useSessionStore } from "@/stores/session-store"; import { normalizeProjectDescriptor, useSessionStore } from "@/stores/session-store";
import { DirectoryRefreshSupersededError, DirectorySync } from "./index"; import { DirectoryRefreshSupersededError, DirectorySync } from "./index";
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>; type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
@@ -9,8 +9,11 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
class FakeDirectoryClient { class FakeDirectoryClient {
fetchAgentsCalls = 0; fetchAgentsCalls = 0;
lastAgentOptions: unknown;
fetchWorkspacesCalls = 0; fetchWorkspacesCalls = 0;
listProjectsCalls = 0; listProjectsCalls = 0;
lastProjectOptions: unknown;
projectResult: ProjectListResult | null = null;
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null; private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
private readonly handlers = new Map< private readonly handlers = new Map<
SessionOutboundMessage["type"], SessionOutboundMessage["type"],
@@ -42,8 +45,9 @@ class FakeDirectoryClient {
return complete; return complete;
} }
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> { async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
this.fetchAgentsCalls += 1; this.fetchAgentsCalls += 1;
this.lastAgentOptions = options;
return { return {
requestId: "agents", requestId: "agents",
entries: [], entries: [],
@@ -66,8 +70,10 @@ class FakeDirectoryClient {
}; };
} }
async listProjects(): Promise<ProjectListResult> { async listProjects(options?: unknown): Promise<ProjectListResult> {
this.listProjectsCalls += 1; this.listProjectsCalls += 1;
this.lastProjectOptions = options;
if (this.projectResult) return this.projectResult;
return { return {
requestId: "projects", requestId: "projects",
projects: [ projects: [
@@ -111,6 +117,52 @@ afterEach(() => {
}); });
describe("DirectorySync session readiness", () => { describe("DirectorySync session readiness", () => {
it("uses the saved agent sequence while bounding the bootstrap page", async () => {
const serverId = "agent-list-sequence-page";
serverIds.add(serverId);
const client = new FakeDirectoryClient();
const directory = new DirectorySync(
serverId,
{
onAgentStoppedRunning: () => undefined,
markAgentLoading: () => undefined,
markAgentReady: () => undefined,
markAgentError: () => undefined,
},
{
readDirectoryCheckpoint: () => ({ agents: { generation: "generation", afterSeq: 12 } }),
writeDirectoryCheckpoint: () => 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 },
});
await directory.refreshAgents({
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: 200 },
});
expect(client.lastAgentOptions).toEqual({
scope: "active",
sort: [{ key: "updated_at", direction: "desc" }],
subscribe: { subscriptionId: `app:${serverId}` },
page: { limit: 200 },
sync: { generation: "generation", afterSeq: 12 },
});
directory.dispose();
});
it("waits for workspace capability metadata before choosing the workspace protocol", async () => { it("waits for workspace capability metadata before choosing the workspace protocol", async () => {
const serverId = "workspace-metadata"; const serverId = "workspace-metadata";
const { client, directory } = createDirectory(serverId); const { client, directory } = createDirectory(serverId);
@@ -159,6 +211,82 @@ describe("DirectorySync session readiness", () => {
directory.dispose(); directory.dispose();
}); });
it("merges project changes from the existing list RPC and advances its cursor", async () => {
const serverId = "project-list-sequence";
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, [
normalizeProjectDescriptor({
projectId: "project-1",
projectDisplayName: "Old name",
projectRootPath: "/repo/one",
projectKind: "git",
}),
normalizeProjectDescriptor({
projectId: "project-2",
projectDisplayName: "Removed",
projectRootPath: "/repo/two",
projectKind: "git",
}),
]);
store.updateSessionServerInfo(serverId, {
serverId,
hostname: null,
version: "test",
features: { workspaceMultiplicity: true, projectList: true, directorySync: true },
});
client.projectResult = {
requestId: "projects",
projects: [
{
projectId: "project-1",
projectDisplayName: "New name",
projectRootPath: "/repo/one",
projectKind: "git",
syncSeq: 5,
},
],
sync: {
generation: "generation",
headSeq: 6,
mode: "changes",
removals: [{ id: "project-2", seq: 6 }],
},
};
await directory.refreshWorkspaces();
expect(client.lastProjectOptions).toEqual({
sync: { generation: "generation", afterSeq: 4 },
});
const projects = useSessionStore.getState().sessions[serverId]?.projects;
expect(Array.from(projects?.keys() ?? [])).toEqual(["project-1"]);
expect(projects?.get("project-1")?.projectDisplayName).toBe("New name");
expect(writes).toContainEqual({ projects: { generation: "generation", afterSeq: 6 } });
directory.dispose();
});
it("rejects a session wait on disconnect so the reconnect can refresh", async () => { it("rejects a session wait on disconnect so the reconnect can refresh", async () => {
const serverId = "session-wait-reconnect"; const serverId = "session-wait-reconnect";
const { client, directory } = createDirectory(serverId); const { client, directory } = createDirectory(serverId);
+196 -29
View File
@@ -36,10 +36,41 @@ const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
{ key: "updated_at", direction: "desc" }, { key: "updated_at", direction: "desc" },
]; ];
function resolveAgentNextPage(pageInfo: AgentPageInfo): {
hasMore: boolean;
nextCursor: string | null;
} {
return {
hasMore: pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false,
nextCursor: pageInfo.nextCursor ?? pageInfo.afterCursor ?? null,
};
}
interface AgentSnapshot { interface AgentSnapshot {
entries: FetchAgentsEntry[]; entries: FetchAgentsEntry[];
subscriptionId: string | null; subscriptionId: string | null;
legacy: boolean; legacy: boolean;
syncMode?: "snapshot" | "changes";
syncCursor?: DirectoryCursor;
syncRemovals: Array<{ id: string; seq: number }>;
}
interface AgentPageInfo {
hasMore?: boolean;
hasMoreAfter?: boolean;
nextCursor?: string | null;
afterCursor?: string | null;
}
interface DirectoryCursor {
generation: string;
afterSeq: number;
}
interface DirectoryCursors {
projects?: DirectoryCursor;
workspaces?: DirectoryCursor;
agents?: DirectoryCursor;
} }
export interface DirectoryConnection { export interface DirectoryConnection {
@@ -48,6 +79,11 @@ export interface DirectoryConnection {
source: DirectorySourceToken; source: DirectorySourceToken;
} }
export interface DirectoryCheckpointStorage {
readDirectoryCheckpoint(serverId: string): unknown;
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void;
}
export interface RefreshAgentDirectoryInput { export interface RefreshAgentDirectoryInput {
filter?: FetchAgentsOptions["filter"]; filter?: FetchAgentsOptions["filter"];
subscribe?: FetchAgentsOptions["subscribe"]; subscribe?: FetchAgentsOptions["subscribe"];
@@ -86,6 +122,7 @@ export class DirectorySync {
markAgentReady: () => void; markAgentReady: () => void;
markAgentError: (error: string) => void; markAgentError: (error: string) => void;
}, },
private readonly checkpoints?: DirectoryCheckpointStorage,
) { ) {
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning); this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning);
this.workspaces = new WorkspaceDirectoryReplica(serverId); this.workspaces = new WorkspaceDirectoryReplica(serverId);
@@ -116,19 +153,26 @@ export class DirectorySync {
const subscriptions = [ const subscriptions = [
client.on("agent_update", (message) => { client.on("agent_update", (message) => {
if (message.type !== "agent_update" || !this.isCurrent(client, source)) return; if (message.type !== "agent_update" || !this.isCurrent(client, source)) return;
if (!this.agentTransactions.record(source, message.payload)) const recorded = this.agentTransactions.record(source, message.payload);
if (!recorded) {
this.agents.applyDelta(message.payload); this.agents.applyDelta(message.payload);
this.noteLiveCursor("agents", message.payload);
}
}), }),
client.on("workspace_update", (message) => { client.on("workspace_update", (message) => {
if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return; if (message.type !== "workspace_update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) { const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.workspaces.applyDelta(message.payload); this.workspaces.applyDelta(message.payload);
this.noteLiveCursor("workspaces", message.payload);
} }
}), }),
client.on("project.update", (message) => { client.on("project.update", (message) => {
if (message.type !== "project.update" || !this.isCurrent(client, source)) return; if (message.type !== "project.update" || !this.isCurrent(client, source)) return;
if (!this.workspaceTransactions.record(source, message.payload)) { const recorded = this.workspaceTransactions.record(source, message.payload);
if (!recorded) {
this.workspaces.applyDelta(message.payload); this.workspaces.applyDelta(message.payload);
this.noteLiveCursor("projects", message.payload);
} }
}), }),
client.on("agent_deleted", (message) => { client.on("agent_deleted", (message) => {
@@ -174,6 +218,7 @@ export class DirectorySync {
entries: [], entries: [],
subscriptionId: null, subscriptionId: null,
legacy: false, legacy: false,
syncRemovals: [],
})); }));
this.callbacks.markAgentLoading(); this.callbacks.markAgentLoading();
try { try {
@@ -222,7 +267,8 @@ export class DirectorySync {
: delta, : delta,
) )
: completion.deltas; : completion.deltas;
const agents = this.agents.commitSnapshot(completion.snapshot.entries, deltas); const agents = this.commitAgentSnapshot(completion.snapshot, deltas);
this.persistAgentCursors(completion.snapshot, completion.deltas);
this.callbacks.markAgentReady(); this.callbacks.markAgentReady();
return { agents, subscriptionId: completion.snapshot.subscriptionId }; return { agents, subscriptionId: completion.snapshot.subscriptionId };
} catch (error) { } catch (error) {
@@ -238,8 +284,9 @@ export class DirectorySync {
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> { async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
const { client, source } = this.requireOnline(); const { client, source } = this.requireOnline();
const transaction = this.workspaceTransactions.begin(source, () => ({ const transaction = this.workspaceTransactions.begin(source, () => ({
workspaces: new Map(), workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
projects: new Map(), projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
syncCursors: {},
})); }));
try { try {
await this.waitForSessionMetadata(client, source); await this.waitForSessionMetadata(client, source);
@@ -250,24 +297,19 @@ export class DirectorySync {
return; return;
} }
const supportsProjectList = serverInfo.features?.projectList === true; const supportsProjectList = serverInfo.features?.projectList === true;
const supportsDirectorySync = serverInfo.features?.directorySync === true;
if (supportsProjectList) { if (supportsProjectList) {
const payload = await client.listProjects(); await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
for (const entry of payload.projects) {
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
} }
await this.fetchWorkspaceSnapshot(client, source, transaction, input?.subscribe === true); await this.fetchWorkspaceSnapshot(
client,
source,
transaction,
input?.subscribe === true,
supportsDirectorySync,
);
if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot); if (!supportsProjectList) this.buildLegacyProjectSnapshot(transaction.snapshot);
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) { this.completeWorkspaceRefresh(client, source, transaction);
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
const completion = this.workspaceTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
} catch (error) { } catch (error) {
const deltas = this.workspaceTransactions.fail(transaction); const deltas = this.workspaceTransactions.fail(transaction);
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta); if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
@@ -280,6 +322,7 @@ export class DirectorySync {
source: DirectorySourceToken, source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>, transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
initialSubscribe: boolean, initialSubscribe: boolean,
supportsDirectorySync: boolean,
): Promise<void> { ): Promise<void> {
let cursor: string | null = null; let cursor: string | null = null;
let subscribe = initialSubscribe; let subscribe = initialSubscribe;
@@ -288,12 +331,23 @@ export class DirectorySync {
sort: [{ key: "activity_at", direction: "desc" }], sort: [{ key: "activity_at", direction: "desc" }],
...(subscribe ? { subscribe: {} } : {}), ...(subscribe ? { subscribe: {} } : {}),
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT }, page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
}); });
this.assertWorkspaceTransactionCurrent(client, source, transaction); this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear();
for (const entry of payload.entries) { for (const entry of payload.entries) {
const workspace = normalizeWorkspaceDescriptor(entry); const workspace = normalizeWorkspaceDescriptor(entry);
transaction.snapshot.workspaces.set(workspace.id, workspace); transaction.snapshot.workspaces.set(workspace.id, workspace);
} }
for (const removal of payload.sync?.removals ?? []) {
transaction.snapshot.workspaces.delete(removal.id);
}
if (payload.sync) {
(transaction.snapshot.syncCursors ??= {}).workspaces = {
generation: payload.sync.generation,
afterSeq: payload.sync.headSeq,
};
}
for (const entry of payload.emptyProjects ?? []) { for (const entry of payload.emptyProjects ?? []) {
const project = normalizeProjectDescriptor(entry); const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project); transaction.snapshot.projects.set(project.projectId, project);
@@ -332,18 +386,15 @@ export class DirectorySync {
sort: AGENT_SORT, sort: AGENT_SORT,
...(subscribe ? { subscribe } : {}), ...(subscribe ? { subscribe } : {}),
page: cursor ? { limit, cursor } : { limit }, page: cursor ? { limit, cursor } : { limit },
...(!input.filter && cursor === null && this.supportsDirectorySync()
? { sync: this.readCursors().agents ?? {} }
: {}),
}); });
this.assertAgentTransactionCurrent(client, source, transaction); this.assertAgentTransactionCurrent(client, source, transaction);
transaction.snapshot.entries.push(...payload.entries); transaction.snapshot.entries.push(...payload.entries);
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null; transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
const pageInfo = payload.pageInfo as { this.recordAgentSync(transaction.snapshot, payload.sync);
hasMore?: boolean; const { hasMore, nextCursor } = resolveAgentNextPage(payload.pageInfo as AgentPageInfo);
hasMoreAfter?: boolean;
nextCursor?: string | null;
afterCursor?: string | null;
};
const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false;
const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null;
if (!hasMore || !nextCursor) break; if (!hasMore || !nextCursor) break;
cursor = nextCursor; cursor = nextCursor;
subscribe = undefined; subscribe = undefined;
@@ -377,6 +428,122 @@ export class DirectorySync {
return { client: this.connection.client, source: this.connection.source }; return { client: this.connection.client, source: this.connection.source };
} }
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
true
);
}
private commitAgentSnapshot(
snapshot: AgentSnapshot,
deltas: readonly AgentDirectoryDelta[],
): Map<string, Agent> {
return snapshot.syncMode === "changes"
? this.agents.commitChanges(snapshot.entries, snapshot.syncRemovals, deltas)
: this.agents.commitSnapshot(snapshot.entries, deltas);
}
private persistAgentCursors(
snapshot: AgentSnapshot,
deltas: readonly AgentDirectoryDelta[],
): void {
if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor);
for (const delta of deltas) this.noteLiveCursor("agents", delta);
}
private async fetchProjectSnapshot(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
supportsDirectorySync: boolean,
): Promise<void> {
const payload = await client.listProjects(
supportsDirectorySync ? { sync: this.readCursors().projects ?? {} } : undefined,
);
this.assertWorkspaceTransactionCurrent(client, source, transaction);
if (payload.sync?.mode === "snapshot") transaction.snapshot.projects.clear();
for (const entry of payload.projects) {
const project = normalizeProjectDescriptor(entry);
transaction.snapshot.projects.set(project.projectId, project);
}
for (const removal of payload.sync?.removals ?? []) {
transaction.snapshot.projects.delete(removal.id);
}
if (payload.sync) {
(transaction.snapshot.syncCursors ??= {}).projects = {
generation: payload.sync.generation,
afterSeq: payload.sync.headSeq,
};
}
}
private completeWorkspaceRefresh(
client: DaemonClient,
source: DirectorySourceToken,
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
): void {
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
}
const completion = this.workspaceTransactions.complete(transaction);
if (completion.kind === "stale") {
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
}
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) {
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
this.noteLiveCursor(entity, delta);
}
}
private recordAgentSync(
snapshot: AgentSnapshot,
sync: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["sync"],
): void {
if (!sync) return;
snapshot.syncMode = sync.mode;
snapshot.syncRemovals.push(...sync.removals);
snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq };
}
private noteLiveCursor(
entity: keyof DirectoryCursors,
payload: { generation?: string; seq?: number },
): void {
if (!payload.generation || payload.seq === undefined) return;
this.writeCursor(entity, {
generation: payload.generation,
afterSeq: payload.seq,
});
}
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<string, unknown>)[entity];
if (!cursor || typeof cursor !== "object" || Array.isArray(cursor)) continue;
const { generation, afterSeq } = cursor as Record<string, unknown>;
if (typeof generation === "string" && typeof afterSeq === "number" && afterSeq >= 0) {
cursors[entity] = { generation, afterSeq };
}
}
return cursors;
}
private writeCursor(entity: keyof DirectoryCursors, cursor: DirectoryCursor): void {
if (!this.checkpoints) return;
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 });
}
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean { private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
return ( return (
this.connection.client === client && this.connection.client === client &&
@@ -21,6 +21,9 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
export interface WorkspaceDirectorySnapshot { export interface WorkspaceDirectorySnapshot {
workspaces: Map<string, WorkspaceDescriptor>; workspaces: Map<string, WorkspaceDescriptor>;
projects: Map<string, ProjectDescriptor>; projects: Map<string, ProjectDescriptor>;
syncCursors?: Partial<
Record<"projects" | "workspaces", { generation: string; afterSeq: number }>
>;
} }
function applyProjectDelta( function applyProjectDelta(
+19
View File
@@ -51,3 +51,22 @@ export function useHostFeatureMap(
[flags, serverIds], [flags, serverIds],
); );
} }
export function useHostFeatureAvailabilityMap(
serverIds: readonly string[],
feature: HostFeatureName,
): ReadonlyMap<string, boolean | null> {
const flags = useSessionStore(
useShallow((state) =>
serverIds.map((serverId) => {
const serverInfo = state.sessions[serverId]?.serverInfo;
return serverInfo ? hostSupportsFeature(serverInfo, feature) : null;
}),
),
);
return useMemo(
() => new Map(serverIds.map((serverId, index) => [serverId, flags[index]] as const)),
[flags, serverIds],
);
}
+28 -30
View File
@@ -60,6 +60,8 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su
import { encodeImages } from "@/utils/encode-images"; import { encodeImages } from "@/utils/encode-images";
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync"; import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
import { ReplicaCache } from "@/runtime/replica-cache"; import { ReplicaCache } from "@/runtime/replica-cache";
import { replicaCacheStorage } from "@/runtime/replica-cache/storage";
import { projectIconCache } from "@/projects/icon-cache";
import { nativePerformanceTrace } from "@/performance/native-trace"; import { nativePerformanceTrace } from "@/performance/native-trace";
import { revokePushNotifications } from "@/push-notifications"; import { revokePushNotifications } from "@/push-notifications";
import { createAppWebSocketFactory } from "./websocket-factory"; import { createAppWebSocketFactory } from "./websocket-factory";
@@ -1374,7 +1376,7 @@ export class HostRuntimeStore {
}) { }) {
this.deps = input?.deps ?? createDefaultDeps(); this.deps = input?.deps ?? createDefaultDeps();
this.storage = input?.storage ?? AsyncStorage; this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(this.storage); this.replicaCache = new ReplicaCache(input?.storage ?? replicaCacheStorage);
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications; this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
} }
@@ -1473,7 +1475,8 @@ export class HostRuntimeStore {
} }
this.hosts = profiles; this.hosts = profiles;
this.replicaCache.setHosts(profiles.map((profile) => profile.serverId)); this.replicaCache.setHosts(profiles.map((profile) => profile.serverId));
await this.replicaCache.restore(); projectIconCache.setHosts(profiles.map((profile) => profile.serverId));
await Promise.all([this.replicaCache.restore(), projectIconCache.restore()]);
this.syncHosts(profiles); this.syncHosts(profiles);
} catch (error) { } catch (error) {
console.error("[HostRuntime] Failed to load host registry from storage", error); console.error("[HostRuntime] Failed to load host registry from storage", error);
@@ -1610,14 +1613,19 @@ export class HostRuntimeStore {
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId); rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId); rekeyMap(this.directoryBootstrapInFlight, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId); this.replicaCache.reconcileServerId(oldServerId, newServerId);
projectIconCache.reconcileServerId(oldServerId, newServerId);
this.directorySyncByServer.get(oldServerId)?.dispose(); this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId); this.directorySyncByServer.delete(oldServerId);
const directory = new DirectorySync(newServerId, { const directory = new DirectorySync(
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId), newServerId,
markAgentLoading: () => controller.markAgentDirectorySyncLoading(), {
markAgentReady: () => controller.markAgentDirectorySyncReady(), onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentError: (error) => controller.markAgentDirectorySyncError(error), markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
}); markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(newServerId, directory); this.directorySyncByServer.set(newServerId, directory);
controller.adoptReconciledServerId(newServerId); controller.adoptReconciledServerId(newServerId);
const snapshot = controller.getSnapshot(); const snapshot = controller.getSnapshot();
@@ -1970,6 +1978,7 @@ export class HostRuntimeStore {
}, },
): void { ): void {
this.replicaCache.setHosts(hosts.map((host) => host.serverId)); this.replicaCache.setHosts(hosts.map((host) => host.serverId));
projectIconCache.setHosts(hosts.map((host) => host.serverId));
const nextIds = new Set(hosts.map((host) => host.serverId)); const nextIds = new Set(hosts.map((host) => host.serverId));
for (const [serverId, controller] of this.controllers) { for (const [serverId, controller] of this.controllers) {
if (nextIds.has(serverId)) { if (nextIds.has(serverId)) {
@@ -2006,12 +2015,17 @@ export class HostRuntimeStore {
this.controllers.set(host.serverId, controller); this.controllers.set(host.serverId, controller);
this.directorySyncByServer.set( this.directorySyncByServer.set(
host.serverId, host.serverId,
new DirectorySync(host.serverId, { new DirectorySync(
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId), host.serverId,
markAgentLoading: () => controller.markAgentDirectorySyncLoading(), {
markAgentReady: () => controller.markAgentDirectorySyncReady(), onAgentStoppedRunning: (agentId) =>
markAgentError: (error) => controller.markAgentDirectorySyncError(error), this.drainQueuedAgentMessage(host.serverId, agentId),
}), markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
),
); );
const initialSnapshot = controller.getSnapshot(); const initialSnapshot = controller.getSnapshot();
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus); this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
@@ -2294,22 +2308,6 @@ export class HostRuntimeStore {
} }
} }
markAgentDirectorySyncLoading(serverId: string): void {
this.controllers.get(serverId)?.markAgentDirectorySyncLoading();
}
markAgentDirectorySyncReady(serverId: string): void {
this.controllers.get(serverId)?.markAgentDirectorySyncReady();
}
markAgentDirectorySyncError(serverId: string, error: string): void {
this.controllers.get(serverId)?.markAgentDirectorySyncError(error);
}
markAgentDirectorySyncIdle(serverId: string): void {
this.controllers.get(serverId)?.markAgentDirectorySyncIdle();
}
private emit(serverId: string): void { private emit(serverId: string): void {
this.version += 1; this.version += 1;
const listeners = this.serverListeners.get(serverId); const listeners = this.serverListeners.get(serverId);
@@ -55,34 +55,50 @@ function workspace(
} }
function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") { function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") {
return normalizeAgentSnapshot( return {
{ ...normalizeAgentSnapshot(
id, {
provider: "codex", id,
cwd, provider: "codex",
workspaceId, cwd,
model: null, workspaceId,
createdAt: "2026-07-18T08:00:00.000Z", model: null,
updatedAt: "2026-07-18T08:01:00.000Z", createdAt: "2026-07-18T08:00:00.000Z",
lastUserMessageAt: "2026-07-18T08:01:00.000Z", updatedAt: "2026-07-18T08:01:00.000Z",
status: "idle", lastUserMessageAt: "2026-07-18T08:01:00.000Z",
capabilities: { status: "idle",
supportsStreaming: true, capabilities: {
supportsSessionPersistence: true, supportsStreaming: true,
supportsDynamicModes: true, supportsSessionPersistence: true,
supportsMcpServers: true, supportsDynamicModes: true,
supportsReasoningStream: true, supportsMcpServers: true,
supportsToolInvocations: true, supportsReasoningStream: true,
supportsToolInvocations: true,
},
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: `Agent ${id}`,
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,
}, },
currentModeId: null,
availableModes: [],
pendingPermissions: [],
persistence: null,
title: `Agent ${id}`,
labels: {},
}, },
SERVER_ID, };
);
} }
function message(id: string, text: string): StreamItem { function message(id: string, text: string): StreamItem {
@@ -212,10 +228,12 @@ describe("ReplicaCache", () => {
expect(session.client).toBeNull(); expect(session.client).toBeNull();
expect(session.hasHydratedAgents).toBe(false); expect(session.hasHydratedAgents).toBe(false);
expect(session.hasHydratedWorkspaces).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.agents.keys())).toEqual(["agent-1"]);
expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]); expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]);
expect(Array.from(session.projects.keys())).toEqual(["project-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")?.updatedAt).toBeInstanceOf(Date);
expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo");
expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date); expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree"); expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree");
expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]); expect(session.agentStreamTail.get("agent-1")).toEqual([message("message-1", "Cached")]);
@@ -230,7 +248,7 @@ describe("ReplicaCache", () => {
}); });
}); });
it("persists only the focused agent view with a short timeline tail", async () => { it("persists the complete directory with only the focused timeline tail", async () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const cache = new ReplicaCache(storage); const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]); cache.setHosts([SERVER_ID]);
@@ -266,9 +284,13 @@ describe("ReplicaCache", () => {
const session = useSessionStore.getState().sessions[SERVER_ID]; const session = useSessionStore.getState().sessions[SERVER_ID];
const timelines = session?.agentStreamTail; const timelines = session?.agentStreamTail;
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]); expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1", "agent-2"]);
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]); expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1", "workspace-2"]);
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]); expect(Array.from(session?.projects.keys() ?? [])).toEqual([
"project-1",
"project-2",
"empty-project",
]);
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]); expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50)); expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
@@ -310,6 +332,53 @@ describe("ReplicaCache", () => {
]); ]);
}); });
it("persists monotonic directory cursors with the complete host replica", async () => {
const storage = new MemoryStorage();
const cache = new ReplicaCache(storage);
cache.setHosts([SERVER_ID]);
seedSession();
await cache.flush();
cache.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 cache.flush();
const reader = new ReplicaCache(storage);
reader.setHosts([SERVER_ID]);
await reader.restore();
expect(reader.readDirectoryCheckpoint(SERVER_ID)).toEqual({
agents: { generation: "daemon-generation", afterSeq: 7 },
});
});
it("restores every registered host directory before any host reconnects", async () => {
const storage = new MemoryStorage();
const writer = new ReplicaCache(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 = new ReplicaCache(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 () => { it("evicts the least recently written host when the cache exceeds its byte budget", async () => {
const storage = new MemoryStorage(); const storage = new MemoryStorage();
const cache = new ReplicaCache(storage, { maxBytes: 7_000 }); const cache = new ReplicaCache(storage, { maxBytes: 7_000 });
+90 -28
View File
@@ -8,7 +8,6 @@ import {
selectAgentTimelineState, selectAgentTimelineState,
useSessionStore, useSessionStore,
type Agent, type Agent,
type AgentTimelineState,
type SessionReplica, type SessionReplica,
type SessionState, type SessionState,
type ProjectDescriptor, type ProjectDescriptor,
@@ -21,7 +20,7 @@ const STORAGE_KEY = "@paseo:replica-cache";
const CACHE_VERSION = 5; const CACHE_VERSION = 5;
const PERSIST_DELAY_MS = 750; const PERSIST_DELAY_MS = 750;
const MAX_TIMELINE_ITEMS = 50; const MAX_TIMELINE_ITEMS = 50;
const MAX_CACHE_BYTES = 1024 * 1024; const MAX_CACHE_BYTES = 32 * 1024 * 1024;
const IsoDateSchema = z.iso.datetime(); const IsoDateSchema = z.iso.datetime();
const TimelinePositionSchema = z.strictObject({ const TimelinePositionSchema = z.strictObject({
epoch: z.string(), epoch: z.string(),
@@ -107,6 +106,43 @@ const AgentCapabilitiesSchema = z.strictObject({
supportsRewindBoth: z.boolean().optional(), supportsRewindBoth: z.boolean().optional(),
}); });
const StoredProjectCheckoutSchema = z.union([
z.strictObject({
cwd: z.string(),
isGit: z.literal(false),
currentBranch: z.null(),
remoteUrl: z.null(),
worktreeRoot: z.null(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.null(),
}),
z.strictObject({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(false),
mainRepoRoot: z.string().nullable(),
}),
z.strictObject({
cwd: z.string(),
isGit: z.literal(true),
currentBranch: z.string().nullable(),
remoteUrl: z.string().nullable(),
worktreeRoot: z.string(),
isPaseoOwnedWorktree: z.literal(true),
mainRepoRoot: z.string(),
}),
]);
const StoredProjectPlacementSchema = z.strictObject({
projectKey: z.string(),
projectName: z.string(),
workspaceName: z.string().nullable().optional(),
checkout: StoredProjectCheckoutSchema,
});
const StoredAgentSnapshotSchema = z.strictObject({ const StoredAgentSnapshotSchema = z.strictObject({
id: z.string(), id: z.string(),
provider: AgentProviderSchema, provider: AgentProviderSchema,
@@ -141,7 +177,7 @@ const StoredAgentSnapshotSchema = z.strictObject({
const StoredAgentSchema = z.strictObject({ const StoredAgentSchema = z.strictObject({
snapshot: StoredAgentSnapshotSchema, snapshot: StoredAgentSnapshotSchema,
projectPlacement: z.null(), projectPlacement: StoredProjectPlacementSchema.nullable(),
lastActivityAt: IsoDateSchema, lastActivityAt: IsoDateSchema,
}); });
@@ -201,6 +237,8 @@ const StoredProjectSchema = z.strictObject({
projectKey: z.string().optional(), projectKey: z.string().optional(),
projectDisplayName: z.string(), projectDisplayName: z.string(),
projectCustomName: z.string().nullable(), projectCustomName: z.string().nullable(),
projectCustomIconRevision: z.string().nullable(),
projectIconRevision: z.string().optional(),
projectRootPath: z.string(), projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]), projectKind: z.enum(["git", "non_git", "directory"]),
}); });
@@ -217,6 +255,7 @@ const StoredHostSchema = z.strictObject({
projects: z.array(StoredProjectSchema), projects: z.array(StoredProjectSchema),
emptyProjects: z.array(StoredProjectSchema), emptyProjects: z.array(StoredProjectSchema),
timeline: StoredTimelineSchema.nullable(), timeline: StoredTimelineSchema.nullable(),
directorySync: z.unknown().optional(),
}); });
const StoredCacheSchema = z.strictObject({ const StoredCacheSchema = z.strictObject({
@@ -231,9 +270,10 @@ type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
type StoredProject = z.infer<typeof StoredProjectSchema>; type StoredProject = z.infer<typeof StoredProjectSchema>;
interface ReplicaInput { interface ReplicaInput {
agent: Agent | undefined; agents: ReadonlyMap<string, Agent>;
workspace: WorkspaceDescriptor | undefined; workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
project: ProjectDescriptor | undefined; projects: ReadonlyMap<string, ProjectDescriptor>;
focusedAgent: Agent | undefined;
timelineItems: StreamItem[] | undefined; timelineItems: StreamItem[] | undefined;
} }
@@ -367,6 +407,10 @@ function deserializeTimelineItem(item: StoredTimelineItem): StreamItem {
} }
} }
function serializeProjectPlacement(agent: Agent): StoredAgent["projectPlacement"] {
return agent.projectPlacement ?? null;
}
function serializeAgent(agent: Agent): StoredAgent { function serializeAgent(agent: Agent): StoredAgent {
const snapshot = { const snapshot = {
id: agent.id, id: agent.id,
@@ -421,7 +465,7 @@ function serializeAgent(agent: Agent): StoredAgent {
}; };
return { return {
snapshot, snapshot,
projectPlacement: null, projectPlacement: serializeProjectPlacement(agent),
lastActivityAt: agent.lastActivityAt.toISOString(), lastActivityAt: agent.lastActivityAt.toISOString(),
}; };
} }
@@ -478,6 +522,8 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
...(project.projectKey ? { projectKey: project.projectKey } : {}), ...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: project.projectDisplayName, projectDisplayName: project.projectDisplayName,
projectCustomName: project.projectCustomName, projectCustomName: project.projectCustomName,
projectCustomIconRevision: project.projectCustomIconRevision ?? null,
projectIconRevision: project.projectIconRevision,
projectRootPath: project.projectRootPath, projectRootPath: project.projectRootPath,
projectKind: project.projectKind, projectKind: project.projectKind,
}; };
@@ -485,50 +531,47 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean { function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean {
return ( return (
left.agent === right.agent && left.agents === right.agents &&
left.workspace === right.workspace && left.workspaces === right.workspaces &&
left.project === right.project && left.projects === right.projects &&
left.focusedAgent === right.focusedAgent &&
left.timelineItems === right.timelineItems left.timelineItems === right.timelineItems
); );
} }
function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput { function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput {
const agent = agentId ? session.agents.get(agentId) : undefined; const agent = agentId ? session.agents.get(agentId) : undefined;
const workspace = agent const timeline = agentId
? ((agent.workspaceId ? session.workspaces.get(agent.workspaceId) : undefined) ??
Array.from(session.workspaces.values()).find(
(candidate) => candidate.workspaceDirectory === agent.cwd,
))
: undefined;
const timeline: AgentTimelineState = agentId
? selectAgentTimelineState(session, agentId) ? selectAgentTimelineState(session, agentId)
: { status: "cold" }; : { status: "cold" as const };
return { return {
agent, agents: session.agents,
workspace, workspaces: session.workspaces,
project: workspace ? session.projects.get(workspace.projectId) : undefined, projects: session.projects,
focusedAgent: agent,
timelineItems: timeline.status === "cold" ? undefined : timeline.items, timelineItems: timeline.status === "cold" ? undefined : timeline.items,
}; };
} }
function serializeHost(serverId: string, input: ReplicaInput): StoredHost { function serializeHost(serverId: string, input: ReplicaInput, directorySync?: unknown): StoredHost {
const items = input.timelineItems const items = input.timelineItems
?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item)) ?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item))
.map(serializeTimelineItem) .map(serializeTimelineItem)
.filter((item) => item !== null); .filter((item) => item !== null);
return { return {
serverId, serverId,
agents: input.agent ? [serializeAgent(input.agent)] : [], agents: Array.from(input.agents.values(), serializeAgent),
workspaces: input.workspace ? [serializeWorkspace(input.workspace)] : [], workspaces: Array.from(input.workspaces.values(), serializeWorkspace),
projects: input.project ? [serializeProject(input.project)] : [], projects: Array.from(input.projects.values(), serializeProject),
emptyProjects: [], emptyProjects: [],
timeline: timeline:
input.agent && items input.focusedAgent && items
? { ? {
agentId: input.agent.id, agentId: input.focusedAgent.id,
items: items.slice(-MAX_TIMELINE_ITEMS), items: items.slice(-MAX_TIMELINE_ITEMS),
} }
: null, : null,
...(directorySync ? { directorySync } : {}),
}; };
} }
@@ -688,6 +731,24 @@ export class ReplicaCache {
this.schedulePersist(); this.schedulePersist();
} }
readDirectoryCheckpoint(serverId: string): unknown {
return this.storedHosts.get(serverId)?.directorySync;
}
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void {
let stored = this.storedHosts.get(serverId);
if (!stored) {
const session = useSessionStore.getState().sessions[serverId];
if (!session) return;
const input = selectReplicaInput(session, this.lastFocusedAgentIds.get(serverId) ?? null);
this.capturedInputs.set(serverId, input);
stored = serializeHost(serverId, input);
}
this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, { ...stored, directorySync: checkpoint });
this.schedulePersist();
}
async flush(): Promise<void> { async flush(): Promise<void> {
if (this.persistTimer) { if (this.persistTimer) {
clearTimeout(this.persistTimer); clearTimeout(this.persistTimer);
@@ -727,8 +788,9 @@ export class ReplicaCache {
if (previous && replicaInputsEqual(previous, input)) return false; if (previous && replicaInputsEqual(previous, input)) return false;
this.capturedInputs.set(serverId, input); this.capturedInputs.set(serverId, input);
const directorySync = this.storedHosts.get(serverId)?.directorySync;
this.storedHosts.delete(serverId); this.storedHosts.delete(serverId);
this.storedHosts.set(serverId, serializeHost(serverId, input)); this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync));
return true; return true;
} }
@@ -0,0 +1,4 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { ReplicaCacheStorage } from "./index";
export const replicaCacheStorage: ReplicaCacheStorage = AsyncStorage;
@@ -0,0 +1,70 @@
import type { ReplicaCacheStorage } from "./index";
const DATABASE_NAME = "paseo-replica-cache";
const STORE_NAME = "key-value";
let databasePromise: Promise<IDBDatabase> | null = null;
function openDatabase(): Promise<IDBDatabase> {
databasePromise ??= new Promise((resolve, reject) => {
const request = indexedDB.open(DATABASE_NAME, 1);
request.addEventListener("upgradeneeded", () => {
if (!request.result.objectStoreNames.contains(STORE_NAME)) {
request.result.createObjectStore(STORE_NAME);
}
});
request.addEventListener("success", () => resolve(request.result));
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to open replica cache")),
);
request.addEventListener("blocked", () =>
reject(new Error("Replica cache database upgrade was blocked")),
);
});
return databasePromise;
}
async function getItem(key: string): Promise<string | null> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const request = database.transaction(STORE_NAME, "readonly").objectStore(STORE_NAME).get(key);
request.addEventListener("success", () =>
resolve(typeof request.result === "string" ? request.result : null),
);
request.addEventListener("error", () =>
reject(request.error ?? new Error("Failed to read replica cache")),
);
});
}
async function setItem(key: string, value: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).put(value, key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to write replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache write was aborted")),
);
});
}
async function removeItem(key: string): Promise<void> {
const database = await openDatabase();
return new Promise((resolve, reject) => {
const transaction = database.transaction(STORE_NAME, "readwrite");
transaction.objectStore(STORE_NAME).delete(key);
transaction.addEventListener("complete", () => resolve());
transaction.addEventListener("error", () =>
reject(transaction.error ?? new Error("Failed to remove replica cache")),
);
transaction.addEventListener("abort", () =>
reject(transaction.error ?? new Error("Replica cache removal was aborted")),
);
});
}
export const replicaCacheStorage: ReplicaCacheStorage = { getItem, setItem, removeItem };
@@ -5,6 +5,7 @@ import {
composeWorkspaceStructure, composeWorkspaceStructure,
selectHasHydratedWorkspaces, selectHasHydratedWorkspaces,
selectHydratedWorkspaceServerIds, selectHydratedWorkspaceServerIds,
selectWorkspaceDirectoryServerIds,
selectHasWorkspaces, selectHasWorkspaces,
selectProjectOrder, selectProjectOrder,
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
@@ -79,6 +80,14 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
); );
} }
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
return useStoreWithEqualityFn(
useSessionStore,
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
workspaceEqualityFns.deep,
);
}
export function useWorkspaceDirectory( export function useWorkspaceDirectory(
serverId: string | null, serverId: string | null,
workspaceId: string | null, workspaceId: string | null,
@@ -6,6 +6,7 @@ import {
composeWorkspaceStructure, composeWorkspaceStructure,
selectHasWorkspaces, selectHasWorkspaces,
selectHydratedWorkspaceServerIds, selectHydratedWorkspaceServerIds,
selectWorkspaceDirectoryServerIds,
selectProjectOrder, selectProjectOrder,
selectRecommendedProjectPaths, selectRecommendedProjectPaths,
selectWorkspace, selectWorkspace,
@@ -131,20 +132,32 @@ afterEach(() => {
}); });
describe("workspace replica authority", () => { describe("workspace replica authority", () => {
it("keeps a cached workspace addressable without publishing it as an authoritative directory", () => { it("publishes a restored directory while keeping remote hydration false", () => {
const cachedWorkspace = createWorkspace({ id: "cached-workspace" }); const cachedWorkspace = createWorkspace({ id: "cached-workspace" });
initializeWorkspaces([cachedWorkspace]); useSessionStore.getState().restoreSessionReplica(SERVER_ID, {
agents: new Map(),
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
projects: new Map([
[cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)],
]),
timeline: null,
});
const cachedSession = useSessionStore.getState().sessions[SERVER_ID];
if (!cachedSession) throw new Error("expected initialized session");
const cachedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [ const cachedServerIds = selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [
SERVER_ID, SERVER_ID,
]); ]);
expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe( expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe(
cachedWorkspace, cachedWorkspace,
); );
expect(selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds)).toEqual( expect(cachedSession.hasHydratedWorkspaces).toBe(false);
[], expect(
); selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds).map(
(project) => project.workspaceKeys,
),
).toEqual([[`${SERVER_ID}:${cachedWorkspace.id}`]]);
const authoritativeWorkspace = createWorkspace({ const authoritativeWorkspace = createWorkspace({
id: "authoritative-workspace", id: "authoritative-workspace",
@@ -16,6 +16,7 @@ export interface SessionsSnapshot {
string, string,
{ {
hasHydratedWorkspaces?: boolean; hasHydratedWorkspaces?: boolean;
hasWorkspaceDirectorySnapshot?: boolean;
workspaces: Map<string, WorkspaceDescriptor>; workspaces: Map<string, WorkspaceDescriptor>;
projects?: Map<string, ProjectDescriptor>; projects?: Map<string, ProjectDescriptor>;
} }
@@ -138,6 +139,18 @@ export function selectHydratedWorkspaceServerIds(
return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true); return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true);
} }
export function selectWorkspaceDirectoryServerIds(
state: SessionsSnapshot,
serverIds: readonly string[],
): string[] {
return serverIds.filter((serverId) => {
const session = state.sessions[serverId];
return (
session?.hasHydratedWorkspaces === true || session?.hasWorkspaceDirectorySnapshot === true
);
});
}
export function selectWorkspaceStructureProjects( export function selectWorkspaceStructureProjects(
state: SessionsSnapshot, state: SessionsSnapshot,
serverIds: readonly string[], serverIds: readonly string[],
+5
View File
@@ -172,6 +172,7 @@ export interface ProjectDescriptor {
projectDisplayName: string; projectDisplayName: string;
projectCustomName: string | null; projectCustomName: string | null;
projectCustomIconRevision?: string | null; projectCustomIconRevision?: string | null;
projectIconRevision?: string;
projectRootPath: string; projectRootPath: string;
projectKind: WorkspaceDescriptorPayload["projectKind"]; projectKind: WorkspaceDescriptorPayload["projectKind"];
} }
@@ -185,6 +186,7 @@ export function normalizeProjectDescriptor(
projectDisplayName: payload.projectDisplayName, projectDisplayName: payload.projectDisplayName,
projectCustomName: payload.projectCustomName ?? null, projectCustomName: payload.projectCustomName ?? null,
projectCustomIconRevision: payload.projectCustomIconRevision ?? null, projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
projectIconRevision: payload.projectIconRevision,
projectRootPath: payload.projectRootPath, projectRootPath: payload.projectRootPath,
projectKind: payload.projectKind, projectKind: payload.projectKind,
}; };
@@ -391,6 +393,7 @@ export interface SessionState {
// Hydration status // Hydration status
hasHydratedAgents: boolean; hasHydratedAgents: boolean;
hasHydratedWorkspaces: boolean; hasHydratedWorkspaces: boolean;
hasWorkspaceDirectorySnapshot: boolean;
// Audio state // Audio state
isPlayingAudio: boolean; isPlayingAudio: boolean;
@@ -653,6 +656,7 @@ function createInitialSessionState(
serverInfo: null, serverInfo: null,
hasHydratedAgents: false, hasHydratedAgents: false,
hasHydratedWorkspaces: false, hasHydratedWorkspaces: false,
hasWorkspaceDirectorySnapshot: false,
isPlayingAudio: false, isPlayingAudio: false,
focusedAgentId: null, focusedAgentId: null,
focusedTerminalId: null, focusedTerminalId: null,
@@ -799,6 +803,7 @@ export const useSessionStore = create<SessionStore>()(
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents), workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
workspaces: replica.workspaces, workspaces: replica.workspaces,
projects: replica.projects, projects: replica.projects,
hasWorkspaceDirectorySnapshot: true,
agentStreamTail, agentStreamTail,
agentTasks, agentTasks,
}, },
+13 -1
View File
@@ -1,7 +1,7 @@
import { describe, expect, it } from "vitest"; import { describe, expect, it } from "vitest";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages"; import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { normalizeAgentSnapshot } from "./agent-snapshots"; import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
function createSnapshot( function createSnapshot(
input: Partial<Omit<AgentSnapshotPayload, "labels">> & { input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
@@ -36,6 +36,18 @@ function createSnapshot(
} }
describe("normalizeAgentSnapshot", () => { describe("normalizeAgentSnapshot", () => {
it("round-trips identified active turns through the canonical snapshot boundary", () => {
const snapshot = createSnapshot({
status: "running",
activeTurn: { turnId: "turn-1", startedAt: "2026-07-31T12:00:00.000Z" },
});
expect(projectAgentSnapshot(normalizeAgentSnapshot(snapshot, "server-1"))).toMatchObject({
status: "running",
activeTurn: snapshot.activeTurn,
});
});
it("normalizes identified and legacy active turns at the snapshot boundary", () => { it("normalizes identified and legacy active turns at the snapshot boundary", () => {
const startedAt = "2026-07-31T12:00:00.000Z"; const startedAt = "2026-07-31T12:00:00.000Z";
expect( expect(
+43
View File
@@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types"; import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type { ActiveTurnIdentity } from "@/timeline/turn-liveness"; import type { ActiveTurnIdentity } from "@/timeline/turn-liveness";
import type { Agent } from "@/stores/session-store";
function normalizeActiveTurn( function normalizeActiveTurn(
snapshot: AgentSnapshotPayload, snapshot: AgentSnapshotPayload,
@@ -17,6 +18,17 @@ function normalizeActiveTurn(
return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null; return snapshot.status === "running" ? { turnId: null, startedAt: lastUserMessageAt } : null;
} }
function projectActiveTurn(agent: Agent): Pick<AgentSnapshotPayload, "activeTurn"> {
if (agent.activeTurn === null) return { activeTurn: null };
if (agent.activeTurn.turnId === null) return {};
return {
activeTurn: {
turnId: agent.activeTurn.turnId,
startedAt: agent.activeTurn.startedAt?.toISOString() ?? null,
},
};
}
export function derivePendingPermissionKey( export function derivePendingPermissionKey(
agentId: string, agentId: string,
request: AgentPermissionRequest, request: AgentPermissionRequest,
@@ -31,6 +43,37 @@ export function derivePendingPermissionKey(
return `${agentId}:${fallbackId}`; return `${agentId}:${fallbackId}`;
} }
export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
return {
id: agent.id,
provider: agent.provider,
cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
model: agent.model,
...(agent.features ? { features: agent.features } : {}),
thinkingOptionId: agent.thinkingOptionId ?? null,
createdAt: agent.createdAt.toISOString(),
updatedAt: agent.updatedAt.toISOString(),
lastUserMessageAt: agent.lastUserMessageAt?.toISOString() ?? null,
status: agent.status,
...projectActiveTurn(agent),
capabilities: agent.capabilities,
currentModeId: agent.currentModeId,
availableModes: agent.availableModes,
pendingPermissions: agent.pendingPermissions,
persistence: agent.persistence,
...(agent.runtimeInfo ? { runtimeInfo: agent.runtimeInfo } : {}),
...(agent.lastUsage ? { lastUsage: agent.lastUsage } : {}),
...(agent.lastError ? { lastError: agent.lastError } : {}),
title: agent.title,
labels: agent.labels,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
attentionTimestamp: agent.attentionTimestamp?.toISOString() ?? null,
archivedAt: agent.archivedAt?.toISOString() ?? null,
};
}
export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) { export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
const createdAt = new Date(snapshot.createdAt); const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt); const updatedAt = new Date(snapshot.updatedAt);
@@ -5,6 +5,7 @@ export interface SidebarProjectHostTarget {
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
} }
export type SidebarProjectTrailingAction = export type SidebarProjectTrailingAction =
@@ -25,6 +26,7 @@ function hostTarget(input: {
projectId: string; projectId: string;
iconWorkingDir: string; iconWorkingDir: string;
customIconRevision?: string | null; customIconRevision?: string | null;
iconRevision?: string;
}): SidebarProjectHostTarget | null { }): SidebarProjectHostTarget | null {
const iconWorkingDir = input.iconWorkingDir.trim(); const iconWorkingDir = input.iconWorkingDir.trim();
if (!input.serverId || !iconWorkingDir) { if (!input.serverId || !iconWorkingDir) {
@@ -35,6 +37,7 @@ function hostTarget(input: {
projectId: input.projectId, projectId: input.projectId,
iconWorkingDir, iconWorkingDir,
customIconRevision: input.customIconRevision, customIconRevision: input.customIconRevision,
iconRevision: input.iconRevision,
}; };
} }
+9 -1
View File
@@ -677,6 +677,10 @@ export type ProjectListPayload = Extract<
SessionOutboundMessage, SessionOutboundMessage,
{ type: "project.list.response" } { type: "project.list.response" }
>["payload"]; >["payload"];
type ProjectListRequest = Extract<SessionInboundMessage, { type: "project.list.request" }>;
export type ProjectListOptions = Omit<ProjectListRequest, "type" | "requestId"> & {
requestId?: string;
};
export interface CreateScheduleOptions { export interface CreateScheduleOptions {
prompt: string; prompt: string;
name?: string | null; name?: string | null;
@@ -2004,6 +2008,7 @@ export class DaemonClient {
...(options?.sort ? { sort: options.sort } : {}), ...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}), ...(options?.page ? { page: options.page } : {}),
...(options?.subscribe ? { subscribe: options.subscribe } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}),
...(options?.sync ? { sync: options.sync } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -2085,6 +2090,7 @@ export class DaemonClient {
...(options?.sort ? { sort: options.sort } : {}), ...(options?.sort ? { sort: options.sort } : {}),
...(options?.page ? { page: options.page } : {}), ...(options?.page ? { page: options.page } : {}),
...(options?.subscribe ? { subscribe: options.subscribe } : {}), ...(options?.subscribe ? { subscribe: options.subscribe } : {}),
...(options?.sync ? { sync: options.sync } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
@@ -2102,11 +2108,13 @@ export class DaemonClient {
}); });
} }
async listProjects(requestId?: string): Promise<ProjectListPayload> { async listProjects(options?: string | ProjectListOptions): Promise<ProjectListPayload> {
const requestId = typeof options === "string" ? options : options?.requestId;
const resolvedRequestId = this.createRequestId(requestId); const resolvedRequestId = this.createRequestId(requestId);
const message = SessionInboundMessageSchema.parse({ const message = SessionInboundMessageSchema.parse({
type: "project.list.request", type: "project.list.request",
requestId: resolvedRequestId, requestId: resolvedRequestId,
...(typeof options === "object" && options.sync ? { sync: options.sync } : {}),
}); });
return this.sendRequest({ return this.sendRequest({
requestId: resolvedRequestId, requestId: resolvedRequestId,
+23
View File
@@ -5,6 +5,7 @@ import {
parseServerInfoStatusPayload, parseServerInfoStatusPayload,
SessionInboundMessageSchema, SessionInboundMessageSchema,
SessionOutboundMessageSchema, SessionOutboundMessageSchema,
WorkspaceProjectDescriptorPayloadSchema,
} from "./messages.js"; } from "./messages.js";
function workspaceDescriptor(overrides: Record<string, unknown> = {}) { function workspaceDescriptor(overrides: Record<string, unknown> = {}) {
@@ -53,6 +54,28 @@ describe("project icon message security", () => {
}); });
}); });
describe("project icon revision compatibility", () => {
const project = {
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo/project",
projectKind: "git" as const,
};
test("accepts an old project snapshot without an effective icon revision", () => {
expect(WorkspaceProjectDescriptorPayloadSchema.parse(project)).toEqual(project);
});
test("accepts an effective icon revision on a new project snapshot", () => {
expect(
WorkspaceProjectDescriptorPayloadSchema.parse({
...project,
projectIconRevision: "automatic:none:v1",
}),
).toEqual({ ...project, projectIconRevision: "automatic:none:v1" });
});
});
describe("workspace descriptor message compatibility", () => { describe("workspace descriptor message compatibility", () => {
test("old-shaped fetch_workspaces_response without project still parses", () => { test("old-shaped fetch_workspaces_response without project still parses", () => {
const parsed = SessionOutboundMessageSchema.parse( const parsed = SessionOutboundMessageSchema.parse(
+61 -2
View File
@@ -1104,6 +1104,26 @@ export const SendAgentMessageSchema = z.object({
// Agent RPCs (requestId-correlated) // Agent RPCs (requestId-correlated)
// ============================================================================ // ============================================================================
const DirectorySyncRequestSchema = z.object({
generation: z.string().optional(),
afterSeq: z.number().int().nonnegative().optional(),
});
const DirectorySyncRemovalSchema = z.object({
id: z.string(),
seq: z.number().int().positive(),
});
const DirectorySyncMetadataSchema = z.object({
generation: z.string(),
headSeq: z.number().int().nonnegative(),
mode: z.enum(["snapshot", "changes"]),
reason: z
.enum(["no_cursor", "generation_changed", "cursor_expired", "changes_too_large"])
.optional(),
removals: z.array(DirectorySyncRemovalSchema),
});
export const FetchAgentsRequestMessageSchema = z.object({ export const FetchAgentsRequestMessageSchema = z.object({
type: z.literal("fetch_agents_request"), type: z.literal("fetch_agents_request"),
requestId: z.string(), requestId: z.string(),
@@ -1128,6 +1148,8 @@ export const FetchAgentsRequestMessageSchema = z.object({
subscriptionId: z.string().optional(), subscriptionId: z.string().optional(),
}) })
.optional(), .optional(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
}); });
const WorkspaceStateBucketSchema = z.enum([ const WorkspaceStateBucketSchema = z.enum([
@@ -1168,11 +1190,15 @@ export const FetchWorkspacesRequestMessageSchema = z.object({
subscriptionId: z.string().optional(), subscriptionId: z.string().optional(),
}) })
.optional(), .optional(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
}); });
export const ProjectListRequestMessageSchema = z.object({ export const ProjectListRequestMessageSchema = z.object({
type: z.literal("project.list.request"), type: z.literal("project.list.request"),
requestId: z.string(), requestId: z.string(),
// COMPAT(directorySync): added in v0.3.x, remove optional after 2027-02-12.
sync: DirectorySyncRequestSchema.optional(),
}); });
export const FetchAgentHistoryRequestMessageSchema = z.object({ export const FetchAgentHistoryRequestMessageSchema = z.object({
@@ -3038,6 +3064,8 @@ export const ServerInfoStatusPayloadSchema = z
providersSnapshot: z.boolean().optional(), providersSnapshot: z.boolean().optional(),
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
providersSnapshotCwd: z.boolean().optional(), providersSnapshotCwd: z.boolean().optional(),
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: z.boolean().optional(),
// COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old // COMPAT(checkoutForgeSetAutoMerge): added in v0.1.106, remove old
// checkoutGithubSetAutoMerge fallback after 2026-12-28. // checkoutGithubSetAutoMerge fallback after 2026-12-28.
checkoutForgeSetAutoMerge: z.boolean().optional(), checkoutForgeSetAutoMerge: z.boolean().optional(),
@@ -3457,6 +3485,8 @@ export const WorkspaceDescriptorPayloadSchema = z
// Old daemons omit it; absent means the client falls back to GitHub. // Old daemons omit it; absent means the client falls back to GitHub.
forge: z.string().optional(), forge: z.string().optional(),
project: ProjectPlacementPayloadSchema.optional(), project: ProjectPlacementPayloadSchema.optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
}) })
.transform((workspace) => ({ .transform((workspace) => ({
...workspace, ...workspace,
@@ -3470,10 +3500,14 @@ export const AgentUpdateMessageSchema = z.object({
kind: z.literal("upsert"), kind: z.literal("upsert"),
agent: AgentSnapshotPayloadSchema, agent: AgentSnapshotPayloadSchema,
project: ProjectPlacementPayloadSchema.nullable().optional(), project: ProjectPlacementPayloadSchema.nullable().optional(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}), }),
z.object({ z.object({
kind: z.literal("remove"), kind: z.literal("remove"),
agentId: z.string(), agentId: z.string(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}), }),
]), ]),
}); });
@@ -3531,6 +3565,8 @@ const AgentDirectoryResponseEntrySchema = z.object({
// anyway; sending it keeps the client from re-deriving a second opinion that // anyway; sending it keeps the client from re-deriving a second opinion that
// could disagree with the ranking it is explaining. // could disagree with the ranking it is explaining.
searchMatches: z.array(AgentSearchMatchSchema).optional(), searchMatches: z.array(AgentSearchMatchSchema).optional(),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
}); });
const AgentDirectoryPageInfoSchema = z.object({ const AgentDirectoryPageInfoSchema = z.object({
@@ -3546,6 +3582,7 @@ export const FetchAgentsResponseMessageSchema = z.object({
subscriptionId: z.string().nullable().optional(), subscriptionId: z.string().nullable().optional(),
entries: z.array(AgentDirectoryResponseEntrySchema), entries: z.array(AgentDirectoryResponseEntrySchema),
pageInfo: AgentDirectoryPageInfoSchema, pageInfo: AgentDirectoryPageInfoSchema,
sync: DirectorySyncMetadataSchema.optional(),
}), }),
}); });
@@ -3583,8 +3620,14 @@ export const WorkspaceProjectDescriptorPayloadSchema = z.object({
projectCustomName: z.string().nullable().optional(), projectCustomName: z.string().nullable().optional(),
// COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20. // COMPAT(projectCustomIcon): added in v0.2.0, remove after 2027-01-20.
projectCustomIconRevision: z.string().nullable().optional(), projectCustomIconRevision: z.string().nullable().optional(),
// Fingerprints the effective icon, including automatic discovery and the
// absence of an icon. Clients may persist icon results against this value.
// COMPAT(projectIconCache): added in v0.2.7, remove optional after 2027-02-12.
projectIconRevision: z.string().optional(),
projectRootPath: z.string(), projectRootPath: z.string(),
projectKind: z.enum(["git", "non_git", "directory"]), projectKind: z.enum(["git", "non_git", "directory"]),
// COMPAT(directorySync): sequence of this latest directory projection.
syncSeq: z.number().int().positive().optional(),
}); });
export const FetchWorkspacesResponseMessageSchema = z.object({ export const FetchWorkspacesResponseMessageSchema = z.object({
@@ -3602,6 +3645,7 @@ export const FetchWorkspacesResponseMessageSchema = z.object({
prevCursor: z.string().nullable(), prevCursor: z.string().nullable(),
hasMore: z.boolean(), hasMore: z.boolean(),
}), }),
sync: DirectorySyncMetadataSchema.optional(),
}), }),
}); });
@@ -3611,6 +3655,8 @@ export const WorkspaceUpdateMessageSchema = z.object({
z.object({ z.object({
kind: z.literal("upsert"), kind: z.literal("upsert"),
workspace: WorkspaceDescriptorPayloadSchema, workspace: WorkspaceDescriptorPayloadSchema,
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}), }),
z.object({ z.object({
kind: z.literal("remove"), kind: z.literal("remove"),
@@ -3625,6 +3671,8 @@ export const WorkspaceUpdateMessageSchema = z.object({
// Project removal is represented on the existing workspace update channel // Project removal is represented on the existing workspace update channel
// so old clients can still parse the message and ignore the extra field. // so old clients can still parse the message and ignore the extra field.
removedProjectId: z.string().optional(), removedProjectId: z.string().optional(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}), }),
]), ]),
}); });
@@ -3632,8 +3680,18 @@ export const WorkspaceUpdateMessageSchema = z.object({
export const ProjectUpdateMessageSchema = z.object({ export const ProjectUpdateMessageSchema = z.object({
type: z.literal("project.update"), type: z.literal("project.update"),
payload: z.discriminatedUnion("kind", [ payload: z.discriminatedUnion("kind", [
z.object({ kind: z.literal("upsert"), project: WorkspaceProjectDescriptorPayloadSchema }), z.object({
z.object({ kind: z.literal("remove"), projectId: z.string() }), kind: z.literal("upsert"),
project: WorkspaceProjectDescriptorPayloadSchema,
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
z.object({
kind: z.literal("remove"),
projectId: z.string(),
generation: z.string().optional(),
seq: z.number().int().positive().optional(),
}),
]), ]),
}); });
@@ -3642,6 +3700,7 @@ export const ProjectListResponseMessageSchema = z.object({
payload: z.object({ payload: z.object({
requestId: z.string(), requestId: z.string(),
projects: z.array(WorkspaceProjectDescriptorPayloadSchema), projects: z.array(WorkspaceProjectDescriptorPayloadSchema),
sync: DirectorySyncMetadataSchema.optional(),
}), }),
}); });
@@ -93,6 +93,59 @@ describe("workspace message schemas", () => {
expect(activeScoped.scope).toBe("active"); expect(activeScoped.scope).toBe("active");
}); });
test("parses optional sequenced directory requests and responses", () => {
expect(
SessionInboundMessageSchema.parse({
type: "project.list.request",
requestId: "projects-sync",
sync: { generation: "daemon-generation", afterSeq: 7 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 7 } });
expect(
SessionInboundMessageSchema.parse({
type: "fetch_workspaces_request",
requestId: "workspaces-sync",
sync: { generation: "daemon-generation", afterSeq: 11 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 11 } });
expect(
SessionInboundMessageSchema.parse({
type: "fetch_agents_request",
requestId: "agents-sync",
scope: "active",
sync: { generation: "daemon-generation", afterSeq: 13 },
}),
).toMatchObject({ sync: { generation: "daemon-generation", afterSeq: 13 } });
const response = SessionOutboundMessageSchema.parse({
type: "project.list.response",
payload: {
requestId: "projects-sync",
projects: [
{
projectId: "project-1",
projectDisplayName: "Project",
projectRootPath: "/repo",
projectKind: "git",
syncSeq: 12,
},
],
sync: {
generation: "daemon-generation",
headSeq: 12,
mode: "changes",
removals: [{ id: "project-removed", seq: 10 }],
},
},
});
expect(response).toMatchObject({
payload: {
projects: [{ projectId: "project-1", syncSeq: 12 }],
sync: { headSeq: 12, removals: [{ id: "project-removed", seq: 10 }] },
},
});
});
test("parses agent_update without project placement", () => { test("parses agent_update without project placement", () => {
const result = SessionOutboundMessageSchema.safeParse({ const result = SessionOutboundMessageSchema.safeParse({
type: "agent_update", type: "agent_update",
@@ -0,0 +1,92 @@
import { describe, expect, test } from "vitest";
import { DirectorySyncService } from "./index.js";
function project(projectId: string, name: string) {
return {
projectId,
projectDisplayName: name,
projectRootPath: `/${projectId}`,
projectKind: "git" as const,
};
}
describe("DirectorySyncService", () => {
test("owns sequencing and returns wire-ready latest project changes", () => {
const service = new DirectorySyncService("generation");
expect(service.synchronizeProjects([project("one", "One")], {}).sync).toMatchObject({
mode: "snapshot",
headSeq: 1,
});
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Renamed") }, true);
service.sequenceProjectUpdate({ kind: "upsert", project: project("one", "Latest") }, true);
expect(
service.synchronizeProjects([project("one", "Latest")], {
generation: "generation",
afterSeq: 1,
}),
).toEqual({
projects: [{ ...project("one", "Latest"), syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("sequences a hard project removal once", () => {
const service = new DirectorySyncService("generation");
service.synchronizeProjects([project("one", "One")], {});
const update = { kind: "remove" as const, projectId: "one" };
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(service.sequenceProjectUpdate(update, true)).toEqual({
...update,
generation: "generation",
seq: 2,
});
expect(
service.synchronizeProjects([], { generation: "generation", afterSeq: 1 }).sync.removals,
).toEqual([{ id: "one", seq: 2 }]);
});
test("keeps one daemon-wide version for repeated observations", () => {
const service = new DirectorySyncService("generation");
const update = { kind: "upsert" as const, project: project("one", "One") };
const sequenced = { ...update, generation: "generation", seq: 1 };
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
expect(service.sequenceProjectUpdate(update, true)).toEqual(sequenced);
});
test("sequences only the project whose effective icon changed", () => {
const service = new DirectorySyncService("generation");
const one = { ...project("one", "One"), projectIconRevision: "icon-a" };
const two = { ...project("two", "Two"), projectIconRevision: "icon-a" };
service.synchronizeProjects([one, two], {});
const changed = { ...one, projectIconRevision: "icon-b" };
service.sequenceProjectUpdate({ kind: "upsert", project: changed }, true);
expect(
service.synchronizeProjects([changed, two], {
generation: "generation",
afterSeq: 2,
}),
).toEqual({
projects: [{ ...changed, syncSeq: 3 }],
sync: { generation: "generation", mode: "changes", headSeq: 3, removals: [] },
});
});
test("falls back to a snapshot when the daemon generation changes", () => {
const service = new DirectorySyncService("new-generation");
const read = service.synchronizeProjects([project("one", "One")], {
generation: "old-generation",
afterSeq: 10,
});
expect(read.sync).toMatchObject({
mode: "snapshot",
reason: "generation_changed",
headSeq: 1,
});
});
});
@@ -0,0 +1,174 @@
import { randomUUID } from "node:crypto";
import type {
AgentSnapshotPayload,
ProjectPlacementPayload,
SessionOutboundMessage,
WorkspaceDescriptorPayload,
WorkspaceProjectDescriptorPayload,
} from "@getpaseo/protocol/messages";
import { type CollectionRead, VersionedCollection } from "./internal/versioned-collection.js";
interface AgentDirectoryEntry {
agent: AgentSnapshotPayload;
project: ProjectPlacementPayload;
}
interface DirectorySyncCursor {
generation?: string;
afterSeq?: number;
}
interface DirectoryVersion {
generation: string;
seq: number;
}
type ProjectUpdate = Extract<SessionOutboundMessage, { type: "project.update" }>;
type ProjectListResponse = Extract<
SessionOutboundMessage,
{ type: "project.list.response" }
>["payload"];
type FetchWorkspacesResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_workspaces_response" }
>["payload"];
type FetchAgentsResponse = Extract<
SessionOutboundMessage,
{ type: "fetch_agents_response" }
>["payload"];
/** Daemon-global latest-state sequence owner for the active app directory. */
export class DirectorySyncService {
private readonly generation: string;
private readonly projects = new VersionedCollection<WorkspaceProjectDescriptorPayload>({
getId: (project) => project.projectId,
});
private readonly workspaces = new VersionedCollection<WorkspaceDescriptorPayload>({
getId: (workspace) => workspace.id,
});
private readonly agents = new VersionedCollection<AgentDirectoryEntry>({
getId: (entry) => entry.agent.id,
});
constructor(generation = randomUUID()) {
this.generation = generation;
}
synchronizeProjects(
snapshot: Iterable<WorkspaceProjectDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<ProjectListResponse, "projects" | "sync"> {
this.projects.replaceAll(snapshot);
const read = this.read(this.projects, cursor);
return {
projects: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
sync: this.metadata(read),
};
}
synchronizeWorkspaces(
snapshot: Iterable<WorkspaceDescriptorPayload>,
cursor: DirectorySyncCursor,
): Pick<FetchWorkspacesResponse, "entries" | "emptyProjects" | "pageInfo" | "sync"> {
this.workspaces.replaceAll(snapshot);
const read = this.read(this.workspaces, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
emptyProjects: [],
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
synchronizeAgents(
snapshot: Iterable<AgentDirectoryEntry>,
cursor: DirectorySyncCursor,
): Pick<FetchAgentsResponse, "entries" | "pageInfo" | "sync"> {
this.agents.replaceAll(snapshot);
const read = this.read(this.agents, cursor);
return {
entries: read.values.map(({ seq, value }) => ({ ...value, syncSeq: seq })),
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
sync: this.metadata(read),
};
}
sequenceProjectUpdate(
update: ProjectUpdate["payload"],
includeSequence: boolean,
): ProjectUpdate["payload"] {
if (update.kind === "upsert") {
this.projects.replace(update.project);
} else {
this.projects.remove(update.projectId);
}
const id = update.kind === "upsert" ? update.project.projectId : update.projectId;
return this.withVersion(update, includeSequence, this.version(this.projects, id));
}
sequenceWorkspaceUpdate<T extends object>(
payload: T,
value: WorkspaceDescriptorPayload | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.workspaces.replace(value);
} else {
this.workspaces.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.workspaces, id));
}
sequenceAgentUpdate<T extends object>(
payload: T,
value: AgentDirectoryEntry | null,
id: string,
includeSequence: boolean,
): T & Partial<DirectoryVersion> {
if (value) {
this.agents.replace(value);
} else {
this.agents.remove(id);
}
return this.withVersion(payload, includeSequence, this.version(this.agents, id));
}
private metadata(read: CollectionRead<unknown>) {
return {
generation: this.generation,
headSeq: read.headSeq,
mode: read.mode,
...(read.reason ? { reason: read.reason } : {}),
removals: read.removals,
};
}
private withVersion<T extends object>(
payload: T,
includeSequence: boolean,
version: DirectoryVersion | null,
): T & Partial<DirectoryVersion> {
return version && includeSequence ? { ...payload, ...version } : payload;
}
private read<T>(
collection: VersionedCollection<T>,
cursor: DirectorySyncCursor,
): CollectionRead<T> {
if (cursor.generation !== this.generation) {
return collection.readSnapshot(
cursor.generation === undefined ? "no_cursor" : "generation_changed",
);
}
if (cursor.afterSeq === undefined) {
return collection.readSnapshot("no_cursor");
}
return collection.readAfter(cursor.afterSeq);
}
private version<T>(collection: VersionedCollection<T>, id: string): DirectoryVersion | null {
const seq = collection.sequenceFor(id);
return seq === null ? null : { generation: this.generation, seq };
}
}
@@ -0,0 +1,77 @@
import { describe, expect, test } from "vitest";
import { VersionedCollection } from "./versioned-collection.js";
describe("VersionedCollection", () => {
test("returns only each entity's latest visible snapshot after a sequence", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "first" });
const cursor = collection.headSeq;
collection.replace({ id: "one", title: "second" });
collection.replace({ id: "one", title: "latest" });
collection.replace({ id: "two", title: "other" });
expect(collection.readAfter(cursor)).toEqual({
mode: "changes",
headSeq: 4,
values: [
{ seq: 3, value: { id: "one", title: "latest" } },
{ seq: 4, value: { id: "two", title: "other" } },
],
removals: [],
});
});
test("does not advance for an unchanged projection or repeated removal", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
expect(collection.replace({ id: "one", title: "same" })?.seq).toBe(1);
expect(collection.replace({ id: "one", title: "same" })).toBeNull();
expect(collection.remove("one")?.seq).toBe(2);
expect(collection.remove("one")).toBeNull();
expect(collection.headSeq).toBe(2);
});
test("returns tombstones and lets a later value replace them", () => {
const collection = new VersionedCollection<{ id: string; title: string }>({
getId: (value) => value.id,
});
collection.replace({ id: "one", title: "before" });
collection.remove("one");
expect(collection.readAfter(1)).toEqual({
mode: "changes",
headSeq: 2,
values: [],
removals: [{ id: "one", seq: 2 }],
});
collection.replace({ id: "one", title: "after" });
expect(collection.readAfter(2)).toEqual({
mode: "changes",
headSeq: 3,
values: [{ seq: 3, value: { id: "one", title: "after" } }],
removals: [],
});
});
test("falls back to a latest snapshot when a required tombstone expired", () => {
const collection = new VersionedCollection<{ id: string }>({
getId: (value) => value.id,
maxTombstones: 1,
});
collection.replace({ id: "one" });
collection.replace({ id: "two" });
collection.remove("one");
collection.remove("two");
expect(collection.readAfter(2)).toEqual({
mode: "snapshot",
reason: "cursor_expired",
headSeq: 4,
values: [],
removals: [],
});
});
});
@@ -0,0 +1,141 @@
export interface VersionedValue<T> {
seq: number;
value: T;
}
export interface VersionedRemoval {
id: string;
seq: number;
}
export interface CollectionRead<T> {
mode: "changes" | "snapshot";
reason?: "no_cursor" | "generation_changed" | "cursor_expired";
headSeq: number;
values: VersionedValue<T>[];
removals: VersionedRemoval[];
}
export type PublishedChange<T> =
| { kind: "upsert"; seq: number; value: T }
| { kind: "remove"; seq: number; id: string };
interface StoredValue<T> extends VersionedValue<T> {
fingerprint: string;
}
interface VersionedCollectionOptions<T> {
getId(value: T): string;
fingerprint?(value: T): string;
maxTombstones?: number;
}
/** Owns latest-state sequencing for one directory entity type. */
export class VersionedCollection<T> {
private readonly valuesById = new Map<string, StoredValue<T>>();
private readonly tombstonesById = new Map<string, number>();
private readonly getId: (value: T) => string;
private readonly fingerprint: (value: T) => string;
private readonly maxTombstones: number;
private expiredThroughSeq = 0;
private nextSeq = 0;
constructor(options: VersionedCollectionOptions<T>) {
this.getId = options.getId;
this.fingerprint = options.fingerprint ?? ((value) => JSON.stringify(value));
this.maxTombstones = options.maxTombstones ?? 5_000;
}
get headSeq(): number {
return this.nextSeq;
}
sequenceFor(id: string): number | null {
return this.valuesById.get(id)?.seq ?? this.tombstonesById.get(id) ?? null;
}
replace(value: T): Extract<PublishedChange<T>, { kind: "upsert" }> | null {
const id = this.getId(value);
const fingerprint = this.fingerprint(value);
const previous = this.valuesById.get(id);
if (previous?.fingerprint === fingerprint) return null;
const seq = this.advance();
this.valuesById.set(id, { seq, fingerprint, value });
this.tombstonesById.delete(id);
return { kind: "upsert", seq, value };
}
remove(id: string): Extract<PublishedChange<T>, { kind: "remove" }> | null {
if (!this.valuesById.has(id) && this.tombstonesById.has(id)) return null;
if (!this.valuesById.delete(id) && !this.tombstonesById.has(id)) return null;
const seq = this.advance();
this.tombstonesById.delete(id);
this.tombstonesById.set(id, seq);
this.trimTombstones();
return { kind: "remove", seq, id };
}
replaceAll(values: Iterable<T>): void {
const retainedIds = new Set<string>();
for (const value of values) {
const id = this.getId(value);
retainedIds.add(id);
this.replace(value);
}
for (const id of this.valuesById.keys()) {
if (!retainedIds.has(id)) this.remove(id);
}
}
readSnapshot(
reason: "no_cursor" | "generation_changed" | "cursor_expired" = "no_cursor",
): CollectionRead<T> {
return {
mode: "snapshot",
reason,
headSeq: this.headSeq,
values: this.sortedValues(),
removals: [],
};
}
readAfter(afterSeq: number): CollectionRead<T> {
if (afterSeq < this.expiredThroughSeq || afterSeq > this.headSeq) {
return this.readSnapshot("cursor_expired");
}
return {
mode: "changes",
headSeq: this.headSeq,
values: this.sortedValues().filter((entry) => entry.seq > afterSeq),
removals: Array.from(this.tombstonesById, ([id, seq]) => ({ id, seq }))
.filter((entry) => entry.seq > afterSeq)
.sort((left, right) => left.seq - right.seq || left.id.localeCompare(right.id)),
};
}
private advance(): number {
this.nextSeq += 1;
return this.nextSeq;
}
private sortedValues(): VersionedValue<T>[] {
return Array.from(this.valuesById.values())
.map(({ seq, value }) => ({ seq, value }))
.sort(
(left, right) =>
left.seq - right.seq || this.getId(left.value).localeCompare(this.getId(right.value)),
);
}
private trimTombstones(): void {
while (this.tombstonesById.size > this.maxTombstones) {
const oldest = this.tombstonesById.entries().next().value;
if (!oldest) return;
const [id, seq] = oldest;
this.tombstonesById.delete(id);
this.expiredThroughSeq = Math.max(this.expiredThroughSeq, seq);
}
}
}
+4 -2
View File
@@ -710,6 +710,7 @@ describe("project command-center RPCs", () => {
projectDisplayName: "new-project", projectDisplayName: "new-project",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null, projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: directoryPath, projectRootPath: directoryPath,
projectKind: "non_git", projectKind: "non_git",
}, },
@@ -4967,7 +4968,7 @@ test("keeps selective delivery scoped per socket when a retained session also ha
]); ]);
}); });
test("sends project updates only to capable sockets in a retained session", () => { test("sends project updates only to capable sockets in a retained session", async () => {
const messages: SessionOutboundMessage[] = []; const messages: SessionOutboundMessage[] = [];
const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = []; const targetedMessages: Array<{ source: object; message: SessionOutboundMessage }> = [];
const session = createSessionForTest({ messages, targetedMessages }); const session = createSessionForTest({ messages, targetedMessages });
@@ -4976,7 +4977,7 @@ test("sends project updates only to capable sockets in a retained session", () =
session.updateClientCapabilities(null, legacySocket); session.updateClientCapabilities(null, legacySocket);
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket); session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }, capableSocket);
session.emitProjectUpdate({ await session.emitProjectUpdate({
kind: "upsert", kind: "upsert",
project: createPersistedProjectRecord({ project: createPersistedProjectRecord({
projectId: "project-capable-socket", projectId: "project-capable-socket",
@@ -5039,6 +5040,7 @@ test("project.list returns every active project descriptor", async () => {
projectDisplayName: "acme/app", projectDisplayName: "acme/app",
projectCustomName: null, projectCustomName: null,
projectCustomIconRevision: null, projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: "/tmp/project-active", projectRootPath: "/tmp/project-active",
projectKind: "git", projectKind: "git",
}, },
+119 -29
View File
@@ -70,6 +70,7 @@ import {
CLIENT_SHUTDOWN_RPC_REASON, CLIENT_SHUTDOWN_RPC_REASON,
normalizeClientRestartRpcReason, normalizeClientRestartRpcReason,
} from "./lifecycle-reasons.js"; } from "./lifecycle-reasons.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js"; import { AgentManager, AgentRunCancellationError } from "./agent/agent-manager.js";
import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js"; import { buildTimelinePromptIndex } from "./agent/timeline-prompt-index.js";
@@ -144,7 +145,7 @@ import {
import { wrapSpokenInput } from "./voice-config.js"; import { wrapSpokenInput } from "./voice-config.js";
import { isVoicePermissionAllowed } from "./voice-permission-policy.js"; import { isVoicePermissionAllowed } from "./voice-permission-policy.js";
import { import {
readProjectIcon, ProjectIconReader,
removeProjectCustomIcon, removeProjectCustomIcon,
setProjectCustomIcon, setProjectCustomIcon,
} from "../utils/project-custom-icon.js"; } from "../utils/project-custom-icon.js";
@@ -394,6 +395,7 @@ type WorkspaceUpdatePayload = Extract<
>["payload"]; >["payload"];
interface WorkspaceUpdatesSubscriptionState { interface WorkspaceUpdatesSubscriptionState {
subscriptionId: string; subscriptionId: string;
syncEnabled?: boolean;
filter?: WorkspaceUpdatesFilter; filter?: WorkspaceUpdatesFilter;
isBootstrapping: boolean; isBootstrapping: boolean;
pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>; pendingUpdatesByWorkspaceId: Map<string, WorkspaceUpdatePayload>;
@@ -446,6 +448,7 @@ export interface SessionOptions {
agentStorage: AgentStorage; agentStorage: AgentStorage;
projectRegistry: ProjectRegistry; projectRegistry: ProjectRegistry;
workspaceRegistry: WorkspaceRegistry; workspaceRegistry: WorkspaceRegistry;
directorySync?: DirectorySyncService;
filesystem?: SessionFileSystem; filesystem?: SessionFileSystem;
scheduleService: ScheduleService; scheduleService: ScheduleService;
checkoutDiffManager: CheckoutDiffManager; checkoutDiffManager: CheckoutDiffManager;
@@ -579,6 +582,10 @@ interface WorkspaceUpdateOptions {
optimisticStatus?: WorkspaceDescriptorPayload["status"]; optimisticStatus?: WorkspaceDescriptorPayload["status"];
} }
function resolveDirectorySync(service: DirectorySyncService | undefined): DirectorySyncService {
return service ?? new DirectorySyncService();
}
function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition { function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition {
if (!record) { if (!record) {
return "created"; return "created";
@@ -612,12 +619,14 @@ export class Session {
| null; | null;
private readonly sessionLogger: pino.Logger; private readonly sessionLogger: pino.Logger;
private readonly paseoHome: string; private readonly paseoHome: string;
private readonly projectIcons: ProjectIconReader;
private readonly worktreesRoot: string | undefined; private readonly worktreesRoot: string | undefined;
private agentManager: AgentManager; private agentManager: AgentManager;
private readonly agentStorage: AgentStorage; private readonly agentStorage: AgentStorage;
private readonly projectRegistry: ProjectRegistry; private readonly projectRegistry: ProjectRegistry;
private readonly workspaceRegistry: WorkspaceRegistry; private readonly workspaceRegistry: WorkspaceRegistry;
private readonly directorySync: DirectorySyncService;
private readonly filesystem: SessionFileSystem; private readonly filesystem: SessionFileSystem;
private readonly github: ForgeService; private readonly github: ForgeService;
private readonly renameCurrentBranch: typeof renameCurrentBranchDefault; private readonly renameCurrentBranch: typeof renameCurrentBranchDefault;
@@ -632,6 +641,7 @@ export class Session {
private unsubscribeProjectMutations: (() => void) | null = null; private unsubscribeProjectMutations: (() => void) | null = null;
private unsubscribeWorkspaceMutations: (() => void) | null = null; private unsubscribeWorkspaceMutations: (() => void) | null = null;
private registryMutationQueue: Promise<void> = Promise.resolve(); private registryMutationQueue: Promise<void> = Promise.resolve();
private projectUpdateQueue: Promise<void> = Promise.resolve();
private isCleanedUp = false; private isCleanedUp = false;
private viewedTimelineAgentIds = new Set<string>(); private viewedTimelineAgentIds = new Set<string>();
private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>(); private readonly viewedTimelineAgentIdsBySource = new Map<object, Set<string>>();
@@ -640,6 +650,7 @@ export class Session {
private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null;
private readonly agentUpdates: AgentUpdatesService; private readonly agentUpdates: AgentUpdatesService;
private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null;
private projectSyncEnabled = false;
private readonly workspaceUpdateTails = new Map<string, Promise<void>>(); private readonly workspaceUpdateTails = new Map<string, Promise<void>>();
private clientActivity: { private clientActivity: {
deviceType: "web" | "mobile"; deviceType: "web" | "mobile";
@@ -699,6 +710,7 @@ export class Session {
agentStorage, agentStorage,
projectRegistry, projectRegistry,
workspaceRegistry, workspaceRegistry,
directorySync,
filesystem, filesystem,
scheduleService, scheduleService,
checkoutDiffManager, checkoutDiffManager,
@@ -744,6 +756,7 @@ export class Session {
this.onWorkspaceRecovered = onWorkspaceRecovered ?? null; this.onWorkspaceRecovered = onWorkspaceRecovered ?? null;
this.pushNotifications = pushNotifications; this.pushNotifications = pushNotifications;
this.paseoHome = paseoHome; this.paseoHome = paseoHome;
this.projectIcons = new ProjectIconReader(paseoHome);
this.worktreesRoot = worktreesRoot; this.worktreesRoot = worktreesRoot;
this.sessionLogger = logger.child({ this.sessionLogger = logger.child({
module: "session", module: "session",
@@ -764,6 +777,7 @@ export class Session {
this.agentStorage = agentStorage; this.agentStorage = agentStorage;
this.projectRegistry = projectRegistry; this.projectRegistry = projectRegistry;
this.workspaceRegistry = workspaceRegistry; this.workspaceRegistry = workspaceRegistry;
this.directorySync = resolveDirectorySync(directorySync);
this.filesystem = filesystem ?? nodeSessionFileSystem; this.filesystem = filesystem ?? nodeSessionFileSystem;
this.github = github ?? createGitHubService(); this.github = github ?? createGitHubService();
this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault; this.renameCurrentBranch = renameCurrentBranch ?? renameCurrentBranchDefault;
@@ -922,6 +936,13 @@ export class Session {
this.buildProjectPlacementForWorkspaceId(workspaceId), this.buildProjectPlacementForWorkspaceId(workspaceId),
emitWorkspaceUpdateForWorkspaceId: (workspaceId) => emitWorkspaceUpdateForWorkspaceId: (workspaceId) =>
this.emitWorkspaceUpdateForWorkspaceId(workspaceId), this.emitWorkspaceUpdateForWorkspaceId(workspaceId),
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
this.directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: this.sessionLogger, logger: this.sessionLogger,
}); });
this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({ this.createAgentLifecycleDispatch = new CreateAgentLifecycleDispatch({
@@ -1141,13 +1162,22 @@ export class Session {
); );
} }
emitProjectUpdate(update: ProjectUpdate): void { emitProjectUpdate(update: ProjectUpdate): Promise<void> {
const queued = this.projectUpdateQueue
.catch(() => undefined)
.then(() => this.publishProjectUpdate(update));
this.projectUpdateQueue = queued;
return queued;
}
private async publishProjectUpdate(update: ProjectUpdate): Promise<void> {
const projectedPayload =
update.kind === "upsert"
? { kind: "upsert" as const, project: await this.buildProjectDescriptor(update.project) }
: update;
const message: SessionOutboundMessage = { const message: SessionOutboundMessage = {
type: "project.update", type: "project.update",
payload: payload: this.directorySync.sequenceProjectUpdate(projectedPayload, this.projectSyncEnabled),
update.kind === "upsert"
? { kind: "upsert", project: this.buildProjectDescriptor(update.project) }
: update,
}; };
if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) { if (this.clientCapabilitiesBySource.size === 0 || !this.onMessageToSource) {
if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message); if (this.supports(CLIENT_CAPS.projectUpdates)) this.emit(message);
@@ -2121,7 +2151,7 @@ export class Session {
case "fetch_workspaces_request": case "fetch_workspaces_request":
return this.handleFetchWorkspacesRequest(msg); return this.handleFetchWorkspacesRequest(msg);
case "project.list.request": case "project.list.request":
return this.handleProjectListRequest(msg.requestId); return this.handleProjectListRequest(msg);
case "paseo_worktree_list_request": case "paseo_worktree_list_request":
return this.handlePaseoWorktreeListRequest(msg); return this.handlePaseoWorktreeListRequest(msg);
case "paseo_worktree_archive_request": case "paseo_worktree_archive_request":
@@ -2692,7 +2722,7 @@ export class Session {
// Emit a project.update so clients that track the project as an empty // Emit a project.update so clients that track the project as an empty
// project (no workspaces yet) receive the resolved name immediately. // project (no workspaces yet) receive the resolved name immediately.
this.emitProjectUpdate({ kind: "upsert", project: updated }); await this.emitProjectUpdate({ kind: "upsert", project: updated });
// Re-emit descriptors for every workspace under this project so the new // Re-emit descriptors for every workspace under this project so the new
// resolved name lands in the UI immediately. // resolved name lands in the UI immediately.
@@ -2746,7 +2776,7 @@ export class Session {
type: "project.icon.set.response", type: "project.icon.set.response",
payload: { requestId, projectId, accepted: true, error: null }, payload: { requestId, projectId, accepted: true, error: null },
}); });
this.emitProjectUpdate({ kind: "upsert", project: updated }); await this.emitProjectUpdate({ kind: "upsert", project: updated });
const affectedWorkspaceIds = (await this.workspaceRegistry.list()) const affectedWorkspaceIds = (await this.workspaceRegistry.list())
.filter((workspace) => workspace.projectId === projectId) .filter((workspace) => workspace.projectId === projectId)
@@ -2772,7 +2802,7 @@ export class Session {
const project = await this.projectRegistry.get(projectId); const project = await this.projectRegistry.get(projectId);
if (!project) throw new Error("Project not found"); if (!project) throw new Error("Project not found");
const icon = await readProjectIcon({ paseoHome: this.paseoHome, project }); const icon = await this.projectIcons.read(project);
this.emit({ this.emit({
type: "project.icon.get.response", type: "project.icon.get.response",
payload: { projectId, icon, error: null, requestId }, payload: { projectId, icon, error: null, requestId },
@@ -4684,15 +4714,17 @@ export class Session {
} }
} }
private buildProjectDescriptor( private async buildProjectDescriptor(
project: PersistedProjectRecord, project: PersistedProjectRecord,
): WorkspaceProjectDescriptorPayload { ): Promise<WorkspaceProjectDescriptorPayload> {
const icon = await this.projectIcons.snapshot(project);
return { return {
projectId: project.projectId, projectId: project.projectId,
...(project.projectKey ? { projectKey: project.projectKey } : {}), ...(project.projectKey ? { projectKey: project.projectKey } : {}),
projectDisplayName: resolveProjectDisplayName(project), projectDisplayName: resolveProjectDisplayName(project),
projectCustomName: project.customName ?? null, projectCustomName: project.customName ?? null,
projectCustomIconRevision: project.customIconRevision ?? null, projectCustomIconRevision: project.customIconRevision ?? null,
projectIconRevision: icon.revision,
projectRootPath: project.rootPath, projectRootPath: project.rootPath,
projectKind: project.kind, projectKind: project.kind,
}; };
@@ -4890,7 +4922,6 @@ export class Session {
continue; continue;
} }
this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace); this.workspaceGitObserver.recordDescriptorState(workspaceId, nextWorkspace);
if (!nextWorkspace) { if (!nextWorkspace) {
if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) {
continue; continue;
@@ -4899,17 +4930,28 @@ export class Session {
return; return;
} }
subscription.lastEmittedByWorkspaceId.delete(workspaceId); subscription.lastEmittedByWorkspaceId.delete(workspaceId);
const removePayload = await this.buildWorkspaceRemoveUpdatePayload(
workspaceId,
options?.removedProjectId,
);
this.bufferOrEmitWorkspaceUpdate( this.bufferOrEmitWorkspaceUpdate(
subscription, subscription,
await this.buildWorkspaceRemoveUpdatePayload(workspaceId, options?.removedProjectId), this.directorySync.sequenceWorkspaceUpdate(
removePayload,
workspace ?? null,
workspaceId,
subscription.syncEnabled === true,
),
); );
continue; continue;
} }
const nextPayload: WorkspaceUpdatePayload = { const nextPayload: WorkspaceUpdatePayload = this.directorySync.sequenceWorkspaceUpdate(
kind: "upsert", { kind: "upsert", workspace: nextWorkspace },
workspace: nextWorkspace, workspace ?? null,
}; workspaceId,
subscription.syncEnabled === true,
);
if ( if (
lastEmitted && lastEmitted &&
@@ -5013,10 +5055,13 @@ export class Session {
this.agentUpdates.beginSubscription({ this.agentUpdates.beginSubscription({
subscriptionId, subscriptionId,
filter: request.filter, filter: request.filter,
syncEnabled: Boolean(request.sync),
}); });
} }
const payload = await this.listFetchAgentsEntries(request); const payload = request.sync
? await this.readAgentDirectorySync(request)
: await this.listFetchAgentsEntries(request);
const snapshotUpdatedAtByAgentId = new Map<string, number>(); const snapshotUpdatedAtByAgentId = new Map<string, number>();
for (const entry of payload.entries) { for (const entry of payload.entries) {
const parsedUpdatedAt = Date.parse(entry.agent.updatedAt); const parsedUpdatedAt = Date.parse(entry.agent.updatedAt);
@@ -5147,6 +5192,7 @@ export class Session {
if (subscriptionId) { if (subscriptionId) {
this.workspaceUpdatesSubscription = { this.workspaceUpdatesSubscription = {
subscriptionId, subscriptionId,
syncEnabled: Boolean(request.sync),
filter: request.filter, filter: request.filter,
isBootstrapping: true, isBootstrapping: true,
pendingUpdatesByWorkspaceId: new Map(), pendingUpdatesByWorkspaceId: new Map(),
@@ -5155,7 +5201,9 @@ export class Session {
}; };
} }
const payload = await this.listFetchWorkspacesEntries(request); const payload = request.sync
? await this.readWorkspaceDirectorySync(request)
: await this.listFetchWorkspacesEntries(request);
this.workspaceGitObserver.syncObservers(payload.entries); this.workspaceGitObserver.syncObservers(payload.entries);
this.sessionLogger.debug( this.sessionLogger.debug(
{ {
@@ -5205,21 +5253,32 @@ export class Session {
} }
} }
private async handleProjectListRequest(requestId: string): Promise<void> { private async handleProjectListRequest(
request: Extract<SessionInboundMessage, { type: "project.list.request" }>,
): Promise<void> {
try { try {
const projects = (await this.projectRegistry.list()) const projects = await Promise.all(
.filter((project) => !project.archivedAt) (await this.projectRegistry.list())
.map((project) => this.buildProjectDescriptor(project)); .filter((project) => !project.archivedAt)
.map((project) => this.buildProjectDescriptor(project)),
);
const synchronized = request.sync
? this.directorySync.synchronizeProjects(projects, request.sync)
: null;
if (synchronized) this.projectSyncEnabled = true;
this.emit({ this.emit({
type: "project.list.response", type: "project.list.response",
payload: { requestId, projects }, payload: {
requestId: request.requestId,
...(synchronized ?? { projects }),
},
}); });
} catch (error) { } catch (error) {
this.sessionLogger.error({ err: error }, "Failed to handle project.list.request"); this.sessionLogger.error({ err: error }, "Failed to handle project.list.request");
this.emit({ this.emit({
type: "rpc_error", type: "rpc_error",
payload: { payload: {
requestId, requestId: request.requestId,
requestType: "project.list.request", requestType: "project.list.request",
error: error instanceof Error ? error.message : "Failed to list projects", error: error instanceof Error ? error.message : "Failed to list projects",
code: "project_list_failed", code: "project_list_failed",
@@ -5228,6 +5287,37 @@ export class Session {
} }
} }
private async readAgentDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_agents_request" }>,
) {
if (request.scope !== "active" || request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced agent directory reads require scope=active and no filter.",
);
}
const snapshot = await this.listFetchAgentsEntries({
...request,
page: { limit: Number.MAX_SAFE_INTEGER },
});
return this.directorySync.synchronizeAgents(snapshot.entries, request.sync ?? {});
}
private async readWorkspaceDirectorySync(
request: Extract<SessionInboundMessage, { type: "fetch_workspaces_request" }>,
) {
if (request.filter) {
throw new SessionRequestError(
"invalid_request",
"Sequenced workspace directory reads do not support filters.",
);
}
return this.directorySync.synchronizeWorkspaces(
await this.workspaceDirectory.listDescriptors(),
request.sync ?? {},
);
}
// Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates` // Build the bootstrap snapshot used by `flushBootstrappedWorkspaceUpdates`
// to decide which pending updates to drop. Captures the status, // to decide which pending updates to drop. Captures the status,
// statusEnteredAt, and activityAt (parsed to ms) for each workspace entry // statusEnteredAt, and activityAt (parsed to ms) for each workspace entry
@@ -5580,7 +5670,7 @@ export class Session {
type: "project.add.response", type: "project.add.response",
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
project: this.buildProjectDescriptor(project), project: await this.buildProjectDescriptor(project),
error: null, error: null,
}, },
}); });
@@ -5614,7 +5704,7 @@ export class Session {
payload: { payload: {
requestId: request.requestId, requestId: request.requestId,
directoryPath: result.directoryPath, directoryPath: result.directoryPath,
project: this.buildProjectDescriptor(result.project), project: await this.buildProjectDescriptor(result.project),
error: null, error: null,
errorCode: null, errorCode: null,
}, },
@@ -5779,7 +5869,7 @@ export class Session {
requestId: request.requestId, requestId: request.requestId,
repo: repo.displayName, repo: repo.displayName,
checkoutPath, checkoutPath,
project: this.buildProjectDescriptor(project), project: await this.buildProjectDescriptor(project),
error: null, error: null,
}, },
}); });
@@ -736,6 +736,55 @@ function createSessionForWorkspaceTests(
return session; return session;
} }
test("project.list.request catches up by sequence on the existing RPC", async () => {
const emitted: SessionOutboundMessage[] = [];
let project = createPersistedProjectRecord({
projectId: "project-sequenced",
rootPath: "/repo/sequenced",
kind: "git",
displayName: "Before",
createdAt: "2026-08-12T08:00:00.000Z",
updatedAt: "2026-08-12T08:00:00.000Z",
});
const session = createSessionForWorkspaceTests({
onMessage: (message) => emitted.push(message),
projectRegistry: {
initialize: async () => {},
existsOnDisk: async () => true,
list: async () => [project],
get: async () => project,
getOrCreateActiveByRoot: async () => project,
upsert: async () => {},
archive: async () => {},
remove: async () => {},
},
});
await session.handleMessage({
type: "project.list.request",
requestId: "initial",
sync: {},
});
const initial = findByType(emitted, "project.list.response")?.payload;
expect(initial?.sync).toMatchObject({ mode: "snapshot", headSeq: 1 });
project = { ...project, displayName: "After", updatedAt: "2026-08-12T08:01:00.000Z" };
await session.handleMessage({
type: "project.list.request",
requestId: "catch-up",
sync: {
generation: initial?.sync?.generation,
afterSeq: initial?.sync?.headSeq,
},
});
const responses = filterByType(emitted, "project.list.response");
expect(responses.at(-1)?.payload).toMatchObject({
requestId: "catch-up",
projects: [{ projectId: "project-sequenced", projectDisplayName: "After", syncSeq: 2 }],
sync: { mode: "changes", headSeq: 2, removals: [] },
});
});
test("agent updates preserve queued live transitions across stored metadata reads", async () => { test("agent updates preserve queued live transitions across stored metadata reads", async () => {
const running = makeManagedAgent({ const running = makeManagedAgent({
id: "agent-coherent", id: "agent-coherent",
@@ -7754,15 +7803,17 @@ const ICON_PNG_1X1 = Buffer.from([
test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => { test("project.icon.set.request publishes a custom icon that project.icon.get serves back", async () => {
const emitted: SessionOutboundMessage[] = []; const emitted: SessionOutboundMessage[] = [];
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const session = asTestSession( const session = asTestSession(
createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), createSessionForWorkspaceTests({
paseoHome: path.join(tempDir, "paseo-home"),
onMessage: (message) => emitted.push(message),
}),
); );
session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true }); session.updateClientCapabilities({ [CLIENT_CAPS.projectUpdates]: true });
const tempDir = realpathSync(mkdtempSync(path.join(tmpdir(), "session-project-icon-test-")));
const projectRoot = path.join(tempDir, "project-without-icons"); const projectRoot = path.join(tempDir, "project-without-icons");
mkdirSync(projectRoot, { recursive: true }); mkdirSync(projectRoot, { recursive: true });
session.paseoHome = path.join(tempDir, "paseo-home");
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "prj_icon", projectId: "prj_icon",
@@ -8,6 +8,7 @@ import type {
} from "../../messages.js"; } from "../../messages.js";
import type { ManagedAgent } from "../../agent/agent-manager.js"; import type { ManagedAgent } from "../../agent/agent-manager.js";
import type { StoredAgentRecord } from "../../agent/agent-storage.js"; import type { StoredAgentRecord } from "../../agent/agent-storage.js";
import { DirectorySyncService } from "../../directory-sync/index.js";
// No mocks — every dependency is an injected in-memory fake. The agent payloads // No mocks — every dependency is an injected in-memory fake. The agent payloads
// are supplied through the fake builders, so each test fully controls the // are supplied through the fake builders, so each test fully controls the
@@ -100,6 +101,7 @@ function buildHarness() {
let providerVisible: (provider: string) => boolean = () => true; let providerVisible: (provider: string) => boolean = () => true;
let buildAgentPayloadError: Error | null = null; let buildAgentPayloadError: Error | null = null;
let enrichProjectedPayload = false; let enrichProjectedPayload = false;
const directorySync = new DirectorySyncService("test-generation");
const service = createAgentUpdatesService({ const service = createAgentUpdatesService({
emit: (message) => emitted.push(message), emit: (message) => emitted.push(message),
@@ -133,6 +135,13 @@ function buildHarness() {
emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => { emitWorkspaceUpdateForWorkspaceId: async (workspaceId) => {
workspaceUpdates.push(workspaceId); workspaceUpdates.push(workspaceId);
}, },
sequenceAgentUpdate: (payload, agent, project, agentId, includeSequence) =>
directorySync.sequenceAgentUpdate(
payload,
agent && project ? { agent, project } : null,
agentId,
includeSequence,
),
logger: { error: (...args: unknown[]) => loggedErrors.push(args) } as unknown as pino.Logger, logger: { error: (...args: unknown[]) => loggedErrors.push(args) } as unknown as pino.Logger,
}); });
@@ -16,6 +16,7 @@ type AgentUpdatesFilter = NonNullable<
interface AgentUpdatesSubscriptionState { interface AgentUpdatesSubscriptionState {
subscriptionId: string; subscriptionId: string;
syncEnabled?: boolean;
filter?: AgentUpdatesFilter; filter?: AgentUpdatesFilter;
isBootstrapping: boolean; isBootstrapping: boolean;
pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>; pendingUpdatesByAgentId: Map<string, AgentUpdatePayload>;
@@ -37,7 +38,11 @@ interface AgentUpdatesSubscriptionState {
* stay consistent. * stay consistent.
*/ */
export interface AgentUpdatesService { export interface AgentUpdatesService {
beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void; beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void;
flushBootstrapped( flushBootstrapped(
subscriptionId: string, subscriptionId: string,
options?: { snapshotUpdatedAtByAgentId?: Map<string, number> }, options?: { snapshotUpdatedAtByAgentId?: Map<string, number> },
@@ -57,6 +62,13 @@ export interface AgentUpdatesServiceDeps {
isProviderVisibleToClient(provider: string): boolean; isProviderVisibleToClient(provider: string): boolean;
buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>; buildProjectPlacementForWorkspaceId(workspaceId: string): Promise<ProjectPlacementPayload | null>;
emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>; emitWorkspaceUpdateForWorkspaceId(workspaceId: string): Promise<void>;
sequenceAgentUpdate<T extends AgentUpdatePayload>(
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
includeSequence: boolean,
): T;
logger: pino.Logger; logger: pino.Logger;
} }
@@ -151,6 +163,13 @@ function agentUpdateTargetId(update: AgentUpdatePayload): string {
export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService { export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentUpdatesService {
let subscription: AgentUpdatesSubscriptionState | null = null; let subscription: AgentUpdatesSubscriptionState | null = null;
const liveAgentUpdateTails = new Map<string, Promise<void>>(); const liveAgentUpdateTails = new Map<string, Promise<void>>();
const sequence = <T extends AgentUpdatePayload>(
sub: AgentUpdatesSubscriptionState,
payload: T,
agent: AgentSnapshotPayload | null,
project: ProjectPlacementPayload | null,
agentId: string,
) => deps.sequenceAgentUpdate(payload, agent, project, agentId, sub.syncEnabled === true);
function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void { function bufferOrEmit(sub: AgentUpdatesSubscriptionState, payload: AgentUpdatePayload): void {
if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) { if (payload.kind === "upsert" && !deps.isProviderVisibleToClient(payload.agent.provider)) {
@@ -167,9 +186,14 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
}); });
} }
function beginSubscription(input: { subscriptionId: string; filter?: AgentUpdatesFilter }): void { function beginSubscription(input: {
subscriptionId: string;
filter?: AgentUpdatesFilter;
syncEnabled?: boolean;
}): void {
subscription = { subscription = {
subscriptionId: input.subscriptionId, subscriptionId: input.subscriptionId,
syncEnabled: input.syncEnabled,
filter: input.filter, filter: input.filter,
isBootstrapping: true, isBootstrapping: true,
pendingUpdatesByAgentId: new Map(), pendingUpdatesByAgentId: new Map(),
@@ -230,10 +254,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null; : null;
if (!project) { if (!project) {
bufferOrEmit(sub, { bufferOrEmit(
kind: "remove", sub,
agentId: payload.id, sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
}); );
return payload; return payload;
} }
@@ -244,16 +268,22 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
}); });
bufferOrEmit( bufferOrEmit(
sub, sub,
matches sequence(
? { sub,
kind: "upsert", matches
agent: payload, ? {
project, kind: "upsert",
} agent: payload,
: { project,
kind: "remove", }
agentId: payload.id, : {
}, kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
); );
return payload; return payload;
} }
@@ -267,10 +297,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId) ? await deps.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null; : null;
if (!project) { if (!project) {
bufferOrEmit(sub, { bufferOrEmit(
kind: "remove", sub,
agentId: payload.id, sequence(sub, { kind: "remove", agentId: payload.id }, null, null, payload.id),
}); );
} else { } else {
const matches = matchesAgentUpdatesFilter({ const matches = matchesAgentUpdatesFilter({
agent: payload, agent: payload,
@@ -279,16 +309,34 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
}); });
if (matches) { if (matches) {
bufferOrEmit(sub, { bufferOrEmit(
kind: "upsert", sub,
agent: payload, sequence(
project, sub,
}); {
kind: "upsert",
agent: payload,
project,
},
payload,
project,
payload.id,
),
);
} else { } else {
bufferOrEmit(sub, { bufferOrEmit(
kind: "remove", sub,
agentId: payload.id, sequence(
}); sub,
{
kind: "remove",
agentId: payload.id,
},
payload,
project,
payload.id,
),
);
} }
} }
} }
@@ -326,7 +374,10 @@ export function createAgentUpdatesService(deps: AgentUpdatesServiceDeps): AgentU
function removeAgent(agentId: string): Promise<void> { function removeAgent(agentId: string): Promise<void> {
return enqueueAgentUpdate(agentId, () => { return enqueueAgentUpdate(agentId, () => {
if (subscription) { if (subscription) {
bufferOrEmit(subscription, { kind: "remove", agentId }); bufferOrEmit(
subscription,
sequence(subscription, { kind: "remove", agentId }, null, null, agentId),
);
} }
}); });
} }
@@ -645,6 +645,16 @@ describe("CheckoutSession", () => {
}, },
]); ]);
}); });
it("does not emit the same checkout status twice", () => {
const { checkout, emitted } = makeCheckoutSession();
const snapshot = createGitSnapshot("/repo", "main");
checkout.emitStatusUpdate("/repo", snapshot);
checkout.emitStatusUpdate("/repo", snapshot);
expect(emitted).toHaveLength(1);
});
}); });
describe("switch branch", () => { describe("switch branch", () => {
@@ -153,6 +153,7 @@ export class CheckoutSession {
private readonly worktreesRoot: string | undefined; private readonly worktreesRoot: string | undefined;
private readonly logger: pino.Logger; private readonly logger: pino.Logger;
private readonly diffSubscriptions = new Map<string, () => void>(); private readonly diffSubscriptions = new Map<string, () => void>();
private readonly statusUpdateFingerprints = new Map<string, string>();
constructor(options: CheckoutSessionOptions) { constructor(options: CheckoutSessionOptions) {
this.host = options.host; this.host = options.host;
@@ -480,20 +481,24 @@ export class CheckoutSession {
emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void { emitStatusUpdate(cwd: string, snapshot: WorkspaceGitRuntimeSnapshot): void {
try { try {
const requestId = `subscription:${cwd}`; const requestId = `subscription:${cwd}`;
const payload = {
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
};
const fingerprint = JSON.stringify(payload);
if (this.statusUpdateFingerprints.get(cwd) === fingerprint) return;
this.statusUpdateFingerprints.set(cwd, fingerprint);
this.host.emit({ this.host.emit({
type: "checkout_status_update", type: "checkout_status_update",
payload: { payload,
...buildCheckoutStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
prStatus: buildCheckoutPrStatusPayloadFromSnapshot({
cwd,
requestId,
snapshot,
}),
},
}); });
} catch (error) { } catch (error) {
this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update"); this.logger.warn({ err: error, cwd }, "Failed to emit workspace checkout status update");
@@ -1411,6 +1416,7 @@ export class CheckoutSession {
unsubscribe(); unsubscribe();
} }
this.diffSubscriptions.clear(); this.diffSubscriptions.clear();
this.statusUpdateFingerprints.clear();
} }
} }
+10 -1
View File
@@ -91,6 +91,7 @@ import {
} from "@getpaseo/protocol/browser-automation/capabilities"; } from "@getpaseo/protocol/browser-automation/capabilities";
import type { BrowserToolsBroker } from "./browser-tools/broker.js"; import type { BrowserToolsBroker } from "./browser-tools/broker.js";
import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js"; import type { DaemonRuntimeConfig } from "./session/daemon/daemon-session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { import {
APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS, APPLICATION_SOCKET_LEASE_CHECK_INTERVAL_MS,
ApplicationSocketLease, ApplicationSocketLease,
@@ -577,6 +578,7 @@ export class VoiceAssistantWebSocketServer {
private acceptingConnections = true; private acceptingConnections = true;
private readonly advertiseDaemonStatusRpc: boolean; private readonly advertiseDaemonStatusRpc: boolean;
private readonly advertiseRelayConfig: boolean; private readonly advertiseRelayConfig: boolean;
private readonly directorySync = new DirectorySyncService();
constructor( constructor(
server: HTTPServer, server: HTTPServer,
@@ -900,7 +902,11 @@ export class VoiceAssistantWebSocketServer {
} }
public publishProjectUpdate(update: ProjectUpdate): void { public publishProjectUpdate(update: ProjectUpdate): void {
for (const session of this.listTrustedSessions()) session.emitProjectUpdate(update); for (const session of this.listTrustedSessions()) {
void session
.emitProjectUpdate(update)
.catch((error) => this.logger.warn({ err: error }, "Failed to publish project update"));
}
} }
public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void { public publishSpeechReadiness(readiness: SpeechReadinessSnapshot | null): void {
@@ -1344,6 +1350,7 @@ export class VoiceAssistantWebSocketServer {
agentStorage: this.agentStorage, agentStorage: this.agentStorage,
projectRegistry: this.projectRegistry, projectRegistry: this.projectRegistry,
workspaceRegistry: this.workspaceRegistry, workspaceRegistry: this.workspaceRegistry,
directorySync: this.directorySync,
scheduleService: this.scheduleService, scheduleService: this.scheduleService,
checkoutDiffManager: this.checkoutDiffManager, checkoutDiffManager: this.checkoutDiffManager,
github: this.github, github: this.github,
@@ -1529,6 +1536,8 @@ export class VoiceAssistantWebSocketServer {
desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true, desktopManaged: this.daemonRuntimeConfig?.desktopManaged === true,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
features: { features: {
// COMPAT(directorySync): added in v0.3.x, remove gate after 2027-02-12.
directorySync: true,
// COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow.
providersSnapshot: true, providersSnapshot: true,
// COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10.
+55 -5
View File
@@ -11,6 +11,7 @@ import {
type SessionOutboundMessage, type SessionOutboundMessage,
} from "@getpaseo/protocol/messages"; } from "@getpaseo/protocol/messages";
import { Session, type SessionOptions } from "./session.js"; import { Session, type SessionOptions } from "./session.js";
import { DirectorySyncService } from "./directory-sync/index.js";
import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js";
import type { AgentTimelineRow } from "./agent/agent-manager.js"; import type { AgentTimelineRow } from "./agent/agent-manager.js";
import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js"; import { InMemoryAgentTimelineStore } from "./agent/agent-timeline-store.js";
@@ -180,6 +181,7 @@ class InMemoryWorktreeWorkflow {
function createSessionForWireCompatTest(options?: { function createSessionForWireCompatTest(options?: {
clientCapabilities?: Record<string, unknown> | null; clientCapabilities?: Record<string, unknown> | null;
directorySync?: DirectorySyncService;
messages?: SessionOutboundMessage[]; messages?: SessionOutboundMessage[];
rows?: AgentTimelineRow[]; rows?: AgentTimelineRow[];
}): Session { }): Session {
@@ -218,6 +220,7 @@ function createSessionForWireCompatTest(options?: {
projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"],
workspaceRegistry: workspaceRegistry:
new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"], new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"],
directorySync: options?.directorySync,
scheduleService: {} as SessionOptions["scheduleService"], scheduleService: {} as SessionOptions["scheduleService"],
checkoutDiffManager: { checkoutDiffManager: {
scheduleRefreshForCwd() {}, scheduleRefreshForCwd() {},
@@ -304,7 +307,7 @@ async function emitTimelineResponse(options?: {
} }
describe("wire compatibility", () => { describe("wire compatibility", () => {
test("sends project updates only to clients that declare support", () => { test("sends project updates only to clients that declare support", async () => {
const project = createPersistedProjectRecord({ const project = createPersistedProjectRecord({
projectId: "project-1", projectId: "project-1",
rootPath: "/tmp/project", rootPath: "/tmp/project",
@@ -322,10 +325,12 @@ describe("wire compatibility", () => {
messages: capableMessages, messages: capableMessages,
}); });
legacy.emitProjectUpdate({ kind: "upsert", project }); await Promise.all([
legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); legacy.emitProjectUpdate({ kind: "upsert", project }),
capable.emitProjectUpdate({ kind: "upsert", project }); legacy.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }); capable.emitProjectUpdate({ kind: "upsert", project }),
capable.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
]);
expect(legacyMessages).toEqual([]); expect(legacyMessages).toEqual([]);
expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([ expect(capableMessages.map((message) => SessionOutboundMessageSchema.parse(message))).toEqual([
@@ -338,6 +343,7 @@ describe("wire compatibility", () => {
projectDisplayName: "Favorite project", projectDisplayName: "Favorite project",
projectCustomName: "Favorite project", projectCustomName: "Favorite project",
projectCustomIconRevision: null, projectCustomIconRevision: null,
projectIconRevision: "automatic:none:v1",
projectRootPath: "/tmp/project", projectRootPath: "/tmp/project",
projectKind: "git", projectKind: "git",
}, },
@@ -350,6 +356,50 @@ describe("wire compatibility", () => {
]); ]);
}); });
test("publishes rapid project mutations in order before incremental reconciliation", async () => {
const directorySync = new DirectorySyncService("generation");
const initial = directorySync.synchronizeProjects([], {});
const messages: SessionOutboundMessage[] = [];
const session = createSessionForWireCompatTest({
clientCapabilities: { [CLIENT_CAPS.projectUpdates]: true },
directorySync,
messages,
});
const project = createPersistedProjectRecord({
projectId: "project-ordered",
rootPath: "/tmp/project-ordered",
kind: "git",
displayName: "Ordered project",
createdAt: "2026-07-15T00:00:00.000Z",
updatedAt: "2026-07-15T00:00:00.000Z",
});
await Promise.all([
session.emitProjectUpdate({ kind: "upsert", project }),
session.emitProjectUpdate({ kind: "remove", projectId: project.projectId }),
]);
expect(
messages.flatMap((message) =>
message.type === "project.update" ? [message.payload.kind] : [],
),
).toEqual(["upsert", "remove"]);
expect(
directorySync.synchronizeProjects([], {
generation: initial.sync.generation,
afterSeq: initial.sync.headSeq,
}),
).toEqual({
projects: [],
sync: {
generation: "generation",
mode: "changes",
headSeq: 2,
removals: [{ id: "project-ordered", seq: 2 }],
},
});
});
test("downgrades reasoning_merge for clients that do not declare the capability", async () => { test("downgrades reasoning_merge for clients that do not declare the capability", async () => {
const response = await emitTimelineResponse(); const response = await emitTimelineResponse();
@@ -10,7 +10,9 @@ import {
type ProjectRegistry, type ProjectRegistry,
} from "../server/workspace-registry.js"; } from "../server/workspace-registry.js";
import { import {
ProjectIconReader,
readProjectIcon, readProjectIcon,
readProjectIconSnapshot,
removeProjectCustomIcon, removeProjectCustomIcon,
setProjectCustomIcon, setProjectCustomIcon,
} from "./project-custom-icon.js"; } from "./project-custom-icon.js";
@@ -52,6 +54,7 @@ async function project() {
return record; return record;
}, },
} as unknown as ProjectRegistry; } as unknown as ProjectRegistry;
const reader = new ProjectIconReader(paseoHome);
return { return {
paseoHome, paseoHome,
@@ -59,6 +62,9 @@ async function project() {
set: (source: ProjectIconSource) => set: (source: ProjectIconSource) =>
setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }), setProjectCustomIcon({ paseoHome, projectId: "project-a", source, projects }),
read: () => readProjectIcon({ paseoHome, project: record }), read: () => readProjectIcon({ paseoHome, project: record }),
snapshot: () => readProjectIconSnapshot({ paseoHome, project: record }),
advertisedSnapshot: () => reader.snapshot(record),
readAdvertised: () => reader.read(record),
revision: () => record.customIconRevision, revision: () => record.customIconRevision,
remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }), remove: () => removeProjectCustomIcon({ paseoHome, projectId: "project-a" }),
}; };
@@ -88,6 +94,40 @@ describe("project custom icon", () => {
await expect(target.read()).resolves.toEqual(PNG_1X1_ICON); await expect(target.read()).resolves.toEqual(PNG_1X1_ICON);
}); });
it("fingerprints automatic icons and explicit no-icon results", async () => {
const target = await project();
const missing = await target.snapshot();
expect(missing).toEqual({ icon: null, revision: "automatic:none:v1" });
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const first = await target.snapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
const changed = await target.snapshot();
expect(first.icon).toEqual(PNG_1X1_ICON);
expect(first.revision).toMatch(/^automatic:[a-f0-9]{64}$/);
expect(changed.revision).not.toBe(first.revision);
});
it("serves the bytes that match the advertised revision", async () => {
const target = await project();
await mkdir(join(target.rootPath, "public"));
await writeFile(join(target.rootPath, "public", "favicon.png"), PNG_1X1);
const advertised = await target.advertisedSnapshot();
await writeFile(
join(target.rootPath, "public", "favicon.png"),
Buffer.concat([PNG_1X1, Buffer.from([1])]),
);
await expect(target.readAdvertised()).resolves.toEqual(advertised.icon);
});
it("drops the stored image when the project goes back to automatic", async () => { it("drops the stored image when the project goes back to automatic", async () => {
const target = await project(); const target = await project();
await target.set({ type: "upload", data: PNG_1X1.toString("base64") }); await target.set({ type: "upload", data: PNG_1X1.toString("base64") });
@@ -57,12 +57,63 @@ export async function readProjectIcon(input: {
paseoHome: string; paseoHome: string;
project: PersistedProjectRecord; project: PersistedProjectRecord;
}): Promise<ProjectIcon | null> { }): Promise<ProjectIcon | null> {
if (!input.project.customIconRevision) return getProjectIcon(input.project.rootPath); return (await readProjectIconSnapshot(input)).icon;
try { }
return validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch { export interface ProjectIconSnapshot {
return null; icon: ProjectIcon | null;
revision: string;
}
/** Keeps the icon bytes served by a session aligned with its advertised revision. */
export class ProjectIconReader {
private readonly snapshots = new Map<string, ProjectIconSnapshot>();
constructor(private readonly paseoHome: string) {}
async snapshot(project: PersistedProjectRecord): Promise<ProjectIconSnapshot> {
const snapshot = await readProjectIconSnapshot({ paseoHome: this.paseoHome, project });
this.snapshots.set(project.projectId, snapshot);
return snapshot;
} }
async read(project: PersistedProjectRecord): Promise<ProjectIcon | null> {
return (this.snapshots.get(project.projectId) ?? (await this.snapshot(project))).icon;
}
}
/** Reads the effective icon and gives that exact result a stable identity. */
export async function readProjectIconSnapshot(input: {
paseoHome: string;
project: PersistedProjectRecord;
}): Promise<ProjectIconSnapshot> {
if (input.project.customIconRevision) {
let icon: ProjectIcon | null = null;
try {
icon = validateIcon(await readFile(cachePath(input.paseoHome, input.project.projectId)));
} catch {
// A missing/corrupt custom icon is itself a cacheable result until the
// registry revision changes.
}
return {
icon,
revision: icon
? iconRevision("custom", icon)
: `custom:none:${input.project.customIconRevision}`,
};
}
const icon = await getProjectIcon(input.project.rootPath);
if (!icon) return { icon: null, revision: "automatic:none:v1" };
return { icon, revision: iconRevision("automatic", icon) };
}
function iconRevision(source: "automatic" | "custom", icon: ProjectIcon): string {
const fingerprint = createHash("sha256")
.update(icon.mimeType)
.update("\0")
.update(icon.data)
.digest("hex");
return `${source}:${fingerprint}`;
} }
export async function removeProjectCustomIcon(input: { export async function removeProjectCustomIcon(input: {