diff --git a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts index c1838440c24..d1694f2effa 100644 --- a/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts +++ b/packages/core/src/custom-tools/__tests__/custom-tool-registry.spec.ts @@ -281,7 +281,7 @@ describe("CustomToolRegistry", () => { const result = await registry.loadFromDirectory(TEST_FIXTURES_DIR) expect(result.loaded).toContain("cached") - }, 30000) + }, 120_000) }) describe.sequential("loadFromDirectories", () => { diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 2eaf5f59816..7ea9da632ba 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -44,6 +44,14 @@ export const MAX_CHECKPOINT_TIMEOUT_SECONDS = 60 */ export const DEFAULT_CHECKPOINT_TIMEOUT_SECONDS = 15 +/** + * Allowed values for the task history retention setting. + * Stored as strings in most UI/extension flows. + */ +export const TASK_HISTORY_RETENTION_OPTIONS = ["never", "90", "60", "30", "7", "3"] as const + +export type TaskHistoryRetentionSetting = (typeof TASK_HISTORY_RETENTION_OPTIONS)[number] + /** * GlobalSettings */ @@ -184,6 +192,16 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), + // Auto-delete task history on extension reload. + taskHistoryRetention: z.enum(TASK_HISTORY_RETENTION_OPTIONS).optional(), + // Calculated task history storage size info for the Settings > About page + taskHistorySize: z + .object({ + totalBytes: z.number(), + taskCount: z.number(), + formattedSize: z.string(), + }) + .optional(), historyPreviewCollapsed: z.boolean().optional(), reasoningBlockCollapsed: z.boolean().optional(), /** diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index cd36b081576..4cbf0777be4 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -336,6 +336,7 @@ export type ExtensionState = Pick< | "includeCurrentCost" | "maxGitStatusFiles" | "requestDelaySeconds" + | "taskHistoryRetention" > & { version: string clineMessages: ClineMessage[] @@ -396,6 +397,15 @@ export type ExtensionState = Pick< marketplaceInstalledMetadata?: { project: Record; global: Record } profileThresholds: Record hasOpenedModeSelector: boolean + /** Task history storage size info for the Settings > About page */ + taskHistorySize?: { + /** Total size in bytes */ + totalBytes: number + /** Number of task directories */ + taskCount: number + /** Formatted size string (e.g., "12.34 MB") */ + formattedSize: string + } openRouterImageApiKey?: string messageQueue?: QueuedMessage[] lastShownAnnouncementId?: string @@ -597,6 +607,7 @@ export interface WebviewMessage { | "requestModes" | "switchMode" | "debugSetting" + | "refreshTaskHistorySize" // Worktree messages | "listWorktrees" | "createWorktree" diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 5b072672699..1803eb4d367 100644 --- a/src/__tests__/extension.spec.ts +++ b/src/__tests__/extension.spec.ts @@ -114,6 +114,8 @@ vi.mock("../core/config/ContextProxy", () => ({ setValue: vi.fn(), getValues: vi.fn().mockReturnValue({}), getProviderSettings: vi.fn().mockReturnValue({}), + // Needed by retention purge on activation + globalStorageUri: { fsPath: "/tmp/roo-retention-test" }, }), }, })) @@ -157,6 +159,16 @@ vi.mock("../utils/autoImportSettings", () => ({ autoImportSettings: vi.fn().mockResolvedValue(undefined), })) +// Avoid filesystem access during activation by stubbing background purge +vi.mock("../utils/task-history-retention", () => ({ + startBackgroundRetentionPurge: vi.fn(), +})) + +// Ensure storage base path resolves to provided path to avoid touching VS Code config +vi.mock("../utils/storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + vi.mock("../extension/api", () => ({ API: vi.fn().mockImplementation(() => ({})), })) diff --git a/src/__tests__/task-history-retention.spec.ts b/src/__tests__/task-history-retention.spec.ts new file mode 100644 index 00000000000..0201c8fcc50 --- /dev/null +++ b/src/__tests__/task-history-retention.spec.ts @@ -0,0 +1,265 @@ +// npx vitest run __tests__/task-history-retention.spec.ts +import * as fs from "fs/promises" +import * as path from "path" +import * as os from "os" + +import { describe, it, expect } from "vitest" + +// Ensure purge uses the provided base path without touching VS Code config +vi.mock("../utils/storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + +import { purgeOldTasks } from "../utils/task-history-retention" +import { GlobalFileNames } from "../shared/globalFileNames" + +// Helpers +async function exists(p: string): Promise { + try { + await fs.access(p) + return true + } catch { + return false + } +} + +async function mkTempBase(): Promise { + const base = await fs.mkdtemp(path.join(os.tmpdir(), "roo-retention-")) + // Ensure /tasks exists + await fs.mkdir(path.join(base, "tasks"), { recursive: true }) + return base +} + +async function createTask(base: string, id: string, ts?: number | "invalid"): Promise { + const dir = path.join(base, "tasks", id) + await fs.mkdir(dir, { recursive: true }) + const metadataPath = path.join(dir, GlobalFileNames.taskMetadata) + const metadata = ts === "invalid" ? "{ invalid json" : JSON.stringify({ ts: ts ?? Date.now() }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + return dir +} + +describe("utils/task-history-retention.ts purgeOldTasks()", () => { + it("purges tasks older than 7 days when retention is '7'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-8d", now - days(8)) + const recent = await createTask(base, "task-6d", now - days(6)) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}, false) + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("purges tasks older than 3 days when retention is '3'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-4d", now - days(4)) + const recent = await createTask(base, "task-2d", now - days(2)) + + const { purgedCount } = await purgeOldTasks("3", base, () => {}, false) + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete anything in dry run mode but still reports purgedCount", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + const old = await createTask(base, "task-8d", now - days(8)) + const recent = await createTask(base, "task-6d", now - days(6)) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}, true) + expect(purgedCount).toBe(1) + // In dry run, nothing is deleted + expect(await exists(old)).toBe(true) + expect(await exists(recent)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does nothing when retention is 'never'", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const oldTs = now - 45 * 24 * 60 * 60 * 1000 // 45 days ago + const t1 = await createTask(base, "task-old", oldTs) + const t2 = await createTask(base, "task-new", now) + + const { purgedCount, cutoff } = await purgeOldTasks("never", base, () => {}) + + expect(purgedCount).toBe(0) + expect(cutoff).toBeNull() + expect(await exists(t1)).toBe(true) + expect(await exists(t2)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("purges tasks older than 30 days and keeps newer or invalid-metadata ones", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // One older than 30 days => delete + const old = await createTask(base, "task-31d", now - days(31)) + // One newer than 30 days => keep + const recent = await createTask(base, "task-29d", now - days(29)) + // Invalid metadata => skipped (kept) + const invalid = await createTask(base, "task-invalid", "invalid") + + const { purgedCount, cutoff } = await purgeOldTasks("30", base, () => {}) + + expect(typeof cutoff).toBe("number") + expect(purgedCount).toBe(1) + expect(await exists(old)).toBe(false) + expect(await exists(recent)).toBe(true) + expect(await exists(invalid)).toBe(true) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("deletes orphan checkpoint-only directories regardless of age", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a normal task that is recent (should be kept) + const normalTask = await createTask(base, "task-normal", now - days(1)) + + // Create an orphan checkpoint-only directory (only has checkpoints/ subdirectory, no metadata) + const orphanDir = path.join(base, "tasks", "task-orphan-checkpoints") + await fs.mkdir(orphanDir, { recursive: true }) + const checkpointsDir = path.join(orphanDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + // Add a dummy file inside checkpoints to make it realistic + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + + // Create another orphan with just checkpoints (no other files) + const orphanDir2 = path.join(base, "tasks", "task-orphan-empty") + await fs.mkdir(orphanDir2, { recursive: true }) + const checkpointsDir2 = path.join(orphanDir2, "checkpoints") + await fs.mkdir(checkpointsDir2, { recursive: true }) + + // Run purge with 7 day retention - orphans should be deleted regardless of age + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Orphan directories should be deleted even though they're "recent" + expect(await exists(orphanDir)).toBe(false) + expect(await exists(orphanDir2)).toBe(false) + // Normal task should still exist (it's recent) + expect(await exists(normalTask)).toBe(true) + // Should have deleted 2 orphan directories + expect(purgedCount).toBe(2) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("does not delete directories with checkpoints AND other content", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a task directory with both checkpoints and other files (but recent, so should be kept) + const taskDir = path.join(base, "tasks", "task-with-content") + await fs.mkdir(taskDir, { recursive: true }) + const checkpointsDir = path.join(taskDir, "checkpoints") + await fs.mkdir(checkpointsDir, { recursive: true }) + await fs.writeFile(path.join(checkpointsDir, "checkpoint-1.json"), "{}", "utf8") + // Add other files (not just checkpoints) + await fs.writeFile(path.join(taskDir, "some-file.txt"), "content", "utf8") + // Note: No metadata file, so it's technically invalid but has content + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should NOT be deleted because it has content besides checkpoints + expect(await exists(taskDir)).toBe(true) + expect(purgedCount).toBe(0) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("falls back to directory mtime for legacy tasks without metadata", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create a legacy task directory without any metadata file + const oldLegacyDir = path.join(base, "tasks", "task-legacy-old") + await fs.mkdir(oldLegacyDir, { recursive: true }) + // Add some content file + await fs.writeFile(path.join(oldLegacyDir, "content.txt"), "old task", "utf8") + // Manually set mtime to 10 days ago by touching the directory + const oldTime = new Date(now - days(10)) + await fs.utimes(oldLegacyDir, oldTime, oldTime) + + // Create another legacy task that is recent + const recentLegacyDir = path.join(base, "tasks", "task-legacy-recent") + await fs.mkdir(recentLegacyDir, { recursive: true }) + await fs.writeFile(path.join(recentLegacyDir, "content.txt"), "recent task", "utf8") + // This one has recent mtime (now) + + // Run purge with 7 day retention + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Old legacy task should be deleted based on mtime + expect(await exists(oldLegacyDir)).toBe(false) + // Recent legacy task should be kept + expect(await exists(recentLegacyDir)).toBe(true) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) + + it("prioritizes metadata timestamp over mtime when both exist", async () => { + const base = await mkTempBase() + try { + const now = Date.now() + const days = (n: number) => n * 24 * 60 * 60 * 1000 + + // Create task with old metadata ts but recent mtime + const taskDir = path.join(base, "tasks", "task-priority-test") + await fs.mkdir(taskDir, { recursive: true }) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + // Metadata says it's 10 days old (should be deleted with 7 day retention) + const metadata = JSON.stringify({ ts: now - days(10) }, null, 2) + await fs.writeFile(metadataPath, metadata, "utf8") + // But directory mtime is recent (could happen after editing) + // (Directory mtime is automatically recent from mkdir/writeFile) + + const { purgedCount } = await purgeOldTasks("7", base, () => {}) + + // Should be deleted based on metadata ts, not mtime + expect(await exists(taskDir)).toBe(false) + expect(purgedCount).toBe(1) + } finally { + await fs.rm(base, { recursive: true, force: true }) + } + }) +}) diff --git a/src/activate/registerCommands.ts b/src/activate/registerCommands.ts index 51368dd9fc7..076e15bb73a 100644 --- a/src/activate/registerCommands.ts +++ b/src/activate/registerCommands.ts @@ -105,7 +105,7 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt return openClineInNewTab({ context, outputChannel }) }, openInNewTab: () => openClineInNewTab({ context, outputChannel }), - settingsButtonClicked: () => { + settingsButtonClicked: (section?: string) => { const visibleProvider = getVisibleProviderOrLog(outputChannel) if (!visibleProvider) { @@ -114,7 +114,11 @@ const getCommandsMap = ({ context, outputChannel, provider }: RegisterCommandOpt TelemetryService.instance.captureTitleButtonClicked("settings") - visibleProvider.postMessageToWebview({ type: "action", action: "settingsButtonClicked" }) + visibleProvider.postMessageToWebview({ + type: "action", + action: "settingsButtonClicked", + values: section ? { section } : undefined, + }) // Also explicitly post the visibility message to trigger scroll reliably visibleProvider.postMessageToWebview({ type: "action", action: "didBecomeVisible" }) }, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 52845543edf..f015097f69c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -1774,18 +1774,17 @@ export class ClineProvider try { await ShadowCheckpointService.deleteTask({ taskId: id, globalStorageDir, workspaceDir }) } catch (error) { - console.error( - `[deleteTaskWithId${id}] failed to delete associated shadow repository or branch: ${error instanceof Error ? error.message : String(error)}`, + this.log( + `[deleteTaskWithId] failed to delete shadow repository for task ${id}: ${error instanceof Error ? error.message : String(error)}`, ) } // delete the entire task directory including checkpoints and all content try { await fs.rm(taskDirPath, { recursive: true, force: true }) - console.log(`[deleteTaskWithId${id}] removed task directory`) } catch (error) { - console.error( - `[deleteTaskWithId${id}] failed to remove task directory: ${error instanceof Error ? error.message : String(error)}`, + this.log( + `[deleteTaskWithId] failed to remove task directory for task ${id}: ${error instanceof Error ? error.message : String(error)}`, ) } } catch (error) { @@ -2023,6 +2022,8 @@ export class ClineProvider reasoningBlockCollapsed, enterBehavior, cloudUserInfo, + taskHistoryRetention, + taskHistorySize, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, @@ -2210,6 +2211,10 @@ export class ClineProvider includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, + // Task history retention setting for About tab dropdown + taskHistoryRetention: taskHistoryRetention ?? "never", + // Task history storage size info for the Settings > About page + taskHistorySize, includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -2436,6 +2441,10 @@ export class ClineProvider organizationSettingsVersion, condensingApiConfigId: stateValues.condensingApiConfigId, customCondensingPrompt: stateValues.customCondensingPrompt, + // Task history retention selection + taskHistoryRetention: stateValues.taskHistoryRetention ?? "never", + // Task history storage size info + taskHistorySize: stateValues.taskHistorySize, codebaseIndexModels: stateValues.codebaseIndexModels ?? EMBEDDING_MODEL_PROFILES, codebaseIndexConfig: { codebaseIndexEnabled: stateValues.codebaseIndexConfig?.codebaseIndexEnabled ?? false, diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 2af791b93ee..cbff9f0a8c5 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -532,6 +532,9 @@ export const webviewMessageHandler = async ( TelemetryService.instance.updateTelemetryState(isOptedIn) }) + // Note: Task history storage size calculation is triggered on-demand when the About + // settings tab is opened (via "refreshTaskHistorySize" message), not on webview launch. + provider.isViewLaunched = true break case "newTask": @@ -593,6 +596,9 @@ export const webviewMessageHandler = async ( await vscode.workspace .getConfiguration(Package.name) .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "taskHistoryRetention") { + // taskHistoryRetention is stored in Roo application state (global state), not VS Code settings. + newValue = ((value ?? "never") as string).toString() } else if (key === "ttsEnabled") { newValue = value ?? true setTtsEnabled(newValue as boolean) @@ -3402,6 +3408,24 @@ export const webviewMessageHandler = async ( break } + case "refreshTaskHistorySize": { + // Refresh the task history storage size calculation + try { + const { calculateTaskStorageSize } = await import("../../utils/task-storage-size") + const globalStoragePath = provider.contextProxy.globalStorageUri.fsPath + const sizeInfo = await calculateTaskStorageSize(globalStoragePath) + + // Update state and notify webview + await provider.contextProxy.setValue("taskHistorySize", sizeInfo) + await provider.postStateToWebview() + } catch (error) { + provider.log( + `Error refreshing task history size: ${error instanceof Error ? error.message : String(error)}`, + ) + } + break + } + /** * Git Worktree Management */ diff --git a/src/extension.ts b/src/extension.ts index b58f3ce0fa0..8582c32f978 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -45,6 +45,8 @@ import { } from "./activate" import { initializeI18n } from "./i18n" import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache" +import { startBackgroundRetentionPurge } from "./utils/task-history-retention" +import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -167,6 +169,8 @@ export async function activate(context: vscode.ExtensionContext) { const contextProxy = await ContextProxy.getInstance(context) + // Initialize the provider *before* the Roo Code Cloud service so we can reuse its task deletion logic. + const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) // Initialize code index managers for all workspace folders. const codeIndexManagers: CodeIndexManager[] = [] @@ -190,9 +194,6 @@ export async function activate(context: vscode.ExtensionContext) { } } - // Initialize the provider *before* the Roo Code Cloud service. - const provider = new ClineProvider(context, outputChannel, "sidebar", contextProxy, mdmService) - // Initialize Roo Code Cloud service. const postStateListener = () => ClineProvider.getVisibleInstance()?.postStateToWebview() @@ -392,6 +393,27 @@ export async function activate(context: vscode.ExtensionContext) { // Allows other extensions to activate once Roo is ready. vscode.commands.executeCommand(`${Package.name}.activationCompleted`) + // Task history retention purge (runs in background after activation) + // By this point, provider is fully initialized and ready to handle deletions + { + const retentionValue = contextProxy.getValue("taskHistoryRetention") + const retention: TaskHistoryRetentionSetting = TASK_HISTORY_RETENTION_OPTIONS.includes( + retentionValue as TaskHistoryRetentionSetting, + ) + ? (retentionValue as TaskHistoryRetentionSetting) + : "never" + startBackgroundRetentionPurge({ + globalStoragePath: contextProxy.globalStorageUri.fsPath, + log: (m) => outputChannel.appendLine(m), + deleteTaskById: async (taskId: string) => { + // Reuse the same internal deletion logic as the History view so that + // checkpoints, shadow repositories, and task state are cleaned up consistently. + await provider.deleteTaskWithId(taskId) + }, + retention, + }) + } + // Implements the `RooCodeAPI` interface. const socketPath = process.env.ROO_CODE_IPC_SOCKET_PATH const enableLogging = typeof socketPath === "string" diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 321a1aa3a06..f5f54651d85 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ha eliminat {{count}} tasca més antiga de {{days}} dies", + "purgeNotification_plural": "Roo Code ha eliminat {{count}} tasques més antigues de {{days}} dies", + "actions": { + "viewSettings": "Veure configuració", + "dismiss": "Descartar" + } } } diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index 0611cf889af..e37b711b1b0 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -258,5 +258,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code hat {{count}} Aufgabe gelöscht, die älter als {{days}} Tage war", + "purgeNotification_plural": "Roo Code hat {{count}} Aufgaben gelöscht, die älter als {{days}} Tage waren", + "actions": { + "viewSettings": "Einstellungen anzeigen", + "dismiss": "Verwerfen" + } } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 90c409feb76..4fca817411e 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -252,5 +252,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code deleted {{count}} task older than {{days}} days", + "purgeNotification_plural": "Roo Code deleted {{count}} tasks older than {{days}} days", + "actions": { + "viewSettings": "View Settings", + "dismiss": "Dismiss" + } } } diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index d0a086173e9..d2f90487bc5 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -258,5 +258,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code eliminó {{count}} tarea más antigua de {{days}} días", + "purgeNotification_plural": "Roo Code eliminó {{count}} tareas más antiguas de {{days}} días", + "actions": { + "viewSettings": "Ver configuración", + "dismiss": "Descartar" + } } } diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index 58350ef02ba..968cb95ba0a 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code a supprimé {{count}} tâche datant de plus de {{days}} jours", + "purgeNotification_plural": "Roo Code a supprimé {{count}} tâches datant de plus de {{days}} jours", + "actions": { + "viewSettings": "Voir les paramètres", + "dismiss": "Ignorer" + } } } diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 33277c71623..bb4aa319c7e 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ने {{days}} दिनों से अधिक पुराना {{count}} कार्य हटा दिया", + "purgeNotification_plural": "Roo Code ने {{days}} दिनों से अधिक पुराने {{count}} कार्य हटा दिए", + "actions": { + "viewSettings": "सेटिंग्स देखें", + "dismiss": "खारिज करें" + } } } diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index c10532beef9..9980756886e 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code menghapus {{count}} tugas yang lebih lama dari {{days}} hari", + "purgeNotification_plural": "Roo Code menghapus {{count}} tugas yang lebih lama dari {{days}} hari", + "actions": { + "viewSettings": "Lihat Pengaturan", + "dismiss": "Tutup" + } } } diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index a75dccd3875..43befbeecb6 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code ha eliminato {{count}} attività più vecchia di {{days}} giorni", + "purgeNotification_plural": "Roo Code ha eliminato {{count}} attività più vecchie di {{days}} giorni", + "actions": { + "viewSettings": "Visualizza Impostazioni", + "dismiss": "Ignora" + } } } diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index b378f00b03f..79e21544eaa 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Codeは{{days}}日より古い{{count}}件のタスクを削除しました", + "purgeNotification_plural": "Roo Codeは{{days}}日より古い{{count}}件のタスクを削除しました", + "actions": { + "viewSettings": "設定を表示", + "dismiss": "閉じる" + } } } diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index e7afdceabce..0d06bff10c5 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code가 {{days}}일보다 오래된 작업 {{count}}개를 삭제했습니다", + "purgeNotification_plural": "Roo Code가 {{days}}일보다 오래된 작업 {{count}}개를 삭제했습니다", + "actions": { + "viewSettings": "설정 보기", + "dismiss": "닫기" + } } } diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index 889cd4b3ab6..c462ac5aacb 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code heeft {{count}} taak verwijderd die ouder is dan {{days}} dagen", + "purgeNotification_plural": "Roo Code heeft {{count}} taken verwijderd die ouder zijn dan {{days}} dagen", + "actions": { + "viewSettings": "Instellingen Bekijken", + "dismiss": "Sluiten" + } } } diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index faa4e9ed3a5..dbea26e2d88 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code usunął {{count}} zadanie starsze niż {{days}} dni", + "purgeNotification_plural": "Roo Code usunął {{count}} zadań starszych niż {{days}} dni", + "actions": { + "viewSettings": "Zobacz Ustawienia", + "dismiss": "Zamknij" + } } } diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index f41a379acbc..202a39e1102 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code excluiu {{count}} tarefa mais antiga que {{days}} dias", + "purgeNotification_plural": "Roo Code excluiu {{count}} tarefas mais antigas que {{days}} dias", + "actions": { + "viewSettings": "Ver Configurações", + "dismiss": "Dispensar" + } } } diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 751637f19e0..a4b5af2aabf 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code удалил {{count}} задачу старше {{days}} дней", + "purgeNotification_plural": "Roo Code удалил {{count}} задач старше {{days}} дней", + "actions": { + "viewSettings": "Посмотреть Настройки", + "dismiss": "Закрыть" + } } } diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7b2ac152a9b..e9b676e1e85 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code, {{days}} günden eski {{count}} görevi sildi", + "purgeNotification_plural": "Roo Code, {{days}} günden eski {{count}} görevi sildi", + "actions": { + "viewSettings": "Ayarları Görüntüle", + "dismiss": "Kapat" + } } } diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 0d88ba07808..9f4c2954d38 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -270,5 +270,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code đã xóa {{count}} tác vụ cũ hơn {{days}} ngày", + "purgeNotification_plural": "Roo Code đã xóa {{count}} tác vụ cũ hơn {{days}} ngày", + "actions": { + "viewSettings": "Xem Cài Đặt", + "dismiss": "Đóng" + } } } diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index 133b3de0794..81548f81400 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -268,5 +268,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code 已删除 {{count}} 个超过 {{days}} 天的任务", + "purgeNotification_plural": "Roo Code 已删除 {{count}} 个超过 {{days}} 天的任务", + "actions": { + "viewSettings": "查看设置", + "dismiss": "关闭" + } } } diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 8039f203b62..e9631847756 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -263,5 +263,13 @@ "docsLink": { "label": "Docs", "url": "https://docs.roocode.com" + }, + "taskHistoryRetention": { + "purgeNotification": "Roo Code 已刪除 {{count}} 個超過 {{days}} 天的工作", + "purgeNotification_plural": "Roo Code 已刪除 {{count}} 個超過 {{days}} 天的工作", + "actions": { + "viewSettings": "檢視設定", + "dismiss": "關閉" + } } } diff --git a/src/services/checkpoints/ShadowCheckpointService.ts b/src/services/checkpoints/ShadowCheckpointService.ts index fee08b2fa4b..417ba9d8870 100644 --- a/src/services/checkpoints/ShadowCheckpointService.ts +++ b/src/services/checkpoints/ShadowCheckpointService.ts @@ -449,22 +449,21 @@ export abstract class ShadowCheckpointService extends EventEmitter { workspaceDir: string }) { const workspaceRepoDir = this.workspaceRepoDir({ globalStorageDir, workspaceDir }) - const branchName = `roo-${taskId}` - const git = createSanitizedGit(workspaceRepoDir) - const success = await this.deleteBranch(git, branchName) - if (success) { - console.log(`[${this.name}#deleteTask.${taskId}] deleted branch ${branchName}`) - } else { - console.error(`[${this.name}#deleteTask.${taskId}] failed to delete branch ${branchName}`) + // Check if the workspace repo directory exists before attempting git operations + if (!(await fileExistsAtPath(workspaceRepoDir))) { + return } + + const branchName = `roo-${taskId}` + const git = createSanitizedGit(workspaceRepoDir) + await this.deleteBranch(git, branchName) } public static async deleteBranch(git: SimpleGit, branchName: string) { const branches = await git.branchLocal() if (!branches.all.includes(branchName)) { - console.error(`[${this.constructor.name}#deleteBranch] branch ${branchName} does not exist`) return false } diff --git a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts index 5172a373694..2539ed99c1b 100644 --- a/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts +++ b/src/services/checkpoints/__tests__/ShadowCheckpointService.spec.ts @@ -12,7 +12,9 @@ import * as fileSearch from "../../../services/search/file-search" import { RepoPerTaskCheckpointService } from "../RepoPerTaskCheckpointService" -const tmpDir = path.join(os.tmpdir(), "CheckpointService") +// Use a unique tmp directory per test run to avoid collisions with other +// Vitest workers/processes and to keep cleanup fast/reliable on Windows. +const tmpDir = path.join(os.tmpdir(), `CheckpointService-${process.pid}-${Date.now()}`) const initWorkspaceRepo = async ({ workspaceDir, @@ -55,10 +57,12 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( let workspaceGit: SimpleGit let testFile: string let service: RepoPerTaskCheckpointService + let shadowDir: string + let workspaceDir: string beforeEach(async () => { - const shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`) - const workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`) + shadowDir = path.join(tmpDir, `${prefix}-${Date.now()}`) + workspaceDir = path.join(tmpDir, `workspace-${Date.now()}`) const repo = await initWorkspaceRepo({ workspaceDir }) workspaceGit = repo.git @@ -70,11 +74,22 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( afterEach(async () => { vitest.restoreAllMocks() + + // Clean up per-test directories to prevent a huge accumulated tmp tree. + // This makes Windows CI much less likely to hit slow/blocked recursive deletes. + await Promise.all([ + fs + .rm(shadowDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + .catch(() => undefined), + fs + .rm(workspaceDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + .catch(() => undefined), + ]) }) afterAll(async () => { - await fs.rm(tmpDir, { recursive: true, force: true }) - }) + await fs.rm(tmpDir, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) + }, 60_000) describe(`${klass.name}#getDiff`, () => { it("returns the correct diff between commits", async () => { @@ -913,5 +928,30 @@ describe.each([[RepoPerTaskCheckpointService, "RepoPerTaskCheckpointService"]])( } }) }) + + describe(`${klass.name}#deleteTask`, () => { + it("handles non-existent workspace repo directory gracefully", async () => { + const nonExistentWorkspaceDir = path.join(tmpDir, `non-existent-workspace-${Date.now()}`) + const nonExistentGlobalStorageDir = path.join(tmpDir, `non-existent-storage-${Date.now()}`) + const taskIdToDelete = "non-existent-task" + + // Verify the workspace repo directory doesn't exist + const workspaceRepoDir = path.join( + nonExistentGlobalStorageDir, + "checkpoints", + klass.hashWorkspaceDir(nonExistentWorkspaceDir), + ) + expect(await fileExistsAtPath(workspaceRepoDir)).toBe(false) + + // Should not throw when the directory doesn't exist + await expect( + klass.deleteTask({ + taskId: taskIdToDelete, + globalStorageDir: nonExistentGlobalStorageDir, + workspaceDir: nonExistentWorkspaceDir, + }), + ).resolves.not.toThrow() + }) + }) }, ) diff --git a/src/utils/__tests__/task-storage-size.spec.ts b/src/utils/__tests__/task-storage-size.spec.ts new file mode 100644 index 00000000000..cc7997b146d --- /dev/null +++ b/src/utils/__tests__/task-storage-size.spec.ts @@ -0,0 +1,242 @@ +import * as path from "path" +import { calculateTaskStorageSize, formatBytes } from "../task-storage-size" + +// Mock storage to avoid VS Code config access during tests +vi.mock("../storage", () => ({ + getStorageBasePath: (p: string) => Promise.resolve(p), +})) + +// Mock fs/promises +const mockReaddir = vi.fn() +const mockStat = vi.fn() + +vi.mock("fs/promises", () => ({ + readdir: (...args: unknown[]) => mockReaddir(...args), + stat: (...args: unknown[]) => mockStat(...args), +})) + +describe("formatBytes", () => { + it("should format 0 bytes", () => { + expect(formatBytes(0)).toBe("0 B") + }) + + it("should format bytes less than 1 KB", () => { + expect(formatBytes(512)).toBe("512 B") + expect(formatBytes(1)).toBe("1 B") + }) + + it("should format kilobytes (0 decimal places)", () => { + expect(formatBytes(1024)).toBe("1 KB") + expect(formatBytes(2048)).toBe("2 KB") + expect(formatBytes(1536)).toBe("2 KB") // Rounds to nearest integer + expect(formatBytes(1280)).toBe("1 KB") // Rounds down + }) + + it("should format megabytes (2 decimal places)", () => { + expect(formatBytes(1048576)).toBe("1.00 MB") + expect(formatBytes(1572864)).toBe("1.50 MB") + expect(formatBytes(10485760)).toBe("10.00 MB") + }) + + it("should format gigabytes (2 decimal places)", () => { + expect(formatBytes(1073741824)).toBe("1.00 GB") + expect(formatBytes(2147483648)).toBe("2.00 GB") + }) + + it("should format terabytes (2 decimal places)", () => { + expect(formatBytes(1099511627776)).toBe("1.00 TB") + }) + + it("should handle decimal precision correctly", () => { + expect(formatBytes(1234567)).toBe("1.18 MB") + expect(formatBytes(123456789)).toBe("117.74 MB") + }) +}) + +describe("calculateTaskStorageSize", () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it("should return zeros when tasks directory does not exist", async () => { + mockReaddir.mockRejectedValue(new Error("ENOENT: no such file or directory")) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result).toEqual({ + totalBytes: 0, + taskCount: 0, + formattedSize: "0 B", + }) + }) + + it("should calculate size of empty tasks directory", async () => { + mockReaddir.mockResolvedValue([]) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result).toEqual({ + totalBytes: 0, + taskCount: 0, + formattedSize: "0 B", + }) + }) + + it("should count task directories correctly", async () => { + // Mock the tasks directory read + mockReaddir.mockImplementation((dirPath: string, options?: { withFileTypes: boolean }) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + // Return task directories + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-2", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { name: "task-3", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + // Task subdirectories are empty + return Promise.resolve([]) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result.taskCount).toBe(3) + }) + + it("should calculate total size including files", async () => { + // Mock the tasks directory read + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + if (pathStr.includes("task-1")) { + return Promise.resolve([ + { name: "file1.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false }, + { name: "file2.json", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false }, + ]) + } + return Promise.resolve([]) + }) + + mockStat.mockImplementation((filePath: string) => { + if (filePath.includes("file1.txt")) { + return Promise.resolve({ size: 1024 }) + } + if (filePath.includes("file2.json")) { + return Promise.resolve({ size: 2048 }) + } + return Promise.resolve({ size: 0 }) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result.totalBytes).toBe(3072) + expect(result.formattedSize).toBe("3 KB") + expect(result.taskCount).toBe(1) + }) + + it("should handle nested directories (like checkpoints)", async () => { + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + if (pathStr.endsWith("task-1") && !pathStr.includes("checkpoints")) { + return Promise.resolve([ + { + name: "api_conversation.json", + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, + { name: "checkpoints", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + if (pathStr.includes("checkpoints")) { + return Promise.resolve([ + { + name: "checkpoint-1.json", + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, + ]) + } + return Promise.resolve([]) + }) + + mockStat.mockImplementation((filePath: string) => { + if (filePath.includes("api_conversation.json")) { + return Promise.resolve({ size: 5000 }) + } + if (filePath.includes("checkpoint-1.json")) { + return Promise.resolve({ size: 10000 }) + } + return Promise.resolve({ size: 0 }) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + expect(result.totalBytes).toBe(15000) + expect(result.taskCount).toBe(1) + }) + + it("should handle stat errors gracefully", async () => { + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + ]) + } + return Promise.resolve([ + { name: "broken-file.txt", isDirectory: () => false, isFile: () => true, isSymbolicLink: () => false }, + ]) + }) + + mockStat.mockRejectedValue(new Error("Permission denied")) + + const result = await calculateTaskStorageSize("/global/storage") + + // Should still return a result, just with 0 bytes for the failed stat + expect(result.taskCount).toBe(1) + expect(result.totalBytes).toBe(0) + }) + + it("should handle mixed files and directories in tasks folder", async () => { + mockReaddir.mockImplementation((dirPath: string) => { + const pathStr = typeof dirPath === "string" ? dirPath : String(dirPath) + if (pathStr.endsWith("tasks")) { + return Promise.resolve([ + { name: "task-1", isDirectory: () => true, isFile: () => false, isSymbolicLink: () => false }, + { + name: "some-file.txt", + isDirectory: () => false, + isFile: () => true, + isSymbolicLink: () => false, + }, // Should not count as task + ]) + } + return Promise.resolve([]) + }) + + mockStat.mockImplementation((filePath: string) => { + if (filePath.includes("some-file.txt")) { + return Promise.resolve({ size: 100 }) + } + return Promise.resolve({ size: 0 }) + }) + + const result = await calculateTaskStorageSize("/global/storage") + + // Only directories count as tasks + expect(result.taskCount).toBe(1) + // But file size should be included + expect(result.totalBytes).toBe(100) + }) +}) diff --git a/src/utils/formatBytes.ts b/src/utils/formatBytes.ts new file mode 100644 index 00000000000..51a621fdd71 --- /dev/null +++ b/src/utils/formatBytes.ts @@ -0,0 +1,18 @@ +/** + * Formats bytes into a human-readable string with appropriate units. + * + * Note: This is intentionally simple (base-2 / 1024) and consistent with existing + * formatting expectations in tests and UI. + */ +export function formatBytes(bytes: number): string { + if (bytes === 0) return "0 B" + + const units = ["B", "KB", "MB", "GB", "TB"] + const k = 1024 + const i = Math.min(Math.floor(Math.log(bytes) / Math.log(k)), units.length - 1) + const size = bytes / Math.pow(k, i) + + // Use 2 decimal places for MB and above, 0 for B and KB + const decimals = i >= 2 ? 2 : 0 + return `${size.toFixed(decimals)} ${units[i]}` +} diff --git a/src/utils/task-history-retention.ts b/src/utils/task-history-retention.ts new file mode 100644 index 00000000000..d59b979bd0b --- /dev/null +++ b/src/utils/task-history-retention.ts @@ -0,0 +1,348 @@ +import * as vscode from "vscode" +import * as path from "path" +import * as fs from "fs/promises" +import type { Dirent } from "fs" + +import { TASK_HISTORY_RETENTION_OPTIONS, type TaskHistoryRetentionSetting } from "@roo-code/types" + +import { getStorageBasePath } from "./storage" +import { GlobalFileNames } from "../shared/globalFileNames" +import { t } from "../i18n" + +export type RetentionSetting = TaskHistoryRetentionSetting + +export type PurgeResult = { + purgedCount: number + cutoff: number | null +} + +/** + * Purge old task directories under /tasks based on task_metadata.json ts value. + * Executes best-effort deletes; errors are logged and skipped. + * + * @param retention Retention setting: "never" | "90" | "60" | "30" | "7" | "3" or number of days + * @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath) + * @param log Optional logger + * @param dryRun When true, logs which tasks would be deleted but does not delete anything + * @returns PurgeResult with count and cutoff used + */ +export async function purgeOldTasks( + retention: RetentionSetting, + globalStoragePath: string, + log?: (message: string) => void, + dryRun: boolean = false, + deleteTaskById?: (taskId: string, taskDirPath: string) => Promise, + verbose: boolean = false, +): Promise { + const days = normalizeDays(retention) + if (!days) { + log?.("[Retention] No purge (setting is 'never' or not a positive number)") + return { purgedCount: 0, cutoff: null } + } + + const cutoff = Date.now() - days * 24 * 60 * 60 * 1000 + const logv = (msg: string) => { + if (verbose) log?.(msg) + } + logv(`[Retention] Starting purge with retention=${retention} (${days} day(s))${dryRun ? " (dry run)" : ""}`) + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.( + `[Retention] Failed to resolve storage base path: ${ + e instanceof Error ? e.message : String(e) + }${dryRun ? " (dry run)" : ""}`, + ) + return { purgedCount: 0, cutoff } + } + + const tasksDir = path.join(basePath, "tasks") + + let entries: Dirent[] + try { + entries = await fs.readdir(tasksDir, { withFileTypes: true }) + } catch (e) { + // No tasks directory yet or unreadable; nothing to purge. + logv(`[Retention] Tasks directory not found or unreadable at ${tasksDir}${dryRun ? " (dry run)" : ""}`) + return { purgedCount: 0, cutoff } + } + + const taskDirs = entries.filter((d) => d.isDirectory()) + + logv(`[Retention] Found ${taskDirs.length} task director${taskDirs.length === 1 ? "y" : "ies"} under ${tasksDir}`) + + // Small helpers + const pathExists = async (p: string): Promise => { + try { + await fs.access(p) + return true + } catch { + return false + } + } + + const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)) + + // Aggressive recursive remove with retries; also directly clears checkpoints if needed + const removeDirAggressive = async (dir: string): Promise => { + // Try up to 3 passes with short delays + for (let attempt = 1; attempt <= 3; attempt++) { + try { + await fs.rm(dir, { recursive: true, force: true }) + } catch { + // ignore and try more targeted cleanup below + } + + // Verify + if (!(await pathExists(dir))) return true + + // Targeted cleanup for stubborn checkpoint-only directories + try { + await fs.rm(path.join(dir, "checkpoints"), { recursive: true, force: true }) + } catch { + // ignore + } + + // Remove children one by one in case some FS impls struggle with rm -r + try { + const entries = await fs.readdir(dir, { withFileTypes: true }) + for (const entry of entries) { + const entryPath = path.join(dir, entry.name) + try { + if (entry.isDirectory()) { + await fs.rm(entryPath, { recursive: true, force: true }) + } else { + await fs.unlink(entryPath) + } + } catch { + // ignore individual failures; we'll retry the parent + } + } + } catch { + // ignore + } + + // Final attempt this pass + try { + await fs.rm(dir, { recursive: true, force: true }) + } catch { + // ignore + } + + if (!(await pathExists(dir))) return true + + // Backoff a bit before next attempt + await sleep(50 * attempt) + } + + return !(await pathExists(dir)) + } + + const results: number[] = [] + + for (const d of taskDirs) { + const taskDir = path.join(tasksDir, d.name) + const metadataPath = path.join(taskDir, GlobalFileNames.taskMetadata) + + let ts: number | null = null + + // First try to get a timestamp from task_metadata.json (if present) + try { + const raw = await fs.readFile(metadataPath, "utf8") + const meta: unknown = JSON.parse(raw) + const maybeTs = Number( + typeof meta === "object" && meta !== null && "ts" in meta ? (meta as { ts: unknown }).ts : undefined, + ) + if (Number.isFinite(maybeTs)) { + ts = maybeTs + } + } catch { + // Missing or invalid metadata; we'll fall back to directory mtime. + } + + let shouldDelete = false + let reason = "" + + // Check for checkpoint-only orphan directories (delete regardless of age) + try { + const childEntries = await fs.readdir(taskDir, { withFileTypes: true }) + const visibleNames = childEntries.map((e) => e.name).filter((n) => !n.startsWith(".")) + const hasCheckpointsDir = childEntries.some((e) => e.isDirectory() && e.name === "checkpoints") + const nonCheckpointVisible = visibleNames.filter((n) => n !== "checkpoints") + const hasMetadataFile = visibleNames.includes(GlobalFileNames.taskMetadata) + if (hasCheckpointsDir && nonCheckpointVisible.length === 0 && !hasMetadataFile) { + shouldDelete = true + reason = "orphan checkpoints_only" + } + } catch { + // Ignore errors while scanning children; proceed with normal logic + } + + if (!shouldDelete && ts !== null && ts < cutoff) { + // Normal case: metadata has a valid ts older than cutoff + shouldDelete = true + reason = `ts=${ts}` + } else if (!shouldDelete) { + // Orphan/legacy case: no valid ts; fall back to directory mtime + try { + const stat = await fs.stat(taskDir) + const mtimeMs = stat.mtime.getTime() + if (mtimeMs < cutoff) { + shouldDelete = true + reason = `no valid ts, mtime=${stat.mtime.toISOString()}` + } + } catch { + // If we can't stat the directory, skip it. + } + } + + if (!shouldDelete) { + results.push(0) + continue + } + + if (dryRun) { + logv(`[Retention][DRY RUN] Would delete task ${d.name} (${reason}) @ ${taskDir}`) + results.push(1) + continue + } + + // Attempt deletion using provider callback (for full cleanup) or direct rm + let deletionError: unknown | null = null + let deleted = false + try { + if (deleteTaskById) { + logv(`[Retention] Deleting task ${d.name} via provider @ ${taskDir} (${reason})`) + await deleteTaskById(d.name, taskDir) + // Provider callback handles full cleanup; check if directory is gone + deleted = !(await pathExists(taskDir)) + } else { + logv(`[Retention] Deleting task ${d.name} via fs.rm @ ${taskDir} (${reason})`) + await fs.rm(taskDir, { recursive: true, force: true }) + deleted = !(await pathExists(taskDir)) + } + } catch (e) { + deletionError = e + } + + // If directory still exists after initial attempt, try aggressive cleanup with retries + if (!deleted) { + deleted = await removeDirAggressive(taskDir) + } + + if (!deleted) { + // Did not actually remove; report the most relevant error + if (deletionError) { + log?.( + `[Retention] Failed to delete task ${d.name} @ ${taskDir}: ${ + deletionError instanceof Error ? deletionError.message : String(deletionError) + } (directory still present)`, + ) + } else { + log?.( + `[Retention] Failed to delete task ${d.name} @ ${taskDir}: directory still present after cleanup attempts`, + ) + } + results.push(0) + continue + } + + logv(`[Retention] Deleted task ${d.name} (${reason}) @ ${taskDir}`) + results.push(1) + } + + const purged = results.reduce((sum, n) => sum + n, 0) + + if (purged > 0) { + log?.( + `[Retention] Purged ${purged} task(s)${dryRun ? " (dry run)" : ""}; cutoff=${new Date(cutoff).toISOString()}`, + ) + } else { + log?.(`[Retention] No tasks met purge criteria${dryRun ? " (dry run)" : ""}`) + } + + return { purgedCount: purged, cutoff } +} + +/** + * Normalize retention into a positive integer day count or 0 (no-op). + */ +function normalizeDays(value: RetentionSetting): number { + if (value === "never") return 0 + const n = parseInt(value, 10) + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0 +} + +/** + * Options for starting the background retention purge. + */ +export interface BackgroundPurgeOptions { + /** VS Code global storage fsPath */ + globalStoragePath: string + /** Logger function */ + log: (message: string) => void + /** Function to delete a task by ID (should use ClineProvider.deleteTaskWithId for full cleanup) */ + deleteTaskById: (taskId: string, taskDirPath: string) => Promise + /** Retention setting value from Roo application state */ + retention: RetentionSetting +} + +/** + * Starts the task history retention purge in the background. + * This function is designed to be called after extension activation completes, + * using a fire-and-forget pattern (void) to avoid blocking activation. + * + * It reads the retention setting from Roo application state, executes the purge, + * and shows a notification if tasks were deleted. + * + * @param options Configuration options for the background purge + */ +export function startBackgroundRetentionPurge(options: BackgroundPurgeOptions): void { + const { globalStoragePath, log, deleteTaskById, retention } = options + + void (async () => { + try { + // Skip if retention is disabled + if (retention === "never") { + log("[Retention] Background purge skipped: retention is set to 'never'") + return + } + + if (!TASK_HISTORY_RETENTION_OPTIONS.includes(retention)) { + log(`[Retention] Background purge skipped: invalid retention value '${retention}'`) + return + } + + log(`[Retention] Starting background purge: setting=${retention}`) + + const result = await purgeOldTasks(retention, globalStoragePath, log, false, deleteTaskById) + + log( + `[Retention] Background purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`, + ) + + // Show user notification if tasks were deleted + if (result.purgedCount > 0) { + const message = t("common:taskHistoryRetention.purgeNotification", { + count: result.purgedCount, + days: retention, + }) + + const viewSettingsLabel = t("common:taskHistoryRetention.actions.viewSettings") + const dismissLabel = t("common:taskHistoryRetention.actions.dismiss") + + vscode.window.showInformationMessage(message, viewSettingsLabel, dismissLabel).then((action) => { + if (action === viewSettingsLabel) { + // Navigate to Roo Code settings About tab + vscode.commands.executeCommand("roo-cline.settingsButtonClicked", "about") + } + }) + } + } catch (error) { + log(`[Retention] Failed during background purge: ${error instanceof Error ? error.message : String(error)}`) + } + })() +} diff --git a/src/utils/task-storage-size.ts b/src/utils/task-storage-size.ts new file mode 100644 index 00000000000..cd16766735d --- /dev/null +++ b/src/utils/task-storage-size.ts @@ -0,0 +1,124 @@ +import * as path from "path" +import * as fs from "fs/promises" +import type { Dirent, Stats } from "fs" + +import { getStorageBasePath } from "./storage" +import { formatBytes } from "./formatBytes" + +/** + * Result of calculating task history storage size + */ +export interface TaskStorageSizeResult { + /** Total size in bytes */ + totalBytes: number + /** Number of task directories found */ + taskCount: number + /** Formatted size string (e.g., "12.34 MB") */ + formattedSize: string +} + +// Re-export for backwards compatibility with existing imports/tests. +export { formatBytes } + +/** + * Recursively calculates the total size of a directory. + * @param dirPath Path to the directory + * @returns Total size in bytes + */ +async function getDirectorySize(dirPath: string, depth: number = 0): Promise { + let totalSize = 0 + + // Safety check: prevent infinite recursion by limiting depth + if (depth > 50) { + return 0 + } + + try { + const entries: Dirent[] = await fs.readdir(dirPath, { withFileTypes: true }) + + // Process entries in parallel for better performance + const sizes = await Promise.all( + entries.map(async (entry) => { + const entryPath = path.join(dirPath, entry.name) + + try { + // Check for symlinks to prevent infinite loops + if (entry.isSymbolicLink()) { + return 0 + } + + if (entry.isDirectory()) { + return await getDirectorySize(entryPath, depth + 1) + } else if (entry.isFile()) { + const stat: Stats = await fs.stat(entryPath) + return stat.size + } + } catch { + // Ignore errors for individual entries (permission issues, deleted files, etc.) + } + + return 0 + }), + ) + + totalSize = sizes.reduce((acc, size) => acc + size, 0) + } catch { + // Directory doesn't exist or can't be read + } + + return totalSize +} + +/** + * Calculates the total storage size used by task history. + * This includes all files in the tasks/ directory (task data, checkpoints, etc.). + * + * This function is designed to be non-blocking and safe for background execution. + * Errors are handled gracefully and will return 0 bytes if the directory doesn't exist + * or can't be read. + * + * @param globalStoragePath VS Code global storage fsPath (context.globalStorageUri.fsPath) + * @param log Optional logger function for debugging + * @returns TaskStorageSizeResult with size info + */ +export async function calculateTaskStorageSize( + globalStoragePath: string, + log?: (message: string) => void, +): Promise { + const defaultResult: TaskStorageSizeResult = { + totalBytes: 0, + taskCount: 0, + formattedSize: "0 B", + } + + let basePath: string + + try { + basePath = await getStorageBasePath(globalStoragePath) + } catch (e) { + log?.(`[TaskStorageSize] Failed to resolve storage base path: ${e instanceof Error ? e.message : String(e)}`) + return defaultResult + } + + const tasksDir = path.join(basePath, "tasks") + + // Count task directories + let taskCount = 0 + try { + const entries = await fs.readdir(tasksDir, { withFileTypes: true }) + taskCount = entries.filter((d) => d.isDirectory()).length + } catch { + // Tasks directory doesn't exist yet + log?.(`[TaskStorageSize] Tasks directory not found at ${tasksDir}`) + return defaultResult + } + + // Calculate total size + const totalBytes = await getDirectorySize(tasksDir) + + return { + totalBytes, + taskCount, + formattedSize: formatBytes(totalBytes), + } +} diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index 17e3d6cfa95..e51df56ef5d 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -1,30 +1,104 @@ -import { HTMLAttributes } from "react" +import { HTMLAttributes, useState, useCallback, useEffect, useRef } from "react" import { useAppTranslation } from "@/i18n/TranslationContext" import { Trans } from "react-i18next" -import { Download, Upload, TriangleAlert, Bug, Lightbulb, Shield, MessageCircle, MessagesSquare } from "lucide-react" +import { + Download, + Upload, + TriangleAlert, + Bug, + Lightbulb, + Shield, + MessageCircle, + MessagesSquare, + RefreshCw, + HardDrive, + Loader2, +} from "lucide-react" import { VSCodeCheckbox, VSCodeLink } from "@vscode/webview-ui-toolkit/react" -import type { TelemetrySetting } from "@roo-code/types" +import type { TelemetrySetting, TaskHistoryRetentionSetting } from "@roo-code/types" import { Package } from "@roo/package" import { vscode } from "@/utils/vscode" import { cn } from "@/lib/utils" -import { Button } from "@/components/ui" +import { Button, Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui" import { SectionHeader } from "./SectionHeader" import { Section } from "./Section" import { SearchableSetting } from "./SearchableSetting" +type TaskHistorySize = { + totalBytes: number + taskCount: number + formattedSize: string +} + type AboutProps = HTMLAttributes & { telemetrySetting: TelemetrySetting setTelemetrySetting: (setting: TelemetrySetting) => void debug?: boolean setDebug?: (debug: boolean) => void + taskHistoryRetention: TaskHistoryRetentionSetting + setTaskHistoryRetention: (value: TaskHistoryRetentionSetting) => void + taskHistorySize?: TaskHistorySize } -export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, className, ...props }: AboutProps) => { +export const About = ({ + telemetrySetting, + setTelemetrySetting, + debug, + setDebug, + taskHistoryRetention, + setTaskHistoryRetention, + taskHistorySize, + className, + ...props +}: AboutProps) => { const { t } = useAppTranslation() + const [isRefreshing, setIsRefreshing] = useState(false) + const [cachedSize, setCachedSize] = useState(taskHistorySize) + const didRequestInitialSize = useRef(false) + + // Update cached size when taskHistorySize changes and reset refreshing state + useEffect(() => { + if (taskHistorySize) { + setCachedSize(taskHistorySize) + setIsRefreshing(false) + } + }, [taskHistorySize]) + + // Trigger initial task history size calculation when this tab mounts + useEffect(() => { + if (didRequestInitialSize.current) return + didRequestInitialSize.current = true + vscode.postMessage({ type: "refreshTaskHistorySize" }) + }, []) + + const handleRefreshStorageSize = useCallback(() => { + setIsRefreshing(true) + vscode.postMessage({ type: "refreshTaskHistorySize" }) + }, []) + + const getStorageDisplayText = (): string => { + // Use cached size if available, otherwise show "Calculating" only if no cached value + const displaySize = taskHistorySize || cachedSize + if (!displaySize) { + return t("settings:taskHistoryStorage.calculating") + } + if (displaySize.taskCount === 0) { + return t("settings:taskHistoryStorage.empty") + } + if (displaySize.taskCount === 1) { + return t("settings:taskHistoryStorage.formatSingular", { + size: displaySize.formattedSize, + }) + } + return t("settings:taskHistoryStorage.format", { + size: displaySize.formattedSize, + count: displaySize.taskCount, + }) + } return (
@@ -131,10 +205,68 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
+ +

{t("settings:aboutRetention.label")}

+
+ +
+ {t("settings:aboutRetention.description")} +
+
{t("settings:aboutRetention.warning")}
+
+
+ + +
+ + + {t("settings:taskHistoryStorage.label")}: {getStorageDisplayText()} + + +
+
+ + label={t("settings:about.manageSettings")} + className="mt-4 pt-4 border-t border-vscode-settings-headerBorder">

{t("settings:about.manageSettings")}