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
This commit is contained in:
Mohamed Boudra
2026-10-19 18:34:53 +07:00
committed by GitHub
parent 19749baeec
commit 1c8c68f93e
35 changed files with 1018 additions and 521 deletions
+8
View File
@@ -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:
@@ -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-", {
@@ -24,6 +24,7 @@ interface E2EDaemonClientConfig {
clientType: "cli";
appVersion?: string;
webSocketFactory?: NodeWebSocketFactory;
capabilities?: Record<string, unknown>;
}
function resolveDaemonWsUrl(port?: number): string {
@@ -34,6 +35,7 @@ export interface ConnectDaemonClientOptions {
clientIdPrefix: string;
appVersion?: string;
port?: number;
capabilities?: Record<string, unknown>;
}
/**
@@ -51,6 +53,7 @@ export async function connectDaemonClient<ClientInstance extends { connect(): Pr
clientType: "cli",
appVersion: options.appVersion ?? loadAppVersion(),
webSocketFactory: createNodeWebSocketFactory(),
capabilities: options.capabilities,
});
await client.connect();
return client;
@@ -35,6 +35,8 @@ export type { WorkspaceSetupDaemonClient };
export async function connectWorkspaceSetupClient(): Promise<WorkspaceSetupDaemonClient> {
const client = await connectDaemonClient<WorkspaceSetupDaemonClient>({
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<string>();
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);
},
};
}
@@ -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({
@@ -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<SessionOutboundMessage, { type: "activity_log" }>["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,
+1
View File
@@ -538,6 +538,7 @@ export const ar: TranslationResources = {
},
},
setup: {
updateHost: "حدّث المضيف لعرض تفاصيل إعداد مساحة العمل",
descriptor: {
label: "يثبت",
completed: "اكتمل الإعداد",
+1
View File
@@ -535,6 +535,7 @@ export const en = {
},
},
setup: {
updateHost: "Update the host to view workspace setup details",
descriptor: {
label: "Setup",
completed: "Setup completed",
+1
View File
@@ -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",
+2
View File
@@ -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",
+1
View File
@@ -544,6 +544,7 @@ export const ja: TranslationResources = {
},
},
setup: {
updateHost: "ワークスペースのセットアップ詳細を表示するにはホストを更新してください",
descriptor: {
label: "セットアップ",
completed: "セットアップ完了",
+1
View File
@@ -539,6 +539,7 @@ export const ko: TranslationResources = {
},
},
setup: {
updateHost: "작업 공간 설정 세부 정보를 보려면 호스트를 업데이트하세요",
descriptor: {
label: "설정",
completed: "설정 완료",
+1
View File
@@ -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",
+1
View File
@@ -543,6 +543,7 @@ export const ru: TranslationResources = {
},
},
setup: {
updateHost: "Обновите хост, чтобы просмотреть сведения о настройке рабочего пространства",
descriptor: {
label: "Настройка",
completed: "Настройка завершена",
+1
View File
@@ -538,6 +538,7 @@ export const zhCN: TranslationResources = {
},
},
setup: {
updateHost: "更新主机以查看工作区设置详情",
descriptor: {
label: "Setup",
completed: "Setup 已完成",
+60 -47
View File
@@ -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<string | null>(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() {
<Text style={styles.hiddenStatus} testID="workspace-setup-status">
{statusLabel}
</Text>
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
{!supportsObservation && <Alert variant="warning" title={t("workspace.setup.updateHost")} />}
{observationError && <Alert variant="error" title={observationError} />}
{supportsObservation && !observationError && (
<SetupPanelBody
serverId={serverId}
workspaceId={target.workspaceId}
snapshot={snapshot}
commands={commands}
log={log}
isWaiting={isWaiting}
hasNoSetupCommands={hasNoSetupCommands}
autoExpandIndex={autoExpandIndex}
expandedIndices={expandedIndices}
manuallyCollapsed={manuallyCollapsed}
onToggle={toggleExpanded}
/>
)}
</ScrollView>
);
}
@@ -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({});
});
@@ -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);
@@ -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<string>();
const EMPTY_SET = new Set<string>();
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(
+3
View File
@@ -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,
@@ -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<WorkspaceSetupStatusResult>) {
const calls: string[] = [];
const client: WorkspaceSetupStatusClient = {
fetchWorkspaceSetupStatus: (workspaceId) => {
calls.push(workspaceId);
return handler(workspaceId);
},
};
return { client, calls };
}
function createDeferred<T>() {
let resolve!: (value: T) => void;
let reject!: (reason: unknown) => void;
const promise = new Promise<T>((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<WorkspaceSetupStatusResult> {
return Promise.resolve(setupResult(workspaceId));
}
function rejectThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.reject(new Error("boom"));
}
return resolveDefault(workspaceId);
};
}
function nullThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
attempt += 1;
if (attempt === 1) {
return Promise.resolve(setupResult(workspaceId, null));
}
return resolveDefault(workspaceId);
};
}
function mismatchThenResolve() {
let attempt = 0;
return (workspaceId: string): Promise<WorkspaceSetupStatusResult> => {
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<WorkspaceSetupStatusResult>();
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({});
});
});
@@ -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<WorkspaceSetupStatusResult>;
}
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<string, WorkspaceSetupSnapshot>;
requestedKeys: Set<string>;
surfacedFailedSetupKeys: Set<string>;
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<WorkspaceSetupStoreState>()((set, get) => ({
export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((set) => ({
pendingWorkspaceSetup: null,
snapshots: {},
requestedKeys: new Set(),
surfacedFailedSetupKeys: new Set(),
beginWorkspaceSetup: (value) => {
set({
@@ -86,30 +83,28 @@ export const useWorkspaceSetupStore = create<WorkspaceSetupStoreState>()((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<WorkspaceSetupStoreState>()((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<WorkspaceSetupStoreState>()((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) {
+1
View File
@@ -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<Exclude<ClientCapability, typeof CLIENT_CAPS.browserHost>, true>;
@@ -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"],
+24 -1
View File
@@ -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<CorrelatedResponsePayload<"workspace.setup.subscribe.response">> {
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,
@@ -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",
+21
View File
@@ -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,
@@ -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<InboundOperation, PermissionRequirement>;
@@ -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",
+90 -9
View File
@@ -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<string, WorkspaceSetupSnapshot>;
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<string, WorkspaceSetupSnapshot>;
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<SessionInboundMessage, { type: "workspace.setup.subscribe.request" }>,
): Promise<void> {
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<void> | 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<void> {
this.sessionLogger.trace({}, "agent.session.lifecycle.cleanup");
this.isCleanedUp = true;
this.unsubscribeSetupObservation();
await this.delivery.close();
if (this.unsubscribeAgentEvents) {
@@ -175,6 +175,7 @@ interface SessionTestAccess {
emitWorkspaceUpdatesForWorkspaceIds(...args: unknown[]): Promise<unknown>;
emitWorkspaceUpdatesForExternalWorkspaceIds(workspaceIds: Iterable<string>): Promise<void>;
updateClientCapabilities(capabilities: Record<string, unknown> | 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([]);
},
);
+11 -3
View File
@@ -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<string, VoiceSpeakHandler>();
private readonly voiceCallerContexts = new Map<string, VoiceCallerContext>();
private readonly workspaceSetupSnapshots = new Map<string, WorkspaceSetupSnapshot>();
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.
+166 -1
View File
@@ -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<void>((resolve) => {
readStarted = resolve;
});
const pendingRead = new Promise<null>((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();
}
});
@@ -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<string, WebSocket[]>();
const observations: Array<{ release(): Promise<void> }> = [];
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);
@@ -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<Listener>();
private readonly statuses = new Map<string, WorkspaceSetupSnapshot["status"] | null>();
private readonly snapshots = new Map<string, WorkspaceSetupSnapshot>();
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<PersistedWorkspaceRecord, "workspaceId" | "untrustedSource">,
): 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);
}
}
@@ -164,7 +164,7 @@ export type CreatePaseoWorktreeWorkflowFn = (
interface HandleWorkspaceSetupStatusRequestDependencies {
emit: EmitSessionMessage;
workspaceSetupSnapshots: ReadonlyMap<string, WorkspaceSetupSnapshot>;
workspaceSetupSnapshots: Pick<ReadonlyMap<string, WorkspaceSetupSnapshot>, "get">;
getWorkspace: (workspaceId: string) => Promise<PersistedWorkspaceRecord | null>;
}