From e4daae017999a724c993b137e152fb8e228765fb Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Fri, 14 Aug 2026 15:07:52 -0400
Subject: [PATCH 01/10] Fix new Cline threads not showing up, add platform
prefix to exports, and opencode threads not showing up
---
README.md | 4 +-
bun.lock | 76 +--
src/client.test.ts | 8 +-
src/client.ts | 6 +-
src/lib/antigravity-db.test.ts | 5 +
src/lib/antigravity-exporter-types.ts | 3 +-
src/lib/cline-db.test.ts | 194 +++---
src/lib/cline-db.ts | 553 ++++++++++--------
src/lib/cline-exporter-types.ts | 33 +-
src/lib/cline-test-helpers.ts | 164 +++---
src/lib/cline-transcript.ts | 4 +-
src/lib/codex-browser-export.test.ts | 4 +-
src/lib/codex-browser-export.ts | 27 +-
src/lib/conversation-api.test.ts | 2 +-
src/lib/conversation-api.ts | 6 +-
.../conversation-data/cline-adapter.test.ts | 28 +-
src/lib/conversation-data/cline-adapter.ts | 30 +-
src/lib/conversation-data/index.test.ts | 4 +-
src/lib/conversation-data/types.ts | 2 +-
src/lib/conversation-zip-export.test.ts | 46 +-
src/lib/conversation-zip-export.ts | 24 +-
src/lib/cursor-db.test.ts | 54 ++
src/lib/cursor-db.ts | 174 +++++-
src/lib/opencode-db.test.ts | 54 ++
src/lib/opencode-db.ts | 105 +++-
src/lib/ui-export-archive.test.ts | 12 +
src/lib/ui-export-archive.ts | 31 +
src/ui-api-server.test.ts | 4 +-
src/ui/lib/antigravity-server.ts | 1 +
src/ui/lib/claude-code-server.ts | 2 +
src/ui/lib/cline-server.ts | 28 +-
src/ui/lib/cline-server.vitest.ts | 33 +-
src/ui/lib/cline-transcript-events.ts | 2 +-
src/ui/lib/cline-transcript-events.vitest.ts | 4 +-
src/ui/lib/cursor-server.ts | 33 +-
src/ui/lib/cursor-server.vitest.ts | 22 +
src/ui/lib/grok-server.ts | 2 +
src/ui/lib/kiro-server.ts | 2 +
src/ui/lib/minimax-code-server.ts | 2 +
src/ui/lib/opencode-server.ts | 2 +
src/ui/lib/qoder-server.ts | 2 +
src/ui/lib/source-session-export-server.ts | 21 +-
.../source-session-export-server.vitest.ts | 9 +-
43 files changed, 1202 insertions(+), 620 deletions(-)
diff --git a/README.md b/README.md
index b25750d..7c4b7da 100644
--- a/README.md
+++ b/README.md
@@ -134,12 +134,12 @@ Focused evidence is a deterministic, lossy Markdown export for qualitative DX an
| --- | --- | --- |
| Codex | shared Codex DB probe list | `SPIRACHA_CODEX_DB` |
| Claude Code | `~/.claude/projects` | `SPIRACHA_CLAUDE_CODE_PROJECTS_DIR` |
-| Cline | VS Code `User/globalStorage/saoudrizwan.claude-dev` | `SPIRACHA_CLINE_GLOBAL_STORAGE_DIR` |
+| Cline | `~/.cline/data/sessions` | `SPIRACHA_CLINE_DATA_DIR` |
| Grok | `~/.grok/sessions` | `SPIRACHA_GROK_SESSIONS_DIR` |
| Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/workspace-sessions` | `SPIRACHA_KIRO_WORKSPACE_SESSIONS_DIR` |
| Qoder | `~/Library/Application Support/Qoder/User/globalStorage/state.vscdb` and `~/Library/Application Support/Qoder/User/workspaceStorage` | `SPIRACHA_QODER_GLOBAL_STATE_DB`, `SPIRACHA_QODER_WORKSPACE_STORAGE_DIR` |
| Cursor | `~/Library/Application Support/Cursor/User` on macOS | `SPIRACHA_CURSOR_USER_DIR`, `SPIRACHA_CURSOR_PROJECTS_DIR` |
-| Antigravity | `~/.gemini/antigravity-ide` and `~/.gemini/antigravity` | `SPIRACHA_ANTIGRAVITY_DIRS`, `SPIRACHA_ANTIGRAVITY_DIR` |
+| Antigravity | `~/.gemini/antigravity-ide`, `~/.gemini/antigravity-cli`, and `~/.gemini/antigravity` | `SPIRACHA_ANTIGRAVITY_DIRS`, `SPIRACHA_ANTIGRAVITY_DIR` |
| MiniMax Code | `~/.minimax/v2/sessions` and `~/.minimax/v2/sqlite/runtime-state.sqlite` | `SPIRACHA_MINIMAX_CODE_SESSIONS_DIR`, `SPIRACHA_MINIMAX_CODE_RUNTIME_DB_PATH` |
| OpenCode | `${XDG_DATA_HOME:-~/.local/share}/opencode/opencode.db` | `SPIRACHA_OPENCODE_DB` |
| UI exports | OS temp directory under `spiracha-ui-exports` | `SPIRACHA_UI_EXPORT_DIR` |
diff --git a/bun.lock b/bun.lock
index ac5763a..73e0816 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,46 +5,46 @@
"": {
"name": "spiracha",
"dependencies": {
- "@tailwindcss/typography": "latest",
- "@tailwindcss/vite": "latest",
- "@tanstack/devtools-vite": "latest",
- "@tanstack/match-sorter-utils": "latest",
- "@tanstack/react-devtools": "latest",
- "@tanstack/react-query": "latest",
- "@tanstack/react-query-devtools": "latest",
- "@tanstack/react-router": "latest",
- "@tanstack/react-router-devtools": "latest",
- "@tanstack/react-router-ssr-query": "latest",
- "@tanstack/react-start": "latest",
- "@tanstack/react-table": "latest",
- "@tanstack/react-virtual": "latest",
- "@tanstack/router-plugin": "latest",
- "@vitejs/plugin-react": "latest",
- "class-variance-authority": "latest",
- "clsx": "latest",
- "fflate": "latest",
- "lucide-react": "latest",
- "radix-ui": "latest",
- "react": "latest",
- "react-dom": "latest",
- "tailwind-merge": "latest",
- "tailwindcss": "latest",
- "tw-animate-css": "latest",
- "vite": "latest",
- "zod": "latest",
+ "@tailwindcss/typography": "0.5.20",
+ "@tailwindcss/vite": "4.3.3",
+ "@tanstack/devtools-vite": "0.8.3",
+ "@tanstack/match-sorter-utils": "9.1.2",
+ "@tanstack/react-devtools": "0.10.10",
+ "@tanstack/react-query": "5.101.4",
+ "@tanstack/react-query-devtools": "5.101.4",
+ "@tanstack/react-router": "1.170.27",
+ "@tanstack/react-router-devtools": "1.167.1",
+ "@tanstack/react-router-ssr-query": "1.167.1",
+ "@tanstack/react-start": "1.168.44",
+ "@tanstack/react-table": "9.1.2",
+ "@tanstack/react-virtual": "3.14.9",
+ "@tanstack/router-plugin": "1.168.30",
+ "@vitejs/plugin-react": "6.0.5",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "fflate": "0.8.3",
+ "lucide-react": "1.31.0",
+ "radix-ui": "1.6.7",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "tailwind-merge": "3.6.0",
+ "tailwindcss": "4.3.3",
+ "tw-animate-css": "1.4.0",
+ "vite": "8.2.1",
+ "zod": "4.4.3",
},
"devDependencies": {
- "@biomejs/biome": "latest",
- "@testing-library/dom": "latest",
- "@testing-library/react": "latest",
- "@types/bun": "latest",
- "@types/node": "latest",
- "@types/react": "latest",
- "@types/react-dom": "latest",
- "@vitest/coverage-v8": "latest",
- "jsdom": "latest",
- "typescript": "latest",
- "vitest": "latest",
+ "@biomejs/biome": "2.5.8",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^26.2.0",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitest/coverage-v8": "4.1.10",
+ "jsdom": "30.0.1",
+ "typescript": "^7.0.2",
+ "vitest": "4.1.10",
},
},
},
diff --git a/src/client.test.ts b/src/client.test.ts
index dabab8d..154f5ef 100644
--- a/src/client.test.ts
+++ b/src/client.test.ts
@@ -134,7 +134,7 @@ describe('conversation client', () => {
locations: {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
- clineGlobalStorageDir: path.join(tempRoot, 'cline'),
+ clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
kiroWorkspaceSessionsDir: path.join(tempRoot, 'kiro'),
@@ -188,7 +188,7 @@ describe('conversation client', () => {
const locations = {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
- clineGlobalStorageDir: path.join(tempRoot, 'cline'),
+ clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
kiroWorkspaceSessionsDir: path.join(tempRoot, 'kiro'),
@@ -424,7 +424,7 @@ describe('conversation client', () => {
return new Response(new Blob(['zip-bytes'], { type: 'application/zip' }), {
headers: {
- 'Content-Disposition': "attachment; filename*=UTF-8''grok-conversations-2.zip",
+ 'Content-Disposition': "attachment; filename*=UTF-8''grok_repo-1970-01-01-0000-threads-2.zip",
'Content-Type': 'application/zip',
},
});
@@ -444,7 +444,7 @@ describe('conversation client', () => {
});
expect(download).not.toBeNull();
- expect(download!.fileName).toBe('grok-conversations-2.zip');
+ expect(download!.fileName).toBe('grok_repo-1970-01-01-0000-threads-2.zip');
expect(download!.mimeType).toBe('application/zip');
await expect(download!.blob.text()).resolves.toBe('zip-bytes');
expect(requests).toEqual([
diff --git a/src/client.ts b/src/client.ts
index 1e1f684..95594a5 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -29,6 +29,7 @@ import type {
ResolvedConversationRef,
} from './lib/conversation-data/types';
import { createConversationMarkdownZip } from './lib/conversation-zip-export';
+import { getExportPlatformName } from './lib/ui-export-archive';
export type {
ConversationDataLocations,
@@ -370,14 +371,17 @@ const exportLocalConversationsZip = async (
entries: conversations.map((conversation, index) => {
const resolvedConversation = conversation!;
return {
+ cwd: resolvedConversation.workspacePath,
fallbackBaseName: `${options.source}-${options.ids[index]}`,
markdown: renderLocalConversationMarkdown(resolvedConversation, {
messageSelector: options.messageSelector ?? 'all',
}),
title: resolvedConversation.title,
+ updatedAtMs: resolvedConversation.updatedAtMs,
};
}),
- fileBaseName: `${options.source}-conversations-${options.ids.length}`,
+ fallbackProjectName: `${options.source}-conversations`,
+ platform: getExportPlatformName(options.source),
});
};
diff --git a/src/lib/antigravity-db.test.ts b/src/lib/antigravity-db.test.ts
index f1d5bcf..15f3627 100644
--- a/src/lib/antigravity-db.test.ts
+++ b/src/lib/antigravity-db.test.ts
@@ -12,6 +12,7 @@ import {
renderAntigravityArtifactsMarkdown,
renderAntigravityConversationMarkdown,
} from './antigravity-db';
+import { resolveAntigravityRoots } from './antigravity-exporter-types';
import { ANTIGRAVITY_TRANSCRIPT_MARKDOWN_VERSION } from './antigravity-transcript-contract';
import { antigravityMarkdownToThreadEvents } from './antigravity-transcript-events';
@@ -276,6 +277,10 @@ const runGit = async (cwd: string, args: string[]): Promise => {
};
describe('antigravity db discovery', () => {
+ it('should include the Antigravity CLI root in default discovery roots', () => {
+ expect(resolveAntigravityRoots()).toContain(path.join(os.homedir(), '.gemini', 'antigravity-cli'));
+ });
+
it('should resolve one conversation without requiring a collection scan', async () => {
const root = await makeRoot();
const conversationId = '12111111-1111-4111-8111-111111111111';
diff --git a/src/lib/antigravity-exporter-types.ts b/src/lib/antigravity-exporter-types.ts
index afcd382..180f696 100644
--- a/src/lib/antigravity-exporter-types.ts
+++ b/src/lib/antigravity-exporter-types.ts
@@ -2,6 +2,7 @@ import os from 'node:os';
import path from 'node:path';
export const DEFAULT_ANTIGRAVITY_IDE_DIR = path.join(os.homedir(), '.gemini', 'antigravity-ide');
+export const DEFAULT_ANTIGRAVITY_CLI_DIR = path.join(os.homedir(), '.gemini', 'antigravity-cli');
export const DEFAULT_ANTIGRAVITY_DIR = path.join(os.homedir(), '.gemini', 'antigravity');
export const resolveAntigravityRoots = (): string[] => {
@@ -13,7 +14,7 @@ export const resolveAntigravityRoots = (): string[] => {
.filter(Boolean);
}
- return [DEFAULT_ANTIGRAVITY_IDE_DIR, DEFAULT_ANTIGRAVITY_DIR];
+ return [DEFAULT_ANTIGRAVITY_IDE_DIR, DEFAULT_ANTIGRAVITY_CLI_DIR, DEFAULT_ANTIGRAVITY_DIR];
};
export const getAntigravityConversationDir = (root: string): string => path.join(root, 'conversations');
diff --git a/src/lib/cline-db.test.ts b/src/lib/cline-db.test.ts
index 9f0a808..6df0429 100644
--- a/src/lib/cline-db.test.ts
+++ b/src/lib/cline-db.test.ts
@@ -1,15 +1,17 @@
+import { Database } from 'bun:sqlite';
import { afterEach, describe, expect, it } from 'bun:test';
-import { mkdtemp, rm } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
deleteClineTask,
- getDefaultClineGlobalStorageDir,
+ getDefaultClineDataDir,
listClineTasksForGroup,
listClineWorkspaceGroups,
readClineTaskTranscript,
+ resolveClineDataDir,
} from './cline-db';
-import { CLINE_TASK_ID, writeClineTaskFixture } from './cline-test-helpers';
+import { CLINE_SESSION_ID, writeClineSessionFixture } from './cline-test-helpers';
import { renderClineTranscript } from './cline-transcript';
const tempRoots: string[] = [];
@@ -20,69 +22,70 @@ const makeTempRoot = async () => {
return root;
};
-describe('Cline task storage', () => {
+describe('Cline session storage', () => {
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
});
- it('should resolve platform-specific VS Code global storage defaults', () => {
- expect(getDefaultClineGlobalStorageDir({}, '/home/tester', 'linux')).toBe(
- '/home/tester/.config/Code/User/globalStorage/saoudrizwan.claude-dev',
+ it('should resolve the current Cline data directory by default', () => {
+ expect(getDefaultClineDataDir('/Users/tester')).toBe('/Users/tester/.cline/data');
+ expect(resolveClineDataDir({}, '/Users/tester')).toBe('/Users/tester/.cline/data');
+ expect(resolveClineDataDir({ SPIRACHA_CLINE_DATA_DIR: '/tmp/cline-data' }, '/Users/tester')).toBe(
+ '/tmp/cline-data',
);
- expect(getDefaultClineGlobalStorageDir({ XDG_CONFIG_HOME: '/config' }, '/home/tester', 'linux')).toBe(
- '/config/Code/User/globalStorage/saoudrizwan.claude-dev',
- );
- expect(getDefaultClineGlobalStorageDir({}, '/Users/tester', 'darwin')).toBe(
- '/Users/tester/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev',
- );
- expect(
- getDefaultClineGlobalStorageDir(
- { APPDATA: 'C:\\Users\\test\\AppData\\Roaming' },
- 'C:\\Users\\test',
- 'win32',
- ),
- ).toContain('saoudrizwan.claude-dev');
});
- it('should discover Cline tasks from task history and visible UI messages', async () => {
+ it('should discover current Cline sessions across multiple workspaces', async () => {
const root = await makeTempRoot();
- const workspacePath = path.join(root, 'repo');
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- await writeClineTaskFixture({ globalStorageDir, workspacePath });
-
- const groups = await listClineWorkspaceGroups(globalStorageDir);
- expect(groups).toEqual([
- expect.objectContaining({
- assistantMessageCount: 2,
- label: 'repo',
- messageCount: 3,
- reasoningCount: 1,
- taskCount: 1,
- toolCallCount: 2,
- toolResultCount: 2,
- worktree: workspacePath,
- }),
- ]);
+ const dataDir = path.join(root, 'cline-data');
+ const kaluWorkspacePath = path.join(root, 'kalu');
+ const researchWorkspacePath = path.join(root, 'research');
+ await writeClineSessionFixture({
+ dataDir,
+ sessionId: '1786126367448',
+ title: 'TRADE-DR-001',
+ workspacePath: kaluWorkspacePath,
+ });
+ await writeClineSessionFixture({
+ dataDir,
+ sessionId: '1786145275579_tx573',
+ title: 'TRADE-DR-012',
+ workspacePath: researchWorkspacePath,
+ });
+ await writeClineSessionFixture({
+ dataDir,
+ sessionId: '1786163719044_tpg1r',
+ title: 'TRADE-DR-018',
+ workspacePath: researchWorkspacePath,
+ });
- const tasks = await listClineTasksForGroup(groups[0]!.key, globalStorageDir);
- expect(tasks).toEqual([
- expect.objectContaining({
- isFavorited: true,
- modelId: 'anthropic/claude-sonnet-4',
- taskId: CLINE_TASK_ID,
- title: 'Fix issue 1494 per the implementation plan',
- worktree: workspacePath,
- }),
- ]);
+ const groups = await listClineWorkspaceGroups(dataDir);
+ expect(groups).toEqual(
+ expect.arrayContaining([
+ expect.objectContaining({ taskCount: 1, worktree: kaluWorkspacePath }),
+ expect.objectContaining({ taskCount: 2, worktree: researchWorkspacePath }),
+ ]),
+ );
+
+ const researchGroup = groups.find((group) => group.worktree === researchWorkspacePath);
+ expect(researchGroup).toBeDefined();
+ const tasks = await listClineTasksForGroup(researchGroup!.key, dataDir);
+ expect(tasks.map((task) => task.title).sort()).toEqual(['TRADE-DR-012', 'TRADE-DR-018']);
+ expect(tasks.find((task) => task.taskId === '1786163719044_tpg1r')).toMatchObject({
+ messagesPath: path.join(dataDir, 'sessions', '1786163719044_tpg1r', '1786163719044_tpg1r.messages.json'),
+ sessionDir: path.join(dataDir, 'sessions', '1786163719044_tpg1r'),
+ taskId: '1786163719044_tpg1r',
+ worktree: researchWorkspacePath,
+ });
});
- it('should parse user, reasoning, commentary, command, tool, and final-answer events', async () => {
+ it('should parse current session messages into normalized transcript phases', async () => {
const root = await makeTempRoot();
+ const dataDir = path.join(root, 'cline-data');
const workspacePath = path.join(root, 'repo');
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- await writeClineTaskFixture({ globalStorageDir, workspacePath });
+ await writeClineSessionFixture({ dataDir, workspacePath });
- const transcript = await readClineTaskTranscript(globalStorageDir, CLINE_TASK_ID);
+ const transcript = await readClineTaskTranscript(dataDir, CLINE_SESSION_ID);
expect(transcript?.messages.map(({ phase, role, text }) => ({ phase, role, text }))).toEqual([
{ phase: 'unknown', role: 'user', text: 'Fix issue 1494 per the implementation plan' },
{
@@ -94,48 +97,51 @@ describe('Cline task storage', () => {
{
phase: 'tool_call',
role: 'assistant',
- text: 'bun test src/vendor-proof-lifecycle.test.ts',
+ text: 'run_commands: {"commands":["bun test src/vendor-proof-lifecycle.test.ts"]}',
},
{ phase: 'tool_output', role: 'tool', text: '57 pass\n0 fail' },
- {
- phase: 'tool_call',
- role: 'assistant',
- text: expect.stringContaining('readFile'),
- },
- { phase: 'tool_output', role: 'tool', text: 'const result = true;' },
{
phase: 'final_answer',
role: 'assistant',
- text: 'Implemented the 1494 fix per 1494-PLAN.md.',
+ text: 'Implemented Fix issue 1494 per the implementation plan.',
},
]);
expect(transcript?.messages.find((message) => message.phase === 'tool_call')?.tool).toMatchObject({
- command: 'bun test src/vendor-proof-lifecycle.test.ts',
- name: 'execute_command',
- status: 'succeeded',
+ callId: `${CLINE_SESSION_ID}-tool-1`,
+ inputText: '{"commands":["bun test src/vendor-proof-lifecycle.test.ts"]}',
+ name: 'run_commands',
+ status: 'unknown',
workdir: workspacePath,
});
});
- it('should delete the task directory and its task-history record', async () => {
+ it('should delete the current Cline session directory', async () => {
const root = await makeTempRoot();
- const workspacePath = path.join(root, 'repo');
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- const fixture = await writeClineTaskFixture({ globalStorageDir, workspacePath });
-
- const result = await deleteClineTask(globalStorageDir, CLINE_TASK_ID);
-
- expect(result.deletedTaskIds).toEqual([CLINE_TASK_ID]);
- expect(result.deletedFiles).toContain(fixture.taskDir);
- expect(await Bun.file(fixture.uiMessagesPath).exists()).toBe(false);
- expect(await Bun.file(fixture.taskHistoryPath).json()).toEqual([]);
+ const dataDir = path.join(root, 'cline-data');
+ const fixture = await writeClineSessionFixture({ dataDir, workspacePath: path.join(root, 'repo') });
+ const databasePath = path.join(dataDir, 'db', 'sessions.db');
+ await mkdir(path.dirname(databasePath), { recursive: true });
+ const database = new Database(databasePath);
+ database.run('CREATE TABLE sessions (session_id TEXT PRIMARY KEY)');
+ database.prepare('INSERT INTO sessions (session_id) VALUES (?)').run(CLINE_SESSION_ID);
+ database.close();
+
+ const result = await deleteClineTask(dataDir, CLINE_SESSION_ID);
+
+ expect(result.deletedTaskIds).toEqual([CLINE_SESSION_ID]);
+ expect(result.deletedFiles).toEqual([fixture.sessionDir, databasePath]);
+ expect(await Bun.file(fixture.messagesPath).exists()).toBe(false);
+ const remainingDatabase = new Database(databasePath, { readonly: true });
+ expect(remainingDatabase.query('SELECT session_id FROM sessions').all()).toEqual([]);
+ remainingDatabase.close();
+ await expect(readClineTaskTranscript(dataDir, CLINE_SESSION_ID)).resolves.toBeNull();
});
- it('should render Markdown and plain-text exports with configurable sections', async () => {
+ it('should render current-session Markdown and plain-text exports', async () => {
const root = await makeTempRoot();
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- await writeClineTaskFixture({ globalStorageDir, workspacePath: path.join(root, 'repo') });
- const transcript = await readClineTaskTranscript(globalStorageDir, CLINE_TASK_ID);
+ const dataDir = path.join(root, 'cline-data');
+ await writeClineSessionFixture({ dataDir, workspacePath: path.join(root, 'repo') });
+ const transcript = await readClineTaskTranscript(dataDir, CLINE_SESSION_ID);
expect(transcript).not.toBeNull();
const markdown = renderClineTranscript(transcript!, {
@@ -145,7 +151,7 @@ describe('Cline task storage', () => {
outputFormat: 'md',
});
expect(markdown).toContain('# Fix issue 1494 per the implementation plan');
- expect(markdown).toContain('exported_from: "cline_ui_messages"');
+ expect(markdown).toContain('exported_from: "cline_session_messages"');
expect(markdown).toContain('## Assistant (Final)');
expect(markdown).not.toContain('protected surface policy');
expect(markdown).not.toContain('Tool Call');
@@ -161,13 +167,31 @@ describe('Cline task storage', () => {
expect(text).not.toContain('exported_from:');
});
- it('should return empty results for malformed task keys and missing storage', async () => {
+ it('should ignore the removed VS Code task-history format', async () => {
+ const root = await makeTempRoot();
+ const dataDir = path.join(root, 'cline-data');
+ await mkdir(path.join(dataDir, 'state'), { recursive: true });
+ await Bun.write(
+ path.join(dataDir, 'state', 'taskHistory.json'),
+ JSON.stringify([
+ {
+ cwdOnTaskInitialization: path.join(root, 'legacy-repo'),
+ id: '1785560414951',
+ task: 'Legacy task that must not be loaded',
+ },
+ ]),
+ );
+
+ await expect(listClineWorkspaceGroups(dataDir)).resolves.toEqual([]);
+ });
+
+ it('should return empty results for malformed session keys and missing data', async () => {
const root = await makeTempRoot();
- const globalStorageDir = path.join(root, 'missing');
- await expect(listClineWorkspaceGroups(globalStorageDir)).resolves.toEqual([]);
- await expect(listClineTasksForGroup('invalid', globalStorageDir)).resolves.toEqual([]);
- await expect(readClineTaskTranscript(globalStorageDir, '../unsafe')).resolves.toBeNull();
- await expect(deleteClineTask(globalStorageDir, '../unsafe')).resolves.toEqual({
+ const dataDir = path.join(root, 'missing');
+ await expect(listClineWorkspaceGroups(dataDir)).resolves.toEqual([]);
+ await expect(listClineTasksForGroup('invalid', dataDir)).resolves.toEqual([]);
+ await expect(readClineTaskTranscript(dataDir, '../unsafe')).resolves.toBeNull();
+ await expect(deleteClineTask(dataDir, '../unsafe')).resolves.toEqual({
deletedFiles: [],
deletedTaskIds: [],
});
diff --git a/src/lib/cline-db.ts b/src/lib/cline-db.ts
index 26edca6..f5af832 100644
--- a/src/lib/cline-db.ts
+++ b/src/lib/cline-db.ts
@@ -1,5 +1,5 @@
-import { randomUUID } from 'node:crypto';
-import { mkdir, rename, rm } from 'node:fs/promises';
+import { Database } from 'bun:sqlite';
+import { rm } from 'node:fs/promises';
import path from 'node:path';
import type {
ClineTaskSummary,
@@ -9,12 +9,20 @@ import type {
ClineWorkspaceGroup,
DeleteClineTaskResult,
} from './cline-exporter-types';
-import { getDefaultClineGlobalStorageDir, resolveClineGlobalStorageDir } from './cline-exporter-types';
+import { resolveClineDataDir } from './cline-exporter-types';
import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
import { getPortablePathBasename } from './portable-path';
-import { asBoolean, asNumber, asObject, asString, cleanInlineTitle, type JsonValue } from './shared';
+import {
+ asBoolean,
+ asNumber,
+ asObject,
+ asString,
+ cleanInlineTitle,
+ type JsonValue,
+ readDirectoryEntriesIfExists,
+} from './shared';
-export { getDefaultClineGlobalStorageDir, resolveClineGlobalStorageDir };
+export { getDefaultClineDataDir, resolveClineDataDir } from './cline-exporter-types';
const WORKSPACE_KEY_PREFIX = 'workspace:';
const READ_CONCURRENCY = 8;
@@ -22,19 +30,20 @@ const clineDeleteLimiter = createConcurrencyLimiter(1);
type ReadTranscriptOptions = { includeRawPayloads?: boolean };
-type TaskHistoryEntry = {
+type ClineSessionEntry = {
cacheReads: number | null;
cacheWrites: number | null;
cwd: string;
id: string;
isFavorited: boolean;
+ messagesPath: string;
modelId: string | null;
- task: string;
+ sessionDir: string;
+ title: string;
tokensIn: number | null;
tokensOut: number | null;
totalCost: number | null;
- ts: number | null;
- ulid: string | null;
+ updatedAtMs: number | null;
};
type ParsedStats = {
@@ -46,8 +55,24 @@ type ParsedStats = {
userMessageCount: number;
};
+type ClineStoredMessage = {
+ content: JsonValue[];
+ id: string | null;
+ role: string | null;
+ ts: number | null;
+};
+
+type ClineSessionHeader = {
+ cwd: string;
+ metadata: Record | null;
+ raw: Record;
+ sessionId: string;
+};
+
const getWorkspaceKey = (worktree: string) => `${WORKSPACE_KEY_PREFIX}${encodeURIComponent(worktree)}`;
+const isSafeSessionId = (sessionId: string): boolean => /^[A-Za-z0-9_-]+$/u.test(sessionId);
+
const getWorktreeFromWorkspaceKey = (workspaceKey: string): string | null => {
if (!workspaceKey.startsWith(WORKSPACE_KEY_PREFIX)) {
return null;
@@ -65,244 +90,299 @@ const readJson = async (filePath: string): Promise => {
.catch(() => null)) as JsonValue | null;
};
-const parseTaskHistoryEntry = (value: JsonValue): TaskHistoryEntry | null => {
+const timestampFromJson = (value: JsonValue | null): number | null => {
+ const numeric = asNumber(value);
+ if (numeric !== null) {
+ return numeric;
+ }
+ const text = asString(value);
+ if (!text) {
+ return null;
+ }
+ const timestamp = Date.parse(text);
+ return Number.isNaN(timestamp) ? null : timestamp;
+};
+
+const textFromClineValue = (value: JsonValue | null): string => {
+ if (typeof value === 'string') {
+ return value;
+ }
+ if (Array.isArray(value)) {
+ return value
+ .map((item) => textFromClineValue(item))
+ .filter(Boolean)
+ .join('\n');
+ }
const raw = asObject(value);
- const id = asString(raw?.id ?? null)?.trim();
- const cwd = asString(raw?.cwdOnTaskInitialization ?? null)?.trim();
- if (!raw || !id || !cwd) {
+ if (!raw) {
+ return value === null ? '' : JSON.stringify(value);
+ }
+ for (const key of ['text', 'thinking', 'result', 'error']) {
+ const text = asString(raw[key] ?? null);
+ if (text !== null) {
+ return text;
+ }
+ }
+ if (raw.content !== undefined) {
+ return textFromClineValue(raw.content);
+ }
+ return Object.entries(raw)
+ .map(([key, item]) => `${key}: ${textFromClineValue(item)}`)
+ .join('\n');
+};
+
+const valueAsText = (value: JsonValue | null): string | null => {
+ if (value === null) {
+ return null;
+ }
+ if (typeof value === 'string') {
+ return value;
+ }
+ return JSON.stringify(value);
+};
+
+const parseSessionHeader = (value: JsonValue, directoryName: string): ClineSessionHeader | null => {
+ const raw = asObject(value);
+ if (!raw) {
+ return null;
+ }
+ const sessionId = asString(raw.session_id ?? null)?.trim() || directoryName;
+ const cwd = asString(raw.workspace_root ?? raw.cwd ?? null)?.trim();
+ if (!cwd) {
return null;
}
+ if (sessionId !== directoryName) {
+ return null;
+ }
+ if (!isSafeSessionId(sessionId)) {
+ return null;
+ }
+ return { cwd, metadata: asObject(raw.metadata ?? null), raw, sessionId };
+};
+
+const metadataValue = (metadata: Record | null, key: string): JsonValue | null =>
+ metadata?.[key] ?? null;
+
+const parseSessionEntry = (value: JsonValue, dataDir: string, directoryName: string): ClineSessionEntry | null => {
+ const header = parseSessionHeader(value, directoryName);
+ if (!header) {
+ return null;
+ }
+ const { cwd, metadata, raw, sessionId } = header;
+ const sessionDir = path.join(dataDir, 'sessions', directoryName);
return {
- cacheReads: asNumber(raw.cacheReads ?? null),
- cacheWrites: asNumber(raw.cacheWrites ?? null),
+ cacheReads: asNumber(metadataValue(metadata, 'cacheReads')),
+ cacheWrites: asNumber(metadataValue(metadata, 'cacheWrites')),
cwd,
- id,
- isFavorited: asBoolean(raw.isFavorited ?? null),
- modelId: asString(raw.modelId ?? null),
- task: asString(raw.task ?? null) ?? id,
- tokensIn: asNumber(raw.tokensIn ?? null),
- tokensOut: asNumber(raw.tokensOut ?? null),
- totalCost: asNumber(raw.totalCost ?? null),
- ts: asNumber(raw.ts ?? null),
- ulid: asString(raw.ulid ?? null),
+ id: sessionId,
+ isFavorited: asBoolean(metadataValue(metadata, 'isFavorited')),
+ messagesPath: path.join(sessionDir, `${directoryName}.messages.json`),
+ modelId: asString(metadataValue(metadata, 'modelId')) ?? asString(raw.model ?? null),
+ sessionDir,
+ title:
+ cleanInlineTitle(asString(metadataValue(metadata, 'title')) ?? asString(raw.prompt ?? null) ?? '') ||
+ sessionId,
+ tokensIn: asNumber(metadataValue(metadata, 'tokensIn')),
+ tokensOut: asNumber(metadataValue(metadata, 'tokensOut')),
+ totalCost: asNumber(metadataValue(metadata, 'totalCost')),
+ updatedAtMs:
+ timestampFromJson(raw.ended_at ?? null) ??
+ timestampFromJson(raw.updated_at ?? null) ??
+ timestampFromJson(raw.started_at ?? null),
};
};
-const readTaskHistory = async (globalStorageDir: string): Promise => {
- const value = await readJson(path.join(globalStorageDir, 'state', 'taskHistory.json'));
- return Array.isArray(value) ? value.flatMap((entry) => parseTaskHistoryEntry(entry) ?? []) : [];
+const listClineSessionEntries = async (dataDir: string): Promise => {
+ const sessionDirectories = (await readDirectoryEntriesIfExists(path.join(dataDir, 'sessions'))).filter(
+ (entry) => entry.isDirectory() && isSafeSessionId(entry.name),
+ );
+ const entries = await mapWithConcurrency(sessionDirectories, READ_CONCURRENCY, async (directory) =>
+ parseSessionEntry(
+ await readJson(path.join(dataDir, 'sessions', directory.name, `${directory.name}.json`)),
+ dataDir,
+ directory.name,
+ ),
+ );
+ return entries.flatMap((entry) => entry ?? []);
};
-const toolStatusFromCommand = (raw: Record): ClineToolEvidence['status'] => {
- if (raw.commandCompleted === true) {
- return 'succeeded';
+const parseStoredMessage = (value: JsonValue): ClineStoredMessage | null => {
+ const raw = asObject(value);
+ if (!raw || !Array.isArray(raw.content)) {
+ return null;
}
- return raw.commandCompleted === false ? 'failed' : 'unknown';
+ return {
+ content: raw.content,
+ id: asString(raw.id ?? null),
+ role: asString(raw.role ?? null),
+ ts: timestampFromJson(raw.ts ?? null),
+ };
};
-const textFromToolInput = (raw: Record): string | null => {
- const fields = Object.entries(raw)
- .filter(([key]) => !['content', 'tool'].includes(key))
- .map(([key, value]) => `${key}: ${typeof value === 'string' ? value : JSON.stringify(value)}`);
- return fields.length ? fields.join('\n') : null;
-};
+const rawPart = (part: Record, message: ClineStoredMessage): Record => ({
+ ...part,
+ messageId: message.id,
+ messageRole: message.role,
+});
type ClineMessageFields = Omit;
-const simpleMessageFields = (say: string | null, text: string): ClineMessageFields | null => {
- const definitions: Record> = {
- completion_result: { phase: 'final_answer', role: 'assistant' },
- reasoning: { phase: 'reasoning', role: 'assistant' },
- resume_task: { phase: 'unknown', role: 'user' },
- task: { phase: 'unknown', role: 'user' },
- text: { phase: 'commentary', role: 'assistant' },
- user_feedback: { phase: 'unknown', role: 'user' },
- };
- const definition = say ? definitions[say] : null;
- return definition ? { ...definition, text, tool: null } : null;
+const roleForStoredMessage = (role: string | null): ClineTranscriptMessage['role'] => {
+ if (role === 'assistant') {
+ return 'assistant';
+ }
+ if (role === 'user') {
+ return 'user';
+ }
+ return 'unknown';
};
-const commandMessageFields = (
- raw: Record,
- text: string,
- callId: string,
- worktree: string,
- includeRawPayloads: boolean,
-): ClineMessageFields => ({
- phase: 'tool_call',
+const parseThinkingPart = (part: Record): ClineMessageFields => ({
+ phase: 'reasoning',
role: 'assistant',
- text,
- tool: {
- callId,
- command: text,
- inputText: text,
- name: 'execute_command',
- outputText: null,
- raw: includeRawPayloads ? raw : {},
- status: toolStatusFromCommand(raw),
- workdir: worktree,
- },
+ text: textFromClineValue(part.thinking ?? part.text ?? null),
+ tool: null,
});
-const commandOutputFields = (
- raw: Record,
- text: string,
- callId: string,
- worktree: string,
- includeRawPayloads: boolean,
-): ClineMessageFields => ({
- phase: 'tool_output',
- role: 'tool',
- text,
- tool: {
- callId,
- command: null,
- inputText: null,
- name: 'execute_command',
- outputText: text,
- raw: includeRawPayloads ? raw : {},
- status: 'unknown',
- workdir: worktree,
- },
-});
+const parseTextPart = (part: Record, message: ClineStoredMessage): ClineMessageFields => {
+ const role = roleForStoredMessage(message.role);
+ return {
+ phase: role === 'assistant' ? 'commentary' : 'unknown',
+ role,
+ text: textFromClineValue(part.text ?? null),
+ tool: null,
+ };
+};
-const parseJsonObject = (text: string): Record | null => {
- try {
- return asObject(JSON.parse(text) as JsonValue);
- } catch {
- return null;
- }
+const parseToolUsePart = (
+ part: Record,
+ message: ClineStoredMessage,
+ worktree: string,
+): ClineMessageFields => {
+ const callId = asString(part.id ?? null) || `cline-tool-${message.id ?? 'unknown'}`;
+ const inputText = valueAsText(part.input ?? null);
+ const name = asString(part.name ?? null)?.trim() || 'unknown';
+ return {
+ phase: 'tool_call',
+ role: 'assistant',
+ text: [name, inputText].filter(Boolean).join(': '),
+ tool: {
+ callId,
+ command: null,
+ inputText,
+ name,
+ outputText: null,
+ raw: rawPart(part, message),
+ status: 'unknown',
+ workdir: worktree,
+ },
+ };
};
-const toolMessageFields = (
- raw: Record,
- text: string,
- callId: string,
+const parseToolResultPart = (
+ part: Record,
+ message: ClineStoredMessage,
worktree: string,
- includeRawPayloads: boolean,
-): ClineMessageFields[] => {
- const parsed = parseJsonObject(text);
- const toolName = asString(parsed?.tool ?? null)?.trim() || 'unknown';
- const outputText = asString(parsed?.content ?? null)?.trim() || null;
- const inputText = parsed ? textFromToolInput(parsed) : text;
+): ClineMessageFields => {
+ const callId = asString(part.tool_use_id ?? null) || `cline-tool-result-${message.id ?? 'unknown'}`;
+ const outputText = textFromClineValue(part.content ?? part.result ?? null);
const tool: ClineToolEvidence = {
callId,
command: null,
- inputText,
- name: toolName,
- outputText: null,
- raw: includeRawPayloads ? (parsed ?? raw) : {},
- status: outputText ? 'succeeded' : 'unknown',
+ inputText: null,
+ name: asString(part.name ?? null)?.trim() || 'unknown',
+ outputText: outputText || null,
+ raw: rawPart(part, message),
+ status: part.is_error === true ? 'failed' : 'succeeded',
workdir: worktree,
};
- const messages: ClineMessageFields[] = [
- {
- phase: 'tool_call',
- role: 'assistant',
- text: [toolName, inputText].filter(Boolean).join(': '),
- tool,
- },
- ];
- if (outputText) {
- messages.push({
- phase: 'tool_output',
- role: 'tool',
- text: outputText,
- tool: { ...tool, inputText: null, outputText },
- });
- }
- return messages;
-};
-
-type ParsedUiItem = {
- fields: ClineMessageFields[];
- pendingCommandCallId: string | null;
- raw: Record | null;
+ return {
+ phase: 'tool_output',
+ role: 'tool',
+ text: outputText,
+ tool,
+ };
};
-const classifyUiMessage = (
- raw: Record,
- text: string,
- index: number,
- pendingCommandCallId: string | null,
+const parseStoredPart = (
+ part: Record,
+ message: ClineStoredMessage,
worktree: string,
- includeRawPayloads: boolean,
-): Pick => {
- const say = asString(raw.say ?? null);
- const simpleFields = simpleMessageFields(say, text);
- if (simpleFields) {
- return { fields: [simpleFields], pendingCommandCallId };
- }
- if (say === 'command') {
- const callId = `cline-command-${asNumber(raw.ts ?? null) ?? index}`;
- return {
- fields: [commandMessageFields(raw, text, callId, worktree, includeRawPayloads)],
- pendingCommandCallId: callId,
- };
+): ClineMessageFields | null => {
+ switch (asString(part.type ?? null)) {
+ case 'thinking':
+ return parseThinkingPart(part);
+ case 'text':
+ return parseTextPart(part, message);
+ case 'tool_use':
+ return parseToolUsePart(part, message, worktree);
+ case 'tool_result':
+ return parseToolResultPart(part, message, worktree);
+ default:
+ return null;
}
- if (asString(raw.ask ?? null) === 'command_output') {
- const callId = pendingCommandCallId ?? `cline-command-output-${asNumber(raw.ts ?? null) ?? index}`;
- return {
- fields: [commandOutputFields(raw, text, callId, worktree, includeRawPayloads)],
- pendingCommandCallId: null,
- };
- }
- if (say === 'tool') {
- const callId = `cline-tool-${asNumber(raw.ts ?? null) ?? index}`;
- return {
- fields: toolMessageFields(raw, text, callId, worktree, includeRawPayloads),
- pendingCommandCallId,
- };
- }
- return { fields: [], pendingCommandCallId };
};
-const parseUiItem = (
- item: JsonValue,
- index: number,
- pendingCommandCallId: string | null,
+const parseSessionMessages = (
+ value: JsonValue,
worktree: string,
+ sessionId: string,
includeRawPayloads: boolean,
-): ParsedUiItem => {
- const raw = asObject(item);
- const text = asString(raw?.text ?? null)?.trim();
- if (!raw || !text) {
- return { fields: [], pendingCommandCallId, raw: null };
- }
- return {
- ...classifyUiMessage(raw, text, index, pendingCommandCallId, worktree, includeRawPayloads),
- raw,
- };
-};
-
-const parseUiMessages = (value: JsonValue, worktree: string, includeRawPayloads: boolean): ClineTranscriptMessage[] => {
- if (!Array.isArray(value)) {
+): ClineTranscriptMessage[] => {
+ const envelope = asObject(value);
+ if (!Array.isArray(envelope?.messages)) {
return [];
}
const messages: ClineTranscriptMessage[] = [];
- let pendingCommandCallId: string | null = null;
- const addMessage = (raw: Record, index: number, fields: ClineMessageFields) => {
- const createdAtMs = asNumber(raw.ts ?? null);
- messages.push({
- createdAtMs,
- messageId: `cline-${createdAtMs ?? 'unknown'}-${index}-${messages.length}`,
- raw: includeRawPayloads ? raw : {},
- ...fields,
- });
- };
-
- value.forEach((item, index) => {
- const parsed = parseUiItem(item, index, pendingCommandCallId, worktree, includeRawPayloads);
- pendingCommandCallId = parsed.pendingCommandCallId;
- parsed.fields.forEach((fields) => {
- if (parsed.raw) {
- addMessage(parsed.raw, index, fields);
+ envelope.messages.forEach((value, messageIndex) => {
+ const message = parseStoredMessage(value);
+ if (!message) {
+ return;
+ }
+ const rawMessage = asObject(value);
+ if (!rawMessage) {
+ return;
+ }
+ message.content.forEach((partValue, partIndex) => {
+ const part = asObject(partValue);
+ if (!part) {
+ return;
}
+ const fields = parseStoredPart(part, message, worktree);
+ if (!fields?.text) {
+ return;
+ }
+ messages.push({
+ createdAtMs: message.ts,
+ messageId: `cline-${sessionId}-${message.id ?? messageIndex}-${partIndex}`,
+ raw: includeRawPayloads ? rawPart(part, message) : {},
+ ...fields,
+ });
});
});
+ const finalAnswerIndex = [...messages]
+ .map((message, index) => ({ index, message }))
+ .reverse()
+ .find(({ message }) => message.role === 'assistant' && message.phase === 'commentary')?.index;
+ if (finalAnswerIndex !== undefined) {
+ messages[finalAnswerIndex] = { ...messages[finalAnswerIndex]!, phase: 'final_answer' };
+ }
return messages;
};
+const deleteClineSessionIndex = async (dataDir: string, sessionId: string): Promise => {
+ const databasePath = path.join(dataDir, 'db', 'sessions.db');
+ if (!(await Bun.file(databasePath).exists())) {
+ return false;
+ }
+ const database = new Database(databasePath, { create: false, readwrite: true });
+ try {
+ return database.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
+ } finally {
+ database.close();
+ }
+};
+
const getStats = (messages: ClineTranscriptMessage[]): ParsedStats => ({
assistantMessageCount: messages.filter(
(message) => message.role === 'assistant' && ['commentary', 'final_answer'].includes(message.phase),
@@ -318,15 +398,12 @@ const getStats = (messages: ClineTranscriptMessage[]): ParsedStats => ({
userMessageCount: messages.filter((message) => message.role === 'user').length,
});
-const readTaskTranscriptFromEntry = async (
- globalStorageDir: string,
- entry: TaskHistoryEntry,
+const readClineTranscriptFromEntry = async (
+ entry: ClineSessionEntry,
options: ReadTranscriptOptions = {},
): Promise => {
- const taskDir = path.join(globalStorageDir, 'tasks', entry.id);
- const uiMessagesPath = path.join(taskDir, 'ui_messages.json');
const includeRawPayloads = options.includeRawPayloads ?? true;
- const messages = parseUiMessages(await readJson(uiMessagesPath), entry.cwd, includeRawPayloads);
+ const messages = parseSessionMessages(await readJson(entry.messagesPath), entry.cwd, entry.id, includeRawPayloads);
if (messages.length === 0) {
return null;
}
@@ -337,24 +414,23 @@ const readTaskTranscriptFromEntry = async (
}
return earliest === null ? message.createdAtMs : Math.min(earliest, message.createdAtMs);
}, null);
- const title = cleanInlineTitle(entry.task) || entry.id;
const task: ClineTaskSummary = {
...stats,
cacheReads: entry.cacheReads,
cacheWrites: entry.cacheWrites,
createdAtMs,
isFavorited: entry.isFavorited,
- lastActiveAtMs: entry.ts,
+ lastActiveAtMs: entry.updatedAtMs,
+ messagesPath: entry.messagesPath,
modelId: entry.modelId,
renderablePartCount: messages.length,
- taskDir,
+ sessionDir: entry.sessionDir,
taskId: entry.id,
- title,
+ title: entry.title,
tokensIn: entry.tokensIn,
tokensOut: entry.tokensOut,
totalCost: entry.totalCost,
- uiMessagesPath,
- ulid: entry.ulid,
+ ulid: null,
workspaceKey: getWorkspaceKey(entry.cwd),
workspaceLabel: getPortablePathBasename(entry.cwd) || entry.cwd,
worktree: entry.cwd,
@@ -367,18 +443,16 @@ const readTaskTranscriptFromEntry = async (
};
};
-const listTaskTranscripts = async (globalStorageDir: string, options: ReadTranscriptOptions = {}) => {
- const entries = await readTaskHistory(globalStorageDir);
+const listClineTranscripts = async (dataDir: string, options: ReadTranscriptOptions = {}) => {
+ const entries = await listClineSessionEntries(dataDir);
const transcripts = await mapWithConcurrency(entries, READ_CONCURRENCY, (entry) =>
- readTaskTranscriptFromEntry(globalStorageDir, entry, options),
+ readClineTranscriptFromEntry(entry, options),
);
return transcripts.flatMap((transcript) => transcript ?? []);
};
-export const listClineWorkspaceGroups = async (
- globalStorageDir = resolveClineGlobalStorageDir(),
-): Promise => {
- const transcripts = await listTaskTranscripts(globalStorageDir, { includeRawPayloads: false });
+export const listClineWorkspaceGroups = async (dataDir = resolveClineDataDir()): Promise => {
+ const transcripts = await listClineTranscripts(dataDir, { includeRawPayloads: false });
const byWorkspace = new Map();
for (const { task } of transcripts) {
byWorkspace.set(task.worktree, [...(byWorkspace.get(task.worktree) ?? []), task]);
@@ -406,50 +480,45 @@ export const listClineWorkspaceGroups = async (
export const listClineTasksForGroup = async (
workspaceKey: string,
- globalStorageDir = resolveClineGlobalStorageDir(),
+ dataDir = resolveClineDataDir(),
): Promise => {
const worktree = getWorktreeFromWorkspaceKey(workspaceKey);
if (!worktree) {
return [];
}
- return (await listTaskTranscripts(globalStorageDir, { includeRawPayloads: false }))
+ return (await listClineTranscripts(dataDir, { includeRawPayloads: false }))
.map(({ task }) => task)
.filter((task) => task.worktree === worktree)
.sort((left, right) => (right.lastActiveAtMs ?? 0) - (left.lastActiveAtMs ?? 0));
};
export const readClineTaskTranscript = async (
- globalStorageDir: string,
+ dataDir: string,
taskId: string,
options: ReadTranscriptOptions = {},
): Promise => {
- if (!/^\d+$/u.test(taskId)) {
+ if (!isSafeSessionId(taskId)) {
return null;
}
- const entry = (await readTaskHistory(globalStorageDir)).find((candidate) => candidate.id === taskId);
- return entry ? readTaskTranscriptFromEntry(globalStorageDir, entry, options) : null;
+ const entry = (await listClineSessionEntries(dataDir)).find((candidate) => candidate.id === taskId);
+ return entry ? readClineTranscriptFromEntry(entry, options) : null;
};
-export const deleteClineTask = async (globalStorageDir: string, taskId: string): Promise =>
+export const deleteClineTask = async (dataDir: string, taskId: string): Promise =>
clineDeleteLimiter(async () => {
- if (!/^\d+$/u.test(taskId)) {
- return { deletedFiles: [], deletedTaskIds: [] };
- }
- const taskHistoryPath = path.join(globalStorageDir, 'state', 'taskHistory.json');
- const rawHistory = await readJson(taskHistoryPath);
- if (!Array.isArray(rawHistory)) {
+ if (!isSafeSessionId(taskId)) {
return { deletedFiles: [], deletedTaskIds: [] };
}
- const remaining = rawHistory.filter((value) => asString(asObject(value)?.id ?? null) !== taskId);
- if (remaining.length === rawHistory.length) {
+ const sessionDir = path.join(dataDir, 'sessions', taskId);
+ const metadataPath = path.join(sessionDir, `${taskId}.json`);
+ const messagesPath = path.join(sessionDir, `${taskId}.messages.json`);
+ if (!(await Bun.file(metadataPath).exists()) && !(await Bun.file(messagesPath).exists())) {
return { deletedFiles: [], deletedTaskIds: [] };
}
-
- await mkdir(path.dirname(taskHistoryPath), { recursive: true });
- const tempPath = `${taskHistoryPath}.${randomUUID()}.tmp`;
- await Bun.write(tempPath, `${JSON.stringify(remaining, null, 2)}\n`);
- await rename(tempPath, taskHistoryPath);
- const taskDir = path.join(globalStorageDir, 'tasks', taskId);
- await rm(taskDir, { force: true, recursive: true });
- return { deletedFiles: [taskDir, taskHistoryPath], deletedTaskIds: [taskId] };
+ const indexDeleted = await deleteClineSessionIndex(dataDir, taskId);
+ await rm(sessionDir, { force: true, recursive: true });
+ return {
+ deletedFiles: [sessionDir, ...(indexDeleted ? [path.join(dataDir, 'db', 'sessions.db')] : [])],
+ deletedTaskIds: [taskId],
+ };
});
diff --git a/src/lib/cline-exporter-types.ts b/src/lib/cline-exporter-types.ts
index 712f31a..85b5bcc 100644
--- a/src/lib/cline-exporter-types.ts
+++ b/src/lib/cline-exporter-types.ts
@@ -2,33 +2,12 @@ import os from 'node:os';
import path from 'node:path';
import type { ExportFormat, JsonValue } from './shared';
-const getVsCodeGlobalStorageRoot = (homeDir: string, platform: NodeJS.Platform, env: NodeJS.ProcessEnv) => {
- if (platform === 'darwin') {
- return path.join(homeDir, 'Library', 'Application Support', 'Code', 'User', 'globalStorage');
- }
- if (platform === 'win32') {
- return path.join(
- env.APPDATA?.trim() || path.join(homeDir, 'AppData', 'Roaming'),
- 'Code',
- 'User',
- 'globalStorage',
- );
- }
- return path.join(env.XDG_CONFIG_HOME?.trim() || path.join(homeDir, '.config'), 'Code', 'User', 'globalStorage');
-};
-
-export const getDefaultClineGlobalStorageDir = (
- env: NodeJS.ProcessEnv = process.env,
- homeDir = os.homedir(),
- platform = process.platform,
-): string => {
- return path.join(getVsCodeGlobalStorageRoot(homeDir, platform, env), 'saoudrizwan.claude-dev');
-};
+export const getDefaultClineDataDir = (homeDir = os.homedir()): string => path.join(homeDir, '.cline', 'data');
-export const DEFAULT_CLINE_GLOBAL_STORAGE_DIR = getDefaultClineGlobalStorageDir();
+export const DEFAULT_CLINE_DATA_DIR = getDefaultClineDataDir();
-export const resolveClineGlobalStorageDir = (): string => {
- return process.env.SPIRACHA_CLINE_GLOBAL_STORAGE_DIR?.trim() || DEFAULT_CLINE_GLOBAL_STORAGE_DIR;
+export const resolveClineDataDir = (env: NodeJS.ProcessEnv = process.env, homeDir = os.homedir()): string => {
+ return env.SPIRACHA_CLINE_DATA_DIR?.trim() || getDefaultClineDataDir(homeDir);
};
export type DeleteClineTaskResult = {
@@ -79,11 +58,12 @@ export type ClineTaskSummary = {
createdAtMs: number | null;
isFavorited: boolean;
lastActiveAtMs: number | null;
+ messagesPath: string;
messageCount: number;
modelId: string | null;
reasoningCount: number;
renderablePartCount: number;
- taskDir: string;
+ sessionDir: string;
taskId: string;
title: string;
tokensIn: number | null;
@@ -91,7 +71,6 @@ export type ClineTaskSummary = {
toolCallCount: number;
toolResultCount: number;
totalCost: number | null;
- uiMessagesPath: string;
ulid: string | null;
userMessageCount: number;
workspaceKey: string;
diff --git a/src/lib/cline-test-helpers.ts b/src/lib/cline-test-helpers.ts
index 0a343f1..1f172a0 100644
--- a/src/lib/cline-test-helpers.ts
+++ b/src/lib/cline-test-helpers.ts
@@ -1,114 +1,98 @@
import { mkdir } from 'node:fs/promises';
import path from 'node:path';
-export const CLINE_TASK_ID = '1785560414951';
+export const CLINE_SESSION_ID = '1785560414951_demo';
const writeJson = async (filePath: string, value: unknown) => {
await Bun.write(filePath, `${JSON.stringify(value, null, 2)}\n`);
};
-export const writeClineTaskFixture = async ({
- globalStorageDir,
- taskId = CLINE_TASK_ID,
+export const writeClineSessionFixture = async ({
+ dataDir,
+ sessionId = CLINE_SESSION_ID,
+ title = 'Fix issue 1494 per the implementation plan',
workspacePath,
}: {
- globalStorageDir: string;
- taskId?: string;
+ dataDir: string;
+ sessionId?: string;
+ title?: string;
workspacePath: string;
}) => {
- const stateDir = path.join(globalStorageDir, 'state');
- const taskDir = path.join(globalStorageDir, 'tasks', taskId);
- await mkdir(stateDir, { recursive: true });
- await mkdir(taskDir, { recursive: true });
+ const sessionDir = path.join(dataDir, 'sessions', sessionId);
+ await mkdir(sessionDir, { recursive: true });
- const taskHistoryPath = path.join(stateDir, 'taskHistory.json');
- await writeJson(taskHistoryPath, [
- {
+ const messagesPath = path.join(sessionDir, `${sessionId}.messages.json`);
+ await writeJson(path.join(sessionDir, `${sessionId}.json`), {
+ cwd: workspacePath,
+ ended_at: '2026-08-08T00:00:02.000Z',
+ metadata: {
cacheReads: 42,
cacheWrites: 3,
- cwdOnTaskInitialization: workspacePath,
- id: taskId,
isFavorited: true,
- modelId: 'anthropic/claude-sonnet-4',
+ modelId: 'deepseek/deepseek-v4-flash',
size: 2048,
- task: 'Fix issue 1494 per the implementation plan',
+ title,
tokensIn: 1200,
tokensOut: 300,
totalCost: 0.42,
- ts: 1_785_563_068_465,
- ulid: '01KYXV3EQ80Z2NJH1SV27GXS9J',
},
- ]);
-
- const uiMessagesPath = path.join(taskDir, 'ui_messages.json');
- await writeJson(uiMessagesPath, [
- {
- conversationHistoryIndex: -1,
- files: [],
- images: [],
- say: 'task',
- text: 'Fix issue 1494 per the implementation plan',
- ts: 1_785_560_414_954,
- type: 'say',
- },
- {
- conversationHistoryIndex: 0,
- say: 'reasoning',
- text: 'I need to inspect the protected surface policy first.',
- ts: 1_785_560_418_335,
- type: 'say',
- },
- {
- conversationHistoryIndex: 0,
- say: 'text',
- text: 'I will inspect the relevant files.',
- ts: 1_785_560_419_303,
- type: 'say',
- },
- {
- commandCompleted: true,
- conversationHistoryIndex: 1,
- say: 'command',
- text: 'bun test src/vendor-proof-lifecycle.test.ts',
- ts: 1_785_560_450_947,
- type: 'say',
- },
- {
- ask: 'command_output',
- conversationHistoryIndex: 2,
- text: '57 pass\n0 fail',
- ts: 1_785_560_457_986,
- type: 'ask',
- },
- {
- conversationHistoryIndex: 3,
- say: 'tool',
- text: JSON.stringify({
- content: 'const result = true;',
- path: path.join(workspacePath, 'src', 'vendor-proof-lifecycle.ts'),
- tool: 'readFile',
- }),
- ts: 1_785_560_462_794,
- type: 'say',
- },
- {
- conversationHistoryIndex: 4,
- modelInfo: {
- mode: 'act',
- modelId: 'anthropic/claude-sonnet-4',
- providerId: 'cline',
+ model: 'deepseek/deepseek-v4-flash',
+ prompt: title,
+ session_id: sessionId,
+ source: 'vscode',
+ started_at: '2026-08-08T00:00:00.000Z',
+ status: 'completed',
+ version: 1,
+ workspace_root: workspacePath,
+ });
+ await writeJson(messagesPath, {
+ agent: 'lead',
+ messages: [
+ {
+ content: [{ text: title, type: 'text' }],
+ id: `${sessionId}-user`,
+ role: 'user',
+ ts: 1_786_147_200_000,
},
- say: 'completion_result',
- text: 'Implemented the 1494 fix per 1494-PLAN.md.',
- ts: 1_785_563_068_163,
- type: 'say',
- },
- ]);
-
- await writeJson(path.join(taskDir, 'api_conversation_history.json'), [
- { content: [{ text: 'Fix issue 1494', type: 'text' }], role: 'user', ts: 1_785_560_416_461 },
- ]);
- await writeJson(path.join(taskDir, 'task_metadata.json'), { environment_history: [], files_in_context: [] });
+ {
+ content: [
+ { thinking: 'I need to inspect the protected surface policy first.', type: 'thinking' },
+ { text: 'I will inspect the relevant files.', type: 'text' },
+ {
+ id: `${sessionId}-tool-1`,
+ input: { commands: ['bun test src/vendor-proof-lifecycle.test.ts'] },
+ name: 'run_commands',
+ type: 'tool_use',
+ },
+ ],
+ id: `${sessionId}-assistant-1`,
+ role: 'assistant',
+ ts: 1_786_147_201_000,
+ },
+ {
+ content: [
+ {
+ content: [{ result: '57 pass\n0 fail', success: true }],
+ name: 'run_commands',
+ tool_use_id: `${sessionId}-tool-1`,
+ type: 'tool_result',
+ },
+ ],
+ id: `${sessionId}-tool-result-1`,
+ role: 'user',
+ ts: 1_786_147_202_000,
+ },
+ {
+ content: [{ text: `Implemented ${title}.`, type: 'text' }],
+ id: `${sessionId}-assistant-2`,
+ role: 'assistant',
+ ts: 1_786_147_203_000,
+ },
+ ],
+ sessionId,
+ updated_at: '2026-08-08T00:00:02.000Z',
+ version: 1,
+ });
- return { taskDir, taskHistoryPath, taskId, uiMessagesPath };
+ return { messagesPath, sessionDir, sessionId };
};
diff --git a/src/lib/cline-transcript.ts b/src/lib/cline-transcript.ts
index 42ce4c6..07b3d39 100644
--- a/src/lib/cline-transcript.ts
+++ b/src/lib/cline-transcript.ts
@@ -24,8 +24,8 @@ export const renderClineTranscript = (transcript: ClineTaskTranscript, options:
const lines = [renderDocumentTitle(cleanInlineTitle(task.title), options.outputFormat), ''];
if (options.includeMetadata) {
const metadata: MetadataEntry[] = [
- { key: 'exported_from', value: 'cline_ui_messages' },
- { key: 'task_id', value: task.taskId },
+ { key: 'exported_from', value: 'cline_session_messages' },
+ { key: 'session_id', value: task.taskId },
{ key: 'workspace', value: task.worktree },
{ key: 'model', value: task.modelId },
{ key: 'created_at_unix_ms', value: task.createdAtMs },
diff --git a/src/lib/codex-browser-export.test.ts b/src/lib/codex-browser-export.test.ts
index 605fea7..1a1f95c 100644
--- a/src/lib/codex-browser-export.test.ts
+++ b/src/lib/codex-browser-export.test.ts
@@ -179,7 +179,7 @@ describe('renderCodexThreadDownload', () => {
}
expect(download.downloadUrl.endsWith('.zip')).toBe(true);
expect(download.fileName.endsWith('.zip')).toBe(true);
- expect(download.fileName.startsWith('spiracha-2026-05-17-1712-019e36d7')).toBe(true);
+ expect(download.fileName.startsWith('codex_spiracha-2026-05-17-1712-019e36d7')).toBe(true);
expect(await Bun.file(path.join(tempRoot, path.basename(download.downloadUrl))).exists()).toBe(true);
});
@@ -206,6 +206,7 @@ describe('renderCodexThreadDownload', () => {
throw new Error('expected zipped download url mode');
}
expect(download.fileName.endsWith('.zip')).toBe(true);
+ expect(download.fileName.startsWith('codex_')).toBe(true);
const zipPath = path.join(tempRoot, path.basename(download.downloadUrl));
const entries = await listZipEntries(zipPath);
@@ -279,6 +280,7 @@ describe('renderCodexThreadDownload', () => {
throw new Error('expected zipped batch download url mode');
}
expect(download.fileName.endsWith('.zip')).toBe(true);
+ expect(download.fileName.startsWith('codex_')).toBe(true);
expect(download.fileName.includes('threads-2')).toBe(true);
expect(await Bun.file(path.join(tempRoot, path.basename(download.downloadUrl))).exists()).toBe(true);
});
diff --git a/src/lib/codex-browser-export.ts b/src/lib/codex-browser-export.ts
index c7900d7..8cb98df 100644
--- a/src/lib/codex-browser-export.ts
+++ b/src/lib/codex-browser-export.ts
@@ -6,7 +6,12 @@ import type { CodexTranscriptRenderOptions } from './codex-thread-types';
import { renderCodexSessionFile, writeCodexSessionFileExport } from './codex-transcript-renderer';
import { applyPathTransforms, type PathDisplaySettings } from './path-transforms';
import type { ExportFormat } from './shared';
-import { buildBatchExportBaseName, buildConversationExportBaseName, getExportMimeType } from './ui-export-archive';
+import {
+ buildBatchExportBaseName,
+ buildConversationExportBaseName,
+ buildExportArchiveBaseName,
+ getExportMimeType,
+} from './ui-export-archive';
import { buildUiExportDownloadUrl, ensureUiExportDir } from './ui-export-files';
import { zipExportDirectory, zipExportFile } from './ui-export-zip';
@@ -54,6 +59,8 @@ const buildExportBaseName = (thread: ReturnType['thr
);
};
+const buildArchiveBaseName = (baseName: string) => buildExportArchiveBaseName('codex', baseName);
+
const buildUniqueArchivePath = (exportDir: string, exportBaseName: string) => {
return path.join(exportDir, `${exportBaseName}-${randomUUID()}.zip`);
};
@@ -186,10 +193,10 @@ export const renderCodexThreadDownload = async (
input.zipArchive ||
rolloutSnapshotBefore.sizeBytes > (input.largeExportThresholdBytes ?? LARGE_BROWSER_EXPORT_THRESHOLD_BYTES)
) {
- const exportBaseName = fileBaseName;
+ const exportBaseName = buildArchiveBaseName(fileBaseName);
const exportDir = await resolvePublicExportDir(input.publicExportDir);
const workspaceDir = await createExportWorkspace(exportDir, exportBaseName);
- const savedPath = path.join(workspaceDir, `${exportBaseName}.${extension}`);
+ const savedPath = path.join(workspaceDir, fileName);
const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
try {
const saved = await writeCodexSessionFileExport(
@@ -287,12 +294,14 @@ export const renderCodexThreadsDownload = async (
const browseEntries = threadIds.map((threadId) => getThreadBrowseData(input.dbPath, threadId));
const threads = browseEntries.map((entry) => entry.thread);
const exportDir = await resolvePublicExportDir(input.publicExportDir);
- const exportBaseName = buildBatchExportBaseName(
- threads.map((thread) => ({
- cwd: thread.cwd,
- updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
- })),
- 'threads',
+ const exportBaseName = buildArchiveBaseName(
+ buildBatchExportBaseName(
+ threads.map((thread) => ({
+ cwd: thread.cwd,
+ updatedAtMs: thread.updated_at_ms ?? thread.updated_at * 1000,
+ })),
+ 'threads',
+ ),
);
const bundleDirectory = await createExportWorkspace(exportDir, exportBaseName);
const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
diff --git a/src/lib/conversation-api.test.ts b/src/lib/conversation-api.test.ts
index 956b067..26ef58d 100644
--- a/src/lib/conversation-api.test.ts
+++ b/src/lib/conversation-api.test.ts
@@ -709,7 +709,7 @@ describe('conversation API handler', () => {
expect(response.status).toBe(200);
expect(response.headers.get('Content-Type')).toBe('application/zip');
- expect(response.headers.get('Content-Disposition')).toContain('grok-conversations-2.zip');
+ expect(response.headers.get('Content-Disposition')).toContain('grok_repo-1970-01-01-0000-threads-2.zip');
expect(renderedSelectors).toEqual(['all', 'all']);
const bytes = new Uint8Array(await response.arrayBuffer());
expect(Array.from(bytes.slice(0, 2))).toEqual([0x50, 0x4b]);
diff --git a/src/lib/conversation-api.ts b/src/lib/conversation-api.ts
index a020e56..551e4af 100644
--- a/src/lib/conversation-api.ts
+++ b/src/lib/conversation-api.ts
@@ -22,6 +22,7 @@ import { validateEvidenceLens } from './conversation-data/evidence-lens';
import { buildEvidenceExport } from './conversation-data/evidence-markdown';
import { decodeConversationCursor } from './conversation-data/pagination';
import { createConversationMarkdownZip } from './conversation-zip-export';
+import { getExportPlatformName } from './ui-export-archive';
type ConversationApiDependencies = {
buildEvidenceExport?: typeof buildEvidenceExport;
@@ -713,9 +714,11 @@ const handleDeleteConversations = async (request: Request, dependencies: ReturnT
};
const getConversationZipEntry = (conversation: ConversationDetail, markdown: string) => ({
+ cwd: conversation.workspacePath,
fallbackBaseName: `${conversation.source}-${conversation.id}`,
markdown,
title: conversation.title,
+ updatedAtMs: conversation.updatedAtMs,
});
const handleExportConversations = async (request: Request, dependencies: ReturnType) => {
@@ -760,7 +763,8 @@ const handleExportConversations = async (request: Request, dependencies: ReturnT
const zip = await createConversationMarkdownZip({
entries: loaded.flatMap(({ entry }) => (entry ? [entry] : [])),
- fileBaseName: `${result.value.source}-conversations-${result.value.ids.length}`,
+ fallbackProjectName: `${result.value.source}-conversations`,
+ platform: getExportPlatformName(result.value.source),
});
return new Response(zip.blob, {
diff --git a/src/lib/conversation-data/cline-adapter.test.ts b/src/lib/conversation-data/cline-adapter.test.ts
index 42ff70a..f8886b4 100644
--- a/src/lib/conversation-data/cline-adapter.test.ts
+++ b/src/lib/conversation-data/cline-adapter.test.ts
@@ -2,7 +2,7 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { CLINE_TASK_ID, writeClineTaskFixture } from '../cline-test-helpers';
+import { CLINE_SESSION_ID, writeClineSessionFixture } from '../cline-test-helpers';
import { deleteConversation, listConversationsForPath, resolveConversationRef } from './index';
const tempRoots: string[] = [];
@@ -16,21 +16,21 @@ describe('Cline conversation adapter', () => {
const root = await mkdtemp(path.join(os.tmpdir(), 'conversation-cline-test-'));
tempRoots.push(root);
const workspacePath = path.join(root, 'repo');
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- await writeClineTaskFixture({ globalStorageDir, workspacePath });
+ const dataDir = path.join(root, 'cline-data');
+ await writeClineSessionFixture({ dataDir, workspacePath });
const page = await listConversationsForPath({
cwd: workspacePath,
includeMessages: true,
- locations: { clineGlobalStorageDir: globalStorageDir },
+ locations: { clineDataDir: dataDir },
messageSelector: 'last_final_answer',
sources: ['cline'],
});
expect(page.data).toHaveLength(1);
expect(page.data[0]).toMatchObject({
- id: CLINE_TASK_ID,
- metadata: { isFavorited: true, modelId: 'anthropic/claude-sonnet-4' },
+ id: CLINE_SESSION_ID,
+ metadata: { isFavorited: true, modelId: 'deepseek/deepseek-v4-flash' },
source: 'cline',
title: 'Fix issue 1494 per the implementation plan',
workspacePath,
@@ -39,27 +39,27 @@ describe('Cline conversation adapter', () => {
expect.objectContaining({
phase: 'final_answer',
role: 'assistant',
- text: 'Implemented the 1494 fix per 1494-PLAN.md.',
+ text: 'Implemented Fix issue 1494 per the implementation plan.',
}),
]);
});
it('should resolve Cline task URLs and delete chats through the stable facade', async () => {
- await expect(resolveConversationRef(`http://localhost:3000/cline-tasks/${CLINE_TASK_ID}`)).resolves.toEqual({
- id: CLINE_TASK_ID,
+ await expect(resolveConversationRef(`http://localhost:3000/cline-tasks/${CLINE_SESSION_ID}`)).resolves.toEqual({
+ id: CLINE_SESSION_ID,
source: 'cline',
});
const root = await mkdtemp(path.join(os.tmpdir(), 'conversation-cline-delete-test-'));
tempRoots.push(root);
- const globalStorageDir = path.join(root, 'saoudrizwan.claude-dev');
- await writeClineTaskFixture({ globalStorageDir, workspacePath: path.join(root, 'repo') });
+ const dataDir = path.join(root, 'cline-data');
+ await writeClineSessionFixture({ dataDir, workspacePath: path.join(root, 'repo') });
await expect(
deleteConversation({
- id: CLINE_TASK_ID,
- locations: { clineGlobalStorageDir: globalStorageDir },
+ id: CLINE_SESSION_ID,
+ locations: { clineDataDir: dataDir },
source: 'cline',
}),
- ).resolves.toMatchObject({ deletedIds: [CLINE_TASK_ID] });
+ ).resolves.toMatchObject({ deletedIds: [CLINE_SESSION_ID] });
});
});
diff --git a/src/lib/conversation-data/cline-adapter.ts b/src/lib/conversation-data/cline-adapter.ts
index ce70e17..3636b95 100644
--- a/src/lib/conversation-data/cline-adapter.ts
+++ b/src/lib/conversation-data/cline-adapter.ts
@@ -5,7 +5,7 @@ import {
readClineTaskTranscript,
} from '../cline-db';
import type { ClineTaskSummary, ClineTaskTranscript } from '../cline-exporter-types';
-import { resolveClineGlobalStorageDir } from '../cline-exporter-types';
+import { resolveClineDataDir } from '../cline-exporter-types';
import { mapWithConcurrency } from '../concurrency';
import { runWithTranscriptLoadLimit } from '../transcript-load-limiter';
import { createConversationUiPath, createDeepLinks, isWithinUpdatedWindow } from './adapter-helpers';
@@ -23,8 +23,8 @@ import type {
const CLINE_CONVERSATION_HYDRATION_CONCURRENCY = 4;
-const getGlobalStorageDir = (options: { locations?: { clineGlobalStorageDir?: string } }) =>
- options.locations?.clineGlobalStorageDir ?? resolveClineGlobalStorageDir();
+const getDataDir = (options: { locations?: { clineDataDir?: string } }) =>
+ options.locations?.clineDataDir ?? resolveClineDataDir();
const transcriptToMessages = (transcript: ClineTaskTranscript): ConversationMessage[] =>
transcript.messages.map((message, order) => ({
@@ -53,7 +53,7 @@ const transcriptToMessages = (transcript: ClineTaskTranscript): ConversationMess
const buildConversation = async (
task: ClineTaskSummary,
- globalStorageDir: string,
+ dataDir: string,
matches: ConversationPathMatch[],
options: Pick,
loadedTranscript: ClineTaskTranscript | null = null,
@@ -62,8 +62,8 @@ const buildConversation = async (
loadedTranscript ??
(options.includeMessages
? await runWithTranscriptLoadLimit(
- () => readClineTaskTranscript(globalStorageDir, task.taskId, { includeRawPayloads: false }),
- { id: task.taskId, integration: 'cline', operation: 'api', path: globalStorageDir },
+ () => readClineTaskTranscript(dataDir, task.taskId, { includeRawPayloads: false }),
+ { id: task.taskId, integration: 'cline', operation: 'api', path: dataDir },
)
: null);
const allMessages = transcript ? transcriptToMessages(transcript) : [];
@@ -95,20 +95,20 @@ const buildConversation = async (
};
const listClineConversationsForPath = async (options: ListConversationsForPathOptions) => {
- const globalStorageDir = getGlobalStorageDir(options);
- const groups = await listClineWorkspaceGroups(globalStorageDir);
+ const dataDir = getDataDir(options);
+ const groups = await listClineWorkspaceGroups(dataDir);
const conversations: ConversationDetail[] = [];
for (const group of groups) {
const match = await getConversationPathMatch(options.cwd, group.worktree);
if (!match) {
continue;
}
- const tasks = (await listClineTasksForGroup(group.key, globalStorageDir)).filter((task) =>
+ const tasks = (await listClineTasksForGroup(group.key, dataDir)).filter((task) =>
isWithinUpdatedWindow(task.lastActiveAtMs, options),
);
conversations.push(
...(await mapWithConcurrency(tasks, CLINE_CONVERSATION_HYDRATION_CONCURRENCY, (task) =>
- buildConversation(task, globalStorageDir, [match], options),
+ buildConversation(task, dataDir, [match], options),
)),
);
}
@@ -116,15 +116,15 @@ const listClineConversationsForPath = async (options: ListConversationsForPathOp
};
const getClineConversation = async (options: GetConversationOptions): Promise => {
- const globalStorageDir = getGlobalStorageDir(options);
+ const dataDir = getDataDir(options);
const transcript = await runWithTranscriptLoadLimit(
- () => readClineTaskTranscript(globalStorageDir, options.id, { includeRawPayloads: false }),
- { id: options.id, integration: 'cline', operation: 'api', path: globalStorageDir },
+ () => readClineTaskTranscript(dataDir, options.id, { includeRawPayloads: false }),
+ { id: options.id, integration: 'cline', operation: 'api', path: dataDir },
);
return transcript
? buildConversation(
transcript.task,
- globalStorageDir,
+ dataDir,
[],
{ includeMessages: true, messageSelector: options.messageSelector ?? 'all' },
transcript,
@@ -133,7 +133,7 @@ const getClineConversation = async (options: GetConversationOptions): Promise {
- const result = await deleteClineTask(getGlobalStorageDir(options), options.id);
+ const result = await deleteClineTask(getDataDir(options), options.id);
return { deletedFiles: result.deletedFiles, deletedIds: result.deletedTaskIds };
};
diff --git a/src/lib/conversation-data/index.test.ts b/src/lib/conversation-data/index.test.ts
index d640298..e2614f0 100644
--- a/src/lib/conversation-data/index.test.ts
+++ b/src/lib/conversation-data/index.test.ts
@@ -32,7 +32,7 @@ describe('conversation data facade', () => {
locations: {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
- clineGlobalStorageDir: path.join(tempRoot, 'cline'),
+ clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
grokSessionsDir: path.join(tempRoot, 'grok', 'sessions'),
@@ -67,7 +67,7 @@ describe('conversation data facade', () => {
locations: {
antigravityRoots: [path.join(tempRoot, 'antigravity')],
claudeCodeProjectsDir: path.join(tempRoot, 'claude'),
- clineGlobalStorageDir: path.join(tempRoot, 'cline'),
+ clineDataDir: path.join(tempRoot, 'cline'),
codexDbPath: path.join(tempRoot, 'missing-codex.sqlite'),
cursorUserDir: path.join(tempRoot, 'cursor'),
grokSessionsDir: path.join(tempRoot, 'grok', 'sessions'),
diff --git a/src/lib/conversation-data/types.ts b/src/lib/conversation-data/types.ts
index 3f70288..b39911b 100644
--- a/src/lib/conversation-data/types.ts
+++ b/src/lib/conversation-data/types.ts
@@ -166,7 +166,7 @@ export type ConversationPage = {
export type ConversationDataLocations = {
antigravityRoots?: string[];
claudeCodeProjectsDir?: string;
- clineGlobalStorageDir?: string;
+ clineDataDir?: string;
codexDbPath?: string;
cursorUserDir?: string;
grokSessionsDir?: string;
diff --git a/src/lib/conversation-zip-export.test.ts b/src/lib/conversation-zip-export.test.ts
index 4cfa51e..3960bcb 100644
--- a/src/lib/conversation-zip-export.test.ts
+++ b/src/lib/conversation-zip-export.test.ts
@@ -10,15 +10,35 @@ describe('createConversationMarkdownZip', () => {
const longTitle = '会話'.repeat(100);
const result = await createConversationMarkdownZip({
entries: [
- { fallbackBaseName: 'first', markdown: '# One', title: longTitle },
- { fallbackBaseName: 'second', markdown: '# Two', title: longTitle },
- { fallbackBaseName: 'fallback-title', markdown: '# Three', title: '' },
+ {
+ cwd: '/Users/example/workspace/spiracha',
+ fallbackBaseName: 'first',
+ markdown: '# One',
+ title: longTitle,
+ updatedAtMs: Date.UTC(2026, 4, 17, 17, 11),
+ },
+ {
+ cwd: '/Users/example/workspace/spiracha',
+ fallbackBaseName: 'second',
+ markdown: '# Two',
+ title: longTitle,
+ updatedAtMs: Date.UTC(2026, 4, 17, 17, 12),
+ },
+ {
+ cwd: '/Users/example/workspace/spiracha',
+ fallbackBaseName: 'fallback-title',
+ markdown: '# Three',
+ title: '',
+ updatedAtMs: null,
+ },
],
- fileBaseName: 'bundle',
+ fallbackProjectName: 'conversations',
+ platform: 'minimax',
});
const archive = unzipSync(new Uint8Array(await result.blob.arrayBuffer()));
const names = Object.keys(archive);
+ expect(result.fileName).toBe('minimax_spiracha-2026-05-17-1712-threads-3.zip');
expect(names).toHaveLength(3);
expect(new Set(names).size).toBe(3);
expect(names.every((name) => Buffer.byteLength(name) <= 255)).toBe(true);
@@ -26,22 +46,30 @@ describe('createConversationMarkdownZip', () => {
});
it('should clean temporary artifacts when building an entry throws', async () => {
- const fileBaseName = `zip-cleanup-${randomUUID()}`;
+ const fallbackProjectName = `zip-cleanup-${randomUUID()}`;
const entry = { fallbackBaseName: 'broken', title: 'Broken' } as {
+ cwd: string | null;
fallbackBaseName: string;
markdown: string;
title: string;
+ updatedAtMs: number | null;
};
+ entry.cwd = null;
+ entry.updatedAtMs = null;
Object.defineProperty(entry, 'markdown', {
get: () => {
throw new Error('synthetic markdown read failure');
},
});
- await expect(createConversationMarkdownZip({ entries: [entry], fileBaseName })).rejects.toThrow(
- 'synthetic markdown read failure',
- );
+ await expect(
+ createConversationMarkdownZip({
+ entries: [entry],
+ fallbackProjectName,
+ platform: 'cline',
+ }),
+ ).rejects.toThrow('synthetic markdown read failure');
- expect((await readdir(os.tmpdir())).filter((name) => name.startsWith(fileBaseName))).toEqual([]);
+ expect((await readdir(os.tmpdir())).filter((name) => name.startsWith(fallbackProjectName))).toEqual([]);
});
});
diff --git a/src/lib/conversation-zip-export.ts b/src/lib/conversation-zip-export.ts
index 16fc285..c439d99 100644
--- a/src/lib/conversation-zip-export.ts
+++ b/src/lib/conversation-zip-export.ts
@@ -2,18 +2,26 @@ import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { resolveUniqueExportFileBaseName, sanitizeExportFileName } from './ui-export-archive';
+import {
+ buildBatchExportBaseName,
+ buildExportArchiveBaseName,
+ resolveUniqueExportFileBaseName,
+ sanitizeExportFileName,
+} from './ui-export-archive';
import { zipExportDirectory } from './ui-export-zip';
type ConversationMarkdownZipEntry = {
+ cwd: string | null;
fallbackBaseName: string;
markdown: string;
title: string | null;
+ updatedAtMs: number | null;
};
type ConversationMarkdownZipOptions = {
entries: ConversationMarkdownZipEntry[];
- fileBaseName: string;
+ fallbackProjectName: string;
+ platform: Parameters[0];
};
const EXPORT_BASE_NAME_BYTE_LIMIT = 120;
@@ -45,15 +53,17 @@ const toSafeFileBaseName = (value: string | null, fallback: string) => {
export const createConversationMarkdownZip = async ({
entries,
- fileBaseName,
+ fallbackProjectName,
+ platform,
}: ConversationMarkdownZipOptions): Promise => {
if (entries.length === 0) {
throw new Error('No conversations selected for export');
}
- const safeBaseName = toSafeFileBaseName(fileBaseName, 'conversations');
- const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${safeBaseName}-`));
- const zipPath = path.join(os.tmpdir(), `${safeBaseName}-${randomUUID()}.zip`);
+ const safeBaseName = buildBatchExportBaseName(entries, fallbackProjectName);
+ const archiveBaseName = buildExportArchiveBaseName(platform, safeBaseName);
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${archiveBaseName}-`));
+ const zipPath = path.join(os.tmpdir(), `${archiveBaseName}-${randomUUID()}.zip`);
const usedBaseNames = new Map();
try {
@@ -66,7 +76,7 @@ export const createConversationMarkdownZip = async ({
await zipExportDirectory(workspaceDir, zipPath);
return {
blob: new Blob([await Bun.file(zipPath).arrayBuffer()], { type: 'application/zip' }),
- fileName: `${safeBaseName}.zip`,
+ fileName: `${archiveBaseName}.zip`,
mimeType: 'application/zip',
};
} finally {
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index 30655f8..4c7f69a 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -385,6 +385,60 @@ describe('cursor-db workspace discovery', () => {
expect(threads[0]?.transcriptDirs).toEqual([transcriptDir]);
});
+ it('should discover a Cursor CLI transcript without a SQLite composer record', async () => {
+ const userDir = await makeUserDir();
+ const composerId = '73d679d2-5311-4e00-8be3-af67fbf0fa87';
+ const projectDir = path.join(userDir, 'projects', 'Users-test-workspace-kalu');
+ const transcriptDir = path.join(projectDir, 'agent-transcripts', composerId);
+ await mkdir(transcriptDir, { recursive: true });
+ await Bun.write(
+ path.join(projectDir, '.workspace-trusted'),
+ JSON.stringify({ workspacePath: '/Users/test/workspace/kalu' }),
+ );
+ await Bun.write(
+ path.join(transcriptDir, `${composerId}.jsonl`),
+ [
+ JSON.stringify({
+ message: {
+ content: [
+ {
+ text: 'READ-ONLY review triage. Read root and nearest AGENTS.',
+ type: 'text',
+ },
+ ],
+ },
+ role: 'user',
+ }),
+ JSON.stringify({
+ message: { content: [{ text: 'Review complete.', type: 'text' }] },
+ role: 'assistant',
+ }),
+ ].join('\n'),
+ );
+
+ const groups = await listCursorWorkspaceGroups(userDir);
+ const group = groups.find((candidate) => candidate.key === 'folder:/Users/test/workspace/kalu');
+ const threads = group ? await listCursorThreadsForGroup(group, userDir) : [];
+ const transcript = await readCursorThreadTranscriptWithAgentFiles(
+ getCursorGlobalDbPath(userDir),
+ composerId,
+ userDir,
+ );
+
+ expect(group?.folders).toEqual(['/Users/test/workspace/kalu']);
+ expect(threads).toHaveLength(1);
+ expect(threads[0]).toMatchObject({
+ bubbleCount: 2,
+ composerId,
+ name: 'READ-ONLY review triage. Read root and nearest AGENTS.',
+ transcriptDirs: [transcriptDir],
+ });
+ expect(transcript?.bubbles.map((bubble) => bubble.text)).toEqual([
+ 'READ-ONLY review triage. Read root and nearest AGENTS.',
+ 'Review complete.',
+ ]);
+ });
+
it('should reject composer ids that escape the agent transcript directory', async () => {
const userDir = await makeUserDir();
await mkdir(path.join(userDir, 'projects', 'demo-project', 'agent-transcripts'), { recursive: true });
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index 8fed257..f93a1bc 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -1112,9 +1112,10 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
const buckets = await loadCursorBuckets(userDir);
const bucketGroups = groupCursorBuckets(buckets);
const globalDbPath = getCursorGlobalDbPath(userDir);
+ const cliThreads = await readCursorCliTranscriptThreads(userDir, options);
if (!(await pathExists(globalDbPath))) {
- return assembleDiscovery([], bucketGroups, new Map());
+ return assembleDiscovery(cliThreads, bucketGroups, new Map());
}
const bucketIdToGroupKey = new Map();
@@ -1130,7 +1131,7 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
try {
const heads = readAllHeads(db, options);
if (options.updatedAfterMs !== undefined && heads.size === 0) {
- return assembleDiscovery([], bucketGroups, new Map());
+ return assembleDiscovery(cliThreads, bucketGroups, new Map());
}
const headerInfo = readHeaderInfo(globalDbPath);
@@ -1159,7 +1160,12 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
const fileHistoryActivity =
options.updatedAfterMs === undefined ? await readCursorFileHistoryProjectActivity(userDir) : new Map();
- return assembleDiscovery(resolved, bucketGroups, fileHistoryActivity);
+ const knownComposerIds = new Set(universe);
+ return assembleDiscovery(
+ [...resolved, ...cliThreads.filter((thread) => !knownComposerIds.has(thread.composerId))],
+ bucketGroups,
+ fileHistoryActivity,
+ );
} finally {
db.close();
}
@@ -1490,17 +1496,146 @@ const listCursorAgentTranscriptFiles = async (transcriptDir: string, composerId:
return [...files].sort();
};
-const readCursorAgentTranscriptBubbles = async (composerId: string, userDir: string): Promise => {
- const transcriptDirs = await findCursorTranscriptDirs(composerId, userDir);
+type CursorAgentTranscript = {
+ bubbles: CursorBubble[];
+ bytes: number;
+ createdAtMs: number | null;
+ lastUpdatedAtMs: number | null;
+};
+
+const readCursorAgentTranscript = async (
+ composerId: string,
+ userDir: string,
+ transcriptDirs?: string[],
+): Promise => {
+ const resolvedTranscriptDirs = transcriptDirs ?? (await findCursorTranscriptDirs(composerId, userDir));
const bubbles: CursorBubble[] = [];
- for (const transcriptDir of transcriptDirs.sort()) {
+ let bytes = 0;
+ let createdAtMs: number | null = null;
+ let lastUpdatedAtMs: number | null = null;
+ for (const transcriptDir of [...resolvedTranscriptDirs].sort()) {
const files = await listCursorAgentTranscriptFiles(transcriptDir, composerId);
for (const file of files) {
+ let fileStat: Awaited>;
+ try {
+ fileStat = await stat(file);
+ } catch {
+ continue;
+ }
+
+ bytes += fileStat.size;
+ if (fileStat.birthtimeMs > 0 && (createdAtMs === null || fileStat.birthtimeMs < createdAtMs)) {
+ createdAtMs = fileStat.birthtimeMs;
+ }
+ lastUpdatedAtMs = Math.max(lastUpdatedAtMs ?? 0, fileStat.mtimeMs);
bubbles.push(...(await readCursorAgentTranscriptFile(file)));
}
}
- return bubbles;
+ return { bubbles, bytes, createdAtMs, lastUpdatedAtMs };
+};
+
+const readCursorProjectWorkspacePath = async (projectDir: string): Promise => {
+ try {
+ const data = (await Bun.file(path.join(projectDir, '.workspace-trusted')).json()) as {
+ workspacePath?: unknown;
+ };
+ return typeof data.workspacePath === 'string' ? normalizeCursorPath(data.workspacePath) || null : null;
+ } catch {
+ return null;
+ }
+};
+
+const getCursorAgentThreadName = (bubbles: CursorBubble[]): string => {
+ const firstUserBubble = bubbles.find((bubble) => bubble.kind === 'user' && bubble.text.trim());
+ const raw = firstUserBubble?.text ?? '';
+ const query = raw.match(/\s*([\s\S]*?)\s*<\/user_query>/u)?.[1] ?? raw;
+ return normalizeBubbleText(query).slice(0, 200) || '(untitled)';
+};
+
+type CursorDirectoryEntry = { isDirectory: () => boolean; name: string };
+
+const readCursorDirectoryEntries = async (directory: string): Promise => {
+ try {
+ return await readdir(directory, { withFileTypes: true });
+ } catch {
+ return [];
+ }
+};
+
+const isCursorCliTranscriptAfterUpdate = (transcript: CursorAgentTranscript, updatedAfterMs?: number): boolean => {
+ return updatedAfterMs === undefined || (transcript.lastUpdatedAtMs ?? 0) > updatedAfterMs;
+};
+
+const readCursorCliTranscriptThread = async (
+ userDir: string,
+ agentTranscriptsDir: string,
+ workspacePath: string | null,
+ transcriptEntry: CursorDirectoryEntry,
+ options: CursorDiscoveryOptions,
+): Promise => {
+ if (!transcriptEntry.isDirectory() || !isSafeCursorComposerId(transcriptEntry.name)) {
+ return null;
+ }
+
+ const transcriptDir = path.join(agentTranscriptsDir, transcriptEntry.name);
+ const transcript = await readCursorAgentTranscript(transcriptEntry.name, userDir, [transcriptDir]);
+ if (transcript.bubbles.length === 0 || !isCursorCliTranscriptAfterUpdate(transcript, options.updatedAfterMs)) {
+ return null;
+ }
+
+ const folder = workspacePath;
+ return {
+ bucketId: null,
+ composerId: transcriptEntry.name,
+ createdAtMs: transcript.createdAtMs,
+ folder,
+ groupKey: folder ? `folder:${folder}` : UNKNOWN_GROUP_KEY,
+ groupLabel: folder ? path.basename(folder) : 'Unknown project',
+ lastUpdatedAtMs: transcript.lastUpdatedAtMs,
+ mode: null,
+ model: null,
+ name: getCursorAgentThreadName(transcript.bubbles),
+ parentComposerId: null,
+ reasoningEffort: null,
+ stat: { bytes: transcript.bytes, count: transcript.bubbles.length },
+ status: null,
+ };
+};
+
+const keepNewestCursorCliThread = (threadsById: Map, candidate: ResolvedThread): void => {
+ const existing = threadsById.get(candidate.composerId);
+ if (!existing || (candidate.lastUpdatedAtMs ?? 0) > (existing.lastUpdatedAtMs ?? 0)) {
+ threadsById.set(candidate.composerId, candidate);
+ }
+};
+
+const readCursorCliTranscriptThreads = async (
+ userDir: string,
+ options: CursorDiscoveryOptions,
+): Promise => {
+ const projectsDir = getCursorProjectsDir(userDir);
+ const projectEntries = (await readCursorDirectoryEntries(projectsDir)).filter((entry) => entry.isDirectory());
+ const threadsById = new Map();
+ for (const projectEntry of projectEntries) {
+ const projectDir = path.join(projectsDir, projectEntry.name);
+ const agentTranscriptsDir = path.join(projectDir, 'agent-transcripts');
+ const workspacePath = await readCursorProjectWorkspacePath(projectDir);
+ for (const transcriptEntry of await readCursorDirectoryEntries(agentTranscriptsDir)) {
+ const candidate = await readCursorCliTranscriptThread(
+ userDir,
+ agentTranscriptsDir,
+ workspacePath,
+ transcriptEntry,
+ options,
+ );
+ if (candidate) {
+ keepNewestCursorCliThread(threadsById, candidate);
+ }
+ }
+ }
+
+ return [...threadsById.values()];
};
const mergeAgentTranscriptTail = (
@@ -1575,13 +1710,30 @@ export const readCursorThreadTranscriptWithAgentFiles = async (
composerId: string,
userDir = inferCursorUserDirFromGlobalDbPath(globalDbPath),
): Promise => {
- const transcript = readCursorThreadTranscript(globalDbPath, composerId);
+ const transcript = (await pathExists(globalDbPath)) ? readCursorThreadTranscript(globalDbPath, composerId) : null;
+ const agentTranscript = await readCursorAgentTranscript(composerId, userDir);
if (!transcript) {
- return null;
+ if (agentTranscript.bubbles.length === 0) {
+ return null;
+ }
+
+ return {
+ bubbles: agentTranscript.bubbles,
+ head: {
+ composerId,
+ createdAtMs: agentTranscript.createdAtMs,
+ lastUpdatedAtMs: agentTranscript.lastUpdatedAtMs,
+ mode: null,
+ name: getCursorAgentThreadName(agentTranscript.bubbles),
+ orderedBubbleIds: agentTranscript.bubbles.map((bubble) => bubble.bubbleId),
+ totalBubbleHeaders: agentTranscript.bubbles.length,
+ },
+ omittedBubbleCount: 0,
+ renderableBubbleCount: agentTranscript.bubbles.length,
+ };
}
- const agentBubbles = await readCursorAgentTranscriptBubbles(composerId, userDir);
- return mergeAgentTranscriptTail(transcript, agentBubbles);
+ return mergeAgentTranscriptTail(transcript, agentTranscript.bubbles);
};
const readAllBubbleIds = (db: Database, composerId: string): string[] => {
diff --git a/src/lib/opencode-db.test.ts b/src/lib/opencode-db.test.ts
index 4a6c4ec..c6f7dbe 100644
--- a/src/lib/opencode-db.test.ts
+++ b/src/lib/opencode-db.test.ts
@@ -194,6 +194,60 @@ describe('opencode db helpers', () => {
expect(groups[0]?.partCount).toBe(6);
});
+ it('should expose global OpenCode sessions as directory workspaces', async () => {
+ const dbPath = await makeDbPath();
+ const worktree = '/Users/test/Downloads/import_export_deep_research_prompt_pack';
+ await createOpenCodeFixture(dbPath, {
+ projects: [{ id: 'global', worktree: '/' }],
+ sessions: [
+ {
+ directory: worktree,
+ id: 'ses_download_one',
+ messages: [],
+ projectId: 'global',
+ timeUpdated: 1_700_000_100_000,
+ title: 'Imported prompt one',
+ },
+ {
+ directory: worktree,
+ id: 'ses_download_two',
+ messages: [],
+ projectId: 'global',
+ timeUpdated: 1_700_000_200_000,
+ title: 'Imported prompt two',
+ },
+ {
+ directory: '/Users/test/Downloads/another-project',
+ id: 'ses_other',
+ messages: [],
+ projectId: 'global',
+ timeUpdated: 1_700_000_300_000,
+ title: 'Another project session',
+ },
+ ],
+ });
+
+ const groups = await listOpenCodeWorkspaceGroups(dbPath);
+ const downloadGroup = groups.find((group) => group.worktree === worktree);
+
+ expect(groups).toHaveLength(2);
+ expect(downloadGroup).toMatchObject({
+ label: 'import_export_deep_research_prompt_pack',
+ sessionCount: 2,
+ worktree,
+ });
+ expect(downloadGroup?.key).toMatch(/^directory:/u);
+
+ const sessions = await listOpenCodeSessionsForGroup(downloadGroup?.key ?? '', dbPath);
+
+ expect(sessions.map((session) => session.sessionId)).toEqual(['ses_download_two', 'ses_download_one']);
+ expect(sessions[0]).toMatchObject({
+ workspaceKey: downloadGroup?.key,
+ workspaceLabel: 'import_export_deep_research_prompt_pack',
+ worktree,
+ });
+ });
+
it('should read a WAL database when its sidecar files do not exist', async () => {
const dbPath = await createFixtureDb();
const db = new Database(dbPath);
diff --git a/src/lib/opencode-db.ts b/src/lib/opencode-db.ts
index 317a8b7..107c172 100644
--- a/src/lib/opencode-db.ts
+++ b/src/lib/opencode-db.ts
@@ -35,6 +35,7 @@ export const OPENCODE_READ_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READWRITE | con
const OPENCODE_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
const DEFAULT_OPENCODE_DB_CONCURRENCY = 2;
const DESKTOP_STATE_FILE_CONCURRENCY = 4;
+const GLOBAL_OPENCODE_PROJECT_ID = 'global';
const OPENCODE_OPTIONAL_SESSION_TABLES = [
'session_context_epoch',
'session_input',
@@ -51,6 +52,7 @@ export type DeleteOpenCodeSessionResult = {
type WorkspaceRow = {
archivedSessionCount: number;
+ directory: string | null;
lastActiveMs: number;
messageCount: number;
name: string | null;
@@ -299,10 +301,24 @@ const getWorkspaceLabel = (name: string | null, worktree: string): string => {
return path.basename(worktree.replace(/\/+$/u, '')) || worktree;
};
-const getWorkspaceKey = (projectId: string): string => `project:${projectId}`;
+const getProjectWorkspaceKey = (projectId: string): string => `project:${projectId}`;
-const getProjectIdFromWorkspaceKey = (workspaceKey: string): string | null => {
- return workspaceKey.startsWith('project:') ? workspaceKey.slice('project:'.length) : null;
+const getDirectoryWorkspaceKey = (directory: string): string =>
+ `directory:${Buffer.from(directory).toString('base64url')}`;
+
+const getWorkspaceSelector = (workspaceKey: string): { directory: string | null; projectId: string } | null => {
+ if (workspaceKey.startsWith('project:')) {
+ const projectId = workspaceKey.slice('project:'.length);
+ return projectId ? { directory: null, projectId } : null;
+ }
+
+ if (!workspaceKey.startsWith('directory:')) {
+ return null;
+ }
+
+ const encodedDirectory = workspaceKey.slice('directory:'.length);
+ const directory = Buffer.from(encodedDirectory, 'base64url').toString('utf8');
+ return directory ? { directory, projectId: GLOBAL_OPENCODE_PROJECT_ID } : null;
};
const parseModelInfo = (value: string | null): OpenCodeModelInfo => {
@@ -340,23 +356,35 @@ const formatModelLabel = (model: OpenCodeModelInfo): string | null => {
};
const toWorkspaceGroup = (row: WorkspaceRow): OpenCodeWorkspaceGroup => {
- const label = getWorkspaceLabel(row.name, row.worktree);
+ const worktree = row.directory ?? row.worktree;
+ const label = getWorkspaceLabel(row.directory ? null : row.name, worktree);
return {
archivedSessionCount: row.archivedSessionCount,
- key: getWorkspaceKey(row.projectId),
+ key: row.directory ? getDirectoryWorkspaceKey(row.directory) : getProjectWorkspaceKey(row.projectId),
label,
lastActiveMs: row.lastActiveMs,
messageCount: row.messageCount,
partCount: row.partCount,
projectId: row.projectId,
sessionCount: row.sessionCount,
- uri: pathToFileURL(row.worktree).href,
- worktree: row.worktree,
+ uri: pathToFileURL(worktree).href,
+ worktree,
};
};
const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
- const label = getWorkspaceLabel(row.projectName, row.worktree);
+ const workspace =
+ row.projectId === GLOBAL_OPENCODE_PROJECT_ID
+ ? {
+ key: getDirectoryWorkspaceKey(row.directory),
+ label: getWorkspaceLabel(null, row.directory),
+ worktree: row.directory,
+ }
+ : {
+ key: getProjectWorkspaceKey(row.projectId),
+ label: getWorkspaceLabel(row.projectName, row.worktree),
+ worktree: row.worktree,
+ };
const model = parseModelInfo(row.model);
const tokensCacheRead = row.tokensCacheRead ?? 0;
const tokensCacheWrite = row.tokensCacheWrite ?? 0;
@@ -392,9 +420,9 @@ const toSessionSummary = (row: SessionRow): OpenCodeSessionSummary => {
tokensReasoning,
toolPartCount: row.toolPartCount,
totalTokens: tokensInput + tokensOutput + tokensReasoning + tokensCacheRead + tokensCacheWrite,
- workspaceKey: getWorkspaceKey(row.projectId),
- workspaceLabel: label,
- worktree: row.worktree,
+ workspaceKey: workspace.key,
+ workspaceLabel: workspace.label,
+ worktree: workspace.worktree,
};
};
@@ -402,6 +430,7 @@ const workspaceRowsQuery = `
SELECT
p.id AS projectId,
p.name AS name,
+ NULL AS directory,
p.worktree AS worktree,
(
SELECT COUNT(*)
@@ -433,6 +462,36 @@ const workspaceRowsQuery = `
FROM project p
`;
+const globalWorkspaceRowsQuery = `
+ SELECT
+ '${GLOBAL_OPENCODE_PROJECT_ID}' AS projectId,
+ NULL AS name,
+ s.directory AS directory,
+ s.directory AS worktree,
+ COUNT(*) AS sessionCount,
+ SUM(CASE WHEN s.time_archived IS NOT NULL THEN 1 ELSE 0 END) AS archivedSessionCount,
+ (
+ SELECT COUNT(*)
+ FROM message m
+ JOIN session ms ON ms.id = m.session_id
+ WHERE ms.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}'
+ AND ms.parent_id IS NULL
+ AND ms.directory = s.directory
+ ) AS messageCount,
+ (
+ SELECT COUNT(*)
+ FROM part prt
+ JOIN session ps ON ps.id = prt.session_id
+ WHERE ps.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}'
+ AND ps.parent_id IS NULL
+ AND ps.directory = s.directory
+ ) AS partCount,
+ MAX(s.time_updated) AS lastActiveMs
+ FROM session s
+ WHERE s.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}' AND ${MAIN_SESSION_FILTER}
+ GROUP BY s.directory
+`;
+
const sessionSelectQuery = `
SELECT
s.id AS sessionId,
@@ -503,10 +562,16 @@ export const listOpenCodeWorkspaceGroups = async (
return runWithOpenCodeDbLimit('list-workspaces', dbPath, () =>
withOpenCodeReadonlyDb(dbPath, (db) => {
- const rows = db
- .query(`${workspaceRowsQuery} ORDER BY lastActiveMs DESC, p.worktree ASC`)
- .all() as WorkspaceRow[];
- return rows.map(toWorkspaceGroup);
+ const projectRows = db
+ .query(`${workspaceRowsQuery} WHERE p.id <> ?`)
+ .all(GLOBAL_OPENCODE_PROJECT_ID) as WorkspaceRow[];
+ const globalRows = db.query(globalWorkspaceRowsQuery).all() as WorkspaceRow[];
+ return [...projectRows, ...globalRows]
+ .sort(
+ (left, right) =>
+ right.lastActiveMs - left.lastActiveMs || left.worktree.localeCompare(right.worktree),
+ )
+ .map(toWorkspaceGroup);
}),
);
};
@@ -551,14 +616,18 @@ export const listOpenCodeSessionsForGroup = async (
workspaceKey: string,
dbPath = resolveOpenCodeDbPath(),
): Promise => {
- const projectId = getProjectIdFromWorkspaceKey(workspaceKey);
- if (!projectId || !(await pathExists(dbPath))) {
+ const selector = getWorkspaceSelector(workspaceKey);
+ if (!selector || !(await pathExists(dbPath))) {
return [];
}
return runWithOpenCodeDbLimit('list-sessions', dbPath, () =>
withOpenCodeReadonlyDb(dbPath, (db) => {
- return readSessionSummaries(db, `s.project_id = ? AND ${MAIN_SESSION_FILTER}`, [projectId]);
+ const whereSql = selector.directory
+ ? `s.project_id = ? AND s.directory = ? AND ${MAIN_SESSION_FILTER}`
+ : `s.project_id = ? AND ${MAIN_SESSION_FILTER}`;
+ const params = selector.directory ? [selector.projectId, selector.directory] : [selector.projectId];
+ return readSessionSummaries(db, whereSql, params);
}),
);
};
diff --git a/src/lib/ui-export-archive.test.ts b/src/lib/ui-export-archive.test.ts
index 8f71712..8999edd 100644
--- a/src/lib/ui-export-archive.test.ts
+++ b/src/lib/ui-export-archive.test.ts
@@ -1,7 +1,9 @@
import { describe, expect, it } from 'bun:test';
import {
buildBatchExportBaseName,
+ buildExportArchiveBaseName,
getExportMimeType,
+ getExportPlatformName,
resolveUniqueExportFileBaseName,
sanitizeExportFileName,
} from './ui-export-archive';
@@ -30,6 +32,16 @@ describe('ui export archive helpers', () => {
expect(resolveUniqueExportFileBaseName('SAME', used)).toBe('SAME-4');
});
+ it('should prefix archive names with the owning platform', () => {
+ expect(buildExportArchiveBaseName('cline', 'spiracha-2026-05-17-1712-019e36d7')).toBe(
+ 'cline_spiracha-2026-05-17-1712-019e36d7',
+ );
+ expect(buildExportArchiveBaseName('minimax', 'project-2026-05-17-1712-threads-2')).toBe(
+ 'minimax_project-2026-05-17-1712-threads-2',
+ );
+ expect(getExportPlatformName('minimax-code')).toBe('minimax');
+ });
+
it('should name batch archives from the project, latest conversation time, and selected thread count', () => {
expect(
buildBatchExportBaseName(
diff --git a/src/lib/ui-export-archive.ts b/src/lib/ui-export-archive.ts
index f96d02e..23fe2ed 100644
--- a/src/lib/ui-export-archive.ts
+++ b/src/lib/ui-export-archive.ts
@@ -1,6 +1,32 @@
+import type { ConversationSource } from './conversation-data/types';
import { getPortablePathBasename } from './portable-path';
import type { ExportFormat } from './shared';
+export type ExportPlatform =
+ | 'antigravity'
+ | 'claude'
+ | 'cline'
+ | 'codex'
+ | 'cursor'
+ | 'grok'
+ | 'kiro'
+ | 'minimax'
+ | 'opencode'
+ | 'qoder';
+
+const EXPORT_PLATFORM_BY_SOURCE: Record = {
+ antigravity: 'antigravity',
+ 'claude-code': 'claude',
+ cline: 'cline',
+ codex: 'codex',
+ cursor: 'cursor',
+ grok: 'grok',
+ kiro: 'kiro',
+ 'minimax-code': 'minimax',
+ opencode: 'opencode',
+ qoder: 'qoder',
+};
+
type BatchExportNameEntry = {
cwd: string | null;
updatedAtMs: number | null;
@@ -18,6 +44,11 @@ export const sanitizeExportFileName = (value: string) => {
.trim();
};
+export const getExportPlatformName = (source: ConversationSource): ExportPlatform => EXPORT_PLATFORM_BY_SOURCE[source];
+
+export const buildExportArchiveBaseName = (platform: ExportPlatform, baseName: string) =>
+ `${platform}_${sanitizeExportFileName(baseName) || 'export'}`;
+
export const getExportMimeType = (outputFormat: ExportFormat) => {
return outputFormat === 'md' ? 'text/markdown; charset=utf-8' : 'text/plain; charset=utf-8';
};
diff --git a/src/ui-api-server.test.ts b/src/ui-api-server.test.ts
index f1b2188..d4faa10 100644
--- a/src/ui-api-server.test.ts
+++ b/src/ui-api-server.test.ts
@@ -190,7 +190,9 @@ describe('UI API server routes', () => {
);
expect(batchExportResponse.status).toBe(200);
expect(batchExportResponse.headers.get('Content-Type')).toBe('application/zip');
- expect(batchExportResponse.headers.get('Content-Disposition')).toContain('codex-conversations-2.zip');
+ expect(batchExportResponse.headers.get('Content-Disposition')).toContain(
+ 'codex_spiracha-2026-05-17-1712-threads-2.zip',
+ );
const batchExportBytes = new Uint8Array(await batchExportResponse.arrayBuffer());
expect(Array.from(batchExportBytes.slice(0, 2))).toEqual([0x50, 0x4b]);
diff --git a/src/ui/lib/antigravity-server.ts b/src/ui/lib/antigravity-server.ts
index 9f5a857..8af34ef 100644
--- a/src/ui/lib/antigravity-server.ts
+++ b/src/ui/lib/antigravity-server.ts
@@ -303,6 +303,7 @@ export const exportAntigravityConversations = async (input: z.input {
const { listClineWorkspaceGroups } = await import('@spiracha/lib/cline-db');
@@ -31,19 +31,19 @@ export const listClineTasksFn = createServerFn({ method: 'GET' })
});
const createClineTranscriptLoader = async () => {
- const { readClineTaskTranscript, resolveClineGlobalStorageDir } = await import('@spiracha/lib/cline-db');
+ const { readClineTaskTranscript, resolveClineDataDir } = await import('@spiracha/lib/cline-db');
const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
- const globalStorageDir = resolveClineGlobalStorageDir();
+ const dataDir = resolveClineDataDir();
return (taskId: string) =>
runWithTranscriptLoadLimit(
async () => {
- const transcript = await readClineTaskTranscript(globalStorageDir, taskId);
+ const transcript = await readClineTaskTranscript(dataDir, taskId);
if (!transcript) {
throw new Error(`Cline chat not found: ${taskId}`);
}
return transcript;
},
- { id: taskId, integration: 'cline', operation: 'ui-detail', path: globalStorageDir },
+ { id: taskId, integration: 'cline', operation: 'ui-detail', path: dataDir },
);
};
@@ -64,6 +64,7 @@ export const exportClineTaskFn = createServerFn({ method: 'POST' })
cwd: transcript.task.worktree,
fallbackBaseName: 'cline-chat',
outputFormat: data.outputFormat,
+ platform: 'cline',
sessionId: transcript.task.taskId,
updatedAtMs: transcript.task.lastActiveAtMs,
zipArchive: data.zipArchive,
@@ -92,6 +93,7 @@ export const exportClineTasksFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'cline-chats',
outputFormat: data.outputFormat,
+ platform: 'cline',
zipArchive: true,
});
});
@@ -99,8 +101,8 @@ export const exportClineTasksFn = createServerFn({ method: 'POST' })
export const deleteClineTaskFn = createServerFn({ method: 'POST' })
.validator(taskSchema)
.handler(async ({ data }) => {
- const { deleteClineTask, resolveClineGlobalStorageDir } = await import('@spiracha/lib/cline-db');
- const result = await deleteClineTask(resolveClineGlobalStorageDir(), data.taskId);
+ const { deleteClineTask, resolveClineDataDir } = await import('@spiracha/lib/cline-db');
+ const result = await deleteClineTask(resolveClineDataDir(), data.taskId);
requireDeletedItems(result.deletedTaskIds, 'Cline chat', data.taskId);
return result;
});
@@ -108,9 +110,9 @@ export const deleteClineTaskFn = createServerFn({ method: 'POST' })
export const deleteClineTasksFn = createServerFn({ method: 'POST' })
.validator(deleteTasksSchema)
.handler(async ({ data }) => {
- const { deleteClineTask, resolveClineGlobalStorageDir } = await import('@spiracha/lib/cline-db');
- const globalStorageDir = resolveClineGlobalStorageDir();
- const results = await runDeleteBatch(data.taskIds, (taskId) => deleteClineTask(globalStorageDir, taskId));
+ const { deleteClineTask, resolveClineDataDir } = await import('@spiracha/lib/cline-db');
+ const dataDir = resolveClineDataDir();
+ const results = await runDeleteBatch(data.taskIds, (taskId) => deleteClineTask(dataDir, taskId));
const deletedTaskIds = [...new Set(results.flatMap((result) => result.deletedTaskIds))];
requireDeletedItems(deletedTaskIds, 'Cline chats', 'batch');
return {
diff --git a/src/ui/lib/cline-server.vitest.ts b/src/ui/lib/cline-server.vitest.ts
index 8eef645..c033e9f 100644
--- a/src/ui/lib/cline-server.vitest.ts
+++ b/src/ui/lib/cline-server.vitest.ts
@@ -3,9 +3,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@tanstack/react-start', () => ({
createServerFn: () => {
+ let inputValidator: { parse: (value: unknown) => unknown } | undefined;
const serverFn = {
- handler: (callback: unknown) => callback,
- validator: () => serverFn,
+ handler: (callback: unknown) => {
+ const handler = callback as (args?: { data?: unknown }) => unknown;
+ return async (args?: { data?: unknown }) => {
+ if (!inputValidator) {
+ return await handler(args);
+ }
+
+ return await handler({ ...args, data: inputValidator.parse(args?.data) });
+ };
+ },
+ validator: (validator: unknown) => {
+ inputValidator = validator as { parse: (value: unknown) => unknown };
+ return serverFn;
+ },
};
return serverFn;
},
@@ -36,10 +49,11 @@ const transcript = {
isFavorited: false,
lastActiveAtMs: 200,
messageCount: 2,
+ messagesPath: '/cline/sessions/1/1.messages.json',
modelId: 'cline-model',
reasoningCount: 0,
renderablePartCount: 1,
- taskDir: '/cline/tasks/1',
+ sessionDir: '/cline/sessions/1',
taskId: '1',
title: 'Cline task',
tokensIn: null,
@@ -47,7 +61,6 @@ const transcript = {
toolCallCount: 0,
toolResultCount: 0,
totalCost: null,
- uiMessagesPath: '/cline/tasks/1/ui_messages.json',
ulid: null,
userMessageCount: 1,
workspaceKey: 'workspace:%2Frepo',
@@ -58,13 +71,13 @@ const transcript = {
const dbMocks = vi.hoisted(() => ({
deleteClineTask: vi.fn(async (_root: string, taskId: string) => ({
- deletedFiles: [`/cline/tasks/${taskId}`],
+ deletedFiles: [`/cline/sessions/${taskId}`],
deletedTaskIds: [taskId],
})),
listClineTasksForGroup: vi.fn(async () => [transcript.task]),
listClineWorkspaceGroups: vi.fn(async () => [{ key: transcript.task.workspaceKey }]),
readClineTaskTranscript: vi.fn(async () => transcript),
- resolveClineGlobalStorageDir: vi.fn(() => '/cline'),
+ resolveClineDataDir: vi.fn(() => '/cline'),
}));
vi.mock('@spiracha/lib/cline-db', () => dbMocks);
@@ -87,7 +100,7 @@ describe('Cline server functions', () => {
beforeEach(() => {
vi.clearAllMocks();
dbMocks.readClineTaskTranscript.mockResolvedValue(transcript);
- dbMocks.resolveClineGlobalStorageDir.mockReturnValue('/cline');
+ dbMocks.resolveClineDataDir.mockReturnValue('/cline');
});
it('should list workspaces, tasks, and task detail', async () => {
@@ -125,8 +138,10 @@ describe('Cline server functions', () => {
it('should delete one or multiple Cline chats', async () => {
await expect(deleteClineTaskFn({ data: { taskId: '1' } })).resolves.toMatchObject({ deletedTaskIds: ['1'] });
- await expect(deleteClineTasksFn({ data: { taskIds: ['1', '2'] } })).resolves.toMatchObject({
- deletedTaskIds: ['1', '2'],
+ await expect(
+ deleteClineTasksFn({ data: { taskIds: ['1786163719044_tpg1r', '1786164800606_0h9tk'] } }),
+ ).resolves.toMatchObject({
+ deletedTaskIds: ['1786163719044_tpg1r', '1786164800606_0h9tk'],
});
});
});
diff --git a/src/ui/lib/cline-transcript-events.ts b/src/ui/lib/cline-transcript-events.ts
index d50ba24..7c6fd8c 100644
--- a/src/ui/lib/cline-transcript-events.ts
+++ b/src/ui/lib/cline-transcript-events.ts
@@ -8,7 +8,7 @@ const raw = (message: ClineTranscriptMessage): Record => ({
messageId: message.messageId,
phase: message.phase,
role: message.role,
- source: 'cline_ui_messages',
+ source: 'cline_session_messages',
...message.raw,
});
diff --git a/src/ui/lib/cline-transcript-events.vitest.ts b/src/ui/lib/cline-transcript-events.vitest.ts
index 5714f4a..872d0a8 100644
--- a/src/ui/lib/cline-transcript-events.vitest.ts
+++ b/src/ui/lib/cline-transcript-events.vitest.ts
@@ -77,10 +77,11 @@ const transcript: ClineTaskTranscript = {
isFavorited: false,
lastActiveAtMs: 500,
messageCount: 2,
+ messagesPath: '/cline/sessions/1/1.messages.json',
modelId: 'cline-model',
reasoningCount: 1,
renderablePartCount: 5,
- taskDir: '/cline/tasks/1',
+ sessionDir: '/cline/sessions/1',
taskId: '1',
title: 'Task',
tokensIn: null,
@@ -88,7 +89,6 @@ const transcript: ClineTaskTranscript = {
toolCallCount: 1,
toolResultCount: 1,
totalCost: null,
- uiMessagesPath: '/cline/tasks/1/ui_messages.json',
ulid: null,
userMessageCount: 1,
workspaceKey: 'workspace:%2Frepo',
diff --git a/src/ui/lib/cursor-server.ts b/src/ui/lib/cursor-server.ts
index dceb5b4..0d537c7 100644
--- a/src/ui/lib/cursor-server.ts
+++ b/src/ui/lib/cursor-server.ts
@@ -1,3 +1,4 @@
+import type { CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
import { isSafeCursorComposerId } from '@spiracha/lib/cursor-id';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
@@ -62,6 +63,30 @@ const findGroupByKey = async (workspaceKey: string) => {
return group;
};
+const findCursorWorkspacesByComposerId = async (
+ workspaceGroups: CursorWorkspaceGroup[],
+ composerIds: string[],
+): Promise> => {
+ const requestedIds = new Set(composerIds);
+ const workspacesByComposerId = new Map();
+ const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
+
+ for (const group of workspaceGroups) {
+ const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ for (const thread of threads) {
+ if (requestedIds.has(thread.composerId)) {
+ workspacesByComposerId.set(thread.composerId, group);
+ }
+ }
+
+ if (workspacesByComposerId.size === requestedIds.size) {
+ break;
+ }
+ }
+
+ return workspacesByComposerId;
+};
+
const renderCursorZipDownload = async (
rendered: Array<{
composerId: string;
@@ -83,6 +108,7 @@ const renderCursorZipDownload = async (
})),
fallbackBaseName: 'cursor',
outputFormat,
+ platform: 'cursor',
zipArchive: true,
});
};
@@ -115,6 +141,7 @@ const renderCursorDownload = async (input: {
const globalDbPath = getCursorGlobalDbPath();
const { listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
const workspaceGroups = await listCursorWorkspaceGroups();
+ const workspacesByComposerId = await findCursorWorkspacesByComposerId(workspaceGroups, input.composerIds);
const rendered = await Promise.all(
input.composerIds.map(async (composerId) => {
const transcript = await runWithTranscriptLoadLimit(
@@ -141,10 +168,7 @@ const renderCursorDownload = async (input: {
throw new Error(`Thread has no exportable content: ${composerId}`);
}
- const workspace =
- workspaceGroups.find((candidate) =>
- candidate.buckets.some((bucket) => bucket.threadComposerIds.includes(composerId)),
- ) ?? null;
+ const workspace = workspacesByComposerId.get(composerId);
return {
composerId,
@@ -167,6 +191,7 @@ const renderCursorDownload = async (input: {
cwd: entry.cwd,
fallbackBaseName: 'cursor-thread',
outputFormat: input.outputFormat,
+ platform: 'cursor',
sessionId: entry.composerId,
updatedAtMs: entry.updatedAtMs,
zipArchive: false,
diff --git a/src/ui/lib/cursor-server.vitest.ts b/src/ui/lib/cursor-server.vitest.ts
index a6fbc1d..5f39dbe 100644
--- a/src/ui/lib/cursor-server.vitest.ts
+++ b/src/ui/lib/cursor-server.vitest.ts
@@ -257,6 +257,7 @@ describe('Cursor export server functions', () => {
beforeEach(() => {
vi.clearAllMocks();
listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValue([makeThread()]);
readCursorThreadTranscriptWithAgentFilesMock.mockResolvedValue(transcript);
renderCursorTranscriptMock.mockReturnValue('rendered transcript');
renderSourceSessionDownloadMock.mockResolvedValue({
@@ -268,6 +269,27 @@ describe('Cursor export server functions', () => {
renderSourceSessionsDownloadMock.mockResolvedValue({ mode: 'download_url' });
});
+ it('should use the Cursor workspace path when naming ZIP archives', async () => {
+ await exportCursorThreadsFn({
+ data: {
+ composerIds: ['thread-1'],
+ includeCommentary: true,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: true,
+ },
+ });
+
+ expect(renderSourceSessionsDownloadMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ entries: [expect.objectContaining({ cwd: '/tmp/one', sessionId: 'thread-1' })],
+ fallbackBaseName: 'cursor',
+ platform: 'cursor',
+ }),
+ );
+ });
+
it('should forward every single-thread export option to the renderer', async () => {
const result = await exportCursorThreadFn({
data: {
diff --git a/src/ui/lib/grok-server.ts b/src/ui/lib/grok-server.ts
index 9c23393..5755ec5 100644
--- a/src/ui/lib/grok-server.ts
+++ b/src/ui/lib/grok-server.ts
@@ -96,6 +96,7 @@ export const exportGrokSessionFn = createServerFn({ method: 'POST' })
cwd: transcript.session.cwd ?? transcript.session.worktree,
fallbackBaseName: 'grok-session',
outputFormat: data.outputFormat,
+ platform: 'grok',
sessionId: transcript.session.sessionId,
updatedAtMs: transcript.session.lastActiveAtMs,
zipArchive: data.zipArchive,
@@ -135,6 +136,7 @@ export const exportGrokSessionsFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'grok-sessions',
outputFormat: data.outputFormat,
+ platform: 'grok',
zipArchive: data.zipArchive,
});
});
diff --git a/src/ui/lib/kiro-server.ts b/src/ui/lib/kiro-server.ts
index 9670ec0..1125f5d 100644
--- a/src/ui/lib/kiro-server.ts
+++ b/src/ui/lib/kiro-server.ts
@@ -94,6 +94,7 @@ export const exportKiroSessionFn = createServerFn({ method: 'POST' })
cwd: transcript.session.workspacePath ?? transcript.session.worktree,
fallbackBaseName: 'kiro-session',
outputFormat: data.outputFormat,
+ platform: 'kiro',
sessionId: transcript.session.sessionId,
updatedAtMs: transcript.session.lastActiveAtMs,
zipArchive: data.zipArchive,
@@ -133,6 +134,7 @@ export const exportKiroSessionsFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'kiro-sessions',
outputFormat: data.outputFormat,
+ platform: 'kiro',
zipArchive: data.zipArchive,
});
});
diff --git a/src/ui/lib/minimax-code-server.ts b/src/ui/lib/minimax-code-server.ts
index db6fd3a..618b0f7 100644
--- a/src/ui/lib/minimax-code-server.ts
+++ b/src/ui/lib/minimax-code-server.ts
@@ -92,6 +92,7 @@ export const exportMiniMaxCodeSessionFn = createServerFn({ method: 'POST' })
cwd: transcript.session.worktree,
fallbackBaseName: 'minimax-code-session',
outputFormat: data.outputFormat,
+ platform: 'minimax',
sessionId: transcript.session.sessionId,
updatedAtMs: transcript.session.lastActiveAtMs,
zipArchive: data.zipArchive,
@@ -130,6 +131,7 @@ export const exportMiniMaxCodeSessionsFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'minimax-code-sessions',
outputFormat: data.outputFormat,
+ platform: 'minimax',
zipArchive: true,
});
});
diff --git a/src/ui/lib/opencode-server.ts b/src/ui/lib/opencode-server.ts
index fcc08c0..4df18f2 100644
--- a/src/ui/lib/opencode-server.ts
+++ b/src/ui/lib/opencode-server.ts
@@ -94,6 +94,7 @@ export const exportOpenCodeSessionFn = createServerFn({ method: 'POST' })
cwd: transcript.session.worktree,
fallbackBaseName: 'opencode-session',
outputFormat: data.outputFormat,
+ platform: 'opencode',
sessionId: transcript.session.sessionId,
updatedAtMs: transcript.session.lastUpdatedAtMs,
zipArchive: data.zipArchive,
@@ -133,6 +134,7 @@ export const exportOpenCodeSessionsFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'opencode-sessions',
outputFormat: data.outputFormat,
+ platform: 'opencode',
zipArchive: data.zipArchive,
});
});
diff --git a/src/ui/lib/qoder-server.ts b/src/ui/lib/qoder-server.ts
index 6f21290..1cae40d 100644
--- a/src/ui/lib/qoder-server.ts
+++ b/src/ui/lib/qoder-server.ts
@@ -92,6 +92,7 @@ export const exportQoderSessionFn = createServerFn({ method: 'POST' })
cwd: transcript.session.workspacePath ?? transcript.session.worktree,
fallbackBaseName: 'qoder-session',
outputFormat: data.outputFormat,
+ platform: 'qoder',
sessionId: transcript.session.sessionId,
updatedAtMs: transcript.session.lastActiveAtMs,
zipArchive: data.zipArchive,
@@ -131,6 +132,7 @@ export const exportQoderSessionsFn = createServerFn({ method: 'POST' })
entries,
fallbackBaseName: 'qoder-sessions',
outputFormat: data.outputFormat,
+ platform: 'qoder',
zipArchive: data.zipArchive,
});
});
diff --git a/src/ui/lib/source-session-export-server.ts b/src/ui/lib/source-session-export-server.ts
index 1c39664..a0a784f 100644
--- a/src/ui/lib/source-session-export-server.ts
+++ b/src/ui/lib/source-session-export-server.ts
@@ -2,9 +2,11 @@ import { randomUUID } from 'node:crypto';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
+import type { ExportPlatform } from '@spiracha/lib/ui-export-archive';
import {
buildBatchExportBaseName,
buildConversationExportBaseName,
+ buildExportArchiveBaseName,
getExportMimeType,
resolveUniqueExportFileBaseName,
sanitizeExportFileName,
@@ -19,6 +21,7 @@ type RenderSourceSessionDownloadOptions = {
cwd: string | null;
fallbackBaseName: string;
outputFormat: ExportFormat;
+ platform: ExportPlatform;
sessionId: string;
updatedAtMs: number | null;
zipArchive: boolean;
@@ -37,6 +40,7 @@ type RenderSourceSessionsDownloadOptions = {
entries: RenderedSourceSession[];
fallbackBaseName: string;
outputFormat: ExportFormat;
+ platform: ExportPlatform;
zipArchive: boolean;
};
@@ -49,6 +53,7 @@ export const renderSourceSessionDownload = async ({
cwd,
fallbackBaseName,
outputFormat,
+ platform,
sessionId,
updatedAtMs,
zipArchive,
@@ -70,9 +75,10 @@ export const renderSourceSessionDownload = async ({
};
}
+ const archiveBaseName = buildExportArchiveBaseName(platform, safeBaseName);
const exportDir = await ensureUiExportDir();
- const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${safeBaseName}-`));
- const zipPath = path.join(exportDir, `${safeBaseName}-${randomUUID()}.zip`);
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${archiveBaseName}-`));
+ const zipPath = path.join(exportDir, `${archiveBaseName}-${randomUUID()}.zip`);
try {
await Bun.write(path.join(workspaceDir, `${safeBaseName}.${outputFormat}`), content);
@@ -83,7 +89,7 @@ export const renderSourceSessionDownload = async ({
return {
downloadUrl: buildUiExportDownloadUrl(zipPath),
- fileName: `${safeBaseName}.zip`,
+ fileName: `${archiveBaseName}.zip`,
mimeType: 'application/zip',
mode: 'download_url' as const,
};
@@ -93,6 +99,7 @@ export const renderSourceSessionsDownload = async ({
entries,
fallbackBaseName,
outputFormat,
+ platform,
zipArchive,
}: RenderSourceSessionsDownloadOptions) => {
if (entries.length === 0) {
@@ -106,6 +113,7 @@ export const renderSourceSessionsDownload = async ({
cwd: entry.cwd,
fallbackBaseName: entry.fallbackBaseName,
outputFormat,
+ platform,
sessionId: entry.sessionId,
updatedAtMs: entry.updatedAtMs,
zipArchive,
@@ -113,9 +121,10 @@ export const renderSourceSessionsDownload = async ({
}
const safeBaseName = buildBatchExportBaseName(entries, fallbackBaseName);
+ const archiveBaseName = buildExportArchiveBaseName(platform, safeBaseName);
const exportDir = await ensureUiExportDir();
- const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${safeBaseName}-`));
- const zipPath = path.join(exportDir, `${safeBaseName}-${randomUUID()}.zip`);
+ const workspaceDir = await mkdtemp(path.join(os.tmpdir(), `${archiveBaseName}-`));
+ const zipPath = path.join(exportDir, `${archiveBaseName}-${randomUUID()}.zip`);
const usedBaseNames = new Map();
try {
@@ -132,7 +141,7 @@ export const renderSourceSessionsDownload = async ({
return {
downloadUrl: buildUiExportDownloadUrl(zipPath),
- fileName: `${safeBaseName}.zip`,
+ fileName: `${archiveBaseName}.zip`,
mimeType: 'application/zip',
mode: 'download_url' as const,
};
diff --git a/src/ui/lib/source-session-export-server.vitest.ts b/src/ui/lib/source-session-export-server.vitest.ts
index 89e614a..e0dd185 100644
--- a/src/ui/lib/source-session-export-server.vitest.ts
+++ b/src/ui/lib/source-session-export-server.vitest.ts
@@ -68,6 +68,7 @@ describe('source session export server helpers', () => {
],
fallbackBaseName: 'source-sessions',
outputFormat: 'md',
+ platform: 'cline',
zipArchive: false,
});
@@ -79,7 +80,7 @@ describe('source session export server helpers', () => {
});
});
- it('should use the Codex-style name when zipping one source session', async () => {
+ it('should prefix a single source-session archive with its platform', async () => {
const result = await renderSourceSessionsDownload({
entries: [
{
@@ -93,6 +94,7 @@ describe('source session export server helpers', () => {
],
fallbackBaseName: 'source-sessions',
outputFormat: 'md',
+ platform: 'cline',
zipArchive: true,
});
@@ -100,7 +102,7 @@ describe('source session export server helpers', () => {
if (result.mode !== 'download_url') {
throw new Error('expected a zip download URL');
}
- expect(result.fileName).toBe('spiracha-2026-05-17-1712-019e36d7.zip');
+ expect(result.fileName).toBe('cline_spiracha-2026-05-17-1712-019e36d7.zip');
});
it('should zip multiple source session exports', async () => {
@@ -125,6 +127,7 @@ describe('source session export server helpers', () => {
],
fallbackBaseName: 'source-sessions',
outputFormat: 'md',
+ platform: 'minimax',
zipArchive: false,
});
@@ -132,7 +135,7 @@ describe('source session export server helpers', () => {
if (result.mode !== 'download_url') {
throw new Error('expected a zip download URL');
}
- expect(result.fileName).toBe('spiracha-2026-05-17-1712-threads-2.zip');
+ expect(result.fileName).toBe('minimax_spiracha-2026-05-17-1712-threads-2.zip');
const metadata = await stat(resolveDownloadPath(result.downloadUrl));
expect(metadata.isFile()).toBe(true);
});
From cb6db0bcc09ec8088189c7dc87bc12304a9aa0bf Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Sat, 15 Aug 2026 14:07:35 -0400
Subject: [PATCH 02/10] Fix Cursor project deleting
---
src/lib/codex-browser-db.test.ts | 290 ++++++++++++++++++
src/lib/codex-browser-db.ts | 143 ++++++---
src/lib/codex-browser-types.ts | 11 +
src/lib/cursor-db.test.ts | 15 +
src/lib/cursor-db.ts | 71 +++--
src/lib/cursor-id.ts | 5 +
src/lib/cursor-recovery.test.ts | 84 ++++-
src/lib/cursor-recovery.ts | 276 +++++++++++++++--
src/ui/components/cursor-workspaces-table.tsx | 21 +-
.../cursor-workspaces-table.vitest.tsx | 34 +-
src/ui/components/data-table.tsx | 4 +-
src/ui/lib/cursor-server.ts | 77 ++++-
src/ui/lib/cursor-server.vitest.ts | 111 ++++++-
src/ui/routes/cursor.$workspaceKey.tsx | 2 +-
src/ui/routes/cursor.index.tsx | 44 ++-
15 files changed, 1066 insertions(+), 122 deletions(-)
diff --git a/src/lib/codex-browser-db.test.ts b/src/lib/codex-browser-db.test.ts
index f6dfcce..2351638 100644
--- a/src/lib/codex-browser-db.test.ts
+++ b/src/lib/codex-browser-db.test.ts
@@ -14,6 +14,7 @@ import {
listProjectThreads,
listScopedThreads,
mergeSessionIndexLinesForRewrite,
+ reconcileCodexSessionIndex,
resolveCodexThreadDbPath,
withReadonlyDb,
} from './codex-browser-db';
@@ -142,6 +143,148 @@ const createLiveSchemaDeleteFixture = async (tempRoot: string) => {
};
};
+const createPaginatedHistoryDatabase = (tempRoot: string, threadIds: string[]) => {
+ const historyPath = path.join(tempRoot, 'thread_history_1.sqlite');
+ const db = new Database(historyPath);
+ db.exec(`
+ CREATE TABLE thread_items (
+ thread_id TEXT NOT NULL,
+ turn_id TEXT NOT NULL,
+ item_id TEXT NOT NULL,
+ rollout_ordinal INTEGER NOT NULL,
+ created_at_ms INTEGER NOT NULL,
+ item_json TEXT NOT NULL,
+ item_type TEXT NOT NULL DEFAULT '',
+ updated_at_ordinal INTEGER NOT NULL DEFAULT 0,
+ PRIMARY KEY (thread_id, turn_id, item_id)
+ );
+
+ CREATE TABLE thread_turns (
+ thread_id TEXT NOT NULL,
+ turn_id TEXT NOT NULL,
+ rollout_ordinal INTEGER NOT NULL,
+ status TEXT NOT NULL,
+ error_json TEXT,
+ started_at INTEGER,
+ completed_at INTEGER,
+ duration_ms INTEGER,
+ first_user_item_id TEXT,
+ final_agent_item_id TEXT,
+ rollout_byte_offset INTEGER,
+ rollout_end_ordinal INTEGER,
+ rollout_end_byte_offset INTEGER,
+ PRIMARY KEY (thread_id, turn_id)
+ );
+
+ CREATE TABLE thread_history_projection_state (
+ thread_id TEXT PRIMARY KEY,
+ next_rollout_byte_offset INTEGER NOT NULL,
+ next_rollout_ordinal INTEGER NOT NULL
+ );
+ `);
+
+ const insertItem = db.prepare(`
+ INSERT INTO thread_items (
+ thread_id, turn_id, item_id, rollout_ordinal, created_at_ms, item_json
+ ) VALUES (?, ?, ?, ?, ?, ?)
+ `);
+ const insertTurn = db.prepare(`
+ INSERT INTO thread_turns (thread_id, turn_id, rollout_ordinal, status)
+ VALUES (?, ?, ?, ?)
+ `);
+ const insertProjectionState = db.prepare(`
+ INSERT INTO thread_history_projection_state (
+ thread_id, next_rollout_byte_offset, next_rollout_ordinal
+ ) VALUES (?, ?, ?)
+ `);
+
+ for (const [index, threadId] of threadIds.entries()) {
+ const turnId = `turn-${index}`;
+ insertItem.run(threadId, turnId, `item-${index}`, index, index, JSON.stringify({ threadId }));
+ insertTurn.run(threadId, turnId, index, 'completed');
+ insertProjectionState.run(threadId, index, index);
+ }
+
+ db.close();
+ return historyPath;
+};
+
+const readPaginatedHistoryCounts = (historyPath: string, threadIds: string[]) => {
+ const db = new Database(historyPath, { readonly: true });
+ const placeholders = threadIds.map(() => '?').join(', ');
+ try {
+ return {
+ items: Number(
+ (
+ db
+ .query(`SELECT COUNT(*) AS count FROM thread_items WHERE thread_id IN (${placeholders})`)
+ .get(...threadIds) as { count: number }
+ ).count,
+ ),
+ projectionStates: Number(
+ (
+ db
+ .query(
+ `SELECT COUNT(*) AS count FROM thread_history_projection_state WHERE thread_id IN (${placeholders})`,
+ )
+ .get(...threadIds) as { count: number }
+ ).count,
+ ),
+ turns: Number(
+ (
+ db
+ .query(`SELECT COUNT(*) AS count FROM thread_turns WHERE thread_id IN (${placeholders})`)
+ .get(...threadIds) as { count: number }
+ ).count,
+ ),
+ };
+ } finally {
+ db.close();
+ }
+};
+
+const addFallbackSessionIndexEntry = async (tempRoot: string, threadId: string, projectName: string) => {
+ const sessionFile = path.join(
+ tempRoot,
+ 'sessions',
+ '2026',
+ '06',
+ '14',
+ `rollout-2026-06-14T10-00-00-${threadId}.jsonl`,
+ );
+ await mkdir(path.dirname(sessionFile), { recursive: true });
+ await Bun.write(
+ sessionFile,
+ `${JSON.stringify({
+ payload: {
+ cli_version: '0.140.0-alpha.2',
+ cwd: `/Users/user/workspace/${projectName}`,
+ id: threadId,
+ model_provider: 'openai',
+ originator: 'Codex Desktop',
+ source: 'vscode',
+ thread_source: 'user',
+ timestamp: '2026-06-14T10:00:00.000Z',
+ },
+ timestamp: '2026-06-14T10:00:00.000Z',
+ type: 'session_meta',
+ })}\n`,
+ );
+
+ const sessionIndexPath = path.join(tempRoot, 'session_index.jsonl');
+ const existing = (await Bun.file(sessionIndexPath).exists()) ? await Bun.file(sessionIndexPath).text() : '';
+ await Bun.write(
+ sessionIndexPath,
+ `${existing}${JSON.stringify({
+ id: threadId,
+ thread_name: `Fallback ${threadId}`,
+ updated_at: '2026-06-14T10:00:00.000Z',
+ })}\n`,
+ );
+
+ return sessionFile;
+};
+
const createMinimalBrowseSchemaFixture = async (tempRoot: string) => {
const dbPath = path.join(tempRoot, 'state.sqlite');
const sessionFile = path.join(tempRoot, 'sessions', '2026', '05', '24', 'rollout-thread-minimal.jsonl');
@@ -1240,6 +1383,153 @@ describe('codex browser db', () => {
db.close();
});
+ it('should delete paginated history rows for an individual thread', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-history-thread-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const targetThreadId = fixture.threads[0]!.threadId;
+ const retainedThreadId = fixture.threads[1]!.threadId;
+ const historyPath = createPaginatedHistoryDatabase(tempRoot, [targetThreadId, retainedThreadId]);
+
+ const result = await deleteCodexThread(fixture.dbPath, targetThreadId);
+
+ expect(result.deletedThreadIds).toEqual([targetThreadId]);
+ expect(readPaginatedHistoryCounts(historyPath, [targetThreadId])).toEqual({
+ items: 0,
+ projectionStates: 0,
+ turns: 0,
+ });
+ expect(readPaginatedHistoryCounts(historyPath, [retainedThreadId])).toEqual({
+ items: 1,
+ projectionStates: 1,
+ turns: 1,
+ });
+ });
+
+ it('should bulk-delete paginated history rows while retaining unrelated threads', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-history-batch-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const deletedThreadIds = fixture.threads.slice(0, 2).map((thread) => thread.threadId);
+ const retainedThreadId = fixture.threads[2]!.threadId;
+ const historyPath = createPaginatedHistoryDatabase(tempRoot, [...deletedThreadIds, retainedThreadId]);
+
+ const result = await deleteCodexThreads(fixture.dbPath, [...deletedThreadIds, deletedThreadIds[0]!]);
+
+ expect(result.deletedThreadIds.sort()).toEqual([...deletedThreadIds].sort());
+ expect(readPaginatedHistoryCounts(historyPath, deletedThreadIds)).toEqual({
+ items: 0,
+ projectionStates: 0,
+ turns: 0,
+ });
+ expect(readPaginatedHistoryCounts(historyPath, [retainedThreadId])).toEqual({
+ items: 1,
+ projectionStates: 1,
+ turns: 1,
+ });
+ });
+
+ it('should roll back state and paginated history deletion when history mutation fails', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-history-rollback-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const deletedThreadIds = fixture.threads.slice(0, 2).map((thread) => thread.threadId);
+ const historyPath = createPaginatedHistoryDatabase(tempRoot, deletedThreadIds);
+ const historyDb = new Database(historyPath);
+ historyDb.exec(`
+ CREATE TRIGGER fail_history_delete
+ BEFORE DELETE ON thread_turns
+ BEGIN
+ SELECT RAISE(ABORT, 'history deletion blocked');
+ END;
+ `);
+ historyDb.close();
+
+ await expect(deleteCodexThreads(fixture.dbPath, deletedThreadIds)).rejects.toThrow('history deletion blocked');
+
+ const stateDb = new Database(fixture.dbPath, { readonly: true });
+ try {
+ expect(
+ stateDb.query('SELECT COUNT(*) AS count FROM threads WHERE id IN (?, ?)').get(...deletedThreadIds),
+ ).toEqual({ count: deletedThreadIds.length });
+ } finally {
+ stateDb.close();
+ }
+ expect(readPaginatedHistoryCounts(historyPath, deletedThreadIds)).toEqual({
+ items: deletedThreadIds.length,
+ projectionStates: deletedThreadIds.length,
+ turns: deletedThreadIds.length,
+ });
+ });
+
+ it('should delete project paginated history for state and fallback-only threads', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-project-history-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const fallbackThreadId = '019ec3d5-859d-77d0-b851-256ae567ff67';
+ const fallbackSessionFile = await addFallbackSessionIndexEntry(tempRoot, fallbackThreadId, 'spiracha');
+ const retainedThreadId = fixture.threads[2]!.threadId;
+ const sessionIndexPath = path.join(tempRoot, 'session_index.jsonl');
+ const retainedSessionIndexEntry = JSON.stringify({
+ id: retainedThreadId,
+ thread_name: 'Retained thread',
+ updated_at: '2026-06-14T10:01:00.000Z',
+ });
+ await Bun.write(sessionIndexPath, `${await Bun.file(sessionIndexPath).text()}${retainedSessionIndexEntry}\n`);
+ const deletedThreadIds = [fixture.threads[0]!.threadId, fixture.threads[1]!.threadId, fallbackThreadId];
+ const historyPath = createPaginatedHistoryDatabase(tempRoot, [...deletedThreadIds, retainedThreadId]);
+
+ const result = await deleteCodexProject(fixture.dbPath, 'spiracha', { deleteSessionFiles: true });
+
+ expect(result.deletedThreadIds.sort()).toEqual([...deletedThreadIds].sort());
+ expect(await Bun.file(fallbackSessionFile).exists()).toBe(false);
+ expect(await Bun.file(sessionIndexPath).text()).toBe(`${retainedSessionIndexEntry}\n`);
+ expect(readPaginatedHistoryCounts(historyPath, deletedThreadIds)).toEqual({
+ items: 0,
+ projectionStates: 0,
+ turns: 0,
+ });
+ expect(readPaginatedHistoryCounts(historyPath, [retainedThreadId])).toEqual({
+ items: 1,
+ projectionStates: 1,
+ turns: 1,
+ });
+ });
+
+ it('should report stale session-index entries in a dry run without mutating history', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-reconcile-session-index-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const indexedThreadId = fixture.threads[0]!.threadId;
+ const fallbackThreadId = '019ec3d5-859d-77d0-b851-256ae567ff68';
+ const staleThreadId = '019ec3d5-859d-77d0-b851-256ae567ff69';
+ const fallbackSessionFile = await addFallbackSessionIndexEntry(tempRoot, fallbackThreadId, 'spiracha');
+ const sessionIndexPath = path.join(tempRoot, 'session_index.jsonl');
+ await Bun.write(
+ sessionIndexPath,
+ [
+ { id: indexedThreadId, thread_name: 'Indexed state thread' },
+ { id: fallbackThreadId, thread_name: 'Indexed rollout thread' },
+ { id: staleThreadId, thread_name: 'Stale index entry' },
+ ]
+ .map((entry) => JSON.stringify(entry))
+ .join('\n') + '\n',
+ );
+ const historyPath = createPaginatedHistoryDatabase(tempRoot, [indexedThreadId, staleThreadId]);
+ const initialSessionIndex = await Bun.file(sessionIndexPath).text();
+ const initialHistoryCounts = readPaginatedHistoryCounts(historyPath, [indexedThreadId, staleThreadId]);
+
+ const result = reconcileCodexSessionIndex(fixture.dbPath);
+
+ expect(result).toEqual({
+ dryRun: true,
+ staleEntries: [{ id: staleThreadId, thread_name: 'Stale index entry' }],
+ });
+ expect(await Bun.file(sessionIndexPath).text()).toBe(initialSessionIndex);
+ expect(readPaginatedHistoryCounts(historyPath, [indexedThreadId, staleThreadId])).toEqual(initialHistoryCounts);
+ expect(await Bun.file(fallbackSessionFile).exists()).toBe(true);
+ });
+
it('should resolve the configured Codex thread database path', () => {
const previous = process.env.SPIRACHA_CODEX_DB;
const configuredPath = path.join(os.tmpdir(), 'configured-codex-state.sqlite');
diff --git a/src/lib/codex-browser-db.ts b/src/lib/codex-browser-db.ts
index c5e06dc..ea28aaf 100644
--- a/src/lib/codex-browser-db.ts
+++ b/src/lib/codex-browser-db.ts
@@ -6,6 +6,7 @@ import path from 'node:path';
import { StringDecoder } from 'node:string_decoder';
import { pathToFileURL } from 'node:url';
import type {
+ CodexSessionIndexReconciliation,
DashboardSummary,
DashboardThreadSummary,
DeleteProjectResult,
@@ -336,6 +337,17 @@ const resolveCodexDirFromDbPath = (dbPath: string) => {
return path.basename(dbDir) === 'sqlite' ? path.dirname(dbDir) : dbDir;
};
+const resolveCodexHistoryDbPath = (dbPath: string) =>
+ path.join(resolveCodexDirFromDbPath(dbPath), 'thread_history_1.sqlite');
+
+const hasRegularFile = (filePath: string) => {
+ try {
+ return statSync(filePath).isFile();
+ } catch {
+ return false;
+ }
+};
+
const resolveCodexRolloutPath = (dbPath: string, rolloutPath: string) =>
path.isAbsolute(rolloutPath) ? rolloutPath : path.join(resolveCodexDirFromDbPath(dbPath), rolloutPath);
@@ -1210,6 +1222,13 @@ const getExistingTableNames = (db: Database) => {
return new Set(rows.map((row) => row.name));
};
+const getExistingCodexHistoryTableNames = (db: Database) => {
+ const rows = db.query('SELECT name FROM codex_history.sqlite_master WHERE type = ?').all('table') as Array<{
+ name: string;
+ }>;
+ return new Set(rows.map((row) => row.name));
+};
+
type ThreadSpawnEdge = {
child_thread_id: string;
parent_thread_id: string;
@@ -1297,53 +1316,93 @@ const getThreadDeleteTargets = (db: Database, threadIds: string[]) => {
return targets;
};
-const deleteThreadIds = (db: Database, threadIds: string[]): DeleteThreadsResult => {
+const deleteStateThreadRows = (db: Database, existingTableNames: Set, threadIds: string[]) => {
if (threadIds.length === 0) {
- return {
- deletedSessionFiles: [],
- deletedThreadIds: [],
- };
+ return;
}
- const existingTableNames = getExistingTableNames(db);
- const threadTargets = getThreadDeleteTargets(db, threadIds);
- const existingIds = threadTargets.map((target) => target.id);
+ const placeholders = threadIds.map(() => '?').join(', ');
+
+ // Codex schema differs across versions, so only touch dependent tables that actually exist.
+ if (existingTableNames.has('thread_dynamic_tools')) {
+ db.query(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`).run(...threadIds);
+ }
+
+ if (existingTableNames.has('thread_goals')) {
+ db.query(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`).run(...threadIds);
+ }
+
+ if (existingTableNames.has('stage1_outputs')) {
+ db.query(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`).run(...threadIds);
+ }
+
+ if (existingTableNames.has('thread_spawn_edges')) {
+ db.query(
+ `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
+ ).run(...threadIds, ...threadIds);
+ }
+
+ db.query(`DELETE FROM threads WHERE id IN (${placeholders})`).run(...threadIds);
+};
+
+const deleteHistoryThreadRows = (db: Database, historyTableNames: Set, threadIds: string[]) => {
+ if (threadIds.length === 0) {
+ return;
+ }
- if (existingIds.length === 0) {
+ const placeholders = threadIds.map(() => '?').join(', ');
+ if (historyTableNames.has('thread_items')) {
+ db.query(`DELETE FROM codex_history.thread_items WHERE thread_id IN (${placeholders})`).run(...threadIds);
+ }
+
+ if (historyTableNames.has('thread_turns')) {
+ db.query(`DELETE FROM codex_history.thread_turns WHERE thread_id IN (${placeholders})`).run(...threadIds);
+ }
+
+ if (historyTableNames.has('thread_history_projection_state')) {
+ db.query(`DELETE FROM codex_history.thread_history_projection_state WHERE thread_id IN (${placeholders})`).run(
+ ...threadIds,
+ );
+ }
+};
+
+const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): DeleteThreadsResult => {
+ const uniqueThreadIds = uniqueValues(threadIds);
+ if (uniqueThreadIds.length === 0) {
return {
deletedSessionFiles: [],
deletedThreadIds: [],
};
}
- const deleteMany = db.transaction((ids: string[]) => {
- for (const threadIdChunk of chunkValues(ids, SQLITE_DELETE_BATCH_SIZE)) {
- const placeholders = threadIdChunk.map(() => '?').join(', ');
-
- // Codex schema differs across versions, so only touch dependent tables that actually exist.
- if (existingTableNames.has('thread_dynamic_tools')) {
- db.query(`DELETE FROM thread_dynamic_tools WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
- }
-
- if (existingTableNames.has('thread_goals')) {
- db.query(`DELETE FROM thread_goals WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
- }
+ const existingTableNames = getExistingTableNames(db);
+ const threadTargets = getThreadDeleteTargets(db, uniqueThreadIds);
+ const existingIds = threadTargets.map((target) => target.id);
- if (existingTableNames.has('stage1_outputs')) {
- db.query(`DELETE FROM stage1_outputs WHERE thread_id IN (${placeholders})`).run(...threadIdChunk);
- }
+ const existingIdSet = new Set(existingIds);
+ let historyAttached = false;
+ try {
+ const historyDbPath = resolveCodexHistoryDbPath(dbPath);
+ if (hasRegularFile(historyDbPath)) {
+ db.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
+ historyAttached = true;
+ }
- if (existingTableNames.has('thread_spawn_edges')) {
- db.query(
- `DELETE FROM thread_spawn_edges WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})`,
- ).run(...threadIdChunk, ...threadIdChunk);
+ const historyTableNames = historyAttached ? getExistingCodexHistoryTableNames(db) : new Set();
+ const deleteMany = db.transaction((ids: string[]) => {
+ for (const threadIdChunk of chunkValues(ids, SQLITE_DELETE_BATCH_SIZE)) {
+ const stateThreadIds = threadIdChunk.filter((threadId) => existingIdSet.has(threadId));
+ deleteStateThreadRows(db, existingTableNames, stateThreadIds);
+ deleteHistoryThreadRows(db, historyTableNames, threadIdChunk);
}
+ });
- db.query(`DELETE FROM threads WHERE id IN (${placeholders})`).run(...threadIdChunk);
+ deleteMany(uniqueThreadIds);
+ } finally {
+ if (historyAttached) {
+ db.query('DETACH DATABASE codex_history').run();
}
- });
-
- deleteMany(existingIds);
+ }
return {
deletedSessionFiles: threadTargets.map((target) => target.rollout_path),
@@ -1559,6 +1618,20 @@ const readDbThreadIds = (db: Database) => {
return new Set(rows.map((row) => row.id));
};
+export const reconcileCodexSessionIndex = (dbPath: string): CodexSessionIndexReconciliation => {
+ const codexDir = resolveCodexDirFromDbPath(dbPath);
+ const existingThreadIds = withReadonlyDb(dbPath, (db) => readDbThreadIds(db));
+ const sessionFilesByThreadId = getSessionFilesByThreadId(path.join(codexDir, 'sessions'));
+ const staleEntries = readSessionIndexEntries(codexDir).filter(
+ (entry) => !existingThreadIds.has(entry.id) && !sessionFilesByThreadId.has(entry.id),
+ );
+
+ return {
+ dryRun: true,
+ staleEntries,
+ };
+};
+
const readProjectSummaryDatabaseData = (dbPath: string) => {
return withReadonlyDb(dbPath, (db) => ({
existingThreadIds: readDbThreadIds(db),
@@ -1814,7 +1887,7 @@ export const deleteCodexThread = async (
await validateSessionFileDeletionTargets(dbPath, threadIds);
}
const result = withWritableDb(dbPath, (db) => {
- return deleteThreadIds(db, threadIds);
+ return deleteThreadIds(db, dbPath, threadIds);
});
try {
@@ -1844,7 +1917,7 @@ export const deleteCodexThreads = async (
await validateSessionFileDeletionTargets(dbPath, uniqueThreadIds);
}
const result = withWritableDb(dbPath, (db) => {
- return deleteThreadIds(db, uniqueThreadIds);
+ return deleteThreadIds(db, dbPath, uniqueThreadIds);
});
try {
@@ -1887,7 +1960,7 @@ export const deleteCodexProject = async (
await validateSessionFileDeletionTargets(dbPath, allThreadIds);
}
const result = withWritableDb(dbPath, (db) => {
- const deleted = deleteThreadIds(db, projectThreadIds);
+ const deleted = deleteThreadIds(db, dbPath, allThreadIds);
return {
...deleted,
diff --git a/src/lib/codex-browser-types.ts b/src/lib/codex-browser-types.ts
index eb7f51c..db07861 100644
--- a/src/lib/codex-browser-types.ts
+++ b/src/lib/codex-browser-types.ts
@@ -210,6 +210,17 @@ export type DashboardSummary = {
totalTokens: number;
};
+export type CodexSessionIndexEntry = {
+ id: string;
+ thread_name?: string;
+ updated_at?: string;
+};
+
+export type CodexSessionIndexReconciliation = {
+ dryRun: true;
+ staleEntries: CodexSessionIndexEntry[];
+};
+
export type DeleteThreadsResult = {
deletedSessionFiles: string[];
deletedThreadIds: string[];
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index 4c7f69a..e4d7cc8 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -7,6 +7,7 @@ import {
CURSOR_READONLY_DB_OPEN_FLAGS,
decodeCursorUri,
findCursorTranscriptDirs,
+ findCursorTranscriptDirsForComposerIds,
findCursorWorkspaceGroups,
getCursorReadonlyDbUri,
listCursorThreadsForGroup,
@@ -385,6 +386,20 @@ describe('cursor-db workspace discovery', () => {
expect(threads[0]?.transcriptDirs).toEqual([transcriptDir]);
});
+ it('should resolve transcript directories for a deletion batch in one result map', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+ const firstDir = path.join(userDir, 'projects', 'first', 'agent-transcripts', 'thread-1');
+ const secondDir = path.join(userDir, 'projects', 'second', 'agent-transcripts', 'thread-2');
+ await Promise.all([mkdir(firstDir, { recursive: true }), mkdir(secondDir, { recursive: true })]);
+
+ const matches = await findCursorTranscriptDirsForComposerIds(['thread-1', 'thread-2', '../../unsafe'], userDir);
+
+ expect(matches.get('thread-1')).toEqual([firstDir]);
+ expect(matches.get('thread-2')).toEqual([secondDir]);
+ expect(matches.has('../../unsafe')).toBe(false);
+ });
+
it('should discover a Cursor CLI transcript without a SQLite composer record', async () => {
const userDir = await makeUserDir();
const composerId = '73d679d2-5311-4e00-8be3-af67fbf0fa87';
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index f93a1bc..be7c595 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -491,43 +491,64 @@ const countBubbles = (db: Database, composerId: string): { count: number; bytes:
return { bytes: row.bytes, count: row.count };
};
-export const findCursorTranscriptDirs = async (
- composerId: string,
+export const findCursorTranscriptDirsForComposerIds = async (
+ composerIds: Iterable,
userDir = resolveCursorUserDir(),
-): Promise => {
- if (!isSafeCursorComposerId(composerId)) {
- return [];
+): Promise> => {
+ const safeComposerIds = new Set([...composerIds].filter(isSafeCursorComposerId));
+ const matches = new Map([...safeComposerIds].map((composerId) => [composerId, [] as string[]]));
+ if (safeComposerIds.size === 0) {
+ return matches;
}
const projectsDir = getCursorProjectsDir(userDir);
if (!(await pathExists(projectsDir))) {
- return [];
+ return matches;
}
- const matches: string[] = [];
let projectDirs: string[] = [];
try {
projectDirs = await readdir(projectsDir);
} catch {
- return [];
+ return matches;
}
- for (const projectDir of projectDirs) {
- const agentTranscriptsDir = path.resolve(projectsDir, projectDir, 'agent-transcripts');
- const transcriptDir = path.resolve(agentTranscriptsDir, composerId);
- const relativePath = path.relative(agentTranscriptsDir, transcriptDir);
- if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
- continue;
- }
+ await Promise.all(
+ projectDirs.map(async (projectDir) => {
+ const agentTranscriptsDir = path.resolve(projectsDir, projectDir, 'agent-transcripts');
+ let entries: string[];
+ try {
+ entries = await readdir(agentTranscriptsDir);
+ } catch {
+ return;
+ }
- if (await pathExists(transcriptDir)) {
- matches.push(transcriptDir);
- }
- }
+ for (const composerId of entries) {
+ if (!safeComposerIds.has(composerId)) {
+ continue;
+ }
+
+ const transcriptDir = path.resolve(agentTranscriptsDir, composerId);
+ const relativePath = path.relative(agentTranscriptsDir, transcriptDir);
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
+ continue;
+ }
+
+ matches.get(composerId)!.push(transcriptDir);
+ }
+ }),
+ );
return matches;
};
+export const findCursorTranscriptDirs = async (
+ composerId: string,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ return (await findCursorTranscriptDirsForComposerIds([composerId], userDir)).get(composerId) ?? [];
+};
+
export type ListCursorThreadsOptions = {
includeTranscriptDirs?: boolean;
updatedAfterMs?: number;
@@ -545,12 +566,14 @@ export const listCursorThreadsForGroup = async (
return threads;
}
- return Promise.all(
- threads.map(async (thread) => ({
- ...thread,
- transcriptDirs: await findCursorTranscriptDirs(thread.composerId, userDir),
- })),
+ const transcriptDirsByComposerId = await findCursorTranscriptDirsForComposerIds(
+ threads.map((thread) => thread.composerId),
+ userDir,
);
+ return threads.map((thread) => ({
+ ...thread,
+ transcriptDirs: transcriptDirsByComposerId.get(thread.composerId) ?? [],
+ }));
};
// Older threads' workspace buckets get pruned by Cursor over time, and many threads predate the
diff --git a/src/lib/cursor-id.ts b/src/lib/cursor-id.ts
index 3320024..1884162 100644
--- a/src/lib/cursor-id.ts
+++ b/src/lib/cursor-id.ts
@@ -15,3 +15,8 @@ const escapeSqlLikeValue = (value: string): string => value.replace(/[\\%_]/gu,
export const buildCursorBubbleKeyLikePattern = (composerId: string): string => {
return `bubbleId:${escapeSqlLikeValue(composerId)}:%`;
};
+
+export const getCursorBubbleKeyRange = (composerId: string): { end: string; start: string } => {
+ const start = `bubbleId:${composerId}:`;
+ return { end: `bubbleId:${composerId};`, start };
+};
diff --git a/src/lib/cursor-recovery.test.ts b/src/lib/cursor-recovery.test.ts
index 642be05..2c444a7 100644
--- a/src/lib/cursor-recovery.test.ts
+++ b/src/lib/cursor-recovery.test.ts
@@ -5,7 +5,13 @@ import os from 'node:os';
import path from 'node:path';
import { listCursorWorkspaceGroups } from './cursor-db';
import { getCursorGlobalDbPath } from './cursor-exporter-types';
-import { collectCursorThreadsForDeletion, pruneCursorThreads, recoverCursorWorkspaceGroup } from './cursor-recovery';
+import {
+ collectCursorThreadsForDeletion,
+ deleteCursorWorkspaceBuckets,
+ deleteCursorWorkspaceHistory,
+ pruneCursorThreads,
+ recoverCursorWorkspaceGroup,
+} from './cursor-recovery';
import { type CursorFixtureSpec, createCursorFixture } from './cursor-test-helpers';
const tempDirs: string[] = [];
@@ -223,6 +229,82 @@ describe('pruneCursorThreads', () => {
expect(group?.threadCount).toBe(0);
});
+ it('should delete modern composer headers so removed threads are not rediscovered', async () => {
+ const userDir = await makeUserDir('cursor-delete-modern-headers-');
+ await createCursorFixture(userDir, {
+ buckets: [
+ {
+ bucketId: 'modern-bucket',
+ folder: 'file:///Users/test/workspace/modern',
+ },
+ ],
+ composerTableHeaders: [{ bucketId: 'modern-bucket', composerId: 'thread-1' }],
+ threads: [
+ {
+ bubbles: [{ bubbleId: 'b1', text: 'delete me', type: 1 }],
+ composerId: 'thread-1',
+ name: 'Modern thread',
+ },
+ ],
+ });
+
+ const deletable = await collectCursorThreadsForDeletion(['thread-1'], userDir);
+ const result = await pruneCursorThreads(deletable, true, userDir);
+ const db = new Database(getCursorGlobalDbPath(userDir), { readonly: true });
+ try {
+ expect(db.query('SELECT COUNT(*) AS count FROM composerHeaders').get()).toEqual({ count: 0 });
+ } finally {
+ db.close();
+ }
+
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ await deleteCursorWorkspaceBuckets(group!, userDir);
+ const groups = await listCursorWorkspaceGroups(userDir);
+ expect(result.headersRemoved).toBe(1);
+ expect(groups.find((group) => group.key === 'folder:/Users/test/workspace/modern')).toBeUndefined();
+ });
+
+ it('should physically remove every storage bucket for an already-empty workspace', async () => {
+ const userDir = await makeUserDir('cursor-delete-empty-workspace-');
+ await createCursorFixture(userDir, {
+ buckets: [
+ { bucketId: 'empty-a', folder: 'file:///Users/test/workspace/empty' },
+ { bucketId: 'empty-b', folder: 'file:///Users/test/workspace/empty' },
+ ],
+ threads: [],
+ });
+ const [group] = await listCursorWorkspaceGroups(userDir);
+
+ await expect(deleteCursorWorkspaceBuckets(group!, userDir)).resolves.toBe(2);
+
+ for (const bucket of group!.buckets) {
+ expect(await Bun.file(bucket.workspaceJsonPath).exists()).toBe(false);
+ }
+ expect(await listCursorWorkspaceGroups(userDir)).toEqual([]);
+ });
+
+ it('should remove matching file-history entries without deleting a sibling workspace', async () => {
+ const userDir = await makeUserDir('cursor-delete-file-history-');
+ await createCursorFixture(userDir, {
+ buckets: [],
+ historyEntries: [
+ { resource: 'file:///Users/test/workspace/file-history/src/index.ts' },
+ { resource: 'file:///Users/test/workspace/file-history-other/src/index.ts' },
+ { resource: 'file:///Users/test/workspace/file-history/docs/notes.md' },
+ ],
+ threads: [],
+ });
+ const groups = await listCursorWorkspaceGroups(userDir);
+ const group = groups.find((candidate) => candidate.key === 'folder:/Users/test/workspace/file-history');
+
+ expect(group).toMatchObject({ buckets: [], threadCount: 0 });
+ await expect(deleteCursorWorkspaceHistory(group!, userDir)).resolves.toBe(2);
+
+ expect(await Bun.file(path.join(userDir, 'History', 'history-0', 'entries.json')).exists()).toBe(false);
+ expect(await Bun.file(path.join(userDir, 'History', 'history-1', 'entries.json')).exists()).toBe(true);
+ expect(await Bun.file(path.join(userDir, 'History', 'history-2', 'entries.json')).exists()).toBe(false);
+ });
+
it('should restore earlier bucket updates when a later bucket mutation fails', async () => {
const userDir = await makeUserDir('cursor-delete-bucket-rollback-');
const spec = recoverySpec();
diff --git a/src/lib/cursor-recovery.ts b/src/lib/cursor-recovery.ts
index 225d458..66a5e1a 100644
--- a/src/lib/cursor-recovery.ts
+++ b/src/lib/cursor-recovery.ts
@@ -4,7 +4,8 @@ import { readdir, realpath, rm } from 'node:fs/promises';
import path from 'node:path';
import { mapWithConcurrency } from './concurrency';
import {
- findCursorTranscriptDirs,
+ decodeCursorUri,
+ findCursorTranscriptDirsForComposerIds,
invalidateCursorDiscoveryCache,
listCursorWorkspaceGroups,
loadGlobalComposerHeaders,
@@ -20,9 +21,10 @@ import {
type CursorWorkspaceGroup,
getCursorGlobalDbPath,
getCursorProjectsDir,
+ getCursorWorkspaceStorageDir,
resolveCursorUserDir,
} from './cursor-exporter-types';
-import { assertSafeCursorComposerId, buildCursorBubbleKeyLikePattern } from './cursor-id';
+import { assertSafeCursorComposerId, buildCursorBubbleKeyLikePattern, getCursorBubbleKeyRange } from './cursor-id';
type ComposerEntry = {
composerId?: string;
@@ -55,6 +57,15 @@ export const isCursorRunning = async (): Promise => {
const backupStamp = (): string => new Date().toISOString().replace(/[-:]/gu, '').replace(/\..+/u, '').replace('T', '-');
const CURSOR_BACKUP_RETENTION_COUNT = 5;
+const CURSOR_SQLITE_BATCH_SIZE = 200;
+
+const chunkValues = (values: T[], size: number): T[][] => {
+ const chunks: T[][] = [];
+ for (let index = 0; index < values.length; index += size) {
+ chunks.push(values.slice(index, index + size));
+ }
+ return chunks;
+};
const writeRetainedCursorBackup = async (basePath: string, label: string, value: unknown): Promise => {
const directory = path.dirname(basePath);
@@ -87,12 +98,15 @@ const backupComposerHeaders = async (globalDbPath: string): Promise => {
const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]): Promise => {
const db = openCursorReadonlyDb(globalDbPath);
try {
+ const hasModernHeaders = hasComposerHeadersTable(db);
+ const bubblesByComposerId = readBubblesForComposerIds(db, composerIds);
const dump = composerIds.map((composerId) => ({
- bubbles: db
- .query(`SELECT key, value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`)
- .all(buildCursorBubbleKeyLikePattern(composerId)),
+ bubbles: bubblesByComposerId.get(composerId) ?? [],
composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
composerId,
+ modernComposerHeader: hasModernHeaders
+ ? db.query('SELECT * FROM composerHeaders WHERE composerId = ?').get(composerId)
+ : null,
}));
return writeRetainedCursorBackup(globalDbPath, 'prunedThreads', dump);
} finally {
@@ -100,6 +114,30 @@ const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]):
}
};
+const readBubblesForComposerIds = (
+ db: Database,
+ composerIds: string[],
+): Map> => {
+ const bubblesByComposerId = new Map>(
+ composerIds.map((composerId) => [composerId, []]),
+ );
+ for (const chunk of chunkValues(composerIds, CURSOR_SQLITE_BATCH_SIZE)) {
+ const parameters: string[] = [];
+ const query = chunk
+ .map((composerId) => {
+ const range = getCursorBubbleKeyRange(composerId);
+ parameters.push(composerId, range.start, range.end);
+ return 'SELECT ? AS composerId, key, value FROM cursorDiskKV WHERE key >= ? AND key < ?';
+ })
+ .join(' UNION ALL ');
+ const rows = db.query(query).all(...parameters) as Array<{ composerId: string; key: string; value: string }>;
+ for (const row of rows) {
+ bubblesByComposerId.get(row.composerId)?.push({ key: row.key, value: row.value });
+ }
+ }
+ return bubblesByComposerId;
+};
+
const readJsonItemFromKv = (db: Database, key: string): unknown => {
const row = db.query('SELECT value FROM cursorDiskKV WHERE key = ?').get(key) as { value?: string } | null;
if (!row?.value) {
@@ -134,6 +172,9 @@ const writeJsonItem = (db: Database, key: string, value: unknown): void => {
);
};
+const hasComposerHeadersTable = (db: Database): boolean =>
+ Boolean(db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'composerHeaders'").get());
+
const scoreComposer = (entry: ComposerEntry): number =>
Number(Boolean(entry.name)) + Number(entry.lastUpdatedAt ?? 0) + Number(entry.totalLinesAdded ?? 0);
@@ -250,6 +291,25 @@ const countBubbles = (db: Database, composerId: string): number => {
return row.count;
};
+const countBubblesForComposerIds = (db: Database, composerIds: string[]): Map => {
+ const counts = new Map();
+ for (const chunk of chunkValues(composerIds, CURSOR_SQLITE_BATCH_SIZE)) {
+ const parameters: string[] = [];
+ const query = chunk
+ .map((composerId) => {
+ const range = getCursorBubbleKeyRange(composerId);
+ parameters.push(composerId, range.start, range.end);
+ return 'SELECT ? AS composerId, COUNT(*) AS count FROM cursorDiskKV WHERE key >= ? AND key < ?';
+ })
+ .join(' UNION ALL ');
+ const rows = db.query(query).all(...parameters) as Array<{ composerId: string; count: number }>;
+ for (const row of rows) {
+ counts.set(row.composerId, row.count);
+ }
+ }
+ return counts;
+};
+
export const recoverCursorWorkspaceGroup = async (
group: CursorWorkspaceGroup,
apply: boolean,
@@ -410,27 +470,39 @@ const removeThreadFromBucket = (db: Database, composerIds: Set): boolean
};
const pruneGlobalThread = (db: Database, composerId: string): { bubbles: number; composerData: number } => {
- const bubbleResult = db.run(`DELETE FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`, [
- buildCursorBubbleKeyLikePattern(composerId),
- ]);
+ const range = getCursorBubbleKeyRange(composerId);
+ const bubbleResult = db.run('DELETE FROM cursorDiskKV WHERE key >= ? AND key < ?', [range.start, range.end]);
const headResult = db.run('DELETE FROM cursorDiskKV WHERE key = ?', [`composerData:${composerId}`]);
return { bubbles: bubbleResult.changes ?? 0, composerData: headResult.changes ?? 0 };
};
const removeThreadHeaders = (db: Database, composerIds: Set): number => {
+ const removedIds = new Set();
const data = readJsonItem<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY);
- if (!data?.allComposers?.length) {
- return 0;
+ if (data?.allComposers?.length) {
+ data.allComposers = data.allComposers.filter((entry) => {
+ const composerId = entry.composerId ?? '';
+ if (composerIds.has(composerId)) {
+ removedIds.add(composerId);
+ return false;
+ }
+ return true;
+ });
+ if (removedIds.size > 0) {
+ writeJsonItem(db, COMPOSER_HEADERS_KEY, data);
+ }
}
- const before = data.allComposers.length;
- data.allComposers = data.allComposers.filter((entry) => !composerIds.has(entry.composerId ?? ''));
- const removed = before - data.allComposers.length;
- if (removed > 0) {
- writeJsonItem(db, COMPOSER_HEADERS_KEY, data);
+ if (hasComposerHeadersTable(db)) {
+ for (const composerId of composerIds) {
+ const result = db.run('DELETE FROM composerHeaders WHERE composerId = ?', [composerId]);
+ if ((result.changes ?? 0) > 0) {
+ removedIds.add(composerId);
+ }
+ }
}
- return removed;
+ return removedIds.size;
};
export const pruneCursorThreads = async (
@@ -551,16 +623,25 @@ const restoreBucketComposerData = (dbPath: string, snapshot: BucketComposerDataS
}
};
-const pruneWorkspaceBuckets = async (composerIds: Set, userDir: string) => {
- // Scan every bucket: a thread can live in more than one bucket's composer.composerData (e.g. the
- // current bucket plus the older bucket it was recovered from), so remove it from all of them.
- const groups = await listCursorWorkspaceGroups(userDir);
+const getCursorBucketDbPathsForComposerIds = (
+ groups: CursorWorkspaceGroup[],
+ composerIds: Set,
+): Set => {
const dbPaths = new Set();
for (const group of groups) {
for (const bucket of group.buckets) {
- dbPaths.add(bucket.dbPath);
+ if (bucket.threadComposerIds.some((composerId) => composerIds.has(composerId))) {
+ dbPaths.add(bucket.dbPath);
+ }
}
}
+ return dbPaths;
+};
+
+const pruneWorkspaceBuckets = async (composerIds: Set, userDir: string) => {
+ // A thread can live in multiple recovered buckets, so update every matching bucket without
+ // opening unrelated workspace databases.
+ const dbPaths = getCursorBucketDbPathsForComposerIds(await listCursorWorkspaceGroups(userDir), composerIds);
const snapshots = new Map();
for (const dbPath of dbPaths) {
@@ -619,6 +700,151 @@ const pruneTranscriptDirs = async (threads: CursorThreadSummary[], result: Curso
result.transcriptDirsRemoved = transcriptDirs.length;
};
+export const deleteCursorWorkspaceBuckets = async (
+ group: CursorWorkspaceGroup,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ const workspaceStorageDir = path.resolve(getCursorWorkspaceStorageDir(userDir));
+ const canonicalStorageDir = await realpath(workspaceStorageDir).catch(() => workspaceStorageDir);
+ const bucketRoots: Array<{ exists: boolean; path: string }> = [];
+
+ for (const bucket of group.buckets) {
+ const bucketRoot = path.resolve(path.dirname(bucket.workspaceJsonPath));
+ const dbRoot = path.resolve(path.dirname(bucket.dbPath));
+ if (
+ bucketRoot !== dbRoot ||
+ path.dirname(bucketRoot) !== workspaceStorageDir ||
+ path.basename(bucketRoot) !== bucket.bucketId
+ ) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
+ }
+
+ const canonicalBucketRoot = await realpath(bucketRoot).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ if (canonicalBucketRoot && path.dirname(canonicalBucketRoot) !== canonicalStorageDir) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
+ }
+
+ bucketRoots.push({ exists: canonicalBucketRoot !== null, path: bucketRoot });
+ }
+
+ let removed = 0;
+ try {
+ for (const bucketRoot of bucketRoots) {
+ await rm(bucketRoot.path, { force: true, recursive: true });
+ removed += Number(bucketRoot.exists);
+ }
+ } finally {
+ invalidateCursorDiscoveryCache();
+ }
+
+ return removed;
+};
+
+const normalizeCursorHistoryPath = (value: string): string | null => {
+ const decoded = decodeCursorUri(value.trim());
+ return decoded && path.isAbsolute(decoded) ? path.resolve(decoded) : null;
+};
+
+const isCursorHistoryEntryForWorkspace = (resource: string, folders: string[]): boolean => {
+ const resourcePath = normalizeCursorHistoryPath(resource);
+ if (!resourcePath) {
+ return false;
+ }
+
+ return folders.some((folder) => {
+ const relative = path.relative(folder, resourcePath);
+ return (
+ relative === '' ||
+ (!relative.startsWith(`..${path.sep}`) && relative !== '..' && !path.isAbsolute(relative))
+ );
+ });
+};
+
+const assertSafeCursorHistoryEntryPath = (
+ canonicalHistoryDir: string,
+ entryPath: string,
+ canonicalEntryPath: string,
+): void => {
+ const relativeEntryPath = path.relative(canonicalHistoryDir, canonicalEntryPath);
+ if (
+ relativeEntryPath === '' ||
+ relativeEntryPath === '..' ||
+ relativeEntryPath.startsWith(`..${path.sep}`) ||
+ path.isAbsolute(relativeEntryPath) ||
+ path.dirname(relativeEntryPath) !== '.'
+ ) {
+ throw new Error(`Unsafe Cursor history entry directory: ${entryPath}`);
+ }
+};
+
+export const deleteCursorWorkspaceHistory = async (
+ group: CursorWorkspaceGroup,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ const folders = group.folders.map(normalizeCursorHistoryPath).filter((folder): folder is string => folder !== null);
+ const historyDir = path.resolve(path.join(userDir, 'History'));
+ let removed = 0;
+
+ try {
+ if (folders.length === 0) {
+ return 0;
+ }
+
+ const canonicalHistoryDir = await realpath(historyDir).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ if (!canonicalHistoryDir) {
+ return 0;
+ }
+
+ const entries = await readdir(historyDir, { withFileTypes: true });
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+
+ const entryPath = path.join(historyDir, entry.name);
+ const canonicalEntryPath = await realpath(entryPath).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ if (!canonicalEntryPath) {
+ continue;
+ }
+
+ assertSafeCursorHistoryEntryPath(canonicalHistoryDir, entryPath, canonicalEntryPath);
+
+ let data: { resource?: unknown };
+ try {
+ data = (await Bun.file(path.join(entryPath, 'entries.json')).json()) as { resource?: unknown };
+ } catch {
+ continue;
+ }
+
+ if (typeof data.resource !== 'string' || !isCursorHistoryEntryForWorkspace(data.resource, folders)) {
+ continue;
+ }
+
+ await rm(entryPath, { force: true, recursive: true });
+ removed += 1;
+ }
+ } finally {
+ invalidateCursorDiscoveryCache();
+ }
+
+ return removed;
+};
+
// Builds the minimal thread records needed to fully delete the given composer ids (bubble counts for
// reporting and the on-disk transcript directories to remove). Used by the UI delete actions.
export const collectCursorThreadsForDeletion = async (
@@ -632,9 +858,13 @@ export const collectCursorThreadsForDeletion = async (
try {
for (const composerId of composerIds) {
assertSafeCursorComposerId(composerId);
+ }
+ const bubbleCounts = countBubblesForComposerIds(db, composerIds);
+ const transcriptDirs = await findCursorTranscriptDirsForComposerIds(composerIds, userDir);
+ for (const composerId of composerIds) {
summaries.push({
bubbleBytes: 0,
- bubbleCount: countBubbles(db, composerId),
+ bubbleCount: bubbleCounts.get(composerId) ?? 0,
bucketId: null,
composerId,
createdAtMs: null,
@@ -644,7 +874,7 @@ export const collectCursorThreadsForDeletion = async (
name: '',
parentComposerId: null,
reasoningEffort: null,
- transcriptDirs: await findCursorTranscriptDirs(composerId, userDir),
+ transcriptDirs: transcriptDirs.get(composerId) ?? [],
workspaceKey: '',
workspaceLabel: '',
});
diff --git a/src/ui/components/cursor-workspaces-table.tsx b/src/ui/components/cursor-workspaces-table.tsx
index cafab21..c172187 100644
--- a/src/ui/components/cursor-workspaces-table.tsx
+++ b/src/ui/components/cursor-workspaces-table.tsx
@@ -1,7 +1,9 @@
import type { CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
import { Link } from '@tanstack/react-router';
import { MoreHorizontal, RefreshCcw, Trash2 } from 'lucide-react';
+import { useMemo } from 'react';
import { DataTable } from '#/components/data-table';
+import { SelectionActionsToolbar } from '#/components/selection-actions-toolbar';
import { Badge } from '#/components/ui/badge';
import { Button } from '#/components/ui/button';
import {
@@ -15,6 +17,7 @@ import { formatDateTime, formatNumber } from '#/lib/formatters';
type CursorWorkspacesTableProps = {
onDeleteWorkspace: (workspace: CursorWorkspaceGroup) => void;
+ onDeleteWorkspaces: (workspaces: CursorWorkspaceGroup[]) => void;
onRecoverWorkspace: (workspace: CursorWorkspaceGroup) => void;
workspaces: CursorWorkspaceGroup[];
};
@@ -127,14 +130,30 @@ const columns = (
export const CursorWorkspacesTable = ({
onDeleteWorkspace,
+ onDeleteWorkspaces,
onRecoverWorkspace,
workspaces,
}: CursorWorkspacesTableProps) => {
+ const tableColumns = useMemo(
+ () => columns(onDeleteWorkspace, onRecoverWorkspace),
+ [onDeleteWorkspace, onRecoverWorkspace],
+ );
+
return (
row.key}
+ renderToolbar={({ clearSelection, selectedRows }) => (
+ onDeleteWorkspaces(selectedRows)}
+ />
+ )}
/>
);
};
diff --git a/src/ui/components/cursor-workspaces-table.vitest.tsx b/src/ui/components/cursor-workspaces-table.vitest.tsx
index 320a038..2036178 100644
--- a/src/ui/components/cursor-workspaces-table.vitest.tsx
+++ b/src/ui/components/cursor-workspaces-table.vitest.tsx
@@ -147,7 +147,12 @@ afterEach(() => {
describe('CursorWorkspacesTable', () => {
it('should render workspace links with the detail route href', () => {
render(
- ,
+ ,
);
const link = screen.getByRole('link', { name: /demo/i });
@@ -161,6 +166,7 @@ describe('CursorWorkspacesTable', () => {
render(
,
@@ -183,4 +189,30 @@ describe('CursorWorkspacesTable', () => {
expect(onRecoverWorkspace).toHaveBeenCalledWith(workspace);
expect(onDeleteWorkspace).toHaveBeenCalledWith(workspace);
});
+
+ it('should select multiple workspaces and request batch deletion', () => {
+ const onDeleteWorkspaces = vi.fn();
+
+ render(
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('checkbox', { name: `Select row ${workspace.key}` }));
+ fireEvent.click(
+ screen.getByRole('checkbox', {
+ name: 'Select row folder:/Users/user/workspace/other',
+ }),
+ );
+ fireEvent.click(screen.getByRole('button', { name: 'Delete selected workspaces' }));
+
+ expect(onDeleteWorkspaces).toHaveBeenCalledWith([
+ workspace,
+ { ...workspace, key: 'folder:/Users/user/workspace/other', label: 'other' },
+ ]);
+ });
});
diff --git a/src/ui/components/data-table.tsx b/src/ui/components/data-table.tsx
index 32e31b9..1022ce6 100644
--- a/src/ui/components/data-table.tsx
+++ b/src/ui/components/data-table.tsx
@@ -112,13 +112,13 @@ export function DataTable({
if (shiftKey && lastSelectedRowIdRef.current) {
const rangeRowIds = getRangeRowIds(visibleRowIds, lastSelectedRowIdRef.current, rowId);
if (rangeRowIds) {
- setRowSelection(applySelectionState(rowSelection, rangeRowIds, checked));
+ setRowSelection((selection) => applySelectionState(selection, rangeRowIds, checked));
lastSelectedRowIdRef.current = rowId;
return;
}
}
- setRowSelection(applySelectionState(rowSelection, [rowId], checked));
+ setRowSelection((selection) => applySelectionState(selection, [rowId], checked));
lastSelectedRowIdRef.current = rowId;
};
diff --git a/src/ui/lib/cursor-server.ts b/src/ui/lib/cursor-server.ts
index 0d537c7..e9b5fec 100644
--- a/src/ui/lib/cursor-server.ts
+++ b/src/ui/lib/cursor-server.ts
@@ -8,6 +8,10 @@ const workspaceSchema = z.object({
workspaceKey: z.string().min(1),
});
+const workspacesSchema = z.object({
+ workspaceKeys: z.array(z.string().min(1)).min(1),
+});
+
const composerIdSchema = z.string().refine(isSafeCursorComposerId, {
message: 'Invalid Cursor composer id.',
});
@@ -63,6 +67,52 @@ const findGroupByKey = async (workspaceKey: string) => {
return group;
};
+const findGroupsByKeys = async (workspaceKeys: string[]) => {
+ const { listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
+ const groupsByKey = new Map((await listCursorWorkspaceGroups()).map((group) => [group.key, group]));
+
+ return workspaceKeys.map((workspaceKey) => {
+ const group = groupsByKey.get(workspaceKey);
+ if (!group) {
+ throw new Error(`Cursor workspace not found: ${workspaceKey}`);
+ }
+
+ return group;
+ });
+};
+
+const deleteCursorWorkspaceGroup = async (group: CursorWorkspaceGroup) => {
+ const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
+ const {
+ collectCursorThreadsForDeletion,
+ deleteCursorWorkspaceBuckets,
+ deleteCursorWorkspaceHistory,
+ pruneCursorThreads,
+ } = await import('@spiracha/lib/cursor-recovery');
+ const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ const composerIds = [
+ ...new Set([
+ ...threads.map((thread) => thread.composerId),
+ ...group.buckets.flatMap((bucket) => bucket.threadComposerIds),
+ ]),
+ ];
+ const result =
+ composerIds.length === 0
+ ? {
+ bubblesDeleted: 0,
+ composerDataDeleted: 0,
+ composerIds: [],
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ workspaceBucketsUpdated: 0,
+ }
+ : await pruneCursorThreads(await collectCursorThreadsForDeletion(composerIds), true);
+
+ await deleteCursorWorkspaceBuckets(group);
+ await deleteCursorWorkspaceHistory(group);
+ return result;
+};
+
const findCursorWorkspacesByComposerId = async (
workspaceGroups: CursorWorkspaceGroup[],
composerIds: string[],
@@ -292,23 +342,20 @@ export const deleteCursorThreadsFn = createServerFn({ method: 'POST' })
export const deleteCursorWorkspaceFn = createServerFn({ method: 'POST' })
.validator(workspaceSchema)
.handler(async ({ data }) => {
- const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
- const { collectCursorThreadsForDeletion, pruneCursorThreads } = await import('@spiracha/lib/cursor-recovery');
await ensureCursorClosedForWrite();
const group = await findGroupByKey(data.workspaceKey);
- const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
- const composerIds = threads.map((thread) => thread.composerId);
- if (composerIds.length === 0) {
- return {
- bubblesDeleted: 0,
- composerDataDeleted: 0,
- composerIds: [],
- headersRemoved: 0,
- transcriptDirsRemoved: 0,
- workspaceBucketsUpdated: 0,
- };
+ return deleteCursorWorkspaceGroup(group);
+ });
+
+export const deleteCursorWorkspacesFn = createServerFn({ method: 'POST' })
+ .validator(workspacesSchema)
+ .handler(async ({ data }) => {
+ await ensureCursorClosedForWrite();
+ const groups = await findGroupsByKeys(data.workspaceKeys);
+ const results = [];
+ for (const group of groups) {
+ results.push(await deleteCursorWorkspaceGroup(group));
}
- const deletable = await collectCursorThreadsForDeletion(composerIds);
- return pruneCursorThreads(deletable, true);
+ return results;
});
diff --git a/src/ui/lib/cursor-server.vitest.ts b/src/ui/lib/cursor-server.vitest.ts
index 5f39dbe..45fd341 100644
--- a/src/ui/lib/cursor-server.vitest.ts
+++ b/src/ui/lib/cursor-server.vitest.ts
@@ -1,12 +1,15 @@
import type {
CursorThreadSummary,
CursorThreadTranscript,
+ CursorWorkspaceBucket,
CursorWorkspaceGroup,
} from '@spiracha/lib/cursor-exporter-types';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
collectCursorThreadsForDeletionMock,
+ deleteCursorWorkspaceBucketsMock,
+ deleteCursorWorkspaceHistoryMock,
isCursorRunningMock,
listCursorThreadsForGroupMock,
listCursorWorkspaceGroupsMock,
@@ -18,6 +21,8 @@ const {
renderSourceSessionsDownloadMock,
} = vi.hoisted(() => ({
collectCursorThreadsForDeletionMock: vi.fn(),
+ deleteCursorWorkspaceBucketsMock: vi.fn(),
+ deleteCursorWorkspaceHistoryMock: vi.fn(),
isCursorRunningMock: vi.fn(),
listCursorThreadsForGroupMock: vi.fn(),
listCursorWorkspaceGroupsMock: vi.fn(),
@@ -66,6 +71,8 @@ vi.mock('@spiracha/lib/cursor-exporter-types', () => ({
vi.mock('@spiracha/lib/cursor-recovery', () => ({
collectCursorThreadsForDeletion: collectCursorThreadsForDeletionMock,
+ deleteCursorWorkspaceBuckets: deleteCursorWorkspaceBucketsMock,
+ deleteCursorWorkspaceHistory: deleteCursorWorkspaceHistoryMock,
isCursorRunning: isCursorRunningMock,
pruneCursorThreads: pruneCursorThreadsMock,
recoverCursorWorkspaceGroup: recoverCursorWorkspaceGroupMock,
@@ -83,6 +90,7 @@ vi.mock('./source-session-export-server', () => ({
import {
deleteCursorThreadsFn,
deleteCursorWorkspaceFn,
+ deleteCursorWorkspacesFn,
exportCursorThreadFn,
exportCursorThreadsFn,
findCursorThreadByComposerId,
@@ -112,6 +120,21 @@ const workspaceTwo: CursorWorkspaceGroup = {
uri: 'file:///tmp/two',
};
+const workspaceBucket: CursorWorkspaceBucket = {
+ bucketId: 'bucket-1',
+ composerCount: 0,
+ dbPath: '/tmp/cursor/workspaceStorage/bucket-1/state.vscdb',
+ dbSizeBytes: 1024,
+ folders: workspaceOne.folders,
+ globalHeaderCount: 3,
+ kind: 'folder',
+ label: workspaceOne.label,
+ mtimeMs: workspaceOne.lastActiveMs,
+ threadComposerIds: ['thread-1', 'thread-2', 'aborted-thread'],
+ uri: workspaceOne.uri,
+ workspaceJsonPath: '/tmp/cursor/workspaceStorage/bucket-1/workspace.json',
+};
+
const makeThread = (overrides: Partial = {}): CursorThreadSummary => ({
bubbleBytes: 128,
bubbleCount: 3,
@@ -191,6 +214,7 @@ describe('deleteCursorThreadsFn', () => {
};
collectCursorThreadsForDeletionMock.mockResolvedValue(deletableThreads);
pruneCursorThreadsMock.mockResolvedValue(result);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue(1);
await expect(deleteCursorThreadsFn({ data: { composerIds: ['thread-1', 'thread-2'] } })).resolves.toBe(result);
@@ -228,28 +252,99 @@ describe('deleteCursorWorkspaceFn', () => {
});
it('should delete every Cursor thread in a workspace', async () => {
+ const workspace = { ...workspaceOne, buckets: [workspaceBucket], threadCount: 3 };
const workspaceThreads = [makeThread(), makeThread({ composerId: 'thread-2' })];
- const deletableThreads = workspaceThreads.map((thread) => ({ composerId: thread.composerId }));
+ const deletableThreads = [...workspaceThreads, makeThread({ composerId: 'aborted-thread' })].map((thread) => ({
+ composerId: thread.composerId,
+ }));
const result = {
bubblesDeleted: 5,
- composerDataDeleted: 2,
- composerIds: ['thread-1', 'thread-2'],
- headersRemoved: 2,
+ composerDataDeleted: 3,
+ composerIds: ['thread-1', 'thread-2', 'aborted-thread'],
+ headersRemoved: 3,
transcriptDirsRemoved: 2,
workspaceBucketsUpdated: 1,
};
- listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspace]);
listCursorThreadsForGroupMock.mockResolvedValue(workspaceThreads);
collectCursorThreadsForDeletionMock.mockResolvedValue(deletableThreads);
pruneCursorThreadsMock.mockResolvedValue(result);
- await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } })).resolves.toBe(result);
+ await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspace.key } })).resolves.toBe(result);
- expect(listCursorThreadsForGroupMock).toHaveBeenCalledWith(workspaceOne, undefined, {
+ expect(listCursorThreadsForGroupMock).toHaveBeenCalledWith(workspace, undefined, {
includeTranscriptDirs: false,
});
- expect(collectCursorThreadsForDeletionMock).toHaveBeenCalledWith(['thread-1', 'thread-2']);
+ expect(collectCursorThreadsForDeletionMock).toHaveBeenCalledWith(['thread-1', 'thread-2', 'aborted-thread']);
expect(pruneCursorThreadsMock).toHaveBeenCalledWith(deletableThreads, true);
+ expect(deleteCursorWorkspaceBucketsMock).toHaveBeenCalledWith(workspace);
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenCalledWith(workspace);
+ });
+
+ it('should remove an already-empty Cursor workspace bucket', async () => {
+ const result = {
+ bubblesDeleted: 0,
+ composerDataDeleted: 0,
+ composerIds: [],
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ workspaceBucketsUpdated: 0,
+ };
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValue([]);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue(1);
+
+ await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } })).resolves.toEqual(result);
+
+ expect(collectCursorThreadsForDeletionMock).not.toHaveBeenCalled();
+ expect(pruneCursorThreadsMock).not.toHaveBeenCalled();
+ expect(deleteCursorWorkspaceBucketsMock).toHaveBeenCalledWith(workspaceOne);
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenCalledWith(workspaceOne);
+ });
+});
+
+describe('deleteCursorWorkspacesFn', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ isCursorRunningMock.mockResolvedValue(false);
+ });
+
+ it('should delete multiple Cursor workspaces in one request', async () => {
+ const workspaceOneResult = {
+ bubblesDeleted: 1,
+ composerDataDeleted: 1,
+ composerIds: ['thread-1'],
+ headersRemoved: 1,
+ transcriptDirsRemoved: 1,
+ workspaceBucketsUpdated: 1,
+ };
+ const workspaceTwoResult = {
+ bubblesDeleted: 2,
+ composerDataDeleted: 1,
+ composerIds: ['thread-2'],
+ headersRemoved: 1,
+ transcriptDirsRemoved: 1,
+ workspaceBucketsUpdated: 1,
+ };
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne, workspaceTwo]);
+ listCursorThreadsForGroupMock
+ .mockResolvedValueOnce([makeThread()])
+ .mockResolvedValueOnce([makeThread({ composerId: 'thread-2', workspaceKey: workspaceTwo.key })]);
+ collectCursorThreadsForDeletionMock
+ .mockResolvedValueOnce([{ composerId: 'thread-1' }])
+ .mockResolvedValueOnce([{ composerId: 'thread-2' }]);
+ pruneCursorThreadsMock.mockResolvedValueOnce(workspaceOneResult).mockResolvedValueOnce(workspaceTwoResult);
+
+ await expect(
+ deleteCursorWorkspacesFn({ data: { workspaceKeys: [workspaceOne.key, workspaceTwo.key] } }),
+ ).resolves.toEqual([workspaceOneResult, workspaceTwoResult]);
+
+ expect(isCursorRunningMock).toHaveBeenCalledTimes(1);
+ expect(listCursorWorkspaceGroupsMock).toHaveBeenCalledTimes(1);
+ expect(deleteCursorWorkspaceBucketsMock).toHaveBeenNthCalledWith(1, workspaceOne);
+ expect(deleteCursorWorkspaceBucketsMock).toHaveBeenNthCalledWith(2, workspaceTwo);
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenNthCalledWith(1, workspaceOne);
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenNthCalledWith(2, workspaceTwo);
});
});
diff --git a/src/ui/routes/cursor.$workspaceKey.tsx b/src/ui/routes/cursor.$workspaceKey.tsx
index 6f6fae8..72165b0 100644
--- a/src/ui/routes/cursor.$workspaceKey.tsx
+++ b/src/ui/routes/cursor.$workspaceKey.tsx
@@ -85,7 +85,7 @@ const getCursorDeleteDescription = (pendingDelete: PendingCursorDelete | null) =
}
if (pendingDelete.kind === 'workspace') {
- return `Permanently delete every thread for "${pendingDelete.workspace.label}" from Cursor's database and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete every thread for "${pendingDelete.workspace.label}" from Cursor's database, remove any on-disk transcript directories, and delete its Cursor workspace storage buckets. Quit Cursor first. This cannot be undone.`;
}
if (pendingDelete.threads.length === 1) {
diff --git a/src/ui/routes/cursor.index.tsx b/src/ui/routes/cursor.index.tsx
index 159cac4..57ac3f5 100644
--- a/src/ui/routes/cursor.index.tsx
+++ b/src/ui/routes/cursor.index.tsx
@@ -9,7 +9,7 @@ import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
-import { deleteCursorWorkspaceFn, recoverCursorWorkspaceFn } from '#/lib/cursor-server';
+import { deleteCursorWorkspaceFn, deleteCursorWorkspacesFn, recoverCursorWorkspaceFn } from '#/lib/cursor-server';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { matchesTextQuery } from '#/lib/text-filter';
@@ -21,11 +21,23 @@ const CursorPendingComponent = () => (
);
+const getWorkspaceDeleteDescription = (workspaces: CursorWorkspaceGroup[] | null) => {
+ if (!workspaces) {
+ return '';
+ }
+
+ if (workspaces.length === 1) {
+ return `Permanently delete every thread for "${workspaces[0]!.label}" from Cursor's database and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ }
+
+ return `Permanently delete every thread from ${workspaces.length} selected Cursor workspaces and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+};
+
const CursorPage = () => {
const queryClient = useQueryClient();
const workspaces = useSuspenseQuery(cursorWorkspacesQueryOptions()).data;
const [searchInput, setSearchInput] = useState('');
- const [pendingDelete, setPendingDelete] = useState(null);
+ const [pendingDelete, setPendingDelete] = useState(null);
const deferredSearch = useDeferredValue(searchInput);
const invalidateCursorQueries = async () => {
@@ -39,8 +51,15 @@ const CursorPage = () => {
});
const deleteWorkspaceMutation = useMutation({
- mutationFn: (workspace: CursorWorkspaceGroup) =>
- deleteCursorWorkspaceFn({ data: { workspaceKey: workspace.key } }),
+ mutationFn: async (selectedWorkspaces: CursorWorkspaceGroup[]) => {
+ if (selectedWorkspaces.length === 1) {
+ return [await deleteCursorWorkspaceFn({ data: { workspaceKey: selectedWorkspaces[0]!.key } })];
+ }
+
+ return deleteCursorWorkspacesFn({
+ data: { workspaceKeys: selectedWorkspaces.map((workspace) => workspace.key) },
+ });
+ },
onSuccess: async () => {
await invalidateCursorQueries();
setPendingDelete(null);
@@ -72,7 +91,8 @@ const CursorPage = () => {
/>
setPendingDelete([workspace])}
+ onDeleteWorkspaces={setPendingDelete}
onRecoverWorkspace={(workspace) => recoverWorkspaceMutation.mutate(workspace)}
workspaces={visibleWorkspaces}
/>
@@ -94,15 +114,17 @@ const CursorPage = () => {
) : null}
{
if (!pendingDelete) {
return;
From b7e458888b076c5db2e437a746d4816a74eff292 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Sun, 16 Aug 2026 01:26:53 -0400
Subject: [PATCH 03/10] #30 #12, #13, #18, #20, #23, #24, #28, #29
---
README.md | 14 +
src/lib/antigravity-db.test.ts | 16 +
src/lib/antigravity-db.ts | 257 ++++++-
src/lib/antigravity-keychain.test.ts | 23 +-
src/lib/antigravity-keychain.ts | 55 +-
src/lib/antigravity-trajectory.test.ts | 61 ++
src/lib/antigravity-trajectory.ts | 34 +-
.../antigravity-transcript-history.test.ts | 44 +-
src/lib/antigravity-transcript-history.ts | 227 +++++-
src/lib/codex-browser-db.test.ts | 142 ++++
src/lib/codex-browser-db.ts | 675 +++++++++++++++---
src/lib/codex-browser-export.test.ts | 81 ++-
src/lib/codex-browser-export.ts | 522 +++++++++-----
src/lib/codex-browser-types.ts | 25 +
src/lib/codex-rollout-snapshot.test.ts | 56 ++
src/lib/codex-rollout-snapshot.ts | 162 +++++
src/lib/cursor-db.test.ts | 33 +-
src/lib/cursor-db.ts | 228 +++---
src/lib/cursor-recovery.test.ts | 102 ++-
src/lib/cursor-recovery.ts | 198 ++---
src/lib/cursor-test-helpers.ts | 42 ++
.../components/antigravity-keychain-panel.tsx | 2 +-
src/ui/components/export-dialog.tsx | 45 +-
src/ui/components/export-dialog.vitest.tsx | 9 +-
src/ui/lib/antigravity-server.ts | 66 +-
src/ui/lib/antigravity-server.vitest.ts | 48 +-
src/ui/lib/download.ts | 314 ++++++--
src/ui/lib/download.vitest.ts | 146 +++-
...igravity-conversations.$conversationId.tsx | 5 +-
src/ui/routes/antigravity.$workspaceKey.tsx | 5 +-
.../claude-code-sessions.$sessionId.tsx | 5 +-
src/ui/routes/claude-code.$workspaceKey.tsx | 5 +-
src/ui/routes/cline-tasks.$taskId.tsx | 5 +-
src/ui/routes/cline.$workspaceKey.tsx | 5 +-
src/ui/routes/codex.$project.tsx | 5 +-
src/ui/routes/cursor-threads.$composerId.tsx | 5 +-
src/ui/routes/cursor.$workspaceKey.tsx | 5 +-
src/ui/routes/grok-sessions.$sessionId.tsx | 5 +-
src/ui/routes/grok.$workspaceKey.tsx | 5 +-
src/ui/routes/kiro-sessions.$sessionId.tsx | 5 +-
src/ui/routes/kiro.$workspaceKey.tsx | 5 +-
.../minimax-code-sessions.$sessionId.tsx | 5 +-
src/ui/routes/minimax-code.$workspaceKey.tsx | 5 +-
.../routes/opencode-sessions.$sessionId.tsx | 5 +-
src/ui/routes/opencode.$workspaceKey.tsx | 5 +-
src/ui/routes/qoder-sessions.$sessionId.tsx | 5 +-
src/ui/routes/qoder.$workspaceKey.tsx | 5 +-
src/ui/routes/threads.$threadId.tsx | 5 +-
48 files changed, 3021 insertions(+), 706 deletions(-)
create mode 100644 src/lib/antigravity-trajectory.test.ts
create mode 100644 src/lib/codex-rollout-snapshot.test.ts
create mode 100644 src/lib/codex-rollout-snapshot.ts
diff --git a/README.md b/README.md
index 7c4b7da..4b6a406 100644
--- a/README.md
+++ b/README.md
@@ -154,6 +154,20 @@ When an Antigravity conversation has a live trajectory database, Spiracha treats
Markdown transcript exports identify this parser contract with `transcript_schema: antigravity-transcript/v2`. The UI parser retains complete tool output in its event data and export, but bounds the rendered preview to 20,000 characters so a single large operation result cannot dominate the detail page.
+Encrypted Antigravity transcripts use the macOS Keychain item `Antigravity Safe Storage` / `Antigravity Key` and the Electron-compatible `saltysalt` PBKDF2 derivation. Keychain access is reacquired for each protected server request; the raw secret is not stored in process-global state or returned to the browser. Non-encrypted transcripts do not require Keychain access, and other platforms report decryption as unsupported.
+
+### Codex browser database compatibility
+
+Codex browser reads target the `codex-state-5-thread-browse-v1` compatibility profile. The `threads` table and its browse columns are required; missing tables, missing columns, or invalid decoded row values produce an actionable compatibility error instead of a guessed result. The `thread_dynamic_tools`, `thread_goals`, and `thread_spawn_edges` tables are optional and are read when present, so older databases without those tables remain browseable.
+
+Multi-table thread browse reads run inside one SQLite deferred read transaction. This gives each browse result a consistent database snapshot while allowing Codex's normal writers to continue; it does not claim crash-level atomicity across the main database, attached history databases, rollout files, or the session index. Destructive DB changes commit before optional session-file and session-index cleanup, and cleanup results identify what was removed.
+
+UI batch Codex exports use one batch browse pass and include a versioned `spiracha-manifest.json` in every successful archive. The manifest preserves requested ID order and records exported, missing, unreadable, and unstable entries. A batch succeeds when at least one selected thread is exportable; a single-thread export remains fail-fast. Active rollout files are copied to an attempt-local snapshot, checked by size, inode, and high-resolution timestamps, and retried once when they mutate during the copy.
+
+### Cursor SQLite access
+
+Cursor reads use a retry-aware synchronous callback that opens a fresh read handle for each attempt and closes it before retrying. Cursor mutations use a same-database `BEGIN IMMEDIATE` transaction with the shared bounded SQLite retry policy and no stacked long `busy_timeout`. Missing writable databases fail closed instead of being created. Recovery and deletion keep cross-database compensation and filesystem cleanup outside retry callbacks; destructive discovery uses strict readers so exhausted locks cannot be mistaken for empty data. UI mutation entrypoints still require Cursor to be closed before writing because Cursor can rewrite its history on exit.
+
## UI Routes
- `/codex` and `/codex/$project` for Codex inventory and project threads.
diff --git a/src/lib/antigravity-db.test.ts b/src/lib/antigravity-db.test.ts
index 15f3627..47ee50b 100644
--- a/src/lib/antigravity-db.test.ts
+++ b/src/lib/antigravity-db.test.ts
@@ -9,6 +9,7 @@ import {
groupAntigravityConversations,
listAntigravityConversations,
readAntigravityConversationMessages,
+ readAntigravitySummaryIndexWithDiagnostics,
renderAntigravityArtifactsMarkdown,
renderAntigravityConversationMarkdown,
} from './antigravity-db';
@@ -281,6 +282,21 @@ describe('antigravity db discovery', () => {
expect(resolveAntigravityRoots()).toContain(path.join(os.homedir(), '.gemini', 'antigravity-cli'));
});
+ it('should preserve valid summary records around protobuf corruption with diagnostics', async () => {
+ const root = await makeRoot();
+ const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
+ const first = encodeSummaryIndex([{ id: '11111111-1111-4111-8111-111111111111', title: 'First summary' }]);
+ const second = encodeSummaryIndex([{ id: '22222222-2222-4222-8222-222222222222', title: 'Second summary' }]);
+ await Bun.write(summaryPath, new Uint8Array([...first, 0xff, ...second]));
+
+ const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);
+
+ expect(result.entries.map((entry) => entry.title)).toEqual(['First summary', 'Second summary']);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({ byteOffset: first.byteLength, kind: 'protobuf' }),
+ ]);
+ });
+
it('should resolve one conversation without requiring a collection scan', async () => {
const root = await makeRoot();
const conversationId = '12111111-1111-4111-8111-111111111111';
diff --git a/src/lib/antigravity-db.ts b/src/lib/antigravity-db.ts
index 08e2add..1a49ec9 100644
--- a/src/lib/antigravity-db.ts
+++ b/src/lib/antigravity-db.ts
@@ -11,7 +11,7 @@ import {
getAntigravitySummaryIndexPath,
resolveAntigravityRoots,
} from './antigravity-exporter-types';
-import { decryptAntigravitySafeStoragePayload } from './antigravity-keychain';
+import type { AntigravityDecryptionCapability } from './antigravity-keychain';
import { resolveAntigravityProjectNames } from './antigravity-projects';
import {
type AntigravityTrajectoryEntry,
@@ -23,7 +23,12 @@ import {
ANTIGRAVITY_TRANSCRIPT_MARKDOWN_VERSION,
ANTIGRAVITY_TRANSCRIPT_VERSION_METADATA_KEY,
} from './antigravity-transcript-contract';
-import { readAntigravityTranscriptHistory } from './antigravity-transcript-history';
+import {
+ type AntigravityParseDiagnostic,
+ parseAntigravityJsonlText,
+ readAntigravityJsonlFile,
+ readAntigravityTranscriptHistory,
+} from './antigravity-transcript-history';
import { getAntigravityAssistantPhase, getFinalAntigravityAssistantSequences } from './antigravity-transcript-phase';
import { mapWithConcurrency } from './concurrency';
import {
@@ -75,6 +80,7 @@ type ConversationFile = {
const ANTIGRAVITY_ARTIFACT_READ_CONCURRENCY = 16;
const ANTIGRAVITY_CONVERSATION_FILE_READ_CONCURRENCY = 8;
+const ANTIGRAVITY_MAX_PROTO_RECORD_BYTES = 8 * 1024 * 1024;
const SAFE_CONVERSATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
const isSafeConversationId = (value: string) => SAFE_CONVERSATION_ID_PATTERN.test(value);
@@ -100,10 +106,10 @@ export type AntigravityConversationMessage = {
};
export type AntigravityConversationRenderOptions = {
+ decryptionCapability?: AntigravityDecryptionCapability;
includeCommentary?: boolean;
includeMetadata?: boolean;
includeTools?: boolean;
- keychainSecret?: string | null;
outputFormat?: ExportFormat;
};
@@ -231,6 +237,159 @@ const parseProtoFields = (buffer: Uint8Array, start = 0, end = buffer.length): P
return fields;
};
+type IndexedProtoRecord = {
+ byteOffset: number;
+ bytes: Uint8Array;
+};
+
+type ProtoBounds = {
+ end: number;
+ fieldNumber: number;
+ payloadEnd: number;
+ payloadStart: number;
+ wireType: number;
+};
+
+const getLengthDelimitedProtoBounds = (
+ buffer: Uint8Array,
+ fieldNumber: number,
+ index: number,
+ wireType: number,
+): ProtoBounds | null => {
+ const length = readVarint(buffer, index, buffer.length);
+ const payloadStart = length.next;
+ if (length.value > ANTIGRAVITY_MAX_PROTO_RECORD_BYTES) {
+ throw new Error(`Protobuf record exceeds ${ANTIGRAVITY_MAX_PROTO_RECORD_BYTES} bytes`);
+ }
+
+ const end = payloadStart + length.value;
+ if (end > buffer.length) {
+ return null;
+ }
+
+ return { end, fieldNumber, payloadEnd: end, payloadStart, wireType };
+};
+
+const tryGetProtoBounds = (buffer: Uint8Array, start: number): ProtoBounds | null => {
+ try {
+ const key = readVarint(buffer, start, buffer.length);
+ const fieldNumber = key.value >> 3;
+ const wireType = key.value & 7;
+ if (fieldNumber <= 0) {
+ throw new Error(`Invalid protobuf field number: ${fieldNumber}`);
+ }
+ const index = key.next;
+ switch (wireType) {
+ case 0: {
+ const value = readVarint(buffer, index, buffer.length);
+ return { end: value.next, fieldNumber, payloadEnd: value.next, payloadStart: index, wireType };
+ }
+ case 1:
+ return index + 8 > buffer.length
+ ? null
+ : { end: index + 8, fieldNumber, payloadEnd: index + 8, payloadStart: index, wireType };
+ case 2:
+ return getLengthDelimitedProtoBounds(buffer, fieldNumber, index, wireType);
+ case 5:
+ return index + 4 > buffer.length
+ ? null
+ : { end: index + 4, fieldNumber, payloadEnd: index + 4, payloadStart: index, wireType };
+ default:
+ throw new Error(`Unsupported protobuf wire type: ${wireType}`);
+ }
+ } catch (error) {
+ if (error instanceof Error && /Unterminated protobuf varint/iu.test(error.message)) {
+ return null;
+ }
+ throw error;
+ }
+};
+
+const appendBytes = (left: Uint8Array, right: Uint8Array): Uint8Array => {
+ const combined = new Uint8Array(left.length + right.length);
+ combined.set(left);
+ combined.set(right, left.length);
+ return combined;
+};
+
+const consumeProtoRecord = (
+ buffer: Uint8Array,
+ index: number,
+ bufferOffset: number,
+ fieldNumber: number,
+ diagnostics: AntigravityParseDiagnostic[],
+ records: IndexedProtoRecord[],
+): number | null => {
+ let bounds: ProtoBounds | null;
+ try {
+ bounds = tryGetProtoBounds(buffer, index);
+ } catch (error) {
+ diagnostics.push({
+ byteOffset: bufferOffset + index,
+ kind: 'protobuf',
+ message: `Invalid Antigravity protobuf field: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ return index + 1;
+ }
+
+ if (!bounds) {
+ return null;
+ }
+
+ if (bounds.fieldNumber === fieldNumber && bounds.wireType === 2) {
+ records.push({
+ byteOffset: bufferOffset + index,
+ bytes: buffer.slice(index, bounds.end),
+ });
+ }
+
+ return bounds.end;
+};
+
+const readAntigravityProtobufRecords = async (
+ filePath: string,
+ fieldNumber: number,
+): Promise<{ diagnostics: AntigravityParseDiagnostic[]; records: IndexedProtoRecord[] }> => {
+ const reader = Bun.file(filePath).stream().getReader();
+ const diagnostics: AntigravityParseDiagnostic[] = [];
+ const records: IndexedProtoRecord[] = [];
+ let buffer: Uint8Array = new Uint8Array();
+ let bufferOffset = 0;
+
+ const consume = () => {
+ let index = 0;
+ while (index < buffer.length) {
+ const nextIndex = consumeProtoRecord(buffer, index, bufferOffset, fieldNumber, diagnostics, records);
+ if (nextIndex === null) {
+ break;
+ }
+ index = nextIndex;
+ }
+ if (index > 0) {
+ buffer = buffer.slice(index);
+ bufferOffset += index;
+ }
+ };
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ buffer = appendBytes(buffer, value);
+ consume();
+ }
+ consume();
+ if (buffer.length > 0) {
+ diagnostics.push({
+ byteOffset: bufferOffset,
+ kind: 'protobuf',
+ message: 'Truncated Antigravity protobuf input',
+ });
+ }
+ return { diagnostics, records };
+};
+
const firstField = (fields: ProtoField[], fieldNumber: number): ProtoField | null =>
fields.find((field) => field.fieldNumber === fieldNumber) ?? null;
@@ -441,22 +600,61 @@ const resolveConversationParentIds = (summaries: ReadonlyMap => {
+export type AntigravitySummaryReadResult = {
+ diagnostics: AntigravityParseDiagnostic[];
+ entries: SummaryEntry[];
+};
+
+export const readAntigravitySummaryIndexWithDiagnostics = async (
+ summaryPath: string,
+): Promise => {
if (!(await pathExists(summaryPath))) {
- return [];
+ return { diagnostics: [], entries: [] };
}
try {
- const buffer = new Uint8Array(await Bun.file(summaryPath).arrayBuffer());
- return parseProtoFields(buffer)
- .filter((field) => field.fieldNumber === 1)
- .map((field) => parseSummaryEntry(field, summaryPath))
- .filter((entry): entry is SummaryEntry => entry !== null);
+ const result = await readAntigravityProtobufRecords(summaryPath, 1);
+ const entries: SummaryEntry[] = [];
+ for (const record of result.records) {
+ try {
+ const field = parseProtoFields(record.bytes)[0];
+ const entry = field ? parseSummaryEntry(field, summaryPath) : null;
+ if (entry) {
+ entries.push(entry);
+ } else {
+ result.diagnostics.push({
+ byteOffset: record.byteOffset,
+ kind: 'protobuf',
+ message: 'Invalid Antigravity summary record',
+ });
+ }
+ } catch (error) {
+ result.diagnostics.push({
+ byteOffset: record.byteOffset,
+ kind: 'protobuf',
+ message: `Invalid Antigravity summary record: ${error instanceof Error ? error.message : String(error)}`,
+ });
+ }
+ }
+ return { diagnostics: result.diagnostics, entries };
} catch {
- return [];
+ return {
+ diagnostics: [
+ {
+ byteOffset: null,
+ kind: 'protobuf',
+ message: 'Unable to read Antigravity summary index',
+ },
+ ],
+ entries: [],
+ };
}
};
+export const readAntigravitySummaryIndex = async (summaryPath: string): Promise => {
+ return (await readAntigravitySummaryIndexWithDiagnostics(summaryPath)).entries;
+};
+
const getFieldBounds = (
buffer: Uint8Array,
start: number,
@@ -824,9 +1022,9 @@ const analyzeTranscriptFile = async (filePath: string, size: number, mtimeMs: nu
if (cached) {
return cached;
}
- const text = await Bun.file(filePath).text();
let model: string | null = null;
- const entries = parseLogEntries(text);
+ const entries = (await readAntigravityJsonlFile(filePath, (line) => JSON.parse(line) as AntigravityLogEntry))
+ .records;
for (const entry of entries) {
model = extractModelSelectionChange(getString(entry.content) ?? '')?.to ?? model;
}
@@ -1450,18 +1648,7 @@ type AntigravityLogEntry = {
};
const parseLogEntries = (content: string): AntigravityLogEntry[] => {
- return content
- .split(/\r?\n/u)
- .map((line) => line.trim())
- .filter(Boolean)
- .map((line) => {
- try {
- return JSON.parse(line) as AntigravityLogEntry;
- } catch {
- return null;
- }
- })
- .filter((entry): entry is AntigravityLogEntry => entry !== null);
+ return parseAntigravityJsonlText(content, (line) => JSON.parse(line) as AntigravityLogEntry).records;
};
const getString = (value: unknown): string | null => (typeof value === 'string' ? value : null);
@@ -1515,20 +1702,20 @@ const logEntryHeading = (entry: AntigravityLogEntry): string => {
};
type ResolvedAntigravityConversationRenderOptions = {
+ decryptionCapability: AntigravityDecryptionCapability | null;
includeCommentary: boolean;
includeMetadata: boolean;
includeTools: boolean;
- keychainSecret: string | null;
outputFormat: ExportFormat;
};
const resolveAntigravityRenderOptions = (
options: AntigravityConversationRenderOptions,
): ResolvedAntigravityConversationRenderOptions => ({
+ decryptionCapability: options.decryptionCapability ?? null,
includeCommentary: options.includeCommentary ?? true,
includeMetadata: options.includeMetadata ?? true,
includeTools: options.includeTools ?? true,
- keychainSecret: options.keychainSecret ?? null,
outputFormat: options.outputFormat ?? 'md',
});
@@ -1702,9 +1889,15 @@ const carryForwardLogEntryModels = (entries: AntigravityLogEntry[]): Antigravity
};
const readConversationLogEntries = async (conversation: AntigravityConversation): Promise => {
- const generatedContent = conversation.transcriptPath ? await Bun.file(conversation.transcriptPath).text() : '';
- const generatedEntries = parseLogEntries(generatedContent);
- if (generatedContent.trim() && generatedEntries.length === 0 && conversation.transcriptSource !== 'trajectory') {
+ const generatedResult = conversation.transcriptPath
+ ? await readAntigravityJsonlFile(conversation.transcriptPath, (line) => JSON.parse(line) as AntigravityLogEntry)
+ : { diagnostics: [], records: [] };
+ const generatedEntries = generatedResult.records;
+ if (
+ generatedResult.diagnostics.length > 0 &&
+ generatedEntries.length === 0 &&
+ conversation.transcriptSource !== 'trajectory'
+ ) {
throw new Error(`Antigravity transcript is corrupt: ${conversation.transcriptPath}`);
}
const historicalContents = conversation.transcriptPath
@@ -2003,9 +2196,9 @@ export const renderAntigravityConversationMarkdown = async (
return transcript;
}
- if (resolvedOptions.keychainSecret && conversation.conversationPath) {
+ if (resolvedOptions.decryptionCapability && conversation.conversationPath) {
const encrypted = Buffer.from(await Bun.file(conversation.conversationPath).arrayBuffer());
- const decrypted = decryptAntigravitySafeStoragePayload(encrypted, resolvedOptions.keychainSecret);
+ const decrypted = resolvedOptions.decryptionCapability.decryptSafeStoragePayload(encrypted);
if (decrypted) {
return renderDecryptedSafeStorage(conversation, decrypted, resolvedOptions);
}
diff --git a/src/lib/antigravity-keychain.test.ts b/src/lib/antigravity-keychain.test.ts
index 0ca5e58..164f6a9 100644
--- a/src/lib/antigravity-keychain.test.ts
+++ b/src/lib/antigravity-keychain.test.ts
@@ -1,6 +1,7 @@
import { describe, expect, it } from 'bun:test';
import { createCipheriv, pbkdf2Sync } from 'node:crypto';
import {
+ createAntigravityDecryptionCapability,
decryptAntigravitySafeStoragePayload,
deriveAntigravitySafeStorageKey,
getAntigravityDecryptionState,
@@ -32,8 +33,17 @@ describe('antigravity keychain helpers', () => {
expect(decrypted).toBe('decrypted transcript text');
});
+ it('should expose only an opaque decryption capability instead of the Keychain secret', () => {
+ const encrypted = encryptSafeStorageFixture('scoped transcript text', 'fixture-secret');
+ const capability = createAntigravityDecryptionCapability('fixture-secret');
+
+ expect(Object.keys(capability)).toEqual(['decryptSafeStoragePayload']);
+ expect(capability.decryptSafeStoragePayload(encrypted)).toBe('scoped transcript text');
+ expect(capability).not.toHaveProperty('secret');
+ });
+
it('should report locked state without touching Keychain secrets', () => {
- const state = getAntigravityDecryptionState({ cachedSecret: null, platform: 'darwin' });
+ const state = getAntigravityDecryptionState({ hasAccess: false, platform: 'darwin' });
expect(state).toMatchObject({
canRequestAccess: true,
@@ -45,6 +55,17 @@ describe('antigravity keychain helpers', () => {
});
});
+ it('should report unlocked state from explicit request-scoped access', () => {
+ const state = getAntigravityDecryptionState({ hasAccess: true, platform: 'darwin' });
+
+ expect(state).toMatchObject({
+ canRequestAccess: true,
+ error: null,
+ isUnlocked: true,
+ status: 'unlocked',
+ });
+ });
+
it('should bound Keychain subprocess execution time', () => {
expect(getAntigravityKeychainExecOptions().timeout).toBeGreaterThan(0);
});
diff --git a/src/lib/antigravity-keychain.ts b/src/lib/antigravity-keychain.ts
index 92bd21f..9f89856 100644
--- a/src/lib/antigravity-keychain.ts
+++ b/src/lib/antigravity-keychain.ts
@@ -16,15 +16,16 @@ export type AntigravityDecryptionState = {
status: 'error' | 'locked' | 'unlocked' | 'unsupported';
};
+export type AntigravityDecryptionCapability = Readonly<{
+ decryptSafeStoragePayload: (payload: Buffer | Uint8Array | string) => string | null;
+}>;
+
const execFileAsync = promisify(execFile);
const SAFE_STORAGE_SALT = 'saltysalt';
const SAFE_STORAGE_ITERATIONS = 1003;
const SAFE_STORAGE_KEY_LENGTH = 16;
const SAFE_STORAGE_IV = Buffer.alloc(16, 0x20);
-let cachedKeychainSecret: string | null = null;
-let lastKeychainError: string | null = null;
-
export const deriveAntigravitySafeStorageKey = (keychainSecret: string | Buffer): Buffer => {
return pbkdf2Sync(keychainSecret, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS, SAFE_STORAGE_KEY_LENGTH, 'sha1');
};
@@ -120,13 +121,17 @@ export const decryptAntigravitySafeStoragePayload = (
return null;
};
+export const createAntigravityDecryptionCapability = (keychainSecret: string): AntigravityDecryptionCapability => ({
+ decryptSafeStoragePayload: (payload) => decryptAntigravitySafeStoragePayload(payload, keychainSecret),
+});
+
export const getAntigravityDecryptionState = ({
- cachedSecret = cachedKeychainSecret,
- lastError = lastKeychainError,
+ error = null,
+ hasAccess = false,
platform = process.platform,
}: {
- cachedSecret?: string | null;
- lastError?: string | null;
+ error?: string | null;
+ hasAccess?: boolean;
platform?: NodeJS.Platform;
} = {}): AntigravityDecryptionState => {
if (platform !== 'darwin') {
@@ -142,7 +147,7 @@ export const getAntigravityDecryptionState = ({
};
}
- if (cachedSecret) {
+ if (hasAccess) {
return {
canRequestAccess: true,
error: null,
@@ -157,18 +162,16 @@ export const getAntigravityDecryptionState = ({
return {
canRequestAccess: true,
- error: lastError,
+ error,
isUnlocked: false,
keychainAccount: ANTIGRAVITY_KEYCHAIN_ACCOUNT,
keychainService: ANTIGRAVITY_KEYCHAIN_SERVICE,
platform,
provider: 'keychain',
- status: lastError ? 'error' : 'locked',
+ status: error ? 'error' : 'locked',
};
};
-export const getCachedAntigravityKeychainSecret = (): string | null => cachedKeychainSecret;
-
export const getAntigravityKeychainExecOptions = () => ({ timeout: 10_000 });
export const readAntigravityKeychainSecret = async (): Promise => {
@@ -189,14 +192,28 @@ export const readAntigravityKeychainSecret = async (): Promise => {
return secret;
};
-export const unlockAntigravityDecryption = async (): Promise => {
+const getAntigravityKeychainError = (error: unknown): string => {
+ return error instanceof Error ? error.message : String(error);
+};
+
+export const withAntigravityDecryptionCapability = async (
+ action: (capability: AntigravityDecryptionCapability) => T | Promise,
+): Promise => {
+ const keychainSecret = await readAntigravityKeychainSecret();
+ return action(createAntigravityDecryptionCapability(keychainSecret));
+};
+
+export const probeAntigravityDecryptionState = async (): Promise => {
+ if (process.platform !== 'darwin') {
+ return getAntigravityDecryptionState();
+ }
+
try {
- cachedKeychainSecret = await readAntigravityKeychainSecret();
- lastKeychainError = null;
+ await readAntigravityKeychainSecret();
+ return getAntigravityDecryptionState({ hasAccess: true });
} catch (error) {
- cachedKeychainSecret = null;
- lastKeychainError = error instanceof Error ? error.message : String(error);
+ return getAntigravityDecryptionState({ error: getAntigravityKeychainError(error) });
}
-
- return getAntigravityDecryptionState();
};
+
+export const unlockAntigravityDecryption = probeAntigravityDecryptionState;
diff --git a/src/lib/antigravity-trajectory.test.ts b/src/lib/antigravity-trajectory.test.ts
new file mode 100644
index 0000000..8fa1d63
--- /dev/null
+++ b/src/lib/antigravity-trajectory.test.ts
@@ -0,0 +1,61 @@
+import { Database } from 'bun:sqlite';
+import { afterEach, describe, expect, it } from 'bun:test';
+import { mkdtemp, rm } from 'node:fs/promises';
+import { tmpdir } from 'node:os';
+import path from 'node:path';
+import { readAntigravityTrajectoryEntriesWithDiagnostics } from './antigravity-trajectory';
+
+const temporaryDirectories: string[] = [];
+
+afterEach(async () => {
+ await Promise.all(
+ temporaryDirectories.splice(0).map((directory) => rm(directory, { force: true, recursive: true })),
+ );
+});
+
+const encodeVarint = (value: number): number[] => {
+ const bytes: number[] = [];
+ let remaining = value;
+ while (remaining >= 0x80) {
+ bytes.push((remaining & 0x7f) | 0x80);
+ remaining = Math.floor(remaining / 0x80);
+ }
+ bytes.push(remaining);
+ return bytes;
+};
+
+const encodeString = (fieldNumber: number, value: string): Uint8Array => {
+ const bytes = [...Buffer.from(value, 'utf8')];
+ return new Uint8Array([...encodeVarint((fieldNumber << 3) | 2), ...encodeVarint(bytes.length), ...bytes]);
+};
+
+const encodeMessage = (fieldNumber: number, value: Uint8Array): Uint8Array =>
+ new Uint8Array([...encodeVarint((fieldNumber << 3) | 2), ...encodeVarint(value.length), ...value]);
+
+describe('Antigravity trajectory reader', () => {
+ it('should keep valid steps when a neighboring protobuf row is corrupt', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'antigravity-trajectory-'));
+ temporaryDirectories.push(directory);
+ const databasePath = path.join(directory, 'conversation.db');
+ const database = new Database(databasePath, { create: true });
+ database.exec(
+ 'CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL, status INTEGER NOT NULL, metadata BLOB, step_payload BLOB)',
+ );
+ database
+ .prepare('INSERT INTO steps (idx, step_type, status, metadata, step_payload) VALUES (?, ?, ?, ?, ?)')
+ .run(1, 14, 3, null, new Uint8Array([0xff]));
+ database
+ .prepare('INSERT INTO steps (idx, step_type, status, metadata, step_payload) VALUES (?, ?, ?, ?, ?)')
+ .run(2, 14, 3, null, encodeMessage(19, encodeString(2, 'keep me')));
+ database.close();
+
+ const result = await readAntigravityTrajectoryEntriesWithDiagnostics(databasePath);
+
+ expect(result.entries).toEqual([
+ expect.objectContaining({ content: 'keep me', step_index: 2, type: 'USER_INPUT' }),
+ ]);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({ byteOffset: null, message: expect.stringContaining('step 1'), stepIndex: 1 }),
+ ]);
+ });
+});
diff --git a/src/lib/antigravity-trajectory.ts b/src/lib/antigravity-trajectory.ts
index ea1096a..f051e19 100644
--- a/src/lib/antigravity-trajectory.ts
+++ b/src/lib/antigravity-trajectory.ts
@@ -1,5 +1,6 @@
import { constants, Database } from 'bun:sqlite';
import { pathToFileURL } from 'node:url';
+import type { AntigravityParseDiagnostic } from './antigravity-transcript-history';
type ProtoField = {
bytes?: Uint8Array;
@@ -38,6 +39,11 @@ export type AntigravityTrajectoryEntry = {
workdir?: string;
};
+export type AntigravityTrajectoryReadResult = {
+ diagnostics: AntigravityParseDiagnostic[];
+ entries: AntigravityTrajectoryEntry[];
+};
+
const decoder = new TextDecoder();
const ANTIGRAVITY_READONLY_DB_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
const ANTIGRAVITY_SQLITE_BUSY_TIMEOUT_MS = 50;
@@ -283,11 +289,33 @@ export const readAntigravityTrajectoryStepIndexes = (dbPath: string): Promise =>
+ readAntigravityTrajectoryEntriesWithDiagnostics(dbPath).then((result) => result.entries);
+
+export const readAntigravityTrajectoryEntriesWithDiagnostics = (
+ dbPath: string,
+): Promise =>
withTrajectoryDb(dbPath, (db) => {
const rows = db
.query('SELECT idx, step_type, status, metadata, step_payload FROM steps ORDER BY idx')
.all() as TrajectoryStepRow[];
- return rows
- .map((row) => parseTrajectoryStep(row))
- .filter((entry): entry is AntigravityTrajectoryEntry => entry !== null);
+ const diagnostics: AntigravityParseDiagnostic[] = [];
+ const entries: AntigravityTrajectoryEntry[] = [];
+ for (const row of rows) {
+ try {
+ const entry = parseTrajectoryStep(row);
+ if (entry) {
+ entries.push(entry);
+ }
+ } catch (error) {
+ diagnostics.push({
+ byteOffset: null,
+ kind: 'protobuf',
+ message: `Invalid Antigravity trajectory protobuf at step ${row.idx}: ${
+ error instanceof Error ? error.message : String(error)
+ }`,
+ stepIndex: row.idx,
+ });
+ }
+ }
+ return { diagnostics, entries };
});
diff --git a/src/lib/antigravity-transcript-history.test.ts b/src/lib/antigravity-transcript-history.test.ts
index b80d596..6c76bc8 100644
--- a/src/lib/antigravity-transcript-history.test.ts
+++ b/src/lib/antigravity-transcript-history.test.ts
@@ -2,7 +2,11 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdir, mkdtemp, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
-import { readAntigravityTranscriptHistory } from './antigravity-transcript-history';
+import {
+ parseAntigravityJsonlText,
+ readAntigravityJsonlFile,
+ readAntigravityTranscriptHistory,
+} from './antigravity-transcript-history';
const temporaryDirectories: string[] = [];
@@ -27,6 +31,44 @@ const makeRepository = async (): Promise => {
};
describe('Antigravity transcript history', () => {
+ it('should preserve valid JSONL records around corruption with bounded diagnostics', () => {
+ const result = parseAntigravityJsonlText(
+ `${JSON.stringify({ step_index: 1 })}\n{malformed}\n${JSON.stringify({ step_index: 3 })}\n`,
+ (line) => JSON.parse(line) as { step_index: number },
+ );
+
+ expect(result.records).toEqual([{ step_index: 1 }, { step_index: 3 }]);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({
+ byteOffset: Buffer.byteLength(`${JSON.stringify({ step_index: 1 })}\n`),
+ line: 2,
+ message: 'Invalid Antigravity JSONL record',
+ }),
+ ]);
+ });
+
+ it('should bound overlong JSONL lines and continue reading later records', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'antigravity-jsonl-'));
+ temporaryDirectories.push(directory);
+ const transcriptPath = path.join(directory, 'transcript.jsonl');
+ await Bun.write(transcriptPath, `${'x'.repeat(32)}\n{"step_index":9}\n`);
+
+ const result = await readAntigravityJsonlFile(
+ transcriptPath,
+ (line) => JSON.parse(line) as { step_index: number },
+ { maxLineBytes: 16 },
+ );
+
+ expect(result.records).toEqual([{ step_index: 9 }]);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({
+ byteOffset: 0,
+ line: 1,
+ truncated: true,
+ }),
+ ]);
+ });
+
it('should skip history recovery when the current transcript already starts at its first step', async () => {
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', null)).toEqual([]);
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', 0)).toEqual([]);
diff --git a/src/lib/antigravity-transcript-history.ts b/src/lib/antigravity-transcript-history.ts
index 6b66035..657f400 100644
--- a/src/lib/antigravity-transcript-history.ts
+++ b/src/lib/antigravity-transcript-history.ts
@@ -15,6 +15,22 @@ type CachedHistory = {
contents: string[];
};
+export type AntigravityParseDiagnostic = {
+ byteOffset: number | null;
+ kind: 'jsonl' | 'protobuf';
+ line?: number;
+ message: string;
+ stepIndex?: number;
+ truncated?: boolean;
+};
+
+export type AntigravityJsonlReadResult = {
+ diagnostics: AntigravityParseDiagnostic[];
+ records: T[];
+};
+
+const DEFAULT_JSONL_MAX_LINE_BYTES = 1024 * 1024;
+
const MAX_HISTORY_CACHE_BYTES = 32 * 1024 * 1024;
const MAX_HISTORY_CACHE_ENTRIES = 32;
const historyCache = new Map();
@@ -37,20 +53,207 @@ const runGit = async (cwd: string, args: string[]): Promise =>
}
};
-const getMinimumStepIndex = (content: string): number | null => {
- let minimum: number | null = null;
- for (const line of content.split(/\r?\n/u)) {
- if (!line.trim()) {
- continue;
+const parseJsonlLine = (
+ line: string,
+ lineNumber: number,
+ byteOffset: number,
+ parse: (line: string) => T,
+ diagnostics: AntigravityParseDiagnostic[],
+ records: T[],
+ maxLineBytes: number,
+): void => {
+ const lineBytes = Buffer.byteLength(line);
+ if (lineBytes > maxLineBytes) {
+ diagnostics.push({
+ byteOffset,
+ kind: 'jsonl',
+ line: lineNumber,
+ message: `Antigravity JSONL line exceeds ${maxLineBytes} bytes`,
+ truncated: true,
+ });
+ return;
+ }
+
+ const trimmed = line.trim();
+ if (!trimmed) {
+ return;
+ }
+
+ try {
+ records.push(parse(trimmed));
+ } catch {
+ diagnostics.push({
+ byteOffset,
+ kind: 'jsonl',
+ line: lineNumber,
+ message: 'Invalid Antigravity JSONL record',
+ });
+ }
+};
+
+export const parseAntigravityJsonlText = (
+ content: string,
+ parse: (line: string) => T,
+ options: { maxLineBytes?: number } = {},
+): AntigravityJsonlReadResult => {
+ const diagnostics: AntigravityParseDiagnostic[] = [];
+ const records: T[] = [];
+ const maxLineBytes = options.maxLineBytes ?? DEFAULT_JSONL_MAX_LINE_BYTES;
+ let lineStart = 0;
+ let byteOffset = 0;
+ let lineNumber = 1;
+ while (lineStart < content.length) {
+ const newline = content.indexOf('\n', lineStart);
+ const hasNewline = newline >= 0;
+ const lineEnd = hasNewline ? newline : content.length;
+ const line = content.slice(lineStart, lineEnd).replace(/\r$/u, '');
+ parseJsonlLine(line, lineNumber, byteOffset, parse, diagnostics, records, maxLineBytes);
+ byteOffset += Buffer.byteLength(content.slice(lineStart, lineEnd)) + (hasNewline ? 1 : 0);
+ lineStart = hasNewline ? lineEnd + 1 : content.length;
+ lineNumber += 1;
+ }
+ return { diagnostics, records };
+};
+
+type JsonlStreamState = {
+ discardingOverlongLine: boolean;
+ lineBytesSeen: number;
+ lineNumber: number;
+ lineOffset: number;
+ pending: string;
+ pendingBytes: number;
+};
+
+type JsonlFragmentInput = {
+ diagnostics: AntigravityParseDiagnostic[];
+ fragment: string;
+ hasNewline: boolean;
+ maxLineBytes: number;
+ parse: (line: string) => T;
+ records: T[];
+ state: JsonlStreamState;
+};
+
+const consumeJsonlFragment = ({
+ diagnostics,
+ fragment,
+ hasNewline,
+ maxLineBytes,
+ parse,
+ records,
+ state,
+}: JsonlFragmentInput) => {
+ const fragmentBytes = Buffer.byteLength(fragment);
+ const fullLineBytes = state.lineBytesSeen + fragmentBytes;
+ state.lineBytesSeen = fullLineBytes;
+ if (fullLineBytes > maxLineBytes && !state.discardingOverlongLine) {
+ diagnostics.push({
+ byteOffset: state.lineOffset,
+ kind: 'jsonl',
+ line: state.lineNumber,
+ message: `Antigravity JSONL line exceeds ${maxLineBytes} bytes`,
+ truncated: true,
+ });
+ state.discardingOverlongLine = true;
+ state.pending = '';
+ state.pendingBytes = 0;
+ }
+
+ if (!hasNewline) {
+ if (!state.discardingOverlongLine) {
+ state.pending += fragment;
+ state.pendingBytes = fullLineBytes;
}
+ return;
+ }
+
+ if (!state.discardingOverlongLine) {
+ parseJsonlLine(
+ `${state.pending}${fragment}`,
+ state.lineNumber,
+ state.lineOffset,
+ parse,
+ diagnostics,
+ records,
+ maxLineBytes,
+ );
+ }
+ state.lineOffset += state.lineBytesSeen + 1;
+ state.lineNumber += 1;
+ state.pending = '';
+ state.pendingBytes = 0;
+ state.lineBytesSeen = 0;
+ state.discardingOverlongLine = false;
+};
+
+const consumeJsonlText = (
+ text: string,
+ state: JsonlStreamState,
+ input: Omit, 'fragment' | 'hasNewline' | 'state'>,
+) => {
+ let start = 0;
+ while (start < text.length) {
+ const newline = text.indexOf('\n', start);
+ const hasNewline = newline >= 0;
+ const end = hasNewline ? newline : text.length;
+ const fragment = text.slice(start, end).replace(/\r$/u, '');
+ consumeJsonlFragment({ ...input, fragment, hasNewline, state });
+ start = hasNewline ? end + 1 : text.length;
+ }
+};
- try {
- const stepIndex = (JSON.parse(line) as { step_index?: unknown }).step_index;
- if (typeof stepIndex === 'number' && Number.isFinite(stepIndex)) {
- minimum = minimum === null ? stepIndex : Math.min(minimum, stepIndex);
- }
- } catch {
- // Historical snapshots are optional recovery evidence and may include partial writes.
+export const readAntigravityJsonlFile = async (
+ filePath: string,
+ parse: (line: string) => T,
+ options: { maxLineBytes?: number } = {},
+): Promise> => {
+ const diagnostics: AntigravityParseDiagnostic[] = [];
+ const records: T[] = [];
+ const maxLineBytes = options.maxLineBytes ?? DEFAULT_JSONL_MAX_LINE_BYTES;
+ const reader = Bun.file(filePath).stream().getReader();
+ const decoder = new TextDecoder();
+ const state: JsonlStreamState = {
+ discardingOverlongLine: false,
+ lineBytesSeen: 0,
+ lineNumber: 1,
+ lineOffset: 0,
+ pending: '',
+ pendingBytes: 0,
+ };
+ const consume = (text: string) => consumeJsonlText(text, state, { diagnostics, maxLineBytes, parse, records });
+
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ consume(decoder.decode());
+ break;
+ }
+ consume(decoder.decode(value, { stream: true }));
+ }
+ if (state.pending || state.pendingBytes > 0) {
+ if (!state.discardingOverlongLine) {
+ parseJsonlLine(
+ state.pending,
+ state.lineNumber,
+ state.lineOffset,
+ parse,
+ diagnostics,
+ records,
+ maxLineBytes,
+ );
+ }
+ }
+
+ return { diagnostics, records };
+};
+
+const getMinimumStepIndex = (content: string): number | null => {
+ let minimum: number | null = null;
+ const result = parseAntigravityJsonlText(content, (line) => JSON.parse(line) as { step_index?: unknown });
+ for (const entry of result.records) {
+ const stepIndex = entry.step_index;
+ if (typeof stepIndex === 'number' && Number.isFinite(stepIndex)) {
+ minimum = minimum === null ? stepIndex : Math.min(minimum, stepIndex);
}
}
return minimum;
diff --git a/src/lib/codex-browser-db.test.ts b/src/lib/codex-browser-db.test.ts
index 2351638..8e5949f 100644
--- a/src/lib/codex-browser-db.test.ts
+++ b/src/lib/codex-browser-db.test.ts
@@ -4,12 +4,14 @@ import { mkdir, mkdtemp, rm, utimes } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
+ CodexDbCompatibilityError,
CodexThreadNotFoundError,
deleteCodexProject,
deleteCodexThread,
deleteCodexThreads,
getCodexDashboardSummary,
getThreadBrowseData,
+ getThreadBrowseDataBatch,
listCodexProjects,
listProjectThreads,
listScopedThreads,
@@ -1174,6 +1176,60 @@ describe('codex browser db', () => {
expect(threads[0]?.rolloutSizeBytes).toBeGreaterThan(0);
});
+ it('should reject a browse database with an actionable typed compatibility error', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-schema-compatibility-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createMinimalBrowseSchemaFixture(tempRoot);
+ const db = new Database(fixture.dbPath);
+ db.exec('ALTER TABLE threads DROP COLUMN preview');
+ db.close();
+
+ expect(() => getThreadBrowseData(fixture.dbPath, fixture.threadId)).toThrow(CodexDbCompatibilityError);
+ try {
+ getThreadBrowseData(fixture.dbPath, fixture.threadId);
+ throw new Error('expected schema compatibility failure');
+ } catch (error) {
+ expect(error).toBeInstanceOf(CodexDbCompatibilityError);
+ expect(error).toMatchObject({
+ code: 'CODEX_DB_INCOMPATIBLE',
+ missingColumns: ['threads.preview'],
+ });
+ expect((error as Error).message).toContain('threads.preview');
+ }
+ });
+
+ it('should batch browse in request order with fallback and missing outcomes without unbounded id parameters', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-batch-browse-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const fallbackThreadId = '019ec3d5-859d-77d0-b851-256ae567ff70';
+ await addFallbackSessionIndexEntry(tempRoot, fallbackThreadId, 'spiracha');
+ const missingThreadId = '019ec3d5-859d-77d0-b851-256ae567ff71';
+ const ids = [
+ fixture.threads[1]!.threadId,
+ missingThreadId,
+ fallbackThreadId,
+ fixture.threads[0]!.threadId,
+ ...Array.from({ length: 1_000 }, (_, index) => `missing-${index}`),
+ ];
+
+ const results = getThreadBrowseDataBatch(fixture.dbPath, ids);
+
+ expect(results).toHaveLength(ids.length);
+ expect(results.slice(0, 4).map((result) => [result.threadId, result.status, result.source])).toEqual([
+ [fixture.threads[1]!.threadId, 'found', 'database'],
+ [missingThreadId, 'missing', 'missing'],
+ [fallbackThreadId, 'found', 'fallback'],
+ [fixture.threads[0]!.threadId, 'found', 'database'],
+ ]);
+ expect(results[0]).toMatchObject({
+ data: {
+ relations: { parentThreadId: fixture.threads[0]!.threadId },
+ },
+ });
+ expect(results.every((result) => result.threadId === ids[results.indexOf(result)])).toBe(true);
+ });
+
it('should prefer the Codex session index title over the stored prompt text', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-session-index-title-test-'));
tempPaths.push(tempRoot);
@@ -1383,6 +1439,92 @@ describe('codex browser db', () => {
db.close();
});
+ it('should make repeated same-id deletion idempotent and report optional cleanup outcomes', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-idempotence-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const threadId = fixture.threads[0]!.threadId;
+
+ const first = await deleteCodexThreads(fixture.dbPath, [threadId, threadId]);
+ const second = await deleteCodexThreads(fixture.dbPath, [threadId]);
+
+ expect(first).toMatchObject({
+ cleanup: {
+ deletedSessionFiles: [],
+ requested: false,
+ },
+ deletedThreadIds: [threadId],
+ });
+ expect(second).toMatchObject({
+ cleanup: {
+ deletedSessionFiles: [],
+ requested: false,
+ },
+ deletedThreadIds: [],
+ });
+ });
+
+ it('should let a competing writer release its lock before the configured busy timeout expires', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-busy-timeout-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const threadId = fixture.threads[0]!.threadId;
+ const childScript = `
+ import { Database } from 'bun:sqlite';
+ const db = new Database(Bun.argv.at(-1));
+ db.exec('BEGIN EXCLUSIVE');
+ await Bun.sleep(150);
+ db.exec('COMMIT');
+ db.close();
+ `;
+ const holder = Bun.spawn([process.execPath, '-e', childScript, fixture.dbPath], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ await Bun.sleep(25);
+ const result = await deleteCodexThread(fixture.dbPath, threadId);
+ const holderExitCode = await holder.exited;
+
+ expect(holderExitCode).toBe(0);
+ expect(result.deletedThreadIds).toEqual([threadId]);
+ });
+
+ it('should make concurrent child-process deletes of one id idempotent', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-competing-delete-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const threadId = fixture.threads[0]!.threadId;
+ const childScript = `
+ import { deleteCodexThread } from './src/lib/codex-browser-db.ts';
+ const result = await deleteCodexThread(Bun.argv.at(-2), Bun.argv.at(-1));
+ console.log(JSON.stringify(result.deletedThreadIds));
+ `;
+ const processes = [0, 1].map(() =>
+ Bun.spawn([process.execPath, '-e', childScript, fixture.dbPath, threadId], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ }),
+ );
+ const outcomes = await Promise.all(
+ processes.map(async (processHandle) => {
+ const output = await new Response(processHandle.stdout).text();
+ const errorOutput = await new Response(processHandle.stderr).text();
+ const exitCode = await processHandle.exited;
+ return { errorOutput: errorOutput.trim(), exitCode, output: output.trim() };
+ }),
+ );
+
+ if (!outcomes.every((outcome) => outcome.exitCode === 0)) {
+ throw new Error(JSON.stringify(outcomes));
+ }
+ expect(outcomes.flatMap((outcome) => JSON.parse(outcome.output) as string[]).sort()).toEqual([threadId]);
+ const db = new Database(fixture.dbPath, { readonly: true });
+ expect(db.query('SELECT COUNT(*) AS count FROM threads WHERE id = ?').get(threadId)).toEqual({ count: 0 });
+ db.close();
+ });
+
it('should delete paginated history rows for an individual thread', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-history-thread-test-'));
tempPaths.push(tempRoot);
diff --git a/src/lib/codex-browser-db.ts b/src/lib/codex-browser-db.ts
index ea28aaf..4f98919 100644
--- a/src/lib/codex-browser-db.ts
+++ b/src/lib/codex-browser-db.ts
@@ -6,7 +6,9 @@ import path from 'node:path';
import { StringDecoder } from 'node:string_decoder';
import { pathToFileURL } from 'node:url';
import type {
+ CodexDbSchemaProfile,
CodexSessionIndexReconciliation,
+ CodexThreadBrowseBatchResult,
DashboardSummary,
DashboardThreadSummary,
DeleteProjectResult,
@@ -21,7 +23,7 @@ import {
getCachedCodexTranscriptStats,
getThreadRolloutLoadState,
} from './codex-thread-cache';
-import type { ThreadRelations, ThreadRow } from './codex-thread-types';
+import type { SpawnEdgeRow, ThreadRelations, ThreadRow } from './codex-thread-types';
import { DEFAULT_CODEX_DIR, DEFAULT_DB_PATH } from './codex-thread-types';
import { mapWithConcurrency } from './concurrency';
import { getPortablePathBasename } from './portable-path';
@@ -58,6 +60,65 @@ const THREAD_ROW_COLUMNS = `
agent_role, memory_mode, model, reasoning_effort, agent_path, created_at_ms,
updated_at_ms, thread_source, preview
`;
+const REQUIRED_BROWSE_THREAD_COLUMNS = [
+ 'id',
+ 'rollout_path',
+ 'created_at',
+ 'updated_at',
+ 'source',
+ 'model_provider',
+ 'cwd',
+ 'title',
+ 'sandbox_policy',
+ 'approval_mode',
+ 'tokens_used',
+ 'has_user_event',
+ 'archived',
+ 'archived_at',
+ 'git_sha',
+ 'git_branch',
+ 'git_origin_url',
+ 'cli_version',
+ 'first_user_message',
+ 'agent_nickname',
+ 'agent_role',
+ 'memory_mode',
+ 'model',
+ 'reasoning_effort',
+ 'agent_path',
+ 'created_at_ms',
+ 'updated_at_ms',
+ 'thread_source',
+ 'preview',
+] as const;
+const CODEX_BROWSE_SCHEMA_PROFILE: CodexDbSchemaProfile = {
+ name: 'codex-state-5-thread-browse-v1',
+ requiredColumns: {
+ thread_dynamic_tools: [
+ 'thread_id',
+ 'position',
+ 'name',
+ 'description',
+ 'input_schema',
+ 'defer_loading',
+ 'namespace',
+ ],
+ thread_goals: [
+ 'thread_id',
+ 'goal_id',
+ 'objective',
+ 'status',
+ 'token_budget',
+ 'tokens_used',
+ 'time_used_seconds',
+ 'created_at_ms',
+ 'updated_at_ms',
+ ],
+ thread_spawn_edges: ['parent_thread_id', 'child_thread_id', 'status'],
+ threads: REQUIRED_BROWSE_THREAD_COLUMNS,
+ },
+ requiredTables: ['threads'],
+};
const PROJECT_CWD_FILTER = `
typeof(cwd) = 'text'
AND (
@@ -220,15 +281,28 @@ const compareThreadsByRecentActivity = (
right: Pick,
) => getThreadUpdatedAtMs(right) - getThreadUpdatedAtMs(left) || compareCodeUnits(right.id, left.id);
-const parseDynamicToolRow = (row: Record): DynamicToolRow => {
+const parseDynamicToolRow = (row: unknown): DynamicToolRow => {
+ const values = row as Record;
+ const requiredString = (field: string) => {
+ const value = values[field];
+ if (typeof value !== 'string') {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_dynamic_tools.${field}`]);
+ }
+ return value;
+ };
+ const position = values.position;
+ if (typeof position !== 'number' || !Number.isFinite(position)) {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], ['thread_dynamic_tools.position']);
+ }
+
return {
- deferLoading: Number(row.defer_loading ?? 0) === 1,
- description: String(row.description ?? ''),
- inputSchema: parseJsonSafely(typeof row.input_schema === 'string' ? row.input_schema : null),
- name: String(row.name ?? 'unknown'),
- namespace: typeof row.namespace === 'string' ? row.namespace : null,
- position: Number(row.position ?? 0),
- threadId: String(row.thread_id),
+ deferLoading: Number(values.defer_loading ?? 0) === 1,
+ description: requiredString('description'),
+ inputSchema: parseJsonSafely(typeof values.input_schema === 'string' ? values.input_schema : null),
+ name: requiredString('name'),
+ namespace: typeof values.namespace === 'string' ? values.namespace : null,
+ position,
+ threadId: requiredString('thread_id'),
};
};
@@ -244,6 +318,232 @@ const parseJsonSafely = (value: string | null) => {
}
};
+const decodeThreadGoalRow = (row: unknown): ThreadGoalRow => {
+ const values = row as Record;
+ const requiredString = (field: keyof ThreadGoalRow) => {
+ const value = values[field];
+ if (typeof value !== 'string') {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
+ }
+ return value;
+ };
+ const requiredNumber = (field: keyof ThreadGoalRow) => {
+ const value = values[field];
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
+ }
+ return value;
+ };
+ const nullableNumber = (field: keyof ThreadGoalRow) => {
+ const value = values[field];
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
+ }
+ return value;
+ };
+
+ return {
+ created_at_ms: requiredNumber('created_at_ms'),
+ goal_id: requiredString('goal_id'),
+ objective: requiredString('objective'),
+ status: requiredString('status'),
+ time_used_seconds: requiredNumber('time_used_seconds'),
+ token_budget: nullableNumber('token_budget'),
+ tokens_used: requiredNumber('tokens_used'),
+ updated_at_ms: requiredNumber('updated_at_ms'),
+ };
+};
+
+const decodeThreadSpawnEdgeRow = (row: unknown): ThreadSpawnEdge => {
+ const values = row as Record;
+ if (typeof values.parent_thread_id !== 'string') {
+ throw new CodexDbCompatibilityError(
+ CODEX_BROWSE_SCHEMA_PROFILE,
+ [],
+ [],
+ ['thread_spawn_edges.parent_thread_id'],
+ );
+ }
+ if (typeof values.child_thread_id !== 'string') {
+ throw new CodexDbCompatibilityError(
+ CODEX_BROWSE_SCHEMA_PROFILE,
+ [],
+ [],
+ ['thread_spawn_edges.child_thread_id'],
+ );
+ }
+ if (typeof values.status !== 'string') {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], ['thread_spawn_edges.status']);
+ }
+ return {
+ child_thread_id: values.child_thread_id,
+ parent_thread_id: values.parent_thread_id,
+ status: values.status,
+ };
+};
+
+export class CodexDbCompatibilityError extends Error {
+ readonly code = 'CODEX_DB_INCOMPATIBLE';
+ readonly invalidFields: string[];
+ readonly missingColumns: string[];
+ readonly missingTables: string[];
+ readonly profileName: string;
+
+ constructor(
+ profile: CodexDbSchemaProfile,
+ missingTables: string[],
+ missingColumns: string[],
+ invalidFields: string[] = [],
+ ) {
+ const details = [
+ missingTables.length > 0 ? `missing tables: ${missingTables.join(', ')}` : '',
+ missingColumns.length > 0 ? `missing columns: ${missingColumns.join(', ')}` : '',
+ invalidFields.length > 0 ? `invalid fields: ${invalidFields.join(', ')}` : '',
+ ].filter(Boolean);
+ super(`Unsupported Codex database schema for ${profile.name}; ${details.join('; ')}`);
+ this.name = 'CodexDbCompatibilityError';
+ this.missingColumns = missingColumns;
+ this.missingTables = missingTables;
+ this.profileName = profile.name;
+ this.invalidFields = invalidFields;
+ }
+}
+
+const getSchemaTableColumns = (db: Database, tableName: string) => {
+ return new Set(
+ (db.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>).map((column) => column.name),
+ );
+};
+
+const assertCodexSchemaCompatibility = (db: Database, profile: CodexDbSchemaProfile) => {
+ const tableNames = new Set(
+ (db.query("SELECT name FROM sqlite_master WHERE type = 'table'").all() as Array<{ name: string }>).map(
+ (table) => table.name,
+ ),
+ );
+ const missingTables = profile.requiredTables.filter((tableName) => !tableNames.has(tableName));
+ const missingColumns: string[] = [];
+
+ for (const [tableName, requiredColumns] of Object.entries(profile.requiredColumns)) {
+ if (!tableNames.has(tableName)) {
+ continue;
+ }
+
+ const actualColumns = getSchemaTableColumns(db, tableName);
+ for (const columnName of requiredColumns) {
+ if (!actualColumns.has(columnName)) {
+ missingColumns.push(`${tableName}.${columnName}`);
+ }
+ }
+ }
+
+ if (missingTables.length > 0 || missingColumns.length > 0) {
+ throw new CodexDbCompatibilityError(profile, missingTables, missingColumns);
+ }
+
+ return tableNames;
+};
+
+const decodeThreadRow = (row: unknown): ThreadRow => {
+ const values = row as Record;
+ const invalidFields: string[] = [];
+ const requiredString = (field: keyof ThreadRow) => {
+ const value = values[field];
+ if (typeof value !== 'string') {
+ invalidFields.push(`threads.${String(field)}`);
+ return '';
+ }
+ return value;
+ };
+ const requiredNumber = (field: keyof ThreadRow) => {
+ const value = values[field];
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ invalidFields.push(`threads.${String(field)}`);
+ return 0;
+ }
+ return value;
+ };
+ const nullableString = (field: keyof ThreadRow) => {
+ const value = values[field];
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (typeof value !== 'string') {
+ invalidFields.push(`threads.${String(field)}`);
+ return null;
+ }
+ return value;
+ };
+ const nullableNumber = (field: keyof ThreadRow) => {
+ const value = values[field];
+ if (value === null || value === undefined) {
+ return null;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ invalidFields.push(`threads.${String(field)}`);
+ return null;
+ }
+ return value;
+ };
+
+ const decoded: ThreadRow = {
+ agent_nickname: nullableString('agent_nickname'),
+ agent_path: nullableString('agent_path'),
+ agent_role: nullableString('agent_role'),
+ approval_mode: requiredString('approval_mode'),
+ archived: requiredNumber('archived'),
+ archived_at: nullableNumber('archived_at'),
+ cli_version: requiredString('cli_version'),
+ created_at: requiredNumber('created_at'),
+ created_at_ms: nullableNumber('created_at_ms'),
+ cwd: requiredString('cwd'),
+ first_user_message: requiredString('first_user_message'),
+ git_branch: nullableString('git_branch'),
+ git_origin_url: nullableString('git_origin_url'),
+ git_sha: nullableString('git_sha'),
+ has_user_event: requiredNumber('has_user_event'),
+ id: requiredString('id'),
+ memory_mode: requiredString('memory_mode'),
+ model: nullableString('model'),
+ model_provider: requiredString('model_provider'),
+ preview: requiredString('preview'),
+ reasoning_effort: nullableString('reasoning_effort'),
+ rollout_path: requiredString('rollout_path'),
+ sandbox_policy: requiredString('sandbox_policy'),
+ source: requiredString('source'),
+ thread_source: nullableString('thread_source'),
+ title: requiredString('title'),
+ tokens_used: requiredNumber('tokens_used'),
+ updated_at: requiredNumber('updated_at'),
+ updated_at_ms: nullableNumber('updated_at_ms'),
+ };
+ if (invalidFields.length > 0) {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], invalidFields);
+ }
+ return decoded;
+};
+
+const withSqliteTransaction = (db: Database, callback: (db: Database) => T): T => {
+ db.exec('BEGIN DEFERRED');
+ let open = true;
+ try {
+ const result = callback(db);
+ db.exec('COMMIT');
+ open = false;
+ return result;
+ } catch (error) {
+ if (open) {
+ try {
+ db.exec('ROLLBACK');
+ } catch {}
+ }
+ throw error;
+ }
+};
+
const getUserVisibleThreadFilter = (db: Database) => {
const threadColumns = db.query('PRAGMA table_info(threads)').all() as Array<{ name: string }>;
if (!threadColumns.some((column) => column.name === 'thread_source')) {
@@ -279,7 +579,7 @@ const withWritableDb = (dbPath: string, callback: (db: Database) => T): T =>
},
});
try {
- const result = callback(db);
+ const result = runWithSqliteRetry({ action: () => callback(db) });
if (isPromiseLike(result)) {
throw new Error('Database callbacks must be synchronous');
}
@@ -1184,39 +1484,6 @@ const mapProjectSummaries = (projectMap: ProjectSummaryMap): ProjectSummary[] =>
});
};
-const getRelationsForThread = (db: Database, threadId: string, existingTableNames: Set): ThreadRelations => {
- if (!existingTableNames.has('thread_spawn_edges')) {
- return {
- childEdges: [],
- parentThreadId: null,
- };
- }
-
- const parentRow = db
- .query(
- 'SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges WHERE child_thread_id = ? LIMIT 1',
- )
- .get(threadId) as {
- child_thread_id: string;
- parent_thread_id: string;
- status: string;
- } | null;
- const childRows = db
- .query(
- 'SELECT parent_thread_id, child_thread_id, status FROM thread_spawn_edges WHERE parent_thread_id = ? ORDER BY child_thread_id ASC',
- )
- .all(threadId) as Array<{
- child_thread_id: string;
- parent_thread_id: string;
- status: string;
- }>;
-
- return {
- childEdges: childRows,
- parentThreadId: parentRow?.parent_thread_id ?? null,
- };
-};
-
const getExistingTableNames = (db: Database) => {
const rows = db.query('SELECT name FROM sqlite_master WHERE type = ?').all('table') as Array<{ name: string }>;
return new Set(rows.map((row) => row.name));
@@ -1229,10 +1496,7 @@ const getExistingCodexHistoryTableNames = (db: Database) => {
return new Set(rows.map((row) => row.name));
};
-type ThreadSpawnEdge = {
- child_thread_id: string;
- parent_thread_id: string;
-};
+type ThreadSpawnEdge = SpawnEdgeRow;
const readThreadHierarchyEdges = (db: Database, threadIds: string[]): ThreadSpawnEdge[] => {
if (!getExistingTableNames(db).has('thread_spawn_edges')) {
@@ -1370,34 +1634,42 @@ const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): Del
const uniqueThreadIds = uniqueValues(threadIds);
if (uniqueThreadIds.length === 0) {
return {
+ cleanup: {
+ deletedSessionFiles: [],
+ requested: false,
+ sessionIndexEntriesRemoved: [],
+ },
deletedSessionFiles: [],
deletedThreadIds: [],
};
}
- const existingTableNames = getExistingTableNames(db);
- const threadTargets = getThreadDeleteTargets(db, uniqueThreadIds);
- const existingIds = threadTargets.map((target) => target.id);
-
- const existingIdSet = new Set(existingIds);
+ let threadTargets: Array<{ id: string; rollout_path: string }> = [];
+ let existingIds: string[] = [];
let historyAttached = false;
try {
- const historyDbPath = resolveCodexHistoryDbPath(dbPath);
- if (hasRegularFile(historyDbPath)) {
- db.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
- historyAttached = true;
- }
+ withSqliteTransaction(db, (transactionDb) => {
+ const existingTableNames = getExistingTableNames(transactionDb);
+ threadTargets = getThreadDeleteTargets(transactionDb, uniqueThreadIds);
+ existingIds = threadTargets.map((target) => target.id);
+ const existingIdSet = new Set(existingIds);
+ const historyDbPath = resolveCodexHistoryDbPath(dbPath);
+ if (hasRegularFile(historyDbPath)) {
+ // SQLite coordinates this transaction across the attached database for normal commits. A process
+ // crash during WAL commit is not claimed to be crash-atomic across both database files.
+ transactionDb.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
+ historyAttached = true;
+ }
- const historyTableNames = historyAttached ? getExistingCodexHistoryTableNames(db) : new Set();
- const deleteMany = db.transaction((ids: string[]) => {
- for (const threadIdChunk of chunkValues(ids, SQLITE_DELETE_BATCH_SIZE)) {
+ const historyTableNames = historyAttached
+ ? getExistingCodexHistoryTableNames(transactionDb)
+ : new Set();
+ for (const threadIdChunk of chunkValues(uniqueThreadIds, SQLITE_DELETE_BATCH_SIZE)) {
const stateThreadIds = threadIdChunk.filter((threadId) => existingIdSet.has(threadId));
- deleteStateThreadRows(db, existingTableNames, stateThreadIds);
- deleteHistoryThreadRows(db, historyTableNames, threadIdChunk);
+ deleteStateThreadRows(transactionDb, existingTableNames, stateThreadIds);
+ deleteHistoryThreadRows(transactionDb, historyTableNames, threadIdChunk);
}
});
-
- deleteMany(uniqueThreadIds);
} finally {
if (historyAttached) {
db.query('DETACH DATABASE codex_history').run();
@@ -1405,6 +1677,11 @@ const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): Del
}
return {
+ cleanup: {
+ deletedSessionFiles: [],
+ requested: false,
+ sessionIndexEntriesRemoved: [],
+ },
deletedSessionFiles: threadTargets.map((target) => target.rollout_path),
deletedThreadIds: existingIds,
};
@@ -1727,58 +2004,223 @@ export const listProjectThreads = async (
return entries.sort((left, right) => compareThreadsByRecentActivity(left.thread, right.thread));
};
-export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBrowseData => {
- return withReadonlyDb(dbPath, (db) => {
- const existingTableNames = getExistingTableNames(db);
- const dbThread = db.query('SELECT * FROM threads WHERE id = ? LIMIT 1').get(threadId) as ThreadRow | null;
- const thread = dbThread ?? readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true }) ?? null;
- if (!thread) {
- throw new CodexThreadNotFoundError(threadId);
- }
- const indexedThread = applySessionIndexThreadNames(dbPath, [thread])[0]!;
- const normalizedThread = normalizeThreadDisplayText({
- ...indexedThread,
- rollout_path: resolveCodexRolloutPath(dbPath, indexedThread.rollout_path),
- });
+type ThreadBrowseDatabaseData = {
+ dynamicToolsByThreadId: Map;
+ goalsByThreadId: Map;
+ relationsByThreadId: Map;
+ threadsById: Map;
+};
- const dynamicTools =
- dbThread && existingTableNames.has('thread_dynamic_tools')
- ? (
- db
- .query(
- 'SELECT thread_id, position, name, description, input_schema, defer_loading, namespace FROM thread_dynamic_tools WHERE thread_id = ? ORDER BY position ASC',
- )
- .all(threadId) as Array>
- ).map((row) => parseDynamicToolRow(row))
- : parseFallbackDynamicTools(readFallbackSessionMeta(thread.rollout_path) ?? {}, threadId);
- const goals =
- dbThread && existingTableNames.has('thread_goals')
- ? (db
- .query(
- 'SELECT goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms FROM thread_goals WHERE thread_id = ? ORDER BY updated_at_ms DESC, goal_id ASC',
- )
- .all(threadId) as ThreadGoalRow[])
- : [];
+const readBrowseThreadRows = (db: Database, threadIdChunk: string[], threadsById: Map) => {
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
+ const rows = db
+ .query(`SELECT ${THREAD_ROW_COLUMNS} FROM threads WHERE id IN (${placeholders})`)
+ .all(...threadIdChunk) as unknown[];
+ for (const row of rows) {
+ const thread = decodeThreadRow(row);
+ threadsById.set(thread.id, thread);
+ }
+};
+
+const readBrowseDynamicTools = (
+ db: Database,
+ threadIdChunk: string[],
+ dynamicToolsByThreadId: Map,
+) => {
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
+ const rows = db
+ .query(
+ `SELECT thread_id, position, name, description, input_schema, defer_loading, namespace
+ FROM thread_dynamic_tools
+ WHERE thread_id IN (${placeholders})
+ ORDER BY thread_id ASC, position ASC`,
+ )
+ .all(...threadIdChunk) as unknown[];
+ for (const row of rows) {
+ const tool = parseDynamicToolRow(row);
+ const tools = dynamicToolsByThreadId.get(tool.threadId) ?? [];
+ tools.push(tool);
+ dynamicToolsByThreadId.set(tool.threadId, tools);
+ }
+};
+
+const readBrowseGoals = (db: Database, threadIdChunk: string[], goalsByThreadId: Map) => {
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
+ const rows = db
+ .query(
+ `SELECT thread_id, goal_id, objective, status, token_budget, tokens_used,
+ time_used_seconds, created_at_ms, updated_at_ms
+ FROM thread_goals
+ WHERE thread_id IN (${placeholders})
+ ORDER BY thread_id ASC, updated_at_ms DESC, goal_id ASC`,
+ )
+ .all(...threadIdChunk) as unknown[];
+ for (const row of rows) {
+ const values = row as Record;
+ if (typeof values.thread_id !== 'string') {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], ['thread_goals.thread_id']);
+ }
+ const goals = goalsByThreadId.get(values.thread_id) ?? [];
+ goals.push(decodeThreadGoalRow(row));
+ goalsByThreadId.set(values.thread_id, goals);
+ }
+};
+
+const readBrowseRelations = (
+ db: Database,
+ threadIdChunk: string[],
+ relationsByThreadId: Map,
+) => {
+ const placeholders = threadIdChunk.map(() => '?').join(', ');
+ const rows = db
+ .query(
+ `SELECT parent_thread_id, child_thread_id, status
+ FROM thread_spawn_edges
+ WHERE parent_thread_id IN (${placeholders}) OR child_thread_id IN (${placeholders})
+ ORDER BY parent_thread_id ASC, child_thread_id ASC`,
+ )
+ .all(...threadIdChunk, ...threadIdChunk) as unknown[];
+ for (const row of rows) {
+ const edge = decodeThreadSpawnEdgeRow(row);
+ const parentRelations = relationsByThreadId.get(edge.parent_thread_id);
+ if (parentRelations) {
+ parentRelations.childEdges.push(edge);
+ }
+ const childRelations = relationsByThreadId.get(edge.child_thread_id);
+ if (childRelations) {
+ childRelations.parentThreadId = edge.parent_thread_id;
+ }
+ }
+};
+
+const readThreadBrowseDatabaseData = (dbPath: string, requestedThreadIds: string[]): ThreadBrowseDatabaseData => {
+ const threadIds = uniqueValues(requestedThreadIds);
+ return withReadonlyDb(dbPath, (db) =>
+ withSqliteTransaction(db, (snapshotDb) => {
+ const existingTableNames = assertCodexSchemaCompatibility(snapshotDb, CODEX_BROWSE_SCHEMA_PROFILE);
+ const threadsById = new Map();
+ const dynamicToolsByThreadId = new Map();
+ const goalsByThreadId = new Map();
+ const relationsByThreadId = new Map(
+ threadIds.map((threadId) => [threadId, { childEdges: [], parentThreadId: null }]),
+ );
+
+ for (const threadIdChunk of chunkValues(threadIds, SQLITE_DELETE_BATCH_SIZE)) {
+ readBrowseThreadRows(snapshotDb, threadIdChunk, threadsById);
+ if (existingTableNames.has('thread_dynamic_tools')) {
+ readBrowseDynamicTools(snapshotDb, threadIdChunk, dynamicToolsByThreadId);
+ }
+
+ if (existingTableNames.has('thread_goals')) {
+ readBrowseGoals(snapshotDb, threadIdChunk, goalsByThreadId);
+ }
+
+ if (existingTableNames.has('thread_spawn_edges')) {
+ readBrowseRelations(snapshotDb, threadIdChunk, relationsByThreadId);
+ }
+ }
+
+ return { dynamicToolsByThreadId, goalsByThreadId, relationsByThreadId, threadsById };
+ }),
+ );
+};
+
+const buildThreadBrowseData = (
+ dbPath: string,
+ thread: ThreadRow,
+ source: 'database' | 'fallback',
+ databaseData: ThreadBrowseDatabaseData | null,
+): ThreadBrowseData => {
+ // Session-index titles and fallback transcript metadata are filesystem reads and intentionally happen after
+ // the SQLite snapshot has committed.
+ const indexedThread = applySessionIndexThreadNames(dbPath, [thread])[0]!;
+ const normalizedThread = normalizeThreadDisplayText({
+ ...indexedThread,
+ rollout_path: resolveCodexRolloutPath(dbPath, indexedThread.rollout_path),
+ });
+ const dynamicTools =
+ source === 'database'
+ ? (databaseData?.dynamicToolsByThreadId.get(thread.id) ?? [])
+ : parseFallbackDynamicTools(readFallbackSessionMeta(normalizedThread.rollout_path) ?? {}, thread.id);
+ const goals = source === 'database' ? (databaseData?.goalsByThreadId.get(thread.id) ?? []) : [];
+ const relations =
+ source === 'database'
+ ? (databaseData?.relationsByThreadId.get(thread.id) ?? { childEdges: [], parentThreadId: null })
+ : { childEdges: [], parentThreadId: null };
+
+ return {
+ dynamicTools,
+ goals: goals.map((goal) => ({
+ createdAtMs: goal.created_at_ms,
+ goalId: goal.goal_id,
+ objective: goal.objective,
+ status: goal.status,
+ timeUsedSeconds: goal.time_used_seconds,
+ tokenBudget: goal.token_budget,
+ tokensUsed: goal.tokens_used,
+ updatedAtMs: goal.updated_at_ms,
+ })),
+ project: getPortablePathBasename(normalizedThread.cwd),
+ relations,
+ thread: normalizedThread,
+ };
+};
+
+export const getThreadBrowseDataBatch = (dbPath: string, threadIds: string[]): CodexThreadBrowseBatchResult[] => {
+ if (threadIds.length === 0) {
+ return [];
+ }
+
+ const databaseData = readThreadBrowseDatabaseData(dbPath, threadIds);
+ const fallbackThreadsById = new Map();
+ for (const threadId of uniqueValues(threadIds)) {
+ if (databaseData.threadsById.has(threadId)) {
+ continue;
+ }
+ const fallbackThread = readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true });
+ if (fallbackThread) {
+ fallbackThreadsById.set(threadId, fallbackThread);
+ }
+ }
+
+ return threadIds.map((threadId) => {
+ const databaseThread = databaseData.threadsById.get(threadId);
+ if (databaseThread) {
+ return {
+ data: buildThreadBrowseData(dbPath, databaseThread, 'database', databaseData),
+ source: 'database',
+ status: 'found',
+ threadId,
+ };
+ }
+
+ const fallbackThread = fallbackThreadsById.get(threadId);
+ if (fallbackThread) {
+ return {
+ data: buildThreadBrowseData(dbPath, fallbackThread, 'fallback', null),
+ source: 'fallback',
+ status: 'found',
+ threadId,
+ };
+ }
return {
- dynamicTools,
- goals: goals.map((goal) => ({
- createdAtMs: goal.created_at_ms,
- goalId: goal.goal_id,
- objective: goal.objective,
- status: goal.status,
- timeUsedSeconds: goal.time_used_seconds,
- tokenBudget: goal.token_budget,
- tokensUsed: goal.tokens_used,
- updatedAtMs: goal.updated_at_ms,
- })),
- project: getPortablePathBasename(normalizedThread.cwd),
- relations: getRelationsForThread(db, threadId, existingTableNames),
- thread: normalizedThread,
+ data: null,
+ source: 'missing',
+ status: 'missing',
+ threadId,
};
});
};
+export const getThreadBrowseData = (dbPath: string, threadId: string): ThreadBrowseData => {
+ const result = getThreadBrowseDataBatch(dbPath, [threadId])[0]!;
+ if (result.status === 'missing') {
+ throw new CodexThreadNotFoundError(threadId);
+ }
+ return result.data;
+};
+
type DashboardDatabaseTotals = {
archived_threads: number;
total_threads: number;
@@ -1899,6 +2341,11 @@ export const deleteCodexThread = async (
);
return {
+ cleanup: {
+ deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
+ requested: Boolean(options.deleteSessionFiles),
+ sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
+ },
deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
};
@@ -1929,6 +2376,11 @@ export const deleteCodexThreads = async (
);
return {
+ cleanup: {
+ deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
+ requested: Boolean(options.deleteSessionFiles),
+ sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
+ },
deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
};
@@ -1978,6 +2430,11 @@ export const deleteCodexProject = async (
return {
...result,
+ cleanup: {
+ deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
+ requested: Boolean(options.deleteSessionFiles),
+ sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
+ },
deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
};
diff --git a/src/lib/codex-browser-export.test.ts b/src/lib/codex-browser-export.test.ts
index 1a1f95c..83b170e 100644
--- a/src/lib/codex-browser-export.test.ts
+++ b/src/lib/codex-browser-export.test.ts
@@ -381,8 +381,85 @@ describe('renderCodexThreadDownload', () => {
const zipPath = path.join(tempRoot, path.basename(download.downloadUrl));
const entries = await listZipEntries(zipPath);
- expect(entries).toHaveLength(2);
- expect(new Set(entries).size).toBe(2);
+ expect(entries).toHaveLength(3);
+ expect(entries).toContain('spiracha-manifest.json');
+ expect(new Set(entries).size).toBe(3);
+ });
+
+ it('should keep exportable threads and record skipped threads in a batch manifest', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-export-batch-partial-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const exportedThreadId = fixture.threads[0]!.threadId;
+ const missingThreadId = '019ec3d5-859d-77d0-b851-256ae567ff99';
+
+ const download = await renderCodexThreadsDownload({
+ dbPath: fixture.dbPath,
+ includeCommentary: true,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ publicExportDir: tempRoot,
+ threadIds: [exportedThreadId, missingThreadId],
+ });
+
+ expect(download.mode).toBe('download_url');
+ if (download.mode !== 'download_url') {
+ throw new Error('expected zipped batch download url mode');
+ }
+
+ const zipPath = path.join(tempRoot, path.basename(download.downloadUrl));
+ const manifest = JSON.parse(await readZipEntry(zipPath, 'spiracha-manifest.json')) as {
+ exportedCount: number;
+ entries: Array<{
+ code?: string;
+ fileName?: string;
+ message?: string;
+ status: string;
+ threadId: string;
+ }>;
+ requestedThreadIds: string[];
+ schemaVersion: number;
+ skippedCount: number;
+ };
+ expect(manifest).toMatchObject({
+ exportedCount: 1,
+ requestedThreadIds: [exportedThreadId, missingThreadId],
+ schemaVersion: 1,
+ skippedCount: 1,
+ });
+ expect(manifest.entries).toEqual([
+ {
+ fileName: 'spiracha-2026-05-17-1712-019e36d7.md',
+ status: 'exported',
+ threadId: exportedThreadId,
+ },
+ {
+ code: 'CODEX_THREAD_NOT_FOUND',
+ message: `Thread ${missingThreadId} was not found.`,
+ status: 'missing',
+ threadId: missingThreadId,
+ },
+ ]);
+ });
+
+ it('should fail a batch export when no selected thread can be exported', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-export-batch-empty-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const missingThreadId = '019ec3d5-859d-77d0-b851-256ae567ff99';
+
+ await expect(
+ renderCodexThreadsDownload({
+ dbPath: fixture.dbPath,
+ includeCommentary: true,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ publicExportDir: tempRoot,
+ threadIds: [missingThreadId],
+ }),
+ ).rejects.toThrow('No exportable threads');
});
it('should return a unique zip url for repeated multi-thread exports of the same selection', async () => {
diff --git a/src/lib/codex-browser-export.ts b/src/lib/codex-browser-export.ts
index 8cb98df..2f4418c 100644
--- a/src/lib/codex-browser-export.ts
+++ b/src/lib/codex-browser-export.ts
@@ -1,7 +1,20 @@
import { randomUUID } from 'node:crypto';
-import { mkdir, mkdtemp, rm, stat } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import os from 'node:os';
import path from 'node:path';
-import { getThreadBrowseData } from './codex-browser-db';
+import {
+ CodexDbCompatibilityError,
+ CodexThreadNotFoundError,
+ getThreadBrowseData,
+ getThreadBrowseDataBatch,
+} from './codex-browser-db';
+import type { ThreadBrowseData } from './codex-browser-types';
+import {
+ CodexRolloutMutationError,
+ type CodexRolloutSnapshot,
+ CodexRolloutSourceError,
+ copyStableCodexRollout,
+} from './codex-rollout-snapshot';
import type { CodexTranscriptRenderOptions } from './codex-thread-types';
import { renderCodexSessionFile, writeCodexSessionFileExport } from './codex-transcript-renderer';
import { applyPathTransforms, type PathDisplaySettings } from './path-transforms';
@@ -44,9 +57,36 @@ export type CodexThreadDownload =
fileName: string;
mimeType: string;
mode: 'download_url';
+ skippedThreadCount?: number;
};
const LARGE_BROWSER_EXPORT_THRESHOLD_BYTES = 128 * 1024 * 1024;
+const MAX_ROLLOUT_EXPORT_ATTEMPTS = 2;
+const BATCH_MANIFEST_FILE_NAME = 'spiracha-manifest.json';
+const BATCH_MANIFEST_SCHEMA_VERSION = 1;
+const ARCHIVE_WIDE_FILE_ERROR_CODES = new Set(['EACCES', 'EIO', 'ENOSPC', 'ENOTDIR', 'EPERM', 'EROFS']);
+
+type BatchExportManifestEntry =
+ | {
+ fileName: string;
+ status: 'exported';
+ threadId: string;
+ }
+ | {
+ code: string;
+ message: string;
+ status: 'missing' | 'unreadable' | 'unstable';
+ threadId: string;
+ };
+
+type BatchExportManifest = {
+ entries: BatchExportManifestEntry[];
+ exportedCount: number;
+ generatedAt: string;
+ requestedThreadIds: string[];
+ schemaVersion: number;
+ skippedCount: number;
+};
const buildExportBaseName = (thread: ReturnType['thread']) => {
return buildConversationExportBaseName(
@@ -76,11 +116,6 @@ const buildUniqueBatchEntryBaseName = (baseName: string, threadId: string, usedB
return collisionSafeBaseName;
};
-type RolloutSnapshot = {
- mtimeMs: number;
- sizeBytes: number;
-};
-
const toDownloadOptions = (input: RenderCodexThreadDownloadInput): CodexTranscriptRenderOptions => {
return {
includeCommentary: input.includeCommentary,
@@ -107,171 +142,227 @@ const createExportWorkspace = async (exportDir: string, exportBaseName: string)
return mkdtemp(path.join(exportDir, `${exportBaseName}-`));
};
-const getRolloutSnapshot = async (rolloutPath: string): Promise => {
- const metadata = await stat(rolloutPath);
- return {
- mtimeMs: metadata.mtimeMs,
- sizeBytes: metadata.size,
- };
-};
-
-const isMissingFileError = (error: unknown) => {
- return error instanceof Error && 'code' in error && error.code === 'ENOENT';
+const logExportEvent = (level: 'error' | 'info' | 'warn', event: string, details: Record) => {
+ console[level](`[spiracha:export] ${event}`, details);
};
-const getExistingRolloutSnapshot = async (threadId: string, rolloutPath: string): Promise => {
+const cleanupExportWorkspace = async (workspacePath: string) => {
try {
- return await getRolloutSnapshot(rolloutPath);
+ await rm(workspacePath, { force: true, recursive: true });
} catch (error) {
- if (isMissingFileError(error)) {
- throw new Error(`Thread ${threadId} rollout file is missing: ${rolloutPath}`, { cause: error });
- }
-
- throw error;
+ logExportEvent('warn', 'workspace_cleanup_failed', {
+ error: error instanceof Error ? error.message : String(error),
+ workspacePath,
+ });
}
};
-const logExportEvent = (level: 'error' | 'info' | 'warn', event: string, details: Record) => {
- console[level](`[spiracha:export] ${event}`, details);
+type StableRolloutContext = {
+ browseData: ThreadBrowseData;
+ rollout: CodexRolloutSnapshot;
+ snapshotPath: string;
+};
+
+const withStableRolloutSnapshot = async ({
+ dbPath,
+ initialBrowseData,
+ render,
+ threadId,
+}: {
+ dbPath: string;
+ initialBrowseData?: ThreadBrowseData;
+ render: (context: StableRolloutContext) => Promise;
+ threadId: string;
+}): Promise => {
+ for (let attempt = 1; attempt <= MAX_ROLLOUT_EXPORT_ATTEMPTS; attempt += 1) {
+ const browseData =
+ attempt === 1 && initialBrowseData ? initialBrowseData : getThreadBrowseData(dbPath, threadId);
+ const attemptWorkspace = await mkdtemp(path.join(os.tmpdir(), 'spiracha-codex-rollout-attempt-'));
+ const snapshotPath = path.join(attemptWorkspace, 'rollout.jsonl');
+
+ try {
+ const rollout = await copyStableCodexRollout({
+ attempt,
+ snapshotPath,
+ sourcePath: browseData.thread.rollout_path,
+ threadId,
+ });
+
+ return await render({ browseData, rollout, snapshotPath });
+ } catch (error) {
+ if (error instanceof CodexRolloutMutationError && attempt < MAX_ROLLOUT_EXPORT_ATTEMPTS) {
+ logExportEvent('warn', 'rollout_retry', {
+ attempt,
+ nextAttempt: attempt + 1,
+ threadId,
+ });
+ continue;
+ }
+
+ throw error;
+ } finally {
+ await cleanupExportWorkspace(attemptWorkspace);
+ }
+ }
+
+ throw new Error(`Unable to create a stable rollout snapshot for thread ${threadId}`);
};
-const logRolloutChangeIfDetected = (
- threadId: string,
- beforeSnapshot: RolloutSnapshot,
- afterSnapshot: RolloutSnapshot,
-) => {
- if (beforeSnapshot.mtimeMs === afterSnapshot.mtimeMs && beforeSnapshot.sizeBytes === afterSnapshot.sizeBytes) {
- return;
+const getBatchFailure = (threadId: string, error: unknown): BatchExportManifestEntry => {
+ if (error instanceof CodexThreadNotFoundError || error instanceof CodexRolloutSourceError) {
+ return {
+ code: error instanceof CodexRolloutSourceError ? error.code : 'CODEX_THREAD_NOT_FOUND',
+ message: error.message,
+ status:
+ error instanceof CodexRolloutSourceError && error.code === 'CODEX_ROLLOUT_UNREADABLE'
+ ? 'unreadable'
+ : 'missing',
+ threadId,
+ };
+ }
+
+ if (error instanceof CodexRolloutMutationError) {
+ return {
+ code: error.code,
+ message: error.message,
+ status: 'unstable',
+ threadId,
+ };
}
- logExportEvent('warn', 'rollout_changed_during_export', {
- afterMtimeMs: afterSnapshot.mtimeMs,
- afterSizeBytes: afterSnapshot.sizeBytes,
- beforeMtimeMs: beforeSnapshot.mtimeMs,
- beforeSizeBytes: beforeSnapshot.sizeBytes,
+ return {
+ code: 'CODEX_EXPORT_UNREADABLE',
+ message: error instanceof Error ? error.message : String(error),
+ status: 'unreadable',
threadId,
- });
+ };
};
-const cleanupExportWorkspace = async (workspacePath: string) => {
- try {
- await rm(workspacePath, { force: true, recursive: true });
- } catch (error) {
- logExportEvent('warn', 'workspace_cleanup_failed', {
- error: error instanceof Error ? error.message : String(error),
- workspacePath,
- });
+const writeBatchManifest = async (bundleDirectory: string, manifest: BatchExportManifest) => {
+ await Bun.write(path.join(bundleDirectory, BATCH_MANIFEST_FILE_NAME), `${JSON.stringify(manifest, null, 2)}\n`);
+};
+
+const isArchiveWideFailure = (error: unknown) => {
+ if (error instanceof CodexDbCompatibilityError) {
+ return true;
+ }
+
+ if (typeof error !== 'object' || error === null || !('code' in error)) {
+ return false;
}
+
+ const code = (error as { code?: unknown }).code;
+ return typeof code === 'string' && ARCHIVE_WIDE_FILE_ERROR_CODES.has(code);
};
export const renderCodexThreadDownload = async (
input: RenderCodexThreadDownloadInput,
): Promise => {
const startedAt = Date.now();
- const browseData = getThreadBrowseData(input.dbPath, input.threadId);
- const extension = input.outputFormat === 'md' ? 'md' : 'txt';
- const fileBaseName = buildExportBaseName(browseData.thread);
- const fileName = `${fileBaseName}.${extension}`;
- const mimeType = getExportMimeType(input.outputFormat);
- const transform = (text: string) =>
- input.pathDisplaySettings
- ? applyPathTransforms(text, {
- ...input.pathDisplaySettings,
- projectPath: browseData.thread.cwd,
- })
- : text;
- const rolloutSnapshotBefore = await getExistingRolloutSnapshot(input.threadId, browseData.thread.rollout_path);
-
- logExportEvent('info', 'single_start', {
- fileName,
- sizeBytes: rolloutSnapshotBefore.sizeBytes,
- threadId: input.threadId,
- });
-
+ let fileName = input.threadId;
try {
- if (
- input.zipArchive ||
- rolloutSnapshotBefore.sizeBytes > (input.largeExportThresholdBytes ?? LARGE_BROWSER_EXPORT_THRESHOLD_BYTES)
- ) {
- const exportBaseName = buildArchiveBaseName(fileBaseName);
- const exportDir = await resolvePublicExportDir(input.publicExportDir);
- const workspaceDir = await createExportWorkspace(exportDir, exportBaseName);
- const savedPath = path.join(workspaceDir, fileName);
- const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
- try {
- const saved = await writeCodexSessionFileExport(
+ return await withStableRolloutSnapshot({
+ dbPath: input.dbPath,
+ render: async ({ browseData, rollout, snapshotPath }) => {
+ const extension = input.outputFormat === 'md' ? 'md' : 'txt';
+ const fileBaseName = buildExportBaseName(browseData.thread);
+ fileName = `${fileBaseName}.${extension}`;
+ const mimeType = getExportMimeType(input.outputFormat);
+ const transform = (text: string) =>
+ input.pathDisplaySettings
+ ? applyPathTransforms(text, {
+ ...input.pathDisplaySettings,
+ projectPath: browseData.thread.cwd,
+ })
+ : text;
+
+ logExportEvent('info', 'single_start', {
+ fileName,
+ sizeBytes: rollout.before.sizeBytes,
+ threadId: input.threadId,
+ });
+
+ if (
+ input.zipArchive ||
+ rollout.before.sizeBytes > (input.largeExportThresholdBytes ?? LARGE_BROWSER_EXPORT_THRESHOLD_BYTES)
+ ) {
+ const exportBaseName = buildArchiveBaseName(fileBaseName);
+ const exportDir = await resolvePublicExportDir(input.publicExportDir);
+ const workspaceDir = await createExportWorkspace(exportDir, exportBaseName);
+ const savedPath = path.join(workspaceDir, fileName);
+ const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
+ try {
+ const saved = await writeCodexSessionFileExport(
+ {
+ fallbackReason: null,
+ outputRelativePath: fileName,
+ relations: browseData.relations,
+ sessionFile: snapshotPath,
+ thread: browseData.thread,
+ },
+ toDownloadOptions(input),
+ savedPath,
+ transform,
+ );
+
+ if (!saved) {
+ throw new Error(`Thread ${input.threadId} produced no exportable content`);
+ }
+
+ await zipExportFile(savedPath, zipPath);
+ } finally {
+ await cleanupExportWorkspace(workspaceDir);
+ }
+
+ const zipStat = await Bun.file(zipPath).stat();
+ logExportEvent('info', 'single_zip_ready', {
+ downloadUrl: buildUiExportDownloadUrl(zipPath),
+ durationMs: Date.now() - startedAt,
+ fileName: `${exportBaseName}.zip`,
+ sizeBytes: zipStat.size,
+ threadId: input.threadId,
+ zipPath,
+ });
+
+ return {
+ downloadUrl: buildUiExportDownloadUrl(zipPath),
+ fileName: `${exportBaseName}.zip`,
+ mimeType: 'application/zip',
+ mode: 'download_url' as const,
+ };
+ }
+
+ const content = await renderCodexSessionFile(
{
fallbackReason: null,
outputRelativePath: fileName,
relations: browseData.relations,
- sessionFile: browseData.thread.rollout_path,
+ sessionFile: snapshotPath,
thread: browseData.thread,
},
toDownloadOptions(input),
- savedPath,
- transform,
);
- if (!saved) {
+ if (!content) {
throw new Error(`Thread ${input.threadId} produced no exportable content`);
}
- await zipExportFile(savedPath, zipPath);
- } finally {
- await cleanupExportWorkspace(workspaceDir);
- }
-
- const rolloutSnapshotAfter = await getRolloutSnapshot(browseData.thread.rollout_path);
- logRolloutChangeIfDetected(input.threadId, rolloutSnapshotBefore, rolloutSnapshotAfter);
-
- const zipStat = await Bun.file(zipPath).stat();
- logExportEvent('info', 'single_zip_ready', {
- downloadUrl: buildUiExportDownloadUrl(zipPath),
- durationMs: Date.now() - startedAt,
- fileName: `${exportBaseName}.zip`,
- sizeBytes: zipStat.size,
- threadId: input.threadId,
- zipPath,
- });
-
- return {
- downloadUrl: buildUiExportDownloadUrl(zipPath),
- fileName: `${exportBaseName}.zip`,
- mimeType: 'application/zip',
- mode: 'download_url',
- };
- }
+ logExportEvent('info', 'single_inline_ready', {
+ durationMs: Date.now() - startedAt,
+ fileName,
+ sizeBytes: content.length,
+ threadId: input.threadId,
+ });
- const content = await renderCodexSessionFile(
- {
- fallbackReason: null,
- outputRelativePath: fileName,
- relations: browseData.relations,
- sessionFile: browseData.thread.rollout_path,
- thread: browseData.thread,
+ return {
+ content: transform(content),
+ fileName,
+ mimeType,
+ mode: 'download' as const,
+ };
},
- toDownloadOptions(input),
- );
-
- if (!content) {
- throw new Error(`Thread ${input.threadId} produced no exportable content`);
- }
-
- const rolloutSnapshotAfter = await getRolloutSnapshot(browseData.thread.rollout_path);
- logRolloutChangeIfDetected(input.threadId, rolloutSnapshotBefore, rolloutSnapshotAfter);
- logExportEvent('info', 'single_inline_ready', {
- durationMs: Date.now() - startedAt,
- fileName,
- sizeBytes: content.length,
threadId: input.threadId,
});
-
- return {
- content: transform(content),
- fileName,
- mimeType,
- mode: 'download',
- };
} catch (error) {
logExportEvent('error', 'single_error', {
error: error instanceof Error ? error.message : String(error),
@@ -282,6 +373,94 @@ export const renderCodexThreadDownload = async (
}
};
+const renderCodexBatchEntry = async (
+ input: RenderCodexThreadsDownloadInput,
+ result: ReturnType[number],
+ bundleDirectory: string,
+ usedBatchEntryBaseNames: Set,
+): Promise => {
+ if (result.status !== 'found' || !result.data) {
+ return {
+ code: 'CODEX_THREAD_NOT_FOUND',
+ message: `Thread ${result.threadId} was not found.`,
+ status: 'missing',
+ threadId: result.threadId,
+ };
+ }
+
+ try {
+ const relativeFileName = await withStableRolloutSnapshot({
+ dbPath: input.dbPath,
+ initialBrowseData: result.data,
+ render: async ({ browseData, snapshotPath }) => {
+ const singleBaseName = buildExportBaseName(browseData.thread);
+ const uniqueBaseName = buildUniqueBatchEntryBaseName(
+ singleBaseName,
+ browseData.thread.id,
+ usedBatchEntryBaseNames,
+ );
+ const extension = input.outputFormat === 'md' ? 'md' : 'txt';
+ const resolvedFileName = `${uniqueBaseName}.${extension}`;
+ const savedPath = path.join(bundleDirectory, resolvedFileName);
+ const transform = (text: string) =>
+ input.pathDisplaySettings
+ ? applyPathTransforms(text, {
+ ...input.pathDisplaySettings,
+ projectPath: browseData.thread.cwd,
+ })
+ : text;
+
+ if (uniqueBaseName !== singleBaseName) {
+ logExportEvent('warn', 'batch_entry_name_collision', {
+ resolvedFileName,
+ singleBaseName,
+ threadId: browseData.thread.id,
+ });
+ }
+
+ const saved = await writeCodexSessionFileExport(
+ {
+ fallbackReason: null,
+ outputRelativePath: resolvedFileName,
+ relations: browseData.relations,
+ sessionFile: snapshotPath,
+ thread: browseData.thread,
+ },
+ {
+ ...toDownloadOptions({
+ ...input,
+ threadId: browseData.thread.id,
+ }),
+ },
+ savedPath,
+ transform,
+ );
+
+ if (!saved) {
+ throw new Error(`Thread ${browseData.thread.id} produced no exportable content`);
+ }
+
+ return resolvedFileName;
+ },
+ threadId: result.threadId,
+ });
+
+ return { fileName: relativeFileName, status: 'exported', threadId: result.threadId };
+ } catch (error) {
+ if (isArchiveWideFailure(error)) {
+ throw error;
+ }
+
+ const failure = getBatchFailure(result.threadId, error);
+ logExportEvent('warn', 'batch_entry_skipped', {
+ error: error instanceof Error ? error.message : String(error),
+ status: failure.status,
+ threadId: result.threadId,
+ });
+ return failure;
+ }
+};
+
export const renderCodexThreadsDownload = async (
input: RenderCodexThreadsDownloadInput,
): Promise => {
@@ -291,8 +470,13 @@ export const renderCodexThreadsDownload = async (
throw new Error('No threads selected for export');
}
- const browseEntries = threadIds.map((threadId) => getThreadBrowseData(input.dbPath, threadId));
- const threads = browseEntries.map((entry) => entry.thread);
+ const browseResults = getThreadBrowseDataBatch(input.dbPath, threadIds);
+ const browseEntries = browseResults.flatMap((result) => (result.status === 'found' && result.data ? [result] : []));
+ if (browseEntries.length === 0) {
+ throw new Error('No exportable threads');
+ }
+
+ const threads = browseEntries.map((result) => result.data.thread);
const exportDir = await resolvePublicExportDir(input.publicExportDir);
const exportBaseName = buildArchiveBaseName(
buildBatchExportBaseName(
@@ -306,6 +490,7 @@ export const renderCodexThreadsDownload = async (
const bundleDirectory = await createExportWorkspace(exportDir, exportBaseName);
const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
const usedBatchEntryBaseNames = new Set();
+ const manifestEntries: BatchExportManifestEntry[] = [];
logExportEvent('info', 'batch_start', {
exportBaseName,
@@ -315,59 +500,23 @@ export const renderCodexThreadsDownload = async (
});
try {
- for (const entry of browseEntries) {
- const rolloutSnapshotBefore = await getExistingRolloutSnapshot(entry.thread.id, entry.thread.rollout_path);
- const singleBaseName = buildExportBaseName(entry.thread);
- const uniqueBaseName = buildUniqueBatchEntryBaseName(
- singleBaseName,
- entry.thread.id,
- usedBatchEntryBaseNames,
- );
- const extension = input.outputFormat === 'md' ? 'md' : 'txt';
- const relativeFileName = `${uniqueBaseName}.${extension}`;
- const savedPath = path.join(bundleDirectory, relativeFileName);
- const transform = (text: string) =>
- input.pathDisplaySettings
- ? applyPathTransforms(text, {
- ...input.pathDisplaySettings,
- projectPath: entry.thread.cwd,
- })
- : text;
-
- if (uniqueBaseName !== singleBaseName) {
- logExportEvent('warn', 'batch_entry_name_collision', {
- resolvedFileName: relativeFileName,
- singleBaseName,
- threadId: entry.thread.id,
- });
- }
-
- const saved = await writeCodexSessionFileExport(
- {
- fallbackReason: null,
- outputRelativePath: relativeFileName,
- relations: entry.relations,
- sessionFile: entry.thread.rollout_path,
- thread: entry.thread,
- },
- {
- ...toDownloadOptions({
- ...input,
- threadId: entry.thread.id,
- }),
- },
- savedPath,
- transform,
- );
-
- if (!saved) {
- throw new Error(`Thread ${entry.thread.id} produced no exportable content`);
- }
+ for (const result of browseResults) {
+ manifestEntries.push(await renderCodexBatchEntry(input, result, bundleDirectory, usedBatchEntryBaseNames));
+ }
- const rolloutSnapshotAfter = await getRolloutSnapshot(entry.thread.rollout_path);
- logRolloutChangeIfDetected(entry.thread.id, rolloutSnapshotBefore, rolloutSnapshotAfter);
+ const exportedCount = manifestEntries.filter((entry) => entry.status === 'exported').length;
+ if (exportedCount === 0) {
+ throw new Error('No exportable threads');
}
+ await writeBatchManifest(bundleDirectory, {
+ entries: manifestEntries,
+ exportedCount,
+ generatedAt: new Date().toISOString(),
+ requestedThreadIds: threadIds,
+ schemaVersion: BATCH_MANIFEST_SCHEMA_VERSION,
+ skippedCount: manifestEntries.length - exportedCount,
+ });
await zipExportDirectory(bundleDirectory, zipPath);
} catch (error) {
logExportEvent('error', 'batch_error', {
@@ -398,5 +547,6 @@ export const renderCodexThreadsDownload = async (
fileName: `${exportBaseName}.zip`,
mimeType: 'application/zip',
mode: 'download_url',
+ skippedThreadCount: manifestEntries.filter((entry) => entry.status !== 'exported').length,
};
};
diff --git a/src/lib/codex-browser-types.ts b/src/lib/codex-browser-types.ts
index db07861..f96c80d 100644
--- a/src/lib/codex-browser-types.ts
+++ b/src/lib/codex-browser-types.ts
@@ -188,6 +188,26 @@ export type ThreadBrowseData = {
thread: ThreadRow;
};
+export type CodexDbSchemaProfile = {
+ name: string;
+ requiredColumns: Readonly>;
+ requiredTables: readonly string[];
+};
+
+export type CodexThreadBrowseBatchResult =
+ | {
+ data: ThreadBrowseData;
+ source: 'database' | 'fallback';
+ status: 'found';
+ threadId: string;
+ }
+ | {
+ data: null;
+ source: 'missing';
+ status: 'missing';
+ threadId: string;
+ };
+
export type DashboardThreadSummary = Pick<
ThreadRow,
'cwd' | 'id' | 'model' | 'preview' | 'title' | 'tokens_used' | 'updated_at' | 'updated_at_ms'
@@ -222,6 +242,11 @@ export type CodexSessionIndexReconciliation = {
};
export type DeleteThreadsResult = {
+ cleanup: {
+ deletedSessionFiles: string[];
+ requested: boolean;
+ sessionIndexEntriesRemoved: string[];
+ };
deletedSessionFiles: string[];
deletedThreadIds: string[];
};
diff --git a/src/lib/codex-rollout-snapshot.test.ts b/src/lib/codex-rollout-snapshot.test.ts
new file mode 100644
index 0000000..38f01ad
--- /dev/null
+++ b/src/lib/codex-rollout-snapshot.test.ts
@@ -0,0 +1,56 @@
+import { describe, expect, it } from 'bun:test';
+import { CodexRolloutMutationError, copyStableCodexRollout } from './codex-rollout-snapshot';
+
+const identity = (overrides: Partial>['before']> = {}) => ({
+ changeTimeMs: 2,
+ inode: 1,
+ modificationTimeMs: 3,
+ sizeBytes: 4,
+ ...overrides,
+});
+
+describe('Codex rollout snapshots', () => {
+ it('should copy and accept a rollout when source identity remains stable', async () => {
+ const copied: string[] = [];
+
+ const result = await copyStableCodexRollout(
+ {
+ attempt: 1,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-1',
+ },
+ {
+ copy: async (sourcePath, snapshotPath) => {
+ copied.push(`${sourcePath}->${snapshotPath}`);
+ },
+ stat: async () => identity(),
+ },
+ );
+
+ expect(copied).toEqual(['/tmp/source.jsonl->/tmp/attempt.jsonl']);
+ expect(result).toMatchObject({ attempt: 1, threadId: 'thread-1' });
+ });
+
+ it('should reject a source that changes while the snapshot is copied', async () => {
+ let statCall = 0;
+
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 2,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-2',
+ },
+ {
+ copy: async () => {},
+ stat: async () => {
+ statCall += 1;
+ return identity(statCall === 2 ? { sizeBytes: 5 } : {});
+ },
+ },
+ ),
+ ).rejects.toBeInstanceOf(CodexRolloutMutationError);
+ });
+});
diff --git a/src/lib/codex-rollout-snapshot.ts b/src/lib/codex-rollout-snapshot.ts
new file mode 100644
index 0000000..09feb7f
--- /dev/null
+++ b/src/lib/codex-rollout-snapshot.ts
@@ -0,0 +1,162 @@
+import { stat } from 'node:fs/promises';
+
+export type CodexRolloutIdentity = {
+ changeTimeMs: number;
+ inode: number;
+ modificationTimeMs: number;
+ sizeBytes: number;
+};
+
+export type CodexRolloutSnapshot = {
+ after: CodexRolloutIdentity;
+ attempt: number;
+ before: CodexRolloutIdentity;
+ sourcePath: string;
+ snapshotPath: string;
+ threadId: string;
+};
+
+export class CodexRolloutMutationError extends Error {
+ readonly after: CodexRolloutIdentity;
+ readonly attempt: number;
+ readonly before: CodexRolloutIdentity;
+ readonly code = 'CODEX_ROLLOUT_MUTATED';
+ readonly threadId: string;
+
+ constructor({
+ after,
+ attempt,
+ before,
+ threadId,
+ }: {
+ after: CodexRolloutIdentity;
+ attempt: number;
+ before: CodexRolloutIdentity;
+ threadId: string;
+ }) {
+ super(`Codex rollout changed while exporting thread ${threadId} (attempt ${attempt}).`);
+ this.name = 'CodexRolloutMutationError';
+ this.after = after;
+ this.attempt = attempt;
+ this.before = before;
+ this.threadId = threadId;
+ }
+}
+
+export class CodexRolloutSourceError extends Error {
+ readonly code: 'CODEX_ROLLOUT_MISSING' | 'CODEX_ROLLOUT_UNREADABLE';
+ readonly threadId: string;
+ readonly sourcePath: string;
+
+ constructor({
+ cause,
+ code,
+ sourcePath,
+ threadId,
+ }: {
+ cause?: unknown;
+ code: 'CODEX_ROLLOUT_MISSING' | 'CODEX_ROLLOUT_UNREADABLE';
+ sourcePath: string;
+ threadId: string;
+ }) {
+ super(
+ code === 'CODEX_ROLLOUT_MISSING'
+ ? `Thread ${threadId} rollout file is missing: ${sourcePath}`
+ : `Thread ${threadId} rollout file could not be read: ${sourcePath}`,
+ cause === undefined ? undefined : { cause },
+ );
+ this.name = 'CodexRolloutSourceError';
+ this.code = code;
+ this.sourcePath = sourcePath;
+ this.threadId = threadId;
+ }
+}
+
+export type CodexRolloutSnapshotOperations = {
+ copy: (sourcePath: string, snapshotPath: string) => Promise;
+ stat: (sourcePath: string) => Promise;
+};
+
+const defaultOperations: CodexRolloutSnapshotOperations = {
+ copy: async (sourcePath, snapshotPath) => {
+ await Bun.write(snapshotPath, Bun.file(sourcePath));
+ },
+ stat: async (sourcePath) => {
+ const metadata = await stat(sourcePath);
+ return {
+ changeTimeMs: metadata.ctimeMs,
+ inode: metadata.ino,
+ modificationTimeMs: metadata.mtimeMs,
+ sizeBytes: metadata.size,
+ };
+ },
+};
+
+const isMissingError = (error: unknown) => {
+ return error instanceof Error && 'code' in error && error.code === 'ENOENT';
+};
+
+const isSameIdentity = (before: CodexRolloutIdentity, after: CodexRolloutIdentity) => {
+ return (
+ before.changeTimeMs === after.changeTimeMs &&
+ before.inode === after.inode &&
+ before.modificationTimeMs === after.modificationTimeMs &&
+ before.sizeBytes === after.sizeBytes
+ );
+};
+
+export const copyStableCodexRollout = async (
+ {
+ attempt,
+ snapshotPath,
+ sourcePath,
+ threadId,
+ }: {
+ attempt: number;
+ snapshotPath: string;
+ sourcePath: string;
+ threadId: string;
+ },
+ operations: CodexRolloutSnapshotOperations = defaultOperations,
+): Promise => {
+ let before: CodexRolloutIdentity;
+ try {
+ before = await operations.stat(sourcePath);
+ } catch (error) {
+ throw new CodexRolloutSourceError({
+ cause: error,
+ code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
+ sourcePath,
+ threadId,
+ });
+ }
+
+ try {
+ await operations.copy(sourcePath, snapshotPath);
+ } catch (error) {
+ throw new CodexRolloutSourceError({
+ cause: error,
+ code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
+ sourcePath,
+ threadId,
+ });
+ }
+
+ let after: CodexRolloutIdentity;
+ try {
+ after = await operations.stat(sourcePath);
+ } catch (error) {
+ throw new CodexRolloutSourceError({
+ cause: error,
+ code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
+ sourcePath,
+ threadId,
+ });
+ }
+
+ if (!isSameIdentity(before, after)) {
+ throw new CodexRolloutMutationError({ after, attempt, before, threadId });
+ }
+
+ return { after, attempt, before, snapshotPath, sourcePath, threadId };
+};
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index e4d7cc8..38b1869 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -17,9 +17,10 @@ import {
readCursorThreadHead,
readCursorThreadTranscript,
readCursorThreadTranscriptWithAgentFiles,
+ withCursorReadonlyDb,
} from './cursor-db';
import { getCursorGlobalDbPath } from './cursor-exporter-types';
-import { type CursorFixtureSpec, createCursorFixture } from './cursor-test-helpers';
+import { type CursorFixtureSpec, createCursorFixture, holdCursorWriteLock } from './cursor-test-helpers';
const tempDirs: string[] = [];
@@ -860,6 +861,36 @@ describe('openCursorReadonlyDb', () => {
writable.close();
}
});
+
+ it('should read through a retry-aware callback while a writer holds the WAL database', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+ const globalDbPath = getCursorGlobalDbPath(userDir);
+ const lockProcess = await holdCursorWriteLock(globalDbPath);
+
+ try {
+ const count = withCursorReadonlyDb(globalDbPath, (db) => {
+ return (db.query('SELECT COUNT(*) AS count FROM cursorDiskKV').get() as { count: number }).count;
+ });
+
+ expect(count).toBeGreaterThan(0);
+ } finally {
+ lockProcess.kill();
+ await lockProcess.exited;
+ }
+ });
+
+ it('should reject an asynchronous callback instead of closing its handle early', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+
+ expect(() =>
+ withCursorReadonlyDb(getCursorGlobalDbPath(userDir), async () => {
+ await Promise.resolve();
+ return null;
+ }),
+ ).toThrow('Cursor SQLite callbacks must be synchronous');
+ });
});
describe('parseCursorBubble', () => {
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index be7c595..3de448b 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -22,6 +22,7 @@ import {
} from './cursor-exporter-types';
import { buildCursorBubbleKeyLikePattern, isSafeCursorComposerId } from './cursor-id';
import { asNumber, asObject, asString, type JsonValue, pathExists, toFileUri } from './shared';
+import { runWithSqliteRetry } from './sqlite-retry';
type ComposerEntry = Record & {
composerId?: string;
@@ -61,6 +62,57 @@ export const openCursorReadonlyDb = (dbPath: string): Database => {
return new Database(getCursorReadonlyDbUri(dbPath, !hasWalSidecars), CURSOR_READONLY_DB_OPEN_FLAGS);
};
+const assertSynchronousCursorCallback = (result: T): T => {
+ if (
+ result !== null &&
+ (typeof result === 'object' || typeof result === 'function') &&
+ 'then' in result &&
+ typeof result.then === 'function'
+ ) {
+ throw new TypeError('Cursor SQLite callbacks must be synchronous');
+ }
+
+ return result;
+};
+
+export const withCursorReadonlyDb = (dbPath: string, callback: (db: Database) => T): T =>
+ runWithSqliteRetry({
+ action: () => {
+ const db = openCursorReadonlyDb(dbPath);
+ try {
+ return assertSynchronousCursorCallback(callback(db));
+ } finally {
+ db.close();
+ }
+ },
+ });
+
+export const withCursorWriteTransaction = (dbPath: string, callback: (db: Database) => T): T =>
+ runWithSqliteRetry({
+ action: () => {
+ const db = new Database(dbPath, { create: false, readwrite: true });
+ let transactionStarted = false;
+ try {
+ db.exec('PRAGMA busy_timeout = 0');
+ db.exec('BEGIN IMMEDIATE');
+ transactionStarted = true;
+ const result = assertSynchronousCursorCallback(callback(db));
+ db.exec('COMMIT');
+ transactionStarted = false;
+ return result;
+ } catch (error) {
+ if (transactionStarted) {
+ try {
+ db.exec('ROLLBACK');
+ } catch {}
+ }
+ throw error;
+ } finally {
+ db.close();
+ }
+ },
+ });
+
const isMissingOrUnreadableCursorStoreError = (error: unknown): boolean => {
const code = (error as { code?: unknown }).code;
return code === 'ENOENT' || code === 'ENOTDIR' || code === 'SQLITE_CANTOPEN';
@@ -204,21 +256,21 @@ const readModernComposerHeaders = (db: Database): ComposerEntry[] => {
return rows.map(parseComposerHeaderRow);
};
-export const loadGlobalComposerHeaders = (globalDbPath: string): ComposerEntry[] => {
- try {
- const db = openCursorReadonlyDb(globalDbPath);
- try {
- const legacy = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY);
- const headersById = new Map();
- for (const header of [...(legacy?.allComposers ?? []), ...readModernComposerHeaders(db)]) {
- if (header.composerId) {
- headersById.set(header.composerId, header);
- }
+export const loadGlobalComposerHeadersStrict = (globalDbPath: string): ComposerEntry[] =>
+ withCursorReadonlyDb(globalDbPath, (db) => {
+ const legacy = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY);
+ const headersById = new Map();
+ for (const header of [...(legacy?.allComposers ?? []), ...readModernComposerHeaders(db)]) {
+ if (header.composerId) {
+ headersById.set(header.composerId, header);
}
- return [...headersById.values()];
- } finally {
- db.close();
}
+ return [...headersById.values()];
+ });
+
+export const loadGlobalComposerHeaders = (globalDbPath: string): ComposerEntry[] => {
+ try {
+ return loadGlobalComposerHeadersStrict(globalDbPath);
} catch (error) {
warnCursorDataIssue('global_composer_headers_unavailable', {
error: error instanceof Error ? error.message : String(error),
@@ -265,23 +317,29 @@ const resolveBucketIdentity = async (
return { folders: [], kind: 'unknown', label: bucketId, uri: '' };
};
+const readBucketComposerIdsStrict = (dbPath: string): string[] =>
+ withCursorReadonlyDb(dbPath, (db) => {
+ const data = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_DATA_KEY);
+ return (data?.allComposers ?? [])
+ .map((entry) => entry.composerId)
+ .filter((value): value is string => Boolean(value));
+ });
+
const readBucketComposerIds = (dbPath: string): string[] => {
try {
- const db = openCursorReadonlyDb(dbPath);
- try {
- const data = readItemValue<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_DATA_KEY);
- return (data?.allComposers ?? [])
- .map((entry) => entry.composerId)
- .filter((value): value is string => Boolean(value));
- } finally {
- db.close();
- }
+ return readBucketComposerIdsStrict(dbPath);
} catch {
return [];
}
};
-export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promise => {
+type CursorBucketComposerIdReader = (dbPath: string) => string[];
+
+const loadCursorBucketsInternal = async (
+ userDir: string,
+ readHeaders: (globalDbPath: string) => ComposerEntry[],
+ readComposerIds: CursorBucketComposerIdReader,
+): Promise => {
const workspaceStorageDir = getCursorWorkspaceStorageDir(userDir);
let bucketIds: string[] = [];
try {
@@ -293,7 +351,7 @@ export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promi
const globalDbPath = getCursorGlobalDbPath(userDir);
const headerIdsByBucket = new Map>();
if (await pathExists(globalDbPath)) {
- for (const header of loadGlobalComposerHeaders(globalDbPath)) {
+ for (const header of readHeaders(globalDbPath)) {
const id = header.workspaceIdentifier?.id;
if (id && header.composerId) {
const set = headerIdsByBucket.get(id) ?? new Set();
@@ -305,7 +363,7 @@ export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promi
const buckets: CursorWorkspaceBucket[] = [];
for (const bucketId of bucketIds) {
- const bucket = await buildBucket(workspaceStorageDir, bucketId, headerIdsByBucket);
+ const bucket = await buildBucket(workspaceStorageDir, bucketId, headerIdsByBucket, readComposerIds);
if (bucket) {
buckets.push(bucket);
}
@@ -314,10 +372,17 @@ export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promi
return buckets;
};
+export const loadCursorBuckets = async (userDir = resolveCursorUserDir()): Promise =>
+ loadCursorBucketsInternal(userDir, loadGlobalComposerHeaders, readBucketComposerIds);
+
+const loadCursorBucketsStrict = async (userDir: string): Promise =>
+ loadCursorBucketsInternal(userDir, loadGlobalComposerHeadersStrict, readBucketComposerIdsStrict);
+
const buildBucket = async (
workspaceStorageDir: string,
bucketId: string,
headerIdsByBucket: Map>,
+ readComposerIds: CursorBucketComposerIdReader,
): Promise => {
const root = path.join(workspaceStorageDir, bucketId);
const workspaceJsonPath = path.join(root, 'workspace.json');
@@ -337,7 +402,7 @@ const buildBucket = async (
try {
identity = await resolveBucketIdentity(wsData, bucketId);
dbStat = await stat(dbPath);
- composerIds = readBucketComposerIds(dbPath);
+ composerIds = readComposerIds(dbPath);
} catch (error) {
if (isMissingOrUnreadableCursorStoreError(error)) {
return null;
@@ -606,6 +671,7 @@ type CursorDiscovery = {
};
type CursorDiscoveryOptions = {
+ strict?: boolean;
updatedAfterMs?: number;
};
@@ -880,9 +946,9 @@ const readBubbleStats = (db: Database, composerIds?: Iterable): Map [row.id, { bytes: row.bytes, count: row.count }]));
};
-const readHeaderInfo = (globalDbPath: string): Map => {
+const readHeaderInfo = (globalDbPath: string, strict = false): Map => {
const info = new Map();
- for (const header of loadGlobalComposerHeaders(globalDbPath)) {
+ for (const header of (strict ? loadGlobalComposerHeadersStrict : loadGlobalComposerHeaders)(globalDbPath)) {
if (!header.composerId) {
continue;
}
@@ -903,10 +969,10 @@ const readHeaderInfo = (globalDbPath: string): Map => {
return info;
};
-const collectBucketComposerIds = (buckets: CursorWorkspaceBucket[]): Map => {
+const collectBucketComposerIds = (buckets: CursorWorkspaceBucket[], strict = false): Map => {
const map = new Map();
for (const bucket of buckets) {
- for (const composerId of readBucketComposerIds(bucket.dbPath)) {
+ for (const composerId of (strict ? readBucketComposerIdsStrict : readBucketComposerIds)(bucket.dbPath)) {
if (!map.has(composerId)) {
map.set(composerId, bucket.bucketId);
}
@@ -1132,7 +1198,7 @@ const buildDiscoveryGroups = (
};
const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions = {}): Promise => {
- const buckets = await loadCursorBuckets(userDir);
+ const buckets = await (options.strict ? loadCursorBucketsStrict(userDir) : loadCursorBuckets(userDir));
const bucketGroups = groupCursorBuckets(buckets);
const globalDbPath = getCursorGlobalDbPath(userDir);
const cliThreads = await readCursorCliTranscriptThreads(userDir, options);
@@ -1150,15 +1216,15 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
}
}
- const db = openCursorReadonlyDb(globalDbPath);
- try {
+ const databaseResult = withCursorReadonlyDb(globalDbPath, (db) => {
const heads = readAllHeads(db, options);
if (options.updatedAfterMs !== undefined && heads.size === 0) {
- return assembleDiscovery(cliThreads, bucketGroups, new Map());
+ return { kind: 'empty' as const };
}
- const headerInfo = readHeaderInfo(globalDbPath);
- const bucketComposerIds = options.updatedAfterMs === undefined ? collectBucketComposerIds(buckets) : new Map();
+ const headerInfo = readHeaderInfo(globalDbPath, options.strict);
+ const bucketComposerIds =
+ options.updatedAfterMs === undefined ? collectBucketComposerIds(buckets, options.strict) : new Map();
const stats = readBubbleStats(db, options.updatedAfterMs === undefined ? undefined : heads.keys());
const universe =
options.updatedAfterMs === undefined
@@ -1181,24 +1247,31 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
);
}
- const fileHistoryActivity =
- options.updatedAfterMs === undefined ? await readCursorFileHistoryProjectActivity(userDir) : new Map();
const knownComposerIds = new Set(universe);
- return assembleDiscovery(
- [...resolved, ...cliThreads.filter((thread) => !knownComposerIds.has(thread.composerId))],
- bucketGroups,
- fileHistoryActivity,
- );
- } finally {
- db.close();
+ return { kind: 'resolved' as const, knownComposerIds, resolved };
+ });
+
+ if (databaseResult.kind === 'empty') {
+ return assembleDiscovery(cliThreads, bucketGroups, new Map());
}
+
+ const fileHistoryActivity =
+ options.updatedAfterMs === undefined ? await readCursorFileHistoryProjectActivity(userDir) : new Map();
+ return assembleDiscovery(
+ [
+ ...databaseResult.resolved,
+ ...cliThreads.filter((thread) => !databaseResult.knownComposerIds.has(thread.composerId)),
+ ],
+ bucketGroups,
+ fileHistoryActivity,
+ );
};
const discoverCursorWorkspaces = async (
userDir: string,
options: CursorDiscoveryOptions = {},
): Promise => {
- if (options.updatedAfterMs !== undefined) {
+ if (options.updatedAfterMs !== undefined || options.strict) {
return await buildDiscovery(userDir, options);
}
@@ -1212,35 +1285,33 @@ const discoverCursorWorkspaces = async (
return value;
};
-export const readCursorThreadHead = (globalDbPath: string, composerId: string): CursorThreadHead | null => {
- const db = openCursorReadonlyDb(globalDbPath);
- try {
- const head = readKvValue>(db, `composerData:${composerId}`);
- if (!head) {
- return null;
- }
+const readCursorThreadHeadFromDb = (db: Database, composerId: string): CursorThreadHead | null => {
+ const head = readKvValue>(db, `composerData:${composerId}`);
+ if (!head) {
+ return null;
+ }
- const headerList = Array.isArray(head.fullConversationHeadersOnly)
- ? (head.fullConversationHeadersOnly as JsonValue[])
- : [];
- const orderedBubbleIds = headerList
- .map((item) => asString(asObject(item)?.bubbleId ?? null))
- .filter((value): value is string => Boolean(value));
+ const headerList = Array.isArray(head.fullConversationHeadersOnly)
+ ? (head.fullConversationHeadersOnly as JsonValue[])
+ : [];
+ const orderedBubbleIds = headerList
+ .map((item) => asString(asObject(item)?.bubbleId ?? null))
+ .filter((value): value is string => Boolean(value));
- return {
- composerId,
- createdAtMs: asNumber(head.createdAt ?? null),
- lastUpdatedAtMs: asNumber(head.lastUpdatedAt ?? null),
- mode: asString(head.unifiedMode ?? null),
- name: asString(head.name ?? null),
- orderedBubbleIds,
- totalBubbleHeaders: headerList.length,
- };
- } finally {
- db.close();
- }
+ return {
+ composerId,
+ createdAtMs: asNumber(head.createdAt ?? null),
+ lastUpdatedAtMs: asNumber(head.lastUpdatedAt ?? null),
+ mode: asString(head.unifiedMode ?? null),
+ name: asString(head.name ?? null),
+ orderedBubbleIds,
+ totalBubbleHeaders: headerList.length,
+ };
};
+export const readCursorThreadHead = (globalDbPath: string, composerId: string): CursorThreadHead | null =>
+ withCursorReadonlyDb(globalDbPath, (db) => readCursorThreadHeadFromDb(db, composerId));
+
const toBubbleKind = (rawType: JsonValue): CursorBubbleKind => {
if (rawType === 1) {
return 'user';
@@ -1697,13 +1768,12 @@ const inferCursorUserDirFromGlobalDbPath = (globalDbPath: string): string => {
};
export const readCursorThreadTranscript = (globalDbPath: string, composerId: string): CursorThreadTranscript | null => {
- const head = readCursorThreadHead(globalDbPath, composerId);
- if (!head) {
- return null;
- }
+ return withCursorReadonlyDb(globalDbPath, (db) => {
+ const head = readCursorThreadHeadFromDb(db, composerId);
+ if (!head) {
+ return null;
+ }
- const db = openCursorReadonlyDb(globalDbPath);
- try {
const orderedIds = head.orderedBubbleIds.length > 0 ? head.orderedBubbleIds : readAllBubbleIds(db, composerId);
const bubbles: CursorBubble[] = [];
for (const bubbleId of orderedIds) {
@@ -1723,9 +1793,7 @@ export const readCursorThreadTranscript = (globalDbPath: string, composerId: str
omittedBubbleCount,
renderableBubbleCount: bubbles.length,
};
- } finally {
- db.close();
- }
+ });
};
export const readCursorThreadTranscriptWithAgentFiles = async (
diff --git a/src/lib/cursor-recovery.test.ts b/src/lib/cursor-recovery.test.ts
index 2c444a7..abb629c 100644
--- a/src/lib/cursor-recovery.test.ts
+++ b/src/lib/cursor-recovery.test.ts
@@ -3,7 +3,7 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { listCursorWorkspaceGroups } from './cursor-db';
+import { listCursorWorkspaceGroups, withCursorWriteTransaction } from './cursor-db';
import { getCursorGlobalDbPath } from './cursor-exporter-types';
import {
collectCursorThreadsForDeletion,
@@ -12,7 +12,7 @@ import {
pruneCursorThreads,
recoverCursorWorkspaceGroup,
} from './cursor-recovery';
-import { type CursorFixtureSpec, createCursorFixture } from './cursor-test-helpers';
+import { type CursorFixtureSpec, createCursorFixture, holdCursorWriteLock } from './cursor-test-helpers';
const tempDirs: string[] = [];
@@ -113,6 +113,61 @@ describe('recoverCursorWorkspaceGroup', () => {
db.close();
}
});
+
+ it('should retry a same-database write transaction after a competing writer releases the lock', async () => {
+ const userDir = await makeUserDir('cursor-recover-lock-');
+ await createCursorFixture(userDir, recoverySpec());
+ const globalDbPath = getCursorGlobalDbPath(userDir);
+ const lockProcess = await holdCursorWriteLock(globalDbPath);
+
+ try {
+ const changes = withCursorWriteTransaction(
+ globalDbPath,
+ (db) =>
+ db.run('INSERT OR REPLACE INTO ItemTable (key, value) VALUES (?, ?)', ['spiracha.retry-test', 'ok'])
+ .changes,
+ );
+
+ expect(changes).toBe(1);
+ } finally {
+ lockProcess.kill();
+ await lockProcess.exited;
+ }
+ });
+
+ it('should restore the target bucket when the global transaction exhausts its retry budget', async () => {
+ const userDir = await makeUserDir('cursor-recover-lock-exhausted-');
+ await createCursorFixture(userDir, recoverySpec());
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const targetBucket = group!.buckets[0]!;
+ const globalDbPath = getCursorGlobalDbPath(userDir);
+ const lockProcess = await holdCursorWriteLock(globalDbPath, 1_000);
+
+ try {
+ await expect(recoverCursorWorkspaceGroup(group!, true, userDir)).rejects.toThrow(
+ 'SQLite operation failed after 4 attempts',
+ );
+ } finally {
+ lockProcess.kill();
+ await lockProcess.exited;
+ }
+
+ const targetDb = new Database(targetBucket.dbPath, { readonly: true });
+ try {
+ expect(targetDb.query("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get()).toBeNull();
+ } finally {
+ targetDb.close();
+ }
+ expect(readHeaders(globalDbPath)[0]?.workspaceIdentifier?.id).toBe('bucket-old');
+ });
+
+ it('should fail closed without creating a missing Cursor database', async () => {
+ const userDir = await makeUserDir('cursor-recover-missing-');
+ const dbPath = path.join(userDir, 'missing', 'state.vscdb');
+
+ expect(() => withCursorWriteTransaction(dbPath, () => undefined)).toThrow();
+ expect(await Bun.file(dbPath).exists()).toBe(false);
+ });
});
describe('pruneCursorThreads', () => {
@@ -356,6 +411,49 @@ describe('pruneCursorThreads', () => {
globalDb.close();
});
+ it('should restore earlier bucket updates when a later bucket remains locked', async () => {
+ const userDir = await makeUserDir('cursor-delete-bucket-lock-');
+ const spec = recoverySpec();
+ spec.buckets.push({
+ bucketId: 'bucket-second',
+ composerIds: ['thread-1'],
+ folder: spec.buckets[0]!.folder,
+ threadsInComposerData: true,
+ });
+ await createCursorFixture(userDir, spec);
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const failingBucket = group!.buckets.at(-1)!;
+ const lockProcess = await holdCursorWriteLock(failingBucket.dbPath, 1_000);
+ const deletable = await collectCursorThreadsForDeletion(['thread-1'], userDir);
+
+ try {
+ await expect(pruneCursorThreads(deletable, true, userDir)).rejects.toThrow(
+ 'SQLite operation failed after 4 attempts',
+ );
+ } finally {
+ lockProcess.kill();
+ await lockProcess.exited;
+ }
+
+ let bucketsContainingThread = 0;
+ for (const bucket of group!.buckets) {
+ const db = new Database(bucket.dbPath, { readonly: true });
+ const row = db.query("SELECT value FROM ItemTable WHERE key = 'composer.composerData'").get() as {
+ value: string;
+ } | null;
+ db.close();
+ if (!row) {
+ continue;
+ }
+ const composerIds = (
+ JSON.parse(row.value) as { allComposers: Array<{ composerId: string }> }
+ ).allComposers.map((composer) => composer.composerId);
+ expect(composerIds).toContain('thread-1');
+ bucketsContainingThread += 1;
+ }
+ expect(bucketsContainingThread).toBe(2);
+ });
+
it('should treat underscores in composer ids as literals when deleting bubble keys', async () => {
const userDir = await makeUserDir('cursor-delete-wildcard-');
await createCursorFixture(userDir, {
diff --git a/src/lib/cursor-recovery.ts b/src/lib/cursor-recovery.ts
index 66a5e1a..2787edf 100644
--- a/src/lib/cursor-recovery.ts
+++ b/src/lib/cursor-recovery.ts
@@ -1,4 +1,4 @@
-import { Database } from 'bun:sqlite';
+import type { Database } from 'bun:sqlite';
import { randomUUID } from 'node:crypto';
import { readdir, realpath, rm } from 'node:fs/promises';
import path from 'node:path';
@@ -8,8 +8,9 @@ import {
findCursorTranscriptDirsForComposerIds,
invalidateCursorDiscoveryCache,
listCursorWorkspaceGroups,
- loadGlobalComposerHeaders,
- openCursorReadonlyDb,
+ loadGlobalComposerHeadersStrict,
+ withCursorReadonlyDb,
+ withCursorWriteTransaction,
} from './cursor-db';
import {
COMPOSER_DATA_KEY,
@@ -84,23 +85,19 @@ const writeRetainedCursorBackup = async (basePath: string, label: string, value:
// The Cursor global DB can be multiple gigabytes, so copying the whole file per operation is not
// viable. We instead write small, targeted JSON backups of only the data each operation touches.
const backupComposerHeaders = async (globalDbPath: string): Promise => {
- const db = openCursorReadonlyDb(globalDbPath);
- let headers: unknown;
- try {
- headers = readJsonItem(db, COMPOSER_HEADERS_KEY) ?? { allComposers: [] };
- } finally {
- db.close();
- }
+ const headers = withCursorReadonlyDb(
+ globalDbPath,
+ (db) => readJsonItem(db, COMPOSER_HEADERS_KEY) ?? { allComposers: [] },
+ );
return writeRetainedCursorBackup(globalDbPath, 'composerHeaders', headers);
};
const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]): Promise => {
- const db = openCursorReadonlyDb(globalDbPath);
- try {
+ const dump = withCursorReadonlyDb(globalDbPath, (db) => {
const hasModernHeaders = hasComposerHeadersTable(db);
const bubblesByComposerId = readBubblesForComposerIds(db, composerIds);
- const dump = composerIds.map((composerId) => ({
+ return composerIds.map((composerId) => ({
bubbles: bubblesByComposerId.get(composerId) ?? [],
composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
composerId,
@@ -108,10 +105,8 @@ const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]):
? db.query('SELECT * FROM composerHeaders WHERE composerId = ?').get(composerId)
: null,
}));
- return writeRetainedCursorBackup(globalDbPath, 'prunedThreads', dump);
- } finally {
- db.close();
- }
+ });
+ return writeRetainedCursorBackup(globalDbPath, 'prunedThreads', dump);
};
const readBubblesForComposerIds = (
@@ -216,17 +211,10 @@ const buildWorkspaceIdentifier = (bucket: CursorWorkspaceBucket): { id: string;
};
const composersForBucket = (bucket: CursorWorkspaceBucket, headers: ComposerEntry[]): ComposerEntry[] => {
- let fromBucket: ComposerEntry[] = [];
- try {
- const db = openCursorReadonlyDb(bucket.dbPath);
- try {
- fromBucket = readJsonItem(db, COMPOSER_DATA_KEY)?.allComposers ?? [];
- } finally {
- db.close();
- }
- } catch {
- fromBucket = [];
- }
+ const fromBucket = withCursorReadonlyDb(
+ bucket.dbPath,
+ (db) => readJsonItem(db, COMPOSER_DATA_KEY)?.allComposers ?? [],
+ );
const linked = headers.filter((header) => header.workspaceIdentifier?.id === bucket.bucketId);
return mergeComposerEntries([...fromBucket, ...linked]);
@@ -322,7 +310,7 @@ export const recoverCursorWorkspaceGroup = async (
}
const globalDbPath = getCursorGlobalDbPath(userDir);
- const headers = loadGlobalComposerHeaders(globalDbPath);
+ const headers = loadGlobalComposerHeadersStrict(globalDbPath);
const { target, sources } = chooseTargetBucket(group);
const sourceBucketIds = new Set(sources.map((bucket) => bucket.bucketId));
@@ -339,32 +327,23 @@ export const recoverCursorWorkspaceGroup = async (
await backupComposerHeaders(globalDbPath);
await backupTargetBucketComposerData(target, currentBucketData);
- const db = new Database(globalDbPath);
- let committed = false;
+ writeTargetBucketComposerData(target, buildTargetBucketComposerData(currentBucketData.data, merged));
+
let relinked = 0;
let added = 0;
try {
- db.exec('BEGIN IMMEDIATE');
- writeTargetBucketComposerData(target, buildTargetBucketComposerData(currentBucketData.data, merged));
- ({ relinked, added } = relinkHeaders(db, merged, sourceBucketIds, target));
- db.exec('COMMIT');
- committed = true;
+ ({ relinked, added } = withCursorWriteTransaction(globalDbPath, (db) =>
+ relinkHeaders(db, merged, sourceBucketIds, target),
+ ));
} catch (error) {
- if (!committed) {
- try {
- db.exec('ROLLBACK');
- } catch {}
-
- try {
- writeTargetBucketComposerData(target, currentBucketData);
- } catch {}
+ try {
+ writeTargetBucketComposerData(target, currentBucketData);
+ } catch (rollbackError) {
+ throw new AggregateError([error, rollbackError], 'Cursor recovery and bucket rollback both failed');
}
-
throw error;
- } finally {
- db.close();
- invalidateCursorDiscoveryCache();
}
+ invalidateCursorDiscoveryCache();
return buildRecoverResult(group, target, merged, globalDbPath, relinked, added);
};
@@ -373,16 +352,13 @@ export const recoverCursorWorkspaceGroup = async (
// the global headers, so we write the merged threads into the active bucket as well as relinking
// global headers. This mirrors what Cursor itself stores and makes recovery work for both layouts.
const readTargetBucketComposerData = (target: CursorWorkspaceBucket): BucketComposerDataSnapshot => {
- const db = openCursorReadonlyDb(target.dbPath);
- try {
+ return withCursorReadonlyDb(target.dbPath, (db) => {
const data = readJsonItem(db, COMPOSER_DATA_KEY);
return {
data: data ?? {},
exists: data !== null,
};
- } finally {
- db.close();
- }
+ });
};
const backupTargetBucketComposerData = async (
@@ -409,17 +385,14 @@ const writeTargetBucketComposerData = (
target: CursorWorkspaceBucket,
snapshot: BucketComposerDataSnapshot | ComposerData,
): void => {
- const db = new Database(target.dbPath);
- try {
+ withCursorWriteTransaction(target.dbPath, (db) => {
if ('exists' in snapshot && !snapshot.exists) {
db.run('DELETE FROM ItemTable WHERE key = ?', [COMPOSER_DATA_KEY]);
return;
}
writeJsonItem(db, COMPOSER_DATA_KEY, 'exists' in snapshot ? snapshot.data : snapshot);
- } finally {
- db.close();
- }
+ });
};
const buildRecoverResult = (
@@ -430,8 +403,7 @@ const buildRecoverResult = (
relinked: number,
added: number,
): CursorRecoverResult => {
- const db = openCursorReadonlyDb(globalDbPath);
- try {
+ return withCursorReadonlyDb(globalDbPath, (db) => {
return {
activeBucketId: target.bucketId,
addedHeaderCount: added,
@@ -446,9 +418,7 @@ const buildRecoverResult = (
})),
workspaceKey: group.key,
};
- } finally {
- db.close();
- }
+ });
};
const removeThreadFromBucket = (db: Database, composerIds: Set): boolean => {
@@ -580,12 +550,7 @@ const pruneGlobalThreads = async (
composerIds: Set,
result: CursorPruneResult,
): Promise => {
- const db = new Database(globalDbPath);
- let transactionStarted = false;
- try {
- db.exec('PRAGMA busy_timeout = 5000');
- db.exec('BEGIN IMMEDIATE');
- transactionStarted = true;
+ const deleted = withCursorWriteTransaction(globalDbPath, (db) => {
let bubblesDeleted = 0;
let composerDataDeleted = 0;
for (const thread of threads) {
@@ -595,32 +560,21 @@ const pruneGlobalThreads = async (
}
const headersRemoved = removeThreadHeaders(db, composerIds);
- db.exec('COMMIT');
- transactionStarted = false;
- result.bubblesDeleted += bubblesDeleted;
- result.composerDataDeleted += composerDataDeleted;
- result.headersRemoved = headersRemoved;
- } catch (error) {
- if (transactionStarted) {
- db.exec('ROLLBACK');
- }
- throw error;
- } finally {
- db.close();
- }
+ return { bubblesDeleted, composerDataDeleted, headersRemoved };
+ });
+ result.bubblesDeleted += deleted.bubblesDeleted;
+ result.composerDataDeleted += deleted.composerDataDeleted;
+ result.headersRemoved = deleted.headersRemoved;
};
const restoreBucketComposerData = (dbPath: string, snapshot: BucketComposerDataSnapshot): void => {
- const db = new Database(dbPath);
- try {
+ withCursorWriteTransaction(dbPath, (db) => {
if (snapshot.exists) {
writeJsonItem(db, COMPOSER_DATA_KEY, snapshot.data);
} else {
db.run('DELETE FROM ItemTable WHERE key = ?', [COMPOSER_DATA_KEY]);
}
- } finally {
- db.close();
- }
+ });
};
const getCursorBucketDbPathsForComposerIds = (
@@ -641,17 +595,18 @@ const getCursorBucketDbPathsForComposerIds = (
const pruneWorkspaceBuckets = async (composerIds: Set, userDir: string) => {
// A thread can live in multiple recovered buckets, so update every matching bucket without
// opening unrelated workspace databases.
- const dbPaths = getCursorBucketDbPathsForComposerIds(await listCursorWorkspaceGroups(userDir), composerIds);
+ const dbPaths = getCursorBucketDbPathsForComposerIds(
+ await listCursorWorkspaceGroups(userDir, { strict: true }),
+ composerIds,
+ );
const snapshots = new Map();
for (const dbPath of dbPaths) {
- const db = openCursorReadonlyDb(dbPath);
- try {
+ const snapshot = withCursorReadonlyDb(dbPath, (db) => {
const data = readJsonItem(db, COMPOSER_DATA_KEY);
- snapshots.set(dbPath, { data: data ?? {}, exists: data !== null });
- } finally {
- db.close();
- }
+ return { data: data ?? {}, exists: data !== null };
+ });
+ snapshots.set(dbPath, snapshot);
}
const updatedPaths: string[] = [];
@@ -671,13 +626,9 @@ const pruneWorkspaceBuckets = async (composerIds: Set, userDir: string)
try {
for (const dbPath of dbPaths) {
- const db = new Database(dbPath);
- try {
- if (removeThreadFromBucket(db, composerIds)) {
- updatedPaths.push(dbPath);
- }
- } finally {
- db.close();
+ const updated = withCursorWriteTransaction(dbPath, (db) => removeThreadFromBucket(db, composerIds));
+ if (updated) {
+ updatedPaths.push(dbPath);
}
}
} catch (error) {
@@ -852,35 +803,32 @@ export const collectCursorThreadsForDeletion = async (
userDir = resolveCursorUserDir(),
): Promise => {
const globalDbPath = getCursorGlobalDbPath(userDir);
- const db = openCursorReadonlyDb(globalDbPath);
const summaries: CursorThreadSummary[] = [];
- try {
+ const bubbleCounts = withCursorReadonlyDb(globalDbPath, (db) => {
for (const composerId of composerIds) {
assertSafeCursorComposerId(composerId);
}
- const bubbleCounts = countBubblesForComposerIds(db, composerIds);
- const transcriptDirs = await findCursorTranscriptDirsForComposerIds(composerIds, userDir);
- for (const composerId of composerIds) {
- summaries.push({
- bubbleBytes: 0,
- bubbleCount: bubbleCounts.get(composerId) ?? 0,
- bucketId: null,
- composerId,
- createdAtMs: null,
- lastUpdatedAtMs: null,
- mode: null,
- model: null,
- name: '',
- parentComposerId: null,
- reasoningEffort: null,
- transcriptDirs: transcriptDirs.get(composerId) ?? [],
- workspaceKey: '',
- workspaceLabel: '',
- });
- }
- } finally {
- db.close();
+ return countBubblesForComposerIds(db, composerIds);
+ });
+ const transcriptDirs = await findCursorTranscriptDirsForComposerIds(composerIds, userDir);
+ for (const composerId of composerIds) {
+ summaries.push({
+ bubbleBytes: 0,
+ bubbleCount: bubbleCounts.get(composerId) ?? 0,
+ bucketId: null,
+ composerId,
+ createdAtMs: null,
+ lastUpdatedAtMs: null,
+ mode: null,
+ model: null,
+ name: '',
+ parentComposerId: null,
+ reasoningEffort: null,
+ transcriptDirs: transcriptDirs.get(composerId) ?? [],
+ workspaceKey: '',
+ workspaceLabel: '',
+ });
}
return summaries;
diff --git a/src/lib/cursor-test-helpers.ts b/src/lib/cursor-test-helpers.ts
index 09cca79..c268866 100644
--- a/src/lib/cursor-test-helpers.ts
+++ b/src/lib/cursor-test-helpers.ts
@@ -280,3 +280,45 @@ export const createCursorFixture = async (userDir: string, spec: CursorFixtureSp
await writeGlobalDb(userDir, spec);
await writeHistoryEntries(userDir, spec);
};
+
+export const holdCursorWriteLock = async (dbPath: string, durationMs = 180) => {
+ const script = `
+import { Database } from 'bun:sqlite';
+
+const db = new Database(Bun.argv.at(-1), { create: false, readwrite: true });
+db.exec('PRAGMA journal_mode = WAL');
+db.exec('PRAGMA busy_timeout = 0');
+db.exec('BEGIN IMMEDIATE');
+console.log('CURSOR_LOCK_READY');
+await Bun.sleep(Number(Bun.argv.at(-2)));
+db.exec('COMMIT');
+db.close();
+`;
+ const childProcess = Bun.spawn([process.execPath, '-e', script, String(durationMs), dbPath], {
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ const reader = childProcess.stdout?.getReader();
+ if (!reader) {
+ childProcess.kill();
+ await childProcess.exited;
+ throw new Error('Cursor SQLite lock helper did not expose stdout');
+ }
+
+ const decoder = new TextDecoder();
+ let output = '';
+ try {
+ while (true) {
+ const next = await reader.read();
+ output += decoder.decode(next.value ?? new Uint8Array(), { stream: !next.done });
+ if (output.includes('CURSOR_LOCK_READY')) {
+ return childProcess;
+ }
+ if (next.done) {
+ throw new Error(`Cursor SQLite lock helper exited before locking: ${output}`);
+ }
+ }
+ } finally {
+ reader.releaseLock();
+ }
+};
diff --git a/src/ui/components/antigravity-keychain-panel.tsx b/src/ui/components/antigravity-keychain-panel.tsx
index 54e2276..0eb3747 100644
--- a/src/ui/components/antigravity-keychain-panel.tsx
+++ b/src/ui/components/antigravity-keychain-panel.tsx
@@ -49,7 +49,7 @@ export function AntigravityKeychainPanel() {
{isUnlocked
- ? 'The Antigravity key is cached in this server process only. Transcript exports are available for local logs and safe-storage payloads.'
+ ? 'The current Keychain check succeeded. Transcript exports can reacquire access for each protected request.'
: `Spiracha needs one-time access to ${decryptionState.keychainService} to decrypt Antigravity transcript data. macOS will ask for approval after you click unlock.`}
{error ? (
diff --git a/src/ui/components/export-dialog.tsx b/src/ui/components/export-dialog.tsx
index 15c7092..c41dc11 100644
--- a/src/ui/components/export-dialog.tsx
+++ b/src/ui/components/export-dialog.tsx
@@ -16,7 +16,12 @@ import {
DialogTitle,
} from '#/components/ui/dialog';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '#/components/ui/select';
-import { downloadTextFile } from '#/lib/download';
+import {
+ cancelActiveDownloads,
+ type DownloadLifecycleState,
+ downloadTextFile,
+ resetActiveDownloads,
+} from '#/lib/download';
import { requestEvidenceExport } from '#/lib/evidence-export';
import type { ExportDialogOptions } from '#/lib/export-options';
import { useSettings } from '#/lib/settings-store';
@@ -147,6 +152,28 @@ const EvidencePreview = ({ preview }: { preview: ConversationEvidenceExport }) =
);
+const getDownloadStateMessage = (state: DownloadLifecycleState) => {
+ switch (state) {
+ case 'preparing':
+ return 'Preparing export...';
+ case 'ready':
+ return 'Export ready.';
+ case 'downloading':
+ return 'Starting download...';
+ case 'cancelled':
+ return 'Export cancelled.';
+ case 'failed':
+ return 'Export failed.';
+ }
+};
+
+const DownloadStateMessage = ({ state }: { state: DownloadLifecycleState | null }) =>
+ state ? (
+
+ {getDownloadStateMessage(state)}
+
+ ) : null;
+
export function ExportDialog({
disabled = false,
errorMessage = null,
@@ -168,11 +195,20 @@ export function ExportDialog({
const [preview, setPreview] = useState(null);
const [evidenceError, setEvidenceError] = useState(null);
const [evidencePending, setEvidencePending] = useState(false);
+ const [downloadState, setDownloadState] = useState(null);
const submissionInProgress = useRef(false);
const previousPending = useRef(pending);
const effectiveZipArchive = forceZipArchive || options.zipArchive;
const displayedError = evidenceError ?? errorMessage;
const zipDescriptionId = useId();
+ const handleOpenChange = (nextOpen: boolean) => {
+ if (nextOpen) {
+ resetActiveDownloads();
+ } else {
+ cancelActiveDownloads();
+ }
+ onOpenChange(nextOpen);
+ };
useEffect(() => {
if (!open) {
@@ -184,6 +220,7 @@ export function ExportDialog({
setPreview(null);
setEvidenceError(null);
setEvidencePending(false);
+ setDownloadState(null);
}
}, [open, settings.exportDefaults]);
@@ -191,6 +228,7 @@ export function ExportDialog({
if ((previousPending.current && !pending) || errorMessage) {
setSubmitted(false);
submissionInProgress.current = false;
+ setDownloadState(errorMessage ? 'failed' : null);
}
previousPending.current = pending;
}, [errorMessage, pending]);
@@ -219,6 +257,7 @@ export function ExportDialog({
}
submissionInProgress.current = true;
setSubmitted(true);
+ setDownloadState('preparing');
if (mode === 'focused') {
const result = preview ?? (await loadEvidence());
if (result && focusedEvidenceTarget) {
@@ -226,6 +265,7 @@ export function ExportDialog({
`${focusedEvidenceTarget.source}-${focusedEvidenceTarget.id}-focused-evidence.md`,
result.markdown,
'text/markdown; charset=utf-8',
+ { onStateChange: setDownloadState },
);
}
submissionInProgress.current = false;
@@ -237,7 +277,7 @@ export function ExportDialog({
};
return (
-
+
{title}
@@ -296,6 +336,7 @@ export function ExportDialog({
{preview && mode === 'focused' ? : null}
+
{displayedError ? {displayedError}
: null}
diff --git a/src/ui/components/export-dialog.vitest.tsx b/src/ui/components/export-dialog.vitest.tsx
index b88e78a..40264dc 100644
--- a/src/ui/components/export-dialog.vitest.tsx
+++ b/src/ui/components/export-dialog.vitest.tsx
@@ -41,7 +41,12 @@ describe('ExportDialog', () => {
vi.stubGlobal('fetch', fetchMock);
vi.stubGlobal('URL', { ...URL, createObjectURL: vi.fn(() => 'blob:evidence'), revokeObjectURL: vi.fn() });
const anchorClick = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => undefined);
- const downloadTextFile = vi.spyOn(download, 'downloadTextFile').mockImplementation(() => undefined);
+ const downloadTextFile = vi
+ .spyOn(download, 'downloadTextFile')
+ .mockImplementation((_fileName, _content, _mimeType, options) => {
+ options?.onStateChange?.('ready');
+ options?.onStateChange?.('downloading');
+ });
try {
render(
@@ -68,7 +73,9 @@ describe('ExportDialog', () => {
'codex-thread-1-focused-evidence.md',
'# Focused evidence: Thread 1\n',
'text/markdown; charset=utf-8',
+ { onStateChange: expect.any(Function) },
);
+ expect(screen.getByRole('status').textContent).toBe('Starting download...');
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
anchorClick.mockRestore();
diff --git a/src/ui/lib/antigravity-server.ts b/src/ui/lib/antigravity-server.ts
index 8af34ef..6f11e83 100644
--- a/src/ui/lib/antigravity-server.ts
+++ b/src/ui/lib/antigravity-server.ts
@@ -2,7 +2,11 @@ import type { AntigravityConversation } from '@spiracha/lib/antigravity-exporter
import { buildConversationExportBaseName } from '@spiracha/lib/ui-export-archive';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
-import { canExportAntigravityConversation, isAntigravityConversationLocked } from './antigravity-conversation-state';
+import {
+ canExportAntigravityConversation,
+ hasEncryptedAntigravityConversation,
+ isAntigravityConversationLocked,
+} from './antigravity-conversation-state';
import { runDeleteBatch } from './delete-batch';
const workspaceSchema = z.object({
@@ -41,8 +45,8 @@ export const listAntigravityWorkspacesFn = createServerFn({ method: 'GET' }).han
});
export const getAntigravityDecryptionStateFn = createServerFn({ method: 'GET' }).handler(async () => {
- const { getAntigravityDecryptionState } = await import('@spiracha/lib/antigravity-keychain');
- return getAntigravityDecryptionState();
+ const { probeAntigravityDecryptionState } = await import('@spiracha/lib/antigravity-keychain');
+ return probeAntigravityDecryptionState();
});
export const unlockAntigravityDecryptionFn = createServerFn({ method: 'POST' }).handler(async () => {
@@ -88,16 +92,33 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
const { renderAntigravityArtifactsMarkdown, renderAntigravityConversationMarkdown } = await import(
'@spiracha/lib/antigravity-db'
);
- const { getCachedAntigravityKeychainSecret } = await import('@spiracha/lib/antigravity-keychain');
const conversation = await findAntigravityConversationById(conversationId);
- const keychainSecret = getCachedAntigravityKeychainSecret();
- const hasKeychainSecret = Boolean(keychainSecret);
- const transcriptLocked = isAntigravityConversationLocked(conversation, hasKeychainSecret);
+ const isEncrypted = hasEncryptedAntigravityConversation(conversation);
+ const renderConversation = async () => {
+ if (!isEncrypted) {
+ return renderAntigravityConversationMarkdown(conversation);
+ }
+
+ const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
+ return withAntigravityDecryptionCapability((decryptionCapability) =>
+ renderAntigravityConversationMarkdown(conversation, { decryptionCapability }),
+ );
+ };
+ let renderedConversationMarkdown: string | null = null;
+ let transcriptLocked = false;
+ try {
+ renderedConversationMarkdown = await renderConversation();
+ } catch (error) {
+ if (!isEncrypted) {
+ throw error;
+ }
+ transcriptLocked = true;
+ }
const [[conversationMarkdown, artifactsMarkdown], conversationGroup] = await Promise.all([
runWithTranscriptLoadLimit(
async () =>
Promise.all([
- transcriptLocked ? null : renderAntigravityConversationMarkdown(conversation, { keychainSecret }),
+ transcriptLocked ? null : renderedConversationMarkdown,
conversation.artifactCount > 0 ? renderAntigravityArtifactsMarkdown(conversation) : null,
]),
{
@@ -134,13 +155,11 @@ export const loadAntigravityConversationExport = async (
) => {
const { runWithTranscriptLoadLimit } = await import('@spiracha/lib/transcript-load-limiter');
const { renderAntigravityConversationMarkdown } = await import('@spiracha/lib/antigravity-db');
- const { getCachedAntigravityKeychainSecret } = await import('@spiracha/lib/antigravity-keychain');
const conversation = loadedConversation ?? (await findAntigravityConversationById(conversationId));
- const keychainSecret = getCachedAntigravityKeychainSecret();
- const hasKeychainSecret = Boolean(keychainSecret);
+ const isEncrypted = hasEncryptedAntigravityConversation(conversation);
- if (!canExportAntigravityConversation(conversation, hasKeychainSecret)) {
- if (isAntigravityConversationLocked(conversation, hasKeychainSecret)) {
+ if (!canExportAntigravityConversation(conversation, false) && !isEncrypted) {
+ if (isAntigravityConversationLocked(conversation, true)) {
throw new Error('Unlock Antigravity Keychain access before exporting transcript logs.');
}
@@ -148,14 +167,27 @@ export const loadAntigravityConversationExport = async (
}
const content = await runWithTranscriptLoadLimit(
- () =>
- renderAntigravityConversationMarkdown(conversation, {
+ async () => {
+ const renderOptions = {
includeCommentary: options.includeCommentary,
includeMetadata: options.includeMetadata,
includeTools: options.includeTools,
- keychainSecret,
outputFormat: options.outputFormat,
- }),
+ };
+ try {
+ if (!isEncrypted) {
+ return renderAntigravityConversationMarkdown(conversation, renderOptions);
+ }
+
+ const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
+ return withAntigravityDecryptionCapability((decryptionCapability) =>
+ renderAntigravityConversationMarkdown(conversation, { ...renderOptions, decryptionCapability }),
+ );
+ } catch (error) {
+ const message = error instanceof Error ? ` ${error.message}` : '';
+ throw new Error(`Unlock Antigravity Keychain access before exporting transcript logs.${message}`);
+ }
+ },
{
id: conversation.conversationId,
integration: 'antigravity',
diff --git a/src/ui/lib/antigravity-server.vitest.ts b/src/ui/lib/antigravity-server.vitest.ts
index affd7fa..7b38548 100644
--- a/src/ui/lib/antigravity-server.vitest.ts
+++ b/src/ui/lib/antigravity-server.vitest.ts
@@ -3,9 +3,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
deleteAntigravityConversationMock,
- getAntigravityDecryptionStateMock,
+ probeAntigravityDecryptionStateMock,
getAntigravityConversationByIdMock,
- getCachedAntigravityKeychainSecretMock,
listAntigravityConversationsMock,
listAntigravityWorkspaceGroupsMock,
renderAntigravityArtifactsMarkdownMock,
@@ -14,19 +13,20 @@ const {
resolveAntigravityProjectNamesMock,
resolveAntigravityRootsMock,
unlockAntigravityDecryptionMock,
+ withAntigravityDecryptionCapabilityMock,
} = vi.hoisted(() => ({
deleteAntigravityConversationMock: vi.fn(),
getAntigravityConversationByIdMock: vi.fn(),
- getAntigravityDecryptionStateMock: vi.fn(),
- getCachedAntigravityKeychainSecretMock: vi.fn(),
listAntigravityConversationsMock: vi.fn(),
listAntigravityWorkspaceGroupsMock: vi.fn(),
+ probeAntigravityDecryptionStateMock: vi.fn(),
renderAntigravityArtifactsMarkdownMock: vi.fn(),
renderAntigravityConversationMarkdownMock: vi.fn(),
renderSourceSessionsDownloadMock: vi.fn(),
resolveAntigravityProjectNamesMock: vi.fn(),
resolveAntigravityRootsMock: vi.fn(),
unlockAntigravityDecryptionMock: vi.fn(),
+ withAntigravityDecryptionCapabilityMock: vi.fn(),
}));
vi.mock('@tanstack/react-start', () => ({
@@ -56,9 +56,9 @@ vi.mock('@spiracha/lib/antigravity-trajectory', () => ({
}));
vi.mock('@spiracha/lib/antigravity-keychain', () => ({
- getAntigravityDecryptionState: getAntigravityDecryptionStateMock,
- getCachedAntigravityKeychainSecret: getCachedAntigravityKeychainSecretMock,
+ probeAntigravityDecryptionState: probeAntigravityDecryptionStateMock,
unlockAntigravityDecryption: unlockAntigravityDecryptionMock,
+ withAntigravityDecryptionCapability: withAntigravityDecryptionCapabilityMock,
}));
vi.mock('@spiracha/lib/antigravity-projects', () => ({
@@ -118,7 +118,7 @@ const makeConversation = (overrides: Partial = {}): Ant
describe('antigravity-server', () => {
beforeEach(() => {
vi.clearAllMocks();
- getAntigravityDecryptionStateMock.mockResolvedValue(null);
+ probeAntigravityDecryptionStateMock.mockResolvedValue(null);
getAntigravityConversationByIdMock.mockImplementation(async (conversationId: string) =>
(await listAntigravityConversationsMock()).find(
(conversation: AntigravityConversation) => conversation.conversationId === conversationId,
@@ -128,6 +128,9 @@ describe('antigravity-server', () => {
resolveAntigravityProjectNamesMock.mockResolvedValue(new Map());
resolveAntigravityRootsMock.mockReturnValue(['/tmp/root']);
unlockAntigravityDecryptionMock.mockResolvedValue(null);
+ withAntigravityDecryptionCapabilityMock.mockImplementation(async (action: (capability: unknown) => unknown) =>
+ action({ decryptSafeStoragePayload: vi.fn() }),
+ );
renderSourceSessionsDownloadMock.mockImplementation(async ({ entries }) => ({
fileName: `${entries[0]?.cwd}-threads-${entries.length}.zip`,
mode: 'download_url',
@@ -142,7 +145,6 @@ describe('antigravity-server', () => {
transcriptSource,
});
listAntigravityConversationsMock.mockResolvedValue([conversation]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('transcript markdown');
renderAntigravityArtifactsMarkdownMock.mockResolvedValue(null);
@@ -153,6 +155,28 @@ describe('antigravity-server', () => {
},
);
+ it('should reacquire an opaque decryption capability for every protected request', async () => {
+ const conversation = makeConversation({
+ transcriptPath: null,
+ transcriptSource: 'safe-storage',
+ });
+ listAntigravityConversationsMock.mockResolvedValue([conversation]);
+ renderAntigravityConversationMarkdownMock.mockResolvedValue('decrypted transcript');
+ renderAntigravityArtifactsMarkdownMock.mockResolvedValue(null);
+
+ await loadAntigravityConversationDetail(conversation.conversationId);
+ await loadAntigravityConversationExport(conversation.conversationId);
+
+ expect(withAntigravityDecryptionCapabilityMock).toHaveBeenCalledTimes(2);
+ expect(renderAntigravityConversationMarkdownMock).toHaveBeenCalledWith(conversation, {
+ decryptionCapability: expect.objectContaining({ decryptSafeStoragePayload: expect.any(Function) }),
+ });
+ expect(renderAntigravityConversationMarkdownMock).not.toHaveBeenCalledWith(
+ conversation,
+ expect.objectContaining({ keychainSecret: expect.anything() }),
+ );
+ });
+
it('should return the resolved Antigravity project group for detail navigation', async () => {
const projectId = '00ea3331-909e-4010-a208-78f964ecfb59';
const conversation = makeConversation({ projectId });
@@ -170,7 +194,6 @@ describe('antigravity-server', () => {
uri: null,
},
]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('transcript markdown');
renderAntigravityArtifactsMarkdownMock.mockResolvedValue(null);
@@ -221,7 +244,6 @@ describe('antigravity-server', () => {
uri: null,
},
]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('transcript markdown');
const result = await exportAntigravityConversations({
@@ -244,7 +266,6 @@ describe('antigravity-server', () => {
it('should forward every dialog option to Antigravity transcript rendering', async () => {
const conversation = makeConversation();
listAntigravityConversationsMock.mockResolvedValue([conversation]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('plain transcript');
await exportAntigravityConversations({
@@ -260,7 +281,6 @@ describe('antigravity-server', () => {
includeCommentary: false,
includeMetadata: false,
includeTools: false,
- keychainSecret: null,
outputFormat: 'txt',
});
expect(renderSourceSessionsDownloadMock).toHaveBeenCalledWith(
@@ -275,7 +295,6 @@ describe('antigravity-server', () => {
it('should apply every dialog option and download mode to a single conversation export', async () => {
const conversation = makeConversation();
listAntigravityConversationsMock.mockResolvedValue([conversation]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('plain transcript');
const result = await exportAntigravityConversationFn({
@@ -293,7 +312,6 @@ describe('antigravity-server', () => {
includeCommentary: false,
includeMetadata: false,
includeTools: false,
- keychainSecret: null,
outputFormat: 'txt',
});
expect(renderSourceSessionsDownloadMock).toHaveBeenCalledWith(
@@ -312,7 +330,6 @@ describe('antigravity-server', () => {
it('should suppress duplicate conversation markdown when artifacts render the same content', async () => {
const conversation = makeConversation({ artifactCount: 1 });
listAntigravityConversationsMock.mockResolvedValue([conversation]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('# Duplicate\n\nsame body');
renderAntigravityArtifactsMarkdownMock.mockResolvedValue('# Duplicate\n\nsame body');
@@ -332,7 +349,6 @@ describe('antigravity-server', () => {
transcriptSource: null,
});
listAntigravityConversationsMock.mockResolvedValue([conversation]);
- getCachedAntigravityKeychainSecretMock.mockReturnValue(null);
renderAntigravityConversationMarkdownMock.mockResolvedValue('# Artifacts\n\nartifact body');
await expect(loadAntigravityConversationExport(conversation.conversationId)).rejects.toThrow(
diff --git a/src/ui/lib/download.ts b/src/ui/lib/download.ts
index b76610b..dfa087c 100644
--- a/src/ui/lib/download.ts
+++ b/src/ui/lib/download.ts
@@ -1,9 +1,21 @@
+import { useEffect, useRef } from 'react';
+
type DownloadLogger = Pick;
+export type DownloadLifecycleState = 'preparing' | 'ready' | 'downloading' | 'failed' | 'cancelled';
+
+export class DownloadCancelledError extends Error {
+ constructor() {
+ super('Download cancelled');
+ this.name = 'DownloadCancelledError';
+ }
+}
+
type DownloadTextOptions = {
createObjectUrl?: (blob: Blob) => string;
documentRef?: Document;
logger?: DownloadLogger;
+ onStateChange?: (state: DownloadLifecycleState) => void;
revokeDelayMs?: number;
revokeObjectUrl?: (url: string) => void;
schedule?: (callback: () => void, delayMs: number) => void;
@@ -14,10 +26,22 @@ type DownloadUrlOptions = {
fetchImpl?: typeof fetch;
logger?: DownloadLogger;
maxAttempts?: number;
+ onStateChange?: (state: DownloadLifecycleState) => void;
retryDelayMs?: number;
- sleep?: (delayMs: number) => Promise;
+ signal?: AbortSignal;
+ sleep?: (delayMs: number, signal?: AbortSignal) => Promise;
+};
+
+export type DownloadCancellation = {
+ begin: () => AbortSignal;
+ cancel: () => void;
+ finish: (signal: AbortSignal) => void;
+ reset: () => void;
};
+const DOWNLOAD_CANCEL_EVENT = 'spiracha:cancel-active-downloads';
+const DOWNLOAD_RESET_EVENT = 'spiracha:reset-active-downloads';
+
const DEFAULT_DOWNLOAD_ATTEMPTS = 6;
const DEFAULT_DOWNLOAD_RETRY_DELAY_MS = 250;
const DEFAULT_INLINE_REVOKE_DELAY_MS = 30000;
@@ -31,11 +55,65 @@ const logDownloadEvent = (
logger[level](`[spiracha:download] ${event}`, details);
};
-const delay = (delayMs: number) =>
- new Promise((resolve) => {
- window.setTimeout(resolve, delayMs);
+const throwIfAborted = (signal?: AbortSignal) => {
+ if (signal?.aborted) {
+ throw new DownloadCancelledError();
+ }
+};
+
+const raceWithAbort = async (promise: Promise, signal?: AbortSignal): Promise => {
+ throwIfAborted(signal);
+ if (!signal) {
+ return promise;
+ }
+
+ let onAbort: (() => void) | undefined;
+ const abortPromise = new Promise((_, reject) => {
+ onAbort = () => reject(new DownloadCancelledError());
+ signal.addEventListener('abort', onAbort, { once: true });
});
+ try {
+ return await Promise.race([promise, abortPromise]);
+ } finally {
+ if (onAbort) {
+ signal.removeEventListener('abort', onAbort);
+ }
+ }
+};
+
+const delay = (delayMs: number, signal?: AbortSignal) =>
+ new Promise((resolve, reject) => {
+ let timeoutId: number | undefined;
+ const onAbort = () => {
+ if (timeoutId !== undefined) {
+ window.clearTimeout(timeoutId);
+ }
+ signal?.removeEventListener('abort', onAbort);
+ reject(new DownloadCancelledError());
+ };
+
+ timeoutId = window.setTimeout(() => {
+ signal?.removeEventListener('abort', onAbort);
+ resolve();
+ }, delayMs);
+
+ if (signal?.aborted) {
+ onAbort();
+ } else {
+ signal?.addEventListener('abort', onAbort, { once: true });
+ }
+ });
+
+const isAbortError = (error: unknown) => error instanceof Error && error.name === 'AbortError';
+
+const asCancellationError = (error: unknown, signal?: AbortSignal) => {
+ if (error instanceof DownloadCancelledError || signal?.aborted || isAbortError(error)) {
+ return error instanceof DownloadCancelledError ? error : new DownloadCancelledError();
+ }
+ return null;
+};
+
const triggerAnchorDownload = (documentRef: Document, href: string, fileName: string) => {
const link = documentRef.createElement('a');
link.href = href;
@@ -49,51 +127,141 @@ const isReadyStatus = (status: number) => {
return (status >= 200 && status < 400) || status === 405;
};
-export const waitForDownloadUrlAvailability = async (
+export const createDownloadCancellation = (): DownloadCancellation => {
+ let activeController: AbortController | null = null;
+ let cancellationRequested = false;
+
+ return {
+ begin: () => {
+ activeController?.abort();
+ activeController = new AbortController();
+ if (cancellationRequested) {
+ activeController.abort();
+ }
+ return activeController.signal;
+ },
+ cancel: () => {
+ cancellationRequested = true;
+ activeController?.abort();
+ activeController = null;
+ },
+ finish: (signal) => {
+ if (activeController?.signal === signal) {
+ activeController = null;
+ }
+ },
+ reset: () => {
+ cancellationRequested = false;
+ },
+ };
+};
+
+export const useDownloadCancellation = () => {
+ const cancellationRef = useRef(null);
+ if (!cancellationRef.current) {
+ cancellationRef.current = createDownloadCancellation();
+ }
+
+ const cancellation = cancellationRef.current;
+ useEffect(() => {
+ const cancelActiveDownload = () => cancellation.cancel();
+ const resetActiveDownload = () => cancellation.reset();
+ window.addEventListener(DOWNLOAD_CANCEL_EVENT, cancelActiveDownload);
+ window.addEventListener(DOWNLOAD_RESET_EVENT, resetActiveDownload);
+ return () => {
+ window.removeEventListener(DOWNLOAD_CANCEL_EVENT, cancelActiveDownload);
+ window.removeEventListener(DOWNLOAD_RESET_EVENT, resetActiveDownload);
+ cancellation.cancel();
+ };
+ }, [cancellation]);
+
+ return cancellation;
+};
+
+export const cancelActiveDownloads = () => {
+ window.dispatchEvent(new Event(DOWNLOAD_CANCEL_EVENT));
+};
+
+export const resetActiveDownloads = () => {
+ window.dispatchEvent(new Event(DOWNLOAD_RESET_EVENT));
+};
+
+const probeDownloadUrl = async (
downloadUrl: string,
fileName: string,
{
+ attempt,
fetchImpl = fetch,
logger = console,
- maxAttempts = DEFAULT_DOWNLOAD_ATTEMPTS,
- retryDelayMs = DEFAULT_DOWNLOAD_RETRY_DELAY_MS,
- sleep = delay,
- }: Omit = {},
+ signal,
+ }: Pick & { attempt: number },
) => {
- for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
- try {
- const response = await fetchImpl(downloadUrl, {
- cache: 'no-store',
- method: 'HEAD',
- });
-
- if (isReadyStatus(response.status)) {
- logDownloadEvent(logger, 'info', 'url_ready', {
- attempt,
- downloadUrl,
- fileName,
- status: response.status,
- });
- return;
- }
+ const requestInit: RequestInit = {
+ cache: 'no-store',
+ method: 'HEAD',
+ };
+ if (signal) {
+ requestInit.signal = signal;
+ }
- logDownloadEvent(logger, 'warn', 'url_not_ready', {
+ try {
+ const response = await raceWithAbort(fetchImpl(downloadUrl, requestInit), signal);
+ if (isReadyStatus(response.status)) {
+ logDownloadEvent(logger, 'info', 'url_ready', {
attempt,
downloadUrl,
fileName,
status: response.status,
});
- } catch (error) {
- logDownloadEvent(logger, 'warn', 'url_probe_failed', {
- attempt,
- downloadUrl,
- error: error instanceof Error ? error.message : String(error),
- fileName,
- });
+ return true;
+ }
+
+ logDownloadEvent(logger, 'warn', 'url_not_ready', {
+ attempt,
+ downloadUrl,
+ fileName,
+ status: response.status,
+ });
+ return false;
+ } catch (error) {
+ const cancellationError = asCancellationError(error, signal);
+ if (cancellationError) {
+ throw cancellationError;
+ }
+
+ logDownloadEvent(logger, 'warn', 'url_probe_failed', {
+ attempt,
+ downloadUrl,
+ error: error instanceof Error ? error.message : String(error),
+ fileName,
+ });
+ return false;
+ }
+};
+
+export const waitForDownloadUrlAvailability = async (
+ downloadUrl: string,
+ fileName: string,
+ {
+ fetchImpl = fetch,
+ logger = console,
+ maxAttempts = DEFAULT_DOWNLOAD_ATTEMPTS,
+ retryDelayMs = DEFAULT_DOWNLOAD_RETRY_DELAY_MS,
+ signal,
+ sleep = delay,
+ }: Omit = {},
+) => {
+ for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
+ throwIfAborted(signal);
+ const ready = await probeDownloadUrl(downloadUrl, fileName, { attempt, fetchImpl, logger, signal });
+ if (ready) {
+ return;
}
if (attempt < maxAttempts) {
- await sleep(retryDelayMs);
+ throwIfAborted(signal);
+ const sleepPromise = signal ? sleep(retryDelayMs, signal) : sleep(retryDelayMs);
+ await raceWithAbort(sleepPromise, signal);
}
}
@@ -108,7 +276,9 @@ export const downloadUrlFile = async (
fetchImpl = fetch,
logger = console,
maxAttempts = DEFAULT_DOWNLOAD_ATTEMPTS,
+ onStateChange,
retryDelayMs = DEFAULT_DOWNLOAD_RETRY_DELAY_MS,
+ signal,
sleep = delay,
}: DownloadUrlOptions = {},
) => {
@@ -117,19 +287,45 @@ export const downloadUrlFile = async (
fileName,
});
- await waitForDownloadUrlAvailability(downloadUrl, fileName, {
- fetchImpl,
- logger,
- maxAttempts,
- retryDelayMs,
- sleep,
- });
+ onStateChange?.('preparing');
+ try {
+ await waitForDownloadUrlAvailability(downloadUrl, fileName, {
+ fetchImpl,
+ logger,
+ maxAttempts,
+ retryDelayMs,
+ signal,
+ sleep,
+ });
+ throwIfAborted(signal);
+ onStateChange?.('ready');
+ throwIfAborted(signal);
+ onStateChange?.('downloading');
+ throwIfAborted(signal);
+ triggerAnchorDownload(documentRef, downloadUrl, fileName);
+ logDownloadEvent(logger, 'info', 'triggered', {
+ downloadUrl,
+ fileName,
+ });
+ } catch (error) {
+ const cancellationError = asCancellationError(error, signal);
+ onStateChange?.(cancellationError ? 'cancelled' : 'failed');
+ throw cancellationError ?? error;
+ }
+};
- triggerAnchorDownload(documentRef, downloadUrl, fileName);
- logDownloadEvent(logger, 'info', 'triggered', {
- downloadUrl,
- fileName,
- });
+export const downloadUrlFileWithCancellation = async (
+ cancellation: DownloadCancellation,
+ fileName: string,
+ downloadUrl: string,
+ options: Omit = {},
+) => {
+ const signal = cancellation.begin();
+ try {
+ return await downloadUrlFile(fileName, downloadUrl, { ...options, signal });
+ } finally {
+ cancellation.finish(signal);
+ }
};
export const downloadTextFile = (
@@ -140,6 +336,7 @@ export const downloadTextFile = (
createObjectUrl = (blob) => URL.createObjectURL(blob),
documentRef = document,
logger = console,
+ onStateChange,
revokeDelayMs = DEFAULT_INLINE_REVOKE_DELAY_MS,
revokeObjectUrl = (url) => URL.revokeObjectURL(url),
schedule = (callback, delayMs) => {
@@ -153,14 +350,21 @@ export const downloadTextFile = (
sizeBytes: content.length,
});
- const blob = new Blob([content], { type: mimeType });
- const url = createObjectUrl(blob);
- triggerAnchorDownload(documentRef, url, fileName);
- schedule(() => revokeObjectUrl(url), revokeDelayMs);
+ try {
+ const blob = new Blob([content], { type: mimeType });
+ const url = createObjectUrl(blob);
+ onStateChange?.('ready');
+ onStateChange?.('downloading');
+ triggerAnchorDownload(documentRef, url, fileName);
+ schedule(() => revokeObjectUrl(url), revokeDelayMs);
- logDownloadEvent(logger, 'info', 'inline_triggered', {
- fileName,
- mimeType,
- sizeBytes: content.length,
- });
+ logDownloadEvent(logger, 'info', 'inline_triggered', {
+ fileName,
+ mimeType,
+ sizeBytes: content.length,
+ });
+ } catch (error) {
+ onStateChange?.('failed');
+ throw error;
+ }
};
diff --git a/src/ui/lib/download.vitest.ts b/src/ui/lib/download.vitest.ts
index 683ad28..5dd8fec 100644
--- a/src/ui/lib/download.vitest.ts
+++ b/src/ui/lib/download.vitest.ts
@@ -1,7 +1,47 @@
import { describe, expect, it, vi } from 'vitest';
-import { downloadTextFile, downloadUrlFile, waitForDownloadUrlAvailability } from './download';
+import {
+ createDownloadCancellation,
+ DownloadCancelledError,
+ downloadTextFile,
+ downloadUrlFile,
+ waitForDownloadUrlAvailability,
+} from './download';
describe('downloadUrlFile', () => {
+ it('should cancel the active download when its owner unmounts', () => {
+ const cancellation = createDownloadCancellation();
+ const signal = cancellation.begin();
+
+ cancellation.cancel();
+
+ expect(signal.aborted).toBe(true);
+ });
+
+ it('should leave a replacement download active when an older one finishes', () => {
+ const cancellation = createDownloadCancellation();
+ const firstSignal = cancellation.begin();
+ const secondSignal = cancellation.begin();
+
+ cancellation.finish(firstSignal);
+
+ expect(firstSignal.aborted).toBe(true);
+ expect(secondSignal.aborted).toBe(false);
+ cancellation.cancel();
+ expect(secondSignal.aborted).toBe(true);
+ });
+
+ it('should remember cancellation until the owner explicitly resets it', () => {
+ const cancellation = createDownloadCancellation();
+
+ cancellation.cancel();
+ const cancelledSignal = cancellation.begin();
+ cancellation.reset();
+ const activeSignal = cancellation.begin();
+
+ expect(cancelledSignal.aborted).toBe(true);
+ expect(activeSignal.aborted).toBe(false);
+ });
+
it('should retry the download url until it becomes available before clicking the anchor', async () => {
const fetchImpl = vi
.fn()
@@ -94,4 +134,108 @@ describe('downloadUrlFile', () => {
}),
).rejects.toThrow('Download file was not available after 2 attempts: missing.zip');
});
+
+ it('should abort a pending HEAD fetch without probing again or creating an anchor', async () => {
+ const controller = new AbortController();
+ const fetchImpl = vi.fn();
+ fetchImpl.mockReturnValue(new Promise(() => {}));
+ const anchorClick = vi.fn();
+ const documentRef = {
+ body: { append: vi.fn() },
+ createElement: vi.fn(() => ({
+ click: anchorClick,
+ download: '',
+ href: '',
+ remove: vi.fn(),
+ })),
+ } as unknown as Document;
+
+ const download = downloadUrlFile('export.zip', '/__exports/export.zip', {
+ documentRef,
+ fetchImpl,
+ maxAttempts: 3,
+ signal: controller.signal,
+ });
+ controller.abort();
+
+ await expect(download).rejects.toBeInstanceOf(DownloadCancelledError);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ expect(fetchImpl).toHaveBeenCalledWith('/__exports/export.zip', {
+ cache: 'no-store',
+ method: 'HEAD',
+ signal: controller.signal,
+ });
+ expect(anchorClick).not.toHaveBeenCalled();
+ });
+
+ it('should abort during retry backoff without probing again or creating an anchor', async () => {
+ const controller = new AbortController();
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
+ const sleep = vi.fn(() => new Promise(() => {}));
+ const anchorClick = vi.fn();
+ const documentRef = {
+ body: { append: vi.fn() },
+ createElement: vi.fn(() => ({
+ click: anchorClick,
+ download: '',
+ href: '',
+ remove: vi.fn(),
+ })),
+ } as unknown as Document;
+
+ const download = downloadUrlFile('export.zip', '/__exports/export.zip', {
+ documentRef,
+ fetchImpl,
+ maxAttempts: 3,
+ signal: controller.signal,
+ sleep,
+ });
+ await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1));
+ controller.abort();
+
+ await expect(download).rejects.toBeInstanceOf(DownloadCancelledError);
+ expect(fetchImpl).toHaveBeenCalledTimes(1);
+ expect(anchorClick).not.toHaveBeenCalled();
+ });
+
+ it('should report the observable download lifecycle in order', async () => {
+ const states: string[] = [];
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 200 }));
+
+ await downloadUrlFile('export.zip', '/__exports/export.zip', {
+ documentRef: {
+ body: { append: vi.fn() },
+ createElement: vi.fn(() => ({
+ click: vi.fn(),
+ download: '',
+ href: '',
+ remove: vi.fn(),
+ })),
+ } as unknown as Document,
+ fetchImpl,
+ onStateChange: (state) => states.push(state),
+ });
+
+ expect(states).toEqual(['preparing', 'ready', 'downloading']);
+ });
+
+ it('should report cancellation instead of downloading when aborted during backoff', async () => {
+ const controller = new AbortController();
+ const states: string[] = [];
+ const fetchImpl = vi.fn().mockResolvedValue(new Response(null, { status: 404 }));
+ const sleep = vi.fn(() => new Promise(() => {}));
+
+ const download = downloadUrlFile('export.zip', '/__exports/export.zip', {
+ fetchImpl,
+ maxAttempts: 2,
+ onStateChange: (state) => states.push(state),
+ signal: controller.signal,
+ sleep,
+ });
+ await vi.waitFor(() => expect(sleep).toHaveBeenCalledTimes(1));
+ controller.abort();
+
+ await expect(download).rejects.toBeInstanceOf(DownloadCancelledError);
+ expect(states).toEqual(['preparing', 'cancelled']);
+ });
});
diff --git a/src/ui/routes/antigravity-conversations.$conversationId.tsx b/src/ui/routes/antigravity-conversations.$conversationId.tsx
index 2736bcf..5d5d235 100644
--- a/src/ui/routes/antigravity-conversations.$conversationId.tsx
+++ b/src/ui/routes/antigravity-conversations.$conversationId.tsx
@@ -33,7 +33,7 @@ import {
exportAntigravityConversationFn,
type getAntigravityConversationDetailFn,
} from '#/lib/antigravity-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatBytes, formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import {
@@ -249,6 +249,7 @@ function AntigravityRawPanels({
}
function AntigravityConversationDetailPage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const transcriptSearch = Route.useSearch();
@@ -292,7 +293,7 @@ function AntigravityConversationDetailPage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setExportOpen(false);
diff --git a/src/ui/routes/antigravity.$workspaceKey.tsx b/src/ui/routes/antigravity.$workspaceKey.tsx
index d816084..a3bb527 100644
--- a/src/ui/routes/antigravity.$workspaceKey.tsx
+++ b/src/ui/routes/antigravity.$workspaceKey.tsx
@@ -23,7 +23,7 @@ import {
exportAntigravityArtifactsFn,
exportAntigravityConversationsFn,
} from '#/lib/antigravity-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { matchesTextQuery } from '#/lib/text-filter';
import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
@@ -129,6 +129,7 @@ function AntigravityWorkspaceErrorComponent({ error }: { error: Error }) {
}
function AntigravityWorkspacePage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -167,7 +168,7 @@ function AntigravityWorkspacePage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/claude-code-sessions.$sessionId.tsx b/src/ui/routes/claude-code-sessions.$sessionId.tsx
index d2a2668..3bd04f7 100644
--- a/src/ui/routes/claude-code-sessions.$sessionId.tsx
+++ b/src/ui/routes/claude-code-sessions.$sessionId.tsx
@@ -33,7 +33,7 @@ import {
claudeCodeTranscriptToThreadEvents,
getClaudeCodeThreadTranscriptStats,
} from '#/lib/claude-code-transcript-events';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatModelLabel, formatNumber, formatTokens } from '#/lib/formatters';
import { getMutationErrorMessage } from '#/lib/mutation-error';
@@ -180,6 +180,7 @@ const ClaudeCodeTranscriptPreviewNotice = ({
};
function ClaudeCodeSessionDetailPage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const transcriptSearch = Route.useSearch();
const transcriptDisplay = getTranscriptDisplayState(transcriptSearch);
@@ -263,7 +264,7 @@ function ClaudeCodeSessionDetailPage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/claude-code.$workspaceKey.tsx b/src/ui/routes/claude-code.$workspaceKey.tsx
index 76f544b..6988423 100644
--- a/src/ui/routes/claude-code.$workspaceKey.tsx
+++ b/src/ui/routes/claude-code.$workspaceKey.tsx
@@ -16,7 +16,7 @@ import {
exportClaudeCodeSessionFn,
exportClaudeCodeSessionsFn,
} from '#/lib/claude-code-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { matchesTextQuery } from '#/lib/text-filter';
import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
@@ -87,6 +87,7 @@ function ClaudeCodeWorkspaceErrorComponent({ error }: { error: Error }) {
}
function ClaudeCodeWorkspacePage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -127,7 +128,7 @@ function ClaudeCodeWorkspacePage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/cline-tasks.$taskId.tsx b/src/ui/routes/cline-tasks.$taskId.tsx
index 0a057d8..9052cad 100644
--- a/src/ui/routes/cline-tasks.$taskId.tsx
+++ b/src/ui/routes/cline-tasks.$taskId.tsx
@@ -20,7 +20,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
import { clineTaskDetailQueryOptions, clineWorkspacesQueryOptions } from '#/lib/cline-queries';
import { deleteClineTaskFn, exportClineTaskFn } from '#/lib/cline-server';
import { clineTranscriptToThreadEvents, getClineThreadTranscriptStats } from '#/lib/cline-transcript-events';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import { RouteStateResetBoundary } from '#/lib/route-state-reset';
@@ -60,6 +60,7 @@ const statsItems = (detail: ClineTaskTranscript, events: ThreadEvent[], stats: T
];
const ClineTaskDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const detail = useSuspenseQuery(clineTaskDetailQueryOptions(Route.useParams().taskId)).data;
@@ -78,7 +79,7 @@ const ClineTaskDetailPage = () => {
if (download.mode === 'download') {
downloadTextFile(download.fileName, download.content, download.mimeType);
} else {
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
}
},
onSuccess: () => setExportOpen(false),
diff --git a/src/ui/routes/cline.$workspaceKey.tsx b/src/ui/routes/cline.$workspaceKey.tsx
index 969ca03..a3b511e 100644
--- a/src/ui/routes/cline.$workspaceKey.tsx
+++ b/src/ui/routes/cline.$workspaceKey.tsx
@@ -13,7 +13,7 @@ import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
import { clineTasksQueryOptions, clineWorkspacesQueryOptions } from '#/lib/cline-queries';
import { deleteClineTaskFn, deleteClineTasksFn, exportClineTaskFn, exportClineTasksFn } from '#/lib/cline-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { matchesTextQuery } from '#/lib/text-filter';
import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
@@ -27,6 +27,7 @@ const findWorkspace = (workspaces: ClineWorkspaceGroup[], key: string) => {
};
const ClineWorkspacePage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const workspaces = useSuspenseQuery(clineWorkspacesQueryOptions()).data;
@@ -65,7 +66,7 @@ const ClineWorkspacePage = () => {
if (download.mode === 'download') {
downloadTextFile(download.fileName, download.content, download.mimeType);
} else {
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
}
},
onSuccess: () => setExportTasks([]),
diff --git a/src/ui/routes/codex.$project.tsx b/src/ui/routes/codex.$project.tsx
index 1554310..bfc8986 100644
--- a/src/ui/routes/codex.$project.tsx
+++ b/src/ui/routes/codex.$project.tsx
@@ -19,7 +19,7 @@ import {
exportThreadsFn,
recoverProjectThreadsFn,
} from '#/lib/codex-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { parseTextQuerySearch, withTextQuerySearch } from '#/lib/route-search';
@@ -115,6 +115,7 @@ const getThreadExportErrorMessage = (error: unknown): string | null => {
};
function ProjectDetailPage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const project = useMemo(() => decodeProjectParam(params.project), [params.project]);
@@ -209,7 +210,7 @@ function ProjectDetailPage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onError: (error, variables) => {
console.error('[spiracha:export-ui] failed', {
diff --git a/src/ui/routes/cursor-threads.$composerId.tsx b/src/ui/routes/cursor-threads.$composerId.tsx
index 1e722c4..2e4ff9e 100644
--- a/src/ui/routes/cursor-threads.$composerId.tsx
+++ b/src/ui/routes/cursor-threads.$composerId.tsx
@@ -19,7 +19,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
import { cursorThreadDetailQueryOptions, cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
import { deleteCursorThreadsFn, exportCursorThreadFn, type getCursorThreadDetailFn } from '#/lib/cursor-server';
import { cursorTranscriptToThreadEvents, getCursorThreadTranscriptStats } from '#/lib/cursor-transcript-events';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatBytes, formatDateTime, formatList, formatModelLabel, formatNumber } from '#/lib/formatters';
import {
@@ -141,6 +141,7 @@ const CursorThreadDetailErrorComponent = ({ error }: { error: Error }) => {
};
const CursorThreadDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const transcriptSearch = Route.useSearch();
@@ -201,7 +202,7 @@ const CursorThreadDetailPage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/cursor.$workspaceKey.tsx b/src/ui/routes/cursor.$workspaceKey.tsx
index 72165b0..d42f9fa 100644
--- a/src/ui/routes/cursor.$workspaceKey.tsx
+++ b/src/ui/routes/cursor.$workspaceKey.tsx
@@ -19,7 +19,7 @@ import {
exportCursorThreadsFn,
recoverCursorWorkspaceFn,
} from '#/lib/cursor-server';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { matchesTextQuery } from '#/lib/text-filter';
@@ -112,6 +112,7 @@ const CursorWorkspaceErrorComponent = ({ error }: { error: Error }) => {
};
const CursorWorkspacePage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate();
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -188,7 +189,7 @@ const CursorWorkspacePage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/grok-sessions.$sessionId.tsx b/src/ui/routes/grok-sessions.$sessionId.tsx
index 748a1b1..b39ad47 100644
--- a/src/ui/routes/grok-sessions.$sessionId.tsx
+++ b/src/ui/routes/grok-sessions.$sessionId.tsx
@@ -17,7 +17,7 @@ import { TranscriptControls } from '#/components/transcript-controls';
import { TranscriptView } from '#/components/transcript-view';
import { Button } from '#/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import { grokSessionDetailQueryOptions, grokWorkspacesQueryOptions } from '#/lib/grok-queries';
@@ -87,6 +87,7 @@ const GrokRawPanels = ({ detail, events }: { detail: GrokSessionTranscript; even
};
const GrokSessionDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const transcriptSearch = Route.useSearch();
@@ -121,7 +122,7 @@ const GrokSessionDetailPage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/grok.$workspaceKey.tsx b/src/ui/routes/grok.$workspaceKey.tsx
index cfcb425..d81f989 100644
--- a/src/ui/routes/grok.$workspaceKey.tsx
+++ b/src/ui/routes/grok.$workspaceKey.tsx
@@ -11,7 +11,7 @@ import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { grokSessionsQueryOptions, grokWorkspacesQueryOptions } from '#/lib/grok-queries';
import {
@@ -101,6 +101,7 @@ function GrokWorkspaceErrorComponent({ error }: { error: Error }) {
}
function GrokWorkspacePage() {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -141,7 +142,7 @@ function GrokWorkspacePage() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/kiro-sessions.$sessionId.tsx b/src/ui/routes/kiro-sessions.$sessionId.tsx
index 2ab1d1e..965185a 100644
--- a/src/ui/routes/kiro-sessions.$sessionId.tsx
+++ b/src/ui/routes/kiro-sessions.$sessionId.tsx
@@ -17,7 +17,7 @@ import { TranscriptControls } from '#/components/transcript-controls';
import { TranscriptView } from '#/components/transcript-view';
import { Button } from '#/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import { kiroSessionDetailQueryOptions, kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
@@ -110,6 +110,7 @@ const KiroRawPanels = ({ detail, events }: { detail: KiroSessionTranscript; even
};
const KiroSessionDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const transcriptSearch = Route.useSearch();
const transcriptDisplay = getTranscriptDisplayState(transcriptSearch);
@@ -146,7 +147,7 @@ const KiroSessionDetailPage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/kiro.$workspaceKey.tsx b/src/ui/routes/kiro.$workspaceKey.tsx
index e5c6d08..1f4f0f6 100644
--- a/src/ui/routes/kiro.$workspaceKey.tsx
+++ b/src/ui/routes/kiro.$workspaceKey.tsx
@@ -11,7 +11,7 @@ import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { kiroSessionsQueryOptions, kiroWorkspacesQueryOptions } from '#/lib/kiro-queries';
import {
@@ -90,6 +90,7 @@ const KiroWorkspaceErrorComponent = ({ error }: { error: Error }) => {
};
const KiroWorkspacePage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -130,7 +131,7 @@ const KiroWorkspacePage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/minimax-code-sessions.$sessionId.tsx b/src/ui/routes/minimax-code-sessions.$sessionId.tsx
index 18a51a4..614e50a 100644
--- a/src/ui/routes/minimax-code-sessions.$sessionId.tsx
+++ b/src/ui/routes/minimax-code-sessions.$sessionId.tsx
@@ -17,7 +17,7 @@ import { TranscriptControls } from '#/components/transcript-controls';
import { DEFAULT_SHOW_USER_MESSAGES, TranscriptView } from '#/components/transcript-view';
import { Button } from '#/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import { miniMaxCodeSessionDetailQueryOptions, miniMaxCodeWorkspacesQueryOptions } from '#/lib/minimax-code-queries';
@@ -75,6 +75,7 @@ const buildTranscriptStatsItems = (
];
const MiniMaxCodeSessionDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const detail = useSuspenseQuery(miniMaxCodeSessionDetailQueryOptions(Route.useParams().sessionId)).data;
@@ -103,7 +104,7 @@ const MiniMaxCodeSessionDetailPage = () => {
downloadTextFile(download.fileName, download.content, download.mimeType);
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => setPendingExport(false),
});
diff --git a/src/ui/routes/minimax-code.$workspaceKey.tsx b/src/ui/routes/minimax-code.$workspaceKey.tsx
index ca3c618..4adf85e 100644
--- a/src/ui/routes/minimax-code.$workspaceKey.tsx
+++ b/src/ui/routes/minimax-code.$workspaceKey.tsx
@@ -11,7 +11,7 @@ import { MiniMaxCodeSessionsTable } from '#/components/minimax-code-sessions-tab
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { miniMaxCodeSessionsQueryOptions, miniMaxCodeWorkspacesQueryOptions } from '#/lib/minimax-code-queries';
import {
@@ -84,6 +84,7 @@ const MiniMaxCodeWorkspaceErrorComponent = ({ error }: { error: Error }) => {
};
const MiniMaxCodeWorkspacePage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const params = Route.useParams();
const queryClient = useQueryClient();
@@ -140,7 +141,7 @@ const MiniMaxCodeWorkspacePage = () => {
downloadTextFile(download.fileName, download.content, download.mimeType);
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => setPendingExport(null),
});
diff --git a/src/ui/routes/opencode-sessions.$sessionId.tsx b/src/ui/routes/opencode-sessions.$sessionId.tsx
index ed6cec5..b652bf1 100644
--- a/src/ui/routes/opencode-sessions.$sessionId.tsx
+++ b/src/ui/routes/opencode-sessions.$sessionId.tsx
@@ -17,7 +17,7 @@ import { TranscriptControls } from '#/components/transcript-controls';
import { TranscriptView } from '#/components/transcript-view';
import { Button } from '#/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber, formatTokens } from '#/lib/formatters';
import { openCodeSessionDetailQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
@@ -87,6 +87,7 @@ const OpenCodeRawPanels = ({ detail, events }: { detail: OpenCodeSessionTranscri
};
const OpenCodeSessionDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const transcriptSearch = Route.useSearch();
const transcriptDisplay = getTranscriptDisplayState(transcriptSearch);
@@ -127,7 +128,7 @@ const OpenCodeSessionDetailPage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/opencode.$workspaceKey.tsx b/src/ui/routes/opencode.$workspaceKey.tsx
index 3589e46..b2291ad 100644
--- a/src/ui/routes/opencode.$workspaceKey.tsx
+++ b/src/ui/routes/opencode.$workspaceKey.tsx
@@ -11,7 +11,7 @@ import { OpenCodeSessionsTable } from '#/components/opencode-sessions-table';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { openCodeSessionsQueryOptions, openCodeWorkspacesQueryOptions } from '#/lib/opencode-queries';
import {
@@ -118,6 +118,7 @@ function OpenCodeWorkspaceContent({
sessions: OpenCodeSessionSummary[];
workspace: OpenCodeWorkspaceGroup;
}) {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const queryClient = useQueryClient();
const [searchInput, setSearchInput] = useState('');
@@ -154,7 +155,7 @@ function OpenCodeWorkspaceContent({
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/qoder-sessions.$sessionId.tsx b/src/ui/routes/qoder-sessions.$sessionId.tsx
index 151b513..dcb6f03 100644
--- a/src/ui/routes/qoder-sessions.$sessionId.tsx
+++ b/src/ui/routes/qoder-sessions.$sessionId.tsx
@@ -16,7 +16,7 @@ import { TranscriptControls } from '#/components/transcript-controls';
import { TranscriptView } from '#/components/transcript-view';
import { Button } from '#/components/ui/button';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '#/components/ui/tabs';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import type { ExportDialogOptions } from '#/lib/export-options';
import { formatDateTime, formatList, formatNumber } from '#/lib/formatters';
import { qoderSessionDetailQueryOptions } from '#/lib/qoder-queries';
@@ -94,6 +94,7 @@ const QoderRawPanels = ({ detail, events }: { detail: QoderSessionTranscript; ev
};
const QoderSessionDetailPage = () => {
+ const downloadCancellation = useDownloadCancellation();
const navigate = useNavigate({ from: Route.fullPath });
const transcriptSearch = Route.useSearch();
const transcriptDisplay = getTranscriptDisplayState(transcriptSearch);
@@ -127,7 +128,7 @@ const QoderSessionDetailPage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(false);
diff --git a/src/ui/routes/qoder.$workspaceKey.tsx b/src/ui/routes/qoder.$workspaceKey.tsx
index d92ac72..cb7e1ab 100644
--- a/src/ui/routes/qoder.$workspaceKey.tsx
+++ b/src/ui/routes/qoder.$workspaceKey.tsx
@@ -8,7 +8,7 @@ import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { QoderSessionsTable } from '#/components/qoder-sessions-table';
import { RouteErrorPanel } from '#/components/route-error-panel';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { createExportSelectionMutationInput, type ExportSelectionMutationInput } from '#/lib/export-mutation';
import { qoderSessionsQueryOptions, qoderWorkspacesQueryOptions } from '#/lib/qoder-queries';
import { exportQoderSessionFn, exportQoderSessionsFn } from '#/lib/qoder-server';
@@ -33,6 +33,7 @@ const QoderWorkspaceErrorComponent = ({ error }: { error: Error }) => {
};
const QoderWorkspacePage = () => {
+ const downloadCancellation = useDownloadCancellation();
const params = Route.useParams();
const workspaces = useSuspenseQuery(qoderWorkspacesQueryOptions()).data;
const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
@@ -70,7 +71,7 @@ const QoderWorkspacePage = () => {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onSuccess: () => {
setPendingExport(null);
diff --git a/src/ui/routes/threads.$threadId.tsx b/src/ui/routes/threads.$threadId.tsx
index 0af5cdd..5a20715 100644
--- a/src/ui/routes/threads.$threadId.tsx
+++ b/src/ui/routes/threads.$threadId.tsx
@@ -33,7 +33,7 @@ import {
import { deleteThreadFn, exportThreadFn, type getThreadSnapshotFn } from '#/lib/codex-server';
import { connectCodexThreadLiveUpdates, refreshCodexThreadLiveQueries } from '#/lib/codex-thread-live';
import type { CodexThreadLiveStatus } from '#/lib/codex-thread-live-types';
-import { downloadTextFile, downloadUrlFile } from '#/lib/download';
+import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
import { formatBooleanLabel, formatBytes, formatDateTime, formatList, formatTokens } from '#/lib/formatters';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { applyPathTransforms } from '#/lib/path-utils';
@@ -639,6 +639,7 @@ function ThreadToolsTab({
}
function ThreadDetailPageContent() {
+ const downloadCancellation = useDownloadCancellation();
const { navigate, search, transcriptFilters, updateTranscriptSearch } = useThreadTranscriptRouteSearch();
const queryClient = useQueryClient();
const params = Route.useParams();
@@ -742,7 +743,7 @@ function ThreadDetailPageContent() {
return;
}
- await downloadUrlFile(download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
},
onError: (error) => {
console.error('[spiracha:export-ui] failed', {
From ea97e71730fd0be0b6e5bbe166666d7e82009288 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Sun, 16 Aug 2026 02:08:36 -0400
Subject: [PATCH 04/10] Bump version and dependencies
---
bun.lock | 100 ++++++++++----------
package.json | 254 +++++++++++++++++++++++++--------------------------
2 files changed, 177 insertions(+), 177 deletions(-)
diff --git a/bun.lock b/bun.lock
index 73e0816..2d01bc8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,46 +5,46 @@
"": {
"name": "spiracha",
"dependencies": {
- "@tailwindcss/typography": "0.5.20",
- "@tailwindcss/vite": "4.3.3",
- "@tanstack/devtools-vite": "0.8.3",
- "@tanstack/match-sorter-utils": "9.1.2",
- "@tanstack/react-devtools": "0.10.10",
- "@tanstack/react-query": "5.101.4",
- "@tanstack/react-query-devtools": "5.101.4",
- "@tanstack/react-router": "1.170.27",
- "@tanstack/react-router-devtools": "1.167.1",
- "@tanstack/react-router-ssr-query": "1.167.1",
- "@tanstack/react-start": "1.168.44",
- "@tanstack/react-table": "9.1.2",
- "@tanstack/react-virtual": "3.14.9",
- "@tanstack/router-plugin": "1.168.30",
- "@vitejs/plugin-react": "6.0.5",
- "class-variance-authority": "0.7.1",
- "clsx": "2.1.1",
- "fflate": "0.8.3",
- "lucide-react": "1.31.0",
- "radix-ui": "1.6.7",
- "react": "19.2.8",
- "react-dom": "19.2.8",
- "tailwind-merge": "3.6.0",
- "tailwindcss": "4.3.3",
- "tw-animate-css": "1.4.0",
- "vite": "8.2.1",
- "zod": "4.4.3",
+ "@tailwindcss/typography": "latest",
+ "@tailwindcss/vite": "latest",
+ "@tanstack/devtools-vite": "latest",
+ "@tanstack/match-sorter-utils": "latest",
+ "@tanstack/react-devtools": "latest",
+ "@tanstack/react-query": "latest",
+ "@tanstack/react-query-devtools": "latest",
+ "@tanstack/react-router": "latest",
+ "@tanstack/react-router-devtools": "latest",
+ "@tanstack/react-router-ssr-query": "latest",
+ "@tanstack/react-start": "latest",
+ "@tanstack/react-table": "latest",
+ "@tanstack/react-virtual": "latest",
+ "@tanstack/router-plugin": "latest",
+ "@vitejs/plugin-react": "latest",
+ "class-variance-authority": "latest",
+ "clsx": "latest",
+ "fflate": "latest",
+ "lucide-react": "latest",
+ "radix-ui": "latest",
+ "react": "latest",
+ "react-dom": "latest",
+ "tailwind-merge": "latest",
+ "tailwindcss": "latest",
+ "tw-animate-css": "latest",
+ "vite": "latest",
+ "zod": "latest",
},
"devDependencies": {
- "@biomejs/biome": "2.5.8",
- "@testing-library/dom": "10.4.1",
- "@testing-library/react": "16.3.2",
- "@types/bun": "^1.3.14",
- "@types/node": "^26.2.0",
- "@types/react": "19.2.18",
- "@types/react-dom": "19.2.4",
- "@vitest/coverage-v8": "4.1.10",
- "jsdom": "30.0.1",
- "typescript": "^7.0.2",
- "vitest": "4.1.10",
+ "@biomejs/biome": "latest",
+ "@testing-library/dom": "latest",
+ "@testing-library/react": "latest",
+ "@types/bun": "latest",
+ "@types/node": "latest",
+ "@types/react": "latest",
+ "@types/react-dom": "latest",
+ "@vitest/coverage-v8": "latest",
+ "jsdom": "latest",
+ "typescript": "latest",
+ "vitest": "latest",
},
},
},
@@ -429,19 +429,19 @@
"@tanstack/react-query-devtools": ["@tanstack/react-query-devtools@5.101.4", "", { "dependencies": { "@tanstack/query-devtools": "5.101.4" }, "peerDependencies": { "@tanstack/react-query": "^5.101.4", "react": "^18 || ^19" } }, "sha512-VeK2gtmfj7kvRBjtxS7TKxt/6qKhn8VzabY4UiYMr7NV9CddjSRYRgeYyld+NpjAkgMV9dd+2Qdr8ah5I03NeA=="],
- "@tanstack/react-router": ["@tanstack/react-router@1.170.27", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.22", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-Hxl49xzd8ffWd2ZMigqfXZmpySpixWGvjq5zfh2nK2DbzzDH6IGVh+iUuwarK9MJNcnhWPZ85tOoy6o/OmNlww=="],
+ "@tanstack/react-router": ["@tanstack/react-router@1.170.29", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.24", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-xQwakR0ReaUGu3xeXEl2CqzlIXk4i9vxaf/zqdzoELMY0mDZgMOr+xobiA5cQdn8cnovqZgqqUxcwXhsruC+NA=="],
"@tanstack/react-router-devtools": ["@tanstack/react-router-devtools@1.167.1", "", { "dependencies": { "@tanstack/router-devtools-core": "1.168.1" }, "peerDependencies": { "@tanstack/react-router": "^1.170.19", "@tanstack/router-core": "^1.171.16", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" }, "optionalPeers": ["@tanstack/router-core"] }, "sha512-pjfGrmjj4d7naEPM7oshqFfwBoxDPNo/UxltlHH5ePbHsJ+plBhd+JaAewm1ueYOjZ0js9hckjWWDYXpCrSfKw=="],
"@tanstack/react-router-ssr-query": ["@tanstack/react-router-ssr-query@1.167.1", "", { "dependencies": { "@tanstack/router-ssr-query-core": "1.169.1" }, "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/react-query": ">=5.90.0", "@tanstack/react-router": ">=1.127.0", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-W9j5JPnBikyafvuUfykFfHIWod58OAbAAa5leNkXBcoDoocghMmu6w9uZOmUZvAWT7CSvgj5tBUtF7CM2OoHXQ=="],
- "@tanstack/react-start": ["@tanstack/react-start@1.168.44", "", { "dependencies": { "@tanstack/react-router": "1.170.27", "@tanstack/react-start-client": "1.168.25", "@tanstack/react-start-rsc": "0.1.43", "@tanstack/react-start-server": "1.167.32", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.22", "@tanstack/start-plugin-core": "1.171.34", "@tanstack/start-server-core": "1.169.26", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-varBaGENYuX0qdRMy8xZvr3BpHCHfkOeBaFCQkXUaOzK3NmWdpqj6CSbPq5uMCHkvFTDXNN6PZukp5zRa5Wmcg=="],
+ "@tanstack/react-start": ["@tanstack/react-start@1.168.46", "", { "dependencies": { "@tanstack/react-router": "1.170.29", "@tanstack/react-start-client": "1.168.27", "@tanstack/react-start-rsc": "0.1.45", "@tanstack/react-start-server": "1.167.34", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.24", "@tanstack/start-plugin-core": "1.171.36", "@tanstack/start-server-core": "1.169.28", "pathe": "^2.0.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "@vitejs/plugin-rsc": "*", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "@vitejs/plugin-rsc", "vite"] }, "sha512-GvjKkZQ4bzuqyDIFvY7Puh7nCDc5Uh7r3puO8EDr1ZSqbZu8LGUaeNRrAHepxEIepr2iYbSDtKVaB7A9fWUMeA=="],
- "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.25", "", { "dependencies": { "@tanstack/react-router": "1.170.27", "@tanstack/router-core": "1.171.22", "@tanstack/start-client-core": "1.170.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-vwjhuXUXFEAS8btmmXa24J/cP12AuAqIn/9OdZnrVufBE489IaxUEE3KuqEyUyZ+dtwPxChgVxFPHLcG9zonwA=="],
+ "@tanstack/react-start-client": ["@tanstack/react-start-client@1.168.27", "", { "dependencies": { "@tanstack/react-router": "1.170.29", "@tanstack/router-core": "1.171.24", "@tanstack/start-client-core": "1.170.24" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-N18avSX3aAobLkpyYQiRkXrtcc5E+0A97HEWSfzUxT6+1mwMdSg8CBZL1m59zGp9pXWFfO2aFUlBEryGy9beTA=="],
- "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.43", "", { "dependencies": { "@tanstack/react-router": "1.170.27", "@tanstack/router-core": "1.171.22", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.22", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.34", "@tanstack/start-storage-context": "1.167.24", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-5h5qytaTlhakTWX0oMz7pVxS/4FuCJeg5a/jKkbLsEU2hWy/R098zQG9tftkWbzPhq+B3pfrGk32ufUzsiwK5A=="],
+ "@tanstack/react-start-rsc": ["@tanstack/react-start-rsc@0.1.45", "", { "dependencies": { "@tanstack/react-router": "1.170.29", "@tanstack/router-core": "1.171.24", "@tanstack/router-utils": "1.162.2", "@tanstack/start-client-core": "1.170.24", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-plugin-core": "1.171.36", "@tanstack/start-storage-context": "1.167.26", "pathe": "^2.0.3" }, "peerDependencies": { "@rspack/core": ">=2.0.0-0", "@vitejs/plugin-rsc": ">=0.5.30", "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0", "react-server-dom-rspack": ">=0.0.2" }, "optionalPeers": ["@rspack/core", "@vitejs/plugin-rsc", "react-server-dom-rspack"] }, "sha512-n962onqz6wZkra9cHygPQumKaShqZAfO2BRvrRzbtYa8M5ytbOG3Wvg6TUbZJ7gPKmjDvFsyrAPgZZ8ZhkrbwA=="],
- "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.32", "", { "dependencies": { "@tanstack/react-router": "1.170.27", "@tanstack/router-core": "1.171.22", "@tanstack/start-server-core": "1.169.26" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-HZV8EE9f2XU+KnyJcB20SCZgAm6G3uGPUwihaqgAz+MPnuJ/xG1iNy1oyQRz/SeqI3PVnPKzWK3Dkv+7xiO7cA=="],
+ "@tanstack/react-start-server": ["@tanstack/react-start-server@1.167.34", "", { "dependencies": { "@tanstack/react-router": "1.170.29", "@tanstack/router-core": "1.171.24", "@tanstack/start-server-core": "1.169.28" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-9C1r6mUyDJPF2GRZBJfp8Zf/IH6YpXKwq09gKcCIt6CX/2z4Ir0SAtdzjgVYsAxX8a5qNQNLbwCkp4xa3kjyqw=="],
"@tanstack/react-store": ["@tanstack/react-store@0.9.3", "", { "dependencies": { "@tanstack/store": "0.9.3", "use-sync-external-store": "^1.6.0" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg=="],
@@ -449,27 +449,27 @@
"@tanstack/react-virtual": ["@tanstack/react-virtual@3.14.9", "", { "dependencies": { "@tanstack/virtual-core": "3.17.7" }, "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-qZyr0FZDP8rDC4WBhsryIZmAd9bveJvFGUJJtskWaew6/0dTRS6wZxnR6VQ5bY2KwL3LjerrHqQLk3a0GKcPXQ=="],
- "@tanstack/router-core": ["@tanstack/router-core@1.171.22", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-sitsuRkz4qpTjIAV97S5zFCoeGv0OFd6+VWg3ZcIlQbi5R4NIXfz6ogg51n5w6rvTwSODz/lKWb5dl5tvh2bQw=="],
+ "@tanstack/router-core": ["@tanstack/router-core@1.171.24", "", { "dependencies": { "@tanstack/history": "1.162.1", "cookie-es": "^3.0.0", "seroval": "^1.6.2", "seroval-plugins": "^1.6.2" } }, "sha512-+j+8NmV4QOS+ILNLdXFlLTdKPPbBQIy6mXDZ0QWEKYZ0xQOdlnOCXVHPflyGanD2SWSxjqGkw8CsW0CXXP7fLg=="],
"@tanstack/router-devtools-core": ["@tanstack/router-devtools-core@1.168.1", "", { "dependencies": { "clsx": "^2.1.1", "goober": "^2.1.16" }, "peerDependencies": { "@tanstack/router-core": "^1.171.16", "csstype": "^3.0.10" }, "optionalPeers": ["csstype"] }, "sha512-qr4voa4cpSMwQvS3867xkU3AB3MtJbTuovKIy+btjJ/Faju6er9w0nDylmD+005Mk/3YKw9/iueZJl2JAB7JOA=="],
- "@tanstack/router-generator": ["@tanstack/router-generator@1.167.28", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.22", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-AxvzdqQoBxrA8hoO1fHYg4cAUVug/xLqQhsGbNG1PelU3RbYRYEtDim7+HbYQCOqJaEuvV8tCvrTPTnT69iIwg=="],
+ "@tanstack/router-generator": ["@tanstack/router-generator@1.167.30", "", { "dependencies": { "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.24", "@tanstack/router-utils": "1.162.2", "@tanstack/virtual-file-routes": "1.162.0", "jiti": "^2.7.0", "magic-string": "^0.30.21", "prettier": "^3.5.0", "zod": "^4.4.3" } }, "sha512-Tf9TJfdpP0yL3k1D1ke6hR1dvPCgKShTik3HcBRDeTK5eNrp9/X0Q0YApN8thE6pVcXL6a78O3ZE7XN7s7KwNw=="],
- "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.30", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.22", "@tanstack/router-generator": "1.167.28", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.26", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-Z53FeZjSddyn3c+lmUGHOU3bhZPpaFabcynja8/s87CZeyMYS7oNgUMoaYZAhVLk9cAEC/z3fkqISXqktZadDA=="],
+ "@tanstack/router-plugin": ["@tanstack/router-plugin@1.168.32", "", { "dependencies": { "@babel/core": "^7.28.5", "@babel/template": "^7.27.2", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.24", "@tanstack/router-generator": "1.167.30", "@tanstack/router-utils": "1.162.2", "chokidar": "^5.0.0", "unplugin": "^3.0.0", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": ">=1.0.2 || ^2.0.0", "@tanstack/react-router": "^1.170.29", "vite": ">=5.0.0 || >=6.0.0 || >=7.0.0 || >=8.0.0", "vite-plugin-solid": "^2.11.10 || ^3.0.0-0", "webpack": ">=5.92.0" }, "optionalPeers": ["@rsbuild/core", "@tanstack/react-router", "vite", "vite-plugin-solid", "webpack"] }, "sha512-P+qYIY9b/K+ZGUPNiyLqsd20vN/1abgmSNG0Ch9KTKldag3icR7TlrJgnM2km86yuRdXa4nJ5YT7+JLV5rJRZg=="],
"@tanstack/router-ssr-query-core": ["@tanstack/router-ssr-query-core@1.169.1", "", { "peerDependencies": { "@tanstack/query-core": ">=5.90.0", "@tanstack/router-core": ">=1.127.0" } }, "sha512-rngux8s/3mPQzcjLYDLkNU31coYVyCgrVTfpdwqUdY5jIEHqGTXrO73DTkPR1PppwYUeVhmNCgl8TctRcnupjg=="],
"@tanstack/router-utils": ["@tanstack/router-utils@1.162.2", "", { "dependencies": { "@babel/generator": "^7.28.5", "@babel/parser": "^7.28.5", "@babel/types": "^7.28.5", "ansis": "^4.1.0", "babel-dead-code-elimination": "^1.0.12", "diff": "^8.0.2", "pathe": "^2.0.3", "tinyglobby": "^0.2.15" } }, "sha512-hTWqJtqIFFdvuCl8WXNyrodp2L9zo2G37xKRrcVmVRWpAB2h+U1LuRAfS4tsFTiWOIoE/B+WDVFB8JpoEdw6jQ=="],
- "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.22", "", { "dependencies": { "@tanstack/router-core": "1.171.22", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.24", "seroval": "^1.6.2" } }, "sha512-18gHvhrVLEmhO0qZVIJaR/y3Mb7tvC9UQnCEiooQG/aIpYg4/kqoQ06E7WUJ/0WFy4NZOkEjk8QkDQNkFED+1g=="],
+ "@tanstack/start-client-core": ["@tanstack/start-client-core@1.170.24", "", { "dependencies": { "@tanstack/router-core": "1.171.24", "@tanstack/start-fn-stubs": "1.162.0", "@tanstack/start-storage-context": "1.167.26", "seroval": "^1.6.2" } }, "sha512-BRdV6TZSLVZ43fEVdIdkpkZCwwaEm57JUPpNiNszZeutBZLU0bxR/cbs0cC7sE98oVsgBTNvaBjyk2CDea+4sw=="],
"@tanstack/start-fn-stubs": ["@tanstack/start-fn-stubs@1.162.0", "", {}, "sha512-QWfUZ3Yo923tdQn38LyKMU8rcTw69zc+T4dAvgTWV4O56SqFRsGfS0lSWIMhJRwXIx/bvdi7nTUBDdZtTHtpTQ=="],
- "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.34", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.22", "@tanstack/router-generator": "1.167.28", "@tanstack/router-plugin": "1.168.30", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.26", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-MMp5Mlt8HAtstkVKwHW6dY5w58CP/Vtq+DhXdH4mIPuOeEhFtl/BYErJCzpnq80QqbEpRxCfMZPf9ng2PzApaA=="],
+ "@tanstack/start-plugin-core": ["@tanstack/start-plugin-core@1.171.36", "", { "dependencies": { "@babel/code-frame": "7.27.1", "@babel/core": "^7.28.5", "@babel/types": "^7.28.5", "@tanstack/router-core": "1.171.24", "@tanstack/router-generator": "1.167.30", "@tanstack/router-plugin": "1.168.32", "@tanstack/router-utils": "1.162.2", "@tanstack/start-server-core": "1.169.28", "exsolve": "^1.0.7", "lightningcss": "^1.32.0", "pathe": "^2.0.3", "picomatch": "^4.0.3", "seroval": "^1.6.2", "source-map": "^0.7.6", "srvx": "^0.11.9", "tinyglobby": "^0.2.15", "ufo": "^1.5.4", "vitefu": "^1.1.1", "xmlbuilder2": "^4.0.3", "zod": "^4.4.3" }, "peerDependencies": { "@rsbuild/core": "^2.0.0", "vite": ">=7.0.0" }, "optionalPeers": ["@rsbuild/core", "vite"] }, "sha512-wWFd0sSpoW3k5cJJ1EpJuQ1REzMcv4LcOmPKotyhH2PNQPqBIRv9ZCxRtH7QWxXcwAaE0G1YE8PTYh9ZARPYOg=="],
- "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.26", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/router-core": "1.171.22", "@tanstack/start-client-core": "1.170.22", "@tanstack/start-storage-context": "1.167.24", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-57Pvc00i/Y6l8+kqlV/QElVUh5onFUPeMUBDDRNwelAA4eoOmRDmfEWfVfkJNYlcAF4kt/s0mgnD58DlCgC9JA=="],
+ "@tanstack/start-server-core": ["@tanstack/start-server-core@1.169.28", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/router-core": "1.171.24", "@tanstack/start-client-core": "1.170.24", "@tanstack/start-storage-context": "1.167.26", "fetchdts": "^0.1.6", "h3-v2": "npm:h3@2.0.1-rc.20", "seroval": "^1.6.2" } }, "sha512-iGytKBUIzEWxVZ20x2UYyTNGOviYSxUXR4SqAz8NRh0ElcbMHPK0twkE7ZeKLDigyQh9rG1DHGyiGVs6plQTvQ=="],
- "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.24", "", { "dependencies": { "@tanstack/router-core": "1.171.22" } }, "sha512-HPltgydit1LmLJFX3BhMcXMxkmUgtWJopPUx6Eg0YTC4tVwFvUhSWCrfgtTIiGWcHd1Ah+fi6fK9BAe8Y5KZtw=="],
+ "@tanstack/start-storage-context": ["@tanstack/start-storage-context@1.167.26", "", { "dependencies": { "@tanstack/router-core": "1.171.24" } }, "sha512-uWUX1zk4rY0vrSzW7JyIGYRLX0jx75Mc+CeaJL8TPTE4UHq2DxZ+4rwrx8/KBRyDFXTzgIzJAXIvp8Tm6hQaNQ=="],
"@tanstack/store": ["@tanstack/store@0.9.3", "", {}, "sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw=="],
diff --git a/package.json b/package.json
index 35a1b10..76d3c84 100644
--- a/package.json
+++ b/package.json
@@ -1,131 +1,131 @@
{
- "author": {
- "name": "Ragaeeb Haq",
- "url": "https://github.com/ragaeeb"
+ "author": {
+ "name": "Ragaeeb Haq",
+ "url": "https://github.com/ragaeeb"
+ },
+ "bin": {
+ "spiracha": "./bin/spiracha.ts"
+ },
+ "bugs": {
+ "url": "https://github.com/ragaeeb/spiracha/issues"
+ },
+ "dependencies": {
+ "@tailwindcss/typography": "0.5.20",
+ "@tailwindcss/vite": "4.3.3",
+ "@tanstack/devtools-vite": "0.8.3",
+ "@tanstack/match-sorter-utils": "9.1.2",
+ "@tanstack/react-devtools": "0.10.10",
+ "@tanstack/react-query": "5.101.4",
+ "@tanstack/react-query-devtools": "5.101.4",
+ "@tanstack/react-router": "1.170.29",
+ "@tanstack/react-router-devtools": "1.167.1",
+ "@tanstack/react-router-ssr-query": "1.167.1",
+ "@tanstack/react-start": "1.168.46",
+ "@tanstack/react-table": "9.1.2",
+ "@tanstack/react-virtual": "3.14.9",
+ "@tanstack/router-plugin": "1.168.32",
+ "@vitejs/plugin-react": "6.0.5",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "fflate": "0.8.3",
+ "lucide-react": "1.31.0",
+ "radix-ui": "1.6.7",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "tailwind-merge": "3.6.0",
+ "tailwindcss": "4.3.3",
+ "tw-animate-css": "1.4.0",
+ "vite": "8.2.1",
+ "zod": "4.4.3"
+ },
+ "description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, MiniMax Code, and OpenCode conversation history through a local UI and stable data API.",
+ "devDependencies": {
+ "@biomejs/biome": "2.5.8",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^26.2.0",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitest/coverage-v8": "4.1.10",
+ "jsdom": "30.0.1",
+ "typescript": "^7.0.2",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "bun": ">=1.3.14"
+ },
+ "exports": {
+ ".": {
+ "import": "./src/client.ts",
+ "types": "./src/client.ts"
},
- "bin": {
- "spiracha": "./bin/spiracha.ts"
+ "./client": {
+ "import": "./src/client.ts",
+ "types": "./src/client.ts"
},
- "bugs": {
- "url": "https://github.com/ragaeeb/spiracha/issues"
- },
- "dependencies": {
- "@tailwindcss/typography": "0.5.20",
- "@tailwindcss/vite": "4.3.3",
- "@tanstack/devtools-vite": "0.8.3",
- "@tanstack/match-sorter-utils": "9.1.2",
- "@tanstack/react-devtools": "0.10.10",
- "@tanstack/react-query": "5.101.4",
- "@tanstack/react-query-devtools": "5.101.4",
- "@tanstack/react-router": "1.170.27",
- "@tanstack/react-router-devtools": "1.167.1",
- "@tanstack/react-router-ssr-query": "1.167.1",
- "@tanstack/react-start": "1.168.44",
- "@tanstack/react-table": "9.1.2",
- "@tanstack/react-virtual": "3.14.9",
- "@tanstack/router-plugin": "1.168.30",
- "@vitejs/plugin-react": "6.0.5",
- "class-variance-authority": "0.7.1",
- "clsx": "2.1.1",
- "fflate": "0.8.3",
- "lucide-react": "1.31.0",
- "radix-ui": "1.6.7",
- "react": "19.2.8",
- "react-dom": "19.2.8",
- "tailwind-merge": "3.6.0",
- "tailwindcss": "4.3.3",
- "tw-animate-css": "1.4.0",
- "vite": "8.2.1",
- "zod": "4.4.3"
- },
- "description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, MiniMax Code, and OpenCode conversation history through a local UI and stable data API.",
- "devDependencies": {
- "@biomejs/biome": "2.5.8",
- "@testing-library/dom": "10.4.1",
- "@testing-library/react": "16.3.2",
- "@types/bun": "^1.3.14",
- "@types/node": "^26.2.0",
- "@types/react": "19.2.18",
- "@types/react-dom": "19.2.4",
- "@vitest/coverage-v8": "4.1.10",
- "jsdom": "30.0.1",
- "typescript": "^7.0.2",
- "vitest": "4.1.10"
- },
- "engines": {
- "bun": ">=1.3.14"
- },
- "exports": {
- ".": {
- "import": "./src/client.ts",
- "types": "./src/client.ts"
- },
- "./client": {
- "import": "./src/client.ts",
- "types": "./src/client.ts"
- },
- "./types": {
- "import": "./src/lib/conversation-data/types.ts",
- "types": "./src/lib/conversation-data/types.ts"
- }
- },
- "files": [
- "bin/spiracha.ts",
- "components.json",
- "public/**/*",
- "src/ui/**/*",
- "!src/ui/**/*.vitest.ts",
- "!src/ui/**/*.vitest.tsx",
- "tsconfig.json",
- "vite.config.ts",
- "src/client.ts",
- "src/lib/**/*.ts",
- "!src/lib/**/*.test.ts",
- "!src/lib/*-test-helpers.ts",
- "README.md",
- "AGENTS.md"
- ],
- "homepage": "https://github.com/ragaeeb/spiracha",
- "imports": {
- "#/*": "./src/ui/*"
- },
- "keywords": [
- "codex",
- "claude",
- "cline",
- "grok",
- "kiro",
- "qoder",
- "cursor",
- "antigravity",
- "minimax",
- "opencode",
- "transcripts",
- "api",
- "markdown",
- "bun"
- ],
- "license": "MIT",
- "name": "spiracha",
- "packageManager": "bun@1.3.14",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/ragaeeb/spiracha.git"
- },
- "scripts": {
- "build": "bun run typecheck && bun --bun vite build",
- "coverage": "bun run coverage:root && bun run coverage:ui",
- "coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
- "coverage:ui": "vitest run --coverage --config vitest.config.ts && bun run ./src/coverage-check.ts ui",
- "format": "biome check . --write && biome lint . --write",
- "lint": "biome check .",
- "prepublishOnly": "bun run build && bun run test:package",
- "start": "bun --bun vite dev --host 127.0.0.1 --port 3000",
- "test:package": "bun run ./src/package-smoke.ts",
- "test:ui": "vitest run --config vitest.config.ts",
- "typecheck": "bunx tsc --noEmit",
- "ui:preview": "bun --bun vite preview"
- },
- "type": "module",
- "version": "2.4.1"
+ "./types": {
+ "import": "./src/lib/conversation-data/types.ts",
+ "types": "./src/lib/conversation-data/types.ts"
+ }
+ },
+ "files": [
+ "bin/spiracha.ts",
+ "components.json",
+ "public/**/*",
+ "src/ui/**/*",
+ "!src/ui/**/*.vitest.ts",
+ "!src/ui/**/*.vitest.tsx",
+ "tsconfig.json",
+ "vite.config.ts",
+ "src/client.ts",
+ "src/lib/**/*.ts",
+ "!src/lib/**/*.test.ts",
+ "!src/lib/*-test-helpers.ts",
+ "README.md",
+ "AGENTS.md"
+ ],
+ "homepage": "https://github.com/ragaeeb/spiracha",
+ "imports": {
+ "#/*": "./src/ui/*"
+ },
+ "keywords": [
+ "codex",
+ "claude",
+ "cline",
+ "grok",
+ "kiro",
+ "qoder",
+ "cursor",
+ "antigravity",
+ "minimax",
+ "opencode",
+ "transcripts",
+ "api",
+ "markdown",
+ "bun"
+ ],
+ "license": "MIT",
+ "name": "spiracha",
+ "packageManager": "bun@1.3.14",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ragaeeb/spiracha.git"
+ },
+ "scripts": {
+ "build": "bun run typecheck && bun --bun vite build",
+ "coverage": "bun run coverage:root && bun run coverage:ui",
+ "coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
+ "coverage:ui": "vitest run --coverage --config vitest.config.ts && bun run ./src/coverage-check.ts ui",
+ "format": "biome check . --write && biome lint . --write",
+ "lint": "biome check .",
+ "prepublishOnly": "bun run build && bun run test:package",
+ "start": "bun --bun vite dev --host 127.0.0.1 --port 3000",
+ "test:package": "bun run ./src/package-smoke.ts",
+ "test:ui": "vitest run --config vitest.config.ts",
+ "typecheck": "bunx tsc --noEmit",
+ "ui:preview": "bun --bun vite preview"
+ },
+ "type": "module",
+ "version": "2.5.0"
}
From d50c59749298cae0fb4ff2bb3d5a74417a6a94f3 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Sun, 16 Aug 2026 22:50:05 -0400
Subject: [PATCH 05/10] Fix issues raised by Coderabbit
---
src/lib/antigravity-db.test.ts | 48 ++-
src/lib/antigravity-db.ts | 59 ++-
src/lib/antigravity-keychain.ts | 16 +-
.../antigravity-transcript-history.test.ts | 23 ++
src/lib/antigravity-transcript-history.ts | 12 +-
src/lib/cline-db.test.ts | 63 ++-
src/lib/cline-db.ts | 128 ++++---
src/lib/cline-exporter-types.ts | 11 +
src/lib/codex-browser-db.test.ts | 176 ++++++++-
src/lib/codex-browser-db.ts | 362 ++++++++++--------
src/lib/codex-browser-export.test.ts | 12 +-
src/lib/codex-browser-export.ts | 18 +-
src/lib/codex-browser-types.ts | 1 -
src/lib/codex-rollout-snapshot.test.ts | 61 ++-
src/lib/codex-rollout-snapshot.ts | 53 +--
src/lib/conversation-data/cline-adapter.ts | 11 +-
src/lib/conversation-zip-export.test.ts | 2 +-
src/lib/cursor-db.test.ts | 55 ++-
src/lib/cursor-db.ts | 32 +-
src/lib/cursor-id.ts | 1 +
src/lib/cursor-recovery.test.ts | 27 +-
src/lib/cursor-recovery.ts | 8 +-
src/lib/cursor-test-helpers.ts | 102 ++++-
src/lib/opencode-db.test.ts | 27 ++
src/lib/opencode-db.ts | 16 +-
src/ui/components/cursor-workspaces-table.tsx | 4 +-
.../cursor-workspaces-table.vitest.tsx | 24 +-
src/ui/components/export-dialog.tsx | 43 ++-
src/ui/components/export-dialog.vitest.tsx | 92 ++++-
src/ui/lib/antigravity-server.ts | 37 +-
src/ui/lib/antigravity-server.vitest.ts | 24 ++
src/ui/lib/cline-server.ts | 10 +-
src/ui/lib/cline-server.vitest.ts | 6 +
src/ui/lib/cursor-server.ts | 13 +-
src/ui/lib/cursor-server.vitest.ts | 30 ++
src/ui/routes/cursor.$workspaceKey.tsx | 6 +-
src/ui/routes/cursor.index.tsx | 18 +-
37 files changed, 1278 insertions(+), 353 deletions(-)
diff --git a/src/lib/antigravity-db.test.ts b/src/lib/antigravity-db.test.ts
index 47ee50b..dee1aa3 100644
--- a/src/lib/antigravity-db.test.ts
+++ b/src/lib/antigravity-db.test.ts
@@ -278,8 +278,25 @@ const runGit = async (cwd: string, args: string[]): Promise => {
};
describe('antigravity db discovery', () => {
- it('should include the Antigravity CLI root in default discovery roots', () => {
- expect(resolveAntigravityRoots()).toContain(path.join(os.homedir(), '.gemini', 'antigravity-cli'));
+ it('should include the Antigravity CLI root in default discovery roots without leaking overrides', () => {
+ const originalDirs = process.env.SPIRACHA_ANTIGRAVITY_DIRS;
+ const originalDir = process.env.SPIRACHA_ANTIGRAVITY_DIR;
+ try {
+ delete process.env.SPIRACHA_ANTIGRAVITY_DIRS;
+ delete process.env.SPIRACHA_ANTIGRAVITY_DIR;
+ expect(resolveAntigravityRoots()).toContain(path.join(os.homedir(), '.gemini', 'antigravity-cli'));
+ } finally {
+ if (originalDirs === undefined) {
+ delete process.env.SPIRACHA_ANTIGRAVITY_DIRS;
+ } else {
+ process.env.SPIRACHA_ANTIGRAVITY_DIRS = originalDirs;
+ }
+ if (originalDir === undefined) {
+ delete process.env.SPIRACHA_ANTIGRAVITY_DIR;
+ } else {
+ process.env.SPIRACHA_ANTIGRAVITY_DIR = originalDir;
+ }
+ }
});
it('should preserve valid summary records around protobuf corruption with diagnostics', async () => {
@@ -297,6 +314,33 @@ describe('antigravity db discovery', () => {
]);
});
+ it('should cap protobuf diagnostics while continuing to parse later records', async () => {
+ const root = await makeRoot();
+ const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
+ const valid = encodeSummaryIndex([{ id: '33333333-3333-4333-8333-333333333333', title: 'After noise' }]);
+ await Bun.write(summaryPath, new Uint8Array([...new Array(150).fill(0x0b), ...valid]));
+
+ const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);
+
+ expect(result.entries.map((entry) => entry.title)).toEqual(['After noise']);
+ expect(result.diagnostics).toHaveLength(100);
+ });
+
+ it('should cap summary-record diagnostics without dropping later valid records', async () => {
+ const root = await makeRoot();
+ const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
+ const invalid = encodeMessage(1, [0x0b]);
+ const valid = encodeSummaryIndex([
+ { id: '44444444-4444-4444-8444-444444444444', title: 'After invalid summaries' },
+ ]);
+ await Bun.write(summaryPath, new Uint8Array([...new Array(150).fill(invalid).flat(), ...valid]));
+
+ const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);
+
+ expect(result.entries.map((entry) => entry.title)).toEqual(['After invalid summaries']);
+ expect(result.diagnostics).toHaveLength(100);
+ });
+
it('should resolve one conversation without requiring a collection scan', async () => {
const root = await makeRoot();
const conversationId = '12111111-1111-4111-8111-111111111111';
diff --git a/src/lib/antigravity-db.ts b/src/lib/antigravity-db.ts
index 1a49ec9..515fe98 100644
--- a/src/lib/antigravity-db.ts
+++ b/src/lib/antigravity-db.ts
@@ -81,6 +81,7 @@ type ConversationFile = {
const ANTIGRAVITY_ARTIFACT_READ_CONCURRENCY = 16;
const ANTIGRAVITY_CONVERSATION_FILE_READ_CONCURRENCY = 8;
const ANTIGRAVITY_MAX_PROTO_RECORD_BYTES = 8 * 1024 * 1024;
+const ANTIGRAVITY_MAX_PARSE_DIAGNOSTICS = 100;
const SAFE_CONVERSATION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/iu;
const isSafeConversationId = (value: string) => SAFE_CONVERSATION_ID_PATTERN.test(value);
@@ -305,11 +306,13 @@ const tryGetProtoBounds = (buffer: Uint8Array, start: number): ProtoBounds | nul
}
};
-const appendBytes = (left: Uint8Array, right: Uint8Array): Uint8Array => {
- const combined = new Uint8Array(left.length + right.length);
- combined.set(left);
- combined.set(right, left.length);
- return combined;
+const appendAntigravityDiagnostic = (
+ diagnostics: AntigravityParseDiagnostic[],
+ diagnostic: AntigravityParseDiagnostic,
+): void => {
+ if (diagnostics.length < ANTIGRAVITY_MAX_PARSE_DIAGNOSTICS) {
+ diagnostics.push(diagnostic);
+ }
};
const consumeProtoRecord = (
@@ -324,7 +327,7 @@ const consumeProtoRecord = (
try {
bounds = tryGetProtoBounds(buffer, index);
} catch (error) {
- diagnostics.push({
+ appendAntigravityDiagnostic(diagnostics, {
byteOffset: bufferOffset + index,
kind: 'protobuf',
message: `Invalid Antigravity protobuf field: ${error instanceof Error ? error.message : String(error)}`,
@@ -354,20 +357,48 @@ const readAntigravityProtobufRecords = async (
const diagnostics: AntigravityParseDiagnostic[] = [];
const records: IndexedProtoRecord[] = [];
let buffer: Uint8Array = new Uint8Array();
+ let readIndex = 0;
+ let writeIndex = 0;
let bufferOffset = 0;
+ const appendChunk = (chunk: Uint8Array) => {
+ const unreadBytes = writeIndex - readIndex;
+ if (buffer.length - writeIndex < chunk.length) {
+ if (readIndex > 0 && (readIndex >= buffer.length / 2 || buffer.length - writeIndex < chunk.length)) {
+ buffer.copyWithin(0, readIndex, writeIndex);
+ readIndex = 0;
+ writeIndex = unreadBytes;
+ }
+ if (buffer.length - writeIndex < chunk.length) {
+ const capacity = Math.max(buffer.length * 2, writeIndex + chunk.length, 64);
+ const expanded = new Uint8Array(capacity);
+ expanded.set(buffer.subarray(readIndex, writeIndex));
+ buffer = expanded;
+ writeIndex = unreadBytes;
+ readIndex = 0;
+ }
+ }
+ buffer.set(chunk, writeIndex);
+ writeIndex += chunk.length;
+ };
+
const consume = () => {
+ const pending = buffer.subarray(readIndex, writeIndex);
let index = 0;
- while (index < buffer.length) {
- const nextIndex = consumeProtoRecord(buffer, index, bufferOffset, fieldNumber, diagnostics, records);
+ while (index < pending.length) {
+ const nextIndex = consumeProtoRecord(pending, index, bufferOffset, fieldNumber, diagnostics, records);
if (nextIndex === null) {
break;
}
index = nextIndex;
}
if (index > 0) {
- buffer = buffer.slice(index);
+ readIndex += index;
bufferOffset += index;
+ if (readIndex === writeIndex) {
+ readIndex = 0;
+ writeIndex = 0;
+ }
}
};
@@ -376,12 +407,12 @@ const readAntigravityProtobufRecords = async (
if (done) {
break;
}
- buffer = appendBytes(buffer, value);
+ appendChunk(value);
consume();
}
consume();
- if (buffer.length > 0) {
- diagnostics.push({
+ if (writeIndex > readIndex) {
+ appendAntigravityDiagnostic(diagnostics, {
byteOffset: bufferOffset,
kind: 'protobuf',
message: 'Truncated Antigravity protobuf input',
@@ -622,14 +653,14 @@ export const readAntigravitySummaryIndexWithDiagnostics = async (
if (entry) {
entries.push(entry);
} else {
- result.diagnostics.push({
+ appendAntigravityDiagnostic(result.diagnostics, {
byteOffset: record.byteOffset,
kind: 'protobuf',
message: 'Invalid Antigravity summary record',
});
}
} catch (error) {
- result.diagnostics.push({
+ appendAntigravityDiagnostic(result.diagnostics, {
byteOffset: record.byteOffset,
kind: 'protobuf',
message: `Invalid Antigravity summary record: ${error instanceof Error ? error.message : String(error)}`,
diff --git a/src/lib/antigravity-keychain.ts b/src/lib/antigravity-keychain.ts
index 9f89856..ae83466 100644
--- a/src/lib/antigravity-keychain.ts
+++ b/src/lib/antigravity-keychain.ts
@@ -20,6 +20,15 @@ export type AntigravityDecryptionCapability = Readonly<{
decryptSafeStoragePayload: (payload: Buffer | Uint8Array | string) => string | null;
}>;
+export class AntigravityDecryptionCapabilityError extends Error {
+ readonly code = 'ANTIGRAVITY_DECRYPTION_CAPABILITY';
+
+ constructor(cause: unknown) {
+ super('Antigravity decryption capability is unavailable', { cause });
+ this.name = 'AntigravityDecryptionCapabilityError';
+ }
+}
+
const execFileAsync = promisify(execFile);
const SAFE_STORAGE_SALT = 'saltysalt';
const SAFE_STORAGE_ITERATIONS = 1003;
@@ -199,7 +208,12 @@ const getAntigravityKeychainError = (error: unknown): string => {
export const withAntigravityDecryptionCapability = async (
action: (capability: AntigravityDecryptionCapability) => T | Promise,
): Promise => {
- const keychainSecret = await readAntigravityKeychainSecret();
+ let keychainSecret: string;
+ try {
+ keychainSecret = await readAntigravityKeychainSecret();
+ } catch (error) {
+ throw new AntigravityDecryptionCapabilityError(error);
+ }
return action(createAntigravityDecryptionCapability(keychainSecret));
};
diff --git a/src/lib/antigravity-transcript-history.test.ts b/src/lib/antigravity-transcript-history.test.ts
index 6c76bc8..8bf6951 100644
--- a/src/lib/antigravity-transcript-history.test.ts
+++ b/src/lib/antigravity-transcript-history.test.ts
@@ -69,6 +69,29 @@ describe('Antigravity transcript history', () => {
]);
});
+ it('should preserve CRLF byte offsets across large chunked JSONL records', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'antigravity-jsonl-chunked-'));
+ temporaryDirectories.push(directory);
+ const transcriptPath = path.join(directory, 'transcript.jsonl');
+ const prefix = '{"payload":"';
+ const payload = 'x'.repeat(65_535 - Buffer.byteLength(prefix) - 2);
+ const first = `${prefix}${payload}"}`;
+ const second = '{malformed}';
+ const third = JSON.stringify({ step_index: 12 });
+ await Bun.write(transcriptPath, `${first}\r\n${second}\r\n${third}\r\n`);
+
+ const result = await readAntigravityJsonlFile(
+ transcriptPath,
+ (line) => JSON.parse(line) as { payload?: string; step_index?: number },
+ { maxLineBytes: 100_000 },
+ );
+
+ expect(result.records).toEqual([{ payload }, { step_index: 12 }]);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({ byteOffset: Buffer.byteLength(`${first}\r\n`), line: 2 }),
+ ]);
+ });
+
it('should skip history recovery when the current transcript already starts at its first step', async () => {
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', null)).toEqual([]);
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', 0)).toEqual([]);
diff --git a/src/lib/antigravity-transcript-history.ts b/src/lib/antigravity-transcript-history.ts
index 657f400..befa69b 100644
--- a/src/lib/antigravity-transcript-history.ts
+++ b/src/lib/antigravity-transcript-history.ts
@@ -128,6 +128,7 @@ type JsonlFragmentInput = {
diagnostics: AntigravityParseDiagnostic[];
fragment: string;
hasNewline: boolean;
+ lineTerminatorBytes: number;
maxLineBytes: number;
parse: (line: string) => T;
records: T[];
@@ -138,12 +139,13 @@ const consumeJsonlFragment = ({
diagnostics,
fragment,
hasNewline,
+ lineTerminatorBytes,
maxLineBytes,
parse,
records,
state,
}: JsonlFragmentInput) => {
- const fragmentBytes = Buffer.byteLength(fragment);
+ const fragmentBytes = Buffer.byteLength(fragment) + lineTerminatorBytes;
const fullLineBytes = state.lineBytesSeen + fragmentBytes;
state.lineBytesSeen = fullLineBytes;
if (fullLineBytes > maxLineBytes && !state.discardingOverlongLine) {
@@ -189,15 +191,17 @@ const consumeJsonlFragment = ({
const consumeJsonlText = (
text: string,
state: JsonlStreamState,
- input: Omit, 'fragment' | 'hasNewline' | 'state'>,
+ input: Omit, 'fragment' | 'hasNewline' | 'lineTerminatorBytes' | 'state'>,
) => {
let start = 0;
while (start < text.length) {
const newline = text.indexOf('\n', start);
const hasNewline = newline >= 0;
const end = hasNewline ? newline : text.length;
- const fragment = text.slice(start, end).replace(/\r$/u, '');
- consumeJsonlFragment({ ...input, fragment, hasNewline, state });
+ const rawFragment = text.slice(start, end);
+ const lineTerminatorBytes = rawFragment.endsWith('\r') ? 1 : 0;
+ const fragment = rawFragment.replace(/\r$/u, '');
+ consumeJsonlFragment({ ...input, fragment, hasNewline, lineTerminatorBytes, state });
start = hasNewline ? end + 1 : text.length;
}
};
diff --git a/src/lib/cline-db.test.ts b/src/lib/cline-db.test.ts
index 6df0429..fbef333 100644
--- a/src/lib/cline-db.test.ts
+++ b/src/lib/cline-db.test.ts
@@ -115,6 +115,48 @@ describe('Cline session storage', () => {
});
});
+ it('should retain a safe session with missing workspace metadata using an explicit storage fallback', async () => {
+ const root = await makeTempRoot();
+ const dataDir = path.join(root, 'cline-data');
+ const sessionId = '1785560414951_missing_workspace';
+ const sessionDir = path.join(dataDir, 'sessions', sessionId);
+ await mkdir(sessionDir, { recursive: true });
+ await Bun.write(
+ path.join(sessionDir, `${sessionId}.json`),
+ JSON.stringify({ prompt: 'No workspace metadata', session_id: sessionId }),
+ );
+ await Bun.write(path.join(sessionDir, `${sessionId}.messages.json`), JSON.stringify({ messages: [] }));
+
+ const transcript = await readClineTaskTranscript(dataDir, sessionId);
+
+ expect(transcript).toMatchObject({
+ messages: [],
+ task: {
+ taskId: sessionId,
+ workspaceSource: 'session_directory',
+ worktree: sessionDir,
+ },
+ });
+ });
+
+ it('should read a validated task entry directly and preserve empty transcripts', async () => {
+ const root = await makeTempRoot();
+ const dataDir = path.join(root, 'cline-data');
+ const sessionId = '1785560414951_direct';
+ const sessionDir = path.join(dataDir, 'sessions', sessionId);
+ await mkdir(sessionDir, { recursive: true });
+ await Bun.write(
+ path.join(sessionDir, `${sessionId}.json`),
+ JSON.stringify({ cwd: root, session_id: sessionId }),
+ );
+ await Bun.write(path.join(sessionDir, `${sessionId}.messages.json`), JSON.stringify({ messages: [] }));
+
+ const transcript = await readClineTaskTranscript(dataDir, sessionId);
+
+ expect(transcript?.messages).toEqual([]);
+ expect(transcript?.renderablePartCount).toBe(0);
+ });
+
it('should delete the current Cline session directory', async () => {
const root = await makeTempRoot();
const dataDir = path.join(root, 'cline-data');
@@ -129,7 +171,8 @@ describe('Cline session storage', () => {
const result = await deleteClineTask(dataDir, CLINE_SESSION_ID);
expect(result.deletedTaskIds).toEqual([CLINE_SESSION_ID]);
- expect(result.deletedFiles).toEqual([fixture.sessionDir, databasePath]);
+ expect(result.deletedFiles).toEqual([fixture.sessionDir]);
+ expect(result.indexCleanup).toEqual({ status: 'deleted' });
expect(await Bun.file(fixture.messagesPath).exists()).toBe(false);
const remainingDatabase = new Database(databasePath, { readonly: true });
expect(remainingDatabase.query('SELECT session_id FROM sessions').all()).toEqual([]);
@@ -137,6 +180,23 @@ describe('Cline session storage', () => {
await expect(readClineTaskTranscript(dataDir, CLINE_SESSION_ID)).resolves.toBeNull();
});
+ it('should remove the session directory when index cleanup fails and report the failure', async () => {
+ const root = await makeTempRoot();
+ const dataDir = path.join(root, 'cline-data');
+ const fixture = await writeClineSessionFixture({ dataDir, workspacePath: path.join(root, 'repo') });
+ const databasePath = path.join(dataDir, 'db', 'sessions.db');
+ await mkdir(path.dirname(databasePath), { recursive: true });
+ const database = new Database(databasePath);
+ database.close();
+
+ const result = await deleteClineTask(dataDir, fixture.sessionId);
+
+ expect(result.deletedFiles).toEqual([fixture.sessionDir]);
+ expect(result.deletedTaskIds).toEqual([fixture.sessionId]);
+ expect(result.indexCleanup.status).toBe('failed');
+ expect(await Bun.file(fixture.sessionDir).exists()).toBe(false);
+ });
+
it('should render current-session Markdown and plain-text exports', async () => {
const root = await makeTempRoot();
const dataDir = path.join(root, 'cline-data');
@@ -194,6 +254,7 @@ describe('Cline session storage', () => {
await expect(deleteClineTask(dataDir, '../unsafe')).resolves.toEqual({
deletedFiles: [],
deletedTaskIds: [],
+ indexCleanup: { status: 'not_found' },
});
});
});
diff --git a/src/lib/cline-db.ts b/src/lib/cline-db.ts
index f5af832..9ac80f8 100644
--- a/src/lib/cline-db.ts
+++ b/src/lib/cline-db.ts
@@ -1,7 +1,8 @@
import { Database } from 'bun:sqlite';
-import { rm } from 'node:fs/promises';
+import { rm, stat } from 'node:fs/promises';
import path from 'node:path';
import type {
+ ClineIndexCleanupResult,
ClineTaskSummary,
ClineTaskTranscript,
ClineToolEvidence,
@@ -9,7 +10,7 @@ import type {
ClineWorkspaceGroup,
DeleteClineTaskResult,
} from './cline-exporter-types';
-import { resolveClineDataDir } from './cline-exporter-types';
+import { isSafeClineSessionId, resolveClineDataDir } from './cline-exporter-types';
import { createConcurrencyLimiter, mapWithConcurrency } from './concurrency';
import { getPortablePathBasename } from './portable-path';
import {
@@ -44,6 +45,7 @@ type ClineSessionEntry = {
tokensOut: number | null;
totalCost: number | null;
updatedAtMs: number | null;
+ workspaceSource: 'metadata' | 'session_directory';
};
type ParsedStats = {
@@ -63,15 +65,21 @@ type ClineStoredMessage = {
};
type ClineSessionHeader = {
- cwd: string;
+ cwd: string | null;
metadata: Record | null;
raw: Record;
sessionId: string;
+ workspaceSource: 'metadata' | 'session_directory';
};
-const getWorkspaceKey = (worktree: string) => `${WORKSPACE_KEY_PREFIX}${encodeURIComponent(worktree)}`;
+export type ClineTranscriptCache = {
+ dataDir: string;
+ transcripts?: ClineTaskTranscript[];
+};
-const isSafeSessionId = (sessionId: string): boolean => /^[A-Za-z0-9_-]+$/u.test(sessionId);
+export const createClineTranscriptCache = (dataDir: string): ClineTranscriptCache => ({ dataDir });
+
+const getWorkspaceKey = (worktree: string) => `${WORKSPACE_KEY_PREFIX}${encodeURIComponent(worktree)}`;
const getWorktreeFromWorkspaceKey = (workspaceKey: string): string | null => {
if (!workspaceKey.startsWith(WORKSPACE_KEY_PREFIX)) {
@@ -141,35 +149,43 @@ const valueAsText = (value: JsonValue | null): string | null => {
return JSON.stringify(value);
};
-const parseSessionHeader = (value: JsonValue, directoryName: string): ClineSessionHeader | null => {
+const parseSessionHeader = (value: JsonValue | null, directoryName: string): ClineSessionHeader | null => {
const raw = asObject(value);
if (!raw) {
return null;
}
const sessionId = asString(raw.session_id ?? null)?.trim() || directoryName;
- const cwd = asString(raw.workspace_root ?? raw.cwd ?? null)?.trim();
- if (!cwd) {
- return null;
- }
+ const cwd = asString(raw.workspace_root ?? raw.cwd ?? null)?.trim() || null;
if (sessionId !== directoryName) {
return null;
}
- if (!isSafeSessionId(sessionId)) {
+ if (!isSafeClineSessionId(sessionId)) {
return null;
}
- return { cwd, metadata: asObject(raw.metadata ?? null), raw, sessionId };
+ return {
+ cwd,
+ metadata: asObject(raw.metadata ?? null),
+ raw,
+ sessionId,
+ workspaceSource: cwd ? 'metadata' : 'session_directory',
+ };
};
const metadataValue = (metadata: Record | null, key: string): JsonValue | null =>
metadata?.[key] ?? null;
-const parseSessionEntry = (value: JsonValue, dataDir: string, directoryName: string): ClineSessionEntry | null => {
+const parseSessionEntry = (
+ value: JsonValue | null,
+ dataDir: string,
+ directoryName: string,
+): ClineSessionEntry | null => {
const header = parseSessionHeader(value, directoryName);
if (!header) {
return null;
}
- const { cwd, metadata, raw, sessionId } = header;
const sessionDir = path.join(dataDir, 'sessions', directoryName);
+ const { metadata, raw, sessionId } = header;
+ const cwd = header.cwd ?? sessionDir;
return {
cacheReads: asNumber(metadataValue(metadata, 'cacheReads')),
cacheWrites: asNumber(metadataValue(metadata, 'cacheWrites')),
@@ -189,12 +205,13 @@ const parseSessionEntry = (value: JsonValue, dataDir: string, directoryName: str
timestampFromJson(raw.ended_at ?? null) ??
timestampFromJson(raw.updated_at ?? null) ??
timestampFromJson(raw.started_at ?? null),
+ workspaceSource: header.workspaceSource,
};
};
const listClineSessionEntries = async (dataDir: string): Promise => {
const sessionDirectories = (await readDirectoryEntriesIfExists(path.join(dataDir, 'sessions'))).filter(
- (entry) => entry.isDirectory() && isSafeSessionId(entry.name),
+ (entry) => entry.isDirectory() && isSafeClineSessionId(entry.name),
);
const entries = await mapWithConcurrency(sessionDirectories, READ_CONCURRENCY, async (directory) =>
parseSessionEntry(
@@ -339,10 +356,6 @@ const parseSessionMessages = (
if (!message) {
return;
}
- const rawMessage = asObject(value);
- if (!rawMessage) {
- return;
- }
message.content.forEach((partValue, partIndex) => {
const part = asObject(partValue);
if (!part) {
@@ -370,16 +383,22 @@ const parseSessionMessages = (
return messages;
};
-const deleteClineSessionIndex = async (dataDir: string, sessionId: string): Promise => {
+const deleteClineSessionIndex = async (dataDir: string, sessionId: string): Promise => {
const databasePath = path.join(dataDir, 'db', 'sessions.db');
if (!(await Bun.file(databasePath).exists())) {
- return false;
+ return { status: 'not_found' };
}
- const database = new Database(databasePath, { create: false, readwrite: true });
try {
- return database.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0;
- } finally {
- database.close();
+ const database = new Database(databasePath, { create: false, readwrite: true });
+ try {
+ return database.prepare('DELETE FROM sessions WHERE session_id = ?').run(sessionId).changes > 0
+ ? { status: 'deleted' }
+ : { status: 'not_found' };
+ } finally {
+ database.close();
+ }
+ } catch (error) {
+ return { message: error instanceof Error ? error.message : String(error), status: 'failed' };
}
};
@@ -404,9 +423,6 @@ const readClineTranscriptFromEntry = async (
): Promise => {
const includeRawPayloads = options.includeRawPayloads ?? true;
const messages = parseSessionMessages(await readJson(entry.messagesPath), entry.cwd, entry.id, includeRawPayloads);
- if (messages.length === 0) {
- return null;
- }
const stats = getStats(messages);
const createdAtMs = messages.reduce((earliest, message) => {
if (message.createdAtMs === null) {
@@ -433,6 +449,7 @@ const readClineTranscriptFromEntry = async (
ulid: null,
workspaceKey: getWorkspaceKey(entry.cwd),
workspaceLabel: getPortablePathBasename(entry.cwd) || entry.cwd,
+ workspaceSource: entry.workspaceSource,
worktree: entry.cwd,
};
return {
@@ -443,16 +460,30 @@ const readClineTranscriptFromEntry = async (
};
};
-const listClineTranscripts = async (dataDir: string, options: ReadTranscriptOptions = {}) => {
+const listClineTranscripts = async (
+ dataDir: string,
+ options: ReadTranscriptOptions = {},
+ cache?: ClineTranscriptCache,
+) => {
+ if (!options.includeRawPayloads && cache?.dataDir === dataDir && cache.transcripts) {
+ return cache.transcripts;
+ }
const entries = await listClineSessionEntries(dataDir);
const transcripts = await mapWithConcurrency(entries, READ_CONCURRENCY, (entry) =>
readClineTranscriptFromEntry(entry, options),
);
- return transcripts.flatMap((transcript) => transcript ?? []);
+ const result = transcripts.flatMap((transcript) => transcript ?? []);
+ if (!options.includeRawPayloads && cache?.dataDir === dataDir) {
+ cache.transcripts = result;
+ }
+ return result;
};
-export const listClineWorkspaceGroups = async (dataDir = resolveClineDataDir()): Promise => {
- const transcripts = await listClineTranscripts(dataDir, { includeRawPayloads: false });
+export const listClineWorkspaceGroups = async (
+ dataDir = resolveClineDataDir(),
+ cache?: ClineTranscriptCache,
+): Promise => {
+ const transcripts = await listClineTranscripts(dataDir, { includeRawPayloads: false }, cache);
const byWorkspace = new Map();
for (const { task } of transcripts) {
byWorkspace.set(task.worktree, [...(byWorkspace.get(task.worktree) ?? []), task]);
@@ -481,12 +512,13 @@ export const listClineWorkspaceGroups = async (dataDir = resolveClineDataDir()):
export const listClineTasksForGroup = async (
workspaceKey: string,
dataDir = resolveClineDataDir(),
+ cache?: ClineTranscriptCache,
): Promise => {
const worktree = getWorktreeFromWorkspaceKey(workspaceKey);
if (!worktree) {
return [];
}
- return (await listClineTranscripts(dataDir, { includeRawPayloads: false }))
+ return (await listClineTranscripts(dataDir, { includeRawPayloads: false }, cache))
.map(({ task }) => task)
.filter((task) => task.worktree === worktree)
.sort((left, right) => (right.lastActiveAtMs ?? 0) - (left.lastActiveAtMs ?? 0));
@@ -497,28 +529,38 @@ export const readClineTaskTranscript = async (
taskId: string,
options: ReadTranscriptOptions = {},
): Promise => {
- if (!isSafeSessionId(taskId)) {
+ if (!isSafeClineSessionId(taskId)) {
return null;
}
- const entry = (await listClineSessionEntries(dataDir)).find((candidate) => candidate.id === taskId);
+ const entry = await parseSessionEntry(
+ await readJson(path.join(dataDir, 'sessions', taskId, `${taskId}.json`)),
+ dataDir,
+ taskId,
+ );
return entry ? readClineTranscriptFromEntry(entry, options) : null;
};
export const deleteClineTask = async (dataDir: string, taskId: string): Promise =>
clineDeleteLimiter(async () => {
- if (!isSafeSessionId(taskId)) {
- return { deletedFiles: [], deletedTaskIds: [] };
+ if (!isSafeClineSessionId(taskId)) {
+ return { deletedFiles: [], deletedTaskIds: [], indexCleanup: { status: 'not_found' } };
}
const sessionDir = path.join(dataDir, 'sessions', taskId);
const metadataPath = path.join(sessionDir, `${taskId}.json`);
const messagesPath = path.join(sessionDir, `${taskId}.messages.json`);
- if (!(await Bun.file(metadataPath).exists()) && !(await Bun.file(messagesPath).exists())) {
- return { deletedFiles: [], deletedTaskIds: [] };
- }
- const indexDeleted = await deleteClineSessionIndex(dataDir, taskId);
+ const sessionDirExists = await stat(sessionDir)
+ .then(() => true)
+ .catch(() => false);
+ const hasSessionFiles = (await Bun.file(metadataPath).exists()) || (await Bun.file(messagesPath).exists());
+ const indexCleanup = await deleteClineSessionIndex(dataDir, taskId);
await rm(sessionDir, { force: true, recursive: true });
+ const deleted = sessionDirExists || indexCleanup.status === 'deleted';
+ if (!deleted && !hasSessionFiles) {
+ return { deletedFiles: [], deletedTaskIds: [], indexCleanup };
+ }
return {
- deletedFiles: [sessionDir, ...(indexDeleted ? [path.join(dataDir, 'db', 'sessions.db')] : [])],
- deletedTaskIds: [taskId],
+ deletedFiles: sessionDirExists ? [sessionDir] : [],
+ deletedTaskIds: deleted ? [taskId] : [],
+ indexCleanup,
};
});
diff --git a/src/lib/cline-exporter-types.ts b/src/lib/cline-exporter-types.ts
index 85b5bcc..bec58f3 100644
--- a/src/lib/cline-exporter-types.ts
+++ b/src/lib/cline-exporter-types.ts
@@ -10,9 +10,19 @@ export const resolveClineDataDir = (env: NodeJS.ProcessEnv = process.env, homeDi
return env.SPIRACHA_CLINE_DATA_DIR?.trim() || getDefaultClineDataDir(homeDir);
};
+export const CLINE_SESSION_ID_PATTERN = /^[\p{L}\p{N}_-]+$/u;
+
+export const isSafeClineSessionId = (sessionId: string): boolean => CLINE_SESSION_ID_PATTERN.test(sessionId);
+
+export type ClineIndexCleanupResult =
+ | { status: 'deleted' }
+ | { status: 'not_found' }
+ | { message: string; status: 'failed' };
+
export type DeleteClineTaskResult = {
deletedFiles: string[];
deletedTaskIds: string[];
+ indexCleanup: ClineIndexCleanupResult;
};
export type ClineWorkspaceGroup = {
@@ -75,6 +85,7 @@ export type ClineTaskSummary = {
userMessageCount: number;
workspaceKey: string;
workspaceLabel: string;
+ workspaceSource?: 'metadata' | 'session_directory';
worktree: string;
};
diff --git a/src/lib/codex-browser-db.test.ts b/src/lib/codex-browser-db.test.ts
index 8e5949f..cde3d5c 100644
--- a/src/lib/codex-browser-db.test.ts
+++ b/src/lib/codex-browser-db.test.ts
@@ -24,6 +24,23 @@ import { createCodexBrowserFixture } from './codex-test-helpers';
const tempPaths: string[] = [];
+const waitForOutputMarker = async (stream: ReadableStream, marker: string) => {
+ const reader = stream.getReader();
+ const decoder = new TextDecoder();
+ let output = '';
+ try {
+ while (!output.includes(marker)) {
+ const { done, value } = await reader.read();
+ if (done) {
+ throw new Error(`Process exited before emitting ${marker}`);
+ }
+ output += decoder.decode(value, { stream: true });
+ }
+ } finally {
+ reader.releaseLock();
+ }
+};
+
afterEach(async () => {
await Promise.all(tempPaths.splice(0).map((targetPath) => rm(targetPath, { force: true, recursive: true })));
});
@@ -1184,7 +1201,6 @@ describe('codex browser db', () => {
db.exec('ALTER TABLE threads DROP COLUMN preview');
db.close();
- expect(() => getThreadBrowseData(fixture.dbPath, fixture.threadId)).toThrow(CodexDbCompatibilityError);
try {
getThreadBrowseData(fixture.dbPath, fixture.threadId);
throw new Error('expected schema compatibility failure');
@@ -1198,6 +1214,106 @@ describe('codex browser db', () => {
}
});
+ it('should report every invalid dynamic-tool field in one compatibility error', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-invalid-dynamic-tool-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const db = new Database(fixture.dbPath);
+ db.exec(`
+ DROP TABLE thread_dynamic_tools;
+ CREATE TABLE thread_dynamic_tools (
+ thread_id, position, name, description, input_schema, defer_loading, namespace
+ );
+ `);
+ db.query('INSERT INTO thread_dynamic_tools VALUES (?, ?, ?, ?, ?, ?, ?)').run(
+ fixture.threads[0]!.threadId,
+ 'invalid',
+ 42,
+ 42,
+ null,
+ 0,
+ null,
+ );
+ db.close();
+
+ expect(() => getThreadBrowseData(fixture.dbPath, fixture.threads[0]!.threadId)).toThrow(
+ CodexDbCompatibilityError,
+ );
+ try {
+ getThreadBrowseData(fixture.dbPath, fixture.threads[0]!.threadId);
+ throw new Error('expected invalid dynamic-tool fields');
+ } catch (error) {
+ expect(error).toMatchObject({
+ invalidFields: expect.arrayContaining([
+ 'thread_dynamic_tools.description',
+ 'thread_dynamic_tools.name',
+ 'thread_dynamic_tools.position',
+ ]),
+ });
+ }
+ });
+
+ it('should report every invalid goal field in one compatibility error', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-invalid-goal-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const db = new Database(fixture.dbPath);
+ db.exec(
+ 'DROP TABLE thread_goals; CREATE TABLE thread_goals (thread_id, goal_id, objective, status, token_budget, tokens_used, time_used_seconds, created_at_ms, updated_at_ms);',
+ );
+ db.query('INSERT INTO thread_goals VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)').run(
+ fixture.threads[0]!.threadId,
+ 42,
+ 'objective',
+ 'status',
+ 'invalid',
+ 1,
+ 1,
+ 'invalid',
+ 1,
+ );
+ db.close();
+
+ try {
+ getThreadBrowseData(fixture.dbPath, fixture.threads[0]!.threadId);
+ throw new Error('expected invalid goal fields');
+ } catch (error) {
+ expect(error).toMatchObject({
+ invalidFields: expect.arrayContaining([
+ 'thread_goals.created_at_ms',
+ 'thread_goals.goal_id',
+ 'thread_goals.token_budget',
+ ]),
+ });
+ }
+ });
+
+ it('should report every invalid spawn-edge field in one compatibility error', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-invalid-edge-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const db = new Database(fixture.dbPath);
+ db.exec(`
+ DROP TABLE thread_spawn_edges;
+ CREATE TABLE thread_spawn_edges (parent_thread_id, child_thread_id, status);
+ `);
+ db.query('INSERT INTO thread_spawn_edges VALUES (?, ?, ?)').run(42, 42, 42);
+ db.close();
+
+ try {
+ getThreadBrowseDataBatch(fixture.dbPath, [42 as unknown as string]);
+ throw new Error('expected invalid spawn-edge fields');
+ } catch (error) {
+ expect(error).toMatchObject({
+ invalidFields: expect.arrayContaining([
+ 'thread_spawn_edges.child_thread_id',
+ 'thread_spawn_edges.parent_thread_id',
+ 'thread_spawn_edges.status',
+ ]),
+ });
+ }
+ });
+
it('should batch browse in request order with fallback and missing outcomes without unbounded id parameters', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-batch-browse-test-'));
tempPaths.push(tempRoot);
@@ -1227,7 +1343,28 @@ describe('codex browser db', () => {
relations: { parentThreadId: fixture.threads[0]!.threadId },
},
});
- expect(results.every((result) => result.threadId === ids[results.indexOf(result)])).toBe(true);
+ expect(results.map((result) => result.threadId)).toEqual(ids);
+ });
+
+ it('should preserve one relation across browse chunks without duplicate child edges', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-cross-chunk-relations-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const parentThreadId = fixture.threads[0]!.threadId;
+ const childThreadId = fixture.threads[1]!.threadId;
+ const ids = [
+ parentThreadId,
+ ...Array.from({ length: 399 }, (_, index) => `missing-cross-chunk-${index}`),
+ childThreadId,
+ ];
+
+ const results = getThreadBrowseDataBatch(fixture.dbPath, ids);
+
+ expect(results.map((result) => result.threadId)).toEqual(ids);
+ expect(results[0]?.data?.relations.childEdges).toEqual([
+ { child_thread_id: childThreadId, parent_thread_id: parentThreadId, status: 'completed' },
+ ]);
+ expect(results.at(-1)?.data?.relations.parentThreadId).toBe(parentThreadId);
});
it('should prefer the Codex session index title over the stored prompt text', async () => {
@@ -1450,18 +1587,18 @@ describe('codex browser db', () => {
expect(first).toMatchObject({
cleanup: {
- deletedSessionFiles: [],
requested: false,
},
deletedThreadIds: [threadId],
});
expect(second).toMatchObject({
cleanup: {
- deletedSessionFiles: [],
requested: false,
},
deletedThreadIds: [],
});
+ expect(first.cleanup).not.toHaveProperty('deletedSessionFiles');
+ expect(second.cleanup).not.toHaveProperty('deletedSessionFiles');
});
it('should let a competing writer release its lock before the configured busy timeout expires', async () => {
@@ -1473,21 +1610,41 @@ describe('codex browser db', () => {
import { Database } from 'bun:sqlite';
const db = new Database(Bun.argv.at(-1));
db.exec('BEGIN EXCLUSIVE');
- await Bun.sleep(150);
+ console.log('LOCK_HELD');
+ await new Response(Bun.stdin).text();
db.exec('COMMIT');
db.close();
`;
const holder = Bun.spawn([process.execPath, '-e', childScript, fixture.dbPath], {
cwd: process.cwd(),
stderr: 'pipe',
+ stdin: 'pipe',
stdout: 'pipe',
});
- await Bun.sleep(25);
- const result = await deleteCodexThread(fixture.dbPath, threadId);
+ await waitForOutputMarker(holder.stdout, 'LOCK_HELD');
+
+ const deleteScript = `
+ import { deleteCodexThread } from './src/lib/codex-browser-db.ts';
+ console.error('DELETE_STARTED');
+ const result = await deleteCodexThread(Bun.argv.at(-2), Bun.argv.at(-1));
+ console.log(JSON.stringify(result.deletedThreadIds));
+ `;
+ const deleter = Bun.spawn([process.execPath, '-e', deleteScript, fixture.dbPath, threadId], {
+ cwd: process.cwd(),
+ stderr: 'pipe',
+ stdout: 'pipe',
+ });
+ await waitForOutputMarker(deleter.stderr, 'DELETE_STARTED');
+ holder.stdin.write('release\n');
+ holder.stdin.end();
+
+ const result = JSON.parse(await new Response(deleter.stdout).text()) as string[];
const holderExitCode = await holder.exited;
+ const deleterExitCode = await deleter.exited;
expect(holderExitCode).toBe(0);
- expect(result.deletedThreadIds).toEqual([threadId]);
+ expect(deleterExitCode).toBe(0);
+ expect(result).toEqual([threadId]);
});
it('should make concurrent child-process deletes of one id idempotent', async () => {
@@ -1519,7 +1676,8 @@ describe('codex browser db', () => {
if (!outcomes.every((outcome) => outcome.exitCode === 0)) {
throw new Error(JSON.stringify(outcomes));
}
- expect(outcomes.flatMap((outcome) => JSON.parse(outcome.output) as string[]).sort()).toEqual([threadId]);
+ const reportedThreadIds = outcomes.flatMap((outcome) => JSON.parse(outcome.output) as string[]);
+ expect(reportedThreadIds.every((reportedThreadId) => reportedThreadId === threadId)).toBe(true);
const db = new Database(fixture.dbPath, { readonly: true });
expect(db.query('SELECT COUNT(*) AS count FROM threads WHERE id = ?').get(threadId)).toEqual({ count: 0 });
db.close();
diff --git a/src/lib/codex-browser-db.ts b/src/lib/codex-browser-db.ts
index 4f98919..b701fb1 100644
--- a/src/lib/codex-browser-db.ts
+++ b/src/lib/codex-browser-db.ts
@@ -40,7 +40,8 @@ type DeleteProjectOptions = {
};
const SQLITE_DELETE_BATCH_SIZE = 400;
-const SQLITE_BUSY_TIMEOUT_MS = 5_000;
+// Let the bounded retry policy own lock waiting so a failed transaction is retried on a fresh connection.
+const SQLITE_BUSY_TIMEOUT_MS = 0;
const SESSION_FILE_DELETE_CONCURRENCY = 16;
const THREAD_LIST_IO_CONCURRENCY = 8;
const DASHBOARD_RESULT_LIMIT = 5;
@@ -219,6 +220,11 @@ type FallbackThreadRowOptions = ReadFallbackThreadRowsOptions & {
projectName?: string | null;
};
+type DeleteThreadMutationResult = {
+ deletedRolloutPaths: string[];
+ deletedThreadIds: string[];
+};
+
const isSqliteCantOpenError = (error: unknown) => {
return (error as { code?: unknown }).code === 'SQLITE_CANTOPEN';
};
@@ -262,7 +268,7 @@ const openReadonlyDb = (dbPath: string) => {
};
const openWritableDb = (dbPath: string, busyTimeoutMs: number) => {
- const db = new Database(dbPath);
+ const db = new Database(dbPath, { create: false, readwrite: true });
try {
db.exec(`PRAGMA busy_timeout = ${busyTimeoutMs}`);
return db;
@@ -281,31 +287,76 @@ const compareThreadsByRecentActivity = (
right: Pick,
) => getThreadUpdatedAtMs(right) - getThreadUpdatedAtMs(left) || compareCodeUnits(right.id, left.id);
-const parseDynamicToolRow = (row: unknown): DynamicToolRow => {
+type CodexRowDecoder = {
+ assertValid: () => void;
+ nullableNumber: (field: string) => number | null;
+ requiredNumber: (field: string) => number;
+ requiredString: (field: string) => string;
+ values: Record;
+};
+
+const createCodexRowDecoder = (row: unknown, tableName: string): CodexRowDecoder => {
const values = row as Record;
+ const invalidFields: string[] = [];
+ const fieldPath = (field: string) => `${tableName}.${field}`;
const requiredString = (field: string) => {
const value = values[field];
if (typeof value !== 'string') {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_dynamic_tools.${field}`]);
+ invalidFields.push(fieldPath(field));
+ return '';
+ }
+ return value;
+ };
+ const requiredNumber = (field: string) => {
+ const value = values[field];
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ invalidFields.push(fieldPath(field));
+ return 0;
+ }
+ return value;
+ };
+ const nullableNumber = (field: string) => {
+ const value = values[field];
+ if (value === null) {
+ return null;
+ }
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ invalidFields.push(fieldPath(field));
+ return null;
}
return value;
};
- const position = values.position;
- if (typeof position !== 'number' || !Number.isFinite(position)) {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], ['thread_dynamic_tools.position']);
- }
return {
- deferLoading: Number(values.defer_loading ?? 0) === 1,
- description: requiredString('description'),
- inputSchema: parseJsonSafely(typeof values.input_schema === 'string' ? values.input_schema : null),
- name: requiredString('name'),
- namespace: typeof values.namespace === 'string' ? values.namespace : null,
- position,
- threadId: requiredString('thread_id'),
+ assertValid: () => {
+ if (invalidFields.length > 0) {
+ throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], invalidFields);
+ }
+ },
+ nullableNumber,
+ requiredNumber,
+ requiredString,
+ values,
};
};
+const parseDynamicToolRow = (row: unknown): DynamicToolRow => {
+ const decoder = createCodexRowDecoder(row, 'thread_dynamic_tools');
+ const decoded = {
+ deferLoading: Number(decoder.values.defer_loading ?? 0) === 1,
+ description: decoder.requiredString('description'),
+ inputSchema: parseJsonSafely(
+ typeof decoder.values.input_schema === 'string' ? decoder.values.input_schema : null,
+ ),
+ name: decoder.requiredString('name'),
+ namespace: typeof decoder.values.namespace === 'string' ? decoder.values.namespace : null,
+ position: decoder.requiredNumber('position'),
+ threadId: decoder.requiredString('thread_id'),
+ };
+ decoder.assertValid();
+ return decoded;
+};
+
const parseJsonSafely = (value: string | null) => {
if (!value) {
return null;
@@ -319,70 +370,30 @@ const parseJsonSafely = (value: string | null) => {
};
const decodeThreadGoalRow = (row: unknown): ThreadGoalRow => {
- const values = row as Record;
- const requiredString = (field: keyof ThreadGoalRow) => {
- const value = values[field];
- if (typeof value !== 'string') {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
- }
- return value;
- };
- const requiredNumber = (field: keyof ThreadGoalRow) => {
- const value = values[field];
- if (typeof value !== 'number' || !Number.isFinite(value)) {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
- }
- return value;
- };
- const nullableNumber = (field: keyof ThreadGoalRow) => {
- const value = values[field];
- if (value === null) {
- return null;
- }
- if (typeof value !== 'number' || !Number.isFinite(value)) {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], [`thread_goals.${String(field)}`]);
- }
- return value;
- };
-
- return {
- created_at_ms: requiredNumber('created_at_ms'),
- goal_id: requiredString('goal_id'),
- objective: requiredString('objective'),
- status: requiredString('status'),
- time_used_seconds: requiredNumber('time_used_seconds'),
- token_budget: nullableNumber('token_budget'),
- tokens_used: requiredNumber('tokens_used'),
- updated_at_ms: requiredNumber('updated_at_ms'),
+ const decoder = createCodexRowDecoder(row, 'thread_goals');
+ const decoded = {
+ created_at_ms: decoder.requiredNumber('created_at_ms'),
+ goal_id: decoder.requiredString('goal_id'),
+ objective: decoder.requiredString('objective'),
+ status: decoder.requiredString('status'),
+ time_used_seconds: decoder.requiredNumber('time_used_seconds'),
+ token_budget: decoder.nullableNumber('token_budget'),
+ tokens_used: decoder.requiredNumber('tokens_used'),
+ updated_at_ms: decoder.requiredNumber('updated_at_ms'),
};
+ decoder.assertValid();
+ return decoded;
};
const decodeThreadSpawnEdgeRow = (row: unknown): ThreadSpawnEdge => {
- const values = row as Record;
- if (typeof values.parent_thread_id !== 'string') {
- throw new CodexDbCompatibilityError(
- CODEX_BROWSE_SCHEMA_PROFILE,
- [],
- [],
- ['thread_spawn_edges.parent_thread_id'],
- );
- }
- if (typeof values.child_thread_id !== 'string') {
- throw new CodexDbCompatibilityError(
- CODEX_BROWSE_SCHEMA_PROFILE,
- [],
- [],
- ['thread_spawn_edges.child_thread_id'],
- );
- }
- if (typeof values.status !== 'string') {
- throw new CodexDbCompatibilityError(CODEX_BROWSE_SCHEMA_PROFILE, [], [], ['thread_spawn_edges.status']);
- }
- return {
- child_thread_id: values.child_thread_id,
- parent_thread_id: values.parent_thread_id,
- status: values.status,
+ const decoder = createCodexRowDecoder(row, 'thread_spawn_edges');
+ const decoded = {
+ child_thread_id: decoder.requiredString('child_thread_id'),
+ parent_thread_id: decoder.requiredString('parent_thread_id'),
+ status: decoder.requiredString('status'),
};
+ decoder.assertValid();
+ return decoded;
};
export class CodexDbCompatibilityError extends Error {
@@ -412,10 +423,20 @@ export class CodexDbCompatibilityError extends Error {
}
}
+const CODEX_SCHEMA_TABLE_PRAGMAS: Record = {
+ thread_dynamic_tools: 'PRAGMA table_info(thread_dynamic_tools)',
+ thread_goals: 'PRAGMA table_info(thread_goals)',
+ thread_spawn_edges: 'PRAGMA table_info(thread_spawn_edges)',
+ threads: 'PRAGMA table_info(threads)',
+};
+
const getSchemaTableColumns = (db: Database, tableName: string) => {
- return new Set(
- (db.query(`PRAGMA table_info(${tableName})`).all() as Array<{ name: string }>).map((column) => column.name),
- );
+ if (!Object.hasOwn(CODEX_SCHEMA_TABLE_PRAGMAS, tableName)) {
+ throw new Error(`Unsupported Codex schema table: ${tableName}`);
+ }
+ const pragma = CODEX_SCHEMA_TABLE_PRAGMAS[tableName]!;
+
+ return new Set((db.query(pragma).all() as Array<{ name: string }>).map((column) => column.name));
};
const assertCodexSchemaCompatibility = (db: Database, profile: CodexDbSchemaProfile) => {
@@ -528,17 +549,19 @@ const decodeThreadRow = (row: unknown): ThreadRow => {
const withSqliteTransaction = (db: Database, callback: (db: Database) => T): T => {
db.exec('BEGIN DEFERRED');
- let open = true;
try {
const result = callback(db);
db.exec('COMMIT');
- open = false;
return result;
} catch (error) {
- if (open) {
+ if (db.inTransaction) {
try {
db.exec('ROLLBACK');
- } catch {}
+ } catch (rollbackError) {
+ console.warn('[spiracha:codex] SQLite rollback failed', {
+ error: rollbackError instanceof Error ? rollbackError.message : String(rollbackError),
+ });
+ }
}
throw error;
}
@@ -573,21 +596,27 @@ export const withReadonlyDb = (dbPath: string, callback: (db: Database) => T)
};
const withWritableDb = (dbPath: string, callback: (db: Database) => T): T => {
- const db = runWithSqliteRetry({
+ return runWithSqliteRetry({
action: () => {
- return openWritableDb(dbPath, SQLITE_BUSY_TIMEOUT_MS);
+ const db = openWritableDb(dbPath, SQLITE_BUSY_TIMEOUT_MS);
+ try {
+ const result = callback(db);
+ if (isPromiseLike(result)) {
+ throw new Error('Database callbacks must be synchronous');
+ }
+
+ return result;
+ } finally {
+ try {
+ db.close();
+ } catch (error) {
+ console.warn('[spiracha:codex] SQLite close failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ }
},
});
- try {
- const result = runWithSqliteRetry({ action: () => callback(db) });
- if (isPromiseLike(result)) {
- throw new Error('Database callbacks must be synchronous');
- }
-
- return result;
- } finally {
- db.close();
- }
};
export const resolveCodexThreadDbPath = () => {
@@ -766,9 +795,9 @@ const readSessionIndexEntries = (codexDir: string): SessionIndexEntry[] => {
return entries;
};
-const getSessionIndexThreadNamesById = (codexDir: string) => {
+const getSessionIndexThreadNamesById = (codexDir: string, suppliedEntries?: SessionIndexEntry[]) => {
const threadNamesById = new Map();
- for (const entry of readSessionIndexEntries(codexDir)) {
+ for (const entry of suppliedEntries ?? readSessionIndexEntries(codexDir)) {
const threadName = entry.thread_name?.trim();
if (threadName) {
threadNamesById.set(entry.id, threadName);
@@ -777,8 +806,13 @@ const getSessionIndexThreadNamesById = (codexDir: string) => {
return threadNamesById;
};
-const applySessionIndexThreadNames = (dbPath: string, threads: T[]): T[] => {
- const threadNamesById = getSessionIndexThreadNamesById(resolveCodexDirFromDbPath(dbPath));
+const applySessionIndexThreadNames = (
+ dbPath: string,
+ threads: T[],
+ suppliedThreadNamesById?: Map,
+): T[] => {
+ const threadNamesById =
+ suppliedThreadNamesById ?? getSessionIndexThreadNamesById(resolveCodexDirFromDbPath(dbPath));
return threads.map((thread) => {
const threadName = threadNamesById.get(thread.id);
return threadName ? { ...thread, title: threadName } : thread;
@@ -846,8 +880,7 @@ const getSessionFileIndexFingerprint = (sessionsDir: string) => {
)}`;
};
-const getSessionFilesByThreadId = (sessionsDir: string) => {
- const fingerprint = getSessionFileIndexFingerprint(sessionsDir);
+const getSessionFilesByThreadId = (sessionsDir: string, fingerprint = getSessionFileIndexFingerprint(sessionsDir)) => {
const cached = sessionFileIndexCache.get(sessionsDir);
if (cached?.fingerprint === fingerprint) {
return cached.sessionFilesByThreadId;
@@ -1285,18 +1318,41 @@ const readFallbackThreadRows = (
return fallbackThreads;
};
+type BrowseFilesystemData = {
+ sessionFilesByThreadId: Map;
+ sessionIndexEntries: SessionIndexEntry[];
+ sessionIndexThreadNames: Map;
+};
+
+const readBrowseFilesystemData = (dbPath: string): BrowseFilesystemData => {
+ const codexDir = resolveCodexDirFromDbPath(dbPath);
+ const sessionsDir = path.join(codexDir, 'sessions');
+ const sessionFileIndexFingerprint = getSessionFileIndexFingerprint(sessionsDir);
+ const sessionIndexEntries = readSessionIndexEntries(codexDir);
+ return {
+ sessionFilesByThreadId: getSessionFilesByThreadId(sessionsDir, sessionFileIndexFingerprint),
+ sessionIndexEntries,
+ sessionIndexThreadNames: getSessionIndexThreadNamesById(codexDir, sessionIndexEntries),
+ };
+};
+
const readFallbackThreadRowById = (
dbPath: string,
threadId: string,
options: ReadFallbackThreadRowsOptions = {},
+ suppliedFilesystemData?: BrowseFilesystemData,
): ThreadRow | null => {
const codexDir = resolveCodexDirFromDbPath(dbPath);
- const entry = readSessionIndexEntries(codexDir).find((candidate) => candidate.id === threadId);
+ const entry = (suppliedFilesystemData?.sessionIndexEntries ?? readSessionIndexEntries(codexDir)).find(
+ (candidate) => candidate.id === threadId,
+ );
if (!entry) {
return null;
}
- const sessionFile = findSessionFileByThreadId(path.join(codexDir, 'sessions'), threadId);
+ const sessionFile = suppliedFilesystemData
+ ? (suppliedFilesystemData.sessionFilesByThreadId.get(threadId) ?? null)
+ : findSessionFileByThreadId(path.join(codexDir, 'sessions'), threadId);
if (!sessionFile) {
return null;
}
@@ -1630,18 +1686,10 @@ const deleteHistoryThreadRows = (db: Database, historyTableNames: Set, t
}
};
-const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): DeleteThreadsResult => {
+const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): DeleteThreadMutationResult => {
const uniqueThreadIds = uniqueValues(threadIds);
if (uniqueThreadIds.length === 0) {
- return {
- cleanup: {
- deletedSessionFiles: [],
- requested: false,
- sessionIndexEntriesRemoved: [],
- },
- deletedSessionFiles: [],
- deletedThreadIds: [],
- };
+ return { deletedRolloutPaths: [], deletedThreadIds: [] };
}
let threadTargets: Array<{ id: string; rollout_path: string }> = [];
@@ -1657,8 +1705,8 @@ const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): Del
if (hasRegularFile(historyDbPath)) {
// SQLite coordinates this transaction across the attached database for normal commits. A process
// crash during WAL commit is not claimed to be crash-atomic across both database files.
- transactionDb.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
historyAttached = true;
+ transactionDb.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
}
const historyTableNames = historyAttached
@@ -1672,17 +1720,18 @@ const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): Del
});
} finally {
if (historyAttached) {
- db.query('DETACH DATABASE codex_history').run();
+ try {
+ db.query('DETACH DATABASE codex_history').run();
+ } catch (error) {
+ console.warn('[spiracha:codex] SQLite history detach failed', {
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
}
}
return {
- cleanup: {
- deletedSessionFiles: [],
- requested: false,
- sessionIndexEntriesRemoved: [],
- },
- deletedSessionFiles: threadTargets.map((target) => target.rollout_path),
+ deletedRolloutPaths: threadTargets.map((target) => target.rollout_path),
deletedThreadIds: existingIds,
};
};
@@ -1872,6 +1921,19 @@ const deleteSessionIndexEntriesForThreads = async (
};
};
+const buildDeleteThreadsResult = (
+ sessionIndexResult: Awaited>,
+ deleteSessionFiles: boolean | undefined,
+ deletedThreadIds: string[],
+): DeleteThreadsResult => ({
+ cleanup: {
+ requested: Boolean(deleteSessionFiles),
+ sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
+ },
+ deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
+ deletedThreadIds: uniqueValues([...deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
+});
+
const readProjectAggregateRows = (db: Database) => {
return db
.query(`
@@ -2070,6 +2132,7 @@ const readBrowseRelations = (
db: Database,
threadIdChunk: string[],
relationsByThreadId: Map,
+ seenEdgeIds: Set,
) => {
const placeholders = threadIdChunk.map(() => '?').join(', ');
const rows = db
@@ -2082,6 +2145,11 @@ const readBrowseRelations = (
.all(...threadIdChunk, ...threadIdChunk) as unknown[];
for (const row of rows) {
const edge = decodeThreadSpawnEdgeRow(row);
+ const edgeId = `${edge.parent_thread_id}\u0000${edge.child_thread_id}\u0000${edge.status}`;
+ if (seenEdgeIds.has(edgeId)) {
+ continue;
+ }
+ seenEdgeIds.add(edgeId);
const parentRelations = relationsByThreadId.get(edge.parent_thread_id);
if (parentRelations) {
parentRelations.childEdges.push(edge);
@@ -2104,6 +2172,7 @@ const readThreadBrowseDatabaseData = (dbPath: string, requestedThreadIds: string
const relationsByThreadId = new Map(
threadIds.map((threadId) => [threadId, { childEdges: [], parentThreadId: null }]),
);
+ const seenEdgeIds = new Set();
for (const threadIdChunk of chunkValues(threadIds, SQLITE_DELETE_BATCH_SIZE)) {
readBrowseThreadRows(snapshotDb, threadIdChunk, threadsById);
@@ -2116,7 +2185,7 @@ const readThreadBrowseDatabaseData = (dbPath: string, requestedThreadIds: string
}
if (existingTableNames.has('thread_spawn_edges')) {
- readBrowseRelations(snapshotDb, threadIdChunk, relationsByThreadId);
+ readBrowseRelations(snapshotDb, threadIdChunk, relationsByThreadId, seenEdgeIds);
}
}
@@ -2130,10 +2199,11 @@ const buildThreadBrowseData = (
thread: ThreadRow,
source: 'database' | 'fallback',
databaseData: ThreadBrowseDatabaseData | null,
+ filesystemData?: BrowseFilesystemData,
): ThreadBrowseData => {
// Session-index titles and fallback transcript metadata are filesystem reads and intentionally happen after
// the SQLite snapshot has committed.
- const indexedThread = applySessionIndexThreadNames(dbPath, [thread])[0]!;
+ const indexedThread = applySessionIndexThreadNames(dbPath, [thread], filesystemData?.sessionIndexThreadNames)[0]!;
const normalizedThread = normalizeThreadDisplayText({
...indexedThread,
rollout_path: resolveCodexRolloutPath(dbPath, indexedThread.rollout_path),
@@ -2171,13 +2241,14 @@ export const getThreadBrowseDataBatch = (dbPath: string, threadIds: string[]): C
return [];
}
+ const filesystemData = readBrowseFilesystemData(dbPath);
const databaseData = readThreadBrowseDatabaseData(dbPath, threadIds);
const fallbackThreadsById = new Map();
for (const threadId of uniqueValues(threadIds)) {
if (databaseData.threadsById.has(threadId)) {
continue;
}
- const fallbackThread = readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true });
+ const fallbackThread = readFallbackThreadRowById(dbPath, threadId, { includeSubagents: true }, filesystemData);
if (fallbackThread) {
fallbackThreadsById.set(threadId, fallbackThread);
}
@@ -2187,7 +2258,7 @@ export const getThreadBrowseDataBatch = (dbPath: string, threadIds: string[]): C
const databaseThread = databaseData.threadsById.get(threadId);
if (databaseThread) {
return {
- data: buildThreadBrowseData(dbPath, databaseThread, 'database', databaseData),
+ data: buildThreadBrowseData(dbPath, databaseThread, 'database', databaseData, filesystemData),
source: 'database',
status: 'found',
threadId,
@@ -2197,7 +2268,7 @@ export const getThreadBrowseDataBatch = (dbPath: string, threadIds: string[]): C
const fallbackThread = fallbackThreadsById.get(threadId);
if (fallbackThread) {
return {
- data: buildThreadBrowseData(dbPath, fallbackThread, 'fallback', null),
+ data: buildThreadBrowseData(dbPath, fallbackThread, 'fallback', null, filesystemData),
source: 'fallback',
status: 'found',
threadId,
@@ -2336,19 +2407,11 @@ export const deleteCodexThread = async (
const sessionIndexResult = await deleteSessionIndexEntriesForThreads(
dbPath,
threadIds,
- result.deletedSessionFiles,
+ result.deletedRolloutPaths,
Boolean(options.deleteSessionFiles),
);
- return {
- cleanup: {
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- requested: Boolean(options.deleteSessionFiles),
- sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
- },
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
- };
+ return buildDeleteThreadsResult(sessionIndexResult, options.deleteSessionFiles, result.deletedThreadIds);
} finally {
await invalidateCodexUiCaches();
}
@@ -2371,19 +2434,11 @@ export const deleteCodexThreads = async (
const sessionIndexResult = await deleteSessionIndexEntriesForThreads(
dbPath,
uniqueThreadIds,
- result.deletedSessionFiles,
+ result.deletedRolloutPaths,
Boolean(options.deleteSessionFiles),
);
- return {
- cleanup: {
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- requested: Boolean(options.deleteSessionFiles),
- sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
- },
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
- };
+ return buildDeleteThreadsResult(sessionIndexResult, options.deleteSessionFiles, result.deletedThreadIds);
} finally {
await invalidateCodexUiCaches();
}
@@ -2412,31 +2467,20 @@ export const deleteCodexProject = async (
await validateSessionFileDeletionTargets(dbPath, allThreadIds);
}
const result = withWritableDb(dbPath, (db) => {
- const deleted = deleteThreadIds(db, dbPath, allThreadIds);
-
- return {
- ...deleted,
- projectName,
- };
+ return deleteThreadIds(db, dbPath, allThreadIds);
});
try {
const sessionIndexResult = await deleteSessionIndexEntriesForThreads(
dbPath,
[...result.deletedThreadIds, ...fallbackThreadIds],
- result.deletedSessionFiles,
+ result.deletedRolloutPaths,
Boolean(options.deleteSessionFiles),
);
return {
- ...result,
- cleanup: {
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- requested: Boolean(options.deleteSessionFiles),
- sessionIndexEntriesRemoved: sessionIndexResult.deletedThreadIds,
- },
- deletedSessionFiles: sessionIndexResult.deletedSessionFiles,
- deletedThreadIds: uniqueValues([...result.deletedThreadIds, ...sessionIndexResult.deletedThreadIds]),
+ projectName,
+ ...buildDeleteThreadsResult(sessionIndexResult, options.deleteSessionFiles, result.deletedThreadIds),
};
} finally {
await invalidateCodexUiCaches();
diff --git a/src/lib/codex-browser-export.test.ts b/src/lib/codex-browser-export.test.ts
index 83b170e..97433fc 100644
--- a/src/lib/codex-browser-export.test.ts
+++ b/src/lib/codex-browser-export.test.ts
@@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { strFromU8, unzipSync } from 'fflate';
-import { renderCodexThreadDownload, renderCodexThreadsDownload } from './codex-browser-export';
+import { isArchiveWideFailure, renderCodexThreadDownload, renderCodexThreadsDownload } from './codex-browser-export';
import { createCodexBrowserFixture, createCodexFixture } from './codex-test-helpers';
import { UI_EXPORT_DIR_ENV } from './ui-export-files';
@@ -79,6 +79,15 @@ const appendLargeAssistantRecord = async (sessionFile: string) => {
};
describe('renderCodexThreadDownload', () => {
+ it('should treat wrapped archive file errors as archive-wide failures', () => {
+ const cause = Object.assign(new Error('permission denied'), { code: 'EACCES' });
+
+ expect(isArchiveWideFailure(new Error('wrapped', { cause }))).toBe(true);
+ expect(isArchiveWideFailure({ code: 'EACCES' })).toBe(true);
+ expect(isArchiveWideFailure({ cause: { code: 'OTHER' } })).toBe(false);
+ expect(isArchiveWideFailure({ code: 42 })).toBe(false);
+ });
+
it('should render a thread export to downloadable markdown content', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-export-test-'));
tempPaths.push(tempRoot);
@@ -428,6 +437,7 @@ describe('renderCodexThreadDownload', () => {
schemaVersion: 1,
skippedCount: 1,
});
+ expect(download.skippedThreadCount).toBe(1);
expect(manifest.entries).toEqual([
{
fileName: 'spiracha-2026-05-17-1712-019e36d7.md',
diff --git a/src/lib/codex-browser-export.ts b/src/lib/codex-browser-export.ts
index 2f4418c..6a7d6f7 100644
--- a/src/lib/codex-browser-export.ts
+++ b/src/lib/codex-browser-export.ts
@@ -242,17 +242,18 @@ const writeBatchManifest = async (bundleDirectory: string, manifest: BatchExport
await Bun.write(path.join(bundleDirectory, BATCH_MANIFEST_FILE_NAME), `${JSON.stringify(manifest, null, 2)}\n`);
};
-const isArchiveWideFailure = (error: unknown) => {
+export const isArchiveWideFailure = (error: unknown) => {
if (error instanceof CodexDbCompatibilityError) {
return true;
}
- if (typeof error !== 'object' || error === null || !('code' in error)) {
+ if (typeof error !== 'object' || error === null || (!('code' in error) && !('cause' in error))) {
return false;
}
- const code = (error as { code?: unknown }).code;
- return typeof code === 'string' && ARCHIVE_WIDE_FILE_ERROR_CODES.has(code);
+ const candidate = error as { cause?: unknown; code?: unknown };
+ const codes = [candidate.code, (candidate.cause as { code?: unknown } | undefined)?.code];
+ return codes.some((code): code is string => typeof code === 'string' && ARCHIVE_WIDE_FILE_ERROR_CODES.has(code));
};
export const renderCodexThreadDownload = async (
@@ -491,6 +492,8 @@ export const renderCodexThreadsDownload = async (
const zipPath = buildUniqueArchivePath(exportDir, exportBaseName);
const usedBatchEntryBaseNames = new Set();
const manifestEntries: BatchExportManifestEntry[] = [];
+ let exportedCount = 0;
+ let skippedCount = 0;
logExportEvent('info', 'batch_start', {
exportBaseName,
@@ -504,7 +507,8 @@ export const renderCodexThreadsDownload = async (
manifestEntries.push(await renderCodexBatchEntry(input, result, bundleDirectory, usedBatchEntryBaseNames));
}
- const exportedCount = manifestEntries.filter((entry) => entry.status === 'exported').length;
+ exportedCount = manifestEntries.filter((entry) => entry.status === 'exported').length;
+ skippedCount = manifestEntries.length - exportedCount;
if (exportedCount === 0) {
throw new Error('No exportable threads');
}
@@ -515,7 +519,7 @@ export const renderCodexThreadsDownload = async (
generatedAt: new Date().toISOString(),
requestedThreadIds: threadIds,
schemaVersion: BATCH_MANIFEST_SCHEMA_VERSION,
- skippedCount: manifestEntries.length - exportedCount,
+ skippedCount,
});
await zipExportDirectory(bundleDirectory, zipPath);
} catch (error) {
@@ -547,6 +551,6 @@ export const renderCodexThreadsDownload = async (
fileName: `${exportBaseName}.zip`,
mimeType: 'application/zip',
mode: 'download_url',
- skippedThreadCount: manifestEntries.filter((entry) => entry.status !== 'exported').length,
+ skippedThreadCount: skippedCount,
};
};
diff --git a/src/lib/codex-browser-types.ts b/src/lib/codex-browser-types.ts
index f96c80d..8b3074d 100644
--- a/src/lib/codex-browser-types.ts
+++ b/src/lib/codex-browser-types.ts
@@ -243,7 +243,6 @@ export type CodexSessionIndexReconciliation = {
export type DeleteThreadsResult = {
cleanup: {
- deletedSessionFiles: string[];
requested: boolean;
sessionIndexEntriesRemoved: string[];
};
diff --git a/src/lib/codex-rollout-snapshot.test.ts b/src/lib/codex-rollout-snapshot.test.ts
index 38f01ad..de708da 100644
--- a/src/lib/codex-rollout-snapshot.test.ts
+++ b/src/lib/codex-rollout-snapshot.test.ts
@@ -1,7 +1,12 @@
import { describe, expect, it } from 'bun:test';
-import { CodexRolloutMutationError, copyStableCodexRollout } from './codex-rollout-snapshot';
+import {
+ type CodexRolloutIdentity,
+ CodexRolloutMutationError,
+ type CodexRolloutSourceError,
+ copyStableCodexRollout,
+} from './codex-rollout-snapshot';
-const identity = (overrides: Partial>['before']> = {}) => ({
+const identity = (overrides: Partial = {}) => ({
changeTimeMs: 2,
inode: 1,
modificationTimeMs: 3,
@@ -53,4 +58,56 @@ describe('Codex rollout snapshots', () => {
),
).rejects.toBeInstanceOf(CodexRolloutMutationError);
});
+
+ it('should classify a missing source during stat as missing', async () => {
+ const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
+
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 1,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-missing',
+ },
+ {
+ copy: async () => {},
+ stat: async () => {
+ throw error;
+ },
+ },
+ ),
+ ).rejects.toMatchObject({
+ cause: error,
+ code: 'CODEX_ROLLOUT_MISSING',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-missing',
+ } satisfies Partial);
+ });
+
+ it('should classify an unreadable source during copy and preserve its cause', async () => {
+ const error = Object.assign(new Error('permission denied'), { code: 'EACCES' });
+
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 1,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-unreadable',
+ },
+ {
+ copy: async () => {
+ throw error;
+ },
+ stat: async () => identity(),
+ },
+ ),
+ ).rejects.toMatchObject({
+ cause: error,
+ code: 'CODEX_ROLLOUT_UNREADABLE',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-unreadable',
+ } satisfies Partial);
+ });
});
diff --git a/src/lib/codex-rollout-snapshot.ts b/src/lib/codex-rollout-snapshot.ts
index 09feb7f..91f311a 100644
--- a/src/lib/codex-rollout-snapshot.ts
+++ b/src/lib/codex-rollout-snapshot.ts
@@ -105,6 +105,21 @@ const isSameIdentity = (before: CodexRolloutIdentity, after: CodexRolloutIdentit
);
};
+const runSourceOperation = async (
+ operation: () => Promise,
+ context: { sourcePath: string; threadId: string },
+): Promise => {
+ try {
+ return await operation();
+ } catch (error) {
+ throw new CodexRolloutSourceError({
+ cause: error,
+ code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
+ ...context,
+ });
+ }
+};
+
export const copyStableCodexRollout = async (
{
attempt,
@@ -119,40 +134,10 @@ export const copyStableCodexRollout = async (
},
operations: CodexRolloutSnapshotOperations = defaultOperations,
): Promise => {
- let before: CodexRolloutIdentity;
- try {
- before = await operations.stat(sourcePath);
- } catch (error) {
- throw new CodexRolloutSourceError({
- cause: error,
- code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
- sourcePath,
- threadId,
- });
- }
-
- try {
- await operations.copy(sourcePath, snapshotPath);
- } catch (error) {
- throw new CodexRolloutSourceError({
- cause: error,
- code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
- sourcePath,
- threadId,
- });
- }
-
- let after: CodexRolloutIdentity;
- try {
- after = await operations.stat(sourcePath);
- } catch (error) {
- throw new CodexRolloutSourceError({
- cause: error,
- code: isMissingError(error) ? 'CODEX_ROLLOUT_MISSING' : 'CODEX_ROLLOUT_UNREADABLE',
- sourcePath,
- threadId,
- });
- }
+ const context = { sourcePath, threadId };
+ const before = await runSourceOperation(() => operations.stat(sourcePath), context);
+ await runSourceOperation(() => operations.copy(sourcePath, snapshotPath), context);
+ const after = await runSourceOperation(() => operations.stat(sourcePath), context);
if (!isSameIdentity(before, after)) {
throw new CodexRolloutMutationError({ after, attempt, before, threadId });
diff --git a/src/lib/conversation-data/cline-adapter.ts b/src/lib/conversation-data/cline-adapter.ts
index 3636b95..4defb11 100644
--- a/src/lib/conversation-data/cline-adapter.ts
+++ b/src/lib/conversation-data/cline-adapter.ts
@@ -1,4 +1,5 @@
import {
+ createClineTranscriptCache,
deleteClineTask,
listClineTasksForGroup,
listClineWorkspaceGroups,
@@ -13,6 +14,7 @@ import { selectConversationMessages } from './message-selector';
import { getConversationPathMatch } from './path-match';
import type {
ConversationAdapter,
+ ConversationDataLocations,
ConversationDetail,
ConversationMessage,
ConversationPathMatch,
@@ -23,7 +25,7 @@ import type {
const CLINE_CONVERSATION_HYDRATION_CONCURRENCY = 4;
-const getDataDir = (options: { locations?: { clineDataDir?: string } }) =>
+const getDataDir = (options: { locations?: ConversationDataLocations }) =>
options.locations?.clineDataDir ?? resolveClineDataDir();
const transcriptToMessages = (transcript: ClineTaskTranscript): ConversationMessage[] =>
@@ -90,20 +92,21 @@ const buildConversation = async (
title: task.title,
updatedAtMs: task.lastActiveAtMs,
workspaceKey: task.workspaceKey,
- workspacePath: task.worktree,
+ workspacePath: task.workspaceSource === 'session_directory' ? null : task.worktree,
};
};
const listClineConversationsForPath = async (options: ListConversationsForPathOptions) => {
const dataDir = getDataDir(options);
- const groups = await listClineWorkspaceGroups(dataDir);
+ const transcriptCache = createClineTranscriptCache(dataDir);
+ const groups = await listClineWorkspaceGroups(dataDir, transcriptCache);
const conversations: ConversationDetail[] = [];
for (const group of groups) {
const match = await getConversationPathMatch(options.cwd, group.worktree);
if (!match) {
continue;
}
- const tasks = (await listClineTasksForGroup(group.key, dataDir)).filter((task) =>
+ const tasks = (await listClineTasksForGroup(group.key, dataDir, transcriptCache)).filter((task) =>
isWithinUpdatedWindow(task.lastActiveAtMs, options),
);
conversations.push(
diff --git a/src/lib/conversation-zip-export.test.ts b/src/lib/conversation-zip-export.test.ts
index 3960bcb..4c20c77 100644
--- a/src/lib/conversation-zip-export.test.ts
+++ b/src/lib/conversation-zip-export.test.ts
@@ -70,6 +70,6 @@ describe('createConversationMarkdownZip', () => {
}),
).rejects.toThrow('synthetic markdown read failure');
- expect((await readdir(os.tmpdir())).filter((name) => name.startsWith(fallbackProjectName))).toEqual([]);
+ expect((await readdir(os.tmpdir())).filter((name) => name.startsWith('cline_'))).toEqual([]);
});
});
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index 38b1869..8f43e98 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -1,10 +1,11 @@
import { constants, Database } from 'bun:sqlite';
import { afterEach, describe, expect, it } from 'bun:test';
-import { mkdir, mkdtemp, rm } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm, utimes } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
CURSOR_READONLY_DB_OPEN_FLAGS,
+ CURSOR_SQLITE_RETRY_DELAYS_MS,
decodeCursorUri,
findCursorTranscriptDirs,
findCursorTranscriptDirsForComposerIds,
@@ -591,6 +592,50 @@ describe('cursor-db transcript reads', () => {
expect(transcript?.renderableBubbleCount).toBe(3);
});
+ it('should use pre-resolved transcript directories without rediscovering them', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+ const transcriptDir = path.join(userDir, 'projects', 'demo-project', 'agent-transcripts', 'thread-1');
+ await mkdir(transcriptDir, { recursive: true });
+ await Bun.write(
+ path.join(transcriptDir, 'thread-1.jsonl'),
+ JSON.stringify({ message: { content: [{ text: 'Agent-only tail', type: 'text' }] }, role: 'assistant' }),
+ );
+
+ const transcript = await readCursorThreadTranscriptWithAgentFiles(
+ getCursorGlobalDbPath(userDir),
+ 'thread-1',
+ userDir,
+ [],
+ );
+
+ expect(transcript?.bubbles.map((bubble) => bubble.text)).toEqual(['First user request', 'Assistant reply', '']);
+ expect(transcript?.bubbles.some((bubble) => bubble.text === 'Agent-only tail')).toBe(false);
+ });
+
+ it('should skip stale CLI transcript parsing before reading its contents', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+ const projectDir = path.join(userDir, 'projects', 'stale-project');
+ const transcriptDir = path.join(projectDir, 'agent-transcripts', 'stale-thread');
+ const transcriptPath = path.join(transcriptDir, 'stale-thread.jsonl');
+ await mkdir(transcriptDir, { recursive: true });
+ await Bun.write(path.join(projectDir, '.workspace-trusted'), JSON.stringify({ workspacePath: '/tmp/stale' }));
+ await Bun.write(transcriptPath, '{not-json');
+ await utimes(transcriptPath, new Date(1_000), new Date(1_000));
+
+ const originalWarn = console.warn;
+ const warnings: unknown[][] = [];
+ console.warn = (...args: unknown[]) => warnings.push(args);
+ try {
+ await listCursorWorkspaceGroups(userDir, { updatedAfterMs: Date.now() });
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ expect(warnings.some((args) => String(args[0]).includes('invalid_agent_transcript_jsonl'))).toBe(false);
+ });
+
it('should append all agent transcript messages when no overlap exists', async () => {
const userDir = await makeUserDir();
const spec = baseSpec();
@@ -862,11 +907,15 @@ describe('openCursorReadonlyDb', () => {
}
});
- it('should read through a retry-aware callback while a writer holds the WAL database', async () => {
+ it('should retry a blocked readonly query while an exclusive writer holds the database', async () => {
const userDir = await makeUserDir();
await createCursorFixture(userDir, baseSpec());
const globalDbPath = getCursorGlobalDbPath(userDir);
- const lockProcess = await holdCursorWriteLock(globalDbPath);
+ const retryBudgetMs = CURSOR_SQLITE_RETRY_DELAYS_MS.reduce((total, delayMs) => total + delayMs, 0);
+ const lockProcess = await holdCursorWriteLock(globalDbPath, {
+ durationMs: Math.floor(retryBudgetMs / 2),
+ lockingMode: 'exclusive',
+ });
try {
const count = withCursorReadonlyDb(globalDbPath, (db) => {
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index 3de448b..c0e5c2d 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -40,6 +40,7 @@ type ComposerHeaderRow = {
};
export const CURSOR_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
+export const CURSOR_SQLITE_RETRY_DELAYS_MS = [40, 120, 250] as const;
// Cursor databases are WAL-mode. A plain read-only open fails once Cursor cleanly shuts down and
// removes the -wal/-shm sidecars, and the failure only surfaces at query time (so a try/catch around
@@ -85,6 +86,7 @@ export const withCursorReadonlyDb = (dbPath: string, callback: (db: Database)
db.close();
}
},
+ delaysMs: CURSOR_SQLITE_RETRY_DELAYS_MS,
});
export const withCursorWriteTransaction = (dbPath: string, callback: (db: Database) => T): T =>
@@ -108,9 +110,16 @@ export const withCursorWriteTransaction = (dbPath: string, callback: (db: Dat
}
throw error;
} finally {
- db.close();
+ try {
+ db.close();
+ } catch (closeError) {
+ console.warn('[spiracha:cursor] SQLite close failed', {
+ error: closeError instanceof Error ? closeError.message : String(closeError),
+ });
+ }
}
},
+ delaysMs: CURSOR_SQLITE_RETRY_DELAYS_MS,
});
const isMissingOrUnreadableCursorStoreError = (error: unknown): boolean => {
@@ -1629,6 +1638,18 @@ const readCursorAgentTranscript = async (
return { bubbles, bytes, createdAtMs, lastUpdatedAtMs };
};
+const getNewestCursorTranscriptMtimeMs = async (transcriptDir: string, composerId: string): Promise => {
+ let newestMtimeMs = 0;
+ for (const file of await listCursorAgentTranscriptFiles(transcriptDir, composerId)) {
+ try {
+ newestMtimeMs = Math.max(newestMtimeMs, (await stat(file)).mtimeMs);
+ } catch {
+ // The transcript may disappear while Cursor is writing it; the normal reader handles that race.
+ }
+ }
+ return newestMtimeMs;
+};
+
const readCursorProjectWorkspacePath = async (projectDir: string): Promise => {
try {
const data = (await Bun.file(path.join(projectDir, '.workspace-trusted')).json()) as {
@@ -1673,6 +1694,12 @@ const readCursorCliTranscriptThread = async (
}
const transcriptDir = path.join(agentTranscriptsDir, transcriptEntry.name);
+ if (
+ options.updatedAfterMs !== undefined &&
+ (await getNewestCursorTranscriptMtimeMs(transcriptDir, transcriptEntry.name)) <= options.updatedAfterMs
+ ) {
+ return null;
+ }
const transcript = await readCursorAgentTranscript(transcriptEntry.name, userDir, [transcriptDir]);
if (transcript.bubbles.length === 0 || !isCursorCliTranscriptAfterUpdate(transcript, options.updatedAfterMs)) {
return null;
@@ -1800,9 +1827,10 @@ export const readCursorThreadTranscriptWithAgentFiles = async (
globalDbPath: string,
composerId: string,
userDir = inferCursorUserDirFromGlobalDbPath(globalDbPath),
+ transcriptDirs?: string[],
): Promise => {
const transcript = (await pathExists(globalDbPath)) ? readCursorThreadTranscript(globalDbPath, composerId) : null;
- const agentTranscript = await readCursorAgentTranscript(composerId, userDir);
+ const agentTranscript = await readCursorAgentTranscript(composerId, userDir, transcriptDirs);
if (!transcript) {
if (agentTranscript.bubbles.length === 0) {
return null;
diff --git a/src/lib/cursor-id.ts b/src/lib/cursor-id.ts
index 1884162..486659d 100644
--- a/src/lib/cursor-id.ts
+++ b/src/lib/cursor-id.ts
@@ -18,5 +18,6 @@ export const buildCursorBubbleKeyLikePattern = (composerId: string): string => {
export const getCursorBubbleKeyRange = (composerId: string): { end: string; start: string } => {
const start = `bubbleId:${composerId}:`;
+ // ';' is the next code point after ':'; this half-open range depends on BINARY key collation.
return { end: `bubbleId:${composerId};`, start };
};
diff --git a/src/lib/cursor-recovery.test.ts b/src/lib/cursor-recovery.test.ts
index abb629c..789b905 100644
--- a/src/lib/cursor-recovery.test.ts
+++ b/src/lib/cursor-recovery.test.ts
@@ -1,9 +1,9 @@
import { Database } from 'bun:sqlite';
import { afterEach, describe, expect, it } from 'bun:test';
-import { mkdir, mkdtemp, rm, symlink } from 'node:fs/promises';
+import { mkdir, mkdtemp, rm, symlink, utimes } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { listCursorWorkspaceGroups, withCursorWriteTransaction } from './cursor-db';
+import { CURSOR_SQLITE_RETRY_DELAYS_MS, listCursorWorkspaceGroups, withCursorWriteTransaction } from './cursor-db';
import { getCursorGlobalDbPath } from './cursor-exporter-types';
import {
collectCursorThreadsForDeletion,
@@ -118,7 +118,10 @@ describe('recoverCursorWorkspaceGroup', () => {
const userDir = await makeUserDir('cursor-recover-lock-');
await createCursorFixture(userDir, recoverySpec());
const globalDbPath = getCursorGlobalDbPath(userDir);
- const lockProcess = await holdCursorWriteLock(globalDbPath);
+ const retryBudgetMs = CURSOR_SQLITE_RETRY_DELAYS_MS.reduce((total, delayMs) => total + delayMs, 0);
+ const lockProcess = await holdCursorWriteLock(globalDbPath, {
+ durationMs: Math.floor(retryBudgetMs / 2),
+ });
try {
const changes = withCursorWriteTransaction(
@@ -141,7 +144,10 @@ describe('recoverCursorWorkspaceGroup', () => {
const [group] = await listCursorWorkspaceGroups(userDir);
const targetBucket = group!.buckets[0]!;
const globalDbPath = getCursorGlobalDbPath(userDir);
- const lockProcess = await holdCursorWriteLock(globalDbPath, 1_000);
+ const retryBudgetMs = CURSOR_SQLITE_RETRY_DELAYS_MS.reduce((total, delayMs) => total + delayMs, 0);
+ const lockProcess = await holdCursorWriteLock(globalDbPath, {
+ durationMs: retryBudgetMs + Math.max(...CURSOR_SQLITE_RETRY_DELAYS_MS),
+ });
try {
await expect(recoverCursorWorkspaceGroup(group!, true, userDir)).rejects.toThrow(
@@ -370,8 +376,10 @@ describe('pruneCursorThreads', () => {
threadsInComposerData: true,
});
await createCursorFixture(userDir, spec);
+ await utimes(path.join(userDir, 'workspaceStorage', 'bucket-second', 'state.vscdb'), new Date(1), new Date(1));
const [group] = await listCursorWorkspaceGroups(userDir);
- const failingBucket = group!.buckets.at(-1)!;
+ const failingBucket = group!.buckets.find((bucket) => bucket.bucketId === 'bucket-second')!;
+ expect(failingBucket.bucketId).not.toBe(group!.buckets[0]!.bucketId);
const triggerDb = new Database(failingBucket.dbPath);
triggerDb.exec(`
CREATE TRIGGER fail_bucket_update
@@ -421,9 +429,14 @@ describe('pruneCursorThreads', () => {
threadsInComposerData: true,
});
await createCursorFixture(userDir, spec);
+ await utimes(path.join(userDir, 'workspaceStorage', 'bucket-second', 'state.vscdb'), new Date(1), new Date(1));
const [group] = await listCursorWorkspaceGroups(userDir);
- const failingBucket = group!.buckets.at(-1)!;
- const lockProcess = await holdCursorWriteLock(failingBucket.dbPath, 1_000);
+ const failingBucket = group!.buckets.find((bucket) => bucket.bucketId === 'bucket-second')!;
+ expect(failingBucket.bucketId).not.toBe(group!.buckets[0]!.bucketId);
+ const retryBudgetMs = CURSOR_SQLITE_RETRY_DELAYS_MS.reduce((total, delayMs) => total + delayMs, 0);
+ const lockProcess = await holdCursorWriteLock(failingBucket.dbPath, {
+ durationMs: retryBudgetMs + Math.max(...CURSOR_SQLITE_RETRY_DELAYS_MS),
+ });
const deletable = await collectCursorThreadsForDeletion(['thread-1'], userDir);
try {
diff --git a/src/lib/cursor-recovery.ts b/src/lib/cursor-recovery.ts
index 2787edf..bb88f47 100644
--- a/src/lib/cursor-recovery.ts
+++ b/src/lib/cursor-recovery.ts
@@ -113,10 +113,11 @@ const readBubblesForComposerIds = (
db: Database,
composerIds: string[],
): Map> => {
+ const uniqueComposerIds = [...new Set(composerIds)];
const bubblesByComposerId = new Map>(
- composerIds.map((composerId) => [composerId, []]),
+ uniqueComposerIds.map((composerId) => [composerId, []]),
);
- for (const chunk of chunkValues(composerIds, CURSOR_SQLITE_BATCH_SIZE)) {
+ for (const chunk of chunkValues(uniqueComposerIds, CURSOR_SQLITE_BATCH_SIZE)) {
const parameters: string[] = [];
const query = chunk
.map((composerId) => {
@@ -280,8 +281,9 @@ const countBubbles = (db: Database, composerId: string): number => {
};
const countBubblesForComposerIds = (db: Database, composerIds: string[]): Map => {
+ const uniqueComposerIds = [...new Set(composerIds)];
const counts = new Map();
- for (const chunk of chunkValues(composerIds, CURSOR_SQLITE_BATCH_SIZE)) {
+ for (const chunk of chunkValues(uniqueComposerIds, CURSOR_SQLITE_BATCH_SIZE)) {
const parameters: string[] = [];
const query = chunk
.map((composerId) => {
diff --git a/src/lib/cursor-test-helpers.ts b/src/lib/cursor-test-helpers.ts
index c268866..e5ef6f2 100644
--- a/src/lib/cursor-test-helpers.ts
+++ b/src/lib/cursor-test-helpers.ts
@@ -281,44 +281,118 @@ export const createCursorFixture = async (userDir: string, spec: CursorFixtureSp
await writeHistoryEntries(userDir, spec);
};
-export const holdCursorWriteLock = async (dbPath: string, durationMs = 180) => {
+type CursorWriteLockOptions = {
+ durationMs?: number;
+ lockingMode?: 'normal' | 'exclusive';
+};
+
+export const holdCursorWriteLock = async (dbPath: string, options: CursorWriteLockOptions = {}) => {
+ const durationMs = options.durationMs ?? 180;
+ const lockingMode = options.lockingMode ?? 'normal';
+ if (!Number.isFinite(durationMs) || durationMs < 0) {
+ throw new Error(`Invalid Cursor lock duration: ${durationMs}`);
+ }
+
const script = `
import { Database } from 'bun:sqlite';
-const db = new Database(Bun.argv.at(-1), { create: false, readwrite: true });
+const dbPath = Bun.env.CURSOR_LOCK_DB_PATH;
+const durationMs = Number(Bun.env.CURSOR_LOCK_DURATION_MS);
+const lockingMode = Bun.env.CURSOR_LOCKING_MODE;
+if (!dbPath || !Number.isFinite(durationMs) || durationMs < 0 || !['normal', 'exclusive'].includes(lockingMode ?? '')) {
+ throw new Error('Invalid Cursor SQLite lock helper environment');
+}
+
+const db = new Database(dbPath, { create: false, readwrite: true });
+if (lockingMode === 'exclusive') {
+ db.exec('PRAGMA locking_mode = EXCLUSIVE');
+}
db.exec('PRAGMA journal_mode = WAL');
db.exec('PRAGMA busy_timeout = 0');
db.exec('BEGIN IMMEDIATE');
console.log('CURSOR_LOCK_READY');
-await Bun.sleep(Number(Bun.argv.at(-2)));
+await Bun.sleep(durationMs);
db.exec('COMMIT');
db.close();
`;
- const childProcess = Bun.spawn([process.execPath, '-e', script, String(durationMs), dbPath], {
+ const childProcess = Bun.spawn([process.execPath, '-e', script], {
+ env: {
+ ...process.env,
+ CURSOR_LOCK_DB_PATH: dbPath,
+ CURSOR_LOCK_DURATION_MS: String(durationMs),
+ CURSOR_LOCKING_MODE: lockingMode,
+ },
stderr: 'pipe',
stdout: 'pipe',
});
- const reader = childProcess.stdout?.getReader();
- if (!reader) {
+ const stdoutReader = childProcess.stdout?.getReader();
+ const stderrReader = childProcess.stderr?.getReader();
+ if (!stdoutReader || !stderrReader) {
childProcess.kill();
await childProcess.exited;
- throw new Error('Cursor SQLite lock helper did not expose stdout');
+ throw new Error('Cursor SQLite lock helper did not expose stdout and stderr');
}
const decoder = new TextDecoder();
- let output = '';
- try {
+ let stdout = '';
+ let stderr = '';
+ let ready = false;
+ let resolveReady: (() => void) | undefined;
+ const readyPromise = new Promise((resolve) => {
+ resolveReady = resolve;
+ });
+ const capture = async (reader: ReadableStreamDefaultReader, stream: 'stdout' | 'stderr') => {
while (true) {
const next = await reader.read();
- output += decoder.decode(next.value ?? new Uint8Array(), { stream: !next.done });
- if (output.includes('CURSOR_LOCK_READY')) {
- return childProcess;
+ const text = decoder.decode(next.value ?? new Uint8Array(), { stream: !next.done });
+ if (stream === 'stdout') {
+ stdout += text;
+ if (stdout.includes('CURSOR_LOCK_READY')) {
+ ready = true;
+ resolveReady?.();
+ }
+ } else {
+ stderr += text;
}
if (next.done) {
- throw new Error(`Cursor SQLite lock helper exited before locking: ${output}`);
+ return;
}
}
+ };
+
+ const stdoutTask = capture(stdoutReader, 'stdout');
+ const stderrTask = capture(stderrReader, 'stderr');
+ let timeoutId: ReturnType | undefined;
+ const timeoutPromise = new Promise((_, reject) => {
+ timeoutId = setTimeout(() => {
+ childProcess.kill();
+ reject(new Error(`Timed out waiting for Cursor SQLite lock helper. stdout: ${stdout} stderr: ${stderr}`));
+ }, 5_000);
+ });
+ try {
+ await Promise.race([
+ readyPromise,
+ childProcess.exited.then(() => {
+ if (!ready) {
+ throw new Error(
+ `Cursor SQLite lock helper exited before locking. stdout: ${stdout} stderr: ${stderr}`,
+ );
+ }
+ }),
+ timeoutPromise,
+ ]);
+ return childProcess;
+ } catch (error) {
+ childProcess.kill();
+ await childProcess.exited;
+ throw error;
} finally {
- reader.releaseLock();
+ if (timeoutId) {
+ clearTimeout(timeoutId);
+ }
+ stdoutReader.releaseLock();
+ stderrReader.releaseLock();
+ void stdoutTask.catch(() => undefined);
+ void stderrTask.catch(() => undefined);
}
};
diff --git a/src/lib/opencode-db.test.ts b/src/lib/opencode-db.test.ts
index c6f7dbe..65781de 100644
--- a/src/lib/opencode-db.test.ts
+++ b/src/lib/opencode-db.test.ts
@@ -248,6 +248,33 @@ describe('opencode db helpers', () => {
});
});
+ it('should reject blank global session directories and emit one diagnostic per session', async () => {
+ const dbPath = await makeDbPath();
+ await createOpenCodeFixture(dbPath, {
+ projects: [{ id: 'global', worktree: '/' }],
+ sessions: [
+ { directory: '', id: 'ses_blank_one', messages: [], projectId: 'global', title: 'Blank one' },
+ { directory: ' ', id: 'ses_blank_two', messages: [], projectId: 'global', title: 'Blank two' },
+ { directory: '/valid/worktree', id: 'ses_valid', messages: [], projectId: 'global', title: 'Valid' },
+ ],
+ });
+ process.env.SPIRACHA_OPENCODE_DB_LOGS = '1';
+ const infoSpy = spyOn(console, 'info').mockImplementation(() => undefined);
+
+ try {
+ const groups = await listOpenCodeWorkspaceGroups(dbPath);
+ expect(groups.map((group) => group.worktree)).toEqual(['/valid/worktree']);
+ expect(infoSpy).toHaveBeenCalledWith('[spiracha:opencode-db] invalid-global-session-directory', {
+ sessionId: 'ses_blank_one',
+ });
+ expect(infoSpy).toHaveBeenCalledWith('[spiracha:opencode-db] invalid-global-session-directory', {
+ sessionId: 'ses_blank_two',
+ });
+ } finally {
+ infoSpy.mockRestore();
+ }
+ });
+
it('should read a WAL database when its sidecar files do not exist', async () => {
const dbPath = await createFixtureDb();
const db = new Database(dbPath);
diff --git a/src/lib/opencode-db.ts b/src/lib/opencode-db.ts
index 107c172..c6cab08 100644
--- a/src/lib/opencode-db.ts
+++ b/src/lib/opencode-db.ts
@@ -488,10 +488,20 @@ const globalWorkspaceRowsQuery = `
) AS partCount,
MAX(s.time_updated) AS lastActiveMs
FROM session s
- WHERE s.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}' AND ${MAIN_SESSION_FILTER}
+ WHERE s.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}'
+ AND ${MAIN_SESSION_FILTER}
+ AND trim(coalesce(s.directory, '')) <> ''
GROUP BY s.directory
`;
+const invalidGlobalWorkspaceRowsQuery = `
+ SELECT s.id AS sessionId
+ FROM session s
+ WHERE s.project_id = '${GLOBAL_OPENCODE_PROJECT_ID}'
+ AND ${MAIN_SESSION_FILTER}
+ AND trim(coalesce(s.directory, '')) = ''
+`;
+
const sessionSelectQuery = `
SELECT
s.id AS sessionId,
@@ -565,6 +575,10 @@ export const listOpenCodeWorkspaceGroups = async (
const projectRows = db
.query(`${workspaceRowsQuery} WHERE p.id <> ?`)
.all(GLOBAL_OPENCODE_PROJECT_ID) as WorkspaceRow[];
+ const invalidGlobalRows = db.query(invalidGlobalWorkspaceRowsQuery).all() as { sessionId: string }[];
+ for (const row of invalidGlobalRows) {
+ logOpenCodeDb('invalid-global-session-directory', { sessionId: row.sessionId });
+ }
const globalRows = db.query(globalWorkspaceRowsQuery).all() as WorkspaceRow[];
return [...projectRows, ...globalRows]
.sort(
diff --git a/src/ui/components/cursor-workspaces-table.tsx b/src/ui/components/cursor-workspaces-table.tsx
index c172187..04f46fe 100644
--- a/src/ui/components/cursor-workspaces-table.tsx
+++ b/src/ui/components/cursor-workspaces-table.tsx
@@ -22,6 +22,8 @@ type CursorWorkspacesTableProps = {
workspaces: CursorWorkspaceGroup[];
};
+const getCursorWorkspaceRowId = (row: CursorWorkspaceGroup): string => row.key;
+
const columnHelper = createDataTableColumnHelper();
const getWorkspaceLocation = (workspace: CursorWorkspaceGroup) => workspace.folders[0] ?? workspace.uri;
@@ -145,7 +147,7 @@ export const CursorWorkspacesTable = ({
data={workspaces}
emptyMessage="No Cursor workspaces match the current search."
enableRowSelection
- getRowId={(row) => row.key}
+ getRowId={getCursorWorkspaceRowId}
renderToolbar={({ clearSelection, selectedRows }) => (
{
{ ...workspace, key: 'folder:/Users/user/workspace/other', label: 'other' },
]);
});
+
+ it('should retain both selections when row clicks are batched in one act', () => {
+ const onDeleteWorkspaces = vi.fn();
+ const otherWorkspace = { ...workspace, key: 'folder:/Users/user/workspace/other', label: 'other' };
+
+ render(
+ ,
+ );
+
+ act(() => {
+ fireEvent.click(screen.getByRole('checkbox', { name: `Select row ${workspace.key}` }));
+ fireEvent.click(screen.getByRole('checkbox', { name: `Select row ${otherWorkspace.key}` }));
+ });
+ fireEvent.click(screen.getByRole('button', { name: 'Delete selected workspaces' }));
+
+ expect(onDeleteWorkspaces).toHaveBeenCalledWith([workspace, otherWorkspace]);
+ });
});
diff --git a/src/ui/components/export-dialog.tsx b/src/ui/components/export-dialog.tsx
index c41dc11..20e5d28 100644
--- a/src/ui/components/export-dialog.tsx
+++ b/src/ui/components/export-dialog.tsx
@@ -197,6 +197,7 @@ export function ExportDialog({
const [evidencePending, setEvidencePending] = useState(false);
const [downloadState, setDownloadState] = useState(null);
const submissionInProgress = useRef(false);
+ const submissionToken = useRef(0);
const previousPending = useRef(pending);
const effectiveZipArchive = forceZipArchive || options.zipArchive;
const displayedError = evidenceError ?? errorMessage;
@@ -205,6 +206,7 @@ export function ExportDialog({
if (nextOpen) {
resetActiveDownloads();
} else {
+ submissionToken.current += 1;
cancelActiveDownloads();
}
onOpenChange(nextOpen);
@@ -212,6 +214,7 @@ export function ExportDialog({
useEffect(() => {
if (!open) {
+ submissionToken.current += 1;
setOptions(settings.exportDefaults);
setSubmitted(false);
submissionInProgress.current = false;
@@ -251,25 +254,41 @@ export function ExportDialog({
}
};
+ const submitFocusedExport = async (token: number) => {
+ const result = preview ?? (await loadEvidence());
+ if (submissionToken.current !== token) {
+ return;
+ }
+ if (!result || !focusedEvidenceTarget) {
+ setDownloadState('failed');
+ submissionInProgress.current = false;
+ setSubmitted(false);
+ return;
+ }
+ if (submissionToken.current !== token) {
+ return;
+ }
+ downloadTextFile(
+ `${focusedEvidenceTarget.source}-${focusedEvidenceTarget.id}-focused-evidence.md`,
+ result.markdown,
+ 'text/markdown; charset=utf-8',
+ { onStateChange: setDownloadState },
+ );
+ submissionInProgress.current = false;
+ setSubmitted(false);
+ };
+
const submitExport = async () => {
if (submissionInProgress.current) {
return;
}
submissionInProgress.current = true;
+ const token = submissionToken.current + 1;
+ submissionToken.current = token;
setSubmitted(true);
setDownloadState('preparing');
if (mode === 'focused') {
- const result = preview ?? (await loadEvidence());
- if (result && focusedEvidenceTarget) {
- downloadTextFile(
- `${focusedEvidenceTarget.source}-${focusedEvidenceTarget.id}-focused-evidence.md`,
- result.markdown,
- 'text/markdown; charset=utf-8',
- { onStateChange: setDownloadState },
- );
- }
- submissionInProgress.current = false;
- setSubmitted(false);
+ await submitFocusedExport(token);
return;
}
updateSetting('exportDefaults', options);
@@ -340,7 +359,7 @@ export function ExportDialog({
{displayedError ? {displayedError}
: null}
- onOpenChange(false)}>
+ handleOpenChange(false)}>
Cancel
{mode === 'focused' ? (
diff --git a/src/ui/components/export-dialog.vitest.tsx b/src/ui/components/export-dialog.vitest.tsx
index 40264dc..37704e9 100644
--- a/src/ui/components/export-dialog.vitest.tsx
+++ b/src/ui/components/export-dialog.vitest.tsx
@@ -1,4 +1,4 @@
-import { cleanup, fireEvent, render, screen, within } from '@testing-library/react';
+import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react';
import { afterEach, describe, expect, it, vi } from 'vitest';
import * as download from '#/lib/download';
import { SettingsProvider } from '#/lib/settings-store';
@@ -152,6 +152,7 @@ describe('ExportDialog', () => {
it('should allow disabling tool-call inclusion and closing the dialog', () => {
const onExport = vi.fn();
const onOpenChange = vi.fn();
+ const cancelActiveDownloads = vi.spyOn(download, 'cancelActiveDownloads');
render( );
@@ -167,6 +168,95 @@ describe('ExportDialog', () => {
zipArchive: false,
});
expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(cancelActiveDownloads).toHaveBeenCalledTimes(1);
+ cancelActiveDownloads.mockRestore();
+ });
+
+ it('should not download focused evidence after the dialog is cancelled', async () => {
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+ let resolveFetch: ((response: Response) => void) | undefined;
+ const fetchPromise = new Promise((resolve) => {
+ resolveFetch = resolve;
+ });
+ const fetchMock = vi.fn(() => fetchPromise);
+ const downloadTextFile = vi.spyOn(download, 'downloadTextFile');
+ const onOpenChange = vi.fn();
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('combobox', { name: 'Export mode' }));
+ fireEvent.click(screen.getByText('Focused evidence'));
+ fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
+ fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
+ resolveFetch?.(
+ Response.json({
+ data: {
+ markdown: '# Focused evidence\n',
+ meta: {
+ approximateTokens: 1,
+ episodeCount: 1,
+ generatedAt: '2026-07-19T12:00:00.000Z',
+ omission: {
+ budgetReached: false,
+ deduplicatedDiagnostics: 0,
+ inputCharacters: 1,
+ inputEvents: 1,
+ omittedBinaryPayloads: 0,
+ omittedEvents: 0,
+ selectedEvents: 1,
+ truncatedArrays: 0,
+ truncatedFields: 0,
+ },
+ projectedCharacters: 20,
+ rendererVersion: 'focused-evidence/v2',
+ },
+ },
+ }),
+ );
+
+ await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(1));
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ expect(downloadTextFile).not.toHaveBeenCalled();
+ } finally {
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ downloadTextFile.mockRestore();
+ vi.unstubAllGlobals();
+ }
+ });
+
+ it('should mark focused preparation as failed when no export result is returned', async () => {
+ const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
+ HTMLElement.prototype.scrollIntoView = vi.fn();
+ const fetchMock = vi.fn(async () => Response.json({ error: { message: 'No evidence' } }));
+ vi.stubGlobal('fetch', fetchMock);
+
+ try {
+ render(
+ ,
+ );
+ fireEvent.click(screen.getByRole('combobox', { name: 'Export mode' }));
+ fireEvent.click(screen.getByText('Focused evidence'));
+ fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
+
+ await waitFor(() => expect(screen.getByRole('status').textContent).toBe('Export failed.'));
+ } finally {
+ HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
+ vi.unstubAllGlobals();
+ }
});
it('should submit zip archive when selected', () => {
diff --git a/src/ui/lib/antigravity-server.ts b/src/ui/lib/antigravity-server.ts
index 6f11e83..5548edb 100644
--- a/src/ui/lib/antigravity-server.ts
+++ b/src/ui/lib/antigravity-server.ts
@@ -39,6 +39,26 @@ const deleteConversationsSchema = z.object({
conversationIds: z.array(z.string().min(1)).min(1),
});
+export class AntigravityDecryptionCapabilityError extends Error {
+ readonly code = 'ANTIGRAVITY_DECRYPTION_CAPABILITY';
+
+ constructor(cause: unknown) {
+ super('Antigravity decryption capability is unavailable', { cause });
+ this.name = 'AntigravityDecryptionCapabilityError';
+ }
+}
+
+const acquireAntigravityDecryptionCapability = async () => {
+ const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
+ return withAntigravityDecryptionCapability((capability) => capability);
+};
+
+const isAntigravityDecryptionCapabilityError = (error: unknown): boolean =>
+ error instanceof AntigravityDecryptionCapabilityError ||
+ (typeof error === 'object' &&
+ error !== null &&
+ (error as { code?: unknown }).code === 'ANTIGRAVITY_DECRYPTION_CAPABILITY');
+
export const listAntigravityWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
return listAntigravityWorkspaceGroups();
@@ -99,17 +119,15 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
return renderAntigravityConversationMarkdown(conversation);
}
- const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
- return withAntigravityDecryptionCapability((decryptionCapability) =>
- renderAntigravityConversationMarkdown(conversation, { decryptionCapability }),
- );
+ const decryptionCapability = await acquireAntigravityDecryptionCapability();
+ return renderAntigravityConversationMarkdown(conversation, { decryptionCapability });
};
let renderedConversationMarkdown: string | null = null;
let transcriptLocked = false;
try {
renderedConversationMarkdown = await renderConversation();
} catch (error) {
- if (!isEncrypted) {
+ if (!isEncrypted || !isAntigravityDecryptionCapabilityError(error)) {
throw error;
}
transcriptLocked = true;
@@ -179,11 +197,12 @@ export const loadAntigravityConversationExport = async (
return renderAntigravityConversationMarkdown(conversation, renderOptions);
}
- const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
- return withAntigravityDecryptionCapability((decryptionCapability) =>
- renderAntigravityConversationMarkdown(conversation, { ...renderOptions, decryptionCapability }),
- );
+ const decryptionCapability = await acquireAntigravityDecryptionCapability();
+ return renderAntigravityConversationMarkdown(conversation, { ...renderOptions, decryptionCapability });
} catch (error) {
+ if (!isAntigravityDecryptionCapabilityError(error)) {
+ throw error;
+ }
const message = error instanceof Error ? ` ${error.message}` : '';
throw new Error(`Unlock Antigravity Keychain access before exporting transcript logs.${message}`);
}
diff --git a/src/ui/lib/antigravity-server.vitest.ts b/src/ui/lib/antigravity-server.vitest.ts
index 7b38548..b6250e2 100644
--- a/src/ui/lib/antigravity-server.vitest.ts
+++ b/src/ui/lib/antigravity-server.vitest.ts
@@ -78,6 +78,7 @@ vi.mock('./source-session-export-server', () => ({
}));
import {
+ AntigravityDecryptionCapabilityError,
deleteAntigravityConversationById,
deleteAntigravityConversationsById,
exportAntigravityConversationFn,
@@ -177,6 +178,29 @@ describe('antigravity-server', () => {
);
});
+ it('should map only decryption capability acquisition failures to locked detail state', async () => {
+ const conversation = makeConversation({ transcriptPath: null, transcriptSource: 'safe-storage' });
+ listAntigravityConversationsMock.mockResolvedValue([conversation]);
+ withAntigravityDecryptionCapabilityMock.mockRejectedValue(new Error('keychain denied'));
+
+ await expect(loadAntigravityConversationDetail(conversation.conversationId)).rejects.toThrow('keychain denied');
+
+ withAntigravityDecryptionCapabilityMock.mockRejectedValue(
+ new AntigravityDecryptionCapabilityError(new Error('keychain denied')),
+ );
+ const detail = await loadAntigravityConversationDetail(conversation.conversationId);
+ expect(detail.transcriptLocked).toBe(true);
+ });
+
+ it('should propagate protected transcript renderer failures unchanged', async () => {
+ const conversation = makeConversation({ transcriptPath: null, transcriptSource: 'safe-storage' });
+ listAntigravityConversationsMock.mockResolvedValue([conversation]);
+ const rendererError = new Error('malformed encrypted transcript');
+ renderAntigravityConversationMarkdownMock.mockRejectedValue(rendererError);
+
+ await expect(loadAntigravityConversationDetail(conversation.conversationId)).rejects.toBe(rendererError);
+ });
+
it('should return the resolved Antigravity project group for detail navigation', async () => {
const projectId = '00ea3331-909e-4010-a208-78f964ecfb59';
const conversation = makeConversation({ projectId });
diff --git a/src/ui/lib/cline-server.ts b/src/ui/lib/cline-server.ts
index 29fff16..df65d58 100644
--- a/src/ui/lib/cline-server.ts
+++ b/src/ui/lib/cline-server.ts
@@ -1,22 +1,23 @@
+import { CLINE_SESSION_ID_PATTERN } from '@spiracha/lib/cline-exporter-types';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
import { requireDeletedItems, runDeleteBatch } from './delete-batch';
import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
const workspaceSchema = z.object({ workspaceKey: z.string().min(1) });
-const taskSchema = z.object({ taskId: z.string().regex(/^[A-Za-z0-9_-]+$/u) });
+const taskSchema = z.object({ taskId: z.string().regex(CLINE_SESSION_ID_PATTERN) });
const exportTaskSchema = z.object({
includeCommentary: z.boolean().default(true),
includeMetadata: z.boolean().default(true),
includeTools: z.boolean().default(true),
outputFormat: z.enum(['md', 'txt']).default('md'),
- taskId: z.string().regex(/^[A-Za-z0-9_-]+$/u),
+ taskId: z.string().regex(CLINE_SESSION_ID_PATTERN),
zipArchive: z.boolean().default(false),
});
const exportTasksSchema = exportTaskSchema.omit({ taskId: true, zipArchive: true }).extend({
- taskIds: z.array(z.string().regex(/^[A-Za-z0-9_-]+$/u)).min(1),
+ taskIds: z.array(z.string().regex(CLINE_SESSION_ID_PATTERN)).min(1),
});
-const deleteTasksSchema = z.object({ taskIds: z.array(z.string().regex(/^[A-Za-z0-9_-]+$/u)).min(1) });
+const deleteTasksSchema = z.object({ taskIds: z.array(z.string().regex(CLINE_SESSION_ID_PATTERN)).min(1) });
export const listClineWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
const { listClineWorkspaceGroups } = await import('@spiracha/lib/cline-db');
@@ -118,5 +119,6 @@ export const deleteClineTasksFn = createServerFn({ method: 'POST' })
return {
deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
deletedTaskIds,
+ indexCleanup: results.map((result) => result.indexCleanup),
};
});
diff --git a/src/ui/lib/cline-server.vitest.ts b/src/ui/lib/cline-server.vitest.ts
index c033e9f..cb6362b 100644
--- a/src/ui/lib/cline-server.vitest.ts
+++ b/src/ui/lib/cline-server.vitest.ts
@@ -73,6 +73,7 @@ const dbMocks = vi.hoisted(() => ({
deleteClineTask: vi.fn(async (_root: string, taskId: string) => ({
deletedFiles: [`/cline/sessions/${taskId}`],
deletedTaskIds: [taskId],
+ indexCleanup: { status: 'deleted' as const },
})),
listClineTasksForGroup: vi.fn(async () => [transcript.task]),
listClineWorkspaceGroups: vi.fn(async () => [{ key: transcript.task.workspaceKey }]),
@@ -144,4 +145,9 @@ describe('Cline server functions', () => {
deletedTaskIds: ['1786163719044_tpg1r', '1786164800606_0h9tk'],
});
});
+
+ it('should reject traversal-style task IDs through the validator before the handler', async () => {
+ await expect(getClineTaskDetailFn({ data: { taskId: '../../etc' } })).rejects.toThrow();
+ expect(dbMocks.readClineTaskTranscript).not.toHaveBeenCalled();
+ });
});
diff --git a/src/ui/lib/cursor-server.ts b/src/ui/lib/cursor-server.ts
index e9b5fec..08881dc 100644
--- a/src/ui/lib/cursor-server.ts
+++ b/src/ui/lib/cursor-server.ts
@@ -189,13 +189,22 @@ const renderCursorDownload = async (input: {
const { getCursorGlobalDbPath } = await import('@spiracha/lib/cursor-exporter-types');
const { renderCursorTranscript } = await import('@spiracha/lib/cursor-transcript');
const globalDbPath = getCursorGlobalDbPath();
- const { listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
+ const { findCursorTranscriptDirsForComposerIds, listCursorWorkspaceGroups } = await import(
+ '@spiracha/lib/cursor-db'
+ );
const workspaceGroups = await listCursorWorkspaceGroups();
const workspacesByComposerId = await findCursorWorkspacesByComposerId(workspaceGroups, input.composerIds);
+ const transcriptDirsByComposerId = await findCursorTranscriptDirsForComposerIds(input.composerIds);
const rendered = await Promise.all(
input.composerIds.map(async (composerId) => {
const transcript = await runWithTranscriptLoadLimit(
- () => readCursorThreadTranscriptWithAgentFiles(globalDbPath, composerId),
+ () =>
+ readCursorThreadTranscriptWithAgentFiles(
+ globalDbPath,
+ composerId,
+ undefined,
+ transcriptDirsByComposerId.get(composerId) ?? [],
+ ),
{
id: composerId,
integration: 'cursor',
diff --git a/src/ui/lib/cursor-server.vitest.ts b/src/ui/lib/cursor-server.vitest.ts
index 45fd341..11cf0f2 100644
--- a/src/ui/lib/cursor-server.vitest.ts
+++ b/src/ui/lib/cursor-server.vitest.ts
@@ -10,6 +10,7 @@ const {
collectCursorThreadsForDeletionMock,
deleteCursorWorkspaceBucketsMock,
deleteCursorWorkspaceHistoryMock,
+ findCursorTranscriptDirsForComposerIdsMock,
isCursorRunningMock,
listCursorThreadsForGroupMock,
listCursorWorkspaceGroupsMock,
@@ -23,6 +24,7 @@ const {
collectCursorThreadsForDeletionMock: vi.fn(),
deleteCursorWorkspaceBucketsMock: vi.fn(),
deleteCursorWorkspaceHistoryMock: vi.fn(),
+ findCursorTranscriptDirsForComposerIdsMock: vi.fn(),
isCursorRunningMock: vi.fn(),
listCursorThreadsForGroupMock: vi.fn(),
listCursorWorkspaceGroupsMock: vi.fn(),
@@ -59,6 +61,7 @@ vi.mock('@tanstack/react-start', () => ({
}));
vi.mock('@spiracha/lib/cursor-db', () => ({
+ findCursorTranscriptDirsForComposerIds: findCursorTranscriptDirsForComposerIdsMock,
listCursorThreadsForGroup: listCursorThreadsForGroupMock,
listCursorWorkspaceGroups: listCursorWorkspaceGroupsMock,
readCursorThreadTranscript: vi.fn(),
@@ -346,6 +349,25 @@ describe('deleteCursorWorkspacesFn', () => {
expect(deleteCursorWorkspaceHistoryMock).toHaveBeenNthCalledWith(1, workspaceOne);
expect(deleteCursorWorkspaceHistoryMock).toHaveBeenNthCalledWith(2, workspaceTwo);
});
+
+ it('should propagate a later workspace deletion failure after preserving the first result', async () => {
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne, workspaceTwo]);
+ listCursorThreadsForGroupMock
+ .mockResolvedValueOnce([makeThread()])
+ .mockResolvedValueOnce([makeThread({ composerId: 'thread-2', workspaceKey: workspaceTwo.key })]);
+ collectCursorThreadsForDeletionMock
+ .mockResolvedValueOnce([{ composerId: 'thread-1' }])
+ .mockResolvedValueOnce([{ composerId: 'thread-2' }]);
+ pruneCursorThreadsMock.mockResolvedValueOnce({}).mockRejectedValueOnce(new Error('second prune failed'));
+
+ await expect(
+ deleteCursorWorkspacesFn({ data: { workspaceKeys: [workspaceOne.key, workspaceTwo.key] } }),
+ ).rejects.toThrow('second prune failed');
+
+ expect(pruneCursorThreadsMock).toHaveBeenNthCalledWith(1, [{ composerId: 'thread-1' }], true);
+ expect(deleteCursorWorkspaceBucketsMock).toHaveBeenNthCalledWith(1, workspaceOne);
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenNthCalledWith(1, workspaceOne);
+ });
});
describe('Cursor export server functions', () => {
@@ -353,6 +375,7 @@ describe('Cursor export server functions', () => {
vi.clearAllMocks();
listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
listCursorThreadsForGroupMock.mockResolvedValue([makeThread()]);
+ findCursorTranscriptDirsForComposerIdsMock.mockResolvedValue(new Map([['thread-1', ['/tmp/transcript']]]));
readCursorThreadTranscriptWithAgentFilesMock.mockResolvedValue(transcript);
renderCursorTranscriptMock.mockReturnValue('rendered transcript');
renderSourceSessionDownloadMock.mockResolvedValue({
@@ -405,6 +428,13 @@ describe('Cursor export server functions', () => {
});
expect(result).toMatchObject({ content: 'rendered transcript', mode: 'download' });
expect(result.fileName).toMatch(/\.txt$/u);
+ expect(findCursorTranscriptDirsForComposerIdsMock).toHaveBeenCalledWith(['thread-1']);
+ expect(readCursorThreadTranscriptWithAgentFilesMock).toHaveBeenCalledWith(
+ '/tmp/global.db',
+ 'thread-1',
+ undefined,
+ ['/tmp/transcript'],
+ );
expect(renderSourceSessionDownloadMock).toHaveBeenCalledWith(
expect.objectContaining({ outputFormat: 'txt', sessionId: 'thread-1', zipArchive: false }),
);
diff --git a/src/ui/routes/cursor.$workspaceKey.tsx b/src/ui/routes/cursor.$workspaceKey.tsx
index d42f9fa..e675058 100644
--- a/src/ui/routes/cursor.$workspaceKey.tsx
+++ b/src/ui/routes/cursor.$workspaceKey.tsx
@@ -85,14 +85,14 @@ const getCursorDeleteDescription = (pendingDelete: PendingCursorDelete | null) =
}
if (pendingDelete.kind === 'workspace') {
- return `Permanently delete every thread for "${pendingDelete.workspace.label}" from Cursor's database, remove any on-disk transcript directories, and delete its Cursor workspace storage buckets. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete every thread for "${pendingDelete.workspace.label}" from Cursor's database, remove any on-disk transcript directories, permanently delete local file history under the workspace folders, and delete its Cursor workspace storage buckets. Quit Cursor first. This cannot be undone.`;
}
if (pendingDelete.threads.length === 1) {
- return `Permanently delete "${pendingDelete.threads[0]!.name}" from Cursor's database and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete "${pendingDelete.threads[0]!.name}" from Cursor's database, remove any on-disk transcript directories, and permanently delete local file history under the workspace folders. Quit Cursor first. This cannot be undone.`;
}
- return `Permanently delete ${pendingDelete.threads.length} selected Cursor threads and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete ${pendingDelete.threads.length} selected Cursor threads, remove any on-disk transcript directories, and permanently delete local file history under the workspace folders. Quit Cursor first. This cannot be undone.`;
};
const getCursorDeleteTitle = (pendingDelete: PendingCursorDelete | null) => {
diff --git a/src/ui/routes/cursor.index.tsx b/src/ui/routes/cursor.index.tsx
index 57ac3f5..85c1af7 100644
--- a/src/ui/routes/cursor.index.tsx
+++ b/src/ui/routes/cursor.index.tsx
@@ -22,15 +22,15 @@ const CursorPendingComponent = () => (
);
const getWorkspaceDeleteDescription = (workspaces: CursorWorkspaceGroup[] | null) => {
- if (!workspaces) {
+ if (!workspaces || workspaces.length === 0) {
return '';
}
if (workspaces.length === 1) {
- return `Permanently delete every thread for "${workspaces[0]!.label}" from Cursor's database and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete every thread for "${workspaces[0]!.label}" from Cursor's database, remove any on-disk transcript directories, and permanently delete local file history under the workspace folders. Quit Cursor first. This cannot be undone.`;
}
- return `Permanently delete every thread from ${workspaces.length} selected Cursor workspaces and remove any on-disk transcript directories. Quit Cursor first. This cannot be undone.`;
+ return `Permanently delete every thread from ${workspaces.length} selected Cursor workspaces, remove any on-disk transcript directories, and permanently delete local file history under the workspace folders. Quit Cursor first. This cannot be undone.`;
};
const CursorPage = () => {
@@ -60,8 +60,8 @@ const CursorPage = () => {
data: { workspaceKeys: selectedWorkspaces.map((workspace) => workspace.key) },
});
},
- onSuccess: async () => {
- await invalidateCursorQueries();
+ onSettled: invalidateCursorQueries,
+ onSuccess: () => {
setPendingDelete(null);
},
});
@@ -92,7 +92,11 @@ const CursorPage = () => {
setPendingDelete([workspace])}
- onDeleteWorkspaces={setPendingDelete}
+ onDeleteWorkspaces={(selectedWorkspaces) => {
+ if (selectedWorkspaces.length > 0) {
+ setPendingDelete(selectedWorkspaces);
+ }
+ }}
onRecoverWorkspace={(workspace) => recoverWorkspaceMutation.mutate(workspace)}
workspaces={visibleWorkspaces}
/>
@@ -123,7 +127,7 @@ const CursorPage = () => {
}
description={getWorkspaceDeleteDescription(pendingDelete)}
errorMessage={getMutationErrorMessage(deleteWorkspaceMutation.error, 'Workspace deletion failed')}
- open={pendingDelete !== null}
+ open={pendingDelete !== null && pendingDelete.length > 0}
title={pendingDelete?.length === 1 ? 'Delete Cursor workspace?' : 'Delete Cursor workspaces?'}
onConfirm={() => {
if (!pendingDelete) {
From 56bfd2808960e4da97b27ffc1ba913b6a92c6a6f Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Mon, 17 Aug 2026 01:00:57 -0400
Subject: [PATCH 06/10] Fix minimax regression
---
src/lib/minimax-code-db.test.ts | 157 ++++++++++++++
src/lib/minimax-code-db.ts | 350 +++++++++++++++++++++++++++++++-
2 files changed, 500 insertions(+), 7 deletions(-)
diff --git a/src/lib/minimax-code-db.test.ts b/src/lib/minimax-code-db.test.ts
index dea770b..838d526 100644
--- a/src/lib/minimax-code-db.test.ts
+++ b/src/lib/minimax-code-db.test.ts
@@ -9,6 +9,7 @@ import {
listMiniMaxCodeWorkspaceGroups,
readMiniMaxCodeSessionTranscript,
} from './minimax-code-db';
+import { resolveMiniMaxCodeRuntimeDbPath } from './minimax-code-exporter-types';
import { writeMiniMaxCodeRuntimeFixture, writeMiniMaxCodeSessionFixture } from './minimax-code-test-helpers';
const tempRoots: string[] = [];
@@ -19,6 +20,119 @@ const makeTempRoot = async () => {
return tempRoot;
};
+const writeManifestMessagesSession = async (sessionsDir: string, sessionId: string) => {
+ const encodedSessionId = Buffer.from(sessionId).toString('base64').replace(/=+$/u, '');
+ const sessionDir = path.join(sessionsDir, '2026', '08', '17', `12-00-00-000-session_${encodedSessionId}`);
+ await mkdir(sessionDir, { recursive: true });
+ await Bun.write(
+ path.join(sessionDir, 'manifest.json'),
+ `${JSON.stringify({
+ createdAtMs: 1_786_000_000_000,
+ layout: 'v2-final-dated-session',
+ paths: {
+ ledger: 'ledger.jsonl',
+ messages: 'messages.jsonl',
+ reports: 'reports',
+ sessionDir: path.basename(sessionDir),
+ snapshot: 'snapshot.json',
+ },
+ schemaVersion: 1,
+ sessionId,
+ source: 'local-runtime',
+ updatedAtMs: 1_786_000_000_100,
+ })}\n`,
+ );
+ const messages = [
+ {
+ message: {
+ content: [{ text: 'Please review this workspace.', type: 'text' }],
+ role: 'user',
+ timestamp: 1_786_000_000_010,
+ },
+ message_id: 'message-user',
+ turn_id: 'turn-1',
+ },
+ {
+ message: {
+ content: [
+ { thinking: 'I will inspect the workspace first.', type: 'thinking' },
+ { arguments: { command: 'pwd' }, id: 'call-1', name: 'bash', type: 'toolCall' },
+ ],
+ model: 'MiniMax-M3',
+ role: 'assistant',
+ stopReason: 'toolUse',
+ timestamp: 1_786_000_000_020,
+ },
+ message_id: 'message-assistant-tool',
+ turn_id: 'turn-1',
+ },
+ {
+ message: {
+ content: [{ text: '/workspace/spiracha', type: 'text' }],
+ details: {},
+ isError: false,
+ role: 'toolResult',
+ timestamp: 1_786_000_000_030,
+ toolCallId: 'call-1',
+ toolName: 'bash',
+ },
+ message_id: 'message-tool-result',
+ turn_id: 'turn-1',
+ },
+ {
+ message: {
+ content: [{ text: 'The workspace is ready for review.', type: 'text' }],
+ model: 'MiniMax-M3',
+ role: 'assistant',
+ stopReason: 'stop',
+ timestamp: 1_786_000_000_040,
+ },
+ message_id: 'message-assistant-final',
+ turn_id: 'turn-1',
+ },
+ ];
+ await Bun.write(
+ path.join(sessionDir, 'messages.jsonl'),
+ `${messages.map((message) => JSON.stringify(message)).join('\n')}\n`,
+ );
+ return { sessionDir, sessionId };
+};
+
+const writeRuntimeSessionMetadata = async (
+ sessionsDir: string,
+ sessionId: string,
+ workspacePath?: string,
+): Promise => {
+ const runtimeDbPath = resolveMiniMaxCodeRuntimeDbPath(sessionsDir);
+ await mkdir(path.dirname(runtimeDbPath), { recursive: true });
+ const db = new Database(runtimeDbPath, { create: true, strict: true });
+ try {
+ db.run(
+ 'CREATE TABLE local_runtime_sessions (session_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL)',
+ );
+ db.query('INSERT INTO local_runtime_sessions (session_id, record_json, updated_at_ms) VALUES (?, ?, ?)').run(
+ sessionId,
+ JSON.stringify({
+ agentName: 'main',
+ archived: false,
+ createdAtMs: 1_786_000_000_000,
+ effectiveModel: 'minimax/MiniMax-M3',
+ effectiveModelVariant: 'thinking',
+ runtime: 'pi-agent',
+ sessionId,
+ sessionType: 'root',
+ status: 'idle',
+ title: 'Manifest messages review',
+ updatedAtMs: 1_786_000_000_100,
+ ...(workspacePath ? { workspaceDir: workspacePath } : {}),
+ }),
+ 1_786_000_000_100,
+ );
+ } finally {
+ db.close();
+ }
+};
+
describe('MiniMax Code db helpers', () => {
afterEach(async () => {
await Promise.all(tempRoots.splice(0).map((root) => rm(root, { force: true, recursive: true })));
@@ -120,6 +234,49 @@ describe('MiniMax Code db helpers', () => {
await expect(listMiniMaxCodeWorkspaceGroups(sessionsDir)).resolves.toEqual([]);
});
+ it('should list and read sessions stored as manifest.json plus messages.jsonl', async () => {
+ const tempRoot = await makeTempRoot();
+ const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
+ const workspacePath = path.join(tempRoot, 'project');
+ const fixture = await writeManifestMessagesSession(sessionsDir, 'mvs_112233aabbcc');
+ await writeRuntimeSessionMetadata(sessionsDir, fixture.sessionId, workspacePath);
+
+ const workspaces = await listMiniMaxCodeWorkspaceGroups(sessionsDir);
+ expect(workspaces).toEqual([
+ expect.objectContaining({
+ key: `workspace:${encodeURIComponent(workspacePath)}`,
+ messageCount: 3,
+ sessionCount: 1,
+ worktree: workspacePath,
+ }),
+ ]);
+
+ const transcript = await readMiniMaxCodeSessionTranscript(sessionsDir, fixture.sessionId);
+ expect(transcript?.messages.map((message) => message.role)).toEqual(['user', 'assistant', 'assistant']);
+ expect(transcript?.messages[1]?.reasoning).toBe('I will inspect the workspace first.');
+ expect(transcript?.messages[1]?.toolCalls[0]).toMatchObject({
+ callId: 'call-1',
+ command: 'pwd',
+ outputText: '/workspace/spiracha',
+ status: 'succeeded',
+ toolName: 'bash',
+ });
+ expect(transcript?.session).toMatchObject({
+ sessionId: fixture.sessionId,
+ title: 'Manifest messages review',
+ worktree: workspacePath,
+ });
+ });
+
+ it('should reject manifest messages sessions without runtime workspace metadata', async () => {
+ const tempRoot = await makeTempRoot();
+ const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
+ await writeManifestMessagesSession(sessionsDir, 'mvs_aabbcc112233');
+
+ await expect(listMiniMaxCodeWorkspaceGroups(sessionsDir)).resolves.toEqual([]);
+ await expect(readMiniMaxCodeSessionTranscript(sessionsDir, 'mvs_aabbcc112233')).resolves.toBeNull();
+ });
+
it('should delete a finalized session directory and every authoritative runtime row', async () => {
const tempRoot = await makeTempRoot();
const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
diff --git a/src/lib/minimax-code-db.ts b/src/lib/minimax-code-db.ts
index f633438..72801df 100644
--- a/src/lib/minimax-code-db.ts
+++ b/src/lib/minimax-code-db.ts
@@ -27,6 +27,7 @@ import {
cleanInlineTitle,
type JsonValue,
readDirectoryEntriesIfExists,
+ readJsonlObjects,
} from './shared';
import { runWithSqliteRetry } from './sqlite-retry';
@@ -108,6 +109,20 @@ const listSnapshotPaths = async (root: string): Promise => {
return paths.sort();
};
+const listManifestPaths = async (root: string): Promise => {
+ const entries = await readDirectoryEntriesIfExists(root);
+ const nestedPaths = await mapWithConcurrency(
+ entries.filter((entry) => entry.isDirectory()),
+ READ_CONCURRENCY,
+ (entry) => listManifestPaths(path.join(root, entry.name)),
+ );
+ const paths = entries
+ .filter((entry) => entry.isFile() && entry.name === 'manifest.json')
+ .map((entry) => path.join(root, entry.name));
+ paths.push(...nestedPaths.flat());
+ return paths.sort();
+};
+
const listFilesRecursively = async (root: string): Promise => {
const entries = await readDirectoryEntriesIfExists(root);
const files: string[] = [];
@@ -237,6 +252,174 @@ const parseMessage = (value: JsonValue, includeRawPayloads: boolean): MiniMaxCod
};
};
+const textFromMessageContent = (value: JsonValue | undefined): string | null => {
+ if (typeof value === 'string') {
+ return value.trim() || null;
+ }
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ const text = value
+ .flatMap((part) => {
+ const partObject = asObject(part);
+ const partText = asString(partObject?.text ?? null)?.trim();
+ return partText ? [partText] : [];
+ })
+ .join('\n\n')
+ .trim();
+ return text || null;
+};
+
+const serializeJsonValue = (value: JsonValue | undefined): string | null => {
+ if (value === undefined) {
+ return null;
+ }
+ return JSON.stringify(value);
+};
+
+type ParsedMiniMaxCodeToolResult = {
+ outputText: string | null;
+ status: MiniMaxCodeToolStatus;
+};
+
+type NewMessageParsingContext = {
+ includeRawPayloads: boolean;
+ pendingToolResults: Map;
+ toolCallsById: Map;
+};
+
+const getNewMessageContentParts = (message: Record): Record[] => {
+ return Array.isArray(message.content)
+ ? message.content.flatMap((part) => {
+ const partObject = asObject(part);
+ return partObject ? [partObject] : [];
+ })
+ : [];
+};
+
+const consumeNewToolResult = (
+ message: Record,
+ context: Pick,
+) => {
+ const toolCallId = asString(message.toolCallId ?? null)?.trim();
+ if (!toolCallId) {
+ return;
+ }
+
+ const result = {
+ outputText: textFromMessageContent(message.content),
+ status: asBoolean(message.isError ?? null) ? 'failed' : 'succeeded',
+ } satisfies ParsedMiniMaxCodeToolResult;
+ const toolCall = context.toolCallsById.get(toolCallId);
+ if (toolCall) {
+ toolCall.outputText = result.outputText;
+ toolCall.status = result.status;
+ return;
+ }
+ context.pendingToolResults.set(toolCallId, result);
+};
+
+const parseNewToolCall = (
+ part: Record,
+ context: NewMessageParsingContext,
+): MiniMaxCodeToolCall | null => {
+ if (part.type !== 'toolCall') {
+ return null;
+ }
+
+ const callId = asString(part.id ?? null)?.trim() || null;
+ const argumentsText = serializeJsonValue(part.arguments);
+ const pendingResult = callId ? context.pendingToolResults.get(callId) : undefined;
+ const toolCall: MiniMaxCodeToolCall = {
+ argumentsText,
+ callId,
+ command: commandFromToolArguments(argumentsText),
+ outputText: pendingResult?.outputText ?? null,
+ raw: context.includeRawPayloads ? part : {},
+ status: pendingResult?.status ?? 'unknown',
+ toolName: asString(part.name ?? null)?.trim() || 'unknown',
+ };
+ if (callId) {
+ context.toolCallsById.set(callId, toolCall);
+ context.pendingToolResults.delete(callId);
+ }
+ return toolCall;
+};
+
+const parseNewTranscriptMessage = (
+ row: Record,
+ message: Record,
+ context: NewMessageParsingContext,
+): MiniMaxCodeTranscriptMessage | null => {
+ const role = asString(message.role ?? null);
+ if (role !== 'assistant' && role !== 'user') {
+ return null;
+ }
+
+ const messageId = asString(row.message_id ?? null)?.trim() || asString(message.id ?? null)?.trim();
+ if (!messageId) {
+ return null;
+ }
+
+ const contentParts = getNewMessageContentParts(message);
+ const reasoning = contentParts
+ .flatMap((part) => {
+ const thinking = asString(part.thinking ?? null)?.trim();
+ return part.type === 'thinking' && thinking ? [thinking] : [];
+ })
+ .join('\n\n')
+ .trim();
+ const toolCalls = contentParts.flatMap((part) => {
+ const toolCall = parseNewToolCall(part, context);
+ return toolCall ? [toolCall] : [];
+ });
+
+ return {
+ content: textFromMessageContent(message.content),
+ createdAtMs: asNumber(message.timestamp ?? null),
+ finishReason: asString(message.stopReason ?? null),
+ messageId,
+ messageType: role === 'user' ? 1 : 2,
+ raw: context.includeRawPayloads ? message : {},
+ reasoning: reasoning || null,
+ role,
+ thinkingDurationMs: asNumber(message.thinkingDurationMs ?? null),
+ toolCalls,
+ };
+};
+
+const parseNewMessageLog = async (
+ messagesPath: string,
+ includeRawPayloads: boolean,
+): Promise => {
+ const messages: MiniMaxCodeTranscriptMessage[] = [];
+ const context: NewMessageParsingContext = {
+ includeRawPayloads,
+ pendingToolResults: new Map(),
+ toolCallsById: new Map(),
+ };
+
+ for await (const row of readJsonlObjects(messagesPath)) {
+ const message = asObject(row.message ?? null);
+ if (!message) {
+ continue;
+ }
+
+ if (message.role === 'toolResult') {
+ consumeNewToolResult(message, context);
+ continue;
+ }
+
+ const parsedMessage = parseNewTranscriptMessage(row, message, context);
+ if (parsedMessage) {
+ messages.push(parsedMessage);
+ }
+ }
+
+ return messages;
+};
+
const getSessionStats = (messages: MiniMaxCodeTranscriptMessage[]): SessionStats => {
const toolCalls = messages.flatMap((message) => message.toolCalls);
const userMessageCount = messages.filter((message) => message.role === 'user').length;
@@ -326,15 +509,161 @@ const readSnapshot = async (
};
};
+const isPathInsideDirectory = (directory: string, candidate: string): boolean => {
+ const relative = path.relative(path.resolve(directory), path.resolve(candidate));
+ return relative !== '..' && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative);
+};
+
+const resolveManifestFilePath = (sessionDir: string, manifestValue: string): string | null => {
+ const resolved = path.resolve(sessionDir, manifestValue);
+ return isPathInsideDirectory(sessionDir, resolved) ? resolved : null;
+};
+
+const toRuntimeJsonValue = (value: unknown): JsonValue | null => {
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return value;
+ }
+ return null;
+};
+
+const readRuntimeSessionRecord = (row: Record): Record | null => {
+ const record = asObject(typeof row.record_json === 'string' ? parseJsonValue(row.record_json) : null) ?? {};
+ const columnMappings = [
+ ['agent_name', 'agentName'],
+ ['app_mode', 'appMode'],
+ ['archived', 'archived'],
+ ['created_at_ms', 'createdAtMs'],
+ ['effective_model', 'effectiveModel'],
+ ['effective_model_variant', 'effectiveModelVariant'],
+ ['project_workspace_dir', 'projectWorkspaceDir'],
+ ['runtime', 'runtime'],
+ ['session_type', 'sessionType'],
+ ['status', 'status'],
+ ['title', 'title'],
+ ['updated_at_ms', 'updatedAtMs'],
+ ['workspace_dir', 'workspaceDir'],
+ ] as const;
+ for (const [columnName, recordName] of columnMappings) {
+ if (record[recordName] !== undefined) {
+ continue;
+ }
+ const value = toRuntimeJsonValue(row[columnName]);
+ if (value !== null) {
+ record[recordName] = value;
+ }
+ }
+
+ const sessionId =
+ asString(record.sessionId ?? null)?.trim() || (typeof row.session_id === 'string' ? row.session_id.trim() : '');
+ if (!sessionId) {
+ return null;
+ }
+ record.sessionId = sessionId;
+ if (record.workspaceDir === undefined && record.projectWorkspaceDir !== undefined) {
+ record.workspaceDir = record.projectWorkspaceDir;
+ }
+ return record;
+};
+
+const hasRuntimeTable = (db: Database, tableName: string): boolean => {
+ return Boolean(db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
+};
+
+const readRuntimeSessionRecords = async (sessionsDir: string): Promise>> => {
+ const records = new Map>();
+ const runtimeDbPath = resolveMiniMaxCodeRuntimeDbPath(sessionsDir);
+ if (!(await Bun.file(runtimeDbPath).exists())) {
+ return records;
+ }
+
+ let db: Database | null = null;
+ try {
+ db = new Database(runtimeDbPath, { create: false, readonly: true, strict: true });
+ if (!hasRuntimeTable(db, 'local_runtime_sessions')) {
+ return records;
+ }
+ const rows = db.query('SELECT * FROM local_runtime_sessions').all() as Array>;
+ for (const row of rows) {
+ const record = readRuntimeSessionRecord(row);
+ const sessionId = record ? asString(record.sessionId ?? null) : null;
+ if (record && sessionId) {
+ records.set(sessionId, record);
+ }
+ }
+ } catch {
+ return records;
+ } finally {
+ db?.close();
+ }
+ return records;
+};
+
+const readManifestMessagesSession = async (
+ manifestPath: string,
+ runtimeRecords: Map>,
+ options: ReadSnapshotOptions = {},
+): Promise => {
+ const parsed = (await Bun.file(manifestPath)
+ .json()
+ .catch(() => null)) as JsonValue | null;
+ const manifest = asObject(parsed);
+ const paths = asObject(manifest?.paths ?? null);
+ const sessionId = asString(manifest?.sessionId ?? null)?.trim();
+ const manifestMessagesPath = asString(paths?.messages ?? null)?.trim();
+ if (!manifest || !paths || !sessionId || !manifestMessagesPath) {
+ return null;
+ }
+
+ const sessionDir = path.dirname(manifestPath);
+ const messagesPath = resolveManifestFilePath(sessionDir, manifestMessagesPath);
+ if (!messagesPath || !(await Bun.file(messagesPath).exists())) {
+ return null;
+ }
+
+ const runtimeRecord = runtimeRecords.get(sessionId) ?? {};
+ const record: Record = {
+ ...runtimeRecord,
+ createdAtMs: runtimeRecord.createdAtMs ?? manifest.createdAtMs ?? null,
+ sessionId,
+ updatedAtMs: runtimeRecord.updatedAtMs ?? manifest.updatedAtMs ?? null,
+ };
+ const includeRawPayloads = options.includeRawPayloads ?? true;
+ const messages = await parseNewMessageLog(messagesPath, includeRawPayloads);
+ const session = toSessionSummary(messagesPath, record, sessionId, messages);
+ if (!session) {
+ return null;
+ }
+
+ return {
+ messages,
+ rawPayloadsOmitted: includeRawPayloads ? undefined : true,
+ renderablePartCount: session.renderablePartCount,
+ session,
+ };
+};
+
const listSessionTranscripts = async (
sessionsDir: string,
options: ReadSnapshotOptions = {},
): Promise => {
- const snapshotPaths = await listSnapshotPaths(sessionsDir);
- const transcripts = await mapWithConcurrency(snapshotPaths, READ_CONCURRENCY, (snapshotPath) =>
+ const [snapshotPaths, manifestPaths, runtimeRecords] = await Promise.all([
+ listSnapshotPaths(sessionsDir),
+ listManifestPaths(sessionsDir),
+ readRuntimeSessionRecords(sessionsDir),
+ ]);
+ const snapshotTranscripts = await mapWithConcurrency(snapshotPaths, READ_CONCURRENCY, (snapshotPath) =>
readSnapshot(snapshotPath, options),
);
- return transcripts.flatMap((transcript) => (transcript?.session.messageCount ? [transcript] : []));
+ const manifestTranscripts = await mapWithConcurrency(manifestPaths, READ_CONCURRENCY, (manifestPath) =>
+ readManifestMessagesSession(manifestPath, runtimeRecords, options),
+ );
+ const transcriptsBySessionId = new Map();
+ for (const transcript of [...snapshotTranscripts, ...manifestTranscripts]) {
+ if (transcript?.session.messageCount && !transcriptsBySessionId.has(transcript.session.sessionId)) {
+ transcriptsBySessionId.set(transcript.session.sessionId, transcript);
+ }
+ }
+ return [...transcriptsBySessionId.values()];
};
const compareNullableMsDesc = (left: number | null, right: number | null): number => (right ?? 0) - (left ?? 0);
@@ -421,11 +750,18 @@ export const readMiniMaxCodeSessionTranscript = async (
return transcript;
}
}
- return null;
-};
-const hasRuntimeTable = (db: Database, tableName: string): boolean => {
- return Boolean(db.query("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?").get(tableName));
+ const [manifestPaths, runtimeRecords] = await Promise.all([
+ listManifestPaths(sessionsDir),
+ readRuntimeSessionRecords(sessionsDir),
+ ]);
+ for (const manifestPath of manifestPaths) {
+ const transcript = await readManifestMessagesSession(manifestPath, runtimeRecords, options);
+ if (transcript?.session.sessionId === sessionId) {
+ return transcript;
+ }
+ }
+ return null;
};
const deleteRuntimeRows = (db: Database, tableName: string, where: string, values: string[]): number => {
From c280d79581142c16598cbcc6e94601fd80d5a3f8 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Mon, 17 Aug 2026 11:13:02 -0400
Subject: [PATCH 07/10] Fix all issues raised by reviewers
---
src/lib/antigravity-db.test.ts | 37 +
src/lib/antigravity-db.ts | 128 +++-
src/lib/antigravity-decryption-error.ts | 8 +
src/lib/antigravity-keychain.ts | 10 +-
src/lib/antigravity-trajectory.test.ts | 25 +
src/lib/antigravity-trajectory.ts | 44 +-
.../antigravity-transcript-history.test.ts | 51 ++
src/lib/antigravity-transcript-history.ts | 73 +-
src/lib/codex-browser-db.test.ts | 43 ++
src/lib/codex-browser-db.ts | 13 +-
src/lib/codex-browser-export.test.ts | 13 +-
src/lib/codex-browser-export.ts | 24 +-
src/lib/codex-rollout-snapshot.test.ts | 88 ++-
src/lib/codex-rollout-snapshot.ts | 92 ++-
.../conversation-data/adapter-helpers.test.ts | 15 +
src/lib/conversation-data/adapter-helpers.ts | 2 +
.../antigravity-adapter.test.ts | 5 +-
.../conversation-data/antigravity-adapter.ts | 16 +-
.../claude-code-adapter.test.ts | 4 +-
.../conversation-data/claude-code-adapter.ts | 8 +-
.../conversation-data/cline-adapter.test.ts | 4 +-
src/lib/conversation-data/cline-adapter.ts | 2 +-
.../conversation-data/codex-adapter.test.ts | 3 +
src/lib/conversation-data/codex-adapter.ts | 4 +-
.../conversation-data/cursor-adapter.test.ts | 28 +-
src/lib/conversation-data/cursor-adapter.ts | 15 +-
.../conversation-data/grok-adapter.test.ts | 8 +-
src/lib/conversation-data/grok-adapter.ts | 6 +-
src/lib/conversation-data/index.ts | 3 +
.../conversation-data/kiro-adapter.test.ts | 5 +-
src/lib/conversation-data/kiro-adapter.ts | 5 +-
.../minimax-code-adapter.test.ts | 3 +-
.../conversation-data/minimax-code-adapter.ts | 2 +-
.../opencode-adapter.test.ts | 5 +
src/lib/conversation-data/opencode-adapter.ts | 4 +-
.../conversation-data/qoder-adapter.test.ts | 3 +-
src/lib/conversation-data/qoder-adapter.ts | 2 +-
src/lib/conversation-data/types.ts | 9 +
src/lib/conversation-zip-export.test.ts | 12 +-
src/lib/conversation-zip-export.ts | 32 +-
src/lib/cursor-db.test.ts | 105 +++
src/lib/cursor-db.ts | 193 +++--
src/lib/cursor-exporter-types.ts | 30 +
src/lib/cursor-id.test.ts | 11 +
src/lib/cursor-id.ts | 32 +-
src/lib/cursor-recovery.test.ts | 288 ++++++-
src/lib/cursor-recovery.ts | 711 ++++++++++++++----
src/lib/minimax-code-db.test.ts | 43 ++
src/lib/minimax-code-db.ts | 5 +-
src/lib/opencode-db.test.ts | 29 +-
src/lib/opencode-db.ts | 41 +-
src/lib/sqlite-retry.test.ts | 22 +
src/lib/sqlite-retry.ts | 6 +-
src/lib/ui-export-zip.test.ts | 10 +
src/lib/ui-export-zip.ts | 16 +-
src/ui/components/export-dialog.tsx | 249 ++++--
src/ui/components/export-dialog.vitest.tsx | 166 ++--
src/ui/components/transcript-view.vitest.tsx | 46 ++
.../lib/antigravity-server-browser.vitest.ts | 4 +
src/ui/lib/antigravity-server.ts | 38 +-
src/ui/lib/antigravity-server.vitest.ts | 35 +
src/ui/lib/cursor-delete-result.ts | 19 +
src/ui/lib/cursor-delete-result.vitest.ts | 38 +
src/ui/lib/cursor-server.ts | 225 +++++-
src/ui/lib/cursor-server.vitest.ts | 191 ++++-
src/ui/lib/download.ts | 18 +
src/ui/lib/download.vitest.ts | 19 +
src/ui/lib/export-mutation.ts | 7 +-
src/ui/lib/export-options.ts | 6 +
.../minimax-code-transcript-events.vitest.ts | 30 +
src/ui/routes/-cursor-workspace.vitest.tsx | 16 +
src/ui/routes/codex.$project.tsx | 28 +-
src/ui/routes/cursor.$workspaceKey.tsx | 70 +-
src/ui/routes/cursor.index.tsx | 58 +-
src/ui/routes/threads.$threadId.tsx | 39 +-
75 files changed, 3135 insertions(+), 563 deletions(-)
create mode 100644 src/lib/antigravity-decryption-error.ts
create mode 100644 src/lib/cursor-id.test.ts
create mode 100644 src/ui/lib/cursor-delete-result.ts
create mode 100644 src/ui/lib/cursor-delete-result.vitest.ts
create mode 100644 src/ui/routes/-cursor-workspace.vitest.tsx
diff --git a/src/lib/antigravity-db.test.ts b/src/lib/antigravity-db.test.ts
index dee1aa3..e80adf1 100644
--- a/src/lib/antigravity-db.test.ts
+++ b/src/lib/antigravity-db.test.ts
@@ -341,6 +341,43 @@ describe('antigravity db discovery', () => {
expect(result.diagnostics).toHaveLength(100);
});
+ it('should continue after an oversized protobuf record with one bounded diagnostic', async () => {
+ const root = await makeRoot();
+ const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
+ const oversizedLength = 8 * 1024 * 1024 + 1;
+ const oversized = [0x0a, ...encodeVarint(oversizedLength), ...new Array(oversizedLength).fill(0)];
+ const valid = encodeSummaryIndex([
+ { id: '55555555-5555-4555-8555-555555555555', title: 'After oversized record' },
+ ]);
+ await Bun.write(summaryPath, new Uint8Array([...oversized, ...valid]));
+
+ const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);
+
+ expect(result.entries.map((entry) => entry.title)).toEqual(['After oversized record']);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({
+ byteOffset: 0,
+ kind: 'protobuf',
+ message: expect.stringContaining('exceeds'),
+ }),
+ ]);
+ });
+
+ it('should diagnose an oversized protobuf record truncated at EOF', async () => {
+ const root = await makeRoot();
+ const summaryPath = path.join(root, 'agyhub_summaries_proto.pb');
+ const oversizedLength = 8 * 1024 * 1024 + 1;
+ await Bun.write(summaryPath, new Uint8Array([0x0a, ...encodeVarint(oversizedLength), 0x01, 0x02, 0x03]));
+
+ const result = await readAntigravitySummaryIndexWithDiagnostics(summaryPath);
+
+ expect(result.entries).toEqual([]);
+ expect(result.diagnostics).toEqual([
+ expect.objectContaining({ byteOffset: 0, message: expect.stringContaining('exceeds') }),
+ expect.objectContaining({ byteOffset: 0, message: 'Truncated Antigravity protobuf input' }),
+ ]);
+ });
+
it('should resolve one conversation without requiring a collection scan', async () => {
const root = await makeRoot();
const conversationId = '12111111-1111-4111-8111-111111111111';
diff --git a/src/lib/antigravity-db.ts b/src/lib/antigravity-db.ts
index 515fe98..51aad3a 100644
--- a/src/lib/antigravity-db.ts
+++ b/src/lib/antigravity-db.ts
@@ -248,6 +248,7 @@ type ProtoBounds = {
fieldNumber: number;
payloadEnd: number;
payloadStart: number;
+ oversized?: boolean;
wireType: number;
};
@@ -259,11 +260,17 @@ const getLengthDelimitedProtoBounds = (
): ProtoBounds | null => {
const length = readVarint(buffer, index, buffer.length);
const payloadStart = length.next;
+ const end = payloadStart + length.value;
if (length.value > ANTIGRAVITY_MAX_PROTO_RECORD_BYTES) {
- throw new Error(`Protobuf record exceeds ${ANTIGRAVITY_MAX_PROTO_RECORD_BYTES} bytes`);
+ return {
+ end,
+ fieldNumber,
+ oversized: true,
+ payloadEnd: end,
+ payloadStart,
+ wireType,
+ };
}
-
- const end = payloadStart + length.value;
if (end > buffer.length) {
return null;
}
@@ -322,7 +329,7 @@ const consumeProtoRecord = (
fieldNumber: number,
diagnostics: AntigravityParseDiagnostic[],
records: IndexedProtoRecord[],
-): number | null => {
+): number | null | { skipBytes: number } => {
let bounds: ProtoBounds | null;
try {
bounds = tryGetProtoBounds(buffer, index);
@@ -339,6 +346,15 @@ const consumeProtoRecord = (
return null;
}
+ if (bounds.oversized) {
+ appendAntigravityDiagnostic(diagnostics, {
+ byteOffset: bufferOffset + index,
+ kind: 'protobuf',
+ message: `Protobuf record exceeds ${ANTIGRAVITY_MAX_PROTO_RECORD_BYTES} bytes`,
+ });
+ return { skipBytes: bounds.end - index };
+ }
+
if (bounds.fieldNumber === fieldNumber && bounds.wireType === 2) {
records.push({
byteOffset: bufferOffset + index,
@@ -349,6 +365,58 @@ const consumeProtoRecord = (
return bounds.end;
};
+const consumePendingProtoBytes = (
+ pending: Uint8Array,
+ index: number,
+ skipBytes: number,
+): { consumed: number; blocked: boolean; remainingSkipBytes: number } => {
+ if (skipBytes === 0) {
+ return { blocked: false, consumed: 0, remainingSkipBytes: 0 };
+ }
+
+ const consumed = Math.min(skipBytes, pending.length - index);
+ return {
+ blocked: consumed < skipBytes,
+ consumed,
+ remainingSkipBytes: skipBytes - consumed,
+ };
+};
+
+type ProtoConsumeState = {
+ skipByteOffset: number | null;
+ skipBytes: number;
+};
+
+const consumeOneProtoSegment = (
+ pending: Uint8Array,
+ index: number,
+ bufferOffset: number,
+ fieldNumber: number,
+ diagnostics: AntigravityParseDiagnostic[],
+ records: IndexedProtoRecord[],
+ state: ProtoConsumeState,
+): { blocked: boolean; nextIndex: number } => {
+ if (state.skipBytes > 0) {
+ const skipped = consumePendingProtoBytes(pending, index, state.skipBytes);
+ state.skipBytes = skipped.remainingSkipBytes;
+ if (state.skipBytes === 0) {
+ state.skipByteOffset = null;
+ }
+ return { blocked: skipped.blocked, nextIndex: index + skipped.consumed };
+ }
+
+ const nextIndex = consumeProtoRecord(pending, index, bufferOffset, fieldNumber, diagnostics, records);
+ if (nextIndex === null) {
+ return { blocked: true, nextIndex: index };
+ }
+ if (typeof nextIndex === 'object') {
+ state.skipBytes = nextIndex.skipBytes;
+ state.skipByteOffset = bufferOffset + index;
+ return { blocked: false, nextIndex: index };
+ }
+ return { blocked: false, nextIndex };
+};
+
const readAntigravityProtobufRecords = async (
filePath: string,
fieldNumber: number,
@@ -360,6 +428,7 @@ const readAntigravityProtobufRecords = async (
let readIndex = 0;
let writeIndex = 0;
let bufferOffset = 0;
+ const protoState: ProtoConsumeState = { skipByteOffset: null, skipBytes: 0 };
const appendChunk = (chunk: Uint8Array) => {
const unreadBytes = writeIndex - readIndex;
@@ -386,11 +455,19 @@ const readAntigravityProtobufRecords = async (
const pending = buffer.subarray(readIndex, writeIndex);
let index = 0;
while (index < pending.length) {
- const nextIndex = consumeProtoRecord(pending, index, bufferOffset, fieldNumber, diagnostics, records);
- if (nextIndex === null) {
+ const result = consumeOneProtoSegment(
+ pending,
+ index,
+ bufferOffset,
+ fieldNumber,
+ diagnostics,
+ records,
+ protoState,
+ );
+ index = result.nextIndex;
+ if (result.blocked) {
break;
}
- index = nextIndex;
}
if (index > 0) {
readIndex += index;
@@ -402,23 +479,32 @@ const readAntigravityProtobufRecords = async (
}
};
- while (true) {
- const { done, value } = await reader.read();
- if (done) {
- break;
+ let completed = false;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ break;
+ }
+ appendChunk(value);
+ consume();
}
- appendChunk(value);
consume();
+ if (writeIndex > readIndex || protoState.skipBytes > 0) {
+ appendAntigravityDiagnostic(diagnostics, {
+ byteOffset: protoState.skipByteOffset ?? bufferOffset,
+ kind: 'protobuf',
+ message: 'Truncated Antigravity protobuf input',
+ });
+ }
+ completed = true;
+ return { diagnostics, records };
+ } finally {
+ if (!completed) {
+ await reader.cancel().catch(() => undefined);
+ }
+ reader.releaseLock();
}
- consume();
- if (writeIndex > readIndex) {
- appendAntigravityDiagnostic(diagnostics, {
- byteOffset: bufferOffset,
- kind: 'protobuf',
- message: 'Truncated Antigravity protobuf input',
- });
- }
- return { diagnostics, records };
};
const firstField = (fields: ProtoField[], fieldNumber: number): ProtoField | null =>
diff --git a/src/lib/antigravity-decryption-error.ts b/src/lib/antigravity-decryption-error.ts
new file mode 100644
index 0000000..ff08339
--- /dev/null
+++ b/src/lib/antigravity-decryption-error.ts
@@ -0,0 +1,8 @@
+export class AntigravityDecryptionCapabilityError extends Error {
+ readonly code = 'ANTIGRAVITY_DECRYPTION_CAPABILITY';
+
+ constructor(cause: unknown) {
+ super('Antigravity decryption capability is unavailable', { cause });
+ this.name = 'AntigravityDecryptionCapabilityError';
+ }
+}
diff --git a/src/lib/antigravity-keychain.ts b/src/lib/antigravity-keychain.ts
index ae83466..28b5147 100644
--- a/src/lib/antigravity-keychain.ts
+++ b/src/lib/antigravity-keychain.ts
@@ -1,6 +1,7 @@
import { execFile } from 'node:child_process';
import { createDecipheriv, pbkdf2Sync } from 'node:crypto';
import { promisify } from 'node:util';
+import { AntigravityDecryptionCapabilityError } from './antigravity-decryption-error';
export const ANTIGRAVITY_KEYCHAIN_SERVICE = 'Antigravity Safe Storage';
export const ANTIGRAVITY_KEYCHAIN_ACCOUNT = 'Antigravity Key';
@@ -20,15 +21,6 @@ export type AntigravityDecryptionCapability = Readonly<{
decryptSafeStoragePayload: (payload: Buffer | Uint8Array | string) => string | null;
}>;
-export class AntigravityDecryptionCapabilityError extends Error {
- readonly code = 'ANTIGRAVITY_DECRYPTION_CAPABILITY';
-
- constructor(cause: unknown) {
- super('Antigravity decryption capability is unavailable', { cause });
- this.name = 'AntigravityDecryptionCapabilityError';
- }
-}
-
const execFileAsync = promisify(execFile);
const SAFE_STORAGE_SALT = 'saltysalt';
const SAFE_STORAGE_ITERATIONS = 1003;
diff --git a/src/lib/antigravity-trajectory.test.ts b/src/lib/antigravity-trajectory.test.ts
index 8fa1d63..c759dcd 100644
--- a/src/lib/antigravity-trajectory.test.ts
+++ b/src/lib/antigravity-trajectory.test.ts
@@ -58,4 +58,29 @@ describe('Antigravity trajectory reader', () => {
expect.objectContaining({ byteOffset: null, message: expect.stringContaining('step 1'), stepIndex: 1 }),
]);
});
+
+ it('should cap trajectory protobuf diagnostics while retaining valid later steps', async () => {
+ const directory = await mkdtemp(path.join(tmpdir(), 'antigravity-trajectory-bounded-'));
+ temporaryDirectories.push(directory);
+ const databasePath = path.join(directory, 'conversation.db');
+ const database = new Database(databasePath, { create: true });
+ database.exec(
+ 'CREATE TABLE steps (idx INTEGER PRIMARY KEY, step_type INTEGER NOT NULL, status INTEGER NOT NULL, metadata BLOB, step_payload BLOB)',
+ );
+ const insert = database.prepare(
+ 'INSERT INTO steps (idx, step_type, status, metadata, step_payload) VALUES (?, ?, ?, ?, ?)',
+ );
+ for (let index = 1; index <= 150; index += 1) {
+ insert.run(index, 14, 3, null, new Uint8Array([0xff]));
+ }
+ insert.run(151, 14, 3, null, encodeMessage(19, encodeString(2, 'keep me')));
+ database.close();
+
+ const result = await readAntigravityTrajectoryEntriesWithDiagnostics(databasePath);
+
+ expect(result.entries).toEqual([
+ expect.objectContaining({ content: 'keep me', step_index: 151, type: 'USER_INPUT' }),
+ ]);
+ expect(result.diagnostics).toHaveLength(100);
+ });
});
diff --git a/src/lib/antigravity-trajectory.ts b/src/lib/antigravity-trajectory.ts
index f051e19..5a08d3d 100644
--- a/src/lib/antigravity-trajectory.ts
+++ b/src/lib/antigravity-trajectory.ts
@@ -47,6 +47,7 @@ export type AntigravityTrajectoryReadResult = {
const decoder = new TextDecoder();
const ANTIGRAVITY_READONLY_DB_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
const ANTIGRAVITY_SQLITE_BUSY_TIMEOUT_MS = 50;
+const ANTIGRAVITY_MAX_TRAJECTORY_DIAGNOSTICS = 100;
const advanceFixedWidth = (buffer: Uint8Array, index: number, width: number): number => {
const next = index + width;
@@ -291,22 +292,17 @@ export const readAntigravityTrajectoryStepIndexes = (dbPath: string): Promise =>
readAntigravityTrajectoryEntriesWithDiagnostics(dbPath).then((result) => result.entries);
-export const readAntigravityTrajectoryEntriesWithDiagnostics = (
- dbPath: string,
-): Promise =>
- withTrajectoryDb(dbPath, (db) => {
- const rows = db
- .query('SELECT idx, step_type, status, metadata, step_payload FROM steps ORDER BY idx')
- .all() as TrajectoryStepRow[];
- const diagnostics: AntigravityParseDiagnostic[] = [];
- const entries: AntigravityTrajectoryEntry[] = [];
- for (const row of rows) {
- try {
- const entry = parseTrajectoryStep(row);
- if (entry) {
- entries.push(entry);
- }
- } catch (error) {
+const readTrajectoryEntriesFromRows = (rows: TrajectoryStepRow[]): AntigravityTrajectoryReadResult => {
+ const diagnostics: AntigravityParseDiagnostic[] = [];
+ const entries: AntigravityTrajectoryEntry[] = [];
+ for (const row of rows) {
+ try {
+ const entry = parseTrajectoryStep(row);
+ if (entry) {
+ entries.push(entry);
+ }
+ } catch (error) {
+ if (diagnostics.length < ANTIGRAVITY_MAX_TRAJECTORY_DIAGNOSTICS) {
diagnostics.push({
byteOffset: null,
kind: 'protobuf',
@@ -317,5 +313,17 @@ export const readAntigravityTrajectoryEntriesWithDiagnostics = (
});
}
}
- return { diagnostics, entries };
- });
+ }
+ return { diagnostics, entries };
+};
+
+export const readAntigravityTrajectoryEntriesWithDiagnostics = (
+ dbPath: string,
+): Promise =>
+ withTrajectoryDb(dbPath, (db) =>
+ readTrajectoryEntriesFromRows(
+ db
+ .query('SELECT idx, step_type, status, metadata, step_payload FROM steps ORDER BY idx')
+ .all() as TrajectoryStepRow[],
+ ),
+ );
diff --git a/src/lib/antigravity-transcript-history.test.ts b/src/lib/antigravity-transcript-history.test.ts
index 8bf6951..f0a6a6f 100644
--- a/src/lib/antigravity-transcript-history.test.ts
+++ b/src/lib/antigravity-transcript-history.test.ts
@@ -5,6 +5,7 @@ import path from 'node:path';
import {
parseAntigravityJsonlText,
readAntigravityJsonlFile,
+ readAntigravityJsonlStream,
readAntigravityTranscriptHistory,
} from './antigravity-transcript-history';
@@ -92,6 +93,56 @@ describe('Antigravity transcript history', () => {
]);
});
+ it('should cancel an interrupted JSONL stream and release its reader', async () => {
+ const firstRecord = new TextEncoder().encode('{"step_index":1}\n');
+ let cancelled = false;
+ const stream = new ReadableStream({
+ cancel() {
+ cancelled = true;
+ },
+ pull(controller) {
+ if (controller.desiredSize && controller.desiredSize > 0) {
+ controller.enqueue(firstRecord);
+ }
+ },
+ });
+ const abortController = new AbortController();
+ const read = readAntigravityJsonlStream(stream, (line) => JSON.parse(line) as { step_index: number }, {
+ signal: abortController.signal,
+ });
+ await Promise.resolve();
+ abortController.abort();
+
+ await expect(read).rejects.toThrow('aborted');
+ expect(cancelled).toBe(true);
+ });
+
+ it('should reject when abort cancels a blocked reader that reports done', async () => {
+ let resolveRead: ((result: ReadableStreamReadResult) => void) | undefined;
+ let cancelled = false;
+ const stream = {
+ getReader: () => ({
+ cancel: async () => {
+ cancelled = true;
+ resolveRead?.({ done: true, value: undefined });
+ },
+ read: () =>
+ new Promise>((resolve) => {
+ resolveRead = resolve;
+ }),
+ releaseLock: () => undefined,
+ }),
+ } as unknown as ReadableStream;
+ const abortController = new AbortController();
+ const read = readAntigravityJsonlStream(stream, JSON.parse, { signal: abortController.signal });
+
+ await Promise.resolve();
+ abortController.abort();
+
+ await expect(read).rejects.toThrow('aborted');
+ expect(cancelled).toBe(true);
+ });
+
it('should skip history recovery when the current transcript already starts at its first step', async () => {
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', null)).toEqual([]);
expect(await readAntigravityTranscriptHistory('/missing/transcript.jsonl', 0)).toEqual([]);
diff --git a/src/lib/antigravity-transcript-history.ts b/src/lib/antigravity-transcript-history.ts
index befa69b..595fc37 100644
--- a/src/lib/antigravity-transcript-history.ts
+++ b/src/lib/antigravity-transcript-history.ts
@@ -206,16 +206,20 @@ const consumeJsonlText = (
}
};
-export const readAntigravityJsonlFile = async (
- filePath: string,
+export const readAntigravityJsonlStream = async (
+ stream: ReadableStream,
parse: (line: string) => T,
- options: { maxLineBytes?: number } = {},
+ options: { maxLineBytes?: number; signal?: AbortSignal } = {},
): Promise> => {
const diagnostics: AntigravityParseDiagnostic[] = [];
const records: T[] = [];
const maxLineBytes = options.maxLineBytes ?? DEFAULT_JSONL_MAX_LINE_BYTES;
- const reader = Bun.file(filePath).stream().getReader();
+ const reader = stream.getReader();
const decoder = new TextDecoder();
+ const abort = () => {
+ void reader.cancel().catch(() => undefined);
+ };
+ options.signal?.addEventListener('abort', abort, { once: true });
const state: JsonlStreamState = {
discardingOverlongLine: false,
lineBytesSeen: 0,
@@ -226,31 +230,52 @@ export const readAntigravityJsonlFile = async (
};
const consume = (text: string) => consumeJsonlText(text, state, { diagnostics, maxLineBytes, parse, records });
- while (true) {
- const { done, value } = await reader.read();
- if (done) {
- consume(decoder.decode());
- break;
+ let completed = false;
+ try {
+ while (true) {
+ if (options.signal?.aborted) {
+ throw new Error('Antigravity JSONL stream aborted');
+ }
+ const { done, value } = await reader.read();
+ if (options.signal?.aborted) {
+ throw new Error('Antigravity JSONL stream aborted');
+ }
+ if (done) {
+ consume(decoder.decode());
+ break;
+ }
+ consume(decoder.decode(value, { stream: true }));
}
- consume(decoder.decode(value, { stream: true }));
- }
- if (state.pending || state.pendingBytes > 0) {
- if (!state.discardingOverlongLine) {
- parseJsonlLine(
- state.pending,
- state.lineNumber,
- state.lineOffset,
- parse,
- diagnostics,
- records,
- maxLineBytes,
- );
+ if (state.pending || state.pendingBytes > 0) {
+ if (!state.discardingOverlongLine) {
+ parseJsonlLine(
+ state.pending,
+ state.lineNumber,
+ state.lineOffset,
+ parse,
+ diagnostics,
+ records,
+ maxLineBytes,
+ );
+ }
}
+ completed = true;
+ return { diagnostics, records };
+ } finally {
+ options.signal?.removeEventListener('abort', abort);
+ if (!completed) {
+ await reader.cancel().catch(() => undefined);
+ }
+ reader.releaseLock();
}
-
- return { diagnostics, records };
};
+export const readAntigravityJsonlFile = async (
+ filePath: string,
+ parse: (line: string) => T,
+ options: { maxLineBytes?: number; signal?: AbortSignal } = {},
+): Promise> => readAntigravityJsonlStream(Bun.file(filePath).stream(), parse, options);
+
const getMinimumStepIndex = (content: string): number | null => {
let minimum: number | null = null;
const result = parseAntigravityJsonlText(content, (line) => JSON.parse(line) as { step_index?: unknown });
diff --git a/src/lib/codex-browser-db.test.ts b/src/lib/codex-browser-db.test.ts
index cde3d5c..7f19372 100644
--- a/src/lib/codex-browser-db.test.ts
+++ b/src/lib/codex-browser-db.test.ts
@@ -1385,6 +1385,29 @@ describe('codex browser db', () => {
expect(detail.thread.title).toBe('Implement thread hierarchy');
});
+ it('should invalidate session-index metadata when content changes without size or mtime changes', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-session-index-fingerprint-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ const sessionIndexPath = path.join(tempRoot, 'session_index.jsonl');
+ const fixedTime = new Date('2026-01-01T00:00:00.000Z');
+ await Bun.write(
+ sessionIndexPath,
+ `${JSON.stringify({ id: fixture.threads[0]!.threadId, thread_name: 'Alpha title' })}\n`,
+ );
+ await utimes(sessionIndexPath, fixedTime, fixedTime);
+
+ expect(getThreadBrowseData(fixture.dbPath, fixture.threads[0]!.threadId).thread.title).toBe('Alpha title');
+
+ await Bun.write(
+ sessionIndexPath,
+ `${JSON.stringify({ id: fixture.threads[0]!.threadId, thread_name: 'Bravo title' })}\n`,
+ );
+ await utimes(sessionIndexPath, fixedTime, fixedTime);
+
+ expect(getThreadBrowseData(fixture.dbPath, fixture.threads[0]!.threadId).thread.title).toBe('Bravo title');
+ });
+
it('should retain every model used by a project thread instead of only the final database model', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-model-history-test-'));
tempPaths.push(tempRoot);
@@ -1910,6 +1933,26 @@ describe('codex browser db', () => {
verificationDb.close();
});
+ it('should detach history only after a successful ATTACH', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-attach-cleanup-test-'));
+ tempPaths.push(tempRoot);
+ const fixture = await createCodexBrowserFixture(tempRoot);
+ await Bun.write(path.join(tempRoot, 'thread_history_1.sqlite'), 'not a sqlite database');
+ const warnings: unknown[][] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args) => {
+ warnings.push(args);
+ };
+
+ try {
+ await expect(deleteCodexThread(fixture.dbPath, fixture.threads[0]!.threadId)).rejects.toThrow();
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ expect(warnings.some((args) => String(args[0]).includes('SQLite history detach failed'))).toBe(false);
+ });
+
it('should keep a deleted DB thread from reappearing through the fallback session index', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-db-delete-thread-index-test-'));
tempPaths.push(tempRoot);
diff --git a/src/lib/codex-browser-db.ts b/src/lib/codex-browser-db.ts
index b701fb1..458a0c5 100644
--- a/src/lib/codex-browser-db.ts
+++ b/src/lib/codex-browser-db.ts
@@ -592,6 +592,13 @@ export const withReadonlyDb = (dbPath: string, callback: (db: Database) => T)
db.close();
}
},
+ onRetry: ({ attempt, delayMs, error }) => {
+ console.warn('[spiracha:codex] SQLite read retry', {
+ attempt,
+ delayMs,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ },
});
};
@@ -781,7 +788,7 @@ const readSessionIndexEntries = (codexDir: string): SessionIndexEntry[] => {
let fingerprint = 'missing';
try {
const metadata = statSync(sessionIndexPath);
- fingerprint = `${metadata.size}:${metadata.mtimeMs}`;
+ fingerprint = `${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}:${metadata.ino}`;
} catch {}
const cached = sessionIndexEntriesCache.get(codexDir);
if (cached?.fingerprint === fingerprint) {
@@ -869,7 +876,7 @@ const getSessionFileIndexFingerprint = (sessionsDir: string) => {
const toFingerprintPart = (targetPath: string) => {
try {
const metadata = statSync(targetPath);
- return `${metadata.size}:${metadata.mtimeMs}`;
+ return `${metadata.size}:${metadata.mtimeMs}:${metadata.ctimeMs}:${metadata.ino}`;
} catch {
return 'missing';
}
@@ -1705,8 +1712,8 @@ const deleteThreadIds = (db: Database, dbPath: string, threadIds: string[]): Del
if (hasRegularFile(historyDbPath)) {
// SQLite coordinates this transaction across the attached database for normal commits. A process
// crash during WAL commit is not claimed to be crash-atomic across both database files.
- historyAttached = true;
transactionDb.query('ATTACH DATABASE ? AS codex_history').run(historyDbPath);
+ historyAttached = true;
}
const historyTableNames = historyAttached
diff --git a/src/lib/codex-browser-export.test.ts b/src/lib/codex-browser-export.test.ts
index 97433fc..81216df 100644
--- a/src/lib/codex-browser-export.test.ts
+++ b/src/lib/codex-browser-export.test.ts
@@ -4,7 +4,13 @@ import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import { strFromU8, unzipSync } from 'fflate';
-import { isArchiveWideFailure, renderCodexThreadDownload, renderCodexThreadsDownload } from './codex-browser-export';
+import { CodexThreadNotFoundError } from './codex-browser-db';
+import {
+ isArchiveWideFailure,
+ isPerEntryExportFailure,
+ renderCodexThreadDownload,
+ renderCodexThreadsDownload,
+} from './codex-browser-export';
import { createCodexBrowserFixture, createCodexFixture } from './codex-test-helpers';
import { UI_EXPORT_DIR_ENV } from './ui-export-files';
@@ -88,6 +94,11 @@ describe('renderCodexThreadDownload', () => {
expect(isArchiveWideFailure({ code: 42 })).toBe(false);
});
+ it('should only classify known rollout failures as per-entry export failures', () => {
+ expect(isPerEntryExportFailure(new Error('unexpected renderer bug'))).toBe(false);
+ expect(isPerEntryExportFailure(new CodexThreadNotFoundError('thread-missing'))).toBe(true);
+ });
+
it('should render a thread export to downloadable markdown content', async () => {
const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'codex-browser-export-test-'));
tempPaths.push(tempRoot);
diff --git a/src/lib/codex-browser-export.ts b/src/lib/codex-browser-export.ts
index 6a7d6f7..820f3f8 100644
--- a/src/lib/codex-browser-export.ts
+++ b/src/lib/codex-browser-export.ts
@@ -10,6 +10,7 @@ import {
} from './codex-browser-db';
import type { ThreadBrowseData } from './codex-browser-types';
import {
+ CodexRolloutContentError,
CodexRolloutMutationError,
type CodexRolloutSnapshot,
CodexRolloutSourceError,
@@ -62,6 +63,7 @@ export type CodexThreadDownload =
const LARGE_BROWSER_EXPORT_THRESHOLD_BYTES = 128 * 1024 * 1024;
const MAX_ROLLOUT_EXPORT_ATTEMPTS = 2;
+const ROLLOUT_RETRY_BACKOFF_MS = 40;
const BATCH_MANIFEST_FILE_NAME = 'spiracha-manifest.json';
const BATCH_MANIFEST_SCHEMA_VERSION = 1;
const ARCHIVE_WIDE_FILE_ERROR_CODES = new Set(['EACCES', 'EIO', 'ENOSPC', 'ENOTDIR', 'EPERM', 'EROFS']);
@@ -190,12 +192,19 @@ const withStableRolloutSnapshot = async ({
return await render({ browseData, rollout, snapshotPath });
} catch (error) {
- if (error instanceof CodexRolloutMutationError && attempt < MAX_ROLLOUT_EXPORT_ATTEMPTS) {
+ if (
+ (error instanceof CodexRolloutContentError || error instanceof CodexRolloutMutationError) &&
+ attempt < MAX_ROLLOUT_EXPORT_ATTEMPTS
+ ) {
+ const delayMs = ROLLOUT_RETRY_BACKOFF_MS * 2 ** (attempt - 1);
logExportEvent('warn', 'rollout_retry', {
attempt,
+ delayMs,
+ errorCode: error.code,
nextAttempt: attempt + 1,
threadId,
});
+ await Bun.sleep(delayMs);
continue;
}
@@ -256,6 +265,15 @@ export const isArchiveWideFailure = (error: unknown) => {
return codes.some((code): code is string => typeof code === 'string' && ARCHIVE_WIDE_FILE_ERROR_CODES.has(code));
};
+export const isPerEntryExportFailure = (error: unknown) => {
+ return (
+ error instanceof CodexThreadNotFoundError ||
+ error instanceof CodexRolloutContentError ||
+ error instanceof CodexRolloutMutationError ||
+ error instanceof CodexRolloutSourceError
+ );
+};
+
export const renderCodexThreadDownload = async (
input: RenderCodexThreadDownloadInput,
): Promise => {
@@ -452,6 +470,10 @@ const renderCodexBatchEntry = async (
throw error;
}
+ if (!isPerEntryExportFailure(error)) {
+ throw error;
+ }
+
const failure = getBatchFailure(result.threadId, error);
logExportEvent('warn', 'batch_entry_skipped', {
error: error instanceof Error ? error.message : String(error),
diff --git a/src/lib/codex-rollout-snapshot.test.ts b/src/lib/codex-rollout-snapshot.test.ts
index de708da..1dd2f2e 100644
--- a/src/lib/codex-rollout-snapshot.test.ts
+++ b/src/lib/codex-rollout-snapshot.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'bun:test';
import {
+ type CodexRolloutContentError,
type CodexRolloutIdentity,
CodexRolloutMutationError,
type CodexRolloutSourceError,
@@ -28,7 +29,9 @@ describe('Codex rollout snapshots', () => {
{
copy: async (sourcePath, snapshotPath) => {
copied.push(`${sourcePath}->${snapshotPath}`);
+ return 4;
},
+ hash: async () => 'stable-content',
stat: async () => identity(),
},
);
@@ -39,6 +42,7 @@ describe('Codex rollout snapshots', () => {
it('should reject a source that changes while the snapshot is copied', async () => {
let statCall = 0;
+ let hashCall = 0;
await expect(
copyStableCodexRollout(
@@ -49,7 +53,11 @@ describe('Codex rollout snapshots', () => {
threadId: 'thread-2',
},
{
- copy: async () => {},
+ copy: async () => 4,
+ hash: async () => {
+ hashCall += 1;
+ return hashCall === 3 ? 'changed-content' : 'stable-content';
+ },
stat: async () => {
statCall += 1;
return identity(statCall === 2 ? { sizeBytes: 5 } : {});
@@ -59,6 +67,80 @@ describe('Codex rollout snapshots', () => {
).rejects.toBeInstanceOf(CodexRolloutMutationError);
});
+ it('should accept metadata churn when the source content still matches the snapshot', async () => {
+ let statCall = 0;
+
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 2,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-metadata-churn',
+ },
+ {
+ copy: async () => 4,
+ hash: async () => 'stable-content',
+ stat: async () => {
+ statCall += 1;
+ return identity(statCall === 2 ? { inode: 9, modificationTimeMs: 8 } : {});
+ },
+ },
+ ),
+ ).resolves.toMatchObject({ threadId: 'thread-metadata-churn' });
+ });
+
+ it('should reject a snapshot when the copy reports a byte mismatch', async () => {
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 1,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-truncated',
+ },
+ {
+ copy: async () => 3,
+ hash: async () => 'stable-content',
+ stat: async () => identity({ sizeBytes: 4 }),
+ },
+ ),
+ ).rejects.toMatchObject({
+ actualBytes: 3,
+ code: 'CODEX_ROLLOUT_CONTENT_INVALID',
+ expectedBytes: 4,
+ threadId: 'thread-truncated',
+ } satisfies Partial);
+ });
+
+ it('should reject a same-size snapshot whose content hash differs', async () => {
+ let hashCall = 0;
+
+ await expect(
+ copyStableCodexRollout(
+ {
+ attempt: 1,
+ snapshotPath: '/tmp/attempt.jsonl',
+ sourcePath: '/tmp/source.jsonl',
+ threadId: 'thread-torn-copy',
+ },
+ {
+ copy: async () => 4,
+ hash: async () => {
+ hashCall += 1;
+ return hashCall === 1 ? 'source-content' : 'snapshot-content';
+ },
+ stat: async () => identity(),
+ },
+ ),
+ ).rejects.toMatchObject({
+ actualHash: 'snapshot-content',
+ code: 'CODEX_ROLLOUT_CONTENT_INVALID',
+ expectedHash: 'source-content',
+ threadId: 'thread-torn-copy',
+ });
+ });
+
it('should classify a missing source during stat as missing', async () => {
const error = Object.assign(new Error('missing'), { code: 'ENOENT' });
@@ -71,7 +153,8 @@ describe('Codex rollout snapshots', () => {
threadId: 'thread-missing',
},
{
- copy: async () => {},
+ copy: async () => 4,
+ hash: async () => 'stable-content',
stat: async () => {
throw error;
},
@@ -100,6 +183,7 @@ describe('Codex rollout snapshots', () => {
copy: async () => {
throw error;
},
+ hash: async () => 'stable-content',
stat: async () => identity(),
},
),
diff --git a/src/lib/codex-rollout-snapshot.ts b/src/lib/codex-rollout-snapshot.ts
index 91f311a..b0caea9 100644
--- a/src/lib/codex-rollout-snapshot.ts
+++ b/src/lib/codex-rollout-snapshot.ts
@@ -1,3 +1,4 @@
+import { createHash } from 'node:crypto';
import { stat } from 'node:fs/promises';
export type CodexRolloutIdentity = {
@@ -72,15 +73,74 @@ export class CodexRolloutSourceError extends Error {
}
}
+export class CodexRolloutContentError extends Error {
+ readonly actualBytes: number;
+ readonly actualHash?: string;
+ readonly code = 'CODEX_ROLLOUT_CONTENT_INVALID';
+ readonly expectedBytes: number;
+ readonly expectedHash?: string;
+ readonly threadId: string;
+
+ constructor({
+ actualBytes,
+ actualHash,
+ expectedBytes,
+ expectedHash,
+ threadId,
+ }: {
+ actualBytes: number;
+ actualHash?: string;
+ expectedBytes: number;
+ expectedHash?: string;
+ threadId: string;
+ }) {
+ super(
+ actualHash === undefined || expectedHash === undefined
+ ? `Thread ${threadId} rollout copy has ${actualBytes} bytes; expected ${expectedBytes}.`
+ : `Thread ${threadId} rollout copy content does not match the source.`,
+ );
+ this.name = 'CodexRolloutContentError';
+ this.actualBytes = actualBytes;
+ this.actualHash = actualHash;
+ this.expectedBytes = expectedBytes;
+ this.expectedHash = expectedHash;
+ this.threadId = threadId;
+ }
+}
+
export type CodexRolloutSnapshotOperations = {
- copy: (sourcePath: string, snapshotPath: string) => Promise;
+ copy: (sourcePath: string, snapshotPath: string) => Promise;
+ hash: (sourcePath: string) => Promise;
stat: (sourcePath: string) => Promise;
};
+const hashFile = async (filePath: string): Promise => {
+ const hash = createHash('sha256');
+ const reader = Bun.file(filePath).stream().getReader();
+ let completed = false;
+ try {
+ while (true) {
+ const { done, value } = await reader.read();
+ if (done) {
+ completed = true;
+ return hash.digest('hex');
+ }
+
+ hash.update(value);
+ }
+ } finally {
+ if (!completed) {
+ await reader.cancel().catch(() => undefined);
+ }
+ reader.releaseLock();
+ }
+};
+
const defaultOperations: CodexRolloutSnapshotOperations = {
copy: async (sourcePath, snapshotPath) => {
- await Bun.write(snapshotPath, Bun.file(sourcePath));
+ return Bun.write(snapshotPath, Bun.file(sourcePath));
},
+ hash: hashFile,
stat: async (sourcePath) => {
const metadata = await stat(sourcePath);
return {
@@ -96,15 +156,6 @@ const isMissingError = (error: unknown) => {
return error instanceof Error && 'code' in error && error.code === 'ENOENT';
};
-const isSameIdentity = (before: CodexRolloutIdentity, after: CodexRolloutIdentity) => {
- return (
- before.changeTimeMs === after.changeTimeMs &&
- before.inode === after.inode &&
- before.modificationTimeMs === after.modificationTimeMs &&
- before.sizeBytes === after.sizeBytes
- );
-};
-
const runSourceOperation = async (
operation: () => Promise,
context: { sourcePath: string; threadId: string },
@@ -136,10 +187,25 @@ export const copyStableCodexRollout = async (
): Promise => {
const context = { sourcePath, threadId };
const before = await runSourceOperation(() => operations.stat(sourcePath), context);
- await runSourceOperation(() => operations.copy(sourcePath, snapshotPath), context);
+ const sourceHash = await runSourceOperation(() => operations.hash(sourcePath), context);
+ const copiedBytes = await runSourceOperation(() => operations.copy(sourcePath, snapshotPath), context);
+ if (copiedBytes !== before.sizeBytes) {
+ throw new CodexRolloutContentError({ actualBytes: copiedBytes, expectedBytes: before.sizeBytes, threadId });
+ }
+ const snapshotHash = await operations.hash(snapshotPath);
+ if (snapshotHash !== sourceHash) {
+ throw new CodexRolloutContentError({
+ actualBytes: copiedBytes,
+ actualHash: snapshotHash,
+ expectedBytes: before.sizeBytes,
+ expectedHash: sourceHash,
+ threadId,
+ });
+ }
const after = await runSourceOperation(() => operations.stat(sourcePath), context);
- if (!isSameIdentity(before, after)) {
+ const finalSourceHash = await runSourceOperation(() => operations.hash(sourcePath), context);
+ if (finalSourceHash !== snapshotHash) {
throw new CodexRolloutMutationError({ after, attempt, before, threadId });
}
diff --git a/src/lib/conversation-data/adapter-helpers.test.ts b/src/lib/conversation-data/adapter-helpers.test.ts
index 9143017..ebfa54f 100644
--- a/src/lib/conversation-data/adapter-helpers.test.ts
+++ b/src/lib/conversation-data/adapter-helpers.test.ts
@@ -2,6 +2,7 @@ import { describe, expect, it } from 'bun:test';
import {
createConversationUiPath,
createDeepLinks,
+ createTextMessage,
decodeFileUri,
getToolNamespace,
isWithinUpdatedWindow,
@@ -40,4 +41,18 @@ describe('conversation adapter helpers', () => {
expect(getToolNamespace('workspace.read')).toBe('workspace');
expect(getToolNamespace('read')).toBeNull();
});
+
+ it('should omit an unavailable canonical model from normalized messages', () => {
+ const [message] = createTextMessage({
+ createdAtMs: null,
+ id: 'message-1',
+ model: undefined,
+ order: 0,
+ phase: 'unknown',
+ role: 'unknown',
+ text: 'message',
+ });
+
+ expect(message).not.toHaveProperty('model');
+ });
});
diff --git a/src/lib/conversation-data/adapter-helpers.ts b/src/lib/conversation-data/adapter-helpers.ts
index 371c3bf..0575ef3 100644
--- a/src/lib/conversation-data/adapter-helpers.ts
+++ b/src/lib/conversation-data/adapter-helpers.ts
@@ -101,6 +101,7 @@ export const normalizeAssistantPhase = (
export const createTextMessage = (input: {
createdAtMs: number | null;
id: string;
+ model?: string;
metadata?: Record;
order: number;
phase: ConversationMessagePhase;
@@ -117,6 +118,7 @@ export const createTextMessage = (input: {
{
createdAtMs: input.createdAtMs,
id: input.id,
+ ...(input.model ? { model: input.model } : {}),
metadata: input.metadata ?? {},
order: input.order,
phase: input.phase,
diff --git a/src/lib/conversation-data/antigravity-adapter.test.ts b/src/lib/conversation-data/antigravity-adapter.test.ts
index 27631e4..b56a42f 100644
--- a/src/lib/conversation-data/antigravity-adapter.test.ts
+++ b/src/lib/conversation-data/antigravity-adapter.test.ts
@@ -228,7 +228,8 @@ describe('antigravity conversation adapter', () => {
});
expect(page.data).toHaveLength(1);
- expect(page.data[0]?.metadata.model).toBe('Gemini 3.6 Flash');
+ expect(page.data[0]?.model).toBe('Gemini 3.6 Flash');
+ expect(page.data[0]?.metadata).not.toHaveProperty('model');
expect(page.data[0]?.messages).toEqual([
expect.objectContaining({
phase: 'final_answer',
@@ -249,7 +250,7 @@ describe('antigravity conversation adapter', () => {
expect(
detail?.messages
.filter((message) => message.role === 'assistant' && message.text)
- .map((message) => [message.text, message.metadata.model]),
+ .map((message) => [message.text, message.model]),
).toEqual([
['First draft answer that should not be selected.', 'Gemini 3.1 Pro'],
['I will inspect the manifest before answering.', 'Gemini 3.1 Pro'],
diff --git a/src/lib/conversation-data/antigravity-adapter.ts b/src/lib/conversation-data/antigravity-adapter.ts
index ee4f708..68bca63 100644
--- a/src/lib/conversation-data/antigravity-adapter.ts
+++ b/src/lib/conversation-data/antigravity-adapter.ts
@@ -155,19 +155,21 @@ const readMessages = async (conversation: AntigravityConversation) => {
)
: await load();
return finalizeMessages(
- messages.map(
- (message, entryIndex): ConversationMessage => ({
+ messages.map((message, entryIndex): ConversationMessage => {
+ const { model: sourceModel, ...sourceMetadata } = message.metadata;
+ const model = typeof sourceModel === 'string' ? sourceModel : undefined;
+ return {
...message,
+ ...(model ? { model } : {}),
id: `${conversation.conversationId}:${message.order}:${message.role}:${message.phase}:${entryIndex}`,
metadata: {
- ...message.metadata,
+ ...sourceMetadata,
...getEvidenceLimitationMetadata(message),
- model: typeof message.metadata.model === 'string' ? message.metadata.model : null,
transcriptSource: conversation.transcriptSource,
},
toolEvidence: antigravityToolEvidence(message),
- }),
- ),
+ };
+ }),
);
};
@@ -192,12 +194,12 @@ const buildConversation = async (
),
id: conversation.conversationId,
matches,
+ ...(conversation.model ? { model: conversation.model } : {}),
messageCount: options.includeMessages ? allMessages.length : conversation.transcriptEntryCount,
messages,
metadata: {
artifactCount: conversation.artifactCount,
lockedTranscript: conversation.transcriptSource === 'safe-storage',
- model: conversation.model,
transcriptSource: conversation.transcriptSource,
},
source: 'antigravity',
diff --git a/src/lib/conversation-data/claude-code-adapter.test.ts b/src/lib/conversation-data/claude-code-adapter.test.ts
index da59f08..b7a7008 100644
--- a/src/lib/conversation-data/claude-code-adapter.test.ts
+++ b/src/lib/conversation-data/claude-code-adapter.test.ts
@@ -96,12 +96,13 @@ describe('Claude Code conversation adapter', () => {
id: sessionId,
metadata: {
continuationSessionIds: [sessionId],
- model: 'claude-sonnet-4-5',
version: '2.1.148',
},
+ model: 'claude-sonnet-4-5',
source: 'claude-code',
workspacePath: cwd,
});
+ expect(conversation?.metadata).not.toHaveProperty('model');
expect(conversation?.messages.map((message) => message.order)).toEqual(
conversation?.messages.map((_, index) => index),
);
@@ -125,6 +126,7 @@ describe('Claude Code conversation adapter', () => {
}),
expect.objectContaining({ metadata: { attachmentType: 'image' }, text: '[Attachment: image]' }),
expect.objectContaining({
+ model: 'claude-sonnet-4-5',
phase: 'final_answer',
role: 'assistant',
text: 'The implementation is correct.',
diff --git a/src/lib/conversation-data/claude-code-adapter.ts b/src/lib/conversation-data/claude-code-adapter.ts
index 6b0a3e0..7423aff 100644
--- a/src/lib/conversation-data/claude-code-adapter.ts
+++ b/src/lib/conversation-data/claude-code-adapter.ts
@@ -50,10 +50,12 @@ const partToMessages = (
): ConversationMessage[] => {
const createdAtMs = toDateMs(entry.timestamp);
const baseId = `${entry.entryId}:${partIndex}`;
+ const model = entry.model ?? undefined;
if (part.type === 'text') {
return createTextMessage({
createdAtMs,
id: baseId,
+ model,
order: partIndex,
phase: normalizeAssistantPhase(getClaudeCodeAssistantMessagePhase(entry), 'unknown'),
role: normalizeRole(entry.role),
@@ -65,6 +67,7 @@ const partToMessages = (
return createTextMessage({
createdAtMs,
id: baseId,
+ model,
order: partIndex,
phase: 'reasoning',
role: 'assistant',
@@ -78,6 +81,7 @@ const partToMessages = (
createdAtMs,
id: baseId,
metadata: { toolName: part.toolName, toolUseId: part.toolUseId },
+ model,
order: partIndex,
phase: 'tool_call',
role: 'tool',
@@ -102,6 +106,7 @@ const partToMessages = (
createdAtMs,
id: baseId,
metadata: { isError: part.isError, toolUseId: part.toolUseId },
+ model,
order: partIndex,
phase: 'tool_output',
role: 'tool',
@@ -127,6 +132,7 @@ const partToMessages = (
createdAtMs,
id: baseId,
metadata: { attachmentType: part.attachmentType ?? null },
+ model,
order: partIndex,
phase: 'unknown',
role: normalizeRole(entry.role),
@@ -175,13 +181,13 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(session.model ? { model: session.model } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
continuationSessionIds: session.continuationSessionIds,
filePath: session.filePath,
gitBranch: session.gitBranch,
- model: session.model,
totalTokens: session.totalTokens,
version: session.version,
},
diff --git a/src/lib/conversation-data/cline-adapter.test.ts b/src/lib/conversation-data/cline-adapter.test.ts
index f8886b4..5e2ee88 100644
--- a/src/lib/conversation-data/cline-adapter.test.ts
+++ b/src/lib/conversation-data/cline-adapter.test.ts
@@ -30,11 +30,13 @@ describe('Cline conversation adapter', () => {
expect(page.data).toHaveLength(1);
expect(page.data[0]).toMatchObject({
id: CLINE_SESSION_ID,
- metadata: { isFavorited: true, modelId: 'deepseek/deepseek-v4-flash' },
+ metadata: { isFavorited: true },
+ model: 'deepseek/deepseek-v4-flash',
source: 'cline',
title: 'Fix issue 1494 per the implementation plan',
workspacePath,
});
+ expect(page.data[0]?.metadata).not.toHaveProperty('modelId');
expect(page.data[0]?.messages).toEqual([
expect.objectContaining({
phase: 'final_answer',
diff --git a/src/lib/conversation-data/cline-adapter.ts b/src/lib/conversation-data/cline-adapter.ts
index 4defb11..bfad384 100644
--- a/src/lib/conversation-data/cline-adapter.ts
+++ b/src/lib/conversation-data/cline-adapter.ts
@@ -74,6 +74,7 @@ const buildConversation = async (
deepLinks: createDeepLinks('cline', task.taskId, createConversationUiPath('cline-tasks', task.taskId)),
id: task.taskId,
matches,
+ ...(task.modelId ? { model: task.modelId } : {}),
messageCount: options.includeMessages ? allMessages.length : task.messageCount,
messages: options.includeMessages
? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
@@ -82,7 +83,6 @@ const buildConversation = async (
cacheReads: task.cacheReads,
cacheWrites: task.cacheWrites,
isFavorited: task.isFavorited,
- modelId: task.modelId,
tokensIn: task.tokensIn,
tokensOut: task.tokensOut,
totalCost: task.totalCost,
diff --git a/src/lib/conversation-data/codex-adapter.test.ts b/src/lib/conversation-data/codex-adapter.test.ts
index 16ec5a6..d198e50 100644
--- a/src/lib/conversation-data/codex-adapter.test.ts
+++ b/src/lib/conversation-data/codex-adapter.test.ts
@@ -37,11 +37,14 @@ describe('codex conversation adapter', () => {
expect(page.meta).toEqual({ hasNext: false, nextCursor: null });
expect(page.data[0]).toMatchObject({
id: fixture.threads[0]!.threadId,
+ model: 'gpt-5.4',
source: 'codex',
workspacePath: fixture.threads[0]!.cwd,
});
+ expect(page.data[0]?.metadata).not.toHaveProperty('model');
expect(page.data[0]!.messages).toHaveLength(1);
expect(page.data[0]!.messages[0]).toMatchObject({
+ model: 'gpt-5.4',
phase: 'final_answer',
role: 'assistant',
});
diff --git a/src/lib/conversation-data/codex-adapter.ts b/src/lib/conversation-data/codex-adapter.ts
index 76535e9..aa10dd1 100644
--- a/src/lib/conversation-data/codex-adapter.ts
+++ b/src/lib/conversation-data/codex-adapter.ts
@@ -55,8 +55,8 @@ const toMessageEventMessage = (event: MessageEvent): ConversationMessage | null
return {
createdAtMs: toDateMs(event.timestamp),
id: `codex:${event.sequence}`,
+ ...(event.model ? { model: event.model } : {}),
metadata: {
- model: event.model,
variant: event.variant,
},
order: event.sequence,
@@ -217,6 +217,7 @@ const buildCodexConversation = async (
),
id: thread.id,
matches,
+ ...(thread.model ? { model: thread.model } : {}),
messageCount: options.includeMessages ? allMessages.length : null,
messages,
metadata: {
@@ -225,7 +226,6 @@ const buildCodexConversation = async (
agentRole: thread.agent_role,
archived: Boolean(thread.archived),
cliVersion: thread.cli_version,
- model: thread.model,
modelProvider: thread.model_provider,
tokensUsed: thread.tokens_used,
},
diff --git a/src/lib/conversation-data/cursor-adapter.test.ts b/src/lib/conversation-data/cursor-adapter.test.ts
index cbfe0d8..7690dd2 100644
--- a/src/lib/conversation-data/cursor-adapter.test.ts
+++ b/src/lib/conversation-data/cursor-adapter.test.ts
@@ -2,8 +2,13 @@ import { afterEach, describe, expect, it } from 'bun:test';
import { mkdtemp, rm } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
+import type { CursorPruneResult } from '../cursor-exporter-types';
import { createCursorFixture } from '../cursor-test-helpers';
-import { cursorConversationAdapter, deleteCursorConversation } from './cursor-adapter';
+import {
+ cursorConversationAdapter,
+ deleteCursorConversation,
+ toCursorDeleteConversationResult,
+} from './cursor-adapter';
const tempDirs: string[] = [];
@@ -12,6 +17,25 @@ afterEach(async () => {
});
describe('cursorConversationAdapter', () => {
+ it('should report only confirmed transcript removals and preserve cleanup failures', () => {
+ const result: CursorPruneResult = {
+ bubblesDeleted: 1,
+ cleanupFailures: [{ error: 'permission denied', path: '/failed', phase: 'transcript_directory' }],
+ composerDataDeleted: 1,
+ composerIds: ['thread-1'],
+ headersRemoved: 1,
+ transcriptDirsRemoved: 1,
+ transcriptDirsRemovedPaths: ['/confirmed'],
+ workspaceBucketsUpdated: 1,
+ };
+
+ expect(toCursorDeleteConversationResult(result)).toEqual({
+ cleanupFailures: result.cleanupFailures,
+ deletedFiles: ['/confirmed'],
+ deletedIds: ['thread-1'],
+ });
+ });
+
it('should refuse custom-directory deletes while Cursor is running', async () => {
await expect(
deleteCursorConversation(
@@ -55,6 +79,7 @@ describe('cursorConversationAdapter', () => {
},
],
composerId: 'thread-1',
+ model: 'claude-sonnet-4.5',
name: 'Export fix',
},
],
@@ -67,6 +92,7 @@ describe('cursorConversationAdapter', () => {
source: 'cursor',
});
+ expect(conversation?.model).toBe('claude-sonnet-4.5');
expect(
conversation?.messages
.filter((message) => message.role === 'assistant')
diff --git a/src/lib/conversation-data/cursor-adapter.ts b/src/lib/conversation-data/cursor-adapter.ts
index 02dd646..6af3dfe 100644
--- a/src/lib/conversation-data/cursor-adapter.ts
+++ b/src/lib/conversation-data/cursor-adapter.ts
@@ -6,6 +6,7 @@ import {
} from '../cursor-db';
import type {
CursorBubble,
+ CursorPruneResult,
CursorThreadSummary,
CursorThreadTranscript,
CursorWorkspaceGroup,
@@ -31,6 +32,7 @@ import type {
ConversationMessage,
ConversationPathMatch,
DeleteConversationOptions,
+ DeleteConversationResult,
GetConversationOptions,
ListConversationsForPathOptions,
} from './types';
@@ -155,6 +157,7 @@ const buildConversation = async (
),
id: thread.composerId,
matches,
+ ...(thread.model ? { model: thread.model } : {}),
messageCount: options.includeMessages ? allMessages.length : thread.bubbleCount,
messages,
metadata: {
@@ -229,14 +232,16 @@ export const deleteCursorConversation = async (
if (threads.length === 0) {
return { deletedFiles: [], deletedIds: [] };
}
- const deletedFiles = threads.flatMap((thread) => thread.transcriptDirs);
const result = await pruneCursorThreads(threads, true, userDir);
- return {
- deletedFiles,
- deletedIds: result.composerIds,
- };
+ return toCursorDeleteConversationResult(result);
};
+export const toCursorDeleteConversationResult = (result: CursorPruneResult): DeleteConversationResult => ({
+ ...(result.cleanupFailures.length > 0 ? { cleanupFailures: result.cleanupFailures } : {}),
+ deletedFiles: result.transcriptDirsRemovedPaths,
+ deletedIds: result.composerIds,
+});
+
export const cursorConversationAdapter: ConversationAdapter = {
deleteConversation: deleteCursorConversation,
getConversation: getCursorConversation,
diff --git a/src/lib/conversation-data/grok-adapter.test.ts b/src/lib/conversation-data/grok-adapter.test.ts
index 2615d3a..a7ae4e8 100644
--- a/src/lib/conversation-data/grok-adapter.test.ts
+++ b/src/lib/conversation-data/grok-adapter.test.ts
@@ -99,16 +99,16 @@ describe('grok conversation adapter', () => {
expect(page.data).toHaveLength(1);
expect(page.data[0]).toMatchObject({
id: fixture.sessionId,
- metadata: {
- currentModelId: 'grok-composer-2.5-fast',
- modelLabel: 'Composer 2.5',
- },
+ model: 'grok-composer-2.5-fast',
source: 'grok',
title: 'Review #109 #209',
workspacePath,
});
+ expect(page.data[0]?.metadata).not.toHaveProperty('currentModelId');
+ expect(page.data[0]?.metadata).not.toHaveProperty('modelLabel');
expect(page.data[0]?.messages).toEqual([
expect.objectContaining({
+ model: 'grok-composer-2.5-fast',
phase: 'final_answer',
role: 'assistant',
text: 'Failed refresh leaves a mutated candidate tree with stale artifacts.',
diff --git a/src/lib/conversation-data/grok-adapter.ts b/src/lib/conversation-data/grok-adapter.ts
index f4e874b..650bc3f 100644
--- a/src/lib/conversation-data/grok-adapter.ts
+++ b/src/lib/conversation-data/grok-adapter.ts
@@ -53,7 +53,7 @@ const partToMessages = (
return createTextMessage({
createdAtMs: entry.createdAtMs,
id: part.partId,
- metadata: { modelFingerprint: entry.modelFingerprint, modelId: entry.modelId },
+ model: entry.modelId ?? undefined,
order,
phase: normalizeAssistantPhase(getGrokTextPartPhase(entry, part, finalTextPartIds), 'unknown'),
role: normalizeRole(entry.role),
@@ -158,6 +158,7 @@ const buildConversation = async (
const messages = options.includeMessages
? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
: [];
+ const model = session.currentModelId ?? session.modelLabel ?? undefined;
return {
createdAtMs: session.createdAtMs,
@@ -168,14 +169,13 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(model ? { model } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
agentName: session.agentName,
- currentModelId: session.currentModelId,
gitBranch: session.gitBranch,
headCommit: session.headCommit,
- modelLabel: session.modelLabel,
renderablePartCount: session.renderablePartCount,
sandboxProfile: session.sandboxProfile,
},
diff --git a/src/lib/conversation-data/index.ts b/src/lib/conversation-data/index.ts
index a9c8acf..9f3fdfa 100644
--- a/src/lib/conversation-data/index.ts
+++ b/src/lib/conversation-data/index.ts
@@ -242,13 +242,16 @@ export const deleteConversations = async (
);
const deletedIdSet = new Set(rawResults.flatMap(({ result }) => result.deletedIds));
const results: DeleteConversationItemResult[] = rawResults.map(({ id, result }) => ({
+ ...(result.cleanupFailures?.length ? { cleanupFailures: result.cleanupFailures } : {}),
deleted: result.deletedIds.length > 0 || deletedIdSet.has(id),
deletedFiles: result.deletedFiles,
deletedIds: result.deletedIds,
id,
}));
+ const cleanupFailures = results.flatMap((result) => result.cleanupFailures ?? []);
return {
+ ...(cleanupFailures.length > 0 ? { cleanupFailures } : {}),
deletedFiles: [...new Set(results.flatMap((result) => result.deletedFiles))],
deletedIds: [...deletedIdSet],
missingIds: results.filter((result) => !result.deleted).map((result) => result.id),
diff --git a/src/lib/conversation-data/kiro-adapter.test.ts b/src/lib/conversation-data/kiro-adapter.test.ts
index b574936..7997866 100644
--- a/src/lib/conversation-data/kiro-adapter.test.ts
+++ b/src/lib/conversation-data/kiro-adapter.test.ts
@@ -156,12 +156,15 @@ describe('Kiro conversation adapter', () => {
id: sessionId,
metadata: {
continuationSessionIds: [sessionId],
- model: 'claude-sonnet-4.5',
sessionType: 'spec',
},
+ model: 'claude-sonnet-4.5',
source: 'kiro',
workspacePath,
});
+ expect(conversation?.metadata).not.toHaveProperty('model');
+ expect(conversation?.metadata).not.toHaveProperty('selectedModel');
+ expect(conversation?.metadata).not.toHaveProperty('defaultModelTitle');
expect(conversation?.messages).toEqual(
expect.arrayContaining([
expect.objectContaining({
diff --git a/src/lib/conversation-data/kiro-adapter.ts b/src/lib/conversation-data/kiro-adapter.ts
index 7928376..8c0b4eb 100644
--- a/src/lib/conversation-data/kiro-adapter.ts
+++ b/src/lib/conversation-data/kiro-adapter.ts
@@ -163,6 +163,7 @@ const buildConversation = async (
const messages = options.includeMessages
? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
: [];
+ const model = session.selectedModel ?? session.defaultModelTitle ?? undefined;
return {
createdAtMs: session.createdAtMs,
@@ -173,14 +174,12 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(model ? { model } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
continuationSessionIds: session.continuationSessionIds,
- defaultModelTitle: session.defaultModelTitle,
filePath: session.filePath,
- model: session.selectedModel ?? session.defaultModelTitle,
- selectedModel: session.selectedModel,
sessionType: session.sessionType,
},
source: 'kiro',
diff --git a/src/lib/conversation-data/minimax-code-adapter.test.ts b/src/lib/conversation-data/minimax-code-adapter.test.ts
index 83f6b00..029187f 100644
--- a/src/lib/conversation-data/minimax-code-adapter.test.ts
+++ b/src/lib/conversation-data/minimax-code-adapter.test.ts
@@ -37,13 +37,14 @@ describe('MiniMax Code conversation adapter', () => {
id: fixture.sessionId,
metadata: {
agentName: 'main',
- currentModelId: 'minimax/MiniMax-M3',
currentModelVariant: 'thinking',
},
+ model: 'minimax/MiniMax-M3',
source: 'minimax-code',
title: 'Refactor evidence extraction module',
workspacePath,
});
+ expect(page.data[0]?.metadata).not.toHaveProperty('currentModelId');
expect(page.data[0]?.messages).toEqual([
expect.objectContaining({
phase: 'final_answer',
diff --git a/src/lib/conversation-data/minimax-code-adapter.ts b/src/lib/conversation-data/minimax-code-adapter.ts
index 6180172..893aa8f 100644
--- a/src/lib/conversation-data/minimax-code-adapter.ts
+++ b/src/lib/conversation-data/minimax-code-adapter.ts
@@ -172,12 +172,12 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(session.currentModelId ? { model: session.currentModelId } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
agentName: session.agentName,
appMode: session.appMode,
- currentModelId: session.currentModelId,
currentModelVariant: session.currentModelVariant,
runtime: session.runtime,
sessionType: session.sessionType,
diff --git a/src/lib/conversation-data/opencode-adapter.test.ts b/src/lib/conversation-data/opencode-adapter.test.ts
index cab04c5..cf6b0e6 100644
--- a/src/lib/conversation-data/opencode-adapter.test.ts
+++ b/src/lib/conversation-data/opencode-adapter.test.ts
@@ -53,6 +53,7 @@ describe('opencodeConversationAdapter', () => {
role: 'assistant',
},
],
+ model: { id: 'openai/gpt-5.4', providerID: 'openai', variant: null },
projectId: 'project-1',
title: 'Think literal',
},
@@ -74,6 +75,9 @@ describe('opencodeConversationAdapter', () => {
text: 'literal user input ',
}),
);
+ expect(conversation).toMatchObject({ model: 'openai/gpt-5.4' });
+ expect(conversation?.metadata).not.toHaveProperty('model');
+ expect(conversation?.metadata).not.toHaveProperty('modelLabel');
expect(conversation?.messages).not.toContainEqual(
expect.objectContaining({ role: 'assistant', text: 'literal user input' }),
);
@@ -125,6 +129,7 @@ describe('opencodeConversationAdapter', () => {
expect(conversations.map(({ id }) => id)).toEqual(['session-in-window']);
expect(conversations[0]?.matches[0]?.kind).toBe('exact');
expect(conversations[0]?.messages).toEqual([]);
+ expect(conversations[0]).not.toHaveProperty('model');
expect(excluded).toEqual([]);
});
});
diff --git a/src/lib/conversation-data/opencode-adapter.ts b/src/lib/conversation-data/opencode-adapter.ts
index 6c36407..6d63254 100644
--- a/src/lib/conversation-data/opencode-adapter.ts
+++ b/src/lib/conversation-data/opencode-adapter.ts
@@ -178,6 +178,7 @@ const buildConversation = async (
const messages = options.includeMessages
? selectConversationMessages(allMessages, options.messageSelector ?? 'last_final_answer')
: [];
+ const model = session.model.id ?? session.modelLabel ?? undefined;
return {
createdAtMs: session.createdAtMs,
@@ -188,13 +189,12 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(model ? { model } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
agent: session.agent,
cost: session.cost,
- model: session.model,
- modelLabel: session.modelLabel,
totalTokens: session.totalTokens,
},
source: 'opencode',
diff --git a/src/lib/conversation-data/qoder-adapter.test.ts b/src/lib/conversation-data/qoder-adapter.test.ts
index cb4c387..f08ffdc 100644
--- a/src/lib/conversation-data/qoder-adapter.test.ts
+++ b/src/lib/conversation-data/qoder-adapter.test.ts
@@ -127,13 +127,14 @@ describe('qoder conversation adapter', () => {
expect(page.data[0]?.metadata).toMatchObject({
assistantMessageCount: 2,
fileOperationCount: 0,
- model: 'Qwen 3.7 Max',
query: expect.stringContaining(path.join(project, 'src/index.ts')),
renderablePartCount: 5,
sourceStatePath: null,
userMessageCount: 1,
workspaceStorageId: 'ws-a',
});
+ expect(page.data[0]?.model).toBe('Qwen 3.7 Max');
+ expect(page.data[0]?.metadata).not.toHaveProperty('model');
expect(page.data[0]?.messages).toEqual([
expect.objectContaining({
phase: 'unknown',
diff --git a/src/lib/conversation-data/qoder-adapter.ts b/src/lib/conversation-data/qoder-adapter.ts
index a7bbcca..1b4214b 100644
--- a/src/lib/conversation-data/qoder-adapter.ts
+++ b/src/lib/conversation-data/qoder-adapter.ts
@@ -194,6 +194,7 @@ const buildConversation = async (
),
id: session.sessionId,
matches,
+ ...(session.model ? { model: session.model } : {}),
messageCount: options.includeMessages ? allMessages.length : session.messageCount,
messages,
metadata: {
@@ -202,7 +203,6 @@ const buildConversation = async (
executionMode: session.executionMode,
fileOperationCount: session.fileOperationCount,
historyIds: session.historyIds,
- model: session.model,
query: session.query,
renderablePartCount,
requestId: session.requestId,
diff --git a/src/lib/conversation-data/types.ts b/src/lib/conversation-data/types.ts
index b39911b..a2be252 100644
--- a/src/lib/conversation-data/types.ts
+++ b/src/lib/conversation-data/types.ts
@@ -132,6 +132,7 @@ export type ConversationDeepLinks = {
export type ConversationMessage = {
createdAtMs: number | null;
id: string;
+ model?: string;
metadata: Record;
order: number;
phase: ConversationMessagePhase;
@@ -145,6 +146,7 @@ export type ConversationDetail = {
deepLinks: ConversationDeepLinks;
id: string;
matches: ConversationPathMatch[];
+ model?: string;
messageCount: number | null;
messages: ConversationMessage[];
metadata: Record;
@@ -206,10 +208,17 @@ export type DeleteConversationOptions = {
};
export type DeleteConversationResult = {
+ cleanupFailures?: ConversationCleanupFailure[];
deletedFiles: string[];
deletedIds: string[];
};
+export type ConversationCleanupFailure = {
+ error: string;
+ path?: string;
+ phase: string;
+};
+
export type ConversationIdSetOptions = {
ids: string[];
locations?: ConversationDataLocations;
diff --git a/src/lib/conversation-zip-export.test.ts b/src/lib/conversation-zip-export.test.ts
index 4c20c77..fa18a3d 100644
--- a/src/lib/conversation-zip-export.test.ts
+++ b/src/lib/conversation-zip-export.test.ts
@@ -3,7 +3,7 @@ import { randomUUID } from 'node:crypto';
import { readdir } from 'node:fs/promises';
import os from 'node:os';
import { unzipSync } from 'fflate';
-import { createConversationMarkdownZip } from './conversation-zip-export';
+import { cleanupConversationZipArtifacts, createConversationMarkdownZip } from './conversation-zip-export';
describe('createConversationMarkdownZip', () => {
it('should byte-limit multibyte entry names and keep duplicate names unique', async () => {
@@ -72,4 +72,14 @@ describe('createConversationMarkdownZip', () => {
expect((await readdir(os.tmpdir())).filter((name) => name.startsWith('cline_'))).toEqual([]);
});
+
+ it('should retain temporary cleanup failures for reporting without throwing them', async () => {
+ const failures = await cleanupConversationZipArtifacts('/tmp/workspace', '/tmp/archive.zip', async (target) => {
+ if (target === '/tmp/archive.zip') {
+ throw new Error('archive cleanup failed');
+ }
+ });
+
+ expect(failures).toEqual([{ error: 'archive cleanup failed', path: '/tmp/archive.zip' }]);
+ });
});
diff --git a/src/lib/conversation-zip-export.ts b/src/lib/conversation-zip-export.ts
index c439d99..0e1554f 100644
--- a/src/lib/conversation-zip-export.ts
+++ b/src/lib/conversation-zip-export.ts
@@ -46,6 +46,33 @@ export type ConversationMarkdownZip = {
mimeType: 'application/zip';
};
+export type ConversationZipCleanupFailure = {
+ error: string;
+ path: string;
+};
+
+export const cleanupConversationZipArtifacts = async (
+ workspaceDir: string,
+ zipPath: string,
+ remove: typeof rm = rm,
+): Promise => {
+ const results = await Promise.allSettled([
+ remove(workspaceDir, { force: true, recursive: true }),
+ remove(zipPath, { force: true }),
+ ]);
+ const paths = [workspaceDir, zipPath];
+ return results.flatMap((result, index) =>
+ result.status === 'rejected'
+ ? [
+ {
+ error: result.reason instanceof Error ? result.reason.message : String(result.reason),
+ path: paths[index]!,
+ },
+ ]
+ : [],
+ );
+};
+
const toSafeFileBaseName = (value: string | null, fallback: string) => {
const sanitized = sanitizeExportFileName(value?.trim() || '') || sanitizeExportFileName(fallback) || 'conversation';
return truncateUtf8(sanitized, EXPORT_BASE_NAME_BYTE_LIMIT) || 'conversation';
@@ -80,6 +107,9 @@ export const createConversationMarkdownZip = async ({
mimeType: 'application/zip',
};
} finally {
- await Promise.all([rm(workspaceDir, { force: true, recursive: true }), rm(zipPath, { force: true })]);
+ const cleanupFailures = await cleanupConversationZipArtifacts(workspaceDir, zipPath);
+ for (const failure of cleanupFailures) {
+ console.warn('[spiracha:export] temporary cleanup failed', failure);
+ }
}
};
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index 8f43e98..f1e7b93 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -4,6 +4,7 @@ import { mkdir, mkdtemp, rm, utimes } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
+ CURSOR_MAX_HISTORY_ENTRIES_BYTES,
CURSOR_READONLY_DB_OPEN_FLAGS,
CURSOR_SQLITE_RETRY_DELAYS_MS,
decodeCursorUri,
@@ -116,6 +117,110 @@ describe('cursor-db workspace discovery', () => {
expect(group?.threadCount).toBe(1);
});
+ it('should count bubbles for composer ids containing colons', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, {
+ buckets: [
+ {
+ bucketId: 'colon-bucket',
+ composerIds: ['thread:colon'],
+ folder: 'file:///Users/test/workspace/colon',
+ threadsInComposerData: true,
+ },
+ ],
+ headerLinks: [{ bucketId: 'colon-bucket', composerId: 'thread:colon' }],
+ threads: [{ bubbles: [{ bubbleId: 'b1', text: 'colon-safe', type: 1 }], composerId: 'thread:colon' }],
+ });
+
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const threads = await listCursorThreadsForGroup(group!, userDir, { includeTranscriptDirs: false });
+
+ expect(threads).toEqual([expect.objectContaining({ bubbleCount: 1, composerId: 'thread:colon' })]);
+ });
+
+ it('should keep a short composer id separate from a colon-bearing child id', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, {
+ buckets: [
+ {
+ bucketId: 'prefix-bucket',
+ composerIds: ['thread', 'thread:child'],
+ folder: 'file:///Users/test/workspace/prefix',
+ threadsInComposerData: true,
+ },
+ ],
+ headerLinks: [
+ { bucketId: 'prefix-bucket', composerId: 'thread' },
+ { bucketId: 'prefix-bucket', composerId: 'thread:child' },
+ ],
+ threads: [
+ { bubbles: [{ bubbleId: 'parent-bubble', text: 'parent', type: 1 }], composerId: 'thread' },
+ {
+ bubbles: [{ bubbleId: 'child-bubble', text: 'child', type: 1 }],
+ composerId: 'thread:child',
+ },
+ ],
+ });
+
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const threads = await listCursorThreadsForGroup(group!, userDir, { includeTranscriptDirs: false });
+
+ expect(new Map(threads.map((thread) => [thread.composerId, thread.bubbleCount]))).toEqual(
+ new Map([
+ ['thread', 1],
+ ['thread:child', 1],
+ ]),
+ );
+ });
+
+ it('should diagnose oversized history entries before parsing them', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, {
+ buckets: [],
+ historyEntries: [{ resource: 'file:///Users/test/workspace/oversized/src/index.ts' }],
+ threads: [],
+ });
+ const entriesPath = path.join(userDir, 'History', 'history-0', 'entries.json');
+ await Bun.write(
+ entriesPath,
+ JSON.stringify({
+ padding: 'x'.repeat(CURSOR_MAX_HISTORY_ENTRIES_BYTES),
+ resource: 'file:///Users/test/workspace/oversized/src/index.ts',
+ }),
+ );
+
+ const warnings: unknown[][] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args);
+ try {
+ await listCursorWorkspaceGroups(userDir);
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ expect(warnings.some((args) => String(args[0]).includes('history_entries_oversized'))).toBe(true);
+ });
+
+ it('should tolerate a valid but malformed history entries shape', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, {
+ buckets: [],
+ historyEntries: [{ resource: 'file:///Users/test/workspace/malformed-shape/src/index.ts' }],
+ threads: [],
+ });
+ await Bun.write(
+ path.join(userDir, 'History', 'history-0', 'entries.json'),
+ JSON.stringify({
+ entries: { timestamp: 'not-a-number' },
+ resource: 'file:///Users/test/workspace/malformed-shape',
+ }),
+ );
+
+ await expect(listCursorWorkspaceGroups(userDir)).resolves.toEqual([
+ expect.objectContaining({ key: 'folder:/Users/test/workspace/malformed-shape' }),
+ ]);
+ });
+
it('should group modern Cursor orchestrators and subagents under their multi-root workspace', async () => {
const userDir = await makeUserDir();
const workspaceFilePath = path.join(userDir, 'multi-root.code-workspace');
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index c0e5c2d..0efa5d6 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -20,7 +20,7 @@ import {
getCursorWorkspaceStorageDir,
resolveCursorUserDir,
} from './cursor-exporter-types';
-import { buildCursorBubbleKeyLikePattern, isSafeCursorComposerId } from './cursor-id';
+import { getCursorBubbleKeyRange, isCursorBubbleKeyForComposer, isSafeCursorComposerId } from './cursor-id';
import { asNumber, asObject, asString, type JsonValue, pathExists, toFileUri } from './shared';
import { runWithSqliteRetry } from './sqlite-retry';
@@ -41,6 +41,7 @@ type ComposerHeaderRow = {
export const CURSOR_READONLY_DB_OPEN_FLAGS = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
export const CURSOR_SQLITE_RETRY_DELAYS_MS = [40, 120, 250] as const;
+export const CURSOR_MAX_HISTORY_ENTRIES_BYTES = 8 * 1024 * 1024;
// Cursor databases are WAL-mode. A plain read-only open fails once Cursor cleanly shuts down and
// removes the -wal/-shm sidecars, and the failure only surfaces at query time (so a try/catch around
@@ -557,12 +558,15 @@ export const findCursorWorkspaceGroups = (groups: CursorWorkspaceGroup[], query:
};
const countBubbles = (db: Database, composerId: string): { count: number; bytes: number } => {
- const row = db
- .query(
- `SELECT COUNT(*) AS count, COALESCE(SUM(length(value)), 0) AS bytes FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`,
- )
- .get(buildCursorBubbleKeyLikePattern(composerId)) as { count: number; bytes: number };
- return { bytes: row.bytes, count: row.count };
+ const range = getCursorBubbleKeyRange(composerId);
+ const rows = db
+ .query('SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
+ .all(range.start, range.end) as Array<{ key: string; value: string }>;
+ const exactRows = rows.filter((row) => isCursorBubbleKeyForComposer(row.key, row.value, composerId));
+ return {
+ bytes: exactRows.reduce((sum, row) => sum + row.value.length, 0),
+ count: exactRows.length,
+ };
};
export const findCursorTranscriptDirsForComposerIds = async (
@@ -769,6 +773,58 @@ const inferFolderFromBlob = (blob: string): string | null => {
return matches ? inferFolderFromPaths(matches) : null;
};
+const readCursorHistoryActivityEntry = async (
+ entriesPath: string,
+): Promise<{ folder: string; lastActiveMs: number } | null> => {
+ let entriesStat: Awaited>;
+ try {
+ entriesStat = await stat(entriesPath);
+ } catch (error) {
+ if ((error as { code?: unknown }).code !== 'ENOENT' && (error as { code?: unknown }).code !== 'ENOTDIR') {
+ warnCursorDataIssue('history_entries_stat_failed', {
+ entriesPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ return null;
+ }
+ if (entriesStat.size > CURSOR_MAX_HISTORY_ENTRIES_BYTES) {
+ warnCursorDataIssue('history_entries_oversized', {
+ entriesPath,
+ maxBytes: CURSOR_MAX_HISTORY_ENTRIES_BYTES,
+ sizeBytes: entriesStat.size,
+ });
+ return null;
+ }
+
+ let data: { resource?: unknown; entries?: unknown };
+ try {
+ data = (await Bun.file(entriesPath).json()) as { resource?: unknown; entries?: unknown };
+ } catch (error) {
+ warnCursorDataIssue('invalid_history_entries_json', {
+ entriesPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return null;
+ }
+
+ const resource = typeof data.resource === 'string' ? data.resource : '';
+ const folder = containerRootFromPath(resource);
+ if (!folder || isNoisePath(folder)) {
+ return null;
+ }
+
+ const entries = Array.isArray(data.entries) ? data.entries : [];
+ const timestamps = entries.map((item) => {
+ if (!item || typeof item !== 'object') {
+ return 0;
+ }
+ const timestamp = (item as { timestamp?: unknown }).timestamp;
+ return typeof timestamp === 'number' && Number.isFinite(timestamp) ? timestamp : 0;
+ });
+ return { folder, lastActiveMs: Math.max(0, ...timestamps) };
+};
+
const readCursorFileHistoryProjectActivity = async (userDir: string): Promise> => {
const historyDir = path.join(userDir, 'History');
let entries: Array<{ isDirectory: () => boolean; name: string }> = [];
@@ -784,54 +840,44 @@ const readCursorFileHistoryProjectActivity = async (userDir: string): Promise };
- try {
- data = (await Bun.file(entriesPath).json()) as {
- resource?: string;
- entries?: Array<{ timestamp?: number }>;
- };
- } catch {
- continue;
+ const record = await readCursorHistoryActivityEntry(path.join(historyDir, entry.name, 'entries.json'));
+ if (record) {
+ activity.set(record.folder, Math.max(activity.get(record.folder) ?? 0, record.lastActiveMs));
}
+ }
- const resource = typeof data.resource === 'string' ? data.resource : '';
- const folder = containerRootFromPath(resource);
- if (!folder || isNoisePath(folder)) {
- continue;
- }
+ return activity;
+};
- const lastActiveMs = Math.max(0, ...(data.entries ?? []).map((item) => item.timestamp ?? 0));
- activity.set(folder, Math.max(activity.get(folder) ?? 0, lastActiveMs));
+const extractCursorBubblePaths = (value: string): string[] => {
+ let bubble: Record;
+ try {
+ bubble = JSON.parse(value) as Record;
+ } catch {
+ return [];
}
- return activity;
+ const tool = asObject(bubble.toolFormerData ?? null);
+ if (!tool) {
+ return [];
+ }
+
+ const blob = `${asString(tool.rawArgs ?? null) ?? ''} ${asString(tool.params ?? null) ?? ''}`;
+ return blob.match(ABS_PATH_RE) ?? [];
};
const inferFolderFromBubbles = (db: Database, composerId: string): string | null => {
+ const range = getCursorBubbleKeyRange(composerId);
const rows = db
- .query(`SELECT value FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' LIMIT 80`)
- .all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ value: string }>;
+ .query('SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ? LIMIT 80')
+ .all(range.start, range.end) as Array<{ key: string; value: string }>;
const paths: string[] = [];
- for (const { value } of rows) {
- let bubble: Record;
- try {
- bubble = JSON.parse(value) as Record;
- } catch {
- continue;
- }
-
- const tool = asObject(bubble.toolFormerData ?? null);
- if (!tool) {
+ for (const { key, value } of rows) {
+ if (!isCursorBubbleKeyForComposer(key, value, composerId)) {
continue;
}
-
- const blob = `${asString(tool.rawArgs ?? null) ?? ''} ${asString(tool.params ?? null) ?? ''}`;
- const matches = blob.match(ABS_PATH_RE);
- if (matches) {
- paths.push(...matches);
- }
+ paths.push(...extractCursorBubblePaths(value));
if (paths.length > 200) {
break;
@@ -929,30 +975,42 @@ const parseGlobalHead = (value: string | null): GlobalHead => {
};
};
-const readBubbleStats = (db: Database, composerIds?: Iterable): Map => {
- const ids = composerIds ? [...composerIds] : null;
- if (ids?.length === 0) {
+const readBubbleStats = (db: Database, composerIds: Iterable): Map => {
+ const ids = [...new Set(composerIds)];
+ if (ids.length === 0) {
return new Map();
}
- // Keys are `bubbleId::`; composer ids contain no colon, so slice up to the
- // next ':' rather than assuming a fixed UUID length (keeps tests and any id format working).
- const query =
- ids === null
- ? `SELECT substr(key, 10, instr(substr(key, 10), ':') - 1) AS id,
- COUNT(*) AS count,
- COALESCE(SUM(length(value)), 0) AS bytes
- FROM cursorDiskKV WHERE key GLOB 'bubbleId:*:*' GROUP BY id`
- : `SELECT substr(key, 10, instr(substr(key, 10), ':') - 1) AS id,
- COUNT(*) AS count,
- COALESCE(SUM(length(value)), 0) AS bytes
- FROM cursorDiskKV
- WHERE key GLOB 'bubbleId:*:*'
- AND substr(key, 10, instr(substr(key, 10), ':') - 1) IN (${ids.map(() => '?').join(',')})
- GROUP BY id`;
- const rows = db.query(query).all(...(ids ?? [])) as Array<{ id: string; count: number; bytes: number }>;
+ const stats = new Map();
+ for (let index = 0; index < ids.length; index += 200) {
+ const chunk = ids.slice(index, index + 200);
+ const query = chunk
+ .map(() => 'SELECT ? AS composerId, key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
+ .join(' UNION ALL ');
+ const parameters = chunk.flatMap((composerId) => {
+ const range = getCursorBubbleKeyRange(composerId);
+ return [composerId, range.start, range.end];
+ });
+ const rows = db.query(query).all(...parameters) as Array<{
+ composerId: string;
+ key: string;
+ value: string;
+ }>;
+ for (const row of rows) {
+ if (!isCursorBubbleKeyForComposer(row.key, row.value, row.composerId)) {
+ continue;
+ }
+
+ const composerId = row.composerId;
+ const current = stats.get(composerId) ?? { bytes: 0, count: 0 };
+ stats.set(composerId, {
+ bytes: current.bytes + row.value.length,
+ count: current.count + 1,
+ });
+ }
+ }
- return new Map(rows.map((row) => [row.id, { bytes: row.bytes, count: row.count }]));
+ return stats;
};
const readHeaderInfo = (globalDbPath: string, strict = false): Map => {
@@ -1234,11 +1292,11 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
const headerInfo = readHeaderInfo(globalDbPath, options.strict);
const bucketComposerIds =
options.updatedAfterMs === undefined ? collectBucketComposerIds(buckets, options.strict) : new Map();
- const stats = readBubbleStats(db, options.updatedAfterMs === undefined ? undefined : heads.keys());
const universe =
options.updatedAfterMs === undefined
? new Set([...heads.keys(), ...headerInfo.keys(), ...bucketComposerIds.keys()])
: new Set(heads.keys());
+ const stats = readBubbleStats(db, universe);
const resolved: ResolvedThread[] = [];
for (const composerId of universe) {
@@ -1857,8 +1915,11 @@ export const readCursorThreadTranscriptWithAgentFiles = async (
const readAllBubbleIds = (db: Database, composerId: string): string[] => {
const prefix = `bubbleId:${composerId}:`;
+ const range = getCursorBubbleKeyRange(composerId);
const rows = db
- .query(`SELECT key FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\' ORDER BY key ASC`)
- .all(buildCursorBubbleKeyLikePattern(composerId)) as Array<{ key: string }>;
- return rows.map((row) => row.key.slice(prefix.length));
+ .query('SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ? ORDER BY key ASC')
+ .all(range.start, range.end) as Array<{ key: string; value: string }>;
+ return rows
+ .filter((row) => isCursorBubbleKeyForComposer(row.key, row.value, composerId))
+ .map((row) => row.key.slice(prefix.length));
};
diff --git a/src/lib/cursor-exporter-types.ts b/src/lib/cursor-exporter-types.ts
index c34df74..e1891a5 100644
--- a/src/lib/cursor-exporter-types.ts
+++ b/src/lib/cursor-exporter-types.ts
@@ -164,11 +164,41 @@ export type CursorRecoverResult = {
threads: Array<{ composerId: string; name: string; bubbleCount: number }>;
};
+export type CursorCleanupFailure = {
+ error: string;
+ path?: string;
+ phase: 'transcript_directory' | 'workspace_buckets' | 'workspace_history';
+};
+
+export type CursorFilesystemCleanupResult = {
+ cleanupFailures: CursorCleanupFailure[];
+ removedPaths: string[];
+};
+
+export type CursorCleanupRetryPlan = {
+ bucketPaths: string[];
+ composerIds: string[];
+ folders: string[];
+ historyPaths: string[];
+ transcriptDirs: string[];
+ workspaceKey: string;
+};
+
+export type CursorCleanupRetryTarget = {
+ token: string;
+};
+
export type CursorPruneResult = {
bubblesDeleted: number;
+ cleanupFailures: CursorCleanupFailure[];
composerDataDeleted: number;
headersRemoved: number;
workspaceBucketsUpdated: number;
transcriptDirsRemoved: number;
+ transcriptDirsRemovedPaths: string[];
composerIds: string[];
+ retryPlan?: CursorCleanupRetryPlan;
+ retryTarget?: CursorCleanupRetryTarget;
+ workspaceBucketsRemovedPaths?: string[];
+ workspaceHistoryRemovedPaths?: string[];
};
diff --git a/src/lib/cursor-id.test.ts b/src/lib/cursor-id.test.ts
new file mode 100644
index 0000000..0fa03cf
--- /dev/null
+++ b/src/lib/cursor-id.test.ts
@@ -0,0 +1,11 @@
+import { describe, expect, it } from 'bun:test';
+import { getCursorBubbleKeyRange } from './cursor-id';
+
+describe('Cursor bubble key ranges', () => {
+ it('should bound a composer prefix before the semicolon terminator', () => {
+ expect(getCursorBubbleKeyRange('thread')).toEqual({
+ end: 'bubbleId:thread;',
+ start: 'bubbleId:thread:',
+ });
+ });
+});
diff --git a/src/lib/cursor-id.ts b/src/lib/cursor-id.ts
index 486659d..4df3e81 100644
--- a/src/lib/cursor-id.ts
+++ b/src/lib/cursor-id.ts
@@ -10,14 +10,32 @@ export const assertSafeCursorComposerId = (value: string): void => {
}
};
-const escapeSqlLikeValue = (value: string): string => value.replace(/[\\%_]/gu, '\\$&');
+// The semicolon terminator is the next code point after the colon, so this half-open range
+// requires BINARY collation on the key column to remain an indexed prefix scan.
+export const getCursorBubbleKeyRange = (composerId: string): { end: string; start: string } => ({
+ end: `bubbleId:${composerId};`,
+ start: `bubbleId:${composerId}:`,
+});
-export const buildCursorBubbleKeyLikePattern = (composerId: string): string => {
- return `bubbleId:${escapeSqlLikeValue(composerId)}:%`;
+const readStoredCursorBubbleId = (value: string): string | null => {
+ try {
+ const bubbleId = (JSON.parse(value) as { bubbleId?: unknown } | null)?.bubbleId;
+ return typeof bubbleId === 'string' ? bubbleId : null;
+ } catch {
+ return null;
+ }
};
-export const getCursorBubbleKeyRange = (composerId: string): { end: string; start: string } => {
- const start = `bubbleId:${composerId}:`;
- // ';' is the next code point after ':'; this half-open range depends on BINARY key collation.
- return { end: `bubbleId:${composerId};`, start };
+// Composer ids may contain ':', so a lexical half-open range cannot identify the key boundary by itself.
+// Valid bubble payloads carry their id; malformed legacy payloads are accepted only when the suffix has no
+// additional ':' so a short composer id cannot consume a colon-bearing composer id's rows.
+export const isCursorBubbleKeyForComposer = (key: string, value: string, composerId: string): boolean => {
+ const prefix = `bubbleId:${composerId}:`;
+ if (!key.startsWith(prefix)) {
+ return false;
+ }
+
+ const suffix = key.slice(prefix.length);
+ const storedBubbleId = readStoredCursorBubbleId(value);
+ return storedBubbleId === null ? !suffix.includes(':') : storedBubbleId === suffix;
};
diff --git a/src/lib/cursor-recovery.test.ts b/src/lib/cursor-recovery.test.ts
index 789b905..0fbf520 100644
--- a/src/lib/cursor-recovery.test.ts
+++ b/src/lib/cursor-recovery.test.ts
@@ -1,9 +1,14 @@
import { Database } from 'bun:sqlite';
import { afterEach, describe, expect, it } from 'bun:test';
-import { mkdir, mkdtemp, rm, symlink, utimes } from 'node:fs/promises';
+import { mkdir, mkdtemp, readdir, rename, rm, symlink, utimes } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
-import { CURSOR_SQLITE_RETRY_DELAYS_MS, listCursorWorkspaceGroups, withCursorWriteTransaction } from './cursor-db';
+import {
+ CURSOR_MAX_HISTORY_ENTRIES_BYTES,
+ CURSOR_SQLITE_RETRY_DELAYS_MS,
+ listCursorWorkspaceGroups,
+ withCursorWriteTransaction,
+} from './cursor-db';
import { getCursorGlobalDbPath } from './cursor-exporter-types';
import {
collectCursorThreadsForDeletion,
@@ -11,6 +16,7 @@ import {
deleteCursorWorkspaceHistory,
pruneCursorThreads,
recoverCursorWorkspaceGroup,
+ retryCursorWorkspaceCleanup,
} from './cursor-recovery';
import { type CursorFixtureSpec, createCursorFixture, holdCursorWriteLock } from './cursor-test-helpers';
@@ -212,6 +218,38 @@ describe('pruneCursorThreads', () => {
expect(await Bun.file(path.join(externalTranscriptDir, 'sentinel.txt')).exists()).toBe(true);
});
+ it('should reject a transcript symlink to another composer directory inside Cursor projects', async () => {
+ const userDir = await makeUserDir('cursor-prune-internal-symlink-');
+ const transcriptRoot = path.join(userDir, 'projects', 'demo', 'agent-transcripts');
+ const targetDir = path.join(transcriptRoot, 'thread-2');
+ const linkedDir = path.join(transcriptRoot, 'thread-1');
+ await mkdir(targetDir, { recursive: true });
+ await Bun.write(path.join(targetDir, 'sentinel.txt'), 'keep');
+ await symlink(targetDir, linkedDir);
+
+ const thread = {
+ bubbleBytes: 0,
+ bubbleCount: 0,
+ bucketId: null,
+ composerId: 'thread-1',
+ createdAtMs: null,
+ lastUpdatedAtMs: null,
+ mode: null,
+ model: null,
+ name: 'Unsafe thread',
+ parentComposerId: null,
+ reasoningEffort: null,
+ transcriptDirs: [linkedDir],
+ workspaceKey: '',
+ workspaceLabel: '',
+ };
+
+ await expect(pruneCursorThreads([thread], true, userDir)).rejects.toThrow(
+ `Unsafe Cursor transcript directory: ${linkedDir}`,
+ );
+ expect(await Bun.file(path.join(targetDir, 'sentinel.txt')).exists()).toBe(true);
+ });
+
it('should preview deletion impact without applying', async () => {
const userDir = await makeUserDir('cursor-prune-dry-');
await createCursorFixture(userDir, recoverySpec());
@@ -336,7 +374,10 @@ describe('pruneCursorThreads', () => {
});
const [group] = await listCursorWorkspaceGroups(userDir);
- await expect(deleteCursorWorkspaceBuckets(group!, userDir)).resolves.toBe(2);
+ await expect(deleteCursorWorkspaceBuckets(group!, userDir)).resolves.toEqual({
+ cleanupFailures: [],
+ removedPaths: group!.buckets.map((bucket) => path.dirname(bucket.workspaceJsonPath)),
+ });
for (const bucket of group!.buckets) {
expect(await Bun.file(bucket.workspaceJsonPath).exists()).toBe(false);
@@ -344,6 +385,24 @@ describe('pruneCursorThreads', () => {
expect(await listCursorWorkspaceGroups(userDir)).toEqual([]);
});
+ it('should reject a bucket symlink even when its target is inside workspace storage', async () => {
+ const userDir = await makeUserDir('cursor-delete-bucket-symlink-');
+ await createCursorFixture(userDir, {
+ buckets: [{ bucketId: 'bucket-link', folder: 'file:///Users/test/workspace/link' }],
+ threads: [],
+ });
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const bucketRoot = path.join(userDir, 'workspaceStorage', 'bucket-link');
+ const targetRoot = path.join(userDir, 'workspaceStorage', 'bucket-target');
+ await rename(bucketRoot, targetRoot);
+ await symlink(targetRoot, bucketRoot);
+
+ await expect(deleteCursorWorkspaceBuckets(group!, userDir)).rejects.toThrow(
+ 'Unsafe Cursor workspace bucket directory',
+ );
+ expect(await Bun.file(path.join(targetRoot, 'workspace.json')).exists()).toBe(true);
+ });
+
it('should remove matching file-history entries without deleting a sibling workspace', async () => {
const userDir = await makeUserDir('cursor-delete-file-history-');
await createCursorFixture(userDir, {
@@ -359,13 +418,181 @@ describe('pruneCursorThreads', () => {
const group = groups.find((candidate) => candidate.key === 'folder:/Users/test/workspace/file-history');
expect(group).toMatchObject({ buckets: [], threadCount: 0 });
- await expect(deleteCursorWorkspaceHistory(group!, userDir)).resolves.toBe(2);
+ await expect(deleteCursorWorkspaceHistory(group!, userDir)).resolves.toEqual({
+ cleanupFailures: [],
+ removedPaths: expect.arrayContaining([
+ path.join(userDir, 'History', 'history-0'),
+ path.join(userDir, 'History', 'history-2'),
+ ]),
+ });
expect(await Bun.file(path.join(userDir, 'History', 'history-0', 'entries.json')).exists()).toBe(false);
expect(await Bun.file(path.join(userDir, 'History', 'history-1', 'entries.json')).exists()).toBe(true);
expect(await Bun.file(path.join(userDir, 'History', 'history-2', 'entries.json')).exists()).toBe(false);
});
+ it('should diagnose corrupt history entries and continue deleting valid siblings', async () => {
+ const userDir = await makeUserDir('cursor-delete-corrupt-history-');
+ await createCursorFixture(userDir, {
+ buckets: [],
+ historyEntries: [
+ { resource: 'file:///Users/test/workspace/corrupt/src/index.ts' },
+ { resource: 'file:///Users/test/workspace/corrupt/src/other.ts' },
+ ],
+ threads: [],
+ });
+ await Bun.write(path.join(userDir, 'History', 'history-0', 'entries.json'), '{not-json');
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const warnings: unknown[][] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args);
+ let result: Awaited>;
+ try {
+ result = await deleteCursorWorkspaceHistory(group!, userDir);
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ expect(await Bun.file(path.join(userDir, 'History', 'history-0', 'entries.json')).exists()).toBe(true);
+ expect(warnings.some((args) => String(args[0]).includes('invalid_history_entries_json'))).toBe(true);
+ expect(result.removedPaths).toEqual([path.join(userDir, 'History', 'history-1')]);
+ expect(result.cleanupFailures).toEqual([
+ expect.objectContaining({
+ path: path.join(userDir, 'History', 'history-0'),
+ phase: 'workspace_history',
+ }),
+ ]);
+ });
+
+ it('should skip oversized history entries and report partial cleanup', async () => {
+ const userDir = await makeUserDir('cursor-delete-oversized-history-');
+ await createCursorFixture(userDir, {
+ buckets: [],
+ historyEntries: [
+ { resource: 'file:///Users/test/workspace/oversized/src/index.ts' },
+ { resource: 'file:///Users/test/workspace/oversized/src/other.ts' },
+ ],
+ threads: [],
+ });
+ await Bun.write(
+ path.join(userDir, 'History', 'history-0', 'entries.json'),
+ JSON.stringify({
+ padding: 'x'.repeat(CURSOR_MAX_HISTORY_ENTRIES_BYTES),
+ resource: 'file:///Users/test/workspace/oversized/src/index.ts',
+ }),
+ );
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const warnings: unknown[][] = [];
+ const originalWarn = console.warn;
+ console.warn = (...args: unknown[]) => warnings.push(args);
+ let result: Awaited>;
+ try {
+ result = await deleteCursorWorkspaceHistory(group!, userDir);
+ } finally {
+ console.warn = originalWarn;
+ }
+
+ expect(result.removedPaths).toEqual([path.join(userDir, 'History', 'history-1')]);
+ expect(await Bun.file(path.join(userDir, 'History', 'history-0', 'entries.json')).exists()).toBe(true);
+ expect(warnings.some((args) => String(args[0]).includes('history_entries_oversized'))).toBe(true);
+ expect(result.cleanupFailures).toEqual([
+ expect.objectContaining({
+ path: path.join(userDir, 'History', 'history-0'),
+ phase: 'workspace_history',
+ }),
+ ]);
+ });
+
+ it('should create an atomic prune backup containing legacy global headers', async () => {
+ const userDir = await makeUserDir('cursor-prune-backup-');
+ await createCursorFixture(userDir, recoverySpec());
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ const threads = await collectCursorThreadsForDeletion(['thread-1'], userDir);
+
+ await pruneCursorThreads(threads, true, userDir);
+
+ const backupFiles = (await readdir(path.join(userDir, 'globalStorage'))).filter(
+ (entry) => entry.includes('.prunedThreads.') && entry.endsWith('.json'),
+ );
+ expect(backupFiles).toHaveLength(1);
+ expect((await readdir(path.join(userDir, 'globalStorage'))).some((entry) => entry.includes('.tmp-'))).toBe(
+ false,
+ );
+ const backup = (await Bun.file(path.join(userDir, 'globalStorage', backupFiles[0]!)).json()) as {
+ headers?: { allComposers?: Array<{ composerId?: string }> };
+ };
+ expect(backup.headers?.allComposers?.map((header) => header.composerId)).toContain('thread-1');
+ expect(group).toBeDefined();
+ });
+
+ it('should count unique transcript directories that were actually removed', async () => {
+ const userDir = await makeUserDir('cursor-prune-partial-cleanup-');
+ await createCursorFixture(userDir, recoverySpec());
+ const transcriptDir = path.join(userDir, 'projects', 'demo', 'agent-transcripts', 'thread-1');
+ await mkdir(transcriptDir, { recursive: true });
+ const thread = (await collectCursorThreadsForDeletion(['thread-1'], userDir))[0]!;
+ const result = await pruneCursorThreads(
+ [{ ...thread, transcriptDirs: [transcriptDir, transcriptDir] }],
+ true,
+ userDir,
+ );
+
+ expect(result.transcriptDirsRemoved).toBe(1);
+ expect(result.transcriptDirsRemovedPaths).toEqual([transcriptDir]);
+ });
+
+ it('should discover transcript directories when a deletion summary omits them', async () => {
+ const userDir = await makeUserDir('cursor-prune-discover-transcript-');
+ await createCursorFixture(userDir, recoverySpec());
+ const transcriptDir = path.join(userDir, 'projects', 'demo', 'agent-transcripts', 'thread-1');
+ await mkdir(transcriptDir, { recursive: true });
+ await Bun.write(path.join(transcriptDir, 'messages.jsonl'), '{}\n');
+ const thread = (await collectCursorThreadsForDeletion(['thread-1'], userDir))[0]!;
+
+ const result = await pruneCursorThreads([{ ...thread, transcriptDirs: [] }], true, userDir);
+
+ expect(result.transcriptDirsRemoved).toBe(1);
+ expect(result.transcriptDirsRemovedPaths).toEqual([transcriptDir]);
+ expect(await Bun.file(path.join(transcriptDir, 'messages.jsonl')).exists()).toBe(false);
+ });
+
+ it('should retry a failed transcript cleanup from its target after workspace discovery is gone', async () => {
+ const userDir = await makeUserDir('cursor-retry-transcript-cleanup-');
+ await createCursorFixture(userDir, recoverySpec());
+ const transcriptDir = path.join(userDir, 'projects', 'demo', 'agent-transcripts', 'thread-1');
+ await mkdir(transcriptDir, { recursive: true });
+ await Bun.write(path.join(transcriptDir, 'messages.jsonl'), '{}\n');
+ await rm(path.join(userDir, 'workspaceStorage'), { force: true, recursive: true });
+
+ const result = await retryCursorWorkspaceCleanup(
+ {
+ bucketPaths: [],
+ composerIds: ['thread-1'],
+ folders: ['/Users/test/workspace/demo'],
+ historyPaths: [],
+ transcriptDirs: [transcriptDir],
+ workspaceKey: 'folder:/Users/test/workspace/demo',
+ },
+ userDir,
+ );
+
+ expect(result.cleanupFailures).toEqual([]);
+ expect(result.transcriptDirsRemovedPaths).toEqual([transcriptDir]);
+ expect(await Bun.file(path.join(transcriptDir, 'messages.jsonl')).exists()).toBe(false);
+ });
+
+ it('should treat an already-missing workspace storage directory as an idempotent cleanup', async () => {
+ const userDir = await makeUserDir('cursor-delete-missing-storage-');
+ await createCursorFixture(userDir, recoverySpec());
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ await rm(path.join(userDir, 'workspaceStorage'), { force: true, recursive: true });
+
+ await expect(deleteCursorWorkspaceBuckets(group!, userDir)).resolves.toEqual({
+ cleanupFailures: [],
+ removedPaths: [],
+ });
+ });
+
it('should restore earlier bucket updates when a later bucket mutation fails', async () => {
const userDir = await makeUserDir('cursor-delete-bucket-rollback-');
const spec = recoverySpec();
@@ -511,6 +738,59 @@ describe('pruneCursorThreads', () => {
}
});
+ it('should count, back up, and delete only the requested composer when ids share a colon prefix', async () => {
+ const userDir = await makeUserDir('cursor-delete-prefix-');
+ await createCursorFixture(userDir, {
+ buckets: [
+ {
+ bucketId: 'prefix-bucket',
+ composerIds: ['thread', 'thread:child'],
+ folder: 'file:///Users/test/workspace/prefix',
+ threadsInComposerData: true,
+ },
+ ],
+ headerLinks: [
+ { bucketId: 'prefix-bucket', composerId: 'thread' },
+ { bucketId: 'prefix-bucket', composerId: 'thread:child' },
+ ],
+ threads: [
+ { bubbles: [{ bubbleId: 'parent-bubble', text: 'parent', type: 1 }], composerId: 'thread' },
+ {
+ bubbles: [{ bubbleId: 'child-bubble', text: 'child', type: 1 }],
+ composerId: 'thread:child',
+ },
+ ],
+ });
+
+ const deletable = await collectCursorThreadsForDeletion(['thread'], userDir);
+ expect(deletable[0]?.bubbleCount).toBe(1);
+
+ const result = await pruneCursorThreads(deletable, true, userDir);
+ expect(result.bubblesDeleted).toBe(1);
+
+ const backupFiles = (await readdir(path.join(userDir, 'globalStorage'))).filter(
+ (entry) => entry.includes('.prunedThreads.') && entry.endsWith('.json'),
+ );
+ const backup = (await Bun.file(path.join(userDir, 'globalStorage', backupFiles[0]!)).json()) as {
+ threads?: Array<{ composerId: string; bubbles: Array<{ key: string }> }>;
+ };
+ expect(
+ backup.threads?.map((thread) => ({
+ bubbles: thread.bubbles.map((bubble) => bubble.key),
+ composerId: thread.composerId,
+ })),
+ ).toEqual([{ bubbles: ['bubbleId:thread:parent-bubble'], composerId: 'thread' }]);
+
+ const db = new Database(getCursorGlobalDbPath(userDir), { readonly: true });
+ try {
+ expect(db.query("SELECT key FROM cursorDiskKV WHERE key LIKE 'bubbleId:%' ORDER BY key").all()).toEqual([
+ { key: 'bubbleId:thread:child:child-bubble' },
+ ]);
+ } finally {
+ db.close();
+ }
+ });
+
it('should roll back global thread deletion when a later global mutation fails', async () => {
const userDir = await makeUserDir('cursor-delete-transaction-');
await createCursorFixture(userDir, recoverySpec());
diff --git a/src/lib/cursor-recovery.ts b/src/lib/cursor-recovery.ts
index bb88f47..4af963f 100644
--- a/src/lib/cursor-recovery.ts
+++ b/src/lib/cursor-recovery.ts
@@ -1,9 +1,10 @@
import type { Database } from 'bun:sqlite';
import { randomUUID } from 'node:crypto';
-import { readdir, realpath, rm } from 'node:fs/promises';
+import { readdir, realpath, rename, rm, stat } from 'node:fs/promises';
import path from 'node:path';
import { mapWithConcurrency } from './concurrency';
import {
+ CURSOR_MAX_HISTORY_ENTRIES_BYTES,
decodeCursorUri,
findCursorTranscriptDirsForComposerIds,
invalidateCursorDiscoveryCache,
@@ -15,6 +16,8 @@ import {
import {
COMPOSER_DATA_KEY,
COMPOSER_HEADERS_KEY,
+ type CursorCleanupRetryPlan,
+ type CursorFilesystemCleanupResult,
type CursorPruneResult,
type CursorRecoverResult,
type CursorThreadSummary,
@@ -25,7 +28,7 @@ import {
getCursorWorkspaceStorageDir,
resolveCursorUserDir,
} from './cursor-exporter-types';
-import { assertSafeCursorComposerId, buildCursorBubbleKeyLikePattern, getCursorBubbleKeyRange } from './cursor-id';
+import { assertSafeCursorComposerId, getCursorBubbleKeyRange, isCursorBubbleKeyForComposer } from './cursor-id';
type ComposerEntry = {
composerId?: string;
@@ -60,6 +63,23 @@ const backupStamp = (): string => new Date().toISOString().replace(/[-:]/gu, '')
const CURSOR_BACKUP_RETENTION_COUNT = 5;
const CURSOR_SQLITE_BATCH_SIZE = 200;
+const getCursorCleanupErrorMessage = (error: unknown): string =>
+ error instanceof Error ? error.message : String(error);
+
+const isCursorSafetyError = (error: unknown): boolean =>
+ error instanceof Error && error.message.startsWith('Unsafe Cursor');
+
+const createCursorPruneResult = (composerIds: string[]): CursorPruneResult => ({
+ bubblesDeleted: 0,
+ cleanupFailures: [],
+ composerDataDeleted: 0,
+ composerIds,
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
+ workspaceBucketsUpdated: 0,
+});
+
const chunkValues = (values: T[], size: number): T[][] => {
const chunks: T[][] = [];
for (let index = 0; index < values.length; index += size) {
@@ -72,7 +92,13 @@ const writeRetainedCursorBackup = async (basePath: string, label: string, value:
const directory = path.dirname(basePath);
const filePrefix = `${path.basename(basePath)}.${label}.`;
const backupPath = `${basePath}.${label}.${backupStamp()}.${randomUUID()}.json`;
- await Bun.write(backupPath, JSON.stringify(value));
+ const temporaryPath = `${backupPath}.tmp-${randomUUID()}`;
+ try {
+ await Bun.write(temporaryPath, JSON.stringify(value));
+ await rename(temporaryPath, backupPath);
+ } finally {
+ await rm(temporaryPath, { force: true });
+ }
const backups = (await readdir(directory))
.filter((entry) => entry.startsWith(filePrefix) && entry.endsWith('.json'))
.sort((left, right) => right.localeCompare(left));
@@ -97,14 +123,17 @@ const backupPrunedThreads = async (globalDbPath: string, composerIds: string[]):
const dump = withCursorReadonlyDb(globalDbPath, (db) => {
const hasModernHeaders = hasComposerHeadersTable(db);
const bubblesByComposerId = readBubblesForComposerIds(db, composerIds);
- return composerIds.map((composerId) => ({
- bubbles: bubblesByComposerId.get(composerId) ?? [],
- composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
- composerId,
- modernComposerHeader: hasModernHeaders
- ? db.query('SELECT * FROM composerHeaders WHERE composerId = ?').get(composerId)
- : null,
- }));
+ return {
+ headers: readJsonItem<{ allComposers?: ComposerEntry[] }>(db, COMPOSER_HEADERS_KEY) ?? { allComposers: [] },
+ threads: composerIds.map((composerId) => ({
+ bubbles: bubblesByComposerId.get(composerId) ?? [],
+ composerData: readJsonItemFromKv(db, `composerData:${composerId}`),
+ composerId,
+ modernComposerHeader: hasModernHeaders
+ ? db.query('SELECT * FROM composerHeaders WHERE composerId = ?').get(composerId)
+ : null,
+ })),
+ };
});
return writeRetainedCursorBackup(globalDbPath, 'prunedThreads', dump);
};
@@ -118,17 +147,22 @@ const readBubblesForComposerIds = (
uniqueComposerIds.map((composerId) => [composerId, []]),
);
for (const chunk of chunkValues(uniqueComposerIds, CURSOR_SQLITE_BATCH_SIZE)) {
- const parameters: string[] = [];
const query = chunk
- .map((composerId) => {
- const range = getCursorBubbleKeyRange(composerId);
- parameters.push(composerId, range.start, range.end);
- return 'SELECT ? AS composerId, key, value FROM cursorDiskKV WHERE key >= ? AND key < ?';
- })
+ .map(() => 'SELECT ? AS composerId, key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
.join(' UNION ALL ');
- const rows = db.query(query).all(...parameters) as Array<{ composerId: string; key: string; value: string }>;
+ const parameters = chunk.flatMap((composerId) => {
+ const range = getCursorBubbleKeyRange(composerId);
+ return [composerId, range.start, range.end];
+ });
+ const rows = db.query(query).all(...parameters) as Array<{
+ composerId: string;
+ key: string;
+ value: string;
+ }>;
for (const row of rows) {
- bubblesByComposerId.get(row.composerId)?.push({ key: row.key, value: row.value });
+ if (isCursorBubbleKeyForComposer(row.key, row.value, row.composerId)) {
+ bubblesByComposerId.get(row.composerId)?.push({ key: row.key, value: row.value });
+ }
}
}
return bubblesByComposerId;
@@ -274,27 +308,33 @@ const relinkHeaders = (
};
const countBubbles = (db: Database, composerId: string): number => {
- const row = db
- .query(`SELECT COUNT(*) AS count FROM cursorDiskKV WHERE key LIKE ? ESCAPE '\\'`)
- .get(buildCursorBubbleKeyLikePattern(composerId)) as { count: number };
- return row.count;
+ const range = getCursorBubbleKeyRange(composerId);
+ const rows = db
+ .query('SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
+ .all(range.start, range.end) as Array<{ key: string; value: string }>;
+ return rows.filter((row) => isCursorBubbleKeyForComposer(row.key, row.value, composerId)).length;
};
const countBubblesForComposerIds = (db: Database, composerIds: string[]): Map => {
const uniqueComposerIds = [...new Set(composerIds)];
const counts = new Map();
for (const chunk of chunkValues(uniqueComposerIds, CURSOR_SQLITE_BATCH_SIZE)) {
- const parameters: string[] = [];
const query = chunk
- .map((composerId) => {
- const range = getCursorBubbleKeyRange(composerId);
- parameters.push(composerId, range.start, range.end);
- return 'SELECT ? AS composerId, COUNT(*) AS count FROM cursorDiskKV WHERE key >= ? AND key < ?';
- })
+ .map(() => 'SELECT ? AS composerId, key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
.join(' UNION ALL ');
- const rows = db.query(query).all(...parameters) as Array<{ composerId: string; count: number }>;
+ const parameters = chunk.flatMap((composerId) => {
+ const range = getCursorBubbleKeyRange(composerId);
+ return [composerId, range.start, range.end];
+ });
+ const rows = db.query(query).all(...parameters) as Array<{
+ composerId: string;
+ key: string;
+ value: string;
+ }>;
for (const row of rows) {
- counts.set(row.composerId, row.count);
+ if (isCursorBubbleKeyForComposer(row.key, row.value, row.composerId)) {
+ counts.set(row.composerId, (counts.get(row.composerId) ?? 0) + 1);
+ }
}
}
return counts;
@@ -443,9 +483,22 @@ const removeThreadFromBucket = (db: Database, composerIds: Set): boolean
const pruneGlobalThread = (db: Database, composerId: string): { bubbles: number; composerData: number } => {
const range = getCursorBubbleKeyRange(composerId);
- const bubbleResult = db.run('DELETE FROM cursorDiskKV WHERE key >= ? AND key < ?', [range.start, range.end]);
+ const rows = db
+ .query('SELECT key, value FROM cursorDiskKV WHERE key >= ? AND key < ?')
+ .all(range.start, range.end) as Array<{ key: string; value: string }>;
+ const bubbleKeys = rows
+ .filter((row) => isCursorBubbleKeyForComposer(row.key, row.value, composerId))
+ .map((row) => row.key);
+ let bubbles = 0;
+ for (const keys of chunkValues(bubbleKeys, CURSOR_SQLITE_BATCH_SIZE)) {
+ if (keys.length === 0) {
+ continue;
+ }
+ const placeholders = keys.map(() => '?').join(', ');
+ bubbles += db.run(`DELETE FROM cursorDiskKV WHERE key IN (${placeholders})`, keys).changes ?? 0;
+ }
const headResult = db.run('DELETE FROM cursorDiskKV WHERE key = ?', [`composerData:${composerId}`]);
- return { bubbles: bubbleResult.changes ?? 0, composerData: headResult.changes ?? 0 };
+ return { bubbles, composerData: headResult.changes ?? 0 };
};
const removeThreadHeaders = (db: Database, composerIds: Set): number => {
@@ -477,60 +530,88 @@ const removeThreadHeaders = (db: Database, composerIds: Set): number =>
return removedIds.size;
};
-export const pruneCursorThreads = async (
+const validateCursorTranscriptDirs = async (
threads: CursorThreadSummary[],
- apply: boolean,
- userDir = resolveCursorUserDir(),
-): Promise => {
+ userDir: string,
+): Promise> => {
const projectsDir = path.resolve(getCursorProjectsDir(userDir));
- const canonicalProjectsDir = await realpath(projectsDir).catch(() => projectsDir);
+ const canonicalProjectsDir = await realpath(projectsDir).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return projectsDir;
+ }
+ throw error;
+ });
+ const validatedTranscriptDirs = new Map();
for (const thread of threads) {
assertSafeCursorComposerId(thread.composerId);
for (const transcriptDir of thread.transcriptDirs) {
const resolvedDir = path.resolve(transcriptDir);
const resolvedProjectsDir = path.dirname(path.dirname(path.dirname(resolvedDir)));
- const canonicalDir = await realpath(resolvedDir).catch(() => resolvedDir);
+ const canonicalDir = await realpath(resolvedDir).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return resolvedDir;
+ }
+ throw error;
+ });
const canonicalRelativePath = path.relative(canonicalProjectsDir, canonicalDir);
+ const lexicalRelativePath = path.relative(projectsDir, resolvedDir);
+ const expectedCanonicalDir = path.resolve(canonicalProjectsDir, lexicalRelativePath);
if (
path.basename(resolvedDir) !== thread.composerId ||
path.basename(path.dirname(resolvedDir)) !== 'agent-transcripts' ||
resolvedProjectsDir !== projectsDir ||
+ canonicalDir !== expectedCanonicalDir ||
!canonicalRelativePath ||
canonicalRelativePath.startsWith(`..${path.sep}`) ||
path.isAbsolute(canonicalRelativePath)
) {
throw new Error(`Unsafe Cursor transcript directory: ${transcriptDir}`);
}
+ validatedTranscriptDirs.set(transcriptDir, canonicalDir);
}
}
+ return validatedTranscriptDirs;
+};
+
+export const pruneCursorThreads = async (
+ threads: CursorThreadSummary[],
+ apply: boolean,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ const discoveredTranscriptDirs = await findCursorTranscriptDirsForComposerIds(
+ threads.map((thread) => thread.composerId),
+ userDir,
+ );
+ const effectiveThreads = threads.map((thread) =>
+ thread.transcriptDirs.length > 0
+ ? thread
+ : {
+ ...thread,
+ transcriptDirs: discoveredTranscriptDirs.get(thread.composerId) ?? [],
+ },
+ );
+ const validatedTranscriptDirs = await validateCursorTranscriptDirs(effectiveThreads, userDir);
- const composerIds = new Set(threads.map((thread) => thread.composerId));
+ const composerIds = new Set(effectiveThreads.map((thread) => thread.composerId));
const globalDbPath = getCursorGlobalDbPath(userDir);
- const result: CursorPruneResult = {
- bubblesDeleted: 0,
- composerDataDeleted: 0,
- composerIds: [...composerIds],
- headersRemoved: 0,
- transcriptDirsRemoved: 0,
- workspaceBucketsUpdated: 0,
- };
+ const result = createCursorPruneResult([...composerIds]);
if (composerIds.size === 0) {
return result;
}
if (!apply) {
- result.bubblesDeleted = threads.reduce((sum, thread) => sum + thread.bubbleCount, 0);
- result.composerDataDeleted = threads.length;
- result.headersRemoved = threads.length;
- result.transcriptDirsRemoved = threads.reduce((sum, thread) => sum + thread.transcriptDirs.length, 0);
+ result.bubblesDeleted = effectiveThreads.reduce((sum, thread) => sum + thread.bubbleCount, 0);
+ result.composerDataDeleted = effectiveThreads.length;
+ result.headersRemoved = effectiveThreads.length;
+ result.transcriptDirsRemoved = effectiveThreads.reduce((sum, thread) => sum + thread.transcriptDirs.length, 0);
return result;
}
await backupPrunedThreads(globalDbPath, [...composerIds]);
const bucketMutation = await pruneWorkspaceBuckets(composerIds, userDir);
try {
- await pruneGlobalThreads(globalDbPath, threads, composerIds, result);
+ await pruneGlobalThreads(globalDbPath, effectiveThreads, composerIds, result);
} catch (error) {
try {
await bucketMutation.rollback();
@@ -540,7 +621,7 @@ export const pruneCursorThreads = async (
throw error;
}
result.workspaceBucketsUpdated = bucketMutation.updatedCount;
- await pruneTranscriptDirs(threads, result);
+ await pruneTranscriptDirs(effectiveThreads, result, validatedTranscriptDirs);
invalidateCursorDiscoveryCache();
return result;
@@ -645,57 +726,182 @@ const pruneWorkspaceBuckets = async (composerIds: Set, userDir: string)
return { rollback, updatedCount: updatedPaths.length };
};
-const pruneTranscriptDirs = async (threads: CursorThreadSummary[], result: CursorPruneResult): Promise => {
- const transcriptDirs = threads.flatMap((thread) => thread.transcriptDirs);
- await mapWithConcurrency(transcriptDirs, 4, async (dir) => {
- await rm(dir, { force: true, recursive: true });
+const warnCursorRecoveryDataIssue = (event: string, details: Record): void => {
+ console.warn(`[spiracha:cursor] ${event}`, details);
+};
+
+const removeValidatedTranscriptDir = async (
+ dir: string,
+ result: CursorPruneResult,
+ expectedCanonicalDir: string | undefined,
+): Promise => {
+ if (!expectedCanonicalDir) {
+ warnCursorRecoveryDataIssue('transcript_directory_not_prevalidated', { dir });
+ result.cleanupFailures.push({
+ error: 'Transcript directory was not prevalidated.',
+ path: dir,
+ phase: 'transcript_directory',
+ });
+ return false;
+ }
+
+ try {
+ const currentCanonicalDir = await realpath(dir);
+ if (currentCanonicalDir !== expectedCanonicalDir) {
+ warnCursorRecoveryDataIssue('transcript_directory_changed', {
+ currentCanonicalDir,
+ dir,
+ expectedCanonicalDir,
+ });
+ result.cleanupFailures.push({
+ error: 'Transcript directory changed after validation.',
+ path: dir,
+ phase: 'transcript_directory',
+ });
+ return false;
+ }
+ await rm(expectedCanonicalDir, { force: true, recursive: true });
+ return true;
+ } catch (error) {
+ if ((error as { code?: unknown }).code !== 'ENOENT' && (error as { code?: unknown }).code !== 'ENOTDIR') {
+ warnCursorRecoveryDataIssue('transcript_directory_cleanup_failed', {
+ dir,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ result.cleanupFailures.push({
+ error: error instanceof Error ? error.message : String(error),
+ path: dir,
+ phase: 'transcript_directory',
+ });
+ }
+ return false;
+ }
+};
+
+const pruneTranscriptDirs = async (
+ threads: CursorThreadSummary[],
+ result: CursorPruneResult,
+ validatedTranscriptDirs: Map,
+): Promise => {
+ const transcriptDirs = [...new Set(threads.flatMap((thread) => thread.transcriptDirs))];
+ const removed = await mapWithConcurrency(transcriptDirs, 4, (dir) =>
+ removeValidatedTranscriptDir(dir, result, validatedTranscriptDirs.get(dir)),
+ );
+ result.transcriptDirsRemoved = removed.filter(Boolean).length;
+ result.transcriptDirsRemovedPaths = transcriptDirs.filter((_dir, index) => removed[index]);
+};
+
+type CursorBucketRoot = { exists: boolean; path: string };
+
+const resolveCursorBucketRoot = async (
+ bucket: CursorWorkspaceBucket,
+ workspaceStorageDir: string,
+ canonicalStorageDir: string,
+): Promise => {
+ const bucketRoot = path.resolve(path.dirname(bucket.workspaceJsonPath));
+ const dbRoot = path.resolve(path.dirname(bucket.dbPath));
+ if (
+ bucketRoot !== dbRoot ||
+ path.dirname(bucketRoot) !== workspaceStorageDir ||
+ path.basename(bucketRoot) !== bucket.bucketId
+ ) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
+ }
+
+ const canonicalBucketRoot = await realpath(bucketRoot).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
});
- result.transcriptDirsRemoved = transcriptDirs.length;
+ if (canonicalBucketRoot && path.dirname(canonicalBucketRoot) !== canonicalStorageDir) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
+ }
+ const expectedCanonicalBucketRoot = path.join(canonicalStorageDir, bucket.bucketId);
+ if (canonicalBucketRoot && canonicalBucketRoot !== expectedCanonicalBucketRoot) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
+ }
+
+ return { exists: canonicalBucketRoot !== null, path: bucketRoot };
+};
+
+const removeCursorBucketRoot = async (
+ bucketRoot: CursorBucketRoot,
+ workspaceStorageDir: string,
+ canonicalStorageDir: string,
+): Promise => {
+ const currentStorageDir = await realpath(workspaceStorageDir).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT' || (error as { code?: unknown }).code === 'ENOTDIR') {
+ return null;
+ }
+ throw error;
+ });
+ if (!currentStorageDir) {
+ return false;
+ }
+ if (currentStorageDir !== canonicalStorageDir) {
+ throw new Error(`Unsafe Cursor workspace storage directory: ${workspaceStorageDir}`);
+ }
+ const currentBucketRoot = await realpath(bucketRoot.path).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ const expectedCanonicalBucketRoot = path.join(canonicalStorageDir, path.basename(bucketRoot.path));
+ if (currentBucketRoot !== null && currentBucketRoot !== expectedCanonicalBucketRoot) {
+ throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot.path}`);
+ }
+ await rm(expectedCanonicalBucketRoot, { force: true, recursive: true });
+ return bucketRoot.exists;
+};
+
+const removeCursorBucketRoots = async (
+ bucketRoots: CursorBucketRoot[],
+ workspaceStorageDir: string,
+ canonicalStorageDir: string,
+): Promise => {
+ const removedPaths: string[] = [];
+ const cleanupFailures: CursorFilesystemCleanupResult['cleanupFailures'] = [];
+ for (const bucketRoot of bucketRoots) {
+ try {
+ if (await removeCursorBucketRoot(bucketRoot, workspaceStorageDir, canonicalStorageDir)) {
+ removedPaths.push(bucketRoot.path);
+ }
+ } catch (error) {
+ if (isCursorSafetyError(error)) {
+ throw error;
+ }
+ cleanupFailures.push({
+ error: getCursorCleanupErrorMessage(error),
+ path: bucketRoot.path,
+ phase: 'workspace_buckets',
+ });
+ }
+ }
+
+ return { cleanupFailures, removedPaths };
};
export const deleteCursorWorkspaceBuckets = async (
group: CursorWorkspaceGroup,
userDir = resolveCursorUserDir(),
-): Promise => {
+): Promise => {
const workspaceStorageDir = path.resolve(getCursorWorkspaceStorageDir(userDir));
- const canonicalStorageDir = await realpath(workspaceStorageDir).catch(() => workspaceStorageDir);
- const bucketRoots: Array<{ exists: boolean; path: string }> = [];
-
- for (const bucket of group.buckets) {
- const bucketRoot = path.resolve(path.dirname(bucket.workspaceJsonPath));
- const dbRoot = path.resolve(path.dirname(bucket.dbPath));
- if (
- bucketRoot !== dbRoot ||
- path.dirname(bucketRoot) !== workspaceStorageDir ||
- path.basename(bucketRoot) !== bucket.bucketId
- ) {
- throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
- }
-
- const canonicalBucketRoot = await realpath(bucketRoot).catch((error) => {
+ try {
+ const canonicalStorageDir = await realpath(workspaceStorageDir).catch((error) => {
if ((error as { code?: unknown }).code === 'ENOENT') {
- return null;
+ return workspaceStorageDir;
}
throw error;
});
- if (canonicalBucketRoot && path.dirname(canonicalBucketRoot) !== canonicalStorageDir) {
- throw new Error(`Unsafe Cursor workspace bucket directory: ${bucketRoot}`);
- }
-
- bucketRoots.push({ exists: canonicalBucketRoot !== null, path: bucketRoot });
- }
-
- let removed = 0;
- try {
- for (const bucketRoot of bucketRoots) {
- await rm(bucketRoot.path, { force: true, recursive: true });
- removed += Number(bucketRoot.exists);
- }
+ const bucketRoots = await Promise.all(
+ group.buckets.map((bucket) => resolveCursorBucketRoot(bucket, workspaceStorageDir, canonicalStorageDir)),
+ );
+ return await removeCursorBucketRoots(bucketRoots, workspaceStorageDir, canonicalStorageDir);
} finally {
invalidateCursorDiscoveryCache();
}
-
- return removed;
};
const normalizeCursorHistoryPath = (value: string): string | null => {
@@ -735,17 +941,165 @@ const assertSafeCursorHistoryEntryPath = (
}
};
-export const deleteCursorWorkspaceHistory = async (
- group: CursorWorkspaceGroup,
- userDir = resolveCursorUserDir(),
-): Promise => {
- const folders = group.folders.map(normalizeCursorHistoryPath).filter((folder): folder is string => folder !== null);
+type CursorHistoryEntryReadResult = {
+ cleanupFailure?: CursorFilesystemCleanupResult['cleanupFailures'][number];
+ resource: string | null;
+};
+
+const readCursorHistoryEntryResource = async (
+ entriesPath: string,
+ failurePath = path.dirname(entriesPath),
+): Promise => {
+ let entriesStat: Awaited>;
+ try {
+ entriesStat = await stat(entriesPath);
+ } catch (error) {
+ if ((error as { code?: unknown }).code !== 'ENOENT' && (error as { code?: unknown }).code !== 'ENOTDIR') {
+ warnCursorRecoveryDataIssue('history_entries_stat_failed', {
+ entriesPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ }
+ if ((error as { code?: unknown }).code === 'ENOENT' || (error as { code?: unknown }).code === 'ENOTDIR') {
+ return { resource: null };
+ }
+ return {
+ cleanupFailure: {
+ error: getCursorCleanupErrorMessage(error),
+ path: failurePath,
+ phase: 'workspace_history',
+ },
+ resource: null,
+ };
+ }
+ if (entriesStat.size > CURSOR_MAX_HISTORY_ENTRIES_BYTES) {
+ warnCursorRecoveryDataIssue('history_entries_oversized', {
+ entriesPath,
+ maxBytes: CURSOR_MAX_HISTORY_ENTRIES_BYTES,
+ sizeBytes: entriesStat.size,
+ });
+ return {
+ cleanupFailure: {
+ error: `History entries exceed the ${CURSOR_MAX_HISTORY_ENTRIES_BYTES}-byte limit.`,
+ path: failurePath,
+ phase: 'workspace_history',
+ },
+ resource: null,
+ };
+ }
+
+ try {
+ const data = (await Bun.file(entriesPath).json()) as { resource?: unknown };
+ return { resource: typeof data.resource === 'string' ? data.resource : null };
+ } catch (error) {
+ warnCursorRecoveryDataIssue('invalid_history_entries_json', {
+ entriesPath,
+ error: error instanceof Error ? error.message : String(error),
+ });
+ return {
+ cleanupFailure: {
+ error: `Could not parse Cursor history entries: ${getCursorCleanupErrorMessage(error)}`,
+ path: failurePath,
+ phase: 'workspace_history',
+ },
+ resource: null,
+ };
+ }
+};
+
+const removeCursorHistoryEntry = async (entryPath: string, canonicalEntryPath: string): Promise => {
+ const currentCanonicalEntryPath = await realpath(entryPath).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ if (currentCanonicalEntryPath !== canonicalEntryPath) {
+ warnCursorRecoveryDataIssue('history_entry_directory_changed', {
+ canonicalEntryPath,
+ currentCanonicalEntryPath,
+ entryPath,
+ });
+ return false;
+ }
+ await rm(canonicalEntryPath, { force: true, recursive: true });
+ return true;
+};
+
+type CursorHistoryCleanupEntryResult = {
+ cleanupFailure?: CursorFilesystemCleanupResult['cleanupFailures'][number];
+ removedPath?: string;
+};
+
+const cleanupCursorHistoryEntry = async (
+ entryPath: string,
+ historyDir: string,
+ canonicalHistoryDir: string,
+ folders: string[],
+): Promise => {
+ if (path.dirname(entryPath) !== historyDir) {
+ throw new Error(`Unsafe Cursor history entry directory: ${entryPath}`);
+ }
+ const canonicalEntryPath = await realpath(entryPath).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return null;
+ }
+ throw error;
+ });
+ if (!canonicalEntryPath) {
+ return {};
+ }
+
+ assertSafeCursorHistoryEntryPath(canonicalHistoryDir, entryPath, canonicalEntryPath);
+ const resourceResult = await readCursorHistoryEntryResource(
+ path.join(canonicalEntryPath, 'entries.json'),
+ entryPath,
+ );
+ if (resourceResult.cleanupFailure) {
+ return { cleanupFailure: resourceResult.cleanupFailure };
+ }
+ if (!resourceResult.resource || !isCursorHistoryEntryForWorkspace(resourceResult.resource, folders)) {
+ return {};
+ }
+
+ try {
+ return (await removeCursorHistoryEntry(entryPath, canonicalEntryPath)) ? { removedPath: entryPath } : {};
+ } catch (error) {
+ if (isCursorSafetyError(error)) {
+ throw error;
+ }
+ return {
+ cleanupFailure: {
+ error: getCursorCleanupErrorMessage(error),
+ path: entryPath,
+ phase: 'workspace_history',
+ },
+ };
+ }
+};
+
+const getCursorHistoryEntryPaths = async (historyDir: string, requestedEntryPaths?: string[]): Promise => {
+ if (requestedEntryPaths) {
+ return [...new Set(requestedEntryPaths.map((entryPath) => path.resolve(entryPath)))];
+ }
+
+ return (await readdir(historyDir, { withFileTypes: true }))
+ .filter((entry) => entry.isDirectory())
+ .map((entry) => path.join(historyDir, entry.name));
+};
+
+const deleteCursorWorkspaceHistoryEntries = async (
+ folders: string[],
+ userDir: string,
+ requestedEntryPaths?: string[],
+): Promise => {
const historyDir = path.resolve(path.join(userDir, 'History'));
- let removed = 0;
+ const removedPaths: string[] = [];
+ const cleanupFailures: CursorFilesystemCleanupResult['cleanupFailures'] = [];
try {
if (folders.length === 0) {
- return 0;
+ return { cleanupFailures, removedPaths };
}
const canonicalHistoryDir = await realpath(historyDir).catch((error) => {
@@ -755,47 +1109,150 @@ export const deleteCursorWorkspaceHistory = async (
throw error;
});
if (!canonicalHistoryDir) {
- return 0;
+ return { cleanupFailures, removedPaths };
}
- const entries = await readdir(historyDir, { withFileTypes: true });
- for (const entry of entries) {
- if (!entry.isDirectory()) {
- continue;
+ const entryPaths = await getCursorHistoryEntryPaths(historyDir, requestedEntryPaths);
+ for (const entryPath of entryPaths) {
+ const entryResult = await cleanupCursorHistoryEntry(entryPath, historyDir, canonicalHistoryDir, folders);
+ if (entryResult.removedPath) {
+ removedPaths.push(entryResult.removedPath);
}
-
- const entryPath = path.join(historyDir, entry.name);
- const canonicalEntryPath = await realpath(entryPath).catch((error) => {
- if ((error as { code?: unknown }).code === 'ENOENT') {
- return null;
- }
- throw error;
- });
- if (!canonicalEntryPath) {
- continue;
+ if (entryResult.cleanupFailure) {
+ cleanupFailures.push(entryResult.cleanupFailure);
}
+ }
+ } finally {
+ invalidateCursorDiscoveryCache();
+ }
- assertSafeCursorHistoryEntryPath(canonicalHistoryDir, entryPath, canonicalEntryPath);
+ return { cleanupFailures, removedPaths };
+};
- let data: { resource?: unknown };
- try {
- data = (await Bun.file(path.join(entryPath, 'entries.json')).json()) as { resource?: unknown };
- } catch {
- continue;
- }
+export const deleteCursorWorkspaceHistory = async (
+ group: CursorWorkspaceGroup,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ const folders = group.folders.map(normalizeCursorHistoryPath).filter((folder): folder is string => folder !== null);
+ return deleteCursorWorkspaceHistoryEntries(folders, userDir);
+};
- if (typeof data.resource !== 'string' || !isCursorHistoryEntryForWorkspace(data.resource, folders)) {
- continue;
- }
+const createCursorBucketForPath = (bucketPath: string): CursorWorkspaceBucket => {
+ const resolvedPath = path.resolve(bucketPath);
+ return {
+ bucketId: path.basename(resolvedPath),
+ composerCount: 0,
+ dbPath: path.join(resolvedPath, 'state.vscdb'),
+ dbSizeBytes: 0,
+ folders: [],
+ globalHeaderCount: 0,
+ kind: 'folder',
+ label: '',
+ mtimeMs: 0,
+ threadComposerIds: [],
+ uri: '',
+ workspaceJsonPath: path.join(resolvedPath, 'workspace.json'),
+ };
+};
- await rm(entryPath, { force: true, recursive: true });
- removed += 1;
- }
+const deleteCursorWorkspaceBucketPaths = async (
+ bucketPaths: string[],
+ userDir: string,
+): Promise => {
+ const workspaceStorageDir = path.resolve(getCursorWorkspaceStorageDir(userDir));
+ try {
+ const canonicalStorageDir = await realpath(workspaceStorageDir).catch((error) => {
+ if ((error as { code?: unknown }).code === 'ENOENT') {
+ return workspaceStorageDir;
+ }
+ throw error;
+ });
+ const bucketRoots = await Promise.all(
+ [...new Set(bucketPaths)].map((bucketPath) =>
+ resolveCursorBucketRoot(
+ createCursorBucketForPath(bucketPath),
+ workspaceStorageDir,
+ canonicalStorageDir,
+ ),
+ ),
+ );
+ return await removeCursorBucketRoots(bucketRoots, workspaceStorageDir, canonicalStorageDir);
} finally {
invalidateCursorDiscoveryCache();
}
+};
- return removed;
+const createMinimalCursorThreadSummary = (composerId: string, transcriptDirs: string[]): CursorThreadSummary => ({
+ bubbleBytes: 0,
+ bubbleCount: 0,
+ bucketId: null,
+ composerId,
+ createdAtMs: null,
+ lastUpdatedAtMs: null,
+ mode: null,
+ model: null,
+ name: '',
+ parentComposerId: null,
+ reasoningEffort: null,
+ transcriptDirs,
+ workspaceKey: '',
+ workspaceLabel: '',
+});
+
+export const retryCursorWorkspaceCleanup = async (
+ target: CursorCleanupRetryPlan,
+ userDir = resolveCursorUserDir(),
+): Promise => {
+ const composerIds = [...new Set(target.composerIds)];
+ for (const composerId of composerIds) {
+ assertSafeCursorComposerId(composerId);
+ }
+
+ const result = createCursorPruneResult(composerIds);
+ const transcriptThreads = composerIds.map((composerId) =>
+ createMinimalCursorThreadSummary(
+ composerId,
+ target.transcriptDirs.filter((transcriptDir) => path.basename(path.resolve(transcriptDir)) === composerId),
+ ),
+ );
+ if (target.transcriptDirs.length > 0) {
+ const validatedTranscriptDirs = await validateCursorTranscriptDirs(transcriptThreads, userDir);
+ await pruneTranscriptDirs(transcriptThreads, result, validatedTranscriptDirs);
+ }
+
+ const bucketCleanup = await deleteCursorWorkspaceBucketPaths(target.bucketPaths, userDir);
+ result.workspaceBucketsRemovedPaths = bucketCleanup.removedPaths;
+ result.cleanupFailures.push(...bucketCleanup.cleanupFailures);
+
+ const folders = target.folders
+ .map(normalizeCursorHistoryPath)
+ .filter((folder): folder is string => folder !== null);
+ const historyCleanup = await deleteCursorWorkspaceHistoryEntries(
+ folders,
+ userDir,
+ target.historyPaths.length > 0 ? target.historyPaths : undefined,
+ );
+ result.workspaceHistoryRemovedPaths = historyCleanup.removedPaths;
+ result.cleanupFailures.push(...historyCleanup.cleanupFailures);
+
+ if (result.cleanupFailures.length > 0) {
+ result.retryPlan = {
+ bucketPaths: result.cleanupFailures
+ .filter((failure) => failure.phase === 'workspace_buckets' && failure.path)
+ .map((failure) => failure.path!),
+ composerIds,
+ folders: target.folders,
+ historyPaths: result.cleanupFailures
+ .filter((failure) => failure.phase === 'workspace_history' && failure.path)
+ .map((failure) => failure.path!),
+ transcriptDirs: result.cleanupFailures
+ .filter((failure) => failure.phase === 'transcript_directory' && failure.path)
+ .map((failure) => failure.path!),
+ workspaceKey: target.workspaceKey,
+ };
+ }
+
+ return result;
};
// Builds the minimal thread records needed to fully delete the given composer ids (bubble counts for
diff --git a/src/lib/minimax-code-db.test.ts b/src/lib/minimax-code-db.test.ts
index 838d526..49bccf8 100644
--- a/src/lib/minimax-code-db.test.ts
+++ b/src/lib/minimax-code-db.test.ts
@@ -268,6 +268,49 @@ describe('MiniMax Code db helpers', () => {
});
});
+ it('should read the effective model from runtime extra_data_json', async () => {
+ const tempRoot = await makeTempRoot();
+ const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
+ const workspacePath = path.join(tempRoot, 'project');
+ const fixture = await writeManifestMessagesSession(sessionsDir, 'mvs_extradata123');
+ const runtimeDbPath = resolveMiniMaxCodeRuntimeDbPath(sessionsDir);
+ await mkdir(path.dirname(runtimeDbPath), { recursive: true });
+
+ const db = new Database(runtimeDbPath, { create: true, strict: true });
+ try {
+ db.run(
+ 'CREATE TABLE local_runtime_sessions (session_id TEXT PRIMARY KEY, record_json TEXT NOT NULL, updated_at_ms INTEGER NOT NULL, extra_data_json TEXT NOT NULL)',
+ );
+ db.query(
+ 'INSERT INTO local_runtime_sessions (session_id, record_json, updated_at_ms, extra_data_json) VALUES (?, ?, ?, ?)',
+ ).run(
+ fixture.sessionId,
+ JSON.stringify({
+ sessionId: fixture.sessionId,
+ status: 'idle',
+ title: 'Runtime model metadata',
+ updatedAtMs: 1_786_000_000_100,
+ workspaceDir: workspacePath,
+ }),
+ 1_786_000_000_100,
+ JSON.stringify({
+ effectiveModel: 'minimax/MiniMax-M3',
+ effectiveModelVariant: 'thinking',
+ }),
+ );
+ } finally {
+ db.close();
+ }
+
+ const transcript = await readMiniMaxCodeSessionTranscript(sessionsDir, fixture.sessionId);
+
+ expect(transcript?.session).toMatchObject({
+ currentModelId: 'minimax/MiniMax-M3',
+ currentModelVariant: 'thinking',
+ worktree: workspacePath,
+ });
+ });
+
it('should reject manifest messages sessions without runtime workspace metadata', async () => {
const tempRoot = await makeTempRoot();
const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
diff --git a/src/lib/minimax-code-db.ts b/src/lib/minimax-code-db.ts
index 72801df..79fe2a8 100644
--- a/src/lib/minimax-code-db.ts
+++ b/src/lib/minimax-code-db.ts
@@ -527,7 +527,10 @@ const toRuntimeJsonValue = (value: unknown): JsonValue | null => {
};
const readRuntimeSessionRecord = (row: Record): Record | null => {
- const record = asObject(typeof row.record_json === 'string' ? parseJsonValue(row.record_json) : null) ?? {};
+ const recordJson = asObject(typeof row.record_json === 'string' ? parseJsonValue(row.record_json) : null) ?? {};
+ const extraData =
+ asObject(typeof row.extra_data_json === 'string' ? parseJsonValue(row.extra_data_json) : null) ?? {};
+ const record = { ...extraData, ...recordJson };
const columnMappings = [
['agent_name', 'agentName'],
['app_mode', 'appMode'],
diff --git a/src/lib/opencode-db.test.ts b/src/lib/opencode-db.test.ts
index 65781de..908f10c 100644
--- a/src/lib/opencode-db.test.ts
+++ b/src/lib/opencode-db.test.ts
@@ -1,6 +1,6 @@
import { Database } from 'bun:sqlite';
import { afterEach, describe, expect, it, spyOn } from 'bun:test';
-import { chmod, mkdir, mkdtemp, readdir, rm, stat } from 'node:fs/promises';
+import { chmod, mkdir, mkdtemp, readdir, rm, stat, symlink } from 'node:fs/promises';
import os from 'node:os';
import path from 'node:path';
import {
@@ -275,6 +275,25 @@ describe('opencode db helpers', () => {
}
});
+ it('should reject a blank global session during a direct transcript lookup with a diagnostic', async () => {
+ const dbPath = await makeDbPath();
+ await createOpenCodeFixture(dbPath, {
+ projects: [{ id: 'global', worktree: '/' }],
+ sessions: [{ directory: ' ', id: 'ses_blank_direct', messages: [], projectId: 'global', title: 'Blank' }],
+ });
+ process.env.SPIRACHA_OPENCODE_DB_LOGS = '1';
+ const infoSpy = spyOn(console, 'info').mockImplementation(() => undefined);
+
+ try {
+ await expect(readOpenCodeSessionTranscript(dbPath, 'ses_blank_direct')).resolves.toBeNull();
+ expect(infoSpy).toHaveBeenCalledWith('[spiracha:opencode-db] invalid-global-session-directory', {
+ sessionId: 'ses_blank_direct',
+ });
+ } finally {
+ infoSpy.mockRestore();
+ }
+ });
+
it('should read a WAL database when its sidecar files do not exist', async () => {
const dbPath = await createFixtureDb();
const db = new Database(dbPath);
@@ -780,4 +799,12 @@ describe('opencode db helpers', () => {
}
}
});
+
+ it('should ignore a desktop state file that disappears before it can be read', async () => {
+ const stateDir = await mkdtemp(path.join(os.tmpdir(), 'opencode-desktop-state-race-'));
+ tempDirs.push(stateDir);
+ await symlink(path.join(stateDir, 'missing-target'), path.join(stateDir, 'removed.dat'));
+
+ await expect(deleteOpenCodeDesktopSessionState(['ses_removed'], stateDir)).resolves.toEqual([]);
+ });
});
diff --git a/src/lib/opencode-db.ts b/src/lib/opencode-db.ts
index c6cab08..2f4c1c7 100644
--- a/src/lib/opencode-db.ts
+++ b/src/lib/opencode-db.ts
@@ -128,6 +128,11 @@ const logOpenCodeDb = (event: string, details: Record) => {
}
};
+const isMissingFileError = (error: unknown): boolean => {
+ const code = (error as { code?: unknown }).code;
+ return code === 'ENOENT' || code === 'ENOTDIR';
+};
+
const runWithOpenCodeDbLimit = async (operation: string, dbPath: string, action: () => T): Promise => {
const loadId = nextOpenCodeDbLoadId;
nextOpenCodeDbLoadId += 1;
@@ -647,7 +652,12 @@ export const listOpenCodeSessionsForGroup = async (
};
const readOpenCodeSessionSummary = (db: Database, sessionId: string): OpenCodeSessionSummary | null => {
- return readSessionSummaries(db, `s.id = ? AND ${MAIN_SESSION_FILTER}`, [sessionId])[0] ?? null;
+ const session = readSessionSummaries(db, `s.id = ? AND ${MAIN_SESSION_FILTER}`, [sessionId])[0] ?? null;
+ if (session?.projectId === GLOBAL_OPENCODE_PROJECT_ID && !session.directory.trim()) {
+ logOpenCodeDb('invalid-global-session-directory', { sessionId });
+ return null;
+ }
+ return session;
};
const readOpenCodeSessionTreeIds = (db: Database, sessionId: string): string[] => {
@@ -966,19 +976,34 @@ export const deleteOpenCodeDesktopSessionState = async (
const sessionIdSet = new Set(sessionIds);
const worktreeSet = new Set(worktrees);
- const fileNames = (await readdir(stateDir)).filter((fileName) => fileName.endsWith('.dat'));
+ let fileNames: string[];
+ try {
+ fileNames = (await readdir(stateDir)).filter((fileName) => fileName.endsWith('.dat'));
+ } catch (error) {
+ if (isMissingFileError(error)) {
+ return [];
+ }
+ throw error;
+ }
const changedFiles = await mapWithConcurrency(
fileNames,
DESKTOP_STATE_FILE_CONCURRENCY,
async (fileName): Promise => {
const filePath = path.join(stateDir, fileName);
- const state = parseMutableJsonObject(await Bun.file(filePath).text());
- if (!state || !cleanOpenCodeDesktopStateObject(state, sessionIdSet, worktreeSet)) {
- return null;
+ try {
+ const state = parseMutableJsonObject(await Bun.file(filePath).text());
+ if (!state || !cleanOpenCodeDesktopStateObject(state, sessionIdSet, worktreeSet)) {
+ return null;
+ }
+
+ await writeOpenCodeDesktopState(filePath, state);
+ return filePath;
+ } catch (error) {
+ if (isMissingFileError(error)) {
+ return null;
+ }
+ throw error;
}
-
- await writeOpenCodeDesktopState(filePath, state);
- return filePath;
},
);
diff --git a/src/lib/sqlite-retry.test.ts b/src/lib/sqlite-retry.test.ts
index d24f2d2..d930d7c 100644
--- a/src/lib/sqlite-retry.test.ts
+++ b/src/lib/sqlite-retry.test.ts
@@ -32,6 +32,28 @@ describe('sqlite retry helpers', () => {
expect(delays).toEqual([10, 20]);
});
+ it('should report retry details before sleeping', () => {
+ const retries: Array<{ attempt: number; delayMs: number; error: unknown }> = [];
+ let attempts = 0;
+
+ expect(
+ runWithSqliteRetry({
+ action: () => {
+ attempts += 1;
+ if (attempts === 1) {
+ throw new Error('database is locked');
+ }
+ return 'ok';
+ },
+ delaysMs: [12],
+ onRetry: (details) => retries.push(details),
+ sleep: () => {},
+ }),
+ ).toBe('ok');
+
+ expect(retries).toEqual([{ attempt: 1, delayMs: 12, error: expect.any(Error) }]);
+ });
+
it('should not retry non-retryable failures', () => {
let attempts = 0;
diff --git a/src/lib/sqlite-retry.ts b/src/lib/sqlite-retry.ts
index 5ab8232..e9d387d 100644
--- a/src/lib/sqlite-retry.ts
+++ b/src/lib/sqlite-retry.ts
@@ -6,6 +6,7 @@ const SLEEP_BUFFER = new Int32Array(new SharedArrayBuffer(4));
type SyncRetryOptions = {
action: () => T;
delaysMs?: readonly number[];
+ onRetry?: (details: { attempt: number; delayMs: number; error: unknown }) => void;
sleep?: (delayMs: number) => void;
};
@@ -31,6 +32,7 @@ const shouldRetrySqliteError = (error: unknown, attempt: number, delaysMs: reado
export const runWithSqliteRetry = ({
action,
delaysMs = DEFAULT_RETRY_DELAYS_MS,
+ onRetry,
sleep = sleepSync,
}: SyncRetryOptions): T => {
let attempt = 0;
@@ -46,7 +48,9 @@ export const runWithSqliteRetry = ({
throw error;
}
- sleep(delaysMs[attempt] ?? 0);
+ const delayMs = delaysMs[attempt] ?? 0;
+ onRetry?.({ attempt: attempt + 1, delayMs, error });
+ sleep(delayMs);
attempt += 1;
}
}
diff --git a/src/lib/ui-export-zip.test.ts b/src/lib/ui-export-zip.test.ts
index 8cb62ff..72c3979 100644
--- a/src/lib/ui-export-zip.test.ts
+++ b/src/lib/ui-export-zip.test.ts
@@ -86,4 +86,14 @@ describe('UI export ZIP helpers', () => {
const entries = unzipSync(new Uint8Array(await Bun.file(zipPath).arrayBuffer()));
expect(Object.keys(entries)).toEqual(['thread.md']);
});
+
+ it('should remove a stale destination when a ZIP input fails', async () => {
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), 'ui-export-zip-cleanup-'));
+ tempPaths.push(tempRoot);
+ const zipPath = path.join(tempRoot, 'partial.zip');
+ await Bun.write(zipPath, 'partial archive');
+
+ await expect(zipExportFile(path.join(tempRoot, 'missing.md'), zipPath)).rejects.toThrow();
+ expect(await Bun.file(zipPath).exists()).toBe(false);
+ });
});
diff --git a/src/lib/ui-export-zip.ts b/src/lib/ui-export-zip.ts
index 338eabd..c3f4258 100644
--- a/src/lib/ui-export-zip.ts
+++ b/src/lib/ui-export-zip.ts
@@ -1,4 +1,4 @@
-import { readdir } from 'node:fs/promises';
+import { readdir, rm } from 'node:fs/promises';
import path from 'node:path';
import { type Zippable, zipSync } from 'fflate';
@@ -32,9 +32,19 @@ const readZipDirectory = async (
};
export const zipExportFile = async (sourcePath: string, zipPath: string) => {
- await Bun.write(zipPath, await createZip({ [path.basename(sourcePath)]: await readZipFile(sourcePath) }));
+ try {
+ await Bun.write(zipPath, await createZip({ [path.basename(sourcePath)]: await readZipFile(sourcePath) }));
+ } catch (error) {
+ await rm(zipPath, { force: true }).catch(() => undefined);
+ throw error;
+ }
};
export const zipExportDirectory = async (sourceDirectory: string, zipPath: string) => {
- await Bun.write(zipPath, await createZip(await readZipDirectory(sourceDirectory)));
+ try {
+ await Bun.write(zipPath, await createZip(await readZipDirectory(sourceDirectory)));
+ } catch (error) {
+ await rm(zipPath, { force: true }).catch(() => undefined);
+ throw error;
+ }
};
diff --git a/src/ui/components/export-dialog.tsx b/src/ui/components/export-dialog.tsx
index 20e5d28..c42dd90 100644
--- a/src/ui/components/export-dialog.tsx
+++ b/src/ui/components/export-dialog.tsx
@@ -23,7 +23,7 @@ import {
resetActiveDownloads,
} from '#/lib/download';
import { requestEvidenceExport } from '#/lib/evidence-export';
-import type { ExportDialogOptions } from '#/lib/export-options';
+import type { ExportDialogOptions, ExportLifecycleCallbacks } from '#/lib/export-options';
import { useSettings } from '#/lib/settings-store';
import { EvidenceLensEditor } from './evidence-lens-editor';
@@ -34,10 +34,11 @@ type ExportDialogProps = {
focusedEvidenceTarget?: { id: string; source: ConversationSource };
open: boolean;
pending?: boolean;
+ skippedThreadCount?: number;
showCommentaryOption?: boolean;
showToolsOption?: boolean;
title?: string;
- onExport: (options: ExportDialogOptions) => void;
+ onExport: (options: ExportDialogOptions, callbacks: ExportLifecycleCallbacks) => void;
onOpenChange: (open: boolean) => void;
};
@@ -174,6 +175,137 @@ const DownloadStateMessage = ({ state }: { state: DownloadLifecycleState | null
) : null;
+type ExportModeContentProps = {
+ effectiveZipArchive: boolean;
+ focusedEvidenceTarget?: { id: string; source: ConversationSource };
+ forceZipArchive: boolean;
+ lens: EvidenceLens;
+ mode: 'focused' | 'full';
+ options: ExportDialogOptions;
+ preview: ConversationEvidenceExport | null;
+ showCommentaryOption: boolean;
+ showToolsOption: boolean;
+ zipDescriptionId: string;
+ onLensChange: (lens: EvidenceLens) => void;
+ onModeChange: (mode: 'focused' | 'full') => void;
+ onOptionsChange: (options: Partial) => void;
+};
+
+const ExportModeContent = ({
+ effectiveZipArchive,
+ focusedEvidenceTarget,
+ forceZipArchive,
+ lens,
+ mode,
+ options,
+ preview,
+ showCommentaryOption,
+ showToolsOption,
+ zipDescriptionId,
+ onLensChange,
+ onModeChange,
+ onOptionsChange,
+}: ExportModeContentProps) => (
+ <>
+ {focusedEvidenceTarget ? (
+
+
+ Export mode
+
+ onModeChange(value as 'focused' | 'full')}>
+
+
+
+
+ Full transcript
+ Focused evidence
+
+
+
+ ) : null}
+ {mode === 'focused' && focusedEvidenceTarget ? (
+
+ ) : (
+
+ )}
+ {preview && mode === 'focused' ? : null}
+ >
+);
+
+type ExportDialogStatusProps = {
+ displayedError: string | null;
+ downloadState: DownloadLifecycleState | null;
+ skippedThreadCount: number;
+};
+
+const ExportDialogStatus = ({ displayedError, downloadState, skippedThreadCount }: ExportDialogStatusProps) => (
+ <>
+
+ {skippedThreadCount > 0 ? (
+
+ Export completed with {skippedThreadCount} skipped {skippedThreadCount === 1 ? 'thread' : 'threads'}.
+
+ ) : null}
+ {displayedError ? {displayedError}
: null}
+ >
+);
+
+type ExportDialogFooterProps = {
+ disabled: boolean;
+ evidencePending: boolean;
+ mode: 'focused' | 'full';
+ pending: boolean;
+ submitted: boolean;
+ onCancel: () => void;
+ onPreview: () => void;
+ onSubmit: () => void;
+};
+
+const ExportDialogFooter = ({
+ disabled,
+ evidencePending,
+ mode,
+ pending,
+ submitted,
+ onCancel,
+ onPreview,
+ onSubmit,
+}: ExportDialogFooterProps) => (
+
+
+ Cancel
+
+ {mode === 'focused' ? (
+
+ {evidencePending ? 'Previewing...' : 'Preview evidence'}
+
+ ) : null}
+
+ {pending || evidencePending ? 'Exporting...' : 'Download export'}
+
+
+);
+
export function ExportDialog({
disabled = false,
errorMessage = null,
@@ -181,6 +313,7 @@ export function ExportDialog({
focusedEvidenceTarget,
open,
pending = false,
+ skippedThreadCount = 0,
showCommentaryOption = true,
showToolsOption = true,
title = 'Export thread',
@@ -292,7 +425,7 @@ export function ExportDialog({
return;
}
updateSetting('exportDefaults', options);
- onExport({ ...options, zipArchive: effectiveZipArchive });
+ onExport({ ...options, zipArchive: effectiveZipArchive }, { onDownloadStateChange: setDownloadState });
};
return (
@@ -306,80 +439,46 @@ export function ExportDialog({
- {focusedEvidenceTarget ? (
-
-
- Export mode
-
- {
- setMode(value as 'focused' | 'full');
- setPreview(null);
- setEvidenceError(null);
- }}
- >
-
-
-
-
- Full transcript
- Focused evidence
-
-
-
- ) : null}
- {mode === 'focused' && focusedEvidenceTarget ? (
-
{
- setLens(nextLens);
- setPreview(null);
- setEvidenceError(null);
- }}
- />
- ) : (
- setOptions((current) => ({ ...current, ...nextOptions }))}
- />
- )}
+ {
+ setLens(nextLens);
+ setPreview(null);
+ setEvidenceError(null);
+ }}
+ onModeChange={(nextMode) => {
+ setMode(nextMode);
+ setPreview(null);
+ setEvidenceError(null);
+ }}
+ onOptionsChange={(nextOptions) => setOptions((current) => ({ ...current, ...nextOptions }))}
+ />
- {preview && mode === 'focused' ? : null}
-
- {displayedError ? {displayedError}
: null}
-
-
- handleOpenChange(false)}>
- Cancel
-
- {mode === 'focused' ? (
-
- {evidencePending ? 'Previewing...' : 'Preview evidence'}
-
- ) : null}
-
- {pending || evidencePending ? 'Exporting...' : 'Download export'}
-
-
+
+ handleOpenChange(false)}
+ onPreview={loadEvidence}
+ onSubmit={submitExport}
+ />
);
diff --git a/src/ui/components/export-dialog.vitest.tsx b/src/ui/components/export-dialog.vitest.tsx
index 37704e9..b503741 100644
--- a/src/ui/components/export-dialog.vitest.tsx
+++ b/src/ui/components/export-dialog.vitest.tsx
@@ -90,13 +90,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByText('Download export'));
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
});
it('should submit the selected export options', async () => {
@@ -113,13 +116,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByText('Plain text (.txt)'));
fireEvent.click(screen.getAllByRole('button', { name: 'Download export' })[0]!);
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: true,
- includeMetadata: false,
- includeTools: true,
- outputFormat: 'txt',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: true,
+ includeMetadata: false,
+ includeTools: true,
+ outputFormat: 'txt',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
} finally {
HTMLElement.prototype.scrollIntoView = originalScrollIntoView;
}
@@ -160,13 +166,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }));
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: false,
- outputFormat: 'md',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: false,
+ outputFormat: 'md',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
expect(onOpenChange).toHaveBeenCalledWith(false);
expect(cancelActiveDownloads).toHaveBeenCalledTimes(1);
cancelActiveDownloads.mockRestore();
@@ -259,6 +268,19 @@ describe('ExportDialog', () => {
}
});
+ it('should expose full export lifecycle updates and partial export counts', () => {
+ const onExport = vi.fn((_options, lifecycle) => {
+ lifecycle.onDownloadStateChange?.('ready');
+ });
+
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
+
+ expect(screen.getAllByRole('status').map((status) => status.textContent)).toContain('Export ready.');
+ expect(screen.getByText('Export completed with 2 skipped threads.')).toBeTruthy();
+ });
+
it('should submit zip archive when selected', () => {
const onExport = vi.fn();
@@ -267,13 +289,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByRole('checkbox', { name: /zip archive/i }));
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: true,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: true,
+ }),
+ expect.any(Object),
+ );
});
it('should force zip archive for multi-thread exports', () => {
@@ -288,13 +313,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: true,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: true,
+ }),
+ expect.any(Object),
+ );
});
it('should remember successfully submitted options after closing and reopening', () => {
@@ -316,13 +344,16 @@ describe('ExportDialog', () => {
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenLastCalledWith({
- includeCommentary: true,
- includeMetadata: false,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: true,
- });
+ expect(onExport).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ includeCommentary: true,
+ includeMetadata: false,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: true,
+ }),
+ expect.any(Object),
+ );
});
it('should discard canceled drafts without overwriting submitted defaults', () => {
@@ -345,13 +376,16 @@ describe('ExportDialog', () => {
rerender(renderDialog(true));
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenLastCalledWith({
- includeCommentary: true,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ includeCommentary: true,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
});
it('should not persist forced multi-thread zip as the single-thread default', () => {
@@ -373,13 +407,16 @@ describe('ExportDialog', () => {
rerender(renderDialog(true, false));
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenLastCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
});
it('should show export errors inline while dialog remains open', () => {
@@ -409,12 +446,15 @@ describe('ExportDialog', () => {
expect(dialogQueries.getByText('Choose the transcript format and export options.')).toBeTruthy();
fireEvent.click(screen.getByRole('button', { name: 'Download export' }));
- expect(onExport).toHaveBeenCalledWith({
- includeCommentary: false,
- includeMetadata: true,
- includeTools: true,
- outputFormat: 'md',
- zipArchive: false,
- });
+ expect(onExport).toHaveBeenCalledWith(
+ expect.objectContaining({
+ includeCommentary: false,
+ includeMetadata: true,
+ includeTools: true,
+ outputFormat: 'md',
+ zipArchive: false,
+ }),
+ expect.any(Object),
+ );
});
});
diff --git a/src/ui/components/transcript-view.vitest.tsx b/src/ui/components/transcript-view.vitest.tsx
index a192776..0d897fe 100644
--- a/src/ui/components/transcript-view.vitest.tsx
+++ b/src/ui/components/transcript-view.vitest.tsx
@@ -410,6 +410,52 @@ describe('TranscriptView', () => {
expect(screen.getByText('I am working through the codebase now.')).toBeTruthy();
});
+ it('should reveal a MiniMax commentary finding when commentary is enabled', () => {
+ const commentaryFinding: ThreadEvent = {
+ isHiddenByDefault: false,
+ kind: 'message',
+ memoryCitation: null,
+ model: null,
+ phase: 'commentary',
+ raw: { eventType: 'message', source: 'minimax_code_snapshot' },
+ role: 'assistant',
+ sequence: 142,
+ text: '### 25. validate.ts — not enough view; need to read to identify if it gates requestedPhase',
+ timestamp: null,
+ variant: 'agent_message',
+ };
+
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.queryByText(/not enough view; need to read/u)).toBeNull();
+
+ rerender(
+ ,
+ );
+
+ expect(screen.getByText(/not enough view; need to read/u)).toBeTruthy();
+ });
+
it('should hide user messages when user messages are disabled', () => {
render(
{
throw new Error('node:fs/promises must not load while hydrating Antigravity routes');
});
+vi.mock('node:child_process', () => {
+ throw new Error('node:child_process must not load while hydrating Antigravity routes');
+});
+
describe('antigravity-server browser boundary', () => {
it('should not eagerly load filesystem-only export modules', async () => {
await expect(import('./antigravity-server')).resolves.toBeDefined();
diff --git a/src/ui/lib/antigravity-server.ts b/src/ui/lib/antigravity-server.ts
index 5548edb..c4badad 100644
--- a/src/ui/lib/antigravity-server.ts
+++ b/src/ui/lib/antigravity-server.ts
@@ -1,4 +1,6 @@
+import { AntigravityDecryptionCapabilityError } from '@spiracha/lib/antigravity-decryption-error';
import type { AntigravityConversation } from '@spiracha/lib/antigravity-exporter-types';
+import type { AntigravityDecryptionCapability } from '@spiracha/lib/antigravity-keychain';
import { buildConversationExportBaseName } from '@spiracha/lib/ui-export-archive';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
@@ -39,14 +41,7 @@ const deleteConversationsSchema = z.object({
conversationIds: z.array(z.string().min(1)).min(1),
});
-export class AntigravityDecryptionCapabilityError extends Error {
- readonly code = 'ANTIGRAVITY_DECRYPTION_CAPABILITY';
-
- constructor(cause: unknown) {
- super('Antigravity decryption capability is unavailable', { cause });
- this.name = 'AntigravityDecryptionCapabilityError';
- }
-}
+export { AntigravityDecryptionCapabilityError };
const acquireAntigravityDecryptionCapability = async () => {
const { withAntigravityDecryptionCapability } = await import('@spiracha/lib/antigravity-keychain');
@@ -54,10 +49,21 @@ const acquireAntigravityDecryptionCapability = async () => {
};
const isAntigravityDecryptionCapabilityError = (error: unknown): boolean =>
- error instanceof AntigravityDecryptionCapabilityError ||
- (typeof error === 'object' &&
- error !== null &&
- (error as { code?: unknown }).code === 'ANTIGRAVITY_DECRYPTION_CAPABILITY');
+ error instanceof AntigravityDecryptionCapabilityError;
+
+const acquireAntigravityExportCapability = async (): Promise => {
+ try {
+ return await acquireAntigravityDecryptionCapability();
+ } catch (error) {
+ if (!isAntigravityDecryptionCapabilityError(error)) {
+ throw error;
+ }
+ const message = error instanceof Error ? ` ${error.message}` : '';
+ throw new Error(`Unlock Antigravity Keychain access before exporting transcript logs.${message}`, {
+ cause: error,
+ });
+ }
+};
export const listAntigravityWorkspacesFn = createServerFn({ method: 'GET' }).handler(async () => {
const { listAntigravityWorkspaceGroups } = await import('@spiracha/lib/antigravity-db');
@@ -160,6 +166,7 @@ export const loadAntigravityConversationDetail = async (conversationId: string)
};
type AntigravityConversationExportOptions = {
+ decryptionCapability?: AntigravityDecryptionCapability;
includeCommentary?: boolean;
includeMetadata?: boolean;
includeTools?: boolean;
@@ -197,7 +204,8 @@ export const loadAntigravityConversationExport = async (
return renderAntigravityConversationMarkdown(conversation, renderOptions);
}
- const decryptionCapability = await acquireAntigravityDecryptionCapability();
+ const decryptionCapability =
+ options.decryptionCapability ?? (await acquireAntigravityExportCapability());
return renderAntigravityConversationMarkdown(conversation, { ...renderOptions, decryptionCapability });
} catch (error) {
if (!isAntigravityDecryptionCapabilityError(error)) {
@@ -311,6 +319,9 @@ export const exportAntigravityConversations = async (input: z.input (conversation.projectId ? [conversation.projectId] : [])),
);
@@ -320,6 +331,7 @@ export const exportAntigravityConversations = async (input: z.input {
);
});
+ it('should acquire one decryption capability for a protected batch export', async () => {
+ const conversations = [
+ makeConversation({
+ conversationId: 'encrypted-1',
+ conversationPath: null,
+ transcriptPath: null,
+ transcriptSource: 'safe-storage',
+ }),
+ makeConversation({
+ conversationId: 'encrypted-2',
+ conversationPath: null,
+ transcriptPath: null,
+ transcriptSource: 'safe-storage',
+ }),
+ ];
+ listAntigravityConversationsMock.mockResolvedValue(conversations);
+
+ await exportAntigravityConversations({
+ conversationIds: conversations.map((conversation) => conversation.conversationId),
+ outputFormat: 'md',
+ zipArchive: true,
+ });
+
+ expect(withAntigravityDecryptionCapabilityMock).toHaveBeenCalledTimes(1);
+ });
+
it('should map only decryption capability acquisition failures to locked detail state', async () => {
const conversation = makeConversation({ transcriptPath: null, transcriptSource: 'safe-storage' });
listAntigravityConversationsMock.mockResolvedValue([conversation]);
@@ -192,6 +218,15 @@ describe('antigravity-server', () => {
expect(detail.transcriptLocked).toBe(true);
});
+ it('should not map an untyped capability-shaped error to locked detail state', async () => {
+ const conversation = makeConversation({ transcriptPath: null, transcriptSource: 'safe-storage' });
+ listAntigravityConversationsMock.mockResolvedValue([conversation]);
+ const error = { code: 'ANTIGRAVITY_DECRYPTION_CAPABILITY', message: 'not a typed error' };
+ withAntigravityDecryptionCapabilityMock.mockRejectedValue(error);
+
+ await expect(loadAntigravityConversationDetail(conversation.conversationId)).rejects.toBe(error);
+ });
+
it('should propagate protected transcript renderer failures unchanged', async () => {
const conversation = makeConversation({ transcriptPath: null, transcriptSource: 'safe-storage' });
listAntigravityConversationsMock.mockResolvedValue([conversation]);
diff --git a/src/ui/lib/cursor-delete-result.ts b/src/ui/lib/cursor-delete-result.ts
new file mode 100644
index 0000000..4c943b5
--- /dev/null
+++ b/src/ui/lib/cursor-delete-result.ts
@@ -0,0 +1,19 @@
+import type { CursorCleanupFailure, CursorPruneResult } from '@spiracha/lib/cursor-exporter-types';
+
+export const getCursorCleanupFailures = (result: CursorPruneResult | CursorPruneResult[]): CursorCleanupFailure[] => {
+ const results = Array.isArray(result) ? result : [result];
+ return results.flatMap((entry) => entry.cleanupFailures ?? []);
+};
+
+export const getCursorCleanupFailureMessage = (result: CursorPruneResult | CursorPruneResult[]): string | null => {
+ const failures = getCursorCleanupFailures(result);
+ if (failures.length === 0) {
+ return null;
+ }
+
+ const details = failures.map(({ error, path }) => (path ? `${path}: ${error}` : error)).join('; ');
+ return `Deletion completed with ${failures.length} filesystem cleanup failure${failures.length === 1 ? '' : 's'}: ${details}. Retry to attempt the failed cleanup again.`;
+};
+
+export const hasCursorCleanupFailures = (result: CursorPruneResult | CursorPruneResult[] | undefined): boolean =>
+ result !== undefined && getCursorCleanupFailures(result).length > 0;
diff --git a/src/ui/lib/cursor-delete-result.vitest.ts b/src/ui/lib/cursor-delete-result.vitest.ts
new file mode 100644
index 0000000..bb08c9a
--- /dev/null
+++ b/src/ui/lib/cursor-delete-result.vitest.ts
@@ -0,0 +1,38 @@
+import type { CursorPruneResult } from '@spiracha/lib/cursor-exporter-types';
+import { describe, expect, it } from 'vitest';
+import {
+ getCursorCleanupFailureMessage,
+ getCursorCleanupFailures,
+ hasCursorCleanupFailures,
+} from './cursor-delete-result';
+
+const result = (cleanupFailures: CursorPruneResult['cleanupFailures']): CursorPruneResult => ({
+ bubblesDeleted: 0,
+ cleanupFailures,
+ composerDataDeleted: 0,
+ composerIds: [],
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
+ workspaceBucketsUpdated: 0,
+});
+
+describe('Cursor delete result helpers', () => {
+ it('should flatten partial cleanup failures from every workspace result', () => {
+ const failures = [{ error: 'busy', phase: 'workspace_buckets' as const }];
+
+ expect(getCursorCleanupFailures([result(failures), result([])])).toEqual(failures);
+ expect(getCursorCleanupFailureMessage(result(failures))).toContain('1 filesystem cleanup failure: busy. Retry');
+ });
+
+ it('should return no partial-cleanup message for a complete deletion', () => {
+ expect(getCursorCleanupFailures(result([]))).toEqual([]);
+ expect(getCursorCleanupFailureMessage(result([]))).toBeNull();
+ expect(hasCursorCleanupFailures(result([]))).toBe(false);
+ });
+
+ it('should keep a workspace query mounted while cleanup can be retried', () => {
+ expect(hasCursorCleanupFailures(result([{ error: 'busy', phase: 'workspace_history' }]))).toBe(true);
+ expect(hasCursorCleanupFailures(undefined)).toBe(false);
+ });
+});
diff --git a/src/ui/lib/cursor-server.ts b/src/ui/lib/cursor-server.ts
index 08881dc..a388482 100644
--- a/src/ui/lib/cursor-server.ts
+++ b/src/ui/lib/cursor-server.ts
@@ -1,21 +1,131 @@
-import type { CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
+import { randomUUID } from 'node:crypto';
+import path from 'node:path';
+import type {
+ CursorCleanupRetryPlan,
+ CursorCleanupRetryTarget,
+ CursorPruneResult,
+ CursorWorkspaceGroup,
+} from '@spiracha/lib/cursor-exporter-types';
import { isSafeCursorComposerId } from '@spiracha/lib/cursor-id';
import { createServerFn } from '@tanstack/react-start';
import { z } from 'zod';
import { renderSourceSessionDownload, renderSourceSessionsDownload } from './source-session-export-server';
+const composerIdSchema = z.string().refine(isSafeCursorComposerId, {
+ message: 'Invalid Cursor composer id.',
+});
+
+const cleanupRetryTargetSchema = z.object({
+ token: z.string().uuid(),
+});
+
+const CURSOR_CLEANUP_RETRY_TTL_MS = 5 * 60 * 1000;
+const CURSOR_CLEANUP_RETRY_MAX = 128;
+
const workspaceSchema = z.object({
workspaceKey: z.string().min(1),
});
-const workspacesSchema = z.object({
- workspaceKeys: z.array(z.string().min(1)).min(1),
+const deleteWorkspaceSchema = workspaceSchema.extend({
+ retry: cleanupRetryTargetSchema.optional(),
});
-const composerIdSchema = z.string().refine(isSafeCursorComposerId, {
- message: 'Invalid Cursor composer id.',
+const workspacesSchema = z.object({
+ retryTargets: z.array(cleanupRetryTargetSchema.nullable()).max(CURSOR_CLEANUP_RETRY_MAX).optional(),
+ workspaceKeys: z.array(z.string().min(1)).min(1).max(CURSOR_CLEANUP_RETRY_MAX),
});
+// Keep filesystem retry paths server-side; clients receive only bounded, opaque, single-use tokens.
+const cursorCleanupRetryPlans = new Map();
+
+const purgeExpiredCursorCleanupRetryPlans = (nowMs = Date.now()) => {
+ for (const [token, record] of cursorCleanupRetryPlans) {
+ if (nowMs - record.createdAtMs >= CURSOR_CLEANUP_RETRY_TTL_MS) {
+ cursorCleanupRetryPlans.delete(token);
+ }
+ }
+};
+
+const registerCursorCleanupRetryPlan = (plan: CursorCleanupRetryPlan): CursorCleanupRetryTarget => {
+ const nowMs = Date.now();
+ purgeExpiredCursorCleanupRetryPlans(nowMs);
+ while (cursorCleanupRetryPlans.size >= CURSOR_CLEANUP_RETRY_MAX) {
+ const oldestToken = cursorCleanupRetryPlans.keys().next().value;
+ if (typeof oldestToken !== 'string') {
+ break;
+ }
+ cursorCleanupRetryPlans.delete(oldestToken);
+ }
+
+ const token = randomUUID();
+ cursorCleanupRetryPlans.set(token, { createdAtMs: nowMs, plan });
+ return { token };
+};
+
+const resolveCursorCleanupRetryPlan = (target: CursorCleanupRetryTarget): CursorCleanupRetryPlan => {
+ purgeExpiredCursorCleanupRetryPlans();
+ const record = cursorCleanupRetryPlans.get(target.token);
+ if (!record) {
+ throw new Error('Cursor cleanup retry token is missing or expired.');
+ }
+
+ return record.plan;
+};
+
+const consumeCursorCleanupRetryPlan = (
+ target: CursorCleanupRetryTarget,
+ workspaceKey: string,
+): CursorCleanupRetryPlan => {
+ const plan = resolveCursorCleanupRetryPlan(target);
+ if (plan.workspaceKey !== workspaceKey) {
+ throw new Error('Cursor workspace retry target does not match the workspace key.');
+ }
+
+ cursorCleanupRetryPlans.delete(target.token);
+ return plan;
+};
+
+const consumeCursorCleanupRetryPlans = (
+ targets: Array,
+ workspaceKeys: string[],
+): Array => {
+ const plans = targets.map(() => null as CursorCleanupRetryPlan | null);
+ const seenTokens = new Set();
+ for (const [index, target] of targets.entries()) {
+ if (!target) {
+ continue;
+ }
+ if (seenTokens.has(target.token)) {
+ throw new Error('Cursor workspace retry targets must not reuse a token in one request.');
+ }
+ seenTokens.add(target.token);
+ const plan = resolveCursorCleanupRetryPlan(target);
+ if (plan.workspaceKey !== workspaceKeys[index]) {
+ throw new Error('Cursor workspace retry target does not match the workspace key.');
+ }
+ plans[index] = plan;
+ }
+
+ for (const target of targets) {
+ if (target) {
+ cursorCleanupRetryPlans.delete(target.token);
+ }
+ }
+
+ return plans;
+};
+
+const finalizeCursorPruneResult = (result: CursorPruneResult): CursorPruneResult => {
+ const retryPlan = result.retryPlan;
+ delete result.retryPlan;
+ delete result.retryTarget;
+ if (result.cleanupFailures.length > 0 && retryPlan) {
+ result.retryTarget = registerCursorCleanupRetryPlan(retryPlan);
+ }
+
+ return result;
+};
+
const threadSchema = z.object({
composerId: composerIdSchema,
});
@@ -81,6 +191,11 @@ const findGroupsByKeys = async (workspaceKeys: string[]) => {
});
};
+const toCleanupFailureMessage = (error: unknown): string => (error instanceof Error ? error.message : String(error));
+
+const isCursorSafetyError = (error: unknown): boolean =>
+ error instanceof Error && error.message.startsWith('Unsafe Cursor');
+
const deleteCursorWorkspaceGroup = async (group: CursorWorkspaceGroup) => {
const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
const {
@@ -96,21 +211,81 @@ const deleteCursorWorkspaceGroup = async (group: CursorWorkspaceGroup) => {
...group.buckets.flatMap((bucket) => bucket.threadComposerIds),
]),
];
- const result =
+ const deletableThreads = composerIds.length === 0 ? [] : await collectCursorThreadsForDeletion(composerIds);
+ const result: CursorPruneResult =
composerIds.length === 0
? {
bubblesDeleted: 0,
+ cleanupFailures: [],
composerDataDeleted: 0,
composerIds: [],
headersRemoved: 0,
transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
workspaceBucketsUpdated: 0,
}
- : await pruneCursorThreads(await collectCursorThreadsForDeletion(composerIds), true);
-
- await deleteCursorWorkspaceBuckets(group);
- await deleteCursorWorkspaceHistory(group);
- return result;
+ : await pruneCursorThreads(deletableThreads, true);
+
+ result.cleanupFailures ??= [];
+ try {
+ const bucketCleanup = await deleteCursorWorkspaceBuckets(group);
+ result.workspaceBucketsRemovedPaths = bucketCleanup.removedPaths;
+ result.cleanupFailures.push(...bucketCleanup.cleanupFailures);
+ } catch (error) {
+ if (isCursorSafetyError(error)) {
+ throw error;
+ }
+ result.cleanupFailures.push({
+ error: toCleanupFailureMessage(error),
+ phase: 'workspace_buckets',
+ });
+ }
+ try {
+ const historyCleanup = await deleteCursorWorkspaceHistory(group);
+ result.workspaceHistoryRemovedPaths = historyCleanup.removedPaths;
+ result.cleanupFailures.push(...historyCleanup.cleanupFailures);
+ } catch (error) {
+ if (isCursorSafetyError(error)) {
+ throw error;
+ }
+ result.cleanupFailures.push({
+ error: toCleanupFailureMessage(error),
+ phase: 'workspace_history',
+ });
+ }
+ if (result.cleanupFailures.length > 0) {
+ const transcriptFailure = result.cleanupFailures.some((failure) => failure.phase === 'transcript_directory');
+ const bucketFailure = result.cleanupFailures.some((failure) => failure.phase === 'workspace_buckets');
+ result.retryPlan = {
+ bucketPaths: result.cleanupFailures
+ .filter((failure) => failure.phase === 'workspace_buckets' && failure.path)
+ .map((failure) => failure.path!)
+ .concat(
+ bucketFailure &&
+ result.cleanupFailures.some((failure) => failure.phase === 'workspace_buckets' && !failure.path)
+ ? group.buckets.map((bucket) => path.dirname(bucket.workspaceJsonPath))
+ : [],
+ ),
+ composerIds,
+ folders: group.folders,
+ historyPaths: result.cleanupFailures
+ .filter((failure) => failure.phase === 'workspace_history' && failure.path)
+ .map((failure) => failure.path!),
+ transcriptDirs: result.cleanupFailures
+ .filter((failure) => failure.phase === 'transcript_directory' && failure.path)
+ .map((failure) => failure.path!)
+ .concat(
+ transcriptFailure &&
+ result.cleanupFailures.some(
+ (failure) => failure.phase === 'transcript_directory' && !failure.path,
+ )
+ ? deletableThreads.flatMap((thread) => thread.transcriptDirs)
+ : [],
+ ),
+ workspaceKey: group.key,
+ };
+ }
+ return finalizeCursorPruneResult(result);
};
const findCursorWorkspacesByComposerId = async (
@@ -349,9 +524,14 @@ export const deleteCursorThreadsFn = createServerFn({ method: 'POST' })
});
export const deleteCursorWorkspaceFn = createServerFn({ method: 'POST' })
- .validator(workspaceSchema)
+ .validator(deleteWorkspaceSchema)
.handler(async ({ data }) => {
await ensureCursorClosedForWrite();
+ if (data.retry) {
+ const retryPlan = consumeCursorCleanupRetryPlan(data.retry, data.workspaceKey);
+ const { retryCursorWorkspaceCleanup } = await import('@spiracha/lib/cursor-recovery');
+ return finalizeCursorPruneResult(await retryCursorWorkspaceCleanup(retryPlan));
+ }
const group = await findGroupByKey(data.workspaceKey);
return deleteCursorWorkspaceGroup(group);
});
@@ -360,10 +540,23 @@ export const deleteCursorWorkspacesFn = createServerFn({ method: 'POST' })
.validator(workspacesSchema)
.handler(async ({ data }) => {
await ensureCursorClosedForWrite();
- const groups = await findGroupsByKeys(data.workspaceKeys);
- const results = [];
- for (const group of groups) {
- results.push(await deleteCursorWorkspaceGroup(group));
+ const retryTargets = data.retryTargets ?? [];
+ if (retryTargets.length > 0 && retryTargets.length !== data.workspaceKeys.length) {
+ throw new Error('Cursor workspace retry targets must match the workspace key count.');
+ }
+ const retryPlans = consumeCursorCleanupRetryPlans(retryTargets, data.workspaceKeys);
+ const groupKeys = data.workspaceKeys.filter((_workspaceKey, index) => !retryPlans[index]);
+ const groupsByKey = new Map((await findGroupsByKeys(groupKeys)).map((group) => [group.key, group]));
+ const results: CursorPruneResult[] = [];
+ for (const [index, workspaceKey] of data.workspaceKeys.entries()) {
+ const retryTarget = retryTargets[index];
+ const retryPlan = retryPlans[index];
+ if (retryTarget && retryPlan) {
+ const { retryCursorWorkspaceCleanup } = await import('@spiracha/lib/cursor-recovery');
+ results.push(finalizeCursorPruneResult(await retryCursorWorkspaceCleanup(retryPlan)));
+ continue;
+ }
+ results.push(await deleteCursorWorkspaceGroup(groupsByKey.get(workspaceKey)!));
}
return results;
diff --git a/src/ui/lib/cursor-server.vitest.ts b/src/ui/lib/cursor-server.vitest.ts
index 11cf0f2..b879861 100644
--- a/src/ui/lib/cursor-server.vitest.ts
+++ b/src/ui/lib/cursor-server.vitest.ts
@@ -15,6 +15,7 @@ const {
listCursorThreadsForGroupMock,
listCursorWorkspaceGroupsMock,
pruneCursorThreadsMock,
+ retryCursorWorkspaceCleanupMock,
readCursorThreadTranscriptWithAgentFilesMock,
recoverCursorWorkspaceGroupMock,
renderCursorTranscriptMock,
@@ -34,6 +35,7 @@ const {
renderCursorTranscriptMock: vi.fn(),
renderSourceSessionDownloadMock: vi.fn(),
renderSourceSessionsDownloadMock: vi.fn(),
+ retryCursorWorkspaceCleanupMock: vi.fn(),
}));
vi.mock('@tanstack/react-start', () => ({
@@ -79,6 +81,7 @@ vi.mock('@spiracha/lib/cursor-recovery', () => ({
isCursorRunning: isCursorRunningMock,
pruneCursorThreads: pruneCursorThreadsMock,
recoverCursorWorkspaceGroup: recoverCursorWorkspaceGroupMock,
+ retryCursorWorkspaceCleanup: retryCursorWorkspaceCleanupMock,
}));
vi.mock('@spiracha/lib/cursor-transcript', () => ({
@@ -175,6 +178,8 @@ describe('findCursorThreadByComposerId', () => {
beforeEach(() => {
vi.clearAllMocks();
isCursorRunningMock.mockResolvedValue(false);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
});
it('should query workspace threads without transcript directory discovery', async () => {
@@ -217,7 +222,7 @@ describe('deleteCursorThreadsFn', () => {
};
collectCursorThreadsForDeletionMock.mockResolvedValue(deletableThreads);
pruneCursorThreadsMock.mockResolvedValue(result);
- deleteCursorWorkspaceBucketsMock.mockResolvedValue(1);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
await expect(deleteCursorThreadsFn({ data: { composerIds: ['thread-1', 'thread-2'] } })).resolves.toBe(result);
@@ -252,6 +257,8 @@ describe('deleteCursorWorkspaceFn', () => {
beforeEach(() => {
vi.clearAllMocks();
isCursorRunningMock.mockResolvedValue(false);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
});
it('should delete every Cursor thread in a workspace', async () => {
@@ -287,29 +294,205 @@ describe('deleteCursorWorkspaceFn', () => {
it('should remove an already-empty Cursor workspace bucket', async () => {
const result = {
bubblesDeleted: 0,
+ cleanupFailures: [],
composerDataDeleted: 0,
composerIds: [],
headersRemoved: 0,
transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
workspaceBucketsUpdated: 0,
};
listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
listCursorThreadsForGroupMock.mockResolvedValue([]);
- deleteCursorWorkspaceBucketsMock.mockResolvedValue(1);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
- await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } })).resolves.toEqual(result);
+ await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } })).resolves.toEqual({
+ ...result,
+ workspaceBucketsRemovedPaths: [],
+ workspaceHistoryRemovedPaths: [],
+ });
expect(collectCursorThreadsForDeletionMock).not.toHaveBeenCalled();
expect(pruneCursorThreadsMock).not.toHaveBeenCalled();
expect(deleteCursorWorkspaceBucketsMock).toHaveBeenCalledWith(workspaceOne);
expect(deleteCursorWorkspaceHistoryMock).toHaveBeenCalledWith(workspaceOne);
});
+
+ it('should return filesystem cleanup failures after the database deletion succeeds', async () => {
+ const result = {
+ bubblesDeleted: 1,
+ cleanupFailures: [],
+ composerDataDeleted: 1,
+ composerIds: ['thread-1'],
+ headersRemoved: 1,
+ transcriptDirsRemoved: 1,
+ workspaceBucketsUpdated: 1,
+ };
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValue([makeThread()]);
+ collectCursorThreadsForDeletionMock.mockResolvedValue([{ composerId: 'thread-1' }]);
+ pruneCursorThreadsMock.mockResolvedValue(result);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({
+ cleanupFailures: [{ error: 'workspace bucket is busy', path: '/tmp/bucket-b', phase: 'workspace_buckets' }],
+ removedPaths: ['/tmp/bucket-a'],
+ });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+
+ await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } })).resolves.toMatchObject({
+ ...result,
+ cleanupFailures: [{ error: 'workspace bucket is busy', path: '/tmp/bucket-b', phase: 'workspace_buckets' }],
+ retryTarget: { token: expect.any(String) },
+ workspaceBucketsRemovedPaths: ['/tmp/bucket-a'],
+ });
+ expect(deleteCursorWorkspaceHistoryMock).toHaveBeenCalledWith(workspaceOne);
+ });
+
+ it('should retry cleanup from the returned target after the workspace is no longer discoverable', async () => {
+ const retryPlan = {
+ bucketPaths: [],
+ composerIds: ['thread-1'],
+ folders: workspaceOne.folders,
+ historyPaths: [],
+ transcriptDirs: ['/tmp/transcript/thread-1'],
+ workspaceKey: workspaceOne.key,
+ };
+ const firstResult = {
+ bubblesDeleted: 1,
+ cleanupFailures: [
+ {
+ error: 'transcript is busy',
+ path: '/tmp/transcript/thread-1',
+ phase: 'transcript_directory' as const,
+ },
+ ],
+ composerDataDeleted: 1,
+ composerIds: ['thread-1'],
+ headersRemoved: 1,
+ transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
+ workspaceBucketsUpdated: 1,
+ };
+ const retryResult = { ...firstResult, cleanupFailures: [] };
+ listCursorWorkspaceGroupsMock.mockResolvedValueOnce([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValueOnce([makeThread()]);
+ collectCursorThreadsForDeletionMock.mockResolvedValueOnce([{ composerId: 'thread-1' }]);
+ pruneCursorThreadsMock.mockResolvedValueOnce(firstResult);
+ retryCursorWorkspaceCleanupMock.mockResolvedValue(retryResult);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+
+ const firstResponse = await deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } });
+ expect(firstResponse).toMatchObject({ retryTarget: { token: expect.any(String) } });
+ expect(firstResponse).not.toHaveProperty('retryPlan');
+ if (!firstResponse.retryTarget) {
+ throw new Error('Expected a server-held Cursor cleanup retry token.');
+ }
+ await expect(
+ deleteCursorWorkspaceFn({
+ data: { retry: firstResponse.retryTarget, workspaceKey: 'folder:/tmp/other-workspace' },
+ }),
+ ).rejects.toThrow('does not match the workspace key');
+ expect(retryCursorWorkspaceCleanupMock).not.toHaveBeenCalled();
+ await expect(
+ deleteCursorWorkspaceFn({ data: { retry: firstResponse.retryTarget, workspaceKey: workspaceOne.key } }),
+ ).resolves.toEqual(retryResult);
+
+ expect(retryCursorWorkspaceCleanupMock).toHaveBeenCalledWith(retryPlan);
+ expect(listCursorWorkspaceGroupsMock).toHaveBeenCalledTimes(1);
+ });
+
+ it('should consume a retry token before awaiting cleanup so concurrent replays are rejected', async () => {
+ const result = {
+ bubblesDeleted: 0,
+ cleanupFailures: [],
+ composerDataDeleted: 0,
+ composerIds: ['thread-1'],
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ workspaceBucketsUpdated: 0,
+ };
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValue([makeThread()]);
+ collectCursorThreadsForDeletionMock.mockResolvedValue([{ composerId: 'thread-1' }]);
+ pruneCursorThreadsMock.mockResolvedValue(result);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({
+ cleanupFailures: [{ error: 'busy', path: '/tmp/bucket', phase: 'workspace_buckets' }],
+ removedPaths: [],
+ });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+
+ const firstResponse = await deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } });
+ if (!firstResponse.retryTarget) {
+ throw new Error('Expected a server-held Cursor cleanup retry token.');
+ }
+
+ let releaseRetry!: (value: typeof result) => void;
+ retryCursorWorkspaceCleanupMock.mockImplementation(
+ () => new Promise((resolve) => (releaseRetry = resolve as (value: typeof result) => void)),
+ );
+ const retryInput = { data: { retry: firstResponse.retryTarget, workspaceKey: workspaceOne.key } };
+ const firstRetry = deleteCursorWorkspaceFn(retryInput);
+ await vi.waitFor(() => expect(retryCursorWorkspaceCleanupMock).toHaveBeenCalledTimes(1));
+
+ await expect(deleteCursorWorkspaceFn(retryInput)).rejects.toThrow('missing or expired');
+ releaseRetry({ ...result, cleanupFailures: [] });
+ await expect(firstRetry).resolves.toMatchObject({ cleanupFailures: [] });
+ });
+
+ it('should validate duplicate batch retry tokens before consuming any token', async () => {
+ listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne]);
+ listCursorThreadsForGroupMock.mockResolvedValue([]);
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({
+ cleanupFailures: [{ error: 'busy', path: '/tmp/bucket', phase: 'workspace_buckets' }],
+ removedPaths: [],
+ });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+
+ const firstResponse = await deleteCursorWorkspaceFn({ data: { workspaceKey: workspaceOne.key } });
+ if (!firstResponse.retryTarget) {
+ throw new Error('Expected a server-held Cursor cleanup retry token.');
+ }
+
+ retryCursorWorkspaceCleanupMock.mockResolvedValue({
+ bubblesDeleted: 0,
+ cleanupFailures: [],
+ composerDataDeleted: 0,
+ composerIds: [],
+ headersRemoved: 0,
+ transcriptDirsRemoved: 0,
+ transcriptDirsRemovedPaths: [],
+ workspaceBucketsUpdated: 0,
+ });
+ await expect(
+ deleteCursorWorkspacesFn({
+ data: {
+ retryTargets: [firstResponse.retryTarget, firstResponse.retryTarget],
+ workspaceKeys: [workspaceOne.key, workspaceOne.key],
+ },
+ }),
+ ).rejects.toThrow('must not reuse a token');
+ expect(retryCursorWorkspaceCleanupMock).not.toHaveBeenCalled();
+
+ await expect(
+ deleteCursorWorkspaceFn({ data: { retry: firstResponse.retryTarget, workspaceKey: workspaceOne.key } }),
+ ).resolves.toMatchObject({ cleanupFailures: [] });
+ });
});
describe('deleteCursorWorkspacesFn', () => {
beforeEach(() => {
vi.clearAllMocks();
isCursorRunningMock.mockResolvedValue(false);
+ listCursorWorkspaceGroupsMock.mockReset();
+ deleteCursorWorkspaceBucketsMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+ deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
+ });
+
+ it('should reject workspace deletion batches larger than the retry token capacity', async () => {
+ const workspaceKeys = Array.from({ length: 129 }, (_value, index) => `folder:/tmp/workspace-${index}`);
+
+ await expect(deleteCursorWorkspacesFn({ data: { workspaceKeys } })).rejects.toThrow();
+ expect(isCursorRunningMock).not.toHaveBeenCalled();
});
it('should delete multiple Cursor workspaces in one request', async () => {
@@ -340,7 +523,7 @@ describe('deleteCursorWorkspacesFn', () => {
await expect(
deleteCursorWorkspacesFn({ data: { workspaceKeys: [workspaceOne.key, workspaceTwo.key] } }),
- ).resolves.toEqual([workspaceOneResult, workspaceTwoResult]);
+ ).resolves.toMatchObject([workspaceOneResult, workspaceTwoResult]);
expect(isCursorRunningMock).toHaveBeenCalledTimes(1);
expect(listCursorWorkspaceGroupsMock).toHaveBeenCalledTimes(1);
diff --git a/src/ui/lib/download.ts b/src/ui/lib/download.ts
index dfa087c..a2d7ecf 100644
--- a/src/ui/lib/download.ts
+++ b/src/ui/lib/download.ts
@@ -11,6 +11,16 @@ export class DownloadCancelledError extends Error {
}
}
+export class DownloadAvailabilityError extends Error {
+ readonly status: number;
+
+ constructor(status: number) {
+ super(`Download URL probe failed with HTTP ${status}`);
+ this.name = 'DownloadAvailabilityError';
+ this.status = status;
+ }
+}
+
type DownloadTextOptions = {
createObjectUrl?: (blob: Blob) => string;
documentRef?: Document;
@@ -216,6 +226,10 @@ const probeDownloadUrl = async (
return true;
}
+ if (response.status !== 404) {
+ throw new DownloadAvailabilityError(response.status);
+ }
+
logDownloadEvent(logger, 'warn', 'url_not_ready', {
attempt,
downloadUrl,
@@ -229,6 +243,10 @@ const probeDownloadUrl = async (
throw cancellationError;
}
+ if (error instanceof DownloadAvailabilityError) {
+ throw error;
+ }
+
logDownloadEvent(logger, 'warn', 'url_probe_failed', {
attempt,
downloadUrl,
diff --git a/src/ui/lib/download.vitest.ts b/src/ui/lib/download.vitest.ts
index 5dd8fec..dc9a3a5 100644
--- a/src/ui/lib/download.vitest.ts
+++ b/src/ui/lib/download.vitest.ts
@@ -135,6 +135,25 @@ describe('downloadUrlFile', () => {
).rejects.toThrow('Download file was not available after 2 attempts: missing.zip');
});
+ it('should fail fast for permanent HTTP probe failures', async () => {
+ const sleep = vi.fn(async () => {});
+
+ await expect(
+ waitForDownloadUrlAvailability('/__exports/failed.zip', 'failed.zip', {
+ fetchImpl: vi.fn().mockResolvedValue(new Response(null, { status: 500 })),
+ logger: {
+ error: vi.fn(),
+ info: vi.fn(),
+ warn: vi.fn(),
+ },
+ maxAttempts: 3,
+ sleep,
+ }),
+ ).rejects.toThrow('Download URL probe failed with HTTP 500');
+
+ expect(sleep).not.toHaveBeenCalled();
+ });
+
it('should abort a pending HEAD fetch without probing again or creating an anchor', async () => {
const controller = new AbortController();
const fetchImpl = vi.fn();
diff --git a/src/ui/lib/export-mutation.ts b/src/ui/lib/export-mutation.ts
index 6d72227..88aa4f8 100644
--- a/src/ui/lib/export-mutation.ts
+++ b/src/ui/lib/export-mutation.ts
@@ -1,19 +1,22 @@
-import type { ExportDialogOptions } from '#/lib/export-options';
+import type { ExportDialogOptions, ExportLifecycleCallbacks } from '#/lib/export-options';
export type ExportSelectionMutationInput = Readonly<{
ids: readonly string[];
options: Readonly;
-}>;
+}> &
+ ExportLifecycleCallbacks;
export const createExportSelectionMutationInput = (
ids: readonly string[],
options: ExportDialogOptions,
+ callbacks: ExportLifecycleCallbacks = {},
): ExportSelectionMutationInput => {
if (ids.length === 0) {
throw new Error('No conversations selected for export');
}
return {
+ ...callbacks,
ids: [...ids],
options: { ...options },
};
diff --git a/src/ui/lib/export-options.ts b/src/ui/lib/export-options.ts
index e78ea62..825744f 100644
--- a/src/ui/lib/export-options.ts
+++ b/src/ui/lib/export-options.ts
@@ -1,3 +1,5 @@
+import type { DownloadLifecycleState } from '#/lib/download';
+
export type ExportDialogOptions = {
includeCommentary: boolean;
includeMetadata: boolean;
@@ -6,6 +8,10 @@ export type ExportDialogOptions = {
zipArchive: boolean;
};
+export type ExportLifecycleCallbacks = {
+ onDownloadStateChange?: (state: DownloadLifecycleState) => void;
+};
+
export const DEFAULT_EXPORT_DIALOG_OPTIONS: ExportDialogOptions = {
includeCommentary: false,
includeMetadata: true,
diff --git a/src/ui/lib/minimax-code-transcript-events.vitest.ts b/src/ui/lib/minimax-code-transcript-events.vitest.ts
index fbd2c89..4092bea 100644
--- a/src/ui/lib/minimax-code-transcript-events.vitest.ts
+++ b/src/ui/lib/minimax-code-transcript-events.vitest.ts
@@ -126,4 +126,34 @@ describe('MiniMax Code transcript events', () => {
userMessageCount: 1,
});
});
+
+ it('should preserve long commentary findings so the commentary control can reveal them', () => {
+ const events = miniMaxCodeTranscriptToThreadEvents({
+ ...transcript,
+ messages: [
+ ...transcript.messages,
+ {
+ content:
+ '### 25. validate.ts — not enough view; need to read to identify if it gates requestedPhase',
+ createdAtMs: 1_700_000_003_000,
+ finishReason: 'toolUse',
+ messageId: 'assistant-review-finding',
+ messageType: 2,
+ raw: {},
+ reasoning: null,
+ role: 'assistant',
+ thinkingDurationMs: null,
+ toolCalls: [],
+ },
+ ],
+ });
+
+ expect(events).toContainEqual(
+ expect.objectContaining({
+ kind: 'message',
+ phase: 'commentary',
+ text: expect.stringContaining('not enough view; need to read'),
+ }),
+ );
+ });
});
diff --git a/src/ui/routes/-cursor-workspace.vitest.tsx b/src/ui/routes/-cursor-workspace.vitest.tsx
new file mode 100644
index 0000000..27e75bb
--- /dev/null
+++ b/src/ui/routes/-cursor-workspace.vitest.tsx
@@ -0,0 +1,16 @@
+import { expect, it, vi } from 'vitest';
+
+vi.mock('@tanstack/react-router', () => ({
+ createFileRoute:
+ () =>
+ (options: unknown): { options: unknown } => ({ options }),
+ Link: ({ children }: { children: unknown }) => children,
+ useNavigate: () => vi.fn(),
+}));
+
+import { getCursorWorkspaceQueryOptions } from './cursor.$workspaceKey';
+
+it('should disable workspace discovery refetch while a deletion can be retried', () => {
+ expect(getCursorWorkspaceQueryOptions(true).refetchOnWindowFocus).toBe(false);
+ expect(getCursorWorkspaceQueryOptions(false).refetchOnWindowFocus).toBeUndefined();
+});
diff --git a/src/ui/routes/codex.$project.tsx b/src/ui/routes/codex.$project.tsx
index bfc8986..98d894f 100644
--- a/src/ui/routes/codex.$project.tsx
+++ b/src/ui/routes/codex.$project.tsx
@@ -171,7 +171,7 @@ function ProjectDetailPage() {
});
const exportThreadMutation = useMutation({
- mutationFn: async ({ ids, options }: ExportSelectionMutationInput) => {
+ mutationFn: async ({ ids, onDownloadStateChange, options }: ExportSelectionMutationInput) => {
console.info('[spiracha:export-ui] request', {
outputFormat: options.outputFormat,
project,
@@ -206,11 +206,16 @@ function ProjectDetailPage() {
});
if (download.mode === 'download') {
- downloadTextFile(download.fileName, download.content, download.mimeType);
- return;
+ downloadTextFile(download.fileName, download.content, download.mimeType, {
+ onStateChange: onDownloadStateChange,
+ });
+ return download;
}
- await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl, {
+ onStateChange: onDownloadStateChange,
+ });
+ return download;
},
onError: (error, variables) => {
console.error('[spiracha:export-ui] failed', {
@@ -220,7 +225,11 @@ function ProjectDetailPage() {
selectedThreadIds: variables.ids,
});
},
- onSuccess: () => {
+ onSuccess: (download) => {
+ if (download.mode === 'download_url' && (download.skippedThreadCount ?? 0) > 0) {
+ return;
+ }
+
setPendingExport(null);
},
});
@@ -373,11 +382,16 @@ function ProjectDetailPage() {
forceZipArchive={shouldForceZipArchive(pendingExport)}
open={pendingExport !== null}
pending={exportThreadMutation.isPending}
+ skippedThreadCount={
+ exportThreadMutation.data?.mode === 'download_url'
+ ? exportThreadMutation.data.skippedThreadCount
+ : undefined
+ }
title={pendingExport ? `Export ${pendingExport.threadLabel}` : 'Export thread'}
- onExport={(options) => {
+ onExport={(options, callbacks) => {
if (pendingExport) {
exportThreadMutation.mutate(
- createExportSelectionMutationInput(pendingExport.threadIds, options),
+ createExportSelectionMutationInput(pendingExport.threadIds, options, callbacks),
);
}
}}
diff --git a/src/ui/routes/cursor.$workspaceKey.tsx b/src/ui/routes/cursor.$workspaceKey.tsx
index e675058..6157ad9 100644
--- a/src/ui/routes/cursor.$workspaceKey.tsx
+++ b/src/ui/routes/cursor.$workspaceKey.tsx
@@ -1,4 +1,8 @@
-import type { CursorThreadSummary, CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
+import type {
+ CursorCleanupRetryTarget,
+ CursorThreadSummary,
+ CursorWorkspaceGroup,
+} from '@spiracha/lib/cursor-exporter-types';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
import { RefreshCcw, Trash2 } from 'lucide-react';
@@ -11,6 +15,7 @@ import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
import { Button } from '#/components/ui/button';
+import { getCursorCleanupFailureMessage, hasCursorCleanupFailures } from '#/lib/cursor-delete-result';
import { cursorThreadsQueryOptions, cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
import {
deleteCursorThreadsFn,
@@ -27,13 +32,17 @@ import { isWorkspaceEmptiedByDelete } from '#/lib/workspace-delete-navigation';
type PendingCursorDelete =
| { kind: 'threads'; threads: CursorThreadSummary[] }
- | { kind: 'workspace'; workspace: CursorWorkspaceGroup };
+ | { kind: 'workspace'; retryTarget?: CursorCleanupRetryTarget; workspace: CursorWorkspaceGroup };
type PendingCursorExport = {
composerIds: string[];
label: string;
};
+type CursorWorkspaceQueryOptions = ReturnType & {
+ refetchOnWindowFocus?: boolean;
+};
+
const findWorkspaceOrThrow = (workspaces: CursorWorkspaceGroup[], workspaceKey: string) => {
const workspace = workspaces.find((candidate) => candidate.key === workspaceKey);
if (!workspace) {
@@ -43,6 +52,11 @@ const findWorkspaceOrThrow = (workspaces: CursorWorkspaceGroup[], workspaceKey:
return workspace;
};
+export const getCursorWorkspaceQueryOptions = (workspaceDeletePending: boolean): CursorWorkspaceQueryOptions => ({
+ ...cursorWorkspacesQueryOptions(),
+ refetchOnWindowFocus: workspaceDeletePending ? false : undefined,
+});
+
const getSelectedThreads = (threads: CursorThreadSummary[], composerIds: string[]) => {
const composerIdSet = new Set(composerIds);
return threads.filter((thread) => composerIdSet.has(thread.composerId));
@@ -116,13 +130,14 @@ const CursorWorkspacePage = () => {
const navigate = useNavigate();
const params = Route.useParams();
const queryClient = useQueryClient();
- const workspaces = useSuspenseQuery(cursorWorkspacesQueryOptions()).data;
- const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
- const threads = useSuspenseQuery(cursorThreadsQueryOptions(workspace.key)).data;
const [searchInput, setSearchInput] = useState('');
const [pendingDelete, setPendingDelete] = useState(null);
+ const [partialDeleteError, setPartialDeleteError] = useState(null);
const [pendingExport, setPendingExport] = useState(null);
const deferredSearch = useDeferredValue(searchInput);
+ const workspaces = useSuspenseQuery(getCursorWorkspaceQueryOptions(pendingDelete?.kind === 'workspace')).data;
+ const workspace = findWorkspaceOrThrow(workspaces, params.workspaceKey);
+ const threads = useSuspenseQuery(cursorThreadsQueryOptions(workspace.key)).data;
const invalidateWorkspaceQueries = async () => {
await Promise.all([
@@ -139,9 +154,35 @@ const CursorWorkspacePage = () => {
const deleteMutation = useMutation({
mutationFn: (target: PendingCursorDelete) =>
target.kind === 'workspace'
- ? deleteCursorWorkspaceFn({ data: { workspaceKey: target.workspace.key } })
+ ? deleteCursorWorkspaceFn({
+ data: {
+ ...(target.retryTarget ? { retry: target.retryTarget } : {}),
+ workspaceKey: target.workspace.key,
+ },
+ })
: deleteCursorThreadsFn({ data: { composerIds: target.threads.map((thread) => thread.composerId) } }),
- onSuccess: async (_result, target) => {
+ onSettled: (result, _error, target) => {
+ if (target?.kind === 'workspace') {
+ if (hasCursorCleanupFailures(result)) {
+ return;
+ }
+
+ return queryClient.invalidateQueries({ queryKey: ['cursor-workspaces'] });
+ }
+
+ return invalidateWorkspaceQueries();
+ },
+ onSuccess: async (result, target) => {
+ const cleanupError = getCursorCleanupFailureMessage(result);
+ if (cleanupError) {
+ setPartialDeleteError(cleanupError);
+ if (target.kind === 'workspace' && !Array.isArray(result) && result.retryTarget) {
+ setPendingDelete({ ...target, retryTarget: result.retryTarget });
+ }
+ return;
+ }
+
+ setPartialDeleteError(null);
if (target.kind === 'workspace') {
await navigate({ to: '/cursor' });
queryClient.removeQueries({ queryKey: ['cursor-thread'] });
@@ -202,6 +243,7 @@ const CursorWorkspacePage = () => {
const openDeleteForSelectedThreads = (composerIds: string[]) => {
const nextPendingDelete = buildPendingCursorDelete(getSelectedThreads(visibleThreads, composerIds));
if (nextPendingDelete) {
+ setPartialDeleteError(null);
setPendingDelete(nextPendingDelete);
}
};
@@ -221,7 +263,10 @@ const CursorWorkspacePage = () => {
recoverPending={recoverWorkspaceMutation.isPending}
searchInput={searchInput}
workspace={workspace}
- onDeleteWorkspace={() => setPendingDelete({ kind: 'workspace', workspace })}
+ onDeleteWorkspace={() => {
+ setPartialDeleteError(null);
+ setPendingDelete({ kind: 'workspace', workspace });
+ }}
onRecoverWorkspace={() => recoverWorkspaceMutation.mutate()}
onSearchInputChange={setSearchInput}
/>
@@ -234,7 +279,10 @@ const CursorWorkspacePage = () => {
setPendingDelete({ kind: 'threads', threads: [thread] })}
+ onDeleteThread={(thread) => {
+ setPartialDeleteError(null);
+ setPendingDelete({ kind: 'threads', threads: [thread] });
+ }}
onDeleteThreads={openDeleteForSelectedThreads}
onExportThread={(thread) =>
setPendingExport({
@@ -252,7 +300,7 @@ const CursorWorkspacePage = () => {
/>
{
@@ -260,10 +308,12 @@ const CursorWorkspacePage = () => {
return;
}
+ setPartialDeleteError(null);
deleteMutation.mutate(pendingDelete);
}}
onOpenChange={(open) => {
if (!open) {
+ setPartialDeleteError(null);
setPendingDelete(null);
deleteMutation.reset();
}
diff --git a/src/ui/routes/cursor.index.tsx b/src/ui/routes/cursor.index.tsx
index 85c1af7..b44370a 100644
--- a/src/ui/routes/cursor.index.tsx
+++ b/src/ui/routes/cursor.index.tsx
@@ -1,4 +1,4 @@
-import type { CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
+import type { CursorCleanupRetryTarget, CursorWorkspaceGroup } from '@spiracha/lib/cursor-exporter-types';
import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query';
import { createFileRoute } from '@tanstack/react-router';
import { useDeferredValue, useState } from 'react';
@@ -8,6 +8,7 @@ import { ListSearchInput } from '#/components/list-search-input';
import { LoadingPanel } from '#/components/loading-panel';
import { PageHeader } from '#/components/page-header';
import { RouteErrorPanel } from '#/components/route-error-panel';
+import { getCursorCleanupFailureMessage } from '#/lib/cursor-delete-result';
import { cursorWorkspacesQueryOptions } from '#/lib/cursor-queries';
import { deleteCursorWorkspaceFn, deleteCursorWorkspacesFn, recoverCursorWorkspaceFn } from '#/lib/cursor-server';
import { getMutationErrorMessage } from '#/lib/mutation-error';
@@ -21,6 +22,10 @@ const CursorPendingComponent = () => (
);
+type PendingCursorWorkspace = CursorWorkspaceGroup & {
+ retryTarget?: CursorCleanupRetryTarget;
+};
+
const getWorkspaceDeleteDescription = (workspaces: CursorWorkspaceGroup[] | null) => {
if (!workspaces || workspaces.length === 0) {
return '';
@@ -37,7 +42,8 @@ const CursorPage = () => {
const queryClient = useQueryClient();
const workspaces = useSuspenseQuery(cursorWorkspacesQueryOptions()).data;
const [searchInput, setSearchInput] = useState('');
- const [pendingDelete, setPendingDelete] = useState(null);
+ const [pendingDelete, setPendingDelete] = useState(null);
+ const [partialDeleteError, setPartialDeleteError] = useState(null);
const deferredSearch = useDeferredValue(searchInput);
const invalidateCursorQueries = async () => {
@@ -51,17 +57,44 @@ const CursorPage = () => {
});
const deleteWorkspaceMutation = useMutation({
- mutationFn: async (selectedWorkspaces: CursorWorkspaceGroup[]) => {
+ mutationFn: async (selectedWorkspaces: PendingCursorWorkspace[]) => {
+ const retryTargets = selectedWorkspaces.map((workspace) => workspace.retryTarget ?? null);
if (selectedWorkspaces.length === 1) {
- return [await deleteCursorWorkspaceFn({ data: { workspaceKey: selectedWorkspaces[0]!.key } })];
+ const workspace = selectedWorkspaces[0]!;
+ return [
+ await deleteCursorWorkspaceFn({
+ data: {
+ ...(workspace.retryTarget ? { retry: workspace.retryTarget } : {}),
+ workspaceKey: workspace.key,
+ },
+ }),
+ ];
}
return deleteCursorWorkspacesFn({
- data: { workspaceKeys: selectedWorkspaces.map((workspace) => workspace.key) },
+ data: { retryTargets, workspaceKeys: selectedWorkspaces.map((workspace) => workspace.key) },
});
},
onSettled: invalidateCursorQueries,
- onSuccess: () => {
+ onSuccess: (result, selectedWorkspaces) => {
+ const cleanupError = getCursorCleanupFailureMessage(result);
+ if (cleanupError) {
+ setPartialDeleteError(cleanupError);
+ const results = Array.isArray(result) ? result : [result];
+ const retryableWorkspaces = selectedWorkspaces
+ .map((workspace, index) => ({ retryTarget: results[index]?.retryTarget, workspace }))
+ .filter(
+ (entry): entry is { retryTarget: CursorCleanupRetryTarget; workspace: CursorWorkspaceGroup } =>
+ entry.retryTarget !== undefined,
+ )
+ .map(({ retryTarget, workspace }) => ({ ...workspace, retryTarget }));
+ if (retryableWorkspaces.length > 0) {
+ setPendingDelete(retryableWorkspaces);
+ }
+ return;
+ }
+
+ setPartialDeleteError(null);
setPendingDelete(null);
},
});
@@ -91,9 +124,13 @@ const CursorPage = () => {
/>
setPendingDelete([workspace])}
+ onDeleteWorkspace={(workspace) => {
+ setPartialDeleteError(null);
+ setPendingDelete([workspace]);
+ }}
onDeleteWorkspaces={(selectedWorkspaces) => {
if (selectedWorkspaces.length > 0) {
+ setPartialDeleteError(null);
setPendingDelete(selectedWorkspaces);
}
}}
@@ -126,7 +163,10 @@ const CursorPage = () => {
: 'Delete workspaces'
}
description={getWorkspaceDeleteDescription(pendingDelete)}
- errorMessage={getMutationErrorMessage(deleteWorkspaceMutation.error, 'Workspace deletion failed')}
+ errorMessage={
+ partialDeleteError ??
+ getMutationErrorMessage(deleteWorkspaceMutation.error, 'Workspace deletion failed')
+ }
open={pendingDelete !== null && pendingDelete.length > 0}
title={pendingDelete?.length === 1 ? 'Delete Cursor workspace?' : 'Delete Cursor workspaces?'}
onConfirm={() => {
@@ -134,10 +174,12 @@ const CursorPage = () => {
return;
}
+ setPartialDeleteError(null);
deleteWorkspaceMutation.mutate(pendingDelete);
}}
onOpenChange={(open) => {
if (!open) {
+ setPartialDeleteError(null);
setPendingDelete(null);
deleteWorkspaceMutation.reset();
}
diff --git a/src/ui/routes/threads.$threadId.tsx b/src/ui/routes/threads.$threadId.tsx
index 5a20715..44bd351 100644
--- a/src/ui/routes/threads.$threadId.tsx
+++ b/src/ui/routes/threads.$threadId.tsx
@@ -34,6 +34,7 @@ import { deleteThreadFn, exportThreadFn, type getThreadSnapshotFn } from '#/lib/
import { connectCodexThreadLiveUpdates, refreshCodexThreadLiveQueries } from '#/lib/codex-thread-live';
import type { CodexThreadLiveStatus } from '#/lib/codex-thread-live-types';
import { downloadTextFile, downloadUrlFileWithCancellation, useDownloadCancellation } from '#/lib/download';
+import type { ExportLifecycleCallbacks } from '#/lib/export-options';
import { formatBooleanLabel, formatBytes, formatDateTime, formatList, formatTokens } from '#/lib/formatters';
import { getMutationErrorMessage } from '#/lib/mutation-error';
import { applyPathTransforms } from '#/lib/path-utils';
@@ -707,24 +708,27 @@ function ThreadDetailPageContent() {
};
const exportThreadMutation = useMutation({
- mutationFn: async (options: {
- includeCommentary: boolean;
- includeTools: boolean;
- includeMetadata: boolean;
- outputFormat: 'md' | 'txt';
- zipArchive: boolean;
- }) => {
+ mutationFn: async (
+ options: {
+ includeCommentary: boolean;
+ includeTools: boolean;
+ includeMetadata: boolean;
+ outputFormat: 'md' | 'txt';
+ zipArchive: boolean;
+ } & ExportLifecycleCallbacks,
+ ) => {
+ const { onDownloadStateChange, ...exportOptions } = options;
console.info('[spiracha:export-ui] request', {
- outputFormat: options.outputFormat,
+ outputFormat: exportOptions.outputFormat,
project: snapshot.project,
selectedThreadCount: 1,
selectedThreadIds: [snapshot.thread.id],
- zipArchive: options.zipArchive,
+ zipArchive: exportOptions.zipArchive,
});
const download = await exportThreadFn({
data: {
- ...options,
+ ...exportOptions,
...settings,
threadId: snapshot.thread.id,
},
@@ -739,11 +743,16 @@ function ThreadDetailPageContent() {
});
if (download.mode === 'download') {
- downloadTextFile(download.fileName, download.content, download.mimeType);
- return;
+ downloadTextFile(download.fileName, download.content, download.mimeType, {
+ onStateChange: onDownloadStateChange,
+ });
+ return download;
}
- await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl);
+ await downloadUrlFileWithCancellation(downloadCancellation, download.fileName, download.downloadUrl, {
+ onStateChange: onDownloadStateChange,
+ });
+ return download;
},
onError: (error) => {
console.error('[spiracha:export-ui] failed', {
@@ -923,9 +932,9 @@ function ThreadDetailPageContent() {
focusedEvidenceTarget={{ id: snapshot.thread.id, source: 'codex' }}
open={exportOpen}
pending={exportThreadMutation.isPending}
- onExport={(options) => {
+ onExport={(options, callbacks) => {
if (!transcriptMissing) {
- exportThreadMutation.mutate(options);
+ exportThreadMutation.mutate({ ...options, ...callbacks });
}
}}
onOpenChange={(open) => {
From 8716aaaa89571e189f770ce220b8e5feeeff8f0a Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Mon, 17 Aug 2026 17:58:41 -0400
Subject: [PATCH 08/10] Fix antigravity keychain regression, deletion race
condition and export-dialog cancellation bug
---
src/lib/antigravity-keychain.ts | 47 ++++++++------
.../components/antigravity-keychain-panel.tsx | 2 +-
src/ui/components/export-dialog.tsx | 10 ++-
src/ui/components/export-dialog.vitest.tsx | 14 +++++
src/ui/lib/antigravity-server.ts | 10 +--
src/ui/lib/antigravity-server.vitest.ts | 62 ++++++++++++++++++-
src/ui/lib/delete-batch.ts | 18 +++++-
7 files changed, 135 insertions(+), 28 deletions(-)
diff --git a/src/lib/antigravity-keychain.ts b/src/lib/antigravity-keychain.ts
index 28b5147..661a332 100644
--- a/src/lib/antigravity-keychain.ts
+++ b/src/lib/antigravity-keychain.ts
@@ -27,6 +27,9 @@ const SAFE_STORAGE_ITERATIONS = 1003;
const SAFE_STORAGE_KEY_LENGTH = 16;
const SAFE_STORAGE_IV = Buffer.alloc(16, 0x20);
+let cachedKeychainSecret: string | null = null;
+let lastKeychainError: string | null = null;
+
export const deriveAntigravitySafeStorageKey = (keychainSecret: string | Buffer): Buffer => {
return pbkdf2Sync(keychainSecret, SAFE_STORAGE_SALT, SAFE_STORAGE_ITERATIONS, SAFE_STORAGE_KEY_LENGTH, 'sha1');
};
@@ -127,12 +130,16 @@ export const createAntigravityDecryptionCapability = (keychainSecret: string): A
});
export const getAntigravityDecryptionState = ({
+ cachedSecret = cachedKeychainSecret,
error = null,
- hasAccess = false,
+ hasAccess,
+ lastError = lastKeychainError,
platform = process.platform,
}: {
+ cachedSecret?: string | null;
error?: string | null;
hasAccess?: boolean;
+ lastError?: string | null;
platform?: NodeJS.Platform;
} = {}): AntigravityDecryptionState => {
if (platform !== 'darwin') {
@@ -148,7 +155,10 @@ export const getAntigravityDecryptionState = ({
};
}
- if (hasAccess) {
+ const accessAvailable = hasAccess ?? Boolean(cachedSecret);
+ const stateError = error ?? lastError;
+
+ if (accessAvailable) {
return {
canRequestAccess: true,
error: null,
@@ -163,13 +173,13 @@ export const getAntigravityDecryptionState = ({
return {
canRequestAccess: true,
- error,
+ error: stateError,
isUnlocked: false,
keychainAccount: ANTIGRAVITY_KEYCHAIN_ACCOUNT,
keychainService: ANTIGRAVITY_KEYCHAIN_SERVICE,
platform,
provider: 'keychain',
- status: error ? 'error' : 'locked',
+ status: stateError ? 'error' : 'locked',
};
};
@@ -200,26 +210,27 @@ const getAntigravityKeychainError = (error: unknown): string => {
export const withAntigravityDecryptionCapability = async (
action: (capability: AntigravityDecryptionCapability) => T | Promise,
): Promise => {
- let keychainSecret: string;
- try {
- keychainSecret = await readAntigravityKeychainSecret();
- } catch (error) {
- throw new AntigravityDecryptionCapabilityError(error);
+ if (!cachedKeychainSecret) {
+ throw new AntigravityDecryptionCapabilityError(
+ new Error('Antigravity Keychain access has not been enabled for this server process.'),
+ );
}
- return action(createAntigravityDecryptionCapability(keychainSecret));
+
+ return action(createAntigravityDecryptionCapability(cachedKeychainSecret));
};
export const probeAntigravityDecryptionState = async (): Promise => {
- if (process.platform !== 'darwin') {
- return getAntigravityDecryptionState();
- }
+ return getAntigravityDecryptionState();
+};
+export const unlockAntigravityDecryption = async (): Promise => {
try {
- await readAntigravityKeychainSecret();
- return getAntigravityDecryptionState({ hasAccess: true });
+ cachedKeychainSecret = await readAntigravityKeychainSecret();
+ lastKeychainError = null;
} catch (error) {
- return getAntigravityDecryptionState({ error: getAntigravityKeychainError(error) });
+ cachedKeychainSecret = null;
+ lastKeychainError = getAntigravityKeychainError(error);
}
-};
-export const unlockAntigravityDecryption = probeAntigravityDecryptionState;
+ return getAntigravityDecryptionState();
+};
diff --git a/src/ui/components/antigravity-keychain-panel.tsx b/src/ui/components/antigravity-keychain-panel.tsx
index 0eb3747..35cb35f 100644
--- a/src/ui/components/antigravity-keychain-panel.tsx
+++ b/src/ui/components/antigravity-keychain-panel.tsx
@@ -49,7 +49,7 @@ export function AntigravityKeychainPanel() {
{isUnlocked
- ? 'The current Keychain check succeeded. Transcript exports can reacquire access for each protected request.'
+ ? 'Antigravity Keychain access is enabled for this server process. Transcript exports can use the cached access until the process restarts.'
: `Spiracha needs one-time access to ${decryptionState.keychainService} to decrypt Antigravity transcript data. macOS will ask for approval after you click unlock.`}
{error ? (
diff --git a/src/ui/components/export-dialog.tsx b/src/ui/components/export-dialog.tsx
index c42dd90..38bc2e0 100644
--- a/src/ui/components/export-dialog.tsx
+++ b/src/ui/components/export-dialog.tsx
@@ -336,15 +336,19 @@ export function ExportDialog({
const displayedError = evidenceError ?? errorMessage;
const zipDescriptionId = useId();
const handleOpenChange = (nextOpen: boolean) => {
- if (nextOpen) {
- resetActiveDownloads();
- } else {
+ if (!nextOpen) {
submissionToken.current += 1;
cancelActiveDownloads();
}
onOpenChange(nextOpen);
};
+ useEffect(() => {
+ if (open) {
+ resetActiveDownloads();
+ }
+ }, [open]);
+
useEffect(() => {
if (!open) {
submissionToken.current += 1;
diff --git a/src/ui/components/export-dialog.vitest.tsx b/src/ui/components/export-dialog.vitest.tsx
index b503741..93c71a1 100644
--- a/src/ui/components/export-dialog.vitest.tsx
+++ b/src/ui/components/export-dialog.vitest.tsx
@@ -181,6 +181,20 @@ describe('ExportDialog', () => {
cancelActiveDownloads.mockRestore();
});
+ it('should reset cancelled downloads when the controlled dialog reopens', async () => {
+ const onOpenChange = vi.fn();
+ const resetActiveDownloads = vi.spyOn(download, 'resetActiveDownloads');
+ const renderDialog = (open: boolean) => (
+
+ );
+ const { rerender } = render(renderDialog(false));
+
+ rerender(renderDialog(true));
+
+ await waitFor(() => expect(resetActiveDownloads).toHaveBeenCalledTimes(1));
+ resetActiveDownloads.mockRestore();
+ });
+
it('should not download focused evidence after the dialog is cancelled', async () => {
const originalScrollIntoView = HTMLElement.prototype.scrollIntoView;
HTMLElement.prototype.scrollIntoView = vi.fn();
diff --git a/src/ui/lib/antigravity-server.ts b/src/ui/lib/antigravity-server.ts
index c4badad..d2ba6b9 100644
--- a/src/ui/lib/antigravity-server.ts
+++ b/src/ui/lib/antigravity-server.ts
@@ -71,8 +71,8 @@ export const listAntigravityWorkspacesFn = createServerFn({ method: 'GET' }).han
});
export const getAntigravityDecryptionStateFn = createServerFn({ method: 'GET' }).handler(async () => {
- const { probeAntigravityDecryptionState } = await import('@spiracha/lib/antigravity-keychain');
- return probeAntigravityDecryptionState();
+ const { getAntigravityDecryptionState } = await import('@spiracha/lib/antigravity-keychain');
+ return getAntigravityDecryptionState();
});
export const unlockAntigravityDecryptionFn = createServerFn({ method: 'POST' }).handler(async () => {
@@ -255,8 +255,10 @@ export const deleteAntigravityConversationsById = async (conversationIds: string
const { deleteAntigravityConversation } = await import('@spiracha/lib/antigravity-db');
const { resolveAntigravityRoots } = await import('@spiracha/lib/antigravity-exporter-types');
const roots = resolveAntigravityRoots();
- const results = await runDeleteBatch(conversationIds, (conversationId) =>
- deleteAntigravityConversation(roots, conversationId),
+ const results = await runDeleteBatch(
+ conversationIds,
+ (conversationId) => deleteAntigravityConversation(roots, conversationId),
+ { concurrency: 1 },
);
const deletedConversationIds = results.flatMap((result) => result.deletedConversationIds);
if (deletedConversationIds.length === 0) {
diff --git a/src/ui/lib/antigravity-server.vitest.ts b/src/ui/lib/antigravity-server.vitest.ts
index 74bcffa..5f1c0ef 100644
--- a/src/ui/lib/antigravity-server.vitest.ts
+++ b/src/ui/lib/antigravity-server.vitest.ts
@@ -3,10 +3,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
deleteAntigravityConversationMock,
- probeAntigravityDecryptionStateMock,
getAntigravityConversationByIdMock,
+ getAntigravityDecryptionStateMock,
listAntigravityConversationsMock,
listAntigravityWorkspaceGroupsMock,
+ probeAntigravityDecryptionStateMock,
renderAntigravityArtifactsMarkdownMock,
renderAntigravityConversationMarkdownMock,
renderSourceSessionsDownloadMock,
@@ -17,6 +18,7 @@ const {
} = vi.hoisted(() => ({
deleteAntigravityConversationMock: vi.fn(),
getAntigravityConversationByIdMock: vi.fn(),
+ getAntigravityDecryptionStateMock: vi.fn(),
listAntigravityConversationsMock: vi.fn(),
listAntigravityWorkspaceGroupsMock: vi.fn(),
probeAntigravityDecryptionStateMock: vi.fn(),
@@ -56,6 +58,7 @@ vi.mock('@spiracha/lib/antigravity-trajectory', () => ({
}));
vi.mock('@spiracha/lib/antigravity-keychain', () => ({
+ getAntigravityDecryptionState: getAntigravityDecryptionStateMock,
probeAntigravityDecryptionState: probeAntigravityDecryptionStateMock,
unlockAntigravityDecryption: unlockAntigravityDecryptionMock,
withAntigravityDecryptionCapability: withAntigravityDecryptionCapabilityMock,
@@ -83,6 +86,7 @@ import {
deleteAntigravityConversationsById,
exportAntigravityConversationFn,
exportAntigravityConversations,
+ getAntigravityDecryptionStateFn,
loadAntigravityConversationDetail,
loadAntigravityConversationExport,
} from './antigravity-server';
@@ -119,6 +123,16 @@ const makeConversation = (overrides: Partial = {}): Ant
describe('antigravity-server', () => {
beforeEach(() => {
vi.clearAllMocks();
+ getAntigravityDecryptionStateMock.mockReturnValue({
+ canRequestAccess: true,
+ error: null,
+ isUnlocked: false,
+ keychainAccount: 'Antigravity Key',
+ keychainService: 'Antigravity Safe Storage',
+ platform: 'darwin',
+ provider: 'keychain',
+ status: 'locked',
+ });
probeAntigravityDecryptionStateMock.mockResolvedValue(null);
getAntigravityConversationByIdMock.mockImplementation(async (conversationId: string) =>
(await listAntigravityConversationsMock()).find(
@@ -138,6 +152,26 @@ describe('antigravity-server', () => {
}));
});
+ it('should read cached decryption state without probing Keychain on route entry', async () => {
+ const state = {
+ canRequestAccess: true,
+ error: null,
+ isUnlocked: false,
+ keychainAccount: 'Antigravity Key',
+ keychainService: 'Antigravity Safe Storage',
+ platform: 'darwin' as const,
+ provider: 'keychain' as const,
+ status: 'locked' as const,
+ };
+ getAntigravityDecryptionStateMock.mockReturnValue(state);
+
+ const result = await getAntigravityDecryptionStateFn();
+
+ expect(result).toBe(state);
+ expect(getAntigravityDecryptionStateMock).toHaveBeenCalledOnce();
+ expect(probeAntigravityDecryptionStateMock).not.toHaveBeenCalled();
+ });
+
it.each(['overview', 'trajectory'] as const)(
'should keep readable %s transcripts available without keychain unlock',
async (transcriptSource) => {
@@ -449,6 +483,32 @@ describe('antigravity-server', () => {
});
});
+ it('should serialize bulk Antigravity deletes that rewrite the shared summary index', async () => {
+ let releaseFirstDelete: (() => void) | undefined;
+ const firstDeleteBlocked = new Promise((resolve) => {
+ releaseFirstDelete = resolve;
+ });
+ deleteAntigravityConversationMock.mockImplementation(async (_roots, conversationId: string) => {
+ if (conversationId === 'conversation-1') {
+ await firstDeleteBlocked;
+ }
+ return {
+ deletedConversationIds: [conversationId],
+ deletedPaths: [`/tmp/root/${conversationId}.pb`],
+ };
+ });
+
+ const deletion = deleteAntigravityConversationsById(['conversation-1', 'conversation-2']);
+ await vi.waitFor(() => expect(deleteAntigravityConversationMock).toHaveBeenCalled());
+ try {
+ expect(deleteAntigravityConversationMock).toHaveBeenCalledTimes(1);
+ } finally {
+ releaseFirstDelete?.();
+ }
+ await deletion;
+ expect(deleteAntigravityConversationMock).toHaveBeenCalledTimes(2);
+ });
+
it('should reject bulk Antigravity delete when nothing was removed', async () => {
deleteAntigravityConversationMock.mockResolvedValue({
deletedConversationIds: [],
diff --git a/src/ui/lib/delete-batch.ts b/src/ui/lib/delete-batch.ts
index c7b05a9..7a10344 100644
--- a/src/ui/lib/delete-batch.ts
+++ b/src/ui/lib/delete-batch.ts
@@ -25,8 +25,24 @@ export class DeleteBatchError extends AggregateError {
export const runDeleteBatch = async (
ids: TId[],
deleteOne: (id: TId) => Promise,
+ options: { concurrency?: number } = {},
): Promise => {
- const settled = await Promise.allSettled(ids.map((id) => deleteOne(id)));
+ const requestedConcurrency = options.concurrency ?? ids.length;
+ const concurrency = Math.max(1, Math.min(ids.length, Math.floor(requestedConcurrency)));
+ const settled = new Array>(ids.length);
+ let nextIndex = 0;
+ const worker = async () => {
+ while (nextIndex < ids.length) {
+ const index = nextIndex;
+ nextIndex += 1;
+ try {
+ settled[index] = { status: 'fulfilled', value: await deleteOne(ids[index]!) };
+ } catch (reason) {
+ settled[index] = { reason, status: 'rejected' };
+ }
+ }
+ };
+ await Promise.all(Array.from({ length: concurrency }, () => worker()));
const failures: Array<{ id: TId; reason: unknown }> = [];
const successes: Array<{ id: TId; result: TResult }> = [];
for (const [index, result] of settled.entries()) {
From 9713235e19bb6907c18ecb50834a6da58d1dbe23 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Mon, 17 Aug 2026 18:44:03 -0400
Subject: [PATCH 09/10] Fix MiniMax model detection and Antigravity cwd
classification
---
src/lib/antigravity-db.test.ts | 15 +++++++++++++++
src/lib/antigravity-db.ts | 32 +++++++++++++++++++++++++++++++-
src/lib/minimax-code-db.test.ts | 28 ++++++++++++++++++++++++++++
src/lib/minimax-code-db.ts | 28 ++++++++++++++++++++++++++--
4 files changed, 100 insertions(+), 3 deletions(-)
diff --git a/src/lib/antigravity-db.test.ts b/src/lib/antigravity-db.test.ts
index e80adf1..9ce6dd9 100644
--- a/src/lib/antigravity-db.test.ts
+++ b/src/lib/antigravity-db.test.ts
@@ -1097,6 +1097,21 @@ describe('antigravity db discovery', () => {
});
});
+ it('should derive a workspace from a trajectory workdir before falling back to the source root', async () => {
+ const root = await makeRoot();
+ const conversationId = '78787878-7878-4787-8787-787878787878';
+ await writeTrajectoryDatabase(root, conversationId);
+
+ const [conversation] = await listAntigravityConversations([root]);
+
+ expect(conversation).toMatchObject({
+ conversationId,
+ workspaceFolder: '/Users/example/workspace/ushman',
+ workspaceKey: 'folder:/Users/example/workspace/ushman',
+ workspaceLabel: 'ushman',
+ });
+ });
+
it('should not treat artifacts as conversation transcript markdown', async () => {
const root = await makeRoot();
const conversationId = '88888888-8888-4888-8888-888888888888';
diff --git a/src/lib/antigravity-db.ts b/src/lib/antigravity-db.ts
index 51aad3a..36021d5 100644
--- a/src/lib/antigravity-db.ts
+++ b/src/lib/antigravity-db.ts
@@ -76,6 +76,7 @@ type ConversationFile = {
path: string;
root: string;
stepIndexes: Set;
+ workspaceFolder: string | null;
};
const ANTIGRAVITY_ARTIFACT_READ_CONCURRENCY = 16;
@@ -568,6 +569,29 @@ const normalizeWorkspaceFolder = (value: string): string => {
return decoded.replace(/\/+$/u, '') || decoded;
};
+const isPathWithin = (value: string, parent: string): boolean =>
+ value === parent || value.startsWith(`${parent}${path.sep}`);
+
+const resolveTrajectoryWorkspaceFolder = (root: string, entries: AntigravityTrajectoryEntry[]): string | null => {
+ const sourceRoot = path.resolve(root);
+ const scratchRoot = path.join(sourceRoot, 'scratch');
+ for (const entry of entries) {
+ const workdir = entry.workdir?.trim();
+ if (!workdir || !path.isAbsolute(workdir)) {
+ continue;
+ }
+
+ const normalized = path.normalize(workdir);
+ if (isPathWithin(normalized, sourceRoot) || isPathWithin(normalized, scratchRoot)) {
+ continue;
+ }
+
+ return normalizeWorkspaceFolder(normalized);
+ }
+
+ return null;
+};
+
const workspaceFromFolder = (folderValue: string | null): WorkspaceInfo | null => {
if (!folderValue) {
return null;
@@ -900,6 +924,10 @@ const readConversationFileCandidate = async (
path: filePath,
root,
stepIndexes: format === 'db' ? await readAntigravityTrajectoryStepIndexes(filePath) : new Set(),
+ workspaceFolder:
+ format === 'db'
+ ? resolveTrajectoryWorkspaceFolder(root, await readAntigravityTrajectoryEntries(filePath))
+ : null,
},
};
} catch (error) {
@@ -1319,7 +1347,9 @@ const resolveConversationWorkspace = (
artifacts: AntigravityArtifact[],
): WorkspaceInfo => {
return (
- summary ?? workspaceFromFolder(resolveConversationSourceRoot(file, transcript, artifacts)) ?? UNKNOWN_WORKSPACE
+ summary ??
+ workspaceFromFolder(file?.workspaceFolder ?? resolveConversationSourceRoot(file, transcript, artifacts)) ??
+ UNKNOWN_WORKSPACE
);
};
diff --git a/src/lib/minimax-code-db.test.ts b/src/lib/minimax-code-db.test.ts
index 49bccf8..ef0cb36 100644
--- a/src/lib/minimax-code-db.test.ts
+++ b/src/lib/minimax-code-db.test.ts
@@ -174,6 +174,34 @@ describe('MiniMax Code db helpers', () => {
]);
});
+ it('should derive the latest model from snapshot Pi history when record metadata is absent', async () => {
+ const tempRoot = await makeTempRoot();
+ const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
+ const workspacePath = path.join(tempRoot, 'project');
+ const fixture = await writeMiniMaxCodeSessionFixture({ sessionsDir, workspacePath });
+ const snapshot = (await Bun.file(fixture.snapshotPath).json()) as {
+ piHistory: unknown[];
+ record: Record;
+ };
+ delete snapshot.record.effectiveModel;
+ delete snapshot.record.effectiveModelVariant;
+ snapshot.piHistory = [
+ { model: 'MiniMax-M2', provider: 'minimax' },
+ { model: 'MiniMax-M3', provider: 'minimax' },
+ ];
+ await Bun.write(fixture.snapshotPath, `${JSON.stringify(snapshot)}\n`);
+
+ const sessions = await listMiniMaxCodeSessionsForGroup(fixture.workspaceKey, sessionsDir);
+
+ expect(sessions).toEqual([
+ expect.objectContaining({
+ currentModelId: 'minimax/MiniMax-M3',
+ currentModelVariant: null,
+ sessionId: fixture.sessionId,
+ }),
+ ]);
+ });
+
it('should parse chat, reasoning, tool evidence, and final answers while ignoring todo state', async () => {
const tempRoot = await makeTempRoot();
const sessionsDir = path.join(tempRoot, 'v2', 'sessions');
diff --git a/src/lib/minimax-code-db.ts b/src/lib/minimax-code-db.ts
index 79fe2a8..5dba861 100644
--- a/src/lib/minimax-code-db.ts
+++ b/src/lib/minimax-code-db.ts
@@ -442,11 +442,35 @@ const getSessionStats = (messages: MiniMaxCodeTranscriptMessage[]): SessionStats
};
};
+const getLatestPiHistoryModel = (value: JsonValue | undefined): string | null => {
+ if (!Array.isArray(value)) {
+ return null;
+ }
+
+ for (let index = value.length - 1; index >= 0; index -= 1) {
+ const entry = asObject(value[index]);
+ const model = asString(entry?.model ?? null)?.trim();
+ if (!model) {
+ continue;
+ }
+
+ const provider = asString(entry?.provider ?? null)?.trim();
+ return provider && !model.includes('/') ? `${provider}/${model}` : model;
+ }
+
+ return null;
+};
+
+const getSessionModelId = (record: Record, piHistory?: JsonValue): string | null => {
+ return asString(record.effectiveModel ?? null)?.trim() || getLatestPiHistoryModel(piHistory);
+};
+
const toSessionSummary = (
snapshotPath: string,
record: Record,
sessionId: string,
messages: MiniMaxCodeTranscriptMessage[],
+ piHistory?: JsonValue,
): MiniMaxCodeSessionSummary | null => {
const worktree = asString(record.workspaceDir ?? null)?.trim();
if (!worktree) {
@@ -461,7 +485,7 @@ const toSessionSummary = (
archived: asBoolean(record.archived ?? null),
...stats,
createdAtMs: asNumber(record.createdAtMs ?? null),
- currentModelId: asString(record.effectiveModel ?? null),
+ currentModelId: getSessionModelId(record, piHistory),
currentModelVariant: asString(record.effectiveModelVariant ?? null),
lastActiveAtMs: asNumber(record.updatedAtMs ?? null),
runtime: asString(record.runtime ?? null),
@@ -496,7 +520,7 @@ const readSnapshot = async (
const parsedMessage = parseMessage(message, includeRawPayloads);
return parsedMessage ? [parsedMessage] : [];
});
- const session = toSessionSummary(snapshotPath, record, sessionId, messages);
+ const session = toSessionSummary(snapshotPath, record, sessionId, messages, root.piHistory);
if (!session) {
return null;
}
From c7e54e25178e7929c6696db373fccff09eb633c6 Mon Sep 17 00:00:00 2001
From: Ragaeeb Haq <7464039+ragaeeb@users.noreply.github.com>
Date: Mon, 17 Aug 2026 23:10:36 -0400
Subject: [PATCH 10/10] Bump dependencies and fix linting
---
bun.lock | 18 +-
package.json | 254 ++++++++++----------
src/lib/conversation-data/cursor-adapter.ts | 2 +
src/lib/cursor-chat-store.test.ts | 40 +++
src/lib/cursor-chat-store.ts | 45 ++++
src/lib/cursor-db.test.ts | 72 ++++++
src/lib/cursor-db.ts | 161 +++++++++++--
src/ui/lib/cursor-server.ts | 31 ++-
src/ui/lib/cursor-server.vitest.ts | 40 ++-
9 files changed, 496 insertions(+), 167 deletions(-)
create mode 100644 src/lib/cursor-chat-store.test.ts
create mode 100644 src/lib/cursor-chat-store.ts
diff --git a/bun.lock b/bun.lock
index 2d01bc8..dda9609 100644
--- a/bun.lock
+++ b/bun.lock
@@ -89,23 +89,23 @@
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@1.0.2", "", {}, "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA=="],
- "@biomejs/biome": ["@biomejs/biome@2.5.8", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.8", "@biomejs/cli-darwin-x64": "2.5.8", "@biomejs/cli-linux-arm64": "2.5.8", "@biomejs/cli-linux-arm64-musl": "2.5.8", "@biomejs/cli-linux-x64": "2.5.8", "@biomejs/cli-linux-x64-musl": "2.5.8", "@biomejs/cli-win32-arm64": "2.5.8", "@biomejs/cli-win32-x64": "2.5.8" }, "bin": { "biome": "bin/biome" } }, "sha512-aeAeeJB9fSDc7Gq+2GqpQxA0qBj6gj1k2R6L1cYqGePKP/baIq1WX8y6B+D+nRsO5ViQL22K/8IwbqERW0q1nw=="],
+ "@biomejs/biome": ["@biomejs/biome@2.5.9", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.9", "@biomejs/cli-darwin-x64": "2.5.9", "@biomejs/cli-linux-arm64": "2.5.9", "@biomejs/cli-linux-arm64-musl": "2.5.9", "@biomejs/cli-linux-x64": "2.5.9", "@biomejs/cli-linux-x64-musl": "2.5.9", "@biomejs/cli-win32-arm64": "2.5.9", "@biomejs/cli-win32-x64": "2.5.9" }, "bin": { "biome": "bin/biome" } }, "sha512-KkgCvdHB4IhtpHpF564plA9jo6fDOwWGQ/3jvreLzgOtRLEDoPqr7QO9qejNA8jKwDsSkAKr77hqBHnyUbIw4g=="],
- "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.8", "", { "os": "darwin", "cpu": "arm64" }, "sha512-mk1QON9PHllvvLN5gU3f4rMxeh4syK5p9OvKyWH6/W8ueh04uaC8TUXXByhGufWf/y5mQc03ZLM45zU+cmqMjA=="],
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-am22pX2aBqznqq1eMyIj/bZ++riF3Lk6ct7cbv+gQK0csFhr+d8O0RkOi2FF2qSgFgANbqNkIZ0/PxlnW2pLFg=="],
- "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.8", "", { "os": "darwin", "cpu": "x64" }, "sha512-bsGwFMBNyHPyiLSsQcZJxdoRrg1V4JL+d7wEsvUBczlP9U9lwM+7mzQHxI4o1mhBsTmdOBbAb6fHU3Z3snN45w=="],
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-l44KWDHLDvEnD0N/XcrVs7VXb3A18xL7QS3WB0eL93wbmk529ffIG55vleGCqaunpRUjLrdnjK05Qki1dsjylg=="],
- "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-XmFiA0WPYFC+uiUDC8WRFzAIH9bo7vwQLav38Uoq4ETC+T/+uBi0TsYGJECkugY3r8USl3jc+Ae2/irAF6F2lQ=="],
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-ICaK+IYaVZvKbBxX2rwrPT0DdUDMnE9Vm3nQGe+mltQPmUg19pONzkPWGdY4FCsoreDETWDynvdt4ysCbF5gNQ=="],
- "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.8", "", { "os": "linux", "cpu": "arm64" }, "sha512-VcJNbstduTHx83NGAdhp78/JOcP45BZHXL7yNsfI1uGzdUgegAz2s+mSoT7wK6PBNzLoqG0zDOXaz/RQYVtSiw=="],
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-7ImVPwBLCtkmpR5esd8RHhTqW94f0JLJQum6AneYcy94jRm18TaPPm7slaigGzFhfgt3QiD1Vj52LKmBAnKizA=="],
- "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-S5wcm9OBDvLHodD4PUaN488hCpco9QD/9ZxuYJiw4euWtr/oQvLR72z2ixItH8Wd5BCm6FZaeb+YNvOoM1xHtQ=="],
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-z22Q/zFYSvbIJfW1CbfZPu4X8PddS6Qd2ORbc6h+aT6EcwAxUF3m6fA4HjNvA3TU4X0dTJRwNPB165ES3PJXzg=="],
- "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.8", "", { "os": "linux", "cpu": "x64" }, "sha512-kKmiyokeISRGq2FLwvr+TzsgBusfxaZ0FZNLcOYOpCK/78tRrEjeEBLvq3xLZMpqbANgJdRPI7vZX8ZL37u9/w=="],
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.9", "", { "os": "linux", "cpu": "x64" }, "sha512-RXGaD0o1/pTTguYw1aeDJh9ad6Lfrui0fI7mBderTyGr7WuUJkBIttgLkR3XJyoxOkkgfBDspaUT8wXArTqLZw=="],
- "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.8", "", { "os": "win32", "cpu": "arm64" }, "sha512-nILH0mzm3Hi3iEdd7o7GpB8kBR/mSQwfQG/tyBqyNrY2GFtcgwfV9nV8xLmbtUpMNY/Oi0Ml1XgfR4flOdq+AA=="],
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-nHK+/HHC+D0ogAHUxomgoSTdjImb6fmNNVTKmf0tyu4eDL1DqPKIHc+i+UL8+b0RnAu8224qo8F2tCVnaT0A3w=="],
- "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.8", "", { "os": "win32", "cpu": "x64" }, "sha512-I2czzXTY61f3nFJxXoMDq80t7MivxDEnCjE+8sDKoFfcKMaoQdkqhIFQ3KyY0XLzeSpUBYeNAXgD+iOV/BU0VA=="],
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.9", "", { "os": "win32", "cpu": "x64" }, "sha512-Yiq0H56LjXSSw/hd9YkXgSLQfzyDJzbzU2TezozxyNw+uKWAqOtqGVvBfzKRRDiaFF5avGAhHdWKx7LtDOShUw=="],
"@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="],
diff --git a/package.json b/package.json
index 76d3c84..e75b0e7 100644
--- a/package.json
+++ b/package.json
@@ -1,131 +1,131 @@
{
- "author": {
- "name": "Ragaeeb Haq",
- "url": "https://github.com/ragaeeb"
- },
- "bin": {
- "spiracha": "./bin/spiracha.ts"
- },
- "bugs": {
- "url": "https://github.com/ragaeeb/spiracha/issues"
- },
- "dependencies": {
- "@tailwindcss/typography": "0.5.20",
- "@tailwindcss/vite": "4.3.3",
- "@tanstack/devtools-vite": "0.8.3",
- "@tanstack/match-sorter-utils": "9.1.2",
- "@tanstack/react-devtools": "0.10.10",
- "@tanstack/react-query": "5.101.4",
- "@tanstack/react-query-devtools": "5.101.4",
- "@tanstack/react-router": "1.170.29",
- "@tanstack/react-router-devtools": "1.167.1",
- "@tanstack/react-router-ssr-query": "1.167.1",
- "@tanstack/react-start": "1.168.46",
- "@tanstack/react-table": "9.1.2",
- "@tanstack/react-virtual": "3.14.9",
- "@tanstack/router-plugin": "1.168.32",
- "@vitejs/plugin-react": "6.0.5",
- "class-variance-authority": "0.7.1",
- "clsx": "2.1.1",
- "fflate": "0.8.3",
- "lucide-react": "1.31.0",
- "radix-ui": "1.6.7",
- "react": "19.2.8",
- "react-dom": "19.2.8",
- "tailwind-merge": "3.6.0",
- "tailwindcss": "4.3.3",
- "tw-animate-css": "1.4.0",
- "vite": "8.2.1",
- "zod": "4.4.3"
- },
- "description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, MiniMax Code, and OpenCode conversation history through a local UI and stable data API.",
- "devDependencies": {
- "@biomejs/biome": "2.5.8",
- "@testing-library/dom": "10.4.1",
- "@testing-library/react": "16.3.2",
- "@types/bun": "^1.3.14",
- "@types/node": "^26.2.0",
- "@types/react": "19.2.18",
- "@types/react-dom": "19.2.4",
- "@vitest/coverage-v8": "4.1.10",
- "jsdom": "30.0.1",
- "typescript": "^7.0.2",
- "vitest": "4.1.10"
- },
- "engines": {
- "bun": ">=1.3.14"
- },
- "exports": {
- ".": {
- "import": "./src/client.ts",
- "types": "./src/client.ts"
+ "author": {
+ "name": "Ragaeeb Haq",
+ "url": "https://github.com/ragaeeb"
},
- "./client": {
- "import": "./src/client.ts",
- "types": "./src/client.ts"
+ "bin": {
+ "spiracha": "./bin/spiracha.ts"
},
- "./types": {
- "import": "./src/lib/conversation-data/types.ts",
- "types": "./src/lib/conversation-data/types.ts"
- }
- },
- "files": [
- "bin/spiracha.ts",
- "components.json",
- "public/**/*",
- "src/ui/**/*",
- "!src/ui/**/*.vitest.ts",
- "!src/ui/**/*.vitest.tsx",
- "tsconfig.json",
- "vite.config.ts",
- "src/client.ts",
- "src/lib/**/*.ts",
- "!src/lib/**/*.test.ts",
- "!src/lib/*-test-helpers.ts",
- "README.md",
- "AGENTS.md"
- ],
- "homepage": "https://github.com/ragaeeb/spiracha",
- "imports": {
- "#/*": "./src/ui/*"
- },
- "keywords": [
- "codex",
- "claude",
- "cline",
- "grok",
- "kiro",
- "qoder",
- "cursor",
- "antigravity",
- "minimax",
- "opencode",
- "transcripts",
- "api",
- "markdown",
- "bun"
- ],
- "license": "MIT",
- "name": "spiracha",
- "packageManager": "bun@1.3.14",
- "repository": {
- "type": "git",
- "url": "git+https://github.com/ragaeeb/spiracha.git"
- },
- "scripts": {
- "build": "bun run typecheck && bun --bun vite build",
- "coverage": "bun run coverage:root && bun run coverage:ui",
- "coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
- "coverage:ui": "vitest run --coverage --config vitest.config.ts && bun run ./src/coverage-check.ts ui",
- "format": "biome check . --write && biome lint . --write",
- "lint": "biome check .",
- "prepublishOnly": "bun run build && bun run test:package",
- "start": "bun --bun vite dev --host 127.0.0.1 --port 3000",
- "test:package": "bun run ./src/package-smoke.ts",
- "test:ui": "vitest run --config vitest.config.ts",
- "typecheck": "bunx tsc --noEmit",
- "ui:preview": "bun --bun vite preview"
- },
- "type": "module",
- "version": "2.5.0"
+ "bugs": {
+ "url": "https://github.com/ragaeeb/spiracha/issues"
+ },
+ "dependencies": {
+ "@tailwindcss/typography": "0.5.20",
+ "@tailwindcss/vite": "4.3.3",
+ "@tanstack/devtools-vite": "0.8.3",
+ "@tanstack/match-sorter-utils": "9.1.2",
+ "@tanstack/react-devtools": "0.10.10",
+ "@tanstack/react-query": "5.101.4",
+ "@tanstack/react-query-devtools": "5.101.4",
+ "@tanstack/react-router": "1.170.29",
+ "@tanstack/react-router-devtools": "1.167.1",
+ "@tanstack/react-router-ssr-query": "1.167.1",
+ "@tanstack/react-start": "1.168.46",
+ "@tanstack/react-table": "9.1.2",
+ "@tanstack/react-virtual": "3.14.9",
+ "@tanstack/router-plugin": "1.168.32",
+ "@vitejs/plugin-react": "6.0.5",
+ "class-variance-authority": "0.7.1",
+ "clsx": "2.1.1",
+ "fflate": "0.8.3",
+ "lucide-react": "1.31.0",
+ "radix-ui": "1.6.7",
+ "react": "19.2.8",
+ "react-dom": "19.2.8",
+ "tailwind-merge": "3.6.0",
+ "tailwindcss": "4.3.3",
+ "tw-animate-css": "1.4.0",
+ "vite": "8.2.1",
+ "zod": "4.4.3"
+ },
+ "description": "Browse and expose local Codex, Claude Code, Grok, Kiro, Qoder, Cursor, Antigravity, MiniMax Code, and OpenCode conversation history through a local UI and stable data API.",
+ "devDependencies": {
+ "@biomejs/biome": "2.5.9",
+ "@testing-library/dom": "10.4.1",
+ "@testing-library/react": "16.3.2",
+ "@types/bun": "^1.3.14",
+ "@types/node": "^26.2.0",
+ "@types/react": "19.2.18",
+ "@types/react-dom": "19.2.4",
+ "@vitest/coverage-v8": "4.1.10",
+ "jsdom": "30.0.1",
+ "typescript": "^7.0.2",
+ "vitest": "4.1.10"
+ },
+ "engines": {
+ "bun": ">=1.3.14"
+ },
+ "exports": {
+ ".": {
+ "import": "./src/client.ts",
+ "types": "./src/client.ts"
+ },
+ "./client": {
+ "import": "./src/client.ts",
+ "types": "./src/client.ts"
+ },
+ "./types": {
+ "import": "./src/lib/conversation-data/types.ts",
+ "types": "./src/lib/conversation-data/types.ts"
+ }
+ },
+ "files": [
+ "bin/spiracha.ts",
+ "components.json",
+ "public/**/*",
+ "src/ui/**/*",
+ "!src/ui/**/*.vitest.ts",
+ "!src/ui/**/*.vitest.tsx",
+ "tsconfig.json",
+ "vite.config.ts",
+ "src/client.ts",
+ "src/lib/**/*.ts",
+ "!src/lib/**/*.test.ts",
+ "!src/lib/*-test-helpers.ts",
+ "README.md",
+ "AGENTS.md"
+ ],
+ "homepage": "https://github.com/ragaeeb/spiracha",
+ "imports": {
+ "#/*": "./src/ui/*"
+ },
+ "keywords": [
+ "codex",
+ "claude",
+ "cline",
+ "grok",
+ "kiro",
+ "qoder",
+ "cursor",
+ "antigravity",
+ "minimax",
+ "opencode",
+ "transcripts",
+ "api",
+ "markdown",
+ "bun"
+ ],
+ "license": "MIT",
+ "name": "spiracha",
+ "packageManager": "bun@1.3.14",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/ragaeeb/spiracha.git"
+ },
+ "scripts": {
+ "build": "bun run typecheck && bun --bun vite build",
+ "coverage": "bun run coverage:root && bun run coverage:ui",
+ "coverage:root": "bun test --coverage --coverage-reporter=lcov --coverage-dir coverage && bun run ./src/coverage-check.ts root",
+ "coverage:ui": "vitest run --coverage --config vitest.config.ts && bun run ./src/coverage-check.ts ui",
+ "format": "biome check . --write && biome lint . --write",
+ "lint": "biome check .",
+ "prepublishOnly": "bun run build && bun run test:package",
+ "start": "bun --bun vite dev --host 127.0.0.1 --port 3000",
+ "test:package": "bun run ./src/package-smoke.ts",
+ "test:ui": "vitest run --config vitest.config.ts",
+ "typecheck": "bunx tsc --noEmit",
+ "ui:preview": "bun --bun vite preview"
+ },
+ "type": "module",
+ "version": "2.5.0"
}
diff --git a/src/lib/conversation-data/cursor-adapter.ts b/src/lib/conversation-data/cursor-adapter.ts
index 6af3dfe..e255e1d 100644
--- a/src/lib/conversation-data/cursor-adapter.ts
+++ b/src/lib/conversation-data/cursor-adapter.ts
@@ -184,6 +184,8 @@ const listCursorConversationsForPath = async (options: ListConversationsForPathO
continue;
}
const threads = await listCursorThreadsForGroup(group, userDir, {
+ includeBubbleStats: true,
+ includeModelAttribution: true,
includeTranscriptDirs: false,
});
for (const thread of threads) {
diff --git a/src/lib/cursor-chat-store.test.ts b/src/lib/cursor-chat-store.test.ts
new file mode 100644
index 0000000..547470f
--- /dev/null
+++ b/src/lib/cursor-chat-store.test.ts
@@ -0,0 +1,40 @@
+import { describe, expect, it } from 'bun:test';
+import { mkdir, mkdtemp } from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { decodeCursorChatModel, resolveCursorChatStorePath } from './cursor-chat-store';
+
+describe('Cursor chat stores', () => {
+ it('should decode the persisted model and reasoning effort from modern chat blobs', () => {
+ const blob = new TextEncoder().encode('\u0000other metadata\u0000cursor-grok-4.6-low\u0000');
+
+ expect(decodeCursorChatModel([blob])).toEqual({ model: 'grok-4.6', reasoningEffort: 'low' });
+ });
+
+ it('should resolve a chat store through the trusted workspace path hash', async () => {
+ const cursorDir = await mkdtemp(path.join(os.tmpdir(), 'cursor-chat-store-'));
+ const projectDir = path.join(cursorDir, 'projects', 'workspace-project');
+ const composerId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa';
+ const storePath = path.join(cursorDir, 'chats', '3e5df7fc57ed37c7864c9a0f7ec0d12d', composerId, 'store.db');
+ await mkdir(projectDir, { recursive: true });
+ await mkdir(path.dirname(storePath), { recursive: true });
+ await Bun.write(
+ path.join(projectDir, '.workspace-trusted'),
+ JSON.stringify({ workspacePath: '/Users/test/workspace/kalu' }),
+ );
+ await Bun.write(storePath, 'fixture');
+
+ await expect(resolveCursorChatStorePath(projectDir, composerId)).resolves.toBe(storePath);
+ });
+
+ it('should reject malformed trusted workspace metadata', async () => {
+ const cursorDir = await mkdtemp(path.join(os.tmpdir(), 'cursor-chat-store-escape-'));
+ const projectDir = path.join(cursorDir, 'projects', 'workspace-project');
+ await mkdir(projectDir, { recursive: true });
+ await Bun.write(path.join(projectDir, '.workspace-trusted'), JSON.stringify({ workspacePath: null }));
+
+ await expect(
+ resolveCursorChatStorePath(projectDir, 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'),
+ ).resolves.toBeNull();
+ });
+});
diff --git a/src/lib/cursor-chat-store.ts b/src/lib/cursor-chat-store.ts
new file mode 100644
index 0000000..757d743
--- /dev/null
+++ b/src/lib/cursor-chat-store.ts
@@ -0,0 +1,45 @@
+import { createHash } from 'node:crypto';
+import path from 'node:path';
+
+export type CursorChatModel = {
+ model: string;
+ reasoningEffort: string | null;
+};
+
+const CURSOR_GROK_MODEL_PATTERN = /(?:^|[^A-Za-z0-9._-])cursor-(grok-[0-9.]+)-(low|medium|high)(?=$|[^A-Za-z0-9._-])/u;
+
+export const decodeCursorChatModel = (values: Iterable): CursorChatModel | null => {
+ const decoder = new TextDecoder();
+ for (const value of values) {
+ const text = typeof value === 'string' ? value : decoder.decode(value);
+ const match = text.match(CURSOR_GROK_MODEL_PATTERN);
+ if (match) {
+ return { model: match[1], reasoningEffort: match[2] };
+ }
+ }
+
+ return null;
+};
+
+export const resolveCursorChatStorePath = async (projectDir: string, composerId: string): Promise => {
+ try {
+ const trustedWorkspace = (await Bun.file(path.join(projectDir, '.workspace-trusted')).json()) as {
+ workspacePath?: unknown;
+ };
+ if (typeof trustedWorkspace.workspacePath !== 'string' || !trustedWorkspace.workspacePath.trim()) {
+ return null;
+ }
+
+ const chatsDir = path.resolve(projectDir, '..', '..', 'chats');
+ const workspaceHash = createHash('md5').update(trustedWorkspace.workspacePath).digest('hex');
+ const storePath = path.resolve(chatsDir, workspaceHash, composerId, 'store.db');
+ const relativePath = path.relative(chatsDir, storePath);
+ if (!relativePath || relativePath.startsWith(`..${path.sep}`) || path.isAbsolute(relativePath)) {
+ return null;
+ }
+
+ return (await Bun.file(storePath).exists()) ? storePath : null;
+ } catch {
+ return null;
+ }
+};
diff --git a/src/lib/cursor-db.test.ts b/src/lib/cursor-db.test.ts
index f1e7b93..20e8f6e 100644
--- a/src/lib/cursor-db.test.ts
+++ b/src/lib/cursor-db.test.ts
@@ -117,6 +117,43 @@ describe('cursor-db workspace discovery', () => {
expect(group?.threadCount).toBe(1);
});
+ it('should defer bubble payload scans until exact thread stats are requested', async () => {
+ const userDir = await makeUserDir();
+ await createCursorFixture(userDir, baseSpec());
+
+ const originalQuery = Database.prototype.query;
+ let bubblePayloadQueryCount = 0;
+ Database.prototype.query = function (this: Database, sql: string) {
+ if (sql.includes('SELECT ? AS composerId, key, value FROM cursorDiskKV')) {
+ bubblePayloadQueryCount += 1;
+ }
+
+ return originalQuery.call(this, sql);
+ } as typeof originalQuery;
+
+ try {
+ const [group] = await listCursorWorkspaceGroups(userDir);
+ expect(bubblePayloadQueryCount).toBe(0);
+
+ const threadsWithoutStats = await listCursorThreadsForGroup(group!, userDir, {
+ includeBubbleStats: false,
+ includeTranscriptDirs: false,
+ });
+ expect(threadsWithoutStats).toEqual([expect.objectContaining({ bubbleBytes: 0, bubbleCount: 0 })]);
+ expect(bubblePayloadQueryCount).toBe(0);
+
+ const threads = await listCursorThreadsForGroup(group!, userDir, {
+ includeBubbleStats: true,
+ includeTranscriptDirs: false,
+ });
+
+ expect(bubblePayloadQueryCount).toBe(1);
+ expect(threads).toEqual([expect.objectContaining({ bubbleBytes: expect.any(Number), bubbleCount: 3 })]);
+ } finally {
+ Database.prototype.query = originalQuery;
+ }
+ });
+
it('should count bubbles for composer ids containing colons', async () => {
const userDir = await makeUserDir();
await createCursorFixture(userDir, {
@@ -561,6 +598,41 @@ describe('cursor-db workspace discovery', () => {
]);
});
+ it('should hydrate a CLI-only thread model from its modern Cursor chat store', async () => {
+ const userDir = await makeUserDir();
+ const composerId = '73d679d2-5311-4e00-8be3-af67fbf0fa87';
+ const projectDir = path.join(userDir, 'projects', 'Users-test-workspace-kalu');
+ const transcriptDir = path.join(projectDir, 'agent-transcripts', composerId);
+ const storePath = path.join(userDir, 'chats', '3e5df7fc57ed37c7864c9a0f7ec0d12d', composerId, 'store.db');
+ await mkdir(transcriptDir, { recursive: true });
+ await mkdir(path.dirname(storePath), { recursive: true });
+ await Bun.write(
+ path.join(projectDir, '.workspace-trusted'),
+ JSON.stringify({ workspacePath: '/Users/test/workspace/kalu' }),
+ );
+ await Bun.write(
+ path.join(transcriptDir, `${composerId}.jsonl`),
+ JSON.stringify({ message: { content: [{ text: 'Review this.', type: 'text' }] }, role: 'user' }),
+ );
+ const store = new Database(storePath);
+ store.exec('CREATE TABLE blobs (id TEXT PRIMARY KEY, data BLOB NOT NULL)');
+ store
+ .query('INSERT INTO blobs (id, data) VALUES (?, ?)')
+ .run('chat', new TextEncoder().encode('\u0000metadata\u0000cursor-grok-4.6-low\u0000'));
+ store.close();
+
+ const group = (await listCursorWorkspaceGroups(userDir)).find(
+ (candidate) => candidate.key === 'folder:/Users/test/workspace/kalu',
+ );
+ const threads = group ? await listCursorThreadsForGroup(group, userDir, { includeTranscriptDirs: false }) : [];
+
+ expect(threads[0]).toMatchObject({
+ composerId,
+ model: 'grok-4.6',
+ reasoningEffort: 'low',
+ });
+ });
+
it('should reject composer ids that escape the agent transcript directory', async () => {
const userDir = await makeUserDir();
await mkdir(path.join(userDir, 'projects', 'demo-project', 'agent-transcripts'), { recursive: true });
diff --git a/src/lib/cursor-db.ts b/src/lib/cursor-db.ts
index 0efa5d6..d7a32ab 100644
--- a/src/lib/cursor-db.ts
+++ b/src/lib/cursor-db.ts
@@ -3,6 +3,7 @@ import { createHash } from 'node:crypto';
import { existsSync } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import path from 'node:path';
+import { decodeCursorChatModel, resolveCursorChatStorePath } from './cursor-chat-store';
import {
COMPOSER_DATA_KEY,
COMPOSER_HEADERS_KEY,
@@ -628,27 +629,87 @@ export const findCursorTranscriptDirs = async (
};
export type ListCursorThreadsOptions = {
+ includeBubbleStats?: boolean;
+ includeModelAttribution?: boolean;
includeTranscriptDirs?: boolean;
updatedAfterMs?: number;
};
+const readCursorChatStoreModel = async (
+ composerId: string,
+ transcriptDirs: string[],
+): Promise<{ model: string; reasoningEffort: string | null } | null> => {
+ for (const transcriptDir of transcriptDirs) {
+ const projectDir = path.dirname(path.dirname(transcriptDir));
+ const storePath = await resolveCursorChatStorePath(projectDir, composerId);
+ if (!storePath) {
+ continue;
+ }
+
+ try {
+ const model = withCursorReadonlyDb(storePath, (db) => {
+ const rows = db
+ .query("SELECT data FROM blobs WHERE instr(CAST(data AS TEXT), 'cursor-grok-') > 0")
+ .all() as Array<{ data: string | Uint8Array }>;
+ return decodeCursorChatModel(rows.map((row) => row.data));
+ });
+ if (model) {
+ return model;
+ }
+ } catch {
+ // Modern chat stores are optional and may be concurrently replaced by Cursor.
+ }
+ }
+
+ return null;
+};
+
+const hydrateCursorChatStoreModels = async (
+ threads: CursorThreadSummary[],
+ transcriptDirsByComposerId: Map,
+): Promise =>
+ Promise.all(
+ threads.map(async (thread) => {
+ if (thread.model) {
+ return thread;
+ }
+
+ const model = await readCursorChatStoreModel(
+ thread.composerId,
+ transcriptDirsByComposerId.get(thread.composerId) ?? [],
+ );
+ return model ? { ...thread, ...model } : thread;
+ }),
+ );
+
export const listCursorThreadsForGroup = async (
group: CursorWorkspaceGroup,
userDir = resolveCursorUserDir(),
options: ListCursorThreadsOptions = {},
): Promise => {
const discovery = await discoverCursorWorkspaces(userDir, options);
- const threads = discovery.threadsByKey.get(group.key) ?? [];
-
+ const discoveredThreads = discovery.threadsByKey.get(group.key) ?? [];
+ const threads =
+ options.includeBubbleStats === false
+ ? discoveredThreads
+ : await hydrateCursorThreadBubbleStats(discoveredThreads, userDir);
+
+ const shouldHydrateModels = options.includeModelAttribution !== false;
+ const shouldResolveTranscriptDirs = options.includeTranscriptDirs !== false || shouldHydrateModels;
+ const transcriptDirsByComposerId = shouldResolveTranscriptDirs
+ ? await findCursorTranscriptDirsForComposerIds(
+ threads.map((thread) => thread.composerId),
+ userDir,
+ )
+ : new Map();
+ const hydratedThreads = shouldHydrateModels
+ ? await hydrateCursorChatStoreModels(threads, transcriptDirsByComposerId)
+ : threads;
if (options.includeTranscriptDirs === false) {
- return threads;
+ return hydratedThreads;
}
- const transcriptDirsByComposerId = await findCursorTranscriptDirsForComposerIds(
- threads.map((thread) => thread.composerId),
- userDir,
- );
- return threads.map((thread) => ({
+ return hydratedThreads.map((thread) => ({
...thread,
transcriptDirs: transcriptDirsByComposerId.get(thread.composerId) ?? [],
}));
@@ -660,7 +721,7 @@ export const listCursorThreadsForGroup = async (
// its global header workspace uri, an existing bucket it points at, or — for threads with no such
// link — the dominant absolute path found in its tool calls.
-type GlobalHead = {
+type ParsedGlobalHead = {
name: string | null;
createdAtMs: number | null;
lastUpdatedAtMs: number | null;
@@ -670,6 +731,7 @@ type GlobalHead = {
reasoningEffort: string | null;
status: string | null;
};
+type GlobalHead = ParsedGlobalHead & { hasBubbleData: boolean };
type HeaderInfo = {
name: string | null;
uriPath: string | null;
@@ -898,17 +960,49 @@ const readAllHeads = (db: Database, options: CursorDiscoveryOptions = {}): Map;
- return new Map(rows.map((row) => [row.id, parseGlobalHead(row.value)]));
+ return new Map(
+ rows.map((row) => [
+ row.id,
+ { ...parseGlobalHead(row.value), hasBubbleData: hasStoredCursorBubbleData(row.value) },
+ ]),
+ );
}
const rows = db
.query(`SELECT substr(key, 14) AS id, value FROM cursorDiskKV WHERE key LIKE 'composerData:%'`)
.all() as Array<{ id: string; value: string | null }>;
- return new Map(rows.map((row) => [row.id, parseGlobalHead(row.value)]));
+ return new Map(
+ rows.map((row) => [
+ row.id,
+ { ...parseGlobalHead(row.value), hasBubbleData: hasStoredCursorBubbleData(row.value) },
+ ]),
+ );
};
-const parseGlobalHead = (value: string | null): GlobalHead => {
+const hasStoredCursorBubbleData = (value: string | null): boolean => {
+ if (value === null) {
+ return false;
+ }
+
+ try {
+ const parsed = asObject(JSON.parse(value) as JsonValue);
+ if (!parsed) {
+ return false;
+ }
+
+ const totalBubbleHeaderCount = asNumber(parsed.totalBubbleHeaderCount ?? null);
+ if (totalBubbleHeaderCount !== null) {
+ return totalBubbleHeaderCount > 0;
+ }
+
+ return Array.isArray(parsed.fullConversationHeadersOnly) && parsed.fullConversationHeadersOnly.length > 0;
+ } catch {
+ return true;
+ }
+};
+
+const parseGlobalHead = (value: string | null): ParsedGlobalHead => {
let parsed: Record | null = {};
if (value === null) {
return {
@@ -1013,6 +1107,37 @@ const readBubbleStats = (db: Database, composerIds: Iterable): Map => {
+ if (threads.length === 0) {
+ return threads;
+ }
+
+ const globalDbPath = getCursorGlobalDbPath(userDir);
+ if (!(await pathExists(globalDbPath))) {
+ return threads;
+ }
+
+ const stats = withCursorReadonlyDb(globalDbPath, (db) =>
+ readBubbleStats(
+ db,
+ threads.map((thread) => thread.composerId),
+ ),
+ );
+ return threads.map((thread) => {
+ const stat = stats.get(thread.composerId);
+ return stat
+ ? {
+ ...thread,
+ bubbleBytes: stat.bytes,
+ bubbleCount: stat.count,
+ }
+ : thread;
+ });
+};
+
const readHeaderInfo = (globalDbPath: string, strict = false): Map => {
const info = new Map();
for (const header of (strict ? loadGlobalComposerHeadersStrict : loadGlobalComposerHeaders)(globalDbPath)) {
@@ -1063,6 +1188,7 @@ type ResolvedThread = {
model: string | null;
reasoningEffort: string | null;
parentComposerId: string | null;
+ hasBubbleData?: boolean;
status: string | null;
};
@@ -1104,7 +1230,7 @@ const resolveThreadFolderHint = (
return { folder: head.pathHint, groupKey: `folder:${head.pathHint}` };
}
- if (stat.count > 0) {
+ if (stat.count > 0 || head?.hasBubbleData) {
const folder = inferFolderFromBubbles(db, composerId);
return { folder, groupKey: folder ? `folder:${folder}` : UNKNOWN_GROUP_KEY };
}
@@ -1141,6 +1267,7 @@ const resolveThreadFolder = (
folder,
groupKey,
groupLabel: folder ? path.basename(folder) : 'Unknown project',
+ hasBubbleData: Boolean(head?.hasBubbleData || stat.count > 0),
lastUpdatedAtMs: head?.lastUpdatedAtMs ?? null,
mode: head?.mode ?? null,
model: head?.model ?? null,
@@ -1179,12 +1306,13 @@ const assembleDiscovery = (
const groupLabels = new Map(bucketGroups.map((group) => [group.key, group.label]));
for (const thread of resolved) {
- if (thread.stat.count === 0 && thread.status === 'aborted') {
+ const hasBubbleData = thread.hasBubbleData ?? thread.stat.count > 0;
+ if (!hasBubbleData && thread.status === 'aborted') {
continue;
}
// Empty threads with no resolvable workspace are pure noise; keep them out of the catch-all.
- if (thread.groupKey === UNKNOWN_GROUP_KEY && thread.stat.count === 0) {
+ if (thread.groupKey === UNKNOWN_GROUP_KEY && !hasBubbleData) {
continue;
}
@@ -1296,7 +1424,6 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
options.updatedAfterMs === undefined
? new Set([...heads.keys(), ...headerInfo.keys(), ...bucketComposerIds.keys()])
: new Set(heads.keys());
- const stats = readBubbleStats(db, universe);
const resolved: ResolvedThread[] = [];
for (const composerId of universe) {
@@ -1305,7 +1432,7 @@ const buildDiscovery = async (userDir: string, options: CursorDiscoveryOptions =
composerId,
heads.get(composerId),
headerInfo.get(composerId),
- stats.get(composerId) ?? { bytes: 0, count: 0 },
+ { bytes: 0, count: 0 },
bucketIdToGroupKey,
bucketIdToFolder,
bucketComposerIds,
diff --git a/src/ui/lib/cursor-server.ts b/src/ui/lib/cursor-server.ts
index a388482..aefddda 100644
--- a/src/ui/lib/cursor-server.ts
+++ b/src/ui/lib/cursor-server.ts
@@ -204,7 +204,11 @@ const deleteCursorWorkspaceGroup = async (group: CursorWorkspaceGroup) => {
deleteCursorWorkspaceHistory,
pruneCursorThreads,
} = await import('@spiracha/lib/cursor-recovery');
- const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ const threads = await listCursorThreadsForGroup(group, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
+ includeTranscriptDirs: false,
+ });
const composerIds = [
...new Set([
...threads.map((thread) => thread.composerId),
@@ -297,7 +301,11 @@ const findCursorWorkspacesByComposerId = async (
const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
for (const group of workspaceGroups) {
- const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ const threads = await listCursorThreadsForGroup(group, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
+ includeTranscriptDirs: false,
+ });
for (const thread of threads) {
if (requestedIds.has(thread.composerId)) {
workspacesByComposerId.set(thread.composerId, group);
@@ -341,10 +349,19 @@ const renderCursorZipDownload = async (
export const findCursorThreadByComposerId = async (composerId: string) => {
const { listCursorThreadsForGroup, listCursorWorkspaceGroups } = await import('@spiracha/lib/cursor-db');
for (const group of await listCursorWorkspaceGroups()) {
- const threads = await listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ const threads = await listCursorThreadsForGroup(group, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
+ includeTranscriptDirs: false,
+ });
const thread = threads.find((candidate) => candidate.composerId === composerId);
if (thread) {
- return thread;
+ const hydratedThreads = await listCursorThreadsForGroup(group, undefined, {
+ includeBubbleStats: true,
+ includeModelAttribution: true,
+ includeTranscriptDirs: false,
+ });
+ return hydratedThreads.find((candidate) => candidate.composerId === composerId) ?? thread;
}
}
@@ -445,7 +462,11 @@ export const listCursorThreadsFn = createServerFn({ method: 'GET' })
.handler(async ({ data }) => {
const { listCursorThreadsForGroup } = await import('@spiracha/lib/cursor-db');
const group = await findGroupByKey(data.workspaceKey);
- return listCursorThreadsForGroup(group, undefined, { includeTranscriptDirs: false });
+ return listCursorThreadsForGroup(group, undefined, {
+ includeBubbleStats: true,
+ includeModelAttribution: true,
+ includeTranscriptDirs: false,
+ });
});
export const getCursorThreadDetailFn = createServerFn({ method: 'GET' })
diff --git a/src/ui/lib/cursor-server.vitest.ts b/src/ui/lib/cursor-server.vitest.ts
index b879861..914fe73 100644
--- a/src/ui/lib/cursor-server.vitest.ts
+++ b/src/ui/lib/cursor-server.vitest.ts
@@ -182,23 +182,43 @@ describe('findCursorThreadByComposerId', () => {
deleteCursorWorkspaceHistoryMock.mockResolvedValue({ cleanupFailures: [], removedPaths: [] });
});
- it('should query workspace threads without transcript directory discovery', async () => {
+ it('should query detail stats without scanning transcript directories for unrelated workspaces', async () => {
listCursorWorkspaceGroupsMock.mockResolvedValue([workspaceOne, workspaceTwo]);
- listCursorThreadsForGroupMock.mockResolvedValueOnce([makeThread()]).mockResolvedValueOnce([
- makeThread({
- composerId: 'thread-2',
- workspaceKey: workspaceTwo.key,
- workspaceLabel: workspaceTwo.label,
- }),
- ]);
+ listCursorThreadsForGroupMock
+ .mockResolvedValueOnce([makeThread()])
+ .mockResolvedValueOnce([
+ makeThread({
+ composerId: 'thread-2',
+ workspaceKey: workspaceTwo.key,
+ workspaceLabel: workspaceTwo.label,
+ }),
+ ])
+ .mockResolvedValueOnce([
+ makeThread({
+ bubbleBytes: 256,
+ bubbleCount: 7,
+ composerId: 'thread-2',
+ workspaceKey: workspaceTwo.key,
+ workspaceLabel: workspaceTwo.label,
+ }),
+ ]);
const thread = await findCursorThreadByComposerId('thread-2');
- expect(thread?.composerId).toBe('thread-2');
+ expect(thread).toMatchObject({ bubbleBytes: 256, bubbleCount: 7, composerId: 'thread-2' });
expect(listCursorThreadsForGroupMock).toHaveBeenNthCalledWith(1, workspaceOne, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
includeTranscriptDirs: false,
});
expect(listCursorThreadsForGroupMock).toHaveBeenNthCalledWith(2, workspaceTwo, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
+ includeTranscriptDirs: false,
+ });
+ expect(listCursorThreadsForGroupMock).toHaveBeenNthCalledWith(3, workspaceTwo, undefined, {
+ includeBubbleStats: true,
+ includeModelAttribution: true,
includeTranscriptDirs: false,
});
});
@@ -283,6 +303,8 @@ describe('deleteCursorWorkspaceFn', () => {
await expect(deleteCursorWorkspaceFn({ data: { workspaceKey: workspace.key } })).resolves.toBe(result);
expect(listCursorThreadsForGroupMock).toHaveBeenCalledWith(workspace, undefined, {
+ includeBubbleStats: false,
+ includeModelAttribution: false,
includeTranscriptDirs: false,
});
expect(collectCursorThreadsForDeletionMock).toHaveBeenCalledWith(['thread-1', 'thread-2', 'aborted-thread']);