From a85e3c7e799231a8b092c6f8356e0f81b033d059 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 18:20:32 +0000 Subject: [PATCH 1/9] fix(storage): make worktree cleanup work and show why it skipped Ignored files such as .env no longer block removal by default. A new setting picks which local changes keep a worktree. Settings > Storage gains a Delete now button and a live log of the latest run's removed, kept and failed worktrees with reasons. --- apps/server/src/auth/RpcAuthorization.test.ts | 40 ++ apps/server/src/auth/RpcAuthorization.ts | 1 + .../src/environment/ServerEnvironment.ts | 1 + .../src/observability/RpcInstrumentation.ts | 2 + apps/server/src/server.ts | 4 +- apps/server/src/storageCleanup.test.ts | 322 ++++++++++++++- apps/server/src/storageCleanup.ts | 377 +++++++++++++----- apps/server/src/ws.ts | 4 + .../components/settings/StorageSettings.tsx | 260 +++++++++++- .../settings/scopedSettings.test.ts | 6 + .../src/components/settings/settingsSearch.ts | 17 + docs/user/project-settings.md | 10 +- packages/client-runtime/src/rpc/client.ts | 1 + .../src/state/commandPermissions.test.ts | 27 ++ packages/client-runtime/src/state/server.ts | 10 + .../contracts/src/clientRpcPermissions.ts | 2 + packages/contracts/src/environment.ts | 1 + packages/contracts/src/rpc.ts | 23 +- packages/contracts/src/settings.test.ts | 24 ++ packages/contracts/src/settings.ts | 35 ++ packages/shared/src/projectSettings.test.ts | 2 + packages/shared/src/projectSettings.ts | 18 +- packages/shared/src/serverSettings.test.ts | 1 + packages/shared/src/serverSettings.ts | 1 + 24 files changed, 1078 insertions(+), 111 deletions(-) diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 0359b07b3941..3009e53a7c5f 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -403,3 +403,43 @@ it.effect("separates host file URLs from readable attachment URLs", () => expect(handled).toBe(1); }).pipe(Effect.scoped), ); + +it.effect("denies manual cleanup before the handler without settings permission", () => + Effect.gen(function* () { + const group = WsRpcGroup.omit( + ...[...WsRpcGroup.requests.keys()].filter( + ( + tag, + ): tag is Exclude< + keyof typeof RPC_REQUIRED_SCOPES, + typeof WS_METHODS.serverRunStorageCleanup + > => tag !== WS_METHODS.serverRunStorageCleanup, + ), + ); + let handled = false; + const client = yield* RpcTest.makeClient(group).pipe( + Effect.provide( + Layer.mergeAll( + group.toLayerHandler(WS_METHODS.serverRunStorageCleanup, () => + Effect.sync(() => { + handled = true; + return { + trigger: "manual" as const, + startedAt: "2026-10-09T12:00:00.000Z", + finishedAt: "2026-10-09T12:00:00.000Z", + entries: [], + counts: { removed: 0, kept: 0, failed: 0 }, + omittedCount: 0, + }; + }), + ), + RpcAuthorization.layer([AuthOrchestrationReadScope]), + ), + ), + ); + expect(yield* client[WS_METHODS.serverRunStorageCleanup]({}).pipe(Effect.flip)).toMatchObject({ + requiredPermission: AuthSettingsWriteScope, + }); + expect(handled).toBe(false); + }).pipe(Effect.scoped), +); diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index a33d2f3317aa..511fd77cd147 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, + [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 6381547468ae..c91919b1ee16 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -228,6 +228,7 @@ export const make = Effect.gen(function* () { threadSettlement: true, threadAutoSettlement: true, storageCleanup: true, + storageCleanupRun: true, projectWorktreeCleanup: true, worktreesDirectory: true, threadRestartContinuation: true, diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index b1abaf8d465c..2ad903ff792a 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -54,6 +54,8 @@ const RPC_AGGREGATES = { [WS_METHODS.serverCommitDesktopUpdate]: "server", [WS_METHODS.serverUpsertKeybinding]: "server", [WS_METHODS.serverRemoveKeybinding]: "server", + [WS_METHODS.serverRunStorageCleanup]: "server", + [WS_METHODS.serverGetStorageCleanupReport]: "server", [WS_METHODS.serverGetSettings]: "server", [WS_METHODS.serverUpdateSettings]: "server", [WS_METHODS.serverSearchAcpRegistry]: "server", diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 6a698c2e46ab..2dda30415b91 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -533,9 +533,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( // Asks T3 Connect to deliver webhooks it held while this environment was offline. HeldHooksWaker.layer, layerThreadSettlementWorker, - Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe( - Layer.provide(ProjectionStoreV2.layer), - ), + StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), layerThreadPullRequestWorker, Layer.effectDiscard( Effect.gen(function* () { diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index c66aee5302d6..4779d643ebe7 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,4 +1,24 @@ -import { describe, expect, it } from "vite-plus/test"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import * as Effect from "effect/Effect"; +import * as Context from "effect/Context"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import type * as Path from "effect/Path"; +import type * as Scope from "effect/Scope"; +import * as Stream from "effect/Stream"; +import * as SqlClient from "effect/sql/SqlClient"; +import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen } from "@t3tools/contracts"; +import * as ServerConfig from "./config.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; +import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; +import * as GitManager from "./git/GitManager.ts"; +import * as Settings from "./serverSettings.ts"; +import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; +import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; +import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; +import * as TerminalManager from "./terminal/Manager.ts"; +import { ServerActivation } from "./serverActivation.ts"; +import { describe, expect, it } from "@effect/vitest"; import { ProjectId, ProviderInstanceId, @@ -196,3 +216,303 @@ describe("merged pull request cleanup", () => { expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); }); }); + +const cleanupFixture = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const config = yield* ServerConfig.ServerConfig; + const git = yield* GitVcsDriver.GitVcsDriver; + const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); + const command = (cwd: string, args: string[]) => + git.execute({ operation: "cleanup-test", cwd, args }); + yield* command(repo, ["init", "-b", "main"]); + yield* command(repo, ["config", "user.email", "cleanup@example.test"]); + yield* command(repo, ["config", "user.name", "Cleanup test"]); + yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); + yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); + yield* command(repo, ["add", "."]); + yield* command(repo, ["commit", "-m", "initial"]); + yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); + const worktree = `${config.worktreesDir}/feature`; + yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); + let settings: import("@t3tools/contracts").ServerSettings = { + ...DEFAULT_SERVER_SETTINGS, + storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 8 }, + }; + let threads = [shell({ branch: "feature", worktreePath: worktree })]; + let editBeforeRemoval = false; + const context = yield* Layer.build(StorageCleanup.layer).pipe( + Effect.provideService(GitVcsDriver.GitVcsDriver, { + ...git, + execute: (input) => + Effect.gen(function* () { + if (editBeforeRemoval && input.args.includes("remove")) + yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); + return yield* git.execute(input); + }), + }), + Effect.provideService(Settings.ServerSettingsService, { + getSettings: Effect.sync(() => settings), + subscribeChanges: Effect.succeed(Stream.empty), + } as unknown as Settings.ServerSettingsService["Service"]), + Effect.provideService(ProjectStore.ProjectStoreV2, { + listShells: () => Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), + } as unknown as ProjectStore.ProjectStoreV2["Service"]), + Effect.provideService(ProjectionStore.ProjectionStoreV2, { + getShellSnapshot: (input?: { location?: string }) => + Effect.sync(() => ({ threads: input?.location === "archive" ? [] : threads })), + } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), + Effect.provideService(Orchestrator.OrchestratorV2, { + streamDomainEvents: Stream.empty, + } as unknown as Orchestrator.OrchestratorV2["Service"]), + Effect.provideService(SqlClient.SqlClient, (() => + Effect.succeed([])) as unknown as SqlClient.SqlClient), + Effect.provideService(GitManager.GitManager, { + invalidateStatus: () => Effect.void, + } as unknown as GitManager.GitManager["Service"]), + Effect.provideService(TerminalManager.TerminalManager, { + subscribeMetadata: () => Effect.succeed(() => {}), + } as unknown as TerminalManager.TerminalManager["Service"]), + Effect.provideService(ServerActivation, Effect.never), + ); + return { + service: Context.get(context, StorageCleanup.StorageCleanup), + editBeforeRemoval: () => { + editBeforeRemoval = true; + }, + fs, + worktree, + config, + command, + enableFiles: () => { + settings = { + ...settings, + storageCleanup: { + ...settings.storageCleanup, + browserArtifactsAfterDays: 8, + logsAfterDays: 8, + }, + }; + }, + setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { + settings = { ...settings, storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen } }; + }, + disable: () => { + settings = { + ...settings, + storageCleanup: { ...settings.storageCleanup, worktreeAfterDays: null }, + }; + }, + setThreads: (next: typeof threads) => { + threads = next; + }, + }; +}); +const cleanupTestLayer = GitVcsDriver.layer.pipe( + Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), + Layer.provideMerge(NodeServices.layer), +); +const runCleanupTest = ( + effect: Effect.Effect< + A, + E, + | FileSystem.FileSystem + | ServerConfig.ServerConfig + | GitVcsDriver.GitVcsDriver + | Path.Path + | Scope.Scope + >, +) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); + +describe("storage cleanup reports and local file policies", () => { + it.live.each([ + ["any-local-files", ".env", "kept"], + ["uncommitted-changes", ".env", "removed"], + ["tracked-changes", ".env", "removed"], + ["any-local-files", "notes.txt", "kept"], + ["uncommitted-changes", "notes.txt", "kept"], + ["tracked-changes", "notes.txt", "removed"], + ["any-local-files", "tracked.txt", "kept"], + ["uncommitted-changes", "tracked.txt", "kept"], + ["tracked-changes", "tracked.txt", "kept"], + ] as const)("applies %j", ([policy, file, outcome]) => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + setPolicy(policy); + expect(yield* service.latestReport).toBeNull(); + yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); + const report = yield* service.runNow; + yield* service.drain; + expect(report.trigger).toBe("manual"); + expect(report.entries.filter((entry) => entry.kind === "worktree")).toMatchObject([ + { outcome, path: worktree, threadId: "thread-1", threadTitle: "Thread" }, + ]); + expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); + expect(yield* service.latestReport).toEqual(report); + if (outcome === "kept") + expect(report.entries[0]?.reason).toContain( + file === ".env" ? "ignored files (.env)" : "uncommitted changes (1 file)", + ); + }), + ), + ); + it.live("preserves tracked edits written after the final status check", () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture; + fixture.setPolicy("tracked-changes"); + fixture.editBeforeRemoval(); + yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); + const report = yield* fixture.service.runNow; + expect(report.entries[0]?.outcome).toBe("failed"); + expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); + expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( + "late edit\n", + ); + expect(yield* fixture.fs.exists(`${fixture.worktree}/notes.txt`)).toBe(false); + }), + ), + ); + it.live("streams the current report and subsequent runs", () => + runCleanupTest( + Effect.gen(function* () { + const { service } = yield* cleanupFixture; + const reports = yield* service.reports.pipe( + Stream.take(2), + Stream.tap((report) => (report === null ? service.runNow : Effect.void)), + Stream.runCollect, + ); + expect(reports[0]).toBeNull(); + expect(reports[1]).toEqual(yield* service.latestReport); + expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); + }), + ), + ); + it.live("caps entries while preserving totals and prioritizing removals", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setThreads } = yield* cleanupFixture; + const threads = []; + for (let i = 0; i < 205; i++) { + const worktreePath = `${worktree}-${i}`; + yield* fs.makeDirectory(worktreePath); + threads.push( + shell({ + id: ThreadId.make(`thread-${i + 2}`), + branch: "feature", + worktreePath, + status: "running", + }), + ); + } + threads.push(shell({ branch: "feature", worktreePath: worktree })); + setThreads(threads); + const report = yield* service.runNow; + expect(report.entries).toHaveLength(200); + expect(report.omittedCount).toBe(6); + expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); + expect(report.entries[0]?.outcome).toBe("removed"); + }), + ), + ); + it.live("keeps staged edits in tracked-only mode", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture; + setPolicy("tracked-changes"); + yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n"); + yield* command(worktree, ["add", "tracked.txt"]); + expect((yield* service.runNow).entries[0]).toMatchObject({ + outcome: "kept", + reason: "Kept: has uncommitted changes (1 file)", + }); + expect(yield* fs.exists(worktree)).toBe(true); + }), + ), + ); + it.live( + "reports shared and running worktrees but omits disabled rules and missing directories", + () => + runCleanupTest( + Effect.gen(function* () { + const fixture = yield* cleanupFixture; + const { service, fs, worktree, setThreads } = fixture; + setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); + expect((yield* service.runNow).entries[0]?.reason).toContain("thread is running"); + setThreads([ + shell({ branch: "feature", worktreePath: worktree }), + shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), + ]); + expect((yield* service.runNow).entries[0]?.reason).toBe("Kept: shared by 2 threads"); + fixture.disable(); + expect((yield* service.runNow).entries).toEqual([]); + yield* fs.remove(worktree, { recursive: true }); + expect( + (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), + ).toEqual([]); + }), + ), + ); + it.live("serializes concurrent requests and returns each run's report", () => + runCleanupTest( + Effect.gen(function* () { + const { service } = yield* cleanupFixture; + const reports = yield* Effect.all([service.runNow, service.runNow], { + concurrency: "unbounded", + }); + yield* service.drain; + expect( + reports.flatMap((report) => report.entries).filter((entry) => entry.kind === "worktree"), + ).toMatchObject([{ outcome: "removed" }]); + expect(yield* service.latestReport).toEqual(reports[1]); + }), + ), + ); + it.live("aggregates expired artifacts and rotated logs while retaining current logs", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, config, enableFiles, disable } = yield* cleanupFixture; + disable(); + enableFiles(); + yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true }); + yield* fs.makeDirectory(config.logsDir, { recursive: true }); + for (const name of ["one.png", "two.webm"]) { + const file = config.browserArtifactsDir + "/" + name; + yield* fs.writeFileString(file, "capture"); + yield* fs.utimes(file, 1, 1); + } + for (const name of ["server.log.1", "server.log.2", "server.log"]) { + const file = config.logsDir + "/" + name; + yield* fs.writeFileString(file, "log"); + yield* fs.utimes(file, 1, 1); + } + const report = yield* service.runNow; + expect(report.entries.filter((entry) => entry.kind !== "worktree")).toMatchObject([ + { kind: "browser-artifacts", outcome: "removed", reason: "Removed 2 browser artifacts" }, + { kind: "logs", outcome: "removed", reason: "Removed 2 rotated logs" }, + ]); + expect(yield* fs.readDirectory(config.browserArtifactsDir)).toEqual([]); + expect(yield* fs.exists(config.logsDir + "/server.log")).toBe(true); + expect(yield* fs.exists(config.logsDir + "/server.log.1")).toBe(false); + expect(yield* fs.exists(config.logsDir + "/server.log.2")).toBe(false); + }), + ), + ); + it.live("reports git failures without aborting the remaining categories", () => + runCleanupTest( + Effect.gen(function* () { + const { service, worktree, command, enableFiles } = yield* cleanupFixture; + enableFiles(); + yield* command(worktree, ["worktree", "lock", worktree]); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "failed" }); + expect(report.entries[0]?.reason).toContain("Failed: git worktree remove: fatal:"); + expect(report.entries.map((entry) => entry.kind)).toEqual([ + "worktree", + "browser-artifacts", + "logs", + ]); + }), + ), + ); +}); diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d823e84e45dd..eea318e08911 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -1,4 +1,5 @@ import { + GitCommandError, OrchestrationV2AppThreadJson, OrchestrationV2ProviderSessionJson, } from "@t3tools/contracts"; @@ -11,10 +12,14 @@ import type { ServerSettingsError, TerminalSummary, WorktreeCleanupRules, + StorageCleanupReport, + StorageCleanupReportEntry, } from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; -import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Layer from "effect/Layer"; import * as Clock from "effect/Clock"; import * as DateTime from "effect/DateTime"; import * as Effect from "effect/Effect"; @@ -25,6 +30,7 @@ import * as Path from "effect/Path"; import type { PlatformError } from "effect/PlatformError"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; +import * as SubscriptionRef from "effect/SubscriptionRef"; import * as ServerConfig from "./config.ts"; import * as GitManager from "./git/GitManager.ts"; @@ -47,6 +53,14 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; +const REPORT_ENTRY_LIMIT = 200; +const isGitCommandError = Schema.is(GitCommandError); + +function cleanupFailureReason(error: { readonly message: string }) { + return isGitCommandError(error) + ? `${error.command}: ${error.detail.split(/\r?\n/)[0]}${error.reason ? ` (${error.reason})` : ""}` + : error.message.split(/\r?\n/)[0]; +} const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || @@ -137,7 +151,17 @@ export function storageCleanupPullRequestMerged( ); } -export const make = Effect.gen(function* () { +export class StorageCleanup extends Context.Service< + StorageCleanup, + { + readonly runNow: Effect.Effect; + readonly latestReport: Effect.Effect; + readonly reports: Stream.Stream; + readonly drain: Effect.Effect; + } +>()("t3/storageCleanup") {} + +const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; const settingsService = yield* Settings.ServerSettingsService; const projectStore = yield* ProjectStore.ProjectStoreV2; @@ -203,11 +227,74 @@ export const make = Effect.gen(function* () { return false; }); + const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( + cwd: string, + rules: WorktreeCleanupRules, + ) { + const status = yield* git.execute({ + operation: "StorageCleanup.localChanges", + cwd, + args: [ + "status", + "--porcelain=v1", + "-z", + "--ignore-submodules=none", + rules.worktreeKeepWhen === "tracked-changes" + ? "--untracked-files=no" + : "--untracked-files=all", + ], + maxOutputBytes: 64 * 1024, + }); + if (status.stdoutTruncated) return "local changes exceed the inspection limit"; + const records = status.stdout.split("\0").filter(Boolean); + let count = 0; + for (let i = 0; i < records.length; i++) { + count++; + if (/^[RC]|^.[RC]/.test(records[i]!)) i++; + } + if (count > 0) return `has uncommitted changes (${count} ${count === 1 ? "file" : "files"})`; + if (rules.worktreeKeepWhen !== "any-local-files") return null; + const ignored = yield* git.execute({ + operation: "StorageCleanup.ignoredFiles", + cwd, + args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], + maxOutputBytes: 64 * 1024, + }); + if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit"; + const file = ignored.stdout + .split("\0") + .find((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)); + return file === undefined ? null : `has ignored files (${file})`; + }); + + const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* ( + cwd: string, + args: string[], + command: string, + ) { + const result = yield* git.execute({ + operation: "StorageCleanup.cleanupGit", + cwd, + args, + allowNonZeroExit: true, + timeoutMs: 300_000, + }); + if (result.exitCode !== 0) + return yield* Effect.fail( + new GitCommandError({ + operation: "StorageCleanup.cleanupGit", + command, + cwd, + detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", + }), + ); + }); + const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( serverSettings: ServerSettings, now: number, + entries: StorageCleanupReportEntry[], ) { - if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; const roots: Array = []; for (const directory of managedWorktreesDirectories( serverSettings, @@ -221,26 +308,17 @@ export const make = Effect.gen(function* () { ); if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); } - if (roots.length === 0) return; - const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete); - const deletedRows = hasDeleteRule - ? yield* sql<{ payload_json: string; workspaceRoot: string }>` + const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` SELECT t.payload_json, p.workspace_root AS "workspaceRoot" FROM orchestration_v2_projection_threads t JOIN projection_projects p ON p.project_id = t.project_id WHERE t.deleted_at IS NOT NULL - ` - : []; + `; const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => decodeCleanupThread(row.payload_json).pipe( Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), ), - )).filter( - (thread) => - thread.worktreePath !== null && - thread.branch !== null && - resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete, - ); + )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); const snapshot = yield* readThreads(); const refreshedDefaultRefs = new Map>(); const groups = Map.groupBy( @@ -248,7 +326,7 @@ export const make = Effect.gen(function* () { (thread) => path.resolve(thread.worktreePath!), ); const candidates = [ - ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])), + ...[...groups.values()].map((group) => group[0]!), ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), ]; for (const thread of candidates) { @@ -259,52 +337,56 @@ export const make = Effect.gen(function* () { const project = deleted ? { workspaceRoot: thread.workspaceRoot } : snapshot.projects.find((entry) => entry.id === thread.projectId); - if ( - project === undefined || - (!deleted && !storageCleanupThreadIdle(thread, now)) || - hasTerminal(worktreePath) - ) - continue; + const entry: StorageCleanupReportEntry = { + kind: "worktree", + outcome: "kept", + reason: "", + path: worktreePath, + threadId: thread.id, + threadTitle: thread.title, + }; + const keep = (reason: string) => entries.push({ ...entry, reason: `Kept: ${reason}` }); yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; + if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); + const shared = groups.get(worktreePath)?.length ?? 0; + if (shared > 1) return keep(`shared by ${shared} threads`); + if (project === undefined) return keep("project is unavailable"); + if (!deleted && !storageCleanupThreadIdle(thread, now)) + return keep("thread is running or has pending work"); + if (hasTerminal(worktreePath)) return keep("open terminal"); // Roots are canonical, so compare canonical paths. A symlinked parent // (a linked drive) is fine; a symlinked worktree directory is not. const realPath = yield* fs.realPath(worktreePath); const realParent = yield* fs.realPath(path.dirname(worktreePath)); - if (realPath !== path.join(realParent, path.basename(worktreePath))) return; - if (!roots.some((root) => inside(root, realPath))) return; - if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; + if (realPath !== path.join(realParent, path.basename(worktreePath))) + return keep("worktree is a symbolic link"); + if (!roots.some((root) => inside(root, realPath))) + return keep("outside the managed worktree folder"); + if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) + return keep("contains a project checkout"); // A linked worktree has a .git file. Never remove a main checkout. - if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return; + if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") + return keep("not a linked worktree"); const status = yield* git.statusDetailsLocal(worktreePath); - if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges) - return; + if (!status.isRepo || status.branch !== thread.branch) + return keep("repository or branch changed"); + const changes = yield* localChangesReason(worktreePath, settings); + if (changes !== null) return keep(changes); const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); - const ignored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - // Ignored files can contain secrets or local datasets. Dependency installs - // are reproducible; every other ignored path prevents automatic removal. - if ( - ignored.stdoutTruncated || - ignored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) - ) - return; const old = !deleted && settings.worktreeAfterDays !== null && storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; let eligible = deleted || old; + let removalReason = deleted + ? "thread was deleted" + : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`; if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { const repositoryCwd = path.resolve(project.workspaceRoot); const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); - if (branch === null) return; + if (branch === null) return keep("default branch is unavailable"); const defaultRef = `refs/remotes/${remote}/${branch}`; const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set(); if (!refreshed.has(defaultRef)) { @@ -328,6 +410,7 @@ export const make = Effect.gen(function* () { }); const integrated = ancestor.exitCode === 0; eligible = integrated && settings.worktreeUnchanged; + if (eligible) removalReason = "no commits beyond the default branch"; if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { const pullRequest = yield* gitManager.branchPullRequest( { cwd: worktreePath, branch: thread.branch }, @@ -339,39 +422,48 @@ export const make = Effect.gen(function* () { headSha: head.commitSha, integrated, }); + if (eligible) removalReason = "pull request was merged"; } } - if (!eligible) return; + if (!eligible) + return keep( + !deleted && settings.worktreeAfterDays !== null + ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days` + : settings.worktreeOnMerge + ? "not merged" + : "has commits beyond the default branch", + ); // Re-read after Git/host calls so a queued turn, resumed session or new // thread sharing this path cancels the removal. const latestSnapshot = yield* readThreads(); - if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) return; + if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) + return keep("contains a project checkout"); const latest = latestSnapshot.threads.filter( (entry) => entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, ); - if (hasTerminal(worktreePath)) return; + if (hasTerminal(worktreePath)) return keep("open terminal"); if (deleted) { if ( latest.length > 0 || !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) .worktreeOnDelete ) - return; + return keep("thread or cleanup settings changed since check"); // V2 deletion queues durable cleanup. Do not remove its checkout until // every effect has finished successfully or was explicitly cancelled. const pendingCleanup = yield* sql` SELECT 1 FROM orchestration_v2_effect_outbox WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 `; - if (pendingCleanup.length > 0) return; + if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); } else if ( latest.length !== 1 || latest[0]!.id !== thread.id || !storageCleanupThreadIdle(latest[0]!, now) || storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) ) - return; + return keep("thread activity or shared worktree changed since check"); // Sessions can outlive their run and can be shared across app threads. const sessionRows = yield* sql<{ payload_json: string }>` SELECT payload_json FROM orchestration_v2_projection_provider_sessions @@ -386,32 +478,17 @@ export const make = Effect.gen(function* () { return cwd === worktreePath || inside(worktreePath, cwd); }) ) - return; + return keep("provider session is still open"); const finalStatus = yield* git.statusDetailsLocal(worktreePath); - if ( - !finalStatus.isRepo || - finalStatus.branch !== thread.branch || - finalStatus.hasWorkingTreeChanges - ) - return; + if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) + return keep("repository or branch changed since check"); if ( (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== head.commitSha ) - return; - const finalIgnored = yield* git.execute({ - operation: "StorageCleanup.ignoredFiles", - cwd: worktreePath, - args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], - maxOutputBytes: 64 * 1024, - }); - if ( - finalIgnored.stdoutTruncated || - finalIgnored.stdout - .split("\0") - .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) - ) - return; + return keep("commit changed since check"); + const finalChanges = yield* localChangesReason(worktreePath, settings); + if (finalChanges !== null) return keep(finalChanges); const current = resolveWorktreeCleanup( yield* settingsService.getSettings, thread.projectId, @@ -422,8 +499,17 @@ export const make = Effect.gen(function* () { current[key as keyof typeof settings] !== settings[key as keyof typeof settings], ) ) - return; - yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); + return keep("settings changed since check"); + // Clean only untracked files; Git must still refuse removal if a tracked + // edit arrives after our last status check. + if (settings.worktreeKeepWhen === "tracked-changes") + yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); + yield* cleanupGit( + project.workspaceRoot, + ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath], + "git worktree remove", + ); + entries.push({ ...entry, outcome: "removed", reason: `Removed: ${removalReason}` }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. @@ -431,7 +517,17 @@ export const make = Effect.gen(function* () { }).pipe( (effect) => withWorkspaceLease(worktreePath, effect), Effect.catch((error) => - Effect.logDebug("storage cleanup skipped worktree", { threadId: thread.id, error }), + Effect.gen(function* () { + entries.push({ + ...entry, + outcome: "failed", + reason: `Failed: ${cleanupFailureReason(error)}`, + }); + yield* Effect.logWarning("storage cleanup failed for worktree", { + threadId: thread.id, + error, + }); + }), ), ); } @@ -443,9 +539,10 @@ export const make = Effect.gen(function* () { now: number, rotatedLogs: boolean, ) { - if (days === null || !(yield* fs.exists(root))) return; + if (days === null || !(yield* fs.exists(root))) return 0; + let removed = 0; const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return; + if (realRoot !== path.resolve(root)) return 0; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -462,40 +559,116 @@ export const make = Effect.gen(function* () { if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) return; yield* fs.remove(target); + removed++; } } } }); yield* visit(realRoot); + return removed; }); - const sweep = Effect.fn("StorageCleanup.sweep")(function* () { + const reportRef = yield* SubscriptionRef.make(null); + const sweep = Effect.fn("StorageCleanup.sweep")(function* ( + trigger: StorageCleanupReport["trigger"], + ) { + const now = yield* Clock.currentTimeMillis; const serverSettings = yield* settingsService.getSettings; const settings = serverSettings.storageCleanup; - const now = yield* Clock.currentTimeMillis; - yield* cleanWorktrees(serverSettings, now).pipe( - Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })), - ); - yield* cleanFiles( - config.browserArtifactsDir, - settings.browserArtifactsAfterDays, - now, - false, - ).pipe( - Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })), - ); - yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe( - Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })), + const entries: StorageCleanupReportEntry[] = []; + yield* cleanWorktrees(serverSettings, now, entries).pipe( + Effect.catch((error) => + Effect.gen(function* () { + entries.push({ + kind: "worktree", + outcome: "failed", + reason: `Failed: ${cleanupFailureReason(error)}`, + path: null, + threadId: null, + threadTitle: null, + }); + yield* Effect.logWarning("worktree cleanup failed", { error }); + }), + ), ); + for (const category of [ + { + kind: "browser-artifacts" as const, + root: config.browserArtifactsDir, + days: settings.browserArtifactsAfterDays, + label: "browser artifacts", + }, + { + kind: "logs" as const, + root: config.logsDir, + days: settings.logsAfterDays, + label: "rotated logs", + }, + ]) { + if (category.days === null) continue; + yield* cleanFiles(category.root, category.days, now, category.kind === "logs").pipe( + Effect.map((count) => + entries.push({ + kind: category.kind, + outcome: count > 0 ? "removed" : "kept", + reason: count === 0 ? "Kept: no expired files" : `Removed ${count} ${category.label}`, + path: null, + threadId: null, + threadTitle: null, + }), + ), + Effect.catch((error) => + Effect.gen(function* () { + entries.push({ + kind: category.kind, + outcome: "failed", + reason: `Failed: ${cleanupFailureReason(error)}`, + path: null, + threadId: null, + threadTitle: null, + }); + yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error }); + }), + ), + ); + } + const counts = { removed: 0, kept: 0, failed: 0 }; + for (const entry of entries) counts[entry.outcome]++; + const priority = { failed: 0, removed: 1, kept: 2 }; + const latestReport: StorageCleanupReport = { + trigger, + startedAt: DateTime.formatIso(DateTime.makeUnsafe(now)), + finishedAt: DateTime.formatIso(yield* DateTime.now), + entries: entries + .sort((a, b) => priority[a.outcome] - priority[b.outcome]) + .slice(0, REPORT_ENTRY_LIMIT), + counts, + omittedCount: Math.max(0, entries.length - REPORT_ENTRY_LIMIT), + }; + yield* SubscriptionRef.set(reportRef, latestReport); + return latestReport; }); - const worker = yield* makeDrainableWorker(() => - sweep().pipe( - Effect.catchCauseIf( - (cause) => !Cause.hasInterruptsOnly(cause), - (cause) => Effect.logWarning("storage cleanup failed", { cause }), + const worker = yield* makeDrainableWorker( + (completion: Deferred.Deferred | undefined) => + sweep(completion === undefined ? "automatic" : "manual").pipe( + Effect.exit, + Effect.flatMap((exit) => + completion === undefined + ? exit.pipe( + Effect.asVoid, + Effect.catchCause((cause) => + Effect.logWarning("storage cleanup failed", { cause }), + ), + ) + : Deferred.done(completion, exit).pipe(Effect.asVoid), + ), ), - ), ); + const runNow = Effect.gen(function* () { + const completion = yield* Deferred.make(); + yield* worker.enqueue(completion); + return yield* Deferred.await(completion); + }); const start = Effect.fn("StorageCleanup.start")(function* () { const unsubscribe = yield* terminals.subscribeMetadata((event) => @@ -550,5 +723,13 @@ export const make = Effect.gen(function* () { ), ); }); - return { start, drain: worker.drain }; + yield* start(); + return StorageCleanup.of({ + runNow, + latestReport: SubscriptionRef.get(reportRef), + reports: SubscriptionRef.changes(reportRef), + drain: worker.drain, + }); }); + +export const layer = Layer.effect(StorageCleanup, make); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 1b045f7e963f..e014e0033131 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -176,6 +176,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; import * as ServerSettings from "./serverSettings.ts"; +import * as StorageCleanup from "./storageCleanup.ts"; import * as TerminalManager from "./terminal/Manager.ts"; import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; @@ -1282,6 +1283,7 @@ const layerWsRpc = ( const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; const config = yield* ServerConfig.ServerConfig; const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; + const storageCleanup = yield* StorageCleanup.StorageCleanup; const serverSettings = yield* ServerSettings.ServerSettingsService; const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; @@ -2386,6 +2388,8 @@ const layerWsRpc = ( const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); return { keybindings: keybindingsConfig, issues: [] }; }), + [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, + [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, [WS_METHODS.serverGetSettings]: (_input) => serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 0106acc7cebe..29f5568269d4 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -1,6 +1,23 @@ -import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; +import type { + StorageCleanupReport, + StorageCleanupSettings, + WorktreeCleanupRules, +} from "@t3tools/contracts"; import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; -import { useRef, useState } from "react"; +import { useMemo, useRef, useState } from "react"; +import { useAtomValue } from "@effect/atom-react"; +import { Atom } from "effect/reactivity"; +import { CheckIcon, CircleAlertIcon, MinusIcon } from "lucide-react"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { serverEnvironment } from "../../state/server"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { Button } from "../ui/button"; +import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { toastManager } from "../ui/toast"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -144,6 +161,199 @@ function RetentionControl({ ); } +const KEEP_WHEN_LABELS = { + "any-local-files": "Any local files", + "uncommitted-changes": "Uncommitted changes", + "tracked-changes": "Edited tracked files", +} as const; + +function cleanupSummary(report: StorageCleanupReport | null) { + if (report === null) return "Hasn't run yet"; + return `Last ${report.trigger} run ${formatRelativeTimeLabel(report.finishedAt)}: removed ${report.counts.removed}, kept ${report.counts.kept}, ${report.counts.failed} failed`; +} + +function CleanupSection() { + const { connectedEnvironments } = useSettingsScope(); + const [pending, setPending] = useState(false); + const run = useAtomCommand(serverEnvironment.runStorageCleanup); + const supported = connectedEnvironments.filter( + (environment) => environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ); + const state = useAtomValue( + useMemo( + () => + Atom.make((get) => + connectedEnvironments.flatMap((environment) => { + if (environment.serverConfig?.environment.capabilities.storageCleanupRun !== true) + return []; + const query = get( + serverEnvironment.storageCleanupReport({ + environmentId: environment.environmentId, + input: {}, + }), + ); + return [ + { + environmentId: environment.environmentId, + allowed: get( + serverEnvironment.runStorageCleanup.permissionAtom(environment.environmentId), + ), + report: query._tag === "Success" ? query.value : null, + loading: query.waiting, + failed: query._tag === "Failure", + }, + ]; + }), + ), + [connectedEnvironments], + ), + ); + const hasRules = supported.some((environment) => { + const settings = environment.serverConfig?.settings; + if (!settings) return false; + const enabled = (rules: WorktreeCleanupRules) => + rules.worktreeAfterDays !== null || + rules.worktreeOnMerge || + rules.worktreeOnDelete || + rules.worktreeUnchanged; + return ( + settings.storageCleanup.browserArtifactsAfterDays !== null || + settings.storageCleanup.logsAfterDays !== null || + enabled(resolveWorktreeCleanup(settings, null)) || + Object.values(settings.projectSettingsOverrides).some( + (override) => + override.worktreeCleanup?.mode === "custom" && enabled(override.worktreeCleanup.rules), + ) + ); + }); + if (supported.length === 0) return null; + const summaries = supported.map((environment) => { + const query = state.find((entry) => entry.environmentId === environment.environmentId); + const report = query?.report ?? null; + return { + environment, + report, + description: report + ? cleanupSummary(report) + : query?.failed + ? "Could not load the last run" + : query?.loading + ? "Loading last run…" + : "Hasn't run yet", + }; + }); + const deleteNow = async () => { + if (pending) return; + setPending(true); + try { + await Promise.all( + supported.map(async (environment) => { + const result = await run({ environmentId: environment.environmentId, input: {} }); + if (result._tag === "Success") { + const failures = result.value.entries.filter((entry) => entry.outcome === "failed"); + if (failures.length > 0) + toastManager.add({ + type: "error", + title: `Cleanup failed on ${environment.label}`, + description: failures[0]!.reason, + }); + } else if (!isAtomCommandInterrupted(result)) { + toastManager.add({ + type: "error", + title: `Could not run cleanup on ${environment.label}`, + description: String(squashAtomCommandFailure(result)), + }); + } + }), + ); + } finally { + setPending(false); + } + }; + return ( + + + {summaries.map(({ environment, description }) => ( + + {supported.length > 1 ? `${environment.label}: ` : ""} + {description} + + ))} + + } + control={ + + } + /> +
+ {summaries.map(({ environment, report }) => + report && report.entries.length > 0 ? ( +
    + {report.entries.map((entry, index) => { + const Icon = + entry.outcome === "failed" + ? CircleAlertIcon + : entry.outcome === "removed" + ? CheckIcon + : MinusIcon; + const name = + entry.kind === "worktree" + ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") + : entry.kind === "logs" + ? "Rotated logs" + : "Browser artifacts"; + return ( +
  • +
  • + ); + })} + {report.omittedCount > 0 && ( +
  • and {report.omittedCount} more
  • + )} +
+ ) : null, + )} +
+
+ ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -263,7 +473,7 @@ export function StorageSettingsPanel() { } /> + {connectedEnvironments.every( + (environment) => + environment.serverConfig?.environment.capabilities.storageCleanupRun === true, + ) && ( + { + if ( + next === "any-local-files" || + next === "uncommitted-changes" || + next === "tracked-changes" + ) + updateWorktree({ worktreeKeepWhen: next }); + }} + > + + + {ruleStatus("worktreeKeepWhen") + ? "Mixed" + : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} + + + + Any local files + Uncommitted changes + Edited tracked files + + + } + /> + )} )} @@ -346,6 +599,7 @@ export function StorageSettingsPanel() { /> )} + {!isProjectScope && } ); } diff --git a/apps/web/src/components/settings/scopedSettings.test.ts b/apps/web/src/components/settings/scopedSettings.test.ts index 800ff48360a9..a47a4bf00241 100644 --- a/apps/web/src/components/settings/scopedSettings.test.ts +++ b/apps/web/src/components/settings/scopedSettings.test.ts @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, { worktreeAfterDays: null, worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, ]); }); @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: false, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { worktreeOnDelete: true, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }, }, }, diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 853886d81fcd..aff52adeb155 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right * that may not be mounted point at their nearest stable section instead. */ export const SETTINGS_SEARCH_ITEMS = [ + { + id: "storage-worktree-keep-when", + targetId: "storage-worktrees", + title: "Keep worktrees with local changes", + to: "/settings/storage", + scope: "project-defaults", + searchTerms: [ + "any local files uncommitted changes edited tracked files ignored env build output cleanup", + ], + }, + { + id: "storage-delete-now", + title: "Delete now", + to: "/settings/storage", + scope: "environment-defaults", + searchTerms: ["storage cleanup run now results report removed kept failed"], + }, { id: "storage-worktrees", title: "Worktree cleanup", diff --git a/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e..c92226a9ee4f 100644 --- a/docs/user/project-settings.md +++ b/docs/user/project-settings.md @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. Worktrees can be removed after a chosen number of inactive days, after merging, or when they have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active -sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` -prevent removal. Branches and thread history stay; starting another turn recreates the checkout. +sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults +to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored +files such as `.env` and build output. **Any local files** also protects ignored files other than +`node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and +ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. Merge cleanup requires a merged pull request whose commits are included in the remote default branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default branch and the worktree is still at the pull request's last commit. @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele server waits for sessions and terminals to stop and retries skipped worktrees after restart. Existing prompts for deleting a worktree manually remain available when this policy is off. +Choose **Delete now** to run the enabled rules immediately. The latest results show which +worktrees were removed or kept and why, plus any failures. + Browser captures and rotated logs have separate retention periods. Expired capture links stop working. Current logs, message attachments, and browser profiles are kept. diff --git a/packages/client-runtime/src/rpc/client.ts b/packages/client-runtime/src/rpc/client.ts index 35a957e9d0f2..c11a49897b88 100644 --- a/packages/client-runtime/src/rpc/client.ts +++ b/packages/client-runtime/src/rpc/client.ts @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | typeof WS_METHODS.subscribeServerConfig | typeof WS_METHODS.subscribeServerLifecycle | typeof WS_METHODS.scheduledTasksSubscribe + | typeof WS_METHODS.serverGetStorageCleanupReport | typeof WS_METHODS.subscribeTerminalEvents | typeof WS_METHODS.subscribeTerminalMetadata | typeof WS_METHODS.subscribePreviewEvents diff --git a/packages/client-runtime/src/state/commandPermissions.test.ts b/packages/client-runtime/src/state/commandPermissions.test.ts index b9d1ebf6b738..b2c6933fabb7 100644 --- a/packages/client-runtime/src/state/commandPermissions.test.ts +++ b/packages/client-runtime/src/state/commandPermissions.test.ts @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; import { vi } from "vite-plus/test"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, ThreadId, EnvironmentId, @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { }); describe("command permissions", () => { + it.effect("requires the destination settings grant to run storage cleanup", () => + Effect.scoped( + Effect.gen(function* () { + const registry = yield* setup; + const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); + registry.set(sessions(env), AsyncResult.success(grant(true))); + expect(registry.get(cleanup.permissionAtom(env))).toBe(false); + expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( + "EnvironmentAuthorizationError", + ); + registry.set( + sessions(env), + AsyncResult.success({ + ...grant(true), + scopes: [AuthSettingsWriteScope], + permissions: [AuthSettingsWriteScope], + }), + ); + expect(registry.get(cleanup.permissionAtom(env))).toBe(true); + yield* cleanup.authorize(registry, env); + registry.set(sessions(other), AsyncResult.success(grant(false))); + expect(registry.get(cleanup.permissionAtom(other))).toBe(false); + }), + ), + ); + it.effect("uses the target grant for both availability and execution", () => Effect.scoped( Effect.gen(function* () { diff --git a/packages/client-runtime/src/state/server.ts b/packages/client-runtime/src/state/server.ts index 91464b16a9b1..1d49fd71f70c 100644 --- a/packages/client-runtime/src/state/server.ts +++ b/packages/client-runtime/src/state/server.ts @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms( return { configValueAtom, + runStorageCleanup: createEnvironmentRpcCommand(runtime, { + label: "environment-data:server:run-storage-cleanup", + tag: WS_METHODS.serverRunStorageCleanup, + concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, + }), + storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { + label: "environment-data:server:storage-cleanup-report", + tag: WS_METHODS.serverGetStorageCleanupReport, + idleTtlMs: 0, + }), updateStateAtom, settingsValueAtom, providersValueAtom, diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 9605829e47d7..c84169e6e33f 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; import { GitPreparePullRequestThreadInput } from "./git.ts"; import { AuthOrchestrationOperateScope, + AuthSettingsWriteScope, AuthSourceControlWriteScope, type AuthEnvironmentScope, } from "./auth.ts"; @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; /** Incremental client enforcement; the server still authorizes every request. */ export const CLIENT_GUARDED_RPC_SCOPES = { + [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index c9ac7482d4cc..2a61480a907b 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ /** Server evaluates merge and inactivity settlement without a client. */ threadAutoSettlement: Schema.optionalKey(Schema.Boolean), storageCleanup: Schema.optionalKey(Schema.Boolean), + storageCleanupRun: Schema.optionalKey(Schema.Boolean), projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), /** Server honors the `worktreesDirectory` setting. */ worktreesDirectory: Schema.optionalKey(Schema.Boolean), diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index ed14e2e16b7c..36afee2f9efa 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -317,7 +317,12 @@ import { ProviderConsumeResetCreditResult, } from "./providerUsageLimits.ts"; import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; -import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; +import { + StorageCleanupReport, + ServerSettings, + ServerSettingsError, + ServerSettingsPatch, +} from "./settings.ts"; import { ScheduledTaskDeleteInput, ScheduledTaskDeleteResult, @@ -468,6 +473,8 @@ export const WS_METHODS = { serverCommitDesktopUpdate: "server.commitDesktopUpdate", serverUpsertKeybinding: "server.upsertKeybinding", serverRemoveKeybinding: "server.removeKeybinding", + serverRunStorageCleanup: "server.runStorageCleanup", + serverGetStorageCleanupReport: "server.getStorageCleanupReport", serverGetSettings: "server.getSettings", serverUpdateSettings: "server.updateSettings", serverDiscoverSourceControl: "server.discoverSourceControl", @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), }); +const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { + payload: Schema.Struct({}), + success: StorageCleanupReport, + error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), +}); +const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { + payload: Schema.Struct({}), + success: Schema.NullOr(StorageCleanupReport), + stream: true, + error: EnvironmentAuthorizationError, +}); + const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { payload: Schema.Struct({}), success: ServerSettings, @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerCommitDesktopUpdateRpc, WsServerUpsertKeybindingRpc, WsServerRemoveKeybindingRpc, + WsServerRunStorageCleanupRpc, + WsServerGetStorageCleanupReportRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, WsServerDiscoverSourceControlRpc, diff --git a/packages/contracts/src/settings.test.ts b/packages/contracts/src/settings.test.ts index 58e8fa5d9658..b5d39323a23d 100644 --- a/packages/contracts/src/settings.test.ts +++ b/packages/contracts/src/settings.test.ts @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: null, }); @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { ).toThrow(); }); + it("decodes older custom rules with the default local-file policy", () => { + const settings = decodeServerSettings({ + worktreeCleanup: { + mode: "custom", + rules: { + worktreeAfterDays: 8, + worktreeOnMerge: false, + worktreeOnDelete: false, + worktreeUnchanged: false, + }, + }, + }); + expect(settings.worktreeCleanup).toMatchObject({ + rules: { worktreeKeepWhen: "uncommitted-changes" }, + }); + expect( + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), + ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); + expect(() => + decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), + ).toThrow(); + }); + it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { expect(() => decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 0e8027b9f5fd..5ed3b411d6c5 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -13,6 +13,7 @@ import { ForwardCompatibleOptional, OmittedWhenNull, ProjectId, + ThreadId, TrimmedNonEmptyString, TrimmedString, } from "./baseSchemas.ts"; @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), ); +export const WorktreeKeepWhen = Schema.Literals([ + "any-local-files", + "uncommitted-changes", + "tracked-changes", +]); +export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; + export const WorktreeCleanupRules = Schema.Struct({ + worktreeKeepWhen: WorktreeKeepWhen.pipe( + Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), + ), worktreeAfterDays: StorageRetentionDays, worktreeOnMerge: Schema.Boolean, worktreeOnDelete: Schema.Boolean, @@ -1034,7 +1045,29 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet { worktreeOnDelete: false, worktreeOnMerge: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); const custom = applyServerSettingsPatch(off, { @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { worktreeOnDelete: true, worktreeOnMerge: true, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", }); expect( resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) diff --git a/packages/shared/src/projectSettings.ts b/packages/shared/src/projectSettings.ts index f99dacbbb69d..af9a7112c3ea 100644 --- a/packages/shared/src/projectSettings.ts +++ b/packages/shared/src/projectSettings.ts @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( if (policy?.mode === "custom") return policy.rules; if (policy?.mode === "off") return { + worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, worktreeAfterDays: null, worktreeOnMerge: false, worktreeOnDelete: false, worktreeUnchanged: false, }; - const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = - settings.storageCleanup; - return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; + const { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + } = settings.storageCleanup; + return { + worktreeKeepWhen, + worktreeAfterDays, + worktreeOnMerge, + worktreeOnDelete, + worktreeUnchanged, + }; } diff --git a/packages/shared/src/serverSettings.test.ts b/packages/shared/src/serverSettings.test.ts index f7305812e74f..dbc2704171f9 100644 --- a/packages/shared/src/serverSettings.test.ts +++ b/packages/shared/src/serverSettings.test.ts @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { worktreeOnMerge: true, worktreeOnDelete: false, worktreeUnchanged: false, + worktreeKeepWhen: "uncommitted-changes", browserArtifactsAfterDays: null, logsAfterDays: 30, }); diff --git a/packages/shared/src/serverSettings.ts b/packages/shared/src/serverSettings.ts index 99b0f2c336c1..909eeb0a35ce 100644 --- a/packages/shared/src/serverSettings.ts +++ b/packages/shared/src/serverSettings.ts @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( ? { mode: "custom" as const, rules: { + worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, worktreeAfterDays: next.storageCleanup.worktreeAfterDays, worktreeOnMerge: next.storageCleanup.worktreeOnMerge, worktreeOnDelete: next.storageCleanup.worktreeOnDelete, From 6659d54b731e35d0ba5d3faa0371b884f0a65c1f Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:06:37 +0000 Subject: [PATCH 2/9] feat(storage): show space recovered in the cleanup card Cleanup reports now carry the size of each removed worktree and of expired artifacts and logs. The card leads with space recovered and groups results by outcome with per-row sizes. --- apps/server/src/auth/RpcAuthorization.test.ts | 1 + apps/server/src/storageCleanup.test.ts | 113 ++++++++- apps/server/src/storageCleanup.ts | 86 ++++++- .../settings/ResourceTelemetryDiagnostics.tsx | 13 +- .../components/settings/StorageSettings.tsx | 239 ++++++++++++------ apps/web/src/lib/formatBytes.ts | 11 + packages/contracts/src/settings.ts | 3 + 7 files changed, 349 insertions(+), 117 deletions(-) create mode 100644 apps/web/src/lib/formatBytes.ts diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 3009e53a7c5f..d46609edce77 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -430,6 +430,7 @@ it.effect("denies manual cleanup before the handler without settings permission" entries: [], counts: { removed: 0, kept: 0, failed: 0 }, omittedCount: 0, + bytesFreed: 0, }; }), ), diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 4779d643ebe7..095d655e1c2f 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -6,8 +6,13 @@ import * as Layer from "effect/Layer"; import type * as Path from "effect/Path"; import type * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; +import * as Schema from "effect/Schema"; import * as SqlClient from "effect/sql/SqlClient"; -import { DEFAULT_SERVER_SETTINGS, type WorktreeKeepWhen } from "@t3tools/contracts"; +import { + DEFAULT_SERVER_SETTINGS, + StorageCleanupReport, + type WorktreeKeepWhen, +} from "@t3tools/contracts"; import * as ServerConfig from "./config.ts"; import * as StorageCleanup from "./storageCleanup.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; @@ -36,6 +41,7 @@ import { const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); const DAY_MS = 24 * 60 * 60 * 1_000; +const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); function at(offsetMs: number): DateTime.Utc { return DateTime.makeUnsafe(NOW_MS + offsetMs); @@ -324,6 +330,67 @@ const runCleanupTest = ( ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); describe("storage cleanup reports and local file policies", () => { + it.live("measures nested regular files without following symlinks", () => + runCleanupTest( + Effect.gen(function* () { + const { service, fs, worktree, setPolicy } = yield* cleanupFixture; + setPolicy("tracked-changes"); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => + fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + ); + const outside = yield* fs.makeTempDirectoryScoped(); + yield* fs.writeFileString(`${outside}/external`, "must not count"); + yield* fs.makeDirectory(`${worktree}/node_modules/nested`, { recursive: true }); + yield* fs.writeFileString(`${worktree}/node_modules/nested/file`, "count me"); + yield* fs.symlink(outside, `${worktree}/node_modules/directory-link`); + yield* fs.symlink(`${outside}/external`, `${worktree}/node_modules/file-link`); + yield* fs.symlink(`${outside}/missing`, `${worktree}/node_modules/dangling-link`); + const report = yield* service.runNow; + const bytes = sizes.reduce((sum, size) => sum + size, 8); + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes, files: null }); + expect(report.bytesFreed).toBe(bytes); + expect(yield* fs.readFileString(`${outside}/external`)).toBe("must not count"); + }), + ), + ); + it.live("still removes a worktree when measuring its size fails", () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const { service, worktree } = yield* cleanupFixture.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`), + }), + ); + const report = yield* service.runNow; + expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes: null, files: null }); + expect(report.bytesFreed).toBe(0); + expect(yield* fs.exists(worktree)).toBe(false); + }), + ), + ); + it("decodes reports from servers without size fields", () => { + const report = decodeCleanupReport({ + trigger: "manual", + startedAt: "2026-06-10T12:00:00.000Z", + finishedAt: "2026-06-10T12:00:01.000Z", + entries: [ + { + kind: "worktree", + outcome: "removed", + reason: "Removed", + path: null, + threadId: null, + threadTitle: null, + }, + ], + counts: { removed: 1, kept: 0, failed: 0 }, + omittedCount: 0, + }); + expect(report.bytesFreed).toBe(0); + expect(report.entries[0]).toMatchObject({ bytes: null, files: null }); + }); it.live.each([ ["any-local-files", ".env", "kept"], ["uncommitted-changes", ".env", "removed"], @@ -341,12 +408,24 @@ describe("storage cleanup reports and local file policies", () => { setPolicy(policy); expect(yield* service.latestReport).toBeNull(); yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); + const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => + fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), + ); + const bytes = sizes.reduce((sum, size) => sum + size, 0); const report = yield* service.runNow; yield* service.drain; expect(report.trigger).toBe("manual"); expect(report.entries.filter((entry) => entry.kind === "worktree")).toMatchObject([ - { outcome, path: worktree, threadId: "thread-1", threadTitle: "Thread" }, + { + outcome, + path: worktree, + threadId: "thread-1", + threadTitle: "Thread", + bytes: outcome === "removed" ? bytes : null, + files: null, + }, ]); + expect(report.bytesFreed).toBe(outcome === "removed" ? bytes : 0); expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); expect(yield* service.latestReport).toEqual(report); if (outcome === "kept") @@ -364,7 +443,8 @@ describe("storage cleanup reports and local file policies", () => { fixture.editBeforeRemoval(); yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); const report = yield* fixture.service.runNow; - expect(report.entries[0]?.outcome).toBe("failed"); + expect(report.entries[0]).toMatchObject({ outcome: "failed", bytes: null, files: null }); + expect(report.bytesFreed).toBe(0); expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( "late edit\n", @@ -412,6 +492,8 @@ describe("storage cleanup reports and local file policies", () => { expect(report.omittedCount).toBe(6); expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); expect(report.entries[0]?.outcome).toBe("removed"); + expect(report.entries[0]?.bytes).toBeGreaterThan(0); + expect(report.bytesFreed).toBe(report.entries[0]?.bytes); }), ), ); @@ -424,7 +506,7 @@ describe("storage cleanup reports and local file policies", () => { yield* command(worktree, ["add", "tracked.txt"]); expect((yield* service.runNow).entries[0]).toMatchObject({ outcome: "kept", - reason: "Kept: has uncommitted changes (1 file)", + reason: "Has uncommitted changes (1 file)", }); expect(yield* fs.exists(worktree)).toBe(true); }), @@ -438,12 +520,12 @@ describe("storage cleanup reports and local file policies", () => { const fixture = yield* cleanupFixture; const { service, fs, worktree, setThreads } = fixture; setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); - expect((yield* service.runNow).entries[0]?.reason).toContain("thread is running"); + expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); setThreads([ shell({ branch: "feature", worktreePath: worktree }), shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), ]); - expect((yield* service.runNow).entries[0]?.reason).toBe("Kept: shared by 2 threads"); + expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); fixture.disable(); expect((yield* service.runNow).entries).toEqual([]); yield* fs.remove(worktree, { recursive: true }); @@ -488,9 +570,22 @@ describe("storage cleanup reports and local file policies", () => { } const report = yield* service.runNow; expect(report.entries.filter((entry) => entry.kind !== "worktree")).toMatchObject([ - { kind: "browser-artifacts", outcome: "removed", reason: "Removed 2 browser artifacts" }, - { kind: "logs", outcome: "removed", reason: "Removed 2 rotated logs" }, + { + kind: "browser-artifacts", + outcome: "removed", + reason: "Removed 2 browser artifacts", + bytes: 14, + files: 2, + }, + { + kind: "logs", + outcome: "removed", + reason: "Removed 2 rotated logs", + bytes: 6, + files: 2, + }, ]); + expect(report.bytesFreed).toBe(20); expect(yield* fs.readDirectory(config.browserArtifactsDir)).toEqual([]); expect(yield* fs.exists(config.logsDir + "/server.log")).toBe(true); expect(yield* fs.exists(config.logsDir + "/server.log.1")).toBe(false); @@ -506,7 +601,7 @@ describe("storage cleanup reports and local file policies", () => { yield* command(worktree, ["worktree", "lock", worktree]); const report = yield* service.runNow; expect(report.entries[0]).toMatchObject({ outcome: "failed" }); - expect(report.entries[0]?.reason).toContain("Failed: git worktree remove: fatal:"); + expect(report.entries[0]?.reason).toContain("git worktree remove: fatal:"); expect(report.entries.map((entry) => entry.kind)).toEqual([ "worktree", "browser-artifacts", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index eea318e08911..d3bed09055a1 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -53,13 +53,15 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( ); const DAY_MS = 86_400_000; + +const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); const REPORT_ENTRY_LIMIT = 200; const isGitCommandError = Schema.is(GitCommandError); function cleanupFailureReason(error: { readonly message: string }) { return isGitCommandError(error) ? `${error.command}: ${error.detail.split(/\r?\n/)[0]}${error.reason ? ` (${error.reason})` : ""}` - : error.message.split(/\r?\n/)[0]; + : (error.message.split(/\r?\n/)[0] ?? error.message); } const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => @@ -204,6 +206,47 @@ const make = Effect.gen(function* () { ); }); + const measureWorktree = (worktreePath: string) => + Effect.gen(function* () { + const root = yield* fs.realPath(worktreePath); + const pending = [root]; + let entries = 1; + let bytes = 0; + while (pending.length > 0) { + const target = pending.pop()!; + // Effect's stat follows links. Probe with readLink first to get lstat + // semantics, including skipping dangling links and directory links. + const isLink = yield* fs.readLink(target).pipe( + Effect.as(true), + Effect.catchIf( + (error) => + error.cause instanceof Error && + "code" in error.cause && + error.cause.code === "EINVAL", + () => Effect.succeed(false), + ), + ); + if (isLink) continue; + if ((yield* fs.realPath(target)) !== target) return null; + const stat = yield* fs.stat(target); + if (stat.type === "File") bytes += Number(stat.size); + else if (stat.type === "Directory") { + const names = yield* fs.readDirectory(target); + entries += names.length; + if (entries > 2_000_000) return null; + for (const name of names) { + const child = path.join(target, name); + if (!inside(root, child)) return null; + pending.push(child); + } + } + } + return bytes; + }).pipe( + Effect.timeout("30 seconds"), + Effect.orElseSucceed(() => null), + ); + const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { const active = yield* projections.getShellSnapshot(); const archived = yield* projections.getShellSnapshot({ location: "archive" }); @@ -344,8 +387,10 @@ const make = Effect.gen(function* () { path: worktreePath, threadId: thread.id, threadTitle: thread.title, + bytes: null, + files: null, }; - const keep = (reason: string) => entries.push({ ...entry, reason: `Kept: ${reason}` }); + const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); yield* Effect.gen(function* () { if (!(yield* fs.exists(worktreePath))) return; if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); @@ -500,6 +545,7 @@ const make = Effect.gen(function* () { ) ) return keep("settings changed since check"); + const bytes = yield* measureWorktree(worktreePath); // Clean only untracked files; Git must still refuse removal if a tracked // edit arrives after our last status check. if (settings.worktreeKeepWhen === "tracked-changes") @@ -509,7 +555,7 @@ const make = Effect.gen(function* () { ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath], "git worktree remove", ); - entries.push({ ...entry, outcome: "removed", reason: `Removed: ${removalReason}` }); + entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. @@ -521,7 +567,7 @@ const make = Effect.gen(function* () { entries.push({ ...entry, outcome: "failed", - reason: `Failed: ${cleanupFailureReason(error)}`, + reason: cleanupFailureReason(error), }); yield* Effect.logWarning("storage cleanup failed for worktree", { threadId: thread.id, @@ -539,10 +585,10 @@ const make = Effect.gen(function* () { now: number, rotatedLogs: boolean, ) { - if (days === null || !(yield* fs.exists(root))) return 0; - let removed = 0; + const removed = { files: 0, bytes: 0 }; + if (days === null || !(yield* fs.exists(root))) return removed; const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return 0; + if (realRoot !== path.resolve(root)) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -559,7 +605,8 @@ const make = Effect.gen(function* () { if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) return; yield* fs.remove(target); - removed++; + removed.files++; + removed.bytes += Number(stat.size); } } } @@ -582,10 +629,12 @@ const make = Effect.gen(function* () { entries.push({ kind: "worktree", outcome: "failed", - reason: `Failed: ${cleanupFailureReason(error)}`, + reason: cleanupFailureReason(error), path: null, threadId: null, threadTitle: null, + bytes: null, + files: null, }); yield* Effect.logWarning("worktree cleanup failed", { error }); }), @@ -607,14 +656,16 @@ const make = Effect.gen(function* () { ]) { if (category.days === null) continue; yield* cleanFiles(category.root, category.days, now, category.kind === "logs").pipe( - Effect.map((count) => + Effect.map(({ files, bytes }) => entries.push({ kind: category.kind, - outcome: count > 0 ? "removed" : "kept", - reason: count === 0 ? "Kept: no expired files" : `Removed ${count} ${category.label}`, + outcome: files > 0 ? "removed" : "kept", + reason: files === 0 ? "No expired files" : `Removed ${files} ${category.label}`, path: null, threadId: null, threadTitle: null, + bytes: files > 0 ? bytes : null, + files: files > 0 ? files : null, }), ), Effect.catch((error) => @@ -622,10 +673,12 @@ const make = Effect.gen(function* () { entries.push({ kind: category.kind, outcome: "failed", - reason: `Failed: ${cleanupFailureReason(error)}`, + reason: cleanupFailureReason(error), path: null, threadId: null, threadTitle: null, + bytes: null, + files: null, }); yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error }); }), @@ -633,7 +686,11 @@ const make = Effect.gen(function* () { ); } const counts = { removed: 0, kept: 0, failed: 0 }; - for (const entry of entries) counts[entry.outcome]++; + let bytesFreed = 0; + for (const entry of entries) { + counts[entry.outcome]++; + if (entry.outcome === "removed") bytesFreed += entry.bytes ?? 0; + } const priority = { failed: 0, removed: 1, kept: 2 }; const latestReport: StorageCleanupReport = { trigger, @@ -643,6 +700,7 @@ const make = Effect.gen(function* () { .sort((a, b) => priority[a.outcome] - priority[b.outcome]) .slice(0, REPORT_ENTRY_LIMIT), counts, + bytesFreed, omittedCount: Math.max(0, entries.length - REPORT_ENTRY_LIMIT), }; yield* SubscriptionRef.set(reportRef, latestReport); diff --git a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ab721ef54f4c..eb65bb6cf1c5 100644 --- a/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx +++ b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx @@ -39,6 +39,7 @@ import { useResourceTelemetryHistory, } from "../../lib/resourceTelemetryState"; import { cn } from "../../lib/utils"; +import { formatBytes } from "../../lib/formatBytes"; import { ensureLocalApi } from "../../localApi"; import { serverEnvironment } from "../../state/server"; import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; @@ -65,18 +66,6 @@ const HISTORY_WINDOWS = [ { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, ] as const; -function formatBytes(value: number): string { - if (value < 1_024) return `${Math.round(value)} B`; - const units = ["KB", "MB", "GB", "TB"] as const; - let next = value; - let unitIndex = -1; - do { - next /= 1_024; - unitIndex += 1; - } while (next >= 1_024 && unitIndex < units.length - 1); - return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; -} - function formatRate(value: number): string { return `${formatBytes(value)}/s`; } diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 29f5568269d4..c3343d047321 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -7,11 +7,12 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; -import { CheckIcon, CircleAlertIcon, MinusIcon } from "lucide-react"; +import { CheckIcon, CircleAlertIcon, MinusIcon, Trash2Icon } from "lucide-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, } from "@t3tools/client-runtime/state/runtime"; +import { formatBytes } from "../../lib/formatBytes"; import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTimeLabel } from "../../timestampFormat"; @@ -34,6 +35,8 @@ import { SettingsPageContainer, SettingsRow, SettingsSection, + SettingsSearchTarget, + useRelativeTimeTick, } from "./settingsLayout"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; import type { ScopedSettingsTarget } from "./scopedSettings"; @@ -167,12 +170,93 @@ const KEEP_WHEN_LABELS = { "tracked-changes": "Edited tracked files", } as const; -function cleanupSummary(report: StorageCleanupReport | null) { - if (report === null) return "Hasn't run yet"; - return `Last ${report.trigger} run ${formatRelativeTimeLabel(report.finishedAt)}: removed ${report.counts.removed}, kept ${report.counts.kept}, ${report.counts.failed} failed`; +const CLEANUP_OUTCOMES = [ + { outcome: "failed", label: "Failed", Icon: CircleAlertIcon, color: "text-destructive" }, + { outcome: "removed", label: "Removed", Icon: CheckIcon, color: "text-success-foreground" }, + { outcome: "kept", label: "Kept", Icon: MinusIcon, color: "text-muted-foreground" }, +] as const; + +function CleanupResults({ report }: { report: StorageCleanupReport }) { + return ( +
+ {CLEANUP_OUTCOMES.map(({ outcome, label, Icon, color }) => { + const entries = report.entries.filter((entry) => entry.outcome === outcome); + if (entries.length === 0) return null; + return ( +
+

{label}

+
    + {entries.map((entry, index) => { + const name = + entry.kind === "worktree" + ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") + : entry.kind === "logs" + ? "Rotated logs" + : "Browser artifacts"; + return ( +
  • +
  • + ); + })} +
+
+ ); + })} + {report.omittedCount > 0 && ( +

+ {report.omittedCount.toLocaleString()} more results. Recovered space includes all removed + items. +

+ )} + {report.entries.length === 0 && report.omittedCount === 0 && ( +

Nothing needed cleanup.

+ )} +
+ ); } function CleanupSection() { + useRelativeTimeTick(60_000); const { connectedEnvironments } = useSettingsScope(); const [pending, setPending] = useState(false); const run = useAtomCommand(serverEnvironment.runStorageCleanup); @@ -234,12 +318,12 @@ function CleanupSection() { environment, report, description: report - ? cleanupSummary(report) + ? `${report.trigger === "manual" ? "Manual" : "Automatic"} run · ${formatRelativeTimeLabel(report.finishedAt)}` : query?.failed ? "Could not load the last run" : query?.loading ? "Loading last run…" - : "Hasn't run yet", + : "Cleanup hasn't run yet", }; }); const deleteNow = async () => { @@ -272,84 +356,75 @@ function CleanupSection() { }; return ( - - {summaries.map(({ environment, description }) => ( - - {supported.length > 1 ? `${environment.label}: ` : ""} - {description} - + +
+
+ {summaries.map(({ environment, report, description }) => ( +
+ {supported.length > 1 && ( +

{environment.label}

+ )} + {report ? ( + <> +

+ {formatBytes(report.bytesFreed)}{" "} + + recovered + +

+

{description}

+
+ {(["removed", "kept", "failed"] as const).map((outcome) => ( +
+
{outcome}
+
0 ? "text-destructive" : "text-foreground"}`} + > + {report.counts[outcome].toLocaleString()} +
+
+ ))} +
+ + ) : ( +

{description}

+ )} +
))} - - } - control={ - - } - /> -
- {summaries.map(({ environment, report }) => - report && report.entries.length > 0 ? ( -
    +
    +
- ) : null, - )} -
+
+
+
+ {summaries.some(({ report }) => report !== null) && ( +
+ {summaries.map( + ({ environment, report }) => + report && ( +
+ {supported.length > 1 && ( +

{environment.label}

+ )} + +
+ ), + )} +
+ )}
); } diff --git a/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 000000000000..306302db6761 --- /dev/null +++ b/apps/web/src/lib/formatBytes.ts @@ -0,0 +1,11 @@ +export function formatBytes(value: number): string { + if (value < 1_024) return `${Math.round(value)} B`; + const units = ["KB", "MB", "GB", "TB"] as const; + let next = value; + let unitIndex = -1; + do { + next /= 1_024; + unitIndex += 1; + } while (next >= 1_024 && unitIndex < units.length - 1); + return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; +} diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 5ed3b411d6c5..0038ede86c2d 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -1052,6 +1052,8 @@ export const StorageCleanupReportEntry = Schema.Struct({ path: Schema.NullOr(Schema.String), threadId: Schema.NullOr(ThreadId), threadTitle: Schema.NullOr(Schema.String), + bytes: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), + files: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), }); export type StorageCleanupReportEntry = typeof StorageCleanupReportEntry.Type; export const StorageCleanupReport = Schema.Struct({ @@ -1061,6 +1063,7 @@ export const StorageCleanupReport = Schema.Struct({ entries: Schema.Array(StorageCleanupReportEntry), counts: Schema.Struct({ removed: Schema.Number, kept: Schema.Number, failed: Schema.Number }), omittedCount: Schema.Number, + bytesFreed: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))), }); export type StorageCleanupReport = typeof StorageCleanupReport.Type; From c24653ed15f3cedf558aba34ace9f0e3f32f30ae Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 19:13:46 +0000 Subject: [PATCH 3/9] fix(storage): count files removed before an artifact cleanup failure --- apps/server/src/storageCleanup.ts | 12 +++++++----- apps/web/src/components/settings/StorageSettings.tsx | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index d3bed09055a1..46007bad2481 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -584,8 +584,9 @@ const make = Effect.gen(function* () { days: number | null, now: number, rotatedLogs: boolean, + // Owned by the caller so files removed before a failure still count. + removed: { files: number; bytes: number }, ) { - const removed = { files: 0, bytes: 0 }; if (days === null || !(yield* fs.exists(root))) return removed; const realRoot = yield* fs.realPath(root); if (realRoot !== path.resolve(root)) return removed; @@ -655,7 +656,8 @@ const make = Effect.gen(function* () { }, ]) { if (category.days === null) continue; - yield* cleanFiles(category.root, category.days, now, category.kind === "logs").pipe( + const removed = { files: 0, bytes: 0 }; + yield* cleanFiles(category.root, category.days, now, category.kind === "logs", removed).pipe( Effect.map(({ files, bytes }) => entries.push({ kind: category.kind, @@ -677,8 +679,8 @@ const make = Effect.gen(function* () { path: null, threadId: null, threadTitle: null, - bytes: null, - files: null, + bytes: removed.files > 0 ? removed.bytes : null, + files: removed.files > 0 ? removed.files : null, }); yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error }); }), @@ -689,7 +691,7 @@ const make = Effect.gen(function* () { let bytesFreed = 0; for (const entry of entries) { counts[entry.outcome]++; - if (entry.outcome === "removed") bytesFreed += entry.bytes ?? 0; + bytesFreed += entry.bytes ?? 0; } const priority = { failed: 0, removed: 1, kept: 2 }; const latestReport: StorageCleanupReport = { diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index c3343d047321..65c4730aa852 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -215,7 +215,7 @@ function CleanupResults({ report }: { report: StorageCleanupReport }) { name )} - {outcome === "removed" && entry.bytes !== null && ( + {entry.bytes !== null && ( {entry.kind !== "worktree" && entry.files !== null ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"} · ` From cbb68a88bfabae0d4d57e342f7f1174c4698c14a Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 20:26:24 +0000 Subject: [PATCH 4/9] refactor(storage): simplify cleanup settings and results --- .../components/settings/StorageSettings.tsx | 154 ++++++++++-------- 1 file changed, 87 insertions(+), 67 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 65c4730aa852..49838fb2257f 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -7,7 +7,7 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; -import { CheckIcon, CircleAlertIcon, MinusIcon, Trash2Icon } from "lucide-react"; +import { ChevronRightIcon, Trash2Icon } from "lucide-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -17,6 +17,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; +import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; import { toastManager } from "../ui/toast"; @@ -67,9 +68,7 @@ function WorktreesDirectoryRow() { return ( - {CLEANUP_OUTCOMES.map(({ outcome, label, Icon, color }) => { + {CLEANUP_OUTCOMES.map(({ outcome, label }) => { const entries = report.entries.filter((entry) => entry.outcome === outcome); if (entries.length === 0) return null; return ( @@ -196,9 +195,8 @@ function CleanupResults({ report }: { report: StorageCleanupReport }) { return (
  • -
  • ); @@ -244,8 +243,7 @@ function CleanupResults({ report }: { report: StorageCleanupReport }) { })} {report.omittedCount > 0 && (

    - {report.omittedCount.toLocaleString()} more results. Recovered space includes all removed - items. + {report.omittedCount.toLocaleString()} more results. Total includes all removed items.

    )} {report.entries.length === 0 && report.omittedCount === 0 && ( @@ -259,6 +257,7 @@ function CleanupSection() { useRelativeTimeTick(60_000); const { connectedEnvironments } = useSettingsScope(); const [pending, setPending] = useState(false); + const [detailsOpen, setDetailsOpen] = useState(false); const run = useAtomCommand(serverEnvironment.runStorageCleanup); const supported = connectedEnvironments.filter( (environment) => environment.serverConfig?.environment.capabilities.storageCleanupRun === true, @@ -326,6 +325,14 @@ function CleanupSection() { : "Cleanup hasn't run yet", }; }); + const counts = summaries.reduce( + (total, { report }) => ({ + removed: total.removed + (report?.counts.removed ?? 0), + kept: total.kept + (report?.counts.kept ?? 0), + failed: total.failed + (report?.counts.failed ?? 0), + }), + { removed: 0, kept: 0, failed: 0 }, + ); const deleteNow = async () => { if (pending) return; setPending(true); @@ -357,7 +364,7 @@ function CleanupSection() { return ( -
    +
    {summaries.map(({ environment, report, description }) => (
    @@ -365,27 +372,15 @@ function CleanupSection() {

    {environment.label}

    )} {report ? ( - <> -

    - {formatBytes(report.bytesFreed)}{" "} - - recovered - +

    +

    + + {formatBytes(report.bytesFreed)} + {" "} + recovered

    {description}

    -
    - {(["removed", "kept", "failed"] as const).map((outcome) => ( -
    -
    {outcome}
    -
    0 ? "text-destructive" : "text-foreground"}`} - > - {report.counts[outcome].toLocaleString()} -
    -
    - ))} -
    - +
    ) : (

    {description}

    )} @@ -407,23 +402,49 @@ function CleanupSection() {
    {summaries.some(({ report }) => report !== null) && ( -
    - {summaries.map( - ({ environment, report }) => - report && ( -
    - {supported.length > 1 && ( -

    {environment.label}

    - )} - -
    - ), - )} -
    + + + + + {(["removed", "kept", "failed"] as const).map( + (outcome) => + counts[outcome] > 0 && ( + + {counts[outcome].toLocaleString()} {outcome} + + ), + )} + + +
    + {summaries.map( + ({ environment, report }) => + report && ( +
    + {supported.length > 1 && ( +

    + {environment.label} +

    + )} + +
    + ), + )} +
    +
    +
    )} ); @@ -548,7 +569,6 @@ export function StorageSettingsPanel() { Date: Fri, 9 Oct 2026 20:45:03 +0000 Subject: [PATCH 5/9] fix(storage): recheck activity after measuring worktrees --- apps/server/src/storageCleanup.test.ts | 43 ++++++++++++++++++++++++++ apps/server/src/storageCleanup.ts | 6 ++-- 2 files changed, 46 insertions(+), 3 deletions(-) diff --git a/apps/server/src/storageCleanup.test.ts b/apps/server/src/storageCleanup.test.ts index 095d655e1c2f..a8fb530d3f05 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,6 +1,8 @@ import * as NodeServices from "@effect/platform-node/NodeServices"; import * as Effect from "effect/Effect"; import * as Context from "effect/Context"; +import * as Deferred from "effect/Deferred"; +import * as Fiber from "effect/Fiber"; import * as FileSystem from "effect/FileSystem"; import * as Layer from "effect/Layer"; import type * as Path from "effect/Path"; @@ -370,6 +372,47 @@ describe("storage cleanup reports and local file policies", () => { }), ), ); + it.live("keeps a worktree when its thread starts during size measurement", () => + runCleanupTest( + Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem; + const measuring = yield* Deferred.make(); + const resume = yield* Deferred.make(); + let measuredPath: string | undefined; + const fixture = yield* cleanupFixture.pipe( + Effect.provideService(FileSystem.FileSystem, { + ...fs, + readDirectory: (directory) => + Effect.gen(function* () { + if (directory === measuredPath) { + yield* Deferred.succeed(measuring, undefined); + yield* Deferred.await(resume); + } + return yield* fs.readDirectory(directory); + }), + }), + ); + measuredPath = yield* fs.realPath(fixture.worktree); + fixture.setPolicy("tracked-changes"); + yield* fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); + const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); + yield* Deferred.await(measuring); + fixture.setThreads([ + shell({ branch: "feature", worktreePath: fixture.worktree, status: "running" }), + ]); + yield* Deferred.succeed(resume, undefined); + const report = yield* Fiber.join(cleanup); + expect(report.entries[0]).toMatchObject({ + outcome: "kept", + reason: "Thread activity or shared worktree changed since check", + bytes: null, + }); + expect(report.bytesFreed).toBe(0); + expect(yield* fs.exists(fixture.worktree)).toBe(true); + expect(yield* fs.readFileString(`${fixture.worktree}/notes.txt`)).toBe("untracked\n"); + }), + ), + ); it("decodes reports from servers without size fields", () => { const report = decodeCleanupReport({ trigger: "manual", diff --git a/apps/server/src/storageCleanup.ts b/apps/server/src/storageCleanup.ts index 46007bad2481..32a9a21fe91f 100644 --- a/apps/server/src/storageCleanup.ts +++ b/apps/server/src/storageCleanup.ts @@ -478,8 +478,9 @@ const make = Effect.gen(function* () { ? "not merged" : "has commits beyond the default branch", ); - // Re-read after Git/host calls so a queued turn, resumed session or new - // thread sharing this path cancels the removal. + const bytes = yield* measureWorktree(worktreePath); + // Re-read after Git/host calls and size measurement so a queued turn, + // resumed session or new thread sharing this path cancels the removal. const latestSnapshot = yield* readThreads(); if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) return keep("contains a project checkout"); @@ -545,7 +546,6 @@ const make = Effect.gen(function* () { ) ) return keep("settings changed since check"); - const bytes = yield* measureWorktree(worktreePath); // Clean only untracked files; Git must still refuse removal if a tracked // edit arrives after our last status check. if (settings.worktreeKeepWhen === "tracked-changes") From eb55e84ff25ae4ecaa14de79eb1f3b2cf0bd7eca Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 23:14:42 +0000 Subject: [PATCH 6/9] fix(web): make storage cleanup look like the other settings --- .../components/settings/StorageSettings.tsx | 241 ++++++------------ 1 file changed, 76 insertions(+), 165 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 49838fb2257f..0e631bcd6919 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -7,7 +7,6 @@ import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; import { useMemo, useRef, useState } from "react"; import { useAtomValue } from "@effect/atom-react"; import { Atom } from "effect/reactivity"; -import { ChevronRightIcon, Trash2Icon } from "lucide-react"; import { isAtomCommandInterrupted, squashAtomCommandFailure, @@ -17,8 +16,7 @@ import { serverEnvironment } from "../../state/server"; import { useAtomCommand } from "../../state/use-atom-command"; import { formatRelativeTimeLabel } from "../../timestampFormat"; import { Button } from "../ui/button"; -import { Collapsible, CollapsiblePanel, CollapsibleTrigger } from "../ui/collapsible"; -import { Tooltip, TooltipTrigger, TooltipPopup } from "../ui/tooltip"; +import { Dialog, DialogHeader, DialogPanel, DialogPopup, DialogTitle } from "../ui/dialog"; import { toastManager } from "../ui/toast"; import { Input } from "../ui/input"; @@ -36,7 +34,6 @@ import { SettingsPageContainer, SettingsRow, SettingsSection, - SettingsSearchTarget, useRelativeTimeTick, } from "./settingsLayout"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; @@ -169,85 +166,35 @@ const KEEP_WHEN_LABELS = { "tracked-changes": "Edited tracked files", } as const; -const CLEANUP_OUTCOMES = [ - { outcome: "failed", label: "Failed" }, - { outcome: "removed", label: "Removed" }, - { outcome: "kept", label: "Kept" }, -] as const; - function CleanupResults({ report }: { report: StorageCleanupReport }) { return ( -
    - {CLEANUP_OUTCOMES.map(({ outcome, label }) => { - const entries = report.entries.filter((entry) => entry.outcome === outcome); - if (entries.length === 0) return null; +
    + {report.entries.map((entry, index) => { + const name = + entry.kind === "worktree" + ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") + : entry.kind === "logs" + ? "Rotated logs" + : "Browser artifacts"; return ( -
    -

    {label}

    -
      - {entries.map((entry, index) => { - const name = - entry.kind === "worktree" - ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") - : entry.kind === "logs" - ? "Rotated logs" - : "Browser artifacts"; - return ( -
    • -
      -
      -
      - {entry.path ? ( - - } - > - {name} - - - {entry.threadTitle &&

      {entry.threadTitle}

      } -

      {entry.path}

      - {outcome === "removed" &&

      {entry.reason}

      } -
      -
      - ) : ( - name - )} -
      - {entry.bytes !== null && ( - - {entry.kind !== "worktree" && entry.files !== null - ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"} · ` - : ""} - {formatBytes(entry.bytes)} - - )} -
      - {outcome !== "removed" && ( -

      - {entry.reason} -

      - )} -
      -
    • - ); - })} -
    -
    +

    + {name}: {entry.reason} + {entry.outcome === "removed" && + entry.bytes !== null && + ` · ${formatBytes(entry.bytes)}`} +

    ); })} {report.omittedCount > 0 && ( -

    - {report.omittedCount.toLocaleString()} more results. Total includes all removed items. +

    + {report.omittedCount.toLocaleString()} more results.

    )} {report.entries.length === 0 && report.omittedCount === 0 && ( -

    Nothing needed cleanup.

    +

    Nothing needed cleanup.

    )}
    ); @@ -317,22 +264,14 @@ function CleanupSection() { environment, report, description: report - ? `${report.trigger === "manual" ? "Manual" : "Automatic"} run · ${formatRelativeTimeLabel(report.finishedAt)}` + ? `Last run ${formatRelativeTimeLabel(report.finishedAt)}. ${formatBytes(report.bytesFreed)} recovered.${report.counts.failed > 0 ? ` ${report.counts.failed} ${report.counts.failed === 1 ? "item" : "items"} couldn't be removed.` : ""}` : query?.failed - ? "Could not load the last run" + ? "Could not load the last run." : query?.loading ? "Loading last run…" - : "Cleanup hasn't run yet", + : "Cleanup hasn't run yet.", }; }); - const counts = summaries.reduce( - (total, { report }) => ({ - removed: total.removed + (report?.counts.removed ?? 0), - kept: total.kept + (report?.counts.kept ?? 0), - failed: total.failed + (report?.counts.failed ?? 0), - }), - { removed: 0, kept: 0, failed: 0 }, - ); const deleteNow = async () => { if (pending) return; setPending(true); @@ -363,89 +302,61 @@ function CleanupSection() { }; return ( - -
    -
    - {summaries.map(({ environment, report, description }) => ( -
    - {supported.length > 1 && ( -

    {environment.label}

    - )} - {report ? ( -
    -

    - - {formatBytes(report.bytesFreed)} - {" "} - recovered -

    -

    {description}

    -
    - ) : ( -

    {description}

    - )} -
    - ))} -
    -
    - - {!hasRules &&

    No cleanup rules are on.

    } -
    -
    -
    - {summaries.some(({ report }) => report !== null) && ( - - - - - {(["removed", "kept", "failed"] as const).map( - (outcome) => - counts[outcome] > 0 && ( - - {counts[outcome].toLocaleString()} {outcome} + + {!hasRules + ? "Turn on a cleanup rule to use this." + : summaries.map(({ environment, description }) => ( + + {supported.length > 1 ? `${environment.label}: ` : ""} + {description} + ))} + + } + control={ + + } + /> + {summaries.some(({ report }) => report !== null) && ( + setDetailsOpen(true)}> + View + + } + /> + )} + + + + Last cleanup + + + {summaries.map( + ({ environment, report }) => + report && ( +
    + {supported.length > 1 && ( +

    {environment.label}

    + )} + +
    ), )} -
    - -
    - {summaries.map( - ({ environment, report }) => - report && ( -
    - {supported.length > 1 && ( -

    - {environment.label} -

    - )} - -
    - ), - )} -
    -
    -
    - )} + + +
    ); } From 3dab8c07268dee433acf7ab1c168cb7e0c31a48b Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Fri, 9 Oct 2026 23:25:57 +0000 Subject: [PATCH 7/9] fix(web): run storage cleanup on permitted environments only --- .../components/settings/StorageSettings.tsx | 40 +++++++++++-------- 1 file changed, 23 insertions(+), 17 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index 0e631bcd6919..2bd46efcdf1a 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -277,24 +277,30 @@ function CleanupSection() { setPending(true); try { await Promise.all( - supported.map(async (environment) => { - const result = await run({ environmentId: environment.environmentId, input: {} }); - if (result._tag === "Success") { - const failures = result.value.entries.filter((entry) => entry.outcome === "failed"); - if (failures.length > 0) + supported + .filter((environment) => + state.some( + (entry) => entry.environmentId === environment.environmentId && entry.allowed, + ), + ) + .map(async (environment) => { + const result = await run({ environmentId: environment.environmentId, input: {} }); + if (result._tag === "Success") { + const failures = result.value.entries.filter((entry) => entry.outcome === "failed"); + if (failures.length > 0) + toastManager.add({ + type: "error", + title: `Cleanup failed on ${environment.label}`, + description: failures[0]!.reason, + }); + } else if (!isAtomCommandInterrupted(result)) { toastManager.add({ type: "error", - title: `Cleanup failed on ${environment.label}`, - description: failures[0]!.reason, + title: `Could not run cleanup on ${environment.label}`, + description: String(squashAtomCommandFailure(result)), }); - } else if (!isAtomCommandInterrupted(result)) { - toastManager.add({ - type: "error", - title: `Could not run cleanup on ${environment.label}`, - description: String(squashAtomCommandFailure(result)), - }); - } - }), + } + }), ); } finally { setPending(false); @@ -305,7 +311,7 @@ function CleanupSection() { + {!hasRules ? "Turn on a cleanup rule to use this." : summaries.map(({ environment, description }) => ( @@ -320,7 +326,7 @@ function CleanupSection() { - } - /> - {summaries.some(({ report }) => report !== null) && ( + + + {!hasRules + ? "Turn on a cleanup rule to use this." + : summaries.map(({ environment, description }) => ( + + {supported.length > 1 ? `${environment.label}: ` : ""} + {description} + + ))} + + } control={ - } /> + + {summaries.some(({ report }) => report !== null) && ( + + report + ? [ + `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, + ] + : [], + ) + .join(" · ")} + > + {summaries.map( + ({ environment, report }) => + report && ( + 1 ? environment.label : undefined + } + /> + ), + )} + )} - - - - Last cleanup - - - {summaries.map( - ({ environment, report }) => - report && ( -
    - {supported.length > 1 && ( -

    {environment.label}

    - )} - -
    - ), - )} -
    -
    -
    ); } @@ -498,7 +525,7 @@ export function StorageSettingsPanel() { Date: Sat, 10 Oct 2026 00:11:11 +0000 Subject: [PATCH 9/9] fix(web): keep existing storage setting descriptions unchanged --- apps/web/src/components/settings/StorageSettings.tsx | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/apps/web/src/components/settings/StorageSettings.tsx b/apps/web/src/components/settings/StorageSettings.tsx index f905c39ea94a..28e7b4f0e24c 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -66,7 +66,9 @@ function WorktreesDirectoryRow() { return (