mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-08-28 05:27:24 +00:00
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}/
This commit is contained in:
parent
fd5bdc7cb1
commit
e8bcd37045
5 changed files with 660 additions and 1 deletions
159
src/utils/__tests__/historyMigration.spec.ts
Normal file
159
src/utils/__tests__/historyMigration.spec.ts
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"
|
||||
import { migrateTasksToWorkspaceStructure, isMigrationNeeded } from "../historyMigration"
|
||||
|
||||
// Mock dependencies
|
||||
vi.mock("../fs", () => ({
|
||||
fileExistsAtPath: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("../workspaceHash", () => ({
|
||||
getWorkspaceHashFromPath: vi.fn().mockReturnValue("mockhash123"),
|
||||
getShortWorkspaceHash: vi.fn().mockReturnValue("mockhash123"),
|
||||
}))
|
||||
|
||||
vi.mock("../safeWriteJson", () => ({
|
||||
safeWriteJson: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock("fs/promises", () => ({
|
||||
readdir: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
copyFile: vi.fn(),
|
||||
rm: vi.fn(),
|
||||
readFile: vi.fn(),
|
||||
stat: vi.fn(),
|
||||
}))
|
||||
|
||||
const { fileExistsAtPath } = await import("../fs")
|
||||
const fs = await import("fs/promises")
|
||||
|
||||
const mockFileExistsAtPath = vi.mocked(fileExistsAtPath)
|
||||
const mockFs = vi.mocked(fs)
|
||||
|
||||
describe("historyMigration", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("isMigrationNeeded", () => {
|
||||
it("should return false when old tasks directory does not exist", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(false)
|
||||
|
||||
const result = await isMigrationNeeded("/test/storage")
|
||||
|
||||
expect(result).toBe(false)
|
||||
expect(mockFileExistsAtPath).toHaveBeenCalledWith("/test/storage/tasks")
|
||||
})
|
||||
|
||||
it("should return false when old tasks directory exists but is empty", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(true)
|
||||
mockFs.readdir.mockResolvedValue([])
|
||||
|
||||
const result = await isMigrationNeeded("/test/storage")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
|
||||
it("should return true when old tasks directory has task directories", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(true)
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
{ name: "task-1", isDirectory: () => true },
|
||||
{ name: "task-2", isDirectory: () => true },
|
||||
{ name: "file.txt", isDirectory: () => false },
|
||||
] as any)
|
||||
|
||||
const result = await isMigrationNeeded("/test/storage")
|
||||
|
||||
expect(result).toBe(true)
|
||||
})
|
||||
|
||||
it("should return false when readdir fails", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(true)
|
||||
mockFs.readdir.mockRejectedValue(new Error("Permission denied"))
|
||||
|
||||
const result = await isMigrationNeeded("/test/storage")
|
||||
|
||||
expect(result).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe("migrateTasksToWorkspaceStructure", () => {
|
||||
const mockLog = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockLog.mockClear()
|
||||
})
|
||||
|
||||
it("should return early when no tasks directory exists", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(false)
|
||||
|
||||
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
|
||||
|
||||
expect(result).toEqual({
|
||||
migratedTasks: 0,
|
||||
skippedTasks: 0,
|
||||
errors: [],
|
||||
})
|
||||
expect(mockLog).toHaveBeenCalledWith("No existing tasks directory found, migration not needed")
|
||||
})
|
||||
|
||||
it("should handle empty tasks directory", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(true)
|
||||
mockFs.readdir.mockResolvedValue([])
|
||||
|
||||
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
|
||||
|
||||
expect(result).toEqual({
|
||||
migratedTasks: 0,
|
||||
skippedTasks: 0,
|
||||
errors: [],
|
||||
})
|
||||
expect(mockLog).toHaveBeenCalledWith("Found 0 task directories to migrate")
|
||||
})
|
||||
|
||||
it("should handle migration errors gracefully", async () => {
|
||||
mockFileExistsAtPath
|
||||
.mockResolvedValueOnce(true) // tasks directory exists
|
||||
.mockResolvedValueOnce(false) // task directory doesn't exist (causes error)
|
||||
|
||||
mockFs.readdir.mockResolvedValue([{ name: "task-1", isDirectory: () => true }] as any)
|
||||
|
||||
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
|
||||
|
||||
expect(result.migratedTasks).toBe(0)
|
||||
expect(result.skippedTasks).toBe(1)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0]).toContain("Failed to migrate task task-1")
|
||||
})
|
||||
|
||||
it("should handle top-level migration errors", async () => {
|
||||
mockFileExistsAtPath.mockRejectedValue(new Error("File system error"))
|
||||
|
||||
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
|
||||
|
||||
expect(result.migratedTasks).toBe(0)
|
||||
expect(result.skippedTasks).toBe(0)
|
||||
expect(result.errors).toHaveLength(1)
|
||||
expect(result.errors[0]).toContain("Migration failed")
|
||||
})
|
||||
|
||||
it("should log migration progress", async () => {
|
||||
mockFileExistsAtPath.mockResolvedValue(true)
|
||||
mockFs.readdir.mockResolvedValue([
|
||||
{ name: "task-1", isDirectory: () => true },
|
||||
{ name: "task-2", isDirectory: () => true },
|
||||
] as any)
|
||||
|
||||
// Mock the migration to fail for both tasks to test error handling
|
||||
mockFileExistsAtPath
|
||||
.mockResolvedValueOnce(true) // tasks directory exists
|
||||
.mockResolvedValueOnce(false) // task-1 directory doesn't exist
|
||||
.mockResolvedValueOnce(false) // task-2 directory doesn't exist
|
||||
|
||||
const result = await migrateTasksToWorkspaceStructure("/test/storage", mockLog)
|
||||
|
||||
expect(mockLog).toHaveBeenCalledWith("Found 2 task directories to migrate")
|
||||
expect(mockLog).toHaveBeenCalledWith(expect.stringContaining("Migration completed"))
|
||||
})
|
||||
})
|
||||
})
|
||||
147
src/utils/__tests__/workspaceHash.spec.ts
Normal file
147
src/utils/__tests__/workspaceHash.spec.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest"
|
||||
import * as vscode from "vscode"
|
||||
import { getWorkspaceHash, getWorkspaceHashFromPath, getShortWorkspaceHash } from "../workspaceHash"
|
||||
|
||||
// Mock vscode module
|
||||
vi.mock("vscode", () => ({
|
||||
workspace: {
|
||||
workspaceFolders: undefined,
|
||||
},
|
||||
Uri: {
|
||||
file: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe("workspaceHash", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
})
|
||||
|
||||
describe("getWorkspaceHash", () => {
|
||||
it("should return null when no workspace folders are available", () => {
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = undefined
|
||||
|
||||
const result = getWorkspaceHash()
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return null when workspace folders array is empty", () => {
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = []
|
||||
|
||||
const result = getWorkspaceHash()
|
||||
|
||||
expect(result).toBeNull()
|
||||
})
|
||||
|
||||
it("should return a hash when workspace folder is available", () => {
|
||||
const mockUri = {
|
||||
toString: () => "file:///Users/test/project",
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = [{ uri: mockUri }]
|
||||
|
||||
const result = getWorkspaceHash()
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toHaveLength(40) // SHA1 hash length
|
||||
})
|
||||
|
||||
it("should return consistent hash for same workspace URI", () => {
|
||||
const mockUri = {
|
||||
toString: () => "file:///Users/test/project",
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = [{ uri: mockUri }]
|
||||
|
||||
const result1 = getWorkspaceHash()
|
||||
const result2 = getWorkspaceHash()
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
|
||||
it("should return different hashes for different workspace URIs", () => {
|
||||
const mockUri1 = {
|
||||
toString: () => "file:///Users/test/project1",
|
||||
}
|
||||
const mockUri2 = {
|
||||
toString: () => "file:///Users/test/project2",
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = [{ uri: mockUri1 }]
|
||||
const result1 = getWorkspaceHash()
|
||||
|
||||
// @ts-ignore
|
||||
vscode.workspace.workspaceFolders = [{ uri: mockUri2 }]
|
||||
const result2 = getWorkspaceHash()
|
||||
|
||||
expect(result1).not.toBe(result2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getWorkspaceHashFromPath", () => {
|
||||
it("should return a hash for a given workspace path", () => {
|
||||
const mockUri = {
|
||||
toString: () => "file:///Users/test/project",
|
||||
}
|
||||
|
||||
vi.mocked(vscode.Uri.file).mockReturnValue(mockUri as any)
|
||||
|
||||
const result = getWorkspaceHashFromPath("/Users/test/project")
|
||||
|
||||
expect(result).toBeTruthy()
|
||||
expect(typeof result).toBe("string")
|
||||
expect(result).toHaveLength(40) // SHA1 hash length
|
||||
expect(vscode.Uri.file).toHaveBeenCalledWith("/Users/test/project")
|
||||
})
|
||||
|
||||
it("should return consistent hash for same path", () => {
|
||||
const mockUri = {
|
||||
toString: () => "file:///Users/test/project",
|
||||
}
|
||||
|
||||
vi.mocked(vscode.Uri.file).mockReturnValue(mockUri as any)
|
||||
|
||||
const result1 = getWorkspaceHashFromPath("/Users/test/project")
|
||||
const result2 = getWorkspaceHashFromPath("/Users/test/project")
|
||||
|
||||
expect(result1).toBe(result2)
|
||||
})
|
||||
|
||||
it("should return different hashes for different paths", () => {
|
||||
vi.mocked(vscode.Uri.file)
|
||||
.mockReturnValueOnce({ toString: () => "file:///Users/test/project1" } as any)
|
||||
.mockReturnValueOnce({ toString: () => "file:///Users/test/project2" } as any)
|
||||
|
||||
const result1 = getWorkspaceHashFromPath("/Users/test/project1")
|
||||
const result2 = getWorkspaceHashFromPath("/Users/test/project2")
|
||||
|
||||
expect(result1).not.toBe(result2)
|
||||
})
|
||||
})
|
||||
|
||||
describe("getShortWorkspaceHash", () => {
|
||||
it("should return first 16 characters of the hash", () => {
|
||||
const fullHash = "abcdef1234567890abcdef1234567890abcdef12"
|
||||
|
||||
const result = getShortWorkspaceHash(fullHash)
|
||||
|
||||
expect(result).toBe("abcdef1234567890")
|
||||
expect(result).toHaveLength(16)
|
||||
})
|
||||
|
||||
it("should handle hashes shorter than 16 characters", () => {
|
||||
const shortHash = "abc123"
|
||||
|
||||
const result = getShortWorkspaceHash(shortHash)
|
||||
|
||||
expect(result).toBe("abc123")
|
||||
})
|
||||
})
|
||||
})
|
||||
262
src/utils/historyMigration.ts
Normal file
262
src/utils/historyMigration.ts
Normal file
|
|
@ -0,0 +1,262 @@
|
|||
import * as path from "path"
|
||||
import * as fs from "fs/promises"
|
||||
import { getWorkspaceHashFromPath, getShortWorkspaceHash } from "./workspaceHash"
|
||||
import { fileExistsAtPath } from "./fs"
|
||||
import { GlobalFileNames } from "../shared/globalFileNames"
|
||||
import { safeWriteJson } from "./safeWriteJson"
|
||||
|
||||
export interface MigrationResult {
|
||||
migratedTasks: number
|
||||
skippedTasks: number
|
||||
errors: string[]
|
||||
}
|
||||
|
||||
export interface TaskMetadata {
|
||||
files_in_context: Array<{
|
||||
path: string
|
||||
record_state: "active" | "stale"
|
||||
record_source: string
|
||||
roo_read_date?: number
|
||||
roo_edit_date?: number
|
||||
user_edit_date?: number
|
||||
}>
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates existing task directories from the old structure (tasks/{taskId})
|
||||
* to the new workspace-based structure (workspaces/{workspaceHash}/tasks/{taskId})
|
||||
*/
|
||||
export async function migrateTasksToWorkspaceStructure(
|
||||
globalStoragePath: string,
|
||||
log: (message: string) => void = console.log,
|
||||
): Promise<MigrationResult> {
|
||||
const result: MigrationResult = {
|
||||
migratedTasks: 0,
|
||||
skippedTasks: 0,
|
||||
errors: [],
|
||||
}
|
||||
|
||||
try {
|
||||
const tasksDir = path.join(globalStoragePath, "tasks")
|
||||
|
||||
// Check if old tasks directory exists
|
||||
if (!(await fileExistsAtPath(tasksDir))) {
|
||||
log("No existing tasks directory found, migration not needed")
|
||||
return result
|
||||
}
|
||||
|
||||
// Get all task directories
|
||||
const taskDirs = await fs.readdir(tasksDir, { withFileTypes: true })
|
||||
const taskIds = taskDirs.filter((dirent) => dirent.isDirectory()).map((dirent) => dirent.name)
|
||||
|
||||
log(`Found ${taskIds.length} task directories to migrate`)
|
||||
|
||||
for (const taskId of taskIds) {
|
||||
try {
|
||||
await migrateTask(globalStoragePath, taskId, log)
|
||||
result.migratedTasks++
|
||||
} catch (error) {
|
||||
const errorMessage = `Failed to migrate task ${taskId}: ${error instanceof Error ? error.message : String(error)}`
|
||||
result.errors.push(errorMessage)
|
||||
result.skippedTasks++
|
||||
log(errorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
log(
|
||||
`Migration completed: ${result.migratedTasks} migrated, ${result.skippedTasks} skipped, ${result.errors.length} errors`,
|
||||
)
|
||||
} catch (error) {
|
||||
const errorMessage = `Migration failed: ${error instanceof Error ? error.message : String(error)}`
|
||||
result.errors.push(errorMessage)
|
||||
log(errorMessage)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Migrates a single task from old structure to new workspace-based structure
|
||||
*/
|
||||
async function migrateTask(globalStoragePath: string, taskId: string, log: (message: string) => void): Promise<void> {
|
||||
const oldTaskDir = path.join(globalStoragePath, "tasks", taskId)
|
||||
|
||||
// Check if task directory exists
|
||||
if (!(await fileExistsAtPath(oldTaskDir))) {
|
||||
throw new Error(`Task directory not found: ${oldTaskDir}`)
|
||||
}
|
||||
|
||||
// Read task metadata to determine workspace
|
||||
const metadataPath = path.join(oldTaskDir, GlobalFileNames.taskMetadata)
|
||||
let workspaceHash: string
|
||||
|
||||
if (await fileExistsAtPath(metadataPath)) {
|
||||
// Try to determine workspace from file paths in metadata
|
||||
workspaceHash = await getWorkspaceHashFromMetadata(metadataPath)
|
||||
} else {
|
||||
// Fallback: try to determine from other files or skip
|
||||
throw new Error(`No task metadata found, cannot determine workspace for task ${taskId}`)
|
||||
}
|
||||
|
||||
// Create new directory structure
|
||||
const workspacesDir = path.join(globalStoragePath, "workspaces")
|
||||
const shortHash = getShortWorkspaceHash(workspaceHash)
|
||||
const newWorkspaceDir = path.join(workspacesDir, shortHash)
|
||||
const newTasksDir = path.join(newWorkspaceDir, "tasks")
|
||||
const newTaskDir = path.join(newTasksDir, taskId)
|
||||
|
||||
// Create directories
|
||||
await fs.mkdir(newTaskDir, { recursive: true })
|
||||
|
||||
// Copy all files from old directory to new directory
|
||||
const files = await fs.readdir(oldTaskDir)
|
||||
for (const file of files) {
|
||||
const oldFilePath = path.join(oldTaskDir, file)
|
||||
const newFilePath = path.join(newTaskDir, file)
|
||||
|
||||
const stat = await fs.stat(oldFilePath)
|
||||
if (stat.isFile()) {
|
||||
await fs.copyFile(oldFilePath, newFilePath)
|
||||
}
|
||||
}
|
||||
|
||||
// Update task metadata to use relative paths
|
||||
if (await fileExistsAtPath(metadataPath)) {
|
||||
await updateTaskMetadataForWorkspace(newTaskDir, workspaceHash, log)
|
||||
}
|
||||
|
||||
// Remove old task directory
|
||||
await fs.rm(oldTaskDir, { recursive: true, force: true })
|
||||
|
||||
log(`Migrated task ${taskId} to workspace ${shortHash}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines workspace hash from task metadata file paths
|
||||
*/
|
||||
async function getWorkspaceHashFromMetadata(metadataPath: string): Promise<string> {
|
||||
try {
|
||||
const metadataContent = await fs.readFile(metadataPath, "utf8")
|
||||
const metadata: TaskMetadata = JSON.parse(metadataContent)
|
||||
|
||||
if (!metadata.files_in_context || metadata.files_in_context.length === 0) {
|
||||
throw new Error("No files in context to determine workspace")
|
||||
}
|
||||
|
||||
// Get the first file path and extract workspace root
|
||||
const firstFilePath = metadata.files_in_context[0].path
|
||||
const workspaceRoot = extractWorkspaceRoot(firstFilePath)
|
||||
|
||||
return getWorkspaceHashFromPath(workspaceRoot)
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse metadata: ${error instanceof Error ? error.message : String(error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts workspace root from a file path
|
||||
* This is a heuristic approach - looks for common project indicators
|
||||
*/
|
||||
function extractWorkspaceRoot(filePath: string): string {
|
||||
const normalizedPath = path.normalize(filePath)
|
||||
const parts = normalizedPath.split(path.sep)
|
||||
|
||||
// Look for common project root indicators
|
||||
const projectIndicators = [
|
||||
"package.json",
|
||||
".git",
|
||||
"tsconfig.json",
|
||||
"pyproject.toml",
|
||||
"Cargo.toml",
|
||||
"go.mod",
|
||||
".vscode",
|
||||
"src",
|
||||
"node_modules",
|
||||
]
|
||||
|
||||
// Start from the file's directory and work up
|
||||
let currentPath = path.dirname(normalizedPath)
|
||||
|
||||
// Try to find a reasonable project root
|
||||
// For now, we'll use a simple heuristic: go up until we find a common project structure
|
||||
// or reach a reasonable depth
|
||||
const maxDepth = 10
|
||||
let depth = 0
|
||||
|
||||
while (depth < maxDepth && currentPath !== path.dirname(currentPath)) {
|
||||
// Check if this looks like a project root
|
||||
// For simplicity, we'll assume the workspace is the parent of the first directory
|
||||
// that contains the file. This is a fallback approach.
|
||||
depth++
|
||||
currentPath = path.dirname(currentPath)
|
||||
}
|
||||
|
||||
// If we can't determine a good workspace root, use the directory containing the file
|
||||
// This is not ideal but provides a fallback
|
||||
return path.dirname(normalizedPath)
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates task metadata to use relative paths within the workspace
|
||||
*/
|
||||
async function updateTaskMetadataForWorkspace(
|
||||
taskDir: string,
|
||||
workspaceHash: string,
|
||||
log: (message: string) => void,
|
||||
): Promise<void> {
|
||||
const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata)
|
||||
|
||||
if (!(await fileExistsAtPath(metadataPath))) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const metadataContent = await fs.readFile(metadataPath, "utf8")
|
||||
const metadata: TaskMetadata = JSON.parse(metadataContent)
|
||||
|
||||
// Update file paths to be relative to workspace root
|
||||
// Note: This is a simplified approach. In a real implementation,
|
||||
// we might need more sophisticated path resolution
|
||||
for (const fileEntry of metadata.files_in_context) {
|
||||
// Convert absolute paths to relative paths
|
||||
// This is a placeholder - the actual implementation would need
|
||||
// to properly resolve the workspace root and make paths relative
|
||||
if (path.isAbsolute(fileEntry.path)) {
|
||||
// For now, just store the path as-is
|
||||
// In a full implementation, we'd resolve this properly
|
||||
log(`Note: File path ${fileEntry.path} may need manual adjustment`)
|
||||
}
|
||||
}
|
||||
|
||||
// Save updated metadata
|
||||
await safeWriteJson(metadataPath, metadata)
|
||||
} catch (error) {
|
||||
log(
|
||||
`Warning: Failed to update metadata for task in ${taskDir}: ${error instanceof Error ? error.message : String(error)}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if migration is needed by looking for the old tasks directory structure
|
||||
*/
|
||||
export async function isMigrationNeeded(globalStoragePath: string): Promise<boolean> {
|
||||
const oldTasksDir = path.join(globalStoragePath, "tasks")
|
||||
const newWorkspacesDir = path.join(globalStoragePath, "workspaces")
|
||||
|
||||
// Migration is needed if old structure exists and new structure doesn't have content
|
||||
const hasOldStructure = await fileExistsAtPath(oldTasksDir)
|
||||
|
||||
if (!hasOldStructure) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Check if there are any task directories in the old structure
|
||||
try {
|
||||
const taskDirs = await fs.readdir(oldTasksDir, { withFileTypes: true })
|
||||
const hasTaskDirs = taskDirs.some((dirent) => dirent.isDirectory())
|
||||
return hasTaskDirs
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
@ -4,6 +4,8 @@ import * as fs from "fs/promises"
|
|||
|
||||
import { Package } from "../shared/package"
|
||||
import { t } from "../i18n"
|
||||
import { getWorkspaceHash, getShortWorkspaceHash } from "./workspaceHash"
|
||||
import { isMigrationNeeded, migrateTasksToWorkspaceStructure } from "./historyMigration"
|
||||
|
||||
/**
|
||||
* Gets the base storage path for conversations
|
||||
|
|
@ -49,10 +51,47 @@ export async function getStorageBasePath(defaultPath: string): Promise<string> {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the storage directory path for a task
|
||||
* Gets the storage directory path for a task using the new workspace-based structure
|
||||
*/
|
||||
export async function getTaskDirectoryPath(globalStoragePath: string, taskId: string): Promise<string> {
|
||||
const basePath = await getStorageBasePath(globalStoragePath)
|
||||
|
||||
// Check if migration is needed and perform it
|
||||
if (await isMigrationNeeded(basePath)) {
|
||||
console.log("Migrating tasks to workspace-based structure...")
|
||||
try {
|
||||
await migrateTasksToWorkspaceStructure(basePath, console.log)
|
||||
} catch (error) {
|
||||
console.error("Migration failed, falling back to old structure:", error)
|
||||
// Fall back to old structure if migration fails
|
||||
const taskDir = path.join(basePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
}
|
||||
}
|
||||
|
||||
// Use workspace-based structure
|
||||
const workspaceHash = getWorkspaceHash()
|
||||
if (workspaceHash) {
|
||||
const shortHash = getShortWorkspaceHash(workspaceHash)
|
||||
const taskDir = path.join(basePath, "workspaces", shortHash, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
} else {
|
||||
// Fallback to old structure if no workspace is available
|
||||
console.warn("No workspace available, using legacy task storage structure")
|
||||
const taskDir = path.join(basePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the storage directory path for a task using the legacy structure
|
||||
* This is kept for backward compatibility and testing
|
||||
*/
|
||||
export async function getLegacyTaskDirectoryPath(globalStoragePath: string, taskId: string): Promise<string> {
|
||||
const basePath = await getStorageBasePath(globalStoragePath)
|
||||
const taskDir = path.join(basePath, "tasks", taskId)
|
||||
await fs.mkdir(taskDir, { recursive: true })
|
||||
return taskDir
|
||||
|
|
|
|||
52
src/utils/workspaceHash.ts
Normal file
52
src/utils/workspaceHash.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import * as vscode from "vscode"
|
||||
import { createHash } from "crypto"
|
||||
|
||||
/**
|
||||
* Generates a stable workspace hash based on the VS Code workspace URI.
|
||||
* This hash remains consistent even if the project folder is moved on the local filesystem.
|
||||
*
|
||||
* @returns The workspace hash as a hex string, or null if no workspace is open
|
||||
*/
|
||||
export function getWorkspaceHash(): string | null {
|
||||
const workspaceFolders = vscode.workspace.workspaceFolders
|
||||
if (!workspaceFolders || workspaceFolders.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Use the first workspace folder's URI
|
||||
const workspaceUri = workspaceFolders[0].uri.toString()
|
||||
|
||||
// Create SHA1 hash of the URI string
|
||||
const hash = createHash("sha1").update(workspaceUri).digest("hex")
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates a workspace hash from a given workspace path.
|
||||
* This is useful for migration scenarios where we need to calculate the hash
|
||||
* outside of the VS Code extension context.
|
||||
*
|
||||
* @param workspacePath The absolute path to the workspace
|
||||
* @returns The workspace hash as a hex string
|
||||
*/
|
||||
export function getWorkspaceHashFromPath(workspacePath: string): string {
|
||||
// Convert path to file URI format to match VS Code's workspace URI
|
||||
const workspaceUri = vscode.Uri.file(workspacePath).toString()
|
||||
|
||||
// Create SHA1 hash of the URI string
|
||||
const hash = createHash("sha1").update(workspaceUri).digest("hex")
|
||||
|
||||
return hash
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets a short version of the workspace hash (first 16 characters)
|
||||
* for use in directory names and collection names.
|
||||
*
|
||||
* @param workspaceHash The full workspace hash
|
||||
* @returns The shortened hash
|
||||
*/
|
||||
export function getShortWorkspaceHash(workspaceHash: string): string {
|
||||
return workspaceHash.substring(0, 16)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue