fix(sessions): keep missing-worktree relocation manual

Remove automatic moves on session activation, terminal failures, and archive restoration while preserving manual moves and worktree deletion.

Replace directory listing probes with a stat-only endpoint using Node built-ins, including an isolated module-load regression test for packaged desktop.

Validation: focused session, worktree, filesystem, localization, and bridge tests; workspace type-check and lint; web and VS Code builds. Desktop startup and behavior verified by the maintainer.
This commit is contained in:
Bohdan Triapitsyn
2026-09-07 02:10:06 +03:00
parent eb6f7b0904
commit 3132d1361a
32 changed files with 211 additions and 763 deletions
-1
View File
@@ -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.',
-1
View File
@@ -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.',
-1
View File
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
"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.",
-1
View File
@@ -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.',
-1
View File
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
'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': 'ワークツリーパスは利用できません。',
-1
View File
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
'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': '워크트리 경로를 사용할 수 없습니다.',
-1
View File
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
'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.',
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
"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.",
-1
View File
@@ -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.',
-1
View File
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
"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 недоступний.",
@@ -706,7 +706,6 @@ export const dict: Record<I18nKey, string> = {
'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': '工作树路径不可用。',
@@ -719,7 +719,6 @@ export const dict: Record<I18nKey, string> = {
'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 路徑無法使用。',
+13 -4
View File
@@ -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'));
+14 -13
View File
@@ -71,7 +71,7 @@ type SdkResult<T> = {
};
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<T>(result: SdkResult<T>, 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<DirectoryAvailability> {
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 {
@@ -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']);
});
});
@@ -400,29 +400,6 @@ const invalidateWorktreeList = (projectDirectory: string): void => {
_worktreeListCache.delete(projectDirectory);
};
type WorktreeTopologyListener = (projectDirectory: string) => void;
const worktreeTopologyListeners = new Set<WorktreeTopologyListener>();
/**
* 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<WorktreeMetadata[]> => {
const metadataProjectDirectory = await resolveProjectRoot(projectDirectory).catch(() => projectDirectory);
const normalizedProjectDirectory = normalizePath(projectDirectory);