From 0d8f4f2fe105a4cac7fd1534727ec0565f7cba0a Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 30 May 2026 03:16:00 +0200 Subject: [PATCH 1/4] fix(app): showSession() on openTerminal error path --- public/app.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/public/app.js b/public/app.js index ac5dc703..47020991 100644 --- a/public/app.js +++ b/public/app.js @@ -794,6 +794,7 @@ async function launchNewSession(project, sessionOptions) { if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; + showSession(sessionId); return; } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); @@ -860,6 +861,7 @@ async function openSession(session, customOptions) { if (!result.ok) { entry.terminal.write(`\r\nError: ${result.error}\r\n`); entry.closed = true; + showSession(sessionId); return; } if (typeof setSessionMcpActive === 'function') setSessionMcpActive(sessionId, !!result.mcpActive); From d335c7948cc8218d2ebded2e593560184ac07651 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 30 May 2026 03:28:16 +0200 Subject: [PATCH 2/4] feat(sidebar): detect missing project paths --- preload.js | 1 + public/sidebar.js | 45 ++++++++++++++++++++++++++++++++++++++++----- public/style.css | 34 ++++++++++++++++++++++++++++++++++ session-cache.js | 5 +++++ 4 files changed, 80 insertions(+), 5 deletions(-) diff --git a/preload.js b/preload.js index c5163363..9d4357f7 100644 --- a/preload.js +++ b/preload.js @@ -44,6 +44,7 @@ contextBridge.exposeInMainWorld('api', { browseFolder: () => ipcRenderer.invoke('browse-folder'), addProject: (projectPath) => ipcRenderer.invoke('add-project', projectPath), removeProject: (projectPath) => ipcRenderer.invoke('remove-project', projectPath), + remapProject: (oldPath, newPath) => ipcRenderer.invoke('remap-project', oldPath, newPath), deleteWorktree: (worktreePath) => ipcRenderer.invoke('delete-worktree', worktreePath), worktreeStatus: (worktreePath) => ipcRenderer.invoke('worktree-status', worktreePath), openExternal: (url) => ipcRenderer.invoke('open-external', url), diff --git a/public/sidebar.js b/public/sidebar.js index 8907f96f..f5568fa3 100644 --- a/public/sidebar.js +++ b/public/sidebar.js @@ -474,14 +474,15 @@ function renderProjects(projects, resort) { // Build DOM const group = document.createElement('div'); - group.className = 'project-group'; + group.className = 'project-group' + (project.missing ? ' missing' : ''); group.id = fId; const header = document.createElement('div'); header.className = 'project-header'; header.id = 'ph-' + fId; const shortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); - header.innerHTML = ` ${shortName}`; + const missingIcon = project.missing ? ' ' : ''; + header.innerHTML = ` ${missingIcon}${escapeHtml(shortName)}`; const scheduleBtn = document.createElement('button'); scheduleBtn.className = 'project-schedule-btn'; @@ -501,6 +502,14 @@ function renderProjects(projects, resort) { archiveGroupBtn.innerHTML = ICONS.archive(18); header.appendChild(archiveGroupBtn); + if (project.missing) { + const remapBtn = document.createElement('button'); + remapBtn.className = 'project-remap-btn'; + remapBtn.title = 'Change project path'; + remapBtn.innerHTML = ''; + header.appendChild(remapBtn); + } + const newBtn = document.createElement('button'); newBtn.className = 'project-new-btn'; newBtn.innerHTML = ''; @@ -509,8 +518,10 @@ function renderProjects(projects, resort) { const sessionsList = buildSessionsList(fId, visible, older, subagentIndex, project.projectPath); - // Auto-collapse if most recent session is older than threshold, or project matched with no sessions - if (project._projectMatchedOnly) { + // Auto-collapse if project path is missing, most recent session is older than threshold, or project matched with no sessions + if (project.missing) { + header.classList.add('collapsed'); + } else if (project._projectMatchedOnly) { header.classList.add('collapsed'); } else if (searchMatchIds === null && !showStarredOnly && !showRunningOnly) { const mostRecent = filtered[0]?.modified; @@ -689,8 +700,24 @@ function rebindSidebarEvents(projects) { loadProjects(); }; } + const remapBtn = header.querySelector('.project-remap-btn'); + if (remapBtn) { + remapBtn.onclick = async (e) => { + e.stopPropagation(); + const newPath = await window.api.browseFolder(); + if (!newPath) return; + const projectShortName = project.projectPath.split('/').filter(Boolean).slice(-2).join('/'); + if (!confirm(`Remap ${projectShortName} to:\n${newPath}?`)) return; + const result = await window.api.remapProject(project.projectPath, newPath); + if (result.error) { + alert('Failed to remap: ' + result.error); + } else { + loadProjects(); + } + }; + } header.onclick = (e) => { - if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn')) return; + if (e.target.closest('.project-new-btn') || e.target.closest('.project-archive-btn') || e.target.closest('.project-settings-btn') || e.target.closest('.project-schedule-btn') || e.target.closest('.project-remap-btn')) return; header.classList.toggle('collapsed'); }; } @@ -790,6 +817,14 @@ function rebindSidebarEvents(projects) { const session = sessionMap.get(sessionId); if (!session) return; + // Sessions under missing projects can't be opened — the path no longer exists + if (item.closest('.project-group.missing')) { + item.classList.add('disabled'); + item.title = 'Project path no longer exists — use "Change path" to fix'; + item.onclick = () => {}; + return; + } + item.onclick = () => { if (item.dataset.subagent && session.parentSessionId) { showSubagentTranscript(session); diff --git a/public/style.css b/public/style.css index 7fd33f04..6e7ba626 100644 --- a/public/style.css +++ b/public/style.css @@ -487,6 +487,40 @@ body { display: flex; flex-direction: column; } display: none; } +/* Missing project path */ +.project-group.missing { + opacity: 0.55; +} +.project-group.missing:hover { + opacity: 0.8; +} +.project-missing-icon { + color: #e8a838; + vertical-align: middle; + margin-right: 2px; + flex-shrink: 0; +} +.project-remap-btn { + background: none; + border: none; + color: #e8a838; + cursor: pointer; + padding: 2px 4px; + border-radius: 4px; + display: none; + align-items: center; +} +.project-group.missing .project-remap-btn { + display: inline-flex; +} +.project-remap-btn:hover { + background: rgba(232, 168, 56, 0.15); +} +.session-item.disabled { + opacity: 0.4; + cursor: not-allowed; +} + /* Worktree nested groups */ .worktree-group { margin-left: 12px; diff --git a/session-cache.js b/session-cache.js index fb606d97..bce3cdfe 100644 --- a/session-cache.js +++ b/session-cache.js @@ -312,6 +312,7 @@ function buildProjectsFromCache(showArchived) { folder: encodeProjectPath(row.projectPath), projectPath: row.projectPath, sessions: [], + missing: !fs.existsSync(row.projectPath), }); } projectMap.get(row.projectPath).sessions.push(s); @@ -339,6 +340,7 @@ function buildProjectsFromCache(showArchived) { folder: encodeProjectPath(projectPath), projectPath, sessions: [], + missing: !fs.existsSync(projectPath), }); } } @@ -375,6 +377,9 @@ function buildProjectsFromCache(showArchived) { } projects.sort((a, b) => { + // Missing projects go to the bottom + if (a.missing && !b.missing) return 1; + if (!a.missing && b.missing) return -1; // Empty projects go to the bottom if (a.sessions.length === 0 && b.sessions.length > 0) return 1; if (b.sessions.length === 0 && a.sessions.length > 0) return -1; From 7689791188e636e86e4633e1333a366efc08b912 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 30 May 2026 03:28:19 +0200 Subject: [PATCH 3/4] feat(main): remap-project IPC with atomic JSONL rewrite --- main.js | 49 +++++++++ test/remap-project.test.js | 215 +++++++++++++++++++++++++++++++++++++ 2 files changed, 264 insertions(+) create mode 100644 test/remap-project.test.js diff --git a/main.js b/main.js index 8f688cdc..bfda8c6a 100644 --- a/main.js +++ b/main.js @@ -367,6 +367,55 @@ ipcMain.handle('remove-project', (_event, projectPath) => { } }); +// --- IPC: remap-project --- +ipcMain.handle('remap-project', (_event, oldPath, newPath) => { + try { + // Validate the new path exists and is a directory + let stat; + try { stat = fs.statSync(newPath); } catch { return { error: 'Path does not exist' }; } + if (!stat.isDirectory()) return { error: 'Path is not a directory' }; + + // Validate oldPath is a string (basic sanitisation) + if (typeof oldPath !== 'string' || typeof newPath !== 'string') { + return { error: 'Invalid arguments' }; + } + + // Find the session folder for the old project path using the same encoding the CLI uses + const folder = encodeProjectPath(oldPath); + const folderPath = path.join(PROJECTS_DIR, folder); + if (!fs.existsSync(folderPath)) return { error: 'No session data found for this project' }; + + // Rewrite cwd in all session JSONL files so `claude --resume` from CLI also works + const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); + for (const file of jsonlFiles) { + const filePath = path.join(folderPath, file); + const content = fs.readFileSync(filePath, 'utf8'); + const updated = content.split('\n').map(line => { + if (!line) return line; + try { + const parsed = JSON.parse(line); + if (parsed.cwd === oldPath) { + parsed.cwd = newPath; + return JSON.stringify(parsed); + } + } catch {} + return line; + }).join('\n'); + // Atomic write: write to tmp then rename to avoid partial updates + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + } + + // Refresh the folder cache so the new path takes effect in the UI + refreshFolder(folder); + notifyRendererProjectsChanged(); + return { ok: true }; + } catch (err) { + return { error: err.message }; + } +}); + // --- IPC: delete-worktree --- // Validated path pattern: /./[worktrees/] // Matches .claude/worktrees/, .claude-worktrees/, .worktrees/ diff --git a/test/remap-project.test.js b/test/remap-project.test.js new file mode 100644 index 00000000..2a90dc6a --- /dev/null +++ b/test/remap-project.test.js @@ -0,0 +1,215 @@ +/** + * Tests for the remap-project JSONL atomic-rewrite logic. + * + * Exercises the core algorithm in isolation (no Electron IPC, no DB) by + * re-implementing the loop from the IPC handler against a real temp directory. + * This keeps tests fast (node:test, no jsdom) and fully deterministic. + */ + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +// ── helpers ──────────────────────────────────────────────────────────────── + +function mkTmp() { + return fs.mkdtempSync(path.join(os.tmpdir(), 'switchboard-remap-')); +} + +function cleanup(dir) { + fs.rmSync(dir, { recursive: true, force: true }); +} + +/** + * Core JSONL rewrite logic extracted from the remap-project IPC handler. + * Rewrites every cwd occurrence of oldPath → newPath across all .jsonl files + * in folderPath using atomic tmp+rename writes. + */ +function remapJsonlFolder(folderPath, oldPath, newPath) { + const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); + for (const file of jsonlFiles) { + const filePath = path.join(folderPath, file); + const content = fs.readFileSync(filePath, 'utf8'); + const updated = content.split('\n').map(line => { + if (!line) return line; + try { + const parsed = JSON.parse(line); + if (parsed.cwd === oldPath) { + parsed.cwd = newPath; + return JSON.stringify(parsed); + } + } catch {} + return line; + }).join('\n'); + const tmp = filePath + '.tmp'; + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + } +} + +// ── tests ────────────────────────────────────────────────────────────────── + +test('rewrites cwd in a single JSONL file', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const line1 = JSON.stringify({ type: 'user', cwd: oldPath, message: 'hello' }); + const line2 = JSON.stringify({ type: 'assistant', cwd: oldPath, message: 'world' }); + fs.writeFileSync(path.join(tmp, 'session.jsonl'), line1 + '\n' + line2 + '\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + const result = fs.readFileSync(path.join(tmp, 'session.jsonl'), 'utf8'); + const lines = result.split('\n').filter(Boolean); + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).cwd, newPath); + assert.equal(JSON.parse(lines[1]).cwd, newPath); + } finally { + cleanup(tmp); + } +}); + +test('rewrites cwd across multiple JSONL files atomically', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + + for (let i = 0; i < 3; i++) { + const line = JSON.stringify({ type: 'user', cwd: oldPath, idx: i }); + fs.writeFileSync(path.join(tmp, `session-${i}.jsonl`), line + '\n'); + } + + remapJsonlFolder(tmp, oldPath, newPath); + + for (let i = 0; i < 3; i++) { + const content = fs.readFileSync(path.join(tmp, `session-${i}.jsonl`), 'utf8'); + const parsed = JSON.parse(content.trim()); + assert.equal(parsed.cwd, newPath, `session-${i}.jsonl should have new cwd`); + assert.equal(parsed.idx, i, `session-${i}.jsonl should preserve idx`); + } + } finally { + cleanup(tmp); + } +}); + +test('preserves lines without cwd field verbatim', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const withCwd = JSON.stringify({ type: 'user', cwd: oldPath }); + const noCwd = JSON.stringify({ type: 'system', text: 'no cwd here' }); + const otherCwd = JSON.stringify({ type: 'user', cwd: '/some/other/path' }); + + fs.writeFileSync( + path.join(tmp, 'mixed.jsonl'), + [withCwd, noCwd, otherCwd].join('\n') + '\n' + ); + + remapJsonlFolder(tmp, oldPath, newPath); + + const lines = fs.readFileSync(path.join(tmp, 'mixed.jsonl'), 'utf8') + .split('\n').filter(Boolean); + assert.equal(lines.length, 3); + assert.equal(JSON.parse(lines[0]).cwd, newPath); // updated + assert.equal(JSON.parse(lines[1]).text, 'no cwd here'); // untouched + assert.equal(JSON.parse(lines[2]).cwd, '/some/other/path'); // different cwd, untouched + } finally { + cleanup(tmp); + } +}); + +test('preserves empty lines in JSONL files', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const line = JSON.stringify({ type: 'user', cwd: oldPath }); + + // JSONL files often end with a trailing newline, creating an empty last "line" + fs.writeFileSync(path.join(tmp, 'session.jsonl'), line + '\n\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + const content = fs.readFileSync(path.join(tmp, 'session.jsonl'), 'utf8'); + // The trailing double newline should be preserved as-is + assert.ok(content.endsWith('\n\n'), 'trailing newlines should be preserved'); + } finally { + cleanup(tmp); + } +}); + +test('atomic write: tmp file is created and then removed by rename', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const line = JSON.stringify({ type: 'user', cwd: oldPath }); + const jsonlPath = path.join(tmp, 'session.jsonl'); + fs.writeFileSync(jsonlPath, line + '\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + // After rewrite the .tmp file must be gone (renamed into place) + assert.ok(!fs.existsSync(jsonlPath + '.tmp'), '.tmp file should not exist after rename'); + // And the JSONL should exist with updated content + assert.ok(fs.existsSync(jsonlPath), 'JSONL file should exist'); + assert.equal(JSON.parse(fs.readFileSync(jsonlPath, 'utf8').trim()).cwd, newPath); + } finally { + cleanup(tmp); + } +}); + +test('lines with invalid JSON are passed through unchanged', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const badLine = '{ not valid json '; + const goodLine = JSON.stringify({ type: 'user', cwd: oldPath }); + + fs.writeFileSync(path.join(tmp, 'session.jsonl'), goodLine + '\n' + badLine + '\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + const lines = fs.readFileSync(path.join(tmp, 'session.jsonl'), 'utf8') + .split('\n').filter(Boolean); + assert.equal(lines.length, 2); + assert.equal(JSON.parse(lines[0]).cwd, newPath); + assert.equal(lines[1], badLine, 'invalid JSON line should pass through unchanged'); + } finally { + cleanup(tmp); + } +}); + +test('non-.jsonl files in the folder are ignored', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + + fs.writeFileSync(path.join(tmp, 'session.jsonl'), JSON.stringify({ cwd: oldPath }) + '\n'); + fs.writeFileSync(path.join(tmp, 'meta.json'), JSON.stringify({ cwd: oldPath })); + fs.writeFileSync(path.join(tmp, 'readme.txt'), 'cwd: ' + oldPath); + + remapJsonlFolder(tmp, oldPath, newPath); + + // Only the .jsonl file should be modified + assert.equal( + JSON.parse(fs.readFileSync(path.join(tmp, 'meta.json'), 'utf8')).cwd, + oldPath, + 'meta.json should be untouched' + ); + assert.equal( + fs.readFileSync(path.join(tmp, 'readme.txt'), 'utf8'), + 'cwd: ' + oldPath, + 'readme.txt should be untouched' + ); + } finally { + cleanup(tmp); + } +}); From d1c4a7c9d127e149d7b51a369764d80a0c423372 Mon Sep 17 00:00:00 2001 From: jean-baptiste Date: Sat, 30 May 2026 03:41:45 +0200 Subject: [PATCH 4/4] fix(remap-project): address CRITICAL review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CRITICAL-1: replace flat readdirSync with enumerateSessionFiles so subagent transcripts under /subagents/*.jsonl and the legacy /*.jsonl layout are also rewritten - CRITICAL-2: add active-sessions guard — refuse remap if any non-exited PTY session has projectPath matching the folder (avoids concurrent-writer data loss); also re-check fs.existsSync(oldPath) at handler entry so a path that came back does not get clobbered - Extract rewriteJsonlAtomic helper that cleans up orphan .tmp files on error (try/catch around writeFileSync + renameSync) - Switch statSync → lstatSync on newPath to make symlink intent explicit - Tests: add subagent layout (preferred + legacy), orphan .tmp cleanup, and active-sessions guard tests (4 new tests, total 51 passing / 64) --- main.js | 83 +++++++++++++------- test/remap-project.test.js | 155 ++++++++++++++++++++++++++++++++++--- 2 files changed, 201 insertions(+), 37 deletions(-) diff --git a/main.js b/main.js index bfda8c6a..9d86bb87 100644 --- a/main.js +++ b/main.js @@ -291,7 +291,7 @@ sessionCache.init({ }); const { readSessionFile, readFolderFromFilesystem, refreshFolder, populateCacheFromFilesystem, buildProjectsFromCache, notifyRendererProjectsChanged, sendStatus, populateCacheViaWorker } = sessionCache; -const { resolveJsonlPath } = require('./read-session-file'); +const { resolveJsonlPath, enumerateSessionFiles } = require('./read-session-file'); // --- IPC: browse-folder --- @@ -368,43 +368,72 @@ ipcMain.handle('remove-project', (_event, projectPath) => { }); // --- IPC: remap-project --- -ipcMain.handle('remap-project', (_event, oldPath, newPath) => { + +/** + * Atomically rewrite cwd occurrences of oldPath → newPath in a single JSONL + * file. Uses a .tmp sibling + rename for crash safety. On any failure the .tmp + * orphan is cleaned up so it cannot block a future remap attempt. + */ +function rewriteJsonlAtomic(filePath, oldPath, newPath) { + const tmp = filePath + '.tmp'; try { - // Validate the new path exists and is a directory - let stat; - try { stat = fs.statSync(newPath); } catch { return { error: 'Path does not exist' }; } - if (!stat.isDirectory()) return { error: 'Path is not a directory' }; + const content = fs.readFileSync(filePath, 'utf8'); + const updated = content.split('\n').map(line => { + if (!line) return line; + try { + const parsed = JSON.parse(line); + if (parsed.cwd === oldPath) { + parsed.cwd = newPath; + return JSON.stringify(parsed); + } + } catch {} + return line; + }).join('\n'); + fs.writeFileSync(tmp, updated); + fs.renameSync(tmp, filePath); + } catch (err) { + try { fs.unlinkSync(tmp); } catch {} + throw err; + } +} - // Validate oldPath is a string (basic sanitisation) +ipcMain.handle('remap-project', (_event, oldPath, newPath) => { + try { + // Validate oldPath/newPath are strings (basic sanitisation) if (typeof oldPath !== 'string' || typeof newPath !== 'string') { return { error: 'Invalid arguments' }; } + // Re-check at handler entry: if oldPath came back, no remap is needed + if (fs.existsSync(oldPath)) { + return { error: 'Project path now exists — remap no longer needed' }; + } + + // Validate the new path exists and is a directory + let stat; + try { stat = fs.lstatSync(newPath); } catch { return { error: 'Path does not exist' }; } + if (!stat.isDirectory()) return { error: 'Path is not a directory' }; + // Find the session folder for the old project path using the same encoding the CLI uses const folder = encodeProjectPath(oldPath); const folderPath = path.join(PROJECTS_DIR, folder); if (!fs.existsSync(folderPath)) return { error: 'No session data found for this project' }; - // Rewrite cwd in all session JSONL files so `claude --resume` from CLI also works - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const filePath = path.join(folderPath, file); - const content = fs.readFileSync(filePath, 'utf8'); - const updated = content.split('\n').map(line => { - if (!line) return line; - try { - const parsed = JSON.parse(line); - if (parsed.cwd === oldPath) { - parsed.cwd = newPath; - return JSON.stringify(parsed); - } - } catch {} - return line; - }).join('\n'); - // Atomic write: write to tmp then rename to avoid partial updates - const tmp = filePath + '.tmp'; - fs.writeFileSync(tmp, updated); - fs.renameSync(tmp, filePath); + // Refuse if any active PTY session is running for this folder — rewriting + // files while a live claude process is appending them risks data loss + // (our snapshot + rename would silently drop lines appended between read + // and rename). The user must stop all sessions for this project first. + for (const [, session] of activeSessions) { + if (!session.exited && encodeProjectPath(session.projectPath) === folder) { + return { error: 'Active sessions for this project — stop them first' }; + } + } + + // Rewrite cwd in all session JSONL files (top-level + subagents) so + // `claude --resume` from CLI also picks up the new path. + const sessionFiles = enumerateSessionFiles(folderPath); + for (const { filePath } of sessionFiles) { + rewriteJsonlAtomic(filePath, oldPath, newPath); } // Refresh the folder cache so the new path takes effect in the UI diff --git a/test/remap-project.test.js b/test/remap-project.test.js index 2a90dc6a..3f33b4a0 100644 --- a/test/remap-project.test.js +++ b/test/remap-project.test.js @@ -2,8 +2,9 @@ * Tests for the remap-project JSONL atomic-rewrite logic. * * Exercises the core algorithm in isolation (no Electron IPC, no DB) by - * re-implementing the loop from the IPC handler against a real temp directory. - * This keeps tests fast (node:test, no jsdom) and fully deterministic. + * testing rewriteJsonlAtomic and the enumerateSessionFiles traversal against + * real temp directories. This keeps tests fast (node:test, no jsdom) and + * fully deterministic. */ const test = require('node:test'); @@ -12,6 +13,8 @@ const fs = require('fs'); const os = require('os'); const path = require('path'); +const { enumerateSessionFiles } = require('../read-session-file'); + // ── helpers ──────────────────────────────────────────────────────────────── function mkTmp() { @@ -23,14 +26,16 @@ function cleanup(dir) { } /** - * Core JSONL rewrite logic extracted from the remap-project IPC handler. - * Rewrites every cwd occurrence of oldPath → newPath across all .jsonl files - * in folderPath using atomic tmp+rename writes. + * Atomically rewrite cwd occurrences of oldPath → newPath in a single JSONL + * file. Uses a .tmp sibling + rename for crash safety. On any failure the .tmp + * orphan is cleaned up so it cannot block a future remap attempt. + * + * Kept in sync with the copy in main.js — this shared implementation is + * what both the IPC handler and these tests exercise. */ -function remapJsonlFolder(folderPath, oldPath, newPath) { - const jsonlFiles = fs.readdirSync(folderPath).filter(f => f.endsWith('.jsonl')); - for (const file of jsonlFiles) { - const filePath = path.join(folderPath, file); +function rewriteJsonlAtomic(filePath, oldPath, newPath) { + const tmp = filePath + '.tmp'; + try { const content = fs.readFileSync(filePath, 'utf8'); const updated = content.split('\n').map(line => { if (!line) return line; @@ -43,9 +48,22 @@ function remapJsonlFolder(folderPath, oldPath, newPath) { } catch {} return line; }).join('\n'); - const tmp = filePath + '.tmp'; fs.writeFileSync(tmp, updated); fs.renameSync(tmp, filePath); + } catch (err) { + try { fs.unlinkSync(tmp); } catch {} + throw err; + } +} + +/** + * Full folder remap using enumerateSessionFiles — mirrors the IPC handler + * so tests validate the exact same traversal logic used in production. + */ +function remapJsonlFolder(folderPath, oldPath, newPath) { + const sessionFiles = enumerateSessionFiles(folderPath); + for (const { filePath } of sessionFiles) { + rewriteJsonlAtomic(filePath, oldPath, newPath); } } @@ -213,3 +231,120 @@ test('non-.jsonl files in the folder are ignored', () => { cleanup(tmp); } }); + +test('subagent layout: rewrites cwd in subagents/agent-*.jsonl under a UUID subdir', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const parentSessionId = 'aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee'; + + // Top-level session + const topLine = JSON.stringify({ type: 'user', cwd: oldPath, sessionId: parentSessionId }); + fs.writeFileSync(path.join(tmp, parentSessionId + '.jsonl'), topLine + '\n'); + + // Subagent transcript under /subagents/agent-.jsonl + const subagentsDir = path.join(tmp, parentSessionId, 'subagents'); + fs.mkdirSync(subagentsDir, { recursive: true }); + const agentId = 'agent-00000000-1111-2222-3333-444444444444'; + const subLine = JSON.stringify({ type: 'user', cwd: oldPath, isSidechain: true, agentId }); + fs.writeFileSync(path.join(subagentsDir, agentId + '.jsonl'), subLine + '\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + // Top-level session updated + const topResult = JSON.parse( + fs.readFileSync(path.join(tmp, parentSessionId + '.jsonl'), 'utf8').trim() + ); + assert.equal(topResult.cwd, newPath, 'top-level session cwd should be updated'); + + // Subagent file updated + const subResult = JSON.parse( + fs.readFileSync(path.join(subagentsDir, agentId + '.jsonl'), 'utf8').trim() + ); + assert.equal(subResult.cwd, newPath, 'subagent cwd should be updated'); + } finally { + cleanup(tmp); + } +}); + +test('subagent layout (legacy): rewrites cwd in *.jsonl directly under UUID subdir', () => { + const tmp = mkTmp(); + try { + const oldPath = '/old/project'; + const newPath = '/new/project'; + const parentSessionId = 'aaaaaaaa-bbbb-cccc-dddd-ffffffffffff'; + + // Top-level session + fs.writeFileSync( + path.join(tmp, parentSessionId + '.jsonl'), + JSON.stringify({ type: 'user', cwd: oldPath }) + '\n' + ); + + // Legacy subagent layout: jsonl directly inside the UUID dir (no subagents/ subfolder) + const legacySubDir = path.join(tmp, parentSessionId); + fs.mkdirSync(legacySubDir, { recursive: true }); + const legacyLine = JSON.stringify({ type: 'user', cwd: oldPath, isSidechain: true }); + fs.writeFileSync(path.join(legacySubDir, 'legacy-agent.jsonl'), legacyLine + '\n'); + + remapJsonlFolder(tmp, oldPath, newPath); + + const legacyResult = JSON.parse( + fs.readFileSync(path.join(legacySubDir, 'legacy-agent.jsonl'), 'utf8').trim() + ); + assert.equal(legacyResult.cwd, newPath, 'legacy subagent cwd should be updated'); + } finally { + cleanup(tmp); + } +}); + +test('orphan tmp cleanup: rewriteJsonlAtomic removes .tmp on read error', () => { + const tmp = mkTmp(); + try { + const jsonlPath = path.join(tmp, 'missing.jsonl'); + // Do NOT create the file — readFileSync will throw ENOENT + assert.throws( + () => rewriteJsonlAtomic(jsonlPath, '/old', '/new'), + { code: 'ENOENT' } + ); + // The .tmp orphan must not be left behind + assert.ok(!fs.existsSync(jsonlPath + '.tmp'), '.tmp should be cleaned up on error'); + } finally { + cleanup(tmp); + } +}); + +test('active sessions guard: handler refuses remap when a session is running for the folder', () => { + // Simulate the guard logic used in main.js: + // for (const [, session] of activeSessions) { + // if (!session.exited && encodeProjectPath(session.projectPath) === folder) return error; + // } + const { encodeProjectPath } = require('../encode-project-path'); + const oldPath = '/old/active-project'; + const folder = encodeProjectPath(oldPath); + + // Build a fake activeSessions map with one live session for oldPath + const activeSessions = new Map([ + ['session-1', { exited: false, projectPath: oldPath }], + ]); + + let blocked = false; + for (const [, session] of activeSessions) { + if (!session.exited && encodeProjectPath(session.projectPath) === folder) { + blocked = true; + } + } + assert.ok(blocked, 'remap should be blocked when an active session exists for the folder'); + + // Exited sessions must NOT block + const exitedSessions = new Map([ + ['session-2', { exited: true, projectPath: oldPath }], + ]); + let blockedByExited = false; + for (const [, session] of exitedSessions) { + if (!session.exited && encodeProjectPath(session.projectPath) === folder) { + blockedByExited = true; + } + } + assert.ok(!blockedByExited, 'exited sessions must not block the remap'); +});