| author | |
| committer | |
| log | c4ca1b0f94e56bd93899704f03015d51948a6b4d |
| tree | 2aea30e918e2f06dafda1ddc222f00f7b6d93426 |
| parent | 2a47fc905a7a1054823919980f625b7cd869f1c1 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 39 | import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts"; | 39 | import { makeProviderRegistryLayer } from "../src/provider/testUtils/providerRegistryMock.ts"; |
| 40 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; | 40 | import { ProviderSessionDirectoryLive } from "../src/provider/Layers/ProviderSessionDirectory.ts"; |
| 41 | import { ServerSettingsService } from "../src/serverSettings.ts"; | 41 | import { ServerSettingsService } from "../src/serverSettings.ts"; |
| 42 | import * as StorageCleanup from "../src/storageCleanup.ts"; | ||
| 42 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; | 43 | import { makeProviderServiceLive } from "../src/provider/Layers/ProviderService.ts"; |
| 43 | import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; | 44 | import { makeCodexAdapter } from "../src/provider/Layers/CodexAdapter.ts"; |
| 44 | import { | 45 | import { |
| ... | @@ -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"; |
| 15 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; | 15 | import { OrchestrationReactor } from "../Services/OrchestrationReactor.ts"; |
| 16 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; | 16 | import { makeOrchestrationReactor } from "./OrchestrationReactor.ts"; |
| 17 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | 17 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 18 | import { StorageCleanup } from "../../storageCleanup.ts"; | ||
| 18 | 19 | ||
| 19 | describe("OrchestrationReactor", () => { | 20 | describe("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", () => { |
| 31 | 32 | ||
| 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 | ]); |
| 124 | 135 | ||
| 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"; |
| 13 | import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; | 13 | import * as PullRequestSyncReactor from "../PullRequestSyncReactor.ts"; |
| 14 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; | 14 | import * as ThreadPullRequestReactor from "../ThreadPullRequestReactor.ts"; |
| 15 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; | 15 | import * as AgentAwarenessRelay from "../../relay/AgentAwarenessRelay.ts"; |
| 16 | import * as StorageCleanup from "../../storageCleanup.ts"; | ||
| 16 | 17 | ||
| 17 | export const makeOrchestrationReactor = Effect.gen(function* () { | 18 | export 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; | ||
| 26 | 28 | ||
| 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 | }); |
| 37 | 40 | ||
| 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 | ); |
| 1073 | 1107 |
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 | }); |
| 639 | 639 | ||
| 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 @@ |
| 1 | import { withWorkspaceLease } from "../../workspace/workspaceLease.ts"; | ||
| 1 | import { | 2 | import { |
| 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"; |
| 26 | import * as FileSystem from "effect/FileSystem"; | 27 | import * as FileSystem from "effect/FileSystem"; |
| 27 | import * as Layer from "effect/Layer"; | 28 | import * as Layer from "effect/Layer"; |
| 28 | import * as Option from "effect/Option"; | 29 | import * as Option from "effect/Option"; |
| 30 | import * as Path from "effect/Path"; | ||
| 29 | import * as Schedule from "effect/Schedule"; | 31 | import * as Schedule from "effect/Schedule"; |
| 30 | import * as Schema from "effect/Schema"; | 32 | import * as Schema from "effect/Schema"; |
| 31 | import * as Stream from "effect/Stream"; | 33 | import * 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 | ProjectionRepositoryError | 131 | ProjectionRepositoryError |
| 132 | >; | 132 | >; |
| 133 | 133 | ||
| 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 canonical | 148 | * 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 { |
| 17 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | 18 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; |
| 18 | import { assert, describe, it } from "@effect/vitest"; | 19 | import { assert, describe, it } from "@effect/vitest"; |
| 19 | import * as Crypto from "effect/Crypto"; | 20 | import * as Crypto from "effect/Crypto"; |
| 21 | import * as DateTime from "effect/DateTime"; | ||
| 20 | import * as Deferred from "effect/Deferred"; | 22 | import * as Deferred from "effect/Deferred"; |
| 21 | import * as Effect from "effect/Effect"; | 23 | import * as Effect from "effect/Effect"; |
| 24 | import * as Fiber from "effect/Fiber"; | ||
| 22 | import * as FileSystem from "effect/FileSystem"; | 25 | import * as FileSystem from "effect/FileSystem"; |
| 23 | import * as Layer from "effect/Layer"; | 26 | import * as Layer from "effect/Layer"; |
| 24 | import * as PubSub from "effect/PubSub"; | 27 | import * 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"; |
| 42 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; | 45 | import { ProjectionSnapshotQuery } from "./Services/ProjectionSnapshotQuery.ts"; |
| 43 | import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; | 46 | import * as ThreadSettlementReactor from "./ThreadSettlementReactor.ts"; |
| 47 | import * as NodeServices from "@effect/platform-node/NodeServices"; | ||
| 48 | import * as Path from "effect/Path"; | ||
| 49 | import { ChildProcessSpawner } from "effect/unstable/process"; | ||
| 50 | import { ServerConfig } from "../config.ts"; | ||
| 51 | import * as StorageCleanup from "../storageCleanup.ts"; | ||
| 52 | import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; | ||
| 53 | import { TerminalManager } from "../terminal/Manager.ts"; | ||
| 54 | import { GitVcsDriver } from "../vcs/GitVcsDriver.ts"; | ||
| 55 | import { ThreadDeletionReactor } from "./Services/ThreadDeletionReactor.ts"; | ||
| 56 | import { ProviderService } from "../provider/Services/ProviderService.ts"; | ||
| 44 | 57 | ||
| 45 | const NOW = "2026-08-28T12:00:00.000Z"; | 58 | const NOW = "2026-08-28T12:00:00.000Z"; |
| 46 | const PROJECT_ID = ProjectId.make("settlement-project"); | 59 | const 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 | |||
| 1380 | describe("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 |
| 84 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; | 84 | import { CheckpointReactorLive } from "./orchestration/Layers/CheckpointReactor.ts"; |
| 85 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; | 85 | import { ThreadDeletionReactorLive } from "./orchestration/Layers/ThreadDeletionReactor.ts"; |
| 86 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; | 86 | import * as ThreadSettlementReactor from "./orchestration/ThreadSettlementReactor.ts"; |
| 87 | import * as StorageCleanup from "./storageCleanup.ts"; | ||
| 87 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; | 88 | import * as PullRequestSyncReactor from "./orchestration/PullRequestSyncReactor.ts"; |
| 88 | import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; | 89 | import * as ThreadPullRequestReactor from "./orchestration/ThreadPullRequestReactor.ts"; |
| 89 | import * as AgentAwarenessRelay from "./relay/AgentAwarenessRelay.ts"; | 90 | import * 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 @@ | ||
| 1 | import type { | ||
| 2 | OrchestrationThreadShell, | ||
| 3 | ProjectId, | ||
| 4 | ServerSettings, | ||
| 5 | ServerSettingsError, | ||
| 6 | TerminalSummary, | ||
| 7 | WorktreeCleanupRules, | ||
| 8 | } from "@t3tools/contracts"; | ||
| 9 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | ||
| 10 | import { makeDrainableWorker } from "@t3tools/shared/DrainableWorker"; | ||
| 11 | import * as Cause from "effect/Cause"; | ||
| 12 | import * as Clock from "effect/Clock"; | ||
| 13 | import * as Context from "effect/Context"; | ||
| 14 | import * as DateTime from "effect/DateTime"; | ||
| 15 | import * as Effect from "effect/Effect"; | ||
| 16 | import * as Equal from "effect/Equal"; | ||
| 17 | import * as FileSystem from "effect/FileSystem"; | ||
| 18 | import * as Layer from "effect/Layer"; | ||
| 19 | import * as Option from "effect/Option"; | ||
| 20 | import * as Path from "effect/Path"; | ||
| 21 | import type { PlatformError } from "effect/PlatformError"; | ||
| 22 | import * as Schedule from "effect/Schedule"; | ||
| 23 | import type * as Scope from "effect/Scope"; | ||
| 24 | import * as Stream from "effect/Stream"; | ||
| 25 | |||
| 26 | import * as ServerConfig from "./config.ts"; | ||
| 27 | import * as GitManager from "./git/GitManager.ts"; | ||
| 28 | import * as ProjectionSnapshotQuery from "./orchestration/Services/ProjectionSnapshotQuery.ts"; | ||
| 29 | import * as OrchestrationEngine from "./orchestration/Services/OrchestrationEngine.ts"; | ||
| 30 | import * as ThreadDeletionReactor from "./orchestration/Services/ThreadDeletionReactor.ts"; | ||
| 31 | import * as ProviderService from "./provider/Services/ProviderService.ts"; | ||
| 32 | import { threadHasQueuedTurnStart } from "./orchestration/ThreadSettlementPolicy.ts"; | ||
| 33 | import { forkParked } from "./serverActivation.ts"; | ||
| 34 | import * as Settings from "./serverSettings.ts"; | ||
| 35 | import * as TerminalManager from "./terminal/Manager.ts"; | ||
| 36 | import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; | ||
| 37 | import { withWorkspaceLease } from "./workspace/workspaceLease.ts"; | ||
| 38 | |||
| 39 | export 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 | |||
| 47 | const DAY_MS = 86_400_000; | ||
| 48 | |||
| 49 | const worktreeCleanupEnabled = (rules: WorktreeCleanupRules) => | ||
| 50 | rules.worktreeAfterDays !== null || | ||
| 51 | rules.worktreeOnMerge || | ||
| 52 | rules.worktreeOnDelete || | ||
| 53 | rules.worktreeUnchanged; | ||
| 54 | |||
| 55 | function 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 | |||
| 67 | function 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. */ | ||
| 82 | function 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. */ | ||
| 96 | function 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 | |||
| 108 | export 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 | |||
| 482 | export 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 TerminalManager | 7 | * @module TerminalManager |
| 8 | */ | 8 | */ |
| 9 | import { withWorkspaceLease } from "../workspace/workspaceLease.ts"; | ||
| 9 | import { | 10 | import { |
| 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 | ); |
| 2518 | 2519 | ||
| 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); |
| 2522 | 2525 | ||
| ... | @@ -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 | }); |
| 2641 | 2644 | ||
| 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 | ); |
| 3015 | 3031 | ||
| 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 @@ | ||
| 1 | import * as Effect from "effect/Effect"; | ||
| 2 | import * as Semaphore from "effect/Semaphore"; | ||
| 3 | |||
| 4 | const leases = new Map<string, { semaphore: Semaphore.Semaphore; users: number }>(); | ||
| 5 | |||
| 6 | /** Coordinates checkout removal and startup across threads using the same resolved cwd. */ | ||
| 7 | export 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 @@ | ||
| 1 | import type { StorageCleanupSettings, WorktreeCleanupRules } from "@t3tools/contracts"; | ||
| 2 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | ||
| 3 | import { useState } from "react"; | ||
| 4 | |||
| 5 | import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; | ||
| 6 | import { Switch } from "../ui/switch"; | ||
| 7 | import { | ||
| 8 | NumberField, | ||
| 9 | NumberFieldDecrement, | ||
| 10 | NumberFieldGroup, | ||
| 11 | NumberFieldIncrement, | ||
| 12 | NumberFieldInput, | ||
| 13 | } from "../ui/number-field"; | ||
| 14 | import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; | ||
| 15 | import { SettingsScopeNotice } from "./SettingsScopeNotice"; | ||
| 16 | import type { ScopedSettingsTarget } from "./scopedSettings"; | ||
| 17 | import { useSettingsScope } from "./SettingsScopeContext"; | ||
| 18 | import { | ||
| 19 | useClearScopedSettings, | ||
| 20 | useScopedSettings, | ||
| 21 | useUpdateScopedSettings, | ||
| 22 | } from "./useScopedSettings"; | ||
| 23 | |||
| 24 | function 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 | |||
| 85 | export 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"; |
| 7 | import { describe, expect, it, vi } from "vite-plus/test"; | 7 | import { describe, expect, it, vi } from "vite-plus/test"; |
| 8 | import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; | ||
| 9 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | ||
| 8 | 10 | ||
| 9 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; | 11 | import type { SidebarProjectSnapshot } from "../../sidebarProjectGrouping"; |
| 10 | import { | 12 | import { |
| ... | @@ -146,6 +148,48 @@ describe("scoped settings targets", () => { | ... | @@ -146,6 +148,48 @@ describe("scoped settings targets", () => { |
| 146 | }); | 148 | }); |
| 147 | 149 | ||
| 148 | describe("scoped settings writes", () => { | 150 | describe("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 | }); |
| 233 | 277 | ||
| 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 |
| 13 | import { | 13 | import { |
| 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"; |
| 18 | import * as Equal from "effect/Equal"; | 19 | import * 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"; |
| 25 | 26 | ||
| ... | @@ -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 | */ |
| 127 | export const SETTINGS_SEARCH_ITEMS = [ | 129 | export 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 { |
| 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; | 7 | import { settlePromise, squashAtomCommandFailure } from "@t3tools/client-runtime/state/runtime"; |
| 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; | 8 | import { canSnooze, threadWokeAt } from "@t3tools/client-runtime/state/thread-settled"; |
| 9 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; | 9 | import { EnvironmentId, type ScopedThreadRef, ThreadId } from "@t3tools/contracts"; |
| 10 | import { resolveWorktreeCleanup } from "@t3tools/shared/projectSettings"; | ||
| 10 | import * as Cause from "effect/Cause"; | 11 | import * as Cause from "effect/Cause"; |
| 11 | import * as Schema from "effect/Schema"; | 12 | import * as Schema from "effect/Schema"; |
| 12 | import { AsyncResult } from "effect/unstable/reactivity"; | 13 | import { 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"; |
| 16 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; | 17 | import { getFallbackThreadIdAfterDelete, pinOrderKeyBetween } from "../components/Sidebar.logic"; |
| 17 | import { useComposerDraftStore } from "../composerDraftStore"; | 18 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 18 | import { terminalEnvironment } from "../state/terminal"; | 19 | import { terminalEnvironment } from "../state/terminal"; |
| 20 | import { appAtomRegistry } from "../rpc/atomRegistry"; | ||
| 21 | import { environmentServerConfigsAtom } from "../state/server"; | ||
| 19 | import { threadEnvironment } from "../state/threads"; | 22 | import { threadEnvironment } from "../state/threads"; |
| 20 | import { vcsEnvironment } from "../state/vcs"; | 23 | import { vcsEnvironment } from "../state/vcs"; |
| 21 | import { useNewThreadHandler } from "./useHandleNewThread"; | 24 | import { 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' |
| 16 | import { Route as ConnectRouteImport } from './routes/connect' | 16 | import { Route as ConnectRouteImport } from './routes/connect' |
| 17 | import { Route as ChatRouteImport } from './routes/_chat' | 17 | import { Route as ChatRouteImport } from './routes/_chat' |
| 18 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' | 18 | import { Route as ChatIndexRouteImport } from './routes/_chat.index' |
| 19 | import { Route as SettingsStorageRouteImport } from './routes/settings.storage' | ||
| 19 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' | 20 | import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' |
| 20 | import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' | 21 | import { Route as SettingsSnapShotRouteImport } from './routes/settings.snap-shot' |
| 21 | import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' | 22 | import { 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) |
| 71 | const SettingsStorageRoute = SettingsStorageRouteImport.update({ | ||
| 72 | id: '/storage', | ||
| 73 | path: '/storage', | ||
| 74 | getParentRoute: () => SettingsRoute, | ||
| 75 | } as any) | ||
| 70 | const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ | 76 | const 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 SettingsProvidersRoute | 177 | '/settings/providers': typeof SettingsProvidersRoute |
| 172 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 178 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| 173 | '/settings/source-control': typeof SettingsSourceControlRoute | 179 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 180 | '/settings/storage': typeof SettingsStorageRoute | ||
| 174 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 181 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 175 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 182 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| 176 | } | 183 | } |
| ... | @@ -194,6 +201,7 @@ export interface FileRoutesByTo { | ... | @@ -194,6 +201,7 @@ export interface FileRoutesByTo { |
| 194 | '/settings/providers': typeof SettingsProvidersRoute | 201 | '/settings/providers': typeof SettingsProvidersRoute |
| 195 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 202 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| 196 | '/settings/source-control': typeof SettingsSourceControlRoute | 203 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 204 | '/settings/storage': typeof SettingsStorageRoute | ||
| 197 | '/': typeof ChatIndexRoute | 205 | '/': typeof ChatIndexRoute |
| 198 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 206 | '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 199 | '/draft/$draftId': typeof ChatDraftDraftIdRoute | 207 | '/draft/$draftId': typeof ChatDraftDraftIdRoute |
| ... | @@ -220,6 +228,7 @@ export interface FileRoutesById { | ... | @@ -220,6 +228,7 @@ export interface FileRoutesById { |
| 220 | '/settings/providers': typeof SettingsProvidersRoute | 228 | '/settings/providers': typeof SettingsProvidersRoute |
| 221 | '/settings/snap-shot': typeof SettingsSnapShotRoute | 229 | '/settings/snap-shot': typeof SettingsSnapShotRoute |
| 222 | '/settings/source-control': typeof SettingsSourceControlRoute | 230 | '/settings/source-control': typeof SettingsSourceControlRoute |
| 231 | '/settings/storage': typeof SettingsStorageRoute | ||
| 223 | '/_chat/': typeof ChatIndexRoute | 232 | '/_chat/': typeof ChatIndexRoute |
| 224 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute | 233 | '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute |
| 225 | '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute | 234 | '/_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: FileRoutesByTo | 262 | 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 ChatIndexRouteImport | 373 | preLoaderRoute: typeof ChatIndexRouteImport |
| 362 | parentRoute: typeof ChatRoute | 374 | 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 SettingsProvidersRoute | 524 | SettingsProvidersRoute: typeof SettingsProvidersRoute |
| 506 | SettingsSnapShotRoute: typeof SettingsSnapShotRoute | 525 | SettingsSnapShotRoute: typeof SettingsSnapShotRoute |
| 507 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute | 526 | SettingsSourceControlRoute: typeof SettingsSourceControlRoute |
| 527 | SettingsStorageRoute: typeof SettingsStorageRoute | ||
| 508 | } | 528 | } |
| 509 | 529 | ||
| 510 | const SettingsRouteChildren: SettingsRouteChildren = { | 530 | const 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 | } |
| 524 | 545 | ||
| 525 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( | 546 | const SettingsRouteWithChildren = SettingsRoute._addFileChildren( |
apps/web/src/routes/settings.storage.tsx created+4| ... | @@ -0,0 +1,4 @@ | ||
| 1 | import { createFileRoute } from "@tanstack/react-router"; | ||
| 2 | import { StorageSettingsPanel } from "../components/settings/StorageSettings"; | ||
| 3 | |||
| 4 | export 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 |
| 41 | For workspace mode, a project's `t3.json` preference applies when the project has no override. | 41 | For workspace mode, a project's `t3.json` preference applies when the project has no override. |
| 42 | Browser access changes apply when an agent session next starts. | 42 | Browser access changes apply when an agent session next starts. |
| 43 | 43 | ||
| 44 | ## Storage cleanup | ||
| 45 | |||
| 46 | Open **Settings → Storage** to enable automatic cleanup on one machine or all connected | ||
| 47 | environments. Policies are off by default and run on the server at startup, when changed, and | ||
| 48 | hourly. Offline machines keep their existing policies. | ||
| 49 | |||
| 50 | Select a project to set **Automatic worktree cleanup** to **Inherit**, **Off**, or **Custom**. | ||
| 51 | Inherit follows each machine's rules; Off keeps that project's worktrees until you remove them | ||
| 52 | manually. Custom applies separate worktree rules to the selected project or checkout. Browser | ||
| 53 | captures and log retention remain machine-wide. | ||
| 54 | |||
| 55 | Worktrees can be removed after a chosen number of inactive days, after merging, or when they | ||
| 56 | have no commits beyond the default branch. Only T3-managed worktrees are eligible. Active | ||
| 57 | sessions, shared worktrees, uncommitted changes, and ignored files other than `node_modules` | ||
| 58 | prevent removal. Branches and thread history stay; starting another turn recreates the checkout. | ||
| 59 | Merge cleanup requires the commits to be included in the remote default branch, so squash merges | ||
| 60 | may need the inactivity rule instead. | ||
| 61 | |||
| 62 | Enable **Delete worktrees with deleted threads** to remove safe worktrees after their last | ||
| 63 | thread is deleted, including archived threads and worktrees left by earlier deletions. The | ||
| 64 | server waits for sessions and terminals to stop and retries skipped worktrees after restart. | ||
| 65 | Existing prompts for deleting a worktree manually remain available when this policy is off. | ||
| 66 | |||
| 67 | Browser captures and rotated logs have separate retention periods. Expired capture links stop | ||
| 68 | working. Current logs, message attachments, and browser profiles are kept. | ||
| 69 | |||
| 44 | ## Project icons | 70 | ## Project icons |
| 45 | 71 | ||
| 46 | Select the project and open Project to choose an icon, emoji, monogram, or image. The choice applies to | 72 | Select 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); |
| 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); | 20 | const encodeServerSettings = Schema.encodeSync(ServerSettings); |
| 21 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); | 21 | const decodeClaudeSettings = Schema.decodeUnknownSync(ClaudeSettings); |
| 22 | 22 | ||
| 23 | describe("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 | |||
| 23 | describe("ClientSettings rich text composer", () => { | 67 | describe("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; |
| 967 | export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]); | 967 | export const ResponseStreamingMode = Schema.Literals(["turn", "paragraph", "token"]); |
| 968 | export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; | 968 | export type ResponseStreamingMode = typeof ResponseStreamingMode.Type; |
| 969 | 969 | ||
| 970 | const StorageRetentionDays = Schema.NullOr( | ||
| 971 | Schema.Int.check(Schema.isBetween({ minimum: 1, maximum: 3650 })), | ||
| 972 | ); | ||
| 973 | |||
| 974 | export const WorktreeCleanupRules = Schema.Struct({ | ||
| 975 | worktreeAfterDays: StorageRetentionDays, | ||
| 976 | worktreeOnMerge: Schema.Boolean, | ||
| 977 | worktreeOnDelete: Schema.Boolean, | ||
| 978 | worktreeUnchanged: Schema.Boolean, | ||
| 979 | }); | ||
| 980 | export type WorktreeCleanupRules = typeof WorktreeCleanupRules.Type; | ||
| 981 | |||
| 982 | export 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 | ); | ||
| 988 | export type WorktreeCleanup = typeof WorktreeCleanup.Type; | ||
| 989 | |||
| 970 | export const PROJECT_SCOPED_SERVER_SETTING_KEYS = [ | 990 | export 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 | */ |
| 995 | export const ProjectSettingsOverrides = Schema.Struct({ | 1016 | export 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>); |
| 1013 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; | 1035 | export type ProjectSettingsOverrides = typeof ProjectSettingsOverrides.Type; |
| 1014 | 1036 | ||
| 1037 | export 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 | }); | ||
| 1047 | export type StorageCleanupSettings = typeof StorageCleanupSettings.Type; | ||
| 1048 | |||
| 1015 | export const ServerSettings = Schema.Struct({ | 1049 | export 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 fresh | 1056 | // How assistant text reaches clients during a turn. Deliberately a fresh |
| 1017 | // key (was `enableLegacyTokenStreaming`, before that | 1057 | // 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 | }); |
| 1353 | 1393 | ||
| 1354 | export const ServerSettingsPatch = Schema.Struct({ | 1394 | export 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 settings | 1421 | // 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"; |
| 15 | import { applyServerSettingsPatch } from "./serverSettings.ts"; | 16 | import { 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 | |||
| 219 | describe("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"; |
| 10 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; | 11 | import { isModelSelectionProviderEnabled } from "./serverSettings.ts"; |
| 11 | 12 | ||
| ... | @@ -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. */ | ||
| 130 | export 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 { |
| 24 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; | 24 | const FOLDED_SERVER_SETTINGS = { ...DEFAULT_SERVER_SETTINGS, projectSettingsFolded: true }; |
| 25 | 25 | ||
| 26 | describe("serverSettings helpers", () => { | 26 | describe("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 !== undefined | 344 | ...(backgroundActivity !== undefined |
| 324 | ? { | 345 | ? { |
| 325 | backgroundActivity: { | 346 | backgroundActivity: { |