diff --git a/README.md b/README.md index 4249d62949..d78c32968b 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,11 @@ freebuff Then describe what you want. Freebuff finds the relevant files, makes changes, and runs the checks that matter for your project. +**CLI history upgrade:** Chats saved by older versions remain on disk, but need +manual recovery before they appear in `/history` or can be continued. See +[CLI chat history](./docs/cli-chat-history.md#recovering-history-saved-by-older-versions) +for recovery instructions and the reason automatic migration is unsafe. + ## Models Freebuff includes a curated model catalog. The regular picker currently offers: diff --git a/cli/src/__tests__/project-history-isolation.test.ts b/cli/src/__tests__/project-history-isolation.test.ts new file mode 100644 index 0000000000..2a06963ef3 --- /dev/null +++ b/cli/src/__tests__/project-history-isolation.test.ts @@ -0,0 +1,173 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { + getCurrentChatId, + getCurrentChatDir, + getProjectDataDir, + setCurrentChatId, + setProjectRoot, + tryGetProjectRoot, +} from '../project-files' +import { deleteChatSession, getAllChats } from '../utils/chat-history' +import { + loadMostRecentChatState, + saveChatState, +} from '../utils/run-state-storage' + +import type { ChatMessage } from '../types/chat' +import type { RunState } from '@codebuff/sdk' + +describe('project history isolation', () => { + let tempRoot: string + let projectA: string + let projectB: string + let previousConfigDir: string | undefined + let previousProjectRoot: string | undefined + let previousChatId: string + + beforeEach(() => { + previousConfigDir = process.env.FREEBUFF_CONFIG_DIR + previousProjectRoot = tryGetProjectRoot() + previousChatId = getCurrentChatId() + tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'project-history-')) + process.env.FREEBUFF_CONFIG_DIR = path.join(tempRoot, 'config') + projectA = path.join(tempRoot, 'client-a', 'app') + projectB = path.join(tempRoot, 'client-b', 'app') + fs.mkdirSync(projectA, { recursive: true }) + fs.mkdirSync(projectB, { recursive: true }) + }) + + afterEach(() => { + if (previousConfigDir === undefined) delete process.env.FREEBUFF_CONFIG_DIR + else process.env.FREEBUFF_CONFIG_DIR = previousConfigDir + setProjectRoot(previousProjectRoot ?? process.cwd()) + setCurrentChatId(previousChatId) + fs.rmSync(tempRoot, { recursive: true, force: true }) + }) + + function saveChat(chatId: string, content: string) { + setCurrentChatId(chatId) + const runState = { + output: { type: 'error', message: content }, + } as RunState + const messages: ChatMessage[] = [ + { + id: chatId, + variant: 'user', + content, + timestamp: new Date().toISOString(), + }, + ] + saveChatState(runState, messages, getCurrentChatDir()) + } + + test('continuing a same-named project does not restore another project', () => { + setProjectRoot(projectA) + saveChat('a-chat', 'client A context') + const dataDirA = getProjectDataDir() + + setProjectRoot(projectB) + expect(loadMostRecentChatState()).toBeNull() + expect(getAllChats()).toEqual([]) + expect(getProjectDataDir()).not.toBe(dataDirA) + + saveChat('b-chat', 'client B context') + expect(loadMostRecentChatState()?.messages[0]?.content).toBe( + 'client B context', + ) + + setProjectRoot(projectA) + expect(loadMostRecentChatState()?.chatId).toBe('a-chat') + expect(loadMostRecentChatState()?.runState.output).toEqual({ + type: 'error', + message: 'client A context', + }) + expect(getAllChats().map((chat) => chat.chatId)).toEqual(['a-chat']) + }) + + test('matching chat IDs cannot overwrite or delete another project history', () => { + setProjectRoot(projectA) + saveChat('same-chat', 'client A context') + setProjectRoot(projectB) + saveChat('same-chat', 'client B context') + setProjectRoot(projectA) + expect(loadMostRecentChatState()?.messages[0]?.content).toBe( + 'client A context', + ) + setProjectRoot(projectB) + expect(deleteChatSession('same-chat')).toBe(true) + setProjectRoot(projectA) + expect(loadMostRecentChatState()?.messages[0]?.content).toBe( + 'client A context', + ) + }) + + test('equivalent absolute paths retain the same history', () => { + setProjectRoot(projectA) + saveChat('a-chat', 'client A context') + const dataDir = getProjectDataDir() + setProjectRoot(path.join(projectA, '..', 'app')) + expect(getProjectDataDir()).toBe(dataDir) + expect(loadMostRecentChatState()?.chatId).toBe('a-chat') + }) + + test('unowned legacy history is preserved without automatic restoration', () => { + const legacyDir = path.join( + process.env.FREEBUFF_CONFIG_DIR!, + 'projects', + 'app', + 'chats', + 'legacy-chat', + ) + fs.mkdirSync(legacyDir, { recursive: true }) + fs.writeFileSync( + path.join(legacyDir, 'chat-messages.json'), + JSON.stringify([ + { id: 'legacy', variant: 'user', content: 'unknown project context' }, + ]), + ) + const original = fs.readFileSync( + path.join(legacyDir, 'chat-messages.json'), + 'utf8', + ) + for (const project of [projectA, projectB]) { + setProjectRoot(project) + expect(loadMostRecentChatState()).toBeNull() + expect(loadMostRecentChatState('legacy-chat')).toBeNull() + saveChat('new-chat', 'new context') + } + expect( + fs.readFileSync(path.join(legacyDir, 'chat-messages.json'), 'utf8'), + ).toBe(original) + }) + + test('a user-selected legacy chat can be copied into its project history', () => { + setProjectRoot(projectA) + saveChat('verified-chat', 'client A context') + const legacyDir = path.join( + process.env.FREEBUFF_CONFIG_DIR!, + 'projects', + 'app', + 'chats', + 'verified-chat', + ) + fs.mkdirSync(path.dirname(legacyDir), { recursive: true }) + fs.renameSync(getCurrentChatDir(), legacyDir) + expect(loadMostRecentChatState()).toBeNull() + + fs.cpSync(legacyDir, getCurrentChatDir(), { + recursive: true, + force: false, + errorOnExist: true, + }) + expect(loadMostRecentChatState()?.messages[0]?.content).toBe( + 'client A context', + ) + setProjectRoot(projectB) + expect(loadMostRecentChatState()).toBeNull() + expect(fs.existsSync(legacyDir)).toBe(true) + }) +}) diff --git a/cli/src/project-files.ts b/cli/src/project-files.ts index c7d3783354..fa9d351376 100644 --- a/cli/src/project-files.ts +++ b/cli/src/project-files.ts @@ -1,5 +1,6 @@ import { mkdirSync, readdirSync, statSync } from 'fs' import path from 'path' +import { createHash } from 'node:crypto' import { getConfigDir } from './utils/auth' import { IS_FREEBUFF } from './utils/constants' @@ -57,10 +58,13 @@ export function getProjectDataDir(): string { throw new Error('Project root not set') } - const baseName = path.basename(root) - const baseDir = path.join(getConfigDir(), 'projects', baseName) - - return baseDir + // Folder names are not project identities: unrelated checkouts named + // "app" must not share transcripts or resumed agent state. Keep a separate + // namespace so new keys cannot collide with legacy basename directories. + const projectKey = createHash('sha256') + .update(path.resolve(root)) + .digest('hex') + return path.join(getConfigDir(), 'projects', 'by-path', projectKey) } /** diff --git a/docs/cli-chat-history.md b/docs/cli-chat-history.md new file mode 100644 index 0000000000..09b039cac8 --- /dev/null +++ b/docs/cli-chat-history.md @@ -0,0 +1,67 @@ +# Project chat history + +The CLI keeps each project's saved chats under +`/projects/by-path//chats/`. The project key is the +SHA-256 hash of the full absolute project path, resolved with Node's +`path.resolve`. Different checkouts have separate histories even when their +folder names match. Selecting the same path again restores its history. + +`path.resolve` normalizes relative paths and `..`, but does not resolve symlinks. +Opening the same project through a different symlink path can therefore use a +separate history directory. The key follows the path supplied to the CLI; +it does not merge filesystem aliases. + +The production config directory defaults to `~/.config/manicode` on all +platforms. `FREEBUFF_CONFIG_DIR` overrides it with an absolute path; +development and test builds use `manicode-dev` and `manicode-test` respectively. + +## Recovering history saved by older versions + +**Upgrade behavior:** Previously saved chats will not appear in `/history` or +be available for continuation until you recover them. They have not been deleted. +This behavior change should also be included in the release notes when the fix +is shipped. + +Older versions stored chats under `/projects//chats/`. +That directory could contain chats from several unrelated projects with the +same folder name. New versions leave those files untouched, but do not +automatically restore or migrate them: a saved chat does not reliably identify +which project owns it. + +To recover a chat, close the CLI and inspect the old chat directory's +`chat-messages.json`. Only import a chat after confirming it belongs to the +project you want to continue. Keep the original directory as a backup. + +Open a terminal in that project's root and run the following with Node. Replace +`CHAT_ID` with the name of the selected old chat directory. If using a +development build or a custom configuration, set `configDir` accordingly. + +```js +const fs = require('node:fs') +const path = require('node:path') +const os = require('node:os') +const { createHash } = require('node:crypto') + +const configDir = + process.env.FREEBUFF_CONFIG_DIR || + path.join(os.homedir(), '.config', 'manicode') +const root = path.resolve(process.cwd()) +const chatId = 'CHAT_ID' +const key = createHash('sha256').update(root).digest('hex') +const source = path.join( + configDir, + 'projects', + path.basename(root), + 'chats', + chatId, +) +const target = path.join(configDir, 'projects', 'by-path', key, 'chats', chatId) + +fs.mkdirSync(path.dirname(target), { recursive: true }) +fs.cpSync(source, target, { recursive: true, force: false, errorOnExist: true }) +``` + +Restart the CLI in that project. The copied chat is now available in `/history`. +Copy individual verified chats rather than the whole legacy history directory. +If copying reports an error, check the source chat ID and paths. An existing +target file is deliberately not overwritten; the original chat remains intact.