diff --git a/packages/types/src/global-settings.ts b/packages/types/src/global-settings.ts index 0f75e1d610..d1266b3432 100644 --- a/packages/types/src/global-settings.ts +++ b/packages/types/src/global-settings.ts @@ -181,6 +181,8 @@ export const globalSettingsSchema = z.object({ customSupportPrompts: customSupportPromptsSchema.optional(), enhancementApiConfigId: z.string().optional(), includeTaskHistoryInEnhance: z.boolean().optional(), + // Auto-delete task history on extension reload. "never" | "90" | "60" | "30" | "7" | "3" + taskHistoryRetention: z.union([z.enum(["never", "90", "60", "30", "7", "3"]), z.number()]).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 49f9687f00..9e55fab176 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -335,6 +335,7 @@ export type ExtensionState = Pick< | "maxGitStatusFiles" | "requestDelaySeconds" | "showWorktreesInHomeScreen" + | "taskHistoryRetention" > & { version: string clineMessages: ClineMessage[] diff --git a/src/__tests__/extension.spec.ts b/src/__tests__/extension.spec.ts index 5b07267269..1d21549311 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 purge +vi.mock("../utils/task-history-retention", () => ({ + purgeOldTasks: vi.fn().mockResolvedValue({ purgedCount: 0, cutoff: null }), +})) + +// 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 0000000000..3bb4b2a55a --- /dev/null +++ b/src/__tests__/task-history-retention.spec.ts @@ -0,0 +1,141 @@ +// 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 }) + } + }) +}) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index fd70c41af0..64ad5a0640 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2053,6 +2053,7 @@ export class ClineProvider reasoningBlockCollapsed, enterBehavior, cloudUserInfo, + taskHistoryRetention, cloudIsAuthenticated, sharingEnabled, publicSharingEnabled, @@ -2236,6 +2237,8 @@ export class ClineProvider includeDiagnosticMessages: includeDiagnosticMessages ?? true, maxDiagnosticMessages: maxDiagnosticMessages ?? 50, includeTaskHistoryInEnhance: includeTaskHistoryInEnhance ?? true, + // Task history retention setting for About tab dropdown + taskHistoryRetention: taskHistoryRetention ?? "never", includeCurrentTime: includeCurrentTime ?? true, includeCurrentCost: includeCurrentCost ?? true, maxGitStatusFiles: maxGitStatusFiles ?? 0, @@ -2451,6 +2454,8 @@ export class ClineProvider organizationAllowList, organizationSettingsVersion, customCondensingPrompt: stateValues.customCondensingPrompt, + // Task history retention selection + taskHistoryRetention: stateValues.taskHistoryRetention ?? "never", 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 2dc77d0502..9adbbbf003 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -593,6 +593,12 @@ export const webviewMessageHandler = async ( await vscode.workspace .getConfiguration(Package.name) .update("deniedCommands", newValue, vscode.ConfigurationTarget.Global) + } else if (key === "taskHistoryRetention") { + const val = ((value ?? "never") as string).toString() + newValue = val + await vscode.workspace + .getConfiguration(Package.name) + .update("taskHistoryRetention", val, vscode.ConfigurationTarget.Global) } else if (key === "ttsEnabled") { newValue = value ?? true setTtsEnabled(newValue as boolean) diff --git a/src/extension.ts b/src/extension.ts index bcfbe33993..b3184ecbdc 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -1,6 +1,7 @@ import * as vscode from "vscode" import * as dotenvx from "@dotenvx/dotenvx" import * as path from "path" +import * as fs from "fs/promises" // Load environment variables from .env file try { @@ -44,6 +45,7 @@ import { } from "./activate" import { initializeI18n } from "./i18n" import { flushModels, initializeModelCacheRefresh, refreshModels } from "./api/providers/fetchers/modelCache" +import { purgeOldTasks } from "./utils/task-history-retention" /** * Built using https://github.com/microsoft/vscode-webview-ui-toolkit @@ -163,6 +165,40 @@ 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) + + // Task history retention purge (runs only on activation) + try { + const config = vscode.workspace.getConfiguration(Package.name) + const retention = config.get("taskHistoryRetention", "never") ?? "never" + + outputChannel.appendLine(`[Retention] Startup purge: setting=${retention}`) + + const result = await purgeOldTasks( + retention as any, + contextProxy.globalStorageUri.fsPath, + (m) => { + outputChannel.appendLine(m) + console.log(m) + }, + false, + async (taskId: string, _taskDirPath: 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) + }, + ) + + outputChannel.appendLine( + `[Retention] Startup purge complete: purged=${result.purgedCount}, cutoff=${result.cutoff ?? "none"}`, + ) + } catch (error) { + outputChannel.appendLine( + `[Retention] Failed during startup purge: ${error instanceof Error ? error.message : String(error)}`, + ) + } + // Initialize code index managers for all workspace folders. const codeIndexManagers: CodeIndexManager[] = [] @@ -186,9 +222,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() diff --git a/src/package.json b/src/package.json index e5ade27ed3..7e4a8c2164 100644 --- a/src/package.json +++ b/src/package.json @@ -429,6 +429,19 @@ "default": false, "description": "%settings.debugProxy.tlsInsecure.description%", "markdownDescription": "%settings.debugProxy.tlsInsecure.description%" + }, + "roo-cline.taskHistoryRetention": { + "type": "string", + "enum": [ + "never", + "90", + "60", + "30", + "7", + "3" + ], + "default": "never", + "description": "%settings.taskHistoryRetention.description%" } } } diff --git a/src/package.nls.json b/src/package.nls.json index 177b392f77..94107aed14 100644 --- a/src/package.nls.json +++ b/src/package.nls.json @@ -45,5 +45,6 @@ "settings.debug.description": "Enable debug mode to show additional buttons for viewing API conversation history and UI messages as prettified JSON in temporary files.", "settings.debugProxy.enabled.description": "**Enable Debug Proxy** — Route all outbound network requests through a proxy for MITM debugging. Only active when running in debug mode (F5).", "settings.debugProxy.serverUrl.description": "Proxy URL (e.g., `http://127.0.0.1:8888`). Only used when **Debug Proxy** is enabled.", - "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging." + "settings.debugProxy.tlsInsecure.description": "Accept self-signed certificates from the proxy. **Required for MITM inspection.** ⚠️ Insecure — only use for local debugging.", + "settings.taskHistoryRetention.description": "Auto-delete task history on extension reload. Deletes tasks older than the selected period. Options: Never (default), 90 days, 60 days, 30 days, 7 days, or 3 days. Warning: This cannot be undone and only runs on plugin reload." } diff --git a/src/utils/task-history-retention.ts b/src/utils/task-history-retention.ts new file mode 100644 index 0000000000..274033648a --- /dev/null +++ b/src/utils/task-history-retention.ts @@ -0,0 +1,267 @@ +import * as path from "path" +import * as fs from "fs/promises" +import type { Dirent } from "fs" + +import { getStorageBasePath } from "./storage" +import { GlobalFileNames } from "../shared/globalFileNames" + +/** + * Supported values for the retention setting. + * - "never" or 0 disables purging + * - "90" | "60" | "30" | "7" | "3" (string) or 90 | 60 | 30 | 7 | 3 (number) specify days + */ +export type RetentionSetting = "never" | "90" | "60" | "30" | "7" | "3" | 90 | 60 | 30 | 7 | 3 | 0 | "0" | number + +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 = await Promise.all( + taskDirs.map(async (d) => { + 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 = JSON.parse(raw) + const maybeTs = Number((meta as any)?.ts) + 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) { + return 0 + } + + if (dryRun) { + logv(`[Retention][DRY RUN] Would delete task ${d.name} (${reason}) @ ${taskDir}`) + return 1 + } + + // Attempt deletion using provider callback (for full cleanup) or direct rm + let deletionError: unknown | null = null + try { + if (deleteTaskById) { + logv(`[Retention] Deleting task ${d.name} via provider @ ${taskDir} (${reason})`) + await deleteTaskById(d.name, taskDir) + } else { + logv(`[Retention] Deleting task ${d.name} via fs.rm @ ${taskDir} (${reason})`) + await fs.rm(taskDir, { recursive: true, force: true }) + } + } catch (e) { + deletionError = e + } + + // Verify deletion; if still exists, attempt aggressive cleanup with retries + let 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`, + ) + } + return 0 + } + + logv(`[Retention] Deleted task ${d.name} (${reason}) @ ${taskDir}`) + return 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 = typeof value === "number" ? value : parseInt(String(value), 10) + return Number.isFinite(n) && n > 0 ? Math.trunc(n) : 0 +} diff --git a/webview-ui/src/components/settings/About.tsx b/webview-ui/src/components/settings/About.tsx index 17e3d6cfa9..f86414189c 100644 --- a/webview-ui/src/components/settings/About.tsx +++ b/webview-ui/src/components/settings/About.tsx @@ -10,7 +10,7 @@ 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" @@ -21,9 +21,20 @@ type AboutProps = HTMLAttributes & { setTelemetrySetting: (setting: TelemetrySetting) => void debug?: boolean setDebug?: (debug: boolean) => void + taskHistoryRetention: "never" | "90" | "60" | "30" | "7" | "3" + setTaskHistoryRetention: (value: "never" | "90" | "60" | "30" | "7" | "3") => void } -export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug, className, ...props }: AboutProps) => { +export const About = ({ + telemetrySetting, + setTelemetrySetting, + debug, + setDebug, + taskHistoryRetention, + setTaskHistoryRetention, + className, + ...props +}: AboutProps) => { const { t } = useAppTranslation() return ( @@ -131,10 +142,42 @@ export const About = ({ telemetrySetting, setTelemetrySetting, debug, setDebug,
+ +

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

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

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