Skip to content

Commit 64fbf59

Browse files
committed
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.
1 parent 3f966df commit 64fbf59

File tree

7 files changed

+215
-1
lines changed

7 files changed

+215
-1
lines changed

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: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ import { CodeIndexManager } from "../services/code-index/manager"
1616
import { importSettingsWithFeedback } from "../core/config/importExport"
1717
import { MdmService } from "../services/mdm/MdmService"
1818
import { t } from "../i18n"
19+
import { generateProjectId } from "../utils/projectId"
20+
import { getWorkspacePath } from "../utils/path"
1921

2022
/**
2123
* Helper to get the visible ClineProvider instance or log if not found.
@@ -218,6 +220,30 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt
218220

219221
visibleProvider.postMessageToWebview({ type: "acceptInput" })
220222
},
223+
generateProjectId: async () => {
224+
const workspacePath = getWorkspacePath()
225+
if (!workspacePath) {
226+
vscode.window.showErrorMessage(t("common:errors.no_workspace"))
227+
return
228+
}
229+
230+
try {
231+
const projectId = await generateProjectId(workspacePath)
232+
vscode.window.showInformationMessage(t("common:info.project_id_generated", { projectId }))
233+
234+
// Notify the provider to update any cached state
235+
const visibleProvider = getVisibleProviderOrLog(outputChannel)
236+
if (visibleProvider) {
237+
await visibleProvider.postStateToWebview()
238+
}
239+
} catch (error) {
240+
vscode.window.showErrorMessage(
241+
t("common:errors.project_id_generation_failed", {
242+
error: error instanceof Error ? error.message : String(error),
243+
}),
244+
)
245+
}
246+
},
221247
})
222248

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

src/core/task/Task.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ import { TerminalRegistry } from "../../integrations/terminal/TerminalRegistry"
6767
// utils
6868
import { calculateApiCostAnthropic } from "../../shared/cost"
6969
import { getWorkspacePath } from "../../utils/path"
70+
import { getWorkspaceStorageKey } from "../../utils/projectId"
7071

7172
// prompts
7273
import { formatResponse } from "../prompts/responses"
@@ -575,12 +576,15 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
575576
globalStoragePath: this.globalStoragePath,
576577
})
577578

579+
// Use project ID if available, otherwise fall back to workspace path
580+
const workspaceStorageKey = await getWorkspaceStorageKey(this.cwd)
581+
578582
const { historyItem, tokenUsage } = await taskMetadata({
579583
messages: this.clineMessages,
580584
taskId: this.taskId,
581585
taskNumber: this.taskNumber,
582586
globalStoragePath: this.globalStoragePath,
583-
workspace: this.cwd,
587+
workspace: workspaceStorageKey,
584588
mode: this._taskMode || defaultModeSlug, // Use the task's own mode, not the current provider mode
585589
})
586590

src/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@
165165
"title": "%command.importSettings.title%",
166166
"category": "%configuration.title%"
167167
},
168+
{
169+
"command": "roo-cline.generateProjectId",
170+
"title": "%command.generateProjectId.title%",
171+
"category": "%configuration.title%"
172+
},
168173
{
169174
"command": "roo-cline.focusInput",
170175
"title": "%command.focusInput.title%",

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: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import * as fs from "fs/promises"
3+
import * as path from "path"
4+
import { getProjectId, generateProjectId, getWorkspaceStorageKey } from "../projectId"
5+
import { fileExistsAtPath } from "../fs"
6+
7+
vi.mock("fs/promises")
8+
vi.mock("path")
9+
vi.mock("../fs")
10+
11+
describe("projectId", () => {
12+
const mockWorkspaceRoot = "/test/workspace"
13+
const mockProjectIdPath = "/test/workspace/.rooprojectid"
14+
const mockProjectId = "123e4567-e89b-12d3-a456-426614174000"
15+
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
vi.mocked(path.join).mockImplementation((...args) => args.join("/"))
19+
})
20+
21+
afterEach(() => {
22+
vi.restoreAllMocks()
23+
})
24+
25+
describe("getProjectId", () => {
26+
it("should return existing project ID from file", async () => {
27+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
28+
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)
29+
30+
const result = await getProjectId(mockWorkspaceRoot)
31+
32+
expect(result).toBe(mockProjectId)
33+
expect(fileExistsAtPath).toHaveBeenCalledWith(mockProjectIdPath)
34+
expect(fs.readFile).toHaveBeenCalledWith(mockProjectIdPath, "utf8")
35+
})
36+
37+
it("should return null if file does not exist", async () => {
38+
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
39+
40+
const result = await getProjectId(mockWorkspaceRoot)
41+
42+
expect(result).toBeNull()
43+
expect(fs.readFile).not.toHaveBeenCalled()
44+
})
45+
46+
it("should return null if file is empty", async () => {
47+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
48+
vi.mocked(fs.readFile).mockResolvedValue("")
49+
50+
const result = await getProjectId(mockWorkspaceRoot)
51+
52+
expect(result).toBeNull()
53+
})
54+
55+
it("should trim whitespace from project ID", async () => {
56+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
57+
vi.mocked(fs.readFile).mockResolvedValue(` ${mockProjectId} \n`)
58+
59+
const result = await getProjectId(mockWorkspaceRoot)
60+
61+
expect(result).toBe(mockProjectId)
62+
})
63+
64+
it("should return null on read error", async () => {
65+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
66+
vi.mocked(fs.readFile).mockRejectedValue(new Error("Read error"))
67+
const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {})
68+
69+
const result = await getProjectId(mockWorkspaceRoot)
70+
71+
expect(result).toBeNull()
72+
expect(consoleErrorSpy).toHaveBeenCalledWith("Failed to read project ID: Error: Read error")
73+
consoleErrorSpy.mockRestore()
74+
})
75+
})
76+
77+
describe("generateProjectId", () => {
78+
it("should generate and save a new project ID", async () => {
79+
vi.mocked(fs.writeFile).mockResolvedValue()
80+
81+
const result = await generateProjectId(mockWorkspaceRoot)
82+
83+
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)
84+
expect(fs.writeFile).toHaveBeenCalledWith(mockProjectIdPath, result, "utf8")
85+
})
86+
87+
it("should throw error if write fails", async () => {
88+
vi.mocked(fs.writeFile).mockRejectedValue(new Error("Write failed"))
89+
90+
await expect(generateProjectId(mockWorkspaceRoot)).rejects.toThrow("Write failed")
91+
})
92+
})
93+
94+
describe("getWorkspaceStorageKey", () => {
95+
it("should return project ID if it exists", async () => {
96+
vi.mocked(fileExistsAtPath).mockResolvedValue(true)
97+
vi.mocked(fs.readFile).mockResolvedValue(mockProjectId)
98+
99+
const result = await getWorkspaceStorageKey(mockWorkspaceRoot)
100+
101+
expect(result).toBe(mockProjectId)
102+
})
103+
104+
it("should return workspace root if project ID does not exist", async () => {
105+
vi.mocked(fileExistsAtPath).mockResolvedValue(false)
106+
107+
const result = await getWorkspaceStorageKey(mockWorkspaceRoot)
108+
109+
expect(result).toBe(mockWorkspaceRoot)
110+
})
111+
})
112+
})

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+
19+
if (!(await fileExistsAtPath(projectIdPath))) {
20+
return null
21+
}
22+
23+
const content = await fs.readFile(projectIdPath, "utf8")
24+
const projectId = content.trim()
25+
26+
// Validate that it's a non-empty string
27+
if (!projectId) {
28+
return null
29+
}
30+
31+
return projectId
32+
} catch (error) {
33+
// Silently handle errors and return null
34+
console.error(`Failed to read project ID: ${error}`)
35+
return null
36+
}
37+
}
38+
39+
/**
40+
* Generates a new project ID and writes it to the .rooprojectid file.
41+
*
42+
* @param workspaceRoot The root directory of the workspace
43+
* @returns The generated project ID
44+
*/
45+
export async function generateProjectId(workspaceRoot: string): Promise<string> {
46+
const projectId = uuidv4()
47+
const projectIdPath = path.join(workspaceRoot, PROJECT_ID_FILENAME)
48+
49+
await fs.writeFile(projectIdPath, projectId, "utf8")
50+
51+
return projectId
52+
}
53+
54+
/**
55+
* Gets the storage key for a workspace, using the project ID if available,
56+
* otherwise falling back to the workspace path.
57+
*
58+
* @param workspaceRoot The root directory of the workspace
59+
* @returns The storage key to use for this workspace
60+
*/
61+
export async function getWorkspaceStorageKey(workspaceRoot: string): Promise<string> {
62+
const projectId = await getProjectId(workspaceRoot)
63+
return projectId ?? workspaceRoot
64+
}

0 commit comments

Comments
 (0)