Skip to content

Commit 72e6abf

Browse files
committed
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
1 parent 1d714c8 commit 72e6abf

File tree

10 files changed

+253
-3
lines changed

10 files changed

+253
-3
lines changed

packages/types/src/history.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ export const historyItemSchema = z.object({
1616
totalCost: z.number(),
1717
size: z.number().optional(),
1818
workspace: z.string().optional(),
19+
projectId: z.string().optional(),
1920
mode: z.string().optional(),
2021
})
2122

packages/types/src/vscode.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ export const commandIds = [
5353
"focusInput",
5454
"acceptInput",
5555
"focusPanel",
56+
57+
"generateProjectId",
5658
] as const
5759

5860
export type CommandId = (typeof commandIds)[number]

src/activate/registerCommands.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,31 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
218218

219219
visibleProvider.postMessageToWebview({ type: "acceptInput" })
220220
},
221+
generateProjectId: async () => {
222+
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]
223+
if (!workspaceFolder) {
224+
vscode.window.showErrorMessage(t("projectId.noWorkspace"))
225+
return
226+
}
227+
228+
try {
229+
const { generateProjectId, hasProjectId } = await import("../utils/projectId")
230+
231+
// Check if project already has an ID
232+
const hasId = await hasProjectId(workspaceFolder.uri.fsPath)
233+
if (hasId) {
234+
vscode.window.showInformationMessage(t("projectId.alreadyExists"))
235+
return
236+
}
237+
238+
// Generate new project ID
239+
await generateProjectId(workspaceFolder.uri.fsPath)
240+
vscode.window.showInformationMessage(t("projectId.generated"))
241+
} catch (error) {
242+
outputChannel.appendLine(`Error generating project ID: ${error}`)
243+
vscode.window.showErrorMessage(t("projectId.generateError", { error: String(error) }))
244+
}
245+
},
221246
})
222247

223248
export const openClineInNewTab = async ({ context, outputChannel }: Omit<RegisterCommandOptions, "provider">) => {

src/core/task-persistence/taskMetadata.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { combineCommandSequences } from "../../shared/combineCommandSequences"
88
import { getApiMetrics } from "../../shared/getApiMetrics"
99
import { findLastIndex } from "../../shared/array"
1010
import { getTaskDirectoryPath } from "../../utils/storage"
11+
import { getProjectId } from "../../utils/projectId"
1112
import { t } from "../../i18n"
1213

1314
const taskSizeCache = new NodeCache({ stdTTL: 30, checkperiod: 5 * 60 })
@@ -79,6 +80,9 @@ export async function taskMetadata({
7980
}
8081
}
8182

83+
// Get project ID if available
84+
const projectId = await getProjectId(workspace)
85+
8286
// Create historyItem once with pre-calculated values
8387
const historyItem: HistoryItem = {
8488
id: taskId,
@@ -94,6 +98,7 @@ export async function taskMetadata({
9498
totalCost: tokenUsage.totalCost,
9599
size: taskDirSize,
96100
workspace,
101+
projectId: projectId || undefined,
97102
mode,
98103
}
99104

src/core/webview/ClineProvider.ts

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1367,12 +1367,33 @@ export class ClineProvider
13671367
apiConversationHistory: Anthropic.MessageParam[]
13681368
}> {
13691369
const history = this.getGlobalState("taskHistory") ?? []
1370-
const historyItem = history.find((item) => item.id === id)
1370+
1371+
// First try to find by task ID
1372+
let historyItem = history.find((item) => item.id === id)
1373+
1374+
// If not found by ID and the ID looks like a project ID (UUID format),
1375+
// try to find by project ID
1376+
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)) {
1377+
// Get the current workspace root
1378+
const workspaceRoot = this.cwd
1379+
if (workspaceRoot) {
1380+
// Check if this workspace has the given project ID
1381+
const { getProjectId } = await import("../../utils/projectId")
1382+
const currentProjectId = await getProjectId(workspaceRoot)
1383+
1384+
if (currentProjectId === id) {
1385+
// Find the most recent task for this project ID
1386+
historyItem = history
1387+
.filter((item) => item.projectId === id)
1388+
.sort((a, b) => (b.ts || 0) - (a.ts || 0))[0]
1389+
}
1390+
}
1391+
}
13711392

13721393
if (historyItem) {
13731394
const { getTaskDirectoryPath } = await import("../../utils/storage")
13741395
const globalStoragePath = this.contextProxy.globalStorageUri.fsPath
1375-
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, id)
1396+
const taskDirPath = await getTaskDirectoryPath(globalStoragePath, historyItem.id)
13761397
const apiConversationHistoryFilePath = path.join(taskDirPath, GlobalFileNames.apiConversationHistory)
13771398
const uiMessagesFilePath = path.join(taskDirPath, GlobalFileNames.uiMessages)
13781399
const fileExists = await fileExistsAtPath(apiConversationHistoryFilePath)
@@ -1392,7 +1413,9 @@ export class ClineProvider
13921413

13931414
// if we tried to get a task that doesn't exist, remove it from state
13941415
// FIXME: this seems to happen sometimes when the json file doesnt save to disk for some reason
1395-
await this.deleteTaskFromState(id)
1416+
if (historyItem) {
1417+
await this.deleteTaskFromState(historyItem.id)
1418+
}
13961419
throw new Error("Task not found")
13971420
}
13981421

src/i18n/locales/en/common.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,5 +190,11 @@
190190
"preventCompletionWithOpenTodos": {
191191
"description": "Prevent task completion when there are incomplete todos in the todo list"
192192
}
193+
},
194+
"projectId": {
195+
"noWorkspace": "No workspace folder found",
196+
"alreadyExists": "Project already has a unique ID",
197+
"generated": "Project ID generated successfully",
198+
"generateError": "Failed to generate project ID: {{error}}"
193199
}
194200
}

src/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,11 @@
174174
"command": "roo-cline.acceptInput",
175175
"title": "%command.acceptInput.title%",
176176
"category": "%configuration.title%"
177+
},
178+
{
179+
"command": "roo-cline.generateProjectId",
180+
"title": "%command.generateProjectId.title%",
181+
"category": "%configuration.title%"
177182
}
178183
],
179184
"menus": {

src/package.nls.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
"command.terminal.fixCommand.title": "Fix This Command",
2626
"command.terminal.explainCommand.title": "Explain This Command",
2727
"command.acceptInput.title": "Accept Input/Suggestion",
28+
"command.generateProjectId.title": "Generate Project ID",
2829
"configuration.title": "Roo Code",
2930
"commands.allowedCommands.description": "Commands that can be auto-executed when 'Always approve execute operations' is enabled",
3031
"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.",
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import fs from "fs/promises"
3+
import path from "path"
4+
import { v4 as uuidv4 } from "uuid"
5+
import { getProjectId, generateProjectId, hasProjectId } from "../projectId"
6+
import { fileExistsAtPath } from "../fs"
7+
8+
// Mock dependencies
9+
vi.mock("fs/promises")
10+
vi.mock("../fs")
11+
vi.mock("uuid")
12+
13+
describe("projectId", () => {
14+
const mockWorkspaceRoot = "/test/workspace"
15+
const mockProjectId = "12345678-1234-1234-1234-123456789012"
16+
const projectIdPath = path.join(mockWorkspaceRoot, ".rooprojectid")
17+
18+
beforeEach(() => {
19+
vi.clearAllMocks()
20+
})
21+
22+
afterEach(() => {
23+
vi.restoreAllMocks()
24+
})
25+
26+
describe("getProjectId", () => {
27+
it("should return project ID when file exists and contains valid ID", async () => {
28+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
29+
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)
30+
31+
const result = await getProjectId(mockWorkspaceRoot)
32+
33+
expect(result).toBe(mockProjectId)
34+
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
35+
expect(fs.readFile).toHaveBeenCalledWith(projectIdPath, "utf8")
36+
})
37+
38+
it("should return null when file does not exist", async () => {
39+
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
40+
41+
const result = await getProjectId(mockWorkspaceRoot)
42+
43+
expect(result).toBeNull()
44+
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
45+
expect(fs.readFile).not.toHaveBeenCalled()
46+
})
47+
48+
it("should return null when file is empty", async () => {
49+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
50+
vi.mocked(fs.readFile).mockResolvedValue("")
51+
52+
const result = await getProjectId(mockWorkspaceRoot)
53+
54+
expect(result).toBeNull()
55+
})
56+
57+
it("should trim whitespace from project ID", async () => {
58+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
59+
vi.mocked(fs.readFile).mockResolvedValue(` ${mockProjectId} \n`)
60+
61+
const result = await getProjectId(mockWorkspaceRoot)
62+
63+
expect(result).toBe(mockProjectId)
64+
})
65+
66+
it("should handle read errors gracefully", async () => {
67+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
68+
vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error"))
69+
70+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
71+
72+
const result = await getProjectId(mockWorkspaceRoot)
73+
74+
expect(result).toBeNull()
75+
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to read project ID: Error: Read error")
76+
})
77+
})
78+
79+
describe("generateProjectId", () => {
80+
it("should generate and write a new project ID", async () => {
81+
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
82+
vi.mocked(fs.writeFile).mockResolvedValue()
83+
84+
const result = await generateProjectId(mockWorkspaceRoot)
85+
86+
expect(result).toBe(mockProjectId)
87+
expect(uuidv4).toHaveBeenCalled()
88+
expect(fs.writeFile).toHaveBeenCalledWith(projectIdPath, mockProjectId, "utf8")
89+
})
90+
91+
it("should handle write errors", async () => {
92+
vi.mocked(uuidv4).mockReturnValue(mockProjectId as any)
93+
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write error"))
94+
95+
await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write error")
96+
})
97+
})
98+
99+
describe("hasProjectId", () => {
100+
it("should return true when project ID file exists", async () => {
101+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
102+
103+
const result = await hasProjectId(mockWorkspaceRoot)
104+
105+
expect(result).toBe(true)
106+
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
107+
})
108+
109+
it("should return false when project ID file does not exist", async () => {
110+
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
111+
112+
const result = await hasProjectId(mockWorkspaceRoot)
113+
114+
expect(result).toBe(false)
115+
expect(fileExistsAtPath).toHaveBeenCalledWith(projectIdPath)
116+
})
117+
})
118+
})

src/utils/projectId.ts

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
import * as fs from "fs/promises"
2+
import * as path from "path"
3+
import { v4 as uuidv4 } from "uuid"
4+
import { fileExistsAtPath } from "./fs"
5+
6+
const PROJECT_ID_FILENAME = ".rooprojectid"
7+
8+
/**
9+
* Gets the project ID from the .rooprojectid file in the workspace root.
10+
* Returns null if the file doesn't exist or can't be read.
11+
*
12+
* @param workspaceRoot The root directory of the workspace
13+
* @returns The project ID string or null
14+
*/
15+
export async function getProjectId(workspaceRoot: string): Promise<string | null> {
16+
try {
17+
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
18+
const exists = await fileExistsAtPath(projectIdPath)
19+
20+
if (!exists) {
21+
return null
22+
}
23+
24+
const content = await fs.readFile(projectIdPath, "utf8")
25+
const projectId = content.trim()
26+
27+
// Validate that it's not empty
28+
if (!projectId) {
29+
return null
30+
}
31+
32+
return projectId
33+
} catch (error) {
34+
// If we can't read the file for any reason, return null
35+
console.error(`Failed to read project ID: ${error}`)
36+
return null
37+
}
38+
}
39+
40+
/**
41+
* Generates a new project ID and writes it to the .rooprojectid file.
42+
*
43+
* @param workspaceRoot The root directory of the workspace
44+
* @returns The generated project ID
45+
*/
46+
export async function generateProjectId(workspaceRoot: string): Promise<string> {
47+
const projectId = uuidv4()
48+
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
49+
50+
await fs.writeFile(projectIdPath, projectId, "utf8")
51+
52+
return projectId
53+
}
54+
55+
/**
56+
* Checks if a project has a project ID file.
57+
*
58+
* @param workspaceRoot The root directory of the workspace
59+
* @returns True if the project has a .rooprojectid file
60+
*/
61+
export async function hasProjectId(workspaceRoot: string): Promise<boolean> {
62+
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
63+
return await fileExistsAtPath(projectIdPath)
64+
}

0 commit comments

Comments
 (0)