diff --git a/packages/app/src/components/workspace-setup-dialog.tsx b/packages/app/src/components/workspace-setup-dialog.tsx index a7b7d2d73..247e3699e 100644 --- a/packages/app/src/components/workspace-setup-dialog.tsx +++ b/packages/app/src/components/workspace-setup-dialog.tsx @@ -14,6 +14,7 @@ import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host- import { normalizeWorkspaceDescriptor, useSessionStore } from "@/stores/session-store"; import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { normalizeAgentSnapshot } from "@/utils/agent-snapshots"; +import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces"; import { encodeImages } from "@/utils/encode-images"; import { toErrorMessage } from "@/utils/error-messages"; import { splitComposerAttachmentsForSubmit } from "@/composer/attachments/submit"; @@ -322,7 +323,13 @@ export function WorkspaceSetupDialog() { setAgents(serverId, (previous) => { const next = new Map(previous); - next.set(agent.id, normalizeAgentSnapshot(agent, serverId)); + next.set( + agent.id, + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: normalizeAgentSnapshot(agent, serverId), + }), + ); return next; }); navigateAfterCreation(ensuredWorkspace.id, { kind: "agent", agentId: agent.id }); diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 5ebe03f85..5d9f0199f 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -72,6 +72,10 @@ import { isNative } from "@/constants/platform"; import { useToast } from "@/contexts/toast-context"; import { toErrorMessage } from "@/utils/error-messages"; import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache"; +import { + applyLegacyDaemonWorkspaceOwnership, + backfillLegacyDaemonWorkspaceDirectoryIfEmpty, +} from "@/workspace/legacy-daemon-workspaces"; // Re-export types from session-store and draft-store for backward compatibility export type { DraftInput } from "@/stores/draft-store"; @@ -114,6 +118,56 @@ interface BufferedAudioChunk { id: string; } +interface WorkspaceHydrationSnapshot { + workspaces: Map; + emptyProjects: Map; +} + +async function fetchWorkspaceHydrationSnapshot(input: { + client: DaemonClient; + serverId: string; + subscribe: boolean; + isCancelled?: () => boolean; +}): Promise { + const workspaces = new Map(); + const emptyProjects = new Map(); + let cursor: string | null = null; + let includeSubscribe = input.subscribe; + + while (true) { + const payload = await input.client.fetchWorkspaces({ + sort: [{ key: "activity_at", direction: "desc" }], + ...(includeSubscribe ? { subscribe: {} } : {}), + page: cursor ? { limit: 200, cursor } : { limit: 200 }, + }); + if (input.isCancelled?.()) { + return null; + } + + for (const entry of payload.entries) { + const workspace = normalizeWorkspaceDescriptor(entry); + if (shouldSuppressWorkspaceForLocalArchive({ serverId: input.serverId, workspace })) { + continue; + } + workspaces.set(workspace.id, workspace); + } + + // Empty project parents only ride on the first page. + for (const project of payload.emptyProjects ?? []) { + const descriptor = normalizeEmptyProjectDescriptor(project); + emptyProjects.set(descriptor.projectId, descriptor); + } + + if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { + break; + } + cursor = payload.pageInfo.nextCursor; + includeSubscribe = false; + } + + return { workspaces, emptyProjects }; +} + function decodeBase64Chunk(base64: string): Uint8Array { return Buffer.from(base64, "base64"); } @@ -543,48 +597,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider return; } - const workspaces = new Map(); - const emptyProjects = new Map(); - let cursor: string | null = null; - let includeSubscribe = options?.subscribe ?? false; - - while (true) { - const payload = await client.fetchWorkspaces({ - sort: [{ key: "activity_at", direction: "desc" }], - ...(includeSubscribe ? { subscribe: {} } : {}), - page: cursor ? { limit: 200, cursor } : { limit: 200 }, - }); - if (options?.isCancelled?.()) { - return; - } - - for (const entry of payload.entries) { - const workspace = normalizeWorkspaceDescriptor(entry); - if (shouldSuppressWorkspaceForLocalArchive({ serverId, workspace })) { - continue; - } - workspaces.set(workspace.id, workspace); - } - - // Empty project parents only ride on the first page. - for (const project of payload.emptyProjects ?? []) { - const descriptor = normalizeEmptyProjectDescriptor(project); - emptyProjects.set(descriptor.projectId, descriptor); - } - - if (!payload.pageInfo.hasMore || !payload.pageInfo.nextCursor) { - break; - } - cursor = payload.pageInfo.nextCursor; - includeSubscribe = false; - } - - if (options?.isCancelled?.()) { + const snapshot = await fetchWorkspaceHydrationSnapshot({ + client, + serverId, + subscribe: options?.subscribe ?? false, + isCancelled: options?.isCancelled, + }); + if (!snapshot || options?.isCancelled?.()) { return; } - setWorkspaces(serverId, workspaces); - setEmptyProjects(serverId, emptyProjects.values()); + const didBackfillLegacy = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ + client, + serverId, + workspaces: snapshot.workspaces, + emptyProjects: snapshot.emptyProjects, + isCancelled: options?.isCancelled, + }); + if (didBackfillLegacy) { + return; + } + + setWorkspaces(serverId, snapshot.workspaces); + setEmptyProjects(serverId, snapshot.emptyProjects.values()); setHasHydratedWorkspaces(serverId, true); }, [client, isConnected, serverId, setEmptyProjects, setHasHydratedWorkspaces, setWorkspaces], @@ -1044,13 +1079,16 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } const normalized = normalizeAgentSnapshot(update.agent, serverId); - const agent = { - ...normalized, - projectPlacement: resolveProjectPlacement({ - projectPlacement: update.project, - cwd: normalized.cwd, - }), - }; + const agent = applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: { + ...normalized, + projectPlacement: resolveProjectPlacement({ + projectPlacement: update.project, + cwd: normalized.cwd, + }), + }, + }); applyAuthoritativeAgentSnapshot(agent); }, @@ -1110,10 +1148,15 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider if (payload.agent) { const normalized = normalizeAgentSnapshot(payload.agent, serverId); - applyAuthoritativeAgentSnapshot({ - ...normalized, - projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, - }); + applyAuthoritativeAgentSnapshot( + applyLegacyDaemonWorkspaceOwnership({ + serverId, + agent: { + ...normalized, + projectPlacement: session?.agents.get(agentId)?.projectPlacement ?? null, + }, + }), + ); } // Call pure reducer diff --git a/packages/app/src/panels/agent-panel.tsx b/packages/app/src/panels/agent-panel.tsx index a582b92b9..1c52f15cd 100644 --- a/packages/app/src/panels/agent-panel.tsx +++ b/packages/app/src/panels/agent-panel.tsx @@ -83,6 +83,7 @@ import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; import { getInitDeferred, 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"; import { navigateToAgent } from "@/utils/navigate-to-agent"; import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; @@ -238,10 +239,13 @@ function storeFetchedAgentDetail(input: { result: NonNullable; }): Agent { const normalized = normalizeAgentSnapshot(input.result.agent, input.serverId); - const hydrated: Agent = { - ...normalized, - projectPlacement: input.result.project, - }; + const hydrated: Agent = applyLegacyDaemonWorkspaceOwnership({ + serverId: input.serverId, + agent: { + ...normalized, + projectPlacement: input.result.project, + }, + }); const store = useSessionStore.getState(); if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) { @@ -345,9 +349,10 @@ function DraftPanel() { const handleCreated = useCallback( (agentSnapshot: Parameters[0]) => { const normalized = normalizeAgentSnapshot(agentSnapshot, serverId); + const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized }); useSessionStore.getState().setAgents(serverId, (prev) => { const next = new Map(prev); - next.set(agentSnapshot.id, normalized); + next.set(agentSnapshot.id, agent); return next; }); retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id }); diff --git a/packages/app/src/runtime/host-runtime.test.ts b/packages/app/src/runtime/host-runtime.test.ts index b76e85fdf..6f9468966 100644 --- a/packages/app/src/runtime/host-runtime.test.ts +++ b/packages/app/src/runtime/host-runtime.test.ts @@ -1264,6 +1264,83 @@ describe("HostRuntimeStore", () => { store.syncHosts([]); }); + it("bootstraps legacy daemons from unscoped agents and creates path-backed workspaces", async () => { + const host = makeHost({ + serverId: "srv_legacy_workspace_daemon", + connections: [ + { + id: "direct:lan:6767", + type: "directTcp", + endpoint: "lan:6767", + }, + ], + }); + const fakeClient = new FakeDaemonClient(); + fakeClient.setConnectionState({ status: "connected" }); + fakeClient.fetchAgentsResponses.push( + makeFetchAgentsPayload({ + entries: [ + makeFetchAgentsEntry({ + id: "agent-legacy", + cwd: "/repo/legacy-app", + updatedAt: "2026-06-18T12:00:00.000Z", + title: "Legacy daemon agent", + }), + ], + subscriptionId: "app:srv_legacy_workspace_daemon", + }), + ); + const store = new HostRuntimeStore({ + deps: { + createClient: () => fakeClient as unknown as DaemonClient, + connectToDaemon: async ({ host: hostProfile }) => ({ + client: fakeClient as unknown as DaemonClient, + serverId: hostProfile.serverId, + hostname: hostProfile.label ?? null, + }), + getClientId: async () => "cid_test_runtime", + }, + }); + + const sessionStore = useSessionStore.getState(); + sessionStore.initializeSession(host.serverId, fakeClient as unknown as DaemonClient); + sessionStore.updateSessionServerInfo(host.serverId, { + serverId: host.serverId, + hostname: null, + version: "0.1.96", + }); + store.syncHosts([host]); + + const timeoutAt = Date.now() + 300; + while ( + (fakeClient.fetchAgentsCalls.length === 0 || + !useSessionStore.getState().sessions[host.serverId]?.workspaces.has("/repo/legacy-app")) && + Date.now() < timeoutAt + ) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + expect(fakeClient.fetchAgentsCalls).toEqual([ + { + sort: [{ key: "updated_at", direction: "desc" }], + subscribe: { subscriptionId: "app:srv_legacy_workspace_daemon" }, + page: { limit: 200 }, + }, + ]); + const session = useSessionStore.getState().sessions[host.serverId]; + expect(session?.agents.get("agent-legacy")?.workspaceId).toBe("/repo/legacy-app"); + expect(Array.from(session?.workspaces.values() ?? [])).toEqual([ + expect.objectContaining({ + id: "/repo/legacy-app", + workspaceDirectory: "/repo/legacy-app", + name: "legacy-app", + }), + ]); + + store.syncHosts([]); + useSessionStore.getState().clearSession(host.serverId); + }); + it("fetches all pages during bootstrap within the active agent scope", async () => { const host = makeHost({ serverId: "srv_paged", diff --git a/packages/app/src/runtime/host-runtime.ts b/packages/app/src/runtime/host-runtime.ts index 62da522ee..672642fdb 100644 --- a/packages/app/src/runtime/host-runtime.ts +++ b/packages/app/src/runtime/host-runtime.ts @@ -38,6 +38,10 @@ import { } from "@/desktop/daemon/desktop-daemon-transport"; import { replaceFetchedAgentDirectory } from "@/utils/agent-directory-sync"; import { useSessionStore } from "@/stores/session-store"; +import { + fetchLegacyDaemonWorkspaceDirectory, + shouldUseLegacyDaemonWorkspaceDirectory, +} from "@/workspace/legacy-daemon-workspaces"; import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys"; import { queryClient } from "@/query/query-client"; @@ -187,6 +191,54 @@ function readFetchAgentsNextCursor( return null; } +interface AgentDirectoryFetchInput { + client: DaemonClient; + filter?: FetchAgentsOptions["filter"]; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +} + +interface AgentDirectoryFetchResult { + entries: FetchAgentsEntry[]; + subscriptionId: string | null; +} + +async function fetchCurrentAgentDirectory( + input: AgentDirectoryFetchInput, +): Promise { + const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; + let cursor = input.page?.cursor ?? null; + let includeSubscribe = true; + let subscriptionId: string | null = null; + const entries: FetchAgentsEntry[] = []; + + while (true) { + const payload = await input.client.fetchAgents({ + scope: input.filter ? undefined : "active", + ...(input.filter ? { filter: input.filter } : {}), + sort: DEFAULT_AGENT_DIRECTORY_SORT, + ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), + page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, + }); + + entries.push(...payload.entries); + subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; + includeSubscribe = false; + + if (!readFetchAgentsHasMore(payload.pageInfo)) { + break; + } + + const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return { entries, subscriptionId }; +} + function toActiveConnection(connection: HostConnection): ActiveConnection { if (connection.type === "directSocket") { return { @@ -1912,46 +1964,37 @@ export class HostRuntimeStore { controller.markAgentDirectorySyncLoading(); try { - const pageLimit = input.page?.limit ?? DEFAULT_AGENT_DIRECTORY_PAGE_LIMIT; - let cursor = input.page?.cursor ?? null; - let includeSubscribe = true; - let subscriptionId: string | null = null; - const allEntries: FetchAgentsEntry[] = []; - - while (true) { - const payload = await client.fetchAgents({ - scope: input.filter ? undefined : "active", - ...(input.filter ? { filter: input.filter } : {}), - sort: DEFAULT_AGENT_DIRECTORY_SORT, - ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), - page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, + const session = useSessionStore.getState().sessions[input.serverId]; + if (!input.filter && shouldUseLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + const result = await fetchLegacyDaemonWorkspaceDirectory({ + client, + serverId: input.serverId, + subscribe: input.subscribe, + page: input.page, }); - - allEntries.push(...payload.entries); - - subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; - includeSubscribe = false; - - if (!readFetchAgentsHasMore(payload.pageInfo)) { - break; - } - - const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); - if (!nextCursor) { - break; - } - cursor = nextCursor; + controller.markAgentDirectorySyncReady(); + return { + agents: result.agents, + subscriptionId: result.subscriptionId, + }; } + const directory = await fetchCurrentAgentDirectory({ + client, + filter: input.filter, + subscribe: input.subscribe, + page: input.page, + }); + const { agents } = replaceFetchedAgentDirectory({ serverId: input.serverId, - entries: allEntries, + entries: directory.entries, }); controller.markAgentDirectorySyncReady(); return { agents, - subscriptionId, + subscriptionId: directory.subscriptionId, }; } catch (error) { controller.markAgentDirectorySyncError(toErrorMessage(error)); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.test.ts b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts new file mode 100644 index 000000000..43f4ef563 --- /dev/null +++ b/packages/app/src/workspace/legacy-daemon-workspaces.test.ts @@ -0,0 +1,199 @@ +import { afterEach, describe, expect, it } from "vitest"; +import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client"; +import { useSessionStore, type Agent } from "@/stores/session-store"; +import { deriveWorkspaceAgentVisibility } from "@/workspace-tabs/agent-visibility"; +import { + applyLegacyDaemonWorkspaceOwnership, + backfillLegacyDaemonWorkspaceDirectoryIfEmpty, + buildLegacyDaemonWorkspaceSnapshot, +} from "./legacy-daemon-workspaces"; + +const SERVER_ID = "srv_legacy"; + +function legacyAgent(input: { + id: string; + cwd: string; + status?: FetchAgentsEntry["agent"]["status"]; + updatedAt?: string; +}): FetchAgentsEntry { + const updatedAt = input.updatedAt ?? "2026-06-18T10:00:00.000Z"; + return { + agent: { + id: input.id, + provider: "mock", + cwd: input.cwd, + model: null, + createdAt: updatedAt, + updatedAt, + lastUserMessageAt: null, + status: input.status ?? "idle", + capabilities: { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, + }, + currentModeId: null, + availableModes: [], + pendingPermissions: [], + persistence: null, + title: null, + labels: {}, + }, + project: { + projectKey: "/repo", + projectName: "repo", + workspaceName: "app", + checkout: { + cwd: input.cwd, + isGit: true, + currentBranch: "main", + remoteUrl: "git@example.com:repo/app.git", + worktreeRoot: input.cwd, + isPaseoOwnedWorktree: false, + mainRepoRoot: "/repo", + }, + }, + }; +} + +function getSnapshotAgent(snapshot: { agents: Map }, agentId: string): Agent { + const agent = snapshot.agents.get(agentId); + if (!agent) { + throw new Error(`test agent missing: ${agentId}`); + } + return agent; +} + +afterEach(() => { + useSessionStore.getState().clearSession(SERVER_ID); +}); + +describe("buildLegacyDaemonWorkspaceSnapshot", () => { + it("creates path-backed workspace rows and stamps legacy agents with their workspace id", () => { + const snapshot = buildLegacyDaemonWorkspaceSnapshot({ + serverId: SERVER_ID, + entries: [ + legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" }), + legacyAgent({ id: "agent-idle", cwd: "/repo/app", status: "idle" }), + ], + }); + + expect(Array.from(snapshot.workspaces.values())).toEqual([ + expect.objectContaining({ + id: "/repo/app", + projectId: "/repo", + projectDisplayName: "repo", + projectRootPath: "/repo", + workspaceDirectory: "/repo/app", + projectKind: "git", + workspaceKind: "checkout", + name: "app", + status: "running", + scripts: [], + }), + ]); + expect( + Array.from(snapshot.agents.values()).map((agent) => ({ + id: agent.id, + serverId: agent.serverId, + cwd: agent.cwd, + workspaceId: agent.workspaceId, + })), + ).toEqual([ + { + id: "agent-running", + serverId: SERVER_ID, + cwd: "/repo/app", + workspaceId: "/repo/app", + }, + { + id: "agent-idle", + serverId: SERVER_ID, + cwd: "/repo/app", + workspaceId: "/repo/app", + }, + ]); + }); + + it("keeps old-daemon agent updates attached to the path-backed workspace", () => { + const snapshot = buildLegacyDaemonWorkspaceSnapshot({ + serverId: SERVER_ID, + entries: [legacyAgent({ id: "agent-running", cwd: "/repo/app", status: "running" })], + }); + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + store.updateSessionServerInfo(SERVER_ID, { + serverId: SERVER_ID, + hostname: null, + version: "0.1.96", + }); + store.setWorkspaces(SERVER_ID, snapshot.workspaces); + store.setAgents(SERVER_ID, snapshot.agents); + + const existingAgent = getSnapshotAgent(snapshot, "agent-running"); + const oldDaemonUpdate: Agent = { + ...existingAgent, + workspaceId: undefined, + updatedAt: new Date("2026-06-18T10:01:00.000Z"), + lastActivityAt: new Date("2026-06-18T10:01:00.000Z"), + }; + + const stampedUpdate = applyLegacyDaemonWorkspaceOwnership({ + serverId: SERVER_ID, + agent: oldDaemonUpdate, + }); + const visibility = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([[stampedUpdate.id, stampedUpdate]]), + workspaceId: "/repo/app", + }); + + expect(stampedUpdate.workspaceId).toBe("/repo/app"); + expect(visibility.activeAgentIds).toEqual(new Set(["agent-running"])); + }); + + it("does not backfill path-backed workspaces after hydration is cancelled", async () => { + const store = useSessionStore.getState(); + store.initializeSession(SERVER_ID, null as unknown as DaemonClient); + store.updateSessionServerInfo(SERVER_ID, { + serverId: SERVER_ID, + hostname: null, + version: "0.1.96", + }); + let cancelled = false; + let didFetchAgents = false; + const client: Pick = { + fetchAgents: async () => { + didFetchAgents = true; + cancelled = true; + return { + requestId: "req_cancelled_backfill", + subscriptionId: null, + entries: [legacyAgent({ id: "agent-cancelled", cwd: "/repo/app" })], + pageInfo: { + nextCursor: null, + prevCursor: null, + hasMore: false, + }, + }; + }, + }; + + const didBackfill = await backfillLegacyDaemonWorkspaceDirectoryIfEmpty({ + client, + serverId: SERVER_ID, + workspaces: new Map(), + emptyProjects: new Map(), + isCancelled: () => cancelled, + }); + + const session = useSessionStore.getState().sessions[SERVER_ID]; + expect(didFetchAgents).toBe(true); + expect(didBackfill).toBe(true); + expect(session?.agents.size).toBe(0); + expect(session?.workspaces.size).toBe(0); + expect(session?.hasHydratedWorkspaces).toBe(false); + }); +}); diff --git a/packages/app/src/workspace/legacy-daemon-workspaces.ts b/packages/app/src/workspace/legacy-daemon-workspaces.ts new file mode 100644 index 000000000..23de643b3 --- /dev/null +++ b/packages/app/src/workspace/legacy-daemon-workspaces.ts @@ -0,0 +1,392 @@ +import type { + DaemonClient, + FetchAgentsEntry, + FetchAgentsOptions, +} from "@getpaseo/client/internal/daemon-client"; +import { + deriveAgentStateBucket, + getWorkspaceStateBucketPriority, +} from "@getpaseo/protocol/agent-state-bucket"; +import type { Agent, DaemonServerInfo, WorkspaceDescriptor } from "@/stores/session-store"; +import { useSessionStore } from "@/stores/session-store"; +import { + buildAgentDirectoryState, + replaceFetchedAgentDirectory, +} from "@/utils/agent-directory-sync"; +import { normalizeWorkspacePath } from "@/utils/workspace-identity"; + +export interface LegacyDaemonWorkspaceSnapshot { + agents: Map; + workspaces: Map; +} + +export interface LegacyDaemonWorkspaceFetchResult extends LegacyDaemonWorkspaceSnapshot { + subscriptionId: string | null; +} + +interface LegacyDaemonWorkspaceDirectoryReadResult { + entries: FetchAgentsEntry[]; + subscriptionId: string | null; +} + +interface LegacyDaemonWorkspaceBackfillInput { + client: Pick; + serverId: string; + workspaces: ReadonlyMap; + emptyProjects: ReadonlyMap; + isCancelled?: () => boolean; +} + +const LEGACY_AGENT_DIRECTORY_SORT: NonNullable = [ + { key: "updated_at", direction: "desc" }, +]; + +// COMPAT(legacyWorkspaceDaemon): v0.1.97 app talking to <=v0.1.96 daemons. +// Older daemons expose agents by cwd but may have no workspace registry rows. +// Keep all cwd -> synthetic workspace behavior in this file so the shim is +// deleted by removing this module and its call sites once the daemon floor is v0.1.97. +export function buildLegacyDaemonWorkspaceSnapshot(input: { + serverId: string; + entries: FetchAgentsEntry[]; +}): LegacyDaemonWorkspaceSnapshot { + const entries = stampLegacyWorkspaceIds(input.entries); + const { agents } = buildAgentDirectoryState({ + serverId: input.serverId, + entries, + }); + + return { + agents, + workspaces: buildLegacyWorkspaces(entries), + }; +} + +export function shouldUseLegacyDaemonWorkspaceDirectory( + serverInfo: DaemonServerInfo | null | undefined, +): boolean { + return ( + serverInfo !== null && + serverInfo !== undefined && + serverInfo.features?.workspaceMultiplicity !== true + ); +} + +function shouldBackfillLegacyDaemonWorkspaceDirectory( + serverInfo: DaemonServerInfo | null | undefined, +): boolean { + return serverInfo?.features?.workspaceMultiplicity !== true; +} + +export async function fetchLegacyDaemonWorkspaceDirectory(input: { + client: Pick; + serverId: string; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; +}): Promise { + const directory = await readLegacyDaemonWorkspaceDirectory(input); + if (!directory) { + throw new Error("Legacy daemon workspace directory fetch was cancelled."); + } + const snapshot = replaceLegacyDaemonWorkspaceDirectory({ + serverId: input.serverId, + entries: directory.entries, + }); + return { ...snapshot, subscriptionId: directory.subscriptionId }; +} + +export async function backfillLegacyDaemonWorkspaceDirectoryIfEmpty( + input: LegacyDaemonWorkspaceBackfillInput, +): Promise { + if (input.workspaces.size > 0 || input.emptyProjects.size > 0) { + return false; + } + const serverInfo = useSessionStore.getState().sessions[input.serverId]?.serverInfo; + if (!shouldBackfillLegacyDaemonWorkspaceDirectory(serverInfo)) { + return false; + } + if (input.isCancelled?.()) { + return true; + } + const directory = await readLegacyDaemonWorkspaceDirectory({ + client: input.client, + isCancelled: input.isCancelled, + }); + if (!directory || input.isCancelled?.()) { + return true; + } + replaceLegacyDaemonWorkspaceDirectory({ + serverId: input.serverId, + entries: directory.entries, + }); + return true; +} + +async function readLegacyDaemonWorkspaceDirectory(input: { + client: Pick; + subscribe?: FetchAgentsOptions["subscribe"]; + page?: FetchAgentsOptions["page"]; + isCancelled?: () => boolean; +}): Promise { + const entries: FetchAgentsEntry[] = []; + let cursor = input.page?.cursor ?? null; + let includeSubscribe = true; + let subscriptionId: string | null = null; + const pageLimit = input.page?.limit ?? 200; + + while (true) { + if (input.isCancelled?.()) { + return null; + } + + const payload = await input.client.fetchAgents({ + sort: LEGACY_AGENT_DIRECTORY_SORT, + ...(includeSubscribe && input.subscribe ? { subscribe: input.subscribe } : {}), + page: cursor ? { limit: pageLimit, cursor } : { limit: pageLimit }, + }); + if (input.isCancelled?.()) { + return null; + } + + entries.push(...payload.entries); + subscriptionId = subscriptionId ?? payload.subscriptionId ?? null; + includeSubscribe = false; + if (!readFetchAgentsHasMore(payload.pageInfo)) { + break; + } + const nextCursor = readFetchAgentsNextCursor(payload.pageInfo); + if (!nextCursor) { + break; + } + cursor = nextCursor; + } + + return { entries, subscriptionId }; +} + +export function applyLegacyDaemonWorkspaceOwnership(input: { + serverId: string; + agent: Agent; +}): Agent { + if (input.agent.workspaceId) { + return input.agent; + } + + const session = useSessionStore.getState().sessions[input.serverId]; + if (!shouldBackfillLegacyDaemonWorkspaceDirectory(session?.serverInfo)) { + return input.agent; + } + + const existingAgent = + session?.agents.get(input.agent.id) ?? session?.agentDetails.get(input.agent.id) ?? null; + const workspaceId = + existingAgent?.workspaceId ?? + resolveLegacyWorkspaceIdFromAgent(input.agent, session?.workspaces) ?? + null; + if (!workspaceId) { + return input.agent; + } + + return { + ...input.agent, + workspaceId, + projectPlacement: input.agent.projectPlacement ?? existingAgent?.projectPlacement, + }; +} + +function replaceLegacyDaemonWorkspaceDirectory(input: { + serverId: string; + entries: FetchAgentsEntry[]; +}): LegacyDaemonWorkspaceSnapshot { + const entries = stampLegacyWorkspaceIds(input.entries); + const { agents } = replaceFetchedAgentDirectory({ + serverId: input.serverId, + entries, + }); + const workspaces = buildLegacyWorkspaces(entries); + const store = useSessionStore.getState(); + store.setWorkspaces(input.serverId, workspaces); + store.setEmptyProjects(input.serverId, []); + store.setHasHydratedWorkspaces(input.serverId, true); + return { agents, workspaces }; +} + +function readFetchAgentsHasMore( + pageInfo: Awaited>["pageInfo"], +): boolean { + const page = pageInfo as { + hasMore?: boolean; + hasMoreAfter?: boolean; + }; + if (typeof page.hasMore === "boolean") { + return page.hasMore; + } + if (typeof page.hasMoreAfter === "boolean") { + return page.hasMoreAfter; + } + return false; +} + +function readFetchAgentsNextCursor( + pageInfo: Awaited>["pageInfo"], +): string | null { + const page = pageInfo as { + nextCursor?: string | null; + afterCursor?: string | null; + }; + if (typeof page.nextCursor === "string") { + return page.nextCursor; + } + if (typeof page.afterCursor === "string") { + return page.afterCursor; + } + return null; +} + +function stampLegacyWorkspaceIds(entries: FetchAgentsEntry[]): FetchAgentsEntry[] { + return entries.map((entry) => { + const workspaceId = resolveLegacyWorkspaceId(entry); + return { + ...entry, + agent: { + ...entry.agent, + workspaceId, + }, + }; + }); +} + +function buildLegacyWorkspaces(entries: FetchAgentsEntry[]): Map { + const workspaces = new Map(); + for (const entry of entries) { + const workspaceId = entry.agent.workspaceId ?? resolveLegacyWorkspaceId(entry); + const status = deriveAgentStateBucket({ + status: entry.agent.status, + pendingPermissionCount: entry.agent.pendingPermissions.length, + requiresAttention: entry.agent.requiresAttention, + attentionReason: entry.agent.attentionReason, + }); + const statusEnteredAt = parseLegacyAgentTimestamp(entry); + const existing = workspaces.get(workspaceId); + if (!existing) { + workspaces.set(workspaceId, createLegacyWorkspace(entry, status, statusEnteredAt)); + continue; + } + if ( + getWorkspaceStateBucketPriority(status) < getWorkspaceStateBucketPriority(existing.status) + ) { + workspaces.set(workspaceId, { + ...existing, + status, + statusEnteredAt, + }); + } + } + return workspaces; +} + +function createLegacyWorkspace( + entry: FetchAgentsEntry, + status: WorkspaceDescriptor["status"], + statusEnteredAt: Date | null, +): WorkspaceDescriptor { + const workspaceDirectory = resolveLegacyWorkspaceId(entry); + const checkout = entry.project.checkout; + const projectRootPath = + normalizeWorkspacePath(checkout.mainRepoRoot ?? checkout.worktreeRoot ?? checkout.cwd) ?? + workspaceDirectory; + return { + id: workspaceDirectory, + projectId: entry.project.projectKey, + projectDisplayName: entry.project.projectName, + projectCustomName: null, + projectRootPath, + workspaceDirectory, + projectKind: checkout.isGit ? "git" : "non_git", + workspaceKind: resolveLegacyWorkspaceKind(checkout), + name: resolveLegacyWorkspaceName(entry, workspaceDirectory), + title: null, + status, + statusEnteredAt, + archivingAt: null, + diffStat: null, + scripts: [], + gitRuntime: checkout.isGit + ? { + currentBranch: checkout.currentBranch, + remoteUrl: checkout.remoteUrl, + isPaseoOwnedWorktree: checkout.isPaseoOwnedWorktree, + isDirty: null, + aheadBehind: null, + aheadOfOrigin: null, + behindOfOrigin: null, + } + : null, + githubRuntime: null, + project: entry.project, + }; +} + +function resolveLegacyWorkspaceKind( + checkout: FetchAgentsEntry["project"]["checkout"], +): WorkspaceDescriptor["workspaceKind"] { + if (!checkout.isGit) { + return "directory"; + } + if (checkout.isPaseoOwnedWorktree) { + return "worktree"; + } + return "checkout"; +} + +function resolveLegacyWorkspaceId(entry: FetchAgentsEntry): string { + return ( + normalizeWorkspacePath(entry.project.checkout.cwd) ?? + normalizeWorkspacePath(entry.agent.cwd) ?? + entry.agent.cwd + ); +} + +function resolveLegacyWorkspaceIdFromAgent( + agent: Agent, + workspaces: Map | undefined, +): string | null { + const cwd = normalizeWorkspacePath(agent.cwd); + if (!cwd) { + return null; + } + + for (const workspace of workspaces?.values() ?? []) { + if (normalizeWorkspacePath(workspace.workspaceDirectory) === cwd) { + return workspace.id; + } + } + + return cwd; +} + +function resolveLegacyWorkspaceName(entry: FetchAgentsEntry, workspaceDirectory: string): string { + const explicitName = entry.project.workspaceName?.trim(); + if (explicitName) { + return explicitName; + } + const branchName = entry.project.checkout.currentBranch?.trim(); + if (branchName && branchName !== "HEAD") { + return branchName; + } + return workspaceDirectoryName(workspaceDirectory); +} + +function workspaceDirectoryName(directory: string): string { + const trimmed = directory.trim().replace(/[/]+$/g, ""); + const separator = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf("\\")); + return separator >= 0 ? trimmed.slice(separator + 1) : trimmed; +} + +function parseLegacyAgentTimestamp(entry: FetchAgentsEntry): Date | null { + const value = entry.agent.attentionTimestamp ?? entry.agent.updatedAt; + if (!value) { + return null; + } + const date = new Date(value); + return Number.isNaN(date.getTime()) ? null : date; +}