From 13f02d6d732be2ecb683c36a3712bb43cad9c10b Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Tue, 28 Apr 2026 16:46:59 -0700 Subject: [PATCH] fix desktop terminal attach latency --- apps/desktop/docs/V2_LAUNCH_CONTEXT.md | 33 ++- apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md | 7 +- .../src/main/lib/host-service-coordinator.ts | 4 +- .../lib/terminal/terminal-runtime-registry.ts | 25 ++- .../lib/terminal/terminal-ws-transport.ts | 9 + .../useConsumePendingLaunch.ts | 41 +--- .../components/TerminalPane/TerminalPane.tsx | 124 +++++------ .../useV2PresetExecution.ts | 88 ++++---- .../v2-workspace/$workspaceId/types.ts | 1 + docs/V2_WORKSPACE_SETUP_SCRIPTS.md | 193 ++++++------------ .../host-service/src/terminal/terminal.ts | 39 +++- .../host-service/src/trpc/router/host/host.ts | 4 +- .../src/trpc/router/terminal/terminal.ts | 18 +- .../trpc/src/router/automation/dispatch.ts | 6 +- 14 files changed, 260 insertions(+), 332 deletions(-) diff --git a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md index 329cccc3ac9..c6bdc95385c 100644 --- a/apps/desktop/docs/V2_LAUNCH_CONTEXT.md +++ b/apps/desktop/docs/V2_LAUNCH_CONTEXT.md @@ -27,8 +27,8 @@ Launch dispatch uses the **pending row as the transport** between the pending page (producer) and the V2 workspace page (consumer). **Zero V1 primitives.** Same pattern V2 preset execution uses (`useV2PresetExecution`): live-query a record, open a pane in the V2 -`@superset/panes` store, call `workspaceTrpc.terminal.ensureSession` to -attach PTY. +`@superset/panes` store, and pass any terminal startup command as transient +pane data. `TerminalPane` attaches the PTY through the terminal WebSocket. ``` ┌─────────────────────────────────────────────────────────────┐ @@ -62,7 +62,7 @@ attach PTY. │ │ │ if row.terminalLaunch: │ │ store.addTab({ panes: [{ kind:"terminal", … }] }) │ -│ TerminalPane mounts → ensureSession → write command │ +│ TerminalPane mounts → WebSocket open → initialCommand │ │ update(row, { terminalLaunch: null }) │ │ │ │ if row.chatLaunch: │ @@ -207,20 +207,19 @@ fixing before the dispatch rewrite is considered done: branch for `workspaceTrpc.filesystem.writeFile`. Touches V1, V2, chat, and every consumer — deliberate staged PR, not a quick fix. -2. **Reload-mid-launch spawns a second PTY.** `consumeTerminalLaunch` - calls `crypto.randomUUID()` for `terminalId` each time it fires. If - the user reloads the app between `terminalLaunch` being applied to - the pending row and the consume clearing it, the fresh consume - generates a new terminalId and calls `ensureSession` again — first - PTY orphaned, second one created. Fix: store the `terminalId` on - `PendingTerminalLaunch` itself (generate once in `dispatchForkLaunch`); - `ensureSession` becomes idempotent on repeat consumes. - -3. **Silent failure in the consume hook.** `ensureSession` / - `addTab` failures `console.warn` and return — user sees no pane - open and no error UI. Wrap in try/toast with the error message. - Low urgency while `[v2-launch]` debug logs are present; becomes - visible when those are removed. +2. **Reload-mid-launch can create a new terminal ID.** + `consumeTerminalLaunch` calls `crypto.randomUUID()` for `terminalId` + each time it fires. If the user reloads the app between + `terminalLaunch` being applied to the pending row and the consume + clearing it, the fresh consume can generate a new terminal ID. Fix: + store the `terminalId` on `PendingTerminalLaunch` itself (generate + once in `dispatchForkLaunch`). + +3. **Silent failure in the consume hook.** `addTab` failures + `console.warn` and return — user sees no pane open and no error UI. + Wrap in try/toast with the error message. Low urgency while + `[v2-launch]` debug logs are present; becomes visible when those are + removed. 4. **`joinPath` assumes POSIX separators.** Fine on Mac/Linux hosts where the worktree paths come from. When remote-host launch lands diff --git a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md index a54e7eea048..b95f581ecfe 100644 --- a/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md +++ b/apps/desktop/docs/V2_LAUNCH_TEST_PLAN.md @@ -80,9 +80,10 @@ Disable Claude (or set Superset Chat as preferred via order in settings). - [ ] **D2. Attachment write fails** — manually `chmod` the worktree read-only, submit with attachments. Dispatch logs warning; pane still opens; files missing (expected degradation). -- [ ] **D3. `ensureSession` fails** — stop host-service after create but - before navigation. Consume hook logs warning. `terminalLaunch` - stays set. Restart host-service, refresh. Consume re-fires. +- [ ] **D3. Terminal WebSocket attach fails** — stop host-service after + create but before navigation. Terminal pane opens and reports the + connection failure. Restart host-service, refresh. Consume re-fires + only if `terminalLaunch` was not cleared before attach. - [ ] **D4. Agent disabled mid-flow** — enable agent, start submit, disable before create completes. Pending page finishes. No pane opens. Pending row `terminalLaunch` stays null. diff --git a/apps/desktop/src/main/lib/host-service-coordinator.ts b/apps/desktop/src/main/lib/host-service-coordinator.ts index 3f0d406eaef..779627d3e5f 100644 --- a/apps/desktop/src/main/lib/host-service-coordinator.ts +++ b/apps/desktop/src/main/lib/host-service-coordinator.ts @@ -35,12 +35,14 @@ import { HOOK_PROTOCOL_VERSION } from "./terminal/env"; * which is how we prevent the renderer from talking to a stale host-service * that's missing newly-added procedures/params. * + * 0.4.0: terminal launch moved from `terminal.ensureSession` to + * `terminal.launchSession` plus WebSocket attach params. * 0.3.0: host-service registers via cloud `host.ensure` (was * `device.ensureV2Host`); v2_hosts/v2_users_hosts/v2_workspaces use * machineId text instead of uuid surrogates. * 0.2.0: `workspaceCreation.adopt` gained optional `worktreePath`. */ -const MIN_HOST_SERVICE_VERSION = "0.3.0"; +const MIN_HOST_SERVICE_VERSION = "0.4.0"; export type HostServiceStatus = "starting" | "running" | "stopped"; diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts index a9ac96baac1..cece4746360 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -164,28 +164,33 @@ class TerminalRuntimeRegistryImpl { /** * Open (or re-use) the WebSocket transport for this terminal. - * Caller is responsible for ensuring the server session exists before - * calling — otherwise the server replies "Session not found". + * The WebSocket route can create the server session when the URL includes + * workspaceId; initialCommand is sent as the first frame after open. * * Idempotent: no-op if already connected/connecting to the same URL. */ - connect(terminalId: string, wsUrl: string, instanceId = terminalId) { + connect( + terminalId: string, + wsUrl: string, + instanceId = terminalId, + options: { initialCommand?: string } = {}, + ) { const entry = this.getEntry(terminalId, instanceId); if (!entry?.runtime) return; - connect(entry.transport, entry.runtime.terminal, wsUrl); + connect(entry.transport, entry.runtime.terminal, wsUrl, options); } /** * Swap the transport onto a new URL when it's already been brought up * once. Used by effects watching `websocketUrl` — they fire on initial - * mount when the transport is still `"disconnected"` and ensureSession - * is in-flight, and we must not pre-empt that with a premature connect. + * mount when the transport is still `"disconnected"` and the mount effect + * owns the initial connect. * * Skipped states: `"disconnected"` (never opened; caller should use - * `connect()` via the ensureSession path). Allowed states: `"connecting"` - * (connect() cleanly aborts the in-flight socket), `"open"` (standard - * swap), and `"closed"` (previously live and mid-auto-reconnect — swap - * the URL so the reconnect targets the new endpoint). + * `connect()` from the mount path). Allowed states: `"connecting"` (connect() + * cleanly aborts the in-flight socket), `"open"` (standard swap), and + * `"closed"` (previously live and mid-auto-reconnect — swap the URL so the + * reconnect targets the new endpoint). */ reconnect(terminalId: string, wsUrl: string, instanceId = terminalId) { const entry = this.getEntry(terminalId, instanceId); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts index 32b30a974cd..1896c07c69e 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-ws-transport.ts @@ -120,6 +120,7 @@ export function connect( transport: TerminalTransport, terminal: XTerm, wsUrl: string, + options: { initialCommand?: string } = {}, ) { // Idempotent: skip if already connected/connecting to the same endpoint. const isActive = @@ -145,6 +146,14 @@ export function connect( transport._reconnectAttempt = 0; setConnectionState(transport, "open"); sendResize(transport, terminal.cols, terminal.rows); + if (options.initialCommand) { + socket.send( + JSON.stringify({ + type: "initialCommand", + data: options.initialCommand, + }), + ); + } }); socket.addEventListener("message", (event) => { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts index 4562bd9e1c3..41817df7650 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useConsumePendingLaunch/useConsumePendingLaunch.ts @@ -1,6 +1,5 @@ import type { WorkspaceStore } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; -import { workspaceTrpc } from "@superset/workspace-client"; import { eq } from "@tanstack/db"; import { useLiveQuery } from "@tanstack/react-db"; import { useCallback, useEffect, useRef } from "react"; @@ -33,9 +32,6 @@ export function useConsumePendingLaunch({ store, }: UseConsumePendingLaunchArgs): void { const collections = useCollections(); - const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); - const ensureSessionRef = useRef(ensureSession); - ensureSessionRef.current = ensureSession; const consumedRef = useRef>(new Set()); const { data: matches } = useLiveQuery( @@ -87,10 +83,9 @@ export function useConsumePendingLaunch({ console.log("[v2-launch] useConsumePendingLaunch: consuming terminal", { command: pending.terminalLaunch?.command.slice(0, 120), }); - void consumeTerminalLaunch({ + consumeTerminalLaunch({ pending, store, - ensureSession: ensureSessionRef.current.mutateAsync, clear: () => updateRow({ terminalLaunch: null }), }); } @@ -107,21 +102,15 @@ export function useConsumePendingLaunch({ }, [pending, store, updateRow, workspaceId]); } -async function consumeTerminalLaunch({ +function consumeTerminalLaunch({ pending, store, - ensureSession, clear, }: { pending: PendingWorkspaceRow; store: StoreApi>; - ensureSession: (input: { - terminalId: string; - workspaceId: string; - initialCommand?: string; - }) => Promise; clear: () => void; -}): Promise { +}): void { const launch = pending.terminalLaunch; if (!launch || !pending.workspaceId) { console.warn("[v2-launch] consumeTerminalLaunch: bailing", { @@ -138,30 +127,16 @@ async function consumeTerminalLaunch({ } const terminalId = crypto.randomUUID(); - console.log("[v2-launch] consumeTerminalLaunch: ensureSession", { + console.log("[v2-launch] consumeTerminalLaunch: addTab", { terminalId, workspaceId: pending.workspaceId, commandPreview: launch.command.slice(0, 120), }); - try { - await ensureSession({ - terminalId, - workspaceId: pending.workspaceId, - initialCommand: launch.command, - }); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - console.warn( - "[v2-launch] consumeTerminalLaunch: ensureSession failed:", - err, - ); - toast.error("Couldn't start agent terminal", { description: msg }); - return; - } - - const data: TerminalPaneData = { terminalId }; - console.log("[v2-launch] consumeTerminalLaunch: addTab", { terminalId }); + const data: TerminalPaneData = { + terminalId, + initialCommand: launch.command, + }; store.getState().addTab({ panes: [ { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx index 31329a83de7..87e17e4248a 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/components/TerminalPane/TerminalPane.tsx @@ -57,6 +57,7 @@ export function TerminalPane({ const openInExternalEditor = useOpenInExternalEditor(workspaceId); const paneData = ctx.pane.data as TerminalPaneData; const { terminalId } = paneData; + const initialCommandRef = useRef(paneData.initialCommand); const terminalInstanceId = ctx.pane.id; const containerRef = useRef(null); const activeTheme = useTheme(); @@ -73,17 +74,17 @@ export function TerminalPane({ }), ); - // URL is stable — no workspaceId/themeType in query params. - // Session is created via tRPC before WebSocket connects. - const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`); + // Include workspaceId/themeType so the WebSocket route can create the + // session on open. Terminal attach should not wait behind workspace tRPC. + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { + workspaceId, + themeType: initialThemeTypeRef.current, + }); const websocketUrlRef = useRef(websocketUrl); websocketUrlRef.current = websocketUrl; const workspaceIdRef = useRef(workspaceId); workspaceIdRef.current = workspaceId; - const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); - const ensureSessionRef = useRef(ensureSession); - ensureSessionRef.current = ensureSession; const workspaceTrpcUtils = workspaceTrpc.useUtils(); const invalidateTerminalSessionsRef = useRef( workspaceTrpcUtils.terminal.listSessions.invalidate, @@ -119,12 +120,12 @@ export function TerminalPane({ // is visible immediately, even on cold start. For a warm return // (workspace switch) this reparents the wrapper from the parking // container back into the live tree, preserving the buffer. - // 2. ensureSession guarantees the server session exists, then connect() - // opens the WebSocket. Never before — otherwise the server replies - // "Session not found." - // Deps narrowed to [terminalId] so provider key remount churn (workspaceId - // briefly flipping while pane data catches up) doesn't re-run this effect. - // workspaceId / websocketUrl are read through refs. + // 2. connect() opens the WebSocket immediately. The host-service terminal + // route creates the session from the URL workspaceId if needed, avoiding + // tRPC head-of-line blocking during workspace switches. + // Deps narrowed to the terminal identity so provider key remount churn + // (workspaceId briefly flipping while pane data catches up) doesn't re-run + // this effect. workspaceId / websocketUrl are read through refs. useEffect(() => { const container = containerRef.current; if (!container) return; @@ -136,66 +137,65 @@ export function TerminalPane({ terminalInstanceId, ); - let cancelled = false; - const sessionWorkspaceId = workspaceIdRef.current; + terminalRuntimeRegistry.connect( + terminalId, + websocketUrlRef.current, + terminalInstanceId, + { initialCommand: initialCommandRef.current }, + ); - // Always connect after ensureSession settles, even on error: if the - // session actually exists on the server (e.g. we raced another client), - // connect() succeeds; otherwise "Session not found" surfaces in-terminal - // as an error line. connect() is idempotent, so a warm terminal whose - // WS is already open against the same URL is a no-op. - ensureSessionRef.current - .mutateAsync({ - terminalId, - workspaceId: sessionWorkspaceId, - themeType: initialThemeTypeRef.current, - }) - .then((result) => { - if (result.status === "active") { - void invalidateTerminalSessionsRef.current({ - workspaceId: sessionWorkspaceId, - }); - return; - } - if (cancelled) return; - const details = result.error - ? `: ${result.error}` - : " for an unknown reason"; - terminalRuntimeRegistry - .getTerminal(terminalId, terminalInstanceId) - ?.writeln( - `\r\n[terminal] Failed to create terminal session${details}`, - ); - }) - .catch((err) => { - console.error("[TerminalPane] ensureSession failed:", err); - if (cancelled) return; - const message = err instanceof Error ? err.message : String(err); - terminalRuntimeRegistry - .getTerminal(terminalId, terminalInstanceId) - ?.writeln( - `\r\n[terminal] terminal.ensureSession request failed: ${message}`, - ); - }) - .finally(() => { - if (cancelled) return; - terminalRuntimeRegistry.connect( + return () => { + terminalRuntimeRegistry.detach(terminalId, terminalInstanceId); + }; + }, [terminalId, terminalInstanceId]); + + useEffect(() => { + if (connectionState !== "open" || !initialCommandRef.current) return; + + initialCommandRef.current = undefined; + if (paneData.initialCommand === undefined) return; + + ctx.actions.updateData({ + ...paneData, + initialCommand: undefined, + } as PaneViewerData); + }, [connectionState, ctx.actions, paneData]); + + const lastInvalidatedOpenSessionRef = useRef(null); + useEffect(() => { + const invalidateSessionsAfterSocketOpen = () => { + if ( + terminalRuntimeRegistry.getConnectionState( terminalId, - websocketUrlRef.current, terminalInstanceId, - ); - }); + ) !== "open" + ) { + lastInvalidatedOpenSessionRef.current = null; + return; + } - return () => { - cancelled = true; - terminalRuntimeRegistry.detach(terminalId, terminalInstanceId); + const sessionWorkspaceId = workspaceIdRef.current; + const invalidateKey = `${sessionWorkspaceId}:${terminalId}:${terminalInstanceId}:${websocketUrlRef.current}`; + if (lastInvalidatedOpenSessionRef.current === invalidateKey) return; + lastInvalidatedOpenSessionRef.current = invalidateKey; + + void invalidateTerminalSessionsRef.current({ + workspaceId: sessionWorkspaceId, + }); }; + + invalidateSessionsAfterSocketOpen(); + return terminalRuntimeRegistry.onStateChange( + terminalId, + invalidateSessionsAfterSocketOpen, + terminalInstanceId, + ); }, [terminalId, terminalInstanceId]); // WS URL can change while the terminal stays mounted (token refresh, host // URL re-resolution on provider remount). Reconnect only if the transport // is already live — on initial mount the transport is "disconnected" and - // we let the ensureSession path above open it. + // we let the mount path above open it. useEffect(() => { terminalRuntimeRegistry.reconnect( terminalId, diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts index 5cea7c54be9..d953a450a56 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/useV2PresetExecution/useV2PresetExecution.ts @@ -1,8 +1,7 @@ import type { CreatePaneInput, WorkspaceStore } from "@superset/panes"; import { toast } from "@superset/ui/sonner"; -import { workspaceTrpc } from "@superset/workspace-client"; import { useLiveQuery } from "@tanstack/react-db"; -import { useCallback, useMemo, useRef } from "react"; +import { useCallback, useMemo } from "react"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; import type { V2TerminalPresetRow } from "renderer/routes/_authenticated/providers/CollectionsProvider/dashboardSidebarLocal"; import { getPresetLaunchPlan } from "renderer/stores/tabs/preset-launch"; @@ -13,11 +12,12 @@ import type { PaneViewerData, TerminalPaneData } from "../../types"; function makeTerminalPane( terminalId: string, titleOverride?: string, + initialCommand?: string, ): CreatePaneInput { return { kind: "terminal", titleOverride, - data: { terminalId } as TerminalPaneData, + data: { terminalId, initialCommand } as TerminalPaneData, }; } @@ -33,13 +33,9 @@ interface UseV2PresetExecutionArgs { export function useV2PresetExecution({ store, - workspaceId, projectId, }: UseV2PresetExecutionArgs) { const collections = useCollections(); - const ensureSession = workspaceTrpc.terminal.ensureSession.useMutation(); - const ensureSessionRef = useRef(ensureSession); - ensureSessionRef.current = ensureSession; const { data: allPresets = [] } = useLiveQuery( (query) => @@ -54,22 +50,8 @@ export function useV2PresetExecution({ [allPresets, projectId], ); - /** Create a terminal session with a command on the host-service, return the terminalId. */ - const createSessionWithCommand = useCallback( - async (command: string): Promise => { - const terminalId = crypto.randomUUID(); - await ensureSessionRef.current.mutateAsync({ - terminalId, - workspaceId, - initialCommand: command, - }); - return terminalId; - }, - [workspaceId], - ); - const executePreset = useCallback( - async (preset: V2TerminalPresetRow) => { + (preset: V2TerminalPresetRow) => { const state = store.getState(); const activeTabId = state.activeTabId; const target = resolveTarget(preset.executionMode); @@ -84,21 +66,26 @@ export function useV2PresetExecution({ try { switch (plan) { case "new-tab-single": { - const id = await createSessionWithCommand( - preset.commands[0] as string, - ); + const id = crypto.randomUUID(); state.addTab({ - panes: [makeTerminalPane(id, preset.name || undefined)], + panes: [ + makeTerminalPane( + id, + preset.name || undefined, + preset.commands[0], + ), + ], }); break; } case "new-tab-multi-pane": { - const ids = await Promise.all( - preset.commands.map((cmd) => createSessionWithCommand(cmd)), - ); - const panes = ids.map((id) => - makeTerminalPane(id, preset.name || undefined), + const panes = preset.commands.map((command) => + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), ); state.addTab({ panes: @@ -118,13 +105,14 @@ export function useV2PresetExecution({ } case "new-tab-per-command": { - const ids = await Promise.all( - preset.commands.map((cmd) => createSessionWithCommand(cmd)), - ); - for (let i = 0; i < ids.length; i++) { + for (const command of preset.commands) { state.addTab({ panes: [ - makeTerminalPane(ids[i] as string, preset.name || undefined), + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), ], }); } @@ -132,30 +120,34 @@ export function useV2PresetExecution({ } case "active-tab-single": { - const id = await createSessionWithCommand( - preset.commands[0] as string, + const id = crypto.randomUUID(); + const pane = makeTerminalPane( + id, + preset.name || undefined, + preset.commands[0], ); if (!activeTabId) { state.addTab({ - panes: [makeTerminalPane(id, preset.name || undefined)], + panes: [pane], }); break; } state.addPane({ tabId: activeTabId, - pane: makeTerminalPane(id, preset.name || undefined), + pane, }); break; } case "active-tab-multi-pane": { - const ids = await Promise.all( - preset.commands.map((cmd) => createSessionWithCommand(cmd)), + const panes = preset.commands.map((command) => + makeTerminalPane( + crypto.randomUUID(), + preset.name || undefined, + command, + ), ); if (!activeTabId) { - const panes = ids.map((id) => - makeTerminalPane(id, preset.name || undefined), - ); state.addTab({ panes: panes.length > 0 @@ -172,10 +164,10 @@ export function useV2PresetExecution({ }); break; } - for (const id of ids) { + for (const pane of panes) { state.addPane({ tabId: activeTabId, - pane: makeTerminalPane(id, preset.name || undefined), + pane, }); } break; @@ -191,7 +183,7 @@ export function useV2PresetExecution({ }); } }, - [store, createSessionWithCommand], + [store], ); return { matchedPresets, executePreset }; diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts index 0615d9c65f7..cd2737cf785 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types.ts @@ -8,6 +8,7 @@ export interface FilePaneData { export interface TerminalPaneData { terminalId: string; + initialCommand?: string; } export interface ChatPaneData { diff --git a/docs/V2_WORKSPACE_SETUP_SCRIPTS.md b/docs/V2_WORKSPACE_SETUP_SCRIPTS.md index 66da7ef3eac..83cc562ec47 100644 --- a/docs/V2_WORKSPACE_SETUP_SCRIPTS.md +++ b/docs/V2_WORKSPACE_SETUP_SCRIPTS.md @@ -1,161 +1,86 @@ # V2 Workspace Setup Script Execution -## Problem +## Current Model -1. V2 workspace creation returns `initialCommands` (from `.superset/setup.sh`) but never executes them. -2. Presets race with shell init — commands fire before the shell is ready. +V2 terminal startup commands are queued by host-service behind the terminal +shell readiness gate. The renderer should not wait on a tRPC terminal creation +call before mounting a pane. -## Approach +There are two supported paths: -One unified API: all initial commands go through `createTerminalSessionInternal({ initialCommand })`, gated behind `shellReadyPromise`. The renderer never writes commands — it only attaches to sessions. +1. **Renderer-owned pane launch**: create a terminal pane with + `TerminalPaneData.initialCommand`. `TerminalPane` opens the WebSocket + immediately and sends `{ type: "initialCommand" }` after the socket opens. + Host-service queues the command behind `shellReadyPromise`. +2. **Server-side launch**: call + `terminal.launchSession({ workspaceId, terminalId?, initialCommand, themeType? })`. + This is for server/relay callers such as automation dispatch that need a + terminal session without a mounted renderer pane. -1. OSC 133 (FinalTerm standard) for shell readiness detection -2. `initialCommand` on `createTerminalSessionInternal` — queues command behind `shellReadyPromise` -3. `ensureSession` gains optional `initialCommand`, passes through to `createTerminalSessionInternal` -4. Presets call `await ensureSession({ initialCommand })` before adding pane — no `initialCommand` on pane data -5. Setup scripts call `createTerminalSessionInternal({ initialCommand })` directly during workspace creation -6. `TerminalPane` simplified — just calls `ensureSession()` and attaches WebSocket. No command delivery logic. +Plain V2 terminal panes do not pre-create sessions through tRPC. They open the +WebSocket with `workspaceId` and `themeType`; the WebSocket route creates or +attaches the terminal session on open. -Existing output buffering (`bufferOutput`/`replayBuffer`) handles the gap between session creation and WebSocket connect. +## Shell Readiness -## Phase 1: Shell Readiness via OSC 133 ✅ DONE +Shell wrappers emit OSC 133 A/C/D markers. Host-service scans terminal output +and resolves `shellReadyPromise` when the prompt is ready. If the marker never +arrives, the timeout unblocks queued commands so unsupported shells still work. -Shell wrappers updated to emit OSC 133 A/C/D. Scanner + `shellReadyPromise` added to `terminal.ts`. +`createTerminalSessionInternal({ initialCommand })` and WebSocket +`initialCommand` frames both use the same queueing helper, so setup scripts, +automation launches, presets, and pending terminal launches share the same +shell-ready behavior. ---- +## Setup Script Terminals -## Phase 2: `initialCommand` on Session Creation +Workspace setup scripts are created server-side during workspace creation by +calling `createTerminalSessionInternal({ initialCommand })`. The renderer later +opens panes for the returned terminal IDs; buffered output replays on attach. -### `createTerminalSessionInternal` +## Presets And Pending Launches -**File:** `packages/host-service/src/terminal/terminal.ts` +V2 presets and pending terminal launches create panes first: -```typescript -interface CreateTerminalSessionOptions { - // ...existing... - initialCommand?: string; -} - -// After PTY creation + shell ready setup: -if (initialCommand) { - session.shellReadyPromise.then(() => { - if (!session.exited) { - pty.write(initialCommand.endsWith("\n") ? initialCommand : `${initialCommand}\n`); - } - }); -} -``` - -### `ensureSession` tRPC - -**File:** `packages/host-service/src/trpc/router/terminal/terminal.ts` - -Add optional `initialCommand` to input, pass through: - -```typescript -ensureSession: protectedProcedure - .input(z.object({ - terminalId: z.string(), - workspaceId: z.string(), - themeType: z.string().optional(), - initialCommand: z.string().optional(), - })) - .mutation(({ ctx, input }) => { - const result = createTerminalSessionInternal({ - terminalId: input.terminalId, - workspaceId: input.workspaceId, - themeType: parseThemeType(input.themeType), - db: ctx.db, - initialCommand: input.initialCommand, - }); - // ... - }), -``` - -### Update presets - -**File:** `apps/desktop/.../useV2PresetExecution/useV2PresetExecution.ts` - -Before adding the pane, create the session with the command: - -```typescript +```ts const terminalId = crypto.randomUUID(); -await ensureSession({ terminalId, workspaceId, initialCommand: command }); -store.addTab({ panes: [{ kind: "terminal", data: { terminalId } }] }); -``` - -### Simplify TerminalPane - -**File:** `apps/desktop/.../TerminalPane/TerminalPane.tsx` - -Delete the `initialCommand` delivery effect (lines 119-133). `TerminalPane` only does: `ensureSession()` + attach WebSocket. - -### Remove `initialCommand` from pane data - -**File:** `apps/desktop/.../v2-workspace/$workspaceId/types.ts` - -```typescript -export interface TerminalPaneData { - terminalId: string; - // initialCommand removed -} +store.addTab({ + panes: [ + { + kind: "terminal", + data: { terminalId, initialCommand }, + }, + ], +}); ``` ---- - -## Phase 3: Create Setup Terminal During Workspace Creation - -**File:** `packages/host-service/src/trpc/router/workspace-creation/workspace-creation.ts` +`TerminalPane` consumes the transient `initialCommand`, sends it over the +terminal WebSocket, then clears it from pane data after the socket opens. -Replace command resolution (lines 462-469) with terminal creation. Return terminal descriptors: +## Automation -```typescript -const terminals: Array<{ id: string; role: string; label: string }> = []; +Automation dispatch uses the explicit launch API: -if (input.composer.runSetupScript) { - const setupScriptPath = join(worktreePath, ".superset", "setup.sh"); - if (existsSync(setupScriptPath)) { - const terminalId = crypto.randomUUID(); - const result = createTerminalSessionInternal({ - terminalId, - workspaceId: cloudRow.id, - db: ctx.db, - initialCommand: `bash "${setupScriptPath}"`, - }); - if (!("error" in result)) { - terminals.push({ id: terminalId, role: "setup", label: "Workspace Setup" }); - } - } -} - -return { workspace: cloudRow, terminals, warnings: [] as string[] }; +```ts +await terminal.launchSession({ + workspaceId, + terminalId, + initialCommand: command, +}); ``` ---- - -## Phase 4: Renderer Attaches to Pre-Started Terminals - -- Add `terminals` to `pendingWorkspaceSchema` -- Before navigating to workspace, pre-populate `v2WorkspaceLocalState.paneLayout` with terminal panes referencing host-provided `terminalId`s -- `TerminalPane` mounts → `ensureSession` (idempotent, session exists) → WebSocket connects → buffered output replays - -**Files:** -- `apps/desktop/.../dashboardSidebarLocal/schema.ts` -- `apps/desktop/.../pending/$pendingId/page.tsx` -- `apps/desktop/.../pending/$pendingId/buildSetupPaneLayout.ts` (new) - ---- - -## Future: "Run in Current Terminal" - -Not used in v2 today. When needed, add a `terminal.writeCommand` tRPC mutation. - ---- +This API is launch semantics, not idempotent "ensure" semantics. Errors throw +through tRPC so dispatch can fail the automation run instead of marking a +terminal session as dispatched when the PTY could not be created. ## Attribution Shell integration protocol vendored from: -- **WezTerm** (MIT License, Copyright 2018-Present Wez Furlong) — `assets/shell-integration/wezterm.sh` -- **FinalTerm semantic prompts spec** — https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md -Scanner pattern adapted from our v1 desktop terminal host (`apps/desktop/src/main/terminal-host/session.ts`). +- **WezTerm** (MIT License, Copyright 2018-Present Wez Furlong) — + `assets/shell-integration/wezterm.sh` +- **FinalTerm semantic prompts spec** — + https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md + +Scanner pattern adapted from our v1 desktop terminal host +(`apps/desktop/src/main/terminal-host/session.ts`). diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 23cd9288e12..7f7f7e04613 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -51,6 +51,7 @@ function getHostAgentHookUrl(): string { type TerminalClientMessage = | { type: "input"; data: string } + | { type: "initialCommand"; data: string } | { type: "resize"; cols: number; rows: number } | { type: "dispose" }; @@ -114,6 +115,7 @@ interface TerminalSession { shellReadyPromise: Promise; shellReadyTimeoutId: ReturnType | null; scanState: ShellReadyScanState; + initialCommandQueued: boolean; } /** PTY lifetime is independent of socket lifetime — sockets detach/reattach freely. */ @@ -250,6 +252,22 @@ function resolveShellReady( } } +function queueInitialCommand( + session: TerminalSession, + initialCommand: string, +): void { + if (session.initialCommandQueued) return; + session.initialCommandQueued = true; + const cmd = initialCommand.endsWith("\n") + ? initialCommand + : `${initialCommand}\n`; + session.shellReadyPromise.then(() => { + if (!session.exited) { + session.pty.write(cmd); + } + }); +} + /** * Kills the PTY (if live) and marks the DB row disposed. Safe to call even * when there's no in-memory session — e.g. for zombie `active` rows left @@ -448,6 +466,7 @@ export function createTerminalSessionInternal({ shellReadyPromise, shellReadyTimeoutId: null, scanState: createScanState(), + initialCommandQueued: false, }; sessions.set(terminalId, session); portManager.upsertSession(terminalId, workspaceId, pty.pid); @@ -513,14 +532,7 @@ export function createTerminalSessionInternal({ }); if (initialCommand) { - const cmd = initialCommand.endsWith("\n") - ? initialCommand - : `${initialCommand}\n`; - session.shellReadyPromise.then(() => { - if (!session.exited) { - pty.write(cmd); - } - }); + queueInitialCommand(session, initialCommand); } return session; @@ -596,13 +608,13 @@ export function registerWorkspaceTerminalRoute({ const existing = sessions.get(terminalId); if (!existing) { - // Session must be created via tRPC terminal.ensureSession before connecting. - // Fall back to query params for backwards compatibility with v1 callers. + // V2 callers can create a session by opening the WebSocket with + // workspaceId; this keeps terminal attach out of tRPC request queues. const workspaceId = c.req.query("workspaceId") ?? null; if (!workspaceId) { sendMessage(ws, { type: "error", - message: `Terminal session "${terminalId}" not found; use terminal.ensureSession or workspaceId.`, + message: `Terminal session "${terminalId}" not found; open with workspaceId or create it before connecting.`, }); ws.close(1011, "Terminal session not found"); return; @@ -678,6 +690,11 @@ export function registerWorkspaceTerminalRoute({ return; } + if (message.type === "initialCommand") { + queueInitialCommand(session, message.data); + return; + } + if (message.type === "resize") { const cols = Math.max(20, Math.floor(message.cols)); const rows = Math.max(5, Math.floor(message.rows)); diff --git a/packages/host-service/src/trpc/router/host/host.ts b/packages/host-service/src/trpc/router/host/host.ts index c8a62e4f464..d587b0e2501 100644 --- a/packages/host-service/src/trpc/router/host/host.ts +++ b/packages/host-service/src/trpc/router/host/host.ts @@ -4,13 +4,15 @@ import { TRPCError } from "@trpc/server"; import type { ApiClient } from "../../../types"; import { protectedProcedure, router } from "../../index"; +// 0.4.0: terminal launch moved from `terminal.ensureSession` to +// `terminal.launchSession` plus WebSocket attach params. // 0.3.0: cloud `device.*` router renamed to `host.*`; `device.ensureV2Host` // is now `host.ensure`, host registrations are keyed on (orgId, machineId) // composite, and `targetHostId`/`v2_workspaces.host_id` are machineId text // not uuid. Older host-service binaries call the now-removed `device.*` // procedures and fail at registration. // 0.2.0: `workspaceCreation.adopt` accepts optional `worktreePath`. -const HOST_SERVICE_VERSION = "0.3.0"; +const HOST_SERVICE_VERSION = "0.4.0"; const ORGANIZATION_CACHE_TTL_MS = 60 * 60 * 1000; let cachedOrganization: { diff --git a/packages/host-service/src/trpc/router/terminal/terminal.ts b/packages/host-service/src/trpc/router/terminal/terminal.ts index a965f7620c8..b8e36c2a862 100644 --- a/packages/host-service/src/trpc/router/terminal/terminal.ts +++ b/packages/host-service/src/trpc/router/terminal/terminal.ts @@ -11,18 +11,19 @@ import { import { protectedProcedure, router } from "../../index"; export const terminalRouter = router({ - ensureSession: protectedProcedure + launchSession: protectedProcedure .input( z.object({ - terminalId: z.string(), workspaceId: z.string(), + terminalId: z.string().optional(), + initialCommand: z.string().min(1), themeType: z.string().optional(), - initialCommand: z.string().optional(), }), ) .mutation(({ ctx, input }) => { + const terminalId = input.terminalId ?? crypto.randomUUID(); const result = createTerminalSessionInternal({ - terminalId: input.terminalId, + terminalId, workspaceId: input.workspaceId, themeType: parseThemeType(input.themeType), db: ctx.db, @@ -31,11 +32,10 @@ export const terminalRouter = router({ }); if ("error" in result) { - return { - terminalId: input.terminalId, - status: "error" as const, - error: result.error, - }; + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: result.error, + }); } return { terminalId: result.terminalId, status: "active" as const }; diff --git a/packages/trpc/src/router/automation/dispatch.ts b/packages/trpc/src/router/automation/dispatch.ts index a3144ea1ecf..a581e84c4fe 100644 --- a/packages/trpc/src/router/automation/dispatch.ts +++ b/packages/trpc/src/router/automation/dispatch.ts @@ -357,14 +357,14 @@ async function dispatchTerminalSession(args: { const terminalId = crypto.randomUUID(); await relayMutation< { - terminalId: string; workspaceId: string; - initialCommand?: string; + terminalId?: string; + initialCommand: string; }, { terminalId: string; status: string } >( { relayUrl: args.relayUrl, hostId: args.hostId, jwt: args.jwt }, - "terminal.ensureSession", + "terminal.launchSession", { terminalId, workspaceId: args.workspaceId,