From 1c8c68f93e12812210ff969faea0b7052d9a015e Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 19 Oct 2026 18:34:53 +0700 Subject: [PATCH] Keep workspace setup logs scoped to the visible setup panel (#7407) * fix(workspaces): observe setup output only in the visible setup panel Setup logs were requested for the whole session, including unrelated workspaces. Use directory status for discovery and source-owned workspace observations for details, preserving legacy delivery only for clients without the negotiated capability. * fix(workspaces): publish external setup progress once per daemon MCP and scheduled setup enter through the broadcast API. Publish them to the shared setup owner before session fanout so each observation gets one update and snapshots survive periods without connected clients. Keep the setup action available on older hosts to explain the required host update. * fix(app): replace setup details with each observation snapshot A host restart can return an empty setup snapshot. Apply that authoritative snapshot so an open panel does not retain output from the previous daemon. Assert setup membership requests as well as delivery in the browser lifecycle regression. * test(workspaces): cover setup status catch-up after directory release * test(workspaces): synchronize setup output and release fixture resources --- docs/protocol-compatibility.md | 8 + .../browser/workspace-setup-streaming.spec.ts | 40 ++ .../support/helpers/daemon-client-loader.ts | 3 + .../e2e/support/helpers/workspace-setup.ts | 50 +++ .../command-center/workspace-registration.tsx | 13 +- packages/app/src/contexts/session-context.tsx | 22 -- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 2 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + packages/app/src/panels/setup-panel.tsx | 107 +++--- .../directory-sync/workspace-replica.test.ts | 36 ++ .../directory-sync/workspace-replica.ts | 10 + .../screens/workspace/workspace-screen.tsx | 38 +- packages/app/src/stores/session-store.ts | 3 + .../src/stores/workspace-setup-store.test.ts | 352 ++---------------- .../app/src/stores/workspace-setup-store.ts | 128 +++---- packages/client/src/connection/index.ts | 1 + packages/client/src/daemon-client.test.ts | 1 + packages/client/src/daemon-client.ts | 25 +- packages/protocol/src/client-capabilities.ts | 1 + packages/protocol/src/messages.ts | 21 ++ .../authorization/operation-permissions.ts | 2 + packages/server/src/server/session.ts | 99 ++++- .../src/server/session.workspaces.test.ts | 80 ++++ .../server/src/server/websocket-server.ts | 14 +- .../server/src/server/wire-compat.test.ts | 167 ++++++++- ...rkspace-create-worktree-source.e2e.test.ts | 250 +++++++++++++ .../src/server/workspace-setup-observation.ts | 56 +++ .../server/src/server/worktree-session.ts | 2 +- 35 files changed, 1018 insertions(+), 521 deletions(-) create mode 100644 packages/server/src/server/workspace-setup-observation.ts diff --git a/docs/protocol-compatibility.md b/docs/protocol-compatibility.md index a52b8f5064..e5ba1db2a3 100644 --- a/docs/protocol-compatibility.md +++ b/docs/protocol-compatibility.md @@ -74,6 +74,14 @@ The adapter keys legacy slots by physical socket, so an old connection cannot re sibling's observation even when both use the same logical client ID. Optional wire IDs stay accepted for parsing compatibility; modern requests cannot select their subscription ID. +Workspace setup details negotiate `workspace_setup_observation` with +`server_info.features.workspaceSetupObservation`. Capable clients cannot request details through +the legacy session event category. The setup panel owns a workspace-scoped observation while +active and visible; directory metadata signals failures without requesting output. Older clients +retain their legacy event delivery. A new app makes no setup event demand on older hosts and +requires a host update to view details. Subscription-capable older hosts honor that absence of +demand; broadcast-era hosts require an update before wire silence is guaranteed. + ## Every shim is tagged and dated A shim that exists for old-app or old-daemon support carries a comment naming it, the version it arrived in, and when it can go: diff --git a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts index 7fe8ce1814..227e2c8bc1 100644 --- a/packages/app/e2e/browser/workspace-setup-streaming.spec.ts +++ b/packages/app/e2e/browser/workspace-setup-streaming.spec.ts @@ -28,6 +28,7 @@ import { closeWorkspaceScriptsMenu, seedProjectForWorkspaceSetup, waitForWorkspaceSetupProgress, + observeSetupPanelTraffic, } from "../support/helpers/workspace-setup"; interface WorkspaceScriptStarter { @@ -116,6 +117,45 @@ test.describe("Workspace setup streaming", () => { } }); + test("setup panel releases details while retained behind another tab and resubscribes when shown", async ({ + page, + }) => { + const client = await connectWorkspaceSetupClient(); + const repo = await createTempGitRepo("setup-membership-", { + paseoConfig: { worktree: { setup: ["echo setup complete"] } }, + }); + const traffic = observeSetupPanelTraffic(page); + try { + await seedProjectForWorkspaceSetup(client, repo.path); + const completed = waitForWorkspaceSetupProgress( + client, + (payload) => payload.status === "completed", + ); + const workspace = await createWorkspaceThroughDaemon(client, { + cwd: repo.path, + worktreeSlug: "setup-membership", + }); + await completed; + await openHomeWithProject(page, repo.path); + await navigateToWorkspaceViaSidebar(page, workspace.id); + await expectSetupTabNotSeeded(page, workspace.id); + await traffic.expectNoDemand(); + await expectSetupPanel(page); + await traffic.expectSubscribed(workspace.id, 1); + await expect(page.getByTestId("workspace-setup-log").first()).toContainText("setup complete"); + await clickNewChat(page); + await expectComposerVisible(page); + await traffic.expectReleased(0); + await expectSetupPanel(page); + await traffic.expectSubscribed(workspace.id, 2); + await closeSetupTab(page, workspace.id); + await traffic.expectReleased(1); + } finally { + await client.close(); + await repo.cleanup(); + } + }); + daemonTest("streams running and completed setup snapshots for a successful setup", async () => { const client = await connectWorkspaceSetupClient(); const repo = await createTempGitRepo("setup-success-", { diff --git a/packages/app/e2e/support/helpers/daemon-client-loader.ts b/packages/app/e2e/support/helpers/daemon-client-loader.ts index eee8cdaffb..1e4df93958 100644 --- a/packages/app/e2e/support/helpers/daemon-client-loader.ts +++ b/packages/app/e2e/support/helpers/daemon-client-loader.ts @@ -24,6 +24,7 @@ interface E2EDaemonClientConfig { clientType: "cli"; appVersion?: string; webSocketFactory?: NodeWebSocketFactory; + capabilities?: Record; } function resolveDaemonWsUrl(port?: number): string { @@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions { clientIdPrefix: string; appVersion?: string; port?: number; + capabilities?: Record; } /** @@ -51,6 +53,7 @@ export async function connectDaemonClient { const client = await connectDaemonClient({ clientIdPrefix: "workspace-setup", + // COMPAT(workspaceSetupObservation): legacy streaming coverage; remove after 2027-04-19. + capabilities: { workspace_setup_observation: false }, }); // Establish demand before a caller can launch setup. Client close releases it. try { @@ -326,3 +328,51 @@ export async function waitForWorkspaceSetupProgress( }); }); } + +/** Observe the browser's actual setup membership without requesting any output. */ +export function observeSetupPanelTraffic(page: Page) { + const subscriptions: Array<{ workspaceId: string; subscriptionId: string }> = []; + const releases = new Set(); + const requests: string[] = []; + let legacySetupDemand = false; + const outputWorkspaces: string[] = []; + page.on("websocket", (socket) => { + socket.on("framesent", ({ payload }) => { + const frame = JSON.parse(String(payload)); + if (frame.type !== "session") return; + if (frame.message?.type === "subscription.release.request") + releases.add(frame.message.subscriptionId); + if (frame.message?.type === "workspace.setup.subscribe.request") + requests.push(frame.message.workspaceId); + if ( + frame.message?.type === "session.events.set_subscription.request" && + frame.message.events.includes("workspace_setup_progress") + ) + legacySetupDemand = true; + }); + socket.on("framereceived", ({ payload }) => { + const frame = JSON.parse(String(payload)); + if (frame.type !== "session") return; + if (frame.message?.type === "workspace.setup.subscribe.response") + subscriptions.push(frame.message.payload); + if (frame.message?.type === "workspace_setup_progress") + outputWorkspaces.push(frame.message.payload.workspaceId); + }); + }); + return { + async expectNoDemand() { + expect(requests).toEqual([]); + expect(legacySetupDemand).toBe(false); + expect(subscriptions).toEqual([]); + expect(outputWorkspaces).toEqual([]); + }, + async expectSubscribed(workspaceId: string, count: number) { + await expect.poll(() => subscriptions.length).toBe(count); + expect(subscriptions[count - 1].workspaceId).toBe(workspaceId); + expect(requests).toEqual(Array(count).fill(workspaceId)); + }, + async expectReleased(index: number) { + await expect.poll(() => releases.has(subscriptions[index].subscriptionId)).toBe(true); + }, + }; +} diff --git a/packages/app/src/command-center/workspace-registration.tsx b/packages/app/src/command-center/workspace-registration.tsx index 70789e5353..cd9d008c55 100644 --- a/packages/app/src/command-center/workspace-registration.tsx +++ b/packages/app/src/command-center/workspace-registration.tsx @@ -41,7 +41,7 @@ import { findPaneById, useWorkspaceLayoutStore, } from "@/stores/workspace-layout-store"; -import { shouldShowWorkspaceSetup, useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; +import { shouldShowWorkspaceSetup } from "@/stores/workspace-setup-store"; import { clearCommandCenterFocusRestoreElement } from "@/utils/command-center-focus-restore"; import { getShortcutOs } from "@/utils/shortcut-platform"; import { @@ -188,6 +188,7 @@ export function useWorkspaceCommandCenterActions(): void { workspaceDirectory: workspace.workspaceDirectory ?? null, currentBranch: workspace.gitRuntime?.currentBranch ?? null, pinnedAt: workspace.pinnedAt ?? null, + setupStatus: workspace.setupStatus ?? null, labels: workspace.labels ?? [], })); const cwd = useWorkspaceDirectory(serverId, workspaceId); @@ -195,12 +196,10 @@ export function useWorkspaceCommandCenterActions(): void { const isPinned = fields?.pinnedAt != null; const isCompact = useIsCompactFormFactor(); const canPin = useHostFeature(serverId, "workspacePinning"); - const persistenceKey = - serverId && fields - ? buildWorkspaceTabPersistenceKey({ serverId, workspaceId: fields.id }) - : null; - const canShowSetup = useWorkspaceSetupStore((state) => - shouldShowWorkspaceSetup(persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null), + const supportsSetupObservation = useHostFeature(serverId, "workspaceSetupObservation"); + const canShowSetup = shouldShowWorkspaceSetup( + fields?.setupStatus ?? null, + supportsSetupObservation, ); const { overrides } = useKeyboardShortcutOverrides(); const { gitActions, isGit } = useGitActions({ diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 430e4ade74..7190b42462 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -36,7 +36,6 @@ import { useSessionStore, type SessionState, } from "@/stores/session-store"; -import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { sendOsNotification } from "@/utils/os-notifications"; import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility"; import { @@ -168,11 +167,6 @@ function resolveAgentAttentionNotification( }); } -type WorkspaceSetupProgressPayload = Extract< - SessionOutboundMessage, - { type: "workspace_setup_progress" } ->["payload"]; - function notifyVoiceAbortFailure( data: Extract["payload"], notifyError: (message: string) => void, @@ -225,7 +219,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const setPendingPermissions = useSessionStore((state) => state.setPendingPermissions); const updateSessionServerInfo = useSessionStore((state) => state.updateSessionServerInfo); const setViewedTimelineSync = useSessionStore((state) => state.setViewedTimelineSync); - const upsertWorkspaceSetupProgress = useWorkspaceSetupStore((state) => state.upsertProgress); // Track focused agent for heartbeat const focusedAgentId = useSessionStore( @@ -365,13 +358,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider } }, [flushAgentLastActivity, serverId, isConnected, setInitializingAgents]); - const applyWorkspaceSetupProgress = useCallback( - (payload: WorkspaceSetupProgressPayload) => { - upsertWorkspaceSetupProgress({ serverId, payload }); - }, - [serverId, upsertWorkspaceSetupProgress], - ); - useEffect(() => { const setAgentInitializing = createSetAgentInitializing(serverId, setInitializingAgents); const onStream = (message: SessionOutboundMessage) => { @@ -508,7 +494,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider "agent_permission_resolved", "agent.provider_subagents.update", "checkout_status_update", - "workspace_setup_progress", "status.server_info", ], { notifications: true }, @@ -547,11 +532,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider applyCheckoutStatusUpdateFromEvent({ queryClient, serverId, message }); }); - const unsubWorkspaceSetupProgress = onFeed("workspace_setup_progress", (message) => { - if (message.type !== "workspace_setup_progress") return; - applyWorkspaceSetupProgress(message.payload); - }); - const unsubStatus = onFeed("status", (message) => { if (message.type !== "status") return; const serverInfo = parseServerInfoStatusPayload(message.payload); @@ -710,7 +690,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider unsubProviderSubagentUpdate(); unsubAgentAttention(); unsubCheckoutStatusUpdate(); - unsubWorkspaceSetupProgress(); unsubStatus(); unsubPermissionRequest(); unsubPermissionResolved(); @@ -732,7 +711,6 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider setAgents, setPendingPermissions, notifyAgentAttention, - applyWorkspaceSetupProgress, updateSessionServerInfo, toast, voiceRuntime, diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 74f43209e2..b7b81c66ee 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -538,6 +538,7 @@ export const ar: TranslationResources = { }, }, setup: { + updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل", descriptor: { label: "يثبت", completed: "اكتمل الإعداد", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 5aedaad115..d8a4081dda 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -535,6 +535,7 @@ export const en = { }, }, setup: { + updateHost: "Update the host to view workspace setup details", descriptor: { label: "Setup", completed: "Setup completed", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index a996fc97de..7fba1c22b9 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -544,6 +544,7 @@ export const es: TranslationResources = { }, }, setup: { + updateHost: "Actualiza el host para ver los detalles de configuración del espacio de trabajo", descriptor: { label: "Configuración", completed: "Configuración completada", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index 253b4135f9..54b6de6098 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -543,6 +543,8 @@ export const fr: TranslationResources = { }, }, setup: { + updateHost: + "Mettez à jour l’hôte pour afficher les détails de configuration de l’espace de travail", descriptor: { label: "Configuration", completed: "Configuration terminée", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index 7e22c7d5b9..31f1d4241f 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -544,6 +544,7 @@ export const ja: TranslationResources = { }, }, setup: { + updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください", descriptor: { label: "セットアップ", completed: "セットアップ完了", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index a6d8c44cbf..1214aa2435 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -539,6 +539,7 @@ export const ko: TranslationResources = { }, }, setup: { + updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요", descriptor: { label: "설정", completed: "설정 완료", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index ecda7818af..1a24fd1f83 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -543,6 +543,7 @@ export const ptBR: TranslationResources = { }, }, setup: { + updateHost: "Atualize o host para ver os detalhes da configuração do espaço de trabalho", descriptor: { label: "Configuração", completed: "Configuração concluída", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index e110866c52..3b444895e5 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -543,6 +543,7 @@ export const ru: TranslationResources = { }, }, setup: { + updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства", descriptor: { label: "Настройка", completed: "Настройка завершена", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index f60f1a385b..7b517fafb1 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -538,6 +538,7 @@ export const zhCN: TranslationResources = { }, }, setup: { + updateHost: "更新主机以查看工作区设置详情", descriptor: { label: "Setup", completed: "Setup 已完成", diff --git a/packages/app/src/panels/setup-panel.tsx b/packages/app/src/panels/setup-panel.tsx index 3648bf5a1c..7330c513ae 100644 --- a/packages/app/src/panels/setup-panel.tsx +++ b/packages/app/src/panels/setup-panel.tsx @@ -1,7 +1,7 @@ import { LoadingSpinner } from "@/components/ui/loading-spinner"; import { Alert } from "@/components/ui/alert"; import { Button } from "@/components/ui/button"; -import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { CheckCircle2, ChevronRight, CircleAlert, SquareTerminal } from "lucide-react-native"; import { useTranslation } from "react-i18next"; import { Pressable, type PressableStateCallbackType, ScrollView, Text, View } from "react-native"; @@ -17,6 +17,9 @@ import { type WorkspaceSetupSnapshot, } from "@/stores/workspace-setup-store"; import { useHostRuntimeClient } from "@/runtime/host-runtime"; +import { useWorkspaceFields } from "@/stores/session-store-hooks"; +import { useRetainedPanelActive } from "@/components/retained-panel"; +import { useAppActivelyVisible } from "@/hooks/use-app-visible"; import { useHostFeature } from "@/runtime/host-features"; function useSetupPanelDescriptor( @@ -24,13 +27,11 @@ function useSetupPanelDescriptor( context: { serverId: string; workspaceId: string }, ): PanelDescriptor { const { t } = useTranslation(); - const key = buildWorkspaceTabPersistenceKey({ - serverId: context.serverId, - workspaceId: target.workspaceId, - }); - const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); + const fields = useWorkspaceFields(context.serverId, target.workspaceId, (workspace) => ({ + status: workspace.setupStatus ?? null, + })); - if (snapshot?.status === "completed") { + if (fields?.status === "completed") { return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.completed"), @@ -41,7 +42,7 @@ function useSetupPanelDescriptor( }; } - if (snapshot?.status === "failed") { + if (fields?.status === "failed") { return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.failed"), @@ -52,7 +53,7 @@ function useSetupPanelDescriptor( }; } - if (snapshot?.status === "blocked") { + if (fields?.status === "blocked") { return { label: t("workspace.setup.descriptor.label"), subtitle: t("workspace.setup.descriptor.blocked"), @@ -69,7 +70,7 @@ function useSetupPanelDescriptor( tooltip: t("workspace.setup.descriptor.workspace"), titleState: "ready", icon: SquareTerminal, - statusBucket: snapshot?.status === "running" ? "running" : null, + statusBucket: fields?.status === "running" ? "running" : null, }; } @@ -216,33 +217,34 @@ function buildCommandRowState(args: BuildCommandRowPropsArgs) { return { hasError, hasLog, isExpandable, isAutoExpanded, showDetail, processedLog }; } -function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string) { +function useWorkspaceSetupSnapshot(serverId: string, workspaceId: string, enabled: boolean) { const client = useHostRuntimeClient(serverId); const key = buildWorkspaceTabPersistenceKey({ serverId, workspaceId }); const snapshot = useWorkspaceSetupStore((state) => (key ? (state.snapshots[key] ?? null) : null)); - const upsertProgress = useWorkspaceSetupStore((state) => state.upsertProgress); - const requestedRef = useRef(false); - + const applySnapshot = useWorkspaceSetupStore((state) => state.applySnapshot); + const [error, setError] = useState(null); useEffect(() => { - if (snapshot || requestedRef.current || !client) return; - requestedRef.current = true; - client - .fetchWorkspaceSetupStatus(workspaceId) - .then((response) => { - if (response.snapshot) { - upsertProgress({ - serverId, - payload: { workspaceId: response.workspaceId, ...response.snapshot }, - }); - } - return; - }) - .catch(() => { - // Server may not support this yet — ignore. - }); - }, [client, snapshot, serverId, upsertProgress, workspaceId]); - - return snapshot; + if (!client || !enabled) return; + setError(null); + const subscription = client.observeWorkspaceSetup(workspaceId); + const detach = subscription.subscribe({ + snapshot: (response) => { + applySnapshot({ serverId, workspaceId, snapshot: response.snapshot }); + }, + update: (message) => { + if (message.type === "workspace_setup_progress") + applySnapshot({ serverId, workspaceId, snapshot: message.payload }); + }, + error: (failure) => setError(failure instanceof Error ? failure.message : String(failure)), + }); + return () => { + detach(); + void subscription + .release() + .catch((failure) => console.warn("Failed to release setup observation", failure)); + }; + }, [client, enabled, serverId, applySnapshot, workspaceId]); + return { snapshot, error }; } function useExpandedSetupCommands() { @@ -273,7 +275,14 @@ function SetupPanel() { const { serverId, target } = usePaneContext(); invariant(target.kind === "setup", "SetupPanel requires setup target"); - const snapshot = useWorkspaceSetupSnapshot(serverId, target.workspaceId); + const isActive = useRetainedPanelActive(); + const isVisible = useAppActivelyVisible(); + const supportsObservation = useHostFeature(serverId, "workspaceSetupObservation"); + const { snapshot, error: observationError } = useWorkspaceSetupSnapshot( + serverId, + target.workspaceId, + isActive && isVisible && supportsObservation, + ); const commands = snapshot?.detail.commands ?? EMPTY_COMMANDS; const log = snapshot?.detail.log ?? ""; @@ -306,19 +315,23 @@ function SetupPanel() { {statusLabel} - + {!supportsObservation && } + {observationError && } + {supportsObservation && !observationError && ( + + )} ); } diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts index 84f08b22fc..c9a674f3b3 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.test.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.test.ts @@ -10,6 +10,7 @@ import { clearWorkspaceArchivePending, markWorkspaceArchivePending, } from "@/contexts/session-workspace-upserts"; +import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store"; import { WorkspaceDirectoryReplica } from "./workspace-replica"; function workspace(id: string, projectId = "project"): WorkspaceDescriptorPayload { @@ -230,3 +231,38 @@ it("does not restore a targeted cached workspace while its archive is pending", store.clearSession(serverId); } }); + +it("setup metadata from directory snapshots and transitions resets failure surfacing without details", () => { + const serverId = "setup-metadata"; + useSessionStore.getState().initializeSession(serverId, null as unknown as DaemonClient); + const replica = new WorkspaceDirectoryReplica(serverId); + const identity = { serverId, workspaceId: "setup" }; + const store = useWorkspaceSetupStore.getState(); + replica.commitSnapshot( + { + workspaces: new Map([ + ["setup", normalizeWorkspaceDescriptor({ ...workspace("setup"), setupStatus: "blocked" })], + ]), + projects: new Map(), + }, + [], + ); + expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe( + "blocked", + ); + expect(store.claimFailedSetupSurface({ ...identity, status: "blocked" })).toBe(true); + replica.applyDelta({ + kind: "upsert", + workspace: { ...workspace("setup"), setupStatus: "running" }, + }); + replica.applyDelta({ + kind: "upsert", + workspace: { ...workspace("setup"), setupStatus: "failed" }, + }); + expect(useSessionStore.getState().sessions[serverId].workspaces.get("setup")?.setupStatus).toBe( + "failed", + ); + expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(true); + expect(store.claimFailedSetupSurface({ ...identity, status: "failed" })).toBe(false); + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); +}); diff --git a/packages/app/src/runtime/directory-sync/workspace-replica.ts b/packages/app/src/runtime/directory-sync/workspace-replica.ts index d229c196a2..2eb107ac4f 100644 --- a/packages/app/src/runtime/directory-sync/workspace-replica.ts +++ b/packages/app/src/runtime/directory-sync/workspace-replica.ts @@ -102,6 +102,7 @@ export class WorkspaceDirectoryReplica { workspaces.set(workspaceId, workspace); } } + for (const workspace of workspaces.values()) this.applySetupStatus(workspace); this.workspaces = workspaces; this.projects = new Map(snapshot.projects); this.rebuildProjectIndex(); @@ -198,7 +199,16 @@ export class WorkspaceDirectoryReplica { return [{ kind: "workspace", type: "upsert", id: workspace.id, value: workspace }]; } + private applySetupStatus(workspace: WorkspaceDescriptor): void { + useWorkspaceSetupStore.getState().applySetupStatus({ + serverId: this.serverId, + workspaceId: workspace.id, + status: workspace.setupStatus ?? null, + }); + } + private setWorkspace(workspace: WorkspaceDescriptor): void { + this.applySetupStatus(workspace); const previous = this.workspaces.get(workspace.id); if (previous?.projectId !== workspace.projectId) { this.workspaceIdsByProject.get(previous?.projectId ?? "")?.delete(workspace.id); diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 5c3e1560e1..edd28a3af9 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -1,3 +1,4 @@ +import { useHostFeature } from "@/runtime/host-features"; import { AssistantFileLinkResolverProvider, createInlinePathOpener, @@ -223,6 +224,10 @@ const EMPTY_WORKSPACE_SCRIPTS: WorkspaceDescriptor["scripts"] = []; const EMPTY_PINNED_AGENT_IDS = new Set(); const EMPTY_SET = new Set(); +function getWorkspaceSetupStatus(workspace: WorkspaceDescriptor | null | undefined) { + return workspace?.setupStatus ?? null; +} + function getWorkspaceScripts( workspaceDescriptor: WorkspaceDescriptor | null | undefined, ): WorkspaceDescriptor["scripts"] { @@ -1313,12 +1318,13 @@ function WorkspaceScreenContent({ }); const lastMainPaneId = lastMainPaneRef.current.paneId; const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated(); - const workspaceSetupSnapshot = useWorkspaceSetupStore((state) => - persistenceKey ? (state.snapshots[persistenceKey] ?? null) : null, - ); - const ensureWorkspaceSetupStatus = useWorkspaceSetupStore((state) => state.ensureSetupStatus); + const workspaceSetupStatus = getWorkspaceSetupStatus(workspaceDescriptor); const claimFailedSetupSurface = useWorkspaceSetupStore((state) => state.claimFailedSetupSurface); - const showWorkspaceSetup = shouldShowWorkspaceSetup(workspaceSetupSnapshot); + const supportsSetupObservation = useHostFeature(normalizedServerId, "workspaceSetupObservation"); + const showWorkspaceSetup = shouldShowWorkspaceSetup( + workspaceSetupStatus, + supportsSetupObservation, + ); const uiTabs = useMemo( () => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS), [workspaceLayout], @@ -1624,7 +1630,7 @@ function WorkspaceScreenContent({ if (!persistenceKey) { return; } - if (!shouldSeedWorkspaceSetupTab(workspaceSetupSnapshot)) { + if (!shouldSeedWorkspaceSetupTab(workspaceSetupStatus)) { return; } @@ -1639,6 +1645,7 @@ function WorkspaceScreenContent({ !claimFailedSetupSurface({ serverId: normalizedServerId, workspaceId: normalizedWorkspaceId, + status: workspaceSetupStatus, }) ) { return; @@ -1659,24 +1666,7 @@ function WorkspaceScreenContent({ normalizedServerId, openWorkspaceTabInBackground, persistenceKey, - workspaceSetupSnapshot, - ]); - - useEffect(() => { - if (!isRouteFocused || !client || !normalizedServerId || !normalizedWorkspaceId) { - return; - } - ensureWorkspaceSetupStatus({ - serverId: normalizedServerId, - workspaceId: normalizedWorkspaceId, - client, - }); - }, [ - client, - ensureWorkspaceSetupStatus, - isRouteFocused, - normalizedServerId, - normalizedWorkspaceId, + workspaceSetupStatus, ]); const handleOpenFileFromChat = useCallback( diff --git a/packages/app/src/stores/session-store.ts b/packages/app/src/stores/session-store.ts index 9b70d90486..f7c54e07b2 100644 --- a/packages/app/src/stores/session-store.ts +++ b/packages/app/src/stores/session-store.ts @@ -111,6 +111,7 @@ export interface WorkspaceDescriptor { projectRootPath: string; workspaceDirectory: string; worktreeSlug?: WorkspaceDescriptorPayload["worktreeSlug"]; + setupStatus?: WorkspaceDescriptorPayload["setupStatus"]; projectKind: WorkspaceDescriptorPayload["projectKind"]; workspaceKind: WorkspaceDescriptorPayload["workspaceKind"]; background?: boolean; @@ -149,6 +150,8 @@ export function normalizeWorkspaceDescriptor( // usable directory" (older daemons may omit it; the wire field is optional). workspaceDirectory: normalizeWorkspacePath(payload.workspaceDirectory) ?? "", worktreeSlug: payload.worktreeSlug, + // COMPAT(workspaceSetupObservation): added in v0.12.0, old hosts omit metadata; remove after 2027-04-19. + setupStatus: payload.setupStatus ?? null, projectKind: payload.projectKind, workspaceKind: payload.workspaceKind, background: payload.background ?? false, diff --git a/packages/app/src/stores/workspace-setup-store.test.ts b/packages/app/src/stores/workspace-setup-store.test.ts index 85c2696f57..17e012c153 100644 --- a/packages/app/src/stores/workspace-setup-store.test.ts +++ b/packages/app/src/stores/workspace-setup-store.test.ts @@ -3,129 +3,13 @@ import { shouldSeedWorkspaceSetupTab, shouldShowWorkspaceSetup, useWorkspaceSetupStore, - type WorkspaceSetupSnapshot, - type WorkspaceSetupStatusClient, - type WorkspaceSetupStatusResult, } from "./workspace-setup-store"; -function makeSnapshot( - status: WorkspaceSetupSnapshot["status"], - error: string | null = null, -): WorkspaceSetupSnapshot { - return { - workspaceId: "workspace-1", - status, - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error, - updatedAt: Date.now(), - }; -} - -const DEFAULT_SNAPSHOT: WorkspaceSetupStatusResult["snapshot"] = { - status: "running", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: null, -}; - -function setupResult( - workspaceId: string, - snapshot: WorkspaceSetupStatusResult["snapshot"] = DEFAULT_SNAPSHOT, -): WorkspaceSetupStatusResult { - return { requestId: "req-1", workspaceId, snapshot }; -} - -function makeClient(handler: (workspaceId: string) => Promise) { - const calls: string[] = []; - const client: WorkspaceSetupStatusClient = { - fetchWorkspaceSetupStatus: (workspaceId) => { - calls.push(workspaceId); - return handler(workspaceId); - }, - }; - return { client, calls }; -} - -function createDeferred() { - let resolve!: (value: T) => void; - let reject!: (reason: unknown) => void; - const promise = new Promise((res, rej) => { - resolve = res; - reject = rej; - }); - return { promise, resolve, reject }; -} - -function flush() { - return new Promise((resolve) => setTimeout(resolve, 0)); -} - -function storedSnapshots() { - return Object.values(useWorkspaceSetupStore.getState().snapshots); -} - -function resolveDefault(workspaceId: string): Promise { - return Promise.resolve(setupResult(workspaceId)); -} - -function rejectThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.reject(new Error("boom")); - } - return resolveDefault(workspaceId); - }; -} - -function nullThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.resolve(setupResult(workspaceId, null)); - } - return resolveDefault(workspaceId); - }; -} - -function mismatchThenResolve() { - let attempt = 0; - return (workspaceId: string): Promise => { - attempt += 1; - if (attempt === 1) { - return Promise.resolve(setupResult("999")); - } - return resolveDefault(workspaceId); - }; -} - -function ensureSetupStatus(client: WorkspaceSetupStatusClient) { - useWorkspaceSetupStore.getState().ensureSetupStatus({ - serverId: "server-1", - workspaceId: "42", - client, - }); -} - describe("workspace-setup-store", () => { beforeEach(() => { useWorkspaceSetupStore.setState({ pendingWorkspaceSetup: null, snapshots: {}, - requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), }); }); @@ -162,213 +46,39 @@ describe("workspace-setup-store", () => { expect(useWorkspaceSetupStore.getState().pendingWorkspaceSetup).toBeNull(); }); - it("hides empty successful setup snapshots", () => { - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "completed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: null, - updatedAt: Date.now(), - }), - ).toBe(false); - }); - - it("shows setup snapshots with commands or errors", () => { - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "completed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "done\n", - commands: [ - { - index: 1, - command: "npm install", - cwd: "/Users/test/project", - log: "done\n", - status: "completed", - exitCode: 0, - }, - ], - }, - error: null, - updatedAt: Date.now(), - }), - ).toBe(true); - - expect( - shouldShowWorkspaceSetup({ - workspaceId: "workspace-1", - status: "failed", - detail: { - type: "worktree_setup", - worktreePath: "/Users/test/project", - branchName: "main", - log: "", - commands: [], - }, - error: "Failed to parse paseo.json", - updatedAt: Date.now(), - }), - ).toBe(true); - }); - - it("seeds a setup tab for failed or blocked setup", () => { - expect(shouldSeedWorkspaceSetupTab(null)).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("running"))).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("completed"))).toBe(false); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("failed", "Setup failed"))).toBe(true); - expect(shouldSeedWorkspaceSetupTab(makeSnapshot("blocked"))).toBe(true); - }); - - it("claims one failed setup surface until a later setup lifecycle begins", () => { - const store = useWorkspaceSetupStore.getState(); - const identity = { serverId: "server-1", workspaceId: "42" }; - - store.upsertProgress({ - serverId: identity.serverId, - payload: { ...makeSnapshot("failed", "Setup failed"), workspaceId: identity.workspaceId }, - }); - - expect(store.claimFailedSetupSurface(identity)).toBe(true); + it("uses directory metadata to surface failures without requesting setup details", () => { + for (const status of [null, "running", "completed", "failed", "blocked"] as const) { + expect(shouldShowWorkspaceSetup(status)).toBe(status !== null); + expect(shouldSeedWorkspaceSetupTab(status)).toBe(status === "failed" || status === "blocked"); + } + expect(shouldShowWorkspaceSetup(null, false)).toBe(true); + const identity = { serverId: "server-1", workspaceId: "42", status: "failed" as const }; + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); + expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(false); - - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: identity.serverId, - payload: { ...makeSnapshot("running"), workspaceId: identity.workspaceId }, - }); - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: identity.serverId, - payload: { - ...makeSnapshot("failed", "Setup failed again"), - workspaceId: identity.workspaceId, + useWorkspaceSetupStore.getState().applySetupStatus({ ...identity, status: "running" }); + expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); + }); + it("replaces cached setup details with an empty reconnect snapshot", () => { + const identity = { serverId: "server", workspaceId: "workspace" }; + const store = useWorkspaceSetupStore.getState(); + store.applySnapshot({ + ...identity, + snapshot: { + status: "failed", + error: "previous setup failed", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "main", + commands: [], + log: "previous output", + }, }, }); - - expect(useWorkspaceSetupStore.getState().claimFailedSetupSurface(identity)).toBe(true); - }); - - it("ensureSetupStatus fetches setup status once and stores the snapshot", async () => { - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - await flush(); - - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toEqual([ - expect.objectContaining({ workspaceId: "42", status: "running" }), - ]); - }); - - it("ensureSetupStatus does not refetch while a request is in flight", async () => { - const deferred = createDeferred(); - const { client, calls } = makeClient(() => deferred.promise); - - ensureSetupStatus(client); - ensureSetupStatus(client); - - expect(calls).toEqual(["42"]); - - deferred.resolve(setupResult("42")); - await flush(); - - ensureSetupStatus(client); - expect(calls).toEqual(["42"]); - }); - - it("ensureSetupStatus skips fetching when a snapshot already exists", () => { - useWorkspaceSetupStore.getState().upsertProgress({ - serverId: "server-1", - payload: { workspaceId: "42", ...DEFAULT_SNAPSHOT }, - }); - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - - expect(calls).toEqual([]); - }); - - it("ensureSetupStatus ignores a response for a different workspace", async () => { - const { client } = makeClient(() => Promise.resolve(setupResult("999"))); - - ensureSetupStatus(client); - await flush(); - - expect(storedSnapshots()).toHaveLength(0); - }); - - it("ensureSetupStatus does not store a snapshot when the response snapshot is null", async () => { - const { client } = makeClient((workspaceId) => Promise.resolve(setupResult(workspaceId, null))); - - ensureSetupStatus(client); - await flush(); - - expect(storedSnapshots()).toHaveLength(0); - }); - - it("ensureSetupStatus retries after a null-snapshot response", async () => { - const { client, calls } = makeClient(nullThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus retries after a mismatched-workspace response", async () => { - const { client, calls } = makeClient(mismatchThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus clears the in-flight marker on error so a later call retries", async () => { - const { client, calls } = makeClient(rejectThenResolve()); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - expect(storedSnapshots()).toHaveLength(0); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42", "42"]); - expect(storedSnapshots()).toHaveLength(1); - }); - - it("ensureSetupStatus retries after the workspace is removed", async () => { - const { client, calls } = makeClient(resolveDefault); - - ensureSetupStatus(client); - await flush(); - expect(calls).toEqual(["42"]); - - useWorkspaceSetupStore.getState().removeWorkspace({ serverId: "server-1", workspaceId: "42" }); - ensureSetupStatus(client); - await flush(); - - expect(calls).toEqual(["42", "42"]); + expect(Object.values(useWorkspaceSetupStore.getState().snapshots)).toHaveLength(1); + store.applySnapshot({ ...identity, snapshot: null }); + expect(useWorkspaceSetupStore.getState().snapshots).toEqual({}); }); }); diff --git a/packages/app/src/stores/workspace-setup-store.ts b/packages/app/src/stores/workspace-setup-store.ts index d0205ba27a..2a40c04c80 100644 --- a/packages/app/src/stores/workspace-setup-store.ts +++ b/packages/app/src/stores/workspace-setup-store.ts @@ -1,6 +1,9 @@ import { createNameId } from "mnemonic-id"; import { generateDraftId } from "@/stores/draft-keys"; -import type { SessionOutboundMessage } from "@getpaseo/protocol/messages"; +import type { + WorkspaceSetupSnapshot as SetupDetailSnapshot, + SessionOutboundMessage, +} from "@getpaseo/protocol/messages"; import { create } from "zustand"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -19,30 +22,18 @@ export type WorkspaceSetupProgressPayload = Extract< { type: "workspace_setup_progress" } >["payload"]; -export type WorkspaceSetupStatusResult = Extract< - SessionOutboundMessage, - { type: "workspace_setup_status_response" } ->["payload"]; - -export interface WorkspaceSetupStatusClient { - fetchWorkspaceSetupStatus: (workspaceId: string) => Promise; -} - export interface WorkspaceSetupSnapshot extends WorkspaceSetupProgressPayload { updatedAt: number; } -export function shouldShowWorkspaceSetup(snapshot: WorkspaceSetupSnapshot | null): boolean { - if (!snapshot) { - return false; - } - return ( - snapshot.status === "blocked" || snapshot.error !== null || snapshot.detail.commands.length > 0 - ); +export type WorkspaceSetupStatus = WorkspaceSetupSnapshot["status"] | null; + +export function shouldShowWorkspaceSetup(status: WorkspaceSetupStatus, supported = true): boolean { + return !supported || status !== null; } -export function shouldSeedWorkspaceSetupTab(snapshot: WorkspaceSetupSnapshot | null): boolean { - return snapshot?.status === "failed" || snapshot?.status === "blocked"; +export function shouldSeedWorkspaceSetupTab(status: WorkspaceSetupStatus): boolean { + return status === "failed" || status === "blocked"; } interface WorkspaceSetupStoreState { @@ -50,17 +41,24 @@ interface WorkspaceSetupStoreState { | (PendingWorkspaceSetup & { creationId: string; worktreeSlug: string }) | null; snapshots: Record; - requestedKeys: Set; surfacedFailedSetupKeys: Set; beginWorkspaceSetup: (value: PendingWorkspaceSetup) => void; clearWorkspaceSetup: () => void; - upsertProgress: (input: { serverId: string; payload: WorkspaceSetupProgressPayload }) => void; - claimFailedSetupSurface: (input: { serverId: string; workspaceId: string }) => boolean; - ensureSetupStatus: (input: { + applySnapshot: (input: { serverId: string; workspaceId: string; - client: WorkspaceSetupStatusClient; + snapshot: SetupDetailSnapshot | null; }) => void; + applySetupStatus: (input: { + serverId: string; + workspaceId: string; + status: WorkspaceSetupStatus; + }) => void; + claimFailedSetupSurface: (input: { + serverId: string; + workspaceId: string; + status: WorkspaceSetupStatus; + }) => boolean; removeWorkspace: (input: { serverId: string; workspaceId: string }) => void; clearServer: (serverId: string) => void; } @@ -69,10 +67,9 @@ function buildWorkspaceSetupKey(input: { serverId: string; workspaceId: string } return buildWorkspaceTabPersistenceKey(input); } -export const useWorkspaceSetupStore = create()((set, get) => ({ +export const useWorkspaceSetupStore = create()((set) => ({ pendingWorkspaceSetup: null, snapshots: {}, - requestedKeys: new Set(), surfacedFailedSetupKeys: new Set(), beginWorkspaceSetup: (value) => { set({ @@ -86,30 +83,28 @@ export const useWorkspaceSetupStore = create()((set, g clearWorkspaceSetup: () => { set({ pendingWorkspaceSetup: null }); }, - upsertProgress: ({ serverId, payload }) => { - const key = buildWorkspaceSetupKey({ serverId, workspaceId: payload.workspaceId }); - if (!key) { - return; - } - + applySnapshot: ({ serverId, workspaceId, snapshot }) => { + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) return; set((state) => { - const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys); - if (payload.status !== "failed" && payload.status !== "blocked") { - surfacedFailedSetupKeys.delete(key); - } - return { - snapshots: { - ...state.snapshots, - [key]: { - ...payload, - updatedAt: Date.now(), - }, - }, - surfacedFailedSetupKeys, - }; + const snapshots = { ...state.snapshots }; + if (snapshot) snapshots[key] = { ...snapshot, workspaceId, updatedAt: Date.now() }; + else delete snapshots[key]; + return { snapshots }; }); }, - claimFailedSetupSurface: ({ serverId, workspaceId }) => { + applySetupStatus: ({ serverId, workspaceId, status }) => { + if (shouldSeedWorkspaceSetupTab(status)) return; + const key = buildWorkspaceSetupKey({ serverId, workspaceId }); + if (!key) return; + set((state) => { + if (!state.surfacedFailedSetupKeys.has(key)) return state; + const surfacedFailedSetupKeys = new Set(state.surfacedFailedSetupKeys); + surfacedFailedSetupKeys.delete(key); + return { surfacedFailedSetupKeys }; + }); + }, + claimFailedSetupSurface: ({ serverId, workspaceId, status }) => { const key = buildWorkspaceSetupKey({ serverId, workspaceId }); if (!key) { return false; @@ -117,10 +112,7 @@ export const useWorkspaceSetupStore = create()((set, g let claimed = false; set((state) => { - if ( - !["failed", "blocked"].includes(state.snapshots[key]?.status ?? "") || - state.surfacedFailedSetupKeys.has(key) - ) { + if (!shouldSeedWorkspaceSetupTab(status) || state.surfacedFailedSetupKeys.has(key)) { return state; } claimed = true; @@ -128,40 +120,6 @@ export const useWorkspaceSetupStore = create()((set, g }); return claimed; }, - ensureSetupStatus: async ({ serverId, workspaceId, client }) => { - const key = buildWorkspaceSetupKey({ serverId, workspaceId }); - if (!key) { - return; - } - const state = get(); - if (state.snapshots[key] || state.requestedKeys.has(key)) { - return; - } - - // requestedKeys is a pure in-flight marker: it dedupes concurrent fetches and is - // released once the request settles. A settle that stored no snapshot (null snapshot, - // mismatched workspace, or error) leaves no marker, so a later call can retry; once a - // snapshot lands, the snapshots[key] guard above prevents redundant refetches. - set((current) => ({ requestedKeys: new Set(current.requestedKeys).add(key) })); - - try { - const response = await client.fetchWorkspaceSetupStatus(workspaceId); - if (response.workspaceId === workspaceId && response.snapshot) { - get().upsertProgress({ - serverId, - payload: { workspaceId: response.workspaceId, ...response.snapshot }, - }); - } - } catch { - // Swallowed: the finally clears the in-flight marker so a later call retries. - } finally { - set((current) => { - const next = new Set(current.requestedKeys); - next.delete(key); - return { requestedKeys: next }; - }); - } - }, removeWorkspace: ({ serverId, workspaceId }) => { const key = buildWorkspaceSetupKey({ serverId, workspaceId }); if (!key) { diff --git a/packages/client/src/connection/index.ts b/packages/client/src/connection/index.ts index d939b3c72b..eda2fec26b 100644 --- a/packages/client/src/connection/index.ts +++ b/packages/client/src/connection/index.ts @@ -141,6 +141,7 @@ export const DEFAULT_CLIENT_CAPABILITIES = { [CLIENT_CAPS.timelineNotifications]: true, [CLIENT_CAPS.pluginTimelineItems]: true, [CLIENT_CAPS.workspaceSetupBlocked]: true, + [CLIENT_CAPS.workspaceSetupObservation]: true, [CLIENT_CAPS.explicitEventSubscriptions]: true, } satisfies Record, true>; diff --git a/packages/client/src/daemon-client.test.ts b/packages/client/src/daemon-client.test.ts index 6b82922841..7469470ad1 100644 --- a/packages/client/src/daemon-client.test.ts +++ b/packages/client/src/daemon-client.test.ts @@ -1038,6 +1038,7 @@ test("advertises client capabilities in hello", async () => { timeline_notifications: true, plugin_timeline_items: true, workspace_setup_blocked: true, + workspace_setup_observation: true, hello_rejection: true, browser_host: { supportedCommands: ["list_tabs"], diff --git a/packages/client/src/daemon-client.ts b/packages/client/src/daemon-client.ts index e0e4d9ddc5..647e1fddf6 100644 --- a/packages/client/src/daemon-client.ts +++ b/packages/client/src/daemon-client.ts @@ -12,7 +12,7 @@ import { CreationClient } from "./creation/index.js"; import type { CreationSnapshot } from "@getpaseo/protocol/messages"; import type { z } from "zod"; import type { SessionEventSubscription } from "@getpaseo/protocol/messages"; -import type { ClientCapability } from "@getpaseo/protocol/client-capabilities"; +import { CLIENT_CAPS, type ClientCapability } from "@getpaseo/protocol/client-capabilities"; import type { AgentAttentionNotificationPayload } from "@getpaseo/protocol/agent-attention-notification"; import { MAX_WEBSOCKET_MESSAGE_BYTES } from "@getpaseo/protocol/websocket-message-limit"; import { @@ -2313,6 +2313,8 @@ export class DaemonClient { message, async (query, accept) => { resetSource = false; + if (query.type === "workspace.setup.subscribe.request") + this.requireWorkspaceSetupObservation(); const requestId = this.createRequestId(options?.requestId); const request = SessionInboundMessageSchema.parse({ ...query, requestId }); try { @@ -2781,6 +2783,27 @@ export class DaemonClient { }); } + observeWorkspaceSetup( + workspaceId: string, + options?: { signal?: AbortSignal }, + ): OwnedSubscription> { + return this.observe( + "workspace.setup.subscribe.response", + { type: "workspace.setup.subscribe.request", workspaceId }, + options, + ); + } + + private requireWorkspaceSetupObservation(): void { + if ( + this.lastServerInfoMessage?.features?.workspaceSetupObservation !== true || + this.lastServerInfoMessage.features.ownedSubscriptions !== true || + this.config.capabilities?.[CLIENT_CAPS.workspaceSetupObservation] === false || + this.config.capabilities?.[CLIENT_CAPS.ownedSubscriptions] === false + ) + throw new Error("Update the host to view workspace setup details"); + } + async fetchWorkspaceSetupStatus( workspaceId: string, requestId?: string, diff --git a/packages/protocol/src/client-capabilities.ts b/packages/protocol/src/client-capabilities.ts index 0947b81027..a35852cc06 100644 --- a/packages/protocol/src/client-capabilities.ts +++ b/packages/protocol/src/client-capabilities.ts @@ -43,6 +43,7 @@ export const CLIENT_CAPS = { timelineNotifications: "timeline_notifications", // COMPAT(pluginTimelineItems): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. pluginTimelineItems: "plugin_timeline_items", + workspaceSetupObservation: "workspace_setup_observation", // COMPAT(workspaceSetupBlocked): added in v0.8.0, remove after 2027-03-07 once client floor >= v0.8.0. workspaceSetupBlocked: "workspace_setup_blocked", browserHost: "browser_host", diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index a7a2182d94..f7c43f224a 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -2556,6 +2556,12 @@ export const WorkspaceSetupStatusRequestSchema = z.object({ requestId: z.string(), }); +export const WorkspaceSetupSubscribeRequestSchema = z.object({ + type: z.literal("workspace.setup.subscribe.request"), + workspaceId: z.string(), + requestId: z.string(), +}); + export const WorkspaceSetupRunRequestSchema = z.object({ type: z.literal("workspace.setup.run.request"), workspaceId: z.string(), @@ -3335,6 +3341,7 @@ export const SessionInboundMessageSchema = z.discriminatedUnion("type", [ PaseoWorktreeArchiveRequestSchema, CreatePaseoWorktreeRequestSchema, WorkspaceSetupStatusRequestSchema, + WorkspaceSetupSubscribeRequestSchema, WorkspaceSetupRunRequestSchema, LegacyListAvailableEditorsRequestSchema, LegacyOpenInEditorRequestSchema, @@ -3725,6 +3732,7 @@ export const ServerInfoStatusPayloadSchema = z selectiveAgentTimeline: z.boolean().optional(), explicitEventSubscriptions: z.boolean().optional(), ownedSubscriptions: z.boolean().optional(), + workspaceSetupObservation: z.boolean().optional(), // COMPAT(canonicalSubmittedPrompts): added in v0.2.6, remove gate after 2027-01-30. canonicalSubmittedPrompts: z.boolean().optional(), agentMessageProvenance: z.boolean().optional(), @@ -4035,6 +4043,8 @@ export const WorkspaceDescriptorPayloadSchema = z projectCustomIconRevision: z.string().nullable().optional(), projectRootPath: z.string(), workspaceDirectory: z.string().optional(), + // COMPAT(workspaceSetupObservation): added in v0.12.0, remove optional after 2027-04-19. + setupStatus: z.enum(["running", "completed", "failed", "blocked"]).nullable().optional(), // COMPAT(worktreeSlug): added in v0.2.6, remove optional after 2027-01-31. // Present only for Paseo-owned worktrees; this is the basename of their root directory. worktreeSlug: z.string().optional(), @@ -4422,6 +4432,16 @@ export const WorkspaceSetupSnapshotSchema = z.object({ .optional(), }); +export const WorkspaceSetupSubscribeResponseSchema = z.object({ + type: z.literal("workspace.setup.subscribe.response"), + payload: z.object({ + requestId: z.string(), + subscriptionId: z.string().optional(), + workspaceId: z.string(), + snapshot: WorkspaceSetupSnapshotSchema.nullable(), + }), +}); + export const WorkspaceSetupRunResponseMessageSchema = z.object({ type: z.literal("workspace.setup.run.response"), payload: z.object({ @@ -6909,6 +6929,7 @@ export const SessionOutboundMessageSchema = z.discriminatedUnion("type", [ ScriptStatusUpdateMessageSchema, WorkspaceSetupProgressMessageSchema, WorkspaceSetupStatusResponseMessageSchema, + WorkspaceSetupSubscribeResponseSchema, WorkspaceSetupRunResponseMessageSchema, AgentStreamMessageSchema, AgentStatusMessageSchema, diff --git a/packages/server/src/server/authorization/operation-permissions.ts b/packages/server/src/server/authorization/operation-permissions.ts index e7e77bc0d3..0893abe1a3 100644 --- a/packages/server/src/server/authorization/operation-permissions.ts +++ b/packages/server/src/server/authorization/operation-permissions.ts @@ -210,6 +210,7 @@ const INBOUND_PERMISSION = { "workspace.script.stop.request": "workspace.write", "workspace.title.set.request": ["workspace.manage", "hub.execute"], workspace_setup_status_request: "workspace.read", + "workspace.setup.subscribe.request": "workspace.read", "workspace.setup.run.request": "workspace.write", write_project_config_request: "workspace.write", } as const satisfies Record; @@ -436,6 +437,7 @@ const OUTBOUND_PERMISSION = { "workspace.title.set.response": ["workspace.manage", "hub.execute"], workspace_setup_progress: "workspace.read", workspace_setup_status_response: "workspace.read", + "workspace.setup.subscribe.response": "workspace.read", "workspace.setup.run.response": "workspace.write", workspace_update: ["workspace.read", "hub.execute"], write_project_config_response: "workspace.write", diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 08d3f0eefb..87949594aa 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -13,6 +13,7 @@ import { CreationService, type CreatedAgent } from "./creation/index.js"; import type { CreationSnapshot, AgentCreateRequest } from "@getpaseo/protocol/messages"; import type { MessageReceipts } from "./message-receipts/index.js"; import equal from "fast-deep-equal"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { SessionDelivery, type OwnedSubscription } from "./session/owned-subscriptions/index.js"; import { v4 as uuidv4 } from "uuid"; import { lstat, mkdir, mkdtemp, rename, rm, stat } from "node:fs/promises"; @@ -531,7 +532,7 @@ export interface SessionOptions { hubRelationships?: HubRelationshipManagement; serviceProxy?: ServiceProxySubsystem; scriptRuntimeStore?: WorkspaceScriptRuntimeStore; - workspaceSetupSnapshots?: Map; + workspaceSetupObservation?: WorkspaceSetupObservation; workspaceSetupRuntime?: WorkspaceSetupRuntime; onBranchChanged?: ( workspaceId: string, @@ -783,7 +784,8 @@ export class Session { private readonly terminalController: TerminalSessionController; private inflightRequests = 0; private peakInflightRequests = 0; - private readonly workspaceSetupSnapshots: Map; + private readonly workspaceSetupObservation: WorkspaceSetupObservation; + private readonly unsubscribeSetupObservation: () => void; private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly workspaceGitObserver: WorkspaceGitObserverService; private readonly workspaceDirectory: WorkspaceDirectory; @@ -844,7 +846,7 @@ export class Session { providerSnapshotManager, serviceProxy, scriptRuntimeStore, - workspaceSetupSnapshots, + workspaceSetupObservation, workspaceSetupRuntime, onBranchChanged, getDaemonTcpPort, @@ -1151,7 +1153,19 @@ export class Session { this.providerSnapshotManager = providerSnapshotManager; this.serviceProxy = serviceProxy ?? null; this.scriptRuntimeStore = scriptRuntimeStore ?? null; - this.workspaceSetupSnapshots = workspaceSetupSnapshots ?? new Map(); + this.workspaceSetupObservation = workspaceSetupObservation ?? new WorkspaceSetupObservation(); + this.unsubscribeSetupObservation = this.workspaceSetupObservation.subscribe( + (workspaceId, snapshot, statusChanged) => { + if (statusChanged) + void this.emitWorkspaceUpdateForWorkspaceId(workspaceId).catch((error) => + this.sessionLogger.warn({ err: error, workspaceId }, "Failed to publish setup status"), + ); + this.emitSubscribedEvent({ + type: "workspace_setup_progress", + payload: { workspaceId, ...snapshot }, + }); + }, + ); this.workspaceSetupRuntime = resolveWorkspaceSetupRuntime(workspaceSetupRuntime); this.getDaemonTcpPort = getDaemonTcpPort ?? null; this.getDaemonTcpHost = getDaemonTcpHost ?? null; @@ -2293,10 +2307,50 @@ export class Session { } } + private async subscribeWorkspaceSetup( + request: Extract, + ): Promise { + if ( + !this.supports(CLIENT_CAPS.workspaceSetupObservation) || + !this.delivery.isModern(this.delivery.currentSource) + ) + throw new Error("Workspace setup observation requires owned subscriptions"); + // Resolve persisted blocked state first. Register and read the current cache in one + // synchronous turn afterward, so a setup transition during this read cannot be lost. + let initial: WorkspaceSetupSnapshot | null = null; + await handleWorkspaceSetupStatusRequestMessage( + { + workspaceSetupSnapshots: this.workspaceSetupObservation, + getWorkspace: (id) => this.workspaceRegistry.get(id), + emit: (message) => { + if (message.type === "workspace_setup_status_response") + initial = message.payload.snapshot; + }, + }, + { ...request, type: "workspace_setup_status_request" }, + ); + let stop: (() => void) | undefined; + const owner = this.delivery.begin("workspace-setup", undefined, () => stop?.()); + stop = this.workspaceSetupObservation.subscribe((workspaceId, snapshot) => { + if (workspaceId === request.workspaceId) + owner.emit({ type: "workspace_setup_progress", payload: { workspaceId, ...snapshot } }); + }); + this.emit({ + type: "workspace.setup.subscribe.response", + payload: { + requestId: request.requestId, + subscriptionId: owner.responseId, + workspaceId: request.workspaceId, + snapshot: this.workspaceSetupObservation.get(request.workspaceId) ?? initial, + }, + }); + } + private dispatchSubscriptionMessage( msg: SessionInboundMessage, source?: object, ): Promise | undefined { + if (msg.type === "workspace.setup.subscribe.request") return this.subscribeWorkspaceSetup(msg); if (msg.type === "browser.host.register.request") return this.registerBrowserHost(msg); if (msg.type === "browser.automation.execute.response") { if (source) @@ -5602,6 +5656,7 @@ export class Session { projectCustomIconRevision: resolvedProjectRecord?.customIconRevision ?? null, projectRootPath: resolvedProjectRecord?.rootPath ?? workspace.cwd, workspaceDirectory: workspace.cwd, + setupStatus: this.workspaceSetupObservation.status(workspace), worktreeSlug, projectKind: (resolvedProjectRecord?.kind ?? "directory") === "git" ? "git" : "non_git", workspaceKind: workspace.kind, @@ -5692,6 +5747,7 @@ export class Session { projectCustomIconRevision: projectRecord?.customIconRevision ?? null, projectRootPath: projectRecord?.rootPath ?? result.repoRoot, workspaceDirectory: result.workspace.cwd, + setupStatus: this.workspaceSetupObservation.status(result.workspace), worktreeSlug: basename(result.worktree.worktreePath), projectKind: projectRecord?.kind ?? "git", workspaceKind: result.workspace.kind, @@ -7365,7 +7421,7 @@ export class Session { emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => { - this.workspaceSetupSnapshots.set(workspaceId, snapshot); + this.workspaceSetupObservation.cache(workspaceId, snapshot); }, emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, @@ -7390,7 +7446,7 @@ export class Session { return handleWorkspaceSetupStatusRequestMessage( { emit: (message) => this.emit(message), - workspaceSetupSnapshots: this.workspaceSetupSnapshots, + workspaceSetupSnapshots: this.workspaceSetupObservation, getWorkspace: (workspaceId) => this.workspaceRegistry.get(workspaceId), }, request, @@ -7412,7 +7468,7 @@ export class Session { emitWorkspaceUpdateForWorkspaceId: (workspaceId) => this.emitWorkspaceUpdateForWorkspaceId(workspaceId), cacheWorkspaceSetupSnapshot: (workspaceId, snapshot) => - this.workspaceSetupSnapshots.set(workspaceId, snapshot), + this.workspaceSetupObservation.cache(workspaceId, snapshot), emit: (message) => this.emit(message), sessionLogger: this.sessionLogger, terminalManager: this.terminalManager, @@ -8392,8 +8448,22 @@ export class Session { */ // COMPAT(explicitEventSubscriptions): added in v0.8.0, remove legacy broadcasts after 2027-03-08. private wantsEvent(event: SessionEventSubscription, source?: object): boolean { + // COMPAT(workspaceSetupObservation): added in v0.12.0, remove global setup delivery after 2027-04-19 once client floor >= v0.12.0. + if ( + event === "workspace_setup_progress" && + source && + this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, source) + ) + return false; for (const subscription of this.eventSubscriptions.values()) { - if ((!source || subscription.owner.source === source) && subscription.events.has(event)) + if ( + (!source || subscription.owner.source === source) && + subscription.events.has(event) && + !( + event === "workspace_setup_progress" && + this.supportsForSource(CLIENT_CAPS.workspaceSetupObservation, subscription.owner.source) + ) + ) return true; } if (!source && this.clientSources.size > 0) { @@ -8441,6 +8511,11 @@ export class Session { for (const subscription of this.eventSubscriptions.values()) { if ( !subscription.events.has(event) || + (event === "workspace_setup_progress" && + this.supportsForSource( + CLIENT_CAPS.workspaceSetupObservation, + subscription.owner.source, + )) || (onlySource && subscription.owner.source !== onlySource) ) continue; @@ -8464,6 +8539,11 @@ export class Session { private emit(msg: SessionOutboundMessage): void { if (!this.authorization.allowsOutbound(msg)) return; + if (msg.type === "workspace_setup_progress") { + const { workspaceId, subscriptionId: _subscriptionId, ...snapshot } = msg.payload; + this.workspaceSetupObservation.publish(workspaceId, snapshot); + return; + } if (this.delivery.reply(msg)) return; if (this.emitSubscribedEvent(msg)) return; // JSON.stringify(msg) is only computed when trace is enabled — it runs for @@ -8478,7 +8558,7 @@ export class Session { "agent.session.outbound", ); } - if (msg.type === "workspace_setup_progress" || msg.type === "workspace_setup_status_response") { + if (msg.type === "workspace_setup_status_response") { if (this.clientSources.size > 0 && this.onMessageToSource) { for (const [source] of this.clientSources) { this.onMessageToSource(source, this.workspaceSetupMessageForClient(msg, source)); @@ -8552,6 +8632,7 @@ export class Session { public async cleanup(): Promise { this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup"); this.isCleanedUp = true; + this.unsubscribeSetupObservation(); await this.delivery.close(); if (this.unsubscribeAgentEvents) { diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index b9c04e8244..fafcbc182a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -175,6 +175,7 @@ interface SessionTestAccess { emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise; emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable): Promise; updateClientCapabilities(capabilities: Record | null, source?: object): void; + publish: Session["publish"]; emit(message: unknown): void; onMessage(message: unknown): void; paseoHome: string; @@ -9602,3 +9603,82 @@ test("workspace.create.request reports an archived explicit project", async () = errorCode: "archived_project", }); }); + +test.each(["failed", "blocked"] as const)( + "workspace setup %s catches up from an existing directory cursor after releasing observation", + async (status) => { + const emitted: SessionOutboundMessage[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + }); + const source = {}; + const capabilities = { + [CLIENT_CAPS.ownedSubscriptions]: true, + [CLIENT_CAPS.workspaceSetupObservation]: true, + }; + session.updateClientCapabilities(capabilities, source); + await session.handleMessage( + { type: "fetch_workspaces_request", requestId: "before-setup", subscribe: {}, sync: {} }, + source, + ); + const initial = findByType(emitted, "fetch_workspaces_response")!.payload; + expect(initial.entries).toEqual([ + expect.objectContaining({ id: "ws-repo-running", setupStatus: null }), + ]); + expect(initial.sync?.mode).toBe("snapshot"); + await session.handleMessage( + { + type: "subscription.release.request", + requestId: "release-directory", + subscriptionId: initial.subscriptionId, + }, + source, + ); + emitted.length = 0; + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId: "ws-repo-running", + status, + error: status === "failed" ? "setup failed" : null, + detail: { + type: "worktree_setup", + worktreePath: REPO_CWD, + branchName: "main", + log: "output never requested", + commands: [], + }, + }, + }); + await flushWorkspaceUpdateBackgroundWork(); + expect(emitted).toEqual([]); + const reconnectSource = {}; + session.updateClientCapabilities(capabilities, reconnectSource); + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "catch-up-setup", + subscribe: {}, + sync: { generation: initial.sync!.generation, afterSeq: initial.sync!.headSeq }, + }, + reconnectSource, + ); + const caughtUp = findByType(emitted, "fetch_workspaces_response")!.payload; + expect(caughtUp.sync?.mode).toBe("changes"); + expect(caughtUp.entries).toEqual([ + expect.objectContaining({ id: "ws-repo-running", setupStatus: status }), + ]); + expect(caughtUp.entries[0].syncSeq).toBeGreaterThan(initial.sync!.headSeq); + expect(filterByType(emitted, "workspace_setup_progress")).toEqual([]); + emitted.length = 0; + await session.handleMessage( + { + type: "fetch_workspaces_request", + requestId: "after-catch-up", + sync: { generation: caughtUp.sync!.generation, afterSeq: caughtUp.sync!.headSeq }, + }, + reconnectSource, + ); + expect(findByType(emitted, "fetch_workspaces_response")!.payload.entries).toEqual([]); + }, +); diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index e833a07ce3..b95e7696e2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -22,7 +22,6 @@ import { type ServerInfoStatusPayload, type ScriptStatusUpdateMessage, type SessionOutboundMessage, - type WorkspaceSetupSnapshot, type WSHelloMessage, type WSInboundMessage, WSInboundMessageSchema, @@ -43,6 +42,7 @@ import { type SessionRuntimeMetrics, } from "./session.js"; import type { HubRelationshipManagement } from "./hub/relationship-controller.js"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { WorkspaceSetupRuntime } from "./workspace-setup-runtime.js"; import type { HubExecutionAgents } from "./hub/daemon-executions.js"; import type { AgentProvider } from "./agent/agent-sdk-types.js"; @@ -570,7 +570,7 @@ export class VoiceAssistantWebSocketServer { } | null; private readonly voiceSpeakHandlers = new Map(); private readonly voiceCallerContexts = new Map(); - private readonly workspaceSetupSnapshots = new Map(); + private readonly workspaceSetupObservation = new WorkspaceSetupObservation(); private readonly workspaceSetupRuntime: WorkspaceSetupRuntime; private readonly providerSnapshotManager: ProviderSnapshotManager; private onLifecycleIntent!: ((intent: SessionLifecycleIntent) => void) | null; @@ -955,6 +955,13 @@ export class VoiceAssistantWebSocketServer { } public broadcast(message: WSOutboundMessage): void { + // External setup producers (MCP and schedules) publish once, including when no + // clients are connected. Session-owned observers perform the source routing. + if (message.type === "session" && message.message.type === "workspace_setup_progress") { + const { workspaceId, subscriptionId: _subscriptionId, ...snapshot } = message.message.payload; + this.workspaceSetupObservation.publish(workspaceId, snapshot); + return; + } if (message.type === "session") { for (const connection of new Set(this.sessions.values())) { connection.session.publish(message.message); @@ -1506,7 +1513,7 @@ export class VoiceAssistantWebSocketServer { hubRelationships: options.hubRelationships, serviceProxy: this.serviceProxy ?? undefined, scriptRuntimeStore: this.scriptRuntimeStore ?? undefined, - workspaceSetupSnapshots: this.workspaceSetupSnapshots, + workspaceSetupObservation: this.workspaceSetupObservation, workspaceSetupRuntime: this.workspaceSetupRuntime, onBranchChanged: this.onBranchChanged ?? undefined, getDaemonTcpPort: this.getDaemonTcpPort ?? undefined, @@ -1803,6 +1810,7 @@ export class VoiceAssistantWebSocketServer { ...(this.workspaceLabelService ? { workspaceLabels: true } : {}), // COMPAT(workspaceSetupRun): added in v0.7.3, remove gate after 2027-09-02. workspaceSetupRun: true, + workspaceSetupObservation: true, // COMPAT(providersSnapshot): keep optional until all clients rely on snapshot flow. providersSnapshot: true, // COMPAT(providersSnapshotCwd): added in v0.3.2, remove gate after 2027-02-10. diff --git a/packages/server/src/server/wire-compat.test.ts b/packages/server/src/server/wire-compat.test.ts index 34abdebc3f..22c8661544 100644 --- a/packages/server/src/server/wire-compat.test.ts +++ b/packages/server/src/server/wire-compat.test.ts @@ -15,6 +15,7 @@ import { type SessionOutboundMessage, } from "@getpaseo/protocol/messages"; import { Session, type SessionOptions } from "./session.js"; +import { WorkspaceSetupObservation } from "./workspace-setup-observation.js"; import { OWNER_PERMISSIONS } from "./authorization/index.js"; import { DirectorySyncService } from "./directory-sync/index.js"; import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js"; @@ -190,6 +191,7 @@ function createSessionForWireCompatTest(options?: { messages?: SessionOutboundMessage[]; onMessageToSource?: SessionOptions["onMessageToSource"]; rows?: AgentTimelineRow[]; + workspaceRegistry?: SessionOptions["workspaceRegistry"]; }): Session { const messages = options?.messages ?? []; const rows: AgentTimelineRow[] = [ @@ -228,7 +230,8 @@ function createSessionForWireCompatTest(options?: { agentStorage: new EmptyAgentStorage() as unknown as SessionOptions["agentStorage"], projectRegistry: new EmptyProjectRegistry() as unknown as SessionOptions["projectRegistry"], workspaceRegistry: - new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"], + options?.workspaceRegistry ?? + (new EmptyWorkspaceRegistry() as unknown as SessionOptions["workspaceRegistry"]), directorySync: options?.directorySync, scheduleService: {} as SessionOptions["scheduleService"], checkoutDiffManager: { @@ -641,3 +644,165 @@ test("setup progress is adapted per socket without changing the canonical snapsh expect(message.payload.status).toBe("blocked"); await session.cleanup(); }); + +test("scoped setup clients never receive session-wide setup output", async () => { + const messages: SessionOutboundMessage[] = []; + const session = createSessionForWireCompatTest({ + messages, + clientCapabilities: { owned_subscriptions: true, workspace_setup_observation: true }, + }); + const source = {}; + session.updateClientCapabilities( + { owned_subscriptions: true, workspace_setup_observation: true }, + source, + ); + await session.handleMessage( + { + type: "session.events.set_subscription.request", + requestId: "events", + events: ["workspace_setup_progress"], + }, + source, + ); + messages.length = 0; + for (const workspaceId of ["setup-a", "setup-b", "setup-c"]) { + for (let chunk = 0; chunk < 10; chunk++) { + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId, + status: "running", + error: null, + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "setup", + log: "output".repeat(chunk * 1000), + commands: [], + }, + }, + }); + } + } + expect(messages).toEqual([]); + await session.cleanup(); +}); + +test("setup output invalidates directory metadata only when visible setup status changes", () => { + const setup = new WorkspaceSetupObservation(); + const earlier = { + status: "failed" as const, + error: "failed before any connection", + detail: { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "test", + log: "earlier output", + commands: [], + }, + }; + setup.publish("earlier", earlier); + expect(setup.get("earlier")).toEqual(earlier); + expect(setup.status({ workspaceId: "earlier", untrustedSource: null })).toBe("failed"); + const changes: boolean[] = []; + setup.subscribe((_workspaceId, _snapshot, changed) => changes.push(changed)); + const detail = { + type: "worktree_setup" as const, + worktreePath: "/workspace", + branchName: "test", + log: "", + commands: [ + { + index: 0, + command: "setup", + cwd: "/workspace", + log: "", + status: "running" as const, + exitCode: null, + }, + ], + }; + for (const log of ["", "one", "one two", "one two three"]) + setup.publish("workspace", { status: "running", error: null, detail: { ...detail, log } }); + setup.publish("workspace", { status: "failed", error: "failed", detail }); + expect(changes).toEqual([true, false, false, false, true]); + expect(setup.status({ workspaceId: "workspace", untrustedSource: null })).toBe("failed"); + expect(setup.status({ workspaceId: "empty", untrustedSource: null })).toBeNull(); + setup.publish("empty", { status: "completed", error: null, detail: { ...detail, commands: [] } }); + expect(setup.status({ workspaceId: "empty", untrustedSource: null })).toBeNull(); +}); + +test("setup observation includes a transition that happens during its initial registry read", async () => { + let completeRead!: (value: null) => void; + let readStarted!: () => void; + const started = new Promise((resolve) => { + readStarted = resolve; + }); + const pendingRead = new Promise((resolve) => { + completeRead = resolve; + }); + const messages: SessionOutboundMessage[] = []; + const source = {}; + const session = createSessionForWireCompatTest({ + messages, + clientCapabilities: { owned_subscriptions: true, workspace_setup_observation: true }, + workspaceRegistry: { + get: () => { + readStarted(); + return pendingRead; + }, + list: () => [], + } as unknown as SessionOptions["workspaceRegistry"], + }); + session.updateClientCapabilities( + { owned_subscriptions: true, workspace_setup_observation: true }, + source, + ); + try { + const request = session.handleMessage( + { type: "workspace.setup.subscribe.request", workspaceId: "setup", requestId: "observe" }, + source, + ); + await started; + session.publish({ + type: "workspace_setup_progress", + payload: { + workspaceId: "setup", + status: "failed", + error: "bootstrap failure", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "test", + log: "failure output", + commands: [], + }, + }, + }); + completeRead(null); + await request; + expect(messages).toEqual([ + { + type: "workspace.setup.subscribe.response", + payload: { + requestId: "observe", + workspaceId: "setup", + subscriptionId: expect.any(String), + snapshot: { + status: "failed", + error: "bootstrap failure", + detail: { + type: "worktree_setup", + worktreePath: "/workspace", + branchName: "test", + log: "failure output", + commands: [], + }, + }, + }, + }, + ]); + } finally { + await session.cleanup(); + } +}); diff --git a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts index 69c1da2efc..6e757c3770 100644 --- a/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts +++ b/packages/server/src/server/workspace-create-worktree-source.e2e.test.ts @@ -6,6 +6,13 @@ import path from "node:path"; import { expect, onTestFinished, test } from "vitest"; import { DaemonClient } from "./test-utils/index.js"; +import { WebSocket } from "ws"; +import { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; +import { + DaemonClient as SocketDaemonClient, + type WebSocketLike, +} from "@getpaseo/client/internal/daemon-client"; import { createTestPaseoDaemon } from "./test-utils/paseo-daemon.js"; // The reshaped workspace.create.request forwards its worktree `source` @@ -596,3 +603,246 @@ async function expectMissingLocalMergeTarget({ client, cwd }: RestorableWorkspac ).error?.message, ).toContain("No local merge target is recorded"); } + +// Setup output is opt-in even for multiple sockets sharing one logical client. +test("setup details follow one workspace/source and restore after reconnect with minimal directory status", async () => { + const daemon = await createTestPaseoDaemon({ mcpEnabled: true }); + const { repoDir, tempRoot } = createGitRepoWithBranch(); + const clients: SocketDaemonClient[] = []; + const sockets = new Map(); + const observations: Array<{ release(): Promise }> = []; + const workspaces: Array<{ id: string; workspaceDirectory?: string }> = []; + const mcp = new Client({ name: "setup-test", version: "1.0.0" }); + try { + await mcp.connect( + new StreamableHTTPClientTransport(new URL(`http://127.0.0.1:${daemon.port}/mcp/agents`)), + ); + writeFileSync( + path.join(repoDir, "paseo.json"), + JSON.stringify({ worktree: { setup: "node setup.cjs" } }), + ); + writeFileSync( + path.join(repoDir, "setup.cjs"), + ` +const fs = require('node:fs'); +console.log('setup started'); +fs.writeFileSync('started.txt', 'ready'); +let wave = 0; +const timer = setInterval(() => { + if (fs.existsSync('wave-' + wave)) { + console.log('wave-' + wave + ':' + 'setup output '.repeat(1000)); + fs.writeFileSync('emitted-' + wave, 'ready'); + wave++; + } + if (fs.existsSync('finish.txt')) { clearInterval(timer); process.exit(1); } +}, 20); +`, + ); + writeFileSync(path.join(repoDir, ".gitignore"), "*.txt\nwave-*\nemitted-*\n"); + execFileSync("git", ["add", "paseo.json", "setup.cjs", ".gitignore"], { cwd: repoDir }); + execFileSync("git", ["-c", "commit.gpgsign=false", "commit", "-m", "setup fixture"], { + cwd: repoDir, + stdio: "pipe", + }); + const connect = async (clientId: string) => { + const client = new SocketDaemonClient({ + url: `ws://127.0.0.1:${daemon.port}/ws`, + clientId, + reconnect: { baseDelayMs: 10, maxDelayMs: 10 }, + webSocketFactory: (url) => { + const socket = new WebSocket(url); + sockets.set(clientId, [...(sockets.get(clientId) ?? []), socket]); + return socket as unknown as WebSocketLike; + }, + }); + clients.push(client); + await client.connect(); + return client; + }; + const creator = await connect("setup-creator"); + const viewer = await connect("setup-viewer"); + const sibling = await connect("setup-viewer"); + const quiet = await connect("setup-quiet"); + const viewerMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + const quietMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + const siblingMessages: import("@getpaseo/protocol/messages").SessionOutboundMessage[] = []; + viewer.subscribeRawMessages((message) => viewerMessages.push(message)); + quiet.subscribeRawMessages((message) => quietMessages.push(message)); + sibling.subscribeRawMessages((message) => siblingMessages.push(message)); + const directory = quiet.observeWorkspaces(); + observations.push(directory); + await directory.ready; + for (const worktreeSlug of ["noisy-a", "noisy-b", "noisy-c"]) { + const result = await creator.createWorkspace({ + source: { kind: "worktree", cwd: repoDir, worktreeSlug }, + }); + if (!result.workspace || result.error) throw new Error(result.error ?? "No workspace"); + workspaces.push(result.workspace); + await expect + .poll(() => existsSync(path.join(result.workspace!.workspaceDirectory!, "started.txt"))) + .toBe(true); + } + const external = await mcp.callTool({ + name: "create_workspace", + arguments: { isolation: "worktree", path: repoDir, worktreeSlug: "noisy-mcp" }, + }); + if (typeof external.structuredContent?.workspaceId !== "string") + throw new Error("Missing MCP workspace"); + const externalWorkspace = (await creator.fetchWorkspaces()).entries.find( + (entry) => entry.id === external.structuredContent!.workspaceId, + ); + if (!externalWorkspace) throw new Error("Missing MCP directory entry"); + workspaces.unshift(externalWorkspace); + await expect + .poll(() => existsSync(path.join(externalWorkspace.workspaceDirectory!, "started.txt"))) + .toBe(true); + const workspace = workspaces[0]!; + // The child writes its marker before the terminal's output is delivered. + // Wait for the daemon snapshot, rather than treating the file as an output barrier. + await expect + .poll(async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot, { + timeout: 30000, + }) + .toMatchObject({ detail: { log: expect.stringContaining("setup started") } }); + const details = viewer.observeWorkspaceSetup(workspace.id); + observations.push(details); + const initial = await details.ready; + expect(initial.snapshot).toMatchObject({ + status: "running", + detail: { log: expect.stringContaining("setup started") }, + }); + await expect + .poll( + () => + new Set( + quietMessages + .filter( + (message) => + message.type === "workspace_update" && + message.payload.kind === "upsert" && + message.payload.workspace.setupStatus === "running", + ) + .map((message) => + message.type === "workspace_update" && message.payload.kind === "upsert" + ? message.payload.workspace.id + : "", + ), + ).size, + ) + .toBe(4); + for (const entry of workspaces) { + writeFileSync(path.join(entry.workspaceDirectory!, "wave-0"), "go"); + await expect + .poll(() => existsSync(path.join(entry.workspaceDirectory!, "emitted-0"))) + .toBe(true); + } + await expect + .poll( + () => + viewerMessages.filter( + (message) => + message.type === "workspace_setup_progress" && + message.payload.detail.log.includes("wave-0:"), + ).length, + ) + .toBeGreaterThan(0); + expect( + viewerMessages + .filter((message) => message.type === "workspace_setup_progress") + .map((message) => message.payload.workspaceId), + ).toEqual(expect.arrayContaining([workspace.id])); + expect( + new Set( + viewerMessages + .filter((message) => message.type === "workspace_setup_progress") + .map((message) => message.payload.workspaceId), + ), + ).toEqual(new Set([workspace.id])); + expect(quietMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + expect( + siblingMessages.filter((message) => message.type === "workspace_setup_progress"), + ).toEqual([]); + for (const message of quietMessages) { + if (message.type !== "workspace_update" || message.payload.kind !== "upsert") continue; + expect(message.payload.workspace).not.toHaveProperty("detail"); + expect(message.payload.workspace).not.toHaveProperty("error"); + } + const waveLogs = viewerMessages + .filter( + (message) => + message.type === "workspace_setup_progress" && + message.payload.detail.log.includes("wave-0:"), + ) + .map((message) => + message.type === "workspace_setup_progress" ? message.payload.detail.log : "", + ); + expect(waveLogs.length).toBe(new Set(waveLogs).size); + const restoredSnapshots: unknown[] = []; + details.subscribe({ + snapshot: (snapshot) => restoredSnapshots.push(snapshot), + update: () => {}, + }); + const beforeReconnect = details.subscriptionId; + sockets.get("setup-viewer")![0]!.terminate(); + await expect.poll(() => restoredSnapshots.length, { timeout: 10000 }).toBe(2); + expect(details.subscriptionId).not.toBe(beforeReconnect); + expect(restoredSnapshots[1]).toMatchObject({ + snapshot: { detail: { log: expect.stringContaining("wave-0:") } }, + }); + const subscriptionId = details.subscriptionId; + await details.release(); + viewerMessages.length = 0; + writeFileSync(path.join(workspace.workspaceDirectory!, "wave-1"), "go"); + await expect + .poll(() => existsSync(path.join(workspace.workspaceDirectory!, "emitted-1"))) + .toBe(true); + // Wait until the daemon has consumed the output before checking wire silence. + await expect + .poll( + async () => (await viewer.fetchWorkspaceSetupStatus(workspace.id)).snapshot?.detail.log, + { + timeout: 30000, + }, + ) + .toContain("wave-1:"); + expect(viewerMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + const resumed = viewer.observeWorkspaceSetup(workspace.id); + observations.push(resumed); + expect((await resumed.ready).snapshot?.detail.log).toContain("wave-1:"); + expect(resumed.subscriptionId).not.toBe(subscriptionId); + writeFileSync(path.join(workspaces[1]!.workspaceDirectory!, "finish.txt"), "fail"); + await expect + .poll( + () => + quietMessages.filter( + (message) => + message.type === "workspace_update" && + message.payload.kind === "upsert" && + message.payload.workspace.id === workspaces[1]!.id && + message.payload.workspace.setupStatus === "failed", + ).length, + { timeout: 30000 }, + ) + .toBe(1); + expect(quietMessages.filter((message) => message.type === "workspace_setup_progress")).toEqual( + [], + ); + await resumed.release(); + await directory.release(); + } finally { + // End every fixture command and release observations while transports are live, + // including when an assertion fails before the normal release path. + for (const workspace of workspaces) { + writeFileSync(path.join(workspace.workspaceDirectory!, "finish.txt"), "stop"); + } + for (const observation of observations) await observation.release(); + await mcp.close(); + for (const client of clients) await client.close(); + await daemon.close(); + await rm(tempRoot, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }); + } +}, 180000); diff --git a/packages/server/src/server/workspace-setup-observation.ts b/packages/server/src/server/workspace-setup-observation.ts new file mode 100644 index 0000000000..69ad434f57 --- /dev/null +++ b/packages/server/src/server/workspace-setup-observation.ts @@ -0,0 +1,56 @@ +import type { PersistedWorkspaceRecord } from "./workspace-registry.js"; +import type { WorkspaceSetupSnapshot } from "@getpaseo/protocol/messages"; + +type Listener = ( + workspaceId: string, + snapshot: WorkspaceSetupSnapshot, + statusChanged: boolean, +) => void; + +/** Daemon-owned setup snapshots, shared by sessions; output never invalidates the directory. */ +export class WorkspaceSetupObservation { + private readonly listeners = new Set(); + private readonly statuses = new Map(); + + private readonly snapshots = new Map(); + + get(workspaceId: string): WorkspaceSetupSnapshot | undefined { + return this.snapshots.get(workspaceId); + } + + cache(workspaceId: string, snapshot: WorkspaceSetupSnapshot): void { + this.snapshots.set(workspaceId, snapshot); + } + + publish(workspaceId: string, snapshot: WorkspaceSetupSnapshot): void { + const status = this.summary(snapshot); + const statusChanged = (this.statuses.get(workspaceId) ?? null) !== status; + this.statuses.set(workspaceId, status); + this.snapshots.set(workspaceId, snapshot); + for (const listener of this.listeners) listener(workspaceId, snapshot, statusChanged); + } + + status( + workspace: Pick, + ): WorkspaceSetupSnapshot["status"] | null { + const snapshot = this.snapshots.get(workspace.workspaceId); + if (snapshot) return this.summary(snapshot); + return workspace.untrustedSource ? "blocked" : null; + } + + private summary(snapshot: WorkspaceSetupSnapshot): WorkspaceSetupSnapshot["status"] | null { + if ( + snapshot.status === "failed" || + snapshot.status === "blocked" || + snapshot.error !== null || + snapshot.detail.commands.length > 0 + ) + return snapshot.status; + return null; + } + + subscribe(listener: Listener): () => void { + this.listeners.add(listener); + return () => this.listeners.delete(listener); + } +} diff --git a/packages/server/src/server/worktree-session.ts b/packages/server/src/server/worktree-session.ts index 82f0113bd5..02b1625228 100644 --- a/packages/server/src/server/worktree-session.ts +++ b/packages/server/src/server/worktree-session.ts @@ -164,7 +164,7 @@ export type CreatePaseoWorktreeWorkflowFn = ( interface HandleWorkspaceSetupStatusRequestDependencies { emit: EmitSessionMessage; - workspaceSetupSnapshots: ReadonlyMap; + workspaceSetupSnapshots: Pick, "get">; getWorkspace: (workspaceId: string) => Promise; }