diff --git a/packages/ui/src/components/session/SessionSidebar.tsx b/packages/ui/src/components/session/SessionSidebar.tsx index 352153ee..48521e5f 100644 --- a/packages/ui/src/components/session/SessionSidebar.tsx +++ b/packages/ui/src/components/session/SessionSidebar.tsx @@ -28,7 +28,6 @@ import { useShallow } from 'zustand/react/shallow'; import { listProjectWorktrees, partitionWorktreesByRegisteredProject, - subscribeWorktreeTopologyChanged, worktreeMapsEqual, } from '@/lib/worktrees/worktreeManager'; import { checkIsGitRepository } from '@/lib/gitApi'; @@ -324,11 +323,6 @@ const SessionSidebarComponent: React.FC = ({ }); }, [isVSCode]); - React.useEffect(() => { - if (isVSCode) return; - return subscribeWorktreeTopologyChanged(() => requestWorktreeDiscovery()); - }, [isVSCode]); - const isDesktopShellRuntime = React.useMemo(() => isDesktopShell(), []); const { isTablet } = useDeviceInfo(); diff --git a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md index f11da0cc..3fdd2981 100644 --- a/packages/ui/src/components/session/sidebar/DOCUMENTATION.md +++ b/packages/ui/src/components/session/sidebar/DOCUMENTATION.md @@ -67,7 +67,7 @@ make every row observe unrelated streaming updates. - Folder membership may contain both a parent session and its descendants. Rendering treats only the highest assigned ancestors as folder roots because their normal session trees already include assigned descendants; persisted membership remains unchanged for cleanup and move semantics. - Sidebar selection holds the clicked row's viewport position across navigation-driven sidebar updates. Wheel or touch input cancels the hold immediately, so programmatic compensation never fights intentional scrolling. - Global session subscriptions are structural: create/delete, title, share, archive, directory, parent, and slug changes invalidate the tree. Recency-only `time.updated` changes do not trigger a rebuild. The separate lifecycle rank invalidates ordering only on `settled ↔ active` transitions, with root sessions ranked among roots and child sessions only among siblings of the same parent. -- A worktree git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Dropping it would hide every session that lived there, and a hidden session cannot be opened, so it could never be relocated. Opening one of those sessions relocates it to the project root (`recoverMissingSessionDirectory`), and the empty group is removed through the ordinary worktree delete action, which `git worktree remove --force` accepts for a missing directory. Topology refresh stays event-driven: besides `session-created`, the sidebar rediscovers on `subscribeWorktreeTopologyChanged`, which the relocation raises after the server confirmed a directory missing. No idle polling is added. +- A worktree Git still registers but whose directory is gone (`prunable` in `git worktree list`) stays in the topology with `worktreeStatus: 'missing'` and a warning icon on its group header. Its sessions remain accessible for manual movement or archiving through worktree deletion. Opening a session does not move it. The ordinary worktree delete action accepts a missing directory. Topology discovery remains event-driven, including `session-created`, with no idle polling. - Opening the root-session `Move to worktree` submenu force-refreshes the owning project's worktree topology so externally created worktrees appear without a full reload. While that refresh runs, the menu keeps the last known primary/linked topology visible; if the refresh fails, the stale topology remains and the load failure state stays explicit. Failure cleanup never removes or manages an existing destination worktree. - CLI/server-created sessions use the low-frequency OpenChamber control event stream to refresh only the created session directory. The same event retriggers bounded worktree discovery so a newly created external worktree gains ownership without a view reload; it does not re-enable broad session or streaming subscriptions. - Recent membership includes active root sessions immediately even when their last committed `time.updated` falls outside the 48-hour window. Children and archived sessions remain excluded, and inactive roots remain timestamp-based. The active-ID subscription is disabled while the sidebar is hidden and ignores retry/status detail changes, avoiding streaming-frequency rerenders. diff --git a/packages/ui/src/components/views/TerminalView.tsx b/packages/ui/src/components/views/TerminalView.tsx index 056d3731..7f27db70 100644 --- a/packages/ui/src/components/views/TerminalView.tsx +++ b/packages/ui/src/components/views/TerminalView.tsx @@ -17,7 +17,7 @@ import { Icon } from "@/components/icon/Icon"; import type { IconName } from '@/components/icon/icons'; import { useDeviceInfo } from '@/lib/device'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; -import { isTerminalCwdMissingError, terminalSnapshotSize } from '@/lib/terminalApi'; +import { terminalSnapshotSize } from '@/lib/terminalApi'; import { extractTerminalPreviewUrl, isTerminalPreviewUrlAvailable } from '@/lib/terminalPreview'; import { useI18n } from '@/lib/i18n'; import { PROJECT_ACTION_ICONS } from '@/lib/projectActions'; @@ -40,14 +40,6 @@ const resolveTabIconName = (iconKey: string | null): IconName => { export const TerminalView: React.FC = ({ visible, directory }) => { const { t } = useI18n(); const { terminal, runtime } = useRuntimeAPIs(); - // The server rejects a working directory that no longer exists (a worktree - // deleted outside OpenChamber). The session is what is stranded, not the - // terminal: relocating it to its project changes the effective directory, - // and this view then starts a terminal there on its own. - const recoverCurrentSessionDirectory = React.useCallback(() => { - const sessionId = useSessionUIStore.getState().currentSessionId; - if (sessionId) void useSessionUIStore.getState().recoverMissingSessionDirectory(sessionId); - }, []); const { currentTheme } = useThemeSystem(); const terminalAppearanceRef = React.useRef<{ themeMode: 'light' | 'dark'; terminalBackground: string; terminalForeground: string }>({ themeMode: 'dark', terminalBackground: '', terminalForeground: '' }); terminalAppearanceRef.current = { themeMode: currentTheme.metadata.variant === 'light' ? 'light' : 'dark', terminalBackground: currentTheme.colors.surface.background, terminalForeground: currentTheme.colors.syntax.base.foreground }; @@ -546,7 +538,6 @@ export const TerminalView: React.FC = ({ visible, directory } // this tab stopped owning the request; use current store // ownership so a rejected create cannot leave it spinning. if (directoryRef.current !== directory || activeTabIdRef.current !== tabId) return; - if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory(); setConnectionError( error instanceof Error ? error.message @@ -590,7 +581,6 @@ export const TerminalView: React.FC = ({ visible, directory } setTabSessionId, startStream, disconnectStream, - recoverCurrentSessionDirectory, t, terminal, terminalLoginShell, @@ -654,7 +644,6 @@ export const TerminalView: React.FC = ({ visible, directory } || directoryRef.current !== terminalDirectory || activeTabIdRef.current !== tabId ) return; - if (isTerminalCwdMissingError(error)) recoverCurrentSessionDirectory(); setConnectionError( error instanceof Error ? error.message : t('terminalView.error.restartFailed') ); @@ -665,7 +654,7 @@ export const TerminalView: React.FC = ({ visible, directory } } finally { setIsRestarting(false); } - }, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, recoverCurrentSessionDirectory, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); + }, [activeTabId, disconnectStream, terminalDirectory, enableTabs, isActionTab, isRestarting, resetTerminalPreviewScan, setTabLifecycle, setTabSessionId, startStream, t, terminal, terminalLoginShell, terminalShell]); const handleHardRestart = React.useCallback(async () => { // Keep semantics: “close tab -> new clean tab”. diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index b208adc3..1a9745b1 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -609,7 +609,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Angefügter Worktree archiviert.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Angefügte Worktrees archiviert.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archivierte Worktrees und entfernte Remote-Branches.', - 'sessions.missingDirectory.movedToProject': 'Der Ordner dieser Sitzung existiert nicht mehr. Die Sitzung wurde nach {project} verschoben.', 'sessions.sidebar.group.worktreeMissing': 'Worktree-Ordner fehlt', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree-Pfad nicht verfügbar.', diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index f8ba5012..5438f330 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -705,7 +705,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Attached worktree archived.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Attached worktrees archived.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Archived worktrees and removed remote branches.', - 'sessions.missingDirectory.movedToProject': 'This session\'s folder no longer exists. The session was moved to {project}.', 'sessions.sidebar.group.worktreeMissing': 'Worktree folder is missing', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree path unavailable.', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index 8b9da925..9c088b3f 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados y ramas remotas eliminadas.", - "sessions.missingDirectory.movedToProject": "La carpeta de esta sesión ya no existe. La sesión se movió a {project}.", "sessions.sidebar.group.worktreeMissing": "Falta la carpeta del worktree", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Ruta de worktree no disponible.", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 5964867f..c7640993 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -534,7 +534,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Worktree ci-joint archivé.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Worktrees joints archivés.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktrees archivés et branches du dépôt distant supprimées.', - 'sessions.missingDirectory.movedToProject': 'Le dossier de cette session n\'existe plus. La session a été déplacée vers {project}.', 'sessions.sidebar.group.worktreeMissing': 'Le dossier du worktree est introuvable', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Chemin du worktree indisponible.', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 1f6ea593..b9a40728 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '添付のワークツリーをアーカイブしました。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'ワークツリーをアーカイブし、リモートブランチを削除しました。', - 'sessions.missingDirectory.movedToProject': 'このセッションのフォルダーは存在しません。セッションを {project} に移動しました。', 'sessions.sidebar.group.worktreeMissing': 'ワークツリーのフォルダーがありません', 'sessions.sidebar.sessionDialogs.worktree.label': 'ワークツリー', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'ワークツリーパスは利用できません。', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 0a3f7aa8..556fa085 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '첨부됨 워크트리 보관됨.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '워크트리가 보관되고 리모트 브랜치가 제거되었습니다.', - 'sessions.missingDirectory.movedToProject': '이 세션의 폴더가 더 이상 존재하지 않습니다. 세션을 {project}(으)로 이동했습니다.', 'sessions.sidebar.group.worktreeMissing': '워크트리 폴더가 없습니다', 'sessions.sidebar.sessionDialogs.worktree.label': '워크트리', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '워크트리 경로를 사용할 수 없습니다.', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 8c2d23bf..daaff795 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Dołączone drzewo pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Dołączone drzewa pracy zarchiwizowane.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Zarchiwizowane drzewa pracy i usunięte zdalne gałęzie.', - 'sessions.missingDirectory.movedToProject': 'Folder tej sesji już nie istnieje. Sesja została przeniesiona do {project}.', 'sessions.sidebar.group.worktreeMissing': 'Brak folderu worktree', 'sessions.sidebar.sessionDialogs.worktree.label': 'Drzewo pracy', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Ścieżka drzewa pracy niedostępna.', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 50352759..7d090112 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Worktree adjunto archivado.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Worktrees adjuntos archivados.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktrees archivados e branches remotas excluídas.", - "sessions.missingDirectory.movedToProject": "A pasta desta sessão não existe mais. A sessão foi movida para {project}.", "sessions.sidebar.group.worktreeMissing": "A pasta do worktree está ausente", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Caminho de worktree não disponível.", diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 63b5232c..3cddde5d 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -687,7 +687,6 @@ export const dict = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': 'Bağlı worktree arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': 'Bağlı worktree\'ler arşivlendi.', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'Worktree\'ler arşivlendi ve uzak branch\'ler kaldırıldı.', - 'sessions.missingDirectory.movedToProject': 'Bu oturumun klasörü artık mevcut değil. Oturum {project} konumuna taşındı.', 'sessions.sidebar.group.worktreeMissing': 'Worktree klasörü eksik', 'sessions.sidebar.sessionDialogs.worktree.label': 'Worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'Worktree yolu kullanılamıyor.', diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 7b1090c2..2602f96b 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -706,7 +706,6 @@ export const dict: Record = { "sessions.sidebar.sessionDialogs.worktree.attachedArchived": "Прикріплене worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural": "Прикріплені worktree заархівовано.", "sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved": "Worktree заархівовано, віддалені гілки видалено.", - "sessions.missingDirectory.movedToProject": "Теки цієї сесії більше не існує. Сесію перенесено до {project}.", "sessions.sidebar.group.worktreeMissing": "Теки worktree немає", "sessions.sidebar.sessionDialogs.worktree.label": "Worktree", "sessions.sidebar.sessionDialogs.worktree.pathUnavailable": "Шлях worktree недоступний.", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index ee593026..2b5ba5d3 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -706,7 +706,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '关联工作树已归档。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': '工作树已归档且远程分支已移除。', - 'sessions.missingDirectory.movedToProject': '此会话的文件夹已不存在。会话已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作树文件夹缺失', 'sessions.sidebar.sessionDialogs.worktree.label': '工作树', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': '工作树路径不可用。', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 687d520e..81fd0ae1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -719,7 +719,6 @@ export const dict: Record = { 'sessions.sidebar.sessionDialogs.worktree.attachedArchived': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.attachedArchivedPlural': '關聯 worktree 已封存。', 'sessions.sidebar.sessionDialogs.worktree.archivedAndRemoteRemoved': 'worktree 已封存且遠端分支已移除。', - 'sessions.missingDirectory.movedToProject': '此工作階段的資料夾已不存在。工作階段已移至 {project}。', 'sessions.sidebar.group.worktreeMissing': '工作樹資料夾遺失', 'sessions.sidebar.sessionDialogs.worktree.label': 'worktree', 'sessions.sidebar.sessionDialogs.worktree.pathUnavailable': 'worktree 路徑無法使用。', diff --git a/packages/ui/src/lib/opencode/client.test.ts b/packages/ui/src/lib/opencode/client.test.ts index 2be89a52..c6c8406c 100644 --- a/packages/ui/src/lib/opencode/client.test.ts +++ b/packages/ui/src/lib/opencode/client.test.ts @@ -99,16 +99,16 @@ beforeEach(() => { }); describe('opencodeClient directory availability', () => { - type ProbeBody = { error?: string; reason?: string; entries?: never[] }; + type ProbeBody = { error: string; reason?: string } | { isDirectory: boolean } | { isFile: boolean; size: number }; const json = (status: number, body: ProbeBody): Response => new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' }, }); test('stats the directory through the OpenChamber filesystem route, never through OpenCode path resolution', async () => { - runtimeFetchResults.push(json(200, { entries: [] })); + runtimeFetchResults.push(json(200, { isDirectory: true })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('available'); - expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/list', query: { path: '/private/deleted-worktree' } }]); + expect(runtimeFetchCalls).toEqual([{ path: '/api/fs/directory-stat', query: { path: '/private/deleted-worktree' } }]); expect(pathGetCalls).toBe(0); }); @@ -119,10 +119,19 @@ describe('opencodeClient directory availability', () => { runtimeFetchResults.push(json(400, { error: 'Specified path is not a directory', reason: 'not-directory' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('missing'); + runtimeFetchResults.push(json(200, { isFile: true, size: 12 })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + runtimeFetchResults.push(json(404, { error: 'Not Found' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); - runtimeFetchResults.push(json(500, { error: 'Failed to list directory' })); + runtimeFetchResults.push(json(500, { error: 'Failed to stat path' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(403, { error: 'Access to directory denied', reason: 'os-permission' })); + expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); + + runtimeFetchResults.push(json(501, { error: 'Unsupported' })); expect(await opencodeClient.getDirectoryAvailability('/private/deleted-worktree')).toBe('unknown'); runtimeFetchResults.push(new Error('offline')); diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 410e9165..c4480b3c 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -71,7 +71,7 @@ type SdkResult = { }; type DirectoryAvailability = "available" | "missing" | "unknown"; -const directoryProbeErrorSchema = z.object({ reason: z.string().optional() }); +const directoryProbeErrorSchema = z.object({ reason: z.string().optional(), isDirectory: z.boolean().optional() }); function unwrapSdkData(result: SdkResult, operation: string): T { @@ -597,25 +597,26 @@ class OpencodeService { } /** - * Distinguishes a confirmed-missing directory from an unavailable probe. - * Offline, permission, and other transport failures stay `unknown` so callers - * do not treat a temporary outage as proof the path was deleted. - * - * The probe is OpenChamber's own `/api/fs/list`, which stats the path on the - * server's disk. OpenCode's `/path` cannot answer this question: it echoes - * the requested directory and resolves its project through Git discovery - * that swallows errors, so a deleted worktree still comes back as a valid - * location. A runtime without that route (VS Code) answers `unknown`. - */ + * Distinguishes a confirmed-missing directory from an unavailable probe. + * Offline, permission, and other transport failures stay `unknown` so callers + * do not treat a temporary outage as proof the path was deleted. + * + * The probe is OpenChamber's own `/api/fs/directory-stat`, which asks the + * server to stat the path without listing its contents. OpenCode's `/path` + * cannot answer this question: it echoes the requested directory and resolves + * its project through Git discovery that swallows errors, so a deleted worktree + * still comes back as a valid location. A runtime without that route (VS Code) + * answers `unknown`. + */ async getDirectoryAvailability(directory: string): Promise { const normalized = this.normalizeCandidatePath(directory); if (!normalized) { return "unknown"; } try { - const response = await runtimeFetch("/api/fs/list", { query: { path: normalized } }); - if (response.ok) return "available"; + const response = await runtimeFetch("/api/fs/directory-stat", { query: { path: normalized } }); const body = directoryProbeErrorSchema.safeParse(await response.json().catch(() => null)).data; + if (response.ok && body?.isDirectory === true) return "available"; const reason = parseFilesystemErrorReason(body?.reason); return reason === "not-found" || reason === "not-directory" ? "missing" : "unknown"; } catch { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index aab8f46e..14c10c11 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -117,10 +117,8 @@ const { createWorktree, getLatestWorktreeMetadata, listProjectWorktrees, - notifyWorktreeTopologyChanged, partitionWorktreesByRegisteredProject, removeProjectWorktree, - subscribeWorktreeTopologyChanged, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -679,21 +677,4 @@ describe('worktreeManager missing worktrees', () => { expect(worktreeMapsEqual(new Map([['/repo', [ready]]]), new Map([['/repo', [missing]]]))).toBe(false); }); - test('a topology-changed signal drops the cached listing and reaches subscribers', async () => { - const project = { id: 'project-signal', path: '/repo-signal/' }; - listImplementation = async () => []; - await listProjectWorktrees(project, { force: true }); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal']); - - const notified: string[] = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => notified.push(directory)); - notifyWorktreeTopologyChanged('/repo-signal/'); - unsubscribe(); - notifyWorktreeTopologyChanged('/repo-signal'); - - expect(notified).toEqual(['/repo-signal']); - await listProjectWorktrees(project); - expect(listCalls).toEqual(['/repo-signal', '/repo-signal']); - }); }); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index 99caa339..f3c22d80 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -400,29 +400,6 @@ const invalidateWorktreeList = (projectDirectory: string): void => { _worktreeListCache.delete(projectDirectory); }; -type WorktreeTopologyListener = (projectDirectory: string) => void; -const worktreeTopologyListeners = new Set(); - -/** - * Subscribe to in-app evidence that a project's worktree topology changed - * outside the flows that publish it themselves (a session relocated out of a - * directory the server confirmed missing). The sidebar rediscovers on this - * signal the same way it does for the server's `session-created` event, so - * the topology stays event-driven with no idle polling. - */ -export const subscribeWorktreeTopologyChanged = (listener: WorktreeTopologyListener): (() => void) => { - worktreeTopologyListeners.add(listener); - return () => { - worktreeTopologyListeners.delete(listener); - }; -}; - -export const notifyWorktreeTopologyChanged = (projectDirectory: string): void => { - const normalized = normalizePath(projectDirectory); - invalidateWorktreeList(normalized); - for (const listener of worktreeTopologyListeners) listener(normalized); -}; - const readProjectWorktrees = async (projectDirectory: string): Promise => { const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory); const normalizedProjectDirectory = normalizePath(projectDirectory); diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index e2b03d13..d5c50cad 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -288,7 +288,7 @@ Rules: 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. 5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration, resolved text, attachments, and attached context at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. -7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. +7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenChamber's directory stat reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. 9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`. 10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. @@ -341,25 +341,9 @@ feedback stays truthful. Callers whose confirmation can span a runtime switch may pass an `expectedRuntimeKey` captured earlier; ordinary callers are guarded by default. -When the session being restored belongs to a worktree that no longer exists, -writing `time.archived = 0` alone would leave it grouped under a directory the -sidebar can never surface. Restore therefore probes the session's owned -directory with `getDirectoryAvailability` and, only on an exact `missing` -result, relocates it: it resolves the owning OpenCode project's primary -directory by the session's server `projectID` (from `project.list()`, never a -local project ID or the active project), then unarchives and moves the whole -subtree still stranded in the missing directory to that project directory -through `moveSessionToDirectory(..., false)`. `available`, `unknown`, an -availability probe failure, a missing project record, and non-worktree sessions -keep the plain restore path. The subtree is drawn from the global cache so -archived descendants that never materialized in a live child store are still -relocated, and a node is kept while it is archived **or** still owns the -missing directory, so a retry after a partial restore (root already unarchived -but not yet moved) completes the move instead of reporting a false success. -`moveSessionToDirectory` accepts the captured `expectedRuntimeKey` and skips all -local store/routing publication when the runtime changed during the -control-plane request, so the server move can complete without seeding the new -runtime with stale directory state. +`unarchiveSession` clears the archive timestamp in the session's existing directory. It never moves the session, including when that directory is missing. Server failure keeps the session archived locally; confirmation updates the global cache. `unarchiveSessions` preserves partial results and stops committing when its captured runtime changes. + +### Deletion runtime guard Deletion needs this guard more than archiving does. Session IDs are not unique across runtimes, and a committed deletion does more than hide a row: it evicts @@ -383,30 +367,9 @@ reports failure instead of committing. The deletion already accepted by the server stays deleted there; its persisted state is left as harmless stale metadata and the next authoritative load reconciles it. -### Missing directory relocation (active sessions) +### Missing worktree directories -The same directory can disappear under an active session: a worktree removed -by the agent or by hand leaves the session, its tabs, and its prompts pointed -at a path that no longer exists, and the terminal server answers every create -and restart with `Invalid working directory`. `relocateSessionFromMissingDirectory` -(`session-actions.ts`) applies the restore fallback's gate to a live session: -an exact `missing` probe, the destination resolved from the server `projectID`, -and `available`, `unknown`, probe failures, project-root sessions, and sessions -without a project left untouched. Every session of the root's subtree still -stranded in that directory moves with it, root first, so the session the user -is looking at is usable even when a descendant move fails; the result names -the sessions already moved. Moves carry no changes because the source is gone. - -`session-ui-store.recoverMissingSessionDirectory` owns the user-visible side: -one shared attempt per runtime and session, the worktree hint cleared for each -moved session (it is the first thing every directory lookup reads), the current -session re-selected through `setCurrentSession` so the active directory, -project, and OpenCode client follow it, and one toast naming the destination. -It runs from two places: a terminal create/restart rejected with the server's -`TERMINAL_CWD_MISSING` code, and session activation for any session whose -directory is neither a registered project root nor a managed chat directory -(the same probe a reopened draft performs on its inherited directory). VS Code -registers no worktrees, so activation never probes there. +Existing sessions keep their directory when a worktree disappears. Session activation makes no directory-availability probe, and terminal failures and archive restoration never move sessions. Manual movement still goes through `moveSessionToDirectory`. Worktree deletion still archives its sessions before removing the worktree. Missing-worktree groups stay visible with a warning so users can choose either action. ## The golden rule diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index 763b815d..46ca3c33 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -309,7 +309,6 @@ mock.module("../session-actions", () => ({ unrevertSession: mock(async () => undefined), forkFromMessage: mock(async () => undefined), fetchMessagesForSession: mock(async () => undefined), - relocateSessionFromMissingDirectory: mock(async () => ({ status: "unchanged" })), getSessionLastAssistantModel: () => null, patchSessionMetadata: mock(async () => undefined), abortCurrentOperation: mock(async () => undefined), diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index a68f5e81..c3080348 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -1112,231 +1112,34 @@ describe("session restore (unarchive)", () => { expect((globalUpsertedSessions[0] as SessionWithDirectory).directory).toBe(worktreeDirectory) }) - test("moves a restored missing-worktree subtree to its matching project directory without changing descendants or cached transcript state", async () => { + test("restores a missing-worktree session in place without relocating it", async () => { const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootMessage = { - id: "message-root", - sessionID: "session-root", - role: "user", - time: { created: 10 }, - } as Message - const rootPart = { id: "part-root", messageID: rootMessage.id, type: "text", text: "root" } as Part - const childMessage = { - id: "message-child", - sessionID: "session-child", - role: "assistant", - time: { created: 11 }, - } as Message - const childPart = { id: "part-child", messageID: childMessage.id, type: "text", text: "child" } as Part - const rootSession = { - id: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: "session-root", - projectID: "project-main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "project-main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { - ...rootSession, - time: { created: 1, updated: 1, archived: 0 }, - }) - sessionUpdateResultsById.set("session-child", { - ...childSession, - time: { created: 2, updated: 2, archived: 0 }, - }) - - const source = createStore({}, { - session: [rootSession, childSession], - sessionTotal: 2, - message: { - "session-root": [rootMessage], - "session-child": [childMessage], - }, - part: { - [rootMessage.id]: [rootPart], - [childMessage.id]: [childPart], - }, - }) - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[missingWorktreeDirectory, source], [destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-child", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, - ]) - expect(source.getState().session).toEqual([]) - expect(destination.getState().session.map((session) => ({ - id: session.id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - expect(destination.getState().message["session-root"]?.[0]?.id).toBe(rootMessage.id) - expect(destination.getState().message["session-child"]?.[0]?.id).toBe(childMessage.id) - expect(destination.getState().part[rootMessage.id]?.[0]?.id).toBe(rootPart.id) - expect(destination.getState().part[childMessage.id]?.[0]?.id).toBe(childPart.id) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - expect(registeredSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "session-root", directory: destinationDirectory }, - { sessionID: "session-child", directory: destinationDirectory }, - ]) - expect(globalUpsertedSessions.map((session) => ({ - id: (session as SessionWithDirectory).id, - parentID: (session as SessionWithDirectory).parentID ?? null, - directory: (session as SessionWithDirectory).directory ?? null, - }))).toEqual([ - { id: "session-root", parentID: null, directory: destinationDirectory }, - { id: "session-child", parentID: "session-root", directory: destinationDirectory }, - ]) - }) - - test("restores missing-worktree descendants from the global cache when their directory store is unavailable", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - const rootSession = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 2 }, - } as SessionWithDirectory - const childSession = { - id: "session-child", - parentID: rootSession.id, - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 2, archived: 3 }, - } as SessionWithDirectory - globalArchivedSessions.push(rootSession, childSession) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...rootSession, time: { created: 1, updated: 1, archived: 0 } }) - sessionUpdateResultsById.set("session-child", { ...childSession, time: { created: 2, updated: 2, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(rootSession.id)).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession").map((call) => call.params.sessionID)) - .toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.map((session) => session.id)).toEqual([rootSession.id, childSession.id]) - expect(destination.getState().session.every((session) => !session.time?.archived)).toBe(true) - }) - - test("does not publish a missing-worktree move after the runtime changes during the control-plane request", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" const session = { - id: "session-runtime-switch", - projectID: "proj_main", + id: "session-root", + projectID: "project-main", directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, + project: { worktree: "/projects/main" }, time: { created: 1, archived: 2 }, } as SessionWithDirectory globalArchivedSessions.push(session) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set(session.id, { ...session, time: { created: 1, updated: 1, archived: 0 } }) - beforeControlPlaneMoveResolve = () => { - runtimeKey = "new-runtime" - } + sessionUpdateResultsById.set("session-root", { + ...session, + time: { created: 1, updated: 1, archived: 0 }, + }) - const destination = createStore({}) + const store = createStore({}) const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) - - expect(await unarchiveSession(session.id)).toBe(false) - expect(destination.getState().session).toEqual([]) - expect(registeredSessionDirectories).toEqual([]) - expect(globalUpsertedSessions).toEqual([]) - }) - - test("re-moves a root left stranded in a missing worktree after a partial restore", async () => { - const missingWorktreeDirectory = "/projects/main/.worktrees/deleted-branch" - const destinationDirectory = "/projects/main" - // A previous restore attempt already unarchived the root (server echo made - // it active), then the control-plane move failed, leaving it stranded in the - // deleted worktree. The retry must still relocate it, not report a false - // success because the root is no longer archived. - const strandedRoot = { - id: "session-root", - projectID: "proj_main", - directory: missingWorktreeDirectory, - project: { worktree: destinationDirectory }, - time: { created: 1, archived: 0 }, - } as SessionWithDirectory - globalActiveSessions.push(strandedRoot) - openCodeProjects.push({ id: "proj_main", worktree: destinationDirectory } as Project) - directoryAvailability.set(missingWorktreeDirectory, "missing") - sessionUpdateResultsById.set("session-root", { ...strandedRoot, time: { created: 1, updated: 1, archived: 0 } }) - - const destination = createStore({}) - const { unarchiveSession, setActionRefs } = await import("./session-actions") - setActionRefs( - mockSdk as unknown as OpencodeClient, - createChildStores([[destinationDirectory, destination]]), - () => missingWorktreeDirectory, - ) + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([[missingWorktreeDirectory, store]]), () => missingWorktreeDirectory) expect(await unarchiveSession("session-root")).toBe(true) - expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([ - { - method: "controlPlane.moveSession", - params: { - sessionID: "session-root", - destination: { directory: destinationDirectory }, - moveChanges: false, - }, - }, + expect(replyCalls.filter((call) => call.method === "controlPlane.moveSession")).toEqual([]) + expect(store.getState().session).toEqual([]) + expect(registeredSessionDirectories).toEqual([{ sessionID: "session-root", directory: missingWorktreeDirectory }]) + expect(movedSessionDirectories).toEqual([]) + expect(globalUpsertedSessions).toEqual([ + { ...session, time: { created: 1, updated: 1, archived: 0 } }, ]) - expect(destination.getState().session.map((session) => session.id)).toEqual(["session-root"]) }) test("does not move a restored project session that is not a worktree", async () => { @@ -3019,148 +2822,3 @@ describe("dismissOpenPermissionsForSession", () => { } }) }) - -describe("relocateSessionFromMissingDirectory", () => { - const missingWorktree = "/projects/main/.worktrees/gone" - const projectDirectory = "/projects/main" - const worktreeSession = (id: string, parentID: string | null, directory = missingWorktree, archived = 0): Session & { project: { worktree: string } } => ({ - id, - slug: id, - projectID: "project-main", - directory, - title: id, - version: "1", - project: { worktree: projectDirectory }, - time: { created: 1, updated: 1, archived }, - parentID: parentID ?? undefined, - }) - const mainProject: Project = { id: "project-main", worktree: projectDirectory, time: { created: 1, updated: 1 }, sandboxes: [] } - const stores = () => createChildStores([[missingWorktree, createStore({})], [projectDirectory, createStore({})]]) - const movesOf = () => replyCalls - .filter((call) => call.method === "controlPlane.moveSession") - .map((call) => ({ sessionID: call.params.sessionID, destination: call.params.destination, moveChanges: call.params.moveChanges })) - - beforeEach(() => { - replyCalls.length = 0 - registeredSessionDirectories.length = 0 - movedSessionDirectories.length = 0 - globalUpsertedSessions.length = 0 - globalActiveSessions = [] - globalArchivedSessions.length = 0 - openCodeProjects.length = 0 - directoryAvailability.clear() - controlPlaneMoveErrorsById.clear() - beforeDirectoryAvailabilityResolve = null - runtimeKey = "default-runtime" - }) - - test("moves the whole stranded subtree, root first, to the project directory without carrying changes", async () => { - const root = worktreeSession("root", null) - const child = worktreeSession("child", "root") - const archivedChild = worktreeSession("archived-child", "root", missingWorktree, 42) - const elsewhere = worktreeSession("elsewhere", "root", projectDirectory) - globalActiveSessions = [root, child, elsewhere] - globalArchivedSessions.push(archivedChild) - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result).toEqual({ - status: "moved", - sourceDirectory: missingWorktree, - destinationDirectory: projectDirectory, - movedSessionIds: ["root", "child", "archived-child"], - }) - expect(movesOf()).toEqual([ - { sessionID: "root", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "child", destination: { directory: projectDirectory }, moveChanges: false }, - { sessionID: "archived-child", destination: { directory: projectDirectory }, moveChanges: false }, - ]) - expect(movedSessionDirectories).toEqual([ - { sessionID: "root", directory: projectDirectory }, - { sessionID: "child", directory: projectDirectory }, - { sessionID: "archived-child", directory: projectDirectory }, - ]) - }) - - for (const availability of ["available", "unknown"] as const) { - test(`leaves the session alone when its directory is ${availability}`, async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, availability) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - } - - test("leaves a session that already lives in its project directory alone", async () => { - globalActiveSessions = [worktreeSession("root", null, projectDirectory)] - openCodeProjects.push(mainProject) - directoryAvailability.set(projectDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => projectDirectory) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("never relocates to the filesystem root OpenCode reports for its global project", async () => { - const chatDirectory = "/Users/tester/.config/openchamber/chats/2026-09-05/session-gone" - const chat = { ...worktreeSession("chat", null, chatDirectory), projectID: "global", project: { worktree: "/" } } - globalActiveSessions = [chat] - openCodeProjects.push({ id: "global", worktree: "/", time: { created: 1, updated: 1 }, sandboxes: [] }) - directoryAvailability.set(chatDirectory, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => chatDirectory) - - expect(await relocateSessionFromMissingDirectory("chat")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("leaves the session alone when OpenCode knows no project for it", async () => { - globalActiveSessions = [worktreeSession("root", null)] - directoryAvailability.set(missingWorktree, "missing") - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "unchanged" }) - expect(movesOf()).toEqual([]) - }) - - test("reports the sessions already moved when a descendant move fails", async () => { - globalActiveSessions = [worktreeSession("root", null), worktreeSession("child", "root")] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - controlPlaneMoveErrorsById.set("child", new Error("destination busy")) - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - const result = await relocateSessionFromMissingDirectory("root") - - expect(result.status).toBe("failed") - expect(result.status === "failed" ? result.movedSessionIds : null).toEqual(["root"]) - expect(movedSessionDirectories).toEqual([{ sessionID: "root", directory: projectDirectory }]) - }) - - test("publishes nothing when the runtime changes while the directory is being probed", async () => { - globalActiveSessions = [worktreeSession("root", null)] - openCodeProjects.push(mainProject) - directoryAvailability.set(missingWorktree, "missing") - const { switchRuntimeEndpoint } = await import("../lib/runtime-switch") - beforeDirectoryAvailabilityResolve = () => { - switchRuntimeEndpoint({ apiBaseUrl: "http://other.test", runtimeKey: "other-runtime" }) - } - const { relocateSessionFromMissingDirectory, setActionRefs } = await import("./session-actions") - setActionRefs(actionSdk, stores(), () => missingWorktree) - - expect(await relocateSessionFromMissingDirectory("root")).toEqual({ status: "stale" }) - expect(movesOf()).toEqual([]) - expect(movedSessionDirectories).toEqual([]) - }) -}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c170425b..c29e7ab1 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -1531,134 +1531,6 @@ function commitArchivedSessions(sessions: Session[], directory: string): void { */ const UNARCHIVED_TIMESTAMP = 0 -async function getProjectPrimaryDirectory(projectID?: string): Promise { - if (!projectID) return null - - try { - const result = await sdk().project.list() - const projects = assertSdkData(result, "project.list") - const projectDirectory = projects.find((candidate) => candidate.id === projectID)?.worktree?.trim() - return projectDirectory ? normalizePath(projectDirectory) ?? projectDirectory : null - } catch { - return null - } -} - -type MissingWorktreeRelocation = { sourceDirectory: string; destinationDirectory: string } - -const isFilesystemRoot = (directory: string): boolean => directory === "/" || /^[A-Za-z]:\/?$/.test(directory) - -async function resolveMissingWorktreeRelocation( - session: Session & { project?: { worktree?: string | null } | null }, -): Promise { - const ownedDirectory = resolveSessionOwnedDirectory(session) - const projectWorktree = session.project?.worktree?.trim() - if (!ownedDirectory || !projectWorktree) return null - - let availability: Awaited> - try { - availability = await opencodeClient.getDirectoryAvailability(ownedDirectory) - } catch { - return null - } - if (availability !== "missing") return null - - const projectDirectory = await getProjectPrimaryDirectory(session.projectID) - if (!projectDirectory || projectDirectory === ownedDirectory) return null - // OpenCode files a directory outside any Git repository under its global - // project, whose "worktree" is the filesystem root. That is not a home for - // a session; a managed chat whose directory vanished stays where it is. - if (isFilesystemRoot(projectDirectory)) return null - return { sourceDirectory: ownedDirectory, destinationDirectory: projectDirectory } -} - -type OwnedSubtreeEntry = { session: Session; ownedDirectory: string | null } - -/** - * The root's subtree as the global cache knows it, root first. Drawn from the - * global cache rather than a live child store so archived descendants that - * never materialized in a directory store are still included. - */ -function getGlobalSubtree(rootSession: Session): OwnedSubtreeEntry[] { - const global = useGlobalSessionsStore.getState() - const sessionsById = new Map() - - for (const session of [...global.activeSessions, ...global.archivedSessions]) { - const current = sessionsById.get(session.id) - if (!current || Boolean(session.time?.archived)) sessionsById.set(session.id, session) - } - sessionsById.set(rootSession.id, rootSession) - - return [...computeSubtreeIds([...sessionsById.values()], rootSession.id)] - .map((id) => sessionsById.get(id)) - .filter((session): session is Session => Boolean(session)) - .map((session) => ({ session, ownedDirectory: resolveSessionOwnedDirectory(session) })) -} - -function getRestoreSubtree(rootSession: Session, sourceDirectory: string): Array<{ session: Session; sourceDirectory: string }> { - return getGlobalSubtree(rootSession) - // Keep a node while it is still archived or still stranded in the - // confirmed-missing worktree. The second clause matters on retry: a prior - // attempt may have already unarchived the root (server echo made it active) - // but failed to move it, so filtering on `archived` alone would drop the - // root and report a false success while it stays in the deleted worktree. - .filter((entry) => Boolean(entry.session.time?.archived) || entry.ownedDirectory === sourceDirectory) - .map((entry) => (entry.ownedDirectory ? { session: entry.session, sourceDirectory: entry.ownedDirectory } : null)) - .filter((entry): entry is { session: Session; sourceDirectory: string } => entry !== null) -} - -export type MissingDirectoryRelocation = - /** The session's directory is gone; its subtree now lives in the project directory. */ - | { status: "moved"; sourceDirectory: string; destinationDirectory: string; movedSessionIds: string[] } - /** The directory is available, its state is unknown, or the session has no project to move to. */ - | { status: "unchanged" } - /** The runtime changed while the relocation was in flight; nothing local was published. */ - | { status: "stale" } - /** A control-plane move failed; `movedSessionIds` already live in the destination. */ - | { status: "failed"; movedSessionIds: string[]; error: unknown } - -/** - * Move an active session whose worktree no longer exists into its project's - * primary directory. - * - * Same gate as the archived-session restore fallback: only a server-confirmed - * `missing` directory qualifies, the destination is the OpenCode project the - * session belongs to, and `available`, `unknown`, probe failures, and sessions - * without a project leave everything untouched. Every session of the root's - * subtree still stranded in that directory moves with it, root first, so the - * session the user is looking at is usable even if a descendant move fails. - * Moves carry no changes (`moveChanges: false`): the directory is gone, so - * there is nothing to carry. - */ -export async function relocateSessionFromMissingDirectory( - sessionId: string, - expectedRuntimeKey = getRuntimeKey(), -): Promise { - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - const rootSession = getGlobalSessionSnapshot(sessionId) - if (!rootSession) return { status: "unchanged" } - - const relocation = await resolveMissingWorktreeRelocation(rootSession) - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - if (!relocation) return { status: "unchanged" } - - const stranded = getGlobalSubtree(rootSession) - .filter((entry) => entry.ownedDirectory === relocation.sourceDirectory) - .map((entry) => entry.session) - const movedSessionIds: string[] = [] - for (const session of stranded) { - try { - await moveSessionToDirectory(session, relocation.sourceDirectory, relocation.destinationDirectory, false, expectedRuntimeKey) - } catch (error) { - console.error("[session-actions] relocateSessionFromMissingDirectory failed", error) - return { status: "failed", movedSessionIds, error } - } - if (isStaleRuntime(expectedRuntimeKey)) return { status: "stale" } - movedSessionIds.push(session.id) - } - return { status: "moved", ...relocation, movedSessionIds } -} - /** * Restore one archived session back to the active list. * @@ -1671,34 +1543,8 @@ export async function relocateSessionFromMissingDirectory( */ export async function unarchiveSession(sessionId: string, expectedRuntimeKey = getRuntimeKey()): Promise { if (isStaleRuntime(expectedRuntimeKey)) return false - const globalSession = getGlobalSessionSnapshot(sessionId) const sessionDirectory = getSessionDirectory(sessionId) try { - const restore = globalSession - ? await resolveMissingWorktreeRelocation(globalSession) - : null - if (isStaleRuntime(expectedRuntimeKey)) return false - - if (globalSession && restore) { - for (const { session, sourceDirectory } of getRestoreSubtree(globalSession, restore.sourceDirectory)) { - const restored = await opencodeClient.updateSession( - session.id, - { time: { archived: UNARCHIVED_TIMESTAMP } }, - sourceDirectory, - ) - if (isStaleRuntime(expectedRuntimeKey)) return false - if (!restored) { - throw new Error("session.update failed: server did not return the restored session") - } - if (restored.time?.archived) { - throw new Error("session.update failed: server kept the session archived") - } - await moveSessionToDirectory(restored, sourceDirectory, restore.destinationDirectory, false, expectedRuntimeKey) - if (isStaleRuntime(expectedRuntimeKey)) return false - } - return true - } - const restored = await opencodeClient.updateSession(sessionId, { time: { archived: UNARCHIVED_TIMESTAMP } }, sessionDirectory) if (isStaleRuntime(expectedRuntimeKey)) return false if (!restored) { diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index ef086eae..d95fcc8d 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -15,7 +15,6 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; -import { subscribeWorktreeTopologyChanged } from '@/lib/worktrees/worktreeManager'; import { createContextPart } from '@/lib/messages/contextParts'; /** @@ -1347,52 +1346,17 @@ describe('missing session directory recovery', () => { useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, worktreeMetadata: new Map() }); }); - test('moves the current session to its project, drops the worktree hint, and shares one attempt between callers', async () => { - const root = worktreeSession('root', missingWorktree); - const child = worktreeSession('child', missingWorktree, 'root'); - useGlobalSessionsStore.setState({ activeSessions: [root, child], archivedSessions: [] }); - useSessionUIStore.setState({ currentSessionId: 'root', currentSessionDirectory: missingWorktree }); - useSessionUIStore.getState().setWorktreeMetadata('root', { path: missingWorktree, branch: 'gone' }); - useSessionUIStore.getState().setWorktreeMetadata('child', { path: missingWorktree, branch: 'gone' }); - - const topologyChanges = []; - const unsubscribe = subscribeWorktreeTopologyChanged((directory) => topologyChanges.push(directory)); - const store = useSessionUIStore.getState(); - const [first, second] = await Promise.all([ - store.recoverMissingSessionDirectory('root'), - store.recoverMissingSessionDirectory('root'), - ]); - unsubscribe(); - - expect(first).toBe(second); - expect(topologyChanges).toEqual([projectDirectory]); - expect(first.status).toBe('moved'); - expect(moves.map((move) => move.sessionID)).toEqual(['root', 'child']); - expect(moves.every((move) => move.destination.directory === projectDirectory && move.moveChanges === false)).toBe(true); - expect(useSessionUIStore.getState().worktreeMetadata.has('root')).toBe(false); - expect(useSessionUIStore.getState().worktreeMetadata.has('child')).toBe(false); - expect(useSessionWorktreeStore.getState().getAttachment('root')).toBeUndefined(); - expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(projectDirectory); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); - expect(useDirectoryStore.getState().currentDirectory).toBe(projectDirectory); - }); - - test('probes a worktree session on activation and relocates it only when the directory is confirmed missing', async () => { + test('leaves a missing worktree session in place on activation and does not probe or relocate it', async () => { const root = worktreeSession('root', missingWorktree); useGlobalSessionsStore.setState({ activeSessions: [root], archivedSessions: [] }); - availability = 'available'; useSessionUIStore.getState().setCurrentSession('root', missingWorktree); await settle(); - expect(probes).toEqual([missingWorktree]); + + expect(probes).toEqual([]); expect(moves).toEqual([]); expect(useSessionUIStore.getState().currentSessionDirectory).toBe(missingWorktree); - - availability = 'missing'; - useSessionUIStore.getState().setCurrentSession('root', missingWorktree); - await settle(); - expect(moves.map((move) => move.sessionID)).toEqual(['root']); - expect(useSessionUIStore.getState().currentSessionDirectory).toBe(projectDirectory); + expect(useSessionUIStore.getState().getDirectoryForSession('root')).toBe(missingWorktree); }); test('never probes a session that lives in its project root or in a managed chat directory', async () => { diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index a1d8df90..ecaddacd 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -31,8 +31,7 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" -import type { ProjectEntry } from "@/lib/api/types" -import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryForHome, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" import { isVSCodeRuntime } from "@/lib/desktop" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" @@ -72,9 +71,7 @@ import { unrevertSession as unrevertSessionAction, forkFromMessage as forkFromMessageAction, fetchMessagesForSession, - relocateSessionFromMissingDirectory, type ArchiveSessionsOptions, - type MissingDirectoryRelocation, type DeleteSessionOptions, type DeleteSessionsOptions, type UnarchiveSessionsOptions, @@ -378,13 +375,6 @@ export type SessionUIState = { transition?: "submitted-draft", ) => void clearMaterializedDraftSession: (sessionId: string) => void - /** - * Move a session whose directory no longer exists (a worktree deleted - * outside OpenChamber) into its project directory. Concurrent calls for the - * same session share one attempt. Resolves `unchanged` when the directory is - * available, unknown, or the session has no project to move to. - */ - recoverMissingSessionDirectory: (sessionId: string) => Promise prepareForRuntimeSwitch: (apiBaseUrl?: string | null) => void restoreForRuntimeSwitch: (apiBaseUrl?: string | null) => void openNewSessionDraft: (options?: Partial & { automatic?: boolean }) => void @@ -768,27 +758,6 @@ const resolveCreatableDraftDirectory = async ( } } -const pendingDirectoryRecoveries = new Map>() - -/** - * Only a directory that is neither a registered project root nor a managed - * chat directory can be a deleted worktree. Project roots and chat directories - * have nowhere to relocate to, so they are never probed. - */ -const isRelocatableSessionDirectory = (directory: string, projects: readonly ProjectEntry[]): boolean => { - if (isChatDirectoryForHome(directory, useDirectoryStore.getState().homeDirectory)) return false - return !projects.some((project) => normalizePath(project.path) === directory) -} - -const notifySessionRelocated = async (destinationDirectory: string): Promise => { - const { toast } = await import("sonner") - const { useI18nStore, formatMessage } = await import("@/lib/i18n/store") - const project = useProjectsStore.getState().projects.find((entry) => normalizePath(entry.path) === destinationDirectory) - toast.info(formatMessage(useI18nStore.getState().dictionary, "sessions.missingDirectory.movedToProject", { - project: project?.label ?? destinationDirectory, - })) -} - const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Promise => { const resolved = await resolveCreatableDraftDirectory(openedDraft, openedDraft.directoryOverride) if (resolved.status !== "ok") return @@ -1109,16 +1078,6 @@ export const useSessionUIStore = create()((set, get) => ({ console.warn("Failed to set OpenCode directory for session switch:", e) } - // A worktree session may have lost its directory while it was in the - // background. Probe on activation, the same way a reopened draft probes - // its inherited directory, so the session is relocated before its tabs - // and prompts run against a path that is gone. VS Code registers no - // worktrees, so every session there is its workspace root. - if (id && !isGuessedDir && resolvedDir && !isVSCodeRuntime() - && isRelocatableSessionDirectory(resolvedDir, projectsState.projects)) { - void get().recoverMissingSessionDirectory(id) - } - // Defer viewport anchor save for previous session — not needed for the // skeleton to render and reads messages which can be expensive. if (previousSessionId && previousSessionId !== id) { @@ -1210,39 +1169,7 @@ export const useSessionUIStore = create()((set, get) => ({ // --------------------------------------------------------------------------- // openNewSessionDraft // --------------------------------------------------------------------------- - recoverMissingSessionDirectory: (sessionId) => { - const runtimeKey = getRuntimeKey() - const key = `${runtimeKey}:${sessionId}` - const pending = pendingDirectoryRecoveries.get(key) - if (pending) return pending - const recovery = relocateSessionFromMissingDirectory(sessionId, runtimeKey) - .then(async (result) => { - if (result.status !== "moved" && result.status !== "failed") return result - // The worktree hint was the first thing every directory lookup read; - // with the worktree gone it would keep routing tabs to the dead path. - for (const movedId of result.movedSessionIds) { - get().setWorktreeMetadata(movedId, null) - } - if (result.status !== "moved") return result - if (get().currentSessionId === sessionId) { - // Re-select through the normal path so the active directory, project, - // and OpenCode client all follow the session to its new home. - get().setCurrentSession(sessionId, result.destinationDirectory) - } - // The server just confirmed a worktree directory is gone; the sidebar's - // worktree topology for that project is stale, so let it rediscover. - const { notifyWorktreeTopologyChanged } = await import("@/lib/worktrees/worktreeManager") - notifyWorktreeTopologyChanged(result.destinationDirectory) - await notifySessionRelocated(result.destinationDirectory) - return result - }) - .finally(() => { - pendingDirectoryRecoveries.delete(key) - }) - pendingDirectoryRecoveries.set(key, recovery) - return recovery - }, openNewSessionDraft: (options) => { // A USER-initiated draft open is a navigation choice: the next cold launch diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index fa988275..44f431e9 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -49,6 +49,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - `bridge-localfs-proxy-runtime.ts` - Local `/api/fs/read` and `/api/fs/raw` proxy helpers and shared proxy utility helpers. + - `/api/fs/directory-stat` returns 501 locally. Directory-availability probes remain unknown in VS Code rather than falling through to OpenCode. - Workspace-contained Markdown gallery images use these local filesystem routes without calling the server grant route. Grant requests for OpenCode temporary-directory images return an explicit unsupported response instead diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js index 617eac7a..c551856f 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.test.js +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.test.js @@ -61,6 +61,11 @@ describe('bridge local fs proxy', () => { expect(response?.status).toBe(404); }); + it('does not forward directory availability probes to OpenCode', async () => { + const response = await tryHandleLocalFsProxy('GET', '/api/fs/directory-stat?path=%2Fmissing-dir'); + expect(response?.status).toBe(501); + }); + it('reads from the active directory when it is the second workspace root', async () => { existingFiles.add('/workspace-two/image.png'); const response = await tryHandleLocalFsProxy( diff --git a/packages/vscode/src/bridge-localfs-proxy-runtime.ts b/packages/vscode/src/bridge-localfs-proxy-runtime.ts index ba1f3093..9254de54 100644 --- a/packages/vscode/src/bridge-localfs-proxy-runtime.ts +++ b/packages/vscode/src/bridge-localfs-proxy-runtime.ts @@ -56,6 +56,9 @@ export const tryHandleLocalFsProxy = async (method: string, requestPath: string) } const fsProxyPath = normalizeFsProxyPath(parsed.pathname); + if (parsed.pathname === '/api/fs/directory-stat') { + return buildProxyJsonError(501, 'Directory availability probes are not supported in the VS Code runtime'); + } if (/^\/api\/openchamber\/sessions\/[^/]+\/markdown-image-grants$/.test(parsed.pathname)) { return buildProxyJsonError(501, 'Markdown image grants are not supported in the VS Code runtime'); } diff --git a/packages/web/server/lib/fs/DOCUMENTATION.md b/packages/web/server/lib/fs/DOCUMENTATION.md index c3e7a797..774b7d3c 100644 --- a/packages/web/server/lib/fs/DOCUMENTATION.md +++ b/packages/web/server/lib/fs/DOCUMENTATION.md @@ -14,6 +14,8 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - `POST /api/fs/mkdir` - `GET /api/fs/read` - `GET /api/fs/raw` + - `GET /api/fs/stat` + - `GET /api/fs/directory-stat` - `GET /api/fs/serve/:path(*)` - `POST /api/fs/write` - `POST /api/fs/upload` @@ -43,6 +45,7 @@ Own filesystem API behavior for the web server runtime, including workspace-boun - Workspace checks accept, besides the active workspace and its worktrees, the **managed roots**: the OpenChamber config root and the managed chats root (`managedChatsRoot` dependency; `OPENCHAMBER_CHATS_DIR` upstream, default `/chats`). Chat worktrees may legitimately live outside every project workspace. - `GET /api/fs/home` answers `{ home, chatsRoot }`. `chatsRoot` is the server-resolved managed chats root; clients must use it instead of joining `home` + the well-known segment (a relocated root does not contain that segment). - Filesystem `EPERM`/`EACCES` failures use the stable `reason: "os-permission"` response marker. Policy denials such as workspace-boundary or missing-grant failures must not use that marker because a native folder picker cannot remediate them. +- `GET /api/fs/directory-stat?path=...` uses one `stat` without listing contents or resolving project topology. It follows the same authenticated directory-discovery path policy as `/api/fs/list`, including targets outside the active workspace. A directory returns `{ isDirectory: true }`; `ENOENT` returns `not-found`, and a file or `ENOTDIR` returns `not-directory`. Permission and other failures remain distinct from a missing path. VS Code explicitly returns 501, so the shared client treats its probe as unknown. - Read-only routes authorize the requested path against the workspace before resolving symlinks. A symlink reached through the workspace may therefore target a file outside it, while a directly requested outside path still requires an exact-path grant. Write routes keep canonical-target boundary checks. - If adding new `/api/fs/*` endpoints, add them in `routes.js` and extend this document. - `GET /api/fs/list` may resolve symlinks with `realpath` to read directory contents, but the response `path` and each entry `path` must stay in the caller's requested path space (`path.join(requestedPath, name)`). Returning real paths breaks file-tree expansion for directories reached through workspace symlinks. diff --git a/packages/web/server/lib/fs/routes.js b/packages/web/server/lib/fs/routes.js index cda4ec5c..035f110e 100644 --- a/packages/web/server/lib/fs/routes.js +++ b/packages/web/server/lib/fs/routes.js @@ -864,6 +864,37 @@ export const registerFsRoutes = (app, dependencies) => { } }); + app.get('/api/fs/directory-stat', async (req, res) => { + res.setHeader('Cache-Control', 'no-store'); + const paths = new URL(req.url, 'http://openchamber.local').searchParams.getAll('path'); + const directoryPath = paths.length === 1 ? paths[0].trim() : ''; + if (!directoryPath) { + return res.status(400).json({ error: 'Path is required' }); + } + + try { + // Directory discovery uses the same path policy as /api/fs/list, including + // paths outside the current workspace. stat follows symlinks without readdir. + const resolvedPath = path.resolve(normalizeDirectoryPath(directoryPath)); + const stats = await fsPromises.stat(resolvedPath); + if (!stats.isDirectory()) { + return res.status(400).json({ error: 'Specified path is not a directory', reason: 'not-directory' }); + } + return res.json({ isDirectory: true }); + } catch (error) { + if (error.code === 'ENOENT' || error.code === 'ENOTDIR') { + return res.status(error.code === 'ENOENT' ? 404 : 400).json({ + error: error.code === 'ENOENT' ? 'Directory not found' : 'Specified path is not a directory', + reason: error.code === 'ENOENT' ? 'not-found' : 'not-directory', + }); + } + if (isOsPermissionError(error)) { + return sendOsPermissionDenied(res, 'Access to directory denied'); + } + return res.status(500).json({ error: 'Failed to stat directory' }); + } + }); + app.get('/api/fs/stat', async (req, res) => { const filePath = typeof req.query.path === 'string' ? req.query.path.trim() : ''; const optional = req.query.optional === 'true'; diff --git a/packages/web/server/lib/fs/routes.test.js b/packages/web/server/lib/fs/routes.test.js index d592d532..66cc0f38 100644 --- a/packages/web/server/lib/fs/routes.test.js +++ b/packages/web/server/lib/fs/routes.test.js @@ -1,5 +1,9 @@ import { EventEmitter } from 'events'; import path from 'path'; +import { copyFile, mkdir, mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { execFileSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { mintOutsideFileGrant, registerFsRoutes } from './routes.js'; @@ -1385,7 +1389,11 @@ describe('fs stat directory scope (issue 3019)', () => { path: path.posix, fsPromises: { realpath: async (targetPath) => targetPath, - stat: async () => ({ isFile: () => true, size: 12 }), + stat: async (targetPath) => ( + targetPath === '/repo-b' + ? { isDirectory: () => true, mtimeMs: 123 } + : { isFile: () => true, size: 12, mtimeMs: 456 } + ), }, spawn: vi.fn(), crypto: { randomUUID: () => 'job-0' }, @@ -1428,6 +1436,107 @@ describe('fs stat directory scope (issue 3019)', () => { expect(res.statusCode).toBe(200); expect(res.body.isFile).toBe(true); }); + +}); + +describe('fs stat directory error handling', () => { + it('loads in Node without workspace node_modules, as packaged desktop does', async () => { + const directory = await mkdtemp(path.join(tmpdir(), 'openchamber-fs-import-')); + try { + await mkdir(path.join(directory, 'fs')); + await copyFile(new URL('./routes.js', import.meta.url), path.join(directory, 'fs/routes.mjs')); + await copyFile(new URL('../path-realpath-cache.js', import.meta.url), path.join(directory, 'path-realpath-cache.js')); + expect(() => execFileSync('node', [ + '--input-type=module', + '--eval', + 'await import(process.argv[1])', + pathToFileURL(path.join(directory, 'fs/routes.mjs')).href, + ], { cwd: directory, stdio: 'pipe' })).not.toThrow(); + } finally { + await rm(directory, { recursive: true, force: true }); + } + }); + + it('returns directory-missing reasons and permission errors for directory stat', async () => { + const { app, getRoute } = createRouteRegistry(); + const enoent = Object.assign(new Error('missing'), { code: 'ENOENT' }); + const enotdir = Object.assign(new Error('not a directory'), { code: 'ENOTDIR' }); + const eacces = Object.assign(new Error('denied'), { code: 'EACCES' }); + const stat = vi.fn(async (targetPath) => { + if (targetPath === '/repo-b') throw enoent; + if (targetPath === '/repo-b/file.txt/child') throw enotdir; + if (targetPath === '/repo-b/protected') throw eacces; + if (targetPath === '/repo-b/file.txt') return { isDirectory: () => false }; + if (targetPath === '/repo-b/failure') throw new Error('unavailable'); + return { isDirectory: () => true, mtimeMs: 1 }; + }); + const readdir = vi.fn(async () => []); + const callStat = async (handler, { headers = {}, query }) => { + const res = createMockResponse(); + const req = { + url: `/api/fs/directory-stat?${new URLSearchParams(query)}`, + query, + get: (name) => headers[name.toLowerCase()] ?? undefined, + }; + await handler(req, res); + return res; + }; + registerFsRoutes(app, { + os: { homedir: () => '/home/user' }, + path: path.posix, + fsPromises: { + realpath: async (targetPath) => targetPath, + stat, + readdir, + }, + spawn: vi.fn(), + crypto: { randomUUID: () => 'job-0' }, + normalizeDirectoryPath: (p) => p, + resolveProjectDirectory: async () => ({ directory: '/repo' }), + buildAugmentedPath: () => '/usr/bin', + resolveGitBinaryForSpawn: () => 'git', + openchamberUserConfigRoot: '/home/user/.config', + }); + const handler = getRoute('GET', '/api/fs/directory-stat'); + + const available = await callStat(handler, { query: { path: '/other-project' } }); + expect(available.statusCode).toBe(200); + expect(available.body).toEqual({ isDirectory: true }); + expect(available.getHeader('Cache-Control')).toBe('no-store'); + expect(stat).toHaveBeenCalledTimes(1); + + const invalid = await callStat(handler, { query: { path: ' ' } }); + expect(invalid.statusCode).toBe(400); + expect(stat).toHaveBeenCalledTimes(1); + + for (const query of ['path=/repo&path=/other', 'path[]=/repo', '']) { + const malformed = createMockResponse(); + await handler({ url: `/api/fs/directory-stat?${query}` }, malformed); + expect(malformed.statusCode).toBe(400); + } + expect(stat).toHaveBeenCalledTimes(1); + + const missing = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b', directory: 'true' } }); + expect(missing.statusCode).toBe(404); + expect(missing.body).toEqual({ error: 'Directory not found', reason: 'not-found' }); + + const notDir = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/file.txt/child', directory: 'true' } }); + expect(notDir.statusCode).toBe(400); + expect(notDir.body).toEqual({ error: 'Specified path is not a directory', reason: 'not-directory' }); + + const denied = await callStat(handler, { headers: { 'x-opencode-directory': '/repo-b' }, query: { path: '/repo-b/protected', directory: 'true' } }); + expect(denied.statusCode).toBe(403); + expect(denied.body).toEqual({ error: 'Access to directory denied', reason: 'os-permission' }); + + const file = await callStat(handler, { query: { path: '/repo-b/file.txt' } }); + expect(file.statusCode).toBe(400); + expect(file.body.reason).toBe('not-directory'); + + const failure = await callStat(handler, { query: { path: '/repo-b/failure' } }); + expect(failure.statusCode).toBe(500); + expect(failure.body).toEqual({ error: 'Failed to stat directory' }); + expect(readdir).not.toHaveBeenCalled(); + }); }); describe('fs managed chats root', () => { diff --git a/packages/web/server/lib/terminal/DOCUMENTATION.md b/packages/web/server/lib/terminal/DOCUMENTATION.md index 4ecd3b24..730b0b9f 100644 --- a/packages/web/server/lib/terminal/DOCUMENTATION.md +++ b/packages/web/server/lib/terminal/DOCUMENTATION.md @@ -35,7 +35,7 @@ HTTP remains the authenticated command plane for create, resize, appearance upda - Scrollback is retained on the server and capped at 512 KiB with UTF-8-safe trimming. Device-status, device-attribute, cursor-position reply, and color-query exchanges are removed from replay history with incomplete control sequences carried across PTY chunks; live output remains byte-for-byte unchanged. - Exited sessions remain attachable until explicit close, idle cleanup, or a successful replacement of the same project action. Creating a replacement retires only exited records for the same resolved directory and action, after the new PTY starts. Failed creation preserves the old record and output. These replaced records do not exhaust the terminal capacity limit. - Deduplicated create responses may describe another client's execution. Cancellation cleanup closes only the terminal ID allocated for the cancelled request; it never closes an adopted peer execution. -- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. That is the one rejection the client can recover from: the session, not the terminal, is stranded, and the shared UI moves it to its project directory and starts a terminal there. Every other rejection stays generic; the runtime never substitutes a parent directory on its own. +- Create and restart validate the working directory with a real `stat` and answer HTTP 400 `Invalid working directory` when it is not a directory. When the path does not exist at all (`ENOENT`/`ENOTDIR`, a worktree deleted outside OpenChamber) the body also carries `code: "TERMINAL_CWD_MISSING"`. The client shows the failure without moving the session. The runtime never substitutes a parent directory on its own. - Restarts are serialized per terminal. Each restart spawns and wires the replacement before terminating the old process, retaining the terminal ID. Command-mode sessions reject restart with HTTP 400 instead of silently turning into interactive shells with stale action metadata. - A delete that arrives while create is still pending leaves a cancellation tombstone. When the PTY arrives, the runtime terminates it immediately, never inserts the session into the live map, and returns a create error while the delete still succeeds. - Close uses SIGTERM with bounded SIGKILL escalation. Force-kill, idle cleanup, and runtime shutdown terminate process groups immediately where supported. Removal explicitly sends a fatal scoped closure and evicts client projections even when a PTY backend fails to emit `onExit`; attached terminals are not considered idle.