diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 452d1a4dbed5..ff7c9351cef9 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -43,6 +43,7 @@ import { ReviewCommentComposerSheet } from "./features/review/ReviewCommentCompo import { ReviewSheet } from "./features/review/ReviewSheet"; import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRouteScreen"; import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; +import { StopThreadProcessesFeedbackOverlay } from "./features/threads/StopThreadProcessesFeedbackOverlay"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; @@ -614,6 +615,7 @@ function RootStackLayout(props: { return ( + ) : null} - {props.actions.map((action, index) => ( - props.onPress(action)} - > - {action.image && isAppSymbolName(action.image) ? ( - - - - ) : null} - - - - {action.title} - - {action.subtitle ? ( - - {action.subtitle} + {flattenInlineMenuGroups(props.actions).map(({ action, separatorBefore }, index) => ( + + {separatorBefore ? : null} + props.onPress(action)} + > + {action.image && isAppSymbolName(action.image) ? ( + + + + ) : null} + + + + {action.title} - ) : null} - - - {(action.subactions?.length ?? 0) > 0 ? ( - - - - ) : action.state === "on" ? ( - - - - ) : null} - + {action.subtitle ? ( + + {action.subtitle} + + ) : null} + + + {(action.subactions?.length ?? 0) > 0 ? ( + + + + ) : action.state === "on" ? ( + + + + ) : null} + + ))} ); diff --git a/apps/mobile/src/features/threads/StopThreadProcessesFeedbackOverlay.tsx b/apps/mobile/src/features/threads/StopThreadProcessesFeedbackOverlay.tsx new file mode 100644 index 000000000000..90e14b27de4b --- /dev/null +++ b/apps/mobile/src/features/threads/StopThreadProcessesFeedbackOverlay.tsx @@ -0,0 +1,11 @@ +import { useAtomValue } from "@effect/atom-react"; + +import { stopThreadProcessesFeedbackAtom } from "../../state/stop-thread-processes-feedback"; +import { GitActionProgressOverlay } from "./GitActionProgressOverlay"; + +const keepFeedbackVisible = () => {}; + +export function StopThreadProcessesFeedbackOverlay() { + const progress = useAtomValue(stopThreadProcessesFeedbackAtom); + return ; +} diff --git a/apps/mobile/src/features/threads/thread-list-v2-items.tsx b/apps/mobile/src/features/threads/thread-list-v2-items.tsx index 2677474d69fd..a328c8e5e421 100644 --- a/apps/mobile/src/features/threads/thread-list-v2-items.tsx +++ b/apps/mobile/src/features/threads/thread-list-v2-items.tsx @@ -11,6 +11,7 @@ import { RowPressable } from "../../components/RowPressable"; import { CustomSnoozeSheet } from "./CustomSnoozeSheet"; import { appAtomRegistry } from "../../state/atom-registry"; import { threadArrangementOpenAtom } from "../../state/thread-order"; +import { environmentServerConfigsAtom } from "../../state/server"; import type { ThreadMoveDestination } from "./threadOrder"; import type { EnvironmentProject, @@ -20,10 +21,14 @@ import type { EnvironmentThreadSearchMatch } from "@t3tools/client-runtime/state import type { EnvironmentMachineKind } from "@t3tools/contracts"; import { canSnooze, resolveSnoozePresets } from "@t3tools/client-runtime/state/thread-settled"; import type { MenuAction } from "@react-native-menu/menu"; +import { useAtomValue } from "@effect/atom-react"; import { actionRunningPresentation } from "@t3tools/shared/actionResume"; +import { isAtomCommandInterrupted } from "@t3tools/client-runtime/state/runtime"; import * as Cause from "effect/Cause"; +import * as Option from "effect/Option"; +import { AsyncResult } from "effect/unstable/reactivity"; import { memo, useCallback, useEffect, useMemo, useState, type ComponentProps } from "react"; -import { Alert, Pressable, useWindowDimensions, View } from "react-native"; +import { Alert, Platform, Pressable, useWindowDimensions, View } from "react-native"; import type { SwipeableMethods } from "react-native-gesture-handler/ReanimatedSwipeable"; import type { ThreadListProvider } from "../../state/thread-list-environments"; @@ -39,6 +44,8 @@ import { useUniwindTheme } from "../../lib/useUniwindTheme"; import type { PendingNewTask } from "../../state/use-pending-new-tasks"; import { threadEnvironment } from "../../state/threads"; import { terminalEnvironment } from "../../state/terminal"; +import { previewEnvironment } from "../../state/preview"; +import { beginStopThreadProcessesFeedback } from "../../state/stop-thread-processes-feedback"; import { useAtomCommand } from "../../state/use-atom-command"; import { useThreadPr } from "../../state/use-thread-pr"; import { useSwipeRowDormant } from "../home/swipe-row-activation"; @@ -60,6 +67,7 @@ import { PersistentThreadIcon } from "./PersistentThreadIcon"; import { buildThreadPersistenceMenuItems, persistenceIntentForMenuEvent, + withThreadMenuDividers, } from "./thread-persistence-menu"; import { resolveWorktreeCleanupStatus, shouldShowActionWaitingIndicator } from "./thread-status"; import { ThreadSearchMatchExcerpt } from "./thread-search-match"; @@ -595,6 +603,38 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, { reportFailure: false }); + const stopThreadProcesses = useAtomCommand(previewEnvironment.hostingStopThread, { + reportFailure: false, + }); + const hasRunningSubprocess = useAtomValue( + terminalEnvironment.metadata({ environmentId: thread.environmentId, input: null }), + useCallback( + (result) => + Option.exists(AsyncResult.value(result), (terminals) => + terminals.some( + (terminal) => terminal.threadId === thread.id && terminal.hasRunningSubprocess, + ), + ), + [thread.id], + ), + ); + const hasPreviewLease = useAtomValue( + previewEnvironment.hostingLeases({ environmentId: thread.environmentId, input: {} }), + useCallback( + (result) => + Option.exists(AsyncResult.value(result), (leases) => + leases.some((lease) => lease.threadId === thread.id), + ), + [thread.id], + ), + ); + const supportsProcessControls = useAtomValue( + environmentServerConfigsAtom, + (configs) => + configs.get(thread.environmentId)?.environment.capabilities.previewHostingProcessControl === + true, + ); + const hasManagedProcesses = supportsProcessControls && (hasRunningSubprocess || hasPreviewLease); const { providerDrivers, providerIconUrl } = useMemo(() => { const provider = props.providers?.find( @@ -703,6 +743,26 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { } }, [closeTerminal, runningAction, thread.environmentId, thread.id]); + const handleStopThreadProcesses = useCallback(async () => { + const finishFeedback = beginStopThreadProcessesFeedback(thread.title); + const result = await stopThreadProcesses({ + environmentId: thread.environmentId, + input: { threadId: thread.id }, + }); + if (result._tag === "Success") { + finishFeedback("success"); + } else if (isAtomCommandInterrupted(result)) { + finishFeedback("interrupted"); + } else { + finishFeedback("error"); + const error = Cause.squash(result.cause); + Alert.alert( + "Could not stop all previews and processes", + error instanceof Error ? error.message : "The previews and processes could not be stopped.", + ); + } + }, [stopThreadProcesses, thread.environmentId, thread.id, thread.title]); + const handleRetryWorktreeCleanup = useCallback(async () => { const result = await retryWorktreeCleanup({ environmentId: thread.environmentId, @@ -874,11 +934,21 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { const withPersistence = useCallback( (actions: ReadonlyArray) => buildThreadPersistenceMenuItems({ - actions, + actions: hasManagedProcesses + ? [ + { + id: "stop-thread-processes", + title: "Stop all previews & processes", + image: "stop.fill", + attributes: { destructive: true }, + }, + ...actions, + ] + : actions, persistent: thread.persistent === true, supported: props.persistenceSupported, }), - [props.persistenceSupported, thread.persistent], + [hasManagedProcesses, props.persistenceSupported, thread.persistent], ); const snoozableCardMenuActions = useMemo( () => @@ -975,6 +1045,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { copyTextWithHaptic(thread.id, { target: "thread-id" }); } if (nativeEvent.event === "cancel-action") void handleCancelAction(); + if (nativeEvent.event === "stop-thread-processes") void handleStopThreadProcesses(); if (nativeEvent.event === "retry-worktree-cleanup") void handleRetryWorktreeCleanup(); if (nativeEvent.event === "keep-worktree") handleKeepWorktree(); const persistenceIntent = persistenceIntentForMenuEvent(nativeEvent.event); @@ -1000,6 +1071,7 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { thread, handleArchive, handleCancelAction, + handleStopThreadProcesses, handleKeepWorktree, handlePersistence, handleRetryWorktreeCleanup, @@ -1471,27 +1543,30 @@ export const ThreadListV2Row = memo(function ThreadListV2Row(props: { ? FAILED_CLEANUP_MENU_ACTIONS : cleanupPending ? [] - : [ - ...(thread.branch - ? [ - { - id: "new-thread-on-branch", - title: getThreadListV2NewBranchMenuTitle(thread.branch), - image: "square.and.pencil", - }, - ] - : []), - { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, - ...(snoozedRow - ? snoozedMenuActions - : !props.settlementSupported - ? legacyMenuActions - : canUnsettle - ? slimMenuActions - : swipeActions.secondary === "snooze" - ? snoozableCardMenuActions - : cardMenuActions), - ] + : withThreadMenuDividers( + [ + ...(thread.branch + ? [ + { + id: "new-thread-on-branch", + title: getThreadListV2NewBranchMenuTitle(thread.branch), + image: "square.and.pencil", + }, + ] + : []), + { id: "copy-thread-id", title: "Copy thread ID", image: "doc.on.doc" }, + ...(snoozedRow + ? snoozedMenuActions + : !props.settlementSupported + ? legacyMenuActions + : canUnsettle + ? slimMenuActions + : swipeActions.secondary === "snooze" + ? snoozableCardMenuActions + : cardMenuActions), + ], + Platform.OS === "ios" || Platform.OS === "android", + ) } onPressAction={handleMenuAction} shouldOpenOnLongPress={cleanupFailed || !cleanupPending} diff --git a/apps/mobile/src/features/threads/thread-persistence-menu.test.ts b/apps/mobile/src/features/threads/thread-persistence-menu.test.ts index 9f85d7cca72d..510b2a470369 100644 --- a/apps/mobile/src/features/threads/thread-persistence-menu.test.ts +++ b/apps/mobile/src/features/threads/thread-persistence-menu.test.ts @@ -3,6 +3,7 @@ import { describe, expect, it } from "vite-plus/test"; import { buildThreadPersistenceMenuItems, persistenceIntentForMenuEvent, + withThreadMenuDividers, } from "./thread-persistence-menu.ts"; describe("buildThreadPersistenceMenuItems", () => { @@ -41,3 +42,41 @@ describe("buildThreadPersistenceMenuItems", () => { expect(persistenceIntentForMenuEvent("archive")).toBeNull(); }); }); + +describe("thread menu dividers", () => { + const actions = [ + { id: "new-thread-on-branch", title: "New thread on feature" }, + { id: "annotate", title: "Annotate thread" }, + { id: "mark-persistent", title: "Mark as persistent thread" }, + { id: "stop-thread-processes", title: "Stop all previews & processes" }, + { id: "archive", title: "Archive" }, + ]; + + it("keeps persistence and stop together between inline native dividers", () => { + const groups = withThreadMenuDividers(actions, true); + expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([ + ["new-thread-on-branch"], + ["annotate"], + ["mark-persistent", "stop-thread-processes"], + ["archive"], + ]); + expect(groups.every((group) => group.displayInline === true)).toBe(true); + }); + + it("ends the persistence group when there is nothing to stop", () => { + const groups = withThreadMenuDividers( + actions.filter((action) => action.id !== "stop-thread-processes"), + true, + ); + expect(groups.map((group) => group.subactions?.map((action) => action.id))).toEqual([ + ["new-thread-on-branch"], + ["annotate"], + ["mark-persistent"], + ["archive"], + ]); + }); + + it("keeps commands directly accessible when inline groups are unsupported", () => { + expect(withThreadMenuDividers(actions, false)).toEqual(actions); + }); +}); diff --git a/apps/mobile/src/features/threads/thread-persistence-menu.ts b/apps/mobile/src/features/threads/thread-persistence-menu.ts index 14440616248e..c42bc55b2557 100644 --- a/apps/mobile/src/features/threads/thread-persistence-menu.ts +++ b/apps/mobile/src/features/threads/thread-persistence-menu.ts @@ -6,6 +6,37 @@ export function persistenceIntentForMenuEvent(event: string): boolean | null { return null; } +/** Inline native groups place dividers without turning commands into submenus. */ +export function withThreadMenuDividers( + actions: ReadonlyArray, + supportsInlineGroups: boolean, +): MenuAction[] { + if (!supportsInlineGroups) return [...actions]; + const groups: MenuAction[][] = []; + let current: MenuAction[] = []; + actions.forEach((action, index) => { + current.push(action); + const endsGroup = + action.id === "new-thread-on-branch" || + action.id === "annotate" || + action.id === "stop-thread-processes" || + ((action.id === "mark-persistent" || action.id === "disable-persistence") && + actions[index + 1]?.id !== "stop-thread-processes"); + if (endsGroup) { + groups.push(current); + current = []; + } + }); + if (current.length > 0) groups.push(current); + if (groups.length < 2) return [...actions]; + return groups.map((subactions, index) => ({ + id: `thread-menu-group-${index}`, + title: "", + displayInline: true, + subactions, + })); +} + export function buildThreadPersistenceMenuItems(input: { readonly actions: ReadonlyArray; readonly persistent: boolean; diff --git a/apps/mobile/src/lib/inline-menu-groups.test.ts b/apps/mobile/src/lib/inline-menu-groups.test.ts new file mode 100644 index 000000000000..d1e364029621 --- /dev/null +++ b/apps/mobile/src/lib/inline-menu-groups.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vite-plus/test"; +import { flattenInlineMenuGroups } from "./inline-menu-groups"; + +describe("Android inline menu groups", () => { + it("keeps commands at the same level with dividers between groups", () => { + const rows = flattenInlineMenuGroups([ + { title: "", displayInline: true, subactions: [{ id: "new", title: "New thread" }] }, + { + title: "", + displayInline: true, + subactions: [ + { id: "persistent", title: "Mark persistent" }, + { id: "stop", title: "Stop processes" }, + ], + }, + { id: "archive", title: "Archive" }, + ]); + expect(rows.map(({ action, separatorBefore }) => [action.id, separatorBefore])).toEqual([ + ["new", false], + ["persistent", true], + ["stop", false], + ["archive", true], + ]); + }); + + it("preserves real submenus and skips empty or hidden groups", () => { + const submenu = { id: "copy", title: "Copy", subactions: [{ id: "id", title: "Thread ID" }] }; + expect( + flattenInlineMenuGroups([ + { title: "", displayInline: true, subactions: [] }, + { + title: "", + displayInline: true, + subactions: [{ title: "Hidden", attributes: { hidden: true } }], + }, + submenu, + ]), + ).toEqual([{ action: submenu, separatorBefore: false }]); + }); +}); diff --git a/apps/mobile/src/lib/inline-menu-groups.ts b/apps/mobile/src/lib/inline-menu-groups.ts new file mode 100644 index 000000000000..bdb24a479bd8 --- /dev/null +++ b/apps/mobile/src/lib/inline-menu-groups.ts @@ -0,0 +1,23 @@ +import type { MenuAction } from "@react-native-menu/menu"; + +/** Android renders UIKit-style inline groups as direct commands with dividers. */ +export function flattenInlineMenuGroups( + actions: ReadonlyArray, +): Array<{ action: MenuAction; separatorBefore: boolean }> { + const rows: Array<{ action: MenuAction; separatorBefore: boolean }> = []; + let afterGroup = false; + for (const action of actions) { + if (action.attributes?.hidden) continue; + if (action.displayInline && action.subactions) { + const group = flattenInlineMenuGroups(action.subactions); + if (group.length === 0) continue; + group[0]!.separatorBefore = rows.length > 0; + rows.push(...group); + afterGroup = true; + } else { + rows.push({ action, separatorBefore: afterGroup }); + afterGroup = false; + } + } + return rows; +} diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.test.ts b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts new file mode 100644 index 000000000000..bd9c65c4c979 --- /dev/null +++ b/apps/mobile/src/state/stop-thread-processes-feedback.test.ts @@ -0,0 +1,157 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test"; + +import { appAtomRegistry } from "./atom-registry"; +import { + beginStopThreadProcessesFeedback, + stopThreadProcessesFeedbackAtom, +} from "./stop-thread-processes-feedback"; + +const feedback = () => appAtomRegistry.get(stopThreadProcessesFeedbackAtom); + +describe("stop thread processes feedback", () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.runAllTimers(); + vi.useRealTimers(); + }); + + it("shows progress immediately and keeps a fast success visible for three seconds total", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + expect(feedback()).toMatchObject({ phase: "running", description: "Selected thread" }); + vi.advanceTimersByTime(500); + finish("success"); + vi.advanceTimersByTime(2_499); + expect(feedback().phase).toBe("success"); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + + it("never clears pending feedback or claims success before slow shutdown completes", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + vi.advanceTimersByTime(8_000); + expect(feedback().phase).toBe("running"); + finish("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("reports unsuccessful shutdown without claiming completion", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + finish("error"); + expect(feedback()).toMatchObject({ + phase: "error", + label: "Could not confirm previews and processes stopped", + }); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("clears interrupted feedback immediately without allowing late completion to restore it", () => { + const finish = beginStopThreadProcessesFeedback("Selected thread"); + vi.advanceTimersByTime(500); + finish("interrupted"); + expect(feedback()).toEqual({ phase: "idle", label: null, description: null }); + finish("success"); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("keeps an older shutdown pending when a newer request succeeds and expires", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(500); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + vi.advanceTimersByTime(500); + finishSecond("success"); + expect(feedback()).toMatchObject({ phase: "running", description: "1 stopping · 1 stopped" }); + vi.advanceTimersByTime(2_499); + expect(feedback().description).toBe("1 stopping · 1 stopped"); + vi.advanceTimersByTime(1); + expect(feedback()).toMatchObject({ phase: "running", description: "First thread" }); + vi.advanceTimersByTime(4_500); + expect(feedback().phase).toBe("running"); + finishFirst("success"); + expect(feedback()).toMatchObject({ phase: "success", description: "First thread" }); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("interrupts a newer request without clearing an older shutdown or losing its result", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishSecond("interrupted"); + expect(feedback()).toMatchObject({ phase: "running", description: "First thread" }); + finishSecond("error"); + expect(feedback().phase).toBe("running"); + finishFirst("error"); + expect(feedback()).toMatchObject({ phase: "error", description: "First thread" }); + vi.advanceTimersByTime(3_000); + expect(feedback().phase).toBe("idle"); + }); + + it("shows every completed outcome while another shutdown remains pending", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + const finishThird = beginStopThreadProcessesFeedback("Third thread"); + const finishFourth = beginStopThreadProcessesFeedback("Fourth thread"); + finishFirst("success"); + finishSecond("success"); + finishThird("error"); + expect(feedback()).toMatchObject({ + phase: "running", + description: "1 stopping · 2 stopped · 1 failed", + }); + vi.advanceTimersByTime(2_999); + expect(feedback().description).toBe("1 stopping · 2 stopped · 1 failed"); + vi.advanceTimersByTime(1); + expect(feedback()).toMatchObject({ phase: "running", description: "Fourth thread" }); + finishFourth("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); + + it("retains overlapping results until each request's own minimum visibility expires", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + vi.advanceTimersByTime(500); + finishFirst("error"); + expect(feedback()).toMatchObject({ phase: "running", description: "1 stopping · 1 failed" }); + vi.advanceTimersByTime(1_000); + finishSecond("success"); + expect(feedback()).toMatchObject({ phase: "error", description: "1 stopped · 1 failed" }); + vi.advanceTimersByTime(500); + expect(feedback()).toMatchObject({ phase: "success", description: "Second thread" }); + vi.advanceTimersByTime(999); + expect(feedback().phase).toBe("success"); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + + it("keeps newer feedback and its dismissal when an older request is interrupted", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishSecond("success"); + finishFirst("interrupted"); + vi.advanceTimersByTime(2_999); + expect(feedback()).toMatchObject({ phase: "success", description: "Second thread" }); + vi.advanceTimersByTime(1); + expect(feedback().phase).toBe("idle"); + }); + + it("keeps a newer selected thread's feedback when an older request completes", () => { + const finishFirst = beginStopThreadProcessesFeedback("First thread"); + finishFirst("success"); + vi.advanceTimersByTime(1_000); + const finishSecond = beginStopThreadProcessesFeedback("Second thread"); + finishFirst("error"); + vi.advanceTimersByTime(3_000); + expect(feedback()).toMatchObject({ phase: "running", description: "Second thread" }); + finishSecond("success"); + vi.runOnlyPendingTimers(); + expect(feedback().phase).toBe("idle"); + }); +}); diff --git a/apps/mobile/src/state/stop-thread-processes-feedback.ts b/apps/mobile/src/state/stop-thread-processes-feedback.ts new file mode 100644 index 000000000000..65e6c7a5268e --- /dev/null +++ b/apps/mobile/src/state/stop-thread-processes-feedback.ts @@ -0,0 +1,75 @@ +import { Atom } from "effect/unstable/reactivity"; + +import { appAtomRegistry } from "./atom-registry"; +import type { GitActionProgress } from "./use-vcs-action-state"; + +const EMPTY_FEEDBACK: GitActionProgress = { phase: "idle", label: null, description: null }; +const MIN_VISIBLE_MS = 3_000; + +export const stopThreadProcessesFeedbackAtom = Atom.make(EMPTY_FEEDBACK).pipe(Atom.keepAlive); + +interface FeedbackRequest { + readonly threadTitle: string; + readonly startedAt: number; + phase: "running" | "success" | "error"; +} + +const requests = new Set(); + +function publishFeedback() { + if (requests.size === 0) { + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, EMPTY_FEEDBACK); + return; + } + const counts = { running: 0, success: 0, error: 0 }; + for (const request of requests) counts[request.phase] += 1; + const phase = counts.running > 0 ? "running" : counts.error > 0 ? "error" : "success"; + appAtomRegistry.set(stopThreadProcessesFeedbackAtom, { + phase, + label: + phase === "running" + ? "Stopping all previews and processes…" + : phase === "success" + ? "All previews and processes stopped" + : "Could not confirm previews and processes stopped", + description: + requests.size === 1 + ? (requests.values().next().value?.threadTitle ?? null) + : [ + counts.running > 0 ? `${counts.running} stopping` : null, + counts.success > 0 ? `${counts.success} stopped` : null, + counts.error > 0 ? `${counts.error} failed` : null, + ] + .filter((count) => count !== null) + .join(" · "), + }); +} + +/** Feedback lives above recycled rows and stays pending until shutdown completes. */ +export function beginStopThreadProcessesFeedback(threadTitle: string) { + const request: FeedbackRequest = { + threadTitle, + startedAt: Date.now(), + phase: "running", + }; + requests.add(request); + publishFeedback(); + + return (phase: "success" | "error" | "interrupted") => { + if (!requests.has(request) || request.phase !== "running") return; + if (phase === "interrupted") { + requests.delete(request); + publishFeedback(); + return; + } + request.phase = phase; + publishFeedback(); + setTimeout( + () => { + requests.delete(request); + publishFeedback(); + }, + Math.max(0, MIN_VISIBLE_MS - (Date.now() - request.startedAt)), + ); + }; +} diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index c485b6c5427d..888b3936929b 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -61,13 +61,19 @@ describe("RPC authorization scopes", () => { expect(requiredScopeForRpcMethod(WS_METHODS.cloudInstallRelayClient)).toBe(AuthRelayWriteScope); }); - it("keeps hosted preview recovery read-only for listing and terminal-authorized for launch", () => { + it("keeps hosted preview observation read-only and process control terminal-authorized", () => { expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingList)).toBe( AuthOrchestrationReadScope, ); expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingRecover)).toBe( AuthTerminalOperateScope, ); + expect(requiredScopeForRpcMethod(WS_METHODS.subscribePreviewHosting)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.previewHostingStopThread)).toBe( + AuthTerminalOperateScope, + ); }); it("requires permission to operate on a thread before uploading feedback", () => { diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 665cb5cc735e..69cd65e7d5dc 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -193,6 +193,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.previewList]: AuthOrchestrationReadScope, [WS_METHODS.previewHostingList]: AuthOrchestrationReadScope, [WS_METHODS.previewHostingRecover]: AuthTerminalOperateScope, + [WS_METHODS.previewHostingStopThread]: AuthTerminalOperateScope, + [WS_METHODS.subscribePreviewHosting]: AuthOrchestrationReadScope, [WS_METHODS.previewReportStatus]: AuthOrchestrationOperateScope, [WS_METHODS.previewClaimRecovery]: AuthOrchestrationOperateScope, [WS_METHODS.previewAutomationConnect]: AuthOrchestrationOperateScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index db16e7174c07..efddcc37686f 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -239,6 +239,7 @@ export const make = Effect.gen(function* () { usageModelAliases: true, threadPinning: true, threadPersistence: true, + previewHostingProcessControl: true, threadPinReorder: true, threadActiveReorder: true, threadAutoSettleOptOut: true, diff --git a/apps/server/src/mcp/toolkits/preview/handlers.ts b/apps/server/src/mcp/toolkits/preview/handlers.ts index a678249b4011..cdb54869c47c 100644 --- a/apps/server/src/mcp/toolkits/preview/handlers.ts +++ b/apps/server/src/mcp/toolkits/preview/handlers.ts @@ -192,6 +192,21 @@ export const claimPreviewRecording = Effect.fn("PreviewToolkit.claimRecording")( const isPreviewHostingError = Schema.is(PreviewHosting.PreviewHostingError); const handlers = { + preview_stop_thread: () => + Effect.gen(function* () { + const scope = yield* McpInvocationContext.requireMcpCapability("preview"); + const hosting = yield* PreviewHosting.PreviewHosting; + return yield* hosting.stopThread(scope.threadId).pipe( + Effect.as({}), + Effect.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Some previews or processes could not be stopped. Please try again.", + }), + ), + ); + }), preview_host: (input) => Effect.gen(function* () { const scope = yield* McpInvocationContext.requireMcpCapability("preview"); diff --git a/apps/server/src/mcp/toolkits/preview/tools.test.ts b/apps/server/src/mcp/toolkits/preview/tools.test.ts index 63a26e3b8330..c327fb6ba5d2 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.test.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.test.ts @@ -43,7 +43,7 @@ it("exports provider-compatible object schemas with described parameters", () => if (tool.name === "preview_navigate") { expect(schemaHasMultipleAllOfDescriptions(schema)).toBe(false); } - if (tool.name !== "preview_host") { + if (tool.name !== "preview_host" && tool.name !== "preview_stop_thread") { expect( schema.properties?.tabId, `${tool.name} must allow an explicit collaborative browser tab target`, diff --git a/apps/server/src/mcp/toolkits/preview/tools.ts b/apps/server/src/mcp/toolkits/preview/tools.ts index 5cb4ba577c50..439ef4707ef2 100644 --- a/apps/server/src/mcp/toolkits/preview/tools.ts +++ b/apps/server/src/mcp/toolkits/preview/tools.ts @@ -80,6 +80,18 @@ const PreviewHostTool = Tool.make("preview_host", { .annotate(Tool.OpenWorld, true) .annotate(Tool.Destructive, true); +const PreviewStopThreadTool = Tool.make("preview_stop_thread", { + description: + "Permanently cancel every managed preview lease and stop all app-managed terminal processes in this agent's own thread. Stopped previews cannot restart when their old links are opened. Ordinary terminal history is preserved, and other threads and agent providers are untouched.", + success: PreviewActionResult, + failure: Schema.Union([PreviewHostingError, PreviewAutomationUnavailableError]), + dependencies: [McpInvocationContext.McpInvocationContext, PreviewHosting.PreviewHosting], +}) + .annotate(Tool.Title, "Stop thread previews and processes") + .annotate(Tool.OpenWorld, true) + .annotate(Tool.Destructive, true) + .annotate(Tool.Idempotent, true); + const PreviewOpenTool = browserTool( Tool.make("preview_open", { description: @@ -260,6 +272,7 @@ const PreviewRecordingStopTool = safeBrowserTool( export const PreviewToolkit = Toolkit.make( PreviewHostTool, + PreviewStopThreadTool, PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, @@ -278,6 +291,7 @@ export const PreviewToolkit = Toolkit.make( export const PreviewStandardToolkit = Toolkit.make( PreviewHostTool, + PreviewStopThreadTool, PreviewStatusTool, PreviewOpenTool, PreviewNavigateTool, diff --git a/apps/server/src/preview/Hosting.test.ts b/apps/server/src/preview/Hosting.test.ts index 0e392af3daaa..3efb658d18c4 100644 --- a/apps/server/src/preview/Hosting.test.ts +++ b/apps/server/src/preview/Hosting.test.ts @@ -19,6 +19,7 @@ import { PreviewHostingLeaseId, ThreadId, type DiscoveredLocalServer, + type PreviewHostingLeaseMetadata, type TerminalOpenInput, type TerminalSummary, } from "@t3tools/contracts"; @@ -26,6 +27,8 @@ import * as ServerConfig from "../config.ts"; import * as PortScanner from "./PortScanner.ts"; import * as PreviewHosting from "./Hosting.ts"; import * as TerminalManager from "../terminal/Manager.ts"; +import type * as PtyAdapter from "../terminal/PtyAdapter.ts"; +import * as ProcessRunner from "../processRunner.ts"; const PREVIEW_URL = "http://localhost:5173/field-examples"; const encodePersistedHostingState = Schema.encodeSync( @@ -36,6 +39,7 @@ const encodePersistedHostingState = Schema.encodeSync( }), ), ); +const encodeUnknownJson = Schema.encodeEffect(Schema.fromJsonString(Schema.Unknown)); interface TerminalHarness { readonly opens: TerminalOpenInput[]; @@ -60,10 +64,13 @@ interface TerminalHarness { readonly terminalId?: string | undefined; }) => Effect.Effect; readonly onClose?: () => Effect.Effect; + readonly onWaitForThreadShutdown?: ( + threadId: string, + ) => Effect.Effect; } function terminalLayer(harness: TerminalHarness) { - return Layer.mock(TerminalManager.TerminalManager)({ + const service = { open: (input) => { harness.opens.push(input); return (harness.onOpen?.(input) ?? Effect.void).pipe( @@ -180,9 +187,29 @@ function terminalLayer(harness: TerminalHarness) { }, metadata: Effect.sync(() => harness.liveSummaries ?? harness.summaries), history: () => Effect.die("Startup transcripts must remain local."), + waitForThreadShutdown: (threadId) => harness.onWaitForThreadShutdown?.(threadId) ?? Effect.void, refreshMetadata: (harness.onRefreshMetadata?.() ?? Effect.void).pipe( Effect.as(harness.summaries), ), + } satisfies Partial; + return Layer.mock(TerminalManager.TerminalManager)({ + ...service, + shutdownThread: (threadId) => + Effect.gen(function* () { + const sessions = (yield* service.metadata).filter( + (terminal) => terminal.threadId === threadId, + ); + const results = yield* Effect.forEach( + sessions, + (terminal) => + service.close({ threadId, terminalId: terminal.terminalId }).pipe(Effect.result), + { concurrency: "unbounded" }, + ); + const shutdown = yield* service.waitForThreadShutdown(threadId).pipe(Effect.result); + const failure = results.find((result) => result._tag === "Failure"); + if (failure?._tag === "Failure") return yield* failure.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; + }), }); } @@ -233,6 +260,45 @@ function testTerminalHarness(overrides: Partial = {}): Terminal }; } +function terminalFixture( + threadId: string, + terminalId: string, + status: TerminalSummary["status"] = "running", + hasRunningSubprocess = true, +): TerminalSummary { + return { + threadId, + terminalId, + cwd: "/workspace", + worktreePath: "/workspace", + status, + pid: status === "running" ? 100 : null, + exitCode: null, + exitSignal: null, + hasRunningSubprocess, + label: terminalId, + updatedAt: "1970-01-01T00:00:00.000Z", + }; +} + +function leaseFixture( + overrides: Partial = {}, +): PreviewHosting.PreviewHostingLease { + return { + id: "preview-lease", + threadId: "thread-1", + terminalId: "preview-terminal", + command: "pnpm dev --port 5173", + cwd: "/workspace", + worktreePath: "/workspace", + url: PREVIEW_URL, + handedOffAt: "1970-01-01T00:00:00.000Z", + expiresAt: "1970-01-02T00:00:00.000Z", + status: "active", + ...overrides, + }; +} + function hostingLayer( config: ServerConfig.ServerConfig["Service"], harness: TerminalHarness, @@ -306,6 +372,777 @@ function failFirstExpiredStateWriteLayer( } describe("PreviewHosting", () => { + it.effect.each([false, true])( + "lets other threads launch while shutdown blocks new launches in the stopping thread (%s)", + (failClose) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-isolation-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const shutdownStarted = yield* Deferred.make(); + const finishShutdown = yield* Deferred.make(); + let shutdownFinished = false; + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal")], + failClose, + onWaitForThreadShutdown: () => + Effect.gen(function* () { + yield* Deferred.succeed(shutdownStarted, undefined); + yield* Deferred.await(finishShutdown); + shutdownFinished = true; + }), + onOpen: (input) => + Effect.sync(() => { + if (input.threadId === "thread-1") assert.isTrue(shutdownFinished); + }), + }); + // Keep path normalization synchronous so the immediately-started launch + // reaches reservation ordering before the unrelated launch begins. + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + realPath: (filePath) => Effect.succeed(filePath), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const stopping = yield* hosting + .stopThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(shutdownStarted); + const sameThread = yield* hosting + .launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace/one", + worktreePath: "/workspace/one", + url: PREVIEW_URL, + }) + .pipe(Effect.forkScoped({ startImmediately: true })); + const other = yield* hosting.launch({ + threadId: "thread-2", + command: "pnpm dev --port 5174", + cwd: "/workspace/two", + worktreePath: "/workspace/two", + url: "http://localhost:5174/", + }); + + assert.equal(other.status, "active"); + assert.isFalse(shutdownFinished); + assert.isUndefined(stopping.pollUnsafe()); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.deepEqual( + harness.opens.map((terminal) => terminal.threadId), + ["thread-2"], + ); + yield* Deferred.succeed(finishShutdown, undefined); + assert.equal((yield* Fiber.join(stopping))._tag, failClose ? "Failure" : "Success"); + const restarted = yield* Fiber.join(sameThread); + assert.equal(restarted.status, "active"); + assert.deepEqual(yield* hosting.list("thread-1"), [restarted]); + assert.deepEqual(yield* hosting.list("thread-2"), [other]); + assert.deepEqual( + harness.opens.map((terminal) => terminal.threadId), + ["thread-2", "thread-1"], + ); + }).pipe(Effect.provide(hostingLayer(config, harness, true, [], true, fileSystemLayer))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([false, true])( + "waits for thread cleanup before completing even when terminal close fails (%s)", + (failClose) => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-drain-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const cleanupStarted = yield* Deferred.make(); + const finishCleanup = yield* Deferred.make(); + const otherTerminal = terminalFixture("thread-2", "unrelated-terminal"); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal"), otherTerminal], + failClose, + onWaitForThreadShutdown: (threadId) => + Effect.gen(function* () { + assert.equal(threadId, "thread-1"); + yield* Deferred.succeed(cleanupStarted, undefined); + yield* Deferred.await(finishCleanup); + }), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const stopping = yield* hosting + .stopThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(cleanupStarted); + assert.isUndefined(stopping.pollUnsafe()); + assert.deepEqual(harness.closes, [ + { threadId: "thread-1", terminalId: "command-terminal" }, + ]); + assert.include(harness.summaries, otherTerminal); + yield* Deferred.succeed(finishCleanup, undefined); + assert.equal((yield* Fiber.join(stopping))._tag, failClose ? "Failure" : "Success"); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("surfaces failed process cleanup instead of reporting a successful stop", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-kill-failure-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const failure = new TerminalManager.TerminalShutdownError({ + threadId: "thread-1", + terminalIds: ["command-terminal"], + }); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "command-terminal")], + onWaitForThreadShutdown: () => Effect.fail(failure), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.equal(result.failure, failure); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("a second Stop retries a failed terminal without touching another thread", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-retry-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const processes: Array< + PtyAdapter.PtyProcess & { + killSignals: Array; + killFailure: Error | undefined; + } + > = []; + const terminalsLayer = Layer.effect( + TerminalManager.TerminalManager, + TerminalManager.makeWithOptions({ + logsDir: config.terminalLogsDir, + processKillGraceMs: 0, + processTable: Effect.succeed([]), + ptyAdapter: { + spawn: () => + Effect.sync(() => { + const exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + let exitEvent: PtyAdapter.PtyExitEvent | undefined; + const ptyProcess = { + pid: 9000 + processes.length, + killSignals: [] as Array, + killFailure: undefined as Error | undefined, + write: () => {}, + resize: () => {}, + kill: (signal?: string) => { + ptyProcess.killSignals.push(signal); + if (ptyProcess.killFailure !== undefined) throw ptyProcess.killFailure; + if (signal === "SIGKILL" && exitEvent === undefined) { + exitEvent = { exitCode: 0, signal: 9 }; + for (const listener of exitListeners) listener(exitEvent); + exitListeners.clear(); + } + }, + onData: () => () => {}, + onExit: (callback: (event: PtyAdapter.PtyExitEvent) => void) => { + if (exitEvent !== undefined) { + callback(exitEvent); + return () => {}; + } + exitListeners.add(callback); + return () => { + exitListeners.delete(callback); + }; + }, + }; + processes.push(ptyProcess); + return ptyProcess; + }), + }, + }), + ).pipe(Layer.provide(ProcessRunner.layer)); + const dependencies = Layer.mergeAll( + ServerConfig.layer(config), + terminalsLayer, + discoveryLayer(true, [], testTerminalHarness()), + ).pipe(Layer.provideMerge(NodeServices.layer)); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const terminals = yield* TerminalManager.TerminalManager; + yield* terminals.open({ + threadId: "thread-1", + terminalId: "command-terminal", + cwd: root, + }); + yield* terminals.open({ + threadId: "thread-2", + terminalId: "unrelated-terminal", + cwd: root, + }); + const first = processes[0]!; + const other = processes[1]!; + first.killFailure = new Error("transient signal failure"); + + const failure = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(failure._tag, "Failure"); + if (failure._tag === "Failure") { + assert.equal(failure.failure._tag, "TerminalShutdownError"); + } + assert.deepEqual(first.killSignals, ["SIGTERM"]); + assert.deepEqual(other.killSignals, []); + assert.sameMembers( + (yield* terminals.metadata).map((terminal) => terminal.threadId), + ["thread-1", "thread-2"], + ); + + first.killFailure = undefined; + yield* hosting.stopThread("thread-1"); + assert.deepEqual(first.killSignals, ["SIGTERM", "SIGTERM", "SIGKILL"]); + assert.deepEqual(other.killSignals, []); + assert.deepEqual( + (yield* terminals.metadata).map((terminal) => terminal.threadId), + ["thread-2"], + ); + yield* terminals.close({ threadId: "thread-2" }); + yield* terminals.waitForThreadShutdown("thread-2"); + }).pipe(Effect.provide(PreviewHosting.layer.pipe(Layer.provideMerge(dependencies)))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each(["launch", "recover"] as const)( + "stops a preview immediately while %s is waiting for a server that never becomes ready", + (operation) => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-readiness-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const wrote = yield* Deferred.make(); + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "ordinary-terminal")], + onWrite: () => Deferred.succeed(wrote, undefined).pipe(Effect.asVoid), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const pending = yield* ( + operation === "recover" + ? hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }) + : hosting.launch({ + threadId: preview.threadId, + command: preview.command, + cwd: preview.cwd, + worktreePath: preview.worktreePath, + url: preview.url, + }) + ).pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(wrote); + yield* hosting.stopThread("thread-1"); + const cancelled = yield* Fiber.join(pending); + if (operation === "recover") { + assert.equal(cancelled._tag, "Success"); + if (cancelled._tag === "Success") assert.isNull(cancelled.success); + } else { + assert.equal(cancelled._tag, "Failure"); + if (cancelled._tag === "Failure") { + assert.equal(cancelled.failure._tag, "PreviewHostingError"); + } + } + assert.deepEqual(harness.summaries, []); + assert.deepEqual(yield* hosting.list(), []); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "ordinary-terminal", + ); + }).pipe(Effect.provide(hostingLayer(config, harness, false))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "subscribes to initial and changing environment-wide lease summaries without launch secrets", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-subscribe-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const first = leaseFixture({ + command: "SECRET=fixture-secret pnpm dev", + env: { SECRET: "fixture-secret" }, + }); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [first] }), + ); + const harness = testTerminalHarness(); + const snapshots: Array> = []; + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const unsubscribe = yield* hosting.subscribe((leases) => + Effect.sync(() => { + snapshots.push(leases); + }), + ); + assert.deepEqual(snapshots, [ + [ + { + leaseId: first.id, + threadId: ThreadId.make(first.threadId), + terminalId: first.terminalId, + url: first.url, + handedOffAt: first.handedOffAt, + expiresAt: first.expiresAt, + status: "active", + }, + ], + ]); + const second = yield* hosting.launch({ + threadId: "thread-2", + command: "pnpm dev --port 5174", + cwd: "/workspace/two", + env: { SECRET: "another-fixture-secret" }, + url: "http://localhost:5174/", + }); + assert.isTrue( + snapshots.some((leases) => + leases.some((lease) => lease.leaseId === second.id && lease.status === "starting"), + ), + ); + assert.isTrue( + snapshots + .at(-1) + ?.some((lease) => lease.leaseId === second.id && lease.status === "active"), + ); + assert.sameMembers(snapshots.at(-1)?.map((lease) => lease.threadId) ?? [], [ + "thread-1", + "thread-2", + ]); + const serializedSnapshots = yield* encodeUnknownJson(snapshots); + assert.notInclude(serializedSnapshots, "fixture-secret"); + assert.notInclude(serializedSnapshots, '"command"'); + assert.notInclude(serializedSnapshots, '"env"'); + assert.notInclude(serializedSnapshots, '"cwd"'); + yield* hosting.stopThread("thread-1"); + assert.deepEqual( + snapshots.at(-1)?.map((lease) => lease.leaseId), + [second.id], + ); + unsubscribe(); + const count = snapshots.length; + yield* hosting.stopThread("thread-2"); + assert.equal(snapshots.length, count); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "cannot miss a lease mutation while its initial subscription snapshot is being delivered", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-subscribe-race-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const initialStarted = yield* Deferred.make(); + const releaseInitial = yield* Deferred.make(); + const mutationStarted = yield* Deferred.make(); + const harness = testTerminalHarness(); + const snapshots: Array> = []; + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const pendingSubscription = yield* Effect.forkScoped( + hosting.subscribe((leases) => + Effect.gen(function* () { + snapshots.push(leases); + if (snapshots.length === 1) { + yield* Deferred.succeed(initialStarted, undefined); + yield* Deferred.await(releaseInitial); + } + }), + ), + ); + yield* Deferred.await(initialStarted); + const pendingLaunch = yield* Effect.forkScoped( + Deferred.succeed(mutationStarted, undefined).pipe( + Effect.andThen( + hosting.launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace", + url: PREVIEW_URL, + }), + ), + ), + ); + yield* Deferred.await(mutationStarted); + yield* Deferred.succeed(releaseInitial, undefined); + const unsubscribe = yield* Fiber.join(pendingSubscription); + const lease = yield* Fiber.join(pendingLaunch); + assert.deepEqual(snapshots[0], []); + assert.equal(snapshots.at(-1)?.[0]?.leaseId, lease.id); + assert.equal(snapshots.at(-1)?.[0]?.status, "active"); + unsubscribe(); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect.each([1, 3])( + "keeps an in-flight recovery cancelled even if terminal close fails (metadata phase: %s)", + (pauseAt) => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-stop-recover-race-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const refreshStarted = yield* Deferred.make(); + const releaseRefresh = yield* Deferred.make(); + const cancelled = yield* Deferred.make(); + let refreshCount = 0; + const harness = testTerminalHarness({ + summaries: [terminalFixture("thread-1", "ordinary-terminal")], + failClose: (input) => pauseAt === 3 && input.terminalId === preview.terminalId, + onRefreshMetadata: () => + Effect.suspend(() => { + refreshCount++; + return refreshCount === pauseAt + ? Deferred.succeed(refreshStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRefresh)), + ) + : Effect.void; + }), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const unsubscribe = yield* hosting.subscribe((leases) => + leases.length === 0 + ? Deferred.succeed(cancelled, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + const pendingRecovery = yield* Effect.forkScoped( + hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + yield* Deferred.await(refreshStarted); + const pendingStop = yield* Effect.forkScoped( + hosting.stopThread("thread-1").pipe(Effect.result), + ); + yield* Deferred.await(cancelled); + yield* Deferred.succeed(releaseRefresh, undefined); + assert.isNull(yield* Fiber.join(pendingRecovery)); + assert.equal( + (yield* Fiber.join(pendingStop))._tag, + pauseAt === 3 ? "Failure" : "Success", + ); + assert.equal(harness.opens.length, pauseAt === 3 ? 1 : 0); + assert.equal(harness.writes.length, pauseAt === 3 ? 1 : 0); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "ordinary-terminal", + ); + assert.deepEqual(yield* hosting.list(), []); + unsubscribe(); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "cancels running and stopped previews and closes every terminal in only the owning thread", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-thread-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const running = leaseFixture(); + const stopped = leaseFixture({ + id: "stopped-preview", + terminalId: "stopped-preview-terminal", + url: "http://localhost:5174/", + }); + const other = leaseFixture({ + id: "other-preview", + threadId: "thread-2", + terminalId: "other-preview-terminal", + url: "http://localhost:5175/", + }); + const otherTerminal = terminalFixture("thread-2", other.terminalId); + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", running.terminalId), + terminalFixture("thread-1", stopped.terminalId, "exited", false), + terminalFixture("thread-1", "command-terminal"), + terminalFixture("thread-1", "idle-shell", "running", false), + terminalFixture("thread-1", "stopped-shell", "exited", false), + otherTerminal, + ], + }); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [running, stopped, other] }), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + assert.equal((yield* hosting.list("thread-1")).length, 2); + yield* hosting.stopThread("thread-1"); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.deepEqual(yield* hosting.list("thread-2"), [other]); + for (const lease of [running, stopped]) { + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(lease.id), + url: lease.url, + }), + ); + } + assert.deepEqual(harness.summaries, [otherTerminal]); + assert.sameMembers( + harness.closes.map((terminal) => terminal.terminalId), + [ + running.terminalId, + stopped.terminalId, + "command-terminal", + "idle-shell", + "stopped-shell", + ], + ); + assert.isTrue(harness.closes.every((terminal) => terminal.threadId === "thread-1")); + assert.sameMembers( + harness.historyDeletes.map((terminal) => terminal.terminalId), + [running.terminalId, stopped.terminalId], + ); + assert.deepEqual(harness.opens, []); + assert.equal( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + `${encodePersistedHostingState({ version: 1, leases: [other] })}\n`, + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + yield* Effect.scoped( + Effect.gen(function* () { + const restarted = yield* PreviewHosting.PreviewHosting; + assert.isNull( + yield* restarted.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(running.id), + url: running.url, + }), + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect("attempts every ordinary terminal even when preview and terminal cleanup fail", () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-failure-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + let shouldFail = true; + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", preview.terminalId), + terminalFixture("thread-1", "failing-command"), + terminalFixture("thread-1", "other-command"), + terminalFixture("thread-2", "unrelated-command"), + ], + failClose: (input) => + shouldFail && + (input.terminalId === preview.terminalId || input.terminalId === "failing-command"), + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "failing-command", + ); + assert.include( + harness.closes.map((terminal) => terminal.terminalId), + "other-command", + ); + assert.notInclude( + harness.closes.map((terminal) => terminal.terminalId), + "unrelated-command", + ); + assert.deepEqual(yield* hosting.list("thread-1"), []); + assert.include( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + '"status":"expired"', + ); + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + shouldFail = false; + yield* hosting.stopThread("thread-1"); + assert.deepEqual(harness.summaries, [terminalFixture("thread-2", "unrelated-command")]); + assert.deepEqual(yield* hosting.list(), []); + assert.notInclude( + harness.historyDeletes.map((terminal) => terminal.terminalId), + "failing-command", + ); + }).pipe(Effect.provide(hostingLayer(config, harness))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + + it.effect( + "keeps cancelled previews unrecoverable and stops ordinary terminals after a persistence failure", + () => + Effect.gen(function* () { + yield* TestClock.setTime(1_000); + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ prefix: "preview-hosting-stop-persist-" }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const preview = leaseFixture(); + yield* fs.makeDirectory(config.stateDir, { recursive: true }); + yield* fs.writeFileString( + `${config.stateDir}/preview-hosting.json`, + encodePersistedHostingState({ version: 1, leases: [preview] }), + ); + const failedWrite = yield* Deferred.make(); + const releaseFailure = yield* Deferred.make(); + const persistedEmptyState = yield* Deferred.make(); + yield* Deferred.succeed(releaseFailure, undefined); + const harness = testTerminalHarness({ + summaries: [ + terminalFixture("thread-1", preview.terminalId), + terminalFixture("thread-1", "command-terminal"), + ], + }); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const result = yield* hosting.stopThread("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") assert.equal(result.failure._tag, "PreviewHostingError"); + assert.isTrue(yield* Deferred.isDone(failedWrite)); + assert.isTrue(yield* Deferred.isDone(persistedEmptyState)); + assert.sameMembers( + harness.closes.map((terminal) => terminal.terminalId), + [preview.terminalId, "command-terminal"], + ); + assert.deepEqual(harness.summaries, []); + assert.isNull( + yield* hosting.recover({ + threadId: "thread-1", + leaseId: PreviewHostingLeaseId.make(preview.id), + url: preview.url, + }), + ); + assert.equal( + yield* fs.readFileString(`${config.stateDir}/preview-hosting.json`), + `${encodePersistedHostingState({ version: 1, leases: [] })}\n`, + ); + }).pipe( + Effect.provide( + hostingLayer( + config, + harness, + true, + [], + true, + failFirstExpiredStateWriteLayer(failedWrite, releaseFailure, persistedEmptyState), + ), + ), + ), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("launches a named terminal and fixes its 24-hour expiry at handoff", () => Effect.gen(function* () { yield* TestClock.setTime(1_000); @@ -677,6 +1514,79 @@ describe("PreviewHosting", () => { }).pipe(Effect.provide(Layer.mergeAll(NodeServices.layer, TestClock.layer()))), ); + it.effect("reserves discovery ports atomically across concurrent thread launches", () => + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const root = yield* fs.makeTempDirectoryScoped({ + prefix: "preview-hosting-concurrent-reservation-", + }); + const config = yield* Effect.provide( + ServerConfig.ServerConfig, + ServerConfig.layerTest(process.cwd(), root), + ); + const reservationStarted = yield* Deferred.make(); + const finishReservation = yield* Deferred.make(); + let blockedFirstReservation = false; + const fileSystemLayer = Layer.succeed(FileSystem.FileSystem, { + ...fs, + realPath: (filePath) => Effect.succeed(filePath), + writeFileString: (filePath, data, options) => + Effect.suspend(() => { + const write = fs.writeFileString(filePath, data, options); + if (blockedFirstReservation || !data.includes('"status":"starting"')) return write; + blockedFirstReservation = true; + return Deferred.succeed(reservationStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishReservation)), + Effect.andThen(write), + ); + }), + }); + const harness = testTerminalHarness(); + yield* Effect.scoped( + Effect.gen(function* () { + const hosting = yield* PreviewHosting.PreviewHosting; + const first = yield* hosting + .launch({ + threadId: "thread-1", + command: "pnpm dev --port 5173", + cwd: "/workspace/one", + worktreePath: "/workspace/one", + url: PREVIEW_URL, + }) + .pipe(Effect.forkScoped); + yield* Deferred.await(reservationStarted); + // This launch reaches the reservation gate while the first lease's + // durable write is blocked, before that lease appears in memory. + const second = yield* hosting + .launch({ + threadId: "thread-2", + command: "pnpm dev --port 5173", + cwd: "/workspace/two", + worktreePath: "/workspace/two", + url: "http://127.0.0.1:5173/another-page", + }) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + yield* Deferred.succeed(finishReservation, undefined); + + const lease = yield* Fiber.join(first); + const conflict = yield* Fiber.join(second); + assert.equal(lease.status, "active"); + assert.equal(conflict._tag, "Failure"); + if (conflict._tag === "Failure") { + assert.equal(conflict.failure._tag, "PreviewHostingError"); + if (conflict.failure._tag === "PreviewHostingError") { + assert.equal(conflict.failure.operation, "validate"); + assert.match(conflict.failure.detail ?? "", /discovery port 5173/); + } + } + assert.deepEqual(yield* hosting.list(), [lease]); + assert.equal(harness.opens.length, 1); + assert.equal(harness.writes.length, 1); + }).pipe(Effect.provide(hostingLayer(config, harness, true, [], true, fileSystemLayer))), + ); + }).pipe(Effect.provide(NodeServices.layer)), + ); + it.effect("reserves a discovery port across threads regardless of path or loopback alias", () => Effect.gen(function* () { const fs = yield* FileSystem.FileSystem; diff --git a/apps/server/src/preview/Hosting.ts b/apps/server/src/preview/Hosting.ts index 96efd16f073a..36e8a53046a8 100644 --- a/apps/server/src/preview/Hosting.ts +++ b/apps/server/src/preview/Hosting.ts @@ -1,3 +1,4 @@ +import * as KeyedLock from "@t3tools/shared/KeyedLock"; import { isLoopbackHost } from "@t3tools/shared/preview"; import { PREVIEW_URL_MAX_LENGTH, @@ -5,6 +6,7 @@ import { ThreadId, PreviewHostingLeaseId, type PreviewHostingLeaseSummary as ContractPreviewHostingLeaseSummary, + type PreviewHostingLeaseMetadata, type TerminalOpenInput, type DiscoveredLocalServer, } from "@t3tools/contracts"; @@ -129,6 +131,9 @@ export class PreviewHosting extends Context.Service< readonly list: ( threadId?: string, ) => Effect.Effect, PreviewHostingError>; + readonly subscribe: ( + listener: (leases: ReadonlyArray) => Effect.Effect, + ) => Effect.Effect<() => void, PreviewHostingError>; readonly ownsTerminal: ( threadId: string, terminalId: string, @@ -136,6 +141,12 @@ export class PreviewHosting extends Context.Service< readonly removeThread: ( threadId: string, ) => Effect.Effect; + readonly stopThread: ( + threadId: string, + ) => Effect.Effect< + void, + PreviewHostingError | TerminalManager.TerminalError | TerminalManager.TerminalShutdownError + >; readonly protectedWorkspacePaths: () => Effect.Effect< ReadonlyArray, PreviewHostingError @@ -267,6 +278,44 @@ const make = Effect.gen(function* () { ), ); const leasesRef = yield* SynchronizedRef.make>(initialLeases); + const listeners = new Set< + (leases: ReadonlyArray) => Effect.Effect + >(); + + const activeLeases = ( + leases: ReadonlyArray, + currentTime: number, + threadId?: string, + ) => + leases + .filter( + (lease) => + lease.status !== "expired" && + Date.parse(lease.expiresAt) > currentTime && + (threadId === undefined || lease.threadId === threadId), + ) + .toSorted((left, right) => left.expiresAt.localeCompare(right.expiresAt)); + + const leaseSummaries = (leases: ReadonlyArray) => + Effect.map(DateTime.now, (now) => + activeLeases(leases, DateTime.toEpochMillis(now)).map((lease) => ({ + ...toPreviewHostingLeaseSummary(lease), + terminalId: lease.terminalId, + })), + ); + + const publishLeases = Effect.fnUntraced(function* (leases: ReadonlyArray) { + if (listeners.size === 0) return; + const summaries = yield* leaseSummaries(leases); + yield* Effect.forEach([...listeners], (listener) => listener(summaries), { + concurrency: "unbounded", + discard: true, + }).pipe( + Effect.catchCause((cause) => + Effect.logWarning("failed to deliver preview lease update", { cause }), + ), + ); + }); const persistState = (leases: ReadonlyArray) => writeFileStringAtomically({ @@ -299,6 +348,7 @@ const make = Effect.gen(function* () { const [result, next] = f(current); yield* persistState(next); yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); if (wakeWorker) yield* Queue.offer(wakeups, undefined); return result; }), @@ -391,7 +441,15 @@ const make = Effect.gen(function* () { let timeoutMs = 0; const result = yield* Effect.scoped( Effect.gen(function* () { - const ready = yield* Deferred.make(); + const ready = yield* Deferred.make(); + yield* Effect.acquireRelease( + subscribe((leases) => + leases.some((entry) => entry.leaseId === lease.id) + ? Effect.void + : Deferred.succeed(ready, null).pipe(Effect.asVoid), + ), + (unsubscribe) => Effect.sync(unsubscribe), + ); const listener = (servers: ReadonlyArray) => { const expectedUrl = discoveryUrl(lease.url); const responding = servers.find((server) => discoveryUrl(server.url) === expectedUrl); @@ -413,7 +471,7 @@ const make = Effect.gen(function* () { const currentTime = yield* nowMillis; const remainingLeaseMs = Math.max(0, Date.parse(lease.expiresAt) - currentTime); timeoutMs = Math.min(READY_TIMEOUT_MS, remainingLeaseMs); - if (timeoutMs === 0) return Option.none(); + if (timeoutMs === 0) return Option.none(); return yield* Deferred.await(ready).pipe(Effect.timeoutOption(Duration.millis(timeoutMs))); }), ); @@ -460,13 +518,16 @@ const make = Effect.gen(function* () { const launchCommandIfNeeded = (lease: PreviewHostingLease) => Effect.gen(function* () { const summary = yield* terminalSummary(lease); - if (summary?.status === "running" && summary.hasRunningSubprocess) return; + const latest = yield* findLease(lease.id); + if (latest === null || latest.status === "expired") return false; + if (summary?.status === "running" && summary.hasRunningSubprocess) return true; yield* terminals.open(openInput(lease)); yield* terminals.write({ threadId: lease.threadId, terminalId: lease.terminalId, data: lease.command.endsWith("\n") ? lease.command : `${lease.command}\n`, }); + return true; }); const closeOwnedTerminal = (lease: PreviewHostingLease) => @@ -512,8 +573,16 @@ const make = Effect.gen(function* () { const startsHandoff = lease.status === "starting"; yield* verifyLaunchOwnership(lease); - yield* launchCommandIfNeeded(lease); - yield* waitForReady(lease); + if (!(yield* launchCommandIfNeeded(lease))) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + return null; + } + if ((yield* waitForReady(lease)) === null) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + return null; + } const afterReady = yield* findLease(lease.id); const completedAt = yield* nowMillis; if ( @@ -544,8 +613,16 @@ const make = Effect.gen(function* () { : {}), status: "active" as const, }; - yield* setLease(active); - return active; + const committed = yield* changeLeases((leases) => { + const latest = leases.find((entry) => entry.id === lease.id); + if (latest === undefined || latest.status === "expired") return [null, leases]; + return [active, leases.map((entry) => (entry.id === lease.id ? active : entry))]; + }); + if (committed === null) { + const latest = yield* findLease(lease.id); + if (latest !== null) yield* expireLocked(latest); + } + return committed; }), ); @@ -557,17 +634,23 @@ const make = Effect.gen(function* () { // A concurrent launch may already have handed this reservation off. if (latest === null || latest.status === "active") return; const expired = { ...latest, status: "expired" as const }; - yield* SynchronizedRef.update(leasesRef, (leases) => - leases.map((entry) => (entry.id === expired.id ? expired : entry)), - ); - yield* setLease(expired, false).pipe( - Effect.catch((error) => - Effect.logWarning("failed to persist failed preview lease cleanup state", { - threadId: lease.threadId, - terminalId: lease.terminalId, - error: error.message, - }), - ), + yield* persistLock.withPermit( + Effect.gen(function* () { + const next = (yield* SynchronizedRef.get(leasesRef)).map((entry) => + entry.id === expired.id ? expired : entry, + ); + yield* persistState(next).pipe( + Effect.catch((error) => + Effect.logWarning("failed to persist failed preview lease cleanup state", { + threadId: lease.threadId, + terminalId: lease.terminalId, + error: error.message, + }), + ), + ); + yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); + }), ); yield* expireLocked(expired).pipe( Effect.catch((error) => @@ -604,6 +687,9 @@ const make = Effect.gen(function* () { } }); + // Only same-thread reservations wait for terminal cleanup. Readiness stays + // outside this lock so cleanup can cancel launches and recoveries immediately. + const threadLocks = yield* KeyedLock.make(); const launchGate = yield* Semaphore.make(1); const launch: PreviewHosting["Service"]["launch"] = (requestedInput) => Effect.gen(function* () { @@ -634,7 +720,7 @@ const make = Effect.gen(function* () { ); const leaseForCleanup = yield* SynchronizedRef.make(null); const operation = Effect.gen(function* () { - const selected = yield* launchGate.withPermit( + const reservation = launchGate.withPermit( withWorkspaceLease( path.resolve(input.worktreePath ?? input.cwd), Effect.gen(function* () { @@ -759,6 +845,7 @@ const make = Effect.gen(function* () { }), ), ); + const selected = yield* threadLocks.withLock(input.threadId, reservation); // The durable reservation is visible to cleanup before the workspace lock // is released. TerminalManager.open takes the same lock itself, so readiness @@ -812,16 +899,24 @@ const make = Effect.gen(function* () { if (startupError !== null) return yield* startupError; const currentTime = yield* nowMillis; const leases = yield* SynchronizedRef.get(leasesRef); - return leases - .filter( - (lease) => - lease.status !== "expired" && - Date.parse(lease.expiresAt) > currentTime && - (threadId === undefined || lease.threadId === threadId), - ) - .toSorted((left, right) => left.expiresAt.localeCompare(right.expiresAt)); + return activeLeases(leases, currentTime, threadId); }); + const subscribe: PreviewHosting["Service"]["subscribe"] = (listener) => + persistLock.withPermit( + Effect.gen(function* () { + if (startupError !== null) return yield* startupError; + const summaries = yield* leaseSummaries(yield* SynchronizedRef.get(leasesRef)); + // Registration and the initial snapshot share the mutation lock so no + // lease update can fall between them. + yield* listener(summaries); + listeners.add(listener); + return () => { + listeners.delete(listener); + }; + }), + ); + const ownsTerminal: PreviewHosting["Service"]["ownsTerminal"] = (threadId, terminalId) => Effect.gen(function* () { if (startupError !== null) return yield* startupError; @@ -836,32 +931,58 @@ const make = Effect.gen(function* () { ); }); + const removeThreadLocked = Effect.fnUntraced(function* (threadId: string) { + if (startupError !== null) return yield* startupError; + const { owned, cancellation } = yield* persistLock.withPermit( + Effect.uninterruptible( + Effect.gen(function* () { + const current = yield* SynchronizedRef.get(leasesRef); + const owned = current.filter((lease) => lease.threadId === threadId); + const next = current.map((lease) => + lease.threadId === threadId ? { ...lease, status: "expired" as const } : lease, + ); + const cancellation = yield* (owned.length === 0 ? Effect.void : persistState(next)).pipe( + Effect.result, + ); + // Even a failed disk write must prevent an in-flight recovery from + // restarting a preview while best-effort terminal cleanup runs. + yield* SynchronizedRef.set(leasesRef, next); + yield* publishLeases(next); + yield* Queue.offer(wakeups, undefined); + return { owned, cancellation }; + }), + ), + ); + const results = yield* Effect.forEach( + owned, + (lease) => + withLeaseLock( + lease.id, + Effect.gen(function* () { + const latest = yield* findLease(lease.id); + if (latest === null || latest.threadId !== threadId) return; + yield* closeOwnedTerminal(latest); + yield* removeLease(latest.id); + }), + ).pipe(Effect.result), + { concurrency: "unbounded" }, + ); + if (cancellation._tag === "Failure") return yield* cancellation.failure; + const failure = results.find((result) => result._tag === "Failure"); + if (failure?._tag === "Failure") return yield* failure.failure; + }); + const removeThread: PreviewHosting["Service"]["removeThread"] = (threadId) => - launchGate.withPermit( + threadLocks.withLock(threadId, removeThreadLocked(threadId)); + + const stopThread: PreviewHosting["Service"]["stopThread"] = (threadId) => + threadLocks.withLock( + threadId, Effect.gen(function* () { - if (startupError !== null) return yield* startupError; - const owned = (yield* SynchronizedRef.get(leasesRef)).filter( - (lease) => lease.threadId === threadId, - ); - const results = yield* Effect.forEach( - owned, - (lease) => - withLeaseLock( - lease.id, - Effect.gen(function* () { - const latest = yield* findLease(lease.id); - if (latest === null || latest.threadId !== threadId) return; - const expired = - latest.status === "expired" ? latest : { ...latest, status: "expired" as const }; - if (expired !== latest) yield* setLease(expired); - yield* closeOwnedTerminal(expired); - yield* removeLease(expired.id); - }), - ).pipe(Effect.result), - { concurrency: "unbounded" }, - ); - const failure = results.find((result) => result._tag === "Failure"); - if (failure?._tag === "Failure") return yield* failure.failure; + const previews = yield* removeThreadLocked(threadId).pipe(Effect.result); + const shutdown = yield* terminals.shutdownThread(threadId).pipe(Effect.result); + if (previews._tag === "Failure") return yield* previews.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; }), ); @@ -937,8 +1058,10 @@ const make = Effect.gen(function* () { launch, recover, list, + subscribe, ownsTerminal, removeThread, + stopThread, protectedWorkspacePaths, }); }); diff --git a/apps/server/src/terminal/Manager.test.ts b/apps/server/src/terminal/Manager.test.ts index 41a4f5131659..2f7e0974356a 100644 --- a/apps/server/src/terminal/Manager.test.ts +++ b/apps/server/src/terminal/Manager.test.ts @@ -59,8 +59,15 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { killFailure: unknown | undefined; private readonly dataListeners = new Set<(data: string) => void>(); private readonly exitListeners = new Set<(event: PtyAdapter.PtyExitEvent) => void>(); + private exitEvent: PtyAdapter.PtyExitEvent | undefined; killed = false; exitOnSubscribe: PtyAdapter.PtyExitEvent | undefined; + exitOnKill: string | undefined = "SIGKILL"; + onKill: ((signal: string | undefined) => void) | undefined; + + get exitListenerCount(): number { + return this.exitListeners.size; + } constructor(pid: number) { this.pid = pid; @@ -86,6 +93,10 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { if (this.killFailure !== undefined) { throw this.killFailure; } + this.onKill?.(signal); + if (this.exitOnKill !== undefined && signal === this.exitOnKill) { + this.emitExit({ exitCode: 0, signal: signal === "SIGKILL" ? 9 : null }); + } } onData(callback: (data: string) => void): () => void { @@ -96,7 +107,11 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } onExit(callback: (event: PtyAdapter.PtyExitEvent) => void): () => void { - if (this.exitOnSubscribe) callback(this.exitOnSubscribe); + const exited = this.exitEvent ?? this.exitOnSubscribe; + if (exited !== undefined) { + callback(exited); + return () => {}; + } this.exitListeners.add(callback); return () => { this.exitListeners.delete(callback); @@ -110,9 +125,12 @@ class FakePtyProcess implements PtyAdapter.PtyProcess { } emitExit(event: PtyAdapter.PtyExitEvent): void { + if (this.exitEvent !== undefined) return; + this.exitEvent = event; for (const listener of this.exitListeners) { listener(event); } + this.exitListeners.clear(); } } @@ -239,6 +257,7 @@ interface CreateManagerOptions { >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; + processExitWaitMs?: number; maxRetainedInactiveSessions?: number; historyByteLimit?: number; ptyAdapter?: FakePtyAdapter; @@ -247,6 +266,7 @@ interface CreateManagerOptions { >[0]["resolveProviderInstanceEnvironment"]; managedBinaryCacheDir?: string; managedBinaryToolsDir?: string; + unregisterTerminal?: Parameters[0]["unregisterTerminal"]; } interface ManagerFixture { @@ -292,6 +312,7 @@ const createManager = ( ? { subprocessPollIntervalMs: options.subprocessPollIntervalMs } : {}), processKillGraceMs: options.processKillGraceMs ?? 1, + processExitWaitMs: options.processExitWaitMs ?? 10, ...(options.maxRetainedInactiveSessions !== undefined ? { maxRetainedInactiveSessions: options.maxRetainedInactiveSessions } : {}), @@ -304,6 +325,9 @@ const createManager = ( managedBinaryCacheDir: options.managedBinaryCacheDir, managedBinaryToolsDir: options.managedBinaryToolsDir, }), + ...(options.unregisterTerminal === undefined + ? {} + : { unregisterTerminal: options.unregisterTerminal }), }); const eventsRef = yield* Ref.make>([]); const unsubscribe = yield* manager.subscribe((event) => @@ -488,6 +512,390 @@ it.layer( }), ); + it.effect.each(["awaiting-exit", "metadata-removed"] as const)( + "rejects attachment queued during shutdown when %s and permits a fresh attachment afterward", + (phase) => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const removalStarted = yield* Deferred.make(); + const closeStarted = yield* Deferred.make(); + const releaseClose = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 100, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removalStarted, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + const unsubscribeEvents = yield* manager.subscribe((event) => + phase === "metadata-removed" && event.type === "closed" + ? Deferred.succeed(closeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseClose)), + ) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeEvents)); + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(forceKillSent); + if (phase === "metadata-removed") { + // Close owns the thread lock while delivering this event, so a slow + // event consumer pins Stop independently of the exit observers. + yield* Deferred.await(closeStarted); + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Deferred.await(removalStarted); + expect(yield* manager.metadata).toEqual([]); + } + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const attaching = yield* manager + .attachStream(openInput(), (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + + if (phase === "awaiting-exit") process.emitExit({ exitCode: 0, signal: 9 }); + else yield* Deferred.succeed(releaseClose, undefined); + yield* Fiber.join(stopping); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(rejectedEvents).toEqual([]); + expect(yield* manager.metadata).toEqual([]); + + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(rejectedEvents).toEqual([]); + ptyAdapter.processes[1]!.exitOnKill = "SIGTERM"; + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("does not auto-open a session observed alive before concurrent idle close", () => + Effect.gen(function* () { + const checking = yield* Deferred.make(); + const finishCheck = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + subprocessInspector: () => + Deferred.succeed(checking, undefined).pipe( + Effect.andThen(Deferred.await(finishCheck)), + Effect.as({ hasRunningSubprocess: false, childCommand: null, processIds: [] }), + ), + }); + yield* manager.open(openInput()); + const closing = yield* manager.closeIdle({ threadId: "thread-1" }).pipe(Effect.forkScoped); + yield* Deferred.await(checking); + const attaching = yield* manager + .attachStream(openInput(), () => Effect.void) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.processes[0]?.killSignals).toEqual([]); + + yield* Deferred.succeed(finishCheck, undefined); + yield* Fiber.join(closing); + yield* manager.waitForThreadShutdown("thread-1"); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(yield* manager.metadata).toEqual([]); + + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "keeps concurrent shutdown protected after interruption and clears it after completion", + () => + Effect.gen(function* () { + const firstCleanup = yield* Deferred.make(); + const secondCleanup = yield* Deferred.make(); + const releaseFirst = yield* Deferred.make(); + const releaseSecond = yield* Deferred.make(); + const gates = [ + { started: firstCleanup, release: releaseFirst }, + { started: secondCleanup, release: releaseSecond }, + ]; + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + unregisterTerminal: ({ threadId }) => + Effect.suspend(() => { + const gate = threadId === "thread-1" ? gates.shift() : undefined; + return gate === undefined + ? Effect.void + : Deferred.succeed(gate.started, undefined).pipe( + Effect.andThen(Deferred.await(gate.release)), + ); + }), + }); + yield* manager.open(openInput()); + const first = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(firstCleanup); + const second = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.forkScoped({ startImmediately: true })); + yield* Fiber.interrupt(first); + yield* Deferred.await(secondCleanup); + const attaching = yield* manager + .attachStream(openInput({ terminalId: "new-terminal" }), () => Effect.void) + .pipe(Effect.result, Effect.forkScoped({ startImmediately: true })); + expect(attaching.pollUnsafe()).toBeUndefined(); + const unsubscribeOther = yield* manager.attachStream( + openInput({ threadId: "thread-2" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOther)); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + + yield* Deferred.succeed(releaseSecond, undefined); + yield* Fiber.join(second); + const result = yield* Fiber.join(attaching); + expect(result._tag === "Failure" ? result.failure._tag : null).toBe( + "TerminalNotRunningError", + ); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + const unsubscribe = yield* manager.attachStream( + openInput({ terminalId: "new-terminal" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + + it.effect("does not recreate a detached terminal from metadata during normal kill grace", () => + Effect.gen(function* () { + const termSent = yield* Deferred.make(); + const removed = yield* Deferred.make(); + let environmentResolutions = 0; + const { manager, ptyAdapter, logsDir } = yield* createManager(5, { + processKillGraceMs: 10, + resolveProviderInstanceEnvironment: () => + Effect.sync(() => { + environmentResolutions += 1; + return {}; + }), + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.onKill = (signal) => { + if (signal === "SIGTERM") Deferred.doneUnsafe(termSent, Effect.void); + }; + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + + yield* manager.close({ threadId: "thread-1", deleteHistory: true }); + yield* Deferred.await(termSent); + const retainedMetadata = yield* manager.metadata; + const historyPath = yield* historyLogPath(logsDir); + expect(yield* pathExists(historyPath)).toBe(false); + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const error = yield* manager + .attachStream( + { + ...openInput({ cwd: "/missing-terminal-directory" }), + providerInstanceId: ProviderInstanceId.make("test-provider"), + restartIfNotRunning: true, + }, + (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + + expect(error._tag).toBe("TerminalNotRunningError"); + expect(environmentResolutions).toBe(0); + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.metadata).toEqual(retainedMetadata); + expect(yield* pathExists(historyPath)).toBe(false); + + yield* TestClock.adjust("10 millis"); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + const snapshots: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.attachStream(openInput(), (event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(snapshots).toEqual([ + expect.objectContaining({ + type: "snapshot", + snapshot: expect.objectContaining({ + status: "running", + pid: ptyAdapter.processes[1]?.pid, + }), + }), + ]); + expect(rejectedEvents).toEqual([]); + ptyAdapter.processes[1]!.exitOnKill = "SIGTERM"; + yield* manager.shutdownThread("thread-1"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "keeps failed retained terminals visible without respawning on repeated attachment", + () => + Effect.gen(function* () { + const removed = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.killFailure = new Error("signal failure"); + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "remove" && event.threadId === "thread-1" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + expect((yield* manager.shutdownThread("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + const retainedMetadata = yield* manager.metadata; + const rejectedEvents: TerminalAttachStreamEvent[] = []; + for (let attempt = 0; attempt < 3; attempt += 1) { + const error = yield* manager + .attachStream(openInput(), (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + expect(error._tag).toBe("TerminalNotRunningError"); + expect(yield* manager.metadata).toEqual(retainedMetadata); + } + expect(ptyAdapter.spawnInputs).toHaveLength(1); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(retainedMetadata).toEqual([ + expect.objectContaining({ + threadId: "thread-1", + pid: process.pid, + status: "running", + hasRunningSubprocess: true, + }), + ]); + + const unsubscribeOtherThread = yield* manager.attachStream( + openInput({ threadId: "thread-2" }), + () => Effect.void, + ); + const unsubscribeOtherId = yield* manager.attachStream( + openInput({ terminalId: "other-terminal" }), + () => Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOtherThread)); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeOtherId)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + expect(process.killSignals).toEqual(["SIGTERM"]); + + process.emitExit({ exitCode: 0, signal: null }); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + const unsubscribe = yield* manager.attachStream(openInput(), () => Effect.void); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + + expect(ptyAdapter.spawnInputs).toHaveLength(4); + expect(process.killSignals).toEqual(["SIGTERM"]); + expect(rejectedEvents).toEqual([]); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + + it.effect( + "blocks processless session attachment but attaches to an explicit live replacement", + () => + Effect.gen(function* () { + const replacementVisible = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const original = ptyAdapter.processes[0]!; + original.killFailure = new Error("signal failure"); + ptyAdapter.spawnFailures.push(new Error("replacement spawn failed")); + const failedRestart = yield* manager.restart(restartInput()); + expect(failedRestart.status).toBe("error"); + expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + const retainedMetadata = yield* manager.metadata; + const rejectedEvents: TerminalAttachStreamEvent[] = []; + const error = yield* manager + .attachStream({ ...openInput(), restartIfNotRunning: true }, (event) => + Effect.sync(() => { + rejectedEvents.push(event); + }), + ) + .pipe(Effect.flip); + + expect(error._tag).toBe("TerminalNotRunningError"); + expect(ptyAdapter.spawnInputs).toHaveLength(2); + expect(ptyAdapter.processes).toHaveLength(1); + expect(yield* manager.metadata).toEqual(retainedMetadata); + expect(original.killSignals).toEqual(["SIGTERM"]); + + const replacement = yield* manager.restart(restartInput()); + const attachedEvents: TerminalAttachStreamEvent[] = []; + const unsubscribe = yield* manager.attachStream( + { ...openInput(), restartIfNotRunning: true }, + (event) => + Effect.sync(() => { + attachedEvents.push(event); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + expect(ptyAdapter.spawnInputs).toHaveLength(3); + expect(attachedEvents).toEqual([ + expect.objectContaining({ type: "snapshot", snapshot: replacement }), + ]); + expect(original.killSignals).toEqual(["SIGTERM"]); + const unsubscribeMetadata = yield* manager.subscribeMetadata((event) => + event.type === "upsert" && event.terminal.pid === replacement.pid + ? Deferred.succeed(replacementVisible, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribeMetadata)); + + original.emitExit({ exitCode: 0, signal: null }); + yield* Deferred.await(replacementVisible); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ status: "running", pid: replacement.pid }), + ]); + expect(rejectedEvents).toEqual([]); + yield* manager.shutdownThread("thread-1"); + }), + ); + it.effect("keeps attach streams live when a terminal id is closed and reopened", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); @@ -1940,7 +2348,7 @@ it.layer( it.effect("keeps a closing terminal in blocker metadata until kill escalation finishes", () => Effect.gen(function* () { - const { manager } = yield* createManager(5, { processKillGraceMs: 10 }); + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); yield* manager.open(openInput()); const closeFiber = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); @@ -1954,6 +2362,9 @@ it.layer( hasRunningSubprocess: true, }), ]); + expect(yield* manager.metadata).toEqual(yield* manager.refreshMetadata); + yield* manager.close({ threadId: "thread-1" }); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM"]); yield* TestClock.adjust("10 millis"); yield* Fiber.join(closeFiber); @@ -1964,10 +2375,213 @@ it.layer( }).pipe(Effect.provide(TestClock.layer())), ); + it.effect("accepts an exit replayed while detaching a terminal without sending signals", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnSubscribe = { exitCode: 0, signal: null }; + + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual([]); + expect(process.exitListenerCount).toBe(0); + expect(yield* manager.metadata).toEqual([]); + }), + ); + + it.effect.each(["SIGTERM", "SIGKILL"])( + "accepts synchronous %s exit confirmation and releases its subscription", + (signal) => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = signal; + + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual( + signal === "SIGTERM" ? ["SIGTERM"] : ["SIGTERM", "SIGKILL"], + ); + expect(process.exitListenerCount).toBe(0); + expect(yield* manager.metadata).toEqual([]); + }), + ); + + it.effect("holds shutdown until delayed SIGKILL exit while keeping other threads usable", () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 100, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(forceKillSent); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(1); + const opening = yield* manager + .open(openInput({ terminalId: "new-terminal" })) + .pipe(Effect.forkScoped); + const restarting = yield* manager.restart(restartInput()).pipe(Effect.forkScoped); + yield* manager.open(openInput({ threadId: "thread-2" })); + + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(opening.pollUnsafe()).toBeUndefined(); + expect(restarting.pollUnsafe()).toBeUndefined(); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes).toHaveLength(2); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(yield* manager.metadata).toEqual( + expect.arrayContaining([ + expect.objectContaining({ threadId: "thread-1", pid: process.pid, status: "running" }), + expect.objectContaining({ threadId: "thread-2", status: "running" }), + ]), + ); + + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Fiber.join(stopping); + yield* Fiber.join(opening); + yield* Fiber.join(restarting); + expect(process.exitListenerCount).toBe(0); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("times out unconfirmed exit and retries only the selected retained process", () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 10, + }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + const original = (yield* manager.metadata).find( + (terminal) => terminal.threadId === "thread-1", + ); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const stopping = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(forceKillSent); + expect(stopping.pollUnsafe()).toBeUndefined(); + + yield* TestClock.adjust("10 millis"); + const result = yield* Fiber.join(stopping); + expect(result._tag).toBe("Failure"); + if (result._tag === "Failure") { + expect(result.failure._tag).toBe("TerminalShutdownError"); + if (result.failure._tag === "TerminalShutdownError") { + expect(result.failure.terminalIds).toEqual([DEFAULT_TERMINAL_ID]); + } + } + expect(process.exitListenerCount).toBe(1); + expect(yield* manager.metadata).toContainEqual( + expect.objectContaining({ + threadId: "thread-1", + pid: process.pid, + cwd: original?.cwd, + label: original?.label, + status: "running", + hasRunningSubprocess: true, + }), + ); + + process.exitOnKill = "SIGKILL"; + yield* manager.shutdownThread("thread-1"); + + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL", "SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(0); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ threadId: "thread-2", status: "running" }), + ]); + yield* manager.shutdownThread("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect( + "removes retained metadata when actual exit arrives after the confirmation timeout", + () => + Effect.gen(function* () { + const forceKillSent = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + processExitWaitMs: 10, + }); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.exitOnKill = undefined; + process.onKill = (signal) => { + if (signal === "SIGKILL") Deferred.doneUnsafe(forceKillSent, Effect.void); + }; + const unsubscribe = yield* manager.subscribeMetadata((event) => + event.type === "remove" + ? Deferred.succeed(removed, undefined).pipe(Effect.asVoid) + : Effect.void, + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const stopping = yield* manager + .shutdownThread("thread-1") + .pipe(Effect.result, Effect.forkScoped); + yield* Deferred.await(forceKillSent); + yield* TestClock.adjust("10 millis"); + expect((yield* Fiber.join(stopping))._tag).toBe("Failure"); + expect(yield* manager.metadata).toHaveLength(1); + expect(process.exitListenerCount).toBe(1); + + process.emitExit({ exitCode: 0, signal: 9 }); + yield* Deferred.await(removed); + yield* manager.waitForThreadShutdown("thread-1"); + + expect(yield* manager.metadata).toEqual([]); + expect(process.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(process.exitListenerCount).toBe(0); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("releases a retained exit subscription when the manager scope closes", () => + Effect.gen(function* () { + const ptyAdapter = new FakePtyAdapter(); + yield* Effect.scoped( + Effect.gen(function* () { + const { manager } = yield* createManager(5, { ptyAdapter }); + yield* manager.open(openInput()); + ptyAdapter.processes[0]!.killFailure = new Error("signal failure"); + expect((yield* manager.shutdownThread("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect(ptyAdapter.processes[0]?.exitListenerCount).toBe(1); + }), + ); + expect(ptyAdapter.processes[0]?.exitListenerCount).toBe(0); + }), + ); + it.effect("keeps a closing terminal blocked when process signaling fails", () => Effect.gen(function* () { const { manager, ptyAdapter } = yield* createManager(); yield* manager.open(openInput()); + const original = (yield* manager.metadata)[0]; + const metadataEvents = yield* Ref.make>([]); + const unsubscribe = yield* manager.subscribeMetadata((event) => + Ref.update(metadataEvents, (events) => [...events, event]), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); const process = ptyAdapter.processes[0]; expect(process).toBeDefined(); if (!process) return; @@ -1976,6 +2590,31 @@ it.layer( yield* manager.close({ threadId: "thread-1" }); expect(process.killSignals).toEqual(["SIGTERM"]); + const metadata = yield* manager.metadata; + expect(metadata).toEqual([ + expect.objectContaining({ + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + status: "running", + hasRunningSubprocess: true, + pid: process.pid, + cwd: original?.cwd, + worktreePath: original?.worktreePath, + label: original?.label, + }), + ]); + expect((yield* Ref.get(metadataEvents)).at(-1)).toEqual({ + type: "upsert", + terminal: metadata[0], + }); + const snapshots: TerminalMetadataStreamEvent[] = []; + const unsubscribeSnapshot = yield* manager.subscribeMetadata((event) => + Effect.sync(() => { + snapshots.push(event); + }), + ); + unsubscribeSnapshot(); + expect(snapshots).toEqual([{ type: "snapshot", terminals: metadata }]); expect(yield* manager.refreshMetadata).toEqual([ expect.objectContaining({ threadId: "thread-1", @@ -1987,6 +2626,161 @@ it.layer( }), ); + it.effect("retries only failed handles for the selected thread without duplicating cleanup", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 0 }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + const first = ptyAdapter.processes[0]!; + const second = ptyAdapter.processes[1]!; + first.killFailure = new Error("simulated signal failure"); + second.killFailure = new Error("another thread's signal failure"); + yield* manager.close({ threadId: "thread-1" }); + yield* manager.close({ threadId: "thread-2" }); + expect((yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result))._tag).toBe( + "Failure", + ); + expect((yield* manager.waitForThreadShutdown("thread-2").pipe(Effect.result))._tag).toBe( + "Failure", + ); + + first.killFailure = undefined; + yield* manager.close({ threadId: "thread-1" }); + yield* manager.close({ threadId: "thread-1" }); + yield* manager.waitForThreadShutdown("thread-1"); + + expect(first.killSignals).toEqual(["SIGTERM", "SIGTERM", "SIGKILL"]); + expect(second.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.metadata).toEqual([ + expect.objectContaining({ threadId: "thread-2", hasRunningSubprocess: true }), + ]); + }), + ); + + it.effect("delivers successful cleanup after an in-flight retained metadata update", () => + Effect.gen(function* () { + const { manager } = yield* createManager(5, { processKillGraceMs: 10 }); + yield* manager.open(openInput()); + const upsertStarted = yield* Deferred.make(); + const releaseUpsert = yield* Deferred.make(); + const removed = yield* Deferred.make(); + const events: TerminalMetadataStreamEvent[] = []; + const unsubscribe = yield* manager.subscribeMetadata((event) => + Effect.gen(function* () { + if (event.type === "upsert") { + yield* Deferred.succeed(upsertStarted, undefined); + yield* Deferred.await(releaseUpsert); + } + events.push(event); + if (event.type === "remove") yield* Deferred.succeed(removed, undefined); + }), + ); + yield* Effect.addFinalizer(() => Effect.sync(unsubscribe)); + const closing = yield* manager.close({ threadId: "thread-1" }).pipe(Effect.forkScoped); + yield* Deferred.await(upsertStarted); + yield* TestClock.adjust("10 millis"); + expect(yield* manager.metadata).toEqual([]); + yield* Deferred.succeed(releaseUpsert, undefined); + yield* Fiber.join(closing); + yield* manager.waitForThreadShutdown("thread-1"); + yield* Deferred.await(removed); + + expect(events.map((event) => event.type)).toEqual(["snapshot", "upsert", "remove"]); + expect(events.at(-1)).toEqual({ + type: "remove", + threadId: "thread-1", + terminalId: DEFAULT_TERMINAL_ID, + }); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("keeps opens and restarts outside the selected thread's shutdown interval", () => + Effect.gen(function* () { + const cleanupStarted = yield* Deferred.make(); + const finishCleanup = yield* Deferred.make(); + let blockCleanup = true; + const { manager, ptyAdapter } = yield* createManager(5, { + processKillGraceMs: 0, + unregisterTerminal: (terminal) => + terminal.threadId === "thread-1" && blockCleanup + ? Deferred.succeed(cleanupStarted, undefined).pipe( + Effect.andThen(Deferred.await(finishCleanup)), + ) + : Effect.void, + }); + yield* manager.open(openInput()); + const stopping = yield* manager.shutdownThread("thread-1").pipe(Effect.forkScoped); + yield* Deferred.await(cleanupStarted); + const opening = yield* manager + .open(openInput({ terminalId: "new-terminal" })) + .pipe(Effect.forkScoped); + const restarting = yield* manager.restart(restartInput()).pipe(Effect.forkScoped); + + yield* manager.open(openInput({ threadId: "thread-2" })); + expect(stopping.pollUnsafe()).toBeUndefined(); + expect(opening.pollUnsafe()).toBeUndefined(); + expect(restarting.pollUnsafe()).toBeUndefined(); + expect(ptyAdapter.processes).toHaveLength(2); + expect(ptyAdapter.processes[0]?.killSignals).toEqual([]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + + blockCleanup = false; + yield* Deferred.succeed(finishCleanup, undefined); + yield* Fiber.join(stopping); + yield* Fiber.join(opening); + yield* Fiber.join(restarting); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual([]); + expect(ptyAdapter.processes).toHaveLength(4); + yield* manager.shutdownThread("thread-1"); + yield* manager.shutdownThread("thread-2"); + }), + ); + + it.effect("waits for the selected thread's cleanup without waiting for another thread", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(5, { processKillGraceMs: 10 }); + yield* manager.open(openInput()); + yield* manager.open(openInput({ threadId: "thread-2" })); + yield* manager.close({ threadId: "thread-1" }); + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM"]); + + const waiting = yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.forkScoped); + yield* TestClock.adjust("5 millis"); + expect(waiting.pollUnsafe()).toBeUndefined(); + yield* manager.close({ threadId: "thread-2" }); + yield* TestClock.adjust("5 millis"); + yield* Fiber.join(waiting); + + expect(ptyAdapter.processes[0]?.killSignals).toEqual(["SIGTERM", "SIGKILL"]); + expect(ptyAdapter.processes[1]?.killSignals).toEqual(["SIGTERM"]); + expect(yield* manager.refreshMetadata).toEqual([ + expect.objectContaining({ threadId: "thread-2" }), + ]); + yield* TestClock.adjust("5 millis"); + yield* manager.waitForThreadShutdown("thread-2"); + }).pipe(Effect.provide(TestClock.layer())), + ); + + it.effect("reports failed termination after the asynchronous close has returned", () => + Effect.gen(function* () { + const { manager, ptyAdapter } = yield* createManager(); + yield* manager.open(openInput()); + const process = ptyAdapter.processes[0]!; + process.killFailure = new Error("simulated signal failure"); + yield* manager.close({ threadId: "thread-1" }); + + const result = yield* manager.waitForThreadShutdown("thread-1").pipe(Effect.result); + assert.equal(result._tag, "Failure"); + if (result._tag === "Failure") { + assert.equal(result.failure._tag, "TerminalShutdownError"); + assert.equal(result.failure.threadId, "thread-1"); + assert.deepEqual(result.failure.terminalIds, [DEFAULT_TERMINAL_ID]); + } + yield* manager.waitForThreadShutdown("thread-2"); + }), + ); + it.effect("publishes closed events when terminals are explicitly closed", () => Effect.gen(function* () { const { manager, getEvents } = yield* createManager(); diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 09c44cf887f2..668e3a9de5af 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -48,6 +48,7 @@ import { acpRegistryManagedBinaryDirectories } from "../provider/acp/AcpRegistry import { getTerminalLabel } from "@t3tools/shared/terminalLabels"; import * as DateTime from "effect/DateTime"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Encoding from "effect/Encoding"; import * as Equal from "effect/Equal"; @@ -59,6 +60,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; +import * as Semaphore from "effect/Semaphore"; import * as SynchronizedRef from "effect/SynchronizedRef"; import * as ServerConfig from "../config.ts"; @@ -106,6 +108,7 @@ const DEFAULT_PERSIST_DEBOUNCE_MS = 40; const DEFAULT_SUBPROCESS_POLL_INTERVAL_MS = 1_000; const MAX_SUBPROCESS_POLL_INTERVAL_MS = 60_000; const DEFAULT_PROCESS_KILL_GRACE_MS = 1_000; +const DEFAULT_PROCESS_EXIT_WAIT_MS = 5_000; const DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS = 128; const DEFAULT_OPEN_COLS = 120; const DEFAULT_OPEN_ROWS = 30; @@ -150,6 +153,18 @@ class TerminalProcessSignalError extends Schema.TaggedError()( + "TerminalShutdownError", + { + threadId: Schema.String, + terminalIds: Schema.Array(Schema.String), + }, +) { + override get message(): string { + return `Failed to stop terminals: ${this.terminalIds.join(", ")}.`; + } +} + /** * TerminalManager - Service tag for terminal session orchestration. */ @@ -210,6 +225,14 @@ export class TerminalManager extends Context.Service< * When `terminalId` is omitted, closes all sessions for the thread. */ readonly close: (input: TerminalCloseInput) => Effect.Effect; + /** Close and await this thread's terminals without allowing concurrent opens or restarts. */ + readonly shutdownThread: ( + threadId: string, + ) => Effect.Effect; + /** Await already requested process cleanup for this thread and report failed termination. */ + readonly waitForThreadShutdown: ( + threadId: string, + ) => Effect.Effect; /** Archive cleanup also removes untracked histories, except retained preview terminals. */ readonly closeThreadExcept: ( threadId: string, @@ -355,10 +378,15 @@ type DrainProcessEventAction = exitSignal: number | null; }; +interface TerminatingProcess { + readonly terminal: TerminalSummary; + readonly exited: Deferred.Deferred; +} + interface TerminalManagerState { sessions: Map; killFibers: Map>; - terminatingProcesses: Map; + terminatingProcesses: Map; } function truncateTerminalWireLabel(value: string): string { @@ -1417,6 +1445,7 @@ interface TerminalManagerOptions { >; subprocessPollIntervalMs?: number; processKillGraceMs?: number; + processExitWaitMs?: number; maxRetainedInactiveSessions?: number; registerTerminalProcesses?: (input: { readonly threadId: string; @@ -1612,6 +1641,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func const subprocessPollIntervalMs = options.subprocessPollIntervalMs ?? DEFAULT_SUBPROCESS_POLL_INTERVAL_MS; const processKillGraceMs = options.processKillGraceMs ?? DEFAULT_PROCESS_KILL_GRACE_MS; + const processExitWaitMs = options.processExitWaitMs ?? DEFAULT_PROCESS_EXIT_WAIT_MS; const maxRetainedInactiveSessions = options.maxRetainedInactiveSessions ?? DEFAULT_MAX_RETAINED_INACTIVE_SESSIONS; const registerTerminalProcesses = options.registerTerminalProcesses ?? (() => Effect.void); @@ -1625,7 +1655,13 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func terminatingProcesses: new Map(), }); const threadLocks = yield* KeyedLock.make(); + // Includes queued Stops and remains active through thread-lock finalization. + const pendingThreadShutdowns = new Map(); const terminalEventListeners = new Set<(event: TerminalEvent) => Effect.Effect>(); + const terminalMetadataListeners = new Set< + (event: TerminalMetadataStreamEvent) => Effect.Effect + >(); + const metadataDeliveryLock = yield* Semaphore.make(1); const workerScope = yield* Scope.make("sequential"); yield* Effect.addFinalizer(() => Scope.close(workerScope, Exit.void)); @@ -1634,6 +1670,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func for (const listener of terminalEventListeners) { yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } + if (shouldPublishTerminalMetadataEvent(event)) { + yield* publishMetadataUpdate(event); + } }); const historyPath = (threadId: string, terminalId: string) => { @@ -1695,71 +1734,91 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func process: PtyAdapter.PtyProcess, threadId: string, terminalId: string, - ) { - const terminated = yield* Effect.try({ - try: () => process.kill("SIGTERM"), - catch: (cause) => - new TerminalProcessSignalError({ - cause, - signal: "SIGTERM", - terminalPid: process.pid, - }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to kill terminal process", { - threadId, - terminalId, - signal: "SIGTERM", - cause: error, - }).pipe(Effect.as(false)), + retainedExit?: Deferred.Deferred, + ): Effect.fn.Return { + const observedExit = + retainedExit ?? (yield* readManagerState).terminatingProcesses.get(process)?.exited; + const exited = observedExit ?? (yield* Deferred.make()); + return yield* Effect.acquireUseRelease( + Effect.sync(() => + observedExit !== undefined + ? () => {} + : process.onExit(() => { + Deferred.doneUnsafe(exited, Effect.void); + }), ), - ); - if (!terminated) { - return false; - } + () => + Effect.gen(function* () { + if (yield* Deferred.isDone(exited)) return true; + + const signal = (name: "SIGTERM" | "SIGKILL") => + Effect.try({ + try: () => process.kill(name), + catch: (cause) => + new TerminalProcessSignalError({ + cause, + signal: name, + terminalPid: process.pid, + }), + }).pipe( + Effect.as(true), + Effect.catch((error) => + Effect.logWarning("failed to kill terminal process", { + threadId, + terminalId, + signal: name, + cause: error, + }).pipe(Effect.as(false)), + ), + ); - yield* Effect.sleep(processKillGraceMs); + if (!(yield* signal("SIGTERM"))) return yield* Deferred.isDone(exited); + if (yield* Deferred.isDone(exited)) return true; + const gracefulExit = yield* Deferred.await(exited).pipe( + Effect.timeoutOption(processKillGraceMs), + ); + if (Option.isSome(gracefulExit) || (yield* Deferred.isDone(exited))) return true; - return yield* Effect.try({ - try: () => process.kill("SIGKILL"), - catch: (cause) => - new TerminalProcessSignalError({ - cause, - signal: "SIGKILL", - terminalPid: process.pid, + if (!(yield* signal("SIGKILL"))) return yield* Deferred.isDone(exited); + if (yield* Deferred.isDone(exited)) return true; + const forcedExit = yield* Deferred.await(exited).pipe( + Effect.timeoutOption(processExitWaitMs), + ); + if (Option.isSome(forcedExit) || (yield* Deferred.isDone(exited))) return true; + yield* Effect.logWarning("terminal process exit was not confirmed", { + threadId, + terminalId, + terminalPid: process.pid, + }); + return false; }), - }).pipe( - Effect.as(true), - Effect.catch((error) => - Effect.logWarning("failed to force-kill terminal process", { - threadId, - terminalId, - signal: "SIGKILL", - cause: error, - }).pipe(Effect.as(false)), - ), + (unsubscribe) => Effect.sync(unsubscribe), ); }); + const completeProcessShutdown = Effect.fn("terminal.completeProcessShutdown")(function* ( + process: PtyAdapter.PtyProcess, + ): Effect.fn.Return { + const terminal = yield* modifyManagerState((state) => { + const terminating = state.terminatingProcesses.get(process); + if (terminating === undefined) return [undefined, state] as const; + const terminatingProcesses = new Map(state.terminatingProcesses); + terminatingProcesses.delete(process); + return [terminating.terminal, { ...state, terminatingProcesses }] as const; + }); + if (terminal !== undefined) yield* publishMetadataUpdate(terminal); + }); + const startKillEscalation = Effect.fn("terminal.startKillEscalation")(function* ( process: PtyAdapter.PtyProcess, threadId: string, terminalId: string, ) { + const existing = (yield* readManagerState).killFibers.get(process); + if (existing !== undefined && existing.pollUnsafe() === undefined) return; + const fiber = yield* runKillEscalation(process, threadId, terminalId).pipe( - Effect.tap((completed) => - completed - ? modifyManagerState((state) => { - if (!state.terminatingProcesses.has(process)) { - return [undefined, state] as const; - } - const terminatingProcesses = new Map(state.terminatingProcesses); - terminatingProcesses.delete(process); - return [undefined, { ...state, terminatingProcesses }] as const; - }) - : Effect.void, - ), + Effect.tap((completed) => (completed ? completeProcessShutdown(process) : Effect.void)), Effect.asVoid, Effect.ensuring( modifyManagerState((state) => { @@ -2211,26 +2270,44 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func if (!process) return; const updatedAt = yield* nowIso; - yield* modifyManagerState((state) => { - const terminatingProcesses = new Map(state.terminatingProcesses); - terminatingProcesses.set(process, { - ...summary(session), - status: "running", - hasRunningSubprocess: true, - }); - cleanupProcessHandles(session); - session.process = null; - session.pid = null; - session.hasRunningSubprocess = false; - session.childCommandLabel = null; - session.status = "exited"; - session.pendingHistoryControlSequence = ""; - session.pendingProcessEvents = []; - session.pendingProcessEventIndex = 0; - session.processEventDrainRunning = false; - session.updatedAt = updatedAt; - return [undefined, { ...state, terminatingProcesses }] as const; - }); + yield* Effect.uninterruptibleMask((restore) => + Effect.gen(function* () { + const exited = yield* Deferred.make(); + // Keep an exit witness after detaching the session, including between + // failed cleanup attempts. A late exit also clears the visible blocker. + const unsubscribe = process.onExit(() => { + Deferred.doneUnsafe(exited, Effect.void); + }); + yield* modifyManagerState((state) => { + const terminatingProcesses = new Map(state.terminatingProcesses); + terminatingProcesses.set(process, { + terminal: { + ...summary(session), + status: "running", + hasRunningSubprocess: true, + }, + exited, + }); + cleanupProcessHandles(session); + session.process = null; + session.pid = null; + session.hasRunningSubprocess = false; + session.childCommandLabel = null; + session.status = "exited"; + session.pendingHistoryControlSequence = ""; + session.pendingProcessEvents = []; + session.pendingProcessEventIndex = 0; + session.processEventDrainRunning = false; + session.updatedAt = updatedAt; + return [undefined, { ...state, terminatingProcesses }] as const; + }); + yield* restore(Deferred.await(exited)).pipe( + Effect.andThen(completeProcessShutdown(process)), + Effect.ensuring(Effect.sync(unsubscribe)), + Effect.forkIn(workerScope), + ); + }), + ); yield* clearKillFiber(process); yield* unregisterTerminal({ @@ -2682,9 +2759,11 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func }); yield* Effect.forEach( terminatingProcesses, - ([process, terminal]) => + ([process, { terminal, exited }]) => clearKillFiber(process).pipe( - Effect.andThen(runKillEscalation(process, terminal.threadId, terminal.terminalId)), + Effect.andThen( + runKillEscalation(process, terminal.threadId, terminal.terminalId, exited), + ), ), { concurrency: "unbounded", discard: true }, ); @@ -2830,13 +2909,64 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(openLocked)), ); - const openOrAttachForStream = (input: TerminalAttachInput, startIfNeeded = true) => - withThreadLock( + const openOrAttachForStream = Effect.fn("terminal.openOrAttachForStream")(function* ( + input: TerminalAttachInput, + startIfNeeded = true, + ): Effect.fn.Return { + const observed = yield* Effect.sync(() => { + const state = SynchronizedRef.getUnsafe(managerStateRef); + const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); + return { + session, + terminating: + pendingThreadShutdowns.has(input.threadId) || + ((session === undefined || session.process === null) && + [...state.terminatingProcesses.values()].some( + ({ terminal, exited }) => + terminal.threadId === input.threadId && + terminal.terminalId === input.terminalId && + !Deferred.isDoneUnsafe(exited), + )), + }; + }); + return yield* withThreadLock( input.threadId, Effect.gen(function* () { const terminalId = input.terminalId; const existing = yield* getSession(input.threadId, terminalId); + // A queued attachment belongs to the lifecycle it observed before + // waiting, even if Stop has removed its metadata by lock acquisition. + if ( + observed.terminating || + (observed.session !== undefined && + (Option.isNone(existing) || existing.value !== observed.session)) + ) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + + if (Option.isNone(existing) || existing.value.process === null) { + const terminating = (yield* readManagerState).terminatingProcesses.values(); + // Retained metadata keeps Stop available; mounting its viewport must + // not turn a closed session back into a new process before exit. + if ( + [...terminating].some( + ({ terminal, exited }) => + terminal.threadId === input.threadId && + terminal.terminalId === terminalId && + !Deferred.isDoneUnsafe(exited), + ) + ) { + return yield* new TerminalNotRunningError({ + threadId: input.threadId, + terminalId, + }); + } + } + if (Option.isNone(existing)) { if (!input.cwd || !startIfNeeded) { return yield* new TerminalSessionLookupError({ @@ -2881,22 +3011,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func return snapshot(session); }), ); + }); const readAllTerminalMetadata = () => - readManagerState.pipe( - Effect.map((state) => - [...state.sessions.values()] - .map(summary) - .sort( - (left, right) => - right.updatedAt.localeCompare(left.updatedAt) || - left.threadId.localeCompare(right.threadId) || - left.terminalId.localeCompare(right.terminalId), - ), - ), - ); - - const readDrainTerminalMetadata = () => readManagerState.pipe( Effect.map((state) => { const terminals = new Map( @@ -2905,7 +3022,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func summary(session), ]), ); - for (const terminal of state.terminatingProcesses.values()) { + for (const { terminal } of state.terminatingProcesses.values()) { terminals.set(toSessionKey(terminal.threadId, terminal.terminalId), terminal); } return [...terminals.values()].sort( @@ -2921,8 +3038,16 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func readonly threadId: string; readonly terminalId: string; }) => - getSession(input.threadId, input.terminalId).pipe( - Effect.map((session) => (Option.isSome(session) ? summary(session.value) : null)), + readManagerState.pipe( + Effect.map((state) => { + const terminating = [...state.terminatingProcesses.values()].findLast( + ({ terminal }) => + terminal.threadId === input.threadId && terminal.terminalId === input.terminalId, + ); + if (terminating !== undefined) return terminating.terminal; + const session = state.sessions.get(toSessionKey(input.threadId, input.terminalId)); + return session === undefined ? null : summary(session); + }), ); const subscribe: TerminalManager["Service"]["subscribe"] = (listener) => @@ -2994,87 +3119,35 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ); }; - const metadataEventFromTerminalEvent = ( - event: TerminalEvent, - ): Effect.Effect => { - if (!shouldPublishTerminalMetadataEvent(event)) { - return Effect.succeed(null); - } - - if (event.type === "closed") { - return Effect.succeed({ - type: "remove" as const, - threadId: event.threadId, - terminalId: event.terminalId, - }); - } - - return readTerminalMetadata({ - threadId: event.threadId, - terminalId: event.terminalId, - }).pipe( - Effect.map((terminal) => - terminal - ? { - type: "upsert" as const, - terminal, - } - : null, - ), - ); - }; - - const offerMetadataEvent = ( - listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, - event: TerminalEvent, - ) => - metadataEventFromTerminalEvent(event).pipe( - Effect.flatMap((metadataEvent) => (metadataEvent ? listener(metadataEvent) : Effect.void)), - ); - - const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => { - let unsubscribe: (() => void) | null = null; - - return Effect.gen(function* () { - const bufferedEvents: TerminalEvent[] = []; - let deliverLive = false; - - unsubscribe = yield* subscribe((event) => { - if (!deliverLive) { - bufferedEvents.push(event); - return Effect.void; + const publishMetadataUpdate = (input: { + readonly threadId: string; + readonly terminalId: string; + }): Effect.Effect => + metadataDeliveryLock.withPermit( + Effect.gen(function* () { + // Read after acquiring the delivery lock: a completed kill must not be + // followed by an older close update that restores its retained summary. + const terminal = yield* readTerminalMetadata(input); + const event: TerminalMetadataStreamEvent = terminal + ? { type: "upsert", terminal } + : { type: "remove", threadId: input.threadId, terminalId: input.terminalId }; + for (const listener of terminalMetadataListeners) { + yield* listener(event).pipe(Effect.ignoreCause({ log: true })); } + }), + ); - return offerMetadataEvent(listener, event); - }); - - const terminals = yield* readAllTerminalMetadata(); - yield* listener({ - type: "snapshot", - terminals, - }); - - for (const event of bufferedEvents) { - yield* offerMetadataEvent(listener, event); - } - - deliverLive = true; - return () => { - unsubscribe?.(); - unsubscribe = null; - }; - }).pipe( - Effect.catchCause((cause) => - Effect.flatMap( - Effect.sync(() => { - unsubscribe?.(); - unsubscribe = null; - }), - () => Effect.failCause(cause), - ), - ), + const subscribeMetadata: TerminalManager["Service"]["subscribeMetadata"] = (listener) => + metadataDeliveryLock.withPermit( + Effect.gen(function* () { + const terminals = yield* readAllTerminalMetadata(); + yield* listener({ type: "snapshot", terminals }); + terminalMetadataListeners.add(listener); + return () => { + terminalMetadataListeners.delete(listener); + }; + }), ); - }; const write: TerminalManager["Service"]["write"] = Effect.fn("terminal.write")(function* (input) { const terminalId = input.terminalId; @@ -3233,6 +3306,53 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + const closeUnlocked = ( + input: TerminalCloseInput, + retainedTerminalIds: ReadonlyArray = [], + retainedTerminalPrefixes: ReadonlyArray = [], + ): Effect.Effect => + Effect.gen(function* () { + // Failed handles outlive their sessions. A later close retries them, + // while startKillEscalation leaves any current cleanup fiber alone. + const terminating = [...(yield* readManagerState).terminatingProcesses.entries()].filter( + ([, { terminal }]) => + terminal.threadId === input.threadId && + (input.terminalId === undefined || terminal.terminalId === input.terminalId) && + !retainedTerminalIds.includes(terminal.terminalId) && + !retainedTerminalPrefixes.some((prefix) => terminal.terminalId.startsWith(prefix)), + ); + yield* Effect.forEach( + terminating, + ([process, { terminal }]) => + startKillEscalation(process, terminal.threadId, terminal.terminalId), + { discard: true }, + ); + + if (input.terminalId) { + yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); + return; + } + + const threadSessions = yield* sessionsForThread(input.threadId); + yield* Effect.forEach( + threadSessions.filter( + (session) => + !retainedTerminalIds.includes(session.terminalId) && + !retainedTerminalPrefixes.some((prefix) => session.terminalId.startsWith(prefix)), + ), + (session) => closeSession(input.threadId, session.terminalId, false), + { discard: true }, + ); + + if (input.deleteHistory) { + yield* deleteAllHistoryForThread( + input.threadId, + retainedTerminalIds, + retainedTerminalPrefixes, + ); + } + }); + const close = ( input: TerminalCloseInput, retainedTerminalIds: ReadonlyArray = [], @@ -3240,31 +3360,7 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ) => withThreadLock( input.threadId, - Effect.gen(function* () { - if (input.terminalId) { - yield* closeSession(input.threadId, input.terminalId, input.deleteHistory === true); - return; - } - - const threadSessions = yield* sessionsForThread(input.threadId); - yield* Effect.forEach( - threadSessions.filter( - (session) => - !retainedTerminalIds.includes(session.terminalId) && - !retainedTerminalPrefixes.some((prefix) => session.terminalId.startsWith(prefix)), - ), - (session) => closeSession(input.threadId, session.terminalId, false), - { discard: true }, - ); - - if (input.deleteHistory) { - yield* deleteAllHistoryForThread( - input.threadId, - retainedTerminalIds, - retainedTerminalPrefixes, - ); - } - }), + closeUnlocked(input, retainedTerminalIds, retainedTerminalPrefixes), ); const closeIdle: TerminalManager["Service"]["closeIdle"] = (input) => @@ -3315,6 +3411,52 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func ), ); + const waitForThreadShutdownUnlocked = Effect.fn("terminal.waitForThreadShutdown")(function* ( + threadId: string, + ): Effect.fn.Return { + const state = yield* readManagerState; + const fibers = [...state.terminatingProcesses.entries()].flatMap(([process, { terminal }]) => { + const fiber = state.killFibers.get(process); + return terminal.threadId === threadId && fiber !== undefined ? [fiber] : []; + }); + yield* Effect.forEach(fibers, Fiber.await, { concurrency: "unbounded", discard: true }); + const remaining = [...(yield* readManagerState).terminatingProcesses.values()] + .map(({ terminal }) => terminal) + .filter((terminal) => terminal.threadId === threadId); + if (remaining.length > 0) { + return yield* new TerminalShutdownError({ + threadId, + terminalIds: [...new Set(remaining.map((terminal) => terminal.terminalId))], + }); + } + }); + + const waitForThreadShutdown: TerminalManager["Service"]["waitForThreadShutdown"] = (threadId) => + withThreadLock(threadId, waitForThreadShutdownUnlocked(threadId)); + + const shutdownThread: TerminalManager["Service"]["shutdownThread"] = (threadId) => + Effect.acquireUseRelease( + Effect.sync(() => { + pendingThreadShutdowns.set(threadId, (pendingThreadShutdowns.get(threadId) ?? 0) + 1); + }), + () => + withThreadLock( + threadId, + Effect.gen(function* () { + const closeResult = yield* closeUnlocked({ threadId }).pipe(Effect.result); + const shutdown = yield* waitForThreadShutdownUnlocked(threadId).pipe(Effect.result); + if (closeResult._tag === "Failure") return yield* closeResult.failure; + if (shutdown._tag === "Failure") return yield* shutdown.failure; + }), + ), + () => + Effect.sync(() => { + const remaining = (pendingThreadShutdowns.get(threadId) ?? 1) - 1; + if (remaining === 0) pendingThreadShutdowns.delete(threadId); + else pendingThreadShutdowns.set(threadId, remaining); + }), + ); + const history: TerminalManager["Service"]["history"] = (input) => { return flushPersist(input.threadId, input.terminalId).pipe( Effect.andThen(readHistory(input.threadId, input.terminalId)), @@ -3330,13 +3472,15 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func history, restart, close, + shutdownThread, + waitForThreadShutdown, closeThreadExcept: (threadId, retainedTerminalIds, retainedTerminalPrefixes) => close({ threadId, deleteHistory: true }, retainedTerminalIds, retainedTerminalPrefixes), closeIdle, subscribe, subscribeMetadata, metadata: readAllTerminalMetadata(), - refreshMetadata: pollSubprocessActivity().pipe(Effect.andThen(readDrainTerminalMetadata())), + refreshMetadata: pollSubprocessActivity().pipe(Effect.andThen(readAllTerminalMetadata())), }); }); diff --git a/apps/server/src/terminal/NodePtyAdapter.test.ts b/apps/server/src/terminal/NodePtyAdapter.test.ts index 7c6101dfa38e..9b7fcd742569 100644 --- a/apps/server/src/terminal/NodePtyAdapter.test.ts +++ b/apps/server/src/terminal/NodePtyAdapter.test.ts @@ -192,6 +192,42 @@ it.effect.each(["win32", "linux", "darwin"] as const)( }).pipe(Effect.provide(makeTestLayer(platform))), ); +it.effect.each(["win32", "linux", "darwin"] as const)( + "confirms exit only on the native event and replays it to late subscribers on %s", + (platform) => + Effect.gen(function* () { + const adapter = yield* PtyAdapter.PtyAdapter; + const process = yield* adapter.spawn(spawnInput); + const nativeProcess = spawn.mock.results.at(-1)!.value; + const exits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribedExits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribe = process.onExit((event) => { + unsubscribedExits.push(event); + }); + unsubscribe(); + const unsubscribeExit = process.onExit((event) => { + exits.push(event); + }); + + process.kill("SIGKILL"); + assert.deepEqual(exits, []); + assert.equal(nativeProcess.events.listenerCount("exit"), 1); + + nativeProcess.events.emit("exit", { exitCode: 0, signal: 9 }); + assert.deepEqual(exits, [{ exitCode: 0, signal: 9 }]); + assert.deepEqual(unsubscribedExits, []); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + unsubscribeExit(); + const lateExits: PtyAdapter.PtyExitEvent[] = []; + const unsubscribeLate = process.onExit((event) => { + lateExits.push(event); + }); + assert.deepEqual(lateExits, [{ exitCode: 0, signal: 9 }]); + unsubscribeLate(); + assert.equal(nativeProcess.events.listenerCount("exit"), 0); + }).pipe(Effect.provide(makeTestLayer(platform))), +); + it.effect("spawns through the public adapter with the provided host references", () => Effect.gen(function* () { spawn.mockClear(); diff --git a/apps/server/src/terminal/PtyAdapter.ts b/apps/server/src/terminal/PtyAdapter.ts index c56dfa93efe6..3e3fa5927b3a 100644 --- a/apps/server/src/terminal/PtyAdapter.ts +++ b/apps/server/src/terminal/PtyAdapter.ts @@ -38,8 +38,10 @@ export interface PtyProcess { readonly pid: number; write(data: string): void; resize(cols: number, rows: number): void; + /** Requests termination; returning does not confirm that the process exited. */ kill(signal?: string): void; onData(callback: (data: string) => void): () => void; + /** Reports actual exit, replaying it immediately for a late subscriber. */ onExit(callback: (event: PtyExitEvent) => void): () => void; } diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 923d54f0df1f..e5af95e1790b 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -93,6 +93,7 @@ import { type ProviderDriverKind, type ProviderInstanceId, PreviewHostingError as ContractPreviewHostingError, + type PreviewHostingLeaseMetadata, ThreadId, type TerminalAttachStreamEvent, type TerminalError, @@ -3629,6 +3630,42 @@ const makeWsRpcLayer = ( ), { "rpc.aggregate": "preview" }, ), + [WS_METHODS.previewHostingStopThread]: (input) => + observeRpcEffect( + WS_METHODS.previewHostingStopThread, + previewHosting.stopThread(input.threadId).pipe( + Effect.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Some previews or processes could not be stopped. Please try again.", + }), + ), + ), + { "rpc.aggregate": "preview" }, + ), + [WS_METHODS.subscribePreviewHosting]: (_input) => + observeRpcStream( + WS_METHODS.subscribePreviewHosting, + Stream.callback< + ReadonlyArray, + PreviewHosting.PreviewHostingError + >((queue) => + Effect.acquireRelease( + previewHosting.subscribe((leases) => Queue.offer(queue, leases)), + (unsubscribe) => Effect.sync(unsubscribe), + ), + ).pipe( + Stream.mapError( + () => + new ContractPreviewHostingError({ + reason: "unavailable", + message: "Preview hosting is unavailable on this server.", + }), + ), + ), + { "rpc.aggregate": "preview" }, + ), [WS_METHODS.previewReportStatus]: (input) => observeRpcEffect(WS_METHODS.previewReportStatus, previewManager.reportStatus(input), { "rpc.aggregate": "preview", diff --git a/apps/web/src/components/LegacySidebar.tsx b/apps/web/src/components/LegacySidebar.tsx index a9479f05bd3b..ddf8431d5f63 100644 --- a/apps/web/src/components/LegacySidebar.tsx +++ b/apps/web/src/components/LegacySidebar.tsx @@ -32,7 +32,11 @@ import { } from "./ThreadStatusIndicators"; import { ProjectFavicon } from "./ProjectFavicon"; import { SidebarDraftBlock } from "./Sidebar"; -import { buildDraftActionMenuItems } from "./threadActionMenu.logic"; +import { + buildDraftActionMenuItems, + buildStopThreadProcessesMenuItem, + withThreadActionMenuDividers, +} from "./threadActionMenu.logic"; import { discardComposerDraft } from "../lib/discardComposerDraft"; import { useAtomValue } from "@effect/atom-react"; import { autoAnimate } from "@formkit/auto-animate"; @@ -126,6 +130,11 @@ import { } from "./thread-annotation/ThreadAnnotation"; import { selectThreadTerminalUiState, useTerminalUiStateStore } from "../terminalUiStateStore"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { useThreadDiscoveredPorts } from "../portDiscoveryState"; import { openDiscoveredPort } from "./preview/openDiscoveredPort"; import { useAtomCommand } from "../state/use-atom-command"; @@ -433,6 +442,7 @@ interface SidebarThreadRowProps { handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, + hasStoppableProcesses: boolean, ) => Promise; clearSelection: () => void; commitRename: ( @@ -517,6 +527,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP environmentId: thread.environmentId, threadId: thread.id, }); + const previews = useThreadPreviewLeases(threadRef); + const supportsProcessControls = usePreviewProcessControlsSupported(thread.environmentId); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); const isMobile = useIsMobile(); const discoveredPorts = useThreadDiscoveredPorts({ environmentId: thread.environmentId, @@ -825,10 +839,14 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP } void (async () => { const result = await settlePromise(() => - handleThreadContextMenu(threadRef, { - x: event.clientX, - y: event.clientY, - }), + handleThreadContextMenu( + threadRef, + { + x: event.clientX, + y: event.clientY, + }, + hasStoppableProcesses, + ), ); if (result._tag === "Failure") { const error = squashAtomCommandFailure(result); @@ -847,6 +865,7 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: SidebarThreadRowP clearSelection, handleMultiSelectContextMenu, handleThreadContextMenu, + hasStoppableProcesses, isSelected, threadRef, ], @@ -1551,6 +1570,7 @@ interface SidebarProjectThreadListProps { handleThreadContextMenu: ( threadRef: ScopedThreadRef, position: { x: number; y: number }, + hasStoppableProcesses: boolean, ) => Promise; clearSelection: () => void; commitRename: ( @@ -1841,6 +1861,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const resolveThreadAnnotation = useAtomCommand(threadEnvironment.resolveAnnotation, { reportFailure: false, }); + const stopThreadProcesses = useStopThreadProcesses(); const updateSettings = useUpdateClientSettings(); const sidebarThreadPreviewCount = useClientSettings( (settings) => settings.sidebarThreadPreviewCount, @@ -3002,7 +3023,11 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec ]); const handleThreadContextMenu = useCallback( - async (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + async ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => { const api = readLocalApi(); if (!api) return; const threadKey = scopedThreadKey(threadRef); @@ -3021,6 +3046,8 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec persistent: thread.persistent === true, supported: supportsPersistence, }); + const stopProcessesAction = buildStopThreadProcessesMenuItem(hasStoppableProcesses); + const canAnnotate = supportsThreadAnnotations && thread.latestUserMessageAt !== null; const creatorRef = thread.creatorThreadId ? scopeThreadRef(thread.environmentId, thread.creatorThreadId) : null; @@ -3031,14 +3058,12 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec const handoffDescriptors = handoffs.slice(0, handoffsMenuLimit).map(describeHandoff); const clicked = await api.contextMenu.show( protectLegacyThreadActions( - [ + withThreadActionMenuDividers([ ...(thread.branch ? [{ id: "new-thread-on-branch", label: `New thread on ${thread.branch}` }] : []), { id: "rename", label: "Rename thread" }, - ...(supportsThreadAnnotations && thread.latestUserMessageAt !== null - ? [{ id: "annotate", label: "Annotate thread…" }] - : []), + ...(canAnnotate ? [{ id: "annotate", label: "Annotate thread…" }] : []), { id: "mark-unread", label: "Mark unread" }, ...(creatorGroupingEligible ? [ @@ -3056,6 +3081,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec : []), ...(canOpenCreator ? [{ id: "open-creator", label: "Open creator thread" }] : []), ...(persistenceAction ? [persistenceAction] : []), + ...(stopProcessesAction ? [stopProcessesAction] : []), { id: "copy-path", label: "Copy Path" }, { id: "copy-thread-id", label: "Copy Thread ID" }, { id: "open-dashboard", label: "Open dashboard", icon: "layout-dashboard" }, @@ -3074,7 +3100,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec destructive: true, icon: "trash", }, - ], + ]), thread.persistent === true, ), position, @@ -3177,6 +3203,10 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec markThreadUnread(threadRef); return; } + if (clicked === "stop-thread-processes") { + await stopThreadProcesses(threadRef); + return; + } if (clicked === "mark-persistent" || clicked === "disable-persistence") { const result = await setThreadPersistence(threadRef, clicked === "mark-persistent"); if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { @@ -3261,6 +3291,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec setOpenMobile, setThreadPersistence, startThreadRename, + stopThreadProcesses, updateThreadMetadata, navigateToThread, ], diff --git a/apps/web/src/components/Sidebar.tsx b/apps/web/src/components/Sidebar.tsx index 178660b5573b..a82a26d8add1 100644 --- a/apps/web/src/components/Sidebar.tsx +++ b/apps/web/src/components/Sidebar.tsx @@ -261,6 +261,11 @@ import { type ProviderInstanceEntry, } from "../providerInstances"; import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { stackedThreadToast, toastManager } from "./ui/toast"; import { Button, InlineButton } from "./ui/button"; import { @@ -1005,7 +1010,11 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { onCancelRename: () => void; isRenaming: boolean; renamingTitle: string; - onContextMenu: (threadRef: ScopedThreadRef, position: { x: number; y: number }) => void; + onContextMenu: ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => void; onSettle: (threadRef: ScopedThreadRef) => void; onActionSweepStart: ( threadRef: ScopedThreadRef, @@ -1070,6 +1079,10 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { }); const terminalStatus = terminalStatusFromRunningIds(runningTerminalIds); const terminalProcessCount = runningTerminalIds.length; + const previews = useThreadPreviewLeases(threadRef); + const supportsProcessControls = usePreviewProcessControlsSupported(thread.environmentId); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); // Unsent composer text on this thread. The open thread shows its own // composer, so the marker only decorates rows you have navigated away from. const hasUnsentDraft = useThreadHasUnsentDraft(threadRef) && !props.isActive; @@ -1303,9 +1316,9 @@ const SidebarThreadRow = memo(function SidebarThreadRow(props: { (event: ReactMouseEvent) => { event.preventDefault(); if (cleanup !== null) return; - onContextMenu(threadRef, { x: event.clientX, y: event.clientY }); + onContextMenu(threadRef, { x: event.clientX, y: event.clientY }, hasStoppableProcesses); }, - [cleanup, onContextMenu, threadRef], + [cleanup, hasStoppableProcesses, onContextMenu, threadRef], ); const handleKeyDown = useCallback( (event: ReactKeyboardEvent) => { @@ -2414,6 +2427,7 @@ export default function Sidebar() { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); + const stopThreadProcesses = useStopThreadProcesses(); const { copyToClipboard: copyPathToClipboard } = useCopyToClipboard<{ path: string }>({ onCopy: ({ path }) => { toastManager.add({ @@ -4566,7 +4580,11 @@ export default function Sidebar() { ); const handleThreadContextMenu = useCallback( - (threadRef: ScopedThreadRef, position: { x: number; y: number }) => { + ( + threadRef: ScopedThreadRef, + position: { x: number; y: number }, + hasStoppableProcesses: boolean, + ) => { void (async () => { const api = readLocalApi(); if (!api) return; @@ -4635,6 +4653,7 @@ export default function Sidebar() { isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), hasRunningAction: thread.actionResume?.outcome === "running", + hasStoppableProcesses, supports: { settlement: supportsSettlement, autoSettleOptOut: supportsAutoSettleOptOut, @@ -4806,6 +4825,9 @@ export default function Sidebar() { }); return; } + case "stop-thread-processes": + await stopThreadProcesses(threadRef); + return; case "mark-unread": markThreadUnread(threadRef); return; @@ -4893,6 +4915,7 @@ export default function Sidebar() { [ archiveThread, closeTerminal, + stopThreadProcesses, attemptPin, attemptSettle, attemptSnooze, diff --git a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx index 7b582cde1cfc..57b302b2453a 100644 --- a/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx +++ b/apps/web/src/components/sidebar/SidebarThreadHoverContent.tsx @@ -1,5 +1,6 @@ import { ArrowRightLeftIcon, CircleAlertIcon, GitBranchIcon, TerminalIcon } from "lucide-react"; import type { ProjectIconOverride } from "@t3tools/contracts"; +import { scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentIconColor } from "@t3tools/contracts/settings"; import { actionRunningPresentation } from "@t3tools/shared/actionResume"; @@ -14,6 +15,9 @@ import { RotateCcwClockIcon } from "../icons/RotateCcwClockIcon"; import { useSupportsMultiplePullRequests } from "~/hooks/useSupportsMultiplePullRequests"; import { ThreadPullRequestsMiniList } from "../ThreadStatusIndicators"; import { MiddleTruncate } from "../ui/middle-truncate"; +import { useKnownTerminalSessions } from "../../state/terminalSessions"; +import { useThreadPreviewLeases } from "../../state/previewHosting"; +import { threadTerminalProcessLabels } from "./threadTerminalPresentation"; export interface SidebarThreadHoverContentProps { thread: SidebarThreadSummary; @@ -48,6 +52,17 @@ export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) const supportsMultiplePullRequests = useSupportsMultiplePullRequests(props.thread.environmentId); const driverKind = props.providerEntry?.driverKind ?? null; const projectDisplayName = props.projectDisplayName ?? props.projectTitle; + const sessions = useKnownTerminalSessions({ + environmentId: props.thread.environmentId, + threadId: props.thread.id, + }); + const previews = useThreadPreviewLeases( + scopeThreadRef(props.thread.environmentId, props.thread.id), + ); + const terminalLabels = threadTerminalProcessLabels( + sessions.flatMap((session) => (session.state.summary ? [session.state.summary] : [])), + previews, + ); const previousProviderNames = props.thread.providerInstanceHistory .filter((instanceId) => instanceId !== props.modelInstanceId) .map( @@ -138,14 +153,21 @@ export function SidebarThreadHoverContent(props: SidebarThreadHoverContentProps) ) : null} - {props.terminalStatus ? ( -
+ {props.terminalStatus || terminalLabels.length > 0 ? ( +
-
- {terminalProcessLabel(props.terminalProcessCount)} +
+ {props.terminalStatus ? ( +
{terminalProcessLabel(props.terminalProcessCount)}
+ ) : null} + {terminalLabels.map((terminal) => ( +
+ {terminal.label} +
+ ))}
) : null} diff --git a/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts b/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts new file mode 100644 index 000000000000..3dfc5e0cc2d9 --- /dev/null +++ b/apps/web/src/components/sidebar/threadTerminalPresentation.test.ts @@ -0,0 +1,72 @@ +import { describe, expect, it } from "vite-plus/test"; +import { + ThreadId, + type PreviewHostingLeaseMetadata, + type TerminalSummary, +} from "@t3tools/contracts"; +import { formatPreviewExpiry, threadTerminalProcessLabels } from "./threadTerminalPresentation"; + +const expiresAt = new Date(2026, 9, 5, 18, 2).toISOString(); +const preview: PreviewHostingLeaseMetadata = { + leaseId: "preview-lease", + threadId: ThreadId.make("thread-a"), + terminalId: "preview-terminal", + url: "http://localhost:5173/", + handedOffAt: new Date(2026, 9, 4, 18, 2).toISOString(), + expiresAt, + status: "active", +}; +const terminal: TerminalSummary = { + threadId: "thread-a", + terminalId: "preview-terminal", + cwd: "/workspace", + worktreePath: null, + status: "running", + pid: 100, + exitCode: null, + exitSignal: null, + hasRunningSubprocess: true, + label: "node", + updatedAt: preview.handedOffAt, +}; + +describe("thread terminal hover labels", () => { + it("associates each process with its own preview's local expiry", () => { + expect( + threadTerminalProcessLabels( + [ + terminal, + { ...terminal, terminalId: "test-terminal", label: "vitest" }, + { ...terminal, terminalId: "idle-terminal", hasRunningSubprocess: false }, + ], + [preview], + ), + ).toEqual([ + { terminalId: "preview-terminal", label: "node (preview till Oct 5, 1802)" }, + { terminalId: "test-terminal", label: "vitest" }, + ]); + }); + + it("identifies a stopped preview whose recovery lifetime can still be cancelled", () => { + expect(threadTerminalProcessLabels([], [preview])).toEqual([ + { + terminalId: "preview-terminal", + label: "localhost:5173 (preview till Oct 5, 1802; stopped)", + }, + ]); + }); + + it("identifies a preview that is still starting", () => { + expect(threadTerminalProcessLabels([], [{ ...preview, status: "starting" }])).toEqual([ + { + terminalId: "preview-terminal", + label: "localhost:5173 (preview till Oct 5, 1802; starting)", + }, + ]); + }); + + it("formats midnight as 0000 and tolerates an invalid expiry", () => { + expect(formatPreviewExpiry(new Date(2026, 9, 5, 0, 0).toISOString())).toBe("Oct 5, 0000"); + expect(formatPreviewExpiry("invalid")).toBeNull(); + }); +}); diff --git a/apps/web/src/components/sidebar/threadTerminalPresentation.ts b/apps/web/src/components/sidebar/threadTerminalPresentation.ts new file mode 100644 index 000000000000..f8787e753503 --- /dev/null +++ b/apps/web/src/components/sidebar/threadTerminalPresentation.ts @@ -0,0 +1,36 @@ +import type { PreviewHostingLeaseMetadata, TerminalSummary } from "@t3tools/contracts"; + +/** Preview handoffs use a fixed expiry, shown in the viewer's local time. */ +export function formatPreviewExpiry(expiresAt: string): string | null { + const date = new Date(expiresAt); + if (!Number.isFinite(date.getTime())) return null; + const day = date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + const time = `${String(date.getHours()).padStart(2, "0")}${String(date.getMinutes()).padStart(2, "0")}`; + return `${day}, ${time}`; +} + +export function threadTerminalProcessLabels( + terminals: ReadonlyArray, + previews: ReadonlyArray, +): ReadonlyArray<{ terminalId: string; label: string }> { + const previewsByTerminal = new Map(previews.map((preview) => [preview.terminalId, preview])); + const running = terminals.filter((terminal) => terminal.hasRunningSubprocess); + const labels = running.map((terminal) => { + const preview = previewsByTerminal.get(terminal.terminalId); + const expiry = preview ? formatPreviewExpiry(preview.expiresAt) : null; + return { + terminalId: terminal.terminalId, + label: expiry ? `${terminal.label} (preview till ${expiry})` : terminal.label, + }; + }); + const runningIds = new Set(running.map((terminal) => terminal.terminalId)); + for (const preview of previews) { + if (runningIds.has(preview.terminalId)) continue; + const expiry = formatPreviewExpiry(preview.expiresAt); + labels.push({ + terminalId: preview.terminalId, + label: `${new URL(preview.url).host} (preview${expiry ? ` till ${expiry}` : ""}; ${preview.status === "starting" ? "starting" : "stopped"})`, + }); + } + return labels; +} diff --git a/apps/web/src/components/threadActionMenu.logic.test.ts b/apps/web/src/components/threadActionMenu.logic.test.ts index 0f56707ce358..e3cd7228a46b 100644 --- a/apps/web/src/components/threadActionMenu.logic.test.ts +++ b/apps/web/src/components/threadActionMenu.logic.test.ts @@ -3,6 +3,8 @@ import { describe, expect, it } from "vite-plus/test"; import { buildDraftActionMenuItems, buildThreadActionMenuItems, + buildStopThreadProcessesMenuItem, + withThreadActionMenuDividers, type ThreadActionMenuState, } from "./threadActionMenu.logic"; @@ -18,6 +20,7 @@ const baseState: ThreadActionMenuState = { isRegeneratingTitle: false, isRunning: false, hasRunningAction: false, + hasStoppableProcesses: false, supports: { settlement: true, autoSettleOptOut: true, @@ -52,6 +55,54 @@ function allIds(state: ThreadActionMenuState): string[] { } describe("buildThreadActionMenuItems", () => { + it("offers stopping only when the thread owns a preview or running subprocess", () => { + expect(buildStopThreadProcessesMenuItem(false)).toBeNull(); + expect(ids(baseState)).not.toContain("stop-thread-processes"); + const items = buildThreadActionMenuItems({ ...baseState, hasStoppableProcesses: true }); + const persistenceIndex = items.findIndex((item) => item.id === "mark-persistent"); + expect(items[persistenceIndex + 1]).toEqual({ + id: "stop-thread-processes", + label: "Stop all previews & processes", + destructive: true, + }); + expect(items[persistenceIndex + 2]?.separatorBefore).toBe(true); + }); + + it("separates branch creation and persistence without separating persistence from stop", () => { + const items = buildThreadActionMenuItems({ ...baseState, branch: "feature/preview" }); + expect(items[1]?.separatorBefore).toBe(true); + const persistenceIndex = items.findIndex((item) => item.id === "mark-persistent"); + expect(items[persistenceIndex + 1]?.separatorBefore).toBe(true); + const withoutPersistence = buildThreadActionMenuItems({ + ...baseState, + hasStoppableProcesses: true, + supports: { ...baseState.supports, persistence: false }, + }); + const stopIndex = withoutPersistence.findIndex((item) => item.id === "stop-thread-processes"); + expect(withoutPersistence[stopIndex + 1]?.separatorBefore).toBe(true); + }); + + it("groups legacy thread commands with dividers after creation, annotation, and stop", () => { + const commands = [ + "new-thread-on-branch", + "rename", + "annotate", + "mark-unread", + "mark-persistent", + "stop-thread-processes", + "copy-path", + ]; + const items = withThreadActionMenuDividers(commands.map((id) => ({ id, label: id }))); + expect(items.filter((item) => item.separatorBefore).map((item) => item.id)).toEqual([ + "rename", + "mark-unread", + "copy-path", + ]); + const withoutStop = withThreadActionMenuDividers( + commands.filter((id) => id !== "stop-thread-processes").map((id) => ({ id, label: id })), + ); + expect(withoutStop.find((item) => item.id === "copy-path")?.separatorBefore).toBe(true); + }); it("hides lifecycle items when the environment lacks the capabilities", () => { expect( ids({ diff --git a/apps/web/src/components/threadActionMenu.logic.ts b/apps/web/src/components/threadActionMenu.logic.ts index b513285cb369..79c005bba9cf 100644 --- a/apps/web/src/components/threadActionMenu.logic.ts +++ b/apps/web/src/components/threadActionMenu.logic.ts @@ -26,6 +26,7 @@ export type ThreadActionMenuId = | "rename" | "regenerate-title" | "cancel-action" + | "stop-thread-processes" | "mark-unread" | "copy" | "copy-path" @@ -99,6 +100,7 @@ export interface ThreadActionMenuState { /** Archive rejects a thread with an attached provider, so disable it here rather than let the action fail. */ readonly isRunning: boolean; readonly hasRunningAction: boolean; + readonly hasStoppableProcesses: boolean; readonly supports: { readonly settlement: boolean; /** Server understands thread.auto-settle.set. */ @@ -113,6 +115,32 @@ export interface ThreadActionMenuState { readonly handoffsOverflow?: boolean; } +export function buildStopThreadProcessesMenuItem(hasStoppableProcesses: boolean) { + return hasStoppableProcesses + ? { + id: "stop-thread-processes" as const, + label: "Stop all previews & processes", + destructive: true, + } + : null; +} + +/** The native bridge expresses each group divider on the following command. */ +export function withThreadActionMenuDividers( + items: ReadonlyArray>, +): ReadonlyArray> { + return items.map((item, index) => { + const previousId = items[index - 1]?.id; + const startsGroup = + previousId === "new-thread-on-branch" || + previousId === "annotate" || + previousId === "stop-thread-processes" || + ((previousId === "mark-persistent" || previousId === "disable-persistence") && + item.id !== "stop-thread-processes"); + return startsGroup ? { ...item, separatorBefore: true } : item; + }); +} + /** * Single source for the per-thread action menu: the sidebar row's right-click * menu and the chat header menu share labels, ordering, and capability gating. @@ -121,7 +149,8 @@ export interface ThreadActionMenuState { export function buildThreadActionMenuItems( state: ThreadActionMenuState, ): ReadonlyArray> { - return [ + const stopProcesses = buildStopThreadProcessesMenuItem(state.hasStoppableProcesses); + const items: Array> = [ ...(state.branch ? [ { @@ -153,6 +182,7 @@ export function buildThreadActionMenuItems( }, ] : []), + ...(stopProcesses ? [stopProcesses] : []), // Both lifecycle actions stay available on pinned threads: settling // clears the pin ("done" beats "keep on top"), and snoozing hides the // card until wake with the pin intact. @@ -278,4 +308,5 @@ export function buildThreadActionMenuItems( disabled: state.isPersistent, }, ]; + return withThreadActionMenuDividers(items); } diff --git a/apps/web/src/hooks/useThreadActionMenu.ts b/apps/web/src/hooks/useThreadActionMenu.ts index 04db96bc1da2..d5c8771a37dd 100644 --- a/apps/web/src/hooks/useThreadActionMenu.ts +++ b/apps/web/src/hooks/useThreadActionMenu.ts @@ -19,6 +19,12 @@ import { import { stackedThreadToast, toastManager } from "../components/ui/toast"; import { threadEnvironment } from "../state/threads"; import { terminalEnvironment } from "../state/terminal"; +import { useThreadRunningTerminalIds } from "../state/terminalSessions"; +import { + usePreviewProcessControlsSupported, + useStopThreadProcesses, + useThreadPreviewLeases, +} from "../state/previewHosting"; import { useAtomCommand } from "../state/use-atom-command"; import { readEnvironmentSupportsAutoSettleOptOut, @@ -105,6 +111,17 @@ export function useThreadActionMenu(input: { reportFailure: false, }); const closeTerminal = useAtomCommand(terminalEnvironment.close, "cancel Project Action"); + const stopThreadProcesses = useStopThreadProcesses(); + const previews = useThreadPreviewLeases(threadRef); + const runningTerminalIds = useThreadRunningTerminalIds({ + environmentId: threadRef?.environmentId ?? null, + threadId: threadRef?.threadId ?? null, + }); + const supportsProcessControls = usePreviewProcessControlsSupported( + threadRef?.environmentId ?? null, + ); + const hasStoppableProcesses = + supportsProcessControls && (runningTerminalIds.length > 0 || previews.length > 0); const handleNewThread = useNewThreadHandler(); const confirmThreadDelete = useClientSettings((s) => s.confirmThreadDelete); const confirmThreadArchive = useClientSettings((s) => s.confirmThreadArchive); @@ -166,6 +183,7 @@ export function useThreadActionMenu(input: { isRegeneratingTitle, isRunning: !threadRuntimeCanArchive(thread.runtime), hasRunningAction: thread.actionResume?.outcome === "running", + hasStoppableProcesses, supports, snoozePresets, handoffs: handoffDescriptors, @@ -295,6 +313,9 @@ export function useThreadActionMenu(input: { case "mark-unread": markThreadUnread(threadRef); return; + case "stop-thread-processes": + await stopThreadProcesses(threadRef); + return; case "copy-path": { const workspacePath = thread.worktreePath ?? projectCwd; if (!workspacePath) { @@ -373,6 +394,8 @@ export function useThreadActionMenu(input: { [ archiveThread, closeTerminal, + stopThreadProcesses, + hasStoppableProcesses, confirmThreadArchive, confirmThreadDelete, confirmAndUnpinThread, diff --git a/apps/web/src/state/previewHosting.ts b/apps/web/src/state/previewHosting.ts new file mode 100644 index 000000000000..2567cbbfdb2c --- /dev/null +++ b/apps/web/src/state/previewHosting.ts @@ -0,0 +1,79 @@ +import type { + EnvironmentId, + PreviewHostingLeaseMetadata, + ScopedThreadRef, +} from "@t3tools/contracts"; +import { useAtomValue } from "@effect/atom-react"; +import { useCallback, useMemo } from "react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; + +import { stackedThreadToast, toastManager } from "../components/ui/toast"; +import { previewEnvironment } from "./preview"; +import { useEnvironmentQuery } from "./query"; +import { useAtomCommand } from "./use-atom-command"; +import { environmentServerConfigsAtom } from "./server"; + +const EMPTY_PREVIEW_LEASES: ReadonlyArray = []; + +export function usePreviewProcessControlsSupported(environmentId: EnvironmentId | null) { + return useAtomValue( + environmentServerConfigsAtom, + (configs) => + environmentId !== null && + configs.get(environmentId)?.environment.capabilities.previewHostingProcessControl === true, + ); +} + +export function useThreadPreviewLeases(threadRef: ScopedThreadRef | null) { + const leases = useEnvironmentQuery( + threadRef === null + ? null + : previewEnvironment.hostingLeases({ environmentId: threadRef.environmentId, input: {} }), + ).data; + return useMemo( + () => leases?.filter((lease) => lease.threadId === threadRef?.threadId) ?? EMPTY_PREVIEW_LEASES, + [leases, threadRef?.threadId], + ); +} + +export function useStopThreadProcesses() { + const stop = useAtomCommand(previewEnvironment.hostingStopThread, { reportFailure: false }); + return useCallback( + async (threadRef: ScopedThreadRef) => { + const startedAt = performance.now(); + const toastId = toastManager.add({ + type: "loading", + title: "Stopping all previews & processes…", + }); + const result = await stop({ + environmentId: threadRef.environmentId, + input: { threadId: threadRef.threadId }, + }); + if (result._tag === "Success") { + toastManager.update(toastId, { + type: "success", + title: "All previews & processes stopped", + // Zero disables auto-dismiss, so use one millisecond once the minimum has elapsed. + timeout: Math.max(1, 3_000 - (performance.now() - startedAt)), + }); + } else if (isAtomCommandInterrupted(result)) { + toastManager.close(toastId); + } else { + const error = squashAtomCommandFailure(result); + toastManager.update( + toastId, + stackedThreadToast({ + type: "error", + title: "Could not stop all previews and processes", + description: error instanceof Error ? error.message : "An error occurred.", + }), + ); + } + return result; + }, + [stop], + ); +} diff --git a/packages/client-runtime/src/rpc/client.test.ts b/packages/client-runtime/src/rpc/client.test.ts index 8dda4440e673..6bc38b3907f8 100644 --- a/packages/client-runtime/src/rpc/client.test.ts +++ b/packages/client-runtime/src/rpc/client.test.ts @@ -6,6 +6,7 @@ import { ThreadId, type PreviewAutomationStreamEvent, type RelayClientInstallProgressEvent, + type ServerConfig, type ServerConfigStreamEvent, type ServerLifecycleStreamEvent, WS_METHODS, @@ -92,6 +93,44 @@ const makeHarness = Effect.fn("TestEnvironmentRpc.makeHarness")(function* () { }); describe("environment RPC", () => { + it.effect("tags unsupported capability values with their session without opening an RPC", () => + Effect.gen(function* () { + const client = {} as WsRpcProtocolClient; + const unsupportedSession = { + ...session(client), + initialConfig: Effect.succeed({ environment: { capabilities: {} } } as ServerConfig), + }; + const { activeSession, supervisor } = yield* makeHarness(); + yield* SubscriptionRef.set(activeSession, Option.some(unsupportedSession)); + let inputs = 0; + const result = yield* subscribeDynamicWithSession( + WS_METHODS.subscribePreviewHosting, + () => + Effect.sync(() => { + inputs += 1; + return {}; + }), + { + capability: { + supports: (config) => + config.environment.capabilities.previewHostingProcessControl === true, + unsupportedValue: [], + }, + }, + ).pipe( + Stream.runHead, + Effect.provideService(EnvironmentSupervisor.EnvironmentSupervisor, supervisor), + ); + + expect(Option.isSome(result)).toBe(true); + if (Option.isSome(result)) { + expect(result.value[0]).toBe(unsupportedSession); + expect(result.value[1]).toEqual([]); + } + expect(inputs).toBe(0); + }), + ); + it.effect("registers a fresh preview host after completion without replaying requests", () => Effect.gen(function* () { const firstCompleted = yield* Deferred.make(); diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 4ce45cf3ff1b..39b76ae2e6bc 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -2,6 +2,7 @@ import { EnvironmentAuthorizationError, ORCHESTRATION_V2_WS_METHODS, WS_METHODS, + type ServerConfig, } from "@t3tools/contracts"; import * as Cause from "effect/Cause"; import * as Context from "effect/Context"; @@ -57,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents + | typeof WS_METHODS.subscribePreviewHosting | typeof WS_METHODS.subscribeDiscoveredLocalServers | typeof WS_METHODS.subscribeDeviceState | typeof WS_METHODS.subscribeResourceTelemetry @@ -193,6 +195,11 @@ export function runStream( } interface SubscriptionOptions { + /** Check each session's bootstrap config before opening a capability-dependent RPC. */ + readonly capability?: { + readonly supports: (config: ServerConfig) => boolean; + readonly unsupportedValue: EnvironmentRpcStreamValue; + }; /** Reports protocol or programming defects without changing their recovery policy. */ readonly onDefect?: ( cause: Cause.Cause>, @@ -259,6 +266,18 @@ function subscribeDynamicMapped( Stream.suspend(() => Stream.unwrap( Effect.gen(function* () { + if (options?.capability !== undefined) { + // Connection setup owns bootstrap failures. Keep the + // subscription alive for the supervisor's next session. + const config = yield* Effect.option(session.initialConfig); + if (Option.isNone(config)) return Stream.empty; + if (!options.capability.supports(config.value)) { + return mapStream( + session, + Stream.succeed(options.capability.unsupportedValue), + ); + } + } const input = yield* makeInput(session); const completeObservation = yield* observer.observe({ environmentId: supervisor.target.environmentId, diff --git a/packages/client-runtime/src/state/preview.test.ts b/packages/client-runtime/src/state/preview.test.ts index 7a83bccde98a..ceef310c42cc 100644 --- a/packages/client-runtime/src/state/preview.test.ts +++ b/packages/client-runtime/src/state/preview.test.ts @@ -1,6 +1,267 @@ -import { describe, expect, it } from "vite-plus/test"; +import { describe, expect, it } from "@effect/vitest"; +import { + EnvironmentId, + ThreadId, + WS_METHODS, + type PreviewHostingLeaseMetadata, + type ServerConfig, +} from "@t3tools/contracts"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Latch from "effect/Latch"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Queue from "effect/Queue"; +import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; +import { AsyncResult, Atom, AtomRegistry } from "effect/unstable/reactivity"; +import { RpcClientError } from "effect/unstable/rpc"; -import { previewAutomationHostFocusConcurrencyKey } from "./preview.ts"; +import { + AVAILABLE_CONNECTION_STATE, + PrimaryConnectionTarget, + type PreparedConnection, +} from "../connection/model.ts"; +import * as EnvironmentRegistry from "../connection/registry.ts"; +import * as EnvironmentSupervisor from "../connection/supervisor.ts"; +import type { WsRpcProtocolClient } from "../rpc/protocol.ts"; +import type { RpcSession } from "../rpc/session.ts"; +import { + createPreviewEnvironmentAtoms, + previewAutomationHostFocusConcurrencyKey, +} from "./preview.ts"; + +const TARGET = new PrimaryConnectionTarget({ + environmentId: EnvironmentId.make("preview-environment"), + label: "Preview environment", + httpBaseUrl: "https://environment.example.test", + wsBaseUrl: "wss://environment.example.test", +}); + +const LEASE: PreviewHostingLeaseMetadata = { + leaseId: "preview-lease", + threadId: ThreadId.make("thread-1"), + terminalId: "preview-terminal", + url: "http://localhost:5173/", + handedOffAt: "2026-10-05T00:00:00.000Z", + expiresAt: "2026-10-06T00:00:00.000Z", + status: "active", +}; + +function config(supported?: boolean): ServerConfig { + return { + environment: { + capabilities: supported === undefined ? {} : { previewHostingProcessControl: supported }, + }, + } as ServerConfig; +} + +const makeSession = Effect.fn("PreviewTest.makeSession")(function* ( + supported: boolean | undefined, + initialLeases: ReadonlyArray = [LEASE], +) { + const leases = yield* Queue.unbounded>(); + const stopped = yield* Deferred.make(); + const disconnected = yield* Deferred.make(); + let subscriptions = 0; + let configReads = 0; + const client = { + [WS_METHODS.subscribePreviewHosting]: () => { + subscriptions += 1; + return Stream.concat(Stream.succeed(initialLeases), Stream.fromQueue(leases)).pipe( + Stream.merge(Stream.fromEffect(Deferred.await(disconnected)), { haltStrategy: "either" }), + Stream.ensuring(Deferred.succeed(stopped, undefined)), + ); + }, + } as unknown as WsRpcProtocolClient; + const session: RpcSession = { + client, + initialConfig: Effect.sync(() => { + configReads += 1; + return config(supported); + }), + subscribeServerConfig: (input) => client.subscribeServerConfig(input), + ready: Effect.void, + probe: Effect.void, + closed: Effect.never, + }; + return { + session, + leases, + stopped, + disconnected, + subscriptions: () => subscriptions, + configReads: () => configReads, + }; +}); + +const makeHarness = Effect.fn("PreviewTest.makeHarness")(function* ( + initialSession: Option.Option = Option.none(), +) { + const session = yield* SubscriptionRef.make(initialSession); + const streamStarted = Latch.makeUnsafe(); + const supervisor = EnvironmentSupervisor.EnvironmentSupervisor.of({ + target: TARGET, + state: yield* SubscriptionRef.make(AVAILABLE_CONNECTION_STATE), + session, + prepared: yield* SubscriptionRef.make(Option.none()), + connect: Effect.void, + disconnect: Effect.void, + retryNow: Effect.void, + }); + const run: EnvironmentRegistry.EnvironmentRegistry["Service"]["run"] = (_environmentId, effect) => + Effect.provideService(effect, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + const followStream: EnvironmentRegistry.EnvironmentRegistry["Service"]["followStream"] = ( + _environmentId, + stream, + ) => + Stream.suspend(() => { + streamStarted.openUnsafe(); + return Stream.provideService(stream, EnvironmentSupervisor.EnvironmentSupervisor, supervisor); + }); + const environments = EnvironmentRegistry.EnvironmentRegistry.of({ + run, + followStream, + } as EnvironmentRegistry.EnvironmentRegistry["Service"]); + const runtime = Atom.runtime( + Layer.succeed(EnvironmentRegistry.EnvironmentRegistry, environments), + ); + const atom = createPreviewEnvironmentAtoms(runtime).hostingLeases({ + environmentId: TARGET.environmentId, + input: {}, + }); + const registry = yield* Effect.acquireRelease(Effect.sync(AtomRegistry.make), (registry) => + Effect.sync(() => registry.dispose()), + ); + const observed: Array< + AsyncResult.AsyncResult, unknown> + > = []; + const unmount = registry.subscribe(atom, (result) => observed.push(result), { immediate: true }); + yield* Effect.addFinalizer(() => Effect.sync(unmount)); + return { atom, registry, session, streamStarted, observed }; +}); + +function waitForLeases( + registry: AtomRegistry.AtomRegistry, + atom: Atom.Atom, E>>, + expected: ReadonlyArray, +) { + return AtomRegistry.toStream(registry, atom).pipe( + Stream.filter((result) => AsyncResult.isFailure(result) || AsyncResult.isSuccess(result)), + Stream.filter( + (result) => + AsyncResult.isFailure(result) || + (AsyncResult.isSuccess(result) && result.value === expected), + ), + Stream.runHead, + Effect.tap((result) => + Effect.sync(() => { + expect(Option.isSome(result) && AsyncResult.isSuccess(result.value)).toBe(true); + if (Option.isSome(result) && AsyncResult.isSuccess(result.value)) { + expect(result.value.value).toEqual(expected); + } + }), + ), + ); +} + +describe("preview lease subscriptions", () => { + it.effect("waits for the first session when mounted before connection startup", () => + Effect.scoped( + Effect.gen(function* () { + const first = yield* makeSession(true); + const harness = yield* makeHarness(); + yield* harness.streamStarted.await; + yield* Effect.yieldNow; + + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + expect(first.configReads()).toBe(0); + expect(first.subscriptions()).toBe(0); + + const leases = [LEASE]; + yield* Queue.offer(first.leases, leases); + yield* SubscriptionRef.set(harness.session, Option.some(first.session)); + yield* waitForLeases(harness.registry, harness.atom, leases); + expect(first.configReads()).toBe(1); + expect(first.subscriptions()).toBe(1); + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + }), + ), + ); + + it.effect("recovers after transport loss and closes subscriptions on session replacement", () => + Effect.scoped( + Effect.gen(function* () { + const firstLeases = [LEASE]; + const replacementLeases = [{ ...LEASE, leaseId: "replacement-lease" }]; + const first = yield* makeSession(true, firstLeases); + const replacement = yield* makeSession(true, replacementLeases); + const unsupported = yield* makeSession(undefined); + const harness = yield* makeHarness(Option.some(first.session)); + yield* waitForLeases(harness.registry, harness.atom, firstLeases); + + yield* Deferred.fail( + first.disconnected, + new RpcClientError.RpcClientError({ + reason: new RpcClientError.RpcClientDefect({ + message: "Test transport disconnected", + cause: new Error("Test transport disconnected"), + }), + }), + ); + yield* Deferred.await(first.stopped); + yield* SubscriptionRef.set(harness.session, Option.none()); + yield* SubscriptionRef.set(harness.session, Option.some(replacement.session)); + yield* waitForLeases(harness.registry, harness.atom, replacementLeases); + + yield* SubscriptionRef.set(harness.session, Option.some(unsupported.session)); + yield* Deferred.await(replacement.stopped); + const empty = yield* AtomRegistry.toStream(harness.registry, harness.atom).pipe( + Stream.filter( + (result) => + AsyncResult.isFailure(result) || + (AsyncResult.isSuccess(result) && result.value.length === 0), + ), + Stream.runHead, + ); + expect(Option.isSome(empty) && AsyncResult.isSuccess(empty.value)).toBe(true); + expect(first.subscriptions()).toBe(1); + expect(replacement.subscriptions()).toBe(1); + expect(replacement.configReads()).toBe(1); + expect(unsupported.configReads()).toBe(1); + expect(unsupported.subscriptions()).toBe(0); + expect(harness.observed.some(AsyncResult.isFailure)).toBe(false); + }), + ), + ); + + it.effect.each([undefined, false])( + "emits empty leases without a preview RPC when the server capability is %s", + (supported) => + Effect.scoped( + Effect.gen(function* () { + const unsupported = yield* makeSession(supported); + const harness = yield* makeHarness(Option.some(unsupported.session)); + const result = yield* AtomRegistry.toStream(harness.registry, harness.atom).pipe( + Stream.filter((result) => !AsyncResult.isInitial(result)), + Stream.runHead, + ); + expect(Option.isSome(result) && AsyncResult.isSuccess(result.value)).toBe(true); + if (Option.isSome(result) && AsyncResult.isSuccess(result.value)) { + expect(result.value.value).toEqual([]); + } + expect(unsupported.configReads()).toBe(1); + expect(unsupported.subscriptions()).toBe(0); + + const leases = [LEASE]; + const supportedSession = yield* makeSession(true, leases); + yield* SubscriptionRef.set(harness.session, Option.some(supportedSession.session)); + yield* waitForLeases(harness.registry, harness.atom, leases); + expect(supportedSession.subscriptions()).toBe(1); + }), + ), + ); +}); describe("preview state commands", () => { it("keeps focus updates from replacement host connections independent", () => { diff --git a/packages/client-runtime/src/state/preview.ts b/packages/client-runtime/src/state/preview.ts index a40e62b899bb..d486d1f9090c 100644 --- a/packages/client-runtime/src/state/preview.ts +++ b/packages/client-runtime/src/state/preview.ts @@ -7,7 +7,9 @@ import { createEnvironmentRpcCommand, createEnvironmentRpcQueryAtomFamily, createEnvironmentRpcSubscriptionAtomFamily, + createEnvironmentSubscriptionAtomFamily, } from "./runtime.ts"; +import { subscribe } from "../rpc/client.ts"; export const previewAutomationHostFocusConcurrencyKey = (value: { readonly environmentId: string; @@ -30,6 +32,23 @@ export function createPreviewEnvironmentAtoms( JSON.stringify([environmentId, input.threadId]), }; return { + hostingLeases: createEnvironmentSubscriptionAtomFamily(runtime, { + label: "environment-data:preview:hosting-leases", + subscribe: (input: {}) => + subscribe(WS_METHODS.subscribePreviewHosting, input, { + capability: { + supports: (config) => + config.environment.capabilities.previewHostingProcessControl === true, + unsupportedValue: [], + }, + }), + }), + hostingStopThread: createEnvironmentRpcCommand(runtime, { + label: "environment-data:preview:hosting-stop-thread", + tag: WS_METHODS.previewHostingStopThread, + scheduler: hostingScheduler, + concurrency: lifecycleConcurrency, + }), hostingList: createEnvironmentRpcCommand(runtime, { label: "environment-data:preview:hosting-list", tag: WS_METHODS.previewHostingList, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index efd4ac0d8223..7d9e3429e575 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -152,6 +152,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ threadPinning: Schema.optionalKey(Schema.Boolean), /** Server understands thread.persistence.set and enforces archive/delete protection. */ threadPersistence: Schema.optionalKey(Schema.Boolean), + /** Server streams hosted preview lifetimes and can stop a thread's previews and terminals. */ + previewHostingProcessControl: Schema.optionalKey(Schema.Boolean), /** Server understands thread.pin.reorder (and orderKey on thread.pin). Same version-skew contract as threadSettlement. */ threadPinReorder: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/previewHosting.ts b/packages/contracts/src/previewHosting.ts index c69750f33206..1d2891391e8d 100644 --- a/packages/contracts/src/previewHosting.ts +++ b/packages/contracts/src/previewHosting.ts @@ -45,6 +45,12 @@ export const PreviewHostingLeaseSummary = Schema.Struct({ }); export type PreviewHostingLeaseSummary = typeof PreviewHostingLeaseSummary.Type; +export const PreviewHostingLeaseMetadata = Schema.Struct({ + ...PreviewHostingLeaseSummary.fields, + terminalId: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), +}); +export type PreviewHostingLeaseMetadata = typeof PreviewHostingLeaseMetadata.Type; + export const PreviewHostingRecoverInput = Schema.Struct({ threadId: ThreadId, leaseId: PreviewHostingLeaseId, @@ -57,6 +63,11 @@ export const PreviewHostingListInput = Schema.Struct({ }); export type PreviewHostingListInput = typeof PreviewHostingListInput.Type; +export const PreviewHostingStopThreadInput = Schema.Struct({ + threadId: ThreadId, +}); +export type PreviewHostingStopThreadInput = typeof PreviewHostingStopThreadInput.Type; + export class PreviewHostingError extends Schema.TaggedError()( "PreviewHostingError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index c7aff5897d13..8225f9c9fdd5 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -281,10 +281,12 @@ import { } from "./previewAutomation.ts"; import { PreviewHostingError, + PreviewHostingLeaseMetadata, PreviewHostingLeaseSummary, PreviewHostingListInput, PreviewHostingRecoverInput, PreviewHostingRecoverResult, + PreviewHostingStopThreadInput, } from "./previewHosting.ts"; import { ServerConfigStreamEvent, @@ -440,6 +442,7 @@ export const WS_METHODS = { previewList: "preview.list", previewHostingList: "previewHosting.list", previewHostingRecover: "previewHosting.recover", + previewHostingStopThread: "previewHosting.stopThread", previewReportStatus: "preview.reportStatus", previewClaimRecovery: "preview.claimRecovery", previewAutomationConnect: "previewAutomation.connect", @@ -556,6 +559,7 @@ export const WS_METHODS = { subscribeTerminalEvents: "subscribeTerminalEvents", subscribeTerminalMetadata: "subscribeTerminalMetadata", subscribePreviewEvents: "subscribePreviewEvents", + subscribePreviewHosting: "subscribePreviewHosting", subscribeDiscoveredLocalServers: "subscribeDiscoveredLocalServers", subscribeDeviceState: "subscribeDeviceState", subscribeServerConfig: "subscribeServerConfig", @@ -1505,6 +1509,18 @@ const WsPreviewHostingRecoverRpc = Rpc.make(WS_METHODS.previewHostingRecover, { error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), }); +const WsPreviewHostingStopThreadRpc = Rpc.make(WS_METHODS.previewHostingStopThread, { + payload: PreviewHostingStopThreadInput, + error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), +}); + +const WsSubscribePreviewHostingRpc = Rpc.make(WS_METHODS.subscribePreviewHosting, { + payload: Schema.Struct({}), + success: Schema.Array(PreviewHostingLeaseMetadata), + error: Schema.Union([PreviewHostingError, EnvironmentAuthorizationError]), + stream: true, +}); + const WsPreviewReportStatusRpc = Rpc.make(WS_METHODS.previewReportStatus, { payload: PreviewReportStatusInput, error: Schema.Union([PreviewError, EnvironmentAuthorizationError]), @@ -1957,6 +1973,8 @@ export const WsRpcGroup = RpcGroup.make( WsPreviewListRpc, WsPreviewHostingListRpc, WsPreviewHostingRecoverRpc, + WsPreviewHostingStopThreadRpc, + WsSubscribePreviewHostingRpc, WsPreviewReportStatusRpc, WsPreviewClaimRecoveryRpc, WsPreviewAutomationConnectRpc, diff --git a/packages/shared/src/t3McpToolPresentation.ts b/packages/shared/src/t3McpToolPresentation.ts index f373181aaabb..ea530d732b4d 100644 --- a/packages/shared/src/t3McpToolPresentation.ts +++ b/packages/shared/src/t3McpToolPresentation.ts @@ -147,6 +147,11 @@ const T3_MCP_TOOLS: Readonly> = { ), t3_worktree_status: tool(["Get", "Getting", "Got", "thread worktree status"], "worktree-status"), preview_host: tool(["Host", "Hosting", "Hosted", "a preview server"], "browser", "browser"), + preview_stop_thread: tool( + ["Stop", "Stopping", "Stopped", "thread previews and processes"], + "browser", + "browser", + ), preview_status: tool(["Get", "Getting", "Got", "preview browser status"], "browser", "browser"), preview_open: tool( ["Open", "Opening", "Opened", "a page in the preview browser"],