| author | |
| committer | |
| log | 0caa95d6eb471ab033ea670914d61070c65be806 |
| tree | ac05e0fa14a729882ae72df42ac4acc4b7988955 |
| parent | eddc8fb817dbdb6c9ce34a30a2781c02ae8be9e0 |
| signature | Signed by PGP key B5690EEEBB952194 |
26 files changed, 1335 insertions(+), 124 deletions(-)
apps/server/src/auth/RpcAuthorization.test.ts+41| ... | @@ -403,3 +403,44 @@ it.effect("separates host file URLs from readable attachment URLs", () => | ... | @@ -403,3 +403,44 @@ it.effect("separates host file URLs from readable attachment URLs", () => |
| 403 | expect(handled).toBe(1); | 403 | expect(handled).toBe(1); |
| 404 | }).pipe(Effect.scoped), | 404 | }).pipe(Effect.scoped), |
| 405 | ); | 405 | ); |
| 406 | |||
| 407 | it.effect("denies manual cleanup before the handler without settings permission", () => | ||
| 408 | Effect.gen(function* () { | ||
| 409 | const group = WsRpcGroup.omit( | ||
| 410 | ...[...WsRpcGroup.requests.keys()].filter( | ||
| 411 | ( | ||
| 412 | tag, | ||
| 413 | ): tag is Exclude< | ||
| 414 | keyof typeof RPC_REQUIRED_SCOPES, | ||
| 415 | typeof WS_METHODS.serverRunStorageCleanup | ||
| 416 | > => tag !== WS_METHODS.serverRunStorageCleanup, | ||
| 417 | ), | ||
| 418 | ); | ||
| 419 | let handled = false; | ||
| 420 | const client = yield* RpcTest.makeClient(group).pipe( | ||
| 421 | Effect.provide( | ||
| 422 | Layer.mergeAll( | ||
| 423 | group.toLayerHandler(WS_METHODS.serverRunStorageCleanup, () => | ||
| 424 | Effect.sync(() => { | ||
| 425 | handled = true; | ||
| 426 | return { | ||
| 427 | trigger: "manual" as const, | ||
| 428 | startedAt: "2026-10-09T12:00:00.000Z", | ||
| 429 | finishedAt: "2026-10-09T12:00:00.000Z", | ||
| 430 | entries: [], | ||
| 431 | counts: { removed: 0, kept: 0, failed: 0 }, | ||
| 432 | omittedCount: 0, | ||
| 433 | bytesFreed: 0, | ||
| 434 | }; | ||
| 435 | }), | ||
| 436 | ), | ||
| 437 | RpcAuthorization.layer([AuthOrchestrationReadScope]), | ||
| 438 | ), | ||
| 439 | ), | ||
| 440 | ); | ||
| 441 | expect(yield* client[WS_METHODS.serverRunStorageCleanup]({}).pipe(Effect.flip)).toMatchObject({ | ||
| 442 | requiredPermission: AuthSettingsWriteScope, | ||
| 443 | }); | ||
| 444 | expect(handled).toBe(false); | ||
| 445 | }).pipe(Effect.scoped), | ||
| 446 | ); |
apps/server/src/auth/RpcAuthorization.ts+1| ... | @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { | ... | @@ -81,6 +81,7 @@ export const RPC_REQUIRED_SCOPES = { |
| 81 | [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, | 81 | [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, |
| 82 | [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, | 82 | [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, |
| 83 | [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, | 83 | [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, |
| 84 | [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, | ||
| 84 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, | 85 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, |
| 85 | [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, | 86 | [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, |
| 86 | [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, | 87 | [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | @@ -228,6 +228,7 @@ export const make = Effect.gen(function* () { | ... | @@ -228,6 +228,7 @@ export const make = Effect.gen(function* () { |
| 228 | threadSettlement: true, | 228 | threadSettlement: true, |
| 229 | threadAutoSettlement: true, | 229 | threadAutoSettlement: true, |
| 230 | storageCleanup: true, | 230 | storageCleanup: true, |
| 231 | storageCleanupRun: true, | ||
| 231 | projectWorktreeCleanup: true, | 232 | projectWorktreeCleanup: true, |
| 232 | worktreesDirectory: true, | 233 | worktreesDirectory: true, |
| 233 | threadRestartContinuation: true, | 234 | threadRestartContinuation: true, |
apps/server/src/observability/RpcInstrumentation.ts+2| ... | @@ -54,6 +54,8 @@ const RPC_AGGREGATES = { | ... | @@ -54,6 +54,8 @@ const RPC_AGGREGATES = { |
| 54 | [WS_METHODS.serverCommitDesktopUpdate]: "server", | 54 | [WS_METHODS.serverCommitDesktopUpdate]: "server", |
| 55 | [WS_METHODS.serverUpsertKeybinding]: "server", | 55 | [WS_METHODS.serverUpsertKeybinding]: "server", |
| 56 | [WS_METHODS.serverRemoveKeybinding]: "server", | 56 | [WS_METHODS.serverRemoveKeybinding]: "server", |
| 57 | [WS_METHODS.serverRunStorageCleanup]: "server", | ||
| 58 | [WS_METHODS.serverGetStorageCleanupReport]: "server", | ||
| 57 | [WS_METHODS.serverGetSettings]: "server", | 59 | [WS_METHODS.serverGetSettings]: "server", |
| 58 | [WS_METHODS.serverUpdateSettings]: "server", | 60 | [WS_METHODS.serverUpdateSettings]: "server", |
| 59 | [WS_METHODS.serverSearchAcpRegistry]: "server", | 61 | [WS_METHODS.serverSearchAcpRegistry]: "server", |
apps/server/src/server.ts+1-3| ... | @@ -547,9 +547,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( | ... | @@ -547,9 +547,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 547 | // Asks T3 Connect to deliver webhooks it held while this environment was offline. | 547 | // Asks T3 Connect to deliver webhooks it held while this environment was offline. |
| 548 | HeldHooksWaker.layer, | 548 | HeldHooksWaker.layer, |
| 549 | layerThreadSettlementWorker, | 549 | layerThreadSettlementWorker, |
| 550 | Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe( | 550 | StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), |
| 551 | Layer.provide(ProjectionStoreV2.layer), | ||
| 552 | ), | ||
| 553 | layerThreadPullRequestWorker, | 551 | layerThreadPullRequestWorker, |
| 554 | Layer.effectDiscard( | 552 | Layer.effectDiscard( |
| 555 | Effect.gen(function* () { | 553 | Effect.gen(function* () { |
apps/server/src/storageCleanup.test.ts+459-1| ... | @@ -1,4 +1,31 @@ | ... | @@ -1,4 +1,31 @@ |
| 1 | import { describe, expect, it } from "vite-plus/test"; | 1 | import * as NodeServices from "@effect/platform-node/NodeServices"; |
| 2 | import * as Effect from "effect/Effect"; | ||
| 3 | import * as Context from "effect/Context"; | ||
| 4 | import * as Deferred from "effect/Deferred"; | ||
| 5 | import * as Fiber from "effect/Fiber"; | ||
| 6 | import * as FileSystem from "effect/FileSystem"; | ||
| 7 | import * as Layer from "effect/Layer"; | ||
| 8 | import type * as Path from "effect/Path"; | ||
| 9 | import type * as Scope from "effect/Scope"; | ||
| 10 | import * as Stream from "effect/Stream"; | ||
| 11 | import * as Schema from "effect/Schema"; | ||
| 12 | import * as SqlClient from "effect/sql/SqlClient"; | ||
| 13 | import { | ||
| 14 | DEFAULT_SERVER_SETTINGS, | ||
| 15 | StorageCleanupReport, | ||
| 16 | type WorktreeKeepWhen, | ||
| 17 | } from "@t3tools/contracts"; | ||
| 18 | import * as ServerConfig from "./config.ts"; | ||
| 19 | import * as StorageCleanup from "./storageCleanup.ts"; | ||
| 20 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 21 | import * as GitManager from "./git/GitManager.ts"; | ||
| 22 | import * as Settings from "./serverSettings.ts"; | ||
| 23 | import * as ProjectionStore from "./orchestration-v2/ProjectionStore.ts"; | ||
| 24 | import * as ProjectStore from "./orchestration-v2/ProjectStore.ts"; | ||
| 25 | import * as Orchestrator from "./orchestration-v2/Orchestrator.ts"; | ||
| 26 | import * as TerminalManager from "./terminal/Manager.ts"; | ||
| 27 | import { ServerActivation } from "./serverActivation.ts"; | ||
| 28 | import { describe, expect, it } from "@effect/vitest"; | ||
| 2 | import { | 29 | import { |
| 3 | ProjectId, | 30 | ProjectId, |
| 4 | ProviderInstanceId, | 31 | ProviderInstanceId, |
| ... | @@ -16,6 +43,7 @@ import { | ... | @@ -16,6 +43,7 @@ import { |
| 16 | 43 | ||
| 17 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); | 44 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); |
| 18 | const DAY_MS = 24 * 60 * 60 * 1_000; | 45 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 46 | const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); | ||
| 19 | 47 | ||
| 20 | function at(offsetMs: number): DateTime.Utc { | 48 | function at(offsetMs: number): DateTime.Utc { |
| 21 | return DateTime.makeUnsafe(NOW_MS + offsetMs); | 49 | return DateTime.makeUnsafe(NOW_MS + offsetMs); |
| ... | @@ -196,3 +224,433 @@ describe("merged pull request cleanup", () => { | ... | @@ -196,3 +224,433 @@ describe("merged pull request cleanup", () => { |
| 196 | expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); | 224 | expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false); |
| 197 | }); | 225 | }); |
| 198 | }); | 226 | }); |
| 227 | |||
| 228 | const cleanupFixture = Effect.gen(function* () { | ||
| 229 | const fs = yield* FileSystem.FileSystem; | ||
| 230 | const config = yield* ServerConfig.ServerConfig; | ||
| 231 | const git = yield* GitVcsDriver.GitVcsDriver; | ||
| 232 | const repo = yield* fs.makeTempDirectoryScoped({ prefix: "cleanup-repo-" }); | ||
| 233 | const command = (cwd: string, args: string[]) => | ||
| 234 | git.execute({ operation: "cleanup-test", cwd, args }); | ||
| 235 | yield* command(repo, ["init", "-b", "main"]); | ||
| 236 | yield* command(repo, ["config", "user.email", "cleanup@example.test"]); | ||
| 237 | yield* command(repo, ["config", "user.name", "Cleanup test"]); | ||
| 238 | yield* fs.writeFileString(`${repo}/tracked.txt`, "original\n"); | ||
| 239 | yield* fs.writeFileString(`${repo}/.gitignore`, ".env\ndist/\nnode_modules/\n"); | ||
| 240 | yield* command(repo, ["add", "."]); | ||
| 241 | yield* command(repo, ["commit", "-m", "initial"]); | ||
| 242 | yield* fs.makeDirectory(config.worktreesDir, { recursive: true }); | ||
| 243 | const worktree = `${config.worktreesDir}/feature`; | ||
| 244 | yield* command(repo, ["worktree", "add", "-b", "feature", worktree]); | ||
| 245 | let settings: import("@t3tools/contracts").ServerSettings = { | ||
| 246 | ...DEFAULT_SERVER_SETTINGS, | ||
| 247 | storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 8 }, | ||
| 248 | }; | ||
| 249 | let threads = [shell({ branch: "feature", worktreePath: worktree })]; | ||
| 250 | let editBeforeRemoval = false; | ||
| 251 | const context = yield* Layer.build(StorageCleanup.layer).pipe( | ||
| 252 | Effect.provideService(GitVcsDriver.GitVcsDriver, { | ||
| 253 | ...git, | ||
| 254 | execute: (input) => | ||
| 255 | Effect.gen(function* () { | ||
| 256 | if (editBeforeRemoval && input.args.includes("remove")) | ||
| 257 | yield* fs.writeFileString(`${worktree}/tracked.txt`, "late edit\n"); | ||
| 258 | return yield* git.execute(input); | ||
| 259 | }), | ||
| 260 | }), | ||
| 261 | Effect.provideService(Settings.ServerSettingsService, { | ||
| 262 | getSettings: Effect.sync(() => settings), | ||
| 263 | subscribeChanges: Effect.succeed(Stream.empty), | ||
| 264 | } as unknown as Settings.ServerSettingsService["Service"]), | ||
| 265 | Effect.provideService(ProjectStore.ProjectStoreV2, { | ||
| 266 | listShells: () => Effect.succeed([{ id: ProjectId.make("project-1"), workspaceRoot: repo }]), | ||
| 267 | } as unknown as ProjectStore.ProjectStoreV2["Service"]), | ||
| 268 | Effect.provideService(ProjectionStore.ProjectionStoreV2, { | ||
| 269 | getShellSnapshot: (input?: { location?: string }) => | ||
| 270 | Effect.sync(() => ({ threads: input?.location === "archive" ? [] : threads })), | ||
| 271 | } as unknown as ProjectionStore.ProjectionStoreV2["Service"]), | ||
| 272 | Effect.provideService(Orchestrator.OrchestratorV2, { | ||
| 273 | streamDomainEvents: Stream.empty, | ||
| 274 | } as unknown as Orchestrator.OrchestratorV2["Service"]), | ||
| 275 | Effect.provideService(SqlClient.SqlClient, (() => | ||
| 276 | Effect.succeed([])) as unknown as SqlClient.SqlClient), | ||
| 277 | Effect.provideService(GitManager.GitManager, { | ||
| 278 | invalidateStatus: () => Effect.void, | ||
| 279 | } as unknown as GitManager.GitManager["Service"]), | ||
| 280 | Effect.provideService(TerminalManager.TerminalManager, { | ||
| 281 | subscribeMetadata: () => Effect.succeed(() => {}), | ||
| 282 | } as unknown as TerminalManager.TerminalManager["Service"]), | ||
| 283 | Effect.provideService(ServerActivation, Effect.never), | ||
| 284 | ); | ||
| 285 | return { | ||
| 286 | service: Context.get(context, StorageCleanup.StorageCleanup), | ||
| 287 | editBeforeRemoval: () => { | ||
| 288 | editBeforeRemoval = true; | ||
| 289 | }, | ||
| 290 | fs, | ||
| 291 | worktree, | ||
| 292 | config, | ||
| 293 | command, | ||
| 294 | enableFiles: () => { | ||
| 295 | settings = { | ||
| 296 | ...settings, | ||
| 297 | storageCleanup: { | ||
| 298 | ...settings.storageCleanup, | ||
| 299 | browserArtifactsAfterDays: 8, | ||
| 300 | logsAfterDays: 8, | ||
| 301 | }, | ||
| 302 | }; | ||
| 303 | }, | ||
| 304 | setPolicy: (worktreeKeepWhen: WorktreeKeepWhen) => { | ||
| 305 | settings = { ...settings, storageCleanup: { ...settings.storageCleanup, worktreeKeepWhen } }; | ||
| 306 | }, | ||
| 307 | disable: () => { | ||
| 308 | settings = { | ||
| 309 | ...settings, | ||
| 310 | storageCleanup: { ...settings.storageCleanup, worktreeAfterDays: null }, | ||
| 311 | }; | ||
| 312 | }, | ||
| 313 | setThreads: (next: typeof threads) => { | ||
| 314 | threads = next; | ||
| 315 | }, | ||
| 316 | }; | ||
| 317 | }); | ||
| 318 | const cleanupTestLayer = GitVcsDriver.layer.pipe( | ||
| 319 | Layer.provideMerge(ServerConfig.layerTest(process.cwd(), { prefix: "storage-cleanup-" })), | ||
| 320 | Layer.provideMerge(NodeServices.layer), | ||
| 321 | ); | ||
| 322 | const runCleanupTest = <A, E>( | ||
| 323 | effect: Effect.Effect< | ||
| 324 | A, | ||
| 325 | E, | ||
| 326 | | FileSystem.FileSystem | ||
| 327 | | ServerConfig.ServerConfig | ||
| 328 | | GitVcsDriver.GitVcsDriver | ||
| 329 | | Path.Path | ||
| 330 | | Scope.Scope | ||
| 331 | >, | ||
| 332 | ) => effect.pipe(Effect.provide(cleanupTestLayer), Effect.scoped); | ||
| 333 | |||
| 334 | describe("storage cleanup reports and local file policies", () => { | ||
| 335 | it.live("measures nested regular files without following symlinks", () => | ||
| 336 | runCleanupTest( | ||
| 337 | Effect.gen(function* () { | ||
| 338 | const { service, fs, worktree, setPolicy } = yield* cleanupFixture; | ||
| 339 | setPolicy("tracked-changes"); | ||
| 340 | const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => | ||
| 341 | fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), | ||
| 342 | ); | ||
| 343 | const outside = yield* fs.makeTempDirectoryScoped(); | ||
| 344 | yield* fs.writeFileString(`${outside}/external`, "must not count"); | ||
| 345 | yield* fs.makeDirectory(`${worktree}/node_modules/nested`, { recursive: true }); | ||
| 346 | yield* fs.writeFileString(`${worktree}/node_modules/nested/file`, "count me"); | ||
| 347 | yield* fs.symlink(outside, `${worktree}/node_modules/directory-link`); | ||
| 348 | yield* fs.symlink(`${outside}/external`, `${worktree}/node_modules/file-link`); | ||
| 349 | yield* fs.symlink(`${outside}/missing`, `${worktree}/node_modules/dangling-link`); | ||
| 350 | const report = yield* service.runNow; | ||
| 351 | const bytes = sizes.reduce((sum, size) => sum + size, 8); | ||
| 352 | expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes, files: null }); | ||
| 353 | expect(report.bytesFreed).toBe(bytes); | ||
| 354 | expect(yield* fs.readFileString(`${outside}/external`)).toBe("must not count"); | ||
| 355 | }), | ||
| 356 | ), | ||
| 357 | ); | ||
| 358 | it.live("still removes a worktree when measuring its size fails", () => | ||
| 359 | runCleanupTest( | ||
| 360 | Effect.gen(function* () { | ||
| 361 | const fs = yield* FileSystem.FileSystem; | ||
| 362 | const { service, worktree } = yield* cleanupFixture.pipe( | ||
| 363 | Effect.provideService(FileSystem.FileSystem, { | ||
| 364 | ...fs, | ||
| 365 | readDirectory: (directory) => fs.readDirectory(`${directory}/missing-directory`), | ||
| 366 | }), | ||
| 367 | ); | ||
| 368 | const report = yield* service.runNow; | ||
| 369 | expect(report.entries[0]).toMatchObject({ outcome: "removed", bytes: null, files: null }); | ||
| 370 | expect(report.bytesFreed).toBe(0); | ||
| 371 | expect(yield* fs.exists(worktree)).toBe(false); | ||
| 372 | }), | ||
| 373 | ), | ||
| 374 | ); | ||
| 375 | it.live("keeps a worktree when its thread starts during size measurement", () => | ||
| 376 | runCleanupTest( | ||
| 377 | Effect.gen(function* () { | ||
| 378 | const fs = yield* FileSystem.FileSystem; | ||
| 379 | const measuring = yield* Deferred.make<void>(); | ||
| 380 | const resume = yield* Deferred.make<void>(); | ||
| 381 | let measuredPath: string | undefined; | ||
| 382 | const fixture = yield* cleanupFixture.pipe( | ||
| 383 | Effect.provideService(FileSystem.FileSystem, { | ||
| 384 | ...fs, | ||
| 385 | readDirectory: (directory) => | ||
| 386 | Effect.gen(function* () { | ||
| 387 | if (directory === measuredPath) { | ||
| 388 | yield* Deferred.succeed(measuring, undefined); | ||
| 389 | yield* Deferred.await(resume); | ||
| 390 | } | ||
| 391 | return yield* fs.readDirectory(directory); | ||
| 392 | }), | ||
| 393 | }), | ||
| 394 | ); | ||
| 395 | measuredPath = yield* fs.realPath(fixture.worktree); | ||
| 396 | fixture.setPolicy("tracked-changes"); | ||
| 397 | yield* fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); | ||
| 398 | const cleanup = yield* fixture.service.runNow.pipe(Effect.forkChild); | ||
| 399 | yield* Deferred.await(measuring); | ||
| 400 | fixture.setThreads([ | ||
| 401 | shell({ branch: "feature", worktreePath: fixture.worktree, status: "running" }), | ||
| 402 | ]); | ||
| 403 | yield* Deferred.succeed(resume, undefined); | ||
| 404 | const report = yield* Fiber.join(cleanup); | ||
| 405 | expect(report.entries[0]).toMatchObject({ | ||
| 406 | outcome: "kept", | ||
| 407 | reason: "Thread activity or shared worktree changed since check", | ||
| 408 | bytes: null, | ||
| 409 | }); | ||
| 410 | expect(report.bytesFreed).toBe(0); | ||
| 411 | expect(yield* fs.exists(fixture.worktree)).toBe(true); | ||
| 412 | expect(yield* fs.readFileString(`${fixture.worktree}/notes.txt`)).toBe("untracked\n"); | ||
| 413 | }), | ||
| 414 | ), | ||
| 415 | ); | ||
| 416 | it("decodes reports from servers without size fields", () => { | ||
| 417 | const report = decodeCleanupReport({ | ||
| 418 | trigger: "manual", | ||
| 419 | startedAt: "2026-06-10T12:00:00.000Z", | ||
| 420 | finishedAt: "2026-06-10T12:00:01.000Z", | ||
| 421 | entries: [ | ||
| 422 | { | ||
| 423 | kind: "worktree", | ||
| 424 | outcome: "removed", | ||
| 425 | reason: "Removed", | ||
| 426 | path: null, | ||
| 427 | threadId: null, | ||
| 428 | threadTitle: null, | ||
| 429 | }, | ||
| 430 | ], | ||
| 431 | counts: { removed: 1, kept: 0, failed: 0 }, | ||
| 432 | omittedCount: 0, | ||
| 433 | }); | ||
| 434 | expect(report.bytesFreed).toBe(0); | ||
| 435 | expect(report.entries[0]).toMatchObject({ bytes: null, files: null }); | ||
| 436 | }); | ||
| 437 | it.live.each([ | ||
| 438 | ["any-local-files", ".env", "kept"], | ||
| 439 | ["uncommitted-changes", ".env", "removed"], | ||
| 440 | ["tracked-changes", ".env", "removed"], | ||
| 441 | ["any-local-files", "notes.txt", "kept"], | ||
| 442 | ["uncommitted-changes", "notes.txt", "kept"], | ||
| 443 | ["tracked-changes", "notes.txt", "removed"], | ||
| 444 | ["any-local-files", "tracked.txt", "kept"], | ||
| 445 | ["uncommitted-changes", "tracked.txt", "kept"], | ||
| 446 | ["tracked-changes", "tracked.txt", "kept"], | ||
| 447 | ] as const)("applies %j", ([policy, file, outcome]) => | ||
| 448 | runCleanupTest( | ||
| 449 | Effect.gen(function* () { | ||
| 450 | const { service, fs, worktree, setPolicy } = yield* cleanupFixture; | ||
| 451 | setPolicy(policy); | ||
| 452 | expect(yield* service.latestReport).toBeNull(); | ||
| 453 | yield* fs.writeFileString(`${worktree}/${file}`, "local data\n"); | ||
| 454 | const sizes = yield* Effect.forEach(yield* fs.readDirectory(worktree), (name) => | ||
| 455 | fs.stat(`${worktree}/${name}`).pipe(Effect.map((stat) => Number(stat.size))), | ||
| 456 | ); | ||
| 457 | const bytes = sizes.reduce((sum, size) => sum + size, 0); | ||
| 458 | const report = yield* service.runNow; | ||
| 459 | yield* service.drain; | ||
| 460 | expect(report.trigger).toBe("manual"); | ||
| 461 | expect(report.entries.filter((entry) => entry.kind === "worktree")).toMatchObject([ | ||
| 462 | { | ||
| 463 | outcome, | ||
| 464 | path: worktree, | ||
| 465 | threadId: "thread-1", | ||
| 466 | threadTitle: "Thread", | ||
| 467 | bytes: outcome === "removed" ? bytes : null, | ||
| 468 | files: null, | ||
| 469 | }, | ||
| 470 | ]); | ||
| 471 | expect(report.bytesFreed).toBe(outcome === "removed" ? bytes : 0); | ||
| 472 | expect(yield* fs.exists(worktree)).toBe(outcome === "kept"); | ||
| 473 | expect(yield* service.latestReport).toEqual(report); | ||
| 474 | if (outcome === "kept") | ||
| 475 | expect(report.entries[0]?.reason).toContain( | ||
| 476 | file === ".env" ? "ignored files (.env)" : "uncommitted changes (1 file)", | ||
| 477 | ); | ||
| 478 | }), | ||
| 479 | ), | ||
| 480 | ); | ||
| 481 | it.live("preserves tracked edits written after the final status check", () => | ||
| 482 | runCleanupTest( | ||
| 483 | Effect.gen(function* () { | ||
| 484 | const fixture = yield* cleanupFixture; | ||
| 485 | fixture.setPolicy("tracked-changes"); | ||
| 486 | fixture.editBeforeRemoval(); | ||
| 487 | yield* fixture.fs.writeFileString(`${fixture.worktree}/notes.txt`, "untracked\n"); | ||
| 488 | const report = yield* fixture.service.runNow; | ||
| 489 | expect(report.entries[0]).toMatchObject({ outcome: "failed", bytes: null, files: null }); | ||
| 490 | expect(report.bytesFreed).toBe(0); | ||
| 491 | expect(report.entries[0]?.reason).toContain("contains modified or untracked files"); | ||
| 492 | expect(yield* fixture.fs.readFileString(`${fixture.worktree}/tracked.txt`)).toBe( | ||
| 493 | "late edit\n", | ||
| 494 | ); | ||
| 495 | expect(yield* fixture.fs.exists(`${fixture.worktree}/notes.txt`)).toBe(false); | ||
| 496 | }), | ||
| 497 | ), | ||
| 498 | ); | ||
| 499 | it.live("streams the current report and subsequent runs", () => | ||
| 500 | runCleanupTest( | ||
| 501 | Effect.gen(function* () { | ||
| 502 | const { service } = yield* cleanupFixture; | ||
| 503 | const reports = yield* service.reports.pipe( | ||
| 504 | Stream.take(2), | ||
| 505 | Stream.tap((report) => (report === null ? service.runNow : Effect.void)), | ||
| 506 | Stream.runCollect, | ||
| 507 | ); | ||
| 508 | expect(reports[0]).toBeNull(); | ||
| 509 | expect(reports[1]).toEqual(yield* service.latestReport); | ||
| 510 | expect(reports[1]?.counts).toEqual({ removed: 1, kept: 0, failed: 0 }); | ||
| 511 | }), | ||
| 512 | ), | ||
| 513 | ); | ||
| 514 | it.live("caps entries while preserving totals and prioritizing removals", () => | ||
| 515 | runCleanupTest( | ||
| 516 | Effect.gen(function* () { | ||
| 517 | const { service, fs, worktree, setThreads } = yield* cleanupFixture; | ||
| 518 | const threads = []; | ||
| 519 | for (let i = 0; i < 205; i++) { | ||
| 520 | const worktreePath = `${worktree}-${i}`; | ||
| 521 | yield* fs.makeDirectory(worktreePath); | ||
| 522 | threads.push( | ||
| 523 | shell({ | ||
| 524 | id: ThreadId.make(`thread-${i + 2}`), | ||
| 525 | branch: "feature", | ||
| 526 | worktreePath, | ||
| 527 | status: "running", | ||
| 528 | }), | ||
| 529 | ); | ||
| 530 | } | ||
| 531 | threads.push(shell({ branch: "feature", worktreePath: worktree })); | ||
| 532 | setThreads(threads); | ||
| 533 | const report = yield* service.runNow; | ||
| 534 | expect(report.entries).toHaveLength(200); | ||
| 535 | expect(report.omittedCount).toBe(6); | ||
| 536 | expect(report.counts).toEqual({ removed: 1, kept: 205, failed: 0 }); | ||
| 537 | expect(report.entries[0]?.outcome).toBe("removed"); | ||
| 538 | expect(report.entries[0]?.bytes).toBeGreaterThan(0); | ||
| 539 | expect(report.bytesFreed).toBe(report.entries[0]?.bytes); | ||
| 540 | }), | ||
| 541 | ), | ||
| 542 | ); | ||
| 543 | it.live("keeps staged edits in tracked-only mode", () => | ||
| 544 | runCleanupTest( | ||
| 545 | Effect.gen(function* () { | ||
| 546 | const { service, fs, worktree, setPolicy, command } = yield* cleanupFixture; | ||
| 547 | setPolicy("tracked-changes"); | ||
| 548 | yield* fs.writeFileString(`${worktree}/tracked.txt`, "staged\n"); | ||
| 549 | yield* command(worktree, ["add", "tracked.txt"]); | ||
| 550 | expect((yield* service.runNow).entries[0]).toMatchObject({ | ||
| 551 | outcome: "kept", | ||
| 552 | reason: "Has uncommitted changes (1 file)", | ||
| 553 | }); | ||
| 554 | expect(yield* fs.exists(worktree)).toBe(true); | ||
| 555 | }), | ||
| 556 | ), | ||
| 557 | ); | ||
| 558 | it.live( | ||
| 559 | "reports shared and running worktrees but omits disabled rules and missing directories", | ||
| 560 | () => | ||
| 561 | runCleanupTest( | ||
| 562 | Effect.gen(function* () { | ||
| 563 | const fixture = yield* cleanupFixture; | ||
| 564 | const { service, fs, worktree, setThreads } = fixture; | ||
| 565 | setThreads([shell({ branch: "feature", worktreePath: worktree, status: "running" })]); | ||
| 566 | expect((yield* service.runNow).entries[0]?.reason).toContain("Thread is running"); | ||
| 567 | setThreads([ | ||
| 568 | shell({ branch: "feature", worktreePath: worktree }), | ||
| 569 | shell({ id: ThreadId.make("thread-2"), branch: "feature", worktreePath: worktree }), | ||
| 570 | ]); | ||
| 571 | expect((yield* service.runNow).entries[0]?.reason).toBe("Shared by 2 threads"); | ||
| 572 | fixture.disable(); | ||
| 573 | expect((yield* service.runNow).entries).toEqual([]); | ||
| 574 | yield* fs.remove(worktree, { recursive: true }); | ||
| 575 | expect( | ||
| 576 | (yield* service.runNow).entries.filter((entry) => entry.kind === "worktree"), | ||
| 577 | ).toEqual([]); | ||
| 578 | }), | ||
| 579 | ), | ||
| 580 | ); | ||
| 581 | it.live("serializes concurrent requests and returns each run's report", () => | ||
| 582 | runCleanupTest( | ||
| 583 | Effect.gen(function* () { | ||
| 584 | const { service } = yield* cleanupFixture; | ||
| 585 | const reports = yield* Effect.all([service.runNow, service.runNow], { | ||
| 586 | concurrency: "unbounded", | ||
| 587 | }); | ||
| 588 | yield* service.drain; | ||
| 589 | expect( | ||
| 590 | reports.flatMap((report) => report.entries).filter((entry) => entry.kind === "worktree"), | ||
| 591 | ).toMatchObject([{ outcome: "removed" }]); | ||
| 592 | expect(yield* service.latestReport).toEqual(reports[1]); | ||
| 593 | }), | ||
| 594 | ), | ||
| 595 | ); | ||
| 596 | it.live("aggregates expired artifacts and rotated logs while retaining current logs", () => | ||
| 597 | runCleanupTest( | ||
| 598 | Effect.gen(function* () { | ||
| 599 | const { service, fs, config, enableFiles, disable } = yield* cleanupFixture; | ||
| 600 | disable(); | ||
| 601 | enableFiles(); | ||
| 602 | yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true }); | ||
| 603 | yield* fs.makeDirectory(config.logsDir, { recursive: true }); | ||
| 604 | for (const name of ["one.png", "two.webm"]) { | ||
| 605 | const file = config.browserArtifactsDir + "/" + name; | ||
| 606 | yield* fs.writeFileString(file, "capture"); | ||
| 607 | yield* fs.utimes(file, 1, 1); | ||
| 608 | } | ||
| 609 | for (const name of ["server.log.1", "server.log.2", "server.log"]) { | ||
| 610 | const file = config.logsDir + "/" + name; | ||
| 611 | yield* fs.writeFileString(file, "log"); | ||
| 612 | yield* fs.utimes(file, 1, 1); | ||
| 613 | } | ||
| 614 | const report = yield* service.runNow; | ||
| 615 | expect(report.entries.filter((entry) => entry.kind !== "worktree")).toMatchObject([ | ||
| 616 | { | ||
| 617 | kind: "browser-artifacts", | ||
| 618 | outcome: "removed", | ||
| 619 | reason: "Removed 2 browser artifacts", | ||
| 620 | bytes: 14, | ||
| 621 | files: 2, | ||
| 622 | }, | ||
| 623 | { | ||
| 624 | kind: "logs", | ||
| 625 | outcome: "removed", | ||
| 626 | reason: "Removed 2 rotated logs", | ||
| 627 | bytes: 6, | ||
| 628 | files: 2, | ||
| 629 | }, | ||
| 630 | ]); | ||
| 631 | expect(report.bytesFreed).toBe(20); | ||
| 632 | expect(yield* fs.readDirectory(config.browserArtifactsDir)).toEqual([]); | ||
| 633 | expect(yield* fs.exists(config.logsDir + "/server.log")).toBe(true); | ||
| 634 | expect(yield* fs.exists(config.logsDir + "/server.log.1")).toBe(false); | ||
| 635 | expect(yield* fs.exists(config.logsDir + "/server.log.2")).toBe(false); | ||
| 636 | }), | ||
| 637 | ), | ||
| 638 | ); | ||
| 639 | it.live("reports git failures without aborting the remaining categories", () => | ||
| 640 | runCleanupTest( | ||
| 641 | Effect.gen(function* () { | ||
| 642 | const { service, worktree, command, enableFiles } = yield* cleanupFixture; | ||
| 643 | enableFiles(); | ||
| 644 | yield* command(worktree, ["worktree", "lock", worktree]); | ||
| 645 | const report = yield* service.runNow; | ||
| 646 | expect(report.entries[0]).toMatchObject({ outcome: "failed" }); | ||
| 647 | expect(report.entries[0]?.reason).toContain("git worktree remove: fatal:"); | ||
| 648 | expect(report.entries.map((entry) => entry.kind)).toEqual([ | ||
| 649 | "worktree", | ||
| 650 | "browser-artifacts", | ||
| 651 | "logs", | ||
| 652 | ]); | ||
| 653 | }), | ||
| 654 | ), | ||
| 655 | ); | ||
| 656 | }); |
apps/server/src/storageCleanup.ts+341-100| ... | @@ -1,4 +1,5 @@ | ... | @@ -1,4 +1,5 @@ |
| 1 | import { | 1 | import { |
| 2 | GitCommandError, | ||
| 2 | OrchestrationV2AppThreadJson, | 3 | OrchestrationV2AppThreadJson, |
| 3 | OrchestrationV2ProviderSessionJson, | 4 | OrchestrationV2ProviderSessionJson, |
| 4 | } from "@t3tools/contracts"; | 5 | } from "@t3tools/contracts"; |
| ... | @@ -11,10 +12,14 @@ import type { | ... | @@ -11,10 +12,14 @@ import type { |
| 11 | ServerSettingsError, | 12 | ServerSettingsError, |
| 12 | TerminalSummary, | 13 | TerminalSummary, |
| 13 | WorktreeCleanupRules, | 14 | WorktreeCleanupRules, |
| 15 | StorageCleanupReport, | ||
| 16 | StorageCleanupReportEntry, | ||
| 14 | } from "@t3tools/contracts"; | 17 | } from "@t3tools/contracts"; |
| 15 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 16 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | 19 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; |
| 17 | import * as Cause from "effect/Cause"; | 20 | import * as Context from "effect/Context"; |
| 21 | import * as Deferred from "effect/Deferred"; | ||
| 22 | import * as Layer from "effect/Layer"; | ||
| 18 | import * as Clock from "effect/Clock"; | 23 | import * as Clock from "effect/Clock"; |
| 19 | import * as DateTime from "effect/DateTime"; | 24 | import * as DateTime from "effect/DateTime"; |
| 20 | import * as Effect from "effect/Effect"; | 25 | import * as Effect from "effect/Effect"; |
| ... | @@ -25,6 +30,7 @@ import * as Path from "effect/Path"; | ... | @@ -25,6 +30,7 @@ import * as Path from "effect/Path"; |
| 25 | import type { PlatformError } from "effect/PlatformError"; | 30 | import type { PlatformError } from "effect/PlatformError"; |
| 26 | import * as Schedule from "effect/Schedule"; | 31 | import * as Schedule from "effect/Schedule"; |
| 27 | import * as Stream from "effect/Stream"; | 32 | import * as Stream from "effect/Stream"; |
| 33 | import * as SubscriptionRef from "effect/SubscriptionRef"; | ||
| 28 | 34 | ||
| 29 | import * as ServerConfig from "./config.ts"; | 35 | import * as ServerConfig from "./config.ts"; |
| 30 | import * as GitManager from "./git/GitManager.ts"; | 36 | import * as GitManager from "./git/GitManager.ts"; |
| ... | @@ -49,6 +55,16 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( | ... | @@ -49,6 +55,16 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( |
| 49 | 55 | ||
| 50 | const DAY_MS = 86_400_000; | 56 | const DAY_MS = 86_400_000; |
| 51 | 57 | ||
| 58 | const sentence = (text: string) => text.charAt(0).toUpperCase() + text.slice(1); | ||
| 59 | const REPORT_ENTRY_LIMIT = 200; | ||
| 60 | const isGitCommandError = Schema.is(GitCommandError); | ||
| 61 | |||
| 62 | function cleanupFailureReason(error: { readonly message: string }) { | ||
| 63 | return isGitCommandError(error) | ||
| 64 | ? `${error.command}: ${error.detail.split(/\r?\n/)[0]}${error.reason ? ` (${error.reason})` : ""}` | ||
| 65 | : (error.message.split(/\r?\n/)[0] ?? error.message); | ||
| 66 | } | ||
| 67 | |||
| 52 | const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => | 68 | const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => |
| 53 | rules.worktreeAfterDays !== null || | 69 | rules.worktreeAfterDays !== null || |
| 54 | rules.worktreeOnMerge || | 70 | rules.worktreeOnMerge || |
| ... | @@ -138,7 +154,17 @@ export function storageCleanupPullRequestMerged( | ... | @@ -138,7 +154,17 @@ export function storageCleanupPullRequestMerged( |
| 138 | ); | 154 | ); |
| 139 | } | 155 | } |
| 140 | 156 | ||
| 141 | export const make = Effect.gen(function* () { | 157 | export class StorageCleanup extends Context.Service< |
| 158 | StorageCleanup, | ||
| 159 | { | ||
| 160 | readonly runNow: Effect.Effect<StorageCleanupReport, ServerSettingsError>; | ||
| 161 | readonly latestReport: Effect.Effect<StorageCleanupReport | null>; | ||
| 162 | readonly reports: Stream.Stream<StorageCleanupReport | null>; | ||
| 163 | readonly drain: Effect.Effect<void>; | ||
| 164 | } | ||
| 165 | >()("t3/storageCleanup") {} | ||
| 166 | |||
| 167 | const make = Effect.gen(function* () { | ||
| 142 | const config = yield* ServerConfig.ServerConfig; | 168 | const config = yield* ServerConfig.ServerConfig; |
| 143 | const settingsService = yield* Settings.ServerSettingsService; | 169 | const settingsService = yield* Settings.ServerSettingsService; |
| 144 | const projectStore = yield* ProjectStore.ProjectStoreV2; | 170 | const projectStore = yield* ProjectStore.ProjectStoreV2; |
| ... | @@ -181,6 +207,47 @@ export const make = Effect.gen(function* () { | ... | @@ -181,6 +207,47 @@ export const make = Effect.gen(function* () { |
| 181 | ); | 207 | ); |
| 182 | }); | 208 | }); |
| 183 | 209 | ||
| 210 | const measureWorktree = (worktreePath: string) => | ||
| 211 | Effect.gen(function* () { | ||
| 212 | const root = yield* fs.realPath(worktreePath); | ||
| 213 | const pending = [root]; | ||
| 214 | let entries = 1; | ||
| 215 | let bytes = 0; | ||
| 216 | while (pending.length > 0) { | ||
| 217 | const target = pending.pop()!; | ||
| 218 | // Effect's stat follows links. Probe with readLink first to get lstat | ||
| 219 | // semantics, including skipping dangling links and directory links. | ||
| 220 | const isLink = yield* fs.readLink(target).pipe( | ||
| 221 | Effect.as(true), | ||
| 222 | Effect.catchIf( | ||
| 223 | (error) => | ||
| 224 | error.cause instanceof Error && | ||
| 225 | "code" in error.cause && | ||
| 226 | error.cause.code === "EINVAL", | ||
| 227 | () => Effect.succeed(false), | ||
| 228 | ), | ||
| 229 | ); | ||
| 230 | if (isLink) continue; | ||
| 231 | if ((yield* fs.realPath(target)) !== target) return null; | ||
| 232 | const stat = yield* fs.stat(target); | ||
| 233 | if (stat.type === "File") bytes += Number(stat.size); | ||
| 234 | else if (stat.type === "Directory") { | ||
| 235 | const names = yield* fs.readDirectory(target); | ||
| 236 | entries += names.length; | ||
| 237 | if (entries > 2_000_000) return null; | ||
| 238 | for (const name of names) { | ||
| 239 | const child = path.join(target, name); | ||
| 240 | if (!inside(root, child)) return null; | ||
| 241 | pending.push(child); | ||
| 242 | } | ||
| 243 | } | ||
| 244 | } | ||
| 245 | return bytes; | ||
| 246 | }).pipe( | ||
| 247 | Effect.timeout("30 seconds"), | ||
| 248 | Effect.orElseSucceed(() => null), | ||
| 249 | ); | ||
| 250 | |||
| 184 | const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { | 251 | const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { |
| 185 | const active = yield* projections.getShellSnapshot(); | 252 | const active = yield* projections.getShellSnapshot(); |
| 186 | const archived = yield* projections.getShellSnapshot({ location: "archive" }); | 253 | const archived = yield* projections.getShellSnapshot({ location: "archive" }); |
| ... | @@ -204,11 +271,74 @@ export const make = Effect.gen(function* () { | ... | @@ -204,11 +271,74 @@ export const make = Effect.gen(function* () { |
| 204 | return false; | 271 | return false; |
| 205 | }); | 272 | }); |
| 206 | 273 | ||
| 274 | const localChangesReason = Effect.fn("StorageCleanup.localChangesReason")(function* ( | ||
| 275 | cwd: string, | ||
| 276 | rules: WorktreeCleanupRules, | ||
| 277 | ) { | ||
| 278 | const status = yield* git.execute({ | ||
| 279 | operation: "StorageCleanup.localChanges", | ||
| 280 | cwd, | ||
| 281 | args: [ | ||
| 282 | "status", | ||
| 283 | "--porcelain=v1", | ||
| 284 | "-z", | ||
| 285 | "--ignore-submodules=none", | ||
| 286 | rules.worktreeKeepWhen === "tracked-changes" | ||
| 287 | ? "--untracked-files=no" | ||
| 288 | : "--untracked-files=all", | ||
| 289 | ], | ||
| 290 | maxOutputBytes: 64 * 1024, | ||
| 291 | }); | ||
| 292 | if (status.stdoutTruncated) return "local changes exceed the inspection limit"; | ||
| 293 | const records = status.stdout.split("\0").filter(Boolean); | ||
| 294 | let count = 0; | ||
| 295 | for (let i = 0; i < records.length; i++) { | ||
| 296 | count++; | ||
| 297 | if (/^[RC]|^.[RC]/.test(records[i]!)) i++; | ||
| 298 | } | ||
| 299 | if (count > 0) return `has uncommitted changes (${count} ${count === 1 ? "file" : "files"})`; | ||
| 300 | if (rules.worktreeKeepWhen !== "any-local-files") return null; | ||
| 301 | const ignored = yield* git.execute({ | ||
| 302 | operation: "StorageCleanup.ignoredFiles", | ||
| 303 | cwd, | ||
| 304 | args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], | ||
| 305 | maxOutputBytes: 64 * 1024, | ||
| 306 | }); | ||
| 307 | if (ignored.stdoutTruncated) return "ignored files exceed the inspection limit"; | ||
| 308 | const file = ignored.stdout | ||
| 309 | .split("\0") | ||
| 310 | .find((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)); | ||
| 311 | return file === undefined ? null : `has ignored files (${file})`; | ||
| 312 | }); | ||
| 313 | |||
| 314 | const cleanupGit = Effect.fn("StorageCleanup.cleanupGit")(function* ( | ||
| 315 | cwd: string, | ||
| 316 | args: string[], | ||
| 317 | command: string, | ||
| 318 | ) { | ||
| 319 | const result = yield* git.execute({ | ||
| 320 | operation: "StorageCleanup.cleanupGit", | ||
| 321 | cwd, | ||
| 322 | args, | ||
| 323 | allowNonZeroExit: true, | ||
| 324 | timeoutMs: 300_000, | ||
| 325 | }); | ||
| 326 | if (result.exitCode !== 0) | ||
| 327 | return yield* Effect.fail( | ||
| 328 | new GitCommandError({ | ||
| 329 | operation: "StorageCleanup.cleanupGit", | ||
| 330 | command, | ||
| 331 | cwd, | ||
| 332 | detail: result.stderr.trim().split(/\r?\n/)[0]?.slice(0, 1000) || "command failed", | ||
| 333 | }), | ||
| 334 | ); | ||
| 335 | }); | ||
| 336 | |||
| 207 | const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( | 337 | const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( |
| 208 | serverSettings: ServerSettings, | 338 | serverSettings: ServerSettings, |
| 209 | now: number, | 339 | now: number, |
| 340 | entries: StorageCleanupReportEntry[], | ||
| 210 | ) { | 341 | ) { |
| 211 | if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; | ||
| 212 | const roots: Array<string> = []; | 342 | const roots: Array<string> = []; |
| 213 | for (const directory of managedWorktreesDirectories( | 343 | for (const directory of managedWorktreesDirectories( |
| 214 | serverSettings, | 344 | serverSettings, |
| ... | @@ -223,26 +353,17 @@ export const make = Effect.gen(function* () { | ... | @@ -223,26 +353,17 @@ export const make = Effect.gen(function* () { |
| 223 | ); | 353 | ); |
| 224 | if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); | 354 | if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); |
| 225 | } | 355 | } |
| 226 | if (roots.length === 0) return; | 356 | const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` |
| 227 | const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete); | ||
| 228 | const deletedRows = hasDeleteRule | ||
| 229 | ? yield* sql<{ payload_json: string; workspaceRoot: string }>` | ||
| 230 | SELECT t.payload_json, p.workspace_root AS "workspaceRoot" | 357 | SELECT t.payload_json, p.workspace_root AS "workspaceRoot" |
| 231 | FROM orchestration_v2_projection_threads t | 358 | FROM orchestration_v2_projection_threads t |
| 232 | JOIN projection_projects p ON p.project_id = t.project_id | 359 | JOIN projection_projects p ON p.project_id = t.project_id |
| 233 | WHERE t.deleted_at IS NOT NULL | 360 | WHERE t.deleted_at IS NOT NULL |
| 234 | ` | 361 | `; |
| 235 | : []; | ||
| 236 | const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => | 362 | const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => |
| 237 | decodeCleanupThread(row.payload_json).pipe( | 363 | decodeCleanupThread(row.payload_json).pipe( |
| 238 | Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), | 364 | Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), |
| 239 | ), | 365 | ), |
| 240 | )).filter( | 366 | )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); |
| 241 | (thread) => | ||
| 242 | thread.worktreePath !== null && | ||
| 243 | thread.branch !== null && | ||
| 244 | resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete, | ||
| 245 | ); | ||
| 246 | const snapshot = yield* readThreads(); | 367 | const snapshot = yield* readThreads(); |
| 247 | const refreshedDefaultRefs = new Map<string, Set<string>>(); | 368 | const refreshedDefaultRefs = new Map<string, Set<string>>(); |
| 248 | const groups = Map.groupBy( | 369 | const groups = Map.groupBy( |
| ... | @@ -250,7 +371,7 @@ export const make = Effect.gen(function* () { | ... | @@ -250,7 +371,7 @@ export const make = Effect.gen(function* () { |
| 250 | (thread) => path.resolve(thread.worktreePath!), | 371 | (thread) => path.resolve(thread.worktreePath!), |
| 251 | ); | 372 | ); |
| 252 | const candidates = [ | 373 | const candidates = [ |
| 253 | ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])), | 374 | ...[...groups.values()].map((group) => group[0]!), |
| 254 | ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), | 375 | ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), |
| 255 | ]; | 376 | ]; |
| 256 | for (const thread of candidates) { | 377 | for (const thread of candidates) { |
| ... | @@ -261,52 +382,58 @@ export const make = Effect.gen(function* () { | ... | @@ -261,52 +382,58 @@ export const make = Effect.gen(function* () { |
| 261 | const project = deleted | 382 | const project = deleted |
| 262 | ? { workspaceRoot: thread.workspaceRoot } | 383 | ? { workspaceRoot: thread.workspaceRoot } |
| 263 | : snapshot.projects.find((entry) => entry.id === thread.projectId); | 384 | : snapshot.projects.find((entry) => entry.id === thread.projectId); |
| 264 | if ( | 385 | const entry: StorageCleanupReportEntry = { |
| 265 | project === undefined || | 386 | kind: "worktree", |
| 266 | (!deleted && !storageCleanupThreadIdle(thread, now)) || | 387 | outcome: "kept", |
| 267 | hasTerminal(worktreePath) | 388 | reason: "", |
| 268 | ) | 389 | path: worktreePath, |
| 269 | continue; | 390 | threadId: thread.id, |
| 391 | threadTitle: thread.title, | ||
| 392 | bytes: null, | ||
| 393 | files: null, | ||
| 394 | }; | ||
| 395 | const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) }); | ||
| 270 | yield* Effect.gen(function* () { | 396 | yield* Effect.gen(function* () { |
| 271 | if (!(yield* fs.exists(worktreePath))) return; | 397 | if (!(yield* fs.exists(worktreePath))) return; |
| 398 | if (deleted && !settings.worktreeOnDelete) return keep("no rules apply"); | ||
| 399 | const shared = groups.get(worktreePath)?.length ?? 0; | ||
| 400 | if (shared > 1) return keep(`shared by ${shared} threads`); | ||
| 401 | if (project === undefined) return keep("project is unavailable"); | ||
| 402 | if (!deleted && !storageCleanupThreadIdle(thread, now)) | ||
| 403 | return keep("thread is running or has pending work"); | ||
| 404 | if (hasTerminal(worktreePath)) return keep("open terminal"); | ||
| 272 | // Roots are canonical, so compare canonical paths. A symlinked parent | 405 | // Roots are canonical, so compare canonical paths. A symlinked parent |
| 273 | // (a linked drive) is fine; a symlinked worktree directory is not. | 406 | // (a linked drive) is fine; a symlinked worktree directory is not. |
| 274 | const realPath = yield* fs.realPath(worktreePath); | 407 | const realPath = yield* fs.realPath(worktreePath); |
| 275 | const realParent = yield* fs.realPath(path.dirname(worktreePath)); | 408 | const realParent = yield* fs.realPath(path.dirname(worktreePath)); |
| 276 | if (realPath !== path.join(realParent, path.basename(worktreePath))) return; | 409 | if (realPath !== path.join(realParent, path.basename(worktreePath))) |
| 277 | if (!roots.some((root) => inside(root, realPath))) return; | 410 | return keep("worktree is a symbolic link"); |
| 278 | if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; | 411 | if (!roots.some((root) => inside(root, realPath))) |
| 412 | return keep("outside the managed worktree folder"); | ||
| 413 | if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) | ||
| 414 | return keep("contains a project checkout"); | ||
| 279 | // A linked worktree has a .git file. Never remove a main checkout. | 415 | // A linked worktree has a .git file. Never remove a main checkout. |
| 280 | if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return; | 416 | if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") |
| 417 | return keep("not a linked worktree"); | ||
| 281 | const status = yield* git.statusDetailsLocal(worktreePath); | 418 | const status = yield* git.statusDetailsLocal(worktreePath); |
| 282 | if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges) | 419 | if (!status.isRepo || status.branch !== thread.branch) |
| 283 | return; | 420 | return keep("repository or branch changed"); |
| 421 | const changes = yield* localChangesReason(worktreePath, settings); | ||
| 422 | if (changes !== null) return keep(changes); | ||
| 284 | const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); | 423 | const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" }); |
| 285 | const ignored = yield* git.execute({ | ||
| 286 | operation: "StorageCleanup.ignoredFiles", | ||
| 287 | cwd: worktreePath, | ||
| 288 | args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], | ||
| 289 | maxOutputBytes: 64 * 1024, | ||
| 290 | }); | ||
| 291 | // Ignored files can contain secrets or local datasets. Dependency installs | ||
| 292 | // are reproducible; every other ignored path prevents automatic removal. | ||
| 293 | if ( | ||
| 294 | ignored.stdoutTruncated || | ||
| 295 | ignored.stdout | ||
| 296 | .split("\0") | ||
| 297 | .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) | ||
| 298 | ) | ||
| 299 | return; | ||
| 300 | const old = | 424 | const old = |
| 301 | !deleted && | 425 | !deleted && |
| 302 | settings.worktreeAfterDays !== null && | 426 | settings.worktreeAfterDays !== null && |
| 303 | storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; | 427 | storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; |
| 304 | let eligible = deleted || old; | 428 | let eligible = deleted || old; |
| 429 | let removalReason = deleted | ||
| 430 | ? "thread was deleted" | ||
| 431 | : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`; | ||
| 305 | if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { | 432 | if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { |
| 306 | const repositoryCwd = path.resolve(project.workspaceRoot); | 433 | const repositoryCwd = path.resolve(project.workspaceRoot); |
| 307 | const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); | 434 | const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); |
| 308 | const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); | 435 | const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); |
| 309 | if (branch === null) return; | 436 | if (branch === null) return keep("default branch is unavailable"); |
| 310 | const defaultRef = `refs/remotes/${remote}/${branch}`; | 437 | const defaultRef = `refs/remotes/${remote}/${branch}`; |
| 311 | const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>(); | 438 | const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>(); |
| 312 | if (!refreshed.has(defaultRef)) { | 439 | if (!refreshed.has(defaultRef)) { |
| ... | @@ -330,6 +457,7 @@ export const make = Effect.gen(function* () { | ... | @@ -330,6 +457,7 @@ export const make = Effect.gen(function* () { |
| 330 | }); | 457 | }); |
| 331 | const integrated = ancestor.exitCode === 0; | 458 | const integrated = ancestor.exitCode === 0; |
| 332 | eligible = integrated && settings.worktreeUnchanged; | 459 | eligible = integrated && settings.worktreeUnchanged; |
| 460 | if (eligible) removalReason = "no commits beyond the default branch"; | ||
| 333 | if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { | 461 | if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { |
| 334 | const pullRequest = yield* gitManager.branchPullRequest( | 462 | const pullRequest = yield* gitManager.branchPullRequest( |
| 335 | { cwd: worktreePath, branch: thread.branch }, | 463 | { cwd: worktreePath, branch: thread.branch }, |
| ... | @@ -341,39 +469,49 @@ export const make = Effect.gen(function* () { | ... | @@ -341,39 +469,49 @@ export const make = Effect.gen(function* () { |
| 341 | headSha: head.commitSha, | 469 | headSha: head.commitSha, |
| 342 | integrated, | 470 | integrated, |
| 343 | }); | 471 | }); |
| 472 | if (eligible) removalReason = "pull request was merged"; | ||
| 344 | } | 473 | } |
| 345 | } | 474 | } |
| 346 | if (!eligible) return; | 475 | if (!eligible) |
| 347 | // Re-read after Git/host calls so a queued turn, resumed session or new | 476 | return keep( |
| 348 | // thread sharing this path cancels the removal. | 477 | !deleted && settings.worktreeAfterDays !== null |
| 478 | ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days` | ||
| 479 | : settings.worktreeOnMerge | ||
| 480 | ? "not merged" | ||
| 481 | : "has commits beyond the default branch", | ||
| 482 | ); | ||
| 483 | const bytes = yield* measureWorktree(worktreePath); | ||
| 484 | // Re-read after Git/host calls and size measurement so a queued turn, | ||
| 485 | // resumed session or new thread sharing this path cancels the removal. | ||
| 349 | const latestSnapshot = yield* readThreads(); | 486 | const latestSnapshot = yield* readThreads(); |
| 350 | if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) return; | 487 | if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) |
| 488 | return keep("contains a project checkout"); | ||
| 351 | const latest = latestSnapshot.threads.filter( | 489 | const latest = latestSnapshot.threads.filter( |
| 352 | (entry) => | 490 | (entry) => |
| 353 | entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, | 491 | entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath, |
| 354 | ); | 492 | ); |
| 355 | if (hasTerminal(worktreePath)) return; | 493 | if (hasTerminal(worktreePath)) return keep("open terminal"); |
| 356 | if (deleted) { | 494 | if (deleted) { |
| 357 | if ( | 495 | if ( |
| 358 | latest.length > 0 || | 496 | latest.length > 0 || |
| 359 | !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) | 497 | !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) |
| 360 | .worktreeOnDelete | 498 | .worktreeOnDelete |
| 361 | ) | 499 | ) |
| 362 | return; | 500 | return keep("thread or cleanup settings changed since check"); |
| 363 | // V2 deletion queues durable cleanup. Do not remove its checkout until | 501 | // V2 deletion queues durable cleanup. Do not remove its checkout until |
| 364 | // every effect has finished successfully or was explicitly cancelled. | 502 | // every effect has finished successfully or was explicitly cancelled. |
| 365 | const pendingCleanup = yield* sql` | 503 | const pendingCleanup = yield* sql` |
| 366 | SELECT 1 FROM orchestration_v2_effect_outbox | 504 | SELECT 1 FROM orchestration_v2_effect_outbox |
| 367 | WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 | 505 | WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1 |
| 368 | `; | 506 | `; |
| 369 | if (pendingCleanup.length > 0) return; | 507 | if (pendingCleanup.length > 0) return keep("thread deletion is still pending"); |
| 370 | } else if ( | 508 | } else if ( |
| 371 | latest.length !== 1 || | 509 | latest.length !== 1 || |
| 372 | latest[0]!.id !== thread.id || | 510 | latest[0]!.id !== thread.id || |
| 373 | !storageCleanupThreadIdle(latest[0]!, now) || | 511 | !storageCleanupThreadIdle(latest[0]!, now) || |
| 374 | storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) | 512 | storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) |
| 375 | ) | 513 | ) |
| 376 | return; | 514 | return keep("thread activity or shared worktree changed since check"); |
| 377 | // Sessions can outlive their run and can be shared across app threads. | 515 | // Sessions can outlive their run and can be shared across app threads. |
| 378 | const sessionRows = yield* sql<{ payload_json: string }>` | 516 | const sessionRows = yield* sql<{ payload_json: string }>` |
| 379 | SELECT payload_json FROM orchestration_v2_projection_provider_sessions | 517 | SELECT payload_json FROM orchestration_v2_projection_provider_sessions |
| ... | @@ -388,32 +526,17 @@ export const make = Effect.gen(function* () { | ... | @@ -388,32 +526,17 @@ export const make = Effect.gen(function* () { |
| 388 | return cwd === worktreePath || inside(worktreePath, cwd); | 526 | return cwd === worktreePath || inside(worktreePath, cwd); |
| 389 | }) | 527 | }) |
| 390 | ) | 528 | ) |
| 391 | return; | 529 | return keep("provider session is still open"); |
| 392 | const finalStatus = yield* git.statusDetailsLocal(worktreePath); | 530 | const finalStatus = yield* git.statusDetailsLocal(worktreePath); |
| 393 | if ( | 531 | if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) |
| 394 | !finalStatus.isRepo || | 532 | return keep("repository or branch changed since check"); |
| 395 | finalStatus.branch !== thread.branch || | ||
| 396 | finalStatus.hasWorkingTreeChanges | ||
| 397 | ) | ||
| 398 | return; | ||
| 399 | if ( | 533 | if ( |
| 400 | (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== | 534 | (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== |
| 401 | head.commitSha | 535 | head.commitSha |
| 402 | ) | 536 | ) |
| 403 | return; | 537 | return keep("commit changed since check"); |
| 404 | const finalIgnored = yield* git.execute({ | 538 | const finalChanges = yield* localChangesReason(worktreePath, settings); |
| 405 | operation: "StorageCleanup.ignoredFiles", | 539 | if (finalChanges !== null) return keep(finalChanges); |
| 406 | cwd: worktreePath, | ||
| 407 | args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"], | ||
| 408 | maxOutputBytes: 64 * 1024, | ||
| 409 | }); | ||
| 410 | if ( | ||
| 411 | finalIgnored.stdoutTruncated || | ||
| 412 | finalIgnored.stdout | ||
| 413 | .split("\0") | ||
| 414 | .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry)) | ||
| 415 | ) | ||
| 416 | return; | ||
| 417 | const current = resolveWorktreeCleanup( | 540 | const current = resolveWorktreeCleanup( |
| 418 | yield* settingsService.getSettings, | 541 | yield* settingsService.getSettings, |
| 419 | thread.projectId, | 542 | thread.projectId, |
| ... | @@ -424,8 +547,17 @@ export const make = Effect.gen(function* () { | ... | @@ -424,8 +547,17 @@ export const make = Effect.gen(function* () { |
| 424 | current[key as keyof typeof settings] !== settings[key as keyof typeof settings], | 547 | current[key as keyof typeof settings] !== settings[key as keyof typeof settings], |
| 425 | ) | 548 | ) |
| 426 | ) | 549 | ) |
| 427 | return; | 550 | return keep("settings changed since check"); |
| 428 | yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); | 551 | // Clean only untracked files; Git must still refuse removal if a tracked |
| 552 | // edit arrives after our last status check. | ||
| 553 | if (settings.worktreeKeepWhen === "tracked-changes") | ||
| 554 | yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean"); | ||
| 555 | yield* cleanupGit( | ||
| 556 | project.workspaceRoot, | ||
| 557 | ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath], | ||
| 558 | "git worktree remove", | ||
| 559 | ); | ||
| 560 | entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes }); | ||
| 429 | yield* gitManager.invalidateStatus(project.workspaceRoot); | 561 | yield* gitManager.invalidateStatus(project.workspaceRoot); |
| 430 | // Preserve branch and path: ProviderTurnStartService recreates the checkout | 562 | // Preserve branch and path: ProviderTurnStartService recreates the checkout |
| 431 | // from that branch when the thread is resumed. | 563 | // from that branch when the thread is resumed. |
| ... | @@ -433,7 +565,17 @@ export const make = Effect.gen(function* () { | ... | @@ -433,7 +565,17 @@ export const make = Effect.gen(function* () { |
| 433 | }).pipe( | 565 | }).pipe( |
| 434 | (effect) => withWorkspaceLease(worktreePath, effect), | 566 | (effect) => withWorkspaceLease(worktreePath, effect), |
| 435 | Effect.catch((error) => | 567 | Effect.catch((error) => |
| 436 | Effect.logDebug("storage cleanup skipped worktree", { threadId: thread.id, error }), | 568 | Effect.gen(function* () { |
| 569 | entries.push({ | ||
| 570 | ...entry, | ||
| 571 | outcome: "failed", | ||
| 572 | reason: cleanupFailureReason(error), | ||
| 573 | }); | ||
| 574 | yield* Effect.logWarning("storage cleanup failed for worktree", { | ||
| 575 | threadId: thread.id, | ||
| 576 | error, | ||
| 577 | }); | ||
| 578 | }), | ||
| 437 | ), | 579 | ), |
| 438 | ); | 580 | ); |
| 439 | } | 581 | } |
| ... | @@ -444,10 +586,12 @@ export const make = Effect.gen(function* () { | ... | @@ -444,10 +586,12 @@ export const make = Effect.gen(function* () { |
| 444 | days: number | null, | 586 | days: number | null, |
| 445 | now: number, | 587 | now: number, |
| 446 | rotatedLogs: boolean, | 588 | rotatedLogs: boolean, |
| 589 | // Owned by the caller so files removed before a failure still count. | ||
| 590 | removed: { files: number; bytes: number }, | ||
| 447 | ) { | 591 | ) { |
| 448 | if (days === null || !(yield* fs.exists(root))) return; | 592 | if (days === null || !(yield* fs.exists(root))) return removed; |
| 449 | const realRoot = yield* fs.realPath(root); | 593 | const realRoot = yield* fs.realPath(root); |
| 450 | if (realRoot !== path.resolve(root)) return; | 594 | if (realRoot !== path.resolve(root)) return removed; |
| 451 | const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( | 595 | const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( |
| 452 | directory: string, | 596 | directory: string, |
| 453 | ): Effect.fn.Return<void, PlatformError | ServerSettingsError> { | 597 | ): Effect.fn.Return<void, PlatformError | ServerSettingsError> { |
| ... | @@ -464,40 +608,129 @@ export const make = Effect.gen(function* () { | ... | @@ -464,40 +608,129 @@ export const make = Effect.gen(function* () { |
| 464 | if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) | 608 | if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) |
| 465 | return; | 609 | return; |
| 466 | yield* fs.remove(target); | 610 | yield* fs.remove(target); |
| 611 | removed.files++; | ||
| 612 | removed.bytes += Number(stat.size); | ||
| 467 | } | 613 | } |
| 468 | } | 614 | } |
| 469 | } | 615 | } |
| 470 | }); | 616 | }); |
| 471 | yield* visit(realRoot); | 617 | yield* visit(realRoot); |
| 618 | return removed; | ||
| 472 | }); | 619 | }); |
| 473 | 620 | ||
| 474 | const sweep = Effect.fn("StorageCleanup.sweep")(function* () { | 621 | const reportRef = yield* SubscriptionRef.make<StorageCleanupReport | null>(null); |
| 622 | const sweep = Effect.fn("StorageCleanup.sweep")(function* ( | ||
| 623 | trigger: StorageCleanupReport["trigger"], | ||
| 624 | ) { | ||
| 625 | const now = yield* Clock.currentTimeMillis; | ||
| 475 | const serverSettings = yield* settingsService.getSettings; | 626 | const serverSettings = yield* settingsService.getSettings; |
| 476 | const settings = serverSettings.storageCleanup; | 627 | const settings = serverSettings.storageCleanup; |
| 477 | const now = yield* Clock.currentTimeMillis; | 628 | const entries: StorageCleanupReportEntry[] = []; |
| 478 | yield* cleanWorktrees(serverSettings, now).pipe( | 629 | yield* cleanWorktrees(serverSettings, now, entries).pipe( |
| 479 | Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })), | 630 | Effect.catch((error) => |
| 480 | ); | 631 | Effect.gen(function* () { |
| 481 | yield* cleanFiles( | 632 | entries.push({ |
| 482 | config.browserArtifactsDir, | 633 | kind: "worktree", |
| 483 | settings.browserArtifactsAfterDays, | 634 | outcome: "failed", |
| 484 | now, | 635 | reason: cleanupFailureReason(error), |
| 485 | false, | 636 | path: null, |
| 486 | ).pipe( | 637 | threadId: null, |
| 487 | Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })), | 638 | threadTitle: null, |
| 488 | ); | 639 | bytes: null, |
| 489 | yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe( | 640 | files: null, |
| 490 | Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })), | 641 | }); |
| 642 | yield* Effect.logWarning("worktree cleanup failed", { error }); | ||
| 643 | }), | ||
| 644 | ), | ||
| 491 | ); | 645 | ); |
| 646 | for (const category of [ | ||
| 647 | { | ||
| 648 | kind: "browser-artifacts" as const, | ||
| 649 | root: config.browserArtifactsDir, | ||
| 650 | days: settings.browserArtifactsAfterDays, | ||
| 651 | label: "browser artifacts", | ||
| 652 | }, | ||
| 653 | { | ||
| 654 | kind: "logs" as const, | ||
| 655 | root: config.logsDir, | ||
| 656 | days: settings.logsAfterDays, | ||
| 657 | label: "rotated logs", | ||
| 658 | }, | ||
| 659 | ]) { | ||
| 660 | if (category.days === null) continue; | ||
| 661 | const removed = { files: 0, bytes: 0 }; | ||
| 662 | yield* cleanFiles(category.root, category.days, now, category.kind === "logs", removed).pipe( | ||
| 663 | Effect.map(({ files, bytes }) => | ||
| 664 | entries.push({ | ||
| 665 | kind: category.kind, | ||
| 666 | outcome: files > 0 ? "removed" : "kept", | ||
| 667 | reason: files === 0 ? "No expired files" : `Removed ${files} ${category.label}`, | ||
| 668 | path: null, | ||
| 669 | threadId: null, | ||
| 670 | threadTitle: null, | ||
| 671 | bytes: files > 0 ? bytes : null, | ||
| 672 | files: files > 0 ? files : null, | ||
| 673 | }), | ||
| 674 | ), | ||
| 675 | Effect.catch((error) => | ||
| 676 | Effect.gen(function* () { | ||
| 677 | entries.push({ | ||
| 678 | kind: category.kind, | ||
| 679 | outcome: "failed", | ||
| 680 | reason: cleanupFailureReason(error), | ||
| 681 | path: null, | ||
| 682 | threadId: null, | ||
| 683 | threadTitle: null, | ||
| 684 | bytes: removed.files > 0 ? removed.bytes : null, | ||
| 685 | files: removed.files > 0 ? removed.files : null, | ||
| 686 | }); | ||
| 687 | yield* Effect.logWarning("storage file cleanup failed", { kind: category.kind, error }); | ||
| 688 | }), | ||
| 689 | ), | ||
| 690 | ); | ||
| 691 | } | ||
| 692 | const counts = { removed: 0, kept: 0, failed: 0 }; | ||
| 693 | let bytesFreed = 0; | ||
| 694 | for (const entry of entries) { | ||
| 695 | counts[entry.outcome]++; | ||
| 696 | bytesFreed += entry.bytes ?? 0; | ||
| 697 | } | ||
| 698 | const priority = { failed: 0, removed: 1, kept: 2 }; | ||
| 699 | const latestReport: StorageCleanupReport = { | ||
| 700 | trigger, | ||
| 701 | startedAt: DateTime.formatIso(DateTime.makeUnsafe(now)), | ||
| 702 | finishedAt: DateTime.formatIso(yield* DateTime.now), | ||
| 703 | entries: entries | ||
| 704 | .sort((a, b) => priority[a.outcome] - priority[b.outcome]) | ||
| 705 | .slice(0, REPORT_ENTRY_LIMIT), | ||
| 706 | counts, | ||
| 707 | bytesFreed, | ||
| 708 | omittedCount: Math.max(0, entries.length - REPORT_ENTRY_LIMIT), | ||
| 709 | }; | ||
| 710 | yield* SubscriptionRef.set(reportRef, latestReport); | ||
| 711 | return latestReport; | ||
| 492 | }); | 712 | }); |
| 493 | const worker = yield* makeDrainableWorker(() => | 713 | const worker = yield* makeDrainableWorker( |
| 494 | sweep().pipe( | 714 | (completion: Deferred.Deferred<StorageCleanupReport, ServerSettingsError> | undefined) => |
| 495 | Effect.catchCauseIf( | 715 | sweep(completion === undefined ? "automatic" : "manual").pipe( |
| 496 | (cause) => !Cause.hasInterruptsOnly(cause), | 716 | Effect.exit, |
| 497 | (cause) => Effect.logWarning("storage cleanup failed", { cause }), | 717 | Effect.flatMap((exit) => |
| 718 | completion === undefined | ||
| 719 | ? exit.pipe( | ||
| 720 | Effect.asVoid, | ||
| 721 | Effect.catchCause((cause) => | ||
| 722 | Effect.logWarning("storage cleanup failed", { cause }), | ||
| 723 | ), | ||
| 724 | ) | ||
| 725 | : Deferred.done(completion, exit).pipe(Effect.asVoid), | ||
| 726 | ), | ||
| 498 | ), | 727 | ), |
| 499 | ), | ||
| 500 | ); | 728 | ); |
| 729 | const runNow = Effect.gen(function* () { | ||
| 730 | const completion = yield* Deferred.make<StorageCleanupReport, ServerSettingsError>(); | ||
| 731 | yield* worker.enqueue(completion); | ||
| 732 | return yield* Deferred.await(completion); | ||
| 733 | }); | ||
| 501 | 734 | ||
| 502 | const start = Effect.fn("StorageCleanup.start")(function* () { | 735 | const start = Effect.fn("StorageCleanup.start")(function* () { |
| 503 | const unsubscribe = yield* terminals.subscribeMetadata((event) => | 736 | const unsubscribe = yield* terminals.subscribeMetadata((event) => |
| ... | @@ -552,5 +785,13 @@ export const make = Effect.gen(function* () { | ... | @@ -552,5 +785,13 @@ export const make = Effect.gen(function* () { |
| 552 | ), | 785 | ), |
| 553 | ); | 786 | ); |
| 554 | }); | 787 | }); |
| 555 | return { start, drain: worker.drain }; | 788 | yield* start(); |
| 789 | return StorageCleanup.of({ | ||
| 790 | runNow, | ||
| 791 | latestReport: SubscriptionRef.get(reportRef), | ||
| 792 | reports: SubscriptionRef.changes(reportRef), | ||
| 793 | drain: worker.drain, | ||
| 794 | }); | ||
| 556 | }); | 795 | }); |
| 796 | |||
| 797 | export const layer = Layer.effect(StorageCleanup, make); |
apps/server/src/ws.ts+4| ... | @@ -176,6 +176,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; | ... | @@ -176,6 +176,7 @@ import * as ServerSelfUpdate from "./cloud/selfUpdate.ts"; |
| 176 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; | 176 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 177 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; | 177 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 178 | import * as ServerSettings from "./serverSettings.ts"; | 178 | import * as ServerSettings from "./serverSettings.ts"; |
| 179 | import * as StorageCleanup from "./storageCleanup.ts"; | ||
| 179 | import * as TerminalManager from "./terminal/Manager.ts"; | 180 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 180 | import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; | 181 | import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; |
| 181 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; | 182 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| ... | @@ -1278,6 +1279,7 @@ const layerWsRpc = ( | ... | @@ -1278,6 +1279,7 @@ const layerWsRpc = ( |
| 1278 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; | 1279 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; |
| 1279 | const config = yield* ServerConfig.ServerConfig; | 1280 | const config = yield* ServerConfig.ServerConfig; |
| 1280 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; | 1281 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 1282 | const storageCleanup = yield* StorageCleanup.StorageCleanup; | ||
| 1281 | const serverSettings = yield* ServerSettings.ServerSettingsService; | 1283 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1282 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; | 1284 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 1283 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; | 1285 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| ... | @@ -2382,6 +2384,8 @@ const layerWsRpc = ( | ... | @@ -2382,6 +2384,8 @@ const layerWsRpc = ( |
| 2382 | const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); | 2384 | const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); |
| 2383 | return { keybindings: keybindingsConfig, issues: [] }; | 2385 | return { keybindings: keybindingsConfig, issues: [] }; |
| 2384 | }), | 2386 | }), |
| 2387 | [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, | ||
| 2388 | [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, | ||
| 2385 | [WS_METHODS.serverGetSettings]: (_input) => | 2389 | [WS_METHODS.serverGetSettings]: (_input) => |
| 2386 | serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), | 2390 | serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), |
| 2387 | [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => | 2391 | [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+1-12| ... | @@ -39,6 +39,7 @@ import { | ... | @@ -39,6 +39,7 @@ import { |
| 39 | useResourceTelemetryHistory, | 39 | useResourceTelemetryHistory, |
| 40 | } from "../../lib/resourceTelemetryState"; | 40 | } from "../../lib/resourceTelemetryState"; |
| 41 | import { cn } from "../../lib/utils"; | 41 | import { cn } from "../../lib/utils"; |
| 42 | import { formatBytes } from "../../lib/formatBytes"; | ||
| 42 | import { ensureLocalApi } from "../../localApi"; | 43 | import { ensureLocalApi } from "../../localApi"; |
| 43 | import { serverEnvironment } from "../../state/server"; | 44 | import { serverEnvironment } from "../../state/server"; |
| 44 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; | 45 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; |
| ... | @@ -65,18 +66,6 @@ const HISTORY_WINDOWS = [ | ... | @@ -65,18 +66,6 @@ const HISTORY_WINDOWS = [ |
| 65 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, | 66 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, |
| 66 | ] as const; | 67 | ] as const; |
| 67 | 68 | ||
| 68 | function formatBytes(value: number): string { | ||
| 69 | if (value < 1_024) return `${Math.round(value)} B`; | ||
| 70 | const units = ["KB", "MB", "GB", "TB"] as const; | ||
| 71 | let next = value; | ||
| 72 | let unitIndex = -1; | ||
| 73 | do { | ||
| 74 | next /= 1_024; | ||
| 75 | unitIndex += 1; | ||
| 76 | } while (next >= 1_024 && unitIndex < units.length - 1); | ||
| 77 | return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; | ||
| 78 | } | ||
| 79 | |||
| 80 | function formatRate(value: number): string { | 69 | function formatRate(value: number): string { |
| 81 | return `${formatBytes(value)}/s`; | 70 | return `${formatBytes(value)}/s`; |
| 82 | } | 71 | } |
apps/web/src/components/settings/StorageSettings.tsx+298-2| ... | @@ -1,6 +1,22 @@ | ... | @@ -1,6 +1,22 @@ |
| 1 | import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; | 1 | import type { |
| 2 | StorageCleanupReport, | ||
| 3 | StorageCleanupSettings, | ||
| 4 | WorktreeCleanupRules, | ||
| 5 | } from "@t3tools/contracts"; | ||
| 2 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | 6 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 3 | import { useRef, useState } from "react"; | 7 | import { useMemo, useRef, useState } from "react"; |
| 8 | import { useAtomValue } from "@effect/atom-react"; | ||
| 9 | import { Atom } from "effect/reactivity"; | ||
| 10 | import { | ||
| 11 | isAtomCommandInterrupted, | ||
| 12 | squashAtomCommandFailure, | ||
| 13 | } from "@t3tools/client-runtime/state/runtime"; | ||
| 14 | import { formatBytes } from "../../lib/formatBytes"; | ||
| 15 | import { serverEnvironment } from "../../state/server"; | ||
| 16 | import { useAtomCommand } from "../../state/use-atom-command"; | ||
| 17 | import { formatRelativeTimeLabel } from "../../timestampFormat"; | ||
| 18 | import { Button } from "../ui/button"; | ||
| 19 | import { toastManager } from "../ui/toast"; | ||
| 4 | 20 | ||
| 5 | import { Input } from "../ui/input"; | 21 | import { Input } from "../ui/input"; |
| 6 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | 22 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | @@ -17,8 +33,11 @@ import { | ... | @@ -17,8 +33,11 @@ import { |
| 17 | SettingsPageContainer, | 33 | SettingsPageContainer, |
| 18 | SettingsRow, | 34 | SettingsRow, |
| 19 | SettingsSection, | 35 | SettingsSection, |
| 36 | useRelativeTimeTick, | ||
| 20 | } from "./settingsLayout"; | 37 | } from "./settingsLayout"; |
| 21 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; | 38 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 39 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | ||
| 40 | import { SettingsGroup } from "./SettingsGroup"; | ||
| 22 | import type { ScopedSettingsTarget } from "./scopedSettings"; | 41 | import type { ScopedSettingsTarget } from "./scopedSettings"; |
| 23 | import { useSettingsScope } from "./SettingsScopeContext"; | 42 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 24 | import { searchableSetting } from "./settingsSearch"; | 43 | import { searchableSetting } from "./settingsSearch"; |
| ... | @@ -144,6 +163,239 @@ function RetentionControl({ | ... | @@ -144,6 +163,239 @@ function RetentionControl({ |
| 144 | ); | 163 | ); |
| 145 | } | 164 | } |
| 146 | 165 | ||
| 166 | const KEEP_WHEN_LABELS = { | ||
| 167 | "any-local-files": "Any local files", | ||
| 168 | "uncommitted-changes": "Uncommitted changes", | ||
| 169 | "tracked-changes": "Edited tracked files", | ||
| 170 | } as const; | ||
| 171 | |||
| 172 | function CleanupResults({ | ||
| 173 | report, | ||
| 174 | environmentLabel, | ||
| 175 | }: { | ||
| 176 | report: StorageCleanupReport; | ||
| 177 | environmentLabel: string | undefined; | ||
| 178 | }) { | ||
| 179 | return ( | ||
| 180 | <> | ||
| 181 | {report.entries.map((entry, index) => { | ||
| 182 | const name = | ||
| 183 | entry.kind === "worktree" | ||
| 184 | ? (entry.path?.split(/[\\/]/).findLast(Boolean) ?? "Worktrees") | ||
| 185 | : entry.kind === "logs" | ||
| 186 | ? "Rotated logs" | ||
| 187 | : "Browser artifacts"; | ||
| 188 | return ( | ||
| 189 | <SettingsRow | ||
| 190 | key={`${entry.path ?? entry.kind}:${entry.threadId ?? index}`} | ||
| 191 | title={environmentLabel ? `${environmentLabel}: ${name}` : name} | ||
| 192 | description={ | ||
| 193 | <span | ||
| 194 | className={`wrap-anywhere ${entry.outcome === "failed" ? "text-destructive" : ""}`} | ||
| 195 | > | ||
| 196 | {entry.threadTitle && entry.threadTitle !== name ? `${entry.threadTitle}. ` : ""} | ||
| 197 | {entry.reason} | ||
| 198 | </span> | ||
| 199 | } | ||
| 200 | control={ | ||
| 201 | entry.outcome === "removed" && ( | ||
| 202 | <span className="text-xs text-muted-foreground"> | ||
| 203 | {[ | ||
| 204 | entry.kind !== "worktree" && entry.files !== null | ||
| 205 | ? `${entry.files.toLocaleString()} ${entry.files === 1 ? "file" : "files"}` | ||
| 206 | : null, | ||
| 207 | entry.bytes !== null ? formatBytes(entry.bytes) : null, | ||
| 208 | ] | ||
| 209 | .filter(Boolean) | ||
| 210 | .join(" · ")} | ||
| 211 | </span> | ||
| 212 | ) | ||
| 213 | } | ||
| 214 | /> | ||
| 215 | ); | ||
| 216 | })} | ||
| 217 | {report.omittedCount > 0 && ( | ||
| 218 | <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4"> | ||
| 219 | {environmentLabel ? `${environmentLabel}: ` : ""} | ||
| 220 | {report.omittedCount.toLocaleString()} more results. | ||
| 221 | </p> | ||
| 222 | )} | ||
| 223 | {report.entries.length === 0 && report.omittedCount === 0 && ( | ||
| 224 | <p className="px-3 py-2.5 text-xs text-muted-foreground sm:px-4"> | ||
| 225 | {environmentLabel ? `${environmentLabel}: ` : ""}Nothing needed cleanup. | ||
| 226 | </p> | ||
| 227 | )} | ||
| 228 | </> | ||
| 229 | ); | ||
| 230 | } | ||
| 231 | |||
| 232 | function CleanupSection() { | ||
| 233 | useRelativeTimeTick(60_000); | ||
| 234 | const { connectedEnvironments } = useSettingsScope(); | ||
| 235 | const [pending, setPending] = useState(false); | ||
| 236 | const run = useAtomCommand(serverEnvironment.runStorageCleanup); | ||
| 237 | const supported = connectedEnvironments.filter( | ||
| 238 | (environment) => environment.serverConfig?.environment.capabilities.storageCleanupRun === true, | ||
| 239 | ); | ||
| 240 | const state = useAtomValue( | ||
| 241 | useMemo( | ||
| 242 | () => | ||
| 243 | Atom.make((get) => | ||
| 244 | connectedEnvironments.flatMap((environment) => { | ||
| 245 | if (environment.serverConfig?.environment.capabilities.storageCleanupRun !== true) | ||
| 246 | return []; | ||
| 247 | const query = get( | ||
| 248 | serverEnvironment.storageCleanupReport({ | ||
| 249 | environmentId: environment.environmentId, | ||
| 250 | input: {}, | ||
| 251 | }), | ||
| 252 | ); | ||
| 253 | return [ | ||
| 254 | { | ||
| 255 | environmentId: environment.environmentId, | ||
| 256 | allowed: get( | ||
| 257 | serverEnvironment.runStorageCleanup.permissionAtom(environment.environmentId), | ||
| 258 | ), | ||
| 259 | report: query._tag === "Success" ? query.value : null, | ||
| 260 | loading: query.waiting, | ||
| 261 | failed: query._tag === "Failure", | ||
| 262 | }, | ||
| 263 | ]; | ||
| 264 | }), | ||
| 265 | ), | ||
| 266 | [connectedEnvironments], | ||
| 267 | ), | ||
| 268 | ); | ||
| 269 | const hasRules = supported.some((environment) => { | ||
| 270 | const settings = environment.serverConfig?.settings; | ||
| 271 | if (!settings) return false; | ||
| 272 | const enabled = (rules: WorktreeCleanupRules) => | ||
| 273 | rules.worktreeAfterDays !== null || | ||
| 274 | rules.worktreeOnMerge || | ||
| 275 | rules.worktreeOnDelete || | ||
| 276 | rules.worktreeUnchanged; | ||
| 277 | return ( | ||
| 278 | settings.storageCleanup.browserArtifactsAfterDays !== null || | ||
| 279 | settings.storageCleanup.logsAfterDays !== null || | ||
| 280 | enabled(resolveWorktreeCleanup(settings, null)) || | ||
| 281 | Object.values(settings.projectSettingsOverrides).some( | ||
| 282 | (override) => | ||
| 283 | override.worktreeCleanup?.mode === "custom" && enabled(override.worktreeCleanup.rules), | ||
| 284 | ) | ||
| 285 | ); | ||
| 286 | }); | ||
| 287 | if (supported.length === 0) return null; | ||
| 288 | const summaries = supported.map((environment) => { | ||
| 289 | const query = state.find((entry) => entry.environmentId === environment.environmentId); | ||
| 290 | const report = query?.report ?? null; | ||
| 291 | return { | ||
| 292 | environment, | ||
| 293 | report, | ||
| 294 | description: report | ||
| 295 | ? `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.` : ""}` | ||
| 296 | : query?.failed | ||
| 297 | ? "Could not load the last run." | ||
| 298 | : query?.loading | ||
| 299 | ? "Loading last run…" | ||
| 300 | : "Cleanup hasn't run yet.", | ||
| 301 | }; | ||
| 302 | }); | ||
| 303 | const deleteNow = async () => { | ||
| 304 | if (pending) return; | ||
| 305 | setPending(true); | ||
| 306 | try { | ||
| 307 | await Promise.all( | ||
| 308 | supported | ||
| 309 | .filter((environment) => | ||
| 310 | state.some( | ||
| 311 | (entry) => entry.environmentId === environment.environmentId && entry.allowed, | ||
| 312 | ), | ||
| 313 | ) | ||
| 314 | .map(async (environment) => { | ||
| 315 | const result = await run({ environmentId: environment.environmentId, input: {} }); | ||
| 316 | if (result._tag === "Success") { | ||
| 317 | const failures = result.value.entries.filter((entry) => entry.outcome === "failed"); | ||
| 318 | if (failures.length > 0) | ||
| 319 | toastManager.add({ | ||
| 320 | type: "error", | ||
| 321 | title: `Cleanup failed on ${environment.label}`, | ||
| 322 | description: failures[0]!.reason, | ||
| 323 | }); | ||
| 324 | } else if (!isAtomCommandInterrupted(result)) { | ||
| 325 | toastManager.add({ | ||
| 326 | type: "error", | ||
| 327 | title: `Could not run cleanup on ${environment.label}`, | ||
| 328 | description: String(squashAtomCommandFailure(result)), | ||
| 329 | }); | ||
| 330 | } | ||
| 331 | }), | ||
| 332 | ); | ||
| 333 | } finally { | ||
| 334 | setPending(false); | ||
| 335 | } | ||
| 336 | }; | ||
| 337 | return ( | ||
| 338 | <SettingsSection id="storage-cleanup" title="Cleanup" variant="plain"> | ||
| 339 | <SettingsGroup> | ||
| 340 | <SettingsRow | ||
| 341 | {...searchableSetting("storage-delete-now")} | ||
| 342 | description={ | ||
| 343 | <span> | ||
| 344 | {!hasRules | ||
| 345 | ? "Turn on a cleanup rule to use this." | ||
| 346 | : summaries.map(({ environment, description }) => ( | ||
| 347 | <span key={environment.environmentId} className="block"> | ||
| 348 | {supported.length > 1 ? `${environment.label}: ` : ""} | ||
| 349 | {description} | ||
| 350 | </span> | ||
| 351 | ))} | ||
| 352 | </span> | ||
| 353 | } | ||
| 354 | control={ | ||
| 355 | <Button | ||
| 356 | variant="outline" | ||
| 357 | size="sm" | ||
| 358 | disabled={pending || !hasRules || !state.some((entry) => entry.allowed)} | ||
| 359 | onClick={() => void deleteNow()} | ||
| 360 | > | ||
| 361 | {pending ? "Deleting…" : "Delete now"} | ||
| 362 | </Button> | ||
| 363 | } | ||
| 364 | /> | ||
| 365 | </SettingsGroup> | ||
| 366 | {summaries.some(({ report }) => report !== null) && ( | ||
| 367 | <FoldedSettingsSection | ||
| 368 | id="storage-cleanup-last-run" | ||
| 369 | title="Last run" | ||
| 370 | headerPlacement="inside" | ||
| 371 | summary={summaries | ||
| 372 | .flatMap(({ environment, report }) => | ||
| 373 | report | ||
| 374 | ? [ | ||
| 375 | `${connectedEnvironments.length > 1 ? `${environment.label}: ` : ""}${report.counts.removed} removed, ${report.counts.kept} kept${report.counts.failed > 0 ? `, ${report.counts.failed} failed` : ""}`, | ||
| 376 | ] | ||
| 377 | : [], | ||
| 378 | ) | ||
| 379 | .join(" · ")} | ||
| 380 | > | ||
| 381 | {summaries.map( | ||
| 382 | ({ environment, report }) => | ||
| 383 | report && ( | ||
| 384 | <CleanupResults | ||
| 385 | key={environment.environmentId} | ||
| 386 | report={report} | ||
| 387 | environmentLabel={ | ||
| 388 | connectedEnvironments.length > 1 ? environment.label : undefined | ||
| 389 | } | ||
| 390 | /> | ||
| 391 | ), | ||
| 392 | )} | ||
| 393 | </FoldedSettingsSection> | ||
| 394 | )} | ||
| 395 | </SettingsSection> | ||
| 396 | ); | ||
| 397 | } | ||
| 398 | |||
| 147 | export function StorageSettingsPanel() { | 399 | export function StorageSettingsPanel() { |
| 148 | const { scope, connectedEnvironments, targets, target } = useSettingsScope(); | 400 | const { scope, connectedEnvironments, targets, target } = useSettingsScope(); |
| 149 | const scopedSettings = useScopedSettings(); | 401 | const scopedSettings = useScopedSettings(); |
| ... | @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { | ... | @@ -312,6 +564,49 @@ export function StorageSettingsPanel() { |
| 312 | /> | 564 | /> |
| 313 | } | 565 | } |
| 314 | /> | 566 | /> |
| 567 | {connectedEnvironments.every( | ||
| 568 | (environment) => | ||
| 569 | environment.serverConfig?.environment.capabilities.storageCleanupRun === true, | ||
| 570 | ) && ( | ||
| 571 | <SettingsRow | ||
| 572 | {...searchableSetting("storage-worktree-keep-when")} | ||
| 573 | status={ruleStatus("worktreeKeepWhen")} | ||
| 574 | description={ | ||
| 575 | settings.worktreeKeepWhen === "any-local-files" | ||
| 576 | ? "Includes ignored files, except node_modules." | ||
| 577 | : settings.worktreeKeepWhen === "tracked-changes" | ||
| 578 | ? "Deletes untracked and ignored files, including .env." | ||
| 579 | : "Keeps edits and untracked files. Deletes ignored files, including .env." | ||
| 580 | } | ||
| 581 | serverScoped={!isProjectScope} | ||
| 582 | control={ | ||
| 583 | <Select | ||
| 584 | value={ruleStatus("worktreeKeepWhen") ? null : settings.worktreeKeepWhen} | ||
| 585 | onValueChange={(next) => { | ||
| 586 | if ( | ||
| 587 | next === "any-local-files" || | ||
| 588 | next === "uncommitted-changes" || | ||
| 589 | next === "tracked-changes" | ||
| 590 | ) | ||
| 591 | updateWorktree({ worktreeKeepWhen: next }); | ||
| 592 | }} | ||
| 593 | > | ||
| 594 | <SelectTrigger size="sm" aria-label="Keep worktrees with local changes"> | ||
| 595 | <SelectValue> | ||
| 596 | {ruleStatus("worktreeKeepWhen") | ||
| 597 | ? "Mixed" | ||
| 598 | : KEEP_WHEN_LABELS[settings.worktreeKeepWhen]} | ||
| 599 | </SelectValue> | ||
| 600 | </SelectTrigger> | ||
| 601 | <SelectPopup align="end" alignItemWithTrigger={false}> | ||
| 602 | <SelectItem value="any-local-files">Any local files</SelectItem> | ||
| 603 | <SelectItem value="uncommitted-changes">Uncommitted changes</SelectItem> | ||
| 604 | <SelectItem value="tracked-changes">Edited tracked files</SelectItem> | ||
| 605 | </SelectPopup> | ||
| 606 | </Select> | ||
| 607 | } | ||
| 608 | /> | ||
| 609 | )} | ||
| 315 | </> | 610 | </> |
| 316 | )} | 611 | )} |
| 317 | </SettingsSection> | 612 | </SettingsSection> |
| ... | @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { | ... | @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { |
| 346 | /> | 641 | /> |
| 347 | </SettingsSection> | 642 | </SettingsSection> |
| 348 | )} | 643 | )} |
| 644 | {!isProjectScope && <CleanupSection />} | ||
| 349 | </SettingsPageContainer> | 645 | </SettingsPageContainer> |
| 350 | ); | 646 | ); |
| 351 | } | 647 | } |
apps/web/src/components/settings/scopedSettings.test.ts+6| ... | @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { | ... | @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { |
| 160 | worktreeOnDelete: true, | 160 | worktreeOnDelete: true, |
| 161 | worktreeOnMerge: true, | 161 | worktreeOnMerge: true, |
| 162 | worktreeUnchanged: false, | 162 | worktreeUnchanged: false, |
| 163 | worktreeKeepWhen: "uncommitted-changes", | ||
| 163 | }, | 164 | }, |
| 164 | }, | 165 | }, |
| 165 | }, | 166 | }, |
| ... | @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { | ... | @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { |
| 183 | worktreeOnDelete: false, | 184 | worktreeOnDelete: false, |
| 184 | worktreeOnMerge: true, | 185 | worktreeOnMerge: true, |
| 185 | worktreeUnchanged: false, | 186 | worktreeUnchanged: false, |
| 187 | worktreeKeepWhen: "uncommitted-changes", | ||
| 186 | }, | 188 | }, |
| 187 | { | 189 | { |
| 188 | worktreeAfterDays: null, | 190 | worktreeAfterDays: null, |
| 189 | worktreeOnDelete: false, | 191 | worktreeOnDelete: false, |
| 190 | worktreeOnMerge: false, | 192 | worktreeOnMerge: false, |
| 191 | worktreeUnchanged: false, | 193 | worktreeUnchanged: false, |
| 194 | worktreeKeepWhen: "uncommitted-changes", | ||
| 192 | }, | 195 | }, |
| 193 | ]); | 196 | ]); |
| 194 | }); | 197 | }); |
| ... | @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { | ... | @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { |
| 295 | worktreeOnDelete: false, | 298 | worktreeOnDelete: false, |
| 296 | worktreeOnMerge: true, | 299 | worktreeOnMerge: true, |
| 297 | worktreeUnchanged: false, | 300 | worktreeUnchanged: false, |
| 301 | worktreeKeepWhen: "uncommitted-changes", | ||
| 298 | }, | 302 | }, |
| 299 | }, | 303 | }, |
| 300 | }, | 304 | }, |
| ... | @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { | ... | @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { |
| 315 | worktreeOnDelete: true, | 319 | worktreeOnDelete: true, |
| 316 | worktreeOnMerge: true, | 320 | worktreeOnMerge: true, |
| 317 | worktreeUnchanged: false, | 321 | worktreeUnchanged: false, |
| 322 | worktreeKeepWhen: "uncommitted-changes", | ||
| 318 | }, | 323 | }, |
| 319 | }, | 324 | }, |
| 320 | }, | 325 | }, |
| ... | @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { | ... | @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { |
| 328 | worktreeOnDelete: true, | 333 | worktreeOnDelete: true, |
| 329 | worktreeOnMerge: false, | 334 | worktreeOnMerge: false, |
| 330 | worktreeUnchanged: false, | 335 | worktreeUnchanged: false, |
| 336 | worktreeKeepWhen: "uncommitted-changes", | ||
| 331 | }, | 337 | }, |
| 332 | }, | 338 | }, |
| 333 | }, | 339 | }, |
apps/web/src/components/settings/settingsSearch.ts+17| ... | @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right | ... | @@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right |
| 134 | * that may not be mounted point at their nearest stable section instead. | 134 | * that may not be mounted point at their nearest stable section instead. |
| 135 | */ | 135 | */ |
| 136 | export const SETTINGS_SEARCH_ITEMS = [ | 136 | export const SETTINGS_SEARCH_ITEMS = [ |
| 137 | { | ||
| 138 | id: "storage-worktree-keep-when", | ||
| 139 | targetId: "storage-worktrees", | ||
| 140 | title: "Keep worktrees with local changes", | ||
| 141 | to: "/settings/storage", | ||
| 142 | scope: "project-defaults", | ||
| 143 | searchTerms: [ | ||
| 144 | "any local files uncommitted changes edited tracked files ignored env build output cleanup", | ||
| 145 | ], | ||
| 146 | }, | ||
| 147 | { | ||
| 148 | id: "storage-delete-now", | ||
| 149 | title: "Delete now", | ||
| 150 | to: "/settings/storage", | ||
| 151 | scope: "environment-defaults", | ||
| 152 | searchTerms: ["storage cleanup run now results report removed kept failed"], | ||
| 153 | }, | ||
| 137 | { | 154 | { |
| 138 | id: "storage-worktrees", | 155 | id: "storage-worktrees", |
| 139 | title: "Worktree cleanup", | 156 | title: "Worktree cleanup", |
apps/web/src/lib/formatBytes.ts created+11| ... | @@ -0,0 +1,11 @@ | ||
| 1 | export function formatBytes(value: number): string { | ||
| 2 | if (value < 1_024) return `${Math.round(value)} B`; | ||
| 3 | const units = ["KB", "MB", "GB", "TB"] as const; | ||
| 4 | let next = value; | ||
| 5 | let unitIndex = -1; | ||
| 6 | do { | ||
| 7 | next /= 1_024; | ||
| 8 | unitIndex += 1; | ||
| 9 | } while (next >= 1_024 && unitIndex < units.length - 1); | ||
| 10 | return `${next.toFixed(next >= 100 ? 0 : next >= 10 ? 1 : 2)} ${units[unitIndex]}`; | ||
| 11 | } | ||
docs/user/project-settings.md+8-2| ... | @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. | ... | @@ -143,8 +143,11 @@ captures and log retention remain machine-wide. |
| 143 | 143 | ||
| 144 | Worktrees can be removed after a chosen number of inactive days, after merging, or when they | 144 | Worktrees can be removed after a chosen number of inactive days, after merging, or when they |
| 145 | have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active | 145 | have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active |
| 146 | sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` | 146 | sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults |
| 147 | prevent removal. Branches and thread history stay; starting another turn recreates the checkout. | 147 | to **Uncommitted changes**, which protects tracked edits and untracked files but deletes ignored |
| 148 | files such as `.env` and build output. **Any local files** also protects ignored files other than | ||
| 149 | `node_modules`. **Edited tracked files** protects only tracked edits and allows untracked and | ||
| 150 | ignored files to be deleted. Branches and thread history stay; starting another turn recreates the checkout. | ||
| 148 | Merge cleanup requires a merged pull request whose commits are included in the remote default | 151 | Merge cleanup requires a merged pull request whose commits are included in the remote default |
| 149 | branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default | 152 | branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default |
| 150 | branch and the worktree is still at the pull request's last commit. | 153 | 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 | ... | @@ -154,6 +157,9 @@ thread is deleted, including archived threads and worktrees left by earlier dele |
| 154 | server waits for sessions and terminals to stop and retries skipped worktrees after restart. | 157 | server waits for sessions and terminals to stop and retries skipped worktrees after restart. |
| 155 | Existing prompts for deleting a worktree manually remain available when this policy is off. | 158 | Existing prompts for deleting a worktree manually remain available when this policy is off. |
| 156 | 159 | ||
| 160 | Choose **Delete now** to run the enabled rules immediately. The latest results show which | ||
| 161 | worktrees were removed or kept and why, plus any failures. | ||
| 162 | |||
| 157 | Browser captures and rotated logs have separate retention periods. Expired capture links stop | 163 | Browser captures and rotated logs have separate retention periods. Expired capture links stop |
| 158 | working. Current logs, message attachments, and browser profiles are kept. | 164 | working. Current logs, message attachments, and browser profiles are kept. |
| 159 | 165 |
packages/client-runtime/src/rpc/client.ts+1| ... | @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = | ... | @@ -58,6 +58,7 @@ export type EnvironmentSubscriptionRpcTag = |
| 58 | | typeof WS_METHODS.subscribeServerConfig | 58 | | typeof WS_METHODS.subscribeServerConfig |
| 59 | | typeof WS_METHODS.subscribeServerLifecycle | 59 | | typeof WS_METHODS.subscribeServerLifecycle |
| 60 | | typeof WS_METHODS.scheduledTasksSubscribe | 60 | | typeof WS_METHODS.scheduledTasksSubscribe |
| 61 | | typeof WS_METHODS.serverGetStorageCleanupReport | ||
| 61 | | typeof WS_METHODS.subscribeTerminalEvents | 62 | | typeof WS_METHODS.subscribeTerminalEvents |
| 62 | | typeof WS_METHODS.subscribeTerminalMetadata | 63 | | typeof WS_METHODS.subscribeTerminalMetadata |
| 63 | | typeof WS_METHODS.subscribePreviewEvents | 64 | | typeof WS_METHODS.subscribePreviewEvents |
packages/client-runtime/src/state/commandPermissions.test.ts+27| ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; | ... | @@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest"; |
| 8 | import { vi } from "vite-plus/test"; | 8 | import { vi } from "vite-plus/test"; |
| 9 | import { | 9 | import { |
| 10 | AuthOrchestrationOperateScope, | 10 | AuthOrchestrationOperateScope, |
| 11 | AuthSettingsWriteScope, | ||
| 11 | AuthSourceControlWriteScope, | 12 | AuthSourceControlWriteScope, |
| 12 | ThreadId, | 13 | ThreadId, |
| 13 | EnvironmentId, | 14 | EnvironmentId, |
| ... | @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { | ... | @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { |
| 58 | }); | 59 | }); |
| 59 | 60 | ||
| 60 | describe("command permissions", () => { | 61 | describe("command permissions", () => { |
| 62 | it.effect("requires the destination settings grant to run storage cleanup", () => | ||
| 63 | Effect.scoped( | ||
| 64 | Effect.gen(function* () { | ||
| 65 | const registry = yield* setup; | ||
| 66 | const cleanup = createCommandPermissions(runtime, WS_METHODS.serverRunStorageCleanup); | ||
| 67 | registry.set(sessions(env), AsyncResult.success(grant(true))); | ||
| 68 | expect(registry.get(cleanup.permissionAtom(env))).toBe(false); | ||
| 69 | expect((yield* cleanup.authorize(registry, env).pipe(Effect.flip))._tag).toBe( | ||
| 70 | "EnvironmentAuthorizationError", | ||
| 71 | ); | ||
| 72 | registry.set( | ||
| 73 | sessions(env), | ||
| 74 | AsyncResult.success({ | ||
| 75 | ...grant(true), | ||
| 76 | scopes: [AuthSettingsWriteScope], | ||
| 77 | permissions: [AuthSettingsWriteScope], | ||
| 78 | }), | ||
| 79 | ); | ||
| 80 | expect(registry.get(cleanup.permissionAtom(env))).toBe(true); | ||
| 81 | yield* cleanup.authorize(registry, env); | ||
| 82 | registry.set(sessions(other), AsyncResult.success(grant(false))); | ||
| 83 | expect(registry.get(cleanup.permissionAtom(other))).toBe(false); | ||
| 84 | }), | ||
| 85 | ), | ||
| 86 | ); | ||
| 87 | |||
| 61 | it.effect("uses the target grant for both availability and execution", () => | 88 | it.effect("uses the target grant for both availability and execution", () => |
| 62 | Effect.scoped( | 89 | Effect.scoped( |
| 63 | Effect.gen(function* () { | 90 | Effect.gen(function* () { |
packages/client-runtime/src/state/server.ts+10| ... | @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms<R, E>( | ... | @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms<R, E>( |
| 983 | 983 | ||
| 984 | return { | 984 | return { |
| 985 | configValueAtom, | 985 | configValueAtom, |
| 986 | runStorageCleanup: createEnvironmentRpcCommand(runtime, { | ||
| 987 | label: "environment-data:server:run-storage-cleanup", | ||
| 988 | tag: WS_METHODS.serverRunStorageCleanup, | ||
| 989 | concurrency: { mode: "singleFlight", key: ({ environmentId }) => environmentId }, | ||
| 990 | }), | ||
| 991 | storageCleanupReport: createEnvironmentRpcSubscriptionAtomFamily(runtime, { | ||
| 992 | label: "environment-data:server:storage-cleanup-report", | ||
| 993 | tag: WS_METHODS.serverGetStorageCleanupReport, | ||
| 994 | idleTtlMs: 0, | ||
| 995 | }), | ||
| 986 | updateStateAtom, | 996 | updateStateAtom, |
| 987 | settingsValueAtom, | 997 | settingsValueAtom, |
| 988 | providersValueAtom, | 998 | providersValueAtom, |
packages/contracts/src/clientRpcPermissions.ts+2| ... | @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; | ... | @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; |
| 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; | 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; |
| 3 | import { | 3 | import { |
| 4 | AuthOrchestrationOperateScope, | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSettingsWriteScope, | ||
| 5 | AuthSourceControlWriteScope, | 6 | AuthSourceControlWriteScope, |
| 6 | type AuthEnvironmentScope, | 7 | type AuthEnvironmentScope, |
| 7 | } from "./auth.ts"; | 8 | } from "./auth.ts"; |
| ... | @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; | ... | @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; |
| 9 | 10 | ||
| 10 | /** Incremental client enforcement; the server still authorizes every request. */ | 11 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 11 | export const CLIENT_GUARDED_RPC_SCOPES = { | 12 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 13 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, | ||
| 12 | [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, | 14 | [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, |
| 13 | [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, | 15 | [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, |
| 14 | [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, | 16 | [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, |
packages/contracts/src/environment.ts+1| ... | @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ | ... | @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 125 | /** Server evaluates merge and inactivity settlement without a client. */ | 125 | /** Server evaluates merge and inactivity settlement without a client. */ |
| 126 | threadAutoSettlement: Schema.optionalKey(Schema.Boolean), | 126 | threadAutoSettlement: Schema.optionalKey(Schema.Boolean), |
| 127 | storageCleanup: Schema.optionalKey(Schema.Boolean), | 127 | storageCleanup: Schema.optionalKey(Schema.Boolean), |
| 128 | storageCleanupRun: Schema.optionalKey(Schema.Boolean), | ||
| 128 | projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), | 129 | projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), |
| 129 | /** Server honors the `worktreesDirectory` setting. */ | 130 | /** Server honors the `worktreesDirectory` setting. */ |
| 130 | worktreesDirectory: Schema.optionalKey(Schema.Boolean), | 131 | worktreesDirectory: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/rpc.ts+22-1| ... | @@ -317,7 +317,12 @@ import { | ... | @@ -317,7 +317,12 @@ import { |
| 317 | ProviderConsumeResetCreditResult, | 317 | ProviderConsumeResetCreditResult, |
| 318 | } from "./providerUsageLimits.ts"; | 318 | } from "./providerUsageLimits.ts"; |
| 319 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; | 319 | import { UsagePricing, UsageReadError, UsageSummary, UsageSummaryInput } from "./usage.ts"; |
| 320 | import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./settings.ts"; | 320 | import { |
| 321 | StorageCleanupReport, | ||
| 322 | ServerSettings, | ||
| 323 | ServerSettingsError, | ||
| 324 | ServerSettingsPatch, | ||
| 325 | } from "./settings.ts"; | ||
| 321 | import { | 326 | import { |
| 322 | ScheduledTaskDeleteInput, | 327 | ScheduledTaskDeleteInput, |
| 323 | ScheduledTaskDeleteResult, | 328 | ScheduledTaskDeleteResult, |
| ... | @@ -468,6 +473,8 @@ export const WS_METHODS = { | ... | @@ -468,6 +473,8 @@ export const WS_METHODS = { |
| 468 | serverCommitDesktopUpdate: "server.commitDesktopUpdate", | 473 | serverCommitDesktopUpdate: "server.commitDesktopUpdate", |
| 469 | serverUpsertKeybinding: "server.upsertKeybinding", | 474 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 470 | serverRemoveKeybinding: "server.removeKeybinding", | 475 | serverRemoveKeybinding: "server.removeKeybinding", |
| 476 | serverRunStorageCleanup: "server.runStorageCleanup", | ||
| 477 | serverGetStorageCleanupReport: "server.getStorageCleanupReport", | ||
| 471 | serverGetSettings: "server.getSettings", | 478 | serverGetSettings: "server.getSettings", |
| 472 | serverUpdateSettings: "server.updateSettings", | 479 | serverUpdateSettings: "server.updateSettings", |
| 473 | serverDiscoverSourceControl: "server.discoverSourceControl", | 480 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| ... | @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp | ... | @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp |
| 730 | error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), | 737 | error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]), |
| 731 | }); | 738 | }); |
| 732 | 739 | ||
| 740 | const WsServerRunStorageCleanupRpc = Rpc.make(WS_METHODS.serverRunStorageCleanup, { | ||
| 741 | payload: Schema.Struct({}), | ||
| 742 | success: StorageCleanupReport, | ||
| 743 | error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), | ||
| 744 | }); | ||
| 745 | const WsServerGetStorageCleanupReportRpc = Rpc.make(WS_METHODS.serverGetStorageCleanupReport, { | ||
| 746 | payload: Schema.Struct({}), | ||
| 747 | success: Schema.NullOr(StorageCleanupReport), | ||
| 748 | stream: true, | ||
| 749 | error: EnvironmentAuthorizationError, | ||
| 750 | }); | ||
| 751 | |||
| 733 | const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { | 752 | const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 734 | payload: Schema.Struct({}), | 753 | payload: Schema.Struct({}), |
| 735 | success: ServerSettings, | 754 | success: ServerSettings, |
| ... | @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( | ... | @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1833 | WsServerCommitDesktopUpdateRpc, | 1852 | WsServerCommitDesktopUpdateRpc, |
| 1834 | WsServerUpsertKeybindingRpc, | 1853 | WsServerUpsertKeybindingRpc, |
| 1835 | WsServerRemoveKeybindingRpc, | 1854 | WsServerRemoveKeybindingRpc, |
| 1855 | WsServerRunStorageCleanupRpc, | ||
| 1856 | WsServerGetStorageCleanupReportRpc, | ||
| 1836 | WsServerGetSettingsRpc, | 1857 | WsServerGetSettingsRpc, |
| 1837 | WsServerUpdateSettingsRpc, | 1858 | WsServerUpdateSettingsRpc, |
| 1838 | WsServerDiscoverSourceControlRpc, | 1859 | WsServerDiscoverSourceControlRpc, |
packages/contracts/src/settings.test.ts+24| ... | @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { | ... | @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { |
| 57 | worktreeOnMerge: false, | 57 | worktreeOnMerge: false, |
| 58 | worktreeOnDelete: false, | 58 | worktreeOnDelete: false, |
| 59 | worktreeUnchanged: false, | 59 | worktreeUnchanged: false, |
| 60 | worktreeKeepWhen: "uncommitted-changes", | ||
| 60 | browserArtifactsAfterDays: null, | 61 | browserArtifactsAfterDays: null, |
| 61 | logsAfterDays: null, | 62 | logsAfterDays: null, |
| 62 | }); | 63 | }); |
| ... | @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { | ... | @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { |
| 86 | ).toThrow(); | 87 | ).toThrow(); |
| 87 | }); | 88 | }); |
| 88 | 89 | ||
| 90 | it("decodes older custom rules with the default local-file policy", () => { | ||
| 91 | const settings = decodeServerSettings({ | ||
| 92 | worktreeCleanup: { | ||
| 93 | mode: "custom", | ||
| 94 | rules: { | ||
| 95 | worktreeAfterDays: 8, | ||
| 96 | worktreeOnMerge: false, | ||
| 97 | worktreeOnDelete: false, | ||
| 98 | worktreeUnchanged: false, | ||
| 99 | }, | ||
| 100 | }, | ||
| 101 | }); | ||
| 102 | expect(settings.worktreeCleanup).toMatchObject({ | ||
| 103 | rules: { worktreeKeepWhen: "uncommitted-changes" }, | ||
| 104 | }); | ||
| 105 | expect( | ||
| 106 | decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }), | ||
| 107 | ).toEqual({ storageCleanup: { worktreeKeepWhen: "tracked-changes" } }); | ||
| 108 | expect(() => | ||
| 109 | decodeServerSettingsPatch({ storageCleanup: { worktreeKeepWhen: "unknown" } }), | ||
| 110 | ).toThrow(); | ||
| 111 | }); | ||
| 112 | |||
| 89 | it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { | 113 | it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { |
| 90 | expect(() => | 114 | expect(() => |
| 91 | decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), | 115 | decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), |
packages/contracts/src/settings.ts+38| ... | @@ -13,6 +13,7 @@ import { | ... | @@ -13,6 +13,7 @@ import { |
| 13 | ForwardCompatibleOptional, | 13 | ForwardCompatibleOptional, |
| 14 | OmittedWhenNull, | 14 | OmittedWhenNull, |
| 15 | ProjectId, | 15 | ProjectId, |
| 16 | ThreadId, | ||
| 16 | TrimmedNonEmptyString, | 17 | TrimmedNonEmptyString, |
| 17 | TrimmedString, | 18 | TrimmedString, |
| 18 | } from "./baseSchemas.ts"; | 19 | } from "./baseSchemas.ts"; |
| ... | @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( | ... | @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( |
| 941 | Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), | 942 | Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), |
| 942 | ); | 943 | ); |
| 943 | 944 | ||
| 945 | export const WorktreeKeepWhen = Schema.Literals([ | ||
| 946 | "any-local-files", | ||
| 947 | "uncommitted-changes", | ||
| 948 | "tracked-changes", | ||
| 949 | ]); | ||
| 950 | export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type; | ||
| 951 | |||
| 944 | export const WorktreeCleanupRules = Schema.Struct({ | 952 | export const WorktreeCleanupRules = Schema.Struct({ |
| 953 | worktreeKeepWhen: WorktreeKeepWhen.pipe( | ||
| 954 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), | ||
| 955 | ), | ||
| 945 | worktreeAfterDays: StorageRetentionDays, | 956 | worktreeAfterDays: StorageRetentionDays, |
| 946 | worktreeOnMerge: Schema.Boolean, | 957 | worktreeOnMerge: Schema.Boolean, |
| 947 | worktreeOnDelete: Schema.Boolean, | 958 | worktreeOnDelete: Schema.Boolean, |
| ... | @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettin | ... | @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettin |
| 1034 | "sidebarAutoSettleAfterDays", | 1045 | "sidebarAutoSettleAfterDays", |
| 1035 | ]); | 1046 | ]); |
| 1036 | 1047 | ||
| 1048 | export const StorageCleanupReportEntry = Schema.Struct({ | ||
| 1049 | kind: Schema.Literals(["worktree", "browser-artifacts", "logs"]), | ||
| 1050 | outcome: Schema.Literals(["removed", "kept", "failed"]), | ||
| 1051 | reason: Schema.String, | ||
| 1052 | path: Schema.NullOr(Schema.String), | ||
| 1053 | threadId: Schema.NullOr(ThreadId), | ||
| 1054 | threadTitle: Schema.NullOr(Schema.String), | ||
| 1055 | bytes: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | ||
| 1056 | files: Schema.NullOr(Schema.Number).pipe(Schema.withDecodingDefault(Effect.succeed(null))), | ||
| 1057 | }); | ||
| 1058 | export type StorageCleanupReportEntry = typeof StorageCleanupReportEntry.Type; | ||
| 1059 | export const StorageCleanupReport = Schema.Struct({ | ||
| 1060 | trigger: Schema.Literals(["automatic", "manual"]), | ||
| 1061 | startedAt: Schema.String, | ||
| 1062 | finishedAt: Schema.String, | ||
| 1063 | entries: Schema.Array(StorageCleanupReportEntry), | ||
| 1064 | counts: Schema.Struct({ removed: Schema.Number, kept: Schema.Number, failed: Schema.Number }), | ||
| 1065 | omittedCount: Schema.Number, | ||
| 1066 | bytesFreed: Schema.Number.pipe(Schema.withDecodingDefault(Effect.succeed(0))), | ||
| 1067 | }); | ||
| 1068 | export type StorageCleanupReport = typeof StorageCleanupReport.Type; | ||
| 1069 | |||
| 1037 | export const StorageCleanupSettings = Schema.Struct({ | 1070 | export const StorageCleanupSettings = Schema.Struct({ |
| 1071 | worktreeKeepWhen: WorktreeKeepWhen.pipe( | ||
| 1072 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), | ||
| 1073 | ), | ||
| 1038 | worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), | 1074 | worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1039 | worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 1075 | worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1040 | worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), | 1076 | worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | @@ -1391,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1391,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1391 | Schema.Struct({ | 1427 | Schema.Struct({ |
| 1392 | mode: Schema.Literal("custom"), | 1428 | mode: Schema.Literal("custom"), |
| 1393 | rules: Schema.Struct({ | 1429 | rules: Schema.Struct({ |
| 1430 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), | ||
| 1394 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), | 1431 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1395 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), | 1432 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1396 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), | 1433 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
| ... | @@ -1402,6 +1439,7 @@ export const ServerSettingsPatch = Schema.Struct({ | ... | @@ -1402,6 +1439,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1402 | ), | 1439 | ), |
| 1403 | storageCleanup: Schema.optionalKey( | 1440 | storageCleanup: Schema.optionalKey( |
| 1404 | Schema.Struct({ | 1441 | Schema.Struct({ |
| 1442 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), | ||
| 1405 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), | 1443 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1406 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), | 1444 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1407 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), | 1445 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
packages/shared/src/projectSettings.test.ts+2| ... | @@ -337,6 +337,7 @@ describe("resolveWorktreeCleanup", () => { | ... | @@ -337,6 +337,7 @@ describe("resolveWorktreeCleanup", () => { |
| 337 | worktreeOnDelete: false, | 337 | worktreeOnDelete: false, |
| 338 | worktreeOnMerge: false, | 338 | worktreeOnMerge: false, |
| 339 | worktreeUnchanged: false, | 339 | worktreeUnchanged: false, |
| 340 | worktreeKeepWhen: "uncommitted-changes", | ||
| 340 | }); | 341 | }); |
| 341 | expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); | 342 | expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); |
| 342 | const custom = applyServerSettingsPatch(off, { | 343 | const custom = applyServerSettingsPatch(off, { |
| ... | @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { | ... | @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { |
| 370 | worktreeOnDelete: true, | 371 | worktreeOnDelete: true, |
| 371 | worktreeOnMerge: true, | 372 | worktreeOnMerge: true, |
| 372 | worktreeUnchanged: false, | 373 | worktreeUnchanged: false, |
| 374 | worktreeKeepWhen: "uncommitted-changes", | ||
| 373 | }); | 375 | }); |
| 374 | expect( | 376 | expect( |
| 375 | resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) | 377 | resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) |
packages/shared/src/projectSettings.ts+15-3| ... | @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( | ... | @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( |
| 220 | if (policy?.mode === "custom") return policy.rules; | 220 | if (policy?.mode === "custom") return policy.rules; |
| 221 | if (policy?.mode === "off") | 221 | if (policy?.mode === "off") |
| 222 | return { | 222 | return { |
| 223 | worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, | ||
| 223 | worktreeAfterDays: null, | 224 | worktreeAfterDays: null, |
| 224 | worktreeOnMerge: false, | 225 | worktreeOnMerge: false, |
| 225 | worktreeOnDelete: false, | 226 | worktreeOnDelete: false, |
| 226 | worktreeUnchanged: false, | 227 | worktreeUnchanged: false, |
| 227 | }; | 228 | }; |
| 228 | const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = | 229 | const { |
| 229 | settings.storageCleanup; | 230 | worktreeKeepWhen, |
| 230 | return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; | 231 | worktreeAfterDays, |
| 232 | worktreeOnMerge, | ||
| 233 | worktreeOnDelete, | ||
| 234 | worktreeUnchanged, | ||
| 235 | } = settings.storageCleanup; | ||
| 236 | return { | ||
| 237 | worktreeKeepWhen, | ||
| 238 | worktreeAfterDays, | ||
| 239 | worktreeOnMerge, | ||
| 240 | worktreeOnDelete, | ||
| 241 | worktreeUnchanged, | ||
| 242 | }; | ||
| 231 | } | 243 | } |
packages/shared/src/serverSettings.test.ts+1| ... | @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { | ... | @@ -37,6 +37,7 @@ describe("serverSettings helpers", () => { |
| 37 | worktreeOnMerge: true, | 37 | worktreeOnMerge: true, |
| 38 | worktreeOnDelete: false, | 38 | worktreeOnDelete: false, |
| 39 | worktreeUnchanged: false, | 39 | worktreeUnchanged: false, |
| 40 | worktreeKeepWhen: "uncommitted-changes", | ||
| 40 | browserArtifactsAfterDays: null, | 41 | browserArtifactsAfterDays: null, |
| 41 | logsAfterDays: 30, | 42 | logsAfterDays: 30, |
| 42 | }); | 43 | }); |
packages/shared/src/serverSettings.ts+1| ... | @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( | ... | @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( |
| 324 | ? { | 324 | ? { |
| 325 | mode: "custom" as const, | 325 | mode: "custom" as const, |
| 326 | rules: { | 326 | rules: { |
| 327 | worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, | ||
| 327 | worktreeAfterDays: next.storageCleanup.worktreeAfterDays, | 328 | worktreeAfterDays: next.storageCleanup.worktreeAfterDays, |
| 328 | worktreeOnMerge: next.storageCleanup.worktreeOnMerge, | 329 | worktreeOnMerge: next.storageCleanup.worktreeOnMerge, |
| 329 | worktreeOnDelete: next.storageCleanup.worktreeOnDelete, | 330 | worktreeOnDelete: next.storageCleanup.worktreeOnDelete, |