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.
This commit is contained in:
Roo Code 2025-08-03 00:55:35 +00:00
parent 3f966dfaa3
commit 64fbf59d86
7 changed files with 215 additions and 1 deletions

View file

@ -53,6 +53,8 @@ export const commandIds = [
"focusInput",
"acceptInput",
"focusPanel",
"generateProjectId",
] as const
export type CommandId = (typeof commandIds)[number]

View file

@ -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<RegisterCommandOptions, "provider">) => {

View file

@ -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<TaskEvents> 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
})

View file

@ -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%",

View file

@ -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.",

View file

@ -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)
})
})
})

64
src/utils/projectId.ts Normal file
View file

@ -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<string | null> {
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<string> {
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<string> {
const projectId = await getProjectId(workspaceRoot)
return projectId ?? workspaceRoot
}