Skip to content

Commit e8bcd37

Browse files
committed
feat: implement robust project embeddings with workspace hash-based storage
- Add workspace hash utility functions for stable project identification - Implement migration system for existing task storage structure - Update storage.ts to use workspace-based directory structure - Add comprehensive test coverage for new functionality - Maintain backward compatibility with existing task storage This addresses issue #6400 by ensuring project embeddings remain connected when project folders are moved, using VS Code workspace URI for stable hashing. Changes: - src/utils/workspaceHash.ts: Core workspace hash generation - src/utils/historyMigration.ts: Migration utility for existing data - src/utils/storage.ts: Updated to use workspace-based structure - Comprehensive test coverage for all new functionality The new structure: globalStoragePath/workspaces/{workspaceHash}/tasks/{taskId}/ replaces the old: globalStoragePath/tasks/{taskId}/
1 parent fd5bdc7 commit e8bcd37

File tree

5 files changed

+660
-1
lines changed

5 files changed

+660
-1
lines changed
Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,159 @@
1+
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
2+
import { migrateTasksToWorkspaceStructure, isMigrationNeeded } from "../historyMigration"
3+
4+
// Mock dependencies
5+
vi.mock("../fs", () => ({
6+
fileExistsAtPath: vi.fn(),
7+
}))
8+
9+
vi.mock("../workspaceHash", () => ({
10+
getWorkspaceHashFromPath: vi.fn().mockReturnValue("mockhash123"),
11+
getShortWorkspaceHash: vi.fn().mockReturnValue("mockhash123"),
12+
}))
13+
14+
vi.mock("../safeWriteJson", () => ({
15+
safeWriteJson: vi.fn(),
16+
}))
17+
18+
vi.mock("fs/promises", () => ({
19+
readdir: vi.fn(),
20+
mkdir: vi.fn(),
21+
copyFile: vi.fn(),
22+
rm: vi.fn(),
23+
readFile: vi.fn(),
24+
stat: vi.fn(),
25+
}))
26+
27+
const { fileExistsAtPath } = await import("../fs")
28+
const fs = await import("fs/promises")
29+
30+
const mockFileExistsAtPath = vi.mocked(fileExistsAtPath)
31+
const mockFs = vi.mocked(fs)
32+
33+
describe("historyMigration", () => {
34+
beforeEach(() => {
35+
vi.clearAllMocks()
36+
})
37+
38+
describe("isMigrationNeeded", () => {
39+
it("should return false when old tasks directory does not exist", async () => {
40+
mockFileExistsAtPath.mockResolvedValue(false)
41+
42+
const result = await isMigrationNeeded("/test/storage")
43+
44+
expect(result).toBe(false)
45+
expect(mockFileExistsAtPath).toHaveBeenCalledWith("/test/storage/tasks")
46+
})
47+
48+
it("should return false when old tasks directory exists but is empty", async () => {
49+
mockFileExistsAtPath.mockResolvedValue(true)
50+
mockFs.readdir.mockResolvedValue([])
51+
52+
const result = await isMigrationNeeded("/test/storage")
53+
54+
expect(result).toBe(false)
55+
})
56+
57+
it("should return true when old tasks directory has task directories", async () => {
58+
mockFileExistsAtPath.mockResolvedValue(true)
59+
mockFs.readdir.mockResolvedValue([
60+
{ name: "task-1", isDirectory: () => true },
61+
{ name: "task-2", isDirectory: () => true },
62+
{ name: "file.txt", isDirectory: () => false },
63+
] as any)
64+
65+
const result = await isMigrationNeeded("/test/storage")
66+
67+
expect(result).toBe(true)
68+
})
69+
70+
it("should return false when readdir fails", async () => {
71+
mockFileExistsAtPath.mockResolvedValue(true)
72+
mockFs.readdir.mockRejectedValue(new Error("Permission denied"))
73+
74+
const result = await isMigrationNeeded("/test/storage")
75+
76+
expect(result).toBe(false)
77+
})
78+
})
79+
80+
describe("migrateTasksToWorkspaceStructure", () => {
81+
const mockLog = vi.fn()
82+
83+
beforeEach(() => {
84+
mockLog.mockClear()
85+
})
86+
87+
it("should return early when no tasks directory exists", async () => {
88+
mockFileExistsAtPath.mockResolvedValue(false)
89+
90+
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
91+
92+
expect(result).toEqual({
93+
migratedTasks: 0,
94+
skippedTasks: 0,
95+
errors: [],
96+
})
97+
expect(mockLog).toHaveBeenCalledWith("No existing tasks directory found, migration not needed")
98+
})
99+
100+
it("should handle empty tasks directory", async () => {
101+
mockFileExistsAtPath.mockResolvedValue(true)
102+
mockFs.readdir.mockResolvedValue([])
103+
104+
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
105+
106+
expect(result).toEqual({
107+
migratedTasks: 0,
108+
skippedTasks: 0,
109+
errors: [],
110+
})
111+
expect(mockLog).toHaveBeenCalledWith("Found 0 task directories to migrate")
112+
})
113+
114+
it("should handle migration errors gracefully", async () => {
115+
mockFileExistsAtPath
116+
.mockResolvedValueOnce(true) // tasks directory exists
117+
.mockResolvedValueOnce(false) // task directory doesn't exist (causes error)
118+
119+
mockFs.readdir.mockResolvedValue([{ name: "task-1", isDirectory: () => true }] as any)
120+
121+
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
122+
123+
expect(result.migratedTasks).toBe(0)
124+
expect(result.skippedTasks).toBe(1)
125+
expect(result.errors).toHaveLength(1)
126+
expect(result.errors[0]).toContain("Failed to migrate task task-1")
127+
})
128+
129+
it("should handle top-level migration errors", async () => {
130+
mockFileExistsAtPath.mockRejectedValue(new Error("File system error"))
131+
132+
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
133+
134+
expect(result.migratedTasks).toBe(0)
135+
expect(result.skippedTasks).toBe(0)
136+
expect(result.errors).toHaveLength(1)
137+
expect(result.errors[0]).toContain("Migration failed")
138+
})
139+
140+
it("should log migration progress", async () => {
141+
mockFileExistsAtPath.mockResolvedValue(true)
142+
mockFs.readdir.mockResolvedValue([
143+
{ name: "task-1", isDirectory: () => true },
144+
{ name: "task-2", isDirectory: () => true },
145+
] as any)
146+
147+
// Mock the migration to fail for both tasks to test error handling
148+
mockFileExistsAtPath
149+
.mockResolvedValueOnce(true) // tasks directory exists
150+
.mockResolvedValueOnce(false) // task-1 directory doesn't exist
151+
.mockResolvedValueOnce(false) // task-2 directory doesn't exist
152+
153+
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
154+
155+
expect(mockLog).toHaveBeenCalledWith("Found 2 task directories to migrate")
156+
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Migration completed"))
157+
})
158+
})
159+
})
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest"
2+
import * as vscode from "vscode"
3+
import { getWorkspaceHash, getWorkspaceHashFromPath, getShortWorkspaceHash } from "../workspaceHash"
4+
5+
// Mock vscode module
6+
vi.mock("vscode", () => ({
7+
workspace: {
8+
workspaceFolders: undefined,
9+
},
10+
Uri: {
11+
file: vi.fn(),
12+
},
13+
}))
14+
15+
describe("workspaceHash", () => {
16+
beforeEach(() => {
17+
vi.clearAllMocks()
18+
})
19+
20+
describe("getWorkspaceHash", () => {
21+
it("should return null when no workspace folders are available", () => {
22+
// @ts-ignore
23+
vscode.workspace.workspaceFolders = undefined
24+
25+
const result = getWorkspaceHash()
26+
27+
expect(result).toBeNull()
28+
})
29+
30+
it("should return null when workspace folders array is empty", () => {
31+
// @ts-ignore
32+
vscode.workspace.workspaceFolders = []
33+
34+
const result = getWorkspaceHash()
35+
36+
expect(result).toBeNull()
37+
})
38+
39+
it("should return a hash when workspace folder is available", () => {
40+
const mockUri = {
41+
toString: () => "file:///Users/test/project",
42+
}
43+
44+
// @ts-ignore
45+
vscode.workspace.workspaceFolders = [{ uri: mockUri }]
46+
47+
const result = getWorkspaceHash()
48+
49+
expect(result).toBeTruthy()
50+
expect(typeof result).toBe("string")
51+
expect(result).toHaveLength(40) // SHA1 hash length
52+
})
53+
54+
it("should return consistent hash for same workspace URI", () => {
55+
const mockUri = {
56+
toString: () => "file:///Users/test/project",
57+
}
58+
59+
// @ts-ignore
60+
vscode.workspace.workspaceFolders = [{ uri: mockUri }]
61+
62+
const result1 = getWorkspaceHash()
63+
const result2 = getWorkspaceHash()
64+
65+
expect(result1).toBe(result2)
66+
})
67+
68+
it("should return different hashes for different workspace URIs", () => {
69+
const mockUri1 = {
70+
toString: () => "file:///Users/test/project1",
71+
}
72+
const mockUri2 = {
73+
toString: () => "file:///Users/test/project2",
74+
}
75+
76+
// @ts-ignore
77+
vscode.workspace.workspaceFolders = [{ uri: mockUri1 }]
78+
const result1 = getWorkspaceHash()
79+
80+
// @ts-ignore
81+
vscode.workspace.workspaceFolders = [{ uri: mockUri2 }]
82+
const result2 = getWorkspaceHash()
83+
84+
expect(result1).not.toBe(result2)
85+
})
86+
})
87+
88+
describe("getWorkspaceHashFromPath", () => {
89+
it("should return a hash for a given workspace path", () => {
90+
const mockUri = {
91+
toString: () => "file:///Users/test/project",
92+
}
93+
94+
vi.mocked(vscode.Uri.file).mockReturnValue(mockUri as any)
95+
96+
const result = getWorkspaceHashFromPath("/Users/test/project")
97+
98+
expect(result).toBeTruthy()
99+
expect(typeof result).toBe("string")
100+
expect(result).toHaveLength(40) // SHA1 hash length
101+
expect(vscode.Uri.file).toHaveBeenCalledWith("/Users/test/project")
102+
})
103+
104+
it("should return consistent hash for same path", () => {
105+
const mockUri = {
106+
toString: () => "file:///Users/test/project",
107+
}
108+
109+
vi.mocked(vscode.Uri.file).mockReturnValue(mockUri as any)
110+
111+
const result1 = getWorkspaceHashFromPath("/Users/test/project")
112+
const result2 = getWorkspaceHashFromPath("/Users/test/project")
113+
114+
expect(result1).toBe(result2)
115+
})
116+
117+
it("should return different hashes for different paths", () => {
118+
vi.mocked(vscode.Uri.file)
119+
.mockReturnValueOnce({ toString: () => "file:///Users/test/project1" } as any)
120+
.mockReturnValueOnce({ toString: () => "file:///Users/test/project2" } as any)
121+
122+
const result1 = getWorkspaceHashFromPath("/Users/test/project1")
123+
const result2 = getWorkspaceHashFromPath("/Users/test/project2")
124+
125+
expect(result1).not.toBe(result2)
126+
})
127+
})
128+
129+
describe("getShortWorkspaceHash", () => {
130+
it("should return first 16 characters of the hash", () => {
131+
const fullHash = "abcdef1234567890abcdef1234567890abcdef12"
132+
133+
const result = getShortWorkspaceHash(fullHash)
134+
135+
expect(result).toBe("abcdef1234567890")
136+
expect(result).toHaveLength(16)
137+
})
138+
139+
it("should handle hashes shorter than 16 characters", () => {
140+
const shortHash = "abc123"
141+
142+
const result = getShortWorkspaceHash(shortHash)
143+
144+
expect(result).toBe("abc123")
145+
})
146+
})
147+
})

0 commit comments

Comments
 (0)