diff --git a/src/utils/__tests__/historyMigration.spec.ts b/src/utils/__tests__/historyMigration.spec.ts new file mode 100644 index 0000000000..4c2875b3be --- /dev/null +++ b/src/utils/__tests__/historyMigration.spec.ts @@ -0,0 +1,160 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" +import path from "path" +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(path.join("/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")) + }) + }) +}) diff --git a/src/utils/__tests__/workspaceHash.spec.ts b/src/utils/__tests__/workspaceHash.spec.ts new file mode 100644 index 0000000000..df6a6bb6ec --- /dev/null +++ b/src/utils/__tests__/workspaceHash.spec.ts @@ -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") + }) + }) +}) diff --git a/src/utils/historyMigration.ts b/src/utils/historyMigration.ts new file mode 100644 index 0000000000..b6cb86cd9f --- /dev/null +++ b/src/utils/historyMigration.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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 + } +} diff --git a/src/utils/storage.ts b/src/utils/storage.ts index 8240588794..61d801fd52 100644 --- a/src/utils/storage.ts +++ b/src/utils/storage.ts @@ -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 { } /** - * 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 { 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 { + const basePath = await getStorageBasePath(globalStoragePath) const taskDir = path.join(basePath, "tasks", taskId) await fs.mkdir(taskDir, { recursive: true }) return taskDir diff --git a/src/utils/workspaceHash.ts b/src/utils/workspaceHash.ts new file mode 100644 index 0000000000..25f7978a1a --- /dev/null +++ b/src/utils/workspaceHash.ts @@ -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) +}