mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
feat(hosts): bind replica and session lifetime to origin authority
This commit is contained in:
@@ -27,23 +27,19 @@ import {
|
||||
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
|
||||
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
||||
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
|
||||
import type { AudioPlaybackSource } from "@/audio";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
toDaemonServerInfo,
|
||||
useSessionStore,
|
||||
useSessionStoreApi,
|
||||
type SessionState,
|
||||
} from "@/stores/session-store";
|
||||
import { sendOsNotification } from "@/utils/os-notifications";
|
||||
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
|
||||
import {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
createInitDeferred,
|
||||
rejectInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
|
||||
import { useToast } from "@/contexts/toast-context";
|
||||
import { toErrorMessage } from "@/utils/error-messages";
|
||||
@@ -201,6 +197,8 @@ export function SessionProvider(props: SessionProviderProps) {
|
||||
}
|
||||
|
||||
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const { t } = useTranslation();
|
||||
const voiceRuntime = useVoiceRuntimeOptional();
|
||||
const voiceAudioEngine = useVoiceAudioEngineOptional();
|
||||
@@ -265,7 +263,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
notification?: AgentAttentionNotificationPayload;
|
||||
}) => {
|
||||
const appState = appStateRef.current;
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = sessionStore.getState().sessions[serverId];
|
||||
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
|
||||
if (params.reason === "error") {
|
||||
return;
|
||||
@@ -309,7 +307,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
data: notification.data,
|
||||
});
|
||||
},
|
||||
[serverId],
|
||||
[serverId, sessionStore],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -377,7 +375,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
|
||||
]);
|
||||
if (turnLiveness.length > 0) {
|
||||
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
runtime.applyAgentTurnLiveness(serverId, agentId, turnLiveness);
|
||||
}
|
||||
sync.enqueueStreamEvent(agentId, {
|
||||
event: streamEvent,
|
||||
@@ -395,14 +393,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
void requestTimelineReplacement(
|
||||
{
|
||||
fetchAgentTimeline: (agentId, request) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
|
||||
runtime.fetchAgentTimeline(serverId, agentId, request),
|
||||
},
|
||||
message.payload.agentId,
|
||||
).catch((error: unknown) => {
|
||||
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
|
||||
});
|
||||
};
|
||||
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
|
||||
const sync = runtime.createViewedTimelineOwner(serverId, {
|
||||
observe: (agentIds) => {
|
||||
const observation = client.observeTimeline(agentIds);
|
||||
observation.subscribe({
|
||||
@@ -416,7 +414,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
},
|
||||
readCursor: (agentId) => {
|
||||
const timeline = selectAgentTimelineState(
|
||||
useSessionStore.getState().sessions[serverId],
|
||||
sessionStore.getState().sessions[serverId],
|
||||
agentId,
|
||||
);
|
||||
return timeline.status === "synced" && timeline.range
|
||||
@@ -424,15 +422,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
: undefined;
|
||||
},
|
||||
fetchPage: async (agentId, request) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = sessionStore.getState().sessions[serverId];
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
|
||||
if (shouldInitialize) {
|
||||
if (!getInitDeferred(initKey)) {
|
||||
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
|
||||
if (!sessionStore.initializations.getInitDeferred(initKey)) {
|
||||
const deferred = sessionStore.initializations.createInitDeferred(
|
||||
initKey,
|
||||
request.direction ?? "tail",
|
||||
);
|
||||
void deferred.promise.catch(() => undefined);
|
||||
}
|
||||
refreshAgentInitializationTimeout({
|
||||
store: sessionStore,
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing,
|
||||
@@ -440,21 +442,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setAgentInitializing(agentId, true);
|
||||
}
|
||||
try {
|
||||
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
|
||||
if (shouldInitialize && getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
|
||||
const page = await runtime.fetchAgentTimeline(serverId, agentId, request);
|
||||
if (shouldInitialize && sessionStore.initializations.getInitDeferred(initKey)) {
|
||||
refreshAgentInitializationTimeout({
|
||||
store: sessionStore,
|
||||
key: initKey,
|
||||
agentId,
|
||||
setAgentInitializing,
|
||||
});
|
||||
}
|
||||
return page;
|
||||
} catch (error) {
|
||||
if (shouldInitialize) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
|
||||
sessionStore.initializations.rejectInitDeferred(
|
||||
initKey,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
fetchLatestTail: (agentId) =>
|
||||
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
||||
runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
|
||||
reportError: (error) => {
|
||||
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
|
||||
},
|
||||
@@ -478,7 +488,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
||||
setViewedTimelineSync(serverId, null);
|
||||
sync.dispose();
|
||||
};
|
||||
}, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]);
|
||||
}, [
|
||||
client,
|
||||
serverId,
|
||||
sessionStore,
|
||||
runtime,
|
||||
sessionStore.initializations,
|
||||
setInitializingAgents,
|
||||
setViewedTimelineSync,
|
||||
voiceRuntime,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
viewedTimelineSyncRef.current?.setConnected(isConnected);
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
|
||||
export const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
export const createQueryClient = () =>
|
||||
new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: Infinity,
|
||||
refetchOnMount: false,
|
||||
refetchOnReconnect: false,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
export const queryClient = createQueryClient();
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { createSessionStore, useSessionStore } from "@/stores/session-store";
|
||||
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
|
||||
import type { HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
|
||||
@@ -288,3 +288,45 @@ describe("refreshAgent", () => {
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["complete", "fail"] as const)(
|
||||
"keeps routed initialization pending when direct initialization for the same address %s",
|
||||
async (outcome) => {
|
||||
const direct = createSessionStore();
|
||||
const routed = createSessionStore();
|
||||
for (const store of [direct, routed]) store.getState().initializeSession(serverId, null);
|
||||
const directRuntime = new FakeTimelineRuntime();
|
||||
const routedRuntime = new FakeTimelineRuntime();
|
||||
const client = new FakeDaemonClient();
|
||||
const start = (store: typeof direct, runtime: FakeTimelineRuntime) =>
|
||||
ensureAgentIsInitialized({
|
||||
store,
|
||||
serverId,
|
||||
agentId,
|
||||
client: client as never,
|
||||
runtime,
|
||||
setAgentInitializing: createSetAgentInitializing(
|
||||
serverId,
|
||||
store.getState().setInitializingAgents,
|
||||
),
|
||||
});
|
||||
const directReady = start(direct, directRuntime);
|
||||
const routedReady = start(routed, routedRuntime);
|
||||
void directReady.catch(() => {});
|
||||
void routedReady.catch(() => {});
|
||||
try {
|
||||
expect(directReady).not.toBe(routedReady);
|
||||
const key = getInitKey(serverId, agentId);
|
||||
if (outcome === "complete") direct.initializations.resolveInitDeferred(key);
|
||||
else direct.initializations.rejectInitDeferred(key, new Error("Direct history failed"));
|
||||
await Promise.allSettled([directReady]);
|
||||
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
|
||||
routed.initializations.resolveInitDeferred(key);
|
||||
await routedReady;
|
||||
} finally {
|
||||
direct.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
|
||||
routed.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
|
||||
resolveInitDeferred(getInitKey(serverId, agentId));
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,16 +1,14 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createInitDeferred,
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
INIT_TIMEOUT_MS,
|
||||
rejectInitDeferred,
|
||||
refreshInitTimeout,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
useSessionStoreApi,
|
||||
type SessionStoreApi,
|
||||
} from "@/stores/session-store";
|
||||
import { getInitKey, INIT_TIMEOUT_MS } from "@/utils/agent-initialization";
|
||||
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
|
||||
import { i18n } from "@/i18n/i18next";
|
||||
|
||||
@@ -22,19 +20,22 @@ export function createHistorySyncTimeoutError(): Error {
|
||||
|
||||
export function refreshAgentInitializationTimeout(input: {
|
||||
key: string;
|
||||
store?: SessionStoreApi;
|
||||
agentId: string;
|
||||
setAgentInitializing: SetAgentInitializing;
|
||||
}): void {
|
||||
refreshInitTimeout({
|
||||
const initializations = (input.store ?? useSessionStore).initializations;
|
||||
initializations.refreshInitTimeout({
|
||||
key: input.key,
|
||||
onTimeout: () => {
|
||||
input.setAgentInitializing(input.agentId, false);
|
||||
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
|
||||
initializations.rejectInitDeferred(input.key, createHistorySyncTimeoutError());
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export interface EnsureAgentIsInitializedInput {
|
||||
store?: SessionStoreApi;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
|
||||
@@ -45,25 +46,27 @@ export interface EnsureAgentIsInitializedInput {
|
||||
|
||||
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
|
||||
const { serverId, agentId, client, setAgentInitializing } = input;
|
||||
const store = input.store ?? useSessionStore;
|
||||
const initializations = store.initializations;
|
||||
const key = getInitKey(serverId, agentId);
|
||||
const existing = getInitDeferred(key);
|
||||
const existing = initializations.getInitDeferred(key);
|
||||
if (existing) {
|
||||
return existing.promise;
|
||||
}
|
||||
|
||||
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
|
||||
const timeline = selectAgentTimelineState(store.getState().sessions[serverId], agentId);
|
||||
const timelineRequest = planTimelineResumeFetch(
|
||||
timeline.status === "synced" ? timeline.range : null,
|
||||
);
|
||||
|
||||
const deferred = createInitDeferred(key, timelineRequest.direction);
|
||||
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
|
||||
const deferred = initializations.createInitDeferred(key, timelineRequest.direction);
|
||||
refreshAgentInitializationTimeout({ key, store, agentId, setAgentInitializing });
|
||||
|
||||
setAgentInitializing(agentId, true);
|
||||
|
||||
if (!client) {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(
|
||||
initializations.rejectInitDeferred(
|
||||
key,
|
||||
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
|
||||
);
|
||||
@@ -72,7 +75,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
|
||||
|
||||
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
|
||||
setAgentInitializing(agentId, false);
|
||||
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
|
||||
initializations.rejectInitDeferred(
|
||||
key,
|
||||
error instanceof Error ? error : new Error(String(error)),
|
||||
);
|
||||
});
|
||||
|
||||
return deferred.promise;
|
||||
@@ -127,6 +133,8 @@ export function useAgentInitialization({
|
||||
client: DaemonClient | null;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const store = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
|
||||
const setAgentInitializing = useMemo(
|
||||
() => createSetAgentInitializing(serverId, setInitializingAgents),
|
||||
@@ -136,14 +144,15 @@ export function useAgentInitialization({
|
||||
const ensureAgentIsInitializedCallback = useCallback(
|
||||
(agentId: string): Promise<void> =>
|
||||
ensureAgentIsInitialized({
|
||||
store,
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: getHostRuntimeStore(),
|
||||
runtime,
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[client, serverId, setAgentInitializing, t],
|
||||
[client, runtime, serverId, setAgentInitializing, store, t],
|
||||
);
|
||||
|
||||
const refreshAgentCallback = useCallback(
|
||||
@@ -152,11 +161,11 @@ export function useAgentInitialization({
|
||||
serverId,
|
||||
agentId,
|
||||
client,
|
||||
runtime: getHostRuntimeStore(),
|
||||
runtime,
|
||||
setAgentInitializing,
|
||||
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
|
||||
}),
|
||||
[client, serverId, setAgentInitializing, t],
|
||||
[client, runtime, serverId, setAgentInitializing, t],
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { seedSessionHosts } from "@/test/seed-session";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
@@ -161,6 +162,7 @@ describe("useArchiveAgent", () => {
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
runtime: getHostRuntimeStore(),
|
||||
queryClient,
|
||||
serverId: "server-a",
|
||||
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
@@ -233,6 +235,7 @@ describe("useArchiveAgent", () => {
|
||||
});
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
runtime: getHostRuntimeStore(),
|
||||
queryClient,
|
||||
serverId: "server-a",
|
||||
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useCallback, useMemo } from "react";
|
||||
import {
|
||||
useMutation,
|
||||
useQuery,
|
||||
useQueryClient,
|
||||
type QueryClient,
|
||||
type QueryKey,
|
||||
} from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useSessionStore } from "@/stores/session-store";
|
||||
import { useSessionStoreApi, type SessionStoreApi } from "@/stores/session-store";
|
||||
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
|
||||
|
||||
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
|
||||
@@ -241,14 +235,15 @@ interface ArchiveAgentMutationContext {
|
||||
lists: ArchivedAgentListCacheSnapshot;
|
||||
}
|
||||
|
||||
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
|
||||
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||
function getStoredAgentSnapshot(store: SessionStoreApi, input: ArchiveAgentInput) {
|
||||
return store.getState().sessions[input.serverId]?.agents.get(input.agentId);
|
||||
}
|
||||
|
||||
function restoreAgentSnapshot(
|
||||
runtime: HostRuntimeStore,
|
||||
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
|
||||
): void {
|
||||
getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
|
||||
runtime.restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
|
||||
}
|
||||
|
||||
function getArchivedAgentListCacheSnapshot(
|
||||
@@ -299,20 +294,20 @@ function restoreArchivedAgentListCacheSnapshot(
|
||||
}
|
||||
}
|
||||
|
||||
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
|
||||
function markAgentArchivedInStore(
|
||||
runtime: HostRuntimeStore,
|
||||
input: ArchiveAgentInput & { archivedAt: string },
|
||||
): void {
|
||||
const archivedAt = new Date(input.archivedAt);
|
||||
if (Number.isNaN(archivedAt.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
getHostRuntimeStore().archiveAgentSnapshot(
|
||||
input.serverId,
|
||||
input.agentId,
|
||||
archivedAt.toISOString(),
|
||||
);
|
||||
runtime.archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
|
||||
}
|
||||
|
||||
interface ApplyArchivedAgentCloseResultsInput {
|
||||
runtime: HostRuntimeStore;
|
||||
queryClient: QueryClient;
|
||||
serverId: string;
|
||||
results: ArchivedAgentCloseResult[];
|
||||
@@ -325,7 +320,7 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
|
||||
}
|
||||
|
||||
for (const result of input.results) {
|
||||
markAgentArchivedInStore({
|
||||
markAgentArchivedInStore(input.runtime, {
|
||||
serverId: input.serverId,
|
||||
agentId: result.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
@@ -375,87 +370,69 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string>
|
||||
);
|
||||
}
|
||||
|
||||
/** The action owns optimistic state and rollback within its captured authority. */
|
||||
export function createAgentArchiveAction({
|
||||
runtime,
|
||||
sessionStore,
|
||||
queryClient,
|
||||
unavailableMessage,
|
||||
}: {
|
||||
runtime: HostRuntimeStore;
|
||||
sessionStore: SessionStoreApi;
|
||||
queryClient: QueryClient;
|
||||
unavailableMessage: string;
|
||||
}): (input: ArchiveAgentInput) => Promise<void> {
|
||||
return async (input) => {
|
||||
const context: ArchiveAgentMutationContext = {
|
||||
agent: getStoredAgentSnapshot(sessionStore, input),
|
||||
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
|
||||
};
|
||||
applyArchivedAgentCloseResults({
|
||||
runtime,
|
||||
queryClient,
|
||||
serverId: input.serverId,
|
||||
results: [{ agentId: input.agentId, archivedAt: new Date().toISOString() }],
|
||||
invalidateQueries: false,
|
||||
});
|
||||
setAgentArchiving({ ...input, queryClient, isArchiving: true });
|
||||
try {
|
||||
const client = runtime.getClient(input.serverId);
|
||||
if (!client) throw new Error(unavailableMessage);
|
||||
const result = await client.archiveAgent(input.agentId);
|
||||
markAgentArchivedInStore(runtime, { ...input, archivedAt: result.archivedAt });
|
||||
} catch (error) {
|
||||
restoreAgentSnapshot(runtime, { ...input, agent: context.agent });
|
||||
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
|
||||
throw error;
|
||||
} finally {
|
||||
clearArchiveAgentPending({ ...input, queryClient });
|
||||
for (const queryKey of [
|
||||
["sidebarAgentsList", input.serverId],
|
||||
["allAgents", input.serverId],
|
||||
agentHistoryQueryKey(input.serverId),
|
||||
allAgentHistoryQueryRootKey(),
|
||||
])
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function useArchiveAgent() {
|
||||
const queryClient = useQueryClient();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const pendingQuery = useArchiveAgentPendingQuery();
|
||||
|
||||
const archiveMutation = useMutation({
|
||||
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
|
||||
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
|
||||
if (!client) {
|
||||
throw new Error(t("common.errors.daemonClientUnavailable"));
|
||||
}
|
||||
return await client.archiveAgent(input.agentId);
|
||||
},
|
||||
onMutate: (input) => {
|
||||
const context: ArchiveAgentMutationContext = {
|
||||
agent: getStoredAgentSnapshot(input),
|
||||
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
|
||||
};
|
||||
const archivedAt = new Date().toISOString();
|
||||
|
||||
applyArchivedAgentCloseResults({
|
||||
const unavailableMessage = t("common.errors.daemonClientUnavailable");
|
||||
const archiveAgent = useMemo(
|
||||
() =>
|
||||
createAgentArchiveAction({
|
||||
runtime,
|
||||
sessionStore,
|
||||
queryClient,
|
||||
serverId: input.serverId,
|
||||
results: [{ agentId: input.agentId, archivedAt }],
|
||||
invalidateQueries: false,
|
||||
});
|
||||
setAgentArchiving({
|
||||
queryClient,
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
isArchiving: true,
|
||||
});
|
||||
return context;
|
||||
},
|
||||
onSuccess: (result, input) => {
|
||||
markAgentArchivedInStore({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
archivedAt: result.archivedAt,
|
||||
});
|
||||
},
|
||||
onError: (_error, input, context) => {
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
restoreAgentSnapshot({
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
agent: context.agent,
|
||||
});
|
||||
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
|
||||
},
|
||||
onSettled: (_result, _error, input) => {
|
||||
clearArchiveAgentPending({
|
||||
queryClient,
|
||||
serverId: input.serverId,
|
||||
agentId: input.agentId,
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["sidebarAgentsList", input.serverId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: ["allAgents", input.serverId],
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: agentHistoryQueryKey(input.serverId),
|
||||
});
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: allAgentHistoryQueryRootKey(),
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const archiveMutateAsync = archiveMutation.mutateAsync;
|
||||
|
||||
const archiveAgent = useCallback(
|
||||
async (input: ArchiveAgentInput): Promise<void> => {
|
||||
await archiveMutateAsync(input);
|
||||
},
|
||||
[archiveMutateAsync],
|
||||
unavailableMessage,
|
||||
}),
|
||||
[runtime, sessionStore, queryClient, unavailableMessage],
|
||||
);
|
||||
|
||||
const isArchivingAgent = useCallback(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { getHostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
||||
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
|
||||
@@ -87,6 +87,8 @@ import {
|
||||
selectAgentTurnPresentation,
|
||||
type Agent,
|
||||
useSessionStore,
|
||||
useSessionStoreApi,
|
||||
type SessionStoreApi,
|
||||
} from "@/stores/session-store";
|
||||
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
|
||||
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
|
||||
@@ -97,7 +99,7 @@ import type { PendingPermission } from "@/types/shared";
|
||||
import type { StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
|
||||
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
|
||||
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
|
||||
import { getInitKey } from "@/utils/agent-initialization";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
|
||||
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
|
||||
@@ -288,6 +290,8 @@ function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
|
||||
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
|
||||
|
||||
function storeFetchedAgentDetail(input: {
|
||||
runtime: HostRuntimeStore;
|
||||
sessionStore: SessionStoreApi;
|
||||
serverId: string;
|
||||
result: NonNullable<FetchAgentResult>;
|
||||
}): Agent {
|
||||
@@ -299,10 +303,10 @@ function storeFetchedAgentDetail(input: {
|
||||
projectPlacement: input.result.project,
|
||||
},
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
const store = input.sessionStore.getState();
|
||||
|
||||
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
|
||||
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
|
||||
input.runtime.acceptAgentSnapshot(input.serverId, hydrated);
|
||||
} else {
|
||||
store.setAgentDetails(input.serverId, (previous) => {
|
||||
const next = new Map(previous);
|
||||
@@ -388,6 +392,7 @@ function AgentPanel() {
|
||||
}
|
||||
|
||||
function DraftPanel() {
|
||||
const runtime = useHostRuntimeStore();
|
||||
const {
|
||||
serverId,
|
||||
workspaceId,
|
||||
@@ -404,10 +409,10 @@ function DraftPanel() {
|
||||
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
|
||||
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
|
||||
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
|
||||
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
|
||||
runtime.acceptAgentSnapshot(serverId, agent);
|
||||
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
|
||||
},
|
||||
[retargetCurrentTab, serverId],
|
||||
[retargetCurrentTab, runtime, serverId],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -593,10 +598,12 @@ function AgentPanelBody({
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
|
||||
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
useSessionStoreApi(),
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
@@ -656,7 +663,7 @@ function AgentPanelBody({
|
||||
return;
|
||||
}
|
||||
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
|
||||
setLookupState({ tag: "idle" });
|
||||
return;
|
||||
})
|
||||
@@ -671,7 +678,17 @@ function AgentPanelBody({
|
||||
}
|
||||
setLookupState({ tag: "error", message });
|
||||
});
|
||||
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
|
||||
}, [
|
||||
agentId,
|
||||
agentState.id,
|
||||
client,
|
||||
hasSession,
|
||||
isConnected,
|
||||
lookupState.tag,
|
||||
runtime,
|
||||
serverId,
|
||||
sessionStore,
|
||||
]);
|
||||
|
||||
if (lookupState.tag === "not_found") {
|
||||
return (
|
||||
@@ -748,6 +765,8 @@ function ChatAgentContent({
|
||||
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const runtime = useHostRuntimeStore();
|
||||
const isPaneVisible = useRetainedPanelActive();
|
||||
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
|
||||
const { isArchivingAgent } = useArchiveAgent();
|
||||
@@ -763,7 +782,7 @@ function ChatAgentContent({
|
||||
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
|
||||
);
|
||||
const projectPlacement = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
useSessionStoreApi(),
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return null;
|
||||
@@ -863,8 +882,8 @@ function ChatAgentContent({
|
||||
return false;
|
||||
}
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
return Boolean(getInitDeferred(initKey));
|
||||
}, [agentId, isInitializing, serverId]);
|
||||
return Boolean(sessionStore.initializations.getInitDeferred(initKey));
|
||||
}, [agentId, isInitializing, serverId, sessionStore]);
|
||||
const needsAuthoritativeSync = useMemo(() => {
|
||||
if (!agentId) {
|
||||
return false;
|
||||
@@ -1005,7 +1024,7 @@ function ChatAgentContent({
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
}
|
||||
const currentSession = useSessionStore.getState().sessions[serverId];
|
||||
const currentSession = sessionStore.getState().sessions[serverId];
|
||||
const currentAgent =
|
||||
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
|
||||
if (!currentAgent) {
|
||||
@@ -1020,7 +1039,7 @@ function ChatAgentContent({
|
||||
});
|
||||
return;
|
||||
}
|
||||
storeFetchedAgentDetail({ serverId, result });
|
||||
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
|
||||
}
|
||||
if (attemptToken !== initAttemptTokenRef.current) {
|
||||
return;
|
||||
@@ -1050,6 +1069,8 @@ function ChatAgentContent({
|
||||
isPaneVisible,
|
||||
missingAgentState.kind,
|
||||
serverId,
|
||||
sessionStore,
|
||||
runtime,
|
||||
viewedTimelineSync,
|
||||
visibilityCatchUpStatus,
|
||||
]);
|
||||
@@ -1440,7 +1461,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
|
||||
);
|
||||
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
|
||||
const pendingPermissionList = useStoreWithEqualityFn(
|
||||
useSessionStore,
|
||||
useSessionStoreApi(),
|
||||
(state) => {
|
||||
if (!agentId) {
|
||||
return EMPTY_PENDING_PERMISSION_LIST;
|
||||
@@ -1573,6 +1594,7 @@ function ActiveAgentComposer({
|
||||
{ initialIsBelow: isCompactFormFactor },
|
||||
);
|
||||
const paneContext = usePaneContext();
|
||||
const sessionStore = useSessionStoreApi();
|
||||
const openInSidePane = useSettings((settings) => settings.openInSidePane);
|
||||
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
|
||||
const { archiveAgent } = useArchiveAgent();
|
||||
@@ -1604,7 +1626,7 @@ function ActiveAgentComposer({
|
||||
|
||||
const handleClientSlashCommand = useCallback(
|
||||
async (command: ClientSlashCommand) => {
|
||||
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
|
||||
const agent = resolveChatAgentFromSession(sessionStore.getState(), serverId, agentId);
|
||||
if (!agent) {
|
||||
throw new Error("Agent not found");
|
||||
}
|
||||
@@ -1634,6 +1656,7 @@ function ActiveAgentComposer({
|
||||
hideWorkspaceAgent,
|
||||
retargetCurrentTab,
|
||||
serverId,
|
||||
sessionStore,
|
||||
tabId,
|
||||
unpinWorkspaceAgent,
|
||||
workspaceId,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
|
||||
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import {
|
||||
createSessionStore,
|
||||
selectAgentTimelineState,
|
||||
selectAgentTurnPresentation,
|
||||
useSessionStore,
|
||||
@@ -391,3 +392,34 @@ describe("AgentDirectoryReplica", () => {
|
||||
store.clearSession(serverId);
|
||||
});
|
||||
});
|
||||
|
||||
it("partitions equal destination identities by the owning authority store, including late snapshots", () => {
|
||||
const direct = createSessionStore();
|
||||
const routed = createSessionStore();
|
||||
const serverId = "srv_B";
|
||||
direct.getState().initializeSession(serverId, null);
|
||||
routed.getState().initializeSession(serverId, null);
|
||||
const directReplica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => {},
|
||||
() => {},
|
||||
direct,
|
||||
);
|
||||
const routedReplica = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
() => {},
|
||||
() => {},
|
||||
routed,
|
||||
);
|
||||
directReplica.commitSnapshot([entry(payload("Broader direct data"))], []);
|
||||
expect(routed.getState().sessions[serverId]?.agents.size).toBe(0);
|
||||
routedReplica.commitSnapshot([entry(payload("Origin-authorized data"))], []);
|
||||
directReplica.commitSnapshot([entry(payload("Late direct snapshot"))], []);
|
||||
expect(routed.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
|
||||
"Origin-authorized data",
|
||||
);
|
||||
expect(direct.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
|
||||
"Late direct snapshot",
|
||||
);
|
||||
expect(routedReplica.snapshot().get("agent")?.title).toBe("Origin-authorized data");
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
|
||||
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import type { Agent } from "@/stores/session-store";
|
||||
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
|
||||
@@ -38,8 +38,9 @@ export class AgentDirectoryReplica {
|
||||
private readonly serverId: string,
|
||||
private readonly onStoppedRunning: (agentId: string) => void,
|
||||
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
|
||||
store: SessionStoreApi = useSessionStore,
|
||||
) {
|
||||
this.storeProjection = new AgentStoreProjection(serverId);
|
||||
this.storeProjection = new AgentStoreProjection(serverId, store);
|
||||
}
|
||||
|
||||
captureTimeline(agentId: string): AgentLifecycleToken {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type SessionStoreApi,
|
||||
type Agent,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
@@ -172,11 +173,17 @@ export class DirectorySync {
|
||||
markAgentError: (error: string) => void;
|
||||
},
|
||||
private readonly checkpoints?: DirectoryCheckpointStorage,
|
||||
private readonly store: SessionStoreApi = useSessionStore,
|
||||
) {
|
||||
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
|
||||
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
||||
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
|
||||
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
||||
this.agents = new AgentDirectoryReplica(
|
||||
serverId,
|
||||
callbacks.onAgentStoppedRunning,
|
||||
persist,
|
||||
store,
|
||||
);
|
||||
this.workspaces = new WorkspaceDirectoryReplica(serverId, store);
|
||||
}
|
||||
|
||||
connectionChanged(connection: DirectoryConnection): boolean {
|
||||
@@ -401,18 +408,18 @@ export class DirectorySync {
|
||||
|
||||
async loadCachedAgent(agentId: string): Promise<void> {
|
||||
if (!this.checkpoints) return;
|
||||
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
||||
if (this.store.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
||||
const token = this.agents.captureCache(agentId);
|
||||
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
|
||||
if (!agent) return;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
if (!session || session.agents.has(agentId)) return;
|
||||
this.agents.commitCachedAgent(token, agent);
|
||||
}
|
||||
|
||||
async prepareAgentRoute(agentId: string): Promise<void> {
|
||||
await this.loadCachedAgent(agentId);
|
||||
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
|
||||
const agent = this.store.getState().sessions[this.serverId]?.agents.get(agentId);
|
||||
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
||||
}
|
||||
|
||||
@@ -422,12 +429,12 @@ export class DirectorySync {
|
||||
|
||||
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
||||
if (!this.checkpoints) return;
|
||||
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
||||
if (this.store.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
||||
const revision = this.workspaceRevision;
|
||||
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
||||
if (!cached) return;
|
||||
if (this.workspaceRevision !== revision) return;
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
if (!session) return;
|
||||
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
||||
}
|
||||
@@ -439,7 +446,7 @@ export class DirectorySync {
|
||||
const revision = this.revision;
|
||||
const cached = await checkpoints.readDirectory(this.serverId);
|
||||
if (this.cacheAccepted) return;
|
||||
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
||||
if (!this.store.getState().sessions[this.serverId]) return;
|
||||
this.agents.commitCached(cached.agents);
|
||||
this.workspaces.commitCached(cached);
|
||||
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
|
||||
@@ -495,7 +502,7 @@ export class DirectorySync {
|
||||
if (!onlineConnection) {
|
||||
this.callbacks.markAgentReady();
|
||||
return {
|
||||
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
|
||||
agents: new Map(this.store.getState().sessions[this.serverId]?.agents),
|
||||
subscriptionId: null,
|
||||
};
|
||||
}
|
||||
@@ -560,7 +567,7 @@ export class DirectorySync {
|
||||
});
|
||||
try {
|
||||
await this.waitForSessionMetadata(client, source);
|
||||
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
||||
const serverInfo = this.store.getState().sessions[this.serverId]?.serverInfo;
|
||||
const supportsProjectList = serverInfo?.features?.projectList === true;
|
||||
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
||||
const projectsReady = supportsProjectList
|
||||
@@ -690,7 +697,7 @@ export class DirectorySync {
|
||||
}
|
||||
|
||||
markWorkspacesHydrated(hydrated: boolean): void {
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
|
||||
this.store.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
|
||||
}
|
||||
|
||||
private async fetchAgents(
|
||||
@@ -783,8 +790,7 @@ export class DirectorySync {
|
||||
|
||||
private supportsDirectorySync(): boolean {
|
||||
return (
|
||||
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
|
||||
true
|
||||
this.store.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === true
|
||||
);
|
||||
}
|
||||
|
||||
@@ -964,7 +970,7 @@ export class DirectorySync {
|
||||
source: DirectorySourceToken,
|
||||
): Promise<void> {
|
||||
await this.waitForSessionState(client, source, () => {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
|
||||
});
|
||||
}
|
||||
@@ -995,13 +1001,13 @@ export class DirectorySync {
|
||||
}
|
||||
};
|
||||
this.abortSessionWaits.add(abort);
|
||||
unsubscribe = useSessionStore.subscribe(check);
|
||||
unsubscribe = this.store.subscribe(check);
|
||||
check();
|
||||
});
|
||||
}
|
||||
|
||||
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
return session?.client === client && session.clientGeneration === source.clientGeneration;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import equal from "fast-deep-equal";
|
||||
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
|
||||
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
|
||||
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
||||
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
@@ -9,7 +9,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { buildDraftStoreKey } from "@/stores/draft-keys";
|
||||
import { useDraftStore } from "@/stores/draft-store";
|
||||
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
|
||||
import { getInitKey } from "@/utils/agent-initialization";
|
||||
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
|
||||
function withoutAgent(agentId: string) {
|
||||
@@ -32,14 +32,17 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
|
||||
}
|
||||
|
||||
export class AgentStoreProjection {
|
||||
constructor(private readonly serverId: string) {}
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly store: SessionStoreApi = useSessionStore,
|
||||
) {}
|
||||
|
||||
snapshot(): Map<string, Agent> {
|
||||
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||
return this.store.getState().sessions[this.serverId]?.agents ?? new Map();
|
||||
}
|
||||
|
||||
get(agentId: string): Agent | undefined {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
|
||||
}
|
||||
|
||||
@@ -47,17 +50,17 @@ export class AgentStoreProjection {
|
||||
const merged = new Map(agents);
|
||||
for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent);
|
||||
for (const agent of merged.values()) this.publishActivity(agent);
|
||||
useSessionStore.getState().setAgents(this.serverId, merged);
|
||||
this.store.getState().setAgents(this.serverId, merged);
|
||||
return merged;
|
||||
}
|
||||
|
||||
publishActivity(agent: Agent): void {
|
||||
useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||
this.store.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
|
||||
}
|
||||
|
||||
accept(agent: Agent): Agent {
|
||||
let accepted = agent;
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
this.store.getState().setAgents(this.serverId, (current) => {
|
||||
const previous = current.get(agent.id);
|
||||
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
|
||||
if (accepted === previous) return current;
|
||||
@@ -78,7 +81,7 @@ export class AgentStoreProjection {
|
||||
return { agentId: delta.agentId, stoppedRunning: false };
|
||||
}
|
||||
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
|
||||
const legacyWorkspaceId =
|
||||
previous?.workspaceId ??
|
||||
@@ -98,7 +101,7 @@ export class AgentStoreProjection {
|
||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
|
||||
}
|
||||
this.replacePendingPermissions(accepted);
|
||||
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
|
||||
this.store.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
|
||||
return {
|
||||
agentId: accepted.id,
|
||||
stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle",
|
||||
@@ -111,7 +114,7 @@ export class AgentStoreProjection {
|
||||
serverId: this.serverId,
|
||||
entries,
|
||||
});
|
||||
const store = useSessionStore.getState();
|
||||
const store = this.store.getState();
|
||||
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
|
||||
const agents = new Map<string, Agent>();
|
||||
for (const [agentId, fetchedAgent] of fetched) {
|
||||
@@ -141,17 +144,17 @@ export class AgentStoreProjection {
|
||||
}
|
||||
|
||||
replacePendingPermissions(agent: Agent): void {
|
||||
const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions);
|
||||
const pending = new Map(this.store.getState().sessions[this.serverId]?.pendingPermissions);
|
||||
for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key);
|
||||
for (const request of agent.pendingPermissions) {
|
||||
const key = derivePendingPermissionKey(agent.id, request);
|
||||
pending.set(key, { key, agentId: agent.id, request });
|
||||
}
|
||||
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
|
||||
this.store.getState().setPendingPermissions(this.serverId, pending);
|
||||
}
|
||||
|
||||
removeFromDirectory(agentId: string): void {
|
||||
const store = useSessionStore.getState();
|
||||
const store = this.store.getState();
|
||||
const removeKey = withoutAgent(agentId);
|
||||
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
|
||||
store.setAgents(this.serverId, removeKey);
|
||||
@@ -163,7 +166,7 @@ export class AgentStoreProjection {
|
||||
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
|
||||
return next.size === current.size ? current : next;
|
||||
});
|
||||
useSessionStore.setState((state) => {
|
||||
this.store.setState((state) => {
|
||||
if (!state.agentLastActivity.has(agentId)) return state;
|
||||
const agentLastActivity = new Map(state.agentLastActivity);
|
||||
agentLastActivity.delete(agentId);
|
||||
@@ -173,8 +176,11 @@ export class AgentStoreProjection {
|
||||
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
|
||||
});
|
||||
const initKey = getInitKey(this.serverId, agentId);
|
||||
if (getInitDeferred(initKey)) {
|
||||
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
|
||||
if (this.store.initializations.getInitDeferred(initKey)) {
|
||||
this.store.initializations.rejectInitDeferred(
|
||||
initKey,
|
||||
new Error("Agent was removed during initialization"),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,7 +188,7 @@ export class AgentStoreProjection {
|
||||
this.removeFromDirectory(agentId);
|
||||
// Only entity deletion destroys the transcript. A filtered active-list
|
||||
// response can arrive after a fresh history response for an archived agent.
|
||||
const store = useSessionStore.getState();
|
||||
const store = this.store.getState();
|
||||
const removeKey = withoutAgent(agentId);
|
||||
store.setAgentTimelineCursor(this.serverId, removeKey);
|
||||
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
|
||||
@@ -192,7 +198,7 @@ export class AgentStoreProjection {
|
||||
|
||||
archive(agentId: string, archivedAt: string): Agent | null {
|
||||
let archived: Agent | null = null;
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
this.store.getState().setAgents(this.serverId, (current) => {
|
||||
const agent = current.get(agentId);
|
||||
if (!agent) return current;
|
||||
archived = {
|
||||
@@ -213,7 +219,7 @@ export class AgentStoreProjection {
|
||||
): Agent | null {
|
||||
const transitions = Array.isArray(transition) ? transition : [transition];
|
||||
let accepted: Agent | null = null;
|
||||
useSessionStore.getState().setAgents(this.serverId, (current) => {
|
||||
this.store.getState().setAgents(this.serverId, (current) => {
|
||||
const agent = current.get(agentId);
|
||||
if (!agent) return current;
|
||||
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
normalizeProjectDescriptor,
|
||||
normalizeWorkspaceDescriptor,
|
||||
useSessionStore,
|
||||
type SessionStoreApi,
|
||||
type ProjectDescriptor,
|
||||
type WorkspaceDescriptor,
|
||||
} from "@/stores/session-store";
|
||||
@@ -38,7 +39,10 @@ export class WorkspaceDirectoryReplica {
|
||||
private projects = new Map<string, ProjectDescriptor>();
|
||||
private workspaceIdsByProject = new Map<string, Set<string>>();
|
||||
|
||||
constructor(private readonly serverId: string) {}
|
||||
constructor(
|
||||
private readonly serverId: string,
|
||||
private readonly store: SessionStoreApi = useSessionStore,
|
||||
) {}
|
||||
|
||||
applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] {
|
||||
if (delta.kind === "script_status") return this.applyScriptStatus(delta.update);
|
||||
@@ -55,7 +59,7 @@ export class WorkspaceDirectoryReplica {
|
||||
workspaces: new Map([...input.workspaces, ...this.workspaces]),
|
||||
projects: new Map([...input.projects, ...this.projects]),
|
||||
});
|
||||
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
|
||||
this.store.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
|
||||
}
|
||||
|
||||
commitCachedWorkspace(
|
||||
@@ -73,7 +77,7 @@ export class WorkspaceDirectoryReplica {
|
||||
): DirectoryReplicaMutation[] {
|
||||
this.replace(snapshot);
|
||||
const mutations = deltas.flatMap((delta) => this.applyDelta(delta));
|
||||
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||
this.store.getState().setHasHydratedWorkspaces(this.serverId, true);
|
||||
return mutations;
|
||||
}
|
||||
|
||||
@@ -106,7 +110,7 @@ export class WorkspaceDirectoryReplica {
|
||||
this.workspaces = workspaces;
|
||||
this.projects = new Map(snapshot.projects);
|
||||
this.rebuildProjectIndex();
|
||||
const store = useSessionStore.getState();
|
||||
const store = this.store.getState();
|
||||
store.setWorkspaces(this.serverId, this.workspaces);
|
||||
store.setProjects(this.serverId, this.projects.values());
|
||||
}
|
||||
@@ -133,7 +137,7 @@ export class WorkspaceDirectoryReplica {
|
||||
{ kind: "project", type: "delete", id: delta.projectId },
|
||||
];
|
||||
this.projects.delete(delta.projectId);
|
||||
useSessionStore.getState().removeProject(this.serverId, delta.projectId);
|
||||
this.store.getState().removeProject(this.serverId, delta.projectId);
|
||||
for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) {
|
||||
this.deleteWorkspace(workspaceId);
|
||||
mutations.push({ kind: "workspace", type: "delete", id: workspaceId });
|
||||
@@ -179,7 +183,7 @@ export class WorkspaceDirectoryReplica {
|
||||
}
|
||||
if (delta.removedProjectId) {
|
||||
this.projects.delete(delta.removedProjectId);
|
||||
useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId);
|
||||
this.store.getState().removeProject(this.serverId, delta.removedProjectId);
|
||||
mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId });
|
||||
}
|
||||
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id });
|
||||
@@ -217,19 +221,19 @@ export class WorkspaceDirectoryReplica {
|
||||
const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set();
|
||||
projectWorkspaces.add(workspace.id);
|
||||
this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces);
|
||||
useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]);
|
||||
this.store.getState().mergeWorkspaces(this.serverId, [workspace]);
|
||||
}
|
||||
|
||||
private deleteWorkspace(workspaceId: string): void {
|
||||
const workspace = this.workspaces.get(workspaceId);
|
||||
if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId);
|
||||
this.workspaces.delete(workspaceId);
|
||||
useSessionStore.getState().removeWorkspace(this.serverId, workspaceId);
|
||||
this.store.getState().removeWorkspace(this.serverId, workspaceId);
|
||||
}
|
||||
|
||||
private setProject(project: ProjectDescriptor): void {
|
||||
this.projects.set(project.projectId, project);
|
||||
useSessionStore.getState().upsertProject(this.serverId, project);
|
||||
this.store.getState().upsertProject(this.serverId, project);
|
||||
}
|
||||
|
||||
private rebuildProjectIndex(): void {
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
import { expect, it } from "vitest";
|
||||
import { QueryClient, useQueryClient } from "@tanstack/react-query";
|
||||
import { createSessionStore, useSessionStoreApi } from "@/stores/session-store";
|
||||
import { useCreateFlowStoreApi } from "@/stores/create-flow-store";
|
||||
import { HostRuntimeStore, useHostRuntimeStore, type HostAuthorityBinding } from "./host-runtime";
|
||||
import { HostAuthorityProvider } from "./host-authority-provider";
|
||||
|
||||
it("ordinary consumer hooks retain the runtime, store and creation owner of each authority", () => {
|
||||
const binding = (originServerId: string): HostAuthorityBinding => {
|
||||
const sessionStore = createSessionStore();
|
||||
return {
|
||||
reference: { originServerId, serverId: "B" },
|
||||
namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]),
|
||||
sessionStore,
|
||||
queries: new QueryClient(),
|
||||
runtime: new HostRuntimeStore({ sessionStore }),
|
||||
ready: Promise.resolve(),
|
||||
};
|
||||
};
|
||||
const direct = binding("B");
|
||||
const routed = binding("A");
|
||||
function OrdinaryConsumer({ expected }: { expected: HostAuthorityBinding }) {
|
||||
const runtime = useHostRuntimeStore();
|
||||
const store = useSessionStoreApi();
|
||||
const creates = useCreateFlowStoreApi();
|
||||
expect(useQueryClient()).toBe(expected.queries);
|
||||
expect(runtime).toBe(expected.runtime);
|
||||
expect(store).toBe(expected.sessionStore);
|
||||
expect(creates).toBe(expected.sessionStore.createFlows);
|
||||
return null;
|
||||
}
|
||||
renderToStaticMarkup(
|
||||
<>
|
||||
<HostAuthorityProvider binding={direct}>
|
||||
<OrdinaryConsumer expected={direct} />
|
||||
</HostAuthorityProvider>
|
||||
<HostAuthorityProvider binding={routed}>
|
||||
<OrdinaryConsumer expected={routed} />
|
||||
</HostAuthorityProvider>
|
||||
</>,
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ReactNode } from "react";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
import { SessionStoreContext } from "@/stores/session-store";
|
||||
import { CreateFlowStoreContext } from "@/stores/create-flow-store";
|
||||
import { HostRuntimeContext, type HostAuthorityBinding } from "./host-runtime";
|
||||
|
||||
/** The retained screen owns acquisition/release; descendants receive one bound capability. */
|
||||
export function HostAuthorityProvider({
|
||||
binding,
|
||||
children,
|
||||
}: {
|
||||
binding: HostAuthorityBinding;
|
||||
children: ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<QueryClientProvider client={binding.queries}>
|
||||
<HostRuntimeContext.Provider value={binding.runtime}>
|
||||
<SessionStoreContext.Provider value={binding.sessionStore}>
|
||||
<CreateFlowStoreContext.Provider value={binding.sessionStore.createFlows}>
|
||||
{children}
|
||||
</CreateFlowStoreContext.Provider>
|
||||
</SessionStoreContext.Provider>
|
||||
</HostRuntimeContext.Provider>
|
||||
</QueryClientProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
import { indexedDB as fakeIndexedDb, IDBKeyRange as FakeIDBKeyRange } from "fake-indexeddb";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { QueryObserver } from "@tanstack/react-query";
|
||||
import type { AppStateStatus } from "react-native";
|
||||
import {
|
||||
bindHostRuntimeAppState,
|
||||
@@ -13,12 +15,15 @@ import type {
|
||||
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
|
||||
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
|
||||
import { daemonConfigQueryKey } from "@/data/daemon-config";
|
||||
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
|
||||
import type { HostConnection, HostProfile } from "@/types/host-connection";
|
||||
import { defaultHostAppearance } from "@/hosts/appearance";
|
||||
import { useSessionStore, type Agent } from "@/stores/session-store";
|
||||
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
|
||||
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
|
||||
import { createAgentArchiveAction } from "@/hooks/use-archive-agent";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import {
|
||||
HostRuntimeController,
|
||||
@@ -52,6 +57,16 @@ it("requests the managed connection credential through desktop main without a we
|
||||
});
|
||||
|
||||
class FakeDaemonClient {
|
||||
private readonly eventListeners = new Set<(message: SessionOutboundMessage) => void>();
|
||||
publishEvent(message: SessionOutboundMessage): void {
|
||||
for (const receive of this.eventListeners) receive(message);
|
||||
}
|
||||
readonly archiveCalls: string[] = [];
|
||||
archiveResponse: Promise<{ archivedAt: string }> | null = null;
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
this.archiveCalls.push(agentId);
|
||||
return this.archiveResponse ?? { archivedAt: new Date().toISOString() };
|
||||
}
|
||||
supportsBackgroundWorkspaces(): boolean {
|
||||
return false;
|
||||
}
|
||||
@@ -96,7 +111,12 @@ class FakeDaemonClient {
|
||||
}
|
||||
|
||||
observeEvents() {
|
||||
return subscriptionFixture(Promise.resolve({}), () => () => {});
|
||||
return subscriptionFixture(Promise.resolve({}), (receive) => {
|
||||
this.eventListeners.add(receive);
|
||||
return () => {
|
||||
this.eventListeners.delete(receive);
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async waitForAgentUpdates(): Promise<void> {
|
||||
@@ -161,6 +181,29 @@ class FakeDaemonClient {
|
||||
return this.state;
|
||||
}
|
||||
|
||||
private readonly hostDirectoryListeners = new Set<(message: SessionOutboundMessage) => void>();
|
||||
observeHosts(): ReturnType<DaemonClient["observeHosts"]> {
|
||||
return subscriptionFixture(
|
||||
Promise.resolve({ self: { serverId: this.serverId, label: "Origin" }, hosts: [] }),
|
||||
(receive) => {
|
||||
this.hostDirectoryListeners.add(receive);
|
||||
return () => {
|
||||
this.hostDirectoryListeners.delete(receive);
|
||||
};
|
||||
},
|
||||
);
|
||||
}
|
||||
hostsChanged(): void {
|
||||
for (const receive of this.hostDirectoryListeners)
|
||||
receive({
|
||||
type: "hosts.changed",
|
||||
payload: {
|
||||
subscriptionId: "hosts",
|
||||
directory: { self: { serverId: this.serverId, label: "Origin" }, hosts: [] },
|
||||
},
|
||||
});
|
||||
}
|
||||
public serverId = "srv_test";
|
||||
public ownedSubscriptions = true;
|
||||
private daemonVersion: string | null = "0.8.0";
|
||||
|
||||
@@ -168,7 +211,7 @@ class FakeDaemonClient {
|
||||
if (this.daemonVersion === null) return null;
|
||||
return {
|
||||
status: "server_info",
|
||||
serverId: "srv_test",
|
||||
serverId: this.serverId,
|
||||
hostname: "test",
|
||||
version: this.daemonVersion,
|
||||
features: { ownedSubscriptions: this.ownedSubscriptions },
|
||||
@@ -4440,3 +4483,288 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
|
||||
expect(store.getHosts()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it("retains one authority binding for direct and routed consumers without changing a retained direct destination", async () => {
|
||||
globalThis.indexedDB = fakeIndexedDb;
|
||||
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||
const prefix = crypto.randomUUID();
|
||||
const aId = `A-${prefix}`;
|
||||
const bId = `B-${prefix}`;
|
||||
const clients = new Map<string, FakeDaemonClient>();
|
||||
const resolutions: Array<{ origin: DaemonClient; serverId: string }> = [];
|
||||
const releases: string[] = [];
|
||||
const routeClient = makeConnectedProbeClient(5);
|
||||
routeClient.serverId = bId;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
replicaRowStore: createMemoryReplicaRowStore(),
|
||||
deps: {
|
||||
createClient: ({ host }) => {
|
||||
const client = makeConnectedProbeClient(5);
|
||||
client.serverId = host.serverId;
|
||||
clients.set(host.serverId, client);
|
||||
return client as unknown as DaemonClient;
|
||||
},
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("Probe is not needed");
|
||||
},
|
||||
getClientId: async () => "binding-client",
|
||||
},
|
||||
resolveHostConnection: async (origin, serverId) => {
|
||||
resolutions.push({ origin, serverId });
|
||||
const client =
|
||||
origin === (clients.get(aId) as unknown as DaemonClient) ? routeClient : origin;
|
||||
return {
|
||||
client: client as DaemonClient,
|
||||
release: async () => {
|
||||
releases.push(serverId);
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
const hosts = [aId, bId].map((serverId) =>
|
||||
makeHost({ serverId, connections: [makeHost().connections[0]!] }),
|
||||
);
|
||||
const initial = new Map<string, { connectionId: string; existingClient: DaemonClient }>();
|
||||
for (const host of hosts) {
|
||||
const client = makeConnectedProbeClient(5);
|
||||
client.serverId = host.serverId;
|
||||
clients.set(host.serverId, client);
|
||||
initial.set(host.serverId, {
|
||||
connectionId: host.connections[0]!.id,
|
||||
existingClient: client as unknown as DaemonClient,
|
||||
});
|
||||
}
|
||||
store.syncHosts(hosts, { initialConnectionByServerId: initial });
|
||||
await onceHostSnapshotMatches(store, aId, (snapshot) => snapshot?.connectionStatus === "online");
|
||||
await onceHostSnapshotMatches(store, bId, (snapshot) => snapshot?.connectionStatus === "online");
|
||||
const direct = store.acquireBinding({ originServerId: bId, serverId: bId });
|
||||
const routed = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||
const sibling = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||
try {
|
||||
await Promise.all([direct.binding.ready, routed.binding.ready]);
|
||||
expect(sibling.binding).toBe(routed.binding);
|
||||
expect(direct.binding.runtime.getClient(bId)).toBe(clients.get(bId));
|
||||
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
|
||||
expect(direct.binding.sessionStore).not.toBe(routed.binding.sessionStore);
|
||||
expect(direct.binding.namespace).not.toBe(routed.binding.namespace);
|
||||
expect(routed.binding.queries.getDefaultOptions()).toEqual(queryClient.getDefaultOptions());
|
||||
const key = ["schedules", bId];
|
||||
let directFetches = 0;
|
||||
let routedFetches = 0;
|
||||
direct.binding.queries.setQueryData(key, "Direct cache");
|
||||
routed.binding.queries.setQueryData(key, "Routed cache");
|
||||
queryClient.setQueryData(key, "Registry cache");
|
||||
const directObserver = new QueryObserver(direct.binding.queries, {
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
directFetches++;
|
||||
return "Direct recovered";
|
||||
},
|
||||
});
|
||||
const routedObserver = new QueryObserver(routed.binding.queries, {
|
||||
queryKey: key,
|
||||
queryFn: async () => {
|
||||
routedFetches++;
|
||||
return "Routed recovered";
|
||||
},
|
||||
});
|
||||
const stopDirect = directObserver.subscribe(() => {});
|
||||
const stopRouted = routedObserver.subscribe(() => {});
|
||||
try {
|
||||
expect([directFetches, routedFetches]).toEqual([0, 0]);
|
||||
routeClient.setConnectionState({ status: "disconnected", reason: "Recover routed cache" });
|
||||
routeClient.setConnectionState({ status: "connected" });
|
||||
await expect.poll(() => routed.binding.queries.getQueryData(key)).toBe("Routed recovered");
|
||||
expect([directFetches, routedFetches]).toEqual([0, 1]);
|
||||
expect(direct.binding.queries.getQueryData(key)).toBe("Direct cache");
|
||||
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false);
|
||||
clients
|
||||
.get(bId)!
|
||||
.setConnectionState({ status: "disconnected", reason: "Recover direct cache" });
|
||||
clients.get(bId)!.setConnectionState({ status: "connected" });
|
||||
await expect.poll(() => direct.binding.queries.getQueryData(key)).toBe("Direct recovered");
|
||||
expect([directFetches, routedFetches]).toEqual([1, 1]);
|
||||
} finally {
|
||||
stopDirect();
|
||||
stopRouted();
|
||||
queryClient.removeQueries({ queryKey: key });
|
||||
}
|
||||
const configKey = daemonConfigQueryKey(bId);
|
||||
const config = MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: false },
|
||||
mcp: { injectIntoAgents: true },
|
||||
});
|
||||
const directConfig = MutableDaemonConfigSchema.parse({
|
||||
relay: { enabled: true },
|
||||
mcp: { injectIntoAgents: true },
|
||||
});
|
||||
direct.binding.queries.setQueryData(configKey, directConfig);
|
||||
routeClient.publishEvent({
|
||||
type: "status",
|
||||
payload: { status: "daemon_config_changed", config },
|
||||
});
|
||||
await expect.poll(() => routed.binding.queries.getQueryData(configKey)).toEqual(config);
|
||||
expect(direct.binding.queries.getQueryData(configKey)).toEqual(directConfig);
|
||||
const agent = replicaAgent(
|
||||
makeFetchAgentsEntry({
|
||||
id: "same-agent",
|
||||
cwd: "/repo",
|
||||
title: "Direct",
|
||||
updatedAt: new Date().toISOString(),
|
||||
}).agent,
|
||||
bId,
|
||||
);
|
||||
direct.binding.runtime.acceptAgentSnapshot(bId, agent);
|
||||
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.size).toBe(0);
|
||||
routed.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Routed" });
|
||||
direct.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Late direct" });
|
||||
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
|
||||
"Routed",
|
||||
);
|
||||
const archive = createAgentArchiveAction({
|
||||
runtime: routed.binding.runtime,
|
||||
sessionStore: routed.binding.sessionStore,
|
||||
queryClient: routed.binding.queries,
|
||||
unavailableMessage: "Authority unavailable",
|
||||
});
|
||||
const gate = createDeferred<{ archivedAt: string }>();
|
||||
routeClient.archiveResponse = gate.promise;
|
||||
const archiving = archive({ serverId: bId, agentId: agent.id });
|
||||
void archiving.catch(() => {});
|
||||
expect(routeClient.archiveCalls).toEqual([agent.id]);
|
||||
expect(clients.get(bId)!.archiveCalls).toEqual([]);
|
||||
expect(
|
||||
isAgentArchiving({ queryClient: routed.binding.queries, serverId: bId, agentId: agent.id }),
|
||||
).toBe(true);
|
||||
expect(
|
||||
isAgentArchiving({ queryClient: direct.binding.queries, serverId: bId, agentId: agent.id }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
direct.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||
).toBeNull();
|
||||
gate.reject(new Error("Rejected by selected authority"));
|
||||
await expect(archiving).rejects.toThrow("Rejected by selected authority");
|
||||
expect(
|
||||
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||
).toBeNull();
|
||||
routeClient.setConnectionState({ status: "disconnected", reason: "Route revoked" });
|
||||
expect(routed.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("error");
|
||||
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
|
||||
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
|
||||
expect(resolutions).toHaveLength(2);
|
||||
// Withdrawal must retire command sources even if the old client stays alive.
|
||||
routeClient.setConnectionState({ status: "connected" });
|
||||
store.syncHosts(hosts.filter((host) => host.serverId === bId));
|
||||
expect(routed.binding.runtime.getClient(bId)).toBeNull();
|
||||
expect(routed.binding.sessionStore.getState().sessions[bId]?.client).toBeNull();
|
||||
expect(routeClient.getConnectionState().status).toBe("connected");
|
||||
routeClient.publishEvent({
|
||||
type: "status",
|
||||
payload: { status: "daemon_config_changed", config: directConfig },
|
||||
});
|
||||
expect(routed.binding.queries.getQueryData(configKey)).toEqual(config);
|
||||
await expect(archive({ serverId: bId, agentId: agent.id })).rejects.toThrow(
|
||||
"Authority unavailable",
|
||||
);
|
||||
expect(routeClient.archiveCalls).toEqual([agent.id]);
|
||||
expect(clients.get(bId)!.archiveCalls).toEqual([]);
|
||||
expect(
|
||||
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
|
||||
).toBeNull();
|
||||
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
|
||||
expect(direct.binding.sessionStore.getState().sessions[bId]?.client).toBe(clients.get(bId));
|
||||
await routed.release();
|
||||
expect(releases).toEqual([bId]);
|
||||
expect(sibling.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
|
||||
"Routed",
|
||||
);
|
||||
} finally {
|
||||
await Promise.all([direct.release(), routed.release(), sibling.release()]);
|
||||
store.syncHosts([]);
|
||||
}
|
||||
expect(releases).toHaveLength(2);
|
||||
});
|
||||
|
||||
it.each(["after failure", "during acquisition"])(
|
||||
"recovers a cold offline authority when the same origin enables the destination %s",
|
||||
async (timing) => {
|
||||
globalThis.indexedDB = fakeIndexedDb;
|
||||
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||
const aId = `cold-A-${crypto.randomUUID()}`;
|
||||
const bId = `cold-B-${crypto.randomUUID()}`;
|
||||
const origin = makeConnectedProbeClient(5);
|
||||
origin.serverId = aId;
|
||||
const target = makeConnectedProbeClient(5);
|
||||
target.serverId = bId;
|
||||
const gate = createDeferred<void>();
|
||||
let attempts = 0;
|
||||
let enabled = false;
|
||||
const store = new HostRuntimeStore({
|
||||
storage: createMemoryHostRuntimeStorage(),
|
||||
replicaRowStore: createMemoryReplicaRowStore(),
|
||||
resolveHostConnection: async (source, destination) => {
|
||||
expect(source).toBe(origin);
|
||||
expect(destination).toBe(bId);
|
||||
attempts++;
|
||||
if (attempts === 1) {
|
||||
await gate.promise;
|
||||
throw new Error("Destination disabled");
|
||||
}
|
||||
if (!enabled) throw new Error("Destination disabled");
|
||||
return { client: target as unknown as DaemonClient, release: async () => {} };
|
||||
},
|
||||
deps: {
|
||||
createClient: () => {
|
||||
throw new Error("Origin must remain the same client");
|
||||
},
|
||||
connectToDaemon: async () => {
|
||||
throw new Error("No new connection");
|
||||
},
|
||||
getClientId: async () => "cold-authority",
|
||||
},
|
||||
});
|
||||
const host = makeHost({ serverId: aId, connections: [makeHost().connections[0]!] });
|
||||
store.syncHosts([host], {
|
||||
initialConnectionByServerId: new Map([
|
||||
[
|
||||
aId,
|
||||
{
|
||||
connectionId: host.connections[0]!.id,
|
||||
existingClient: origin as unknown as DaemonClient,
|
||||
},
|
||||
],
|
||||
]),
|
||||
});
|
||||
await onceHostSnapshotMatches(
|
||||
store,
|
||||
aId,
|
||||
(snapshot) => snapshot?.connectionStatus === "online",
|
||||
);
|
||||
origin.setConnectionState({ status: "disconnected", reason: "Cold offline origin" });
|
||||
const retained = store.acquireBinding({ originServerId: aId, serverId: bId });
|
||||
try {
|
||||
origin.setConnectionState({ status: "connected" });
|
||||
await expect.poll(() => attempts).toBe(1);
|
||||
if (timing === "after failure") {
|
||||
gate.resolve();
|
||||
await expect
|
||||
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
|
||||
.toBe("error");
|
||||
}
|
||||
enabled = true;
|
||||
origin.hostsChanged();
|
||||
gate.resolve();
|
||||
await expect
|
||||
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
|
||||
.toBe("online");
|
||||
expect(store.getClient(aId)).toBe(origin);
|
||||
expect(retained.binding.runtime.getClient(bId)).toBe(target);
|
||||
expect(attempts).toBeGreaterThanOrEqual(2);
|
||||
} finally {
|
||||
gate.resolve();
|
||||
await retained.release();
|
||||
store.syncHosts([]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useSyncExternalStore, useMemo } from "react";
|
||||
import {
|
||||
acquireHostConnection,
|
||||
type HostConnectionLease,
|
||||
} from "@getpaseo/client/internal/host-connection";
|
||||
import { createContext, useContext, useSyncExternalStore, useMemo } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import AsyncStorage from "@react-native-async-storage/async-storage";
|
||||
import equal from "fast-deep-equal/es6";
|
||||
import {
|
||||
@@ -52,6 +57,8 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
useSessionStore,
|
||||
type SessionStoreApi,
|
||||
createSessionStore,
|
||||
toDaemonServerInfo,
|
||||
type Agent,
|
||||
type WorkspaceDescriptor,
|
||||
@@ -60,7 +67,7 @@ import {
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
|
||||
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
|
||||
import { queryClient } from "@/data/query-client";
|
||||
import { queryClient, createQueryClient } from "@/data/query-client";
|
||||
import {
|
||||
invalidateServerDataQueriesAfterReconnect,
|
||||
mountServerDataPushRouter,
|
||||
@@ -608,21 +615,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
|
||||
}),
|
||||
getClientId: () => getOrCreateClientId(),
|
||||
mountClientHandlers: ({ client, host }) => {
|
||||
const unmountServerData = mountServerDataPushRouter({
|
||||
client,
|
||||
queryClient,
|
||||
serverId: host.serverId,
|
||||
});
|
||||
if (!browserAutomationCapabilities) {
|
||||
return unmountServerData;
|
||||
}
|
||||
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
|
||||
serverId: host.serverId,
|
||||
});
|
||||
return () => {
|
||||
unmountBrowserAutomation();
|
||||
unmountServerData();
|
||||
};
|
||||
if (!browserAutomationCapabilities) return () => {};
|
||||
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1486,8 +1480,54 @@ interface AgentDirectoryRefreshInput {
|
||||
page?: FetchAgentsOptions["page"];
|
||||
}
|
||||
|
||||
export interface HostAuthorityReference {
|
||||
originServerId: string;
|
||||
serverId: string;
|
||||
}
|
||||
|
||||
export interface HostAuthorityBinding {
|
||||
readonly reference: HostAuthorityReference;
|
||||
readonly namespace: string;
|
||||
readonly sessionStore: SessionStoreApi;
|
||||
readonly queries: QueryClient;
|
||||
readonly runtime: HostRuntimeStore;
|
||||
/** Initial acquisition only. Current availability comes from runtime snapshots. */
|
||||
readonly ready: Promise<void>;
|
||||
}
|
||||
|
||||
export interface OwnedHostAuthorityBinding {
|
||||
readonly binding: HostAuthorityBinding;
|
||||
release(): Promise<void>;
|
||||
}
|
||||
|
||||
function authorityNamespace(reference: HostAuthorityReference): string {
|
||||
return JSON.stringify(["host-authority-v1", reference.originServerId, reference.serverId]);
|
||||
}
|
||||
|
||||
function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot {
|
||||
return {
|
||||
serverId,
|
||||
activeConnectionId: null,
|
||||
activeConnection: null,
|
||||
connectionStatus: "connecting",
|
||||
client: null,
|
||||
lastError: null,
|
||||
lastOnlineAt: null,
|
||||
agentDirectoryStatus: "idle",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: false,
|
||||
probeByConnectionId: new Map(),
|
||||
clientGeneration: 0,
|
||||
connectionEpoch: 0,
|
||||
};
|
||||
}
|
||||
|
||||
export class HostRuntimeStore {
|
||||
private controllers = new Map<string, HostRuntimeController>();
|
||||
private readonly authorityBindings = new Map<
|
||||
string,
|
||||
{ binding: HostAuthorityBinding; refs: number; close(): Promise<void> }
|
||||
>();
|
||||
private serverListeners = new Map<string, Set<() => void>>();
|
||||
private globalListeners = new Set<() => void>();
|
||||
private hostListListeners = new Set<() => void>();
|
||||
@@ -1510,20 +1550,280 @@ export class HostRuntimeStore {
|
||||
private readonly hostConfirmations = new HostConfirmations();
|
||||
private storage: HostRuntimeStorage;
|
||||
private replicaCache: ReplicaCache;
|
||||
private readonly sessionStore: SessionStoreApi;
|
||||
private readonly queries: QueryClient;
|
||||
private readonly serverDataClients = new Map<string, { client: DaemonClient; release(): void }>();
|
||||
private readonly resolveHostConnection: typeof acquireHostConnection;
|
||||
private readonly snapshots = new Map<string, HostRuntimeSnapshot>();
|
||||
private readonly revokePushNotifications: typeof revokePushNotifications;
|
||||
|
||||
constructor(input?: {
|
||||
deps?: HostRuntimeControllerDeps;
|
||||
storage?: HostRuntimeStorage;
|
||||
replicaRowStore?: ReplicaRowStore;
|
||||
sessionStore?: SessionStoreApi;
|
||||
queries?: QueryClient;
|
||||
resolveHostConnection?: typeof acquireHostConnection;
|
||||
revokePushNotifications?: typeof revokePushNotifications;
|
||||
}) {
|
||||
this.deps = input?.deps ?? createDefaultDeps();
|
||||
this.sessionStore = input?.sessionStore ?? useSessionStore;
|
||||
this.queries = input?.queries ?? queryClient;
|
||||
this.resolveHostConnection = input?.resolveHostConnection ?? acquireHostConnection;
|
||||
this.storage = input?.storage ?? AsyncStorage;
|
||||
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
|
||||
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
|
||||
}
|
||||
|
||||
/** A retained screen owns a lease; route identity never grants access by itself. */
|
||||
acquireBinding(reference: HostAuthorityReference): OwnedHostAuthorityBinding {
|
||||
const normalized = { originServerId: reference.originServerId, serverId: reference.serverId };
|
||||
const namespace = authorityNamespace(normalized);
|
||||
let entry = this.authorityBindings.get(namespace);
|
||||
if (!entry) {
|
||||
const queries = createQueryClient();
|
||||
const scope = this.createAuthorityScope(normalized, namespace, queries);
|
||||
entry = {
|
||||
binding: {
|
||||
reference: normalized,
|
||||
namespace,
|
||||
sessionStore: scope.runtime.sessionStore,
|
||||
queries,
|
||||
runtime: scope.runtime,
|
||||
ready: scope.ready,
|
||||
},
|
||||
refs: 0,
|
||||
close: async () => {
|
||||
try {
|
||||
await scope.close();
|
||||
} finally {
|
||||
queries.clear();
|
||||
}
|
||||
},
|
||||
};
|
||||
this.authorityBindings.set(namespace, entry);
|
||||
}
|
||||
entry.refs++;
|
||||
const retained = entry;
|
||||
let released: Promise<void> | null = null;
|
||||
return {
|
||||
binding: retained.binding,
|
||||
release: () => {
|
||||
if (!released) {
|
||||
retained.refs--;
|
||||
if (retained.refs === 0) {
|
||||
if (this.authorityBindings.get(namespace) === retained)
|
||||
this.authorityBindings.delete(namespace);
|
||||
released = retained.close();
|
||||
} else released = Promise.resolve();
|
||||
}
|
||||
return released;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createAuthorityScope(
|
||||
reference: HostAuthorityReference,
|
||||
namespace: string,
|
||||
queries: QueryClient,
|
||||
): {
|
||||
runtime: HostRuntimeStore;
|
||||
ready: Promise<void>;
|
||||
close(): Promise<void>;
|
||||
} {
|
||||
const scoped = new HostRuntimeStore({
|
||||
sessionStore: createSessionStore(),
|
||||
queries,
|
||||
replicaRowStore: createReplicaRowStore(namespace),
|
||||
storage: this.storage,
|
||||
deps: this.deps,
|
||||
});
|
||||
const { serverId, originServerId } = reference;
|
||||
scoped.sessionStore.getState().initializeSession(serverId, null);
|
||||
scoped.snapshots.set(serverId, initialAuthoritySnapshot(serverId));
|
||||
const patch = (change: Partial<HostRuntimeSnapshot>) => {
|
||||
const previous = scoped.snapshots.get(serverId)!;
|
||||
scoped.snapshots.set(serverId, { ...previous, ...change });
|
||||
scoped.emit(serverId);
|
||||
};
|
||||
scoped.initializeReplica(serverId, {
|
||||
onAgentStoppedRunning: (agentId) => scoped.drainQueuedAgentMessage(serverId, agentId),
|
||||
markAgentLoading: () =>
|
||||
patch({ agentDirectoryStatus: "initial_loading", agentDirectoryError: null }),
|
||||
markAgentReady: () =>
|
||||
patch({
|
||||
agentDirectoryStatus: "ready",
|
||||
agentDirectoryError: null,
|
||||
hasEverLoadedAgentDirectory: true,
|
||||
}),
|
||||
markAgentError: (failure) =>
|
||||
patch({
|
||||
agentDirectoryStatus: scoped.getSnapshot(serverId)?.hasEverLoadedAgentDirectory
|
||||
? "error_after_ready"
|
||||
: "error_before_first_success",
|
||||
agentDirectoryError: failure,
|
||||
}),
|
||||
});
|
||||
let stopped = false;
|
||||
let origin: DaemonClient | null = null;
|
||||
let controller = new AbortController();
|
||||
let lease: HostConnectionLease | null = null;
|
||||
let stopTarget: (() => void) | null = null;
|
||||
let directory: ReturnType<DaemonClient["observeHosts"]> | null = null;
|
||||
let acquiring: Promise<void> | null = null;
|
||||
let directoryRevision = 0;
|
||||
let resolveReady!: () => void;
|
||||
let rejectReady!: (failure: unknown) => void;
|
||||
const ready = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
void ready.catch(() => {});
|
||||
let generation = 0;
|
||||
const publish = (
|
||||
client: DaemonClient | null,
|
||||
state: ConnectionState,
|
||||
failure: string | null = null,
|
||||
) => {
|
||||
const previous = scoped.snapshots.get(serverId)!;
|
||||
const online = state.status === "connected";
|
||||
let connectionStatus: HostRuntimeConnectionStatus = failure ? "error" : "offline";
|
||||
if (state.status === "connecting") connectionStatus = "connecting";
|
||||
if (online) connectionStatus = "online";
|
||||
const snapshot: HostRuntimeSnapshot = {
|
||||
...previous,
|
||||
client,
|
||||
connectionStatus,
|
||||
lastError: failure,
|
||||
clientGeneration: generation,
|
||||
connectionEpoch:
|
||||
online && previous.connectionStatus !== "online"
|
||||
? previous.connectionEpoch + 1
|
||||
: previous.connectionEpoch,
|
||||
lastOnlineAt: online ? new Date().toISOString() : previous.lastOnlineAt,
|
||||
};
|
||||
scoped.syncSessionReplica(serverId, snapshot);
|
||||
scoped.syncDirectoryConnection(serverId);
|
||||
scoped.emit(serverId);
|
||||
};
|
||||
const acquire = () => {
|
||||
if (
|
||||
stopped ||
|
||||
acquiring ||
|
||||
lease ||
|
||||
!origin ||
|
||||
origin.getConnectionState().status !== "connected"
|
||||
)
|
||||
return;
|
||||
const source = origin;
|
||||
const signal = controller.signal;
|
||||
const attempt = generation;
|
||||
const observedRevision = directoryRevision;
|
||||
publish(null, { status: "connecting", attempt: 1 });
|
||||
acquiring = (async () => {
|
||||
try {
|
||||
const acquired = await this.resolveHostConnection(source, serverId, signal);
|
||||
if (stopped || signal.aborted || generation !== attempt) {
|
||||
await acquired.release();
|
||||
return;
|
||||
}
|
||||
lease = acquired;
|
||||
stopTarget = acquired.client.subscribeConnectionStatus((state) => {
|
||||
if (!stopped && generation === attempt)
|
||||
publish(acquired.client, state, acquired.client.lastError);
|
||||
});
|
||||
resolveReady();
|
||||
} catch (failure) {
|
||||
if (!stopped && generation === attempt) {
|
||||
publish(null, { status: "disconnected" }, toErrorMessage(failure));
|
||||
rejectReady(failure);
|
||||
}
|
||||
} finally {
|
||||
if (generation === attempt) {
|
||||
acquiring = null;
|
||||
if (!lease && directoryRevision !== observedRevision) acquire();
|
||||
}
|
||||
}
|
||||
})();
|
||||
};
|
||||
const retired = new Set<Promise<unknown>>();
|
||||
const retire = (release: Promise<unknown>) => {
|
||||
retired.add(release);
|
||||
void release.then(
|
||||
() => retired.delete(release),
|
||||
() => retired.delete(release),
|
||||
);
|
||||
};
|
||||
const syncOrigin = () => {
|
||||
if (stopped) return;
|
||||
const snapshot = this.getSnapshot(originServerId);
|
||||
const nextOrigin = snapshot?.client ?? null;
|
||||
if (origin !== nextOrigin) {
|
||||
generation++;
|
||||
controller.abort();
|
||||
stopTarget?.();
|
||||
stopTarget = null;
|
||||
if (lease) retire(lease.release());
|
||||
if (directory) retire(directory.release());
|
||||
if (acquiring) retire(acquiring);
|
||||
lease = null;
|
||||
directory = null;
|
||||
acquiring = null;
|
||||
controller = new AbortController();
|
||||
origin = nextOrigin;
|
||||
publish(null, { status: "disconnected" });
|
||||
}
|
||||
if (origin && origin.getConnectionState().status === "connected" && !directory) {
|
||||
try {
|
||||
directory = origin.observeHosts();
|
||||
const changed = () => {
|
||||
directoryRevision++;
|
||||
acquire();
|
||||
};
|
||||
directory.subscribe({ snapshot: changed, update: changed });
|
||||
void directory.ready.catch(() => {});
|
||||
} catch (failure) {
|
||||
publish(null, { status: "disconnected" }, toErrorMessage(failure));
|
||||
}
|
||||
}
|
||||
if (snapshot?.connectionStatus === "online") acquire();
|
||||
else if (!lease)
|
||||
publish(
|
||||
null,
|
||||
{ status: "disconnected" },
|
||||
snapshot?.lastError ?? "Origin authority is unavailable",
|
||||
);
|
||||
};
|
||||
const stopOrigin = this.subscribe(originServerId, syncOrigin);
|
||||
syncOrigin();
|
||||
return {
|
||||
runtime: scoped,
|
||||
ready,
|
||||
close: async () => {
|
||||
stopped = true;
|
||||
generation++;
|
||||
stopOrigin();
|
||||
controller.abort();
|
||||
stopTarget?.();
|
||||
rejectReady(new Error("Host authority binding is disposed"));
|
||||
scoped.syncServerDataClient(serverId, null);
|
||||
scoped.directorySyncByServer.get(serverId)?.dispose();
|
||||
scoped.sessionStore.getState().sessions[serverId]?.viewedTimelineSync?.dispose();
|
||||
for (const agentId of scoped.sessionStore
|
||||
.getState()
|
||||
.sessions[serverId]?.initializingAgents.keys() ?? []) {
|
||||
scoped.sessionStore.initializations.rejectInitDeferred(
|
||||
scoped.sessionStore.initializations.getInitKey(serverId, agentId),
|
||||
new Error("Host authority binding is disposed"),
|
||||
);
|
||||
}
|
||||
await Promise.allSettled([lease?.release(), directory?.release(), acquiring, ...retired]);
|
||||
await scoped.replicaCache.flush();
|
||||
scoped.sessionStore.getState().clearSession(serverId);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// --- Host registry ---
|
||||
|
||||
getHosts(): HostProfile[] {
|
||||
@@ -1772,6 +2072,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
rekeyMap(this.controllers, oldServerId, newServerId);
|
||||
this.snapshots.delete(oldServerId);
|
||||
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
|
||||
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
|
||||
this.replicaCache.reconcileServerId(oldServerId, newServerId);
|
||||
@@ -1779,25 +2080,12 @@ export class HostRuntimeStore {
|
||||
this.directorySyncByServer.get(oldServerId)?.dispose();
|
||||
this.directorySyncByServer.delete(oldServerId);
|
||||
this.timelineReplicaByServer.delete(oldServerId);
|
||||
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);
|
||||
this.timelineReplicaByServer.set(
|
||||
newServerId,
|
||||
createTimelineReplica({
|
||||
serverId: newServerId,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
const directory = this.initializeReplica(newServerId, {
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
});
|
||||
controller.adoptReconciledServerId(newServerId);
|
||||
const snapshot = controller.getSnapshot();
|
||||
this.clearHostReplica(oldServerId);
|
||||
@@ -2259,6 +2547,7 @@ export class HostRuntimeStore {
|
||||
continue;
|
||||
}
|
||||
this.controllers.delete(serverId);
|
||||
this.snapshots.delete(serverId);
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
this.directorySyncByServer.get(serverId)?.dispose();
|
||||
@@ -2287,27 +2576,15 @@ export class HostRuntimeStore {
|
||||
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
|
||||
});
|
||||
this.controllers.set(host.serverId, controller);
|
||||
useSessionStore.getState().initializeSession(host.serverId, null);
|
||||
const directory = new DirectorySync(
|
||||
host.serverId,
|
||||
{
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
},
|
||||
this.replicaCache,
|
||||
);
|
||||
this.directorySyncByServer.set(host.serverId, directory);
|
||||
this.timelineReplicaByServer.set(
|
||||
host.serverId,
|
||||
createTimelineReplica({
|
||||
serverId: host.serverId,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
this.sessionStore.getState().initializeSession(host.serverId, null);
|
||||
this.initializeReplica(host.serverId, {
|
||||
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
|
||||
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
|
||||
markAgentReady: () => controller.markAgentDirectorySyncReady(),
|
||||
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
|
||||
});
|
||||
const initialSnapshot = controller.getSnapshot();
|
||||
this.syncSessionReplica(host.serverId, initialSnapshot);
|
||||
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
|
||||
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
|
||||
controller.subscribe(() => {
|
||||
@@ -2332,13 +2609,31 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private initializeReplica(
|
||||
serverId: string,
|
||||
callbacks: ConstructorParameters<typeof DirectorySync>[1],
|
||||
): DirectorySync {
|
||||
const directory = new DirectorySync(serverId, callbacks, this.replicaCache, this.sessionStore);
|
||||
this.directorySyncByServer.set(serverId, directory);
|
||||
this.timelineReplicaByServer.set(
|
||||
serverId,
|
||||
createTimelineReplica({
|
||||
serverId,
|
||||
store: this.sessionStore,
|
||||
storage: this.replicaCache,
|
||||
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
|
||||
}),
|
||||
);
|
||||
return directory;
|
||||
}
|
||||
|
||||
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
|
||||
if (!snapshot.client) {
|
||||
return;
|
||||
}
|
||||
const sessionStore = useSessionStore.getState();
|
||||
this.snapshots.set(serverId, snapshot);
|
||||
const sessionStore = this.sessionStore.getState();
|
||||
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
|
||||
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
|
||||
this.syncServerDataClient(serverId, snapshot.client);
|
||||
if (!snapshot.client) return;
|
||||
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
|
||||
// change its version and features) only reaches the store here. The client clears it while
|
||||
// disconnected; keep the last known value until the next handshake.
|
||||
@@ -2348,19 +2643,31 @@ export class HostRuntimeStore {
|
||||
}
|
||||
}
|
||||
|
||||
private syncServerDataClient(serverId: string, client: DaemonClient | null): void {
|
||||
const current = this.serverDataClients.get(serverId);
|
||||
if (current?.client === client) return;
|
||||
current?.release();
|
||||
this.serverDataClients.delete(serverId);
|
||||
if (client)
|
||||
this.serverDataClients.set(serverId, {
|
||||
client,
|
||||
release: mountServerDataPushRouter({ client, serverId, queryClient: this.queries }),
|
||||
});
|
||||
}
|
||||
|
||||
private clearHostReplica(serverId: string): void {
|
||||
useSessionStore.getState().clearSession(serverId);
|
||||
this.syncServerDataClient(serverId, null);
|
||||
this.sessionStore.getState().clearSession(serverId);
|
||||
useWorkspaceSetupStore.getState().clearServer(serverId);
|
||||
}
|
||||
|
||||
private syncDirectoryConnection(serverId: string): void {
|
||||
const controller = this.controllers.get(serverId);
|
||||
if (!controller) {
|
||||
const snapshot = this.snapshots.get(serverId);
|
||||
if (!snapshot) {
|
||||
this.lastConnectionStatusByServer.delete(serverId);
|
||||
this.connectionStatusStartedAtByServer.delete(serverId);
|
||||
return;
|
||||
}
|
||||
const snapshot = controller.getSnapshot();
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
directory?.connectionChanged({
|
||||
client: snapshot.client,
|
||||
@@ -2383,20 +2690,20 @@ export class HostRuntimeStore {
|
||||
const didTransitionOnline =
|
||||
snapshot.connectionStatus === "online" && previousStatus !== "online";
|
||||
if (didTransitionOnline) {
|
||||
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
this.sessionStore.getState().bumpHistorySyncGeneration(serverId);
|
||||
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
|
||||
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
|
||||
// queries refetch now and evicted ones on their next mount.
|
||||
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
|
||||
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
|
||||
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
||||
void invalidateCheckoutGitQueriesForServer(this.queries, serverId);
|
||||
invalidateServerDataQueriesAfterReconnect({ queryClient: this.queries, serverId });
|
||||
void this.queries.invalidateQueries({ queryKey: schedulesQueryBaseKey });
|
||||
}
|
||||
}
|
||||
|
||||
drainQueuedAgentMessage(serverId: string, agentId: string): void {
|
||||
const drainKey = `${serverId}:${agentId}`;
|
||||
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
|
||||
const store = useSessionStore.getState();
|
||||
const store = this.sessionStore.getState();
|
||||
const session = store.sessions[serverId];
|
||||
const queue = session?.queuedMessages.get(agentId);
|
||||
const client = session?.client;
|
||||
@@ -2410,12 +2717,13 @@ export class HostRuntimeStore {
|
||||
messageId: next.id,
|
||||
queue: {
|
||||
read: (queuedAgentId) =>
|
||||
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
|
||||
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
|
||||
this.sessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
|
||||
write: (update) => this.sessionStore.getState().setQueuedMessages(serverId, update),
|
||||
},
|
||||
submitMessage: async ({ text, attachments }) => {
|
||||
const supportsForgeAttachments =
|
||||
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
|
||||
this.sessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
|
||||
true;
|
||||
await dispatchComposerAgentMessage({
|
||||
client,
|
||||
agentId,
|
||||
@@ -2500,7 +2808,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
return this.controllers.get(serverId)?.getSnapshot() ?? null;
|
||||
return this.snapshots.get(serverId) ?? null;
|
||||
}
|
||||
|
||||
getConnectionStatusSince(serverId: string): number | null {
|
||||
@@ -2512,7 +2820,7 @@ export class HostRuntimeStore {
|
||||
}
|
||||
|
||||
getClient(serverId: string): DaemonClient | null {
|
||||
return this.controllers.get(serverId)?.getClient() ?? null;
|
||||
return this.snapshots.get(serverId)?.client ?? null;
|
||||
}
|
||||
|
||||
subscribe(serverId: string, listener: () => void): () => void {
|
||||
@@ -2639,9 +2947,9 @@ export class HostRuntimeStore {
|
||||
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
||||
const directory = this.directorySyncByServer.get(serverId);
|
||||
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
|
||||
const owner = this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync;
|
||||
const page = await directory.fetchTimeline(agentId, request);
|
||||
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
|
||||
if (owner && this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
|
||||
owner.flushStreamAgent(agentId);
|
||||
owner.applyTimelineResponse(page);
|
||||
}
|
||||
@@ -2657,6 +2965,7 @@ export class HostRuntimeStore {
|
||||
const replica = this.timelineReplicaByServer.get(serverId);
|
||||
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
|
||||
return createViewedTimelineOwner({
|
||||
store: this.sessionStore,
|
||||
serverId,
|
||||
replica,
|
||||
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
|
||||
@@ -2706,8 +3015,15 @@ export function getHostRuntimeConnectionStatusSince(serverId: string): number |
|
||||
return getHostRuntimeStore().getConnectionStatusSince(serverId);
|
||||
}
|
||||
|
||||
export const HostRuntimeContext = createContext<HostRuntimeStore | null>(null);
|
||||
|
||||
/** Ordinary conversation hooks read the capability bound to their retained screen. */
|
||||
export function useHostRuntimeStore(): HostRuntimeStore {
|
||||
return useContext(HostRuntimeContext) ?? getHostRuntimeStore();
|
||||
}
|
||||
|
||||
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId),
|
||||
@@ -2716,7 +3032,7 @@ export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot |
|
||||
}
|
||||
|
||||
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId)?.client ?? null,
|
||||
@@ -2725,7 +3041,7 @@ export function useHostRuntimeClient(serverId: string): DaemonClient | null {
|
||||
}
|
||||
|
||||
export function useHostRuntimeIsConnected(serverId: string): boolean {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
|
||||
@@ -2734,7 +3050,7 @@ export function useHostRuntimeIsConnected(serverId: string): boolean {
|
||||
}
|
||||
|
||||
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
|
||||
@@ -2745,7 +3061,7 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
|
||||
export function useHostRuntimeConnectionStatuses(
|
||||
serverIds: readonly string[],
|
||||
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
// The snapshot is the statuses themselves, joined into a string so React compares by
|
||||
// value. A version counter read only for reactivity is dropped by the React Compiler.
|
||||
const readStatuses = () =>
|
||||
@@ -2765,7 +3081,7 @@ export function useHostRuntimeConnectionStatuses(
|
||||
}
|
||||
|
||||
export function useHostRuntimeLastError(serverId: string): string | null {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId)?.lastError ?? null,
|
||||
@@ -2776,7 +3092,7 @@ export function useHostRuntimeLastError(serverId: string): string | null {
|
||||
export function useHostRuntimeAgentDirectoryStatus(
|
||||
serverId: string,
|
||||
): HostRuntimeAgentDirectoryStatus {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
|
||||
@@ -2785,7 +3101,7 @@ export function useHostRuntimeAgentDirectoryStatus(
|
||||
}
|
||||
|
||||
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
|
||||
const store = getHostRuntimeStore();
|
||||
const store = useHostRuntimeStore();
|
||||
return useSyncExternalStore(
|
||||
(onStoreChange) => store.subscribe(serverId, onStoreChange),
|
||||
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
|
||||
|
||||
@@ -38,12 +38,14 @@ function createConnection(database: SQLiteDatabase): ReplicaSqliteConnection {
|
||||
};
|
||||
}
|
||||
|
||||
const expoSqliteDriver: ReplicaSqliteDriver = {
|
||||
async open() {
|
||||
return createConnection(await openDatabaseAsync(DATABASE_NAME));
|
||||
},
|
||||
};
|
||||
|
||||
export function createReplicaRowStore(): ReplicaRowStore {
|
||||
return createSqliteReplicaRowStore(expoSqliteDriver, REPLICA_ROW_STORE_SCHEMA_VERSION);
|
||||
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
|
||||
const databaseName = authorityNamespace
|
||||
? `paseo-replica-${encodeURIComponent(authorityNamespace)}.db`
|
||||
: DATABASE_NAME;
|
||||
const driver: ReplicaSqliteDriver = {
|
||||
async open() {
|
||||
return createConnection(await openDatabaseAsync(databaseName));
|
||||
},
|
||||
};
|
||||
return createSqliteReplicaRowStore(driver, REPLICA_ROW_STORE_SCHEMA_VERSION);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
} from "fake-indexeddb";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { runReplicaRowStoreContract } from "./row-store.contract";
|
||||
import { createIndexedDbReplicaRowStore } from "./row-store.web";
|
||||
import { createReplicaRowStore, createIndexedDbReplicaRowStore } from "./row-store.web";
|
||||
|
||||
globalThis.indexedDB = fakeIndexedDb;
|
||||
globalThis.IDBKeyRange = FakeIDBKeyRange;
|
||||
@@ -49,3 +49,27 @@ it("uses exact IndexedDB keys for targeted rows instead of scanning the host", a
|
||||
get.mockRestore();
|
||||
getAll.mockRestore();
|
||||
});
|
||||
|
||||
it("restores only rows in the selected authority namespace while retaining the destination server ID", async () => {
|
||||
const serverId = `B-${crypto.randomUUID()}`;
|
||||
const origin = `A-${crypto.randomUUID()}`;
|
||||
const direct = createReplicaRowStore();
|
||||
const selected = createReplicaRowStore(origin);
|
||||
await Promise.all([direct.open(), selected.open()]);
|
||||
const row = {
|
||||
serverId,
|
||||
kind: "agent" as const,
|
||||
id: "colliding-agent",
|
||||
payload: "broader-direct",
|
||||
};
|
||||
await direct.apply({ upserts: [row], deletes: [] });
|
||||
expect(await selected.read(serverId, ["agent"])).toEqual([]);
|
||||
await selected.apply({ upserts: [{ ...row, payload: "selected-authority" }], deletes: [] });
|
||||
const restored = createReplicaRowStore(origin);
|
||||
await restored.open();
|
||||
expect(await restored.read(serverId, ["agent"])).toEqual([
|
||||
{ ...row, payload: "selected-authority" },
|
||||
]);
|
||||
expect(await direct.read(serverId, ["agent"])).toEqual([row]);
|
||||
await Promise.all([selected.clear(), direct.deleteHost(serverId)]);
|
||||
});
|
||||
|
||||
@@ -210,9 +210,11 @@ export function createIndexedDbReplicaRowStore(
|
||||
return { open, read, readAll, apply, deleteHost, renameHost, clear };
|
||||
}
|
||||
|
||||
export function createReplicaRowStore(): ReplicaRowStore {
|
||||
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
|
||||
return createIndexedDbReplicaRowStore({
|
||||
databaseName: DATABASE_NAME,
|
||||
databaseName: authorityNamespace
|
||||
? `${DATABASE_NAME}:${encodeURIComponent(authorityNamespace)}`
|
||||
: DATABASE_NAME,
|
||||
schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { create } from "zustand";
|
||||
import { create, useStore } from "zustand";
|
||||
import { createContext, useContext } from "react";
|
||||
import type { UserMessageImageAttachment } from "@/types/stream";
|
||||
import type { AgentAttachment } from "@getpaseo/protocol/messages";
|
||||
|
||||
@@ -48,89 +49,105 @@ interface CreateFlowState {
|
||||
clearAll: () => void;
|
||||
}
|
||||
|
||||
export const useCreateFlowStore = create<CreateFlowState>((set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setPending: (pending) =>
|
||||
set((state) => ({
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[pending.draftId]: {
|
||||
...pending,
|
||||
lifecycle: "active",
|
||||
},
|
||||
},
|
||||
})),
|
||||
trySetPending: (pending) => {
|
||||
const existing = get().pendingByDraftId[pending.draftId];
|
||||
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false;
|
||||
get().setPending(pending);
|
||||
return true;
|
||||
},
|
||||
updateAgentId: ({ draftId, agentId }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.agentId === agentId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
export const createCreateFlowStore = () =>
|
||||
create<CreateFlowState>((set, get) => ({
|
||||
pendingByDraftId: {},
|
||||
setPending: (pending) =>
|
||||
set((state) => ({
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, agentId },
|
||||
[pending.draftId]: {
|
||||
...pending,
|
||||
lifecycle: "active",
|
||||
},
|
||||
},
|
||||
};
|
||||
}),
|
||||
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.lifecycle === lifecycle) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, lifecycle, errorMessage },
|
||||
},
|
||||
};
|
||||
}),
|
||||
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[fromDraftId];
|
||||
if (!current) {
|
||||
return state;
|
||||
}
|
||||
if (fromDraftId === toDraftId) {
|
||||
return state;
|
||||
}
|
||||
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...rest,
|
||||
[toDraftId]: { ...current, draftId: toDraftId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
clear: ({ draftId }) =>
|
||||
set((state) => {
|
||||
if (!state.pendingByDraftId[draftId]) {
|
||||
return state;
|
||||
}
|
||||
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return { pendingByDraftId: rest };
|
||||
}),
|
||||
clearByAgent: ({ serverId, agentId }) =>
|
||||
set((state) => {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(state.pendingByDraftId).filter(
|
||||
([, pending]) =>
|
||||
pending.lifecycle !== "sent" ||
|
||||
pending.serverId !== serverId ||
|
||||
pending.agentId !== agentId,
|
||||
),
|
||||
);
|
||||
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
|
||||
return state;
|
||||
}
|
||||
return { pendingByDraftId: next };
|
||||
}),
|
||||
clearAll: () => set({ pendingByDraftId: {} }),
|
||||
}));
|
||||
})),
|
||||
trySetPending: (pending) => {
|
||||
const existing = get().pendingByDraftId[pending.draftId];
|
||||
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned")
|
||||
return false;
|
||||
get().setPending(pending);
|
||||
return true;
|
||||
},
|
||||
updateAgentId: ({ draftId, agentId }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.agentId === agentId) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, agentId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[draftId];
|
||||
if (!current || current.lifecycle === lifecycle) {
|
||||
return state;
|
||||
}
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...state.pendingByDraftId,
|
||||
[draftId]: { ...current, lifecycle, errorMessage },
|
||||
},
|
||||
};
|
||||
}),
|
||||
rekeyDraft: ({ fromDraftId, toDraftId }) =>
|
||||
set((state) => {
|
||||
const current = state.pendingByDraftId[fromDraftId];
|
||||
if (!current) {
|
||||
return state;
|
||||
}
|
||||
if (fromDraftId === toDraftId) {
|
||||
return state;
|
||||
}
|
||||
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return {
|
||||
pendingByDraftId: {
|
||||
...rest,
|
||||
[toDraftId]: { ...current, draftId: toDraftId },
|
||||
},
|
||||
};
|
||||
}),
|
||||
clear: ({ draftId }) =>
|
||||
set((state) => {
|
||||
if (!state.pendingByDraftId[draftId]) {
|
||||
return state;
|
||||
}
|
||||
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
|
||||
return { pendingByDraftId: rest };
|
||||
}),
|
||||
clearByAgent: ({ serverId, agentId }) =>
|
||||
set((state) => {
|
||||
const next = Object.fromEntries(
|
||||
Object.entries(state.pendingByDraftId).filter(
|
||||
([, pending]) =>
|
||||
pending.lifecycle !== "sent" ||
|
||||
pending.serverId !== serverId ||
|
||||
pending.agentId !== agentId,
|
||||
),
|
||||
);
|
||||
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
|
||||
return state;
|
||||
}
|
||||
return { pendingByDraftId: next };
|
||||
}),
|
||||
clearAll: () => set({ pendingByDraftId: {} }),
|
||||
}));
|
||||
|
||||
export type CreateFlowStoreApi = ReturnType<typeof createCreateFlowStore>;
|
||||
export const defaultCreateFlowStore = createCreateFlowStore();
|
||||
export const CreateFlowStoreContext = createContext<CreateFlowStoreApi | null>(null);
|
||||
export function useCreateFlowStoreApi(): CreateFlowStoreApi {
|
||||
return useContext(CreateFlowStoreContext) ?? defaultCreateFlowStore;
|
||||
}
|
||||
export const useCreateFlowStore: CreateFlowStoreApi = Object.assign(function useCreateFlowStore<T>(
|
||||
selector: (state: CreateFlowState) => T = ((state: CreateFlowState) => state) as (
|
||||
state: CreateFlowState,
|
||||
) => T,
|
||||
): T {
|
||||
return useStore(useCreateFlowStoreApi(), selector);
|
||||
}, defaultCreateFlowStore);
|
||||
|
||||
+1198
-1165
File diff suppressed because it is too large
Load Diff
@@ -8,7 +8,12 @@ import {
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
createSessionStore,
|
||||
type SessionStoreApi,
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
} from "@/stores/session-store";
|
||||
import { useCreateFlowStore, type PendingCreateAttempt } from "@/stores/create-flow-store";
|
||||
import type { StreamItem } from "@/types/stream";
|
||||
import {
|
||||
@@ -40,13 +45,18 @@ function cachedTimeline(): CachedTimeline {
|
||||
};
|
||||
}
|
||||
|
||||
function createOwner(storage: TimelineReplicaStorage): ViewedTimelineOwner {
|
||||
function createOwner(
|
||||
storage: TimelineReplicaStorage,
|
||||
store: SessionStoreApi = useSessionStore,
|
||||
): ViewedTimelineOwner {
|
||||
const replica = createTimelineReplica({
|
||||
store,
|
||||
serverId: SERVER_ID,
|
||||
storage,
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
return createViewedTimelineOwner({
|
||||
store,
|
||||
serverId: SERVER_ID,
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
@@ -533,8 +543,8 @@ describe("create handoff lifetime", () => {
|
||||
|
||||
afterEach(() => useCreateFlowStore.getState().clearAll());
|
||||
|
||||
function sentCreateHandoff(): void {
|
||||
useCreateFlowStore.getState().setPending({
|
||||
function sentCreateHandoff(flow = useCreateFlowStore): void {
|
||||
flow.getState().setPending({
|
||||
draftId: DRAFT_ID,
|
||||
serverId: SERVER_ID,
|
||||
agentId: AGENT_ID,
|
||||
@@ -542,7 +552,7 @@ describe("create handoff lifetime", () => {
|
||||
text: "Create this chat.",
|
||||
timestamp: 0,
|
||||
});
|
||||
useCreateFlowStore.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
|
||||
flow.getState().markLifecycle({ draftId: DRAFT_ID, lifecycle: "sent" });
|
||||
}
|
||||
|
||||
function createHandoff(): PendingCreateAttempt | undefined {
|
||||
@@ -575,18 +585,22 @@ describe("create handoff lifetime", () => {
|
||||
release: () => void;
|
||||
}
|
||||
|
||||
async function ownerWithHeldCatchUp(): Promise<HeldCatchUpOwner> {
|
||||
async function ownerWithHeldCatchUp(
|
||||
store: SessionStoreApi = useSessionStore,
|
||||
): Promise<HeldCatchUpOwner> {
|
||||
let releaseTailFetch!: () => void;
|
||||
let tailFetchStarted = false;
|
||||
const tailFetched = new Promise<void>((resolve) => {
|
||||
releaseTailFetch = resolve;
|
||||
});
|
||||
const replica = createTimelineReplica({
|
||||
store,
|
||||
serverId: SERVER_ID,
|
||||
storage: { readTimeline: async () => undefined, commitTimeline: () => undefined },
|
||||
prepareAgent: async () => undefined,
|
||||
});
|
||||
const owner = createViewedTimelineOwner({
|
||||
store,
|
||||
serverId: SERVER_ID,
|
||||
replica,
|
||||
replaceDemandedAgentIds: () => undefined,
|
||||
@@ -611,6 +625,30 @@ describe("create handoff lifetime", () => {
|
||||
return { owner, release: releaseTailFetch };
|
||||
}
|
||||
|
||||
it("clears only the completed authority's creation handoff for equal destination identities", async () => {
|
||||
const direct = createSessionStore();
|
||||
const routed = createSessionStore();
|
||||
for (const store of [direct, routed]) {
|
||||
store.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff(store.createFlows);
|
||||
}
|
||||
const { owner, release } = await ownerWithHeldCatchUp(direct);
|
||||
try {
|
||||
owner.applyTimelineResponse(syncedTailPage());
|
||||
release();
|
||||
await expect
|
||||
.poll(() => direct.createFlows.getState().pendingByDraftId[DRAFT_ID])
|
||||
.toBeUndefined();
|
||||
expect(routed.createFlows.getState().pendingByDraftId[DRAFT_ID]).toMatchObject({
|
||||
agentId: AGENT_ID,
|
||||
lifecycle: "sent",
|
||||
});
|
||||
} finally {
|
||||
release();
|
||||
owner.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps the handoff through the first synchronized page and releases it when the chat is current", async () => {
|
||||
useSessionStore.getState().initializeSession(SERVER_ID, null);
|
||||
sentCreateHandoff();
|
||||
@@ -749,3 +787,66 @@ describe("SQLite baseline restoration", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, "Direct history failed"])(
|
||||
"keeps initialization readiness scoped when another authority returns %s for the same address",
|
||||
async (failureMessage) => {
|
||||
const direct = createSessionStore();
|
||||
const routed = createSessionStore();
|
||||
const key = direct.initializations.getInitKey(SERVER_ID, AGENT_ID);
|
||||
const directReady = direct.initializations.createInitDeferred(key, "tail").promise;
|
||||
const routedReady = routed.initializations.createInitDeferred(key, "tail").promise;
|
||||
void directReady.catch(() => {});
|
||||
let routedSettled = false;
|
||||
void routedReady.then(
|
||||
() => {
|
||||
routedSettled = true;
|
||||
return undefined;
|
||||
},
|
||||
() => {
|
||||
routedSettled = true;
|
||||
return undefined;
|
||||
},
|
||||
);
|
||||
for (const store of [direct, routed]) {
|
||||
store.getState().initializeSession(SERVER_ID, null);
|
||||
store.getState().setInitializingAgents(SERVER_ID, new Map([[AGENT_ID, true]]));
|
||||
}
|
||||
const storage: TimelineReplicaStorage = {
|
||||
readTimeline: async () => undefined,
|
||||
commitTimeline: () => {},
|
||||
};
|
||||
const owners = [createOwner(storage, direct), createOwner(storage, routed)];
|
||||
const response: Parameters<ViewedTimelineOwner["applyTimelineResponse"]>[0] = {
|
||||
requestId: "direct-tail",
|
||||
agentId: AGENT_ID,
|
||||
agent: null,
|
||||
direction: "tail",
|
||||
projection: "projected",
|
||||
reset: false,
|
||||
epoch: "epoch-1",
|
||||
window: { minSeq: 1, maxSeq: 0, nextSeq: 1 },
|
||||
startCursor: null,
|
||||
endCursor: null,
|
||||
entries: [],
|
||||
error: failureMessage,
|
||||
hasNewer: false,
|
||||
hasOlder: false,
|
||||
staleCursor: false,
|
||||
gap: false,
|
||||
};
|
||||
try {
|
||||
owners[0]!.applyTimelineResponse(response);
|
||||
const directResult = await Promise.allSettled([directReady]);
|
||||
expect(directResult[0]?.status).toBe(failureMessage ? "rejected" : "fulfilled");
|
||||
expect(routedSettled).toBe(false);
|
||||
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
|
||||
owners[1]!.applyTimelineResponse({ ...response, requestId: "routed-tail", error: null });
|
||||
await routedReady;
|
||||
expect(routedSettled).toBe(true);
|
||||
} finally {
|
||||
for (const store of [direct, routed]) store.initializations.resolveInitDeferred(key);
|
||||
for (const owner of owners) owner.dispose();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import type { AgentTimelineItem } from "@getpaseo/protocol/agent-types";
|
||||
import type { AgentStreamEventPayload } from "@getpaseo/protocol/messages";
|
||||
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type SessionStoreApi,
|
||||
} from "@/stores/session-store";
|
||||
import type { AssistantMessageItem, StreamItem, TodoEntry } from "@/types/stream";
|
||||
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
||||
import {
|
||||
@@ -1829,6 +1833,7 @@ export function deriveAgentStreamTurnLiveness(
|
||||
}
|
||||
|
||||
export interface CreateSessionAgentStreamReducerQueueInput {
|
||||
store?: SessionStoreApi;
|
||||
serverId: string;
|
||||
setAgentStreamState: (serverId: string, agentId: string, state: StreamStatePatch) => void;
|
||||
setAgentTimelineCursor: (
|
||||
@@ -1895,7 +1900,7 @@ export function createSessionAgentStreamReducerQueue(
|
||||
|
||||
return createAgentStreamReducerQueue({
|
||||
getSnapshot: (agentId) => {
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = (input.store ?? useSessionStore).getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
return {
|
||||
currentTail: timeline.status === "cold" ? [] : timeline.items,
|
||||
|
||||
@@ -7,18 +7,13 @@ import type { CachedTimeline } from "@/runtime/replica-cache";
|
||||
import {
|
||||
selectAgentTimelineState,
|
||||
useSessionStore,
|
||||
type SessionStoreApi,
|
||||
type AgentTimelineCursorState,
|
||||
type AgentTimelineState,
|
||||
} from "@/stores/session-store";
|
||||
import { useCreateFlowStore } from "@/stores/create-flow-store";
|
||||
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
|
||||
import { getSendingClientMessageIds } from "@/composer/submission/model";
|
||||
import {
|
||||
getInitDeferred,
|
||||
getInitKey,
|
||||
rejectInitDeferred,
|
||||
resolveInitDeferred,
|
||||
} from "@/utils/agent-initialization";
|
||||
import { getInitKey } from "@/utils/agent-initialization";
|
||||
import {
|
||||
createSessionAgentStreamReducerQueue,
|
||||
type AgentStreamReducerEvent,
|
||||
@@ -34,12 +29,13 @@ export interface TimelineReplicaStorage {
|
||||
}
|
||||
|
||||
async function prepareCachedTimeline(input: {
|
||||
store: SessionStoreApi;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): Promise<CachedTimeline | undefined> {
|
||||
const before = useSessionStore.getState().sessions[input.serverId];
|
||||
const before = input.store.getState().sessions[input.serverId];
|
||||
const beforeTimeline = selectAgentTimelineState(before, input.agentId);
|
||||
if (beforeTimeline.status === "synced") return undefined;
|
||||
const [stored] = await Promise.all([
|
||||
@@ -47,7 +43,7 @@ async function prepareCachedTimeline(input: {
|
||||
input.prepareAgent(input.agentId),
|
||||
]);
|
||||
if (!stored) return undefined;
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const session = input.store.getState().sessions[input.serverId];
|
||||
const currentTimeline = selectAgentTimelineState(session, input.agentId);
|
||||
const currentHead = session?.agentStreamHead.get(input.agentId);
|
||||
if (currentTimeline.status === "synced") return undefined;
|
||||
@@ -77,7 +73,7 @@ async function prepareCachedTimeline(input: {
|
||||
endSeq: null,
|
||||
},
|
||||
});
|
||||
useSessionStore.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
|
||||
input.store.getState().applyAgentTimelineResponseState(input.serverId, input.agentId, {
|
||||
items: replacement.tail,
|
||||
head: replacement.head,
|
||||
range: stored.range,
|
||||
@@ -104,6 +100,7 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
private readonly serverId: string,
|
||||
private readonly storage: TimelineReplicaStorage,
|
||||
private readonly prepareAgent: (agentId: string) => Promise<void>,
|
||||
private readonly store: SessionStoreApi,
|
||||
) {}
|
||||
|
||||
async prepare(agentId: string): Promise<void> {
|
||||
@@ -123,6 +120,7 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
serverId: this.serverId,
|
||||
agentId,
|
||||
storage: this.storage,
|
||||
store: this.store,
|
||||
prepareAgent: this.prepareAgent,
|
||||
});
|
||||
if (!stored) return;
|
||||
@@ -141,7 +139,7 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
}
|
||||
|
||||
timelineUpdated(agentId: string): void {
|
||||
const session = useSessionStore.getState().sessions[this.serverId];
|
||||
const session = this.store.getState().sessions[this.serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
if (timeline.status === "cold") return;
|
||||
if (timeline.status === "synced") this.cachedRanges.delete(agentId);
|
||||
@@ -155,11 +153,17 @@ class TimelineReplicaOwner implements TimelineReplica {
|
||||
}
|
||||
|
||||
export function createTimelineReplica(input: {
|
||||
store?: SessionStoreApi;
|
||||
serverId: string;
|
||||
storage: TimelineReplicaStorage;
|
||||
prepareAgent: (agentId: string) => Promise<void>;
|
||||
}): TimelineReplica {
|
||||
return new TimelineReplicaOwner(input.serverId, input.storage, input.prepareAgent);
|
||||
return new TimelineReplicaOwner(
|
||||
input.serverId,
|
||||
input.storage,
|
||||
input.prepareAgent,
|
||||
input.store ?? useSessionStore,
|
||||
);
|
||||
}
|
||||
|
||||
export interface TimelinePageResult {
|
||||
@@ -181,8 +185,12 @@ export function consumeForcedTimelineTailReplacement(
|
||||
return { ...payload, reset: true };
|
||||
}
|
||||
|
||||
function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
useSessionStore.getState().setInitializingAgents(serverId, (previous) => {
|
||||
function clearAgentInitializingFlag(
|
||||
store: SessionStoreApi,
|
||||
serverId: string,
|
||||
agentId: string,
|
||||
): void {
|
||||
store.getState().setInitializingAgents(serverId, (previous) => {
|
||||
if (previous.get(agentId) !== true) return previous;
|
||||
const next = new Map(previous);
|
||||
next.set(agentId, false);
|
||||
@@ -191,6 +199,7 @@ function clearAgentInitializingFlag(serverId: string, agentId: string): void {
|
||||
}
|
||||
|
||||
function commitProcessedTimeline(input: {
|
||||
store: SessionStoreApi;
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
result: ProcessTimelineResponseOutput;
|
||||
@@ -200,7 +209,7 @@ function commitProcessedTimeline(input: {
|
||||
}): void {
|
||||
const { serverId, payload, result, timeline, currentCursor, synchronized } = input;
|
||||
const agentId = payload.agentId;
|
||||
const store = useSessionStore.getState();
|
||||
const store = input.store.getState();
|
||||
if (result.commit !== "discard") {
|
||||
store.applyAgentTimelineResponseState(serverId, agentId, {
|
||||
items: result.tail,
|
||||
@@ -232,6 +241,7 @@ function commitProcessedTimeline(input: {
|
||||
}
|
||||
|
||||
function finalizeProcessedTimeline(input: {
|
||||
store: SessionStoreApi;
|
||||
serverId: string;
|
||||
agentId: string;
|
||||
initKey: string;
|
||||
@@ -244,17 +254,19 @@ function finalizeProcessedTimeline(input: {
|
||||
if (effect.type === "catch_up") input.recoverGap(input.agentId, effect.cursor);
|
||||
}
|
||||
if (input.result.clearInitializing) {
|
||||
clearAgentInitializingFlag(input.serverId, input.agentId);
|
||||
clearAgentInitializingFlag(input.store, input.serverId, input.agentId);
|
||||
}
|
||||
if (input.synchronized) {
|
||||
const session = useSessionStore.getState().sessions[input.serverId];
|
||||
const session = input.store.getState().sessions[input.serverId];
|
||||
const agent = session?.agents.get(input.agentId) ?? session?.agentDetails.get(input.agentId);
|
||||
if (agent && agent.turn.phase === "idle") input.drainQueuedAgentMessage(input.agentId);
|
||||
}
|
||||
if (input.result.initResolution === "resolve") resolveInitDeferred(input.initKey);
|
||||
if (input.result.initResolution === "resolve")
|
||||
input.store.initializations.resolveInitDeferred(input.initKey);
|
||||
}
|
||||
|
||||
function applyAuthoritativeTimelineResponse(input: {
|
||||
store: SessionStoreApi;
|
||||
serverId: string;
|
||||
payload: TimelineResponsePayload;
|
||||
cachedCursor?: AgentTimelineCursorState;
|
||||
@@ -264,9 +276,9 @@ function applyAuthoritativeTimelineResponse(input: {
|
||||
const { serverId, payload } = input;
|
||||
const agentId = payload.agentId;
|
||||
const initKey = getInitKey(serverId, agentId);
|
||||
const session = useSessionStore.getState().sessions[serverId];
|
||||
const session = input.store.getState().sessions[serverId];
|
||||
const timeline = selectAgentTimelineState(session, agentId);
|
||||
const activeInitDeferred = getInitDeferred(initKey);
|
||||
const activeInitDeferred = input.store.initializations.getInitDeferred(initKey);
|
||||
const currentCursor =
|
||||
timeline.status === "synced" ? (timeline.range ?? undefined) : input.cachedCursor;
|
||||
const result = processTimelineResponse({
|
||||
@@ -281,8 +293,9 @@ function applyAuthoritativeTimelineResponse(input: {
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(serverId, agentId);
|
||||
if (result.initResolution === "reject") rejectInitDeferred(initKey, new Error(result.error));
|
||||
if (result.clearInitializing) clearAgentInitializingFlag(input.store, serverId, agentId);
|
||||
if (result.initResolution === "reject")
|
||||
input.store.initializations.rejectInitDeferred(initKey, new Error(result.error));
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -291,8 +304,17 @@ function applyAuthoritativeTimelineResponse(input: {
|
||||
hasNewer: payload.hasNewer,
|
||||
error: payload.error,
|
||||
});
|
||||
commitProcessedTimeline({ serverId, payload, result, timeline, currentCursor, synchronized });
|
||||
commitProcessedTimeline({
|
||||
store: input.store,
|
||||
serverId,
|
||||
payload,
|
||||
result,
|
||||
timeline,
|
||||
currentCursor,
|
||||
synchronized,
|
||||
});
|
||||
finalizeProcessedTimeline({
|
||||
store: input.store,
|
||||
serverId,
|
||||
agentId,
|
||||
initKey,
|
||||
@@ -358,12 +380,14 @@ export interface ViewedTimelineOwner extends ViewedTimelineSync {
|
||||
}
|
||||
|
||||
export function createViewedTimelineOwner(input: {
|
||||
store?: SessionStoreApi;
|
||||
serverId: string;
|
||||
replica: TimelineReplica;
|
||||
replaceDemandedAgentIds: (agentIds: string[]) => void;
|
||||
drainQueuedAgentMessage: (agentId: string) => void;
|
||||
ports: ViewedTimelineOwnerPorts;
|
||||
}): ViewedTimelineOwner {
|
||||
const store = input.store ?? useSessionStore;
|
||||
const forcedTailReplacements = new Set<string>();
|
||||
const sync = createViewedTimelineSync({
|
||||
...input.ports,
|
||||
@@ -379,12 +403,13 @@ export function createViewedTimelineOwner(input: {
|
||||
readCursor: (agentId) => input.replica.readCursor(agentId) ?? input.ports.readCursor(agentId),
|
||||
replaceDemandedAgentIds: input.replaceDemandedAgentIds,
|
||||
onCatchUpEnded: (agentId) =>
|
||||
useCreateFlowStore.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
store.createFlows.getState().clearByAgent({ serverId: input.serverId, agentId }),
|
||||
});
|
||||
const streamQueue = createSessionAgentStreamReducerQueue({
|
||||
serverId: input.serverId,
|
||||
setAgentStreamState: (...args) => useSessionStore.getState().setAgentStreamState(...args),
|
||||
setAgentTimelineCursor: (...args) => useSessionStore.getState().setAgentTimelineCursor(...args),
|
||||
store,
|
||||
setAgentStreamState: (...args) => store.getState().setAgentStreamState(...args),
|
||||
setAgentTimelineCursor: (...args) => store.getState().setAgentTimelineCursor(...args),
|
||||
recoverTimelineGap: (agentId, cursor) => sync.recoverGap(agentId, cursor),
|
||||
onCommitted: (agentId) => input.replica.timelineUpdated(agentId),
|
||||
});
|
||||
@@ -393,6 +418,7 @@ export function createViewedTimelineOwner(input: {
|
||||
applyTimelineResponse(receivedPayload) {
|
||||
const payload = consumeForcedTimelineTailReplacement(receivedPayload, forcedTailReplacements);
|
||||
const accepted = applyAuthoritativeTimelineResponse({
|
||||
store,
|
||||
serverId: input.serverId,
|
||||
payload,
|
||||
cachedCursor: input.replica.readRange(payload.agentId),
|
||||
|
||||
@@ -6,74 +6,96 @@ export interface DeferredInit {
|
||||
requestDirection: "tail" | "after";
|
||||
}
|
||||
|
||||
const initPromises = new Map<string, DeferredInit>();
|
||||
export const INIT_TIMEOUT_MS = 65_000;
|
||||
|
||||
export function getInitKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
export function createAgentInitializations() {
|
||||
const initPromises = new Map<string, DeferredInit>();
|
||||
|
||||
export function getInitDeferred(key: string): DeferredInit | undefined {
|
||||
return initPromises.get(key);
|
||||
}
|
||||
function getInitKey(serverId: string, agentId: string): string {
|
||||
return `${serverId}:${agentId}`;
|
||||
}
|
||||
|
||||
export function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
function getInitDeferred(key: string): DeferredInit | undefined {
|
||||
return initPromises.get(key);
|
||||
}
|
||||
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
function createInitDeferred(key: string, requestDirection: "tail" | "after"): DeferredInit {
|
||||
let resolve!: () => void;
|
||||
let reject!: (error: Error) => void;
|
||||
|
||||
const deferred: DeferredInit = {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId: null,
|
||||
requestDirection,
|
||||
const promise = new Promise<void>((res, rej) => {
|
||||
resolve = res;
|
||||
reject = rej;
|
||||
});
|
||||
|
||||
const deferred: DeferredInit = {
|
||||
promise,
|
||||
resolve,
|
||||
reject,
|
||||
timeoutId: null,
|
||||
requestDirection,
|
||||
};
|
||||
initPromises.set(key, deferred);
|
||||
return deferred;
|
||||
}
|
||||
|
||||
function refreshInitTimeout(input: {
|
||||
key: string;
|
||||
onTimeout: () => void;
|
||||
timeoutMs?: number;
|
||||
}): void {
|
||||
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
|
||||
const deferred = initPromises.get(input.key);
|
||||
if (!deferred) {
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
deferred.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
function resolveInitDeferred(key: string): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.resolve();
|
||||
}
|
||||
|
||||
function rejectInitDeferred(key: string, error: Error): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.reject(error);
|
||||
}
|
||||
|
||||
return {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
createInitDeferred,
|
||||
refreshInitTimeout,
|
||||
resolveInitDeferred,
|
||||
rejectInitDeferred,
|
||||
};
|
||||
initPromises.set(key, deferred);
|
||||
return deferred;
|
||||
}
|
||||
|
||||
export function refreshInitTimeout(input: {
|
||||
key: string;
|
||||
onTimeout: () => void;
|
||||
timeoutMs?: number;
|
||||
}): void {
|
||||
const timeoutId = setTimeout(input.onTimeout, input.timeoutMs ?? INIT_TIMEOUT_MS);
|
||||
const deferred = initPromises.get(input.key);
|
||||
if (!deferred) {
|
||||
clearTimeout(timeoutId);
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
deferred.timeoutId = timeoutId;
|
||||
}
|
||||
|
||||
export function resolveInitDeferred(key: string): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.resolve();
|
||||
}
|
||||
|
||||
export function rejectInitDeferred(key: string, error: Error): void {
|
||||
const deferred = initPromises.get(key);
|
||||
if (!deferred) {
|
||||
return;
|
||||
}
|
||||
if (deferred.timeoutId) {
|
||||
clearTimeout(deferred.timeoutId);
|
||||
}
|
||||
initPromises.delete(key);
|
||||
deferred.reject(error);
|
||||
}
|
||||
export const defaultAgentInitializations = createAgentInitializations();
|
||||
export const {
|
||||
getInitKey,
|
||||
getInitDeferred,
|
||||
createInitDeferred,
|
||||
refreshInitTimeout,
|
||||
resolveInitDeferred,
|
||||
rejectInitDeferred,
|
||||
} = defaultAgentInitializations;
|
||||
|
||||
Reference in New Issue
Block a user