authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-17 02:39:49-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-17 02:39:49-03:00
logc4ca1b0f94e56bd93899704f03015d51948a6b4d
tree2aea30e918e2f06dafda1ddc222f00f7b6d93426
parent2a47fc905a7a1054823919980f625b7cd869f1c1
signature Signed by PGP key B5690EEEBB952194

feat(settings): add automatic storage cleanup per machine and project (#11598)

Co-authored-by: maria-rcks <254055478+maria-rcks@users.noreply.github.com>

36 files changed, 1911 insertions(+), 7 deletions(-)

apps/server/integration/OrchestrationEngineHarness.integration.ts+7
......@@ -39,6 +39,7 @@ import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapte
3939import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
4040import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
4141import { ServerSettingsService } from "../src/serverSettings.ts";
42import * as StorageCleanup from "../src/storageCleanup.ts";
4243import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
4344import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";
4445import {
......@@ -384,6 +385,12 @@ export const makeOrchestrationIntegrationHarness = (
384385 Layer.provideMerge(VcsProcess.layer),
385386 );
386387 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
388 Layer.provideMerge(
389 Layer.succeed(StorageCleanup.StorageCleanup, {
390 start: () => Effect.void,
391 drain: Effect.void,
392 }),
393 ),
387394 Layer.provideMerge(runtimeIngestionLayer),
388395 Layer.provideMerge(providerCommandReactorLayer),
389396 Layer.provideMerge(checkpointReactorLayer),
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
......@@ -83,6 +83,7 @@ describe("CheckpointDiffQuery.layer", () => {
8383 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
8484 getShellSnapshot: () =>
8585 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
86 getDeletedWorktreeThreads: () => Effect.die("unused"),
8687 getArchivedShellSnapshot: () =>
8788 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
8889 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
......@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199200 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
200201 getShellSnapshot: () =>
201202 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
203 getDeletedWorktreeThreads: () => Effect.die("unused"),
202204 getArchivedShellSnapshot: () =>
203205 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
204206 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
......@@ -290,6 +292,7 @@ describe("CheckpointDiffQuery.layer", () => {
290292 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
291293 getShellSnapshot: () =>
292294 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
295 getDeletedWorktreeThreads: () => Effect.die("unused"),
293296 getArchivedShellSnapshot: () =>
294297 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
295298 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
......@@ -366,6 +369,7 @@ describe("CheckpointDiffQuery.layer", () => {
366369 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
367370 getShellSnapshot: () =>
368371 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
372 getDeletedWorktreeThreads: () => Effect.die("unused"),
369373 getArchivedShellSnapshot: () =>
370374 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
371375 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
......@@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => {
427431 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
428432 getShellSnapshot: () =>
429433 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
434 getDeletedWorktreeThreads: () => Effect.die("unused"),
430435 getArchivedShellSnapshot: () =>
431436 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
432437 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
apps/server/src/environment/ServerEnvironment.ts+2
......@@ -224,6 +224,8 @@ export const make = Effect.gen(function* () {
224224 inlineMessageContext: true,
225225 threadSettlement: true,
226226 threadAutoSettlement: true,
227 storageCleanup: true,
228 projectWorktreeCleanup: true,
227229 threadRestartContinuation: true,
228230 projectSettingsOverrides: true,
229231 threadSnooze: true,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
......@@ -434,6 +434,7 @@ describe("OrchestrationEngine", () => {
434434 threads: [],
435435 updatedAt: projectionSnapshot.updatedAt,
436436 }),
437 getDeletedWorktreeThreads: () => Effect.die("unused"),
437438 getArchivedShellSnapshot: () =>
438439 Effect.succeed({
439440 snapshotSequence: projectionSnapshot.snapshotSequence,
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11
......@@ -15,6 +15,7 @@ import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1515import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
1616import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
1717import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
18import { StorageCleanup } from "../../storageCleanup.ts";
1819
1920describe("OrchestrationReactor", () => {
2021 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
......@@ -31,6 +32,15 @@ describe("OrchestrationReactor", () => {
3132
3233 runtime = ManagedRuntime.make(
3334 Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe(
35 Layer.provideMerge(
36 Layer.succeed(StorageCleanup, {
37 start: () => {
38 started.push("storage-cleanup");
39 return Effect.void;
40 },
41 drain: Effect.void,
42 }),
43 ),
3444 Layer.provideMerge(
3545 Layer.succeed(ProviderRuntimeIngestionService, {
3646 start: () => {
......@@ -120,6 +130,7 @@ describe("OrchestrationReactor", () => {
120130 "thread-settlement-reactor",
121131 "pull-request-sync-reactor",
122132 "agent-awareness-relay",
133 "storage-cleanup",
123134 ]);
124135
125136 await Effect.runPromise(Scope.close(scope, Exit.void));
apps/server/src/orchestration/Layers/OrchestrationReactor.ts+3
......@@ -13,6 +13,7 @@ import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
1313import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
1414import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
1515import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
16import * as StorageCleanup from "../../storageCleanup.ts";
1617
1718export const makeOrchestrationReactor = Effect.gen(function* () {
1819 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
......@@ -23,6 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
2324 const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
2425 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
2526 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
27 const storageCleanup = yield* StorageCleanup.StorageCleanup;
2628
2729 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
2830 yield* providerRuntimeIngestion.start();
......@@ -33,6 +35,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
3335 yield* threadSettlementReactor.start();
3436 yield* pullRequestSyncReactor.start();
3537 yield* agentAwarenessRelay.start();
38 yield* storageCleanup.start();
3639 });
3740
3841 return {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+34
......@@ -1068,6 +1068,40 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
10681068 (yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-active")))._tag,
10691069 "None",
10701070 );
1071 assert.deepEqual(yield* snapshotQuery.getDeletedWorktreeThreads(), []);
1072 yield* sql`
1073 UPDATE projection_threads
1074 SET branch = 'retained-branch', worktree_path = '/tmp/archived-worktree',
1075 deleted_at = '2026-04-06T00:00:09.000Z'
1076 WHERE thread_id = 'thread-archived'
1077 `;
1078 assert.deepEqual(yield* snapshotQuery.getDeletedWorktreeThreads(), [
1079 {
1080 id: ThreadId.make("thread-archived"),
1081 projectId: ProjectId.make("project-archive-test"),
1082 branch: "retained-branch",
1083 worktreePath: "/tmp/archived-worktree",
1084 workspaceRoot: "/tmp/archive-test",
1085 deletedAt: "2026-04-06T00:00:09.000Z",
1086 },
1087 ]);
1088 assert.deepEqual((yield* snapshotQuery.getArchivedShellSnapshot()).threads, []);
1089 yield* sql`
1090 UPDATE projection_projects
1091 SET deleted_at = '2026-04-06T00:00:10.000Z', updated_at = '2026-04-06T00:00:10.000Z'
1092 WHERE project_id = 'project-archive-test'
1093 `;
1094 assert.deepEqual((yield* snapshotQuery.getShellSnapshot()).projects, []);
1095 assert.deepEqual(yield* snapshotQuery.getDeletedWorktreeThreads(), [
1096 {
1097 id: ThreadId.make("thread-archived"),
1098 projectId: ProjectId.make("project-archive-test"),
1099 branch: "retained-branch",
1100 worktreePath: "/tmp/archived-worktree",
1101 workspaceRoot: "/tmp/archive-test",
1102 deletedAt: "2026-04-06T00:00:09.000Z",
1103 },
1104 ]);
10711105 }),
10721106 );
10731107
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+31
......@@ -637,6 +637,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
637637 `,
638638 });
639639
640 const listDeletedWorktreeRows = SqlSchema.findAll({
641 Request: Schema.Void,
642 Result: Schema.Struct({
643 id: ThreadId,
644 projectId: ProjectId,
645 branch: Schema.String,
646 worktreePath: Schema.String,
647 workspaceRoot: Schema.String,
648 deletedAt: IsoDateTime,
649 }),
650 execute: () => sql`
651 SELECT t.thread_id AS "id", t.project_id AS "projectId", t.branch,
652 t.worktree_path AS "worktreePath", p.workspace_root AS "workspaceRoot",
653 t.deleted_at AS "deletedAt"
654 FROM projection_threads t
655 JOIN projection_projects p ON p.project_id = t.project_id
656 WHERE t.deleted_at IS NOT NULL AND t.worktree_path IS NOT NULL AND t.branch IS NOT NULL
657 ORDER BY t.deleted_at DESC, t.thread_id ASC
658 `,
659 });
660 const getDeletedWorktreeThreads: ProjectionSnapshotQueryShape["getDeletedWorktreeThreads"] = () =>
661 listDeletedWorktreeRows(undefined).pipe(
662 Effect.mapError(
663 toPersistenceSqlOrDecodeError(
664 "ProjectionSnapshotQuery.getDeletedWorktreeThreads:query",
665 "ProjectionSnapshotQuery.getDeletedWorktreeThreads:decodeRows",
666 ),
667 ),
668 );
669
640670 const listArchivedThreadRows = SqlSchema.findAll({
641671 Request: Schema.Void,
642672 Result: ProjectionThreadDbRowSchema,
......@@ -3734,6 +3764,7 @@ pending_approval_requests AS (
37343764 getSnapshot,
37353765 getShellSnapshot,
37363766 getArchivedShellSnapshot,
3767 getDeletedWorktreeThreads,
37373768 searchThreads,
37383769 getSnapshotSequence,
37393770 getCounts,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+13-3
......@@ -1,3 +1,4 @@
1import { withWorkspaceLease } from "../../workspace/workspaceLease.ts";
12import {
23 type ChatAttachment,
34 CommandId,
......@@ -26,6 +27,7 @@ import * as Equal from "effect/Equal";
2627import * as FileSystem from "effect/FileSystem";
2728import * as Layer from "effect/Layer";
2829import * as Option from "effect/Option";
30import * as Path from "effect/Path";
2931import * as Schedule from "effect/Schedule";
3032import * as Schema from "effect/Schema";
3133import * as Stream from "effect/Stream";
......@@ -214,6 +216,7 @@ const make = Effect.gen(function* () {
214216 const providerRegistry = yield* ProviderRegistry;
215217 const gitWorkflow = yield* GitWorkflowService;
216218 const fileSystem = yield* FileSystem.FileSystem;
219 const path = yield* Path.Path;
217220 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
218221 const textGeneration = yield* TextGeneration;
219222 const serverSettingsService = yield* ServerSettingsService;
......@@ -1784,16 +1787,23 @@ const make = Effect.gen(function* () {
17841787 return;
17851788 }
17861789 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
1787 yield* ensureSessionForThread(
1790 const resume = ensureSessionForThread(
17881791 event.payload.threadId,
17891792 event.occurredAt,
17901793 cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {},
17911794 );
1795 yield* thread.worktreePath
1796 ? withWorkspaceLease(path.resolve(thread.worktreePath), resume)
1797 : resume;
17921798 return;
17931799 }
1794 case "thread.turn-start-requested":
1795 yield* processTurnStartRequested(event);
1800 case "thread.turn-start-requested": {
1801 const thread = yield* resolveThreadShell(event.payload.threadId);
1802 yield* thread?.worktreePath
1803 ? withWorkspaceLease(path.resolve(thread.worktreePath), processTurnStartRequested(event))
1804 : processTurnStartRequested(event);
17961805 return;
1806 }
17971807 case "thread.turn-interrupt-requested":
17981808 yield* processTurnInterruptRequested(event);
17991809 return;
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+13
......@@ -131,6 +131,19 @@ export interface ProjectionSnapshotQueryShape {
131131 ProjectionRepositoryError
132132 >;
133133
134 /** Durable worktree ownership retained after thread deletion, including across restarts. */
135 readonly getDeletedWorktreeThreads: () => Effect.Effect<
136 ReadonlyArray<{
137 readonly id: ThreadId;
138 readonly projectId: ProjectId;
139 readonly branch: string;
140 readonly worktreePath: string;
141 readonly workspaceRoot: string;
142 readonly deletedAt: string;
143 }>,
144 ProjectionRepositoryError
145 >;
146
134147 /**
135148 * Search active thread navigation metadata, user messages, and canonical
136149 * assistant outputs without hydrating thread detail snapshots.
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+518
......@@ -3,6 +3,7 @@ import {
33 EventId,
44 ProjectId,
55 ProviderInstanceId,
6 ProviderDriverKind,
67 PullRequestOperationError,
78 ThreadId,
89 type OrchestrationCommand,
......@@ -17,8 +18,10 @@ import {
1718import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
1819import { assert, describe, it } from "@effect/vitest";
1920import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";
2022import * as Deferred from "effect/Deferred";
2123import * as Effect from "effect/Effect";
24import * as Fiber from "effect/Fiber";
2225import * as FileSystem from "effect/FileSystem";
2326import * as Layer from "effect/Layer";
2427import * as PubSub from "effect/PubSub";
......@@ -41,6 +44,16 @@ import {
4144} from "./Services/OrchestrationEngine.ts";
4245import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
4346import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";
47import * as NodeServices from "@effect/platform-node/NodeServices";
48import * as Path from "effect/Path";
49import { ChildProcessSpawner } from "effect/unstable/process";
50import { ServerConfig } from "../config.ts";
51import * as StorageCleanup from "../storageCleanup.ts";
52import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
53import { TerminalManager } from "../terminal/Manager.ts";
54import { GitVcsDriver } from "../vcs/GitVcsDriver.ts";
55import { ThreadDeletionReactor } from "./Services/ThreadDeletionReactor.ts";
56import { ProviderService } from "../provider/Services/ProviderService.ts";
4457
4558const NOW = "2026-08-28T12:00:00.000Z";
4659const PROJECT_ID = ProjectId.make("settlement-project");
......@@ -1363,3 +1376,508 @@ describe("ThreadSettlementReactor", () => {
13631376 ),
13641377 );
13651378});
1379
1380describe("storage cleanup", () => {
1381 it.effect("serializes users of one workspace while other workspaces can start", () =>
1382 Effect.gen(function* () {
1383 const releaseCleanup = yield* Deferred.make<void>();
1384 const providerEntered = yield* Deferred.make<void>();
1385 const cleanup = yield* withWorkspaceLease(
1386 "/workspace/shared",
1387 Deferred.await(releaseCleanup),
1388 ).pipe(Effect.forkScoped({ startImmediately: true }));
1389 const provider = yield* withWorkspaceLease(
1390 "/workspace/shared",
1391 Deferred.succeed(providerEntered, undefined),
1392 ).pipe(Effect.forkScoped({ startImmediately: true }));
1393 assert.strictEqual(yield* Deferred.isDone(providerEntered), false);
1394 yield* withWorkspaceLease("/workspace/other", Effect.void);
1395 yield* Deferred.succeed(releaseCleanup, undefined);
1396 yield* Fiber.join(cleanup);
1397 yield* Fiber.join(provider);
1398 assert.strictEqual(yield* Deferred.isDone(providerEntered), true);
1399 }).pipe(Effect.scoped),
1400 );
1401
1402 for (const protection of [
1403 "none",
1404 "dirty",
1405 "ignored",
1406 "ignored-directory",
1407 "shared",
1408 "project-root",
1409 "nested-project",
1410 "new-nested-project",
1411 "session",
1412 "terminal-cwd",
1413 "terminal-worktree",
1414 "recent",
1415 "merged",
1416 "unmerged",
1417 "unchanged",
1418 "unchanged-two-worktrees",
1419 "diverged",
1420 "head-moved",
1421 "deleted",
1422 "deleted-event",
1423 "deleted-dirty",
1424 "deleted-ignored",
1425 "deleted-shared",
1426 "deleted-project",
1427 "deleted-owner",
1428 "deleted-owner-root",
1429 "deleted-owner-nested",
1430 "deleted-provider",
1431 "project-off",
1432 "project-custom",
1433 "project-policy-disabled",
1434 "deleted-project-custom",
1435 "deleted-project-off",
1436 "policy-disabled",
1437 "policy-extended",
1438 "files-disabled",
1439 "files-extended",
1440 ] as const) {
1441 it.effect(
1442 `retains protected worktrees (${protection}) and expires only old artifacts and rotated logs`,
1443 () =>
1444 Effect.gen(function* () {
1445 yield* TestClock.setTime(Date.parse(NOW));
1446 const fs = yield* FileSystem.FileSystem;
1447 const path = yield* Path.Path;
1448 const config = yield* ServerConfig;
1449 const worktreePath = path.join(config.worktreesDir, "feature");
1450 yield* fs.makeDirectory(worktreePath, { recursive: true });
1451 yield* fs.writeFileString(path.join(worktreePath, ".git"), "gitdir: /test/admin");
1452 const secondWorktreePath = path.join(config.worktreesDir, "feature-two");
1453 if (protection === "unchanged-two-worktrees") {
1454 yield* fs.makeDirectory(secondWorktreePath);
1455 yield* fs.writeFileString(
1456 path.join(secondWorktreePath, ".git"),
1457 "gitdir: /test/admin-two",
1458 );
1459 }
1460 if (protection === "ignored" || protection === "deleted-ignored")
1461 yield* fs.writeFileString(path.join(worktreePath, ".env"), "secret");
1462 if (protection === "ignored-directory") {
1463 yield* fs.makeDirectory(path.join(worktreePath, ".cache"));
1464 yield* fs.writeFileString(path.join(worktreePath, ".cache", "local-data"), "keep");
1465 }
1466 yield* fs.makeDirectory(config.browserArtifactsDir, { recursive: true });
1467 const oldImage = path.join(config.browserArtifactsDir, "old.png");
1468 const recentImage = path.join(config.browserArtifactsDir, "recent.png");
1469 const oldLog = path.join(config.logsDir, "server.log.1");
1470 const activeLog = path.join(config.logsDir, "server.log");
1471 const old = DateTime.toDateUtc(DateTime.makeUnsafe("2026-08-01T00:00:00.000Z"));
1472 for (const file of [oldImage, oldLog, activeLog]) {
1473 yield* fs.writeFileString(file, "keep or remove");
1474 yield* fs.utimes(file, old, old);
1475 }
1476 yield* fs.writeFileString(recentImage, "recent");
1477 const recent = DateTime.toDateUtc(DateTime.makeUnsafe(NOW));
1478 yield* fs.utimes(recentImage, recent, recent);
1479 const thread = makeThread("storage-thread", {
1480 branch: "feature",
1481 worktreePath,
1482 latestUserMessageAt:
1483 protection === "recent" ? "2026-08-26T00:00:00.000Z" : "2026-08-01T00:00:00.000Z",
1484 ...(protection === "session"
1485 ? {
1486 session: {
1487 threadId: ThreadId.make("storage-thread"),
1488 status: "ready",
1489 providerName: "codex",
1490 runtimeMode: "full-access",
1491 activeTurnId: null,
1492 lastError: null,
1493 updatedAt: NOW,
1494 },
1495 }
1496 : {}),
1497 });
1498 const snapshotRead = yield* Deferred.make<void>();
1499 const deletionStarted = yield* Deferred.make<void>();
1500 const deletionStopped = yield* Deferred.make<void>();
1501 if (protection !== "deleted-event") yield* Deferred.succeed(deletionStopped, undefined);
1502 const domainEvents = yield* PubSub.unbounded<OrchestrationEvent>();
1503 const deleteRule = protection.startsWith("deleted");
1504 let tombstoned = deleteRule && protection !== "deleted-event";
1505 const removals: string[] = [];
1506 const mergeRule = protection === "merged" || protection === "unmerged";
1507 const unchangedRule =
1508 protection === "unchanged" ||
1509 protection === "unchanged-two-worktrees" ||
1510 protection === "diverged" ||
1511 protection === "head-moved";
1512 let headReads = 0;
1513 let snapshotReads = 0;
1514 let defaultRefFetched = false;
1515 let fetches = 0;
1516 const settingsService = yield* ServerSettingsService.pipe(
1517 Effect.provide(
1518 ServerSettingsService.layerTest({
1519 projectSettingsOverrides: {
1520 [PROJECT_ID]:
1521 protection === "project-off" || protection === "deleted-project-off"
1522 ? { worktreeCleanup: { mode: "off" as const } }
1523 : protection === "project-custom" || protection === "deleted-project-custom"
1524 ? {
1525 worktreeCleanup: {
1526 mode: "custom" as const,
1527 rules: {
1528 worktreeAfterDays: protection === "project-custom" ? 8 : null,
1529 worktreeOnDelete: deleteRule,
1530 worktreeOnMerge: false,
1531 worktreeUnchanged: false,
1532 },
1533 },
1534 }
1535 : {},
1536 },
1537 storageCleanup: {
1538 worktreeAfterDays:
1539 deleteRule || mergeRule || unchangedRule || protection === "project-custom"
1540 ? null
1541 : 8,
1542 worktreeOnDelete: deleteRule && protection !== "deleted-project-custom",
1543 worktreeOnMerge: mergeRule,
1544 worktreeUnchanged: unchangedRule,
1545 browserArtifactsAfterDays: 8,
1546 logsAfterDays: 8,
1547 },
1548 }),
1549 ),
1550 );
1551 const cleanup = yield* StorageCleanup.make.pipe(
1552 Effect.provide(
1553 Layer.mergeAll(
1554 Layer.succeed(ServerSettingsService, settingsService),
1555 Layer.succeed(FileSystem.FileSystem, {
1556 ...fs,
1557 stat: (target) =>
1558 fs.stat(target).pipe(
1559 Effect.tap(() => {
1560 if (
1561 !protection.startsWith("files-") ||
1562 (target !== oldImage && target !== oldLog)
1563 )
1564 return Effect.void;
1565 return settingsService
1566 .updateSettings({
1567 storageCleanup: {
1568 [target === oldImage ? "browserArtifactsAfterDays" : "logsAfterDays"]:
1569 protection === "files-disabled" ? null : 60,
1570 },
1571 })
1572 .pipe(Effect.orDie);
1573 }),
1574 ),
1575 }),
1576 Layer.mock(ProjectionSnapshotQuery)({
1577 getDeletedWorktreeThreads: () =>
1578 Effect.succeed(
1579 tombstoned
1580 ? [
1581 {
1582 id: thread.id,
1583 projectId: thread.projectId,
1584 branch: "feature",
1585 worktreePath,
1586 workspaceRoot:
1587 protection === "deleted-owner-root"
1588 ? worktreePath
1589 : protection === "deleted-owner-nested"
1590 ? path.join(worktreePath, "nested")
1591 : config.baseDir,
1592 deletedAt: NOW,
1593 },
1594 ]
1595 : [],
1596 ),
1597 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 2 }),
1598 getShellSnapshot: () =>
1599 Deferred.succeed(snapshotRead, undefined).pipe(
1600 Effect.andThen(
1601 Effect.sync(() => {
1602 snapshotReads++;
1603 const projects =
1604 protection.startsWith("deleted-owner") ||
1605 protection === "deleted-project-off" ||
1606 protection === "deleted-project-custom"
1607 ? []
1608 : [makeProject(PROJECT_ID, config.baseDir)];
1609 const threads = tombstoned ? [] : [thread];
1610 if (protection === "deleted-shared")
1611 threads.push({ ...thread, id: ThreadId.make("surviving-thread") });
1612 if (protection === "deleted-project")
1613 projects.push(makeProject(LINKED_PROJECT_ID, worktreePath));
1614 if (
1615 protection === "project-root" ||
1616 protection === "nested-project" ||
1617 (protection === "new-nested-project" && snapshotReads > 1)
1618 ) {
1619 projects.push(
1620 makeProject(
1621 LINKED_PROJECT_ID,
1622 protection === "project-root"
1623 ? worktreePath
1624 : path.join(worktreePath, "nested"),
1625 ),
1626 );
1627 threads.push(
1628 makeThread("local-project-thread", { projectId: LINKED_PROJECT_ID }),
1629 );
1630 }
1631 if (protection === "unchanged-two-worktrees") {
1632 threads.push({
1633 ...thread,
1634 id: ThreadId.make("second-worktree-thread"),
1635 branch: "feature-two",
1636 worktreePath: secondWorktreePath,
1637 });
1638 }
1639 return makeSnapshot(threads, projects);
1640 }),
1641 ),
1642 ),
1643 getArchivedShellSnapshot: () =>
1644 Effect.succeed(
1645 makeSnapshot(
1646 protection === "shared"
1647 ? [
1648 {
1649 ...thread,
1650 id: ThreadId.make("archived-sharing-thread"),
1651 archivedAt: NOW,
1652 },
1653 ]
1654 : [],
1655 ),
1656 ),
1657 }),
1658 Layer.mock(GitManager)({
1659 invalidateStatus: () => Effect.void,
1660 branchPullRequest: (_input, options) => {
1661 assert.strictEqual(options?.refresh, true);
1662 return Effect.succeed(
1663 makeBranchPullRequest(protection === "unmerged" ? "open" : "merged"),
1664 );
1665 },
1666 }),
1667 Layer.mock(OrchestrationEngineService)({
1668 subscribeDomainEvents: PubSub.subscribe(domainEvents).pipe(
1669 Effect.map((subscription) => Stream.fromSubscription(subscription)),
1670 ),
1671 }),
1672 Layer.mock(ThreadDeletionReactor)({
1673 drainThrough: (sequence) => {
1674 assert.strictEqual(sequence, 2);
1675 return Deferred.succeed(deletionStarted, undefined).pipe(
1676 Effect.andThen(Deferred.await(deletionStopped)),
1677 );
1678 },
1679 }),
1680 Layer.mock(ProviderService)({
1681 listSessions: () =>
1682 Effect.succeed(
1683 protection === "deleted-provider"
1684 ? [
1685 {
1686 threadId: thread.id,
1687 provider: ProviderDriverKind.make("codex"),
1688 status: "ready",
1689 runtimeMode: "full-access",
1690 cwd: worktreePath,
1691 createdAt: NOW,
1692 updatedAt: NOW,
1693 },
1694 ]
1695 : [],
1696 ),
1697 }),
1698 Layer.mock(GitVcsDriver)({
1699 resolvePrimaryRemoteName: () => Effect.succeed("origin"),
1700 resolveDefaultBranchName: () => Effect.succeed("main"),
1701 fetchRemoteTrackingBranch: (input) =>
1702 Effect.sync(() => {
1703 assert.deepStrictEqual(input, {
1704 cwd: config.baseDir,
1705 remoteName: "origin",
1706 remoteBranch: "main",
1707 });
1708 defaultRefFetched = true;
1709 fetches++;
1710 }),
1711 resolveCommit: ({ revision }) =>
1712 Effect.sync(() => {
1713 if (revision !== "HEAD")
1714 return { commitSha: (defaultRefFetched ? "b" : "d").repeat(40) };
1715 headReads++;
1716 return {
1717 commitSha:
1718 protection === "head-moved" && headReads > 1
1719 ? "c".repeat(40)
1720 : "a".repeat(40),
1721 };
1722 }),
1723 statusDetailsLocal: (cwd) =>
1724 Effect.succeed({
1725 isRepo: true,
1726 hasOriginRemote: false,
1727 isDefaultBranch: false,
1728 branch: cwd === secondWorktreePath ? "feature-two" : "feature",
1729 upstreamRef: null,
1730 hasWorkingTreeChanges:
1731 protection === "dirty" || protection === "deleted-dirty",
1732 workingTree: { files: [], insertions: 0, deletions: 0 },
1733 hasUpstream: false,
1734 aheadCount: 0,
1735 behindCount: 0,
1736 aheadOfDefaultCount: 0,
1737 }),
1738 execute: (input) =>
1739 Effect.succeed({
1740 exitCode: ChildProcessSpawner.ExitCode(
1741 input.operation === "StorageCleanup.integratedBranch" &&
1742 (protection === "diverged" || input.args.at(-1) !== "b".repeat(40))
1743 ? 1
1744 : 0,
1745 ),
1746 stdout:
1747 protection === "ignored" || protection === "deleted-ignored"
1748 ? ".env\0"
1749 : protection === "ignored-directory"
1750 ? ".cache/\0"
1751 : "",
1752 stderr: "",
1753 stdoutTruncated: false,
1754 stderrTruncated: false,
1755 }).pipe(
1756 Effect.tap(() =>
1757 (protection.startsWith("policy-") ||
1758 protection === "project-policy-disabled") &&
1759 headReads > 1
1760 ? settingsService
1761 .updateSettings({
1762 ...(protection === "project-policy-disabled"
1763 ? {
1764 projectSettingsOverrides: {
1765 [PROJECT_ID]: { worktreeCleanup: { mode: "off" as const } },
1766 },
1767 }
1768 : {}),
1769 ...(protection === "project-policy-disabled"
1770 ? {}
1771 : {
1772 storageCleanup: {
1773 worktreeAfterDays:
1774 protection === "policy-disabled" ? null : 60,
1775 },
1776 }),
1777 })
1778 .pipe(Effect.orDie)
1779 : Effect.void,
1780 ),
1781 ),
1782 removeWorktree: (input) => {
1783 assert.strictEqual(input.force, false);
1784 removals.push(input.path);
1785 return fs.remove(input.path, { recursive: true }).pipe(Effect.orDie);
1786 },
1787 }),
1788 Layer.mock(TerminalManager)({
1789 subscribeMetadata: (listener) =>
1790 listener({
1791 type: "snapshot",
1792 terminals:
1793 protection === "terminal-cwd" || protection === "terminal-worktree"
1794 ? [
1795 {
1796 threadId: "terminal-thread",
1797 terminalId: "default",
1798 cwd:
1799 protection === "terminal-cwd"
1800 ? `${worktreePath}${path.sep}`
1801 : config.baseDir,
1802 worktreePath:
1803 protection === "terminal-worktree"
1804 ? `${worktreePath}${path.sep}`
1805 : null,
1806 status: "running",
1807 pid: 42,
1808 exitCode: null,
1809 exitSignal: null,
1810 hasRunningSubprocess: false,
1811 label: "shell",
1812 updatedAt: NOW,
1813 },
1814 ]
1815 : [],
1816 }).pipe(Effect.as(() => {})),
1817 }),
1818 ),
1819 ),
1820 );
1821 yield* cleanup.start();
1822 yield* Deferred.await(snapshotRead);
1823 yield* cleanup.drain;
1824 if (protection === "deleted-event") {
1825 assert.strictEqual(yield* fs.exists(worktreePath), true);
1826 tombstoned = true;
1827 yield* PubSub.publish(domainEvents, {
1828 type: "thread.deleted",
1829 sequence: 2,
1830 eventId: EventId.make("storage-thread-deleted"),
1831 aggregateKind: "thread",
1832 aggregateId: thread.id,
1833 occurredAt: NOW,
1834 commandId: null,
1835 causationEventId: null,
1836 correlationId: null,
1837 metadata: {},
1838 payload: { threadId: thread.id, deletedAt: NOW },
1839 });
1840 yield* Deferred.await(deletionStarted);
1841 assert.strictEqual(yield* fs.exists(worktreePath), true);
1842 yield* Deferred.succeed(deletionStopped, undefined);
1843 yield* cleanup.drain;
1844 }
1845 const removed =
1846 protection === "project-custom" ||
1847 protection === "deleted-project-custom" ||
1848 protection === "none" ||
1849 protection === "deleted" ||
1850 protection === "deleted-event" ||
1851 protection === "deleted-owner" ||
1852 protection === "files-disabled" ||
1853 protection === "files-extended" ||
1854 protection === "merged" ||
1855 protection === "unchanged" ||
1856 protection === "unchanged-two-worktrees";
1857 assert.strictEqual(yield* fs.exists(worktreePath), !removed);
1858 assert.deepStrictEqual(
1859 removals,
1860 protection === "unchanged-two-worktrees"
1861 ? [worktreePath, secondWorktreePath]
1862 : removed
1863 ? [worktreePath]
1864 : [],
1865 );
1866 assert.strictEqual(fetches, mergeRule || unchangedRule ? 1 : 0);
1867 assert.strictEqual(thread.worktreePath, worktreePath);
1868 assert.strictEqual(thread.branch, "feature");
1869 assert.strictEqual(yield* fs.exists(oldImage), protection.startsWith("files-"));
1870 assert.strictEqual(yield* fs.exists(recentImage), true);
1871 assert.strictEqual(yield* fs.exists(oldLog), protection.startsWith("files-"));
1872 assert.strictEqual(yield* fs.exists(activeLog), true);
1873 }).pipe(
1874 Effect.provide(
1875 ServerConfig.layerTest(process.cwd(), { prefix: "t3-storage-cleanup-" }).pipe(
1876 Layer.provideMerge(NodeServices.layer),
1877 ),
1878 ),
1879 Effect.scoped,
1880 ),
1881 );
1882 }
1883});
apps/server/src/project/AgentSessionScanner.test.ts+1
......@@ -47,6 +47,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray<
4747 threads: [],
4848 updatedAt: "2026-01-01T00:00:00.000Z",
4949 }),
50 getDeletedWorktreeThreads: () => Effect.die("unused"),
5051 getArchivedShellSnapshot: () => Effect.die("unused"),
5152 getSnapshotSequence: () => Effect.die("unused"),
5253 getCounts: () => Effect.die("unused"),
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
......@@ -33,6 +33,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
3333 getCommandReadModel: () => Effect.die("unused"),
3434 getSnapshot: () => Effect.die("unused"),
3535 getShellSnapshot: () => Effect.die("unused"),
36 getDeletedWorktreeThreads: () => Effect.die("unused"),
3637 getArchivedShellSnapshot: () => Effect.die("unused"),
3738 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
3839 getCounts: () => Effect.die("unused"),
apps/server/src/provider/Layers/ProviderService.test.ts+1
......@@ -4970,6 +4970,7 @@ describe("agent browser access", () => {
49704970 getCommandReadModel: () => Effect.die("unused"),
49714971 getSnapshot: () => Effect.die("unused"),
49724972 getShellSnapshot: () => Effect.die("unused"),
4973 getDeletedWorktreeThreads: () => Effect.die("unused"),
49734974 getArchivedShellSnapshot: () => Effect.die("unused"),
49744975 getSnapshotSequence: () => Effect.die("unused"),
49754976 getCounts: () => Effect.die("unused"),
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
......@@ -238,6 +238,7 @@ describe("ProviderSessionReaper", () => {
238238 getCommandReadModel: () => Effect.die("unused"),
239239 getSnapshot: () => Effect.die("unused"),
240240 getShellSnapshot: () => Effect.die("unused"),
241 getDeletedWorktreeThreads: () => Effect.die("unused"),
241242 getArchivedShellSnapshot: () => Effect.die("unused"),
242243 getSnapshotSequence: () =>
243244 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),
apps/server/src/server.ts+2
......@@ -84,6 +84,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
8484import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
8585import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
8686import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
87import * as StorageCleanup from "./storageCleanup.ts";
8788import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
8889import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
8990import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
......@@ -246,6 +247,7 @@ const ReactorLayerLive = Layer.empty.pipe(
246247 Layer.provideMerge(ProviderRuntimeIngestionLive),
247248 Layer.provideMerge(ProviderCommandReactorLive),
248249 Layer.provideMerge(CheckpointReactorLive),
250 Layer.provideMerge(StorageCleanup.layer),
249251 Layer.provideMerge(ThreadDeletionReactorLive),
250252 Layer.provideMerge(ThreadSettlementReactor.layer),
251253 Layer.provideMerge(PullRequestSyncReactor.layer),
apps/server/src/serverRuntimeStartup.test.ts+4
......@@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
168168 getCommandReadModel: () => Effect.die("unused"),
169169 getSnapshot: () => Effect.die("unused"),
170170 getShellSnapshot: () => Effect.die("unused"),
171 getDeletedWorktreeThreads: () => Effect.die("unused"),
171172 getArchivedShellSnapshot: () => Effect.die("unused"),
172173 getSnapshotSequence: () => Effect.die("unused"),
173174 getCounts: () => Effect.die("unused"),
......@@ -298,6 +299,7 @@ it.effect.each([
298299 getCommandReadModel: () => Effect.die("unused"),
299300 getSnapshot: () => Effect.die("unused"),
300301 getShellSnapshot: () => Effect.die("unused"),
302 getDeletedWorktreeThreads: () => Effect.die("unused"),
301303 getArchivedShellSnapshot: () => Effect.die("unused"),
302304 getSnapshotSequence: () => Effect.die("unused"),
303305 getCounts: () => Effect.die("unused"),
......@@ -384,6 +386,7 @@ it.effect(
384386 getCommandReadModel: () => Effect.die("unused"),
385387 getSnapshot: () => Effect.die("unused"),
386388 getShellSnapshot: () => Effect.die("unused"),
389 getDeletedWorktreeThreads: () => Effect.die("unused"),
387390 getArchivedShellSnapshot: () => Effect.die("unused"),
388391 getSnapshotSequence: () => Effect.die("unused"),
389392 getCounts: () => Effect.die("unused"),
......@@ -448,6 +451,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
448451 getCommandReadModel: () => Effect.die("unused"),
449452 getSnapshot: () => Effect.die("unused"),
450453 getShellSnapshot: () => Effect.die("unused"),
454 getDeletedWorktreeThreads: () => Effect.die("unused"),
451455 getArchivedShellSnapshot: () => Effect.die("unused"),
452456 getSnapshotSequence: () => Effect.die("unused"),
453457 getCounts: () => Effect.die("unused"),
apps/server/src/storageCleanup.ts created+482
......@@ -0,0 +1,482 @@
1import type {
2 OrchestrationThreadShell,
3 ProjectId,
4 ServerSettings,
5 ServerSettingsError,
6 TerminalSummary,
7 WorktreeCleanupRules,
8} from "@t3tools/contracts";
9import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
10import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker";
11import * as Cause from "effect/Cause";
12import * as Clock from "effect/Clock";
13import * as Context from "effect/Context";
14import * as DateTime from "effect/DateTime";
15import * as Effect from "effect/Effect";
16import * as Equal from "effect/Equal";
17import * as FileSystem from "effect/FileSystem";
18import * as Layer from "effect/Layer";
19import * as Option from "effect/Option";
20import * as Path from "effect/Path";
21import type { PlatformError } from "effect/PlatformError";
22import * as Schedule from "effect/Schedule";
23import type * as Scope from "effect/Scope";
24import * as Stream from "effect/Stream";
25
26import * as ServerConfig from "./config.ts";
27import * as GitManager from "./git/GitManager.ts";
28import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts";
29import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts";
30import * as ThreadDeletionReactor from "./orchestration/Services/ThreadDeletionReactor.ts";
31import * as ProviderService from "./provider/Services/ProviderService.ts";
32import { threadHasQueuedTurnStart } from "./orchestration/ThreadSettlementPolicy.ts";
33import { forkParked } from "./serverActivation.ts";
34import * as Settings from "./serverSettings.ts";
35import * as TerminalManager from "./terminal/Manager.ts";
36import * as GitVcsDriver from "./vcs/GitVcsDriver.ts";
37import { withWorkspaceLease } from "./workspace/workspaceLease.ts";
38
39export class StorageCleanup extends Context.Service<
40 StorageCleanup,
41 {
42 readonly start: () => Effect.Effect<void, never, Scope.Scope>;
43 readonly drain: Effect.Effect<void>;
44 }
45>()("t3/storageCleanup") {}
46
47const DAY_MS = 86_400_000;
48
49const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) =>
50 rules.worktreeAfterDays !== null ||
51 rules.worktreeOnMerge ||
52 rules.worktreeOnDelete ||
53 rules.worktreeUnchanged;
54
55function anyWorktreePolicy(
56 settings: ServerSettings,
57 predicate: (rules: WorktreeCleanupRules) => boolean,
58): boolean {
59 return (
60 predicate(resolveWorktreeCleanup(settings, null)) ||
61 Object.keys(settings.projectSettingsOverrides).some((projectId) =>
62 predicate(resolveWorktreeCleanup(settings, projectId as ProjectId)),
63 )
64 );
65}
66
67function sameProjectWorktreePolicies(left: ServerSettings, right: ServerSettings): boolean {
68 return [
69 ...new Set([
70 ...Object.keys(left.projectSettingsOverrides),
71 ...Object.keys(right.projectSettingsOverrides),
72 ]),
73 ].every((projectId) =>
74 Equal.equals(
75 left.projectSettingsOverrides[projectId as ProjectId]?.worktreeCleanup,
76 right.projectSettingsOverrides[projectId as ProjectId]?.worktreeCleanup,
77 ),
78 );
79}
80
81/** Live sessions keep their cwd even when no turn is currently running. */
82function storageCleanupThreadIdle(thread: OrchestrationThreadShell, now: number): boolean {
83 return (
84 thread.branch !== null &&
85 thread.worktreePath !== null &&
86 (thread.session === null || thread.session.status === "stopped") &&
87 thread.latestTurn?.state !== "running" &&
88 thread.backgroundLiveness == null &&
89 !thread.hasPendingApprovals &&
90 !thread.hasPendingUserInput &&
91 !threadHasQueuedTurnStart(thread, DateTime.formatIso(DateTime.makeUnsafe(now)))
92 );
93}
94
95/** PR metadata refreshes must not reset the inactivity clock. */
96function storageCleanupActivityAt(thread: OrchestrationThreadShell): number {
97 return Math.max(
98 ...[
99 thread.createdAt,
100 thread.latestUserMessageAt,
101 thread.latestTurn?.requestedAt,
102 thread.latestTurn?.startedAt,
103 thread.latestTurn?.completedAt,
104 ].flatMap((value) => (value == null ? [] : [Date.parse(value)])),
105 );
106}
107
108export const make = Effect.gen(function* () {
109 const config = yield* ServerConfig.ServerConfig;
110 const settingsService = yield* Settings.ServerSettingsService;
111 const snapshots = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery;
112 const engine = yield* OrchestrationEngine.OrchestrationEngineService;
113 const threadDeletion = yield* ThreadDeletionReactor.ThreadDeletionReactor;
114 const providers = yield* ProviderService.ProviderService;
115 const git = yield* GitVcsDriver.GitVcsDriver;
116 const gitManager = yield* GitManager.GitManager;
117 const terminals = yield* TerminalManager.TerminalManager;
118 const fs = yield* FileSystem.FileSystem;
119 const path = yield* Path.Path;
120 const liveTerminals = new Map<string, Map<string, TerminalSummary>>();
121 const noteTerminal = (terminal: TerminalSummary) => {
122 const threadTerminals =
123 liveTerminals.get(terminal.threadId) ?? new Map<string, TerminalSummary>();
124 threadTerminals.set(terminal.terminalId, terminal);
125 liveTerminals.set(terminal.threadId, threadTerminals);
126 };
127
128 const inside = (root: string, target: string) => {
129 const relative = path.relative(root, target);
130 return (
131 relative !== "" &&
132 relative !== ".." &&
133 !relative.startsWith(`..${path.sep}`) &&
134 !path.isAbsolute(relative)
135 );
136 };
137 const hasTerminal = (worktreePath: string) =>
138 [...liveTerminals.values()]
139 .flatMap((entries) => [...entries.values()])
140 .some((terminal) => {
141 if (terminal.status !== "starting" && terminal.status !== "running") return false;
142 const cwd = path.resolve(terminal.cwd);
143 return (
144 (terminal.worktreePath !== null &&
145 path.resolve(terminal.worktreePath) === worktreePath) ||
146 cwd === worktreePath ||
147 inside(worktreePath, cwd)
148 );
149 });
150
151 const readThreads = Effect.fn("StorageCleanup.readThreads")(function* () {
152 const active = yield* snapshots.getShellSnapshot();
153 const archived = yield* snapshots.getArchivedShellSnapshot();
154 return { projects: active.projects, threads: [...active.threads, ...archived.threads] };
155 });
156
157 // Local threads under another project need not have a worktreePath of their own.
158 const containsProjectRoot = Effect.fn("StorageCleanup.containsProjectRoot")(function* (
159 worktreePath: string,
160 projects: ReadonlyArray<{ readonly workspaceRoot: string }>,
161 ) {
162 for (const project of projects) {
163 const projectPath = path.resolve(project.workspaceRoot);
164 if (projectPath === worktreePath || inside(worktreePath, projectPath)) return true;
165 const realPath = yield* fs
166 .realPath(projectPath)
167 .pipe(Effect.orElseSucceed(() => projectPath));
168 if (realPath === worktreePath || inside(worktreePath, realPath)) return true;
169 }
170 return false;
171 });
172
173 const cleanWorktrees = Effect.fn("StorageCleanup.cleanWorktrees")(function* (
174 serverSettings: ServerSettings,
175 now: number,
176 ) {
177 if (!anyWorktreePolicy(serverSettings, worktreeCleanupEnabled)) return;
178 if (!(yield* fs.exists(config.worktreesDir))) return;
179 const hasDeleteRule = anyWorktreePolicy(serverSettings, (rules) => rules.worktreeOnDelete);
180 const deletedThreads = hasDeleteRule
181 ? (yield* snapshots.getDeletedWorktreeThreads()).filter(
182 (thread) => resolveWorktreeCleanup(serverSettings, thread.projectId).worktreeOnDelete,
183 )
184 : [];
185 if (deletedThreads.length > 0) {
186 // Read tombstones before taking this fence. A later deletion waits for the
187 // next sweep; every captured deletion must finish stopping its resources.
188 const { snapshotSequence } = yield* snapshots.getSnapshotSequence();
189 yield* threadDeletion.drainThrough(snapshotSequence);
190 }
191 const snapshot = yield* readThreads();
192 const root = yield* fs.realPath(config.worktreesDir);
193 const refreshedDefaultRefs = new Map<string, Set<string>>();
194 const groups = Map.groupBy(
195 snapshot.threads.filter((thread) => thread.worktreePath !== null),
196 (thread) => path.resolve(thread.worktreePath!),
197 );
198 const candidates = [
199 ...[...groups.values()].flatMap((group) => (group.length === 1 ? [group[0]!] : [])),
200 ...deletedThreads.filter((thread) => !groups.has(path.resolve(thread.worktreePath))),
201 ];
202 for (const thread of candidates) {
203 const settings = resolveWorktreeCleanup(serverSettings, thread.projectId);
204 if (!worktreeCleanupEnabled(settings)) continue;
205 const worktreePath = path.resolve(thread.worktreePath!);
206 const deleted = "deletedAt" in thread;
207 const project = deleted
208 ? { workspaceRoot: thread.workspaceRoot }
209 : snapshot.projects.find((entry) => entry.id === thread.projectId);
210 if (
211 project === undefined ||
212 (!deleted && !storageCleanupThreadIdle(thread, now)) ||
213 hasTerminal(worktreePath)
214 )
215 continue;
216 yield* Effect.gen(function* () {
217 if (!inside(root, worktreePath) || !(yield* fs.exists(worktreePath))) return;
218 if ((yield* fs.realPath(worktreePath)) !== worktreePath) return;
219 if (yield* containsProjectRoot(worktreePath, [project, ...snapshot.projects])) return;
220 // A linked worktree has a .git file. Never remove a main checkout.
221 if ((yield* fs.stat(path.join(worktreePath, ".git"))).type !== "File") return;
222 const status = yield* git.statusDetailsLocal(worktreePath);
223 if (!status.isRepo || status.branch !== thread.branch || status.hasWorkingTreeChanges)
224 return;
225 const head = yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" });
226 const ignored = yield* git.execute({
227 operation: "StorageCleanup.ignoredFiles",
228 cwd: worktreePath,
229 args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"],
230 maxOutputBytes: 64 * 1024,
231 });
232 // Ignored files can contain secrets or local datasets. Dependency installs
233 // are reproducible; every other ignored path prevents automatic removal.
234 if (
235 ignored.stdoutTruncated ||
236 ignored.stdout
237 .split("\0")
238 .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry))
239 )
240 return;
241 const old =
242 !deleted &&
243 settings.worktreeAfterDays !== null &&
244 storageCleanupActivityAt(thread) < now - settings.worktreeAfterDays * DAY_MS;
245 let eligible = deleted || old;
246 if (!eligible && (settings.worktreeUnchanged || settings.worktreeOnMerge)) {
247 const repositoryCwd = path.resolve(project.workspaceRoot);
248 const remote = yield* git.resolvePrimaryRemoteName(repositoryCwd);
249 const branch = yield* git.resolveDefaultBranchName(repositoryCwd, remote);
250 if (branch === null) return;
251 const defaultRef = `refs/remotes/${remote}/${branch}`;
252 const refreshed = refreshedDefaultRefs.get(repositoryCwd) ?? new Set<string>();
253 if (!refreshed.has(defaultRef)) {
254 yield* git.fetchRemoteTrackingBranch({
255 cwd: repositoryCwd,
256 remoteName: remote,
257 remoteBranch: branch,
258 });
259 refreshed.add(defaultRef);
260 refreshedDefaultRefs.set(repositoryCwd, refreshed);
261 }
262 const base = yield* git.resolveCommit({
263 cwd: worktreePath,
264 revision: defaultRef,
265 });
266 const ancestor = yield* git.execute({
267 operation: "StorageCleanup.integratedBranch",
268 cwd: worktreePath,
269 args: ["merge-base", "--is-ancestor", head.commitSha, base.commitSha],
270 allowNonZeroExit: true,
271 });
272 if (ancestor.exitCode !== 0) return;
273 eligible = settings.worktreeUnchanged;
274 if (!eligible && settings.worktreeOnMerge && thread.branch !== null) {
275 const pullRequest = yield* gitManager.branchPullRequest(
276 { cwd: worktreePath, branch: thread.branch },
277 { refresh: true },
278 );
279 eligible = pullRequest?.state === "merged";
280 }
281 }
282 if (!eligible) return;
283 // Re-read after Git/host calls so a queued turn, resumed session or new
284 // thread sharing this path cancels the removal.
285 const latestSnapshot = yield* readThreads();
286 if (yield* containsProjectRoot(worktreePath, [project, ...latestSnapshot.projects])) return;
287 const latest = latestSnapshot.threads.filter(
288 (entry) =>
289 entry.worktreePath !== null && path.resolve(entry.worktreePath) === worktreePath,
290 );
291 if (hasTerminal(worktreePath)) return;
292 if (deleted) {
293 if (
294 latest.length > 0 ||
295 !resolveWorktreeCleanup(yield* settingsService.getSettings, thread.projectId)
296 .worktreeOnDelete
297 )
298 return;
299 // A failed session stop is logged by the deletion reactor. Its drain
300 // alone is not proof that a provider released this checkout.
301 if (
302 (yield* providers.listSessions()).some(
303 (session) =>
304 session.status !== "closed" &&
305 (session.threadId === thread.id ||
306 (session.cwd !== undefined &&
307 (path.resolve(session.cwd) === worktreePath ||
308 inside(worktreePath, path.resolve(session.cwd))))),
309 )
310 )
311 return;
312 } else if (
313 latest.length !== 1 ||
314 latest[0]!.id !== thread.id ||
315 !storageCleanupThreadIdle(latest[0]!, now) ||
316 storageCleanupActivityAt(latest[0]!) !== storageCleanupActivityAt(thread)
317 )
318 return;
319 const finalStatus = yield* git.statusDetailsLocal(worktreePath);
320 if (
321 !finalStatus.isRepo ||
322 finalStatus.branch !== thread.branch ||
323 finalStatus.hasWorkingTreeChanges
324 )
325 return;
326 if (
327 (yield* git.resolveCommit({ cwd: worktreePath, revision: "HEAD" })).commitSha !==
328 head.commitSha
329 )
330 return;
331 const finalIgnored = yield* git.execute({
332 operation: "StorageCleanup.ignoredFiles",
333 cwd: worktreePath,
334 args: ["ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z"],
335 maxOutputBytes: 64 * 1024,
336 });
337 if (
338 finalIgnored.stdoutTruncated ||
339 finalIgnored.stdout
340 .split("\0")
341 .some((entry) => entry !== "" && !/(^|\/)node_modules\/$/.test(entry))
342 )
343 return;
344 const current = resolveWorktreeCleanup(
345 yield* settingsService.getSettings,
346 thread.projectId,
347 );
348 if (
349 Object.keys(settings).some(
350 (key) =>
351 current[key as keyof typeof settings] !== settings[key as keyof typeof settings],
352 )
353 )
354 return;
355 yield* git.removeWorktree({ cwd: project.workspaceRoot, path: worktreePath, force: false });
356 yield* gitManager.invalidateStatus(project.workspaceRoot);
357 // Preserve branch and path: ProviderCommandReactor recreates the checkout
358 // from that branch when the thread is resumed.
359 yield* Effect.logInfo("storage cleanup removed worktree", { threadId: thread.id });
360 }).pipe(
361 (effect) => withWorkspaceLease(worktreePath, effect),
362 Effect.catch((error) =>
363 Effect.logDebug("storage cleanup skipped worktree", { threadId: thread.id, error }),
364 ),
365 );
366 }
367 });
368
369 const cleanFiles = Effect.fn("StorageCleanup.cleanFiles")(function* (
370 root: string,
371 days: number | null,
372 now: number,
373 rotatedLogs: boolean,
374 ) {
375 if (days === null || !(yield* fs.exists(root))) return;
376 const realRoot = yield* fs.realPath(root);
377 if (realRoot !== path.resolve(root)) return;
378 const visit = Effect.fn("StorageCleanup.visitFiles")(function* (
379 directory: string,
380 ): Effect.fn.Return<void, PlatformError | ServerSettingsError> {
381 for (const name of yield* fs.readDirectory(directory)) {
382 const target = path.join(directory, name);
383 if ((yield* fs.realPath(target)) !== target || !inside(realRoot, target)) continue;
384 const stat = yield* fs.stat(target);
385 if (stat.type === "Directory" && rotatedLogs) {
386 yield* visit(target);
387 } else if (stat.type === "File" && (!rotatedLogs || /\.(?:log|ndjson)\.\d+$/.test(name))) {
388 const modified = Option.getOrNull(stat.mtime);
389 if (modified !== null && modified.getTime() < now - days * DAY_MS) {
390 const current = (yield* settingsService.getSettings).storageCleanup;
391 if ((rotatedLogs ? current.logsAfterDays : current.browserArtifactsAfterDays) !== days)
392 return;
393 yield* fs.remove(target);
394 }
395 }
396 }
397 });
398 yield* visit(realRoot);
399 });
400
401 const sweep = Effect.fn("StorageCleanup.sweep")(function* () {
402 const serverSettings = yield* settingsService.getSettings;
403 const settings = serverSettings.storageCleanup;
404 const now = yield* Clock.currentTimeMillis;
405 yield* cleanWorktrees(serverSettings, now).pipe(
406 Effect.catch((error) => Effect.logWarning("worktree cleanup failed", { error })),
407 );
408 yield* cleanFiles(
409 config.browserArtifactsDir,
410 settings.browserArtifactsAfterDays,
411 now,
412 false,
413 ).pipe(
414 Effect.catch((error) => Effect.logWarning("browser artifact cleanup failed", { error })),
415 );
416 yield* cleanFiles(config.logsDir, settings.logsAfterDays, now, true).pipe(
417 Effect.catch((error) => Effect.logWarning("rotated log cleanup failed", { error })),
418 );
419 });
420 const worker = yield* makeDrainableWorker(() =>
421 sweep().pipe(
422 Effect.catchCause((cause) =>
423 Cause.hasInterruptsOnly(cause)
424 ? Effect.failCause(cause)
425 : Effect.logWarning("storage cleanup failed", { cause }),
426 ),
427 ),
428 );
429
430 const start = Effect.fn("StorageCleanup.start")(function* () {
431 const unsubscribe = yield* terminals.subscribeMetadata((event) =>
432 Effect.sync(() => {
433 if (event.type === "snapshot") {
434 liveTerminals.clear();
435 for (const terminal of event.terminals) noteTerminal(terminal);
436 } else if (event.type === "upsert") {
437 noteTerminal(event.terminal);
438 } else {
439 const threadTerminals = liveTerminals.get(event.threadId);
440 threadTerminals?.delete(event.terminalId);
441 if (threadTerminals?.size === 0) liveTerminals.delete(event.threadId);
442 }
443 }),
444 );
445 yield* Effect.addFinalizer(() => Effect.sync(unsubscribe));
446 const changes = yield* settingsService.subscribeChanges;
447 const events = yield* engine.subscribeDomainEvents;
448 let lastSettings = yield* settingsService.getSettings.pipe(Effect.orDie);
449 yield* forkParked(
450 worker
451 .enqueue(undefined)
452 .pipe(
453 Effect.andThen(worker.drain),
454 Effect.repeat(Schedule.spaced("1 hour")),
455 Effect.asVoid,
456 ),
457 );
458 yield* forkParked(
459 Stream.runForEach(changes, (settings) => {
460 if (
461 Equal.equals(settings.storageCleanup, lastSettings.storageCleanup) &&
462 Equal.equals(settings.worktreeCleanup, lastSettings.worktreeCleanup) &&
463 sameProjectWorktreePolicies(settings, lastSettings)
464 )
465 return Effect.void;
466 lastSettings = settings;
467 return worker.enqueue(undefined);
468 }),
469 );
470 yield* forkParked(
471 Stream.runForEach(events, (event) =>
472 event.type === "thread.deleted" &&
473 anyWorktreePolicy(lastSettings, (rules) => rules.worktreeOnDelete)
474 ? worker.enqueue(undefined)
475 : Effect.void,
476 ),
477 );
478 });
479 return { start, drain: worker.drain } satisfies StorageCleanup["Service"];
480});
481
482export const layer = Layer.effect(StorageCleanup, make);
apps/server/src/terminal/Manager.ts+18-2
......@@ -6,6 +6,7 @@
66 *
77 * @module TerminalManager
88 */
9import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
910import {
1011 DEFAULT_TERMINAL_ID,
1112 TerminalCwdError,
......@@ -2516,7 +2517,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
25162517 }).pipe(Effect.ignoreCause({ log: true })),
25172518 );
25182519
2519 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {
2520 const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* (
2521 input: TerminalOpenInput,
2522 ) {
25202523 const terminalId = input.terminalId;
25212524 yield* assertValidCwd(input.cwd);
25222525
......@@ -2639,6 +2642,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
26392642 return snapshot(liveSession);
26402643 });
26412644
2645 const openLocked = (input: TerminalOpenInput) =>
2646 withWorkspaceLease(
2647 path.resolve(input.worktreePath ?? input.cwd),
2648 openWithWorkspaceLease(input),
2649 );
2650
26422651 const open: TerminalManager["Service"]["open"] = (input) =>
26432652 withThreadLock(
26442653 input.threadId,
......@@ -3010,7 +3019,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
30103019 const restart: TerminalManager["Service"]["restart"] = (input) =>
30113020 withThreadLock(
30123021 input.threadId,
3013 resolveLaunchInputEnvironment(input).pipe(Effect.flatMap(restartResolved)),
3022 resolveLaunchInputEnvironment(input).pipe(
3023 Effect.flatMap((resolved) =>
3024 withWorkspaceLease(
3025 path.resolve(resolved.worktreePath ?? resolved.cwd),
3026 restartResolved(resolved),
3027 ),
3028 ),
3029 ),
30143030 );
30153031
30163032 const close: TerminalManager["Service"]["close"] = (input) =>
apps/server/src/workspace/workspaceLease.ts created+23
......@@ -0,0 +1,23 @@
1import * as Effect from "effect/Effect";
2import * as Semaphore from "effect/Semaphore";
3
4const leases = new Map<string, { semaphore: Semaphore.Semaphore; users: number }>();
5
6/** Coordinates checkout removal and startup across threads using the same resolved cwd. */
7export const withWorkspaceLease = <A, E, R>(
8 cwd: string,
9 effect: Effect.Effect<A, E, R>,
10): Effect.Effect<A, E, R> =>
11 Effect.suspend(() => {
12 const lease = leases.get(cwd) ?? { semaphore: Semaphore.makeUnsafe(1), users: 0 };
13 leases.set(cwd, lease);
14 lease.users++;
15 return lease.semaphore.withPermit(effect).pipe(
16 Effect.ensuring(
17 Effect.sync(() => {
18 lease.users--;
19 if (lease.users === 0) leases.delete(cwd);
20 }),
21 ),
22 );
23 });
apps/web/src/components/settings/SettingsSidebarNav.tsx+2
......@@ -15,6 +15,7 @@ import {
1515 BotIcon,
1616 createLucideIcon,
1717 GitBranchIcon,
18 HardDriveIcon,
1819 PanelsTopLeftIcon,
1920 KeyboardIcon,
2021 Link2Icon,
......@@ -83,6 +84,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
8384 "/settings/providers": BotIcon,
8485 "/settings/integrations": BlocksIcon,
8586 "/settings/source-control": GitBranchIcon,
87 "/settings/storage": HardDriveIcon,
8688 "/settings/connections": Link2Icon,
8789 "/settings/archived": ArchiveIcon,
8890};
apps/web/src/components/settings/StorageSettings.tsx created+288
......@@ -0,0 +1,288 @@
1import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts";
2import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
3import { useState } from "react";
4
5import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select";
6import { Switch } from "../ui/switch";
7import {
8 NumberField,
9 NumberFieldDecrement,
10 NumberFieldGroup,
11 NumberFieldIncrement,
12 NumberFieldInput,
13} from "../ui/number-field";
14import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout";
15import { SettingsScopeNotice } from "./SettingsScopeNotice";
16import type { ScopedSettingsTarget } from "./scopedSettings";
17import { useSettingsScope } from "./SettingsScopeContext";
18import {
19 useClearScopedSettings,
20 useScopedSettings,
21 useUpdateScopedSettings,
22} from "./useScopedSettings";
23
24function RetentionControl({
25 label,
26 value,
27 onChange,
28}: {
29 label: string;
30 value: number | null;
31 onChange: (value: number | null) => void;
32}) {
33 const [draft, setDraft] = useState(value);
34 const [savedValue, setSavedValue] = useState(value);
35 if (savedValue !== value) {
36 setSavedValue(value);
37 setDraft(value);
38 }
39
40 return (
41 <div className="flex items-center gap-3">
42 {value !== null ? (
43 <NumberField
44 value={draft}
45 min={1}
46 max={3650}
47 step={1}
48 size="sm"
49 className="w-auto"
50 onValueChange={setDraft}
51 onValueCommitted={(next) => {
52 if (next === null) setDraft(value);
53 else {
54 const days = Math.min(3650, Math.max(1, Math.round(next)));
55 setDraft(days);
56 onChange(days);
57 }
58 }}
59 >
60 <NumberFieldGroup>
61 <NumberFieldDecrement aria-label={`Decrease ${label}`} />
62 <NumberFieldInput
63 aria-label={`${label} in days`}
64 size={new Intl.NumberFormat().format(draft ?? value).length}
65 className="field-sizing-content w-auto min-w-[1ch] grow-0 text-right in-data-[size=sm]:px-1"
66 />
67 <span aria-hidden="true" className="self-center pr-2 text-xs">
68 days
69 </span>
70 <NumberFieldIncrement aria-label={`Increase ${label}`} />
71 </NumberFieldGroup>
72 </NumberField>
73 ) : (
74 <span className="text-xs text-muted-foreground">Off</span>
75 )}
76 <Switch
77 aria-label={label}
78 checked={value !== null}
79 onCheckedChange={(enabled) => onChange(enabled ? 8 : null)}
80 />
81 </div>
82 );
83}
84
85export function StorageSettingsPanel() {
86 const { scope, connectedEnvironments, targets, target } = useSettingsScope();
87 const scopedSettings = useScopedSettings();
88 const isProjectScope = scope.kind === "project" || scope.kind === "checkout";
89 const settings = {
90 ...scopedSettings.storageCleanup,
91 ...resolveWorktreeCleanup(scopedSettings, null),
92 };
93 const projectMode = (entry: ScopedSettingsTarget | null) =>
94 entry?.sources.worktreeCleanup === "project"
95 ? (entry.settings.worktreeCleanup?.mode ?? "inherit")
96 : "inherit";
97 const mode = projectMode(target);
98 const mixedModes = targets.some((entry) => projectMode(entry) !== mode);
99 const updateSettings = useUpdateScopedSettings();
100 const clearSettings = useClearScopedSettings();
101 const ruleStatus = (key: keyof StorageCleanupSettings) =>
102 targets.some(
103 (target) =>
104 ({ ...target.settings.storageCleanup, ...resolveWorktreeCleanup(target.settings, null) })[
105 key
106 ] !== settings[key],
107 )
108 ? "Mixed across selected machines"
109 : undefined;
110 const update = (patch: Partial<StorageCleanupSettings>) =>
111 updateSettings({ storageCleanup: patch });
112 const updateWorktree = (patch: Partial<WorktreeCleanupRules>) =>
113 isProjectScope
114 ? updateSettings({ worktreeCleanup: { mode: "custom", rules: patch } })
115 : update(patch);
116
117 if (
118 isProjectScope &&
119 connectedEnvironments.some(
120 (environment) =>
121 environment.serverConfig?.environment.capabilities.projectWorktreeCleanup !== true,
122 )
123 ) {
124 return (
125 <SettingsScopeNotice target="all">
126 Update the selected machines to configure project worktree cleanup.
127 </SettingsScopeNotice>
128 );
129 }
130
131 if (
132 connectedEnvironments.some(
133 (environment) => environment.serverConfig?.environment.capabilities.storageCleanup !== true,
134 )
135 ) {
136 return (
137 <SettingsScopeNotice
138 target="environment"
139 eligibleEnvironmentIds={connectedEnvironments
140 .filter(
141 (environment) =>
142 environment.serverConfig?.environment.capabilities.storageCleanup === true,
143 )
144 .map((environment) => environment.environmentId)}
145 >
146 Update the selected environments to use storage cleanup, or choose a machine that supports
147 it.
148 </SettingsScopeNotice>
149 );
150 }
151
152 return (
153 <SettingsPageContainer>
154 <SettingsSection id="storage-worktrees" title="Worktrees">
155 {isProjectScope && (
156 <SettingsRow
157 title="Automatic worktree cleanup"
158 description={
159 mode === "off"
160 ? "Keep this project's worktrees until you delete them manually."
161 : mode === "custom"
162 ? "Use these rules for this project."
163 : "Use each machine's worktree cleanup settings."
164 }
165 serverScoped
166 settingKeys={["worktreeCleanup"]}
167 mixed={mixedModes}
168 control={
169 <Select
170 value={mixedModes ? null : mode}
171 onValueChange={(next) => {
172 if (next === "inherit") clearSettings(["worktreeCleanup"]);
173 else if (next === "off") updateSettings({ worktreeCleanup: { mode: "off" } });
174 else if (next === "custom")
175 updateSettings({ worktreeCleanup: { mode: "custom", rules: {} } });
176 }}
177 >
178 <SelectTrigger size="sm" aria-label="Automatic worktree cleanup">
179 <SelectValue>
180 {mixedModes
181 ? "Mixed"
182 : mode === "inherit"
183 ? "Inherit"
184 : mode === "off"
185 ? "Off"
186 : "Custom"}
187 </SelectValue>
188 </SelectTrigger>
189 <SelectPopup align="end" alignItemWithTrigger={false}>
190 <SelectItem value="inherit">Inherit</SelectItem>
191 <SelectItem value="off">Off</SelectItem>
192 <SelectItem value="custom">Custom</SelectItem>
193 </SelectPopup>
194 </Select>
195 }
196 />
197 )}
198 {(!isProjectScope || (!mixedModes && mode === "custom")) && (
199 <>
200 <SettingsRow
201 title="Delete worktrees with deleted threads"
202 status={ruleStatus("worktreeOnDelete")}
203 description="Remove unused worktrees when active or archived threads are deleted. Worktrees with local changes are kept."
204 serverScoped={!isProjectScope}
205 control={
206 <Switch
207 aria-label="Delete worktrees with deleted threads"
208 checked={settings.worktreeOnDelete}
209 onCheckedChange={(worktreeOnDelete) => updateWorktree({ worktreeOnDelete })}
210 />
211 }
212 />
213 <SettingsRow
214 title="Delete inactive worktrees"
215 status={ruleStatus("worktreeAfterDays")}
216 description="Remove worktrees after their threads have been inactive for this many days. Branches and thread history are kept."
217 serverScoped={!isProjectScope}
218 control={
219 <RetentionControl
220 label="Delete inactive worktrees"
221 value={settings.worktreeAfterDays}
222 onChange={(worktreeAfterDays) => updateWorktree({ worktreeAfterDays })}
223 />
224 }
225 />
226 <SettingsRow
227 title="Delete merged worktrees"
228 status={ruleStatus("worktreeOnMerge")}
229 description="Remove worktrees whose pull request is merged and whose commits are included in the default branch."
230 serverScoped={!isProjectScope}
231 control={
232 <Switch
233 aria-label="Delete merged worktrees"
234 checked={settings.worktreeOnMerge}
235 onCheckedChange={(worktreeOnMerge) => updateWorktree({ worktreeOnMerge })}
236 />
237 }
238 />
239 <SettingsRow
240 title="Delete unchanged worktrees"
241 status={ruleStatus("worktreeUnchanged")}
242 description="Remove worktrees with no commits beyond the default branch."
243 serverScoped={!isProjectScope}
244 control={
245 <Switch
246 aria-label="Delete unchanged worktrees"
247 checked={settings.worktreeUnchanged}
248 onCheckedChange={(worktreeUnchanged) => updateWorktree({ worktreeUnchanged })}
249 />
250 }
251 />
252 </>
253 )}
254 </SettingsSection>
255
256 {!isProjectScope && (
257 <SettingsSection id="storage-artifacts" title="Artifacts and logs">
258 <SettingsRow
259 title="Delete old browser artifacts"
260 status={ruleStatus("browserArtifactsAfterDays")}
261 description="Delete saved browser captures after this many days. Older capture links will no longer open."
262 serverScoped
263 control={
264 <RetentionControl
265 label="Delete old browser artifacts"
266 value={settings.browserArtifactsAfterDays}
267 onChange={(browserArtifactsAfterDays) => update({ browserArtifactsAfterDays })}
268 />
269 }
270 />
271 <SettingsRow
272 title="Delete old rotated logs"
273 status={ruleStatus("logsAfterDays")}
274 description="Delete inactive rotated log files after this many days. Current logs are kept."
275 serverScoped
276 control={
277 <RetentionControl
278 label="Delete old rotated logs"
279 value={settings.logsAfterDays}
280 onChange={(logsAfterDays) => update({ logsAfterDays })}
281 />
282 }
283 />
284 </SettingsSection>
285 )}
286 </SettingsPageContainer>
287 );
288}
apps/web/src/components/settings/scopedSettings.test.ts+112
......@@ -5,6 +5,8 @@ import {
55 type ServerSettings,
66} from "@t3tools/contracts";
77import { describe, expect, it, vi } from "vite-plus/test";
8import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
9import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
810
911import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
1012import {
......@@ -146,6 +148,48 @@ describe("scoped settings targets", () => {
146148});
147149
148150describe("scoped settings writes", () => {
151 it("edits the effective machine policy without changing other machines' rules", () => {
152 const custom = environment("Laptop", {
153 settings: {
154 worktreeCleanup: {
155 mode: "custom",
156 rules: {
157 worktreeAfterDays: 12,
158 worktreeOnDelete: true,
159 worktreeOnMerge: true,
160 worktreeUnchanged: false,
161 },
162 },
163 },
164 });
165 const disabled = environment("Server", {
166 settings: { worktreeCleanup: { mode: "off" } },
167 });
168 const targets = [custom, disabled];
169 const plan = planScopedSettingsPatch(all, targets, {
170 storageCleanup: { worktreeOnDelete: false },
171 });
172 const policies = plan.serverWrites.map((write, index) =>
173 resolveWorktreeCleanup(
174 applyServerSettingsPatch(targets[index]!.serverConfig!.settings, write.patch),
175 null,
176 ),
177 );
178 expect(policies).toEqual([
179 {
180 worktreeAfterDays: 12,
181 worktreeOnDelete: false,
182 worktreeOnMerge: true,
183 worktreeUnchanged: false,
184 },
185 {
186 worktreeAfterDays: null,
187 worktreeOnDelete: false,
188 worktreeOnMerge: false,
189 worktreeUnchanged: false,
190 },
191 ]);
192 });
149193 it("isolates a formerly shared server preference to the named environment", async () => {
150194 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
151195 const persistClient = vi.fn();
......@@ -231,6 +275,74 @@ describe("scoped settings writes", () => {
231275 });
232276 });
233277
278 it("keeps each project's other cleanup rules when changing one rule across machines", () => {
279 const machine = environment("Laptop", {
280 settings: {
281 storageCleanup: { ...DEFAULT_SERVER_SETTINGS.storageCleanup, worktreeAfterDays: 30 },
282 },
283 });
284 const customized = environment("Server", {
285 settings: {
286 projectSettingsOverrides: {
287 [projectId]: {
288 defaultAutoPull: true,
289 worktreeCleanup: {
290 mode: "custom",
291 rules: {
292 worktreeAfterDays: 8,
293 worktreeOnDelete: false,
294 worktreeOnMerge: true,
295 worktreeUnchanged: false,
296 },
297 },
298 },
299 },
300 },
301 });
302 const plan = planScopedSettingsPatch(project, [machine, customized], {
303 worktreeCleanup: { mode: "custom", rules: { worktreeOnDelete: true } },
304 });
305 expect(plan.serverWrites.map((write) => write.patch.projectSettingsOverrides)).toEqual([
306 {
307 [projectId]: {
308 defaultAutoPull: true,
309 worktreeCleanup: {
310 mode: "custom",
311 rules: {
312 worktreeAfterDays: 8,
313 worktreeOnDelete: true,
314 worktreeOnMerge: true,
315 worktreeUnchanged: false,
316 },
317 },
318 },
319 },
320 {
321 [laptopProjectId]: {
322 worktreeCleanup: {
323 mode: "custom",
324 rules: {
325 worktreeAfterDays: 30,
326 worktreeOnDelete: true,
327 worktreeOnMerge: false,
328 worktreeUnchanged: false,
329 },
330 },
331 },
332 },
333 ]);
334 expect(
335 planScopedSettingsClear(checkout, [customized], ["worktreeCleanup"]).serverWrites[0]?.patch,
336 ).toEqual({
337 projectSettingsOverrides: { [projectId]: { defaultAutoPull: true } },
338 });
339 expect(
340 planScopedSettingsPatch(project, [machine, customized], {
341 storageCleanup: { browserArtifactsAfterDays: 8 },
342 }).serverWrites,
343 ).toEqual([]);
344 });
345
234346 it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => {
235347 const plan = planScopedSettingsPatch(project, [laptop, server], {
236348 enableAgentDeviceAccess: true,
apps/web/src/components/settings/scopedSettings.ts+30-1
......@@ -13,6 +13,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
1313import {
1414 clearProjectSettingsOverrides,
1515 resolveProjectSettings,
16 resolveWorktreeCleanup,
1617 type ProjectSettingSource,
1718} from "@t3tools/shared/projectSettings";
1819import * as Equal from "effect/Equal";
......@@ -223,6 +224,16 @@ export function planScopedSettingsPatch(
223224 const effective = resolveProjectSettings(settings, projectId).settings;
224225 const next: Record<string, unknown> = { ...current };
225226 for (const [key, value] of Object.entries(serverPatch)) {
227 if (key === "worktreeCleanup" && serverPatch.worktreeCleanup?.mode === "custom") {
228 next[key] = {
229 mode: "custom",
230 rules: {
231 ...resolveWorktreeCleanup(settings, projectId),
232 ...serverPatch.worktreeCleanup.rules,
233 },
234 };
235 continue;
236 }
226237 const base = effective[key as keyof ServerSettings];
227238 next[key] =
228239 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
......@@ -233,7 +244,25 @@ export function planScopedSettingsPatch(
233244 ? connectedEnvironments.map((environment) => ({
234245 environmentId: environment.environmentId,
235246 label: environment.label,
236 patch: serverPatch,
247 patch:
248 environment.serverConfig?.settings.worktreeCleanup != null &&
249 serverPatch.storageCleanup &&
250 Object.keys(serverPatch.storageCleanup).some((key) => key.startsWith("worktree"))
251 ? {
252 ...serverPatch,
253 worktreeCleanup: {
254 mode: "custom",
255 rules: {
256 ...resolveWorktreeCleanup(environment.serverConfig.settings, null),
257 ...Object.fromEntries(
258 Object.entries(serverPatch.storageCleanup).filter(([key]) =>
259 key.startsWith("worktree"),
260 ),
261 ),
262 },
263 },
264 }
265 : serverPatch,
237266 }))
238267 : [];
239268 const hasClientWrite = Object.keys(clientPatch).length > 0;
apps/web/src/components/settings/settingsSearch.ts+19
......@@ -20,6 +20,7 @@ export type SettingsPath =
2020 | "/settings/providers"
2121 | "/settings/integrations"
2222 | "/settings/source-control"
23 | "/settings/storage"
2324 | "/settings/connections"
2425 | "/settings/archived";
2526
......@@ -88,6 +89,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
8889 "/settings/providers": "Providers",
8990 "/settings/integrations": "Integrations",
9091 "/settings/source-control": "Source Control",
92 "/settings/storage": "Storage",
9193 "/settings/connections": "Connections",
9294 "/settings/archived": "Archive",
9395};
......@@ -125,6 +127,22 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right
125127 * that may not be mounted point at their nearest stable section instead.
126128 */
127129export const SETTINGS_SEARCH_ITEMS = [
130 {
131 id: "storage-worktrees",
132 title: "Worktree cleanup",
133 to: "/settings/storage",
134 scope: "project-defaults",
135 searchTerms: [
136 "disk storage delete deleted archived threads old inactive merged unchanged worktrees retention days project inherit off custom",
137 ],
138 },
139 {
140 id: "storage-artifacts",
141 title: "Artifacts and logs",
142 to: "/settings/storage",
143 scope: "environment-defaults",
144 searchTerms: ["disk storage browser screenshots captures rotated logs cleanup retention"],
145 },
128146 {
129147 id: "project-defaults",
130148 title: "Project defaults and overrides",
......@@ -789,6 +807,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScop
789807 "/settings/providers": null,
790808 "/settings/integrations": null,
791809 "/settings/source-control": "environment-defaults",
810 "/settings/storage": "project-defaults",
792811 "/settings/connections": "connections",
793812 "/settings/archived": "project-defaults",
794813};
apps/web/src/hooks/useThreadActions.ts+10-1
......@@ -7,6 +7,7 @@ import {
77import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
88import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
99import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
10import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
1011import * as Cause from "effect/Cause";
1112import * as Schema from "effect/Schema";
1213import { AsyncResult } from "effect/unstable/reactivity";
......@@ -16,6 +17,8 @@ import { useCallback, useMemo, useRef } from "react";
1617import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
1718import { useComposerDraftStore } from "../composerDraftStore";
1819import { terminalEnvironment } from "../state/terminal";
20import { appAtomRegistry } from "../rpc/atomRegistry";
21import { environmentServerConfigsAtom } from "../state/server";
1922import { threadEnvironment } from "../state/threads";
2023import { vcsEnvironment } from "../state/vcs";
2124import { useNewThreadHandler } from "./useHandleNewThread";
......@@ -356,7 +359,13 @@ export function useThreadActions() {
356359 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null;
357360 const localApi = readLocalApi();
358361 let shouldDeleteWorktree = false;
359 if (canDeleteWorktree && localApi) {
362 const environmentSettings = appAtomRegistry
363 .get(environmentServerConfigsAtom)
364 .get(threadRef.environmentId)?.settings;
365 const automaticWorktreeCleanup = environmentSettings
366 ? resolveWorktreeCleanup(environmentSettings, thread.projectId).worktreeOnDelete
367 : false;
368 if (canDeleteWorktree && localApi && !automaticWorktreeCleanup) {
360369 const confirmationResult = await settlePromise(() =>
361370 localApi.dialogs.confirm(
362371 [
apps/web/src/routeTree.gen.ts+21
......@@ -16,6 +16,7 @@ import { Route as PairRouteImport } from './routes/pair'
1616import { Route as ConnectRouteImport } from './routes/connect'
1717import { Route as ChatRouteImport } from './routes/_chat'
1818import { Route as ChatIndexRouteImport } from './routes/_chat.index'
19import { Route as SettingsStorageRouteImport } from './routes/settings.storage'
1920import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
2021import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot'
2122import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
......@@ -67,6 +68,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
6768 path: '/',
6869 getParentRoute: () => ChatRoute,
6970} as any)
71const SettingsStorageRoute = SettingsStorageRouteImport.update({
72 id: '/storage',
73 path: '/storage',
74 getParentRoute: () => SettingsRoute,
75} as any)
7076const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({
7177 id: '/source-control',
7278 path: '/source-control',
......@@ -171,6 +177,7 @@ export interface FileRoutesByFullPath {
171177 '/settings/providers': typeof SettingsProvidersRoute
172178 '/settings/snap-shot': typeof SettingsSnapShotRoute
173179 '/settings/source-control': typeof SettingsSourceControlRoute
180 '/settings/storage': typeof SettingsStorageRoute
174181 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
175182 '/draft/$draftId': typeof ChatDraftDraftIdRoute
176183}
......@@ -194,6 +201,7 @@ export interface FileRoutesByTo {
194201 '/settings/providers': typeof SettingsProvidersRoute
195202 '/settings/snap-shot': typeof SettingsSnapShotRoute
196203 '/settings/source-control': typeof SettingsSourceControlRoute
204 '/settings/storage': typeof SettingsStorageRoute
197205 '/': typeof ChatIndexRoute
198206 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
199207 '/draft/$draftId': typeof ChatDraftDraftIdRoute
......@@ -220,6 +228,7 @@ export interface FileRoutesById {
220228 '/settings/providers': typeof SettingsProvidersRoute
221229 '/settings/snap-shot': typeof SettingsSnapShotRoute
222230 '/settings/source-control': typeof SettingsSourceControlRoute
231 '/settings/storage': typeof SettingsStorageRoute
223232 '/_chat/': typeof ChatIndexRoute
224233 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
225234 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
......@@ -247,6 +256,7 @@ export interface FileRouteTypes {
247256 | '/settings/providers'
248257 | '/settings/snap-shot'
249258 | '/settings/source-control'
259 | '/settings/storage'
250260 | '/$environmentId/$threadId'
251261 | '/draft/$draftId'
252262 fileRoutesByTo: FileRoutesByTo
......@@ -270,6 +280,7 @@ export interface FileRouteTypes {
270280 | '/settings/providers'
271281 | '/settings/snap-shot'
272282 | '/settings/source-control'
283 | '/settings/storage'
273284 | '/'
274285 | '/$environmentId/$threadId'
275286 | '/draft/$draftId'
......@@ -295,6 +306,7 @@ export interface FileRouteTypes {
295306 | '/settings/providers'
296307 | '/settings/snap-shot'
297308 | '/settings/source-control'
309 | '/settings/storage'
298310 | '/_chat/'
299311 | '/_chat/$environmentId/$threadId'
300312 | '/_chat/draft/$draftId'
......@@ -361,6 +373,13 @@ declare module '@tanstack/react-router' {
361373 preLoaderRoute: typeof ChatIndexRouteImport
362374 parentRoute: typeof ChatRoute
363375 }
376 '/settings/storage': {
377 id: '/settings/storage'
378 path: '/storage'
379 fullPath: '/settings/storage'
380 preLoaderRoute: typeof SettingsStorageRouteImport
381 parentRoute: typeof SettingsRoute
382 }
364383 '/settings/source-control': {
365384 id: '/settings/source-control'
366385 path: '/source-control'
......@@ -505,6 +524,7 @@ interface SettingsRouteChildren {
505524 SettingsProvidersRoute: typeof SettingsProvidersRoute
506525 SettingsSnapShotRoute: typeof SettingsSnapShotRoute
507526 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
527 SettingsStorageRoute: typeof SettingsStorageRoute
508528}
509529
510530const SettingsRouteChildren: SettingsRouteChildren = {
......@@ -520,6 +540,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
520540 SettingsProvidersRoute: SettingsProvidersRoute,
521541 SettingsSnapShotRoute: SettingsSnapShotRoute,
522542 SettingsSourceControlRoute: SettingsSourceControlRoute,
543 SettingsStorageRoute: SettingsStorageRoute,
523544}
524545
525546const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
apps/web/src/routes/settings.storage.tsx created+4
......@@ -0,0 +1,4 @@
1import { createFileRoute } from "@tanstack/react-router";
2import { StorageSettingsPanel } from "../components/settings/StorageSettings";
3
4export const Route = createFileRoute("/settings/storage")({ component: StorageSettingsPanel });
docs/user/project-settings.md+26
......@@ -41,6 +41,32 @@ on each selected environment, and reset returns to the environment's shared list
4141For workspace mode, a project's `t3.json` preference applies when the project has no override.
4242Browser access changes apply when an agent session next starts.
4343
44## Storage cleanup
45
46Open **Settings → Storage** to enable automatic cleanup on one machine or all connected
47environments. Policies are off by default and run on the server at startup, when changed, and
48hourly. Offline machines keep their existing policies.
49
50Select a project to set **Automatic worktree cleanup** to **Inherit**, **Off**, or **Custom**.
51Inherit follows each machine's rules; Off keeps that project's worktrees until you remove them
52manually. Custom applies separate worktree rules to the selected project or checkout. Browser
53captures and log retention remain machine-wide.
54
55Worktrees can be removed after a chosen number of inactive days, after merging, or when they
56have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active
57sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules`
58prevent removal. Branches and thread history stay; starting another turn recreates the checkout.
59Merge cleanup requires the commits to be included in the remote default branch, so squash merges
60may need the inactivity rule instead.
61
62Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last
63thread is deleted, including archived threads and worktrees left by earlier deletions. The
64server waits for sessions and terminals to stop and retries skipped worktrees after restart.
65Existing prompts for deleting a worktree manually remain available when this policy is off.
66
67Browser captures and rotated logs have separate retention periods. Expired capture links stop
68working. Current logs, message attachments, and browser profiles are kept.
69
4470## Project icons
4571
4672Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to
packages/contracts/src/environment.ts+2
......@@ -105,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
105105 threadSettlement: Schema.optionalKey(Schema.Boolean),
106106 /** Server evaluates merge and inactivity settlement without a client. */
107107 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
108 storageCleanup: Schema.optionalKey(Schema.Boolean),
109 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
108110 /** Server persists the opt-in for continuing interrupted threads after restarts. */
109111 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
110112 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
packages/contracts/src/settings.test.ts+44
......@@ -20,6 +20,50 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
2020const encodeServerSettings = Schema.encodeSync(ServerSettings);
2121const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);
2222
23describe("storage cleanup settings", () => {
24 it("keeps cleanup disabled for existing installations", () => {
25 expect(decodeServerSettings({}).worktreeCleanup).toBeNull();
26 expect(decodeServerSettings({}).storageCleanup).toEqual({
27 worktreeAfterDays: null,
28 worktreeOnMerge: false,
29 worktreeOnDelete: false,
30 worktreeUnchanged: false,
31 browserArtifactsAfterDays: null,
32 logsAfterDays: null,
33 });
34 });
35
36 it("accepts eight-day retention and disabling one rule without resetting others", () => {
37 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: 8 } })).toEqual({
38 storageCleanup: { worktreeAfterDays: 8 },
39 });
40 expect(decodeServerSettingsPatch({ storageCleanup: { worktreeAfterDays: null } })).toEqual({
41 storageCleanup: { worktreeAfterDays: null },
42 });
43 });
44
45 it("accepts partial custom patches but requires complete stored project rules", () => {
46 expect(
47 decodeServerSettingsPatch({
48 worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } },
49 }),
50 ).toEqual({ worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } });
51 expect(() =>
52 decodeServerSettings({
53 projectSettingsOverrides: {
54 project: { worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 8 } } },
55 },
56 }),
57 ).toThrow();
58 });
59
60 it.each([0, -1, 1.5, 3651])("rejects invalid retention %s", (days) => {
61 expect(() =>
62 decodeServerSettingsPatch({ storageCleanup: { browserArtifactsAfterDays: days } }),
63 ).toThrow();
64 });
65});
66
2367describe("ClientSettings rich text composer", () => {
2468 it("enables rich text for new and existing settings without a saved preference", () => {
2569 expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true);
packages/contracts/src/settings.ts+66
......@@ -967,7 +967,28 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
967967export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]);
968968export type ResponseStreamingMode = typeof ResponseStreamingMode.Type;
969969
970const StorageRetentionDays = Schema.NullOr(
971 Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })),
972);
973
974export const WorktreeCleanupRules = Schema.Struct({
975 worktreeAfterDays: StorageRetentionDays,
976 worktreeOnMerge: Schema.Boolean,
977 worktreeOnDelete: Schema.Boolean,
978 worktreeUnchanged: Schema.Boolean,
979});
980export type WorktreeCleanupRules = typeof WorktreeCleanupRules.Type;
981
982export const WorktreeCleanup = Schema.NullOr(
983 Schema.Union([
984 Schema.Struct({ mode: Schema.Literal("off") }),
985 Schema.Struct({ mode: Schema.Literal("custom"), rules: WorktreeCleanupRules }),
986 ]),
987);
988export type WorktreeCleanup = typeof WorktreeCleanup.Type;
989
970990export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
991 "worktreeCleanup",
971992 "defaultModelSelection",
972993 "defaultRuntimeMode",
973994 "defaultThreadEnvMode",
......@@ -993,6 +1014,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN
9931014 * model, no dedicated writer model, never auto-settle).
9941015 */
9951016export const ProjectSettingsOverrides = Schema.Struct({
1017 worktreeCleanup: Schema.optionalKey(WorktreeCleanup),
9961018 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
9971019 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
9981020 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
......@@ -1012,7 +1034,25 @@ export const ProjectSettingsOverrides = Schema.Struct({
10121034} satisfies Record<ProjectScopedServerSettingKey, unknown>);
10131035export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;
10141036
1037export const StorageCleanupSettings = Schema.Struct({
1038 worktreeAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1039 worktreeOnMerge: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1040 worktreeOnDelete: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1041 worktreeUnchanged: Schema.Boolean.pipe(Schema.withDecodingDefault(Effect.succeed(false))),
1042 browserArtifactsAfterDays: StorageRetentionDays.pipe(
1043 Schema.withDecodingDefault(Effect.succeed(null)),
1044 ),
1045 logsAfterDays: StorageRetentionDays.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1046});
1047export type StorageCleanupSettings = typeof StorageCleanupSettings.Type;
1048
10151049export const ServerSettings = Schema.Struct({
1050 worktreeCleanup: WorktreeCleanup.pipe(Schema.withDecodingDefault(Effect.succeed(null))),
1051 storageCleanup: StorageCleanupSettings.pipe(
1052 Schema.withDecodingDefault(
1053 Effect.succeed(Schema.decodeUnknownSync(StorageCleanupSettings)({})),
1054 ),
1055 ),
10161056 // How assistant text reaches clients during a turn. Deliberately a fresh
10171057 // key (was `enableLegacyTokenStreaming`, before that
10181058 // `enableAssistantStreaming`): decoding drops the old key, so everyone,
......@@ -1352,6 +1392,32 @@ const OpenCodeSettingsPatch = Schema.Struct({
13521392});
13531393
13541394export const ServerSettingsPatch = Schema.Struct({
1395 worktreeCleanup: Schema.optionalKey(
1396 Schema.NullOr(
1397 Schema.Union([
1398 Schema.Struct({ mode: Schema.Literal("off") }),
1399 Schema.Struct({
1400 mode: Schema.Literal("custom"),
1401 rules: Schema.Struct({
1402 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
1403 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
1404 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
1405 worktreeUnchanged: Schema.optionalKey(Schema.Boolean),
1406 }),
1407 }),
1408 ]),
1409 ),
1410 ),
1411 storageCleanup: Schema.optionalKey(
1412 Schema.Struct({
1413 worktreeAfterDays: Schema.optionalKey(StorageRetentionDays),
1414 worktreeOnMerge: Schema.optionalKey(Schema.Boolean),
1415 worktreeOnDelete: Schema.optionalKey(Schema.Boolean),
1416 worktreeUnchanged: Schema.optionalKey(Schema.Boolean),
1417 browserArtifactsAfterDays: Schema.optionalKey(StorageRetentionDays),
1418 logsAfterDays: Schema.optionalKey(StorageRetentionDays),
1419 }),
1420 ),
13551421 // Server settings
13561422 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
13571423 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
packages/shared/src/projectSettings.test.ts+58
......@@ -10,6 +10,7 @@ import {
1010 clearProjectSettingsOverrides,
1111 hasProjectSettingsOverrides,
1212 resolveProjectSettings,
13 resolveWorktreeCleanup,
1314 withProjectSettingsOverrides,
1415} from "./projectSettings.ts";
1516import { applyServerSettingsPatch } from "./serverSettings.ts";
......@@ -214,3 +215,60 @@ describe("projectSettingsOverrides patches", () => {
214215 });
215216 });
216217});
218
219describe("resolveWorktreeCleanup", () => {
220 it("inherits machine rules, disables one project and keeps custom rules isolated", () => {
221 const machine = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
222 storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true, logsAfterDays: 3 },
223 });
224 const inherited = resolveWorktreeCleanup(machine, projectId);
225 const off = applyServerSettingsPatch(machine, {
226 projectSettingsOverrides: {
227 [projectId]: { worktreeCleanup: { mode: "off" } },
228 },
229 });
230 expect(resolveWorktreeCleanup(off, projectId)).toEqual({
231 worktreeAfterDays: null,
232 worktreeOnDelete: false,
233 worktreeOnMerge: false,
234 worktreeUnchanged: false,
235 });
236 expect(resolveWorktreeCleanup(off, otherProjectId)).toEqual(inherited);
237 const custom = applyServerSettingsPatch(off, {
238 projectSettingsOverrides: {
239 [projectId]: {
240 worktreeCleanup: { mode: "custom", rules: { ...inherited, worktreeAfterDays: 15 } },
241 },
242 },
243 });
244 expect(resolveWorktreeCleanup(custom, projectId).worktreeAfterDays).toBe(15);
245 expect(custom.storageCleanup.logsAfterDays).toBe(3);
246 const reset = applyServerSettingsPatch(custom, {
247 projectSettingsOverrides: {
248 [projectId]: clearProjectSettingsOverrides(custom, projectId, ["worktreeCleanup"]),
249 },
250 });
251 expect(resolveWorktreeCleanup(reset, projectId)).toEqual(inherited);
252 });
253 it("completes partial machine custom rules and preserves them across edits", () => {
254 const initial = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
255 storageCleanup: { worktreeAfterDays: 8, worktreeOnDelete: true },
256 });
257 const custom = applyServerSettingsPatch(initial, {
258 worktreeCleanup: { mode: "custom", rules: { worktreeOnMerge: true } },
259 });
260 const edited = applyServerSettingsPatch(custom, {
261 worktreeCleanup: { mode: "custom", rules: { worktreeAfterDays: 15 } },
262 });
263 expect(resolveWorktreeCleanup(edited, null)).toEqual({
264 worktreeAfterDays: 15,
265 worktreeOnDelete: true,
266 worktreeOnMerge: true,
267 worktreeUnchanged: false,
268 });
269 expect(
270 resolveWorktreeCleanup(applyServerSettingsPatch(edited, { worktreeCleanup: null }), null)
271 .worktreeAfterDays,
272 ).toBe(8);
273 });
274});
packages/shared/src/projectSettings.ts+20
......@@ -6,6 +6,7 @@ import {
66 type ProjectSettingsOverrides,
77 type ServerSettings,
88 type ThreadEnvMode,
9 type WorktreeCleanupRules,
910} from "@t3tools/contracts";
1011import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
1112
......@@ -124,3 +125,22 @@ export function clearProjectSettingsOverrides(
124125 for (const key of keys) delete next[key];
125126 return Object.keys(next).length === 0 ? null : next;
126127}
128
129/** Worktree rules are project-scoped; artifact and log retention stays environment-wide. */
130export function resolveWorktreeCleanup(
131 settings: ServerSettings,
132 projectId: ProjectId | null,
133): WorktreeCleanupRules {
134 const policy = resolveProjectSettings(settings, projectId).settings.worktreeCleanup;
135 if (policy?.mode === "custom") return policy.rules;
136 if (policy?.mode === "off")
137 return {
138 worktreeAfterDays: null,
139 worktreeOnMerge: false,
140 worktreeOnDelete: false,
141 worktreeUnchanged: false,
142 };
143 const { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged } =
144 settings.storageCleanup;
145 return { worktreeAfterDays, worktreeOnMerge, worktreeOnDelete, worktreeUnchanged };
146}
packages/shared/src/serverSettings.test.ts+17
......@@ -24,6 +24,23 @@ import {
2424const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
2525
2626describe("serverSettings helpers", () => {
27 it("changes a cleanup rule without replacing the machine's other rules", () => {
28 const enabled = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, {
29 storageCleanup: { worktreeAfterDays: 8, worktreeOnMerge: true, logsAfterDays: 30 },
30 });
31 expect(
32 applyServerSettingsPatch(enabled, {
33 storageCleanup: { worktreeAfterDays: null },
34 }).storageCleanup,
35 ).toEqual({
36 worktreeAfterDays: null,
37 worktreeOnMerge: true,
38 worktreeOnDelete: false,
39 worktreeUnchanged: false,
40 browserArtifactsAfterDays: null,
41 logsAfterDays: 30,
42 });
43 });
2744 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
2845 const host = { id: "mini", label: "Mac mini", target: "mini" };
2946 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
packages/shared/src/serverSettings.ts+21
......@@ -270,6 +270,7 @@ export function applyServerSettingsPatch(
270270 providerHealthRefreshInterval,
271271 backgroundActivityProfile,
272272 backgroundActivity,
273 worktreeCleanup: worktreeCleanupPatch,
273274 // Merged per entry below; its `null` removals must not reach deepMerge.
274275 usageLimitSources: usageLimitSourcesPatch,
275276 usagePriceOverrides: usagePriceOverridesPatch,
......@@ -320,6 +321,26 @@ export function applyServerSettingsPatch(
320321 const next = deepMerge(current, patchForMerge);
321322 const nextWithReplacementsBase = {
322323 ...next,
324 ...(worktreeCleanupPatch === undefined
325 ? {}
326 : {
327 worktreeCleanup:
328 worktreeCleanupPatch?.mode === "custom"
329 ? {
330 mode: "custom" as const,
331 rules: {
332 worktreeAfterDays: next.storageCleanup.worktreeAfterDays,
333 worktreeOnMerge: next.storageCleanup.worktreeOnMerge,
334 worktreeOnDelete: next.storageCleanup.worktreeOnDelete,
335 worktreeUnchanged: next.storageCleanup.worktreeUnchanged,
336 ...(current.worktreeCleanup?.mode === "custom"
337 ? current.worktreeCleanup.rules
338 : {}),
339 ...worktreeCleanupPatch.rules,
340 },
341 }
342 : worktreeCleanupPatch,
343 }),
323344 ...(backgroundActivity !== undefined
324345 ? {
325346 backgroundActivity: {