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:
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
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 output = execFileSync(
|
||||
process.execPath,
|
||||
@@ -11,7 +11,7 @@ describe("Android AsyncStorage configuration", () => {
|
||||
"-e",
|
||||
`
|
||||
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;
|
||||
mod({ ...config, modResults: [], modRequest: {}, modRawConfig: config })
|
||||
.then((result) => process.stdout.write(JSON.stringify(result.modResults)));
|
||||
@@ -21,7 +21,7 @@ describe("Android AsyncStorage configuration", () => {
|
||||
);
|
||||
|
||||
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({
|
||||
visible,
|
||||
onClose,
|
||||
serverId,
|
||||
projectId,
|
||||
projectViewKey,
|
||||
client,
|
||||
@@ -60,7 +59,6 @@ export function ProjectEditSheet({
|
||||
submitProjectEdit({ client, projectId, submission }),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["projects"] });
|
||||
queryClient.invalidateQueries({ queryKey: ["projectIcon", serverId] });
|
||||
toast.show(t("settings.project.edit.savedToast"), { variant: "success" });
|
||||
onClose();
|
||||
},
|
||||
|
||||
@@ -2,7 +2,10 @@ import { useCallback, useEffect, useMemo } from "react";
|
||||
import { useStoreWithEqualityFn } from "zustand/traditional";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useHydratedWorkspaceServerIds } from "@/stores/session-store-hooks";
|
||||
import {
|
||||
useHydratedWorkspaceServerIds,
|
||||
useWorkspaceDirectoryServerIds,
|
||||
} from "@/stores/session-store-hooks";
|
||||
import { workspaceEqualityFns } from "@/stores/session-store-hooks/selectors";
|
||||
import { useHostProjects } from "@/projects/host-projects";
|
||||
import { getHostRuntimeStore, useHostRegistryLoaded, useHosts } from "@/runtime/host-runtime";
|
||||
@@ -132,8 +135,9 @@ export function useSidebarWorkspacesList(options?: {
|
||||
const persistedProjectOrder = useSidebarOrderStore((state) => state.projectOrder ?? EMPTY_ORDER);
|
||||
|
||||
const hydratedServerIds = useHydratedWorkspaceServerIds(serverIds);
|
||||
const directoryServerIds = useWorkspaceDirectoryServerIds(serverIds);
|
||||
|
||||
const hostProjects = useHostProjects(hydratedServerIds);
|
||||
const hostProjects = useHostProjects(directoryServerIds);
|
||||
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -20,4 +20,8 @@ describe("project icon lookup", () => {
|
||||
cwd: "/projects/paseo",
|
||||
});
|
||||
});
|
||||
|
||||
it("waits while custom-icon capability is unknown", () => {
|
||||
expect(resolveProjectIconLookup(target, null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,8 @@ import { useMemo, useRef } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQueries, useQuery } from "@tanstack/react-query";
|
||||
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 {
|
||||
getHostRuntimeStore,
|
||||
isHostRuntimeConnected,
|
||||
@@ -16,6 +17,7 @@ interface ProjectIconTarget {
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
customIconRevision?: string | null;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -24,8 +26,9 @@ interface ProjectIconTarget {
|
||||
*/
|
||||
export function resolveProjectIconLookup(
|
||||
target: Pick<ProjectIconTarget, "projectId" | "iconWorkingDir">,
|
||||
supportsCustomIcons: boolean,
|
||||
): { kind: "project"; projectId: string } | { kind: "legacy"; cwd: string } {
|
||||
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 };
|
||||
@@ -35,10 +38,6 @@ function legacyIconQueryKey(serverId: string, cwd: string) {
|
||||
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 {
|
||||
if (!icon) return null;
|
||||
return `data:${icon.mimeType};base64,${icon.data}`;
|
||||
@@ -88,7 +87,7 @@ export function useProjectIcons(input: {
|
||||
() => [...new Set(input.projects.map((project) => project.serverId))],
|
||||
[input.projects],
|
||||
);
|
||||
const supportsCustomIcons = useHostFeatureMap(serverIds, "projectCustomIcon");
|
||||
const supportsCustomIcons = useHostFeatureAvailabilityMap(serverIds, "projectCustomIcon");
|
||||
const requests = useMemo(() => {
|
||||
const unique = new Map<string, ProjectIconTarget>();
|
||||
for (const project of input.projects) {
|
||||
@@ -100,35 +99,14 @@ export function useProjectIcons(input: {
|
||||
|
||||
const queries = useQueries({
|
||||
queries: requests.map((request) => {
|
||||
const revision = request.customIconRevision ?? "automatic";
|
||||
const lookup = resolveProjectIconLookup(
|
||||
request,
|
||||
supportsCustomIcons.get(request.serverId) === true,
|
||||
);
|
||||
return {
|
||||
queryKey:
|
||||
lookup.kind === "project"
|
||||
? iconQueryKey(request.serverId, lookup.projectId, revision)
|
||||
: legacyIconQueryKey(request.serverId, lookup.cwd),
|
||||
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) &&
|
||||
...projectIconCache.query(
|
||||
request,
|
||||
supportsCustomIcons.get(request.serverId) ?? null,
|
||||
() => getHostRuntimeStore().getClient(request.serverId),
|
||||
isHostRuntimeConnected(getHostRuntimeStore().getSnapshot(request.serverId)),
|
||||
),
|
||||
staleTime: Infinity,
|
||||
gcTime: 1000 * 60 * 60,
|
||||
refetchOnMount: false,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
select: iconDataUri,
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -7,6 +7,7 @@ export interface WorkspaceStructureHostPlacement {
|
||||
iconWorkingDir: string;
|
||||
worktreeSupport: "supported" | "unsupported" | "unknown";
|
||||
customIconRevision?: string | null;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
export interface WorkspaceStructureProject {
|
||||
@@ -160,6 +161,7 @@ function addProjectToView(input: {
|
||||
iconWorkingDir: project.projectRootPath,
|
||||
worktreeSupport: project.projectKind === "git" ? "supported" : "unsupported",
|
||||
customIconRevision: project.projectCustomIconRevision,
|
||||
iconRevision: project.projectIconRevision,
|
||||
};
|
||||
const draft = byProject.get(viewKey);
|
||||
if (!draft) {
|
||||
|
||||
@@ -51,6 +51,31 @@ function entry(agent: AgentSnapshotPayload): FetchAgentsEntry {
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const serverId = "agent-replica";
|
||||
const store = useSessionStore.getState();
|
||||
|
||||
@@ -3,7 +3,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import {
|
||||
applyAgentDirectoryDelta,
|
||||
type AgentDirectoryDelta,
|
||||
@@ -15,6 +15,12 @@ import {
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
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 {
|
||||
readonly agentId: string;
|
||||
readonly version: number;
|
||||
@@ -97,6 +103,25 @@ export class AgentDirectoryReplica {
|
||||
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 {
|
||||
this.advance(agentId);
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
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";
|
||||
|
||||
type WorkspaceFetchResult = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
||||
@@ -9,8 +9,11 @@ type ProjectListResult = Awaited<ReturnType<DaemonClient["listProjects"]>>;
|
||||
|
||||
class FakeDirectoryClient {
|
||||
fetchAgentsCalls = 0;
|
||||
lastAgentOptions: unknown;
|
||||
fetchWorkspacesCalls = 0;
|
||||
listProjectsCalls = 0;
|
||||
lastProjectOptions: unknown;
|
||||
projectResult: ProjectListResult | null = null;
|
||||
private pendingWorkspaceFetch: Promise<WorkspaceFetchResult> | null = null;
|
||||
private readonly handlers = new Map<
|
||||
SessionOutboundMessage["type"],
|
||||
@@ -42,8 +45,9 @@ class FakeDirectoryClient {
|
||||
return complete;
|
||||
}
|
||||
|
||||
async fetchAgents(): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
async fetchAgents(options?: unknown): Promise<Awaited<ReturnType<DaemonClient["fetchAgents"]>>> {
|
||||
this.fetchAgentsCalls += 1;
|
||||
this.lastAgentOptions = options;
|
||||
return {
|
||||
requestId: "agents",
|
||||
entries: [],
|
||||
@@ -66,8 +70,10 @@ class FakeDirectoryClient {
|
||||
};
|
||||
}
|
||||
|
||||
async listProjects(): Promise<ProjectListResult> {
|
||||
async listProjects(options?: unknown): Promise<ProjectListResult> {
|
||||
this.listProjectsCalls += 1;
|
||||
this.lastProjectOptions = options;
|
||||
if (this.projectResult) return this.projectResult;
|
||||
return {
|
||||
requestId: "projects",
|
||||
projects: [
|
||||
@@ -111,6 +117,52 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
const serverId = "workspace-metadata";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
@@ -159,6 +211,82 @@ describe("DirectorySync session readiness", () => {
|
||||
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 () => {
|
||||
const serverId = "session-wait-reconnect";
|
||||
const { client, directory } = createDirectory(serverId);
|
||||
|
||||
@@ -36,10 +36,41 @@ const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
||||
{ 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 {
|
||||
entries: FetchAgentsEntry[];
|
||||
subscriptionId: string | null;
|
||||
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 {
|
||||
@@ -48,6 +79,11 @@ export interface DirectoryConnection {
|
||||
source: DirectorySourceToken;
|
||||
}
|
||||
|
||||
export interface DirectoryCheckpointStorage {
|
||||
readDirectoryCheckpoint(serverId: string): unknown;
|
||||
writeDirectoryCheckpoint(serverId: string, checkpoint: unknown): void;
|
||||
}
|
||||
|
||||
export interface RefreshAgentDirectoryInput {
|
||||
filter?: FetchAgentsOptions["filter"];
|
||||
subscribe?: FetchAgentsOptions["subscribe"];
|
||||
@@ -86,6 +122,7 @@ export class DirectorySync {
|
||||
markAgentReady: () => void;
|
||||
markAgentError: (error: string) => void;
|
||||
},
|
||||
private readonly checkpoints?: DirectoryCheckpointStorage,
|
||||
) {
|
||||
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning);
|
||||
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
||||
@@ -116,19 +153,26 @@ export class DirectorySync {
|
||||
const subscriptions = [
|
||||
client.on("agent_update", (message) => {
|
||||
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.noteLiveCursor("agents", message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("workspace_update", (message) => {
|
||||
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.noteLiveCursor("workspaces", message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("project.update", (message) => {
|
||||
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.noteLiveCursor("projects", message.payload);
|
||||
}
|
||||
}),
|
||||
client.on("agent_deleted", (message) => {
|
||||
@@ -174,6 +218,7 @@ export class DirectorySync {
|
||||
entries: [],
|
||||
subscriptionId: null,
|
||||
legacy: false,
|
||||
syncRemovals: [],
|
||||
}));
|
||||
this.callbacks.markAgentLoading();
|
||||
try {
|
||||
@@ -222,7 +267,8 @@ export class DirectorySync {
|
||||
: delta,
|
||||
)
|
||||
: 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();
|
||||
return { agents, subscriptionId: completion.snapshot.subscriptionId };
|
||||
} catch (error) {
|
||||
@@ -238,8 +284,9 @@ export class DirectorySync {
|
||||
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
|
||||
const { client, source } = this.requireOnline();
|
||||
const transaction = this.workspaceTransactions.begin(source, () => ({
|
||||
workspaces: new Map(),
|
||||
projects: new Map(),
|
||||
workspaces: new Map(useSessionStore.getState().sessions[this.serverId]?.workspaces),
|
||||
projects: new Map(useSessionStore.getState().sessions[this.serverId]?.projects),
|
||||
syncCursors: {},
|
||||
}));
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
@@ -250,24 +297,19 @@ export class DirectorySync {
|
||||
return;
|
||||
}
|
||||
const supportsProjectList = serverInfo.features?.projectList === true;
|
||||
const supportsDirectorySync = serverInfo.features?.directorySync === true;
|
||||
if (supportsProjectList) {
|
||||
const payload = await client.listProjects();
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
for (const entry of payload.projects) {
|
||||
const project = normalizeProjectDescriptor(entry);
|
||||
transaction.snapshot.projects.set(project.projectId, project);
|
||||
}
|
||||
await this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync);
|
||||
}
|
||||
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 (!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);
|
||||
this.completeWorkspaceRefresh(client, source, transaction);
|
||||
} catch (error) {
|
||||
const deltas = this.workspaceTransactions.fail(transaction);
|
||||
if (deltas) for (const delta of deltas) this.workspaces.applyDelta(delta);
|
||||
@@ -280,6 +322,7 @@ export class DirectorySync {
|
||||
source: DirectorySourceToken,
|
||||
transaction: DirectoryTransaction<WorkspaceDirectorySnapshot, WorkspaceDirectoryDelta>,
|
||||
initialSubscribe: boolean,
|
||||
supportsDirectorySync: boolean,
|
||||
): Promise<void> {
|
||||
let cursor: string | null = null;
|
||||
let subscribe = initialSubscribe;
|
||||
@@ -288,12 +331,23 @@ export class DirectorySync {
|
||||
sort: [{ key: "activity_at", direction: "desc" }],
|
||||
...(subscribe ? { subscribe: {} } : {}),
|
||||
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
||||
...(supportsDirectorySync ? { sync: this.readCursors().workspaces ?? {} } : {}),
|
||||
});
|
||||
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
||||
if (payload.sync?.mode === "snapshot") transaction.snapshot.workspaces.clear();
|
||||
for (const entry of payload.entries) {
|
||||
const workspace = normalizeWorkspaceDescriptor(entry);
|
||||
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 ?? []) {
|
||||
const project = normalizeProjectDescriptor(entry);
|
||||
transaction.snapshot.projects.set(project.projectId, project);
|
||||
@@ -332,18 +386,15 @@ export class DirectorySync {
|
||||
sort: AGENT_SORT,
|
||||
...(subscribe ? { subscribe } : {}),
|
||||
page: cursor ? { limit, cursor } : { limit },
|
||||
...(!input.filter && cursor === null && this.supportsDirectorySync()
|
||||
? { sync: this.readCursors().agents ?? {} }
|
||||
: {}),
|
||||
});
|
||||
this.assertAgentTransactionCurrent(client, source, transaction);
|
||||
transaction.snapshot.entries.push(...payload.entries);
|
||||
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
|
||||
const pageInfo = payload.pageInfo as {
|
||||
hasMore?: boolean;
|
||||
hasMoreAfter?: boolean;
|
||||
nextCursor?: string | null;
|
||||
afterCursor?: string | null;
|
||||
};
|
||||
const hasMore = pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false;
|
||||
const nextCursor = pageInfo.nextCursor ?? pageInfo.afterCursor ?? null;
|
||||
this.recordAgentSync(transaction.snapshot, payload.sync);
|
||||
const { hasMore, nextCursor } = resolveAgentNextPage(payload.pageInfo as AgentPageInfo);
|
||||
if (!hasMore || !nextCursor) break;
|
||||
cursor = nextCursor;
|
||||
subscribe = undefined;
|
||||
@@ -377,6 +428,122 @@ export class DirectorySync {
|
||||
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 {
|
||||
return (
|
||||
this.connection.client === client &&
|
||||
|
||||
@@ -21,6 +21,9 @@ type ProjectDirectoryDelta = Extract<SessionOutboundMessage, { type: "project.up
|
||||
export interface WorkspaceDirectorySnapshot {
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects: Map<string, ProjectDescriptor>;
|
||||
syncCursors?: Partial<
|
||||
Record<"projects" | "workspaces", { generation: string; afterSeq: number }>
|
||||
>;
|
||||
}
|
||||
|
||||
function applyProjectDelta(
|
||||
|
||||
@@ -51,3 +51,22 @@ export function useHostFeatureMap(
|
||||
[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],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,8 @@ import { resolveComposerAttachmentSubmitFormat } from "@/composer/attachments/su
|
||||
import { encodeImages } from "@/utils/encode-images";
|
||||
import { DirectorySync, type RefreshAgentDirectoryResult } from "@/runtime/directory-sync";
|
||||
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 { revokePushNotifications } from "@/push-notifications";
|
||||
import { createAppWebSocketFactory } from "./websocket-factory";
|
||||
@@ -1374,7 +1376,7 @@ export class HostRuntimeStore {
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
this.replicaCache = new ReplicaCache(this.storage);
|
||||
this.replicaCache = new ReplicaCache(input?.storage ?? replicaCacheStorage);
|
||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||
}
|
||||
|
||||
@@ -1473,7 +1475,8 @@ export class HostRuntimeStore {
|
||||
}
|
||||
this.hosts = profiles;
|
||||
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);
|
||||
} catch (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.directoryBootstrapInFlight, oldServerId, newServerId);
|
||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||
projectIconCache.reconcileServerId(oldServerId, newServerId);
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
this.directorySyncByServer.delete(oldServerId);
|
||||
const directory = new DirectorySync(newServerId, {
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
});
|
||||
const directory = new DirectorySync(
|
||||
newServerId,
|
||||
{
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
},
|
||||
this.replicaCache,
|
||||
);
|
||||
this.directorySyncByServer.set(newServerId, directory);
|
||||
controller.adoptReconciledServerId(newServerId);
|
||||
const snapshot = controller.getSnapshot();
|
||||
@@ -1970,6 +1978,7 @@ export class HostRuntimeStore {
|
||||
},
|
||||
): void {
|
||||
this.replicaCache.setHosts(hosts.map((host) => host.serverId));
|
||||
projectIconCache.setHosts(hosts.map((host) => host.serverId));
|
||||
const nextIds = new Set(hosts.map((host) => host.serverId));
|
||||
for (const [serverId, controller] of this.controllers) {
|
||||
if (nextIds.has(serverId)) {
|
||||
@@ -2006,12 +2015,17 @@ export class HostRuntimeStore {
|
||||
this.controllers.set(host.serverId, controller);
|
||||
this.directorySyncByServer.set(
|
||||
host.serverId,
|
||||
new DirectorySync(host.serverId, {
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
}),
|
||||
new DirectorySync(
|
||||
host.serverId,
|
||||
{
|
||||
onAgentStoppedRunning: (agentId) =>
|
||||
this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
},
|
||||
this.replicaCache,
|
||||
),
|
||||
);
|
||||
const initialSnapshot = controller.getSnapshot();
|
||||
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 {
|
||||
this.version += 1;
|
||||
const listeners = this.serverListeners.get(serverId);
|
||||
|
||||
@@ -55,34 +55,50 @@ function workspace(
|
||||
}
|
||||
|
||||
function agent(id: string, workspaceId = "workspace-1", cwd = "/repo/paseo") {
|
||||
return normalizeAgentSnapshot(
|
||||
{
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
workspaceId,
|
||||
model: null,
|
||||
createdAt: "2026-07-18T08:00:00.000Z",
|
||||
updatedAt: "2026-07-18T08:01:00.000Z",
|
||||
lastUserMessageAt: "2026-07-18T08:01:00.000Z",
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: true,
|
||||
supportsReasoningStream: true,
|
||||
supportsToolInvocations: true,
|
||||
return {
|
||||
...normalizeAgentSnapshot(
|
||||
{
|
||||
id,
|
||||
provider: "codex",
|
||||
cwd,
|
||||
workspaceId,
|
||||
model: null,
|
||||
createdAt: "2026-07-18T08:00:00.000Z",
|
||||
updatedAt: "2026-07-18T08:01:00.000Z",
|
||||
lastUserMessageAt: "2026-07-18T08:01:00.000Z",
|
||||
status: "idle",
|
||||
capabilities: {
|
||||
supportsStreaming: true,
|
||||
supportsSessionPersistence: true,
|
||||
supportsDynamicModes: true,
|
||||
supportsMcpServers: 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 {
|
||||
@@ -212,10 +228,12 @@ describe("ReplicaCache", () => {
|
||||
expect(session.client).toBeNull();
|
||||
expect(session.hasHydratedAgents).toBe(false);
|
||||
expect(session.hasHydratedWorkspaces).toBe(false);
|
||||
expect(session.hasWorkspaceDirectorySnapshot).toBe(true);
|
||||
expect(Array.from(session.agents.keys())).toEqual(["agent-1"]);
|
||||
expect(Array.from(session.workspaces.keys())).toEqual(["workspace-1"]);
|
||||
expect(Array.from(session.projects.keys())).toEqual(["project-1"]);
|
||||
expect(Array.from(session.projects.keys())).toEqual(["project-1", "empty-project"]);
|
||||
expect(session.agents.get("agent-1")?.updatedAt).toBeInstanceOf(Date);
|
||||
expect(session.agents.get("agent-1")?.projectPlacement?.checkout.cwd).toBe("/repo/paseo");
|
||||
expect(session.workspaces.get("workspace-1")?.statusEnteredAt).toBeInstanceOf(Date);
|
||||
expect(session.workspaces.get("workspace-1")?.worktreeSlug).toBe("owned-worktree");
|
||||
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 cache = new ReplicaCache(storage);
|
||||
cache.setHosts([SERVER_ID]);
|
||||
@@ -266,9 +284,13 @@ describe("ReplicaCache", () => {
|
||||
|
||||
const session = useSessionStore.getState().sessions[SERVER_ID];
|
||||
const timelines = session?.agentStreamTail;
|
||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-2"]);
|
||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-2"]);
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual(["project-2"]);
|
||||
expect(Array.from(session?.agents.keys() ?? [])).toEqual(["agent-1", "agent-2"]);
|
||||
expect(Array.from(session?.workspaces.keys() ?? [])).toEqual(["workspace-1", "workspace-2"]);
|
||||
expect(Array.from(session?.projects.keys() ?? [])).toEqual([
|
||||
"project-1",
|
||||
"project-2",
|
||||
"empty-project",
|
||||
]);
|
||||
expect(Array.from(timelines?.keys() ?? [])).toEqual(["agent-2"]);
|
||||
expect(timelines?.get("agent-2")).toEqual(secondTimeline.slice(-50));
|
||||
|
||||
@@ -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 () => {
|
||||
const storage = new MemoryStorage();
|
||||
const cache = new ReplicaCache(storage, { maxBytes: 7_000 });
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type Agent,
|
||||
type AgentTimelineState,
|
||||
type SessionReplica,
|
||||
type SessionState,
|
||||
type ProjectDescriptor,
|
||||
@@ -21,7 +20,7 @@ const STORAGE_KEY = "@paseo:replica-cache";
|
||||
const CACHE_VERSION = 5;
|
||||
const PERSIST_DELAY_MS = 750;
|
||||
const MAX_TIMELINE_ITEMS = 50;
|
||||
const MAX_CACHE_BYTES = 1024 * 1024;
|
||||
const MAX_CACHE_BYTES = 32 * 1024 * 1024;
|
||||
const IsoDateSchema = z.iso.datetime();
|
||||
const TimelinePositionSchema = z.strictObject({
|
||||
epoch: z.string(),
|
||||
@@ -107,6 +106,43 @@ const AgentCapabilitiesSchema = z.strictObject({
|
||||
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({
|
||||
id: z.string(),
|
||||
provider: AgentProviderSchema,
|
||||
@@ -141,7 +177,7 @@ const StoredAgentSnapshotSchema = z.strictObject({
|
||||
|
||||
const StoredAgentSchema = z.strictObject({
|
||||
snapshot: StoredAgentSnapshotSchema,
|
||||
projectPlacement: z.null(),
|
||||
projectPlacement: StoredProjectPlacementSchema.nullable(),
|
||||
lastActivityAt: IsoDateSchema,
|
||||
});
|
||||
|
||||
@@ -201,6 +237,8 @@ const StoredProjectSchema = z.strictObject({
|
||||
projectKey: z.string().optional(),
|
||||
projectDisplayName: z.string(),
|
||||
projectCustomName: z.string().nullable(),
|
||||
projectCustomIconRevision: z.string().nullable(),
|
||||
projectIconRevision: z.string().optional(),
|
||||
projectRootPath: z.string(),
|
||||
projectKind: z.enum(["git", "non_git", "directory"]),
|
||||
});
|
||||
@@ -217,6 +255,7 @@ const StoredHostSchema = z.strictObject({
|
||||
projects: z.array(StoredProjectSchema),
|
||||
emptyProjects: z.array(StoredProjectSchema),
|
||||
timeline: StoredTimelineSchema.nullable(),
|
||||
directorySync: z.unknown().optional(),
|
||||
});
|
||||
|
||||
const StoredCacheSchema = z.strictObject({
|
||||
@@ -231,9 +270,10 @@ type StoredWorkspace = z.infer<typeof StoredWorkspaceSchema>;
|
||||
type StoredProject = z.infer<typeof StoredProjectSchema>;
|
||||
|
||||
interface ReplicaInput {
|
||||
agent: Agent | undefined;
|
||||
workspace: WorkspaceDescriptor | undefined;
|
||||
project: ProjectDescriptor | undefined;
|
||||
agents: ReadonlyMap<string, Agent>;
|
||||
workspaces: ReadonlyMap<string, WorkspaceDescriptor>;
|
||||
projects: ReadonlyMap<string, ProjectDescriptor>;
|
||||
focusedAgent: Agent | 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 {
|
||||
const snapshot = {
|
||||
id: agent.id,
|
||||
@@ -421,7 +465,7 @@ function serializeAgent(agent: Agent): StoredAgent {
|
||||
};
|
||||
return {
|
||||
snapshot,
|
||||
projectPlacement: null,
|
||||
projectPlacement: serializeProjectPlacement(agent),
|
||||
lastActivityAt: agent.lastActivityAt.toISOString(),
|
||||
};
|
||||
}
|
||||
@@ -478,6 +522,8 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
|
||||
...(project.projectKey ? { projectKey: project.projectKey } : {}),
|
||||
projectDisplayName: project.projectDisplayName,
|
||||
projectCustomName: project.projectCustomName,
|
||||
projectCustomIconRevision: project.projectCustomIconRevision ?? null,
|
||||
projectIconRevision: project.projectIconRevision,
|
||||
projectRootPath: project.projectRootPath,
|
||||
projectKind: project.projectKind,
|
||||
};
|
||||
@@ -485,50 +531,47 @@ function serializeProject(project: ProjectDescriptor): StoredProject {
|
||||
|
||||
function replicaInputsEqual(left: ReplicaInput, right: ReplicaInput): boolean {
|
||||
return (
|
||||
left.agent === right.agent &&
|
||||
left.workspace === right.workspace &&
|
||||
left.project === right.project &&
|
||||
left.agents === right.agents &&
|
||||
left.workspaces === right.workspaces &&
|
||||
left.projects === right.projects &&
|
||||
left.focusedAgent === right.focusedAgent &&
|
||||
left.timelineItems === right.timelineItems
|
||||
);
|
||||
}
|
||||
|
||||
function selectReplicaInput(session: SessionState, agentId: string | null): ReplicaInput {
|
||||
const agent = agentId ? session.agents.get(agentId) : undefined;
|
||||
const workspace = agent
|
||||
? ((agent.workspaceId ? session.workspaces.get(agent.workspaceId) : undefined) ??
|
||||
Array.from(session.workspaces.values()).find(
|
||||
(candidate) => candidate.workspaceDirectory === agent.cwd,
|
||||
))
|
||||
: undefined;
|
||||
const timeline: AgentTimelineState = agentId
|
||||
const timeline = agentId
|
||||
? selectAgentTimelineState(session, agentId)
|
||||
: { status: "cold" };
|
||||
: { status: "cold" as const };
|
||||
return {
|
||||
agent,
|
||||
workspace,
|
||||
project: workspace ? session.projects.get(workspace.projectId) : undefined,
|
||||
agents: session.agents,
|
||||
workspaces: session.workspaces,
|
||||
projects: session.projects,
|
||||
focusedAgent: agent,
|
||||
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
|
||||
?.filter((item) => item.kind !== "user_message" || !isUnreconciledLocalUserMessage(item))
|
||||
.map(serializeTimelineItem)
|
||||
.filter((item) => item !== null);
|
||||
return {
|
||||
serverId,
|
||||
agents: input.agent ? [serializeAgent(input.agent)] : [],
|
||||
workspaces: input.workspace ? [serializeWorkspace(input.workspace)] : [],
|
||||
projects: input.project ? [serializeProject(input.project)] : [],
|
||||
agents: Array.from(input.agents.values(), serializeAgent),
|
||||
workspaces: Array.from(input.workspaces.values(), serializeWorkspace),
|
||||
projects: Array.from(input.projects.values(), serializeProject),
|
||||
emptyProjects: [],
|
||||
timeline:
|
||||
input.agent && items
|
||||
input.focusedAgent && items
|
||||
? {
|
||||
agentId: input.agent.id,
|
||||
agentId: input.focusedAgent.id,
|
||||
items: items.slice(-MAX_TIMELINE_ITEMS),
|
||||
}
|
||||
: null,
|
||||
...(directorySync ? { directorySync } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -688,6 +731,24 @@ export class ReplicaCache {
|
||||
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> {
|
||||
if (this.persistTimer) {
|
||||
clearTimeout(this.persistTimer);
|
||||
@@ -727,8 +788,9 @@ export class ReplicaCache {
|
||||
if (previous && replicaInputsEqual(previous, input)) return false;
|
||||
|
||||
this.capturedInputs.set(serverId, input);
|
||||
const directorySync = this.storedHosts.get(serverId)?.directorySync;
|
||||
this.storedHosts.delete(serverId);
|
||||
this.storedHosts.set(serverId, serializeHost(serverId, input));
|
||||
this.storedHosts.set(serverId, serializeHost(serverId, input, directorySync));
|
||||
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,
|
||||
selectHasHydratedWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
selectHasWorkspaces,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
@@ -79,6 +80,14 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
|
||||
return useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
|
||||
workspaceEqualityFns.deep,
|
||||
);
|
||||
}
|
||||
|
||||
export function useWorkspaceDirectory(
|
||||
serverId: string | null,
|
||||
workspaceId: string | null,
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
composeWorkspaceStructure,
|
||||
selectHasWorkspaces,
|
||||
selectHydratedWorkspaceServerIds,
|
||||
selectWorkspaceDirectoryServerIds,
|
||||
selectProjectOrder,
|
||||
selectRecommendedProjectPaths,
|
||||
selectWorkspace,
|
||||
@@ -131,20 +132,32 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
describe("workspace replica authority", () => {
|
||||
it("keeps a cached workspace addressable without publishing it as an authoritative directory", () => {
|
||||
it("publishes a restored directory while keeping remote hydration false", () => {
|
||||
const cachedWorkspace = createWorkspace({ id: "cached-workspace" });
|
||||
initializeWorkspaces([cachedWorkspace]);
|
||||
useSessionStore.getState().restoreSessionReplica(SERVER_ID, {
|
||||
agents: new Map(),
|
||||
workspaces: new Map([[cachedWorkspace.id, cachedWorkspace]]),
|
||||
projects: new Map([
|
||||
[cachedWorkspace.projectId, projectDescriptorFromTestWorkspace(cachedWorkspace)],
|
||||
]),
|
||||
timeline: null,
|
||||
});
|
||||
const cachedSession = useSessionStore.getState().sessions[SERVER_ID];
|
||||
if (!cachedSession) throw new Error("expected initialized session");
|
||||
|
||||
const cachedServerIds = selectHydratedWorkspaceServerIds(useSessionStore.getState(), [
|
||||
const cachedServerIds = selectWorkspaceDirectoryServerIds(useSessionStore.getState(), [
|
||||
SERVER_ID,
|
||||
]);
|
||||
|
||||
expect(selectWorkspace(useSessionStore.getState(), SERVER_ID, cachedWorkspace.id)).toBe(
|
||||
cachedWorkspace,
|
||||
);
|
||||
expect(selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds)).toEqual(
|
||||
[],
|
||||
);
|
||||
expect(cachedSession.hasHydratedWorkspaces).toBe(false);
|
||||
expect(
|
||||
selectWorkspaceStructureProjects(useSessionStore.getState(), cachedServerIds).map(
|
||||
(project) => project.workspaceKeys,
|
||||
),
|
||||
).toEqual([[`${SERVER_ID}:${cachedWorkspace.id}`]]);
|
||||
|
||||
const authoritativeWorkspace = createWorkspace({
|
||||
id: "authoritative-workspace",
|
||||
|
||||
@@ -16,6 +16,7 @@ export interface SessionsSnapshot {
|
||||
string,
|
||||
{
|
||||
hasHydratedWorkspaces?: boolean;
|
||||
hasWorkspaceDirectorySnapshot?: boolean;
|
||||
workspaces: Map<string, WorkspaceDescriptor>;
|
||||
projects?: Map<string, ProjectDescriptor>;
|
||||
}
|
||||
@@ -138,6 +139,18 @@ export function selectHydratedWorkspaceServerIds(
|
||||
return serverIds.filter((serverId) => state.sessions[serverId]?.hasHydratedWorkspaces === true);
|
||||
}
|
||||
|
||||
export function selectWorkspaceDirectoryServerIds(
|
||||
state: SessionsSnapshot,
|
||||
serverIds: readonly string[],
|
||||
): string[] {
|
||||
return serverIds.filter((serverId) => {
|
||||
const session = state.sessions[serverId];
|
||||
return (
|
||||
session?.hasHydratedWorkspaces === true || session?.hasWorkspaceDirectorySnapshot === true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function selectWorkspaceStructureProjects(
|
||||
state: SessionsSnapshot,
|
||||
serverIds: readonly string[],
|
||||
|
||||
@@ -172,6 +172,7 @@ export interface ProjectDescriptor {
|
||||
projectDisplayName: string;
|
||||
projectCustomName: string | null;
|
||||
projectCustomIconRevision?: string | null;
|
||||
projectIconRevision?: string;
|
||||
projectRootPath: string;
|
||||
projectKind: WorkspaceDescriptorPayload["projectKind"];
|
||||
}
|
||||
@@ -185,6 +186,7 @@ export function normalizeProjectDescriptor(
|
||||
projectDisplayName: payload.projectDisplayName,
|
||||
projectCustomName: payload.projectCustomName ?? null,
|
||||
projectCustomIconRevision: payload.projectCustomIconRevision ?? null,
|
||||
projectIconRevision: payload.projectIconRevision,
|
||||
projectRootPath: payload.projectRootPath,
|
||||
projectKind: payload.projectKind,
|
||||
};
|
||||
@@ -391,6 +393,7 @@ export interface SessionState {
|
||||
// Hydration status
|
||||
hasHydratedAgents: boolean;
|
||||
hasHydratedWorkspaces: boolean;
|
||||
hasWorkspaceDirectorySnapshot: boolean;
|
||||
|
||||
// Audio state
|
||||
isPlayingAudio: boolean;
|
||||
@@ -653,6 +656,7 @@ function createInitialSessionState(
|
||||
serverInfo: null,
|
||||
hasHydratedAgents: false,
|
||||
hasHydratedWorkspaces: false,
|
||||
hasWorkspaceDirectorySnapshot: false,
|
||||
isPlayingAudio: false,
|
||||
focusedAgentId: null,
|
||||
focusedTerminalId: null,
|
||||
@@ -799,6 +803,7 @@ export const useSessionStore = create<SessionStore>()(
|
||||
workspaceAgentActivity: buildWorkspaceAgentActivityIndex(replica.agents),
|
||||
workspaces: replica.workspaces,
|
||||
projects: replica.projects,
|
||||
hasWorkspaceDirectorySnapshot: true,
|
||||
agentStreamTail,
|
||||
agentTasks,
|
||||
},
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { normalizeAgentSnapshot } from "./agent-snapshots";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "./agent-snapshots";
|
||||
|
||||
function createSnapshot(
|
||||
input: Partial<Omit<AgentSnapshotPayload, "labels">> & {
|
||||
@@ -36,6 +36,18 @@ function createSnapshot(
|
||||
}
|
||||
|
||||
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", () => {
|
||||
const startedAt = "2026-07-31T12:00:00.000Z";
|
||||
expect(
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
|
||||
import type { ActiveTurnIdentity } from "@/timeline/turn-liveness";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
|
||||
function normalizeActiveTurn(
|
||||
snapshot: AgentSnapshotPayload,
|
||||
@@ -17,6 +18,17 @@ function normalizeActiveTurn(
|
||||
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(
|
||||
agentId: string,
|
||||
request: AgentPermissionRequest,
|
||||
@@ -31,6 +43,37 @@ export function derivePendingPermissionKey(
|
||||
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) {
|
||||
const createdAt = new Date(snapshot.createdAt);
|
||||
const updatedAt = new Date(snapshot.updatedAt);
|
||||
|
||||
@@ -5,6 +5,7 @@ export interface SidebarProjectHostTarget {
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
customIconRevision?: string | null;
|
||||
iconRevision?: string;
|
||||
}
|
||||
|
||||
export type SidebarProjectTrailingAction =
|
||||
@@ -25,6 +26,7 @@ function hostTarget(input: {
|
||||
projectId: string;
|
||||
iconWorkingDir: string;
|
||||
customIconRevision?: string | null;
|
||||
iconRevision?: string;
|
||||
}): SidebarProjectHostTarget | null {
|
||||
const iconWorkingDir = input.iconWorkingDir.trim();
|
||||
if (!input.serverId || !iconWorkingDir) {
|
||||
@@ -35,6 +37,7 @@ function hostTarget(input: {
|
||||
projectId: input.projectId,
|
||||
iconWorkingDir,
|
||||
customIconRevision: input.customIconRevision,
|
||||
iconRevision: input.iconRevision,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user