feat: implement stable project ID feature (Sprint 1)

- Add project ID utility functions (getProjectId, generateProjectId, hasProjectId)
- Include project ID in task history metadata
- Update getTaskWithId to support searching by project ID
- Add "Generate Project ID" VSCode command
- Add comprehensive unit tests

This implements the core functionality for stable project IDs that persist
across different project locations. Users can now generate a unique project
ID that will be stored in a .rooprojectid file.

Part of #6618
This commit is contained in:
Roo Code 2025-08-04 21:50:06 +00:00
parent 1d714c8ce4
commit 72e6abfc67
10 changed files with 253 additions and 3 deletions

View file

@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
totalCost: z.number(),
size: z.number().optional(),
workspace: z.string().optional(),
projectId: z.string().optional(),
mode: z.string().optional(),
})

View file

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

View file

@ -218,6 +218,31 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
visibleProvider.postMessageToWebview({ type: "acceptInput" })
},
generateProjectId: async () => {
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
if (!workspaceFolder) {
vscode.window.showErrorMessage(t("projectId.noWorkspace"))
return
}
try {
const { generateProjectId, hasProjectId } = await import("../utils/projectId")
// Check if project already has an ID
const hasId = await hasProjectId(workspaceFolder.uri.fsPath)
if (hasId) {
vscode.window.showInformationMessage(t("projectId.alreadyExists"))
return
}
// Generate new project ID
await generateProjectId(workspaceFolder.uri.fsPath)
vscode.window.showInformationMessage(t("projectId.generated"))
} catch (error) {
outputChannel.appendLine(`Error generating project ID: ${error}`)
vscode.window.showErrorMessage(t("projectId.generateError", { error: String(error) }))
}
},
})
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

View file

@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
import { getApiMetrics } from "../../shared/getApiMetrics"
import { findLastIndex } from "../../shared/array"
import { getTaskDirectoryPath } from "../../utils/storage"
import { getProjectId } from "../../utils/projectId"
import { t } from "../../i18n"
const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
@ -79,6 +80,9 @@ export async function taskMetadata({
}
}
// Get project ID if available
const projectId = await getProjectId(workspace)
// Create historyItem once with pre-calculated values
const historyItem: HistoryItem = {
id: taskId,
@ -94,6 +98,7 @@ export async function taskMetadata({
totalCost: tokenUsage.totalCost,
size: taskDirSize,
workspace,
projectId: projectId || undefined,
mode,
}

View file

@ -1367,12 +1367,33 @@ export class ClineProvider
apiConversationHistory: Anthropic.MessageParam[]
}> {
const history = this.getGlobalState("taskHistory") ?? []
const historyItem = history.find((item) => item.id === id)
// First try to find by task ID
let historyItem = history.find((item) => item.id === id)
// If not found by ID and the ID looks like a project ID (UUID format),
// try to find by project ID
if (!historyItem && id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i)) {
// Get the current workspace root
const workspaceRoot = this.cwd
if (workspaceRoot) {
// Check if this workspace has the given project ID
const { getProjectId } = await import("../../utils/projectId")
const currentProjectId = await getProjectId(workspaceRoot)
if (currentProjectId === id) {
// Find the most recent task for this project ID
historyItem = history
.filter((item) => item.projectId === id)
.sort((a, b) => (b.ts || 0) - (a.ts || 0))[0]
}
}
}
if (historyItem) {
const { getTaskDirectoryPath } = await import("../../utils/storage")
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, historyItem.id)
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
@ -1392,7 +1413,9 @@ export class ClineProvider
// if we tried to get a task that doesn't exist, remove it from state
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
await this.deleteTaskFromState(id)
if (historyItem) {
await this.deleteTaskFromState(historyItem.id)
}
throw new Error("Task not found")
}

View file

@ -190,5 +190,11 @@
"preventCompletionWithOpenTodos": {
"description": "Prevent task completion when there are incomplete todos in the todo list"
}
},
"projectId": {
"noWorkspace": "No workspace folder found",
"alreadyExists": "Project already has a unique ID",
"generated": "Project ID generated successfully",
"generateError": "Failed to generate project ID: {{error}}"
}
}

View file

@ -174,6 +174,11 @@
"command": "roo-cline.acceptInput",
"title": "%command.acceptInput.title%",
"category": "%configuration.title%"
},
{
"command": "roo-cline.generateProjectId",
"title": "%command.generateProjectId.title%",
"category": "%configuration.title%"
}
],
"menus": {

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,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
import fs from "fs/promises"
import path from "path"
import { v4 as uuidv4 } from "uuid"
import { getProjectId, generateProjectId, hasProjectId } from "../projectId"
import { fileExistsAtPath } from "../fs"
// Mock dependencies
vi.mock("fs/promises")
vi.mock("../fs")
vi.mock("uuid")
describe("projectId", () => {
const mockWorkspaceRoot = "/test/workspace"
const mockProjectId = "12345678-1234-1234-1234-123456789012"
const projectIdPath = path.join(mockWorkspaceRoot, ".rooprojectid")
beforeEach(() => {
vi.clearAllMocks()
})
afterEach(() => {
vi.restoreAllMocks()
})
describe("getProjectId", () => {
it("should return project ID when file exists and contains valid ID", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)
const result = await getProjectId(mockWorkspaceRoot)
expect(result).toBe(mockProjectId)
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
expect(fs.readFile).toHaveBeenCalledWith(projectIdPath, "utf8")
})
it("should return null when file does not exist", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
const result = await getProjectId(mockWorkspaceRoot)
expect(result).toBeNull()
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
expect(fs.readFile).not.toHaveBeenCalled()
})
it("should return null when 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 handle read errors gracefully", 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")
})
})
describe("generateProjectId", () => {
it("should generate and write a new project ID", async () => {
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
vi.mocked(fs.writeFile).mockResolvedValue()
const result = await generateProjectId(mockWorkspaceRoot)
expect(result).toBe(mockProjectId)
expect(uuidv4).toHaveBeenCalled()
expect(fs.writeFile).toHaveBeenCalledWith(projectIdPath, mockProjectId, "utf8")
})
it("should handle write errors", async () => {
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write error"))
await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write error")
})
})
describe("hasProjectId", () => {
it("should return true when project ID file exists", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
const result = await hasProjectId(mockWorkspaceRoot)
expect(result).toBe(true)
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
})
it("should return false when project ID file does not exist", async () => {
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
const result = await hasProjectId(mockWorkspaceRoot)
expect(result).toBe(false)
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
})
})
})

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)
const exists = await fileExistsAtPath(projectIdPath)
if (!exists) {
return null
}
const content = await fs.readFile(projectIdPath, "utf8")
const projectId = content.trim()
// Validate that it's not empty
if (!projectId) {
return null
}
return projectId
} catch (error) {
// If we can't read the file for any reason, 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
}
/**
* Checks if a project has a project ID file.
*
* @param workspaceRoot The root directory of the workspace
* @returns True if the project has a .rooprojectid file
*/
export async function hasProjectId(workspaceRoot: string): Promise<boolean> {
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
return await fileExistsAtPath(projectIdPath)
}