authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-10-09 23:05:12-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-09 23:05:12-03:00
log0caa95d6eb471ab033ea670914d61070c65be806
treeac05e0fa14a729882ae72df42ac4acc4b7988955
parenteddc8fb817dbdb6c9ce34a30a2781c02ae8be9e0
signature Signed by PGP key B5690EEEBB952194

fix(storage): make worktree cleanup work and show why it skipped (#17563)


26 files changed, 1335 insertions(+), 124 deletions(-)

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