mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-01 14:12:51 +00:00
1032 lines
38 KiB
TypeScript
1032 lines
38 KiB
TypeScript
import type { OwnedSubscription } from "@getpaseo/client";
|
|
import type {
|
|
DaemonClient,
|
|
FetchAgentsEntry,
|
|
FetchAgentsOptions,
|
|
} from "@getpaseo/client/internal/daemon-client";
|
|
import { fetchAgentTimelineOnce } from "@/timeline/fetch-agent-timeline-once";
|
|
import {
|
|
normalizeProjectDescriptor,
|
|
normalizeWorkspaceDescriptor,
|
|
useSessionStore,
|
|
type Agent,
|
|
type ProjectDescriptor,
|
|
type WorkspaceDescriptor,
|
|
} from "@/stores/session-store";
|
|
import type { AgentDirectoryDelta } from "@/utils/agent-directory-sync";
|
|
import { AgentDirectoryReplica } from "./agent-replica";
|
|
import {
|
|
WorkspaceDirectoryReplica,
|
|
type WorkspaceDirectoryDelta,
|
|
type WorkspaceDirectorySnapshot,
|
|
} from "./workspace-replica";
|
|
import {
|
|
DirectoryTransactionOwner,
|
|
type DirectorySourceToken,
|
|
type DirectoryTransaction,
|
|
} from "./transaction";
|
|
import { workspaceLabels } from "@/workspace-labels";
|
|
import type {
|
|
CachedDirectory,
|
|
CachedWorkspace,
|
|
DirectoryCheckpoint,
|
|
DirectoryCursor,
|
|
DirectoryReplicaMutation,
|
|
} from "@/runtime/replica-cache";
|
|
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
|
|
|
|
type FetchAgentsPayload = Awaited<ReturnType<DaemonClient["fetchAgents"]>>;
|
|
type FetchWorkspacesPayload = Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>;
|
|
|
|
const PAGE_LIMIT = 200;
|
|
const AGENT_SORT: NonNullable<FetchAgentsOptions["sort"]> = [
|
|
{ key: "updated_at", direction: "desc" },
|
|
];
|
|
|
|
function resolveAgentNextPage(pageInfo: AgentPageInfo): {
|
|
hasMore: boolean;
|
|
nextCursor: string | null;
|
|
} {
|
|
return {
|
|
hasMore: pageInfo.hasMore ?? pageInfo.hasMoreAfter ?? false,
|
|
nextCursor: pageInfo.nextCursor ?? pageInfo.afterCursor ?? null,
|
|
};
|
|
}
|
|
|
|
interface AgentSnapshot {
|
|
entries: FetchAgentsEntry[];
|
|
subscriptionId: string | null;
|
|
syncMode?: "snapshot" | "changes";
|
|
syncCursor?: DirectoryCursor;
|
|
syncRemovals: Array<{ id: string; seq: number }>;
|
|
}
|
|
|
|
interface WorkspaceRefreshSnapshot extends WorkspaceDirectorySnapshot {
|
|
readonly requestCursors: Readonly<Pick<DirectoryCheckpoint, "projects" | "workspaces">>;
|
|
}
|
|
|
|
interface AgentPageInfo {
|
|
hasMore?: boolean;
|
|
hasMoreAfter?: boolean;
|
|
nextCursor?: string | null;
|
|
afterCursor?: string | null;
|
|
}
|
|
|
|
function applyWorkspaceSnapshotPage(
|
|
snapshot: WorkspaceDirectorySnapshot,
|
|
payload: Awaited<ReturnType<DaemonClient["fetchWorkspaces"]>>,
|
|
firstPage: boolean,
|
|
): void {
|
|
if (firstPage && payload.sync?.mode !== "changes") snapshot.workspaces.clear();
|
|
if (firstPage) (snapshot.syncModes ??= {}).workspaces = payload.sync?.mode ?? "snapshot";
|
|
for (const entry of payload.entries) {
|
|
const workspace = normalizeWorkspaceDescriptor(entry);
|
|
snapshot.workspaces.set(workspace.id, workspace);
|
|
(snapshot.touchedWorkspaceIds ??= new Set()).add(workspace.id);
|
|
(snapshot.touchedProjectIds ??= new Set()).add(workspace.projectId);
|
|
}
|
|
for (const removal of payload.sync?.removals ?? []) {
|
|
snapshot.workspaces.delete(removal.id);
|
|
(snapshot.touchedWorkspaceIds ??= new Set()).add(removal.id);
|
|
}
|
|
if (payload.sync) {
|
|
(snapshot.syncCursors ??= {}).workspaces = {
|
|
generation: payload.sync.generation,
|
|
afterSeq: payload.sync.headSeq,
|
|
};
|
|
}
|
|
for (const entry of payload.emptyProjects ?? []) {
|
|
const project = normalizeProjectDescriptor(entry);
|
|
snapshot.projects.set(project.projectId, project);
|
|
(snapshot.touchedProjectIds ??= new Set()).add(project.projectId);
|
|
}
|
|
}
|
|
|
|
export interface DirectoryConnection {
|
|
client: DaemonClient | null;
|
|
status: "online" | "offline";
|
|
source: DirectorySourceToken;
|
|
}
|
|
|
|
export interface DirectoryCheckpointStorage {
|
|
readAgent(serverId: string, agentId: string): Promise<Agent | undefined>;
|
|
readWorkspace(serverId: string, workspaceId: string): Promise<CachedWorkspace | undefined>;
|
|
readDirectory(serverId: string): Promise<CachedDirectory>;
|
|
commitDirectoryMutations(
|
|
serverId: string,
|
|
mutations: readonly DirectoryReplicaMutation[],
|
|
checkpoint?: DirectoryCheckpoint,
|
|
): void;
|
|
replaceDirectoryBaseline?(serverId: string, directory: CachedDirectory): void;
|
|
}
|
|
|
|
export interface RefreshAgentDirectoryInput {
|
|
filter?: FetchAgentsOptions["filter"];
|
|
subscribe?: FetchAgentsOptions["subscribe"];
|
|
page?: FetchAgentsOptions["page"];
|
|
}
|
|
|
|
export interface RefreshAgentDirectoryResult {
|
|
agents: Map<string, Agent>;
|
|
subscriptionId: string | null;
|
|
}
|
|
|
|
export class DirectorySync {
|
|
private readonly agentTransactions = new DirectoryTransactionOwner<
|
|
AgentSnapshot,
|
|
AgentDirectoryDelta
|
|
>();
|
|
private readonly workspaceTransactions = new DirectoryTransactionOwner<
|
|
WorkspaceRefreshSnapshot,
|
|
WorkspaceDirectoryDelta
|
|
>();
|
|
private readonly agents: AgentDirectoryReplica;
|
|
private readonly workspaces: WorkspaceDirectoryReplica;
|
|
private connection: DirectoryConnection = {
|
|
client: null,
|
|
status: "offline",
|
|
source: { clientGeneration: 0, connectionEpoch: 0 },
|
|
};
|
|
private agentSubscription: OwnedSubscription<FetchAgentsPayload> | null = null;
|
|
private workspaceSubscription: OwnedSubscription<FetchWorkspacesPayload> | null = null;
|
|
private eventSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
|
private scriptStatusSubscription: ReturnType<DaemonClient["observeEvents"]> | null = null;
|
|
private readonly abortSessionWaits = new Set<() => void>();
|
|
private cacheLoad: Promise<void> | null = null;
|
|
private cacheAccepted = false;
|
|
private revision = 0;
|
|
private workspaceRevision = 0;
|
|
private readonly routeDemandIds = new Set<string>();
|
|
private readonly fullDemandSources = new Set<object>();
|
|
private readonly scriptStatusDemand = new Map<object, string>();
|
|
private demandRefresh: Promise<void> | null = null;
|
|
private satisfiedDemandSource: DirectorySourceToken | null = null;
|
|
private cursors: DirectoryCheckpoint = {};
|
|
|
|
constructor(
|
|
private readonly serverId: string,
|
|
private readonly callbacks: {
|
|
onAgentStoppedRunning: (agentId: string) => void;
|
|
markAgentLoading: () => void;
|
|
markAgentReady: () => void;
|
|
markAgentError: (error: string) => void;
|
|
},
|
|
private readonly checkpoints?: DirectoryCheckpointStorage,
|
|
) {
|
|
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
|
|
this.workspaces = new WorkspaceDirectoryReplica(serverId);
|
|
}
|
|
|
|
connectionChanged(connection: DirectoryConnection): boolean {
|
|
const changed =
|
|
this.connection.client !== connection.client ||
|
|
this.connection.source.clientGeneration !== connection.source.clientGeneration ||
|
|
this.connection.source.connectionEpoch !== connection.source.connectionEpoch;
|
|
const wentOffline = this.connection.status === "online" && connection.status === "offline";
|
|
if (!changed && !wentOffline) {
|
|
this.connection = connection;
|
|
return false;
|
|
}
|
|
this.flushAbortedTransactions();
|
|
this.releaseSubscriptions();
|
|
this.releaseScriptStatusEvents();
|
|
workspaceLabels.disconnect(this.serverId);
|
|
this.connection = connection;
|
|
this.abortPendingSessionWaits();
|
|
if (!connection.client || connection.status !== "online") return true;
|
|
if (this.scriptStatusDemand.size > 0) this.observeScriptStatusEvents();
|
|
// Reattach labels here because route-only demand can satisfy the epoch before full demand requests them.
|
|
void this.connectWorkspaceLabels().catch(() => undefined);
|
|
if (this.hasDemand()) void this.requestDemandRefresh().catch(() => undefined);
|
|
return true;
|
|
}
|
|
|
|
setDemand(source: object, demanded: boolean): void {
|
|
const wasDemanded = this.fullDemandSources.size > 0;
|
|
if (demanded) this.fullDemandSources.add(source);
|
|
else this.fullDemandSources.delete(source);
|
|
if (!this.hasDemand()) this.releaseSubscriptions();
|
|
if (!wasDemanded && this.fullDemandSources.size > 0) {
|
|
void this.loadCachedDirectory().catch(() => undefined);
|
|
if (this.getOnlineConnection()) void this.requestDemandRefresh().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
setAgentRouteDemand(agentIds: readonly string[]): void {
|
|
const next = new Set(agentIds);
|
|
if (
|
|
next.size === this.routeDemandIds.size &&
|
|
[...next].every((agentId) => this.routeDemandIds.has(agentId))
|
|
) {
|
|
return;
|
|
}
|
|
this.routeDemandIds.clear();
|
|
for (const agentId of next) this.routeDemandIds.add(agentId);
|
|
if (!this.hasDemand()) this.releaseSubscriptions();
|
|
if (this.routeDemandIds.size > 0 && this.getOnlineConnection()) {
|
|
void this.requestDemandRefresh().catch(() => undefined);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Keeps one workspace's script status live without demanding the directory: a
|
|
* script_status_update subscription, plus one scripts fetch to cover what changed before it.
|
|
*/
|
|
setScriptStatusDemand(source: object, workspaceId: string | null): void {
|
|
if (!workspaceId) {
|
|
this.scriptStatusDemand.delete(source);
|
|
if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents();
|
|
return;
|
|
}
|
|
this.scriptStatusDemand.set(source, workspaceId);
|
|
if (!this.getOnlineConnection()) return;
|
|
// A new subscription fetches every demanded workspace once its snapshot lands.
|
|
if (!this.scriptStatusSubscription) this.observeScriptStatusEvents();
|
|
else
|
|
void this.scriptStatusSubscription.ready
|
|
.then(() => this.fetchScriptStatus(workspaceId))
|
|
.catch(() => undefined);
|
|
}
|
|
|
|
dispose(): void {
|
|
this.flushAbortedTransactions();
|
|
this.abortPendingSessionWaits();
|
|
this.scriptStatusDemand.clear();
|
|
this.releaseSubscriptions();
|
|
this.releaseScriptStatusEvents();
|
|
this.fullDemandSources.clear();
|
|
this.routeDemandIds.clear();
|
|
workspaceLabels.disconnect(this.serverId);
|
|
}
|
|
|
|
private releaseSubscriptions(): void {
|
|
for (const subscription of [
|
|
this.agentSubscription,
|
|
this.workspaceSubscription,
|
|
this.eventSubscription,
|
|
]) {
|
|
void subscription?.release().catch(() => undefined);
|
|
}
|
|
this.agentSubscription = null;
|
|
this.workspaceSubscription = null;
|
|
this.eventSubscription = null;
|
|
this.satisfiedDemandSource = null;
|
|
if (!this.wantsScriptStatus()) this.releaseScriptStatusEvents();
|
|
}
|
|
|
|
private releaseScriptStatusEvents(): void {
|
|
void this.scriptStatusSubscription?.release().catch(() => undefined);
|
|
this.scriptStatusSubscription = null;
|
|
}
|
|
|
|
private receiveAgentDelta(source: DirectorySourceToken, delta: AgentDirectoryDelta): void {
|
|
this.revision += 1;
|
|
if (this.agentTransactions.record(source, delta)) return;
|
|
this.agents.applyDelta(delta);
|
|
this.noteLiveCursor("agents", delta);
|
|
this.persistCheckpoint();
|
|
}
|
|
|
|
private receiveWorkspaceDelta(
|
|
source: DirectorySourceToken,
|
|
delta: WorkspaceDirectoryDelta,
|
|
): void {
|
|
this.revision += 1;
|
|
this.workspaceRevision += 1;
|
|
if (this.workspaceTransactions.record(source, delta)) return;
|
|
this.applyWorkspaceDelta(delta);
|
|
if (delta.kind !== "script_status")
|
|
this.noteLiveCursor(
|
|
"projectId" in delta || "project" in delta ? "projects" : "workspaces",
|
|
delta,
|
|
);
|
|
this.persistCheckpoint();
|
|
}
|
|
|
|
private observeDirectoryEvents(): void {
|
|
if (this.eventSubscription) return;
|
|
const { client, source } = this.requireOnline();
|
|
this.eventSubscription = client.observeEvents(["project.update"]);
|
|
this.eventSubscription.subscribe({
|
|
snapshot: () => {},
|
|
update: (message) => {
|
|
if (!this.isCurrent(client, source)) return;
|
|
if (message.type === "project.update") this.receiveWorkspaceDelta(source, message.payload);
|
|
},
|
|
});
|
|
}
|
|
|
|
// Script status has its own subscription so a workspace screen can hold it without the directory.
|
|
private observeScriptStatusEvents(): void {
|
|
if (this.scriptStatusSubscription) return;
|
|
const { client, source } = this.requireOnline();
|
|
this.scriptStatusSubscription = client.observeEvents(["script_status_update"]);
|
|
this.scriptStatusSubscription.subscribe({
|
|
snapshot: () => {
|
|
for (const workspaceId of new Set(this.scriptStatusDemand.values()))
|
|
void this.fetchScriptStatus(workspaceId).catch(() => undefined);
|
|
},
|
|
update: (message) => {
|
|
if (!this.isCurrent(client, source) || message.type !== "script_status_update") return;
|
|
this.receiveWorkspaceDelta(source, { kind: "script_status", update: message.payload });
|
|
},
|
|
});
|
|
}
|
|
|
|
private async fetchScriptStatus(workspaceId: string): Promise<void> {
|
|
const { client, source } = this.requireOnline();
|
|
const result = await client.listWorkspaceScripts(workspaceId);
|
|
if (!this.isCurrent(client, source) || result.error || !result.scripts) return;
|
|
this.receiveWorkspaceDelta(source, {
|
|
kind: "script_status",
|
|
update: { workspaceId, scripts: result.scripts },
|
|
});
|
|
}
|
|
|
|
private hasDemand(): boolean {
|
|
return this.fullDemandSources.size > 0 || this.routeDemandIds.size > 0;
|
|
}
|
|
|
|
private wantsScriptStatus(): boolean {
|
|
return this.hasDemand() || this.scriptStatusDemand.size > 0;
|
|
}
|
|
|
|
private requestDemandRefresh(force = false): Promise<void> {
|
|
if (this.demandRefresh) return this.demandRefresh;
|
|
if (!this.hasDemand()) return Promise.resolve();
|
|
if (!this.getOnlineConnection()) {
|
|
return this.fullDemandSources.size > 0 ? this.loadCachedDirectory() : Promise.resolve();
|
|
}
|
|
this.observeDirectoryEvents();
|
|
this.observeScriptStatusEvents();
|
|
const source = this.connection.source;
|
|
if (
|
|
!force &&
|
|
this.satisfiedDemandSource?.clientGeneration === source.clientGeneration &&
|
|
this.satisfiedDemandSource.connectionEpoch === source.connectionEpoch
|
|
) {
|
|
return Promise.resolve();
|
|
}
|
|
const refresh =
|
|
this.fullDemandSources.size > 0
|
|
? this.refreshAll()
|
|
: Promise.all([
|
|
this.refreshAgentsInternal({ subscribe: {} }, false),
|
|
this.refreshWorkspacesInternal({ subscribe: true }, false),
|
|
]).then(() => undefined);
|
|
this.demandRefresh = refresh
|
|
.then(() => {
|
|
this.satisfiedDemandSource = source;
|
|
return undefined;
|
|
})
|
|
.finally(() => {
|
|
this.demandRefresh = null;
|
|
const current = this.connection.source;
|
|
if (
|
|
this.hasDemand() &&
|
|
(current.clientGeneration !== source.clientGeneration ||
|
|
current.connectionEpoch !== source.connectionEpoch)
|
|
) {
|
|
void this.requestDemandRefresh().catch(() => undefined);
|
|
}
|
|
});
|
|
return this.demandRefresh;
|
|
}
|
|
|
|
refreshDemand(): Promise<void> {
|
|
return this.requestDemandRefresh(true);
|
|
}
|
|
|
|
async loadCachedAgent(agentId: string): Promise<void> {
|
|
if (!this.checkpoints) return;
|
|
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
|
|
const token = this.agents.captureCache(agentId);
|
|
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
|
|
if (!agent) return;
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
if (!session || session.agents.has(agentId)) return;
|
|
this.agents.commitCachedAgent(token, agent);
|
|
}
|
|
|
|
async prepareAgentRoute(agentId: string): Promise<void> {
|
|
await this.loadCachedAgent(agentId);
|
|
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
|
|
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
|
|
}
|
|
|
|
async prepareWorkspaceRoute(workspaceId: string): Promise<void> {
|
|
await this.loadCachedWorkspace(workspaceId);
|
|
}
|
|
|
|
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
|
|
if (!this.checkpoints) return;
|
|
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
|
|
const revision = this.workspaceRevision;
|
|
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
|
|
if (!cached) return;
|
|
if (this.workspaceRevision !== revision) return;
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
if (!session) return;
|
|
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
|
|
}
|
|
|
|
private loadCachedDirectory(): Promise<void> {
|
|
const checkpoints = this.checkpoints;
|
|
if (!checkpoints) return Promise.resolve();
|
|
this.cacheLoad ??= (async () => {
|
|
const revision = this.revision;
|
|
const cached = await checkpoints.readDirectory(this.serverId);
|
|
if (this.cacheAccepted) return;
|
|
if (!useSessionStore.getState().sessions[this.serverId]) return;
|
|
this.agents.commitCached(cached.agents);
|
|
this.workspaces.commitCached(cached);
|
|
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
|
|
this.cacheAccepted = true;
|
|
})();
|
|
return this.cacheLoad;
|
|
}
|
|
|
|
restoreCachedDirectory(): Promise<void> {
|
|
return this.loadCachedDirectory();
|
|
}
|
|
|
|
applyAgentTurnLiveness(
|
|
agentId: string,
|
|
transition: TurnLivenessTransition | readonly TurnLivenessTransition[],
|
|
): void {
|
|
this.agents.applyTurnLiveness(agentId, transition);
|
|
}
|
|
|
|
acceptAgent(agent: Agent): Agent {
|
|
return this.agents.accept(agent);
|
|
}
|
|
|
|
async fetchTimeline(
|
|
agentId: string,
|
|
request: Parameters<DaemonClient["fetchAgentTimeline"]>[1],
|
|
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
|
|
const { client } = this.requireOnline();
|
|
const token = this.agents.captureTimeline(agentId);
|
|
const page = await fetchAgentTimelineOnce(client, agentId, request);
|
|
if (page.agent && this.agents.submitTimelineAgent(token, page.agent)) {
|
|
this.revision += 1;
|
|
this.persistCheckpoint();
|
|
}
|
|
return page;
|
|
}
|
|
|
|
async refreshAgents(
|
|
input: RefreshAgentDirectoryInput = {},
|
|
): Promise<RefreshAgentDirectoryResult> {
|
|
return this.refreshAgentsInternal(input, true);
|
|
}
|
|
|
|
private async refreshAgentsInternal(
|
|
input: RefreshAgentDirectoryInput,
|
|
loadDirectoryCache: boolean,
|
|
): Promise<RefreshAgentDirectoryResult> {
|
|
if (loadDirectoryCache) {
|
|
this.primeAgentTransactionForCacheLoad();
|
|
await this.loadCachedDirectory();
|
|
}
|
|
const onlineConnection = this.getOnlineConnection();
|
|
if (!onlineConnection) {
|
|
this.callbacks.markAgentReady();
|
|
return {
|
|
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
|
|
subscriptionId: null,
|
|
};
|
|
}
|
|
const { client, source } = onlineConnection;
|
|
const transaction = this.agentTransactions.begin(source, () => ({
|
|
entries: [],
|
|
subscriptionId: null,
|
|
syncRemovals: [],
|
|
}));
|
|
this.callbacks.markAgentLoading();
|
|
try {
|
|
await this.waitForSession(client, source);
|
|
await this.fetchAgents(client, source, transaction, input);
|
|
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("agent completion no longer current");
|
|
}
|
|
const completion = this.agentTransactions.complete(transaction);
|
|
if (completion.kind === "stale") {
|
|
throw new DirectoryRefreshSupersededError("agent completion was superseded");
|
|
}
|
|
this.revision += 1;
|
|
const agents = this.commitAgentSnapshot(completion.snapshot, completion.deltas);
|
|
this.persistAgentCursors(completion.snapshot, completion.deltas);
|
|
this.persistCheckpoint();
|
|
this.callbacks.markAgentReady();
|
|
return { agents, subscriptionId: completion.snapshot.subscriptionId };
|
|
} catch (error) {
|
|
const deltas = this.agentTransactions.fail(transaction);
|
|
if (deltas) for (const delta of deltas) this.agents.applyDelta(delta);
|
|
if (!(error instanceof DirectoryRefreshSupersededError)) {
|
|
this.callbacks.markAgentError(error instanceof Error ? error.message : String(error));
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
async refreshWorkspaces(input?: { subscribe?: boolean }): Promise<void> {
|
|
return this.refreshWorkspacesInternal(input, true);
|
|
}
|
|
|
|
private async refreshWorkspacesInternal(
|
|
input: { subscribe?: boolean } | undefined,
|
|
loadDirectoryCache: boolean,
|
|
): Promise<void> {
|
|
if (loadDirectoryCache) await this.loadCachedDirectory();
|
|
const onlineConnection = this.getOnlineConnection();
|
|
if (!onlineConnection) return;
|
|
const { client, source } = onlineConnection;
|
|
const transaction = this.workspaceTransactions.begin(source, () => {
|
|
const baseline = this.workspaces.snapshot();
|
|
return {
|
|
workspaces: new Map(baseline.workspaces),
|
|
projects: new Map(baseline.projects),
|
|
// Cache hydration can advance the live replica while these requests await responses.
|
|
// Every page must use the cursors belonging to the maps captured here.
|
|
requestCursors: { ...this.cursors },
|
|
syncCursors: {},
|
|
syncModes: {},
|
|
touchedWorkspaceIds: new Set(),
|
|
touchedProjectIds: new Set(),
|
|
};
|
|
});
|
|
try {
|
|
await this.waitForSessionMetadata(client, source);
|
|
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
|
|
const supportsProjectList = serverInfo?.features?.projectList === true;
|
|
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
|
|
const projectsReady = supportsProjectList
|
|
? this.fetchProjectSnapshot(client, source, transaction, supportsDirectorySync)
|
|
: Promise.resolve();
|
|
await Promise.all([
|
|
projectsReady,
|
|
this.fetchWorkspaceSnapshot(
|
|
client,
|
|
source,
|
|
transaction,
|
|
input?.subscribe === true,
|
|
supportsDirectorySync,
|
|
projectsReady,
|
|
),
|
|
]);
|
|
if (!supportsProjectList) {
|
|
this.buildLegacyProjectSnapshot(transaction.snapshot);
|
|
(transaction.snapshot.syncModes ??= {}).projects =
|
|
transaction.snapshot.syncModes?.workspaces;
|
|
}
|
|
this.completeWorkspaceRefresh(client, source, transaction);
|
|
} catch (error) {
|
|
const deltas = this.workspaceTransactions.fail(transaction);
|
|
if (deltas) for (const delta of deltas) this.applyWorkspaceDelta(delta);
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
private async fetchWorkspaceSnapshot(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
|
initialSubscribe: boolean,
|
|
supportsDirectorySync: boolean,
|
|
projectsReady: Promise<void>,
|
|
): Promise<void> {
|
|
let cursor: string | null = null;
|
|
let subscribe = initialSubscribe;
|
|
while (true) {
|
|
const query: Parameters<DaemonClient["observeWorkspaces"]>[0] = {
|
|
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
|
|
sort: [{ key: "activity_at", direction: "desc" }],
|
|
page: cursor ? { limit: PAGE_LIMIT, cursor } : { limit: PAGE_LIMIT },
|
|
...(supportsDirectorySync
|
|
? { sync: transaction.snapshot.requestCursors.workspaces ?? {} }
|
|
: {}),
|
|
};
|
|
let payload: FetchWorkspacesPayload;
|
|
if (subscribe) {
|
|
void this.workspaceSubscription?.release().catch(() => undefined);
|
|
const subscription = client.observeWorkspaces(query);
|
|
this.workspaceSubscription = subscription;
|
|
subscription.subscribe({
|
|
snapshot: () => {},
|
|
update: (message) => {
|
|
if (message.type === "workspace_update" && this.isCurrent(client, source))
|
|
this.receiveWorkspaceDelta(source, message.payload);
|
|
},
|
|
});
|
|
payload = await subscription.ready;
|
|
} else payload = await client.fetchWorkspaces(query);
|
|
// Fetch both directories together, but apply the project baseline before the
|
|
// workspace page's empty-project overlays, regardless of response order.
|
|
await projectsReady;
|
|
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
|
applyWorkspaceSnapshotPage(transaction.snapshot, payload, cursor === null);
|
|
if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) return;
|
|
cursor = payload.pageInfo.nextCursor;
|
|
subscribe = false;
|
|
}
|
|
}
|
|
|
|
private buildLegacyProjectSnapshot(snapshot: WorkspaceDirectorySnapshot): void {
|
|
for (const workspace of snapshot.workspaces.values()) {
|
|
if (!snapshot.projects.has(workspace.projectId)) {
|
|
const project = legacyProjectDescriptorFromWorkspace(workspace);
|
|
snapshot.projects.set(project.projectId, project);
|
|
}
|
|
}
|
|
}
|
|
|
|
async refreshAll(): Promise<void> {
|
|
await Promise.all([
|
|
this.refreshAgents({ subscribe: {} }),
|
|
this.refreshWorkspaces({ subscribe: true }),
|
|
]);
|
|
this.checkpoints?.replaceDirectoryBaseline?.(this.serverId, {
|
|
agents: this.agents.snapshot(),
|
|
...this.workspaces.snapshot(),
|
|
checkpoint: this.cursors,
|
|
});
|
|
if (this.getOnlineConnection()) await this.connectWorkspaceLabels();
|
|
}
|
|
|
|
async connectWorkspaceLabels(): Promise<void> {
|
|
const { client } = this.requireOnline();
|
|
const serverInfo = client.getLastServerInfoMessage();
|
|
await workspaceLabels.connect({
|
|
serverId: this.serverId,
|
|
client,
|
|
supportsWorkspaceLabels: serverInfo?.features?.workspaceLabels === true,
|
|
});
|
|
}
|
|
|
|
acceptWorkspaces(workspaces: readonly WorkspaceDescriptor[]): void {
|
|
const mutations = this.workspaces.acceptWorkspaces(workspaces);
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
}
|
|
|
|
archiveAgent(agentId: string, archivedAt: string): void {
|
|
this.agents.archive(agentId, archivedAt);
|
|
}
|
|
|
|
removeAgent(agentId: string): void {
|
|
this.agents.remove(agentId);
|
|
}
|
|
|
|
acceptProject(project: ProjectDescriptor): void {
|
|
const mutations = this.workspaces.acceptProject(project);
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
}
|
|
|
|
removeWorkspace(workspaceId: string): void {
|
|
const mutations = this.workspaces.removeWorkspaceSnapshot(workspaceId);
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
}
|
|
|
|
markWorkspacesHydrated(hydrated: boolean): void {
|
|
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
|
|
}
|
|
|
|
private async fetchAgents(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
|
|
input: RefreshAgentDirectoryInput,
|
|
): Promise<void> {
|
|
let cursor = input.page?.cursor ?? null;
|
|
let subscribe = input.subscribe;
|
|
while (true) {
|
|
const limit = input.page?.limit ?? PAGE_LIMIT;
|
|
const query: Omit<FetchAgentsOptions, "subscribe"> = {
|
|
...(input.filter ? { filter: input.filter } : { scope: "active" as const }),
|
|
...(client.supportsBackgroundWorkspaces() ? { includeBackground: true } : {}),
|
|
sort: AGENT_SORT,
|
|
page: cursor ? { limit, cursor } : { limit },
|
|
...(!input.filter && cursor === null && this.supportsDirectorySync()
|
|
? { sync: this.readCursors().agents ?? {} }
|
|
: {}),
|
|
};
|
|
let payload: FetchAgentsPayload;
|
|
if (subscribe) {
|
|
if (subscribe.subscriptionId) throw new Error("Subscription IDs are assigned by the host");
|
|
void this.agentSubscription?.release().catch(() => undefined);
|
|
const subscription = client.observeAgents(query);
|
|
this.agentSubscription = subscription;
|
|
subscription.subscribe({
|
|
snapshot: () => {},
|
|
update: (message) => {
|
|
if (message.type === "agent_update" && this.isCurrent(client, source))
|
|
this.receiveAgentDelta(source, message.payload);
|
|
},
|
|
});
|
|
payload = await subscription.ready;
|
|
} else payload = await client.fetchAgents(query);
|
|
this.assertAgentTransactionCurrent(client, source, transaction);
|
|
transaction.snapshot.entries.push(...payload.entries);
|
|
transaction.snapshot.subscriptionId ??= payload.subscriptionId ?? null;
|
|
this.recordAgentSync(transaction.snapshot, payload.sync);
|
|
const { hasMore, nextCursor } = resolveAgentNextPage(payload.pageInfo as AgentPageInfo);
|
|
if (!hasMore || !nextCursor) break;
|
|
cursor = nextCursor;
|
|
subscribe = undefined;
|
|
}
|
|
}
|
|
|
|
private assertAgentTransactionCurrent(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<AgentSnapshot, AgentDirectoryDelta>,
|
|
): void {
|
|
if (!this.agentTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("agent page no longer current");
|
|
}
|
|
}
|
|
|
|
private assertWorkspaceTransactionCurrent(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
|
): void {
|
|
if (!this.workspaceTransactions.isCurrent(transaction) || !this.isCurrent(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("workspace fetch no longer current");
|
|
}
|
|
}
|
|
|
|
private requireOnline(): { client: DaemonClient; source: DirectorySourceToken } {
|
|
const connection = this.getOnlineConnection();
|
|
if (!connection) {
|
|
throw new Error(`Host ${this.serverId} is not connected`);
|
|
}
|
|
return connection;
|
|
}
|
|
|
|
private getOnlineConnection(): { client: DaemonClient; source: DirectorySourceToken } | null {
|
|
if (!this.connection.client || this.connection.status !== "online") return null;
|
|
return { client: this.connection.client, source: this.connection.source };
|
|
}
|
|
|
|
private primeAgentTransactionForCacheLoad(): void {
|
|
const connection = this.getOnlineConnection();
|
|
if (!connection) return;
|
|
this.agentTransactions.begin(connection.source, () => ({
|
|
entries: [],
|
|
subscriptionId: null,
|
|
syncRemovals: [],
|
|
}));
|
|
}
|
|
|
|
private supportsDirectorySync(): boolean {
|
|
return (
|
|
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
|
|
true
|
|
);
|
|
}
|
|
|
|
private commitAgentSnapshot(
|
|
snapshot: AgentSnapshot,
|
|
deltas: readonly AgentDirectoryDelta[],
|
|
): Map<string, Agent> {
|
|
return snapshot.syncMode === "changes"
|
|
? this.agents.commitChanges(snapshot.entries, snapshot.syncRemovals, deltas)
|
|
: this.agents.commitSnapshot(snapshot.entries, deltas);
|
|
}
|
|
|
|
private persistAgentCursors(
|
|
snapshot: AgentSnapshot,
|
|
deltas: readonly AgentDirectoryDelta[],
|
|
): void {
|
|
if (snapshot.syncCursor) this.writeCursor("agents", snapshot.syncCursor);
|
|
for (const delta of deltas) this.noteLiveCursor("agents", delta);
|
|
}
|
|
|
|
private async fetchProjectSnapshot(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
|
supportsDirectorySync: boolean,
|
|
): Promise<void> {
|
|
const payload = await client.listProjects(
|
|
supportsDirectorySync
|
|
? { sync: transaction.snapshot.requestCursors.projects ?? {} }
|
|
: undefined,
|
|
);
|
|
this.assertWorkspaceTransactionCurrent(client, source, transaction);
|
|
if (payload.sync?.mode !== "changes") transaction.snapshot.projects.clear();
|
|
(transaction.snapshot.syncModes ??= {}).projects = payload.sync?.mode ?? "snapshot";
|
|
for (const entry of payload.projects) {
|
|
const project = normalizeProjectDescriptor(entry);
|
|
transaction.snapshot.projects.set(project.projectId, project);
|
|
(transaction.snapshot.touchedProjectIds ??= new Set()).add(project.projectId);
|
|
}
|
|
for (const removal of payload.sync?.removals ?? []) {
|
|
transaction.snapshot.projects.delete(removal.id);
|
|
(transaction.snapshot.touchedProjectIds ??= new Set()).add(removal.id);
|
|
}
|
|
if (payload.sync) {
|
|
(transaction.snapshot.syncCursors ??= {}).projects = {
|
|
generation: payload.sync.generation,
|
|
afterSeq: payload.sync.headSeq,
|
|
};
|
|
}
|
|
}
|
|
|
|
private completeWorkspaceRefresh(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
transaction: DirectoryTransaction<WorkspaceRefreshSnapshot, WorkspaceDirectoryDelta>,
|
|
): void {
|
|
if (!this.isCurrent(client, source) || !this.hasMatchingSession(client, source)) {
|
|
throw new DirectoryRefreshSupersededError("workspace completion no longer current");
|
|
}
|
|
const completion = this.workspaceTransactions.complete(transaction);
|
|
if (completion.kind === "stale") {
|
|
throw new DirectoryRefreshSupersededError("workspace completion was superseded");
|
|
}
|
|
this.revision += 1;
|
|
this.workspaceRevision += 1;
|
|
const previous = this.readWorkspaceState();
|
|
const deltaMutations = this.workspaces.commitSnapshot(completion.snapshot, completion.deltas);
|
|
const next = this.workspaces.snapshot();
|
|
const workspaceIds =
|
|
completion.snapshot.syncModes?.workspaces === "changes"
|
|
? new Set(completion.snapshot.touchedWorkspaceIds)
|
|
: new Set([...previous.workspaces.keys(), ...next.workspaces.keys()]);
|
|
const projectIds =
|
|
completion.snapshot.syncModes?.projects === "changes"
|
|
? new Set(completion.snapshot.touchedProjectIds)
|
|
: new Set([...previous.projects.keys(), ...next.projects.keys()]);
|
|
for (const mutation of deltaMutations) {
|
|
if (mutation.kind === "workspace") workspaceIds.add(mutation.id);
|
|
if (mutation.kind === "project") projectIds.add(mutation.id);
|
|
}
|
|
this.persistWorkspaceChanges(previous, workspaceIds, projectIds);
|
|
for (const [entity, cursor] of Object.entries(completion.snapshot.syncCursors ?? {})) {
|
|
if (cursor) this.writeCursor(entity as "projects" | "workspaces", cursor);
|
|
}
|
|
for (const delta of completion.deltas) {
|
|
if (delta.kind === "script_status") continue;
|
|
const entity = "projectId" in delta || "project" in delta ? "projects" : "workspaces";
|
|
this.noteLiveCursor(entity, delta);
|
|
}
|
|
this.persistCheckpoint();
|
|
}
|
|
|
|
private persistCheckpoint(): void {
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, [], this.cursors);
|
|
}
|
|
|
|
private readWorkspaceState(): WorkspaceDirectorySnapshot {
|
|
return this.workspaces.snapshot();
|
|
}
|
|
|
|
private applyWorkspaceDelta(delta: WorkspaceDirectoryDelta): void {
|
|
const mutations = this.workspaces.applyDelta(delta);
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
}
|
|
|
|
private persistWorkspaceChanges(
|
|
previous: WorkspaceDirectorySnapshot,
|
|
workspaceIds: Set<string>,
|
|
projectIds: Set<string>,
|
|
): void {
|
|
const next = this.workspaces.snapshot();
|
|
const mutations: DirectoryReplicaMutation[] = [];
|
|
for (const id of workspaceIds) {
|
|
const value = next.workspaces.get(id);
|
|
if (value) mutations.push({ kind: "workspace", type: "upsert", id, value });
|
|
else if (previous.workspaces.has(id))
|
|
mutations.push({ kind: "workspace", type: "delete", id });
|
|
}
|
|
for (const id of projectIds) {
|
|
const value = next.projects.get(id);
|
|
if (value) mutations.push({ kind: "project", type: "upsert", id, value });
|
|
else if (previous.projects.has(id)) mutations.push({ kind: "project", type: "delete", id });
|
|
}
|
|
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
|
|
}
|
|
|
|
private recordAgentSync(
|
|
snapshot: AgentSnapshot,
|
|
sync: Awaited<ReturnType<DaemonClient["fetchAgents"]>>["sync"],
|
|
): void {
|
|
if (!sync) return;
|
|
snapshot.syncMode = sync.mode;
|
|
snapshot.syncRemovals.push(...sync.removals);
|
|
snapshot.syncCursor = { generation: sync.generation, afterSeq: sync.headSeq };
|
|
}
|
|
|
|
private noteLiveCursor(
|
|
entity: keyof DirectoryCheckpoint,
|
|
payload: { generation?: string; seq?: number },
|
|
): void {
|
|
if (!payload.generation || payload.seq === undefined) return;
|
|
this.writeCursor(entity, {
|
|
generation: payload.generation,
|
|
afterSeq: payload.seq,
|
|
});
|
|
}
|
|
|
|
private readCursors(): DirectoryCheckpoint {
|
|
return this.cursors;
|
|
}
|
|
|
|
private writeCursor(
|
|
entity: keyof DirectoryCheckpoint,
|
|
cursor: { generation: string; afterSeq: number },
|
|
): void {
|
|
const current = this.readCursors();
|
|
const previous = current[entity];
|
|
if (previous?.generation === cursor.generation && previous.afterSeq >= cursor.afterSeq) return;
|
|
this.cursors = { ...current, [entity]: cursor };
|
|
}
|
|
|
|
private isCurrent(client: DaemonClient, source: DirectorySourceToken): boolean {
|
|
return (
|
|
this.connection.client === client &&
|
|
this.connection.status === "online" &&
|
|
this.connection.source.clientGeneration === source.clientGeneration &&
|
|
this.connection.source.connectionEpoch === source.connectionEpoch
|
|
);
|
|
}
|
|
|
|
private async waitForSession(client: DaemonClient, source: DirectorySourceToken): Promise<void> {
|
|
await this.waitForSessionState(client, source, () => this.hasMatchingSession(client, source));
|
|
}
|
|
|
|
private async waitForSessionMetadata(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
): Promise<void> {
|
|
await this.waitForSessionState(client, source, () => {
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
|
|
});
|
|
}
|
|
|
|
private async waitForSessionState(
|
|
client: DaemonClient,
|
|
source: DirectorySourceToken,
|
|
matches: () => boolean,
|
|
): Promise<void> {
|
|
if (matches()) return;
|
|
await new Promise<void>((resolve, reject) => {
|
|
let settled = false;
|
|
let unsubscribe: () => void = () => undefined;
|
|
const finish = (result: "ready" | "aborted") => {
|
|
if (settled) return;
|
|
settled = true;
|
|
unsubscribe();
|
|
this.abortSessionWaits.delete(abort);
|
|
if (result === "ready") resolve();
|
|
else reject(new DirectoryRefreshSupersededError("session wait no longer current"));
|
|
};
|
|
const abort = () => finish("aborted");
|
|
const check = () => {
|
|
if (matches()) {
|
|
finish("ready");
|
|
} else if (!this.isCurrent(client, source)) {
|
|
finish("aborted");
|
|
}
|
|
};
|
|
this.abortSessionWaits.add(abort);
|
|
unsubscribe = useSessionStore.subscribe(check);
|
|
check();
|
|
});
|
|
}
|
|
|
|
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
|
|
const session = useSessionStore.getState().sessions[this.serverId];
|
|
return session?.client === client && session.clientGeneration === source.clientGeneration;
|
|
}
|
|
|
|
private flushAbortedTransactions(): void {
|
|
for (const delta of this.agentTransactions.abort()) this.agents.applyDelta(delta);
|
|
for (const delta of this.workspaceTransactions.abort()) this.applyWorkspaceDelta(delta);
|
|
}
|
|
|
|
private abortPendingSessionWaits(): void {
|
|
for (const abort of this.abortSessionWaits) abort();
|
|
}
|
|
}
|
|
|
|
function legacyProjectDescriptorFromWorkspace(workspace: WorkspaceDescriptor): ProjectDescriptor {
|
|
return {
|
|
projectId: workspace.projectId,
|
|
projectKey: null,
|
|
projectDisplayName: workspace.projectDisplayName,
|
|
projectCustomName: workspace.projectCustomName ?? null,
|
|
projectRootPath: workspace.projectRootPath,
|
|
projectKind: workspace.projectKind,
|
|
};
|
|
}
|
|
|
|
export class DirectoryRefreshSupersededError extends Error {}
|
|
|
|
export type { DirectorySourceToken } from "./transaction";
|