From 0caa95d6eb471ab033ea670914d61070c65be806 Mon Sep 17 00:00:00 2001 From: maria Date: Fri, 9 Oct 2026 23:05:12 -0300 Subject: [PATCH] fix(storage): make worktree cleanup work and show why it skipped (#17563) --- apps/server/src/auth/RpcAuthorization.test.ts | 41 ++ 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 | 460 +++++++++++++++++- apps/server/src/storageCleanup.ts | 441 +++++++++++++---- apps/server/src/ws.ts | 4 + .../settings/ResourceTelemetryDiagnostics.tsx | 13 +- .../components/settings/StorageSettings.tsx | 300 +++++++++++- .../settings/scopedSettings.test.ts | 6 + .../src/components/settings/settingsSearch.ts | 17 + apps/web/src/lib/formatBytes.ts | 11 + 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 | 38 ++ 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 + 26 files changed, 1335 insertions(+), 124 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 0359b07b3941561423e821530cd07f2e63242209..d46609edce7782b96361382a5522e899bf94e394 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -403,3 +403,44 @@ 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, + bytesFreed: 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 a33d2f3317aaf937ee3558c438ec93250a869cd2..511fd77cd14792d3c33551090ffb67277d596000 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 7fc4a66464e9f36a3851d3ba295ccdec01903f9f..60589ac9106f086e59acbd8c16e29ed9bc50e288 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 b1abaf8d465c45e18e54a38cdb1df23d3187d9d4..2ad903ff792ab69838c5dcc1cb384f2d74b60d12 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 e24cb335b487014a467f9e6047973278aab96259..98a541409380127bc9420bb420dc976d817b00f1 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -547,9 +547,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 c66aee5302d6ca8d6bf49f7314197005808b617b..a8fb530d3f054627ee931461fb0a8402e08c261c 100644 --- a/apps/server/src/storageCleanup.test.ts +++ b/apps/server/src/storageCleanup.test.ts @@ -1,4 +1,31 @@ -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 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"; +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, + 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"; +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, @@ -16,6 +43,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); @@ -196,3 +224,433 @@ 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("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.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", + 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"], + ["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 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", + 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") + 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]).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", + ); + 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"); + expect(report.entries[0]?.bytes).toBeGreaterThan(0); + expect(report.bytesFreed).toBe(report.entries[0]?.bytes); + }), + ), + ); + 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: "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("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", + 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); + 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("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 7804ed42ef10bfbf8faffdb652dd426add900198..481abe7da8cc44675414464cb096a80a82b215f8 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"; @@ -49,6 +55,16 @@ 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); +} + const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => rules.worktreeAfterDays !== null || rules.worktreeOnMerge || @@ -138,7 +154,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; @@ -181,6 +207,47 @@ export 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" }); @@ -204,11 +271,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, @@ -223,26 +353,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( @@ -250,7 +371,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) { @@ -261,52 +382,58 @@ 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, + bytes: null, + files: null, + }; + 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"); + 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)) { @@ -330,6 +457,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 }, @@ -341,39 +469,49 @@ export const make = Effect.gen(function* () { headSha: head.commitSha, integrated, }); + if (eligible) removalReason = "pull request was merged"; } } - if (!eligible) return; - // Re-read after Git/host calls so a queued turn, resumed session or new - // thread sharing this path cancels the removal. + 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", + ); + 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; + 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 @@ -388,32 +526,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, @@ -424,8 +547,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: sentence(removalReason), bytes }); yield* gitManager.invalidateStatus(project.workspaceRoot); // Preserve branch and path: ProviderTurnStartService recreates the checkout // from that branch when the thread is resumed. @@ -433,7 +565,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: cleanupFailureReason(error), + }); + yield* Effect.logWarning("storage cleanup failed for worktree", { + threadId: thread.id, + error, + }); + }), ), ); } @@ -444,10 +586,12 @@ export 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 }, ) { - if (days === null || !(yield* fs.exists(root))) return; + if (days === null || !(yield* fs.exists(root))) return removed; const realRoot = yield* fs.realPath(root); - if (realRoot !== path.resolve(root)) return; + if (realRoot !== path.resolve(root)) return removed; const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( directory: string, ): Effect.fn.Return { @@ -464,40 +608,129 @@ export const make = Effect.gen(function* () { if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) return; yield* fs.remove(target); + removed.files++; + removed.bytes += Number(stat.size); } } } }); 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: cleanupFailureReason(error), + path: null, + threadId: null, + threadTitle: null, + bytes: null, + files: 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; + 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, + 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) => + Effect.gen(function* () { + entries.push({ + kind: category.kind, + outcome: "failed", + reason: cleanupFailureReason(error), + path: null, + threadId: null, + threadTitle: 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 }); + }), + ), + ); + } + const counts = { removed: 0, kept: 0, failed: 0 }; + let bytesFreed = 0; + for (const entry of entries) { + counts[entry.outcome]++; + bytesFreed += entry.bytes ?? 0; + } + 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, + bytesFreed, + 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) => @@ -552,5 +785,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 8e7a1210a507c0be96f8ad57b655bb2615435eeb..fce520ca7e3b58e332ae94afeb501b3fd9af6c9c 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"; @@ -1278,6 +1279,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; @@ -2382,6 +2384,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/ResourceTelemetryDiagnostics.tsx b/apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx index ab721ef54f4c3172647293e9857edc1839bdcd49..eb65bb6cf1c53b108af4eefa6ea4dc41ac77a6d7 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 0106acc7cebe9ea1e7ec9cfe06ef78e87c89f69d..28e7b4f0e24cdd422eee3feeed408bb79a8a7f9a 100644 --- a/apps/web/src/components/settings/StorageSettings.tsx +++ b/apps/web/src/components/settings/StorageSettings.tsx @@ -1,6 +1,22 @@ -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 { + 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"; +import { Button } from "../ui/button"; +import { toastManager } from "../ui/toast"; import { Input } from "../ui/input"; import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; @@ -17,8 +33,11 @@ import { SettingsPageContainer, SettingsRow, SettingsSection, + useRelativeTimeTick, } from "./settingsLayout"; import { SettingsScopeNotice } from "./SettingsScopeNotice"; +import { FoldedSettingsSection } from "./FoldedSettingsSection"; +import { SettingsGroup } from "./SettingsGroup"; import type { ScopedSettingsTarget } from "./scopedSettings"; import { useSettingsScope } from "./SettingsScopeContext"; import { searchableSetting } from "./settingsSearch"; @@ -144,6 +163,239 @@ function RetentionControl({ ); } +const KEEP_WHEN_LABELS = { + "any-local-files": "Any local files", + "uncommitted-changes": "Uncommitted changes", + "tracked-changes": "Edited tracked files", +} as const; + +function CleanupResults({ + report, + environmentLabel, +}: { + report: StorageCleanupReport; + environmentLabel: string | undefined; +}) { + return ( + <> + {report.entries.map((entry, index) => { + const name = + entry.kind === "worktree" + ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") + : entry.kind === "logs" + ? "Rotated logs" + : "Browser artifacts"; + return ( + + {entry.threadTitle && entry.threadTitle !== name ? `${entry.threadTitle}. ` : ""} + {entry.reason} + + } + control={ + entry.outcome === "removed" && ( + + {[ + entry.kind !== "worktree" && entry.files !== null + ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"}` + : null, + entry.bytes !== null ? formatBytes(entry.bytes) : null, + ] + .filter(Boolean) + .join(" · ")} + + ) + } + /> + ); + })} + {report.omittedCount > 0 && ( +

+ {environmentLabel ? `${environmentLabel}: ` : ""} + {report.omittedCount.toLocaleString()} more results. +

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

+ {environmentLabel ? `${environmentLabel}: ` : ""}Nothing needed cleanup. +

+ )} + + ); +} + +function CleanupSection() { + useRelativeTimeTick(60_000); + 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 + ? `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." + : query?.loading + ? "Loading last run…" + : "Cleanup hasn't run yet.", + }; + }); + const deleteNow = async () => { + if (pending) return; + setPending(true); + try { + await Promise.all( + 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: `Could not run cleanup on ${environment.label}`, + description: String(squashAtomCommandFailure(result)), + }); + } + }), + ); + } finally { + setPending(false); + } + }; + return ( + + + + {!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 + } + /> + ), + )} + + )} + + ); +} + export function StorageSettingsPanel() { const { scope, connectedEnvironments, targets, target } = useSettingsScope(); const scopedSettings = useScopedSettings(); @@ -312,6 +564,49 @@ 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 +641,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 800ff48360a9dcc9f533509ab733e03cdaee17ad..a47a4bf002415c69c91a8546d7df21b340d5ca77 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 853886d81fcd9f89a05ce42215ce3688f17dd159..aff52adeb1550f6e7732effb0ee95be84bcd2551 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/apps/web/src/lib/formatBytes.ts b/apps/web/src/lib/formatBytes.ts new file mode 100644 index 0000000000000000000000000000000000000000..306302db6761c5c3fe0045421f99b194127b7a37 --- /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/docs/user/project-settings.md b/docs/user/project-settings.md index 598ef5cb5e2e1184004f681bdbd8afa59175a149..c92226a9ee4f3f07ffa5228ace5588b1068c548f 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 35a957e9d0f20961ec144f4e632738debb4a026b..c11a49897b88006d4d4177502572d365bb1e22bf 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 b9d1ebf6b73884be50dd237a79b62ea087117345..b2c6933fabb7c3465c0e87ceda7ada1a12f419da 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 91464b16a9b154ba4d1915e76d15dd0fc2047e66..1d49fd71f70c498a4a037c4535a6e1ba272699ff 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 9605829e47d7c7abf1e031d75ddb5c24de06ab37..c84169e6e33f61b93dafba71810f4882af9aed3a 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 c9ac7482d4ccca61945a9638a3a23d3eda926751..2a61480a907be40c75f04bee5542330be779578d 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 ed14e2e16b7c7b78311dac03f9629677f576aec1..36afee2f9efa0f730bd17632403823dc5ac62891 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 58e8fa5d96580f549b0fab648e6e482085e305a4..b5d39323a23d1c3451be7065d8414fa50ca55be2 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 0e8027b9f5fdcef949d2802c5d5903c09165146e..0038ede86c2d4dde583c151d24b20b2446160db0 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,32 @@ 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 f99dacbbb69df3814aeeb95e28b88d43b5e1aaba..af9a7112c3ea3cdec31dbc84b858a918b09c6bfb 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 f7305812e74fc13aa706017aa2997a0265e2cc93..dbc2704171f962adc308371efd281ed4c1ad93e3 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 99b0f2c336c134e9960b2d75bd51836c31aaf3be..909eeb0a35ce65720f4c23ba7d440e9f927a6b33 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, -- 2.54.0