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", () =>
403403 expect(handled).toBe(1);
404404 }).pipe(Effect.scoped),
405405);
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 = {
8181 [WS_METHODS.serverCommitDesktopUpdate]: AuthEnvironmentMaintainScope,
8282 [WS_METHODS.serverUpsertKeybinding]: AuthSettingsWriteScope,
8383 [WS_METHODS.serverRemoveKeybinding]: AuthSettingsWriteScope,
84 [WS_METHODS.serverGetStorageCleanupReport]: AuthOrchestrationReadScope,
8485 [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope,
8586 [WS_METHODS.serverUpdateSettings]: AuthSettingsWriteScope,
8687 [WS_METHODS.serverSearchAcpRegistry]: AuthOrchestrationReadScope,
apps/server/src/environment/ServerEnvironment.ts+1
......@@ -228,6 +228,7 @@ export const make = Effect.gen(function* () {
228228 threadSettlement: true,
229229 threadAutoSettlement: true,
230230 storageCleanup: true,
231 storageCleanupRun: true,
231232 projectWorktreeCleanup: true,
232233 worktreesDirectory: true,
233234 threadRestartContinuation: true,
apps/server/src/observability/RpcInstrumentation.ts+2
......@@ -54,6 +54,8 @@ const RPC_AGGREGATES = {
5454 [WS_METHODS.serverCommitDesktopUpdate]: "server",
5555 [WS_METHODS.serverUpsertKeybinding]: "server",
5656 [WS_METHODS.serverRemoveKeybinding]: "server",
57 [WS_METHODS.serverRunStorageCleanup]: "server",
58 [WS_METHODS.serverGetStorageCleanupReport]: "server",
5759 [WS_METHODS.serverGetSettings]: "server",
5860 [WS_METHODS.serverUpdateSettings]: "server",
5961 [WS_METHODS.serverSearchAcpRegistry]: "server",
apps/server/src/server.ts+1-3
......@@ -547,9 +547,7 @@ const layerRuntimeCoreDependenciesBase = Layer.mergeAll(
547547 // Asks T3 Connect to deliver webhooks it held while this environment was offline.
548548 HeldHooksWaker.layer,
549549 layerThreadSettlementWorker,
550 Layer.effectDiscard(StorageCleanup.make.pipe(Effect.flatMap((service) => service.start()))).pipe(
551 Layer.provide(ProjectionStoreV2.layer),
552 ),
550 StorageCleanup.layer.pipe(Layer.provide(ProjectionStoreV2.layer)),
553551 layerThreadPullRequestWorker,
554552 Layer.effectDiscard(
555553 Effect.gen(function* () {
apps/server/src/storageCleanup.test.ts+459-1
......@@ -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";
229import {
330 ProjectId,
431 ProviderInstanceId,
......@@ -16,6 +43,7 @@ import {
1643
1744const NOW_MS = Date.parse("2026-06-10T12:00:00.000Z");
1845const DAY_MS = 24 * 60 * 60 * 1_000;
46const decodeCleanupReport = Schema.decodeSync(StorageCleanupReport);
1947
2048function at(offsetMs: number): DateTime.Utc {
2149 return DateTime.makeUnsafe(NOW_MS + offsetMs);
......@@ -196,3 +224,433 @@ describe("merged pull request cleanup", () => {
196224 expect(storageCleanupPullRequestMerged(pullRequest({ state: "open" }), integrated)).toBe(false);
197225 });
198226});
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 @@
11import {
2 GitCommandError,
23 OrchestrationV2AppThreadJson,
34 OrchestrationV2ProviderSessionJson,
45} from "@t3tools/contracts";
......@@ -11,10 +12,14 @@ import type {
1112 ServerSettingsError,
1213 TerminalSummary,
1314 WorktreeCleanupRules,
15 StorageCleanupReport,
16 StorageCleanupReportEntry,
1417} from "@t3tools/contracts";
1518import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1619import { 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";
1823import * as Clock from "effect/Clock";
1924import * as DateTime from "effect/DateTime";
2025import * as Effect from "effect/Effect";
......@@ -25,6 +30,7 @@ import * as Path from "effect/Path";
2530import type { PlatformError } from "effect/PlatformError";
2631import * as Schedule from "effect/Schedule";
2732import * as Stream from "effect/Stream";
33import * as SubscriptionRef from "effect/SubscriptionRef";
2834
2935import * as ServerConfig from "./config.ts";
3036import * as GitManager from "./git/GitManager.ts";
......@@ -49,6 +55,16 @@ const decodeCleanupSession = Schema.decodeUnknownEffect(
4955
5056const 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
5268const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) =>
5369 rules.worktreeAfterDays !== null ||
5470 rules.worktreeOnMerge ||
......@@ -138,7 +154,17 @@ export function storageCleanupPullRequestMerged(
138154 );
139155}
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* () {
142168 const config = yield* ServerConfig.ServerConfig;
143169 const settingsService = yield* Settings.ServerSettingsService;
144170 const projectStore = yield* ProjectStore.ProjectStoreV2;
......@@ -181,6 +207,47 @@ export const make = Effect.gen(function* () {
181207 );
182208 });
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
184251 const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () {
185252 const active = yield* projections.getShellSnapshot();
186253 const archived = yield* projections.getShellSnapshot({ location: "archive" });
......@@ -204,11 +271,74 @@ export const make = Effect.gen(function* () {
204271 return false;
205272 });
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
207337 const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* (
208338 serverSettings: ServerSettings,
209339 now: number,
340 entries: StorageCleanupReportEntry[],
210341 ) {
211 if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return;
212342 const roots: Array<string> = [];
213343 for (const directory of managedWorktreesDirectories(
214344 serverSettings,
......@@ -223,26 +353,17 @@ export const make = Effect.gen(function* () {
223353 );
224354 if (root !== null && !isFilesystemRoot(root, path)) roots.push(root);
225355 }
226 if (roots.length === 0) return;
227 const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);
228 const deletedRows = hasDeleteRule
229 ? yield* sql<{ payload_json: string; workspaceRoot: string }>`
356 const deletedRows = yield* sql<{ payload_json: string; workspaceRoot: string }>`
230357 SELECT t.payload_json, p.workspace_root AS "workspaceRoot"
231358 FROM orchestration_v2_projection_threads t
232359 JOIN projection_projects p ON p.project_id = t.project_id
233360 WHERE t.deleted_at IS NOT NULL
234 `
235 : [];
361 `;
236362 const deletedThreads = (yield* Effect.forEach(deletedRows, (row) =>
237363 decodeCleanupThread(row.payload_json).pipe(
238364 Effect.map((thread) => ({ ...thread, workspaceRoot: row.workspaceRoot })),
239365 ),
240 )).filter(
241 (thread) =>
242 thread.worktreePath !== null &&
243 thread.branch !== null &&
244 resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,
245 );
366 )).filter((thread) => thread.worktreePath !== null && thread.branch !== null);
246367 const snapshot = yield* readThreads();
247368 const refreshedDefaultRefs = new Map<string, Set<string>>();
248369 const groups = Map.groupBy(
......@@ -250,7 +371,7 @@ export const make = Effect.gen(function* () {
250371 (thread) => path.resolve(thread.worktreePath!),
251372 );
252373 const candidates = [
253 ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])),
374 ...[...groups.values()].map((group) => group[0]!),
254375 ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath!))),
255376 ];
256377 for (const thread of candidates) {
......@@ -261,52 +382,58 @@ export const make = Effect.gen(function* () {
261382 const project = deleted
262383 ? { workspaceRoot: thread.workspaceRoot }
263384 : snapshot.projects.find((entry) => entry.id === thread.projectId);
264 if (
265 project === undefined ||
266 (!deleted && !storageCleanupThreadIdle(thread, now)) ||
267 hasTerminal(worktreePath)
268 )
269 continue;
385 const entry: StorageCleanupReportEntry = {
386 kind: "worktree",
387 outcome: "kept",
388 reason: "",
389 path: worktreePath,
390 threadId: thread.id,
391 threadTitle: thread.title,
392 bytes: null,
393 files: null,
394 };
395 const keep = (reason: string) => entries.push({ ...entry, reason: sentence(reason) });
270396 yield* Effect.gen(function* () {
271397 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");
272405 // Roots are canonical, so compare canonical paths. A symlinked parent
273406 // (a linked drive) is fine; a symlinked worktree directory is not.
274407 const realPath = yield* fs.realPath(worktreePath);
275408 const realParent = yield* fs.realPath(path.dirname(worktreePath));
276 if (realPath !== path.join(realParent, path.basename(worktreePath))) return;
277 if (!roots.some((root) => inside(root, realPath))) return;
278 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;
409 if (realPath !== path.join(realParent, path.basename(worktreePath)))
410 return keep("worktree is a symbolic link");
411 if (!roots.some((root) => inside(root, realPath)))
412 return keep("outside the managed worktree folder");
413 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects]))
414 return keep("contains a project checkout");
279415 // 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");
281418 const status = yield* git.statusDetailsLocal(worktreePath);
282 if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges)
283 return;
419 if (!status.isRepo || status.branch !== thread.branch)
420 return keep("repository or branch changed");
421 const changes = yield* localChangesReason(worktreePath, settings);
422 if (changes !== null) return keep(changes);
284423 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;
300424 const old =
301425 !deleted &&
302426 settings.worktreeAfterDays !== null &&
303427 storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS;
304428 let eligible = deleted || old;
429 let removalReason = deleted
430 ? "thread was deleted"
431 : `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} days`;
305432 if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
306433 const repositoryCwd = path.resolve(project.workspaceRoot);
307434 const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
308435 const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
309 if (branch === null) return;
436 if (branch === null) return keep("default branch is unavailable");
310437 const defaultRef = `refs/remotes/${remote}/${branch}`;
311438 const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>();
312439 if (!refreshed.has(defaultRef)) {
......@@ -330,6 +457,7 @@ export const make = Effect.gen(function* () {
330457 });
331458 const integrated = ancestor.exitCode === 0;
332459 eligible = integrated && settings.worktreeUnchanged;
460 if (eligible) removalReason = "no commits beyond the default branch";
333461 if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
334462 const pullRequest = yield* gitManager.branchPullRequest(
335463 { cwd: worktreePath, branch: thread.branch },
......@@ -341,39 +469,49 @@ export const make = Effect.gen(function* () {
341469 headSha: head.commitSha,
342470 integrated,
343471 });
472 if (eligible) removalReason = "pull request was merged";
344473 }
345474 }
346 if (!eligible) return;
347 // Re-read after Git/host calls so a queued turn, resumed session or new
348 // thread sharing this path cancels the removal.
475 if (!eligible)
476 return keep(
477 !deleted && settings.worktreeAfterDays !== null
478 ? `inactive for ${Math.floor((now - storageCleanupActivityAt(thread)) / DAY_MS)} of ${settings.worktreeAfterDays} days`
479 : settings.worktreeOnMerge
480 ? "not merged"
481 : "has commits beyond the default branch",
482 );
483 const bytes = yield* measureWorktree(worktreePath);
484 // Re-read after Git/host calls and size measurement so a queued turn,
485 // resumed session or new thread sharing this path cancels the removal.
349486 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");
351489 const latest = latestSnapshot.threads.filter(
352490 (entry) =>
353491 entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath,
354492 );
355 if (hasTerminal(worktreePath)) return;
493 if (hasTerminal(worktreePath)) return keep("open terminal");
356494 if (deleted) {
357495 if (
358496 latest.length > 0 ||
359497 !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
360498 .worktreeOnDelete
361499 )
362 return;
500 return keep("thread or cleanup settings changed since check");
363501 // V2 deletion queues durable cleanup. Do not remove its checkout until
364502 // every effect has finished successfully or was explicitly cancelled.
365503 const pendingCleanup = yield* sql`
366504 SELECT 1 FROM orchestration_v2_effect_outbox
367505 WHERE thread_id = ${thread.id} AND status NOT IN ('succeeded', 'cancelled') LIMIT 1
368506 `;
369 if (pendingCleanup.length > 0) return;
507 if (pendingCleanup.length > 0) return keep("thread deletion is still pending");
370508 } else if (
371509 latest.length !== 1 ||
372510 latest[0]!.id !== thread.id ||
373511 !storageCleanupThreadIdle(latest[0]!, now) ||
374512 storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread)
375513 )
376 return;
514 return keep("thread activity or shared worktree changed since check");
377515 // Sessions can outlive their run and can be shared across app threads.
378516 const sessionRows = yield* sql<{ payload_json: string }>`
379517 SELECT payload_json FROM orchestration_v2_projection_provider_sessions
......@@ -388,32 +526,17 @@ export const make = Effect.gen(function* () {
388526 return cwd === worktreePath || inside(worktreePath, cwd);
389527 })
390528 )
391 return;
529 return keep("provider session is still open");
392530 const finalStatus = yield* git.statusDetailsLocal(worktreePath);
393 if (
394 !finalStatus.isRepo ||
395 finalStatus.branch !== thread.branch ||
396 finalStatus.hasWorkingTreeChanges
397 )
398 return;
531 if (!finalStatus.isRepo || finalStatus.branch !== thread.branch)
532 return keep("repository or branch changed since check");
399533 if (
400534 (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !==
401535 head.commitSha
402536 )
403 return;
404 const finalIgnored = yield* git.execute({
405 operation: "StorageCleanup.ignoredFiles",
406 cwd: worktreePath,
407 args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"],
408 maxOutputBytes: 64 * 1024,
409 });
410 if (
411 finalIgnored.stdoutTruncated ||
412 finalIgnored.stdout
413 .split("\0")
414 .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry))
415 )
416 return;
537 return keep("commit changed since check");
538 const finalChanges = yield* localChangesReason(worktreePath, settings);
539 if (finalChanges !== null) return keep(finalChanges);
417540 const current = resolveWorktreeCleanup(
418541 yield* settingsService.getSettings,
419542 thread.projectId,
......@@ -424,8 +547,17 @@ export const make = Effect.gen(function* () {
424547 current[key as keyof typeof settings] !== settings[key as keyof typeof settings],
425548 )
426549 )
427 return;
428 yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false });
550 return keep("settings changed since check");
551 // Clean only untracked files; Git must still refuse removal if a tracked
552 // edit arrives after our last status check.
553 if (settings.worktreeKeepWhen === "tracked-changes")
554 yield* cleanupGit(worktreePath, ["clean", "-ffdx"], "git clean");
555 yield* cleanupGit(
556 project.workspaceRoot,
557 ["-c", "status.showUntrackedFiles=normal", "worktree", "remove", worktreePath],
558 "git worktree remove",
559 );
560 entries.push({ ...entry, outcome: "removed", reason: sentence(removalReason), bytes });
429561 yield* gitManager.invalidateStatus(project.workspaceRoot);
430562 // Preserve branch and path: ProviderTurnStartService recreates the checkout
431563 // from that branch when the thread is resumed.
......@@ -433,7 +565,17 @@ export const make = Effect.gen(function* () {
433565 }).pipe(
434566 (effect) => withWorkspaceLease(worktreePath, effect),
435567 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 }),
437579 ),
438580 );
439581 }
......@@ -444,10 +586,12 @@ export const make = Effect.gen(function* () {
444586 days: number | null,
445587 now: number,
446588 rotatedLogs: boolean,
589 // Owned by the caller so files removed before a failure still count.
590 removed: { files: number; bytes: number },
447591 ) {
448 if (days === null || !(yield* fs.exists(root))) return;
592 if (days === null || !(yield* fs.exists(root))) return removed;
449593 const realRoot = yield* fs.realPath(root);
450 if (realRoot !== path.resolve(root)) return;
594 if (realRoot !== path.resolve(root)) return removed;
451595 const visit = Effect.fn("StorageCleanup.visitFiles")(function* (
452596 directory: string,
453597 ): Effect.fn.Return<void, PlatformError | ServerSettingsError> {
......@@ -464,40 +608,129 @@ export const make = Effect.gen(function* () {
464608 if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days)
465609 return;
466610 yield* fs.remove(target);
611 removed.files++;
612 removed.bytes += Number(stat.size);
467613 }
468614 }
469615 }
470616 });
471617 yield* visit(realRoot);
618 return removed;
472619 });
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;
475626 const serverSettings = yield* settingsService.getSettings;
476627 const settings = serverSettings.storageCleanup;
477 const now = yield* Clock.currentTimeMillis;
478 yield* cleanWorktrees(serverSettings, now).pipe(
479 Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })),
480 );
481 yield* cleanFiles(
482 config.browserArtifactsDir,
483 settings.browserArtifactsAfterDays,
484 now,
485 false,
486 ).pipe(
487 Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })),
488 );
489 yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe(
490 Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })),
628 const entries: StorageCleanupReportEntry[] = [];
629 yield* cleanWorktrees(serverSettings, now, entries).pipe(
630 Effect.catch((error) =>
631 Effect.gen(function* () {
632 entries.push({
633 kind: "worktree",
634 outcome: "failed",
635 reason: cleanupFailureReason(error),
636 path: null,
637 threadId: null,
638 threadTitle: null,
639 bytes: null,
640 files: null,
641 });
642 yield* Effect.logWarning("worktree cleanup failed", { error });
643 }),
644 ),
491645 );
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;
492712 });
493 const worker = yield* makeDrainableWorker(() =>
494 sweep().pipe(
495 Effect.catchCauseIf(
496 (cause) => !Cause.hasInterruptsOnly(cause),
497 (cause) => Effect.logWarning("storage cleanup failed", { cause }),
713 const worker = yield* makeDrainableWorker(
714 (completion: Deferred.Deferred<StorageCleanupReport, ServerSettingsError> | undefined) =>
715 sweep(completion === undefined ? "automatic" : "manual").pipe(
716 Effect.exit,
717 Effect.flatMap((exit) =>
718 completion === undefined
719 ? exit.pipe(
720 Effect.asVoid,
721 Effect.catchCause((cause) =>
722 Effect.logWarning("storage cleanup failed", { cause }),
723 ),
724 )
725 : Deferred.done(completion, exit).pipe(Effect.asVoid),
726 ),
498727 ),
499 ),
500728 );
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
502735 const start = Effect.fn("StorageCleanup.start")(function* () {
503736 const unsubscribe = yield* terminals.subscribeMetadata((event) =>
......@@ -552,5 +785,13 @@ export const make = Effect.gen(function* () {
552785 ),
553786 );
554787 });
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 });
556795});
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";
176176import * as ServerLifecycleEvents from "./serverLifecycleEvents.ts";
177177import * as ServerRuntimeStartup from "./serverRuntimeStartup.ts";
178178import * as ServerSettings from "./serverSettings.ts";
179import * as StorageCleanup from "./storageCleanup.ts";
179180import * as TerminalManager from "./terminal/Manager.ts";
180181import { withTerminalOutputWindow } from "./terminal/OutputProtocol.ts";
181182import * as PreviewAutomationBroker from "./mcp/PreviewAutomationBroker.ts";
......@@ -1278,6 +1279,7 @@ const layerWsRpc = (
12781279 const serverSelfUpdate = yield* ServerSelfUpdate.ServerSelfUpdate;
12791280 const config = yield* ServerConfig.ServerConfig;
12801281 const lifecycleEvents = yield* ServerLifecycleEvents.ServerLifecycleEvents;
1282 const storageCleanup = yield* StorageCleanup.StorageCleanup;
12811283 const serverSettings = yield* ServerSettings.ServerSettingsService;
12821284 const startup = yield* ServerRuntimeStartup.ServerRuntimeStartup;
12831285 const workspaceEntries = yield* WorkspaceEntries.WorkspaceEntries;
......@@ -2382,6 +2384,8 @@ const layerWsRpc = (
23822384 const keybindingsConfig = yield* keybindings.removeKeybindingRule(rule);
23832385 return { keybindings: keybindingsConfig, issues: [] };
23842386 }),
2387 [WS_METHODS.serverRunStorageCleanup]: () => storageCleanup.runNow,
2388 [WS_METHODS.serverGetStorageCleanupReport]: () => storageCleanup.reports,
23852389 [WS_METHODS.serverGetSettings]: (_input) =>
23862390 serverSettings.getSettings.pipe(Effect.map(ServerSettings.redactServerSettingsForClient)),
23872391 [WS_METHODS.serverUpdateSettings]: ({ patch, providerInstanceMutation }) =>
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+1-12
......@@ -39,6 +39,7 @@ import {
3939 useResourceTelemetryHistory,
4040} from "../../lib/resourceTelemetryState";
4141import { cn } from "../../lib/utils";
42import { formatBytes } from "../../lib/formatBytes";
4243import { ensureLocalApi } from "../../localApi";
4344import { serverEnvironment } from "../../state/server";
4445import { readEnvironmentScope, useEnvironmentScope } from "../../state/session";
......@@ -65,18 +66,6 @@ const HISTORY_WINDOWS = [
6566 { label: "1h", windowMs: 60 * 60_000, bucketMs: 2 * 60_000 },
6667] 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
8069function formatRate(value: number): string {
8170 return `${formatBytes(value)}/s`;
8271}
apps/web/src/components/settings/StorageSettings.tsx+298-2
......@@ -1,6 +1,22 @@
1import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts";
1import type {
2 StorageCleanupReport,
3 StorageCleanupSettings,
4 WorktreeCleanupRules,
5} from "@t3tools/contracts";
26import { 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
521import { Input } from "../ui/input";
622import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
......@@ -17,8 +33,11 @@ import {
1733 SettingsPageContainer,
1834 SettingsRow,
1935 SettingsSection,
36 useRelativeTimeTick,
2037} from "./settingsLayout";
2138import { SettingsScopeNotice } from "./SettingsScopeNotice";
39import { FoldedSettingsSection } from "./FoldedSettingsSection";
40import { SettingsGroup } from "./SettingsGroup";
2241import type { ScopedSettingsTarget } from "./scopedSettings";
2342import { useSettingsScope } from "./SettingsScopeContext";
2443import { searchableSetting } from "./settingsSearch";
......@@ -144,6 +163,239 @@ function RetentionControl({
144163 );
145164}
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
147399export function StorageSettingsPanel() {
148400 const { scope, connectedEnvironments, targets, target } = useSettingsScope();
149401 const scopedSettings = useScopedSettings();
......@@ -312,6 +564,49 @@ export function StorageSettingsPanel() {
312564 />
313565 }
314566 />
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 )}
315610 </>
316611 )}
317612 </SettingsSection>
......@@ -346,6 +641,7 @@ export function StorageSettingsPanel() {
346641 />
347642 </SettingsSection>
348643 )}
644 {!isProjectScope && <CleanupSection />}
349645 </SettingsPageContainer>
350646 );
351647}
apps/web/src/components/settings/scopedSettings.test.ts+6
......@@ -160,6 +160,7 @@ describe("scoped settings writes", () => {
160160 worktreeOnDelete: true,
161161 worktreeOnMerge: true,
162162 worktreeUnchanged: false,
163 worktreeKeepWhen: "uncommitted-changes",
163164 },
164165 },
165166 },
......@@ -183,12 +184,14 @@ describe("scoped settings writes", () => {
183184 worktreeOnDelete: false,
184185 worktreeOnMerge: true,
185186 worktreeUnchanged: false,
187 worktreeKeepWhen: "uncommitted-changes",
186188 },
187189 {
188190 worktreeAfterDays: null,
189191 worktreeOnDelete: false,
190192 worktreeOnMerge: false,
191193 worktreeUnchanged: false,
194 worktreeKeepWhen: "uncommitted-changes",
192195 },
193196 ]);
194197 });
......@@ -295,6 +298,7 @@ describe("scoped settings writes", () => {
295298 worktreeOnDelete: false,
296299 worktreeOnMerge: true,
297300 worktreeUnchanged: false,
301 worktreeKeepWhen: "uncommitted-changes",
298302 },
299303 },
300304 },
......@@ -315,6 +319,7 @@ describe("scoped settings writes", () => {
315319 worktreeOnDelete: true,
316320 worktreeOnMerge: true,
317321 worktreeUnchanged: false,
322 worktreeKeepWhen: "uncommitted-changes",
318323 },
319324 },
320325 },
......@@ -328,6 +333,7 @@ describe("scoped settings writes", () => {
328333 worktreeOnDelete: true,
329334 worktreeOnMerge: false,
330335 worktreeUnchanged: false,
336 worktreeKeepWhen: "uncommitted-changes",
331337 },
332338 },
333339 },
apps/web/src/components/settings/settingsSearch.ts+17
......@@ -134,6 +134,23 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right
134134 * that may not be mounted point at their nearest stable section instead.
135135 */
136136export 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 },
137154 {
138155 id: "storage-worktrees",
139156 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.
143143
144144Worktrees can be removed after a chosen number of inactive days, after merging, or when they
145145have 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`
147prevent removal. Branches and thread history stay; starting another turn recreates the checkout.
146sessions and shared worktrees prevent removal. **Keep worktrees with local changes** defaults
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.
148151Merge cleanup requires a merged pull request whose commits are included in the remote default
149152branch. A squash or rebase merge on GitHub also counts when the pull request targeted the default
150153branch 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
154157server waits for sessions and terminals to stop and retries skipped worktrees after restart.
155158Existing 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
157163Browser captures and rotated logs have separate retention periods. Expired capture links stop
158164working. 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 =
5858 | typeof WS_METHODS.subscribeServerConfig
5959 | typeof WS_METHODS.subscribeServerLifecycle
6060 | typeof WS_METHODS.scheduledTasksSubscribe
61 | typeof WS_METHODS.serverGetStorageCleanupReport
6162 | typeof WS_METHODS.subscribeTerminalEvents
6263 | typeof WS_METHODS.subscribeTerminalMetadata
6364 | typeof WS_METHODS.subscribePreviewEvents
packages/client-runtime/src/state/commandPermissions.test.ts+27
......@@ -8,6 +8,7 @@ import { describe, expect, it } from "@effect/vitest";
88import { vi } from "vite-plus/test";
99import {
1010 AuthOrchestrationOperateScope,
11 AuthSettingsWriteScope,
1112 AuthSourceControlWriteScope,
1213 ThreadId,
1314 EnvironmentId,
......@@ -58,6 +59,32 @@ const setup = Effect.gen(function* () {
5859});
5960
6061describe("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
6188 it.effect("uses the target grant for both availability and execution", () =>
6289 Effect.scoped(
6390 Effect.gen(function* () {
packages/client-runtime/src/state/server.ts+10
......@@ -983,6 +983,16 @@ export function createServerEnvironmentAtoms<R, E>(
983983
984984 return {
985985 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 }),
986996 updateStateAtom,
987997 settingsValueAtom,
988998 providersValueAtom,
packages/contracts/src/clientRpcPermissions.ts+2
......@@ -2,6 +2,7 @@ import * as Schema from "effect/Schema";
22import { GitPreparePullRequestThreadInput } from "./git.ts";
33import {
44 AuthOrchestrationOperateScope,
5 AuthSettingsWriteScope,
56 AuthSourceControlWriteScope,
67 type AuthEnvironmentScope,
78} from "./auth.ts";
......@@ -9,6 +10,7 @@ import { WS_METHODS } from "./rpc.ts";
910
1011/** Incremental client enforcement; the server still authorizes every request. */
1112export const CLIENT_GUARDED_RPC_SCOPES = {
13 [WS_METHODS.serverRunStorageCleanup]: AuthSettingsWriteScope,
1214 [WS_METHODS.pullRequestsRunAction]: AuthSourceControlWriteScope,
1315 [WS_METHODS.pullRequestsUpdate]: AuthSourceControlWriteScope,
1416 [WS_METHODS.pullRequestsComment]: AuthSourceControlWriteScope,
packages/contracts/src/environment.ts+1
......@@ -125,6 +125,7 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
125125 /** Server evaluates merge and inactivity settlement without a client. */
126126 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
127127 storageCleanup: Schema.optionalKey(Schema.Boolean),
128 storageCleanupRun: Schema.optionalKey(Schema.Boolean),
128129 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
129130 /** Server honors the `worktreesDirectory` setting. */
130131 worktreesDirectory: Schema.optionalKey(Schema.Boolean),
packages/contracts/src/rpc.ts+22-1
......@@ -317,7 +317,12 @@ import {
317317 ProviderConsumeResetCreditResult,
318318} from "./providerUsageLimits.ts";
319319import { 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";
321326import {
322327 ScheduledTaskDeleteInput,
323328 ScheduledTaskDeleteResult,
......@@ -468,6 +473,8 @@ export const WS_METHODS = {
468473 serverCommitDesktopUpdate: "server.commitDesktopUpdate",
469474 serverUpsertKeybinding: "server.upsertKeybinding",
470475 serverRemoveKeybinding: "server.removeKeybinding",
476 serverRunStorageCleanup: "server.runStorageCleanup",
477 serverGetStorageCleanupReport: "server.getStorageCleanupReport",
471478 serverGetSettings: "server.getSettings",
472479 serverUpdateSettings: "server.updateSettings",
473480 serverDiscoverSourceControl: "server.discoverSourceControl",
......@@ -730,6 +737,18 @@ const WsServerCommitDesktopUpdateRpc = Rpc.make(WS_METHODS.serverCommitDesktopUp
730737 error: Schema.Union([ServerSelfUpdateError, EnvironmentAuthorizationError]),
731738});
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
733752const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, {
734753 payload: Schema.Struct({}),
735754 success: ServerSettings,
......@@ -1833,6 +1852,8 @@ export const WsRpcGroup = RpcGroup.make(
18331852 WsServerCommitDesktopUpdateRpc,
18341853 WsServerUpsertKeybindingRpc,
18351854 WsServerRemoveKeybindingRpc,
1855 WsServerRunStorageCleanupRpc,
1856 WsServerGetStorageCleanupReportRpc,
18361857 WsServerGetSettingsRpc,
18371858 WsServerUpdateSettingsRpc,
18381859 WsServerDiscoverSourceControlRpc,
packages/contracts/src/settings.test.ts+24
......@@ -57,6 +57,7 @@ describe("storage cleanup settings", () => {
5757 worktreeOnMerge: false,
5858 worktreeOnDelete: false,
5959 worktreeUnchanged: false,
60 worktreeKeepWhen: "uncommitted-changes",
6061 browserArtifactsAfterDays: null,
6162 logsAfterDays: null,
6263 });
......@@ -86,6 +87,29 @@ describe("storage cleanup settings", () => {
8687 ).toThrow();
8788 });
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
89113 it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => {
90114 expect(() =>
91115 decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }),
packages/contracts/src/settings.ts+38
......@@ -13,6 +13,7 @@ import {
1313 ForwardCompatibleOptional,
1414 OmittedWhenNull,
1515 ProjectId,
16 ThreadId,
1617 TrimmedNonEmptyString,
1718 TrimmedString,
1819} from "./baseSchemas.ts";
......@@ -941,7 +942,17 @@ const StorageRetentionDays = Schema.NullOr(
941942 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })),
942943);
943944
945export const WorktreeKeepWhen = Schema.Literals([
946 "any-local-files",
947 "uncommitted-changes",
948 "tracked-changes",
949]);
950export type WorktreeKeepWhen = typeof WorktreeKeepWhen.Type;
951
944952export const WorktreeCleanupRules = Schema.Struct({
953 worktreeKeepWhen: WorktreeKeepWhen.pipe(
954 Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")),
955 ),
945956 worktreeAfterDays: StorageRetentionDays,
946957 worktreeOnMerge: Schema.Boolean,
947958 worktreeOnDelete: Schema.Boolean,
......@@ -1034,7 +1045,32 @@ const NULLABLE_PROJECT_SETTINGS_OVERRIDES: ReadonlySet<ProjectScopedServerSettin
10341045 "sidebarAutoSettleAfterDays",
10351046]);
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
10371070export const StorageCleanupSettings = Schema.Struct({
1071 worktreeKeepWhen: WorktreeKeepWhen.pipe(
1072 Schema.withDecodingDefault(Effect.succeed("uncommitted-changes")),
1073 ),
10381074 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
10391075 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
10401076 worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
......@@ -1391,6 +1427,7 @@ export const ServerSettingsPatch = Schema.Struct({
13911427 Schema.Struct({
13921428 mode: Schema.Literal("custom"),
13931429 rules: Schema.Struct({
1430 worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen),
13941431 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
13951432 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
13961433 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
......@@ -1402,6 +1439,7 @@ export const ServerSettingsPatch = Schema.Struct({
14021439 ),
14031440 storageCleanup: Schema.optionalKey(
14041441 Schema.Struct({
1442 worktreeKeepWhen: Schema.optionalKey(WorktreeKeepWhen),
14051443 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
14061444 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
14071445 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
packages/shared/src/projectSettings.test.ts+2
......@@ -337,6 +337,7 @@ describe("resolveWorktreeCleanup", () => {
337337 worktreeOnDelete: false,
338338 worktreeOnMerge: false,
339339 worktreeUnchanged: false,
340 worktreeKeepWhen: "uncommitted-changes",
340341 });
341342 expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited);
342343 const custom = applyServerSettingsPatch(off, {
......@@ -370,6 +371,7 @@ describe("resolveWorktreeCleanup", () => {
370371 worktreeOnDelete: true,
371372 worktreeOnMerge: true,
372373 worktreeUnchanged: false,
374 worktreeKeepWhen: "uncommitted-changes",
373375 });
374376 expect(
375377 resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null)
packages/shared/src/projectSettings.ts+15-3
......@@ -220,12 +220,24 @@ export function resolveWorktreeCleanup(
220220 if (policy?.mode === "custom") return policy.rules;
221221 if (policy?.mode === "off")
222222 return {
223 worktreeKeepWhen: settings.storageCleanup.worktreeKeepWhen,
223224 worktreeAfterDays: null,
224225 worktreeOnMerge: false,
225226 worktreeOnDelete: false,
226227 worktreeUnchanged: false,
227228 };
228 const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } =
229 settings.storageCleanup;
230 return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged };
229 const {
230 worktreeKeepWhen,
231 worktreeAfterDays,
232 worktreeOnMerge,
233 worktreeOnDelete,
234 worktreeUnchanged,
235 } = settings.storageCleanup;
236 return {
237 worktreeKeepWhen,
238 worktreeAfterDays,
239 worktreeOnMerge,
240 worktreeOnDelete,
241 worktreeUnchanged,
242 };
231243}
packages/shared/src/serverSettings.test.ts+1
......@@ -37,6 +37,7 @@ describe("serverSettings helpers", () => {
3737 worktreeOnMerge: true,
3838 worktreeOnDelete: false,
3939 worktreeUnchanged: false,
40 worktreeKeepWhen: "uncommitted-changes",
4041 browserArtifactsAfterDays: null,
4142 logsAfterDays: 30,
4243 });
packages/shared/src/serverSettings.ts+1
......@@ -324,6 +324,7 @@ export function applyServerSettingsPatch(
324324 ? {
325325 mode: "custom" as const,
326326 rules: {
327 worktreeKeepWhen: next.storageCleanup.worktreeKeepWhen,
327328 worktreeAfterDays: next.storageCleanup.worktreeAfterDays,
328329 worktreeOnMerge: next.storageCleanup.worktreeOnMerge,
329330 worktreeOnDelete: next.storageCleanup.worktreeOnDelete,