From 64fbf59d86f71f7f146c52860eab1cf7bd0f5607 Mon Sep 17 00:00:00 2001 From: Roo Code Date: Sun, 3 Aug 2025 00:55:35 +0000 Subject: [PATCH] feat: implement stable project IDs (Sprint 1) - fixes #6618 Sprint 1 implementation includes: - Added projectId utility module with getProjectId, generateProjectId, and getWorkspaceStorageKey functions - Modified Task.ts to use project ID instead of workspace path for task metadata storage - Added "Generate Project ID" command to VSCode extension - Added comprehensive tests for projectId utility functions This is the foundation for stable project IDs that will preserve chat history when projects are moved or renamed. --- packages/types/src/vscode.ts | 2 + src/activate/registerCommands.ts | 26 ++++++ src/core/task/Task.ts | 6 +- src/package.json | 5 ++ src/package.nls.json | 1 + src/utils/__tests__/projectId.test.ts | 112 ++++++++++++++++++++++++++ src/utils/projectId.ts | 64 +++++++++++++++ 7 files changed, 215 insertions(+), 1 deletion(-) create mode 100644 src/utils/__tests__/projectId.test.ts create mode 100644 src/utils/projectId.ts diff --git a/packages/types/src/vscode.ts b/packages/types/src/vscode.ts index 00f6bbbcba..7ab41e68c9 100644 --- a/packages/types/src/vscode.ts +++ b/packages/types/src/vscode.ts @@ -53,6 +53,8 @@ export const commandIds = [ "focusInput", "acceptInput", "focusPanel", + + "generateProjectId", ] as const export type CommandId = (typeof commandIds)[number] diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index bd925b0e90..8f0529574a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -16,6 +16,8 @@ import { CodeIndexManager } from "../services/code-index/manager" import { importSettingsWithFeedback } from "../core/config/importExport" import { MdmService } from "../services/mdm/MdmService" import { t } from "../i18n" +import { generateProjectId } from "../utils/projectId" +import { getWorkspacePath } from "../utils/path" /** * Helper to get the visible ClineProvider instance or log if not found. @@ -218,6 +220,30 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt visibleProvider.postMessageToWebview({ type: "acceptInput" }) }, + generateProjectId: async () => { + const workspacePath = getWorkspacePath() + if (!workspacePath) { + vscode.window.showErrorMessage(t("common:errors.no_workspace")) + return + } + + try { + const projectId = await generateProjectId(workspacePath) + vscode.window.showInformationMessage(t("common:info.project_id_generated", { projectId })) + + // Notify the provider to update any cached state + const visibleProvider = getVisibleProviderOrLog(outputChannel) + if (visibleProvider) { + await visibleProvider.postStateToWebview() + } + } catch (error) { + vscode.window.showErrorMessage( + t("common:errors.project_id_generation_failed", { + error: error instanceof Error ? error.message : String(error), + }), + ) + } + }, }) export const openClineInNewTab = async ({ context, outputChannel }: Omit) => { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 84797b815c..257604b251 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -67,6 +67,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry" // utils import { calculateApiCostAnthropic } from "../../shared/cost" import { getWorkspacePath } from "../../utils/path" +import { getWorkspaceStorageKey } from "../../utils/projectId" // prompts import { formatResponse } from "../prompts/responses" @@ -575,12 +576,15 @@ export class Task extends EventEmitter implements TaskLike { globalStoragePath: this.globalStoragePath, }) + // Use project ID if available, otherwise fall back to workspace path + const workspaceStorageKey = await getWorkspaceStorageKey(this.cwd) + const { historyItem, tokenUsage } = await taskMetadata({ messages: this.clineMessages, taskId: this.taskId, taskNumber: this.taskNumber, globalStoragePath: this.globalStoragePath, - workspace: this.cwd, + workspace: workspaceStorageKey, mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode }) diff --git a/src/package.json b/src/package.json index cf60242533..8497c6dbfe 100644 --- a/src/package.json +++ b/src/package.json @@ -165,6 +165,11 @@ "title": "%command.importSettings.title%", "category": "%configuration.title%" }, + { + "command": "roo-cline.generateProjectId", + "title": "%command.generateProjectId.title%", + "category": "%configuration.title%" + }, { "command": "roo-cline.focusInput", "title": "%command.focusInput.title%", diff --git a/src/package.nls.json b/src/package.nls.json index 36ef72a823..d1988bb106 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -25,6 +25,7 @@ "command.terminal.fixCommand.title": "Fix This Command", "command.terminal.explainCommand.title": "Explain This Command", "command.acceptInput.title": "Accept Input/Suggestion", + "command.generateProjectId.title": "Generate Project ID", "configuration.title": "Roo Code", "commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled", "commands.deniedCommands.description": "Command prefixes that will be automatically denied without asking for approval. In case of conflicts with allowed commands, the longest prefix match takes precedence. Add * to deny all commands.", diff --git a/src/utils/__tests__/projectId.test.ts b/src/utils/__tests__/projectId.test.ts new file mode 100644 index 0000000000..3598fe61f2 --- /dev/null +++ b/src/utils/__tests__/projectId.test.ts @@ -0,0 +1,112 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import * as fs from "fs/promises" +import * as path from "path" +import { getProjectId, generateProjectId, getWorkspaceStorageKey } from "../projectId" +import { fileExistsAtPath } from "../fs" + +vi.mock("fs/promises") +vi.mock("path") +vi.mock("../fs") + +describe("projectId", () => { + const mockWorkspaceRoot = "/test/workspace" + const mockProjectIdPath = "/test/workspace/.rooprojectid" + const mockProjectId = "123e4567-e89b-12d3-a456-426614174000" + + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(path.join).mockImplementation((...args) => args.join("/")) + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + describe("getProjectId", () => { + it("should return existing project ID from file", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue(mockProjectId) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + expect(fileExistsAtPath).toHaveBeenCalledWith(mockProjectIdPath) + expect(fs.readFile).toHaveBeenCalledWith(mockProjectIdPath, "utf8") + }) + + it("should return null if file does not exist", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + expect(fs.readFile).not.toHaveBeenCalled() + }) + + it("should return null if file is empty", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue("") + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + }) + + it("should trim whitespace from project ID", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue(` ${mockProjectId} \n`) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + }) + + it("should return null on read error", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error")) + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) + + const result = await getProjectId(mockWorkspaceRoot) + + expect(result).toBeNull() + expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to read project ID: Error: Read error") + consoleErrorSpy.mockRestore() + }) + }) + + describe("generateProjectId", () => { + it("should generate and save a new project ID", async () => { + vi.mocked(fs.writeFile).mockResolvedValue() + + const result = await generateProjectId(mockWorkspaceRoot) + + expect(result).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(fs.writeFile).toHaveBeenCalledWith(mockProjectIdPath, result, "utf8") + }) + + it("should throw error if write fails", async () => { + vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write failed")) + + await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write failed") + }) + }) + + describe("getWorkspaceStorageKey", () => { + it("should return project ID if it exists", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(true) + vi.mocked(fs.readFile).mockResolvedValue(mockProjectId) + + const result = await getWorkspaceStorageKey(mockWorkspaceRoot) + + expect(result).toBe(mockProjectId) + }) + + it("should return workspace root if project ID does not exist", async () => { + vi.mocked(fileExistsAtPath).mockResolvedValue(false) + + const result = await getWorkspaceStorageKey(mockWorkspaceRoot) + + expect(result).toBe(mockWorkspaceRoot) + }) + }) +}) diff --git a/src/utils/projectId.ts b/src/utils/projectId.ts new file mode 100644 index 0000000000..5d022a6e51 --- /dev/null +++ b/src/utils/projectId.ts @@ -0,0 +1,64 @@ +import * as fs from "fs/promises" +import * as path from "path" +import { v4 as uuidv4 } from "uuid" +import { fileExistsAtPath } from "./fs" + +const PROJECT_ID_FILENAME = ".rooprojectid" + +/** + * Gets the project ID from the .rooprojectid file in the workspace root. + * Returns null if the file doesn't exist or can't be read. + * + * @param workspaceRoot The root directory of the workspace + * @returns The project ID string or null + */ +export async function getProjectId(workspaceRoot: string): Promise { + try { + const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME) + + if (!(await fileExistsAtPath(projectIdPath))) { + return null + } + + const content = await fs.readFile(projectIdPath, "utf8") + const projectId = content.trim() + + // Validate that it's a non-empty string + if (!projectId) { + return null + } + + return projectId + } catch (error) { + // Silently handle errors and return null + console.error(`Failed to read project ID: ${error}`) + return null + } +} + +/** + * Generates a new project ID and writes it to the .rooprojectid file. + * + * @param workspaceRoot The root directory of the workspace + * @returns The generated project ID + */ +export async function generateProjectId(workspaceRoot: string): Promise { + const projectId = uuidv4() + const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME) + + await fs.writeFile(projectIdPath, projectId, "utf8") + + return projectId +} + +/** + * Gets the storage key for a workspace, using the project ID if available, + * otherwise falling back to the workspace path. + * + * @param workspaceRoot The root directory of the workspace + * @returns The storage key to use for this workspace + */ +export async function getWorkspaceStorageKey(workspaceRoot: string): Promise { + const projectId = await getProjectId(workspaceRoot) + return projectId ?? workspaceRoot +}