| 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 | 403 | expect(handled).toBe(1); |
| 404 | 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 | 81 | [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope, |
| 82 | 82 | [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope, |
| 83 | 83 | [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope, |
| 84 | [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope, | |
| 84 | 85 | [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, |
| 85 | 86 | [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope, |
| 86 | 87 | [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope, |
apps/server/src/environment/ServerEnvironment.ts+1| ... | ... | @@ -228,6 +228,7 @@ export const make = Effect.gen(function* () { |
| 228 | 228 | threadSettlement: true, |
| 229 | 229 | threadAutoSettlement: true, |
| 230 | 230 | storageCleanup: true, |
| 231 | storageCleanupRun: true, | |
| 231 | 232 | projectWorktreeCleanup: true, |
| 232 | 233 | worktreesDirectory: true, |
| 233 | 234 | threadRestartContinuation: true, |
apps/server/src/observability/RpcInstrumentation.ts+2| ... | ... | @@ -54,6 +54,8 @@ const RPC_AGGREGATES = { |
| 54 | 54 | [WS_METHODS.serverCommitDesktopUpdate]: "server", |
| 55 | 55 | [WS_METHODS.serverUpsertKeybinding]: "server", |
| 56 | 56 | [WS_METHODS.serverRemoveKeybinding]: "server", |
| 57 | [WS_METHODS.serverRunStorageCleanup]: "server", | |
| 58 | [WS_METHODS.serverGetStorageCleanupReport]: "server", | |
| 57 | 59 | [WS_METHODS.serverGetSettings]: "server", |
| 58 | 60 | [WS_METHODS.serverUpdateSettings]: "server", |
| 59 | 61 | [WS_METHODS.serverSearchAcpRegistry]: "server", |
apps/server/src/server.ts+1-3| ... | ... | @@ -547,9 +547,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll( |
| 547 | 547 | // Asks T3 Connect to deliver webhooks it held while this environment was offline. |
| 548 | 548 | HeldHooksWaker.layer, |
| 549 | 549 | layerThreadSettlementWorker, |
| 550 | Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe( | |
| 551 | Layer.provide(ProjectionStoreV2.layer), | |
| 552 | ), | |
| 550 | StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)), | |
| 553 | 551 | layerThreadPullRequestWorker, |
| 554 | 552 | Layer.effectDiscard( |
| 555 | 553 | Effect.gen(function* () { |
apps/server/src/storageCleanup.test.ts+459-1| ... | ... | @@ -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 | 29 | import { |
| 3 | 30 | ProjectId, |
| 4 | 31 | ProviderInstanceId, |
| ... | ... | @@ -16,6 +43,7 @@ import { |
| 16 | 43 | |
| 17 | 44 | const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z"); |
| 18 | 45 | const DAY_MS = 24 * 60 * 60 * 1_000; |
| 46 | const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport); | |
| 19 | 47 | |
| 20 | 48 | function at(offsetMs: number): DateTime.Utc { |
| 21 | 49 | return DateTime.makeUnsafe(NOW_MS + offsetMs); |
| ... | ... | @@ -196,3 +224,433 @@ describe("merged pull request cleanup", () => { |
| 196 | 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 | 1 | import { |
| 2 | GitCommandError, | |
| 2 | 3 | OrchestrationV2AppThreadJson, |
| 3 | 4 | OrchestrationV2ProviderSessionJson, |
| 4 | 5 | } from "@t3tools/contracts"; |
| ... | ... | @@ -11,10 +12,14 @@ import type { |
| 11 | 12 | ServerSettingsError, |
| 12 | 13 | TerminalSummary, |
| 13 | 14 | WorktreeCleanupRules, |
| 15 | StorageCleanupReport, | |
| 16 | StorageCleanupReportEntry, | |
| 14 | 17 | } from "@t3tools/contracts"; |
| 15 | 18 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; |
| 16 | 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 | 23 | import * as Clock from "effect/Clock"; |
| 19 | 24 | import * as DateTime from "effect/DateTime"; |
| 20 | 25 | import * as Effect from "effect/Effect"; |
| ... | ... | @@ -25,6 +30,7 @@ import * as Path from "effect/Path"; |
| 25 | 30 | import type { PlatformError } from "effect/PlatformError"; |
| 26 | 31 | import * as Schedule from "effect/Schedule"; |
| 27 | 32 | import * as Stream from "effect/Stream"; |
| 33 | import * as SubscriptionRef from "effect/SubscriptionRef"; | |
| 28 | 34 | |
| 29 | 35 | import * as ServerConfig from "./config.ts"; |
| 30 | 36 | import * as GitManager from "./git/GitManager.ts"; |
| ... | ... | @@ -49,6 +55,16 @@ const decodeCleanupSession = Schema.decodeUnknownEffect( |
| 49 | 55 | |
| 50 | 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 | 68 | const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => |
| 53 | 69 | rules.worktreeAfterDays !== null || |
| 54 | 70 | rules.worktreeOnMerge || |
| ... | ... | @@ -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 | 168 | const config = yield* ServerConfig.ServerConfig; |
| 143 | 169 | const settingsService = yield* Settings.ServerSettingsService; |
| 144 | 170 | const projectStore = yield* ProjectStore.ProjectStoreV2; |
| ... | ... | @@ -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 | 251 | const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () { |
| 185 | 252 | const active = yield* projections.getShellSnapshot(); |
| 186 | 253 | const archived = yield* projections.getShellSnapshot({ location: "archive" }); |
| ... | ... | @@ -204,11 +271,74 @@ export const make = Effect.gen(function* () { |
| 204 | 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 | 337 | const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* ( |
| 208 | 338 | serverSettings: ServerSettings, |
| 209 | 339 | now: number, |
| 340 | entries: StorageCleanupReportEntry[], | |
| 210 | 341 | ) { |
| 211 | if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return; | |
| 212 | 342 | const roots: Array<string> = []; |
| 213 | 343 | for (const directory of managedWorktreesDirectories( |
| 214 | 344 | serverSettings, |
| ... | ... | @@ -223,26 +353,17 @@ export const make = Effect.gen(function* () { |
| 223 | 353 | ); |
| 224 | 354 | if (root !== null && !isFilesystemRoot(root, path)) roots.push(root); |
| 225 | 355 | } |
| 226 | if (roots.length === 0) return; | |
| 227 | const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete); | |
| 228 | const deletedRows = hasDeleteRule | |
| 229 | ? yield* sql<{ payload_json: string; workspaceRoot: string }>` | |
| 356 | const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>` | |
| 230 | 357 | SELECT t.payload_json, p.workspace_root AS "workspaceRoot" |
| 231 | 358 | FROM orchestration_v2_projection_threads t |
| 232 | 359 | JOIN projection_projects p ON p.project_id = t.project_id |
| 233 | 360 | WHERE t.deleted_at IS NOT NULL |
| 234 | ` | |
| 235 | : []; | |
| 361 | `; | |
| 236 | 362 | const deletedThreads = (yield* Effect.forEach(deletedRows, (row) => |
| 237 | 363 | decodeCleanupThread(row.payload_json).pipe( |
| 238 | 364 | Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })), |
| 239 | 365 | ), |
| 240 | )).filter( | |
| 241 | (thread) => | |
| 242 | thread.worktreePath !== null && | |
| 243 | thread.branch !== null && | |
| 244 | resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete, | |
| 245 | ); | |
| 366 | )).filter((thread) => thread.worktreePath !== null && thread.branch !== null); | |
| 246 | 367 | const snapshot = yield* readThreads(); |
| 247 | 368 | const refreshedDefaultRefs = new Map<string, Set<string>>(); |
| 248 | 369 | const groups = Map.groupBy( |
| ... | ... | @@ -250,7 +371,7 @@ export const make = Effect.gen(function* () { |
| 250 | 371 | (thread) => path.resolve(thread.worktreePath!), |
| 251 | 372 | ); |
| 252 | 373 | const candidates = [ |
| 253 | ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])), | |
| 374 | ...[...groups.values()].map((group) => group[0]!), | |
| 254 | 375 | ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))), |
| 255 | 376 | ]; |
| 256 | 377 | for (const thread of candidates) { |
| ... | ... | @@ -261,52 +382,58 @@ export const make = Effect.gen(function* () { |
| 261 | 382 | const project = deleted |
| 262 | 383 | ? { workspaceRoot: thread.workspaceRoot } |
| 263 | 384 | : snapshot.projects.find((entry) => entry.id === thread.projectId); |
| 264 | if ( | |
| 265 | project === undefined || | |
| 266 | (!deleted && !storageCleanupThreadIdle(thread, now)) || | |
| 267 | hasTerminal(worktreePath) | |
| 268 | ) | |
| 269 | continue; | |
| 385 | const entry: StorageCleanupReportEntry = { | |
| 386 | kind: "worktree", | |
| 387 | outcome: "kept", | |
| 388 | reason: "", | |
| 389 | path: worktreePath, | |
| 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 | 396 | yield* Effect.gen(function* () { |
| 271 | 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 | 405 | // Roots are canonical, so compare canonical paths. A symlinked parent |
| 273 | 406 | // (a linked drive) is fine; a symlinked worktree directory is not. |
| 274 | 407 | const realPath = yield* fs.realPath(worktreePath); |
| 275 | 408 | const realParent = yield* fs.realPath(path.dirname(worktreePath)); |
| 276 | if (realPath !== path.join(realParent, path.basename(worktreePath))) return; | |
| 277 | if (!roots.some((root) => inside(root, realPath))) return; | |
| 278 | if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return; | |
| 409 | if (realPath !== path.join(realParent, path.basename(worktreePath))) | |
| 410 | return keep("worktree is a symbolic link"); | |
| 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 | 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 | 418 | const status = yield* git.statusDetailsLocal(worktreePath); |
| 282 | if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges) | |
| 283 | return; | |
| 419 | if (!status.isRepo || status.branch !== thread.branch) | |
| 420 | return keep("repository or branch changed"); | |
| 421 | const changes = yield* localChangesReason(worktreePath, settings); | |
| 422 | if (changes !== null) return keep(changes); | |
| 284 | 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 | 424 | const old = |
| 301 | 425 | !deleted && |
| 302 | 426 | settings.worktreeAfterDays !== null && |
| 303 | 427 | storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS; |
| 304 | 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 | 432 | if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) { |
| 306 | 433 | const repositoryCwd = path.resolve(project.workspaceRoot); |
| 307 | 434 | const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd); |
| 308 | 435 | const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote); |
| 309 | if (branch === null) return; | |
| 436 | if (branch === null) return keep("default branch is unavailable"); | |
| 310 | 437 | const defaultRef = `refs/remotes/${remote}/${branch}`; |
| 311 | 438 | const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>(); |
| 312 | 439 | if (!refreshed.has(defaultRef)) { |
| ... | ... | @@ -330,6 +457,7 @@ export const make = Effect.gen(function* () { |
| 330 | 457 | }); |
| 331 | 458 | const integrated = ancestor.exitCode === 0; |
| 332 | 459 | eligible = integrated && settings.worktreeUnchanged; |
| 460 | if (eligible) removalReason = "no commits beyond the default branch"; | |
| 333 | 461 | if (!eligible && settings.worktreeOnMerge && thread.branch !== null) { |
| 334 | 462 | const pullRequest = yield* gitManager.branchPullRequest( |
| 335 | 463 | { cwd: worktreePath, branch: thread.branch }, |
| ... | ... | @@ -341,39 +469,49 @@ export const make = Effect.gen(function* () { |
| 341 | 469 | headSha: head.commitSha, |
| 342 | 470 | integrated, |
| 343 | 471 | }); |
| 472 | if (eligible) removalReason = "pull request was merged"; | |
| 344 | 473 | } |
| 345 | 474 | } |
| 346 | if (!eligible) return; | |
| 347 | // Re-read after Git/host calls so a queued turn, resumed session or new | |
| 348 | // thread sharing this path cancels the removal. | |
| 475 | if (!eligible) | |
| 476 | return keep( | |
| 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 | 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 | 489 | const latest = latestSnapshot.threads.filter( |
| 352 | 490 | (entry) => |
| 353 | 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 | 494 | if (deleted) { |
| 357 | 495 | if ( |
| 358 | 496 | latest.length > 0 || |
| 359 | 497 | !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId) |
| 360 | 498 | .worktreeOnDelete |
| 361 | 499 | ) |
| 362 | return; | |
| 500 | return keep("thread or cleanup settings changed since check"); | |
| 363 | 501 | // V2 deletion queues durable cleanup. Do not remove its checkout until |
| 364 | 502 | // every effect has finished successfully or was explicitly cancelled. |
| 365 | 503 | const pendingCleanup = yield* sql` |
| 366 | 504 | SELECT 1 FROM orchestration_v2_effect_outbox |
| 367 | 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 | 508 | } else if ( |
| 371 | 509 | latest.length !== 1 || |
| 372 | 510 | latest[0]!.id !== thread.id || |
| 373 | 511 | !storageCleanupThreadIdle(latest[0]!, now) || |
| 374 | 512 | storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread) |
| 375 | 513 | ) |
| 376 | return; | |
| 514 | return keep("thread activity or shared worktree changed since check"); | |
| 377 | 515 | // Sessions can outlive their run and can be shared across app threads. |
| 378 | 516 | const sessionRows = yield* sql<{ payload_json: string }>` |
| 379 | 517 | SELECT payload_json FROM orchestration_v2_projection_provider_sessions |
| ... | ... | @@ -388,32 +526,17 @@ export const make = Effect.gen(function* () { |
| 388 | 526 | return cwd === worktreePath || inside(worktreePath, cwd); |
| 389 | 527 | }) |
| 390 | 528 | ) |
| 391 | return; | |
| 529 | return keep("provider session is still open"); | |
| 392 | 530 | const finalStatus = yield* git.statusDetailsLocal(worktreePath); |
| 393 | if ( | |
| 394 | !finalStatus.isRepo || | |
| 395 | finalStatus.branch !== thread.branch || | |
| 396 | finalStatus.hasWorkingTreeChanges | |
| 397 | ) | |
| 398 | return; | |
| 531 | if (!finalStatus.isRepo || finalStatus.branch !== thread.branch) | |
| 532 | return keep("repository or branch changed since check"); | |
| 399 | 533 | if ( |
| 400 | 534 | (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !== |
| 401 | 535 | head.commitSha |
| 402 | 536 | ) |
| 403 | return; | |
| 404 | const finalIgnored = yield* git.execute({ | |
| 405 | operation: "StorageCleanup.ignoredFiles", | |
| 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; | |
| 537 | return keep("commit changed since check"); | |
| 538 | const finalChanges = yield* localChangesReason(worktreePath, settings); | |
| 539 | if (finalChanges !== null) return keep(finalChanges); | |
| 417 | 540 | const current = resolveWorktreeCleanup( |
| 418 | 541 | yield* settingsService.getSettings, |
| 419 | 542 | thread.projectId, |
| ... | ... | @@ -424,8 +547,17 @@ export const make = Effect.gen(function* () { |
| 424 | 547 | current[key as keyof typeof settings] !== settings[key as keyof typeof settings], |
| 425 | 548 | ) |
| 426 | 549 | ) |
| 427 | return; | |
| 428 | yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false }); | |
| 550 | return keep("settings changed since check"); | |
| 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 | 561 | yield* gitManager.invalidateStatus(project.workspaceRoot); |
| 430 | 562 | // Preserve branch and path: ProviderTurnStartService recreates the checkout |
| 431 | 563 | // from that branch when the thread is resumed. |
| ... | ... | @@ -433,7 +565,17 @@ export const make = Effect.gen(function* () { |
| 433 | 565 | }).pipe( |
| 434 | 566 | (effect) => withWorkspaceLease(worktreePath, effect), |
| 435 | 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 | 586 | days: number | null, |
| 445 | 587 | now: number, |
| 446 | 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 | 593 | const realRoot = yield* fs.realPath(root); |
| 450 | if (realRoot !== path.resolve(root)) return; | |
| 594 | if (realRoot !== path.resolve(root)) return removed; | |
| 451 | 595 | const visit = Effect.fn("StorageCleanup.visitFiles")(function* ( |
| 452 | 596 | directory: string, |
| 453 | 597 | ): Effect.fn.Return<void, PlatformError | ServerSettingsError> { |
| ... | ... | @@ -464,40 +608,129 @@ export const make = Effect.gen(function* () { |
| 464 | 608 | if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days) |
| 465 | 609 | return; |
| 466 | 610 | yield* fs.remove(target); |
| 611 | removed.files++; | |
| 612 | removed.bytes += Number(stat.size); | |
| 467 | 613 | } |
| 468 | 614 | } |
| 469 | 615 | } |
| 470 | 616 | }); |
| 471 | 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 | 626 | const serverSettings = yield* settingsService.getSettings; |
| 476 | 627 | const settings = serverSettings.storageCleanup; |
| 477 | const now = yield* Clock.currentTimeMillis; | |
| 478 | yield* cleanWorktrees(serverSettings, now).pipe( | |
| 479 | Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })), | |
| 480 | ); | |
| 481 | yield* cleanFiles( | |
| 482 | config.browserArtifactsDir, | |
| 483 | settings.browserArtifactsAfterDays, | |
| 484 | now, | |
| 485 | false, | |
| 486 | ).pipe( | |
| 487 | Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })), | |
| 488 | ); | |
| 489 | yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe( | |
| 490 | Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })), | |
| 628 | const entries: StorageCleanupReportEntry[] = []; | |
| 629 | yield* cleanWorktrees(serverSettings, now, entries).pipe( | |
| 630 | Effect.catch((error) => | |
| 631 | Effect.gen(function* () { | |
| 632 | entries.push({ | |
| 633 | kind: "worktree", | |
| 634 | outcome: "failed", | |
| 635 | reason: cleanupFailureReason(error), | |
| 636 | path: null, | |
| 637 | threadId: null, | |
| 638 | threadTitle: null, | |
| 639 | bytes: null, | |
| 640 | files: null, | |
| 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(() => | |
| 494 | sweep().pipe( | |
| 495 | Effect.catchCauseIf( | |
| 496 | (cause) => !Cause.hasInterruptsOnly(cause), | |
| 497 | (cause) => Effect.logWarning("storage cleanup failed", { cause }), | |
| 713 | const worker = yield* makeDrainableWorker( | |
| 714 | (completion: Deferred.Deferred<StorageCleanupReport, ServerSettingsError> | undefined) => | |
| 715 | sweep(completion === undefined ? "automatic" : "manual").pipe( | |
| 716 | Effect.exit, | |
| 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 | 735 | const start = Effect.fn("StorageCleanup.start")(function* () { |
| 503 | 736 | const unsubscribe = yield* terminals.subscribeMetadata((event) => |
| ... | ... | @@ -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 | 176 | import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts"; |
| 177 | 177 | import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts"; |
| 178 | 178 | import * as ServerSettings from "./serverSettings.ts"; |
| 179 | import * as StorageCleanup from "./storageCleanup.ts"; | |
| 179 | 180 | import * as TerminalManager from "./terminal/Manager.ts"; |
| 180 | 181 | import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts"; |
| 181 | 182 | import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts"; |
| ... | ... | @@ -1278,6 +1279,7 @@ const layerWsRpc = ( |
| 1278 | 1279 | const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate; |
| 1279 | 1280 | const config = yield* ServerConfig.ServerConfig; |
| 1280 | 1281 | const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents; |
| 1282 | const storageCleanup = yield* StorageCleanup.StorageCleanup; | |
| 1281 | 1283 | const serverSettings = yield* ServerSettings.ServerSettingsService; |
| 1282 | 1284 | const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup; |
| 1283 | 1285 | const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries; |
| ... | ... | @@ -2382,6 +2384,8 @@ const layerWsRpc = ( |
| 2382 | 2384 | const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule); |
| 2383 | 2385 | return { keybindings: keybindingsConfig, issues: [] }; |
| 2384 | 2386 | }), |
| 2387 | [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow, | |
| 2388 | [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports, | |
| 2385 | 2389 | [WS_METHODS.serverGetSettings]: (_input) => |
| 2386 | 2390 | serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)), |
| 2387 | 2391 | [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) => |
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+1-12| ... | ... | @@ -39,6 +39,7 @@ import { |
| 39 | 39 | useResourceTelemetryHistory, |
| 40 | 40 | } from "../../lib/resourceTelemetryState"; |
| 41 | 41 | import { cn } from "../../lib/utils"; |
| 42 | import { formatBytes } from "../../lib/formatBytes"; | |
| 42 | 43 | import { ensureLocalApi } from "../../localApi"; |
| 43 | 44 | import { serverEnvironment } from "../../state/server"; |
| 44 | 45 | import { readEnvironmentScope, useEnvironmentScope } from "../../state/session"; |
| ... | ... | @@ -65,18 +66,6 @@ const HISTORY_WINDOWS = [ |
| 65 | 66 | { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 }, |
| 66 | 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 | 69 | function formatRate(value: number): string { |
| 81 | 70 | return `${formatBytes(value)}/s`; |
| 82 | 71 | } |
apps/web/src/components/settings/StorageSettings.tsx+298-2| ... | ... | @@ -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 | 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 | 21 | import { Input } from "../ui/input"; |
| 6 | 22 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; |
| ... | ... | @@ -17,8 +33,11 @@ import { |
| 17 | 33 | SettingsPageContainer, |
| 18 | 34 | SettingsRow, |
| 19 | 35 | SettingsSection, |
| 36 | useRelativeTimeTick, | |
| 20 | 37 | } from "./settingsLayout"; |
| 21 | 38 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; |
| 39 | import { FoldedSettingsSection } from "./FoldedSettingsSection"; | |
| 40 | import { SettingsGroup } from "./SettingsGroup"; | |
| 22 | 41 | import type { ScopedSettingsTarget } from "./scopedSettings"; |
| 23 | 42 | import { useSettingsScope } from "./SettingsScopeContext"; |
| 24 | 43 | import { searchableSetting } from "./settingsSearch"; |
| ... | ... | @@ -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 | 399 | export function StorageSettingsPanel() { |
| 148 | 400 | const { scope, connectedEnvironments, targets, target } = useSettingsScope(); |
| 149 | 401 | const scopedSettings = useScopedSettings(); |
| ... | ... | @@ -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 | 612 | </SettingsSection> |
| ... | ... | @@ -346,6 +641,7 @@ export function StorageSettingsPanel() { |
| 346 | 641 | /> |
| 347 | 642 | </SettingsSection> |
| 348 | 643 | )} |
| 644 | {!isProjectScope && <CleanupSection />} | |
| 349 | 645 | </SettingsPageContainer> |
| 350 | 646 | ); |
| 351 | 647 | } |
apps/web/src/components/settings/scopedSettings.test.ts+6| ... | ... | @@ -160,6 +160,7 @@ describe("scoped settings writes", () => { |
| 160 | 160 | worktreeOnDelete: true, |
| 161 | 161 | worktreeOnMerge: true, |
| 162 | 162 | worktreeUnchanged: false, |
| 163 | worktreeKeepWhen: "uncommitted-changes", | |
| 163 | 164 | }, |
| 164 | 165 | }, |
| 165 | 166 | }, |
| ... | ... | @@ -183,12 +184,14 @@ describe("scoped settings writes", () => { |
| 183 | 184 | worktreeOnDelete: false, |
| 184 | 185 | worktreeOnMerge: true, |
| 185 | 186 | worktreeUnchanged: false, |
| 187 | worktreeKeepWhen: "uncommitted-changes", | |
| 186 | 188 | }, |
| 187 | 189 | { |
| 188 | 190 | worktreeAfterDays: null, |
| 189 | 191 | worktreeOnDelete: false, |
| 190 | 192 | worktreeOnMerge: false, |
| 191 | 193 | worktreeUnchanged: false, |
| 194 | worktreeKeepWhen: "uncommitted-changes", | |
| 192 | 195 | }, |
| 193 | 196 | ]); |
| 194 | 197 | }); |
| ... | ... | @@ -295,6 +298,7 @@ describe("scoped settings writes", () => { |
| 295 | 298 | worktreeOnDelete: false, |
| 296 | 299 | worktreeOnMerge: true, |
| 297 | 300 | worktreeUnchanged: false, |
| 301 | worktreeKeepWhen: "uncommitted-changes", | |
| 298 | 302 | }, |
| 299 | 303 | }, |
| 300 | 304 | }, |
| ... | ... | @@ -315,6 +319,7 @@ describe("scoped settings writes", () => { |
| 315 | 319 | worktreeOnDelete: true, |
| 316 | 320 | worktreeOnMerge: true, |
| 317 | 321 | worktreeUnchanged: false, |
| 322 | worktreeKeepWhen: "uncommitted-changes", | |
| 318 | 323 | }, |
| 319 | 324 | }, |
| 320 | 325 | }, |
| ... | ... | @@ -328,6 +333,7 @@ describe("scoped settings writes", () => { |
| 328 | 333 | worktreeOnDelete: true, |
| 329 | 334 | worktreeOnMerge: false, |
| 330 | 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 | 134 | * that may not be mounted point at their nearest stable section instead. |
| 135 | 135 | */ |
| 136 | 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 | 155 | id: "storage-worktrees", |
| 139 | 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 | 143 | |
| 144 | 144 | Worktrees can be removed after a chosen number of inactive days, after merging, or when they |
| 145 | 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` | |
| 147 | prevent removal. Branches and thread history stay; starting another turn recreates the checkout. | |
| 146 | sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults | |
| 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 | 151 | Merge cleanup requires a merged pull request whose commits are included in the remote default |
| 149 | 152 | branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default |
| 150 | 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 | 157 | server waits for sessions and terminals to stop and retries skipped worktrees after restart. |
| 155 | 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 | 163 | Browser captures and rotated logs have separate retention periods. Expired capture links stop |
| 158 | 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 | 58 | | typeof WS_METHODS.subscribeServerConfig |
| 59 | 59 | | typeof WS_METHODS.subscribeServerLifecycle |
| 60 | 60 | | typeof WS_METHODS.scheduledTasksSubscribe |
| 61 | | typeof WS_METHODS.serverGetStorageCleanupReport | |
| 61 | 62 | | typeof WS_METHODS.subscribeTerminalEvents |
| 62 | 63 | | typeof WS_METHODS.subscribeTerminalMetadata |
| 63 | 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 | 8 | import { vi } from "vite-plus/test"; |
| 9 | 9 | import { |
| 10 | 10 | AuthOrchestrationOperateScope, |
| 11 | AuthSettingsWriteScope, | |
| 11 | 12 | AuthSourceControlWriteScope, |
| 12 | 13 | ThreadId, |
| 13 | 14 | EnvironmentId, |
| ... | ... | @@ -58,6 +59,32 @@ const setup = Effect.gen(function* () { |
| 58 | 59 | }); |
| 59 | 60 | |
| 60 | 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 | 88 | it.effect("uses the target grant for both availability and execution", () => |
| 62 | 89 | Effect.scoped( |
| 63 | 90 | Effect.gen(function* () { |
packages/client-runtime/src/state/server.ts+10| ... | ... | @@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms<R, E>( |
| 983 | 983 | |
| 984 | 984 | return { |
| 985 | 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 | 996 | updateStateAtom, |
| 987 | 997 | settingsValueAtom, |
| 988 | 998 | providersValueAtom, |
packages/contracts/src/clientRpcPermissions.ts+2| ... | ... | @@ -2,6 +2,7 @@ import * as Schema from "effect/Schema"; |
| 2 | 2 | import { GitPreparePullRequestThreadInput } from "./git.ts"; |
| 3 | 3 | import { |
| 4 | 4 | AuthOrchestrationOperateScope, |
| 5 | AuthSettingsWriteScope, | |
| 5 | 6 | AuthSourceControlWriteScope, |
| 6 | 7 | type AuthEnvironmentScope, |
| 7 | 8 | } from "./auth.ts"; |
| ... | ... | @@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts"; |
| 9 | 10 | |
| 10 | 11 | /** Incremental client enforcement; the server still authorizes every request. */ |
| 11 | 12 | export const CLIENT_GUARDED_RPC_SCOPES = { |
| 13 | [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope, | |
| 12 | 14 | [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope, |
| 13 | 15 | [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope, |
| 14 | 16 | [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope, |
packages/contracts/src/environment.ts+1| ... | ... | @@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ |
| 125 | 125 | /** Server evaluates merge and inactivity settlement without a client. */ |
| 126 | 126 | threadAutoSettlement: Schema.optionalKey(Schema.Boolean), |
| 127 | 127 | storageCleanup: Schema.optionalKey(Schema.Boolean), |
| 128 | storageCleanupRun: Schema.optionalKey(Schema.Boolean), | |
| 128 | 129 | projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean), |
| 129 | 130 | /** Server honors the `worktreesDirectory` setting. */ |
| 130 | 131 | worktreesDirectory: Schema.optionalKey(Schema.Boolean), |
packages/contracts/src/rpc.ts+22-1| ... | ... | @@ -317,7 +317,12 @@ import { |
| 317 | 317 | ProviderConsumeResetCreditResult, |
| 318 | 318 | } from "./providerUsageLimits.ts"; |
| 319 | 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 | 326 | import { |
| 322 | 327 | ScheduledTaskDeleteInput, |
| 323 | 328 | ScheduledTaskDeleteResult, |
| ... | ... | @@ -468,6 +473,8 @@ export const WS_METHODS = { |
| 468 | 473 | serverCommitDesktopUpdate: "server.commitDesktopUpdate", |
| 469 | 474 | serverUpsertKeybinding: "server.upsertKeybinding", |
| 470 | 475 | serverRemoveKeybinding: "server.removeKeybinding", |
| 476 | serverRunStorageCleanup: "server.runStorageCleanup", | |
| 477 | serverGetStorageCleanupReport: "server.getStorageCleanupReport", | |
| 471 | 478 | serverGetSettings: "server.getSettings", |
| 472 | 479 | serverUpdateSettings: "server.updateSettings", |
| 473 | 480 | serverDiscoverSourceControl: "server.discoverSourceControl", |
| ... | ... | @@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp |
| 730 | 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 | 752 | const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { |
| 734 | 753 | payload: Schema.Struct({}), |
| 735 | 754 | success: ServerSettings, |
| ... | ... | @@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make( |
| 1833 | 1852 | WsServerCommitDesktopUpdateRpc, |
| 1834 | 1853 | WsServerUpsertKeybindingRpc, |
| 1835 | 1854 | WsServerRemoveKeybindingRpc, |
| 1855 | WsServerRunStorageCleanupRpc, | |
| 1856 | WsServerGetStorageCleanupReportRpc, | |
| 1836 | 1857 | WsServerGetSettingsRpc, |
| 1837 | 1858 | WsServerUpdateSettingsRpc, |
| 1838 | 1859 | WsServerDiscoverSourceControlRpc, |
packages/contracts/src/settings.test.ts+24| ... | ... | @@ -57,6 +57,7 @@ describe("storage cleanup settings", () => { |
| 57 | 57 | worktreeOnMerge: false, |
| 58 | 58 | worktreeOnDelete: false, |
| 59 | 59 | worktreeUnchanged: false, |
| 60 | worktreeKeepWhen: "uncommitted-changes", | |
| 60 | 61 | browserArtifactsAfterDays: null, |
| 61 | 62 | logsAfterDays: null, |
| 62 | 63 | }); |
| ... | ... | @@ -86,6 +87,29 @@ describe("storage cleanup settings", () => { |
| 86 | 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 | 113 | it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => { |
| 90 | 114 | expect(() => |
| 91 | 115 | decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }), |
packages/contracts/src/settings.ts+38| ... | ... | @@ -13,6 +13,7 @@ import { |
| 13 | 13 | ForwardCompatibleOptional, |
| 14 | 14 | OmittedWhenNull, |
| 15 | 15 | ProjectId, |
| 16 | ThreadId, | |
| 16 | 17 | TrimmedNonEmptyString, |
| 17 | 18 | TrimmedString, |
| 18 | 19 | } from "./baseSchemas.ts"; |
| ... | ... | @@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr( |
| 941 | 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 | 952 | export const WorktreeCleanupRules = Schema.Struct({ |
| 953 | worktreeKeepWhen: WorktreeKeepWhen.pipe( | |
| 954 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), | |
| 955 | ), | |
| 945 | 956 | worktreeAfterDays: StorageRetentionDays, |
| 946 | 957 | worktreeOnMerge: Schema.Boolean, |
| 947 | 958 | worktreeOnDelete: Schema.Boolean, |
| ... | ... | @@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettin |
| 1034 | 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 | 1070 | export const StorageCleanupSettings = Schema.Struct({ |
| 1071 | worktreeKeepWhen: WorktreeKeepWhen.pipe( | |
| 1072 | Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")), | |
| 1073 | ), | |
| 1038 | 1074 | worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))), |
| 1039 | 1075 | worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| 1040 | 1076 | worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))), |
| ... | ... | @@ -1391,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1391 | 1427 | Schema.Struct({ |
| 1392 | 1428 | mode: Schema.Literal("custom"), |
| 1393 | 1429 | rules: Schema.Struct({ |
| 1430 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), | |
| 1394 | 1431 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1395 | 1432 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1396 | 1433 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
| ... | ... | @@ -1402,6 +1439,7 @@ export const ServerSettingsPatch = Schema.Struct({ |
| 1402 | 1439 | ), |
| 1403 | 1440 | storageCleanup: Schema.optionalKey( |
| 1404 | 1441 | Schema.Struct({ |
| 1442 | worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen), | |
| 1405 | 1443 | worktreeAfterDays: Schema.optionalKey(StorageRetentionDays), |
| 1406 | 1444 | worktreeOnMerge: Schema.optionalKey(Schema.Boolean), |
| 1407 | 1445 | worktreeOnDelete: Schema.optionalKey(Schema.Boolean), |
packages/shared/src/projectSettings.test.ts+2| ... | ... | @@ -337,6 +337,7 @@ describe("resolveWorktreeCleanup", () => { |
| 337 | 337 | worktreeOnDelete: false, |
| 338 | 338 | worktreeOnMerge: false, |
| 339 | 339 | worktreeUnchanged: false, |
| 340 | worktreeKeepWhen: "uncommitted-changes", | |
| 340 | 341 | }); |
| 341 | 342 | expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited); |
| 342 | 343 | const custom = applyServerSettingsPatch(off, { |
| ... | ... | @@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => { |
| 370 | 371 | worktreeOnDelete: true, |
| 371 | 372 | worktreeOnMerge: true, |
| 372 | 373 | worktreeUnchanged: false, |
| 374 | worktreeKeepWhen: "uncommitted-changes", | |
| 373 | 375 | }); |
| 374 | 376 | expect( |
| 375 | 377 | resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null) |
packages/shared/src/projectSettings.ts+15-3| ... | ... | @@ -220,12 +220,24 @@ export function resolveWorktreeCleanup( |
| 220 | 220 | if (policy?.mode === "custom") return policy.rules; |
| 221 | 221 | if (policy?.mode === "off") |
| 222 | 222 | return { |
| 223 | worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen, | |
| 223 | 224 | worktreeAfterDays: null, |
| 224 | 225 | worktreeOnMerge: false, |
| 225 | 226 | worktreeOnDelete: false, |
| 226 | 227 | worktreeUnchanged: false, |
| 227 | 228 | }; |
| 228 | const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } = | |
| 229 | settings.storageCleanup; | |
| 230 | return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged }; | |
| 229 | const { | |
| 230 | worktreeKeepWhen, | |
| 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 | 37 | worktreeOnMerge: true, |
| 38 | 38 | worktreeOnDelete: false, |
| 39 | 39 | worktreeUnchanged: false, |
| 40 | worktreeKeepWhen: "uncommitted-changes", | |
| 40 | 41 | browserArtifactsAfterDays: null, |
| 41 | 42 | logsAfterDays: 30, |
| 42 | 43 | }); |
packages/shared/src/serverSettings.ts+1| ... | ... | @@ -324,6 +324,7 @@ export function applyServerSettingsPatch( |
| 324 | 324 | ? { |
| 325 | 325 | mode: "custom" as const, |
| 326 | 326 | rules: { |
| 327 | worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen, | |
| 327 | 328 | worktreeAfterDays: next.storageCleanup.worktreeAfterDays, |
| 328 | 329 | worktreeOnMerge: next.storageCleanup.worktreeOnMerge, |
| 329 | 330 | worktreeOnDelete: next.storageCleanup.worktreeOnDelete, |