From a99ad25ecf0bcb37fb387fd1f093e6ed66c6359d Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Wed, 4 Mar 2026 19:58:05 -0800 Subject: [PATCH 001/489] fix(delete): remove blocking delete scan and use fresh risk checks --- src/renderer/components/ProjectMainView.tsx | 274 ++++++++----------- src/renderer/components/TaskDeleteButton.tsx | 87 +++--- src/renderer/hooks/useDeleteRisks.ts | 240 ++++++++++------ src/renderer/lib/prStatusStore.ts | 4 + 4 files changed, 325 insertions(+), 280 deletions(-) diff --git a/src/renderer/components/ProjectMainView.tsx b/src/renderer/components/ProjectMainView.tsx index 475c1a286b..8aaa89540d 100644 --- a/src/renderer/components/ProjectMainView.tsx +++ b/src/renderer/components/ProjectMainView.tsx @@ -20,6 +20,7 @@ import { Separator } from './ui/separator'; import { Alert, AlertDescription, AlertTitle } from './ui/alert'; import { usePrStatus } from '../hooks/usePrStatus'; import { useTaskChanges } from '../hooks/useTaskChanges'; +import { DELETE_RISK_SCAN_FRESH_MS, useDeleteRisks } from '../hooks/useDeleteRisks'; import { ChangesBadge } from './TaskChanges'; import { Spinner } from './ui/spinner'; import TaskDeleteButton from './TaskDeleteButton'; @@ -43,8 +44,7 @@ import DeletePrNotice from './DeletePrNotice'; import PrPreviewTooltip from './PrPreviewTooltip'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from './ui/tooltip'; import { Popover, PopoverContent, PopoverTrigger } from './ui/popover'; -import { isActivePr, PrInfo } from '../lib/prStatus'; -import { refreshPrStatus } from '../lib/prStatusStore'; +import { isActivePr, type PrInfo } from '../lib/prStatus'; import { rpc } from '../lib/rpc'; import { useTaskBusy } from '../hooks/useTaskBusy'; import { useTaskAgentNames } from '../hooks/useTaskAgentNames'; @@ -61,6 +61,26 @@ const normalizeBaseRef = (ref?: string | null): string | undefined => { return trimmed.length > 0 ? trimmed : undefined; }; +const hasDeleteRisk = (status?: { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: PrInfo | null; +}): boolean => { + if (!status) return false; + return ( + status.staged > 0 || + status.unstaged > 0 || + status.untracked > 0 || + status.ahead > 0 || + !!status.error || + !!(status.pr && isActivePr(status.pr)) + ); +}; + function TaskRow({ ws, active, @@ -331,7 +351,10 @@ const ProjectMainView: React.FC = ({ const [isDeleting, setIsDeleting] = useState(false); const [isArchiving, setIsArchiving] = useState(false); const [isRestoring, setIsRestoring] = useState(false); + const [isCheckingDeleteRisks, setIsCheckingDeleteRisks] = useState(false); const [acknowledgeDirtyDelete, setAcknowledgeDirtyDelete] = useState(false); + const [requiresDeleteAcknowledge, setRequiresDeleteAcknowledge] = useState(false); + const [showDeleteWarnings, setShowDeleteWarnings] = useState(false); const [showConfigEditor, setShowConfigEditor] = useState(false); const [searchFilter, setSearchFilter] = useState(''); const [showFilter, setShowFilter] = useState<'active' | 'all'>('active'); @@ -393,57 +416,22 @@ const ProjectMainView: React.FC = ({ filteredTasks.length > 0 && filteredTasks.every((t) => selectedIds.has(t.id)); const someFilteredSelected = filteredTasks.some((t) => selectedIds.has(t.id)) && !allFilteredSelected; - const [deleteStatus, setDeleteStatus] = useState< - Record< - string, - { - staged: number; - unstaged: number; - untracked: number; - ahead: number; - behind: number; - error?: string; - pr?: PrInfo | null; - } - > - >({}); - const [deleteStatusLoading, setDeleteStatusLoading] = useState(false); - const deleteRisks = useMemo(() => { - const riskyIds = new Set(); - const summaries: Record = {}; - for (const ws of selectedTasks) { - const status = deleteStatus[ws.id]; - if (!status) continue; - const dirty = - status.staged > 0 || - status.unstaged > 0 || - status.untracked > 0 || - status.ahead > 0 || - !!status.error || - (status.pr && isActivePr(status.pr)); - if (dirty) { - riskyIds.add(ws.id); - const parts: string[] = []; - if (status.staged > 0) - parts.push(`${status.staged} ${status.staged === 1 ? 'file' : 'files'} staged`); - if (status.unstaged > 0) - parts.push(`${status.unstaged} ${status.unstaged === 1 ? 'file' : 'files'} unstaged`); - if (status.untracked > 0) - parts.push(`${status.untracked} ${status.untracked === 1 ? 'file' : 'files'} untracked`); - if (status.ahead > 0) - parts.push(`ahead by ${status.ahead} ${status.ahead === 1 ? 'commit' : 'commits'}`); - if (status.behind > 0) - parts.push(`behind by ${status.behind} ${status.behind === 1 ? 'commit' : 'commits'}`); - if (status.pr && isActivePr(status.pr)) parts.push('PR open'); - if (!parts.length && status.error) parts.push('status unavailable'); - summaries[ws.id] = parts.join(', '); - } - } - return { riskyIds, summaries }; - }, [deleteStatus, selectedTasks]); + const deleteRiskTargets = useMemo( + () => + selectedTasks + .filter((ws) => ws.useWorktree !== false) + .map((ws) => ({ id: ws.id, name: ws.name, path: ws.path })), + [selectedTasks] + ); + const { + risks: deleteStatus, + scannedAtById: deleteRiskScannedAt, + summary: deleteRisks, + refresh: refreshDeleteRisks, + } = useDeleteRisks(deleteRiskTargets, deleteRiskTargets.length > 0, { eagerPrRefresh: false }); const deleteDisabled: boolean = - Boolean(isDeleting || deleteStatusLoading) || - (deleteRisks.riskyIds.size > 0 && acknowledgeDirtyDelete !== true); + Boolean(isDeleting || isCheckingDeleteRisks) || + (requiresDeleteAcknowledge && acknowledgeDirtyDelete !== true); const toggleSelect = (id: string) => { setSelectedIds((prev) => { @@ -521,6 +509,61 @@ const ProjectMainView: React.FC = ({ } }; + const handleConfirmBulkDelete = useCallback( + async (event: React.MouseEvent) => { + event.preventDefault(); + + if (isDeleting || isCheckingDeleteRisks) return; + if (deleteRiskTargets.length === 0) { + await handleBulkDelete(); + return; + } + if (requiresDeleteAcknowledge && !acknowledgeDirtyDelete) { + setShowDeleteWarnings(true); + return; + } + + setIsCheckingDeleteRisks(true); + try { + const now = Date.now(); + const needsForceRefresh = deleteRiskTargets.some((target) => { + const status = deleteStatus[target.id]; + if (!status) return true; + if (hasDeleteRisk(status)) return true; + if (!status.prKnown) return true; + const scannedAt = deleteRiskScannedAt[target.id] ?? 0; + return scannedAt <= 0 || now - scannedAt > DELETE_RISK_SCAN_FRESH_MS; + }); + + const latestRisks = needsForceRefresh + ? await refreshDeleteRisks({ force: true }) + : deleteStatus; + const hasRisks = deleteRiskTargets.some((target) => hasDeleteRisk(latestRisks[target.id])); + setRequiresDeleteAcknowledge(hasRisks); + setShowDeleteWarnings(hasRisks); + + if (hasRisks && !acknowledgeDirtyDelete) { + return; + } + + await handleBulkDelete(); + } finally { + setIsCheckingDeleteRisks(false); + } + }, + [ + acknowledgeDirtyDelete, + deleteRiskTargets, + deleteRiskScannedAt, + deleteStatus, + handleBulkDelete, + isCheckingDeleteRisks, + isDeleting, + requiresDeleteAcknowledge, + refreshDeleteRisks, + ] + ); + const handleBulkArchive = async () => { if (!onArchiveTask) return; const toArchive = tasksInProject.filter((ws) => selectedIds.has(ws.id) && !ws.archivedAt); @@ -651,89 +694,18 @@ const ProjectMainView: React.FC = ({ useEffect(() => { if (!showDeleteDialog) { - setDeleteStatus({}); setAcknowledgeDirtyDelete(false); - return; + setRequiresDeleteAcknowledge(false); + setShowDeleteWarnings(false); + setIsCheckingDeleteRisks(false); } + }, [showDeleteDialog]); - let cancelled = false; - const loadStatus = async () => { - setDeleteStatusLoading(true); - const next: typeof deleteStatus = {}; - - for (const ws of selectedTasks) { - try { - const [statusRes, infoRes, rawPr] = await Promise.allSettled([ - window.electronAPI.getGitStatus(ws.path), - window.electronAPI.getGitInfo(ws.path), - project.isRemote ? Promise.resolve(null) : refreshPrStatus(ws.path), - ]); - - let staged = 0; - let unstaged = 0; - let untracked = 0; - if ( - statusRes.status === 'fulfilled' && - statusRes.value?.success && - statusRes.value.changes - ) { - for (const change of statusRes.value.changes) { - if (change.status === 'untracked') { - untracked += 1; - } else if (change.isStaged) { - staged += 1; - } else { - unstaged += 1; - } - } - } - - const ahead = - infoRes.status === 'fulfilled' && typeof infoRes.value?.aheadCount === 'number' - ? infoRes.value.aheadCount - : 0; - const behind = - infoRes.status === 'fulfilled' && typeof infoRes.value?.behindCount === 'number' - ? infoRes.value.behindCount - : 0; - const prValue = rawPr.status === 'fulfilled' ? rawPr.value : null; - const pr = isActivePr(prValue) ? prValue : null; - - next[ws.id] = { - staged, - unstaged, - untracked, - ahead, - behind, - error: - statusRes.status === 'fulfilled' - ? statusRes.value?.error - : statusRes.reason?.message || String(statusRes.reason || ''), - pr, - }; - } catch (error: any) { - next[ws.id] = { - staged: 0, - unstaged: 0, - untracked: 0, - ahead: 0, - behind: 0, - error: error?.message || String(error), - }; - } - } - - if (!cancelled) { - setDeleteStatus(next); - setDeleteStatusLoading(false); - } - }; - - void loadStatus(); - return () => { - cancelled = true; - }; - }, [showDeleteDialog, selectedTasks]); + useEffect(() => { + setAcknowledgeDirtyDelete(false); + setRequiresDeleteAcknowledge(false); + setShowDeleteWarnings(false); + }, [selectedIds]); // Sync baseBranch when branchOptions change useEffect(() => { @@ -1070,29 +1042,6 @@ const ProjectMainView: React.FC = ({
- - {deleteStatusLoading ? ( - - -
- Please wait... - - Scanning tasks for uncommitted changes and open pull requests - -
-
- ) : null} -
{(() => { const tasksWithUncommittedWorkOnly = selectedTasks.filter((ws) => { @@ -1103,7 +1052,7 @@ const ProjectMainView: React.FC = ({ return true; }); - return tasksWithUncommittedWorkOnly.length > 0 && !deleteStatusLoading ? ( + return showDeleteWarnings && tasksWithUncommittedWorkOnly.length > 0 ? ( = ({ const prTasks = selectedTasks .map((ws) => ({ name: ws.name, pr: deleteStatus[ws.id]?.pr })) .filter((w) => w.pr && isActivePr(w.pr)); - return prTasks.length && !deleteStatusLoading ? ( + return showDeleteWarnings && prTasks.length ? ( = ({ - {deleteRisks.riskyIds.size > 0 && !deleteStatusLoading ? ( + {showDeleteWarnings && deleteRisks.riskyIds.size > 0 ? ( = ({
- Cancel + + Cancel + + {isDeleting || isCheckingDeleteRisks ? ( + + ) : null} Delete diff --git a/src/renderer/components/TaskDeleteButton.tsx b/src/renderer/components/TaskDeleteButton.tsx index a305c962d7..0fcef93a7c 100644 --- a/src/renderer/components/TaskDeleteButton.tsx +++ b/src/renderer/components/TaskDeleteButton.tsx @@ -17,7 +17,7 @@ import { import { TooltipProvider, Tooltip, TooltipTrigger, TooltipContent } from './ui/tooltip'; import { Button } from './ui/button'; import { cn } from '@/lib/utils'; -import { useDeleteRisks } from '../hooks/useDeleteRisks'; +import { DELETE_RISK_SCAN_FRESH_MS, useDeleteRisks } from '../hooks/useDeleteRisks'; import DeletePrNotice from './DeletePrNotice'; import { isActivePr } from '../lib/prStatus'; @@ -63,6 +63,9 @@ export const TaskDeleteButton: React.FC = ({ const open = isControlled ? externalOpen : internalOpen; const setOpen = isControlled ? (v: boolean) => onExternalOpenChange?.(v) : setInternalOpen; const [acknowledge, setAcknowledge] = React.useState(false); + const [showWarnings, setShowWarnings] = React.useState(false); + const [requiresAcknowledge, setRequiresAcknowledge] = React.useState(false); + const [isCheckingRisks, setIsCheckingRisks] = React.useState(false); const targets = useMemo( () => [{ id: taskId, name: taskName, path: taskPath }], [taskId, taskName, taskPath] @@ -70,7 +73,9 @@ export const TaskDeleteButton: React.FC = ({ // Only check for deletion risks if the task uses a worktree. // Tasks running directly on the main branch (useWorktree === false) don't need risk assessment // since they don't have isolated changes that could be lost. - const { risks, loading, hasData } = useDeleteRisks(targets, open && useWorktree); + const { risks, scannedAtById, refresh } = useDeleteRisks(targets, useWorktree, { + eagerPrRefresh: false, + }); const status = risks[taskId] || { staged: 0, unstaged: 0, @@ -79,24 +84,29 @@ export const TaskDeleteButton: React.FC = ({ behind: 0, error: undefined, pr: null, + prKnown: false, }; // Determine if deletion is risky based on uncommitted changes or active PRs. // Tasks on main branch (useWorktree === false) are never considered risky // because they don't have worktree-specific changes that would be lost. - const risky: boolean = - useWorktree && - (status.staged > 0 || - status.unstaged > 0 || - status.untracked > 0 || - status.ahead > 0 || - !!status.error || - !!(status.pr && isActivePr(status.pr))); - const disableDelete: boolean = Boolean(isDeleting || loading) || (risky && !acknowledge); + const hasRisk = (targetStatus: typeof status): boolean => + targetStatus.staged > 0 || + targetStatus.unstaged > 0 || + targetStatus.untracked > 0 || + targetStatus.ahead > 0 || + !!targetStatus.error || + !!(targetStatus.pr && isActivePr(targetStatus.pr)); + const risky: boolean = useWorktree && hasRisk(status); + const disableDelete: boolean = + Boolean(isDeleting || isCheckingRisks) || (requiresAcknowledge && !acknowledge); React.useEffect(() => { if (!open) { setAcknowledge(false); + setShowWarnings(false); + setRequiresAcknowledge(false); + setIsCheckingRisks(false); } }, [open]); @@ -142,27 +152,7 @@ export const TaskDeleteButton: React.FC = ({
- {loading && useWorktree ? ( - - -
- Please wait... - - Scanning task for uncommitted changes and open pull requests - -
-
- ) : null} -
- - {risky && !loading ? ( + {showWarnings && (requiresAcknowledge || risky) ? ( = ({ ) : null} - {risky && !loading ? ( + {showWarnings && requiresAcknowledge ? ( = ({ className="bg-destructive px-4 py-2 text-destructive-foreground hover:bg-destructive/90" disabled={disableDelete} onClick={async (e) => { + e.preventDefault(); e.stopPropagation(); + if (requiresAcknowledge && !acknowledge) { + setShowWarnings(true); + return; + } + if (useWorktree && !showWarnings) { + setIsCheckingRisks(true); + try { + const scanAgeMs = Date.now() - (scannedAtById[taskId] ?? 0); + const hasFreshScan = + (scannedAtById[taskId] ?? 0) > 0 && scanAgeMs <= DELETE_RISK_SCAN_FRESH_MS; + const hasKnownStatus = Boolean(risks[taskId]); + const hasKnownRisk = hasKnownStatus && hasRisk(status); + const hasKnownPrState = hasKnownStatus && status.prKnown; + const shouldForceRefresh = + !hasKnownStatus || !hasKnownPrState || !hasFreshScan || hasKnownRisk; + + const latest = shouldForceRefresh ? await refresh({ force: true }) : risks; + const latestStatus = latest[taskId] || status; + if (hasRisk(latestStatus)) { + setRequiresAcknowledge(true); + setShowWarnings(true); + return; + } + setRequiresAcknowledge(false); + } finally { + setIsCheckingRisks(false); + } + } setOpen(false); try { await onConfirm(); } catch {} }} > - {isDeleting ? : null} + {isDeleting || isCheckingRisks ? : null} Delete diff --git a/src/renderer/hooks/useDeleteRisks.ts b/src/renderer/hooks/useDeleteRisks.ts index d24b65319a..8e4faa0dca 100644 --- a/src/renderer/hooks/useDeleteRisks.ts +++ b/src/renderer/hooks/useDeleteRisks.ts @@ -1,6 +1,7 @@ -import { useEffect, useMemo, useState } from 'react'; -import { isActivePr, PrInfo } from '../lib/prStatus'; -import { refreshPrStatus } from '../lib/prStatusStore'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { isActivePr, type PrInfo } from '../lib/prStatus'; +import { getCachedPrStatus, refreshPrStatus } from '../lib/prStatusStore'; +import { getCachedGitStatus } from '../lib/gitStatusCache'; type TaskRef = { id: string; name: string; path: string }; @@ -14,98 +15,167 @@ type RiskState = Record< behind: number; error?: string; pr?: PrInfo | null; + prKnown: boolean; } >; -export function useDeleteRisks(tasks: TaskRef[], enabled: boolean) { +export const DELETE_RISK_SCAN_FRESH_MS = 10_000; + +function hasDeleteRisk(status?: { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: PrInfo | null; +}) { + if (!status) return false; + return ( + status.staged > 0 || + status.unstaged > 0 || + status.untracked > 0 || + status.ahead > 0 || + !!status.error || + !!(status.pr && isActivePr(status.pr)) + ); +} + +type UseDeleteRisksOptions = { + eagerPrRefresh?: boolean; +}; + +export function useDeleteRisks( + tasks: TaskRef[], + enabled: boolean, + options?: UseDeleteRisksOptions +) { const [risks, setRisks] = useState({}); + const [scannedAtById, setScannedAtById] = useState>({}); const [loading, setLoading] = useState(false); const [loaded, setLoaded] = useState(false); + const requestIdRef = useRef(0); + const eagerPrRefresh = options?.eagerPrRefresh ?? true; - useEffect(() => { - if (!enabled || tasks.length === 0) { - setRisks({}); - setLoading(false); - setLoaded(false); - return; - } - let cancelled = false; - const load = async () => { + const scanRisks = useCallback( + async (options?: { force?: boolean }): Promise => { + if (!enabled || tasks.length === 0) { + setRisks({}); + setScannedAtById({}); + setLoading(false); + setLoaded(false); + return {}; + } + + const requestId = requestIdRef.current + 1; + requestIdRef.current = requestId; setLoading(true); - const next: RiskState = {}; - for (const ws of tasks) { - try { - const [statusRes, infoRes, rawPr] = await Promise.allSettled([ - (window as any).electronAPI?.getGitStatus?.(ws.path), - (window as any).electronAPI?.getGitInfo?.(ws.path), - refreshPrStatus(ws.path), - ]); - - let staged = 0; - let unstaged = 0; - let untracked = 0; - if ( - statusRes.status === 'fulfilled' && - statusRes.value?.success && - statusRes.value.changes - ) { - for (const change of statusRes.value.changes) { - if (change.status === 'untracked') { - untracked += 1; - } else if (change.isStaged) { - staged += 1; - } else { - unstaged += 1; + + const entries = await Promise.all( + tasks.map(async (ws) => { + try { + const [statusRes, infoRes, rawPr] = await Promise.allSettled([ + getCachedGitStatus(ws.path, { force: options?.force }), + (window as any).electronAPI?.getGitInfo?.(ws.path), + options?.force || eagerPrRefresh + ? refreshPrStatus(ws.path) + : Promise.resolve(getCachedPrStatus(ws.path)), + ]); + + let staged = 0; + let unstaged = 0; + let untracked = 0; + if ( + statusRes.status === 'fulfilled' && + statusRes.value?.success && + statusRes.value.changes + ) { + for (const change of statusRes.value.changes) { + if (change.status === 'untracked') { + untracked += 1; + } else if (change.isStaged) { + staged += 1; + } else { + unstaged += 1; + } } } + + const ahead = + infoRes.status === 'fulfilled' && typeof infoRes.value?.aheadCount === 'number' + ? infoRes.value.aheadCount + : 0; + const behind = + infoRes.status === 'fulfilled' && typeof infoRes.value?.behindCount === 'number' + ? infoRes.value.behindCount + : 0; + const prKnown = rawPr.status === 'fulfilled' && rawPr.value !== undefined; + const prValue = prKnown ? rawPr.value : null; + const pr = isActivePr(prValue) ? prValue : null; + + return [ + ws.id, + { + staged, + unstaged, + untracked, + ahead, + behind, + error: + statusRes.status === 'fulfilled' + ? statusRes.value?.error + : statusRes.reason?.message || String(statusRes.reason || ''), + pr, + prKnown, + }, + ] as const; + } catch (error: any) { + return [ + ws.id, + { + staged: 0, + unstaged: 0, + untracked: 0, + ahead: 0, + behind: 0, + error: error?.message || String(error), + pr: null, + prKnown: false, + }, + ] as const; } + }) + ); - const ahead = - infoRes.status === 'fulfilled' && typeof infoRes.value?.aheadCount === 'number' - ? infoRes.value.aheadCount - : 0; - const behind = - infoRes.status === 'fulfilled' && typeof infoRes.value?.behindCount === 'number' - ? infoRes.value.behindCount - : 0; - const prValue = rawPr.status === 'fulfilled' ? rawPr.value : null; - const pr = isActivePr(prValue) ? prValue : null; - - next[ws.id] = { - staged, - unstaged, - untracked, - ahead, - behind, - error: - statusRes.status === 'fulfilled' - ? statusRes.value?.error - : statusRes.reason?.message || String(statusRes.reason || ''), - pr, - }; - } catch (error: any) { - next[ws.id] = { - staged: 0, - unstaged: 0, - untracked: 0, - ahead: 0, - behind: 0, - error: error?.message || String(error), - pr: null, - }; - } - } - if (!cancelled) { + const next = Object.fromEntries(entries); + if (requestIdRef.current === requestId) { setRisks(next); + const scannedAt = Date.now(); + setScannedAtById( + Object.fromEntries(entries.map(([id]) => [id, scannedAt])) as Record + ); setLoading(false); setLoaded(true); } - }; - void load(); + + return next; + }, + [eagerPrRefresh, enabled, tasks] + ); + + useEffect(() => { + if (!enabled || tasks.length === 0) { + setRisks({}); + setScannedAtById({}); + setLoading(false); + setLoaded(false); + return; + } + void scanRisks(); return () => { - cancelled = true; + requestIdRef.current += 1; }; - }, [enabled, tasks]); + }, [enabled, tasks, scanRisks]); const hasData = loaded && Object.keys(risks).length > 0; const summary = useMemo(() => { @@ -114,14 +184,7 @@ export function useDeleteRisks(tasks: TaskRef[], enabled: boolean) { for (const ws of tasks) { const status = risks[ws.id]; if (!status) continue; - const dirty = - status.staged > 0 || - status.unstaged > 0 || - status.untracked > 0 || - status.ahead > 0 || - !!status.error || - (status.pr && isActivePr(status.pr)); - if (dirty) { + if (hasDeleteRisk(status)) { riskyIds.add(ws.id); const parts = [ status.staged > 0 @@ -149,5 +212,10 @@ export function useDeleteRisks(tasks: TaskRef[], enabled: boolean) { return { riskyIds, summaries }; }, [risks, tasks]); - return { risks, loading, summary, hasData }; + const refresh = useCallback( + async (options?: { force?: boolean }) => scanRisks({ force: options?.force ?? true }), + [scanRisks] + ); + + return { risks, scannedAtById, loading, summary, hasData, refresh }; } diff --git a/src/renderer/lib/prStatusStore.ts b/src/renderer/lib/prStatusStore.ts index 388bd9c8c6..abfd059ee5 100644 --- a/src/renderer/lib/prStatusStore.ts +++ b/src/renderer/lib/prStatusStore.ts @@ -51,6 +51,10 @@ export async function refreshPrStatus(taskPath: string): Promise Date: Wed, 4 Mar 2026 19:58:30 -0800 Subject: [PATCH 002/489] perf(delete): parallelize lifecycle cleanup and defer snapshot clearing --- src/renderer/hooks/useTaskManagement.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index 5b42131f3f..367bcc8974 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -449,11 +449,15 @@ export function useTaskManagement() { } catch {} const sessionIds = [...mainSessionIds, ...chatSessionIds]; - await Promise.allSettled( + for (const sessionId of sessionIds) { + try { + terminalSessionRegistry.dispose(sessionId); + } catch {} + } + // Snapshot cleanup is best-effort; run it in the background so delete completion + // is not blocked on potentially slow IPC or disk work. + void Promise.allSettled( sessionIds.map(async (sessionId) => { - try { - terminalSessionRegistry.dispose(sessionId); - } catch {} try { await window.electronAPI.ptyClearSnapshot({ id: sessionId }); } catch {} @@ -511,11 +515,13 @@ export function useTaskManagement() { ); } - for (const lifecycleTaskId of getLifecycleTaskIds(task)) { - try { - await window.electronAPI.lifecycleClearTask({ taskId: lifecycleTaskId }); - } catch {} - } + await Promise.allSettled( + getLifecycleTaskIds(task).map(async (lifecycleTaskId) => { + try { + await window.electronAPI.lifecycleClearTask({ taskId: lifecycleTaskId }); + } catch {} + }) + ); void import('../lib/telemetryClient').then(({ captureTelemetry }) => { captureTelemetry('task_deleted'); From ad085c18415d7d7507a20770d034655ccfd8baf5 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:03:49 -0800 Subject: [PATCH 003/489] perf(terminal): fix slash-command UI freeze on new tasks --- src/main/preload.ts | 13 ++ src/main/services/ptyIpc.ts | 15 ++ src/renderer/components/ChatInterface.tsx | 19 +- src/renderer/components/MultiAgentTask.tsx | 5 +- .../hooks/useInitialPromptInjection.ts | 4 +- src/renderer/lib/activityClassifier.ts | 10 + src/renderer/lib/activityStore.ts | 190 +++++++++++++----- .../terminal/TerminalSessionManager.ts | 149 +++++++++++--- src/renderer/types/electron-api.d.ts | 4 + src/renderer/types/global.d.ts | 3 + 10 files changed, 326 insertions(+), 86 deletions(-) diff --git a/src/main/preload.ts b/src/main/preload.ts index 4454b6ca64..a130032e36 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -137,6 +137,19 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.on(channel, wrapped); return () => ipcRenderer.removeListener(channel, wrapped); }, + onPtyActivity: (listener: (data: { id: string; chunk?: string }) => void) => { + const channel = 'pty:activity'; + const wrapped = (_: Electron.IpcRendererEvent, data: { id: string; chunk?: string }) => + listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + onPtyExitGlobal: (listener: (data: { id: string }) => void) => { + const channel = 'pty:exit:global'; + const wrapped = (_: Electron.IpcRendererEvent, data: { id: string }) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, onAgentEvent: (listener: (event: AgentEvent, meta: { appFocused: boolean }) => void) => { const channel = 'agent:event'; const wrapped = ( diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index df8b4b4c62..a8e02c08c0 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -55,6 +55,7 @@ type FinishCause = 'process_exit' | 'app_quit' | 'owner_destroyed' | 'manual_kil const ptyDataBuffers = new Map(); const ptyDataTimers = new Map(); const PTY_DATA_FLUSH_MS = 16; +const PTY_ACTIVITY_SAMPLE_CHARS = 8_192; // Guard IPC sends to prevent crashes when WebContents is destroyed function safeSendToOwner(id: string, channel: string, payload: unknown): boolean { @@ -74,11 +75,19 @@ function safeSendToOwner(id: string, channel: string, payload: unknown): boolean } } +function sendPtyExitGlobal(id: string): void { + safeSendToOwner(id, 'pty:exit:global', { id }); +} + function flushPtyData(id: string): void { const buf = ptyDataBuffers.get(id); if (!buf) return; ptyDataBuffers.delete(id); safeSendToOwner(id, `pty:data:${id}`, buf); + safeSendToOwner(id, 'pty:activity', { + id, + chunk: buf.length <= PTY_ACTIVITY_SAMPLE_CHARS ? buf : buf.slice(-PTY_ACTIVITY_SAMPLE_CHARS), + }); } function clearPtyData(id: string): void { @@ -372,6 +381,7 @@ export function registerPtyIpc(): void { flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); owners.delete(id); listeners.delete(id); removePtyRecord(id); @@ -450,6 +460,7 @@ export function registerPtyIpc(): void { flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); owners.delete(id); listeners.delete(id); removePtyRecord(id); @@ -598,6 +609,7 @@ export function registerPtyIpc(): void { return; } safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); maybeMarkProviderFinish( id, exitCode, @@ -714,6 +726,7 @@ export function registerPtyIpc(): void { try { // Ensure telemetry timers are cleared even on manual kill maybeMarkProviderFinish(args.id, null, undefined, 'manual_kill'); + sendPtyExitGlobal(args.id); // Kill associated tmux session if this PTY was tmux-wrapped if (getPtyTmuxSessionName(args.id)) { killTmuxSession(args.id); @@ -917,6 +930,7 @@ export function registerPtyIpc(): void { flushPtyData(id); clearPtyData(id); safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); maybeMarkProviderFinish(id, exitCode, signal, 'process_exit'); owners.delete(id); listeners.delete(id); @@ -1051,6 +1065,7 @@ export function registerPtyIpc(): void { return; } safeSendToOwner(id, `pty:exit:${id}`, { exitCode, signal }); + sendPtyExitGlobal(id); // For direct spawn: keep owner (shell respawn reuses it), delete listeners (shell respawn re-adds) // For fallback: clean up owner since no shell respawn happens if (usedFallback) { diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 1a13bccf63..21a209da8b 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -78,6 +78,7 @@ const ChatInterface: React.FC = ({ const [conversationsLoaded, setConversationsLoaded] = useState(false); const [showCreateChatModal, setShowCreateChatModal] = useState(false); const [busyByConversationId, setBusyByConversationId] = useState>({}); + const lockedAgentWriteRef = useRef(null); const tabsContainerRef = useRef(null); const [tabsOverflow, setTabsOverflow] = useState(false); @@ -279,6 +280,18 @@ const ChatInterface: React.FC = ({ // Ref to control terminal focus imperatively if needed const terminalRef = useRef<{ focus: () => void }>(null); + const handleTerminalActivity = useCallback(() => { + const storageKey = `agent:locked:${task.id}`; + const writeToken = `${storageKey}:${agent}`; + if (lockedAgentWriteRef.current === writeToken) return; + lockedAgentWriteRef.current = writeToken; + + try { + if (window.localStorage.getItem(storageKey) === agent) return; + window.localStorage.setItem(storageKey, agent); + } catch {} + }, [agent, task.id]); + // Auto-focus terminal when switching to this task useEffect(() => { if (!conversationsLoaded) return; @@ -1076,11 +1089,7 @@ const ChatInterface: React.FC = ({ keepAlive={true} mapShiftEnterToCtrlJ disableSnapshots={false} - onActivity={() => { - try { - window.localStorage.setItem(`agent:locked:${task.id}`, agent); - } catch {} - }} + onActivity={handleTerminalActivity} onStartError={(message) => { setCliStartError(message); }} diff --git a/src/renderer/components/MultiAgentTask.tsx b/src/renderer/components/MultiAgentTask.tsx index 65af24491d..790c43a1fb 100644 --- a/src/renderer/components/MultiAgentTask.tsx +++ b/src/renderer/components/MultiAgentTask.tsx @@ -9,7 +9,7 @@ import { agentMeta } from '@/providers/meta'; import { agentAssets } from '@/providers/assets'; import AgentLogo from './AgentLogo'; import { useTheme } from '@/hooks/useTheme'; -import { classifyActivity } from '@/lib/activityClassifier'; +import { classifyActivity, sampleActivityChunk } from '@/lib/activityClassifier'; import { activityStore } from '@/lib/activityStore'; import { Spinner } from './ui/spinner'; import { BUSY_HOLD_MS, CLEAR_BUSY_MS, INJECT_ENTER_DELAY_MS } from '@/lib/activityConstants'; @@ -351,7 +351,8 @@ const MultiAgentTask: React.FC = ({ const offData = (window as any).electronAPI?.onPtyData?.(ptyId, (chunk: string) => { try { - const signal = classifyActivity(variant.agent, chunk || ''); + const sampledChunk = sampleActivityChunk(chunk || ''); + const signal = classifyActivity(variant.agent, sampledChunk); if (signal === 'busy') setBusy(variantId, true); else if (signal === 'idle') setBusy(variantId, false); else armNeutral(variantId); diff --git a/src/renderer/hooks/useInitialPromptInjection.ts b/src/renderer/hooks/useInitialPromptInjection.ts index 03fe9e1715..753477ff16 100644 --- a/src/renderer/hooks/useInitialPromptInjection.ts +++ b/src/renderer/hooks/useInitialPromptInjection.ts @@ -1,6 +1,6 @@ import { useEffect } from 'react'; import { initialPromptSentKey } from '../lib/keys'; -import { classifyActivity } from '../lib/activityClassifier'; +import { classifyActivity, sampleActivityChunk } from '../lib/activityClassifier'; import { makePtyId } from '@shared/ptyId'; import type { ProviderId } from '@shared/providers/registry'; @@ -45,7 +45,7 @@ export function useInitialPromptInjection(opts: { // Heuristic: if classifier says idle, trigger a quicker send try { - const signal = classifyActivity(providerId, chunk); + const signal = classifyActivity(providerId, sampleActivityChunk(chunk)); if (signal === 'idle' && !sent) { idleSeen = true; setTimeout(send, 250); diff --git a/src/renderer/lib/activityClassifier.ts b/src/renderer/lib/activityClassifier.ts index 1777af8f3f..64af19cf3b 100644 --- a/src/renderer/lib/activityClassifier.ts +++ b/src/renderer/lib/activityClassifier.ts @@ -1,4 +1,5 @@ export type ActivitySignal = 'busy' | 'idle' | 'neutral'; +const MAX_ACTIVITY_CHUNK_CHARS = 8_192; function stripAnsi(s: string): string { // Remove ANSI escape codes and carriage returns @@ -8,6 +9,15 @@ function stripAnsi(s: string): string { .replace(/\x1b\][^\x07]*\x07/g, ''); } +/** + * Keep activity classification work bounded by sampling the most recent part + * of large PTY chunks. Newest output has the strongest signal for busy/idle. + */ +export function sampleActivityChunk(chunk: string): string { + if (!chunk) return ''; + return chunk.length <= MAX_ACTIVITY_CHUNK_CHARS ? chunk : chunk.slice(-MAX_ACTIVITY_CHUNK_CHARS); +} + export function classifyActivity( provider: string | null | undefined, chunk: string diff --git a/src/renderer/lib/activityStore.ts b/src/renderer/lib/activityStore.ts index 5226607c94..21fb97acd6 100644 --- a/src/renderer/lib/activityStore.ts +++ b/src/renderer/lib/activityStore.ts @@ -1,10 +1,15 @@ -import { classifyActivity } from './activityClassifier'; +import { classifyActivity, sampleActivityChunk } from './activityClassifier'; import { CLEAR_BUSY_MS, BUSY_HOLD_MS } from './activityConstants'; import { type PtyIdKind, parsePtyId, makePtyId } from '@shared/ptyId'; import { PROVIDER_IDS } from '@shared/providers/registry'; import type { AgentEvent } from '@shared/agentEvents'; type Listener = (busy: boolean) => void; +type DirectSubscription = { + refCount: number; + offData: Array<() => void>; + offExit: Array<() => void>; +}; class ActivityStore { private listeners = new Map>(); @@ -13,38 +18,81 @@ class ActivityStore { private busySince = new Map(); private subscribed = false; private subscribedIds = new Set(); + private directSubscriptions = new Map(); + private hasGlobalActivityFeed = false; + + private normalizeKinds(kinds?: PtyIdKind[]): PtyIdKind[] { + if (!kinds?.length) return ['main']; + const uniqueKinds = Array.from(new Set(kinds)); + uniqueKinds.sort(); + return uniqueKinds as PtyIdKind[]; + } + + private makeDirectSubscriptionKey(wsId: string, kinds: readonly PtyIdKind[]): string { + return `${wsId}|${kinds.join(',')}`; + } + + private resolveSubscribedTaskFromPtyId(id: string): { wsId: string; provider: string } | null { + const parsed = parsePtyId(id); + if (parsed && this.subscribedIds.has(parsed.suffix)) { + return { wsId: parsed.suffix, provider: parsed.providerId }; + } + + for (const wsId of this.subscribedIds) { + if (!id.endsWith(wsId)) continue; + return { wsId, provider: parsed?.providerId || '' }; + } + return null; + } + + private applyClassifiedSignal(wsId: string, provider: string, chunk: string) { + const sampledChunk = sampleActivityChunk((chunk || '').toString()); + const signal = classifyActivity(provider, sampledChunk); + if (signal === 'busy') { + this.setBusy(wsId, true, true); + } else if (signal === 'idle') { + this.setBusy(wsId, false, true); + } else if (this.states.get(wsId)) { + // neutral: keep current but set soft clear timer + this.armTimer(wsId); + } + } private ensureSubscribed() { if (this.subscribed) return; this.subscribed = true; const api: any = (window as any).electronAPI; - api?.onPtyActivity?.((info: { id: string; chunk?: string }) => { + const offActivity = api?.onPtyActivity?.((info: { id: string; chunk?: string }) => { try { const id = String(info?.id || ''); - // Match any subscribed task id by suffix - for (const wsId of this.subscribedIds) { - if (!id.endsWith(wsId)) continue; - const prov = parsePtyId(id)?.providerId || ''; - const signal = classifyActivity(prov, info?.chunk || ''); - if (signal === 'busy') { - this.setBusy(wsId, true, true); - } else if (signal === 'idle') { - this.setBusy(wsId, false, true); - } else { - // neutral: keep current but set soft clear timer - if (this.states.get(wsId)) this.armTimer(wsId); - } - } + const matched = this.resolveSubscribedTaskFromPtyId(id); + if (!matched) return; + this.applyClassifiedSignal(matched.wsId, matched.provider, info?.chunk || ''); } catch {} }); - api?.onPtyExitGlobal?.((info: { id: string }) => { + const offExit = api?.onPtyExitGlobal?.((info: { id: string }) => { try { const id = String(info?.id || ''); - for (const wsId of this.subscribedIds) { - if (id.endsWith(wsId)) this.setBusy(wsId, false, true); - } + const matched = this.resolveSubscribedTaskFromPtyId(id); + if (!matched) return; + this.setBusy(matched.wsId, false, true); } catch {} }); + + const hasActivityFeed = typeof offActivity === 'function'; + const hasExitFeed = typeof offExit === 'function'; + this.hasGlobalActivityFeed = hasActivityFeed && hasExitFeed; + + // If only one channel is available, disable it and rely on direct fallback + // so busy/idle transitions stay consistent. + if (!this.hasGlobalActivityFeed) { + try { + offActivity?.(); + } catch {} + try { + offExit?.(); + } catch {} + } } private armTimer(wsId: string) { @@ -54,7 +102,7 @@ class ActivityStore { this.timers.set(wsId, t); } - private setBusy(wsId: string, busy: boolean, fromEvent = false) { + private setBusy(wsId: string, busy: boolean, _fromEvent = false) { const current = this.states.get(wsId) || false; // If setting busy: clear timers and record start if (busy) { @@ -124,56 +172,92 @@ class ActivityStore { } } - subscribe(wsId: string, fn: Listener, opts?: { kinds?: PtyIdKind[] }) { - this.ensureSubscribed(); - this.subscribedIds.add(wsId); - const set = this.listeners.get(wsId) || new Set(); - set.add(fn); - this.listeners.set(wsId, set); - // emit current - fn(this.states.get(wsId) || false); - // Fallback: also listen directly to PTY data in case global broadcast is missing. - // `kinds` can be narrowed by callers for performance: - // - task-level busy: { kinds: ['main'] } (default) - // - conversation-level busy: { kinds: ['chat'] } - const offDirect: Array<() => void> = []; - const offExitDirect: Array<() => void> = []; - const kinds = opts?.kinds?.length ? opts.kinds : (['main'] as const); + private retainDirectSubscription(wsId: string, kinds: readonly PtyIdKind[]): string { + const key = this.makeDirectSubscriptionKey(wsId, kinds); + const existing = this.directSubscriptions.get(key); + if (existing) { + existing.refCount += 1; + return key; + } + + const offData: Array<() => void> = []; + const offExit: Array<() => void> = []; + try { const api: any = (window as any).electronAPI; for (const prov of PROVIDER_IDS) { for (const kind of kinds) { const ptyId = makePtyId(prov, kind, wsId); - const off = api?.onPtyData?.(ptyId, (chunk: string) => { + const offChunk = api?.onPtyData?.(ptyId, (chunk: string) => { try { - const signal = classifyActivity(prov, chunk || ''); - if (signal === 'busy') this.setBusy(wsId, true, true); - else if (signal === 'idle') this.setBusy(wsId, false, true); - else if (this.states.get(wsId)) this.armTimer(wsId); + this.applyClassifiedSignal(wsId, prov, chunk || ''); } catch {} }); - if (off) offDirect.push(off); - const offExit = api?.onPtyExit?.(ptyId, () => { + if (offChunk) offData.push(offChunk); + + const offPtyExit = api?.onPtyExit?.(ptyId, () => { try { this.setBusy(wsId, false, true); } catch {} }); - if (offExit) offExitDirect.push(offExit); + if (offPtyExit) offExit.push(offPtyExit); } } } catch {} + this.directSubscriptions.set(key, { refCount: 1, offData, offExit }); + return key; + } + + private releaseDirectSubscription(key: string) { + const existing = this.directSubscriptions.get(key); + if (!existing) return; + + existing.refCount -= 1; + if (existing.refCount > 0) return; + + try { + for (const off of existing.offData) off?.(); + for (const off of existing.offExit) off?.(); + } catch {} + this.directSubscriptions.delete(key); + } + + subscribe(wsId: string, fn: Listener, opts?: { kinds?: PtyIdKind[] }) { + this.ensureSubscribed(); + this.subscribedIds.add(wsId); + const set = this.listeners.get(wsId) || new Set(); + set.add(fn); + this.listeners.set(wsId, set); + // emit current + fn(this.states.get(wsId) || false); + + // Fallback: also listen directly to PTY data in case global broadcast is missing. + // `kinds` can be narrowed by callers for performance: + // - task-level busy: { kinds: ['main'] } (default) + // - conversation-level busy: { kinds: ['chat'] } + const kinds = this.normalizeKinds(opts?.kinds); + const directSubscriptionKey = this.hasGlobalActivityFeed + ? null + : this.retainDirectSubscription(wsId, kinds); + return () => { - const s = this.listeners.get(wsId); - if (s) { - s.delete(fn); - if (s.size === 0) this.listeners.delete(wsId); + if (directSubscriptionKey) this.releaseDirectSubscription(directSubscriptionKey); + + const listenersForTask = this.listeners.get(wsId); + if (listenersForTask) { + listenersForTask.delete(fn); + if (listenersForTask.size === 0) { + this.listeners.delete(wsId); + this.subscribedIds.delete(wsId); + + const pendingTimer = this.timers.get(wsId); + if (pendingTimer) clearTimeout(pendingTimer); + this.timers.delete(wsId); + this.busySince.delete(wsId); + this.states.delete(wsId); + } } - try { - for (const off of offDirect) off?.(); - for (const off of offExitDirect) off?.(); - } catch {} - // keep subscribedIds to avoid thrash; optional cleanup could remove when no listeners }; } } diff --git a/src/renderer/terminal/TerminalSessionManager.ts b/src/renderer/terminal/TerminalSessionManager.ts index a9a2b03b32..829e3ca30b 100644 --- a/src/renderer/terminal/TerminalSessionManager.ts +++ b/src/renderer/terminal/TerminalSessionManager.ts @@ -30,6 +30,9 @@ const PTY_RESIZE_DEBOUNCE_MS = 60; const MIN_TERMINAL_COLS = 2; const MIN_TERMINAL_ROWS = 1; const PANEL_RESIZE_DRAGGING_EVENT = 'emdash:panel-resize-dragging'; +const MAX_TERMINAL_WRITE_CHARS_PER_SLICE = 16_384; +const SLOW_INPUT_HANDLER_MS = 16; +const SLOW_INPUT_LOG_THROTTLE_MS = 2_000; const IS_MAC_PLATFORM = typeof navigator !== 'undefined' && /Mac|iPod|iPhone|iPad/.test(navigator.platform); @@ -103,6 +106,12 @@ export class TerminalSessionManager { private inputBuffer: TerminalInputBuffer | null = null; private autoCopyOnSelection = false; private selectionChangeDebounceTimer: ReturnType | null = null; + private readonly pendingWriteQueue: string[] = []; + private queuedWriteChars = 0; + private writeDrainScheduled = false; + private writeInFlight = false; + private shouldScrollToBottomAfterWrites = false; + private lastSlowInputLogAt = 0; // Timing for startup performance measurement private initStartTime: number = 0; @@ -464,6 +473,11 @@ export class TerminalSessionManager { dispose() { if (this.disposed) return; this.disposed = true; + this.pendingWriteQueue.length = 0; + this.queuedWriteChars = 0; + this.writeDrainScheduled = false; + this.writeInFlight = false; + this.shouldScrollToBottomAfterWrites = false; this.detach(); this.stopSnapshotTimer(); this.cancelScheduledFit(); @@ -552,8 +566,12 @@ export class TerminalSessionManager { } private handleTerminalInput(data: string, isNewlineInsert: boolean = false) { + const startedAt = performance.now(); this.emitActivity(); - if (this.disposed) return; + if (this.disposed) { + this.logSlowInputHandler(startedAt); + return; + } // Filter out focus reporting sequences (CSI I = focus in, CSI O = focus out) only if PTY hasn't started yet. // This prevents raw escape sequences from appearing in the terminal before the CLI is ready, @@ -563,7 +581,10 @@ export class TerminalSessionManager { filtered = data.replace(/\x1b\[I|\x1b\[O/g, ''); } - if (!filtered) return; + if (!filtered) { + this.logSlowInputHandler(startedAt); + return; + } // Feed input to the buffer for first-message capture if (this.inputBuffer && !this.inputBuffer.isComplete) { @@ -588,10 +609,26 @@ export class TerminalSessionManager { const injectedData = stripped + pendingText + enterSequence + enterSequence; window.electronAPI.ptyInput({ id: this.id, data: injectedData }); pendingInjectionManager.markUsed(); + this.logSlowInputHandler(startedAt); return; } window.electronAPI.ptyInput({ id: this.id, data: filtered }); + this.logSlowInputHandler(startedAt); + } + + private logSlowInputHandler(startedAt: number) { + const elapsedMs = performance.now() - startedAt; + if (elapsedMs < SLOW_INPUT_HANDLER_MS) return; + + const now = Date.now(); + if (now - this.lastSlowInputLogAt < SLOW_INPUT_LOG_THROTTLE_MS) return; + this.lastSlowInputLogAt = now; + + log.warn('terminalSession:slowInputHandler', { + id: this.id, + elapsedMs: Math.round(elapsedMs), + }); } private cleanTerminalText(text: string): string { @@ -1072,28 +1109,8 @@ export class TerminalSessionManager { } } - try { - this.terminal.write(chunk); - } catch (err) { - // Guard against xterm.js parser errors (e.g. DECRQM "r is not defined" - // in 6.0.0). Log once and continue — the terminal session stays usable. - log.warn('terminalSession:writeError', { id: this.id, error: (err as Error)?.message }); - } - if (!this.firstFrameRendered) { - this.firstFrameRendered = true; - const firstFrameTime = performance.now() - this.initStartTime; - log.info('terminalSession:firstFrame timing', { - id: this.id, - firstFrameMs: Math.round(firstFrameTime), - }); - try { - this.terminal.refresh(0, this.terminal.rows - 1); - } catch {} - } - - if (isAtBottom) { - this.terminal.scrollToBottom(); - } + if (isAtBottom) this.shouldScrollToBottomAfterWrites = true; + this.enqueueTerminalWrite(chunk); }); const offExit = window.electronAPI.onPtyExit(id, (info) => { @@ -1106,6 +1123,90 @@ export class TerminalSessionManager { this.disposables.push(offData, offExit); } + private enqueueTerminalWrite(chunk: string) { + if (!chunk || this.disposed) return; + this.pendingWriteQueue.push(chunk); + this.queuedWriteChars += chunk.length; + this.scheduleWriteDrain(); + } + + private scheduleWriteDrain() { + if (this.writeDrainScheduled || this.disposed) return; + this.writeDrainScheduled = true; + requestAnimationFrame(() => { + this.writeDrainScheduled = false; + this.drainQueuedWrites(); + }); + } + + private dequeueWriteSlice(maxChars: number): string { + if (!this.pendingWriteQueue.length) return ''; + const first = this.pendingWriteQueue[0]; + if (first.length <= maxChars) { + this.pendingWriteQueue.shift(); + this.queuedWriteChars = Math.max(0, this.queuedWriteChars - first.length); + return first; + } + + const slice = first.slice(0, maxChars); + this.pendingWriteQueue[0] = first.slice(maxChars); + this.queuedWriteChars = Math.max(0, this.queuedWriteChars - slice.length); + return slice; + } + + private drainQueuedWrites() { + if (this.disposed || this.writeInFlight) return; + + const slice = this.dequeueWriteSlice(MAX_TERMINAL_WRITE_CHARS_PER_SLICE); + if (!slice) { + if (this.shouldScrollToBottomAfterWrites) { + this.shouldScrollToBottomAfterWrites = false; + try { + this.terminal.scrollToBottom(); + } catch {} + } + return; + } + + this.writeInFlight = true; + try { + this.terminal.write(slice, () => { + this.writeInFlight = false; + if (this.disposed) return; + this.markFirstFrameRendered(); + if (this.queuedWriteChars > 0) { + this.scheduleWriteDrain(); + return; + } + if (this.shouldScrollToBottomAfterWrites) { + this.shouldScrollToBottomAfterWrites = false; + try { + this.terminal.scrollToBottom(); + } catch {} + } + }); + } catch (err) { + this.writeInFlight = false; + // Guard against xterm.js parser errors (e.g. DECRQM "r is not defined" + // in 6.0.0). Log once and continue — the terminal session stays usable. + log.warn('terminalSession:writeError', { id: this.id, error: (err as Error)?.message }); + if (this.queuedWriteChars > 0) this.scheduleWriteDrain(); + } + } + + private markFirstFrameRendered() { + if (this.firstFrameRendered) return; + this.firstFrameRendered = true; + const firstFrameTime = performance.now() - this.initStartTime; + log.info('terminalSession:firstFrame timing', { + id: this.id, + firstFrameMs: Math.round(firstFrameTime), + }); + try { + this.terminal.refresh(0, this.terminal.rows - 1); + } catch {} + } + private captureSnapshot(reason: 'interval' | 'detach' | 'dispose'): Promise { if (!window.electronAPI.ptySaveSnapshot) return Promise.resolve(); if (this.disposed) return Promise.resolve(); diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 444868b427..adaf6f975a 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -119,6 +119,8 @@ declare global { listener: (info: { exitCode: number; signal?: number }) => void ) => () => void; onPtyStarted: (listener: (data: { id: string }) => void) => () => void; + onPtyActivity: (listener: (data: { id: string; chunk?: string }) => void) => () => void; + onPtyExitGlobal: (listener: (data: { id: string }) => void) => () => void; onAgentEvent: ( listener: (event: AgentEvent, meta: { appFocused: boolean }) => void ) => () => void; @@ -1203,6 +1205,8 @@ export interface ElectronAPI { listener: (info: { exitCode: number; signal?: number }) => void ) => () => void; onPtyStarted: (listener: (data: { id: string }) => void) => () => void; + onPtyActivity: (listener: (data: { id: string; chunk?: string }) => void) => () => void; + onPtyExitGlobal: (listener: (data: { id: string }) => void) => () => void; onAgentEvent: ( listener: (event: AgentEvent, meta: { appFocused: boolean }) => void ) => () => void; diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 69ec6345d6..03e34fe244 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -59,6 +59,9 @@ declare global { id: string, listener: (info: { exitCode: number; signal?: number }) => void ) => () => void; + onPtyStarted: (listener: (data: { id: string }) => void) => () => void; + onPtyActivity: (listener: (data: { id: string; chunk?: string }) => void) => () => void; + onPtyExitGlobal: (listener: (data: { id: string }) => void) => () => void; // Worktree management worktreeCreate: (args: { projectPath: string; From 77949af022cad66b94fa7ee1d9dcf787097a5f3f Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Wed, 4 Mar 2026 20:06:29 -0800 Subject: [PATCH 004/489] perf(delete): batch pty cleanup and fetch delete risks in one IPC --- src/main/ipc/gitIpc.ts | 184 ++++++++++++++++++++++++ src/main/preload.ts | 39 +++++ src/main/services/ptyIpc.ts | 72 ++++++++-- src/renderer/hooks/useDeleteRisks.ts | 42 ++++++ src/renderer/hooks/useTaskManagement.ts | 60 +++----- src/renderer/types/electron-api.d.ts | 40 ++++++ src/renderer/types/global.d.ts | 30 ++++ 7 files changed, 421 insertions(+), 46 deletions(-) diff --git a/src/main/ipc/gitIpc.ts b/src/main/ipc/gitIpc.ts index e588a51eac..6c9d33879d 100644 --- a/src/main/ipc/gitIpc.ts +++ b/src/main/ipc/gitIpc.ts @@ -618,6 +618,69 @@ export function registerGitIpc() { return `'${arg.replace(/'/g, "'\\''")}'`; } + const countStatusBuckets = ( + changes: Array<{ status?: string; isStaged?: boolean }> + ): { staged: number; unstaged: number; untracked: number } => { + let staged = 0; + let unstaged = 0; + let untracked = 0; + for (const change of changes) { + if (change.status === 'untracked') { + untracked += 1; + } else if (change.isStaged) { + staged += 1; + } else { + unstaged += 1; + } + } + return { staged, unstaged, untracked }; + }; + + const getLocalAheadBehind = async ( + taskPath: string + ): Promise<{ ahead: number; behind: number }> => { + try { + const { stdout } = await execFileAsync(GIT, ['status', '-sb'], { cwd: taskPath }); + const firstLine = ((stdout || '').split('\n')[0] || '').trim(); + const aheadMatch = firstLine.match(/ahead\s+(\d+)/i); + const behindMatch = firstLine.match(/behind\s+(\d+)/i); + return { + ahead: aheadMatch ? parseInt(aheadMatch[1], 10) || 0 : 0, + behind: behindMatch ? parseInt(behindMatch[1], 10) || 0 : 0, + }; + } catch { + return { ahead: 0, behind: 0 }; + } + }; + + const getLocalPrForDeleteRisk = async ( + taskPath: string + ): Promise<{ pr: unknown | null; prKnown: boolean; error?: string }> => { + const queryFields = [ + 'number', + 'url', + 'state', + 'isDraft', + 'title', + 'headRefName', + 'baseRefName', + ]; + const cmd = `gh pr view --json ${queryFields.join(',')} -q .`; + try { + const { stdout } = await execAsync(cmd, { cwd: taskPath }); + const json = (stdout || '').trim(); + if (!json) return { pr: null, prKnown: true }; + return { pr: JSON.parse(json), prKnown: true }; + } catch (error) { + const errObj = error as { stderr?: string; message?: string }; + const msg = (errObj?.stderr || errObj?.message || String(error || '')).trim(); + if (/no pull requests? found|not found|could not resolve to a pull request/i.test(msg)) { + return { pr: null, prKnown: true }; + } + return { pr: null, prKnown: false, error: msg || 'Failed to query pull request status' }; + } + }; + ipcMain.handle('git:watch-status', async (_, taskPath: string) => { const remoteProject = await resolveRemoteProjectForWorktreePath(taskPath); if (remoteProject) { @@ -652,6 +715,127 @@ export function registerGitIpc() { } }); + ipcMain.handle( + 'git:get-delete-risks', + async ( + _, + args: { targets: Array<{ id: string; taskPath: string }>; includePr?: boolean } + ): Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: unknown | null; + prKnown: boolean; + } + >; + error?: string; + }> => { + const targets = Array.isArray(args?.targets) ? args.targets : []; + const includePr = args?.includePr === true; + if (targets.length === 0) { + return { success: true, risks: {} }; + } + + try { + const entries = await Promise.all( + targets.map(async (target) => { + const risk: { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: unknown | null; + prKnown: boolean; + } = { + staged: 0, + unstaged: 0, + untracked: 0, + ahead: 0, + behind: 0, + pr: null, + prKnown: false, + }; + + try { + const remoteProject = await resolveRemoteProjectForWorktreePath(target.taskPath); + + const [changesRes, aheadBehindRes, prRes] = await Promise.all([ + (async () => { + if (remoteProject) { + return await remoteGitService.getStatusDetailed( + remoteProject.sshConnectionId, + target.taskPath + ); + } + return await gitGetStatus(target.taskPath); + })(), + (async () => { + if (remoteProject) { + const status = await remoteGitService.getBranchStatus( + remoteProject.sshConnectionId, + target.taskPath + ); + return { ahead: status.ahead, behind: status.behind }; + } + return await getLocalAheadBehind(target.taskPath); + })(), + (async () => { + if (!includePr) return { pr: null, prKnown: false as const, error: undefined }; + if (remoteProject) { + const result = await getPrStatusRemote( + remoteProject.sshConnectionId, + target.taskPath + ); + if (!result.success) { + return { + pr: null, + prKnown: false as const, + error: result.error || 'Failed to query pull request status', + }; + } + return { pr: result.pr ?? null, prKnown: true as const, error: undefined }; + } + return await getLocalPrForDeleteRisk(target.taskPath); + })(), + ]); + + const counts = countStatusBuckets( + changesRes as Array<{ status?: string; isStaged?: boolean }> + ); + risk.staged = counts.staged; + risk.unstaged = counts.unstaged; + risk.untracked = counts.untracked; + risk.ahead = aheadBehindRes.ahead || 0; + risk.behind = aheadBehindRes.behind || 0; + risk.pr = prRes.pr; + risk.prKnown = prRes.prKnown; + if (prRes.error) { + risk.error = prRes.error; + } + } catch (error) { + risk.error = error instanceof Error ? error.message : String(error); + } + + return [target.id, risk] as const; + }) + ); + + return { success: true, risks: Object.fromEntries(entries) }; + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) }; + } + } + ); + // Git: Per-file diff (moved from Codex IPC) ipcMain.handle('git:get-file-diff', async (_, args: { taskPath: string; filePath: string }) => { try { diff --git a/src/main/preload.ts b/src/main/preload.ts index 4454b6ca64..56883fdf48 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -124,6 +124,11 @@ contextBridge.exposeInMainWorld('electronAPI', { ptySaveSnapshot: (args: { id: string; payload: TerminalSnapshotPayload }) => ipcRenderer.invoke('pty:snapshot:save', args), ptyClearSnapshot: (args: { id: string }) => ipcRenderer.invoke('pty:snapshot:clear', args), + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => ipcRenderer.invoke('pty:cleanupSessions', args), onPtyExit: (id: string, listener: (info: { exitCode: number; signal?: number }) => void) => { const channel = `pty:exit:${id}`; const wrapped = (_: Electron.IpcRendererEvent, info: { exitCode: number; signal?: number }) => @@ -320,6 +325,10 @@ contextBridge.exposeInMainWorld('electronAPI', { ipcRenderer.invoke('projectSettings:fetchBaseRef', args), getGitInfo: (projectPath: string) => ipcRenderer.invoke('git:getInfo', projectPath), getGitStatus: (taskPath: string) => ipcRenderer.invoke('git:get-status', taskPath), + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => ipcRenderer.invoke('git:get-delete-risks', args), watchGitStatus: (taskPath: string) => ipcRenderer.invoke('git:watch-status', taskPath), unwatchGitStatus: (taskPath: string, watchId?: string) => ipcRenderer.invoke('git:unwatch-status', taskPath, watchId), @@ -770,6 +779,16 @@ export interface ElectronAPI { payload: TerminalSnapshotPayload; }) => Promise<{ ok: boolean; error?: string }>; ptyClearSnapshot: (args: { id: string }) => Promise<{ ok: boolean }>; + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }>; onPtyExit: ( id: string, listener: (info: { exitCode: number; signal?: number }) => void @@ -926,6 +945,26 @@ export interface ElectronAPI { }>; error?: string; }>; + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: any; + prKnown: boolean; + } + >; + error?: string; + }>; watchGitStatus: (taskPath: string) => Promise<{ success: boolean; watchId?: string; diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index df8b4b4c62..3ca0593fca 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -90,6 +90,18 @@ function clearPtyData(id: string): void { ptyDataBuffers.delete(id); } +function cleanupPtySession(id: string): void { + // Ensure telemetry timers are cleared even on manual kill + maybeMarkProviderFinish(id, null, undefined, 'manual_kill'); + // Kill associated tmux session if this PTY was tmux-wrapped + if (getPtyTmuxSessionName(id)) { + killTmuxSession(id); + } + killPty(id); + owners.delete(id); + listeners.delete(id); +} + function bufferedSendPtyData(id: string, chunk: string): void { const prev = ptyDataBuffers.get(id) || ''; ptyDataBuffers.set(id, prev + chunk); @@ -712,20 +724,62 @@ export function registerPtyIpc(): void { ipcMain.on('pty:kill', (_event, args: { id: string }) => { try { - // Ensure telemetry timers are cleared even on manual kill - maybeMarkProviderFinish(args.id, null, undefined, 'manual_kill'); - // Kill associated tmux session if this PTY was tmux-wrapped - if (getPtyTmuxSessionName(args.id)) { - killTmuxSession(args.id); - } - killPty(args.id); - owners.delete(args.id); - listeners.delete(args.id); + cleanupPtySession(args.id); } catch (e) { log.error('pty:kill error', { id: args.id, error: e }); } }); + ipcMain.handle( + 'pty:cleanupSessions', + async ( + _event, + args: { ids: string[]; clearSnapshots?: boolean; waitForSnapshots?: boolean } + ): Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }> => { + const ids = Array.from(new Set((args?.ids || []).filter(Boolean))); + const failedIds: string[] = []; + + for (const id of ids) { + try { + cleanupPtySession(id); + } catch (error) { + failedIds.push(id); + log.error('pty:cleanupSessions kill error', { id, error }); + } + } + + const clearSnapshots = args?.clearSnapshots === true; + const waitForSnapshots = args?.waitForSnapshots === true; + if (clearSnapshots) { + const clearPromise = Promise.allSettled( + ids.map(async (id) => { + try { + await terminalSnapshotService.deleteSnapshot(id); + } catch {} + }) + ); + + if (waitForSnapshots) { + await clearPromise; + } else { + void clearPromise; + } + } + + return { + ok: failedIds.length === 0, + cleaned: ids.length - failedIds.length, + failedIds, + snapshotClearQueued: clearSnapshots, + }; + } + ); + // Kill a tmux session by PTY ID (used during task deletion cleanup) ipcMain.handle('pty:killTmux', async (_event, args: { id: string }) => { try { diff --git a/src/renderer/hooks/useDeleteRisks.ts b/src/renderer/hooks/useDeleteRisks.ts index 8e4faa0dca..3b20883ae7 100644 --- a/src/renderer/hooks/useDeleteRisks.ts +++ b/src/renderer/hooks/useDeleteRisks.ts @@ -71,6 +71,48 @@ export function useDeleteRisks( requestIdRef.current = requestId; setLoading(true); + const includePr = options?.force || eagerPrRefresh; + try { + const bulkRes = await (window as any).electronAPI?.getDeleteRisks?.({ + targets: tasks.map((task) => ({ id: task.id, taskPath: task.path })), + includePr, + }); + + if (bulkRes?.success && bulkRes.risks && typeof bulkRes.risks === 'object') { + const entries = tasks.map((task) => { + const item = bulkRes.risks[task.id] || {}; + return [ + task.id, + { + staged: typeof item.staged === 'number' ? item.staged : 0, + unstaged: typeof item.unstaged === 'number' ? item.unstaged : 0, + untracked: typeof item.untracked === 'number' ? item.untracked : 0, + ahead: typeof item.ahead === 'number' ? item.ahead : 0, + behind: typeof item.behind === 'number' ? item.behind : 0, + error: typeof item.error === 'string' ? item.error : undefined, + pr: item.pr ?? null, + prKnown: item.prKnown === true, + }, + ] as const; + }); + + const next = Object.fromEntries(entries); + if (requestIdRef.current === requestId) { + setRisks(next); + const scannedAt = Date.now(); + setScannedAtById( + Object.fromEntries(entries.map(([id]) => [id, scannedAt])) as Record + ); + setLoading(false); + setLoaded(true); + } + + return next; + } + } catch { + // Fallback to per-task scan below + } + const entries = await Promise.all( tasks.map(async (ws) => { try { diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index 367bcc8974..a530b2b9de 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -88,17 +88,11 @@ const cleanupPtyResources = async (task: Task): Promise => { for (const v of variants) { const id = `${v.worktreeId}-main`; mainSessionIds.push(id); - try { - window.electronAPI.ptyKill?.(id); - } catch {} } } else { for (const provider of TERMINAL_PROVIDER_IDS) { const id = makePtyId(provider, 'main', task.id); mainSessionIds.push(id); - try { - window.electronAPI.ptyKill?.(id); - } catch {} } } @@ -109,24 +103,25 @@ const cleanupPtyResources = async (task: Task): Promise => { if (!conv.isMain && conv.provider) { const chatId = makePtyId(conv.provider as ProviderId, 'chat', conv.id); chatSessionIds.push(chatId); - try { - window.electronAPI.ptyKill?.(chatId); - } catch {} } } } catch {} const sessionIds = [...mainSessionIds, ...chatSessionIds]; - await Promise.allSettled( - sessionIds.map(async (sessionId) => { - try { - terminalSessionRegistry.dispose(sessionId); - } catch {} - try { - await window.electronAPI.ptyClearSnapshot({ id: sessionId }); - } catch {} - }) - ); + for (const sessionId of sessionIds) { + try { + terminalSessionRegistry.dispose(sessionId); + } catch {} + } + if (sessionIds.length > 0) { + try { + await window.electronAPI.ptyCleanupSessions({ + ids: sessionIds, + clearSnapshots: true, + waitForSnapshots: false, + }); + } catch {} + } const variantPaths = (task.metadata?.multiAgent?.variants || []).map((v) => v.path); const pathsToClean = variantPaths.length > 0 ? variantPaths : [task.path]; @@ -420,17 +415,11 @@ export function useTaskManagement() { for (const v of variants) { const id = `${v.worktreeId}-main`; mainSessionIds.push(id); - try { - window.electronAPI.ptyKill?.(id); - } catch {} } } else { for (const provider of TERMINAL_PROVIDER_IDS) { const id = makePtyId(provider, 'main', task.id); mainSessionIds.push(id); - try { - window.electronAPI.ptyKill?.(id); - } catch {} } } @@ -441,9 +430,6 @@ export function useTaskManagement() { if (!conv.isMain && conv.provider) { const chatId = makePtyId(conv.provider as ProviderId, 'chat', conv.id); chatSessionIds.push(chatId); - try { - window.electronAPI.ptyKill?.(chatId); - } catch {} } } } catch {} @@ -454,15 +440,15 @@ export function useTaskManagement() { terminalSessionRegistry.dispose(sessionId); } catch {} } - // Snapshot cleanup is best-effort; run it in the background so delete completion - // is not blocked on potentially slow IPC or disk work. - void Promise.allSettled( - sessionIds.map(async (sessionId) => { - try { - await window.electronAPI.ptyClearSnapshot({ id: sessionId }); - } catch {} - }) - ); + if (sessionIds.length > 0) { + try { + await window.electronAPI.ptyCleanupSessions({ + ids: sessionIds, + clearSnapshots: true, + waitForSnapshots: false, + }); + } catch {} + } const variantPaths = (task.metadata?.multiAgent?.variants || []).map((v) => v.path); const pathsToClean = variantPaths.length > 0 ? variantPaths : [task.path]; diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 444868b427..dfd810193e 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -114,6 +114,16 @@ declare global { error?: string; }>; ptyClearSnapshot: (args: { id: string }) => Promise<{ ok: boolean }>; + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }>; onPtyExit: ( id: string, listener: (info: { exitCode: number; signal?: number }) => void @@ -349,6 +359,26 @@ declare global { }>; error?: string; }>; + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: any; + prKnown: boolean; + } + >; + error?: string; + }>; watchGitStatus: (taskPath: string) => Promise<{ success: boolean; watchId?: string; @@ -1198,6 +1228,16 @@ export interface ElectronAPI { error?: string; }>; ptyClearSnapshot: (args: { id: string }) => Promise<{ ok: boolean }>; + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }>; onPtyExit: ( id: string, listener: (info: { exitCode: number; signal?: number }) => void diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 69ec6345d6..abf2ed4111 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -55,6 +55,16 @@ declare global { error?: string; }>; ptyClearSnapshot: (args: { id: string }) => Promise<{ ok: boolean }>; + ptyCleanupSessions: (args: { + ids: string[]; + clearSnapshots?: boolean; + waitForSnapshots?: boolean; + }) => Promise<{ + ok: boolean; + cleaned: number; + failedIds: string[]; + snapshotClearQueued: boolean; + }>; onPtyExit: ( id: string, listener: (info: { exitCode: number; signal?: number }) => void @@ -239,6 +249,26 @@ declare global { }>; error?: string; }>; + getDeleteRisks: (args: { + targets: Array<{ id: string; taskPath: string }>; + includePr?: boolean; + }) => Promise<{ + success: boolean; + risks?: Record< + string, + { + staged: number; + unstaged: number; + untracked: number; + ahead: number; + behind: number; + error?: string; + pr?: any; + prKnown: boolean; + } + >; + error?: string; + }>; watchGitStatus: (taskPath: string) => Promise<{ success: boolean; watchId?: string; From ee2b0141c307779d3ae6cdf8366ce612b8dbde15 Mon Sep 17 00:00:00 2001 From: Raban von Spiegel Date: Wed, 4 Mar 2026 20:07:58 -0800 Subject: [PATCH 005/489] perf(delete): delay confirmation spinner to avoid flicker --- src/renderer/components/ProjectMainView.tsx | 16 +++++++++++++--- src/renderer/components/TaskDeleteButton.tsx | 14 +++++++++++++- 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/src/renderer/components/ProjectMainView.tsx b/src/renderer/components/ProjectMainView.tsx index 8aaa89540d..edf8c3617d 100644 --- a/src/renderer/components/ProjectMainView.tsx +++ b/src/renderer/components/ProjectMainView.tsx @@ -355,6 +355,7 @@ const ProjectMainView: React.FC = ({ const [acknowledgeDirtyDelete, setAcknowledgeDirtyDelete] = useState(false); const [requiresDeleteAcknowledge, setRequiresDeleteAcknowledge] = useState(false); const [showDeleteWarnings, setShowDeleteWarnings] = useState(false); + const [showDeleteActionSpinner, setShowDeleteActionSpinner] = useState(false); const [showConfigEditor, setShowConfigEditor] = useState(false); const [searchFilter, setSearchFilter] = useState(''); const [showFilter, setShowFilter] = useState<'active' | 'all'>('active'); @@ -698,6 +699,7 @@ const ProjectMainView: React.FC = ({ setRequiresDeleteAcknowledge(false); setShowDeleteWarnings(false); setIsCheckingDeleteRisks(false); + setShowDeleteActionSpinner(false); } }, [showDeleteDialog]); @@ -707,6 +709,16 @@ const ProjectMainView: React.FC = ({ setShowDeleteWarnings(false); }, [selectedIds]); + useEffect(() => { + const busy = isDeleting || isCheckingDeleteRisks; + if (!busy) { + setShowDeleteActionSpinner(false); + return; + } + const timeoutId = window.setTimeout(() => setShowDeleteActionSpinner(true), 180); + return () => window.clearTimeout(timeoutId); + }, [isCheckingDeleteRisks, isDeleting]); + // Sync baseBranch when branchOptions change useEffect(() => { if (branchOptions.length === 0) return; @@ -1132,9 +1144,7 @@ const ProjectMainView: React.FC = ({ onClick={handleConfirmBulkDelete} disabled={deleteDisabled} > - {isDeleting || isCheckingDeleteRisks ? ( - - ) : null} + {showDeleteActionSpinner ? : null} Delete diff --git a/src/renderer/components/TaskDeleteButton.tsx b/src/renderer/components/TaskDeleteButton.tsx index 0fcef93a7c..4bd23533ba 100644 --- a/src/renderer/components/TaskDeleteButton.tsx +++ b/src/renderer/components/TaskDeleteButton.tsx @@ -66,6 +66,7 @@ export const TaskDeleteButton: React.FC = ({ const [showWarnings, setShowWarnings] = React.useState(false); const [requiresAcknowledge, setRequiresAcknowledge] = React.useState(false); const [isCheckingRisks, setIsCheckingRisks] = React.useState(false); + const [showActionSpinner, setShowActionSpinner] = React.useState(false); const targets = useMemo( () => [{ id: taskId, name: taskName, path: taskPath }], [taskId, taskName, taskPath] @@ -107,9 +108,20 @@ export const TaskDeleteButton: React.FC = ({ setShowWarnings(false); setRequiresAcknowledge(false); setIsCheckingRisks(false); + setShowActionSpinner(false); } }, [open]); + React.useEffect(() => { + const busy = isDeleting || isCheckingRisks; + if (!busy) { + setShowActionSpinner(false); + return; + } + const timeoutId = window.setTimeout(() => setShowActionSpinner(true), 180); + return () => window.clearTimeout(timeoutId); + }, [isCheckingRisks, isDeleting]); + return ( {!hideTrigger && ( @@ -258,7 +270,7 @@ export const TaskDeleteButton: React.FC = ({ } catch {} }} > - {isDeleting || isCheckingRisks ? : null} + {showActionSpinner ? : null} Delete From 91f4e510528e564179bcf5c8486af1c5af997eb5 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:18:39 -0800 Subject: [PATCH 006/489] fix(ssh): resolve SSH config aliases correctly --- src/main/services/ssh/SshService.ts | 23 +++++++++++++++------ src/main/utils/sshConfigParser.ts | 32 +++++++++++++++++++++-------- 2 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/main/services/ssh/SshService.ts b/src/main/services/ssh/SshService.ts index 60ccd1f24e..fbdade8645 100644 --- a/src/main/services/ssh/SshService.ts +++ b/src/main/services/ssh/SshService.ts @@ -7,7 +7,7 @@ import { quoteShellArg } from '../../utils/shellEscape'; import { readFile } from 'fs/promises'; import { randomUUID } from 'crypto'; import { homedir } from 'os'; -import { resolveIdentityAgent } from '../../utils/sshConfigParser'; +import { resolveIdentityAgent, resolveSshConfigHost } from '../../utils/sshConfigParser'; /** Maximum number of concurrent SSH connections allowed in the pool. */ const MAX_CONNECTIONS = 10; @@ -142,16 +142,25 @@ export class SshService extends EventEmitter { } /** - * Builds the ssh2 ConnectConfig from our SshConfig + * Builds the ssh2 ConnectConfig from our SshConfig. + * + * ssh2 does not read ~/.ssh/config, so we resolve the host through + * sshConfigParser first. This enables SSH aliases (e.g. + * "workspace-daniel-1") to resolve to their actual HostName, Port, + * User, and IdentityFile as defined in the user's SSH config. */ private async buildConnectConfig( connectionId: string, config: SshConfig ): Promise { + // Resolve SSH config overrides for this host/alias + const sshConfigEntry = await resolveSshConfigHost(config.host); + const connectConfig: ConnectConfig = { - host: config.host, - port: config.port, - username: config.username, + // Use resolved HostName if available, otherwise the original host + host: sshConfigEntry?.hostname ?? config.host, + port: config.port ?? sshConfigEntry?.port ?? 22, + username: config.username ?? sshConfigEntry?.user, readyTimeout: 20000, keepaliveInterval: 60000, keepaliveCountMax: 3, @@ -199,7 +208,9 @@ export class SshService extends EventEmitter { } case 'agent': { - const identityAgent = await resolveIdentityAgent(config.host); + // Prefer the already-resolved config entry to avoid re-parsing ~/.ssh/config + const identityAgent = + sshConfigEntry?.identityAgent ?? (await resolveIdentityAgent(config.host)); const agentSocket = identityAgent || process.env.SSH_AUTH_SOCK; if (!agentSocket) { throw new Error( diff --git a/src/main/utils/sshConfigParser.ts b/src/main/utils/sshConfigParser.ts index 6a1d8efee1..136bc20bfc 100644 --- a/src/main/utils/sshConfigParser.ts +++ b/src/main/utils/sshConfigParser.ts @@ -112,22 +112,38 @@ export async function parseSshConfigFile(): Promise { } /** - * Resolves the IdentityAgent socket path for a given hostname. + * Resolves SSH config overrides for a given hostname or alias. * * Parses ~/.ssh/config and finds a matching host entry by checking - * both the Host alias and the HostName value. Returns the expanded - * IdentityAgent path if found, or undefined. + * the Host alias. Returns the resolved fields (hostname, port, user, + * identityFile, identityAgent) so callers can apply them before + * connecting with ssh2 (which does not read ~/.ssh/config natively). + * + * Returns undefined if no matching host is found or if parsing fails. */ -export async function resolveIdentityAgent(hostname: string): Promise { +export async function resolveSshConfigHost( + hostOrAlias: string +): Promise { try { const hosts = await parseSshConfigFile(); - const match = hosts.find( + return hosts.find( (h) => - h.host.toLowerCase() === hostname.toLowerCase() || - h.hostname?.toLowerCase() === hostname.toLowerCase() + h.host.toLowerCase() === hostOrAlias.toLowerCase() || + h.hostname?.toLowerCase() === hostOrAlias.toLowerCase() ); - return match?.identityAgent; } catch { return undefined; } } + +/** + * Resolves the IdentityAgent socket path for a given hostname. + * + * Parses ~/.ssh/config and finds a matching host entry by checking + * both the Host alias and the HostName value. Returns the expanded + * IdentityAgent path if found, or undefined. + */ +export async function resolveIdentityAgent(hostname: string): Promise { + const match = await resolveSshConfigHost(hostname); + return match?.identityAgent; +} From f4797ddb481baf2520a23c631746f62c5138f385 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:21:38 -0800 Subject: [PATCH 007/489] feat(db): add workspace_instances table and schema --- drizzle/0010_add_workspace_instances.sql | 19 +++++++++ drizzle/meta/_journal.json | 7 ++++ src/main/db/schema.ts | 41 ++++++++++++++++++++ src/main/services/LifecycleScriptsService.ts | 7 ++++ 4 files changed, 74 insertions(+) create mode 100644 drizzle/0010_add_workspace_instances.sql diff --git a/drizzle/0010_add_workspace_instances.sql b/drizzle/0010_add_workspace_instances.sql new file mode 100644 index 0000000000..e8b9a22fe1 --- /dev/null +++ b/drizzle/0010_add_workspace_instances.sql @@ -0,0 +1,19 @@ +-- Create workspace_instances table for remote workspace provisioning +CREATE TABLE `workspace_instances` ( + `id` text PRIMARY KEY NOT NULL, + `task_id` text NOT NULL, + `external_id` text, + `host` text NOT NULL, + `port` integer DEFAULT 22 NOT NULL, + `username` text, + `worktree_path` text, + `status` text DEFAULT 'provisioning' NOT NULL, + `connection_id` text REFERENCES ssh_connections(id) ON DELETE SET NULL, + `created_at` integer NOT NULL, + `terminated_at` integer, + FOREIGN KEY (`task_id`) REFERENCES `tasks`(`id`) ON DELETE CASCADE +); + +-- Add indexes for workspace_instances +CREATE INDEX `idx_workspace_instances_task_id` ON `workspace_instances` (`task_id`); +CREATE INDEX `idx_workspace_instances_status` ON `workspace_instances` (`status`); diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index ec51ccb786..2c67d5ccad 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1738857600000, "tag": "0009_add_ssh_support", "breakpoints": true + }, + { + "idx": 10, + "version": "6", + "when": 1741017600000, + "tag": "0010_add_workspace_instances", + "breakpoints": true } ] } diff --git a/src/main/db/schema.ts b/src/main/db/schema.ts index 44082d0843..efa3a18943 100644 --- a/src/main/db/schema.ts +++ b/src/main/db/schema.ts @@ -152,8 +152,37 @@ export const lineComments = sqliteTable( }) ); +export const workspaceInstances = sqliteTable( + 'workspace_instances', + { + id: text('id').primaryKey(), + taskId: text('task_id') + .notNull() + .references(() => tasks.id, { onDelete: 'cascade' }), + externalId: text('external_id'), // "id" from script output (e.g. workspace name); nullable + host: text('host').notNull(), + port: integer('port').notNull().default(22), + username: text('username'), + worktreePath: text('worktree_path'), + status: text('status').notNull().default('provisioning'), // provisioning | ready | terminated | error + connectionId: text('connection_id').references(() => sshConnections.id, { + onDelete: 'set null', + }), + createdAt: integer('created_at').notNull(), + terminatedAt: integer('terminated_at'), + }, + (table) => ({ + taskIdIdx: index('idx_workspace_instances_task_id').on(table.taskId), + statusIdx: index('idx_workspace_instances_status').on(table.status), + }) +); + +export type WorkspaceInstanceRow = typeof workspaceInstances.$inferSelect; +export type WorkspaceInstanceInsert = typeof workspaceInstances.$inferInsert; + export const sshConnectionsRelations = relations(sshConnections, ({ many }) => ({ projects: many(projects), + workspaceInstances: many(workspaceInstances), })); export const projectsRelations = relations(projects, ({ one, many }) => ({ @@ -171,6 +200,18 @@ export const tasksRelations = relations(tasks, ({ one, many }) => ({ }), conversations: many(conversations), lineComments: many(lineComments), + workspaceInstances: many(workspaceInstances), +})); + +export const workspaceInstancesRelations = relations(workspaceInstances, ({ one }) => ({ + task: one(tasks, { + fields: [workspaceInstances.taskId], + references: [tasks.id], + }), + sshConnection: one(sshConnections, { + fields: [workspaceInstances.connectionId], + references: [sshConnections.id], + }), })); export const conversationsRelations = relations(conversations, ({ one, many }) => ({ diff --git a/src/main/services/LifecycleScriptsService.ts b/src/main/services/LifecycleScriptsService.ts index 9d11c754b0..ffef3edf9c 100644 --- a/src/main/services/LifecycleScriptsService.ts +++ b/src/main/services/LifecycleScriptsService.ts @@ -3,11 +3,18 @@ import path from 'path'; import { log } from '../lib/logger'; import type { LifecyclePhase, LifecycleScriptConfig } from '@shared/lifecycle'; +export interface WorkspaceProviderConfig { + type: 'script'; + provisionCommand: string; + terminateCommand: string; +} + export interface EmdashConfig { preservePatterns?: string[]; scripts?: LifecycleScriptConfig; shellSetup?: string; tmux?: boolean; + workspaceProvider?: WorkspaceProviderConfig; } /** From 3bc833712edc25e2b58a8eb76fdfb8e6408f31ce Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:29:46 -0800 Subject: [PATCH 008/489] feat(workspace): add WorkspaceProviderService and IPC handlers --- src/main/ipc/index.ts | 2 + src/main/ipc/workspaceIpc.ts | 129 +++++ src/main/main.ts | 6 + src/main/preload.ts | 38 ++ src/main/services/WorkspaceProviderService.ts | 465 +++++++++++++++++ src/main/services/ptyIpc.ts | 12 +- src/renderer/types/electron-api.d.ts | 82 +++ src/renderer/types/global.d.ts | 42 ++ .../main/WorkspaceProviderService.test.ts | 479 ++++++++++++++++++ 9 files changed, 1251 insertions(+), 4 deletions(-) create mode 100644 src/main/ipc/workspaceIpc.ts create mode 100644 src/main/services/WorkspaceProviderService.ts create mode 100644 src/test/main/WorkspaceProviderService.test.ts diff --git a/src/main/ipc/index.ts b/src/main/ipc/index.ts index 3a0ee157d6..f84ece8659 100644 --- a/src/main/ipc/index.ts +++ b/src/main/ipc/index.ts @@ -22,6 +22,7 @@ import { registerNetIpc } from './netIpc'; import { registerLineCommentsIpc } from './lineCommentsIpc'; import { registerSshIpc } from './sshIpc'; import { registerSkillsIpc } from './skillsIpc'; +import { registerWorkspaceIpc } from './workspaceIpc'; import { createRPCRouter, registerRPCRouter } from '../../shared/ipc/rpc'; import { ipcMain } from 'electron'; @@ -63,4 +64,5 @@ export function registerAllIpc() { registerPlanLockIpc(); registerSshIpc(); registerSkillsIpc(); + registerWorkspaceIpc(); } diff --git a/src/main/ipc/workspaceIpc.ts b/src/main/ipc/workspaceIpc.ts new file mode 100644 index 0000000000..0872121979 --- /dev/null +++ b/src/main/ipc/workspaceIpc.ts @@ -0,0 +1,129 @@ +import { ipcMain, BrowserWindow } from 'electron'; +import { log } from '../lib/logger'; +import { + workspaceProviderService, + type ProvisionConfig, +} from '../services/WorkspaceProviderService'; + +const WORKSPACE_CHANNELS = { + PROVISION: 'workspace:provision', + CANCEL: 'workspace:cancel', + TERMINATE: 'workspace:terminate', + STATUS: 'workspace:status', + PROVISION_PROGRESS: 'workspace:provision-progress', + PROVISION_COMPLETE: 'workspace:provision-complete', +} as const; + +/** + * Registers IPC handlers for workspace provisioning. + * + * The provision flow is event-based: + * - `workspace:provision` returns immediately with { success, instanceId } + * - Progress events are pushed to the renderer via `workspace:provision-progress` + * - Completion is signalled via `workspace:provision-complete` + */ +export function registerWorkspaceIpc() { + // Forward service events to the renderer via IPC. + workspaceProviderService.on( + 'provision-progress', + (data: { instanceId: string; line: string }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(WORKSPACE_CHANNELS.PROVISION_PROGRESS, data); + } + } + ); + + workspaceProviderService.on( + 'provision-complete', + (data: { instanceId: string; status: string; error?: string }) => { + for (const win of BrowserWindow.getAllWindows()) { + win.webContents.send(WORKSPACE_CHANNELS.PROVISION_COMPLETE, data); + } + } + ); + + // ── workspace:provision ────────────────────────────────────────────── + ipcMain.handle( + WORKSPACE_CHANNELS.PROVISION, + async ( + _, + args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + } + ) => { + try { + const config: ProvisionConfig = { + taskId: args.taskId, + repoUrl: args.repoUrl, + branch: args.branch, + baseRef: args.baseRef, + provisionCommand: args.provisionCommand, + projectPath: args.projectPath, + }; + const instanceId = await workspaceProviderService.provision(config); + return { success: true, data: { instanceId } }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] provision failed', { error: message }); + return { success: false, error: message }; + } + } + ); + + // ── workspace:cancel ───────────────────────────────────────────────── + ipcMain.handle(WORKSPACE_CHANNELS.CANCEL, async (_, args: { instanceId: string }) => { + try { + await workspaceProviderService.cancel(args.instanceId); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] cancel failed', { error: message }); + return { success: false, error: message }; + } + }); + + // ── workspace:terminate ────────────────────────────────────────────── + ipcMain.handle( + WORKSPACE_CHANNELS.TERMINATE, + async ( + _, + args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + } + ) => { + try { + await workspaceProviderService.terminate({ + instanceId: args.instanceId, + terminateCommand: args.terminateCommand, + projectPath: args.projectPath, + env: args.env, + }); + return { success: true }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] terminate failed', { error: message }); + return { success: false, error: message }; + } + } + ); + + // ── workspace:status ───────────────────────────────────────────────── + ipcMain.handle(WORKSPACE_CHANNELS.STATUS, async (_, args: { taskId: string }) => { + try { + const instance = await workspaceProviderService.getActiveInstance(args.taskId); + return { success: true, data: instance }; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[workspaceIpc] status failed', { error: message }); + return { success: false, error: message }; + } + }); +} diff --git a/src/main/main.ts b/src/main/main.ts index acb34d5c3f..b5a2593dce 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -119,6 +119,7 @@ import { databaseService, DatabaseSchemaMismatchError } from './services/Databas import { connectionsService } from './services/ConnectionsService'; import { autoUpdateService } from './services/AutoUpdateService'; import { worktreePoolService } from './services/WorktreePoolService'; +import { workspaceProviderService } from './services/WorkspaceProviderService'; import { sshService } from './services/ssh/SshService'; import { taskLifecycleService } from './services/TaskLifecycleService'; import { agentEventService } from './services/AgentEventService'; @@ -314,6 +315,11 @@ app.whenReady().then(async () => { console.warn('Failed to cleanup orphaned reserves:', error); }); + // Reconcile workspace instances from previous sessions (mark stale as error, check connectivity) + workspaceProviderService.reconcileOnStartup().catch((error) => { + console.warn('Failed to reconcile workspace instances:', error); + }); + // Warm provider installation cache try { await connectionsService.initProviderStatusCache(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 1dfc3f1097..540ea01538 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -695,6 +695,44 @@ contextBridge.exposeInMainWorld('electronAPI', { skillsGetDetectedAgents: () => ipcRenderer.invoke('skills:getDetectedAgents'), skillsCreate: (args: { name: string; description: string }) => ipcRenderer.invoke('skills:create', args), + + // Workspace provisioning + workspaceProvision: (args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + }) => ipcRenderer.invoke('workspace:provision', args), + workspaceCancel: (args: { instanceId: string }) => ipcRenderer.invoke('workspace:cancel', args), + workspaceTerminate: (args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + }) => ipcRenderer.invoke('workspace:terminate', args), + workspaceStatus: (args: { taskId: string }) => ipcRenderer.invoke('workspace:status', args), + onWorkspaceProvisionProgress: ( + listener: (data: { instanceId: string; line: string }) => void + ) => { + const channel = 'workspace:provision-progress'; + const wrapped = (_: Electron.IpcRendererEvent, data: { instanceId: string; line: string }) => + listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, + onWorkspaceProvisionComplete: ( + listener: (data: { instanceId: string; status: string; error?: string }) => void + ) => { + const channel = 'workspace:provision-complete'; + const wrapped = ( + _: Electron.IpcRendererEvent, + data: { instanceId: string; status: string; error?: string } + ) => listener(data); + ipcRenderer.on(channel, wrapped); + return () => ipcRenderer.removeListener(channel, wrapped); + }, }); // Type definitions for the exposed API diff --git a/src/main/services/WorkspaceProviderService.ts b/src/main/services/WorkspaceProviderService.ts new file mode 100644 index 0000000000..fd240a6534 --- /dev/null +++ b/src/main/services/WorkspaceProviderService.ts @@ -0,0 +1,465 @@ +import { EventEmitter } from 'node:events'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; +import { log } from '../lib/logger'; +import { getDrizzleClient } from '../db/drizzleClient'; +import { workspaceInstances, sshConnections, type WorkspaceInstanceRow } from '../db/schema'; +import { eq, and, inArray } from 'drizzle-orm'; +import { sshService } from './ssh/SshService'; +import type { SshConfig } from '../../shared/ssh/types'; + +/** Default timeout for provision/terminate scripts (5 minutes). */ +const PROVISION_TIMEOUT_MS = 5 * 60 * 1000; + +/** Default timeout for terminate scripts (2 minutes). */ +const TERMINATE_TIMEOUT_MS = 2 * 60 * 1000; + +/** + * JSON shape returned by the provision script on stdout. + * Only `host` is required. + */ +export interface ProvisionOutput { + id?: string; + host: string; + port?: number; + username?: string; + worktreePath?: string; +} + +export interface ProvisionConfig { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; +} + +export interface TerminateConfig { + instanceId: string; + terminateCommand: string; + projectPath: string; + /** Extra env vars forwarded to the terminate script. */ + env?: Record; +} + +/** + * Manages remote workspace provisioning and termination via user-defined + * shell scripts. Emits events so the renderer can stream progress: + * + * - `provision-progress` { instanceId, line } + * - `provision-complete` { instanceId, status, error? } + */ +export class WorkspaceProviderService extends EventEmitter { + /** In-flight provision processes keyed by instanceId. */ + private provisionProcesses = new Map(); + + // --------------------------------------------------------------------------- + // Provision + // --------------------------------------------------------------------------- + + /** + * Starts provisioning a remote workspace. + * + * 1. Creates a `workspace_instances` row with status `provisioning`. + * 2. Spawns the provision script as a child process. + * 3. Streams stderr lines via `provision-progress` events. + * 4. On success: parses JSON stdout, creates an `ssh_connections` row, + * verifies SSH connectivity, updates the instance to `ready`. + * 5. On failure: updates the instance to `error`. + * + * Returns the instanceId immediately (non-blocking). + */ + async provision(config: ProvisionConfig): Promise { + const instanceId = randomUUID(); + + // Create the DB row before spawning so we can track the attempt. + const { db } = await getDrizzleClient(); + await db.insert(workspaceInstances).values({ + id: instanceId, + taskId: config.taskId, + host: '', // placeholder until script returns + status: 'provisioning', + createdAt: Date.now(), + }); + + // Fire and forget — the caller listens for events. + this.runProvision(instanceId, config).catch((err) => { + log.error('[WorkspaceProvider] Unhandled provision error', { instanceId, error: err }); + }); + + return instanceId; + } + + /** Cancel an in-flight provision by killing the child process. */ + async cancel(instanceId: string): Promise { + const child = this.provisionProcesses.get(instanceId); + if (child) { + child.kill('SIGTERM'); + this.provisionProcesses.delete(instanceId); + } + await this.updateStatus(instanceId, 'error'); + } + + // --------------------------------------------------------------------------- + // Terminate + // --------------------------------------------------------------------------- + + /** + * Runs the terminate script for a workspace instance. + * + * On success: updates the instance to `terminated` and deletes the + * associated `ssh_connections` row. + * On failure: updates the instance to `error` (rows kept for retry). + */ + async terminate(config: TerminateConfig): Promise { + const instance = await this.getInstance(config.instanceId); + if (!instance) { + throw new Error(`Workspace instance ${config.instanceId} not found`); + } + + const envVars: Record = { + EMDASH_INSTANCE_ID: instance.externalId || instance.host, + ...(config.env ?? {}), + }; + + try { + await this.runScript({ + command: config.terminateCommand, + cwd: config.projectPath, + envVars, + timeoutMs: TERMINATE_TIMEOUT_MS, + }); + + // Clean up the SSH connection row if one exists. + if (instance.connectionId) { + const { db } = await getDrizzleClient(); + await db.delete(sshConnections).where(eq(sshConnections.id, instance.connectionId)); + } + + await this.updateStatus(config.instanceId, 'terminated', Date.now()); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + log.error('[WorkspaceProvider] Terminate failed', { + instanceId: config.instanceId, + error: message, + }); + await this.updateStatus(config.instanceId, 'error'); + throw err; + } + } + + // --------------------------------------------------------------------------- + // Queries + // --------------------------------------------------------------------------- + + /** Get a workspace instance by ID. */ + async getInstance(instanceId: string): Promise { + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(workspaceInstances) + .where(eq(workspaceInstances.id, instanceId)) + .limit(1); + return rows[0] ?? null; + } + + /** Get the active workspace instance for a task (provisioning or ready). */ + async getActiveInstance(taskId: string): Promise { + const { db } = await getDrizzleClient(); + const rows = await db + .select() + .from(workspaceInstances) + .where( + and( + eq(workspaceInstances.taskId, taskId), + inArray(workspaceInstances.status, ['provisioning', 'ready']) + ) + ) + .limit(1); + return rows[0] ?? null; + } + + /** Get all workspace instances with a given status. */ + async getInstancesByStatus( + status: 'provisioning' | 'ready' | 'terminated' | 'error' + ): Promise { + const { db } = await getDrizzleClient(); + return db.select().from(workspaceInstances).where(eq(workspaceInstances.status, status)); + } + + // --------------------------------------------------------------------------- + // Reconnection (called on app startup) + // --------------------------------------------------------------------------- + + /** + * On app restart, mark any `provisioning` instances as `error` (the child + * process is dead) and attempt to reconnect `ready` instances. + */ + async reconcileOnStartup(): Promise { + // Mark stale provisioning attempts as errors. + const stale = await this.getInstancesByStatus('provisioning'); + for (const instance of stale) { + log.warn('[WorkspaceProvider] Marking stale provisioning instance as error', { + instanceId: instance.id, + taskId: instance.taskId, + }); + await this.updateStatus(instance.id, 'error'); + } + + // Verify ready instances are still reachable. + const ready = await this.getInstancesByStatus('ready'); + for (const instance of ready) { + if (!instance.connectionId) { + await this.updateStatus(instance.id, 'error'); + continue; + } + const connected = sshService.isConnected(instance.connectionId); + if (!connected) { + log.info('[WorkspaceProvider] Ready instance not connected, will need reconnection', { + instanceId: instance.id, + taskId: instance.taskId, + }); + // Don't mark as error — the UI will show "reconnect" option. + // The SSH connection will be re-established when the user opens the task. + } + } + } + + // --------------------------------------------------------------------------- + // Internal: provision flow + // --------------------------------------------------------------------------- + + private async runProvision(instanceId: string, config: ProvisionConfig): Promise { + const envVars: Record = { + EMDASH_TASK_ID: config.taskId, + EMDASH_REPO_URL: config.repoUrl, + EMDASH_BRANCH: config.branch, + EMDASH_BASE_REF: config.baseRef, + }; + + let stdout = ''; + let stderr = ''; + + try { + const result = await this.runScript({ + command: config.provisionCommand, + cwd: config.projectPath, + envVars, + timeoutMs: PROVISION_TIMEOUT_MS, + onStderr: (line) => { + stderr += line; + this.emit('provision-progress', { instanceId, line }); + }, + onStdout: (data) => { + stdout += data; + }, + trackProcess: (child) => { + this.provisionProcesses.set(instanceId, child); + }, + }); + + // Clean up process tracking. + this.provisionProcesses.delete(instanceId); + + if (result.exitCode !== 0) { + throw new Error( + `Provision script exited with code ${result.exitCode}.\n${stderr.slice(-500)}` + ); + } + + // Parse the JSON output from stdout. + const output = this.parseProvisionOutput(stdout); + + // Create an SSH connection row for this workspace. + const connectionId = await this.createSshConnection(instanceId, output); + + // Update the workspace instance with the real data. + const { db } = await getDrizzleClient(); + await db + .update(workspaceInstances) + .set({ + externalId: output.id ?? null, + host: output.host, + port: output.port ?? 22, + username: output.username ?? null, + worktreePath: output.worktreePath ?? null, + connectionId, + }) + .where(eq(workspaceInstances.id, instanceId)); + + // Verify SSH connectivity before marking as ready. + try { + const sshConfig = this.buildSshConfig(connectionId, output); + await sshService.connect(sshConfig); + } catch (sshErr) { + const msg = sshErr instanceof Error ? sshErr.message : String(sshErr); + throw new Error(`Workspace provisioned but SSH connection failed: ${msg}`); + } + + await this.updateStatus(instanceId, 'ready'); + this.emit('provision-complete', { instanceId, status: 'ready' }); + } catch (err) { + this.provisionProcesses.delete(instanceId); + const message = err instanceof Error ? err.message : String(err); + log.error('[WorkspaceProvider] Provision failed', { instanceId, error: message }); + await this.updateStatus(instanceId, 'error'); + this.emit('provision-complete', { instanceId, status: 'error', error: message }); + } + } + + // --------------------------------------------------------------------------- + // Internal: script runner + // --------------------------------------------------------------------------- + + private runScript(opts: { + command: string; + cwd: string; + envVars: Record; + timeoutMs: number; + onStderr?: (line: string) => void; + onStdout?: (data: string) => void; + trackProcess?: (child: ChildProcess) => void; + }): Promise<{ exitCode: number }> { + return new Promise((resolve, reject) => { + const env = { ...process.env, ...opts.envVars }; + + const child = spawn('bash', ['-c', opts.command], { + cwd: opts.cwd, + env, + stdio: ['ignore', 'pipe', 'pipe'], + }); + + opts.trackProcess?.(child); + + let settled = false; + const finish = (result: { exitCode: number } | Error) => { + if (settled) return; + settled = true; + clearTimeout(timer); + if (result instanceof Error) { + reject(result); + } else { + resolve(result); + } + }; + + const timer = setTimeout(() => { + child.kill('SIGTERM'); + finish(new Error(`Script timed out after ${opts.timeoutMs / 1000}s`)); + }, opts.timeoutMs); + + child.stdout?.on('data', (buf: Buffer) => { + opts.onStdout?.(buf.toString('utf-8')); + }); + + child.stderr?.on('data', (buf: Buffer) => { + const text = buf.toString('utf-8'); + // Emit per-line for the UI. + for (const line of text.split('\n')) { + if (line.trim()) { + opts.onStderr?.(line); + } + } + }); + + child.on('error', (err) => { + finish(new Error(`Failed to spawn script: ${err.message}`)); + }); + + child.on('exit', (code) => { + finish({ exitCode: code ?? -1 }); + }); + }); + } + + // --------------------------------------------------------------------------- + // Internal: helpers + // --------------------------------------------------------------------------- + + private parseProvisionOutput(stdout: string): ProvisionOutput { + const trimmed = stdout.trim(); + if (!trimmed) { + throw new Error('Provision script produced no output on stdout.'); + } + + let parsed: unknown; + try { + parsed = JSON.parse(trimmed); + } catch { + throw new Error( + 'Provision script output is not valid JSON. ' + + 'Ensure all log output goes to stderr (>&2) and only JSON is printed to stdout.' + ); + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + throw new Error('Provision script output must be a JSON object.'); + } + + const obj = parsed as Record; + + if (typeof obj.host !== 'string' || !obj.host.trim()) { + throw new Error( + 'Provision script output must include a "host" field (string). ' + + 'This can be a hostname, IP, or SSH config alias.' + ); + } + + return { + id: typeof obj.id === 'string' ? obj.id : undefined, + host: obj.host.trim(), + port: typeof obj.port === 'number' ? obj.port : undefined, + username: typeof obj.username === 'string' ? obj.username : undefined, + worktreePath: typeof obj.worktreePath === 'string' ? obj.worktreePath : undefined, + }; + } + + private async createSshConnection(instanceId: string, output: ProvisionOutput): Promise { + const connectionId = `workspace-${instanceId}`; + const { db } = await getDrizzleClient(); + const now = new Date().toISOString(); + + await db.insert(sshConnections).values({ + id: connectionId, + name: `workspace-${output.host}`, + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env.USER ?? 'root', + authType: 'agent', + useAgent: 1, + createdAt: now, + updatedAt: now, + }); + + return connectionId; + } + + private buildSshConfig(connectionId: string, output: ProvisionOutput): SshConfig { + return { + id: connectionId, + name: `workspace-${output.host}`, + host: output.host, + port: output.port ?? 22, + username: output.username ?? process.env.USER ?? 'root', + authType: 'agent', + useAgent: true, + }; + } + + private async updateStatus( + instanceId: string, + status: string, + terminatedAt?: number + ): Promise { + const { db } = await getDrizzleClient(); + const set: Record = { status }; + if (terminatedAt !== undefined) { + set.terminatedAt = terminatedAt; + } + await db.update(workspaceInstances).set(set).where(eq(workspaceInstances.id, instanceId)); + } +} + +/** Module-level singleton. */ +export const workspaceProviderService = new WorkspaceProviderService(); diff --git a/src/main/services/ptyIpc.ts b/src/main/services/ptyIpc.ts index 70d8d0f72a..08d47e80cb 100644 --- a/src/main/services/ptyIpc.ts +++ b/src/main/services/ptyIpc.ts @@ -398,8 +398,10 @@ export function registerPtyIpc(): void { listeners.add(id); } - // Resolve tmux config from local project settings - const remoteTmux = cwd ? await resolveTmuxEnabled(cwd) : false; + // Resolve tmux config from local project settings. + // Workspace-provisioned connections always use tmux for session persistence. + const isWorkspaceConnection = remote.connectionId.startsWith('workspace-'); + const remoteTmux = isWorkspaceConnection || (cwd ? await resolveTmuxEnabled(cwd) : false); const remoteTmuxOpt = remoteTmux ? { sessionName: getTmuxSessionName(id) } : undefined; const remoteInit = buildRemoteInitKeystrokes({ cwd, tmux: remoteTmuxOpt }); @@ -833,8 +835,10 @@ export function registerPtyIpc(): void { listeners.add(id); } - // Resolve tmux config from local project settings - const remoteTmux = cwd ? await resolveTmuxEnabled(cwd) : false; + // Resolve tmux config from local project settings. + // Workspace-provisioned connections always use tmux for session persistence. + const isWorkspaceConn = remote.connectionId.startsWith('workspace-'); + const remoteTmux = isWorkspaceConn || (cwd ? await resolveTmuxEnabled(cwd) : false); const tmuxOpt = remoteTmux ? { sessionName: getTmuxSessionName(id) } : undefined; const remoteInit = buildRemoteInitKeystrokes({ diff --git a/src/renderer/types/electron-api.d.ts b/src/renderer/types/electron-api.d.ts index 4d16a88405..2705823048 100644 --- a/src/renderer/types/electron-api.d.ts +++ b/src/renderer/types/electron-api.d.ts @@ -1086,6 +1086,48 @@ declare global { data?: import('@shared/skills/types').CatalogSkill; error?: string; }>; + + // Workspace provisioning + workspaceProvision: (args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + }) => Promise<{ success: boolean; data?: { instanceId: string }; error?: string }>; + workspaceCancel: (args: { + instanceId: string; + }) => Promise<{ success: boolean; error?: string }>; + workspaceTerminate: (args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + }) => Promise<{ success: boolean; error?: string }>; + workspaceStatus: (args: { taskId: string }) => Promise<{ + success: boolean; + data?: { + id: string; + taskId: string; + externalId: string | null; + host: string; + port: number; + username: string | null; + worktreePath: string | null; + status: string; + connectionId: string | null; + createdAt: number; + terminatedAt: number | null; + } | null; + error?: string; + }>; + onWorkspaceProvisionProgress: ( + listener: (data: { instanceId: string; line: string }) => void + ) => () => void; + onWorkspaceProvisionComplete: ( + listener: (data: { instanceId: string; status: string; error?: string }) => void + ) => () => void; }; } } @@ -1652,6 +1694,46 @@ export interface ElectronAPI { data?: import('@shared/skills/types').CatalogSkill; error?: string; }>; + + // Workspace provisioning + workspaceProvision: (args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + }) => Promise<{ success: boolean; data?: { instanceId: string }; error?: string }>; + workspaceCancel: (args: { instanceId: string }) => Promise<{ success: boolean; error?: string }>; + workspaceTerminate: (args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + }) => Promise<{ success: boolean; error?: string }>; + workspaceStatus: (args: { taskId: string }) => Promise<{ + success: boolean; + data?: { + id: string; + taskId: string; + externalId: string | null; + host: string; + port: number; + username: string | null; + worktreePath: string | null; + status: string; + connectionId: string | null; + createdAt: number; + terminatedAt: number | null; + } | null; + error?: string; + }>; + onWorkspaceProvisionProgress: ( + listener: (data: { instanceId: string; line: string }) => void + ) => () => void; + onWorkspaceProvisionComplete: ( + listener: (data: { instanceId: string; status: string; error?: string }) => void + ) => () => void; } import type { TerminalSnapshotPayload } from '#types/terminalSnapshot'; import type { OpenInAppId } from '#shared/openInApps'; diff --git a/src/renderer/types/global.d.ts b/src/renderer/types/global.d.ts index 8414631675..4758fd3406 100644 --- a/src/renderer/types/global.d.ts +++ b/src/renderer/types/global.d.ts @@ -365,6 +365,48 @@ declare global { issues?: any[]; error?: string; }>; + + // Workspace provisioning + workspaceProvision: (args: { + taskId: string; + repoUrl: string; + branch: string; + baseRef: string; + provisionCommand: string; + projectPath: string; + }) => Promise<{ success: boolean; data?: { instanceId: string }; error?: string }>; + workspaceCancel: (args: { + instanceId: string; + }) => Promise<{ success: boolean; error?: string }>; + workspaceTerminate: (args: { + instanceId: string; + terminateCommand: string; + projectPath: string; + env?: Record; + }) => Promise<{ success: boolean; error?: string }>; + workspaceStatus: (args: { taskId: string }) => Promise<{ + success: boolean; + data?: { + id: string; + taskId: string; + externalId: string | null; + host: string; + port: number; + username: string | null; + worktreePath: string | null; + status: string; + connectionId: string | null; + createdAt: number; + terminatedAt: number | null; + } | null; + error?: string; + }>; + onWorkspaceProvisionProgress: ( + listener: (data: { instanceId: string; line: string }) => void + ) => () => void; + onWorkspaceProvisionComplete: ( + listener: (data: { instanceId: string; status: string; error?: string }) => void + ) => () => void; }; } } diff --git a/src/test/main/WorkspaceProviderService.test.ts b/src/test/main/WorkspaceProviderService.test.ts new file mode 100644 index 0000000000..0c745a7547 --- /dev/null +++ b/src/test/main/WorkspaceProviderService.test.ts @@ -0,0 +1,479 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { EventEmitter } from 'node:events'; + +// --------------------------------------------------------------------------- +// Mock child type +// --------------------------------------------------------------------------- +type MockChild = EventEmitter & { + stdout: EventEmitter; + stderr: EventEmitter; + pid: number; + exitCode: number | null; + killed: boolean; + kill: (signal?: NodeJS.Signals) => boolean; +}; + +function createChild(pid = 1234): MockChild { + const child = new EventEmitter() as MockChild; + child.stdout = new EventEmitter(); + child.stderr = new EventEmitter(); + child.pid = pid; + child.exitCode = null; + child.killed = false; + child.kill = () => { + child.killed = true; + return true; + }; + return child; +} + +// --------------------------------------------------------------------------- +// Mocks +// --------------------------------------------------------------------------- +const spawnMock = vi.fn(); + +vi.mock('node:child_process', () => ({ + spawn: (...args: any[]) => spawnMock(...args), +})); + +vi.mock('../../main/lib/logger', () => ({ + log: { + info: vi.fn(), + debug: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, +})); + +// In-memory DB mock +const insertMock = vi.fn(); +const updateMock = vi.fn(); +const selectMock = vi.fn(); +const deleteMock = vi.fn(); + +const mockDb = { + insert: () => ({ + values: (vals: any) => { + insertMock(vals); + return { onConflictDoUpdate: () => ({ returning: () => [vals] }), returning: () => [vals] }; + }, + }), + update: () => ({ + set: (vals: any) => { + updateMock(vals); + return { where: () => Promise.resolve() }; + }, + }), + select: () => ({ + from: () => ({ + where: () => ({ + limit: () => { + const rows = selectMock(); + return Promise.resolve(rows ?? []); + }, + }), + }), + }), + delete: () => ({ + where: () => { + deleteMock(); + return Promise.resolve(); + }, + }), +}; + +vi.mock('../../main/db/drizzleClient', () => ({ + getDrizzleClient: () => Promise.resolve({ db: mockDb }), +})); + +const sshConnectMock = vi.fn().mockResolvedValue('conn-id'); +const sshIsConnectedMock = vi.fn().mockReturnValue(false); + +vi.mock('../../main/services/ssh/SshService', () => ({ + sshService: { + connect: (...args: any[]) => sshConnectMock(...args), + isConnected: (...args: any[]) => sshIsConnectedMock(...args), + }, +})); + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- +describe('WorkspaceProviderService', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + // ------------------------------------------------------------------------- + // provision() + // ------------------------------------------------------------------------- + describe('provision', () => { + it('spawns the provision command with correct env vars and cwd', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const instanceId = await workspaceProviderService.provision({ + taskId: 'task-1', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/fix-bug-abc', + baseRef: 'main', + provisionCommand: './scripts/create-workspace.sh', + projectPath: '/home/user/project', + }); + + expect(instanceId).toBeTruthy(); + + // Give the async runProvision a tick to call spawn + await new Promise((r) => setTimeout(r, 50)); + + expect(spawnMock).toHaveBeenCalledWith( + 'bash', + ['-c', './scripts/create-workspace.sh'], + expect.objectContaining({ + cwd: '/home/user/project', + stdio: ['ignore', 'pipe', 'pipe'], + }) + ); + + // Verify env vars + const spawnEnv = spawnMock.mock.calls[0][2].env; + expect(spawnEnv.EMDASH_TASK_ID).toBe('task-1'); + expect(spawnEnv.EMDASH_REPO_URL).toBe('git@github.com:org/repo.git'); + expect(spawnEnv.EMDASH_BRANCH).toBe('emdash/fix-bug-abc'); + expect(spawnEnv.EMDASH_BASE_REF).toBe('main'); + // Inherits parent env + expect(spawnEnv.PATH).toBeDefined(); + }); + + it('parses valid JSON stdout and emits provision-complete with ready', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-2', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-x', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + // Wait for spawn + await new Promise((r) => setTimeout(r, 50)); + + // Simulate script writing JSON to stdout and exiting + const json = JSON.stringify({ + id: 'ws-42', + host: 'workspace-ws-42', + port: 2222, + username: 'dev', + worktreePath: '/home/dev/workspace', + }); + child.stdout.emit('data', Buffer.from(json)); + child.emit('exit', 0); + + // Wait for async processing + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('ready'); + + // Verify SSH connection was attempted + expect(sshConnectMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'workspace-ws-42', + port: 2222, + username: 'dev', + authType: 'agent', + }) + ); + }); + + it('emits provision-complete with error on invalid JSON', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-3', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-y', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Script writes garbage to stdout + child.stdout.emit('data', Buffer.from('not json at all')); + child.emit('exit', 0); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('error'); + expect(events[0].error).toContain('not valid JSON'); + }); + + it('emits provision-complete with error on non-zero exit code', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-4', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-z', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + child.stderr.emit('data', Buffer.from('Error: auth failed\n')); + child.emit('exit', 1); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('error'); + expect(events[0].error).toContain('exited with code 1'); + }); + + it('streams stderr lines as provision-progress events', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const progressLines: string[] = []; + workspaceProviderService.on('provision-progress', (evt: any) => progressLines.push(evt.line)); + + await workspaceProviderService.provision({ + taskId: 'task-5', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-a', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + child.stderr.emit('data', Buffer.from('[INFO] Provisioning workspace...\n')); + child.stderr.emit('data', Buffer.from('[INFO] Creating DNS record...\n')); + + expect(progressLines).toContain('[INFO] Provisioning workspace...'); + expect(progressLines).toContain('[INFO] Creating DNS record...'); + }); + + it('emits error when JSON is missing required host field', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-6', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-b', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Valid JSON but missing host + child.stdout.emit('data', Buffer.from('{"port": 22, "username": "dev"}')); + child.emit('exit', 0); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('error'); + expect(events[0].error).toContain('"host"'); + }); + + it('works with minimal output (only host)', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-7', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/feat-c', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + child.stdout.emit('data', Buffer.from('{"host": "workspace-daniel-1"}')); + child.emit('exit', 0); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('ready'); + + // Should connect with defaults + expect(sshConnectMock).toHaveBeenCalledWith( + expect.objectContaining({ + host: 'workspace-daniel-1', + port: 22, + authType: 'agent', + }) + ); + }); + }); + + // ------------------------------------------------------------------------- + // cancel() + // ------------------------------------------------------------------------- + describe('cancel', () => { + it('kills the child process and marks instance as error', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const instanceId = await workspaceProviderService.provision({ + taskId: 'task-cancel', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/cancel-test', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + await workspaceProviderService.cancel(instanceId); + + expect(child.killed).toBe(true); + expect(updateMock).toHaveBeenCalledWith(expect.objectContaining({ status: 'error' })); + }); + }); + + // ------------------------------------------------------------------------- + // parseProvisionOutput (tested indirectly via provision) + // ------------------------------------------------------------------------- + describe('parseProvisionOutput edge cases', () => { + it('rejects empty stdout', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-empty', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/empty', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + // Script produces nothing on stdout + child.emit('exit', 0); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('error'); + expect(events[0].error).toContain('no output'); + }); + + it('rejects array output', async () => { + vi.resetModules(); + const child = createChild(); + spawnMock.mockReturnValue(child); + + const { workspaceProviderService } = await import( + '../../main/services/WorkspaceProviderService' + ); + + const events: any[] = []; + workspaceProviderService.on('provision-complete', (evt: any) => events.push(evt)); + + await workspaceProviderService.provision({ + taskId: 'task-array', + repoUrl: 'git@github.com:org/repo.git', + branch: 'emdash/array', + baseRef: 'main', + provisionCommand: './provision.sh', + projectPath: '/project', + }); + + await new Promise((r) => setTimeout(r, 50)); + + child.stdout.emit('data', Buffer.from('[{"host": "x"}]')); + child.emit('exit', 0); + + await new Promise((r) => setTimeout(r, 100)); + + expect(events).toHaveLength(1); + expect(events[0].status).toBe('error'); + expect(events[0].error).toContain('JSON object'); + }); + }); +}); From 7a586dd80f6031213492092644a058a4dd417746 Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:31:07 -0800 Subject: [PATCH 009/489] feat(workspace): wire workspace provider into task lifecycle --- src/renderer/hooks/useTaskManagement.ts | 43 ++++++++++++++++++++- src/renderer/lib/taskCreationService.ts | 51 ++++++++++++++++++++++--- src/renderer/types/chat.ts | 5 +++ 3 files changed, 91 insertions(+), 8 deletions(-) diff --git a/src/renderer/hooks/useTaskManagement.ts b/src/renderer/hooks/useTaskManagement.ts index 5b42131f3f..f7bf5e3536 100644 --- a/src/renderer/hooks/useTaskManagement.ts +++ b/src/renderer/hooks/useTaskManagement.ts @@ -80,6 +80,32 @@ const buildLinkedGithubIssueMap = (tasks?: Task[] | null): Map => { + const workspaceConfig = task.metadata?.workspace; + if (!workspaceConfig?.terminateCommand) return; + + try { + const statusResult = await window.electronAPI.workspaceStatus({ taskId: task.id }); + if (!statusResult.success || !statusResult.data) return; + + const instance = statusResult.data; + if (instance.status === 'terminated') return; + + await window.electronAPI.workspaceTerminate({ + instanceId: instance.id, + terminateCommand: workspaceConfig.terminateCommand, + projectPath: project.path, + }); + } catch (err) { + const { log } = await import('../lib/logger'); + log.warn('Workspace termination failed during task cleanup:', err as any); + } +}; + const cleanupPtyResources = async (task: Task): Promise => { try { const variants = task.metadata?.multiAgent?.variants || []; @@ -402,6 +428,9 @@ export function useTaskManagement() { }) => { await runLifecycleTeardownBestEffort(project, task, 'delete', options); + // Terminate remote workspace if this task used workspace provisioning + await terminateWorkspaceIfNeeded(project, task); + try { const { initialPromptSentKey } = await import('../lib/keys'); try { @@ -593,6 +622,10 @@ export function useTaskManagement() { void cleanupPtyResources(task); await runLifecycleTeardownBestEffort(project, task, 'archive', options); + + // Terminate remote workspace if this task used workspace provisioning + await terminateWorkspaceIfNeeded(project, task); + await rpc.db.archiveTask(task.id); for (const lifecycleTaskId of getLifecycleTaskIds(task)) { @@ -915,7 +948,9 @@ export function useTaskManagement() { autoApprove?: boolean, useWorktree: boolean = true, baseRef?: string, - nameGenerated?: boolean + nameGenerated?: boolean, + useRemoteWorkspace?: boolean, + workspaceProvider?: { provisionCommand: string; terminateCommand: string } ) => { if (!selectedProject) return; setIsCreatingTask(true); @@ -931,6 +966,8 @@ export function useTaskManagement() { nameGenerated, useWorktree, baseRef, + useRemoteWorkspace, + workspaceProvider, }); }, [selectedProject, createTaskMutation] @@ -965,7 +1002,9 @@ export function useTaskManagement() { result.autoApprove, result.useWorktree, result.baseRef, - result.nameGenerated + result.nameGenerated, + result.useRemoteWorkspace, + result.workspaceProvider ), }); }; diff --git a/src/renderer/lib/taskCreationService.ts b/src/renderer/lib/taskCreationService.ts index 0a0fdab008..8661126b66 100644 --- a/src/renderer/lib/taskCreationService.ts +++ b/src/renderer/lib/taskCreationService.ts @@ -18,6 +18,13 @@ export interface CreateTaskParams { nameGenerated?: boolean; useWorktree: boolean; baseRef?: string; + /** When true, provision a remote workspace instead of creating a local worktree. */ + useRemoteWorkspace?: boolean; + /** Workspace provider commands from .emdash.json — required when useRemoteWorkspace is true. */ + workspaceProvider?: { + provisionCommand: string; + terminateCommand: string; + }; } export interface CreateTaskResult { @@ -188,6 +195,8 @@ export async function createTask(params: CreateTaskParams): Promise { + const { log } = await import('./logger'); + log.error(`Workspace provision failed for task "${newTask.name}"`, err as any); + }); + } + // Background: setup, telemetry, issue seeding - void runSetupOnCreate(newTask.id, newTask.path, project.path, newTask.name); + if (!useRemoteWorkspace) { + void runSetupOnCreate(newTask.id, newTask.path, project.path, newTask.name); + } void import('./telemetryClient').then(({ captureTelemetry }) => { captureTelemetry('task_created', { provider: (newTask.agentId as string) || 'codex', diff --git a/src/renderer/types/chat.ts b/src/renderer/types/chat.ts index 6e52fa8776..20bf3f63a0 100644 --- a/src/renderer/types/chat.ts +++ b/src/renderer/types/chat.ts @@ -25,6 +25,11 @@ export interface TaskMetadata { nameGenerated?: boolean | null; /** Set to true after the initial injection (prompt/issue) has been sent to the agent */ initialInjectionSent?: boolean | null; + // When present, the task was created with a remote workspace provider + workspace?: { + provisionCommand: string; + terminateCommand: string; + } | null; // When present, this task is a multi-agent task orchestrating multiple worktrees multiAgent?: { enabled: boolean; From 0c6791ed5a9928e06890642522f22893520420ec Mon Sep 17 00:00:00 2001 From: arnestrickmann <115920878+arnestrickmann@users.noreply.github.com> Date: Wed, 4 Mar 2026 20:32:11 -0800 Subject: [PATCH 010/489] feat(workspace): add workspace provider UI --- src/main/services/ssh/SshService.ts | 2 +- src/renderer/components/ChatInterface.tsx | 32 ++- src/renderer/components/ConfigEditorModal.tsx | 117 ++++++++++- src/renderer/components/MainContentArea.tsx | 5 + src/renderer/components/MultiAgentTask.tsx | 11 +- src/renderer/components/RightSidebar.tsx | 36 ++-- .../components/TaskAdvancedSettings.tsx | 54 ++++- src/renderer/components/TaskModal.tsx | 56 +++++- .../WorkspaceProvisioningOverlay.tsx | 188 ++++++++++++++++++ src/renderer/hooks/useWorkspaceConnection.ts | 66 ++++++ .../main/WorkspaceProviderService.test.ts | 4 +- 11 files changed, 521 insertions(+), 50 deletions(-) create mode 100644 src/renderer/components/WorkspaceProvisioningOverlay.tsx create mode 100644 src/renderer/hooks/useWorkspaceConnection.ts diff --git a/src/main/services/ssh/SshService.ts b/src/main/services/ssh/SshService.ts index fbdade8645..631ba2e438 100644 --- a/src/main/services/ssh/SshService.ts +++ b/src/main/services/ssh/SshService.ts @@ -146,7 +146,7 @@ export class SshService extends EventEmitter { * * ssh2 does not read ~/.ssh/config, so we resolve the host through * sshConfigParser first. This enables SSH aliases (e.g. - * "workspace-daniel-1") to resolve to their actual HostName, Port, + * "my-remote-host") to resolve to their actual HostName, Port, * User, and IdentityFile as defined in the user's SSH config. */ private async buildConnectConfig( diff --git a/src/renderer/components/ChatInterface.tsx b/src/renderer/components/ChatInterface.tsx index 7ded86b616..b4b145e47b 100644 --- a/src/renderer/components/ChatInterface.tsx +++ b/src/renderer/components/ChatInterface.tsx @@ -29,6 +29,7 @@ import { makePtyId } from '@shared/ptyId'; import { generateTaskName } from '../lib/branchNameGenerator'; import { ensureUniqueTaskName } from '../lib/taskNames'; import type { Project } from '../types/app'; +import { useWorkspaceConnection } from '../hooks/useWorkspaceConnection'; declare const window: Window & { electronAPI: { @@ -73,6 +74,10 @@ const ChatInterface: React.FC = ({ const currentAgentStatus = agentStatuses[agent]; const [cliStartError, setCliStartError] = useState(null); + // Workspace-provisioned remote connection overrides + const { connectionId: workspaceConnectionId, remotePath: workspaceRemotePath } = + useWorkspaceConnection(task); + // Multi-chat state const [conversations, setConversations] = useState([]); const [activeConversationId, setActiveConversationId] = useState(null); @@ -111,6 +116,25 @@ const ChatInterface: React.FC = ({ return task.path; }, [task.path]); + // For workspace tasks, use workspace connection; otherwise use project-level connection + const effectiveRemote = useMemo(() => { + if (workspaceConnectionId) { + return { connectionId: workspaceConnectionId }; + } + if (projectRemoteConnectionId) { + return { connectionId: projectRemoteConnectionId }; + } + return undefined; + }, [workspaceConnectionId, projectRemoteConnectionId]); + + // For workspace tasks, use the remote worktree path for cd on the remote machine + const effectiveCwd = useMemo(() => { + if (workspaceConnectionId && workspaceRemotePath) { + return workspaceRemotePath; + } + return terminalCwd; + }, [workspaceConnectionId, workspaceRemotePath, terminalCwd]); + const taskEnv = useMemo(() => { if (!projectPath) return undefined; return getTaskEnvVars({ @@ -1090,12 +1114,8 @@ const ChatInterface: React.FC = ({ & { preservePatterns?: string[]; scripts?: Partial; shellSetup?: string; tmux?: boolean; + workspaceProvider?: WorkspaceProviderConfig; }; interface ConfigEditorModalProps { @@ -107,6 +114,25 @@ function applyTmux(config: ConfigShape, tmux: boolean): ConfigShape { return { ...rest, tmux: true }; } +function applyWorkspaceProvider( + config: ConfigShape, + provisionCommand: string, + terminateCommand: string +): ConfigShape { + const { workspaceProvider: _wp, ...rest } = config; + const provision = provisionCommand.trim(); + const terminate = terminateCommand.trim(); + if (!provision && !terminate) return rest; + return { + ...rest, + workspaceProvider: { + type: 'script' as const, + provisionCommand: provision, + terminateCommand: terminate, + }, + }; +} + export const ConfigEditorModal: React.FC = ({ isOpen, onClose, @@ -123,6 +149,11 @@ export const ConfigEditorModal: React.FC = ({ const [originalShellSetup, setOriginalShellSetup] = useState(''); const [tmux, setTmux] = useState(false); const [originalTmux, setOriginalTmux] = useState(false); + const [wpProvisionCommand, setWpProvisionCommand] = useState(''); + const [originalWpProvisionCommand, setOriginalWpProvisionCommand] = useState(''); + const [wpTerminateCommand, setWpTerminateCommand] = useState(''); + const [originalWpTerminateCommand, setOriginalWpTerminateCommand] = useState(''); + const [isLoading, setIsLoading] = useState(false); const [isSaving, setIsSaving] = useState(false); const [error, setError] = useState(null); @@ -141,9 +172,10 @@ export const ConfigEditorModal: React.FC = ({ const withPatterns = applyPreservePatterns(config, preservePatterns); const withShellSetup = applyShellSetup(withPatterns, shellSetup); const withTmux = applyTmux(withShellSetup, tmux); - const withScripts = applyScripts(withTmux, scripts); + const withWp = applyWorkspaceProvider(withTmux, wpProvisionCommand, wpTerminateCommand); + const withScripts = applyScripts(withWp, scripts); return `${JSON.stringify(withScripts, null, 2)}\n`; - }, [config, preservePatterns, shellSetup, tmux, scripts]); + }, [config, preservePatterns, shellSetup, tmux, wpProvisionCommand, wpTerminateCommand, scripts]); const scriptsDirty = useMemo( () => @@ -152,7 +184,9 @@ export const ConfigEditorModal: React.FC = ({ scripts.teardown !== originalScripts.teardown || preservePatternsInput !== originalPreservePatternsInput || shellSetup !== originalShellSetup || - tmux !== originalTmux, + tmux !== originalTmux || + wpProvisionCommand !== originalWpProvisionCommand || + wpTerminateCommand !== originalWpTerminateCommand, [ originalShellSetup, originalPreservePatternsInput, @@ -160,12 +194,16 @@ export const ConfigEditorModal: React.FC = ({ originalScripts.setup, originalScripts.teardown, originalTmux, + originalWpProvisionCommand, + originalWpTerminateCommand, shellSetup, preservePatternsInput, scripts.run, scripts.setup, scripts.teardown, tmux, + wpProvisionCommand, + wpTerminateCommand, ] ); @@ -199,6 +237,15 @@ export const ConfigEditorModal: React.FC = ({ const nextPreservePatterns = preservePatternsFromConfig(parsed); const nextShellSetup = typeof parsed.shellSetup === 'string' ? parsed.shellSetup : ''; const nextTmux = parsed.tmux === true; + const wp = parsed.workspaceProvider; + const nextWpProvision = + wp && typeof wp === 'object' && typeof wp.provisionCommand === 'string' + ? wp.provisionCommand + : ''; + const nextWpTerminate = + wp && typeof wp === 'object' && typeof wp.terminateCommand === 'string' + ? wp.terminateCommand + : ''; setConfig(parsed); setScripts(nextScripts); setOriginalScripts(nextScripts); @@ -208,6 +255,10 @@ export const ConfigEditorModal: React.FC = ({ setOriginalShellSetup(nextShellSetup); setTmux(nextTmux); setOriginalTmux(nextTmux); + setWpProvisionCommand(nextWpProvision); + setOriginalWpProvisionCommand(nextWpProvision); + setWpTerminateCommand(nextWpTerminate); + setOriginalWpTerminateCommand(nextWpTerminate); } catch (err) { setConfig({}); setScripts({ ...EMPTY_SCRIPTS }); @@ -218,6 +269,10 @@ export const ConfigEditorModal: React.FC = ({ setOriginalShellSetup(''); setTmux(false); setOriginalTmux(false); + setWpProvisionCommand(''); + setOriginalWpProvisionCommand(''); + setWpTerminateCommand(''); + setOriginalWpTerminateCommand(''); setError(err instanceof Error ? err.message : 'Failed to load config'); setLoadFailed(true); } finally { @@ -260,9 +315,13 @@ export const ConfigEditorModal: React.FC = ({ } const nextConfig = applyScripts( - applyTmux( - applyShellSetup(applyPreservePatterns(config, preservePatterns), shellSetup), - tmux + applyWorkspaceProvider( + applyTmux( + applyShellSetup(applyPreservePatterns(config, preservePatterns), shellSetup), + tmux + ), + wpProvisionCommand, + wpTerminateCommand ), scripts ); @@ -271,6 +330,8 @@ export const ConfigEditorModal: React.FC = ({ setOriginalPreservePatternsInput(preservePatternsInput); setOriginalShellSetup(shellSetup); setOriginalTmux(tmux); + setOriginalWpProvisionCommand(wpProvisionCommand); + setOriginalWpTerminateCommand(wpTerminateCommand); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Failed to save config'); @@ -289,6 +350,8 @@ export const ConfigEditorModal: React.FC = ({ projectPath, scripts, tmux, + wpProvisionCommand, + wpTerminateCommand, ]); return ( @@ -369,6 +432,48 @@ export const ConfigEditorModal: React.FC = ({ />
+
+ +

+ Shell commands to provision and tear down remote workspaces. When configured, + tasks can choose between a local worktree and a remote workspace. +

+
+
+ + { + setWpProvisionCommand(event.target.value); + setError(null); + }} + placeholder="./scripts/create-workspace.sh" + className="font-mono text-xs" + disabled={isSaving} + /> +
+
+ + { + setWpTerminateCommand(event.target.value); + setError(null); + }} + placeholder="./scripts/destroy-workspace.sh" + className="font-mono text-xs" + disabled={isSaving} + /> +
+
+
+