From 400ca083a2a88d14872c4e872f370b2bf4ff72cf Mon Sep 17 00:00:00 2001 From: Andrew Lee Date: Thu, 2 Jul 2026 17:51:23 +0000 Subject: [PATCH] fix(cursor): map composers to workspace via composer.composerHeaders Recent Cursor builds renamed the per-workspace composer list from ItemTable['composer.composerData'] to 'composer.composerHeaders' (identical { allComposers: [{ composerId }] } shape). loadWorkspaceMap only read the old key, so on these builds the composer->workspace map came back empty and every composer fell through to the 'cursor' orphan bucket, losing per-project attribution. Read both keys and merge their allComposers lists (backward compatible with older installs). Add a regression test covering the new composer.composerHeaders key and the legacy composer.composerData key. Verified against a real workspace state.vscdb: composer.composerData absent, composer.composerHeaders present with composerIds matching the bubbleId/ composerData rows; 21/22 calls now attribute to the real workspace instead of the 'cursor' orphan bucket. AI-Origin: human --- src/providers/cursor.ts | 31 +++++++----- tests/providers/cursor.test.ts | 86 +++++++++++++++++++++++++++++++++- 2 files changed, 104 insertions(+), 13 deletions(-) diff --git a/src/providers/cursor.ts b/src/providers/cursor.ts index cae2e2a..63ec7f2 100644 --- a/src/providers/cursor.ts +++ b/src/providers/cursor.ts @@ -193,22 +193,31 @@ function loadWorkspaceMap(workspaceStorageDir: string): WorkspaceMapping { continue } try { + // Cursor renamed the per-workspace composer list from + // 'composer.composerData' to 'composer.composerHeaders' in newer builds + // (identical { allComposers: [{ composerId }] } shape). Read both keys + // and merge so the composer -> workspace mapping keeps working across + // Cursor versions. Without this, on builds that only write + // 'composer.composerHeaders' every composer falls through to the + // 'cursor' orphan bucket and per-project attribution is lost. const rows = db.query<{ value: string }>( - "SELECT value FROM ItemTable WHERE key='composer.composerData'", + "SELECT value FROM ItemTable WHERE key IN ('composer.composerData', 'composer.composerHeaders')", ) if (rows.length === 0) continue - let parsed: { allComposers?: Array<{ composerId?: string }> } - try { - parsed = JSON.parse(rows[0]!.value) - } catch { - continue - } const project = sanitizeWorkspaceUri(folder) let added = 0 - for (const c of parsed.allComposers ?? []) { - if (typeof c.composerId === 'string') { - result.composerToWorkspace.set(c.composerId, folder) - added += 1 + for (const row of rows) { + let parsed: { allComposers?: Array<{ composerId?: string }> } + try { + parsed = JSON.parse(row.value) + } catch { + continue + } + for (const c of parsed.allComposers ?? []) { + if (typeof c.composerId === 'string') { + result.composerToWorkspace.set(c.composerId, folder) + added += 1 + } } } if (added > 0) { diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 867886a..61151a6 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -1,6 +1,11 @@ -import { describe, it, expect, beforeEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { createRequire } from 'node:module' import { getAllProviders } from '../../src/providers/index.js' -import { getCursorTimeFloor } from '../../src/providers/cursor.js' +import { getCursorTimeFloor, createCursorProvider, clearCursorWorkspaceMapCache } from '../../src/providers/cursor.js' +import { isSqliteAvailable } from '../../src/sqlite.js' import type { Provider } from '../../src/providers/types.js' describe('cursor provider', () => { @@ -83,3 +88,80 @@ describe('cursor cache', () => { expect(result).toBeNull() }) }) + +// Regression: Cursor renamed the per-workspace composer list key from +// 'composer.composerData' to 'composer.composerHeaders'. loadWorkspaceMap must +// read both, otherwise every composer orphans into the 'cursor' catch-all and +// per-project attribution is lost. +describe('cursor workspace mapping (composer.composerHeaders regression)', () => { + const requireForTest = createRequire(import.meta.url) + type TestDb = { + exec(sql: string): void + prepare(sql: string): { run(...params: unknown[]): void } + close(): void + } + let root: string + + function writeItemTableDb(dbPath: string, key: string, composerIds: string[]): void { + const { DatabaseSync } = requireForTest('node:sqlite') as { DatabaseSync: new (p: string) => TestDb } + const db = new DatabaseSync(dbPath) + db.exec('CREATE TABLE ItemTable (key TEXT PRIMARY KEY, value TEXT)') + if (key) { + const value = JSON.stringify({ allComposers: composerIds.map(composerId => ({ composerId })) }) + db.prepare('INSERT INTO ItemTable (key, value) VALUES (?, ?)').run(key, value) + } + db.close() + } + + async function makeWorkspace(hash: string, folderUri: string, key: string, composerIds: string[]): Promise { + const wsDir = join(root, 'User', 'workspaceStorage', hash) + await mkdir(wsDir, { recursive: true }) + await writeFile(join(wsDir, 'workspace.json'), JSON.stringify({ folder: folderUri })) + writeItemTableDb(join(wsDir, 'state.vscdb'), key, composerIds) + } + + async function makeGlobalDb(): Promise { + const gsDir = join(root, 'User', 'globalStorage') + await mkdir(gsDir, { recursive: true }) + const dbPath = join(gsDir, 'state.vscdb') + // discoverSessions only needs the global DB to exist; the workspace map is + // built from the sibling workspaceStorage dir. + writeItemTableDb(dbPath, '', []) + return dbPath + } + + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'cursor-ws-test-')) + clearCursorWorkspaceMapCache() + }) + + afterEach(async () => { + clearCursorWorkspaceMapCache() + await rm(root, { recursive: true, force: true }) + }) + + it.skipIf(!isSqliteAvailable())( + 'maps composers to their workspace via composer.composerHeaders (new Cursor key)', + async () => { + await makeWorkspace('ws-headers', 'file:///home/user/myapp', 'composer.composerHeaders', ['comp-1', 'comp-2']) + const dbPath = await makeGlobalDb() + + const sources = await createCursorProvider(dbPath).discoverSessions() + const projects = sources.map(s => s.project) + + // Before the fix these composers orphaned to the 'cursor' catch-all. + expect(projects).toContain('-home-user-myapp') + }, + ) + + it.skipIf(!isSqliteAvailable())( + 'still maps composers via the legacy composer.composerData key', + async () => { + await makeWorkspace('ws-legacy', 'file:///home/user/legacy', 'composer.composerData', ['old-1']) + const dbPath = await makeGlobalDb() + + const sources = await createCursorProvider(dbPath).discoverSessions() + expect(sources.map(s => s.project)).toContain('-home-user-legacy') + }, + ) +})