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 d6551f6b857..f28c71fb7f3 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime-registry.ts @@ -23,21 +23,21 @@ interface RegistryEntry { class TerminalRuntimeRegistryImpl { private entries = new Map(); - private getOrCreate(paneId: string): RegistryEntry { - let entry = this.entries.get(paneId); + private getOrCreate(terminalId: string): RegistryEntry { + let entry = this.entries.get(terminalId); if (entry) return entry; entry = { - runtime: createRuntime(paneId), + runtime: createRuntime(terminalId), transport: createTransport(), }; - this.entries.set(paneId, entry); + this.entries.set(terminalId, entry); return entry; } - attach(paneId: string, container: HTMLDivElement, wsUrl: string) { - const { runtime, transport } = this.getOrCreate(paneId); + attach(terminalId: string, container: HTMLDivElement, wsUrl: string) { + const { runtime, transport } = this.getOrCreate(terminalId); attachToContainer(runtime, container, () => { sendResize(transport, runtime.terminal.cols, runtime.terminal.rows); @@ -46,49 +46,40 @@ class TerminalRuntimeRegistryImpl { connect(transport, runtime.terminal, wsUrl); } - /** - * Detach the terminal from its DOM container. - * - * This only removes the DOM attachment (wrapper, resize observer, focus). - * The WebSocket and xterm data flow are intentionally kept alive so output - * written while the pane is hidden is not lost. Disposal of the transport - * happens exclusively through {@link dispose} when the paneId is removed - * from persisted pane state. - */ - detach(paneId: string) { - const entry = this.entries.get(paneId); + detach(terminalId: string) { + const entry = this.entries.get(terminalId); if (!entry) return; detachFromContainer(entry.runtime); } - dispose(paneId: string) { - const entry = this.entries.get(paneId); + dispose(terminalId: string) { + const entry = this.entries.get(terminalId); if (!entry) return; sendDispose(entry.transport); disposeTransport(entry.transport); disposeRuntime(entry.runtime); - this.entries.delete(paneId); + this.entries.delete(terminalId); } - getAllPaneIds(): Set { + getAllTerminalIds(): Set { return new Set(this.entries.keys()); } - has(paneId: string): boolean { - return this.entries.has(paneId); + has(terminalId: string): boolean { + return this.entries.has(terminalId); } - getConnectionState(paneId: string): ConnectionState { + getConnectionState(terminalId: string): ConnectionState { return ( - this.entries.get(paneId)?.transport.connectionState ?? "disconnected" + this.entries.get(terminalId)?.transport.connectionState ?? "disconnected" ); } - onStateChange(paneId: string, listener: () => void): () => void { - const { transport } = this.getOrCreate(paneId); + onStateChange(terminalId: string, listener: () => void): () => void { + const { transport } = this.getOrCreate(terminalId); transport.stateListeners.add(listener); return () => { transport.stateListeners.delete(listener); diff --git a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts index 484a44d635f..9e7e4499ccd 100644 --- a/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts +++ b/apps/desktop/src/renderer/lib/terminal/terminal-runtime.ts @@ -9,7 +9,7 @@ const DEFAULT_COLS = 120; const DEFAULT_ROWS = 32; export interface TerminalRuntime { - paneId: string; + terminalId: string; terminal: XTerm; fitAddon: FitAddon; serializeAddon: SerializeAddon; @@ -49,40 +49,40 @@ function createTerminal( return { terminal, fitAddon, serializeAddon }; } -function persistBuffer(paneId: string, serializeAddon: SerializeAddon) { +function persistBuffer(terminalId: string, serializeAddon: SerializeAddon) { try { const data = serializeAddon.serialize({ scrollback: SERIALIZE_SCROLLBACK }); - localStorage.setItem(`${STORAGE_KEY_PREFIX}${paneId}`, data); + localStorage.setItem(`${STORAGE_KEY_PREFIX}${terminalId}`, data); } catch {} } -function restoreBuffer(paneId: string, terminal: XTerm) { +function restoreBuffer(terminalId: string, terminal: XTerm) { try { - const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${paneId}`); + const data = localStorage.getItem(`${STORAGE_KEY_PREFIX}${terminalId}`); if (data) terminal.write(data); } catch {} } -function clearPersistedBuffer(paneId: string) { +function clearPersistedBuffer(terminalId: string) { try { - localStorage.removeItem(`${STORAGE_KEY_PREFIX}${paneId}`); + localStorage.removeItem(`${STORAGE_KEY_PREFIX}${terminalId}`); } catch {} } -function persistDimensions(paneId: string, cols: number, rows: number) { +function persistDimensions(terminalId: string, cols: number, rows: number) { try { localStorage.setItem( - `${DIMS_KEY_PREFIX}${paneId}`, + `${DIMS_KEY_PREFIX}${terminalId}`, JSON.stringify({ cols, rows }), ); } catch {} } function loadSavedDimensions( - paneId: string, + terminalId: string, ): { cols: number; rows: number } | null { try { - const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${paneId}`); + const raw = localStorage.getItem(`${DIMS_KEY_PREFIX}${terminalId}`); if (!raw) return null; const parsed = JSON.parse(raw); if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { @@ -94,9 +94,9 @@ function loadSavedDimensions( } } -function clearPersistedDimensions(paneId: string) { +function clearPersistedDimensions(terminalId: string) { try { - localStorage.removeItem(`${DIMS_KEY_PREFIX}${paneId}`); + localStorage.removeItem(`${DIMS_KEY_PREFIX}${terminalId}`); } catch {} } @@ -112,8 +112,8 @@ function measureAndResize(runtime: TerminalRuntime) { runtime.lastRows = runtime.terminal.rows; } -export function createRuntime(paneId: string): TerminalRuntime { - const savedDims = loadSavedDimensions(paneId); +export function createRuntime(terminalId: string): TerminalRuntime { + const savedDims = loadSavedDimensions(terminalId); const cols = savedDims?.cols ?? DEFAULT_COLS; const rows = savedDims?.rows ?? DEFAULT_ROWS; @@ -123,10 +123,10 @@ export function createRuntime(paneId: string): TerminalRuntime { wrapper.style.width = "100%"; wrapper.style.height = "100%"; terminal.open(wrapper); - restoreBuffer(paneId, terminal); + restoreBuffer(terminalId, terminal); return { - paneId, + terminalId, terminal, fitAddon, serializeAddon, @@ -163,8 +163,8 @@ export function attachToContainer( } export function detachFromContainer(runtime: TerminalRuntime) { - persistBuffer(runtime.paneId, runtime.serializeAddon); - persistDimensions(runtime.paneId, runtime.lastCols, runtime.lastRows); + persistBuffer(runtime.terminalId, runtime.serializeAddon); + persistDimensions(runtime.terminalId, runtime.lastCols, runtime.lastRows); runtime.resizeObserver?.disconnect(); runtime.resizeObserver = null; runtime.wrapper.remove(); @@ -176,6 +176,6 @@ export function disposeRuntime(runtime: TerminalRuntime) { runtime.resizeObserver = null; runtime.wrapper.remove(); runtime.terminal.dispose(); - clearPersistedBuffer(runtime.paneId); - clearPersistedDimensions(runtime.paneId); + clearPersistedBuffer(runtime.terminalId); + clearPersistedDimensions(runtime.terminalId); } 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 dffa3e4810d..2799cbf15d1 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 @@ -1,46 +1,53 @@ +import type { RendererContext } from "@superset/panes"; import "@xterm/xterm/css/xterm.css"; import { useEffect, useRef, useSyncExternalStore } from "react"; import { type ConnectionState, terminalRuntimeRegistry, } from "renderer/lib/terminal/terminal-runtime-registry"; -import { useWorkspaceWsUrl } from "../../../../../providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; +import type { + PaneViewerData, + TerminalPaneData, +} from "renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/types"; +import { useWorkspaceWsUrl } from "renderer/routes/_authenticated/_dashboard/v2-workspace/providers/WorkspaceTrpcProvider/WorkspaceTrpcProvider"; interface TerminalPaneProps { - paneId: string; + ctx: RendererContext; workspaceId: string; } -function subscribeToState(paneId: string) { +function subscribeToState(terminalId: string) { return (callback: () => void) => - terminalRuntimeRegistry.onStateChange(paneId, callback); + terminalRuntimeRegistry.onStateChange(terminalId, callback); } -function getConnectionState(paneId: string): ConnectionState { - return terminalRuntimeRegistry.getConnectionState(paneId); +function getConnectionState(terminalId: string): ConnectionState { + return terminalRuntimeRegistry.getConnectionState(terminalId); } -export function TerminalPane({ paneId, workspaceId }: TerminalPaneProps) { +export function TerminalPane({ ctx, workspaceId }: TerminalPaneProps) { + const { terminalId } = ctx.pane.data as TerminalPaneData; const containerRef = useRef(null); - const websocketUrl = useWorkspaceWsUrl(`/terminal/${paneId}`, { + const websocketUrl = useWorkspaceWsUrl(`/terminal/${terminalId}`, { workspaceId, }); - const connectionState = useSyncExternalStore(subscribeToState(paneId), () => - getConnectionState(paneId), + const connectionState = useSyncExternalStore( + subscribeToState(terminalId), + () => getConnectionState(terminalId), ); useEffect(() => { const container = containerRef.current; if (!container) return; - terminalRuntimeRegistry.attach(paneId, container, websocketUrl); + terminalRuntimeRegistry.attach(terminalId, container, websocketUrl); return () => { - terminalRuntimeRegistry.detach(paneId); + terminalRuntimeRegistry.detach(terminalId); }; - }, [paneId, websocketUrl]); + }, [terminalId, websocketUrl]); return (
diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx index d210db7fa6b..25924bb92a7 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/hooks/usePaneRegistry/usePaneRegistry.tsx @@ -83,7 +83,7 @@ export function usePaneRegistry( getIcon: () => , getTitle: () => "Terminal", renderPane: (ctx: RendererContext) => ( - + ), }, browser: { diff --git a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx index 22e6887ec89..36582ce4eb8 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx +++ b/apps/desktop/src/renderer/routes/_authenticated/_dashboard/v2-workspace/$workspaceId/page.tsx @@ -63,7 +63,6 @@ function V2WorkspacePage() { ); } @@ -71,11 +70,9 @@ function V2WorkspacePage() { function WorkspaceContent({ projectId, workspaceId, - workspaceName, }: { projectId: string; workspaceId: string; - workspaceName: string; }) { const { localWorkspaceState, store } = useV2WorkspacePaneLayout({ projectId, @@ -146,14 +143,12 @@ function WorkspaceContent({ { kind: "terminal", data: { - sessionKey: `${workspaceId}:${crypto.randomUUID()}`, - cwd: `/workspace/${workspaceName}`, - launchMode: "workspace-shell", + terminalId: crypto.randomUUID(), } as TerminalPaneData, }, ], }); - }, [store, workspaceId, workspaceName]); + }, [store]); const addChatTab = useCallback(() => { store.getState().addTab({ @@ -204,9 +199,7 @@ function WorkspaceContent({ ctx.actions.split(position, { kind: "terminal", data: { - sessionKey: `${workspaceId}:${crypto.randomUUID()}`, - cwd: `/workspace/${workspaceName}`, - launchMode: "workspace-shell", + terminalId: crypto.randomUUID(), } as TerminalPaneData, }); }, @@ -220,7 +213,7 @@ function WorkspaceContent({ onClick: (ctx) => ctx.actions.close(), }, ], - [workspaceId, workspaceName], + [], ); const collections = useCollections(); 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 a0f17ea894b..a2cc6d53a04 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 @@ -6,10 +6,7 @@ export interface FilePaneData { } export interface TerminalPaneData { - sessionKey: string; - cwd: string; - launchMode: "workspace-shell" | "command" | "agent"; - command?: string; + terminalId: string; } export interface ChatPaneData { diff --git a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts index 7a3e528c301..172eb65ac26 100644 --- a/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts +++ b/apps/desktop/src/renderer/routes/_authenticated/components/GlobalTerminalLifecycle/hooks/useGlobalTerminalLifecycle/useGlobalTerminalLifecycle.ts @@ -4,18 +4,25 @@ import { useEffect, useRef } from "react"; import { terminalRuntimeRegistry } from "renderer/lib/terminal/terminal-runtime-registry"; import { useCollections } from "renderer/routes/_authenticated/providers/CollectionsProvider"; -/** Cross-workspace moves temporarily remove a paneId then re-add it. Wait before disposing. */ +/** Grace period for cross-workspace pane moves before disposing. */ const DISPOSE_DELAY_MS = 500; -function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { +interface TerminalPaneData { + terminalId: string; +} + +function extractTerminalIds(rows: { paneLayout: unknown }[]): Set { const ids = new Set(); for (const row of rows) { const layout = row.paneLayout as WorkspaceState | undefined; if (!layout?.tabs) continue; for (const tab of layout.tabs) { - for (const [paneId, pane] of Object.entries(tab.panes)) { + for (const pane of Object.values(tab.panes)) { if (pane.kind === "terminal") { - ids.add(paneId); + const data = pane.data as TerminalPaneData; + if (data.terminalId) { + ids.add(data.terminalId); + } } } } @@ -25,7 +32,7 @@ function extractTerminalPaneIds(rows: { paneLayout: unknown }[]): Set { export function useGlobalTerminalLifecycle() { const collections = useCollections(); - const prevPaneIdsRef = useRef>(new Set()); + const prevTerminalIdsRef = useRef>(new Set()); const pendingDisposals = useRef>>( new Map(), ); @@ -39,38 +46,38 @@ export function useGlobalTerminalLifecycle() { ); useEffect(() => { - const currentPaneIds = extractTerminalPaneIds(allWorkspaceRows); - const prevPaneIds = prevPaneIdsRef.current; + const currentTerminalIds = extractTerminalIds(allWorkspaceRows); + const prevTerminalIds = prevTerminalIdsRef.current; - for (const paneId of currentPaneIds) { - const timer = pendingDisposals.current.get(paneId); + for (const terminalId of currentTerminalIds) { + const timer = pendingDisposals.current.get(terminalId); if (timer) { clearTimeout(timer); - pendingDisposals.current.delete(paneId); + pendingDisposals.current.delete(terminalId); } } - for (const paneId of prevPaneIds) { - if (currentPaneIds.has(paneId)) continue; - if (pendingDisposals.current.has(paneId)) continue; + for (const terminalId of prevTerminalIds) { + if (currentTerminalIds.has(terminalId)) continue; + if (pendingDisposals.current.has(terminalId)) continue; const timer = setTimeout(() => { - pendingDisposals.current.delete(paneId); + pendingDisposals.current.delete(terminalId); const freshRows = Array.from( collections.v2WorkspaceLocalState.state.values(), ); - const freshIds = extractTerminalPaneIds(freshRows); + const freshIds = extractTerminalIds(freshRows); - if (!freshIds.has(paneId)) { - terminalRuntimeRegistry.dispose(paneId); + if (!freshIds.has(terminalId)) { + terminalRuntimeRegistry.dispose(terminalId); } }, DISPOSE_DELAY_MS); - pendingDisposals.current.set(paneId, timer); + pendingDisposals.current.set(terminalId, timer); } - prevPaneIdsRef.current = currentPaneIds; + prevTerminalIdsRef.current = currentTerminalIds; }, [allWorkspaceRows, collections]); useEffect(() => { diff --git a/packages/host-service/drizzle/0002_add_terminal_sessions.sql b/packages/host-service/drizzle/0002_add_terminal_sessions.sql new file mode 100644 index 00000000000..ddeea435345 --- /dev/null +++ b/packages/host-service/drizzle/0002_add_terminal_sessions.sql @@ -0,0 +1,12 @@ +CREATE TABLE `terminal_sessions` ( + `id` text PRIMARY KEY NOT NULL, + `origin_workspace_id` text, + `status` text DEFAULT 'active' NOT NULL, + `created_at` integer NOT NULL, + `last_attached_at` integer, + `ended_at` integer, + FOREIGN KEY (`origin_workspace_id`) REFERENCES `workspaces`(`id`) ON UPDATE no action ON DELETE set null +); +--> statement-breakpoint +CREATE INDEX `terminal_sessions_origin_workspace_id_idx` ON `terminal_sessions` (`origin_workspace_id`);--> statement-breakpoint +CREATE INDEX `terminal_sessions_status_idx` ON `terminal_sessions` (`status`); \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/0002_snapshot.json b/packages/host-service/drizzle/meta/0002_snapshot.json new file mode 100644 index 00000000000..a68b6bac9a5 --- /dev/null +++ b/packages/host-service/drizzle/meta/0002_snapshot.json @@ -0,0 +1,470 @@ +{ + "version": "6", + "dialect": "sqlite", + "id": "a2434e05-3865-4783-9247-7bda589c8806", + "prevId": "f5887d62-b5ad-4441-9648-d4456be25acb", + "tables": { + "projects": { + "name": "projects", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "repo_path": { + "name": "repo_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "repo_url": { + "name": "repo_url", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "remote_name": { + "name": "remote_name", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "projects_repo_path_idx": { + "name": "projects_repo_path_idx", + "columns": [ + "repo_path" + ], + "isUnique": false + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "pull_requests": { + "name": "pull_requests", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_provider": { + "name": "repo_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_owner": { + "name": "repo_owner", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "repo_name": { + "name": "repo_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "pr_number": { + "name": "pr_number", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "is_draft": { + "name": "is_draft", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": false + }, + "head_branch": { + "name": "head_branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "review_decision": { + "name": "review_decision", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "checks_status": { + "name": "checks_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'none'" + }, + "checks_json": { + "name": "checks_json", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'[]'" + }, + "last_fetched_at": { + "name": "last_fetched_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "updated_at": { + "name": "updated_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "pull_requests_project_id_idx": { + "name": "pull_requests_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "pull_requests_repo_branch_idx": { + "name": "pull_requests_repo_branch_idx", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "head_branch" + ], + "isUnique": false + }, + "pull_requests_repo_pr_unique": { + "name": "pull_requests_repo_pr_unique", + "columns": [ + "repo_provider", + "repo_owner", + "repo_name", + "pr_number" + ], + "isUnique": true + } + }, + "foreignKeys": { + "pull_requests_project_id_projects_id_fk": { + "name": "pull_requests_project_id_projects_id_fk", + "tableFrom": "pull_requests", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "terminal_sessions": { + "name": "terminal_sessions", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "origin_workspace_id": { + "name": "origin_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false, + "default": "'active'" + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "last_attached_at": { + "name": "last_attached_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "ended_at": { + "name": "ended_at", + "type": "integer", + "primaryKey": false, + "notNull": false, + "autoincrement": false + } + }, + "indexes": { + "terminal_sessions_origin_workspace_id_idx": { + "name": "terminal_sessions_origin_workspace_id_idx", + "columns": [ + "origin_workspace_id" + ], + "isUnique": false + }, + "terminal_sessions_status_idx": { + "name": "terminal_sessions_status_idx", + "columns": [ + "status" + ], + "isUnique": false + } + }, + "foreignKeys": { + "terminal_sessions_origin_workspace_id_workspaces_id_fk": { + "name": "terminal_sessions_origin_workspace_id_workspaces_id_fk", + "tableFrom": "terminal_sessions", + "tableTo": "workspaces", + "columnsFrom": [ + "origin_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + }, + "workspaces": { + "name": "workspaces", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true, + "autoincrement": false + }, + "project_id": { + "name": "project_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "worktree_path": { + "name": "worktree_path", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "branch": { + "name": "branch", + "type": "text", + "primaryKey": false, + "notNull": true, + "autoincrement": false + }, + "head_sha": { + "name": "head_sha", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "pull_request_id": { + "name": "pull_request_id", + "type": "text", + "primaryKey": false, + "notNull": false, + "autoincrement": false + }, + "created_at": { + "name": "created_at", + "type": "integer", + "primaryKey": false, + "notNull": true, + "autoincrement": false + } + }, + "indexes": { + "workspaces_project_id_idx": { + "name": "workspaces_project_id_idx", + "columns": [ + "project_id" + ], + "isUnique": false + }, + "workspaces_branch_idx": { + "name": "workspaces_branch_idx", + "columns": [ + "branch" + ], + "isUnique": false + }, + "workspaces_pull_request_id_idx": { + "name": "workspaces_pull_request_id_idx", + "columns": [ + "pull_request_id" + ], + "isUnique": false + } + }, + "foreignKeys": { + "workspaces_project_id_projects_id_fk": { + "name": "workspaces_project_id_projects_id_fk", + "tableFrom": "workspaces", + "tableTo": "projects", + "columnsFrom": [ + "project_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspaces_pull_request_id_pull_requests_id_fk": { + "name": "workspaces_pull_request_id_pull_requests_id_fk", + "tableFrom": "workspaces", + "tableTo": "pull_requests", + "columnsFrom": [ + "pull_request_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "checkConstraints": {} + } + }, + "views": {}, + "enums": {}, + "_meta": { + "schemas": {}, + "tables": {}, + "columns": {} + }, + "internal": { + "indexes": {} + } +} \ No newline at end of file diff --git a/packages/host-service/drizzle/meta/_journal.json b/packages/host-service/drizzle/meta/_journal.json index af209bd1b35..348757a3259 100644 --- a/packages/host-service/drizzle/meta/_journal.json +++ b/packages/host-service/drizzle/meta/_journal.json @@ -15,6 +15,13 @@ "when": 1773705162679, "tag": "0001_famous_mindworm", "breakpoints": true + }, + { + "idx": 2, + "version": "6", + "when": 1775239013772, + "tag": "0002_add_terminal_sessions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/host-service/src/db/schema.ts b/packages/host-service/src/db/schema.ts index d6b88c7f18a..4ae289694fd 100644 --- a/packages/host-service/src/db/schema.ts +++ b/packages/host-service/src/db/schema.ts @@ -6,6 +6,29 @@ import { uniqueIndex, } from "drizzle-orm/sqlite-core"; +export const terminalSessions = sqliteTable( + "terminal_sessions", + { + id: text().primaryKey(), + originWorkspaceId: text("origin_workspace_id").references( + () => workspaces.id, + { onDelete: "set null" }, + ), + status: text().notNull().default("active"), + createdAt: integer("created_at") + .notNull() + .$defaultFn(() => Date.now()), + lastAttachedAt: integer("last_attached_at"), + endedAt: integer("ended_at"), + }, + (table) => [ + index("terminal_sessions_origin_workspace_id_idx").on( + table.originWorkspaceId, + ), + index("terminal_sessions_status_idx").on(table.status), + ], +); + export const projects = sqliteTable( "projects", { diff --git a/packages/host-service/src/terminal/terminal.ts b/packages/host-service/src/terminal/terminal.ts index 1e917419791..bf97a5e5583 100644 --- a/packages/host-service/src/terminal/terminal.ts +++ b/packages/host-service/src/terminal/terminal.ts @@ -5,7 +5,7 @@ import { eq } from "drizzle-orm"; import type { Hono } from "hono"; import { type IPty, spawn } from "node-pty"; import type { HostDb } from "../db"; -import { workspaces } from "../db/schema"; +import { terminalSessions, workspaces } from "../db/schema"; interface RegisterWorkspaceTerminalRouteOptions { app: Hono; @@ -27,7 +27,7 @@ type TerminalServerMessage = const MAX_BUFFER_BYTES = 64 * 1024; interface TerminalSession { - paneId: string; + terminalId: string; pty: IPty; socket: { send: (data: string) => void; @@ -80,8 +80,8 @@ function replayBuffer( sendMessage(socket, { type: "replay", data: combined }); } -function disposeSession(paneId: string) { - const session = sessions.get(paneId); +function disposeSession(terminalId: string, db: HostDb) { + const session = sessions.get(terminalId); if (!session) return; if (!session.exited) { @@ -91,7 +91,114 @@ function disposeSession(paneId: string) { // PTY may already be dead } } - sessions.delete(paneId); + sessions.delete(terminalId); + + db.update(terminalSessions) + .set({ status: "disposed", endedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); +} + +interface CreateTerminalSessionOptions { + terminalId: string; + workspaceId: string; + db: HostDb; +} + +function createTerminalSessionInternal({ + terminalId, + workspaceId, + db, +}: CreateTerminalSessionOptions): TerminalSession | { error: string } { + const existing = sessions.get(terminalId); + if (existing) { + return existing; + } + + const workspace = db.query.workspaces + .findFirst({ where: eq(workspaces.id, workspaceId) }) + .sync(); + + if (!workspace || !existsSync(workspace.worktreePath)) { + return { error: "Workspace worktree not found" }; + } + + const cwd = workspace.worktreePath; + + let pty: IPty; + try { + pty = spawn(resolveShell(), [], { + name: "xterm-256color", + cwd, + cols: 120, + rows: 32, + env: { + ...process.env, + TERM: "xterm-256color", + COLORTERM: "truecolor", + HOME: process.env.HOME || homedir(), + PWD: cwd, + }, + }); + } catch (error) { + return { + error: + error instanceof Error ? error.message : "Failed to start terminal", + }; + } + + db.insert(terminalSessions) + .values({ + id: terminalId, + originWorkspaceId: workspaceId, + status: "active", + }) + .onConflictDoUpdate({ + target: terminalSessions.id, + set: { status: "active", endedAt: null }, + }) + .run(); + + const session: TerminalSession = { + terminalId, + pty, + socket: null, + buffer: [], + bufferBytes: 0, + exited: false, + exitCode: 0, + exitSignal: 0, + }; + sessions.set(terminalId, session); + + pty.onData((data) => { + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { type: "data", data }); + } else { + bufferOutput(session, data); + } + }); + + pty.onExit(({ exitCode, signal }) => { + session.exited = true; + session.exitCode = exitCode ?? 0; + session.exitSignal = signal ?? 0; + + db.update(terminalSessions) + .set({ status: "exited", endedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); + + if (session.socket?.readyState === 1) { + sendMessage(session.socket, { + type: "exit", + exitCode: session.exitCode, + signal: session.exitSignal, + }); + } + }); + + return session; } export function registerWorkspaceTerminalRoute({ @@ -99,29 +206,54 @@ export function registerWorkspaceTerminalRoute({ db, upgradeWebSocket, }: RegisterWorkspaceTerminalRouteOptions) { + app.post("/terminal/sessions", async (c) => { + const body = await c.req.json<{ + terminalId: string; + workspaceId: string; + }>(); + + if (!body.terminalId || !body.workspaceId) { + return c.json({ error: "Missing terminalId or workspaceId" }, 400); + } + + const result = createTerminalSessionInternal({ + terminalId: body.terminalId, + workspaceId: body.workspaceId, + db, + }); + + if ("error" in result) { + return c.json({ error: result.error }, 500); + } + + return c.json({ terminalId: result.terminalId, status: "active" }); + }); + app.get( - "/terminal/:paneId", + "/terminal/:terminalId", upgradeWebSocket((c) => { - const paneId = c.req.param("paneId"); + const terminalId = c.req.param("terminalId") ?? ""; const workspaceId = c.req.query("workspaceId") ?? null; return { onOpen: (_event, ws) => { - if (!paneId) { - sendMessage(ws, { - type: "error", - message: "Missing paneId", - }); - ws.close(1011, "Missing paneId"); + if (!terminalId) { + ws.close(1011, "Missing terminalId"); return; } - const existing = sessions.get(paneId); + const existing = sessions.get(terminalId); if (existing) { if (existing.socket && existing.socket !== ws) { existing.socket.close(4000, "Displaced by new connection"); } existing.socket = ws; + + db.update(terminalSessions) + .set({ lastAttachedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); + replayBuffer(existing, ws); if (existing.exited) { sendMessage(ws, { @@ -142,83 +274,28 @@ export function registerWorkspaceTerminalRoute({ return; } - const workspace = db.query.workspaces - .findFirst({ where: eq(workspaces.id, workspaceId) }) - .sync(); - - if (!workspace || !existsSync(workspace.worktreePath)) { - sendMessage(ws, { - type: "error", - message: "Workspace worktree not found", - }); - ws.close(1011, "Workspace worktree not found"); - return; - } + const result = createTerminalSessionInternal({ + terminalId, + workspaceId, + db, + }); - let pty: IPty; - try { - pty = spawn(resolveShell(), [], { - name: "xterm-256color", - cwd: workspace.worktreePath, - cols: 120, - rows: 32, - env: { - ...process.env, - TERM: "xterm-256color", - COLORTERM: "truecolor", - HOME: process.env.HOME || homedir(), - PWD: workspace.worktreePath, - }, - }); - } catch (error) { - sendMessage(ws, { - type: "error", - message: - error instanceof Error - ? error.message - : "Failed to start terminal", - }); - ws.close(1011, "Failed to start terminal"); + if ("error" in result) { + sendMessage(ws, { type: "error", message: result.error }); + ws.close(1011, result.error); return; } - const session: TerminalSession = { - paneId, - pty, - socket: ws, - buffer: [], - bufferBytes: 0, - exited: false, - exitCode: 0, - exitSignal: 0, - }; - sessions.set(paneId, session); - - pty.onData((data) => { - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { type: "data", data }); - } else { - bufferOutput(session, data); - } - }); - - pty.onExit(({ exitCode, signal }) => { - session.exited = true; - session.exitCode = exitCode ?? 0; - session.exitSignal = signal ?? 0; + result.socket = ws; - if (session.socket?.readyState === 1) { - sendMessage(session.socket, { - type: "exit", - exitCode: session.exitCode, - signal: session.exitSignal, - }); - } - }); + db.update(terminalSessions) + .set({ lastAttachedAt: Date.now() }) + .where(eq(terminalSessions.id, terminalId)) + .run(); }, onMessage: (event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (!session || session.socket !== ws) return; let message: TerminalClientMessage; @@ -235,7 +312,7 @@ export function registerWorkspaceTerminalRoute({ } if (message.type === "dispose") { - disposeSession(paneId ?? ""); + disposeSession(terminalId ?? "", db); return; } @@ -254,14 +331,14 @@ export function registerWorkspaceTerminalRoute({ }, onClose: (_event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (session?.socket === ws) { session.socket = null; } }, onError: (_event, ws) => { - const session = sessions.get(paneId ?? ""); + const session = sessions.get(terminalId ?? ""); if (session?.socket === ws) { session.socket = null; }