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:
@@ -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.',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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': 'ワークツリーパスは利用できません。',
|
||||
|
||||
@@ -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': '워크트리 경로를 사용할 수 없습니다.',
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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 路徑無法使用。',
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user