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...@@ -39,6 +39,7 @@ import { ProviderAdapterRegistry } from "../src/provider/Services/ProviderAdapte
39import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";39import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts";
40import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";40import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts";
41import { ServerSettingsService } from "../src/serverSettings.ts";41import { ServerSettingsService } from "../src/serverSettings.ts";
42import * as StorageCleanup from "../src/storageCleanup.ts";
42import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";43import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts";
43import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";44import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts";
44import {45import {
...@@ -384,6 +385,12 @@ export const makeOrchestrationIntegrationHarness = (...@@ -384,6 +385,12 @@ export const makeOrchestrationIntegrationHarness = (
384 Layer.provideMerge(VcsProcess.layer),385 Layer.provideMerge(VcsProcess.layer),
385 );386 );
386 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(387 const orchestrationReactorLayer = OrchestrationReactorLive.pipe(
388 Layer.provideMerge(
389 Layer.succeed(StorageCleanup.StorageCleanup, {
390 start: () => Effect.void,
391 drain: Effect.void,
392 }),
393 ),
387 Layer.provideMerge(runtimeIngestionLayer),394 Layer.provideMerge(runtimeIngestionLayer),
388 Layer.provideMerge(providerCommandReactorLayer),395 Layer.provideMerge(providerCommandReactorLayer),
389 Layer.provideMerge(checkpointReactorLayer),396 Layer.provideMerge(checkpointReactorLayer),
apps/server/src/checkpointing/CheckpointDiffQuery.test.ts+5
...@@ -83,6 +83,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -83,6 +83,7 @@ describe("CheckpointDiffQuery.layer", () => {
83 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),83 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
84 getShellSnapshot: () =>84 getShellSnapshot: () =>
85 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),85 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
86 getDeletedWorktreeThreads: () => Effect.die("unused"),
86 getArchivedShellSnapshot: () =>87 getArchivedShellSnapshot: () =>
87 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),88 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
88 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),89 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
...@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -199,6 +200,7 @@ describe("CheckpointDiffQuery.layer", () => {
199 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),200 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
200 getShellSnapshot: () =>201 getShellSnapshot: () =>
201 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),202 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
203 getDeletedWorktreeThreads: () => Effect.die("unused"),
202 getArchivedShellSnapshot: () =>204 getArchivedShellSnapshot: () =>
203 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),205 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
204 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),206 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
...@@ -290,6 +292,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -290,6 +292,7 @@ describe("CheckpointDiffQuery.layer", () => {
290 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),292 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
291 getShellSnapshot: () =>293 getShellSnapshot: () =>
292 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),294 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
295 getDeletedWorktreeThreads: () => Effect.die("unused"),
293 getArchivedShellSnapshot: () =>296 getArchivedShellSnapshot: () =>
294 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),297 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
295 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),298 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
...@@ -366,6 +369,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -366,6 +369,7 @@ describe("CheckpointDiffQuery.layer", () => {
366 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),369 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
367 getShellSnapshot: () =>370 getShellSnapshot: () =>
368 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),371 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
372 getDeletedWorktreeThreads: () => Effect.die("unused"),
369 getArchivedShellSnapshot: () =>373 getArchivedShellSnapshot: () =>
370 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),374 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
371 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),375 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
...@@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => {...@@ -427,6 +431,7 @@ describe("CheckpointDiffQuery.layer", () => {
427 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),431 Effect.die("CheckpointDiffQuery should not request the full orchestration snapshot"),
428 getShellSnapshot: () =>432 getShellSnapshot: () =>
429 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),433 Effect.die("CheckpointDiffQuery should not request the orchestration shell snapshot"),
434 getDeletedWorktreeThreads: () => Effect.die("unused"),
430 getArchivedShellSnapshot: () =>435 getArchivedShellSnapshot: () =>
431 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),436 Effect.die("CheckpointDiffQuery should not request archived shell snapshots"),
432 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),437 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 0 }),
apps/server/src/environment/ServerEnvironment.ts+2
...@@ -224,6 +224,8 @@ export const make = Effect.gen(function* () {...@@ -224,6 +224,8 @@ export const make = Effect.gen(function* () {
224 inlineMessageContext: true,224 inlineMessageContext: true,
225 threadSettlement: true,225 threadSettlement: true,
226 threadAutoSettlement: true,226 threadAutoSettlement: true,
227 storageCleanup: true,
228 projectWorktreeCleanup: true,
227 threadRestartContinuation: true,229 threadRestartContinuation: true,
228 projectSettingsOverrides: true,230 projectSettingsOverrides: true,
229 threadSnooze: true,231 threadSnooze: true,
apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts+1
...@@ -434,6 +434,7 @@ describe("OrchestrationEngine", () => {...@@ -434,6 +434,7 @@ describe("OrchestrationEngine", () => {
434 threads: [],434 threads: [],
435 updatedAt: projectionSnapshot.updatedAt,435 updatedAt: projectionSnapshot.updatedAt,
436 }),436 }),
437 getDeletedWorktreeThreads: () => Effect.die("unused"),
437 getArchivedShellSnapshot: () =>438 getArchivedShellSnapshot: () =>
438 Effect.succeed({439 Effect.succeed({
439 snapshotSequence: projectionSnapshot.snapshotSequence,440 snapshotSequence: projectionSnapshot.snapshotSequence,
apps/server/src/orchestration/Layers/OrchestrationReactor.test.ts+11
...@@ -15,6 +15,7 @@ import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";...@@ -15,6 +15,7 @@ import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
15import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";15import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts";
16import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";16import { makeOrchestrationReactor } from "./OrchestrationReactor.ts";
17import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";17import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
18import { StorageCleanup } from "../../storageCleanup.ts";
1819
19describe("OrchestrationReactor", () => {20describe("OrchestrationReactor", () => {
20 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;21 let runtime: ManagedRuntime.ManagedRuntime<OrchestrationReactor, never> | null = null;
...@@ -31,6 +32,15 @@ describe("OrchestrationReactor", () => {...@@ -31,6 +32,15 @@ describe("OrchestrationReactor", () => {
3132
32 runtime = ManagedRuntime.make(33 runtime = ManagedRuntime.make(
33 Layer.effect(OrchestrationReactor, makeOrchestrationReactor).pipe(34 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 ),
34 Layer.provideMerge(44 Layer.provideMerge(
35 Layer.succeed(ProviderRuntimeIngestionService, {45 Layer.succeed(ProviderRuntimeIngestionService, {
36 start: () => {46 start: () => {
...@@ -120,6 +130,7 @@ describe("OrchestrationReactor", () => {...@@ -120,6 +130,7 @@ describe("OrchestrationReactor", () => {
120 "thread-settlement-reactor",130 "thread-settlement-reactor",
121 "pull-request-sync-reactor",131 "pull-request-sync-reactor",
122 "agent-awareness-relay",132 "agent-awareness-relay",
133 "storage-cleanup",
123 ]);134 ]);
124135
125 await Effect.runPromise(Scope.close(scope, Exit.void));136 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";...@@ -13,6 +13,7 @@ import * as ThreadSettlementReactor from "../ThreadSettlementReactor.ts";
13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";13import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts";
14import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";14import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts";
15import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";15import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts";
16import * as StorageCleanup from "../../storageCleanup.ts";
1617
17export const makeOrchestrationReactor = Effect.gen(function* () {18export const makeOrchestrationReactor = Effect.gen(function* () {
18 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;19 const providerRuntimeIngestion = yield* ProviderRuntimeIngestionService;
...@@ -23,6 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -23,6 +24,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
23 const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;24 const pullRequestSyncReactor = yield* PullRequestSyncReactor.PullRequestSyncReactor;
24 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;25 const threadPullRequestReactor = yield* ThreadPullRequestReactor.ThreadPullRequestReactor;
25 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;26 const agentAwarenessRelay = yield* AgentAwarenessRelay.AgentAwarenessRelay;
27 const storageCleanup = yield* StorageCleanup.StorageCleanup;
2628
27 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {29 const start: OrchestrationReactorShape["start"] = Effect.fn("start")(function* () {
28 yield* providerRuntimeIngestion.start();30 yield* providerRuntimeIngestion.start();
...@@ -33,6 +35,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {...@@ -33,6 +35,7 @@ export const makeOrchestrationReactor = Effect.gen(function* () {
33 yield* threadSettlementReactor.start();35 yield* threadSettlementReactor.start();
34 yield* pullRequestSyncReactor.start();36 yield* pullRequestSyncReactor.start();
35 yield* agentAwarenessRelay.start();37 yield* agentAwarenessRelay.start();
38 yield* storageCleanup.start();
36 });39 });
3740
38 return {41 return {
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.test.ts+34
...@@ -1068,6 +1068,40 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {...@@ -1068,6 +1068,40 @@ projectionSnapshotLayer("ProjectionSnapshotQuery", (it) => {
1068 (yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-active")))._tag,1068 (yield* snapshotQuery.getThreadRuntimeContext(ThreadId.make("thread-active")))._tag,
1069 "None",1069 "None",
1070 );1070 );
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 ]);
1071 }),1105 }),
1072 );1106 );
10731107
apps/server/src/orchestration/Layers/ProjectionSnapshotQuery.ts+31
...@@ -637,6 +637,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {...@@ -637,6 +637,36 @@ const makeProjectionSnapshotQuery = Effect.gen(function* () {
637 `,637 `,
638 });638 });
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
640 const listArchivedThreadRows = SqlSchema.findAll({670 const listArchivedThreadRows = SqlSchema.findAll({
641 Request: Schema.Void,671 Request: Schema.Void,
642 Result: ProjectionThreadDbRowSchema,672 Result: ProjectionThreadDbRowSchema,
...@@ -3734,6 +3764,7 @@ pending_approval_requests AS (...@@ -3734,6 +3764,7 @@ pending_approval_requests AS (
3734 getSnapshot,3764 getSnapshot,
3735 getShellSnapshot,3765 getShellSnapshot,
3736 getArchivedShellSnapshot,3766 getArchivedShellSnapshot,
3767 getDeletedWorktreeThreads,
3737 searchThreads,3768 searchThreads,
3738 getSnapshotSequence,3769 getSnapshotSequence,
3739 getCounts,3770 getCounts,
apps/server/src/orchestration/Layers/ProviderCommandReactor.ts+13-3
...@@ -1,3 +1,4 @@...@@ -1,3 +1,4 @@
1import { withWorkspaceLease } from "../../workspace/workspaceLease.ts";
1import {2import {
2 type ChatAttachment,3 type ChatAttachment,
3 CommandId,4 CommandId,
...@@ -26,6 +27,7 @@ import * as Equal from "effect/Equal";...@@ -26,6 +27,7 @@ import * as Equal from "effect/Equal";
26import * as FileSystem from "effect/FileSystem";27import * as FileSystem from "effect/FileSystem";
27import * as Layer from "effect/Layer";28import * as Layer from "effect/Layer";
28import * as Option from "effect/Option";29import * as Option from "effect/Option";
30import * as Path from "effect/Path";
29import * as Schedule from "effect/Schedule";31import * as Schedule from "effect/Schedule";
30import * as Schema from "effect/Schema";32import * as Schema from "effect/Schema";
31import * as Stream from "effect/Stream";33import * as Stream from "effect/Stream";
...@@ -214,6 +216,7 @@ const make = Effect.gen(function* () {...@@ -214,6 +216,7 @@ const make = Effect.gen(function* () {
214 const providerRegistry = yield* ProviderRegistry;216 const providerRegistry = yield* ProviderRegistry;
215 const gitWorkflow = yield* GitWorkflowService;217 const gitWorkflow = yield* GitWorkflowService;
216 const fileSystem = yield* FileSystem.FileSystem;218 const fileSystem = yield* FileSystem.FileSystem;
219 const path = yield* Path.Path;
217 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;220 const vcsStatusBroadcaster = yield* VcsStatusBroadcaster;
218 const textGeneration = yield* TextGeneration;221 const textGeneration = yield* TextGeneration;
219 const serverSettingsService = yield* ServerSettingsService;222 const serverSettingsService = yield* ServerSettingsService;
...@@ -1784,16 +1787,23 @@ const make = Effect.gen(function* () {...@@ -1784,16 +1787,23 @@ const make = Effect.gen(function* () {
1784 return;1787 return;
1785 }1788 }
1786 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);1789 const cachedModelSelection = threadModelSelections.get(event.payload.threadId);
1787 yield* ensureSessionForThread(1790 const resume = ensureSessionForThread(
1788 event.payload.threadId,1791 event.payload.threadId,
1789 event.occurredAt,1792 event.occurredAt,
1790 cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {},1793 cachedModelSelection !== undefined ? { modelSelection: cachedModelSelection } : {},
1791 );1794 );
1795 yield* thread.worktreePath
1796 ? withWorkspaceLease(path.resolve(thread.worktreePath), resume)
1797 : resume;
1792 return;1798 return;
1793 }1799 }
1794 case "thread.turn-start-requested":1800 case "thread.turn-start-requested": {
1795 yield* processTurnStartRequested(event);1801 const thread = yield* resolveThreadShell(event.payload.threadId);
1802 yield* thread?.worktreePath
1803 ? withWorkspaceLease(path.resolve(thread.worktreePath), processTurnStartRequested(event))
1804 : processTurnStartRequested(event);
1796 return;1805 return;
1806 }
1797 case "thread.turn-interrupt-requested":1807 case "thread.turn-interrupt-requested":
1798 yield* processTurnInterruptRequested(event);1808 yield* processTurnInterruptRequested(event);
1799 return;1809 return;
apps/server/src/orchestration/Services/ProjectionSnapshotQuery.ts+13
...@@ -131,6 +131,19 @@ export interface ProjectionSnapshotQueryShape {...@@ -131,6 +131,19 @@ export interface ProjectionSnapshotQueryShape {
131 ProjectionRepositoryError131 ProjectionRepositoryError
132 >;132 >;
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
134 /**147 /**
135 * Search active thread navigation metadata, user messages, and canonical148 * Search active thread navigation metadata, user messages, and canonical
136 * assistant outputs without hydrating thread detail snapshots.149 * assistant outputs without hydrating thread detail snapshots.
apps/server/src/orchestration/ThreadSettlementReactor.test.ts+518
...@@ -3,6 +3,7 @@ import {...@@ -3,6 +3,7 @@ import {
3 EventId,3 EventId,
4 ProjectId,4 ProjectId,
5 ProviderInstanceId,5 ProviderInstanceId,
6 ProviderDriverKind,
6 PullRequestOperationError,7 PullRequestOperationError,
7 ThreadId,8 ThreadId,
8 type OrchestrationCommand,9 type OrchestrationCommand,
...@@ -17,8 +18,10 @@ import {...@@ -17,8 +18,10 @@ import {
17import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";18import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
18import { assert, describe, it } from "@effect/vitest";19import { assert, describe, it } from "@effect/vitest";
19import * as Crypto from "effect/Crypto";20import * as Crypto from "effect/Crypto";
21import * as DateTime from "effect/DateTime";
20import * as Deferred from "effect/Deferred";22import * as Deferred from "effect/Deferred";
21import * as Effect from "effect/Effect";23import * as Effect from "effect/Effect";
24import * as Fiber from "effect/Fiber";
22import * as FileSystem from "effect/FileSystem";25import * as FileSystem from "effect/FileSystem";
23import * as Layer from "effect/Layer";26import * as Layer from "effect/Layer";
24import * as PubSub from "effect/PubSub";27import * as PubSub from "effect/PubSub";
...@@ -41,6 +44,16 @@ import {...@@ -41,6 +44,16 @@ import {
41} from "./Services/OrchestrationEngine.ts";44} from "./Services/OrchestrationEngine.ts";
42import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";45import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts";
43import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts";46import * 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
45const NOW = "2026-08-28T12:00:00.000Z";58const NOW = "2026-08-28T12:00:00.000Z";
46const PROJECT_ID = ProjectId.make("settlement-project");59const PROJECT_ID = ProjectId.make("settlement-project");
...@@ -1363,3 +1376,508 @@ describe("ThreadSettlementReactor", () => {...@@ -1363,3 +1376,508 @@ describe("ThreadSettlementReactor", () => {
1363 ),1376 ),
1364 );1377 );
1365});1378});
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<...@@ -47,6 +47,7 @@ const makeProjectionSnapshotQueryLayer = (importedWorkspaceRoots: ReadonlyArray<
47 threads: [],47 threads: [],
48 updatedAt: "2026-01-01T00:00:00.000Z",48 updatedAt: "2026-01-01T00:00:00.000Z",
49 }),49 }),
50 getDeletedWorktreeThreads: () => Effect.die("unused"),
50 getArchivedShellSnapshot: () => Effect.die("unused"),51 getArchivedShellSnapshot: () => Effect.die("unused"),
51 getSnapshotSequence: () => Effect.die("unused"),52 getSnapshotSequence: () => Effect.die("unused"),
52 getCounts: () => Effect.die("unused"),53 getCounts: () => Effect.die("unused"),
apps/server/src/project/ProjectSetupScriptRunner.test.ts+1
...@@ -33,6 +33,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>...@@ -33,6 +33,7 @@ const makeProjectionSnapshotQueryLayer = (project: OrchestrationProject) =>
33 getCommandReadModel: () => Effect.die("unused"),33 getCommandReadModel: () => Effect.die("unused"),
34 getSnapshot: () => Effect.die("unused"),34 getSnapshot: () => Effect.die("unused"),
35 getShellSnapshot: () => Effect.die("unused"),35 getShellSnapshot: () => Effect.die("unused"),
36 getDeletedWorktreeThreads: () => Effect.die("unused"),
36 getArchivedShellSnapshot: () => Effect.die("unused"),37 getArchivedShellSnapshot: () => Effect.die("unused"),
37 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),38 getSnapshotSequence: () => Effect.succeed({ snapshotSequence: 1 }),
38 getCounts: () => Effect.die("unused"),39 getCounts: () => Effect.die("unused"),
apps/server/src/provider/Layers/ProviderService.test.ts+1
...@@ -4970,6 +4970,7 @@ describe("agent browser access", () => {...@@ -4970,6 +4970,7 @@ describe("agent browser access", () => {
4970 getCommandReadModel: () => Effect.die("unused"),4970 getCommandReadModel: () => Effect.die("unused"),
4971 getSnapshot: () => Effect.die("unused"),4971 getSnapshot: () => Effect.die("unused"),
4972 getShellSnapshot: () => Effect.die("unused"),4972 getShellSnapshot: () => Effect.die("unused"),
4973 getDeletedWorktreeThreads: () => Effect.die("unused"),
4973 getArchivedShellSnapshot: () => Effect.die("unused"),4974 getArchivedShellSnapshot: () => Effect.die("unused"),
4974 getSnapshotSequence: () => Effect.die("unused"),4975 getSnapshotSequence: () => Effect.die("unused"),
4975 getCounts: () => Effect.die("unused"),4976 getCounts: () => Effect.die("unused"),
apps/server/src/provider/Layers/ProviderSessionReaper.test.ts+1
...@@ -238,6 +238,7 @@ describe("ProviderSessionReaper", () => {...@@ -238,6 +238,7 @@ describe("ProviderSessionReaper", () => {
238 getCommandReadModel: () => Effect.die("unused"),238 getCommandReadModel: () => Effect.die("unused"),
239 getSnapshot: () => Effect.die("unused"),239 getSnapshot: () => Effect.die("unused"),
240 getShellSnapshot: () => Effect.die("unused"),240 getShellSnapshot: () => Effect.die("unused"),
241 getDeletedWorktreeThreads: () => Effect.die("unused"),
241 getArchivedShellSnapshot: () => Effect.die("unused"),242 getArchivedShellSnapshot: () => Effect.die("unused"),
242 getSnapshotSequence: () =>243 getSnapshotSequence: () =>
243 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),244 Effect.succeed({ snapshotSequence: input.readModel.snapshotSequence }),
apps/server/src/server.ts+2
...@@ -84,6 +84,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma...@@ -84,6 +84,7 @@ import { ProviderCommandReactorLive } from "./orchestration/Layers/ProviderComma
84import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";84import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts";
85import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";85import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts";
86import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";86import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts";
87import * as StorageCleanup from "./storageCleanup.ts";
87import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";88import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts";
88import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";89import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts";
89import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";90import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts";
...@@ -246,6 +247,7 @@ const ReactorLayerLive = Layer.empty.pipe(...@@ -246,6 +247,7 @@ const ReactorLayerLive = Layer.empty.pipe(
246 Layer.provideMerge(ProviderRuntimeIngestionLive),247 Layer.provideMerge(ProviderRuntimeIngestionLive),
247 Layer.provideMerge(ProviderCommandReactorLive),248 Layer.provideMerge(ProviderCommandReactorLive),
248 Layer.provideMerge(CheckpointReactorLive),249 Layer.provideMerge(CheckpointReactorLive),
250 Layer.provideMerge(StorageCleanup.layer),
249 Layer.provideMerge(ThreadDeletionReactorLive),251 Layer.provideMerge(ThreadDeletionReactorLive),
250 Layer.provideMerge(ThreadSettlementReactor.layer),252 Layer.provideMerge(ThreadSettlementReactor.layer),
251 Layer.provideMerge(PullRequestSyncReactor.layer),253 Layer.provideMerge(PullRequestSyncReactor.layer),
apps/server/src/serverRuntimeStartup.test.ts+4
...@@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa...@@ -168,6 +168,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets returns existing project and threa
168 getCommandReadModel: () => Effect.die("unused"),168 getCommandReadModel: () => Effect.die("unused"),
169 getSnapshot: () => Effect.die("unused"),169 getSnapshot: () => Effect.die("unused"),
170 getShellSnapshot: () => Effect.die("unused"),170 getShellSnapshot: () => Effect.die("unused"),
171 getDeletedWorktreeThreads: () => Effect.die("unused"),
171 getArchivedShellSnapshot: () => Effect.die("unused"),172 getArchivedShellSnapshot: () => Effect.die("unused"),
172 getSnapshotSequence: () => Effect.die("unused"),173 getSnapshotSequence: () => Effect.die("unused"),
173 getCounts: () => Effect.die("unused"),174 getCounts: () => Effect.die("unused"),
...@@ -298,6 +299,7 @@ it.effect.each([...@@ -298,6 +299,7 @@ it.effect.each([
298 getCommandReadModel: () => Effect.die("unused"),299 getCommandReadModel: () => Effect.die("unused"),
299 getSnapshot: () => Effect.die("unused"),300 getSnapshot: () => Effect.die("unused"),
300 getShellSnapshot: () => Effect.die("unused"),301 getShellSnapshot: () => Effect.die("unused"),
302 getDeletedWorktreeThreads: () => Effect.die("unused"),
301 getArchivedShellSnapshot: () => Effect.die("unused"),303 getArchivedShellSnapshot: () => Effect.die("unused"),
302 getSnapshotSequence: () => Effect.die("unused"),304 getSnapshotSequence: () => Effect.die("unused"),
303 getCounts: () => Effect.die("unused"),305 getCounts: () => Effect.die("unused"),
...@@ -384,6 +386,7 @@ it.effect(...@@ -384,6 +386,7 @@ it.effect(
384 getCommandReadModel: () => Effect.die("unused"),386 getCommandReadModel: () => Effect.die("unused"),
385 getSnapshot: () => Effect.die("unused"),387 getSnapshot: () => Effect.die("unused"),
386 getShellSnapshot: () => Effect.die("unused"),388 getShellSnapshot: () => Effect.die("unused"),
389 getDeletedWorktreeThreads: () => Effect.die("unused"),
387 getArchivedShellSnapshot: () => Effect.die("unused"),390 getArchivedShellSnapshot: () => Effect.die("unused"),
388 getSnapshotSequence: () => Effect.die("unused"),391 getSnapshotSequence: () => Effect.die("unused"),
389 getCounts: () => Effect.die("unused"),392 getCounts: () => Effect.die("unused"),
...@@ -448,6 +451,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa...@@ -448,6 +451,7 @@ it.effect("resolveAutoBootstrapWelcomeTargets preserves typed UUID generation fa
448 getCommandReadModel: () => Effect.die("unused"),451 getCommandReadModel: () => Effect.die("unused"),
449 getSnapshot: () => Effect.die("unused"),452 getSnapshot: () => Effect.die("unused"),
450 getShellSnapshot: () => Effect.die("unused"),453 getShellSnapshot: () => Effect.die("unused"),
454 getDeletedWorktreeThreads: () => Effect.die("unused"),
451 getArchivedShellSnapshot: () => Effect.die("unused"),455 getArchivedShellSnapshot: () => Effect.die("unused"),
452 getSnapshotSequence: () => Effect.die("unused"),456 getSnapshotSequence: () => Effect.die("unused"),
453 getCounts: () => Effect.die("unused"),457 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 @@...@@ -6,6 +6,7 @@
6 *6 *
7 * @module TerminalManager7 * @module TerminalManager
8 */8 */
9import { withWorkspaceLease } from "../workspace/workspaceLease.ts";
9import {10import {
10 DEFAULT_TERMINAL_ID,11 DEFAULT_TERMINAL_ID,
11 TerminalCwdError,12 TerminalCwdError,
...@@ -2516,7 +2517,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2516,7 +2517,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2516 }).pipe(Effect.ignoreCause({ log: true })),2517 }).pipe(Effect.ignoreCause({ log: true })),
2517 );2518 );
25182519
2519 const openLocked = Effect.fn("terminal.openLocked")(function* (input: TerminalOpenInput) {2520 const openWithWorkspaceLease = Effect.fn("terminal.openLocked")(function* (
2521 input: TerminalOpenInput,
2522 ) {
2520 const terminalId = input.terminalId;2523 const terminalId = input.terminalId;
2521 yield* assertValidCwd(input.cwd);2524 yield* assertValidCwd(input.cwd);
25222525
...@@ -2639,6 +2642,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -2639,6 +2642,12 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
2639 return snapshot(liveSession);2642 return snapshot(liveSession);
2640 });2643 });
26412644
2645 const openLocked = (input: TerminalOpenInput) =>
2646 withWorkspaceLease(
2647 path.resolve(input.worktreePath ?? input.cwd),
2648 openWithWorkspaceLease(input),
2649 );
2650
2642 const open: TerminalManager["Service"]["open"] = (input) =>2651 const open: TerminalManager["Service"]["open"] = (input) =>
2643 withThreadLock(2652 withThreadLock(
2644 input.threadId,2653 input.threadId,
...@@ -3010,7 +3019,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func...@@ -3010,7 +3019,14 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func
3010 const restart: TerminalManager["Service"]["restart"] = (input) =>3019 const restart: TerminalManager["Service"]["restart"] = (input) =>
3011 withThreadLock(3020 withThreadLock(
3012 input.threadId,3021 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 ),
3014 );3030 );
30153031
3016 const close: TerminalManager["Service"]["close"] = (input) =>3032 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 {...@@ -15,6 +15,7 @@ import {
15 BotIcon,15 BotIcon,
16 createLucideIcon,16 createLucideIcon,
17 GitBranchIcon,17 GitBranchIcon,
18 HardDriveIcon,
18 PanelsTopLeftIcon,19 PanelsTopLeftIcon,
19 KeyboardIcon,20 KeyboardIcon,
20 Link2Icon,21 Link2Icon,
...@@ -83,6 +84,7 @@ const SETTINGS_SECTION_ICONS: Readonly<...@@ -83,6 +84,7 @@ const SETTINGS_SECTION_ICONS: Readonly<
83 "/settings/providers": BotIcon,84 "/settings/providers": BotIcon,
84 "/settings/integrations": BlocksIcon,85 "/settings/integrations": BlocksIcon,
85 "/settings/source-control": GitBranchIcon,86 "/settings/source-control": GitBranchIcon,
87 "/settings/storage": HardDriveIcon,
86 "/settings/connections": Link2Icon,88 "/settings/connections": Link2Icon,
87 "/settings/archived": ArchiveIcon,89 "/settings/archived": ArchiveIcon,
88};90};
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 {...@@ -5,6 +5,8 @@ import {
5 type ServerSettings,5 type ServerSettings,
6} from "@t3tools/contracts";6} from "@t3tools/contracts";
7import { describe, expect, it, vi } from "vite-plus/test";7import { describe, expect, it, vi } from "vite-plus/test";
8import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings";
9import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
810
9import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";11import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping";
10import {12import {
...@@ -146,6 +148,48 @@ describe("scoped settings targets", () => {...@@ -146,6 +148,48 @@ describe("scoped settings targets", () => {
146});148});
147149
148describe("scoped settings writes", () => {150describe("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 });
149 it("isolates a formerly shared server preference to the named environment", async () => {193 it("isolates a formerly shared server preference to the named environment", async () => {
150 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });194 const persistServer = vi.fn().mockResolvedValue({ _tag: "Success" });
151 const persistClient = vi.fn();195 const persistClient = vi.fn();
...@@ -231,6 +275,74 @@ describe("scoped settings writes", () => {...@@ -231,6 +275,74 @@ describe("scoped settings writes", () => {
231 });275 });
232 });276 });
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
234 it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => {346 it("scopes agent device access to projects while keeping hub and hosts environment-wide", () => {
235 const plan = planScopedSettingsPatch(project, [laptop, server], {347 const plan = planScopedSettingsPatch(project, [laptop, server], {
236 enableAgentDeviceAccess: true,348 enableAgentDeviceAccess: true,
apps/web/src/components/settings/scopedSettings.ts+30-1
...@@ -13,6 +13,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect...@@ -13,6 +13,7 @@ import type { EnvironmentConnectionPhase } from "@t3tools/client-runtime/connect
13import {13import {
14 clearProjectSettingsOverrides,14 clearProjectSettingsOverrides,
15 resolveProjectSettings,15 resolveProjectSettings,
16 resolveWorktreeCleanup,
16 type ProjectSettingSource,17 type ProjectSettingSource,
17} from "@t3tools/shared/projectSettings";18} from "@t3tools/shared/projectSettings";
18import * as Equal from "effect/Equal";19import * as Equal from "effect/Equal";
...@@ -223,6 +224,16 @@ export function planScopedSettingsPatch(...@@ -223,6 +224,16 @@ export function planScopedSettingsPatch(
223 const effective = resolveProjectSettings(settings, projectId).settings;224 const effective = resolveProjectSettings(settings, projectId).settings;
224 const next: Record<string, unknown> = { ...current };225 const next: Record<string, unknown> = { ...current };
225 for (const [key, value] of Object.entries(serverPatch)) {226 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 }
226 const base = effective[key as keyof ServerSettings];237 const base = effective[key as keyof ServerSettings];
227 next[key] =238 next[key] =
228 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;239 isPlainObject(value) && isPlainObject(base) ? { ...base, ...value } : value;
...@@ -233,7 +244,25 @@ export function planScopedSettingsPatch(...@@ -233,7 +244,25 @@ export function planScopedSettingsPatch(
233 ? connectedEnvironments.map((environment) => ({244 ? connectedEnvironments.map((environment) => ({
234 environmentId: environment.environmentId,245 environmentId: environment.environmentId,
235 label: environment.label,246 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,
237 }))266 }))
238 : [];267 : [];
239 const hasClientWrite = Object.keys(clientPatch).length > 0;268 const hasClientWrite = Object.keys(clientPatch).length > 0;
apps/web/src/components/settings/settingsSearch.ts+19
...@@ -20,6 +20,7 @@ export type SettingsPath =...@@ -20,6 +20,7 @@ export type SettingsPath =
20 | "/settings/providers"20 | "/settings/providers"
21 | "/settings/integrations"21 | "/settings/integrations"
22 | "/settings/source-control"22 | "/settings/source-control"
23 | "/settings/storage"
23 | "/settings/connections"24 | "/settings/connections"
24 | "/settings/archived";25 | "/settings/archived";
2526
...@@ -88,6 +89,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {...@@ -88,6 +89,7 @@ export const SETTINGS_SECTION_LABELS: Readonly<Record<SettingsPath, string>> = {
88 "/settings/providers": "Providers",89 "/settings/providers": "Providers",
89 "/settings/integrations": "Integrations",90 "/settings/integrations": "Integrations",
90 "/settings/source-control": "Source Control",91 "/settings/source-control": "Source Control",
92 "/settings/storage": "Storage",
91 "/settings/connections": "Connections",93 "/settings/connections": "Connections",
92 "/settings/archived": "Archive",94 "/settings/archived": "Archive",
93};95};
...@@ -125,6 +127,22 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right...@@ -125,6 +127,22 @@ const KEYBINDING_SEARCH_ITEMS = STATIC_KEYBINDING_COMMANDS.toSorted((left, right
125 * that may not be mounted point at their nearest stable section instead.127 * that may not be mounted point at their nearest stable section instead.
126 */128 */
127export const SETTINGS_SEARCH_ITEMS = [129export 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 },
128 {146 {
129 id: "project-defaults",147 id: "project-defaults",
130 title: "Project defaults and overrides",148 title: "Project defaults and overrides",
...@@ -789,6 +807,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScop...@@ -789,6 +807,7 @@ const SETTINGS_CATEGORY_SCOPES: Readonly<Record<SettingsPath, SettingsSearchScop
789 "/settings/providers": null,807 "/settings/providers": null,
790 "/settings/integrations": null,808 "/settings/integrations": null,
791 "/settings/source-control": "environment-defaults",809 "/settings/source-control": "environment-defaults",
810 "/settings/storage": "project-defaults",
792 "/settings/connections": "connections",811 "/settings/connections": "connections",
793 "/settings/archived": "project-defaults",812 "/settings/archived": "project-defaults",
794};813};
apps/web/src/hooks/useThreadActions.ts+10-1
...@@ -7,6 +7,7 @@ import {...@@ -7,6 +7,7 @@ import {
7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";7import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime";
8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";8import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled";
9import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";9import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts";
10import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings";
10import * as Cause from "effect/Cause";11import * as Cause from "effect/Cause";
11import * as Schema from "effect/Schema";12import * as Schema from "effect/Schema";
12import { AsyncResult } from "effect/unstable/reactivity";13import { AsyncResult } from "effect/unstable/reactivity";
...@@ -16,6 +17,8 @@ import { useCallback, useMemo, useRef } from "react";...@@ -16,6 +17,8 @@ import { useCallback, useMemo, useRef } from "react";
16import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";17import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic";
17import { useComposerDraftStore } from "../composerDraftStore";18import { useComposerDraftStore } from "../composerDraftStore";
18import { terminalEnvironment } from "../state/terminal";19import { terminalEnvironment } from "../state/terminal";
20import { appAtomRegistry } from "../rpc/atomRegistry";
21import { environmentServerConfigsAtom } from "../state/server";
19import { threadEnvironment } from "../state/threads";22import { threadEnvironment } from "../state/threads";
20import { vcsEnvironment } from "../state/vcs";23import { vcsEnvironment } from "../state/vcs";
21import { useNewThreadHandler } from "./useHandleNewThread";24import { useNewThreadHandler } from "./useHandleNewThread";
...@@ -356,7 +359,13 @@ export function useThreadActions() {...@@ -356,7 +359,13 @@ export function useThreadActions() {
356 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null;359 const canDeleteWorktree = orphanedWorktreePath !== null && threadProject !== null;
357 const localApi = readLocalApi();360 const localApi = readLocalApi();
358 let shouldDeleteWorktree = false;361 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) {
360 const confirmationResult = await settlePromise(() =>369 const confirmationResult = await settlePromise(() =>
361 localApi.dialogs.confirm(370 localApi.dialogs.confirm(
362 [371 [
apps/web/src/routeTree.gen.ts+21
...@@ -16,6 +16,7 @@ import { Route as PairRouteImport } from './routes/pair'...@@ -16,6 +16,7 @@ import { Route as PairRouteImport } from './routes/pair'
16import { Route as ConnectRouteImport } from './routes/connect'16import { Route as ConnectRouteImport } from './routes/connect'
17import { Route as ChatRouteImport } from './routes/_chat'17import { Route as ChatRouteImport } from './routes/_chat'
18import { Route as ChatIndexRouteImport } from './routes/_chat.index'18import { Route as ChatIndexRouteImport } from './routes/_chat.index'
19import { Route as SettingsStorageRouteImport } from './routes/settings.storage'
19import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'20import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control'
20import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot'21import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot'
21import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'22import { Route as SettingsProvidersRouteImport } from './routes/settings.providers'
...@@ -67,6 +68,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({...@@ -67,6 +68,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({
67 path: '/',68 path: '/',
68 getParentRoute: () => ChatRoute,69 getParentRoute: () => ChatRoute,
69} as any)70} as any)
71const SettingsStorageRoute = SettingsStorageRouteImport.update({
72 id: '/storage',
73 path: '/storage',
74 getParentRoute: () => SettingsRoute,
75} as any)
70const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({76const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({
71 id: '/source-control',77 id: '/source-control',
72 path: '/source-control',78 path: '/source-control',
...@@ -171,6 +177,7 @@ export interface FileRoutesByFullPath {...@@ -171,6 +177,7 @@ export interface FileRoutesByFullPath {
171 '/settings/providers': typeof SettingsProvidersRoute177 '/settings/providers': typeof SettingsProvidersRoute
172 '/settings/snap-shot': typeof SettingsSnapShotRoute178 '/settings/snap-shot': typeof SettingsSnapShotRoute
173 '/settings/source-control': typeof SettingsSourceControlRoute179 '/settings/source-control': typeof SettingsSourceControlRoute
180 '/settings/storage': typeof SettingsStorageRoute
174 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute181 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
175 '/draft/$draftId': typeof ChatDraftDraftIdRoute182 '/draft/$draftId': typeof ChatDraftDraftIdRoute
176}183}
...@@ -194,6 +201,7 @@ export interface FileRoutesByTo {...@@ -194,6 +201,7 @@ export interface FileRoutesByTo {
194 '/settings/providers': typeof SettingsProvidersRoute201 '/settings/providers': typeof SettingsProvidersRoute
195 '/settings/snap-shot': typeof SettingsSnapShotRoute202 '/settings/snap-shot': typeof SettingsSnapShotRoute
196 '/settings/source-control': typeof SettingsSourceControlRoute203 '/settings/source-control': typeof SettingsSourceControlRoute
204 '/settings/storage': typeof SettingsStorageRoute
197 '/': typeof ChatIndexRoute205 '/': typeof ChatIndexRoute
198 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute206 '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
199 '/draft/$draftId': typeof ChatDraftDraftIdRoute207 '/draft/$draftId': typeof ChatDraftDraftIdRoute
...@@ -220,6 +228,7 @@ export interface FileRoutesById {...@@ -220,6 +228,7 @@ export interface FileRoutesById {
220 '/settings/providers': typeof SettingsProvidersRoute228 '/settings/providers': typeof SettingsProvidersRoute
221 '/settings/snap-shot': typeof SettingsSnapShotRoute229 '/settings/snap-shot': typeof SettingsSnapShotRoute
222 '/settings/source-control': typeof SettingsSourceControlRoute230 '/settings/source-control': typeof SettingsSourceControlRoute
231 '/settings/storage': typeof SettingsStorageRoute
223 '/_chat/': typeof ChatIndexRoute232 '/_chat/': typeof ChatIndexRoute
224 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute233 '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute
225 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute234 '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute
...@@ -247,6 +256,7 @@ export interface FileRouteTypes {...@@ -247,6 +256,7 @@ export interface FileRouteTypes {
247 | '/settings/providers'256 | '/settings/providers'
248 | '/settings/snap-shot'257 | '/settings/snap-shot'
249 | '/settings/source-control'258 | '/settings/source-control'
259 | '/settings/storage'
250 | '/$environmentId/$threadId'260 | '/$environmentId/$threadId'
251 | '/draft/$draftId'261 | '/draft/$draftId'
252 fileRoutesByTo: FileRoutesByTo262 fileRoutesByTo: FileRoutesByTo
...@@ -270,6 +280,7 @@ export interface FileRouteTypes {...@@ -270,6 +280,7 @@ export interface FileRouteTypes {
270 | '/settings/providers'280 | '/settings/providers'
271 | '/settings/snap-shot'281 | '/settings/snap-shot'
272 | '/settings/source-control'282 | '/settings/source-control'
283 | '/settings/storage'
273 | '/'284 | '/'
274 | '/$environmentId/$threadId'285 | '/$environmentId/$threadId'
275 | '/draft/$draftId'286 | '/draft/$draftId'
...@@ -295,6 +306,7 @@ export interface FileRouteTypes {...@@ -295,6 +306,7 @@ export interface FileRouteTypes {
295 | '/settings/providers'306 | '/settings/providers'
296 | '/settings/snap-shot'307 | '/settings/snap-shot'
297 | '/settings/source-control'308 | '/settings/source-control'
309 | '/settings/storage'
298 | '/_chat/'310 | '/_chat/'
299 | '/_chat/$environmentId/$threadId'311 | '/_chat/$environmentId/$threadId'
300 | '/_chat/draft/$draftId'312 | '/_chat/draft/$draftId'
...@@ -361,6 +373,13 @@ declare module '@tanstack/react-router' {...@@ -361,6 +373,13 @@ declare module '@tanstack/react-router' {
361 preLoaderRoute: typeof ChatIndexRouteImport373 preLoaderRoute: typeof ChatIndexRouteImport
362 parentRoute: typeof ChatRoute374 parentRoute: typeof ChatRoute
363 }375 }
376 '/settings/storage': {
377 id: '/settings/storage'
378 path: '/storage'
379 fullPath: '/settings/storage'
380 preLoaderRoute: typeof SettingsStorageRouteImport
381 parentRoute: typeof SettingsRoute
382 }
364 '/settings/source-control': {383 '/settings/source-control': {
365 id: '/settings/source-control'384 id: '/settings/source-control'
366 path: '/source-control'385 path: '/source-control'
...@@ -505,6 +524,7 @@ interface SettingsRouteChildren {...@@ -505,6 +524,7 @@ interface SettingsRouteChildren {
505 SettingsProvidersRoute: typeof SettingsProvidersRoute524 SettingsProvidersRoute: typeof SettingsProvidersRoute
506 SettingsSnapShotRoute: typeof SettingsSnapShotRoute525 SettingsSnapShotRoute: typeof SettingsSnapShotRoute
507 SettingsSourceControlRoute: typeof SettingsSourceControlRoute526 SettingsSourceControlRoute: typeof SettingsSourceControlRoute
527 SettingsStorageRoute: typeof SettingsStorageRoute
508}528}
509529
510const SettingsRouteChildren: SettingsRouteChildren = {530const SettingsRouteChildren: SettingsRouteChildren = {
...@@ -520,6 +540,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {...@@ -520,6 +540,7 @@ const SettingsRouteChildren: SettingsRouteChildren = {
520 SettingsProvidersRoute: SettingsProvidersRoute,540 SettingsProvidersRoute: SettingsProvidersRoute,
521 SettingsSnapShotRoute: SettingsSnapShotRoute,541 SettingsSnapShotRoute: SettingsSnapShotRoute,
522 SettingsSourceControlRoute: SettingsSourceControlRoute,542 SettingsSourceControlRoute: SettingsSourceControlRoute,
543 SettingsStorageRoute: SettingsStorageRoute,
523}544}
524545
525const SettingsRouteWithChildren = SettingsRoute._addFileChildren(546const 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...@@ -41,6 +41,32 @@ on each selected environment, and reset returns to the environment's shared list
41For workspace mode, a project's `t3.json` preference applies when the project has no override.41For workspace mode, a project's `t3.json` preference applies when the project has no override.
42Browser access changes apply when an agent session next starts.42Browser 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
44## Project icons70## Project icons
4571
46Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to72Select 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({...@@ -105,6 +105,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({
105 threadSettlement: Schema.optionalKey(Schema.Boolean),105 threadSettlement: Schema.optionalKey(Schema.Boolean),
106 /** Server evaluates merge and inactivity settlement without a client. */106 /** Server evaluates merge and inactivity settlement without a client. */
107 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),107 threadAutoSettlement: Schema.optionalKey(Schema.Boolean),
108 storageCleanup: Schema.optionalKey(Schema.Boolean),
109 projectWorktreeCleanup: Schema.optionalKey(Schema.Boolean),
108 /** Server persists the opt-in for continuing interrupted threads after restarts. */110 /** Server persists the opt-in for continuing interrupted threads after restarts. */
109 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),111 threadRestartContinuation: Schema.optionalKey(Schema.Boolean),
110 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */112 /** Server resolves `projectSettingsOverrides`; older servers ignore the key. */
packages/contracts/src/settings.test.ts+44
...@@ -20,6 +20,50 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);...@@ -20,6 +20,50 @@ const decodeServerSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch);
20const encodeServerSettings = Schema.encodeSync(ServerSettings);20const encodeServerSettings = Schema.encodeSync(ServerSettings);
21const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings);21const 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
23describe("ClientSettings rich text composer", () => {67describe("ClientSettings rich text composer", () => {
24 it("enables rich text for new and existing settings without a saved preference", () => {68 it("enables rich text for new and existing settings without a saved preference", () => {
25 expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true);69 expect(decodeClientSettings({}).composerRichTextEnabled).toBe(true);
packages/contracts/src/settings.ts+66
...@@ -967,7 +967,28 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;...@@ -967,7 +967,28 @@ export type BackgroundActivitySettings = typeof BackgroundActivitySettings.Type;
967export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]);967export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]);
968export type ResponseStreamingMode = typeof ResponseStreamingMode.Type;968export 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
970export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [990export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [
991 "worktreeCleanup",
971 "defaultModelSelection",992 "defaultModelSelection",
972 "defaultRuntimeMode",993 "defaultRuntimeMode",
973 "defaultThreadEnvMode",994 "defaultThreadEnvMode",
...@@ -993,6 +1014,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN...@@ -993,6 +1014,7 @@ export type ProjectScopedServerSettingKey = (typeof PROJECT_SCOPED_SERVER_SETTIN
993 * model, no dedicated writer model, never auto-settle).1014 * model, no dedicated writer model, never auto-settle).
994 */1015 */
995export const ProjectSettingsOverrides = Schema.Struct({1016export const ProjectSettingsOverrides = Schema.Struct({
1017 worktreeCleanup: Schema.optionalKey(WorktreeCleanup),
996 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),1018 defaultModelSelection: Schema.optionalKey(Schema.NullOr(ModelSelection)),
997 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),1019 defaultRuntimeMode: Schema.optionalKey(RuntimeMode),
998 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),1020 defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode),
...@@ -1012,7 +1034,25 @@ export const ProjectSettingsOverrides = Schema.Struct({...@@ -1012,7 +1034,25 @@ export const ProjectSettingsOverrides = Schema.Struct({
1012} satisfies Record<ProjectScopedServerSettingKey, unknown>);1034} satisfies Record<ProjectScopedServerSettingKey, unknown>);
1013export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type;1035export 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
1015export const ServerSettings = Schema.Struct({1049export 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 ),
1016 // How assistant text reaches clients during a turn. Deliberately a fresh1056 // How assistant text reaches clients during a turn. Deliberately a fresh
1017 // key (was `enableLegacyTokenStreaming`, before that1057 // key (was `enableLegacyTokenStreaming`, before that
1018 // `enableAssistantStreaming`): decoding drops the old key, so everyone,1058 // `enableAssistantStreaming`): decoding drops the old key, so everyone,
...@@ -1352,6 +1392,32 @@ const OpenCodeSettingsPatch = Schema.Struct({...@@ -1352,6 +1392,32 @@ const OpenCodeSettingsPatch = Schema.Struct({
1352});1392});
13531393
1354export const ServerSettingsPatch = Schema.Struct({1394export 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 ),
1355 // Server settings1421 // Server settings
1356 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),1422 responseStreamingMode: Schema.optionalKey(ResponseStreamingMode),
1357 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),1423 enableProviderUpdateChecks: Schema.optionalKey(Schema.Boolean),
packages/shared/src/projectSettings.test.ts+58
...@@ -10,6 +10,7 @@ import {...@@ -10,6 +10,7 @@ import {
10 clearProjectSettingsOverrides,10 clearProjectSettingsOverrides,
11 hasProjectSettingsOverrides,11 hasProjectSettingsOverrides,
12 resolveProjectSettings,12 resolveProjectSettings,
13 resolveWorktreeCleanup,
13 withProjectSettingsOverrides,14 withProjectSettingsOverrides,
14} from "./projectSettings.ts";15} from "./projectSettings.ts";
15import { applyServerSettingsPatch } from "./serverSettings.ts";16import { applyServerSettingsPatch } from "./serverSettings.ts";
...@@ -214,3 +215,60 @@ describe("projectSettingsOverrides patches", () => {...@@ -214,3 +215,60 @@ describe("projectSettingsOverrides patches", () => {
214 });215 });
215 });216 });
216});217});
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 {...@@ -6,6 +6,7 @@ import {
6 type ProjectSettingsOverrides,6 type ProjectSettingsOverrides,
7 type ServerSettings,7 type ServerSettings,
8 type ThreadEnvMode,8 type ThreadEnvMode,
9 type WorktreeCleanupRules,
9} from "@t3tools/contracts";10} from "@t3tools/contracts";
10import { isModelSelectionProviderEnabled } from "./serverSettings.ts";11import { isModelSelectionProviderEnabled } from "./serverSettings.ts";
1112
...@@ -124,3 +125,22 @@ export function clearProjectSettingsOverrides(...@@ -124,3 +125,22 @@ export function clearProjectSettingsOverrides(
124 for (const key of keys) delete next[key];125 for (const key of keys) delete next[key];
125 return Object.keys(next).length === 0 ? null : next;126 return Object.keys(next).length === 0 ? null : next;
126}127}
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 {...@@ -24,6 +24,23 @@ import {
24const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };24const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true };
2525
26describe("serverSettings helpers", () => {26describe("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 });
27 it("replaces SSH host lists when saving, editing, and removing hosts", () => {44 it("replaces SSH host lists when saving, editing, and removing hosts", () => {
28 const host = { id: "mini", label: "Mac mini", target: "mini" };45 const host = { id: "mini", label: "Mac mini", target: "mini" };
29 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });46 const saved = applyServerSettingsPatch(DEFAULT_SERVER_SETTINGS, { deviceHosts: [host] });
packages/shared/src/serverSettings.ts+21
...@@ -270,6 +270,7 @@ export function applyServerSettingsPatch(...@@ -270,6 +270,7 @@ export function applyServerSettingsPatch(
270 providerHealthRefreshInterval,270 providerHealthRefreshInterval,
271 backgroundActivityProfile,271 backgroundActivityProfile,
272 backgroundActivity,272 backgroundActivity,
273 worktreeCleanup: worktreeCleanupPatch,
273 // Merged per entry below; its `null` removals must not reach deepMerge.274 // Merged per entry below; its `null` removals must not reach deepMerge.
274 usageLimitSources: usageLimitSourcesPatch,275 usageLimitSources: usageLimitSourcesPatch,
275 usagePriceOverrides: usagePriceOverridesPatch,276 usagePriceOverrides: usagePriceOverridesPatch,
...@@ -320,6 +321,26 @@ export function applyServerSettingsPatch(...@@ -320,6 +321,26 @@ export function applyServerSettingsPatch(
320 const next = deepMerge(current, patchForMerge);321 const next = deepMerge(current, patchForMerge);
321 const nextWithReplacementsBase = {322 const nextWithReplacementsBase = {
322 ...next,323 ...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 }),
323 ...(backgroundActivity !== undefined344 ...(backgroundActivity !== undefined
324 ? {345 ? {
325 backgroundActivity: {346 backgroundActivity: {