From 900c417ea23dcd922e8c0cf839f1439c67178ff2 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 15:11:50 -0400 Subject: [PATCH 01/15] feat(settings): add worktree storage management --- apps/mobile/src/Stack.tsx | 8 + .../features/settings/SettingsRouteScreen.tsx | 11 + .../SettingsWorktreeStorage.logic.test.ts | 141 ++ .../settings/SettingsWorktreeStorage.logic.ts | 39 + .../SettingsWorktreeStorageRouteScreen.tsx | 621 +++++++ .../components/settings-sheet-targets.ts | 1 + apps/mobile/src/state/server.ts | 2 + apps/mobile/src/state/worktree-storage.ts | 103 ++ apps/server/src/auth/RpcAuthorization.test.ts | 9 + apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/environment/ServerEnvironment.ts | 1 + .../Layers/OrchestrationEngine.test.ts | 87 + apps/server/src/orchestration/decider.ts | 22 +- .../decider.worktreePathCas.test.ts | 150 ++ apps/server/src/server.ts | 7 +- apps/server/src/terminal/Manager.ts | 6 + .../worktree/WorktreeStorage.service.test.ts | 320 ++++ .../src/worktree/WorktreeStorage.test.ts | 364 ++++ apps/server/src/worktree/WorktreeStorage.ts | 1463 +++++++++++++++++ .../server/src/worktree/directorySize.test.ts | 91 + apps/server/src/worktree/directorySize.ts | 160 ++ .../src/worktree/worktreeRemoval.test.ts | 55 + apps/server/src/ws.ts | 10 + .../settings/SettingsSidebarNav.tsx | 2 + .../settings/WorktreeStorageSettings.tsx | 684 ++++++++ .../settings/settingsSearch.test.ts | 15 + .../src/components/settings/settingsSearch.ts | 18 + apps/web/src/routeTree.gen.ts | 21 + .../src/routes/settings.worktree-storage.tsx | 7 + apps/web/src/state/server.ts | 2 + apps/web/src/state/worktree-storage.ts | 117 ++ apps/web/src/worktreeStorage.logic.test.ts | 270 +++ apps/web/src/worktreeStorage.logic.ts | 26 + docs/README.md | 2 + docs/internals/worktree-storage.md | 66 + docs/user/worktree-storage.md | 51 + packages/client-runtime/package.json | 4 + .../src/state/worktreeStorage.ts | 36 + .../src/state/worktreeStorageDomain.test.ts | 107 ++ .../src/state/worktreeStorageDomain.ts | 286 ++++ packages/contracts/src/environment.test.ts | 10 + packages/contracts/src/environment.ts | 2 + packages/contracts/src/index.ts | 1 + packages/contracts/src/orchestration.ts | 1 + packages/contracts/src/rpc.ts | 25 + packages/contracts/src/settings.ts | 5 + .../contracts/src/worktreeStorage.test.ts | 154 ++ packages/contracts/src/worktreeStorage.ts | 167 ++ 48 files changed, 5749 insertions(+), 3 deletions(-) create mode 100644 apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.test.ts create mode 100644 apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts create mode 100644 apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx create mode 100644 apps/mobile/src/state/worktree-storage.ts create mode 100644 apps/server/src/orchestration/decider.worktreePathCas.test.ts create mode 100644 apps/server/src/worktree/WorktreeStorage.service.test.ts create mode 100644 apps/server/src/worktree/WorktreeStorage.test.ts create mode 100644 apps/server/src/worktree/WorktreeStorage.ts create mode 100644 apps/server/src/worktree/directorySize.test.ts create mode 100644 apps/server/src/worktree/directorySize.ts create mode 100644 apps/server/src/worktree/worktreeRemoval.test.ts create mode 100644 apps/web/src/components/settings/WorktreeStorageSettings.tsx create mode 100644 apps/web/src/routes/settings.worktree-storage.tsx create mode 100644 apps/web/src/state/worktree-storage.ts create mode 100644 apps/web/src/worktreeStorage.logic.test.ts create mode 100644 apps/web/src/worktreeStorage.logic.ts create mode 100644 docs/internals/worktree-storage.md create mode 100644 docs/user/worktree-storage.md create mode 100644 packages/client-runtime/src/state/worktreeStorage.ts create mode 100644 packages/client-runtime/src/state/worktreeStorageDomain.test.ts create mode 100644 packages/client-runtime/src/state/worktreeStorageDomain.ts create mode 100644 packages/contracts/src/worktreeStorage.test.ts create mode 100644 packages/contracts/src/worktreeStorage.ts diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index 7cffbf62b0d7..06fb665a5802 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -56,6 +56,7 @@ import { SettingsAuthRouteScreen } from "./features/settings/SettingsAuthRouteSc import { SettingsEnvironmentsRouteScreen } from "./features/settings/SettingsEnvironmentsRouteScreen"; import { SettingsLegalRouteScreen } from "./features/settings/SettingsLegalRouteScreen"; import { SettingsProjectGroupingRouteScreen } from "./features/settings/SettingsProjectGroupingRouteScreen"; +import { SettingsWorktreeStorageRouteScreen } from "./features/settings/SettingsWorktreeStorageRouteScreen"; import { UsageRouteScreen } from "./features/usage/UsageRouteScreen"; import { SettingsRouteScreen } from "./features/settings/SettingsRouteScreen"; import { ShowcaseCaptureCoordinator } from "./features/showcase/ShowcaseCaptureCoordinator"; @@ -191,6 +192,13 @@ const SettingsContentStack = createNativeStackNavigator({ title: "Client Storage", }, }), + SettingsWorktreeStorage: createNativeStackScreen({ + screen: SettingsWorktreeStorageRouteScreen, + linking: "worktree-storage", + options: { + title: "Worktree Storage", + }, + }), SettingsUsage: createNativeStackScreen({ screen: UsageRouteScreen, linking: "usage", diff --git a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx index b0e851b59d88..acee49e4673d 100644 --- a/apps/mobile/src/features/settings/SettingsRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsRouteScreen.tsx @@ -47,6 +47,7 @@ import { SettingsRow } from "./components/SettingsRow"; import { SettingsSection } from "./components/SettingsSection"; import { SettingsSwitchRow } from "./components/SettingsSwitchRow"; import { resolveAgentAwarenessPlatformPresentation } from "./SettingsRouteScreen.logic"; +import { MOBILE_WORKTREE_STORAGE_ROUTE } from "./SettingsWorktreeStorage.logic"; type NotificationStatus = "checking" | "enabled" | "disabled" | "unsupported"; type LiveActivityStatus = "checking" | "enabled" | "disabled" | "signed-out" | "linking"; @@ -123,6 +124,11 @@ function LocalSettingsRouteScreen() { value={`${environmentCount}`} target="SettingsEnvironments" /> + @@ -471,6 +477,11 @@ function ConfiguredSettingsRouteScreen() { value={`${environmentCount}`} target="SettingsEnvironments" /> + & + Pick, +): MobileEnvironmentWorktreeStorageStatus { + return { + connectionPhase: "connected", + capable: true, + state: "ready", + totalBytes: input.report?.totalBytes ?? null, + partial: input.report?.partial ?? false, + report: null, + policy: { mode: "off" }, + isRefreshing: false, + error: null, + ...input, + }; +} + +describe("mobile worktree storage presentation", () => { + it("keeps Worktree Storage distinct from Client Storage", () => { + expect(MOBILE_WORKTREE_STORAGE_ROUTE).toEqual({ + label: "Worktree Storage", + target: "SettingsWorktreeStorage", + }); + expect(MOBILE_WORKTREE_STORAGE_ROUTE.label).not.toBe("Client Storage"); + }); + + it("qualifies totals and targets only connected capable systems", () => { + const environments = [ + environment({ + environmentId: "ready" as EnvironmentId, + label: "Ready", + report: { totalBytes: 1_048_576 } as MobileEnvironmentWorktreeStorageStatus["report"], + }), + environment({ + environmentId: "offline" as EnvironmentId, + label: "Offline", + connectionPhase: "offline", + state: "offline", + }), + environment({ + environmentId: "old" as EnvironmentId, + label: "Old", + capable: false, + state: "unsupported", + }), + ]; + + expect(computeWorktreeStorageCoverage(environments)).toEqual({ + totalKnownBytes: 1_048_576, + knownEnvironmentCount: 1, + environmentCount: 3, + loadingCount: 0, + offlineCount: 1, + unsupportedCount: 1, + errorCount: 0, + partialCount: 0, + complete: false, + }); + expect( + planAcrossEnvironmentPrune(environments).targets.map((item) => item.environmentId), + ).toEqual(["ready"]); + expect( + planAcrossEnvironmentPrune(environments).skipped.map((item) => item.environmentId), + ).toEqual(["offline", "old"]); + expect(worktreeStorageSkippedReason(environments[1]!)).toBe("offline"); + expect(worktreeStorageSkippedReason(environments[2]!)).toBe("unsupported"); + expect( + computeWorktreeStorageCoverage([ + environment({ + environmentId: "partial" as EnvironmentId, + label: "Partial", + report: { + totalBytes: 100, + partial: true, + } as MobileEnvironmentWorktreeStorageStatus["report"], + }), + ]), + ).toMatchObject({ + totalKnownBytes: 100, + knownEnvironmentCount: 1, + partialCount: 1, + complete: false, + }); + + const frozenPlan = resolveFrozenPrunePlan( + [...environments, environment({ environmentId: "new" as EnvironmentId, label: "New" })], + ["ready", "offline"] as EnvironmentId[], + ); + expect(frozenPlan.targets.map((item) => item.environmentId)).toEqual(["ready"]); + expect(frozenPlan.skipped.map((item) => item.environmentId)).toEqual(["offline"]); + }); + + it("formats bounded details, protection reasons, and partial prune summaries", () => { + expect(formatWorktreeStorageBytes(1_572_864)).toBe("1.5 MB"); + expect(worktreeDisplayName("/managed/repo/feature-a")).toBe("feature-a"); + expect(mobileProtectionLabel("active-turn-or-session")).toBe("active turn or session"); + expect(mobileProtectionLabel("unowned-or-orphaned")).toBe("no registered project"); + expect( + summarizeMobilePrune( + summarizePruneOutcomes([ + { + environmentId: "ready", + label: "Ready", + status: "success", + removedCount: 2, + skippedCount: 3, + failedCount: 1, + freedBytes: 1_048_576, + partial: true, + serverErrorCount: 2, + unreportedOutcomeCount: 5, + }, + { environmentId: "offline", label: "Offline", status: "skipped", reason: "offline" }, + { environmentId: "failed", label: "Failed", status: "failure", error: "closed" }, + ]), + ), + ).toBe( + "1 MB estimated reclaimed · 2 removed · 3 protected · 1 worktree failures · 1 partial systems · 2 server errors · 5 outcome details omitted · 1 systems skipped · 1 systems failed", + ); + }); +}); diff --git a/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts new file mode 100644 index 000000000000..dd9b08cd92b5 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts @@ -0,0 +1,39 @@ +import type { WorktreeStorageProtectionReason } from "@t3tools/contracts"; +import { + formatWorktreeStorageBytes, + type PruneOutcomeSummary, +} from "@t3tools/client-runtime/state/worktree-storage"; + +export const MOBILE_WORKTREE_STORAGE_ROUTE = { + label: "Worktree Storage", + target: "SettingsWorktreeStorage", +} as const; + +const PROTECTION_LABELS: Readonly> = { + "outside-managed-root": "outside managed storage", + "shared-across-projects": "shared across projects", + "main-checkout": "main checkout", + missing: "missing on disk", + "locked-or-unknown": "locked or unknown", + "unowned-or-orphaned": "no registered project", + "dirty-or-untracked": "dirty or untracked changes", + "ahead-or-unpushed": "ahead or unpushed commits", + "unsettled-thread": "unsettled thread", + "recent-activity": "recent activity", + "active-turn-or-session": "active turn or session", + "live-provider": "provider is still running", + "live-terminal": "terminal is still running", + "pending-approval": "approval is pending", + "pending-input": "input is pending", + "pending-plan": "plan is pending", + "background-liveness": "background work is running", + "inspection-error": "safety check incomplete", +}; + +export function mobileProtectionLabel(reason: WorktreeStorageProtectionReason): string { + return PROTECTION_LABELS[reason]; +} + +export function summarizeMobilePrune(summary: PruneOutcomeSummary): string { + return `${formatWorktreeStorageBytes(summary.freedBytes)} estimated reclaimed · ${summary.removedCount} removed · ${summary.protectedCount} protected · ${summary.failedWorktreeCount} worktree failures · ${summary.partialEnvironmentCount} partial systems · ${summary.serverErrorCount} server errors · ${summary.unreportedOutcomeCount} outcome details omitted · ${summary.skippedEnvironmentCount} systems skipped · ${summary.failedEnvironmentCount} systems failed`; +} diff --git a/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx new file mode 100644 index 000000000000..214edad72645 --- /dev/null +++ b/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx @@ -0,0 +1,621 @@ +import { useNavigation } from "@react-navigation/native"; +import { + WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS, + WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS, + type EnvironmentId, + type WorktreeAutoPrunePolicy, + type WorktreeStorageDetail, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + computeWorktreeStorageCoverage, + formatWorktreeStorageBytes, + planAcrossEnvironmentPrune, + rankWorktreeEntries, + rankWorktreeProjects, + resolveFrozenPrunePlan, + successfulPruneOutcome, + summarizePruneOutcomes, + worktreeDisplayName, + worktreeStorageSkippedReason, + type EnvironmentPruneOutcome, + type WorktreeStorageSkippedReason, +} from "@t3tools/client-runtime/state/worktree-storage"; +import { useEffect, useMemo, useRef, useState } from "react"; +import { + ActivityIndicator, + Alert, + Platform, + Pressable, + ScrollView, + TextInput, + View, +} from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { AndroidScreenHeader } from "../../components/AndroidScreenHeader"; +import { AppText as Text } from "../../components/AppText"; +import { SymbolView } from "../../components/AppSymbol"; +import { ControlPillMenu } from "../../components/ControlPill"; +import { useThemeColor } from "../../lib/useThemeColor"; +import { NativeStackScreenOptions } from "../../native/StackHeader"; +import { serverEnvironment, worktreeStorageEnvironment } from "../../state/server"; +import { + useMobileWorktreeStorage, + type MobileEnvironmentWorktreeStorageStatus, +} from "../../state/worktree-storage"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { SettingsSection } from "./components/SettingsSection"; +import { mobileProtectionLabel, summarizeMobilePrune } from "./SettingsWorktreeStorage.logic"; + +const PROJECT_LIMIT = 6; +const WORKTREE_LIMIT_PER_PROJECT = 3; +const DEFAULT_INACTIVITY_DAYS = 30; + +interface FrozenMobileEnvironmentRef { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +interface FrozenMobileSkippedEnvironment extends FrozenMobileEnvironmentRef { + readonly reason: WorktreeStorageSkippedReason; +} + +function policyLabel(policy: WorktreeAutoPrunePolicy): string { + switch (policy.mode) { + case "off": + return "Off"; + case "on-settle": + return "When threads settle"; + case "after-inactive-days": + return `After ${policy.inactivityDays} inactive ${policy.inactivityDays === 1 ? "day" : "days"}`; + } +} + +function statusLabel(environment: MobileEnvironmentWorktreeStorageStatus): string { + switch (environment.state) { + case "ready": + return environment.isRefreshing ? "Refreshing…" : "Reported"; + case "loading": + return environment.connectionPhase === "connected" ? "Scanning…" : "Connecting…"; + case "offline": + return "Offline · storage unknown"; + case "unsupported": + return "Server update required"; + case "error": + return environment.error ?? "Storage unavailable"; + } +} + +function DetailRow({ detail }: { readonly detail: WorktreeStorageDetail }) { + const reasons = detail.protectionReasons.map(mobileProtectionLabel); + return ( + + + + {worktreeDisplayName(detail.worktreePath)} + + + {formatWorktreeStorageBytes(detail.bytes)} + + + + {detail.eligible + ? "Eligible for stale pruning" + : `Protected${reasons.length > 0 ? ` · ${reasons.join(" · ")}` : " by server safety checks"}`} + + + ); +} + +function PolicyControl(props: { + readonly environment: MobileEnvironmentWorktreeStorageStatus; + readonly disabled: boolean; + readonly onUpdate: (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => void; +}) { + const chevronColor = useThemeColor("--color-chevron"); + const policy = props.environment.policy; + const policyDays = + policy.mode === "after-inactive-days" ? policy.inactivityDays : DEFAULT_INACTIVITY_DAYS; + const [draftMode, setDraftMode] = useState(policy.mode); + const [draftDays, setDraftDays] = useState(String(policyDays)); + useEffect(() => { + setDraftMode(policy.mode); + setDraftDays(String(policyDays)); + }, [policy.mode, policyDays]); + const days = Number(draftDays); + const daysValid = + Number.isInteger(days) && + days >= WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS && + days <= WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS; + const draftPolicy: WorktreeAutoPrunePolicy | null = + draftMode === "after-inactive-days" + ? daysValid + ? { mode: draftMode, inactivityDays: days } + : null + : { mode: draftMode }; + const hasChanges = + draftPolicy !== null && + (draftPolicy.mode !== policy.mode || + (draftPolicy.mode === "after-inactive-days" && + policy.mode === "after-inactive-days" && + draftPolicy.inactivityDays !== policy.inactivityDays)); + const actions = [ + { + id: "off", + title: "Off", + state: draftMode === "off" ? ("on" as const) : ("off" as const), + }, + { + id: "on-settle", + title: "When threads settle", + state: draftMode === "on-settle" ? ("on" as const) : ("off" as const), + }, + { + id: "after-inactive-days", + title: "After inactivity", + state: draftMode === "after-inactive-days" ? ("on" as const) : ("off" as const), + }, + ]; + + return ( + + { + if (props.disabled) return; + const mode = event.nativeEvent.event; + if (mode === "off" || mode === "on-settle" || mode === "after-inactive-days") { + setDraftMode(mode); + } + }} + > + + + Automatic pruning + + {draftPolicy === null ? "After inactivity" : policyLabel(draftPolicy)} + + + + + + {draftMode === "after-inactive-days" ? ( + + + + days ({WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS}– + {WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS}) + + + ) : null} + draftPolicy && props.onUpdate(props.environment.environmentId, draftPolicy)} + className="min-h-12 items-center justify-center border-t border-border-subtle px-4 py-3 disabled:opacity-40" + > + Apply policy + + + ); +} + +function EnvironmentSection(props: { + readonly environment: MobileEnvironmentWorktreeStorageStatus; + readonly pruning: boolean; + readonly savingPolicy: boolean; + readonly onConfirmPrune: (environmentId: EnvironmentId) => void; + readonly onUpdatePolicy: (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => void; +}) { + const iconColor = useThemeColor("--color-icon"); + const dangerColor = useThemeColor("--color-danger-foreground"); + const report = props.environment.report; + const projects = useMemo( + () => rankWorktreeProjects(report?.projects ?? []).slice(0, PROJECT_LIMIT), + [report?.projects], + ); + const unassignedDetails = useMemo( + () => + rankWorktreeEntries((report?.details ?? []).filter((detail) => detail.projectId === null)), + [report?.details], + ); + const visibleUnassignedDetails = unassignedDetails.slice(0, WORKTREE_LIMIT_PER_PROJECT); + const unassignedBytes = unassignedDetails.reduce((sum, detail) => sum + detail.bytes, 0); + const canManage = props.environment.connectionPhase === "connected" && props.environment.capable; + + return ( + + + + + + + {report ? formatWorktreeStorageBytes(report.totalBytes) : "Unknown"} + + + {statusLabel(props.environment)} + {report ? ` · ${report.worktreeCount} worktrees` : ""} + + + {props.environment.isRefreshing ? : null} + + + {report?.partial || (report?.errors.length ?? 0) > 0 ? ( + + Partial scan. Unknown storage stays protected and may not be included in the total. + + ) : null} + + {projects.map((project, index) => { + const details = rankWorktreeEntries( + (report?.details ?? []).filter((detail) => detail.projectId === project.projectId), + ); + const visibleDetails = details.slice(0, WORKTREE_LIMIT_PER_PROJECT); + return ( + + + + + {index + 1}. {project.projectTitle} + + + {project.worktreeCount} worktrees · {project.eligibleWorktreeCount} eligible ·{" "} + {project.staleWorktreeCount} stale + + + + {formatWorktreeStorageBytes(project.bytes)} + + + {visibleDetails.length > 0 ? ( + + {visibleDetails.map((detail) => ( + + ))} + + ) : null} + {details.length > visibleDetails.length ? ( + + Showing {visibleDetails.length} of {details.length} worktrees. + + ) : null} + + ); + })} + + {unassignedDetails.length > 0 ? ( + + + + Unassigned managed worktrees + + No longer linked to a registered project. Always protected from manual and + automatic bulk pruning. Size is the sum of reported unassigned details. + + + + {formatWorktreeStorageBytes(unassignedBytes)} + + + + {visibleUnassignedDetails.map((detail) => ( + + ))} + + {unassignedDetails.length > visibleUnassignedDetails.length ? ( + + Showing {visibleUnassignedDetails.length} of {unassignedDetails.length} unassigned + worktrees. + + ) : null} + + ) : null} + + {report && + (report.projects.length > projects.length || + report.projectCount > report.projects.length) ? ( + + Showing {projects.length} of {report.projectCount} projects, ranked by known bytes. + + ) : null} + + + + props.onConfirmPrune(props.environment.environmentId)} + className="min-h-14 flex-row items-center gap-3 border-t border-border-subtle px-4 py-3 disabled:opacity-40" + > + + + Prune all stale worktrees on this system + + {props.pruning ? : null} + + + + Automatic pruning is stored on this system, not on this mobile device. + + + ); +} + +export function SettingsWorktreeStorageRouteScreen() { + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const iconColor = useThemeColor("--color-icon"); + const dangerColor = useThemeColor("--color-danger-foreground"); + const { environments, refresh } = useMobileWorktreeStorage(); + const coverage = useMemo(() => computeWorktreeStorageCoverage(environments), [environments]); + const plan = useMemo(() => planAcrossEnvironmentPrune(environments), [environments]); + const pruneStale = useAtomCommand(worktreeStorageEnvironment.pruneStale, { + reportFailure: false, + }); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); + const [pruning, setPruning] = useState(false); + const [savingPolicyEnvironmentId, setSavingPolicyEnvironmentId] = useState( + null, + ); + const [lastSummary, setLastSummary] = useState(null); + const mutationPending = useRef(false); + + const updatePolicy = async (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => { + setSavingPolicyEnvironmentId(environmentId); + const result = await updateSettings({ + environmentId, + input: { patch: { worktreeAutoPrunePolicy: policy } }, + }); + setSavingPolicyEnvironmentId(null); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const cause = squashAtomCommandFailure(result); + Alert.alert( + "Policy update failed", + cause instanceof Error ? cause.message : "Try again when this system is connected.", + ); + } + }; + + const executePrune = async ( + confirmedTargets: readonly FrozenMobileEnvironmentRef[], + initiallySkipped: readonly FrozenMobileSkippedEnvironment[], + ) => { + if (mutationPending.current) return; + mutationPending.current = true; + setPruning(true); + const currentPlan = resolveFrozenPrunePlan( + environments, + confirmedTargets.map((environment) => environment.environmentId), + ); + const targets = currentPlan.targets; + const currentIds = new Set(environments.map((environment) => environment.environmentId)); + const disconnectedTargets: FrozenMobileSkippedEnvironment[] = [ + ...currentPlan.skipped.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + reason: worktreeStorageSkippedReason(environment), + })), + ...confirmedTargets + .filter((environment) => !currentIds.has(environment.environmentId)) + .map((environment) => ({ ...environment, reason: "unavailable" as const })), + ]; + const skippedEnvironments = [...initiallySkipped, ...disconnectedTargets]; + const results = await Promise.all( + targets.map(async (environment) => ({ + environment, + result: await pruneStale({ environmentId: environment.environmentId, input: {} }), + })), + ); + const resultOutcomes = results.map((entry): EnvironmentPruneOutcome => { + if (entry.result._tag === "Success") { + return successfulPruneOutcome(entry.environment, entry.result.value); + } + const cause = squashAtomCommandFailure(entry.result); + return { + environmentId: entry.environment.environmentId, + label: entry.environment.label, + status: "failure", + error: cause instanceof Error ? cause.message : "Prune request failed.", + }; + }); + const skippedOutcomes: readonly EnvironmentPruneOutcome[] = skippedEnvironments.map( + (environment) => ({ ...environment, status: "skipped" }), + ); + const outcomes = [...resultOutcomes, ...skippedOutcomes]; + const aggregate = summarizePruneOutcomes(outcomes); + const summary = summarizeMobilePrune(aggregate); + const skippedLabels = outcomes + .filter((outcome) => outcome.status === "skipped") + .map((outcome) => `${outcome.label} (${outcome.reason})`); + const failedLabels = outcomes + .filter((outcome) => outcome.status === "failure") + .map((outcome) => outcome.label); + const partialLabels = outcomes + .filter((outcome) => outcome.status === "success" && outcome.partial) + .map((outcome) => outcome.label); + const resultDetails = [ + partialLabels.length > 0 ? `Partial: ${partialLabels.join(", ")}.` : null, + skippedLabels.length > 0 ? `Skipped: ${skippedLabels.join(", ")}.` : null, + failedLabels.length > 0 ? `Failed: ${failedLabels.join(", ")}.` : null, + ].filter((detail): detail is string => detail !== null); + const detailedSummary = [summary, ...resultDetails].join("\n"); + setLastSummary(detailedSummary); + setPruning(false); + mutationPending.current = false; + Alert.alert( + aggregate.tone !== "success" + ? "Prune finished with exceptions" + : aggregate.removedCount > 0 + ? "Prune finished" + : "No stale worktrees were pruned", + detailedSummary, + ); + }; + + const confirmPrune = (environmentId: EnvironmentId | null) => { + const candidates = + environmentId === null + ? environments + : environments.filter((environment) => environment.environmentId === environmentId); + const currentPlan = planAcrossEnvironmentPrune(candidates); + const targets: readonly FrozenMobileEnvironmentRef[] = currentPlan.targets.map( + ({ environmentId: id, label }) => ({ environmentId: id, label }), + ); + const skipped: readonly FrozenMobileSkippedEnvironment[] = currentPlan.skipped.map( + (environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + reason: worktreeStorageSkippedReason(environment), + }), + ); + const exactScope = targets.map((environment) => environment.label).join(", "); + const skippedScope = skipped + .map((environment) => `${environment.label} (${environment.reason})`) + .join(", "); + Alert.alert( + environmentId === null + ? `Prune across ${targets.length} connected ${targets.length === 1 ? "system" : "systems"}?` + : `Prune all stale worktrees on ${targets[0]?.label ?? "this system"}?`, + `Included: ${exactScope || "None"}.${skippedScope ? `\n\nSkipped: ${skippedScope}.` : ""}\n\nEach system performs a fresh safety check. Dirty, active, or unknown worktrees remain protected. Offline systems are not queued.`, + [ + { text: "Cancel", style: "cancel" }, + { + text: "Prune all stale worktrees", + style: "destructive", + onPress: () => void executePrune(targets, skipped), + }, + ], + ); + }; + + return ( + + {Platform.OS === "android" ? ( + <> + + navigation.goBack()} /> + + ) : null} + + + + + + + {coverage.knownEnvironmentCount > 0 + ? formatWorktreeStorageBytes(coverage.totalKnownBytes) + : "Unknown"} + + + Known across {coverage.knownEnvironmentCount} of {coverage.environmentCount}{" "} + systems. + {coverage.knownEnvironmentCount < coverage.environmentCount + ? " Missing systems are not counted as zero." + : ""} + {coverage.partialCount > 0 + ? ` ${coverage.partialCount} ${coverage.partialCount === 1 ? "report is" : "reports are"} partial; unknown storage is not counted as zero.` + : ""} + {" Shared Git object storage is excluded."} + + + + + + Refresh now + + + + {environments.map((environment) => ( + confirmPrune(environmentId)} + onUpdatePolicy={(environmentId, policy) => void updatePolicy(environmentId, policy)} + /> + ))} + + + + confirmPrune(null)} + className="min-h-14 flex-row items-center gap-3 p-4 disabled:opacity-40" + > + + + + Prune all stale worktrees across connected systems + + + {plan.targets.length} connected and capable · {plan.skipped.length} skipped + + + {pruning ? : null} + + + + Pruning asks each connected system for a fresh safety check. Dirty, active, and unknown + worktrees remain protected. Offline systems are never queued. + + {lastSummary ? ( + + Last prune: {lastSummary} + + ) : null} + + + + ); +} diff --git a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts index 7189fdc2ebe5..80e2362bc179 100644 --- a/apps/mobile/src/features/settings/components/settings-sheet-targets.ts +++ b/apps/mobile/src/features/settings/components/settings-sheet-targets.ts @@ -3,6 +3,7 @@ export type SettingsSheetTarget = | "SettingsArchive" | "SettingsAppearance" | "SettingsProjectGrouping" + | "SettingsWorktreeStorage" | "SettingsClientStorage" | "SettingsUsage"; diff --git a/apps/mobile/src/state/server.ts b/apps/mobile/src/state/server.ts index 1b7060571a5b..e85a26d845c8 100644 --- a/apps/mobile/src/state/server.ts +++ b/apps/mobile/src/state/server.ts @@ -1,5 +1,6 @@ import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; +import { createWorktreeStorageAtoms } from "@t3tools/client-runtime/state/worktree-storage"; import { environmentCatalog } from "../connection/catalog"; import { connectionAtomRuntime } from "../connection/runtime"; @@ -8,6 +9,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, }); +export const worktreeStorageEnvironment = createWorktreeStorageAtoms(connectionAtomRuntime); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, diff --git a/apps/mobile/src/state/worktree-storage.ts b/apps/mobile/src/state/worktree-storage.ts new file mode 100644 index 000000000000..40090a3a5f30 --- /dev/null +++ b/apps/mobile/src/state/worktree-storage.ts @@ -0,0 +1,103 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + DEFAULT_WORKTREE_AUTO_PRUNE_POLICY, + type EnvironmentId, + type WorktreeAutoPrunePolicy, + type WorktreeStorageReport, +} from "@t3tools/contracts"; +import { + rankWorktreeEnvironments, + type WorktreeStorageEnvironmentState, + type WorktreeStorageEnvironmentSummary, +} from "@t3tools/client-runtime/state/worktree-storage"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { appAtomRegistry } from "./atom-registry"; +import { environmentPresentations } from "./presentation"; +import { worktreeStorageEnvironment } from "./server"; + +export interface MobileEnvironmentWorktreeStorageStatus extends WorktreeStorageEnvironmentSummary { + readonly environmentId: EnvironmentId; + readonly report: WorktreeStorageReport | null; + readonly policy: WorktreeAutoPrunePolicy; + readonly isRefreshing: boolean; + readonly error: string | null; +} + +const mobileWorktreeStorageAtom = Atom.make( + (get): readonly MobileEnvironmentWorktreeStorageStatus[] => { + const presentations = get(environmentPresentations.presentationsAtom); + const statuses: MobileEnvironmentWorktreeStorageStatus[] = []; + + for (const [environmentId, presentation] of presentations) { + const connectionPhase = presentation.connection.phase; + const capable = presentation.serverConfig?.environment.capabilities.worktreeStorage === true; + const policy = + presentation.serverConfig?.settings.worktreeAutoPrunePolicy ?? + DEFAULT_WORKTREE_AUTO_PRUNE_POLICY; + let state: WorktreeStorageEnvironmentState; + let report: WorktreeStorageReport | null = null; + let isRefreshing = false; + let error: string | null = null; + + if (connectionPhase !== "connected") { + state = + connectionPhase === "error" + ? "error" + : connectionPhase === "offline" + ? "offline" + : "loading"; + error = + connectionPhase === "error" + ? (presentation.connection.error ?? "This system could not be reached.") + : null; + } else if (presentation.serverConfig === null) { + state = "loading"; + } else if (!capable) { + state = "unsupported"; + } else { + const result = get(worktreeStorageEnvironment.report({ environmentId, input: {} })); + report = Option.getOrNull(AsyncResult.value(result)); + isRefreshing = result.waiting && report !== null; + if (result._tag === "Failure") { + state = "error"; + error = "This system could not report worktree storage."; + } else { + state = report === null ? "loading" : "ready"; + } + } + + statuses.push({ + environmentId, + label: presentation.entry.target.label, + connectionPhase, + capable, + state, + totalBytes: report?.totalBytes ?? null, + partial: report?.partial ?? false, + report, + policy, + isRefreshing, + error, + }); + } + return statuses; + }, +).pipe(Atom.withLabel("mobile-worktree-storage")); + +export function useMobileWorktreeStorage() { + const rawEnvironments = useAtomValue(mobileWorktreeStorageAtom); + const environments = useMemo(() => rankWorktreeEnvironments(rawEnvironments), [rawEnvironments]); + const refresh = useCallback(() => { + for (const environment of environments) { + if (environment.connectionPhase !== "connected" || !environment.capable) continue; + appAtomRegistry.refresh( + worktreeStorageEnvironment.report({ environmentId: environment.environmentId, input: {} }), + ); + } + }, [environments]); + + return { environments, refresh }; +} diff --git a/apps/server/src/auth/RpcAuthorization.test.ts b/apps/server/src/auth/RpcAuthorization.test.ts index 25971b0c0aec..4f2970594b8e 100644 --- a/apps/server/src/auth/RpcAuthorization.test.ts +++ b/apps/server/src/auth/RpcAuthorization.test.ts @@ -43,6 +43,15 @@ describe("RPC authorization scopes", () => { ); }); + it("reads worktree storage with orchestration read scope and prunes with operate scope", () => { + expect(requiredScopeForRpcMethod(WS_METHODS.worktreeStorageGetReport)).toBe( + AuthOrchestrationReadScope, + ); + expect(requiredScopeForRpcMethod(WS_METHODS.worktreeStoragePruneStale)).toBe( + AuthOrchestrationOperateScope, + ); + }); + it("reads the reviewer menu under the same scope as the pull request it belongs to", () => { // The candidate list is a read like the detail beside it, and asking somebody for a review is // a write like every other pull request operation. diff --git a/apps/server/src/auth/RpcAuthorization.ts b/apps/server/src/auth/RpcAuthorization.ts index 70227cdd4ebf..1d3286273c06 100644 --- a/apps/server/src/auth/RpcAuthorization.ts +++ b/apps/server/src/auth/RpcAuthorization.ts @@ -39,6 +39,8 @@ export const RPC_REQUIRED_SCOPES = { [WS_METHODS.serverRemoveKeybinding]: AuthOrchestrationOperateScope, [WS_METHODS.serverGetSettings]: AuthOrchestrationReadScope, [WS_METHODS.serverUpdateSettings]: AuthOrchestrationOperateScope, + [WS_METHODS.worktreeStorageGetReport]: AuthOrchestrationReadScope, + [WS_METHODS.worktreeStoragePruneStale]: AuthOrchestrationOperateScope, [WS_METHODS.serverDiscoverSourceControl]: AuthOrchestrationReadScope, [WS_METHODS.serverGetTraceDiagnostics]: AuthOrchestrationReadScope, [WS_METHODS.serverGetProcessDiagnostics]: AuthOrchestrationReadScope, diff --git a/apps/server/src/environment/ServerEnvironment.ts b/apps/server/src/environment/ServerEnvironment.ts index 45dc0ee9cfd5..6517d43e34cf 100644 --- a/apps/server/src/environment/ServerEnvironment.ts +++ b/apps/server/src/environment/ServerEnvironment.ts @@ -152,6 +152,7 @@ export const make = Effect.gen(function* () { threadPinning: true, threadPinReorder: true, threadTitleRegeneration: true, + worktreeStorage: true, ...(serverSelfUpdate === null ? {} : { serverSelfUpdate }), ...(serverSelfUpdate === "boot-service" ? { serverSelfUpdateProgress: true } : {}), }, diff --git a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts index 382c253fe60b..c804c9431bbe 100644 --- a/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts +++ b/apps/server/src/orchestration/Layers/OrchestrationEngine.test.ts @@ -620,6 +620,93 @@ describe("OrchestrationEngine", () => { await system.dispose(); }); + it("persists an observable no-op event when a worktree path CAS mismatches", async () => { + const system = await createOrchestrationSystem(); + const { engine } = system; + const createdAt = now(); + + await system.run( + engine.dispatch({ + type: "project.create", + commandId: CommandId.make("cmd-worktree-cas-project-create"), + projectId: asProjectId("project-worktree-cas"), + title: "Worktree CAS Project", + workspaceRoot: "/tmp/project-worktree-cas", + defaultModelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + createdAt, + }), + ); + await system.run( + engine.dispatch({ + type: "thread.create", + commandId: CommandId.make("cmd-worktree-cas-thread-create"), + threadId: ThreadId.make("thread-worktree-cas"), + projectId: asProjectId("project-worktree-cas"), + title: "Worktree CAS Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5-codex", + }, + interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE, + runtimeMode: "approval-required", + branch: "feature", + worktreePath: "/tmp/rebound-worktree", + createdAt, + }), + ); + + const beforeMismatch = await system.run(engine.latestSequence); + const mismatch = await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-worktree-cas-mismatch"), + threadId: ThreadId.make("thread-worktree-cas"), + worktreePath: null, + expectedWorktreePath: "/tmp/original-worktree", + }), + ); + const mismatchEvents = await system.run( + Stream.runCollect(engine.readEvents(beforeMismatch, 1)).pipe( + Effect.map((events) => Array.from(events)), + ), + ); + expect(mismatch.sequence).toBe(beforeMismatch + 1); + expect(mismatchEvents).toHaveLength(1); + expect(mismatchEvents[0]?.sequence).toBe(mismatch.sequence); + expect(mismatchEvents[0]?.type).toBe("thread.meta-updated"); + if (mismatchEvents[0]?.type === "thread.meta-updated") { + expect(mismatchEvents[0].payload).not.toHaveProperty("worktreePath"); + expect(mismatchEvents[0].payload.updatedAt).toBe(createdAt); + } + expect((await system.readModel()).threads[0]?.worktreePath).toBe("/tmp/rebound-worktree"); + + const applied = await system.run( + engine.dispatch({ + type: "thread.meta.update", + commandId: CommandId.make("cmd-worktree-cas-applied"), + threadId: ThreadId.make("thread-worktree-cas"), + worktreePath: null, + expectedWorktreePath: "/tmp/rebound-worktree", + }), + ); + const appliedEvents = await system.run( + Stream.runCollect(engine.readEvents(mismatch.sequence, 1)).pipe( + Effect.map((events) => Array.from(events)), + ), + ); + expect(appliedEvents[0]?.sequence).toBe(applied.sequence); + if (appliedEvents[0]?.type === "thread.meta-updated") { + expect(appliedEvents[0].payload.worktreePath).toBeNull(); + expect(appliedEvents[0].payload.updatedAt).toBe(createdAt); + } + expect((await system.readModel()).threads[0]?.worktreePath).toBeNull(); + + await system.dispose(); + }); + it("records command ack duration using the first committed event type", async () => { const system = await createOrchestrationSystem(); const { engine } = system; diff --git a/apps/server/src/orchestration/decider.ts b/apps/server/src/orchestration/decider.ts index 4f61955fa6aa..68c054e6edf1 100644 --- a/apps/server/src/orchestration/decider.ts +++ b/apps/server/src/orchestration/decider.ts @@ -817,7 +817,25 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" thread.branch !== command.expectedBranch ? thread.branch : command.branch; + const worktreePath = + command.worktreePath !== undefined && + command.expectedWorktreePath !== undefined && + thread.worktreePath !== command.expectedWorktreePath + ? undefined + : command.worktreePath; const occurredAt = yield* nowIso; + // Worktree pruning uses expected-path metadata updates as an internal + // reservation/restore CAS. That bookkeeping must not count as durable + // user activity for inactivity-based pruning. + const isWorktreePathOnlyCas = + command.worktreePath !== undefined && + command.expectedWorktreePath !== undefined && + command.title === undefined && + command.regenerateTitle === undefined && + command.modelSelection === undefined && + command.branch === undefined && + command.expectedBranch === undefined; + const updatedAt = isWorktreePathOnlyCas ? thread.updatedAt : occurredAt; return { ...(yield* withEventBase({ aggregateKind: "thread", @@ -846,8 +864,8 @@ export const decideOrchestrationCommand = Effect.fn("decideOrchestrationCommand" ? { modelSelection: command.modelSelection } : {}), ...(branch !== undefined ? { branch } : {}), - ...(command.worktreePath !== undefined ? { worktreePath: command.worktreePath } : {}), - updatedAt: occurredAt, + ...(worktreePath !== undefined ? { worktreePath } : {}), + updatedAt, }, }; } diff --git a/apps/server/src/orchestration/decider.worktreePathCas.test.ts b/apps/server/src/orchestration/decider.worktreePathCas.test.ts new file mode 100644 index 000000000000..4482bb560d94 --- /dev/null +++ b/apps/server/src/orchestration/decider.worktreePathCas.test.ts @@ -0,0 +1,150 @@ +import { + CommandId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationReadModel, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Effect from "effect/Effect"; + +import { decideOrchestrationCommand } from "./decider.ts"; + +const NOW = "2026-01-01T00:00:00.000Z"; + +function makeReadModel(worktreePath: string | null): OrchestrationReadModel { + return { + snapshotSequence: 0, + projects: [], + threads: [ + { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath, + latestTurn: null, + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + settledOverride: "settled", + settledAt: NOW, + snoozedUntil: null, + snoozedAt: null, + pinnedAt: null, + deletedAt: null, + messages: [], + proposedPlans: [], + activities: [], + checkpoints: [], + session: null, + }, + ], + updatedAt: NOW, + }; +} + +it.layer(NodeServices.layer)("thread worktree path compare-and-set", (it) => { + it.effect("clears the path when the expected value still matches", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-clear-worktree"), + threadId: ThreadId.make("thread-1"), + worktreePath: null, + expectedWorktreePath: "/managed/old", + }, + readModel: makeReadModel("/managed/old"), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.worktreePath).toBeNull(); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("does not clear a path that was rebound before the metadata update", () => + Effect.gen(function* () { + const event = yield* decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make("cmd-raced-clear-worktree"), + threadId: ThreadId.make("thread-1"), + worktreePath: null, + expectedWorktreePath: "/managed/old", + }, + readModel: makeReadModel("/managed/rebound"), + }); + const events = Array.isArray(event) ? event : [event]; + expect(events).toHaveLength(1); + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload).not.toHaveProperty("worktreePath"); + expect(events[0].payload.updatedAt).toBe(NOW); + } + }), + ); + + it.effect("restores a reservation only while the path remains null", () => + Effect.gen(function* () { + const restore = (currentPath: string | null) => + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make(`cmd-restore-${currentPath ?? "null"}`), + threadId: ThreadId.make("thread-1"), + worktreePath: "/managed/old", + expectedWorktreePath: null, + }, + readModel: makeReadModel(currentPath), + }); + + const fromNull = yield* restore(null); + const nullEvents = Array.isArray(fromNull) ? fromNull : [fromNull]; + if (nullEvents[0]?.type === "thread.meta-updated") { + expect(nullEvents[0].payload.worktreePath).toBe("/managed/old"); + expect(nullEvents[0].payload.updatedAt).toBe(NOW); + } + + const fromRebound = yield* restore("/managed/new"); + const reboundEvents = Array.isArray(fromRebound) ? fromRebound : [fromRebound]; + if (reboundEvents[0]?.type === "thread.meta-updated") { + expect(reboundEvents[0].payload).not.toHaveProperty("worktreePath"); + } + }), + ); + + it.effect("continues to advance activity for ordinary metadata updates", () => + Effect.gen(function* () { + const decide = (withExpectedPath: boolean) => + decideOrchestrationCommand({ + command: { + type: "thread.meta.update", + commandId: CommandId.make( + withExpectedPath ? "cmd-combined-meta-update" : "cmd-ordinary-meta-update", + ), + threadId: ThreadId.make("thread-1"), + title: "Renamed", + ...(withExpectedPath + ? { worktreePath: null, expectedWorktreePath: "/managed/old" } + : {}), + }, + readModel: makeReadModel("/managed/old"), + }); + const event = yield* decide(false); + const combinedEvent = yield* decide(true); + for (const decided of [event, combinedEvent]) { + const events = Array.isArray(decided) ? decided : [decided]; + if (events[0]?.type === "thread.meta-updated") { + expect(events[0].payload.updatedAt).not.toBe(NOW); + } + } + }), + ); +}); diff --git a/apps/server/src/server.ts b/apps/server/src/server.ts index 3e41b4390f82..38159b6489eb 100644 --- a/apps/server/src/server.ts +++ b/apps/server/src/server.ts @@ -108,6 +108,7 @@ import * as ResourceAttribution from "./resourceTelemetry/ResourceAttribution.ts import * as ResourceMonitorBinary from "./resourceTelemetry/ResourceMonitorBinary.ts"; import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as WorktreeStorage from "./worktree/WorktreeStorage.ts"; import { OrchestrationLayerLive } from "./orchestration/runtimeLayer.ts"; import { clearPersistedServerRuntimeState, @@ -415,7 +416,11 @@ const RuntimeCoreDependenciesLive = ReactorLayerLive.pipe( ), ); -const RuntimeDependenciesLive = RuntimeCoreDependenciesLive.pipe( +const RuntimeWithWorktreeStorageLive = WorktreeStorage.layer.pipe( + Layer.provideMerge(RuntimeCoreDependenciesLive), +); + +const RuntimeDependenciesLive = RuntimeWithWorktreeStorageLive.pipe( // Misc. Layer.provideMerge(BackgroundLayerLive), Layer.provideMerge(ResourceDiagnosticsLayerLive), diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 64c2dbb913fb..41f6ba4542a5 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -194,6 +194,9 @@ export class TerminalManager extends Context.Service< readonly subscribeMetadata: ( listener: (event: TerminalMetadataStreamEvent) => Effect.Effect, ) => Effect.Effect<() => void>; + + /** Read current in-memory summaries for safety-sensitive host operations. */ + readonly listSummaries?: Effect.Effect>; } >()("t3/terminal/Manager/TerminalManager") {} @@ -2663,6 +2666,9 @@ export const makeWithOptions = Effect.fn("TerminalManager.makeWithOptions")(func close, subscribe, subscribeMetadata, + listSummaries: readManagerState.pipe( + Effect.map((state) => [...state.sessions.values()].map(summary)), + ), }); }); diff --git a/apps/server/src/worktree/WorktreeStorage.service.test.ts b/apps/server/src/worktree/WorktreeStorage.service.test.ts new file mode 100644 index 000000000000..22e27f7281be --- /dev/null +++ b/apps/server/src/worktree/WorktreeStorage.service.test.ts @@ -0,0 +1,320 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +import { + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationProjectShell, + type OrchestrationThreadShell, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { afterEach, expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Stream from "effect/Stream"; +import { ChildProcessSpawner } from "effect/unstable/process"; + +import * as ServerConfig from "../config.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderService from "../provider/Services/ProviderService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import * as WorktreeStorage from "./WorktreeStorage.ts"; + +const OLD = "2025-01-01T00:00:00.000Z"; +const temporaryDirectories: string[] = []; + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); + +function processOutput( + input: { + readonly stdout?: string; + readonly stderr?: string; + readonly exitCode?: number; + } = {}, +): VcsProcess.VcsProcessOutput { + return { + exitCode: ChildProcessSpawner.ExitCode(input.exitCode ?? 0), + stdout: input.stdout ?? "", + stderr: input.stderr ?? "", + stdoutTruncated: false, + stderrTruncated: false, + stdoutInvalidUtf8: false, + stderrInvalidUtf8: false, + }; +} + +function makeProject(mainPath: string): OrchestrationProjectShell { + return { + id: ProjectId.make("project-service"), + title: "Project", + workspaceRoot: mainPath, + defaultModelSelection: null, + scripts: [], + createdAt: OLD, + updatedAt: OLD, + }; +} + +function makeThread(worktreePath: string | null): OrchestrationThreadShell { + return { + id: ThreadId.make("thread-service"), + projectId: ProjectId.make("project-service"), + title: "Thread", + modelSelection: { + instanceId: ProviderInstanceId.make("codex"), + model: "gpt-5.4", + }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath, + latestTurn: null, + createdAt: OLD, + updatedAt: OLD, + archivedAt: null, + settledOverride: "settled", + settledAt: OLD, + session: null, + latestUserMessageAt: OLD, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: null, + planProgress: null, + }; +} + +const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(function* (input: { + readonly remove: "success" | "failure" | "blocked-failure"; + readonly rebindBeforeReservation?: boolean; +}) { + const root = yield* Effect.promise(() => + NodeFSP.mkdtemp(NodePath.join(process.cwd(), ".worktree-storage-service-test-")), + ); + temporaryDirectories.push(root); + const mainPath = NodePath.join(root, "main"); + const candidatePath = NodePath.join(root, "worktrees", "feature"); + const reboundPath = NodePath.join(root, "worktrees", "rebound"); + yield* Effect.promise(() => + Promise.all([ + NodeFSP.mkdir(mainPath, { recursive: true }), + NodeFSP.mkdir(candidatePath, { recursive: true }), + ]), + ); + yield* Effect.promise(() => + NodeFSP.writeFile(NodePath.join(candidatePath, ".git"), "gitdir: test"), + ); + + let threadPath: string | null = candidatePath; + let sequence = 0; + let lastEvent: OrchestrationEvent | null = null; + let removeCallCount = 0; + let worktreeListCallCount = 0; + let shouldRebind = input.rebindBeforeReservation === true; + const removeStarted = yield* Deferred.make(); + const releaseRemove = yield* Deferred.make(); + const project = makeProject(mainPath); + + const engineLayer = Layer.mock(OrchestrationEngine.OrchestrationEngineService)({ + dispatch: (command) => + Effect.sync(() => { + if (command.type !== "thread.meta.update") { + throw new Error(`Unexpected command: ${command.type}`); + } + if (shouldRebind && command.worktreePath === null) { + shouldRebind = false; + threadPath = reboundPath; + } + const applied = + command.expectedWorktreePath === undefined || command.expectedWorktreePath === threadPath; + if (applied && command.worktreePath !== undefined) { + threadPath = command.worktreePath; + } + sequence += 1; + lastEvent = { + sequence, + eventId: EventId.make(`event-${sequence}`), + aggregateKind: "thread", + aggregateId: command.threadId, + occurredAt: OLD, + commandId: command.commandId, + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.meta-updated", + payload: { + threadId: command.threadId, + ...(applied && command.worktreePath !== undefined + ? { worktreePath: command.worktreePath } + : {}), + updatedAt: OLD, + }, + }; + return { sequence }; + }), + readEvents: (afterSequence) => + lastEvent !== null && lastEvent.sequence > afterSequence + ? Stream.succeed(lastEvent) + : Stream.empty, + streamDomainEvents: Stream.empty, + latestSequence: Effect.sync(() => sequence), + }); + const projectionLayer = Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ + getShellSnapshot: () => + Effect.sync(() => ({ + snapshotSequence: sequence, + projects: [project], + threads: [makeThread(threadPath)], + updatedAt: OLD, + })), + getArchivedShellSnapshot: () => + Effect.sync(() => ({ + snapshotSequence: sequence, + projects: [], + threads: [], + updatedAt: OLD, + })), + getThreadShellById: () => Effect.sync(() => Option.some(makeThread(threadPath))), + }); + const vcsLayer = Layer.mock(VcsProcess.VcsProcess)({ + run: (request) => { + const [first, second] = request.args; + if (first === "worktree" && second === "list") { + worktreeListCallCount += 1; + return Effect.succeed( + processOutput({ + stdout: + `worktree ${mainPath}\0HEAD abc\0branch refs/heads/main\0\0` + + `worktree ${candidatePath}\0HEAD def\0branch refs/heads/feature\0\0`, + }), + ); + } + if (first === "status") return Effect.succeed(processOutput()); + if (first === "rev-parse") return Effect.succeed(processOutput({ exitCode: 1 })); + if (first === "branch") { + return Effect.succeed(processOutput({ stdout: "refs/remotes/origin/feature\n" })); + } + if (first === "worktree" && second === "remove") { + removeCallCount += 1; + if (input.remove === "blocked-failure") { + return Deferred.succeed(removeStarted, undefined).pipe( + Effect.andThen(Deferred.await(releaseRemove)), + Effect.as(processOutput({ exitCode: 1, stderr: "blocked failure" })), + ); + } + return Effect.succeed( + input.remove === "success" + ? processOutput() + : processOutput({ exitCode: 1, stderr: "remove failure" }), + ); + } + return Effect.die(`Unexpected Git command: ${request.args.join(" ")}`); + }, + }); + + const configLayer = ServerConfig.layerTest(process.cwd(), root).pipe( + Layer.provide(NodeServices.layer), + ); + const dependencies = Layer.mergeAll( + NodeServices.layer, + configLayer, + ServerSettings.layerTest({ worktreeAutoPrunePolicy: { mode: "off" } }), + engineLayer, + projectionLayer, + Layer.mock(ProviderService.ProviderService)({ listSessions: () => Effect.succeed([]) }), + Layer.mock(TerminalManager.TerminalManager)({ listSummaries: Effect.succeed([]) }), + vcsLayer, + Layer.mock(VcsStatusBroadcaster.VcsStatusBroadcaster)({ + refreshLocalStatus: () => + Effect.succeed({ + isRepo: true, + hasPrimaryRemote: false, + isDefaultRef: false, + refName: "feature", + hasWorkingTreeChanges: false, + workingTree: { files: [], insertions: 0, deletions: 0 }, + }), + }), + ); + + return { + program: WorktreeStorage.make.pipe(Effect.provide(dependencies)), + candidatePath, + reboundPath, + removeStarted, + releaseRemove, + get threadPath() { + return threadPath; + }, + get removeCallCount() { + return removeCallCount; + }, + get worktreeListCallCount() { + return worktreeListCallCount; + }, + }; +}); + +it.effect("runs reservation, removal, restoration, and fresh report service flows", () => + Effect.scoped( + Effect.gen(function* () { + const success = yield* makeHarness({ remove: "success" }); + const successService = yield* success.program; + yield* successService.getReport; + yield* successService.getReport; + expect(success.worktreeListCallCount).toBe(2); + const successResult = yield* successService.pruneStale; + expect(successResult.removedCount).toBe(1); + expect(success.threadPath).toBeNull(); + expect(success.removeCallCount).toBe(1); + + const failure = yield* makeHarness({ remove: "failure" }); + const failureService = yield* failure.program; + const failureResult = yield* failureService.pruneStale; + expect(failureResult.failedCount).toBe(1); + expect(failure.threadPath).toBe(failure.candidatePath); + expect(failure.removeCallCount).toBe(1); + + const mismatch = yield* makeHarness({ + remove: "success", + rebindBeforeReservation: true, + }); + const mismatchService = yield* mismatch.program; + const mismatchResult = yield* mismatchService.pruneStale; + expect(mismatchResult.removedCount).toBe(0); + expect(mismatch.threadPath).toBe(mismatch.reboundPath); + expect(mismatch.removeCallCount).toBe(0); + }), + ), +); + +it.effect("restores a reservation when pruning is interrupted during bounded removal", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ remove: "blocked-failure" }); + const service = yield* harness.program; + const pruneFiber = yield* service.pruneStale.pipe(Effect.forkChild); + yield* Deferred.await(harness.removeStarted); + const interruptFiber = yield* Fiber.interrupt(pruneFiber).pipe(Effect.forkChild); + yield* Deferred.succeed(harness.releaseRemove, undefined); + yield* Fiber.join(interruptFiber); + expect(harness.threadPath).toBe(harness.candidatePath); + expect(harness.removeCallCount).toBe(1); + }), + ), +); diff --git a/apps/server/src/worktree/WorktreeStorage.test.ts b/apps/server/src/worktree/WorktreeStorage.test.ts new file mode 100644 index 000000000000..2b1a0292bcd8 --- /dev/null +++ b/apps/server/src/worktree/WorktreeStorage.test.ts @@ -0,0 +1,364 @@ +import { + CommandId, + EventId, + ProjectId, + ProviderInstanceId, + ThreadId, + type OrchestrationEvent, + type OrchestrationThreadShell, + type WorktreeStorageDetail, + type WorktreeStorageProjectAggregate, +} from "@t3tools/contracts"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { expect, it } from "@effect/vitest"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Stream from "effect/Stream"; + +import { + associationReasons, + automaticPolicyKey, + automaticScanMode, + decodeWorktreePorcelain, + hasLivePathUse, + isAppliedThreadPathEvent, + isCanonicallyContained, + parseWorktreePorcelain, + rankDetails, + rankProjects, + reservationIsValid, + selectCandidateWindow, + shouldProtectOrphan, + shouldRunAutomaticFallback, + shouldRestoreReservedPaths, + threadReasons, + withReservationRestoration, + worktreeListOutputError, + worktreeRemovalArgs, +} from "./WorktreeStorage.ts"; + +const OLD = "2026-01-01T00:00:00.000Z"; + +function makeThread(overrides: Partial = {}): OrchestrationThreadShell { + return { + id: ThreadId.make("thread-1"), + projectId: ProjectId.make("project-1"), + title: "Thread", + modelSelection: { instanceId: ProviderInstanceId.make("codex"), model: "gpt-5.4" }, + runtimeMode: "full-access", + interactionMode: "default", + branch: "feature", + worktreePath: "/managed/worktree", + latestTurn: null, + createdAt: OLD, + updatedAt: OLD, + archivedAt: null, + settledOverride: "settled", + settledAt: OLD, + session: null, + latestUserMessageAt: OLD, + hasPendingApprovals: false, + hasPendingUserInput: false, + hasActionableProposedPlan: false, + backgroundLiveness: null, + planProgress: null, + ...overrides, + }; +} + +function makeDetail(worktreePath: string, bytes: number): WorktreeStorageDetail { + return { + projectId: ProjectId.make("project-1"), + projectTitle: "Project", + worktreePath, + bytes, + associatedThreadCount: 1, + associatedThreadIds: [ThreadId.make("thread-1")], + latestActivityAt: OLD, + stale: true, + eligible: true, + protectionReasons: [], + scanErrors: [], + }; +} + +it.layer(NodeServices.layer)("worktree storage safety decisions", (it) => { + it.effect("uses canonical containment and excludes the managed root itself", () => + Effect.gen(function* () { + const path = yield* Path.Path; + expect(isCanonicallyContained(path, "/managed", "/managed/worktree")).toBe(true); + expect(isCanonicallyContained(path, "/managed", "/managed")).toBe(false); + expect(isCanonicallyContained(path, "/managed", "/managed-other/worktree")).toBe(false); + expect(hasLivePathUse(path, "/managed/worktree", ["/managed/worktree/subdirectory"])).toBe( + true, + ); + expect(hasLivePathUse(path, "/managed/worktree", ["/managed/other"])).toBe(false); + }), + ); + + it("protects shared and live-terminal associations", () => { + expect(associationReasons({ projectCount: 2, hasLiveTerminalPath: true })).toEqual([ + "shared-across-projects", + "live-terminal", + ]); + }); + + it("protects active, provider, terminal, approval, input, plan, and background liveness", () => { + const thread = makeThread({ + session: { + threadId: ThreadId.make("thread-1"), + status: "running", + providerName: "Codex", + runtimeMode: "full-access", + activeTurnId: null, + lastError: null, + updatedAt: OLD, + }, + hasPendingApprovals: true, + hasPendingUserInput: true, + hasActionableProposedPlan: true, + backgroundLiveness: "working", + }); + const reasons = threadReasons( + thread, + { mode: "manual" }, + new Set([thread.id]), + new Set([thread.id]), + ); + expect(reasons).toEqual( + expect.arrayContaining([ + "active-turn-or-session", + "live-provider", + "live-terminal", + "pending-approval", + "pending-input", + "pending-plan", + "background-liveness", + ]), + ); + }); + + it("gates automatic modes and protects activity at the cutoff", () => { + const nowMs = Date.parse("2026-02-01T00:00:00.000Z"); + expect(automaticScanMode({ mode: "off" }, nowMs)).toBeNull(); + expect(automaticScanMode({ mode: "on-settle" }, nowMs)).toEqual({ + mode: "manual", + }); + const mode = automaticScanMode({ mode: "after-inactive-days", inactivityDays: 7 }, nowMs); + expect(mode).toEqual({ mode: "inactive", cutoffMs: nowMs - 7 * 24 * 60 * 60 * 1_000 }); + if (mode !== null) { + expect(threadReasons(makeThread(), mode, new Set(), new Set())).not.toContain( + "recent-activity", + ); + expect(shouldProtectOrphan(0)).toBe(true); + } + expect(shouldProtectOrphan(0)).toBe(true); + expect(shouldProtectOrphan(1)).toBe(false); + expect(automaticPolicyKey({ mode: "off" })).toBe("off"); + expect(automaticPolicyKey({ mode: "on-settle" })).toBe("on-settle"); + expect(automaticPolicyKey({ mode: "after-inactive-days", inactivityDays: 30 })).toBe( + "after-inactive-days:30", + ); + expect(shouldRunAutomaticFallback({ mode: "on-settle" })).toBe(false); + expect(shouldRunAutomaticFallback({ mode: "after-inactive-days", inactivityDays: 30 })).toBe( + true, + ); + }); + + it.effect("deduplicates automatic triggers by policy value", () => + Stream.fromIterable([ + { mode: "on-settle" } as const, + { mode: "on-settle" } as const, + { mode: "after-inactive-days", inactivityDays: 30 } as const, + { mode: "after-inactive-days", inactivityDays: 30 } as const, + { mode: "after-inactive-days", inactivityDays: 60 } as const, + ]).pipe( + Stream.map(automaticPolicyKey), + Stream.changes, + Stream.runCollect, + Effect.map((keys) => + expect(Array.from(keys)).toEqual([ + "on-settle", + "after-inactive-days:30", + "after-inactive-days:60", + ]), + ), + ), + ); + + it("restores reserved metadata only when physical removal fails", () => { + expect(shouldRestoreReservedPaths(false)).toBe(true); + expect(shouldRestoreReservedPaths(true)).toBe(false); + }); + + it("distinguishes an applied path CAS from its persisted no-op event", () => { + const threadId = ThreadId.make("thread-cas"); + const base = { + sequence: 42, + eventId: EventId.make("event-cas"), + aggregateKind: "thread", + aggregateId: threadId, + occurredAt: OLD, + commandId: CommandId.make("command-cas"), + causationEventId: null, + correlationId: null, + metadata: {}, + type: "thread.meta-updated", + } as const; + const applied: OrchestrationEvent = { + ...base, + payload: { threadId, worktreePath: null, updatedAt: OLD }, + }; + const mismatch: OrchestrationEvent = { + ...base, + payload: { threadId, updatedAt: OLD }, + }; + expect( + isAppliedThreadPathEvent({ event: applied, sequence: 42, threadId, worktreePath: null }), + ).toBe(true); + expect( + isAppliedThreadPathEvent({ event: mismatch, sequence: 42, threadId, worktreePath: null }), + ).toBe(false); + expect( + reservationIsValid({ + candidateStillRegistered: true, + associationThreadCount: 0, + expectedThreadCount: 1, + currentThreadPaths: [null], + becameLive: false, + }), + ).toBe(true); + expect( + reservationIsValid({ + candidateStillRegistered: true, + associationThreadCount: 0, + expectedThreadCount: 1, + currentThreadPaths: ["/managed/rebound"], + becameLive: false, + }), + ).toBe(false); + }); + + it.effect("finalizes reservations on failure and interruption but not successful removal", () => + Effect.gen(function* () { + const restoreCount = yield* Ref.make(0); + const restore = Ref.update(restoreCount, (count) => count + 1); + + const failed = yield* Effect.result( + withReservationRestoration(Effect.fail("failed before removal"), restore), + ); + expect(Result.isFailure(failed)).toBe(true); + expect(yield* Ref.get(restoreCount)).toBe(1); + + yield* withReservationRestoration( + Effect.succeed({ value: undefined, physicalRemovalSucceeded: false }), + restore, + ); + expect(yield* Ref.get(restoreCount)).toBe(2); + + const entered = yield* Deferred.make(); + const blocked = yield* Deferred.make(); + const interrupted = yield* withReservationRestoration( + Deferred.succeed(entered, undefined).pipe( + Effect.andThen(Deferred.await(blocked)), + Effect.as({ value: undefined, physicalRemovalSucceeded: false }), + ), + restore, + ).pipe(Effect.forkChild); + yield* Deferred.await(entered); + yield* Fiber.interrupt(interrupted); + expect(yield* Ref.get(restoreCount)).toBe(3); + + yield* withReservationRestoration( + Effect.succeed({ value: undefined, physicalRemovalSucceeded: true }), + restore, + ); + expect(yield* Ref.get(restoreCount)).toBe(3); + }), + ); + + it("treats detached, locked, and prunable porcelain entries as unsafe metadata", () => { + const validOutput = + "worktree /main\0HEAD abc\0branch refs/heads/main\0\0" + + "worktree /detached\0HEAD def\0detached\0\0" + + "worktree /locked\0HEAD ghi\0branch refs/heads/locked\0locked reason\0\0" + + "worktree /prunable\0HEAD jkl\0branch refs/heads/prunable\0prunable reason\0\0"; + expect(parseWorktreePorcelain(validOutput)).toEqual([ + { path: "/main", locked: false, prunable: false, detached: false }, + { path: "/detached", locked: false, prunable: false, detached: true }, + { path: "/locked", locked: true, prunable: false, detached: false }, + { path: "/prunable", locked: false, prunable: true, detached: false }, + ]); + expect("entries" in decodeWorktreePorcelain(validOutput)).toBe(true); + + expect("error" in decodeWorktreePorcelain("worktree /detached\0HEAD def\0")).toBe(true); + expect("error" in decodeWorktreePorcelain("worktree /detached\0HEAD def\0\0")).toBe(true); + expect(worktreeListOutputError({ stdoutTruncated: true })).toContain("safety limit"); + expect(worktreeListOutputError({ stdoutTruncated: false, stdoutInvalidUtf8: true })).toContain( + "UTF-8", + ); + expect( + worktreeListOutputError({ stdoutTruncated: false, stdoutInvalidUtf8: false }), + ).toBeNull(); + }); + + it("uses stable ranking tie-breakers and a non-force removal command", () => { + const projects: WorktreeStorageProjectAggregate[] = [ + { + projectId: ProjectId.make("project-b"), + projectTitle: "B", + bytes: 10, + worktreeCount: 1, + staleWorktreeCount: 1, + eligibleWorktreeCount: 1, + }, + { + projectId: ProjectId.make("project-a"), + projectTitle: "A", + bytes: 10, + worktreeCount: 1, + staleWorktreeCount: 1, + eligibleWorktreeCount: 1, + }, + { + projectId: ProjectId.make("project-c"), + projectTitle: "C", + bytes: 20, + worktreeCount: 1, + staleWorktreeCount: 1, + eligibleWorktreeCount: 1, + }, + ]; + expect(rankProjects(projects).map((project) => project.projectId)).toEqual([ + "project-c", + "project-a", + "project-b", + ]); + expect( + rankDetails([makeDetail("/managed/b", 10), makeDetail("/managed/a", 10)]).map( + (detail) => detail.worktreePath, + ), + ).toEqual(["/managed/a", "/managed/b"]); + expect(worktreeRemovalArgs("/managed/worktree")).toEqual([ + "worktree", + "remove", + "/managed/worktree", + ]); + expect(worktreeRemovalArgs("/managed/worktree")).not.toContain("--force"); + }); + + it("bounds and rotates aggregate candidate scans", () => { + expect(selectCandidateWindow(["a", "b", "c", "d"], 2, 2)).toEqual({ + selected: ["c", "d"], + omittedCandidateCount: 2, + }); + expect(selectCandidateWindow(["a", "b", "c", "d"], 3, 2)).toEqual({ + selected: ["d", "a"], + omittedCandidateCount: 2, + }); + }); +}); diff --git a/apps/server/src/worktree/WorktreeStorage.ts b/apps/server/src/worktree/WorktreeStorage.ts new file mode 100644 index 000000000000..ae906d6a79ff --- /dev/null +++ b/apps/server/src/worktree/WorktreeStorage.ts @@ -0,0 +1,1463 @@ +import { + CommandId, + ThreadId, + WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS, + WORKTREE_STORAGE_MAX_DETAILS, + WORKTREE_STORAGE_MAX_ERRORS, + WORKTREE_STORAGE_MAX_OUTCOMES, + WORKTREE_STORAGE_MAX_PROJECTS, + WorktreeStorageError, + type OrchestrationEvent, + type OrchestrationProjectShell, + type OrchestrationThreadShell, + type ProjectId, + type WorktreeAutoPrunePolicy, + type WorktreeStorageDetail, + type WorktreeStorageProjectAggregate, + type WorktreeStorageProtectionReason, + type WorktreeStoragePruneOutcome, + type WorktreeStoragePruneResult, + type WorktreeStorageReport, + type WorktreeStorageScanError, +} from "@t3tools/contracts"; +import * as Context from "effect/Context"; +import * as Crypto from "effect/Crypto"; +import * as DateTime from "effect/DateTime"; +import * as Duration from "effect/Duration"; +import * as Effect from "effect/Effect"; +import * as Exit from "effect/Exit"; +import * as FileSystem from "effect/FileSystem"; +import * as Layer from "effect/Layer"; +import * as Option from "effect/Option"; +import * as Path from "effect/Path"; +import * as Ref from "effect/Ref"; +import * as Result from "effect/Result"; +import * as Semaphore from "effect/Semaphore"; +import * as Stream from "effect/Stream"; + +import * as ServerConfig from "../config.ts"; +import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; +import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; +import * as ProviderService from "../provider/Services/ProviderService.ts"; +import * as ServerSettings from "../serverSettings.ts"; +import * as TerminalManager from "../terminal/Manager.ts"; +import * as VcsProcess from "../vcs/VcsProcess.ts"; +import * as VcsStatusBroadcaster from "../vcs/VcsStatusBroadcaster.ts"; +import { + discoverWorktreeDirectoriesNoFollowPromise, + measureDirectoryNoFollowPromise, +} from "./directorySize.ts"; + +const SIZE_SCAN_CONCURRENCY = 4; +const MAX_CANDIDATES_PER_SCAN = 200; +const SIZE_SCAN_MAX_ENTRIES = 250_000; +const SIZE_SCAN_MAX_DURATION_MS = 15_000; +const AUTO_SWEEP_INTERVAL = Duration.hours(6); +const GIT_TIMEOUT_MS = 30_000; +const GIT_MAX_OUTPUT_BYTES = 1_000_000; + +export type ScanMode = + | { readonly mode: "manual" } + | { readonly mode: "inactive"; cutoffMs: number }; + +interface CandidateAssociation { + readonly key: string; + readonly worktreePath: string; + readonly projects: ReadonlyArray; + readonly threads: ReadonlyArray; +} + +interface ScanContext { + readonly associations: ReadonlyArray; + readonly threadsById: ReadonlyMap; + readonly liveProviderThreadIds: ReadonlySet; + readonly liveProviderPaths: ReadonlyArray; + readonly liveTerminalThreadIds: ReadonlySet; + readonly liveTerminalPaths: ReadonlyArray; + readonly inventoryErrors: ReadonlyArray; +} + +interface InternalCandidateScan { + readonly key: string; + readonly association: CandidateAssociation; + readonly detail: WorktreeStorageDetail; + readonly mainWorktreePath: string | null; + readonly removalPath: string | null; +} + +interface WorktreePorcelainEntry { + readonly path: string; + readonly locked: boolean; + readonly prunable: boolean; + readonly detached: boolean; +} + +interface CandidateScanBatch { + readonly scans: ReadonlyArray; + readonly omittedCandidateCount: number; + readonly inventoryErrors: ReadonlyArray; +} + +interface ReservedThreadPath { + readonly thread: OrchestrationThreadShell; + readonly restoreCommandId: CommandId; +} + +interface ThreadPathReservation { + readonly threads: ReadonlyArray; + readonly errors: ReadonlyArray; +} + +interface DirectorySizeResult { + readonly bytes: number; + readonly errors: ReadonlyArray; +} + +interface GitInspectionResult { + readonly reasons: ReadonlyArray; + readonly errors: ReadonlyArray; + readonly mainWorktreePath: string | null; + readonly targetWorktreePath: string | null; +} + +function boundedMessage(cause: unknown): string { + const value = cause instanceof Error ? cause.message : String(cause); + const trimmed = value.trim(); + return (trimmed.length === 0 ? "Unknown inspection failure." : trimmed).slice(0, 1_024); +} + +function scanError(operation: string, cause: unknown, path?: string): WorktreeStorageScanError { + return { + operation: operation.slice(0, 128) || "inspect", + message: boundedMessage(cause), + ...(path === undefined ? {} : { path: path.slice(0, 4_096) }), + }; +} + +function safeByteCount(value: number): number { + return Math.min(Number.MAX_SAFE_INTEGER, Math.max(0, Math.trunc(value))); +} + +export function isCanonicallyContained(path: Path.Path, root: string, candidate: string): boolean { + const relative = path.relative(root, candidate); + return ( + relative !== "" && + relative !== ".." && + !relative.startsWith(`..${path.sep}`) && + !path.isAbsolute(relative) + ); +} + +function isSameOrDescendant(path: Path.Path, parent: string, candidate: string): boolean { + const relative = path.relative(parent, candidate); + return ( + relative === "" || + (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative)) + ); +} + +export function hasLivePathUse( + path: Path.Path, + candidatePath: string, + livePaths: ReadonlyArray, +): boolean { + return livePaths.some((livePath) => isSameOrDescendant(path, candidatePath, livePath)); +} + +export function parseWorktreePorcelain(output: string): ReadonlyArray { + const entries: WorktreePorcelainEntry[] = []; + let current: { path?: string; locked: boolean; prunable: boolean; detached: boolean } = { + locked: false, + prunable: false, + detached: false, + }; + const flush = () => { + if (current.path !== undefined) { + entries.push({ + path: current.path, + locked: current.locked, + prunable: current.prunable, + detached: current.detached, + }); + } + current = { locked: false, prunable: false, detached: false }; + }; + + for (const field of output.split("\0")) { + if (field === "") { + flush(); + continue; + } + if (field.startsWith("worktree ")) { + current.path = field.slice("worktree ".length); + } else if (field === "locked" || field.startsWith("locked ")) { + current.locked = true; + } else if (field === "prunable" || field.startsWith("prunable ")) { + current.prunable = true; + } else if (field === "detached") { + current.detached = true; + } + } + flush(); + return entries; +} + +export function decodeWorktreePorcelain( + output: string, +): { readonly entries: ReadonlyArray } | { readonly error: string } { + if (output.length === 0 || !output.endsWith("\0\0")) { + return { error: "Git worktree porcelain output was empty or unterminated." }; + } + const records = output.slice(0, -2).split("\0\0"); + const entries: WorktreePorcelainEntry[] = []; + for (const record of records) { + const fields = record.split("\0"); + const worktreeFields = fields.filter((field) => field.startsWith("worktree ")); + const headFields = fields.filter((field) => field.startsWith("HEAD ")); + const branchFields = fields.filter((field) => field.startsWith("branch ")); + const detachedFields = fields.filter((field) => field === "detached"); + const worktreeField = worktreeFields[0]; + const headField = headFields[0]; + if ( + fields[0]?.startsWith("worktree ") !== true || + worktreeFields.length !== 1 || + worktreeField === undefined || + worktreeField.slice("worktree ".length).length === 0 || + headFields.length !== 1 || + headField === undefined || + headField.slice("HEAD ".length).length === 0 || + branchFields.length + detachedFields.length !== 1 + ) { + return { error: "Git worktree porcelain output contained a malformed record." }; + } + entries.push({ + path: worktreeField.slice("worktree ".length), + locked: fields.some((field) => field === "locked" || field.startsWith("locked ")), + prunable: fields.some((field) => field === "prunable" || field.startsWith("prunable ")), + detached: detachedFields.length === 1, + }); + } + return entries.length === 0 + ? { error: "Git worktree porcelain output contained no records." } + : { entries }; +} + +export function worktreeListOutputError(output: { + readonly stdoutTruncated: boolean; + readonly stdoutInvalidUtf8?: boolean; +}): string | null { + if (output.stdoutTruncated) { + return "Git worktree porcelain output exceeded the safety limit."; + } + if (output.stdoutInvalidUtf8 === true) { + return "Git worktree porcelain output was not valid UTF-8."; + } + return null; +} + +export function isAppliedThreadPathEvent(input: { + readonly event: OrchestrationEvent; + readonly sequence: number; + readonly threadId: ThreadId; + readonly worktreePath: string | null; +}): boolean { + return ( + input.event.sequence === input.sequence && + input.event.type === "thread.meta-updated" && + input.event.payload.threadId === input.threadId && + Object.prototype.hasOwnProperty.call(input.event.payload, "worktreePath") && + input.event.payload.worktreePath === input.worktreePath + ); +} + +export function withReservationRestoration( + use: Effect.Effect<{ readonly value: A; readonly physicalRemovalSucceeded: boolean }, E, R>, + restore: Effect.Effect, +): Effect.Effect { + return use.pipe( + Effect.onExit((exit) => + Exit.isSuccess(exit) && exit.value.physicalRemovalSucceeded ? Effect.void : restore, + ), + Effect.map(({ value }) => value), + ); +} + +export const measureDirectoryNoFollow = Effect.fn("WorktreeStorage.measureDirectoryNoFollow")( + function* (rootPath: string): Effect.fn.Return { + const result = yield* Effect.promise(() => + measureDirectoryNoFollowPromise(rootPath, { + maxEntries: SIZE_SCAN_MAX_ENTRIES, + maxDurationMs: SIZE_SCAN_MAX_DURATION_MS, + maxFailures: WORKTREE_STORAGE_MAX_ERRORS, + }), + ); + return { + bytes: safeByteCount(result.bytes), + errors: result.failures.map((failure) => + scanError(failure.operation, failure.cause, failure.path), + ), + }; + }, +); + +function latestDurableActivity(thread: OrchestrationThreadShell): { + readonly value: string | null; + readonly epochMs: number | null; + readonly unknown: boolean; +} { + const values = [ + thread.createdAt, + thread.updatedAt, + thread.archivedAt, + thread.settledAt, + thread.latestUserMessageAt, + thread.latestTurn?.requestedAt, + thread.latestTurn?.startedAt, + thread.latestTurn?.completedAt, + thread.session?.updatedAt, + ].filter((value): value is string => value !== null && value !== undefined); + let latestValue: string | null = null; + let latestEpochMs: number | null = null; + for (const value of values) { + const parsed = DateTime.make(value); + if (Option.isNone(parsed)) return { value: null, epochMs: null, unknown: true }; + const epochMs = DateTime.toEpochMillis(parsed.value); + if (latestEpochMs === null || epochMs > latestEpochMs) { + latestValue = value; + latestEpochMs = epochMs; + } + } + return { value: latestValue, epochMs: latestEpochMs, unknown: latestEpochMs === null }; +} + +function addReason( + reasons: Set, + condition: boolean, + reason: WorktreeStorageProtectionReason, +): void { + if (condition) reasons.add(reason); +} + +export function threadReasons( + thread: OrchestrationThreadShell, + mode: ScanMode, + liveProviderThreadIds: ReadonlySet, + liveTerminalThreadIds: ReadonlySet, +): ReadonlyArray { + const reasons = new Set(); + const activity = latestDurableActivity(thread); + if (mode.mode === "manual") { + addReason(reasons, thread.archivedAt === null && thread.settledAt === null, "unsettled-thread"); + } else { + addReason( + reasons, + activity.unknown || activity.epochMs === null || activity.epochMs >= mode.cutoffMs, + activity.unknown ? "inspection-error" : "recent-activity", + ); + } + + addReason(reasons, thread.latestTurn?.state === "running", "active-turn-or-session"); + addReason( + reasons, + thread.session !== null && + (thread.session.activeTurnId !== null || + thread.session.status === "starting" || + thread.session.status === "running" || + thread.session.status === "ready"), + "active-turn-or-session", + ); + addReason(reasons, thread.hasPendingApprovals, "pending-approval"); + addReason(reasons, thread.hasPendingUserInput, "pending-input"); + addReason( + reasons, + thread.hasActionableProposedPlan || thread.planProgress != null, + "pending-plan", + ); + addReason(reasons, thread.backgroundLiveness != null, "background-liveness"); + addReason(reasons, liveProviderThreadIds.has(thread.id), "live-provider"); + addReason(reasons, liveTerminalThreadIds.has(thread.id), "live-terminal"); + return [...reasons].sort(); +} + +export function associationReasons(input: { + readonly projectCount: number; + readonly hasLiveTerminalPath: boolean; +}): ReadonlyArray { + const reasons: WorktreeStorageProtectionReason[] = []; + if (input.projectCount > 1) reasons.push("shared-across-projects"); + if (input.hasLiveTerminalPath) reasons.push("live-terminal"); + return reasons; +} + +export function rankProjects( + projects: ReadonlyArray, +): ReadonlyArray { + return [...projects].sort( + (left, right) => right.bytes - left.bytes || left.projectId.localeCompare(right.projectId), + ); +} + +export function rankDetails( + details: ReadonlyArray, +): ReadonlyArray { + return [...details].sort( + (left, right) => + right.bytes - left.bytes || left.worktreePath.localeCompare(right.worktreePath), + ); +} + +export function worktreeRemovalArgs(worktreePath: string): ReadonlyArray { + return ["worktree", "remove", worktreePath]; +} + +export function shouldRestoreReservedPaths(removalSucceeded: boolean): boolean { + return !removalSucceeded; +} + +export function reservationIsValid(input: { + readonly candidateStillRegistered: boolean; + readonly associationThreadCount: number; + readonly expectedThreadCount: number; + readonly currentThreadPaths: ReadonlyArray; + readonly becameLive: boolean; +}): boolean { + return ( + input.candidateStillRegistered && + input.associationThreadCount === 0 && + input.currentThreadPaths.length === input.expectedThreadCount && + input.currentThreadPaths.every((worktreePath) => worktreePath === null) && + !input.becameLive + ); +} + +export function automaticScanMode(policy: WorktreeAutoPrunePolicy, nowMs: number): ScanMode | null { + switch (policy.mode) { + case "off": + return null; + case "on-settle": + return { mode: "manual" }; + case "after-inactive-days": + return { + mode: "inactive", + cutoffMs: nowMs - Duration.toMillis(Duration.days(policy.inactivityDays)), + }; + } +} + +export function automaticPolicyKey(policy: WorktreeAutoPrunePolicy): string { + return policy.mode === "after-inactive-days" + ? `${policy.mode}:${policy.inactivityDays}` + : policy.mode; +} + +export function shouldRunAutomaticFallback(policy: WorktreeAutoPrunePolicy): boolean { + return policy.mode === "after-inactive-days"; +} + +export function shouldProtectOrphan(threadCount: number): boolean { + return threadCount === 0; +} + +export function selectCandidateWindow( + candidates: ReadonlyArray, + startIndex: number, + limit = MAX_CANDIDATES_PER_SCAN, +): { readonly selected: ReadonlyArray; readonly omittedCandidateCount: number } { + const normalizedStart = candidates.length === 0 ? 0 : startIndex % candidates.length; + const ordered = [...candidates.slice(normalizedStart), ...candidates.slice(0, normalizedStart)]; + const selected = ordered.slice(0, limit); + return { + selected, + omittedCandidateCount: Math.max(0, ordered.length - selected.length), + }; +} + +function aggregateLatestActivity(threads: ReadonlyArray): string | null { + let latest: { value: string; epochMs: number } | null = null; + for (const thread of threads) { + const activity = latestDurableActivity(thread); + if (activity.value === null || activity.epochMs === null) continue; + if (latest === null || activity.epochMs > latest.epochMs) { + latest = { value: activity.value, epochMs: activity.epochMs }; + } + } + return latest?.value ?? null; +} + +export interface WorktreeStorageService { + readonly getReport: Effect.Effect; + readonly pruneStale: Effect.Effect; +} + +const unavailable = (operation: "report" | "prune") => + Effect.fail( + new WorktreeStorageError({ + operation, + message: "Worktree storage is not available on this environment.", + }), + ); + +/** Defaulting keeps older test/server layer compositions version-skew safe. */ +export class WorktreeStorage extends Context.Reference( + "t3/worktree/WorktreeStorage", + { + defaultValue: () => ({ + getReport: unavailable("report"), + pruneStale: unavailable("prune"), + }), + }, +) {} + +export const make = Effect.gen(function* () { + const config = yield* ServerConfig.ServerConfig; + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const projection = yield* ProjectionSnapshotQuery.ProjectionSnapshotQuery; + const engine = yield* OrchestrationEngine.OrchestrationEngineService; + const providers = yield* ProviderService.ProviderService; + const terminals = yield* TerminalManager.TerminalManager; + const settings = yield* ServerSettings.ServerSettingsService; + const vcsProcess = yield* VcsProcess.VcsProcess; + const vcsStatus = yield* VcsStatusBroadcaster.VcsStatusBroadcaster; + const crypto = yield* Crypto.Crypto; + const pruneLock = yield* Semaphore.make(1); + const pruneCursor = yield* Ref.make(0); + + const runGit = (cwd: string, args: ReadonlyArray) => + vcsProcess.run({ + operation: "worktree-storage", + command: "git", + args, + cwd, + allowNonZeroExit: true, + timeoutMs: GIT_TIMEOUT_MS, + maxOutputBytes: GIT_MAX_OUTPUT_BYTES, + }); + + const inspectGitCandidate = Effect.fn("WorktreeStorage.inspectGitCandidate")(function* ( + candidatePath: string, + ): Effect.fn.Return { + const reasons = new Set(); + const errors: WorktreeStorageScanError[] = []; + const listResult = yield* Effect.result( + runGit(candidatePath, ["worktree", "list", "--porcelain", "-z"]), + ); + if (Result.isFailure(listResult) || listResult.success.exitCode !== 0) { + const cause = Result.isFailure(listResult) + ? listResult.failure + : listResult.success.stderr || `git exited ${listResult.success.exitCode}`; + return { + reasons: ["inspection-error"], + errors: [scanError("git-worktree-list", cause, candidatePath)], + mainWorktreePath: null, + targetWorktreePath: null, + }; + } + + const unsafeOutput = worktreeListOutputError(listResult.success); + if (unsafeOutput !== null) { + return { + reasons: ["inspection-error"], + errors: [scanError("git-worktree-list", unsafeOutput, candidatePath)], + mainWorktreePath: null, + targetWorktreePath: null, + }; + } + const decodedEntries = decodeWorktreePorcelain(listResult.success.stdout); + if ("error" in decodedEntries) { + return { + reasons: ["inspection-error"], + errors: [scanError("git-worktree-list", decodedEntries.error, candidatePath)], + mainWorktreePath: null, + targetWorktreePath: null, + }; + } + const entries = decodedEntries.entries; + const canonicalEntries = yield* Effect.forEach( + entries, + (entry) => + fileSystem.realPath(entry.path).pipe( + Effect.orElseSucceed(() => path.resolve(entry.path)), + Effect.map((canonicalPath) => ({ + ...entry, + originalPath: entry.path, + path: canonicalPath, + })), + ), + { concurrency: 4 }, + ); + const mainWorktreePath = canonicalEntries[0]?.path ?? null; + const target = canonicalEntries.find((entry) => entry.path === candidatePath); + const targetWorktreePath = target?.originalPath ?? null; + addReason(reasons, target === undefined, "locked-or-unknown"); + addReason(reasons, mainWorktreePath === candidatePath, "main-checkout"); + addReason( + reasons, + target?.locked === true || target?.prunable === true || target?.detached === true, + "locked-or-unknown", + ); + if (reasons.size > 0) { + return { reasons: [...reasons].sort(), errors, mainWorktreePath, targetWorktreePath }; + } + + const statusResult = yield* Effect.result( + runGit(candidatePath, ["status", "--porcelain=v1", "--untracked-files=normal"]), + ); + if (Result.isFailure(statusResult) || statusResult.success.exitCode !== 0) { + const cause = Result.isFailure(statusResult) + ? statusResult.failure + : statusResult.success.stderr || `git exited ${statusResult.success.exitCode}`; + reasons.add("inspection-error"); + errors.push(scanError("git-status", cause, candidatePath)); + } else if (statusResult.success.stdout.trim().length > 0) { + reasons.add("dirty-or-untracked"); + } + + const upstreamResult = yield* Effect.result( + runGit(candidatePath, ["rev-parse", "--verify", "@{upstream}"]), + ); + if (Result.isFailure(upstreamResult)) { + reasons.add("inspection-error"); + errors.push(scanError("git-upstream", upstreamResult.failure, candidatePath)); + } else if (upstreamResult.success.exitCode === 0) { + const aheadResult = yield* Effect.result( + runGit(candidatePath, ["rev-list", "--count", "@{upstream}..HEAD"]), + ); + if (Result.isFailure(aheadResult) || aheadResult.success.exitCode !== 0) { + reasons.add("inspection-error"); + errors.push( + scanError( + "git-ahead", + Result.isFailure(aheadResult) + ? aheadResult.failure + : aheadResult.success.stderr || `git exited ${aheadResult.success.exitCode}`, + candidatePath, + ), + ); + } else if (Number.parseInt(aheadResult.success.stdout.trim(), 10) > 0) { + reasons.add("ahead-or-unpushed"); + } + } else { + const remoteContainsResult = yield* Effect.result( + runGit(candidatePath, ["branch", "-r", "--contains", "HEAD", "--format=%(refname)"]), + ); + if ( + Result.isFailure(remoteContainsResult) || + remoteContainsResult.success.exitCode !== 0 || + remoteContainsResult.success.stdout.trim().length === 0 + ) { + reasons.add("ahead-or-unpushed"); + if (Result.isFailure(remoteContainsResult)) { + errors.push( + scanError("git-remote-contains", remoteContainsResult.failure, candidatePath), + ); + } + } + } + + return { reasons: [...reasons].sort(), errors, mainWorktreePath, targetWorktreePath }; + }); + + const loadScanContext = Effect.fn("WorktreeStorage.loadScanContext")( + function* (): Effect.fn.Return { + const snapshots = yield* Effect.all({ + active: projection.getShellSnapshot(), + archived: projection.getArchivedShellSnapshot(), + providerSessions: providers.listSessions(), + terminalSummaries: + terminals.listSummaries ?? Effect.fail("Terminal summary inspection is unavailable."), + inventory: Effect.promise(() => + discoverWorktreeDirectoriesNoFollowPromise(config.worktreesDir, { + maxEntries: 10_000, + maxDurationMs: 5_000, + maxFailures: WORKTREE_STORAGE_MAX_ERRORS, + }), + ), + }).pipe( + Effect.mapError( + (cause) => + new WorktreeStorageError({ + operation: "report", + message: "Failed to load current thread state for worktree inspection.", + cause, + }), + ), + ); + const projectsById = new Map( + [...snapshots.active.projects, ...snapshots.archived.projects].map( + (project) => [project.id, project] as const, + ), + ); + const associations = new Map< + string, + { + worktreePath: string; + projects: Map; + threads: OrchestrationThreadShell[]; + } + >(); + const referencedThreads = yield* Effect.forEach( + [...snapshots.active.threads, ...snapshots.archived.threads], + (thread) => + thread.worktreePath === null + ? Effect.succeed(null) + : fileSystem.realPath(thread.worktreePath).pipe( + Effect.orElseSucceed(() => path.resolve(thread.worktreePath!)), + Effect.map((key) => ({ key, thread, worktreePath: thread.worktreePath! })), + ), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + for (const referenced of referencedThreads) { + if (referenced === null) continue; + const { key, thread, worktreePath } = referenced; + const project = projectsById.get(thread.projectId); + const existing = associations.get(key); + if (existing === undefined) { + associations.set(key, { + worktreePath, + projects: new Map(project === undefined ? [] : ([[project.id, project]] as const)), + threads: [thread], + }); + } else { + if (project !== undefined) existing.projects.set(project.id, project); + existing.threads.push(thread); + } + } + const discoveredPaths = yield* Effect.forEach( + snapshots.inventory.paths, + (worktreePath) => + fileSystem.realPath(worktreePath).pipe( + Effect.orElseSucceed(() => path.resolve(worktreePath)), + Effect.map((key) => ({ key, worktreePath })), + ), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + for (const discovered of discoveredPaths) { + if (!associations.has(discovered.key)) { + associations.set(discovered.key, { + worktreePath: discovered.worktreePath, + projects: new Map(), + threads: [], + }); + } + } + const liveProviderSessions = snapshots.providerSessions.filter( + (session) => + session.status === "connecting" || + session.status === "ready" || + session.status === "running", + ); + const liveProviderThreadIds = new Set( + liveProviderSessions.map((session) => session.threadId), + ); + const liveProviderPaths = yield* Effect.forEach( + liveProviderSessions, + (session) => + session.cwd === undefined + ? Effect.succeed(null) + : fileSystem + .realPath(session.cwd) + .pipe(Effect.orElseSucceed(() => path.resolve(session.cwd!))), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + const liveTerminals = snapshots.terminalSummaries.filter( + (terminal) => + terminal.status === "starting" || + terminal.status === "running" || + terminal.hasRunningSubprocess, + ); + const liveTerminalPaths = yield* Effect.forEach( + liveTerminals.flatMap((terminal) => [terminal.worktreePath, terminal.cwd]), + (terminalPath) => + terminalPath === null + ? Effect.succeed(null) + : fileSystem + .realPath(terminalPath) + .pipe(Effect.orElseSucceed(() => path.resolve(terminalPath))), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + return { + associations: [...associations.entries()] + .map(([key, value]) => ({ + key, + worktreePath: value.worktreePath, + projects: [...value.projects.values()].sort((left, right) => + left.id.localeCompare(right.id), + ), + threads: [...value.threads].sort((left, right) => left.id.localeCompare(right.id)), + })) + .sort((left, right) => left.key.localeCompare(right.key)), + threadsById: new Map( + [...snapshots.active.threads, ...snapshots.archived.threads].map( + (thread) => [thread.id, thread] as const, + ), + ), + liveProviderThreadIds, + liveProviderPaths: liveProviderPaths.filter( + (providerPath): providerPath is string => providerPath !== null, + ), + liveTerminalThreadIds: new Set(liveTerminals.map((terminal) => terminal.threadId)), + liveTerminalPaths: liveTerminalPaths.filter( + (worktreePath): worktreePath is string => worktreePath !== null, + ), + inventoryErrors: snapshots.inventory.failures.map((failure) => + scanError(`inventory-${failure.operation}`, failure.cause, failure.path), + ), + }; + }, + ); + + const scanCandidate = Effect.fn("WorktreeStorage.scanCandidate")(function* ( + association: CandidateAssociation, + context: ScanContext, + mode: ScanMode, + ): Effect.fn.Return { + const reasons = new Set(); + const errors: WorktreeStorageScanError[] = []; + let canonicalRoot: string | null = null; + let canonicalCandidate: string | null = null; + const canonical = yield* Effect.result( + Effect.all({ + root: fileSystem.realPath(config.worktreesDir), + candidate: fileSystem.realPath(association.worktreePath), + }), + ); + if (Result.isFailure(canonical)) { + const exists = yield* fileSystem + .exists(association.worktreePath) + .pipe(Effect.orElseSucceed(() => false)); + reasons.add(exists ? "inspection-error" : "missing"); + errors.push(scanError("canonicalize", canonical.failure, association.worktreePath)); + } else { + canonicalRoot = canonical.success.root; + canonicalCandidate = canonical.success.candidate; + addReason( + reasons, + !isCanonicallyContained(path, canonicalRoot, canonicalCandidate), + "outside-managed-root", + ); + } + for (const reason of associationReasons({ + projectCount: association.projects.length, + hasLiveTerminalPath: hasLivePathUse( + path, + canonicalCandidate ?? association.key, + context.liveTerminalPaths, + ), + })) { + reasons.add(reason); + } + addReason( + reasons, + hasLivePathUse(path, canonicalCandidate ?? association.key, context.liveProviderPaths), + "live-provider", + ); + addReason(reasons, shouldProtectOrphan(association.threads.length), "unowned-or-orphaned"); + for (const thread of association.threads) { + for (const reason of threadReasons( + thread, + mode, + context.liveProviderThreadIds, + context.liveTerminalThreadIds, + )) { + reasons.add(reason); + } + } + + const size = yield* measureDirectoryNoFollow(association.worktreePath); + errors.push(...size.errors.slice(0, WORKTREE_STORAGE_MAX_ERRORS - errors.length)); + if (size.errors.length > 0) reasons.add("inspection-error"); + + let gitInspection: GitInspectionResult = { + reasons: [], + errors: [], + mainWorktreePath: null, + targetWorktreePath: null, + }; + if (canonicalCandidate !== null && reasons.has("outside-managed-root") === false) { + gitInspection = yield* inspectGitCandidate(canonicalCandidate); + for (const reason of gitInspection.reasons) reasons.add(reason); + errors.push(...gitInspection.errors.slice(0, WORKTREE_STORAGE_MAX_ERRORS - errors.length)); + } + + const project = association.projects[0]; + // A thread cannot exist without a project, but fail closed if projection data is inconsistent. + if (project === undefined && association.threads.length > 0) { + reasons.add("inspection-error"); + } + const stale = + mode.mode === "manual" + ? association.threads.every( + (thread) => thread.archivedAt !== null || thread.settledAt !== null, + ) + : association.threads.length > 0 && + !reasons.has("recent-activity") && + !reasons.has("inspection-error"); + const sortedReasons = [...reasons].sort(); + return { + key: association.key, + association, + mainWorktreePath: gitInspection.mainWorktreePath, + removalPath: gitInspection.targetWorktreePath, + detail: { + projectId: project?.id ?? null, + projectTitle: project?.title ?? "Unassigned worktree", + worktreePath: association.worktreePath, + bytes: size.bytes, + associatedThreadCount: association.threads.length, + associatedThreadIds: association.threads + .map((thread) => thread.id) + .slice(0, WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS), + latestActivityAt: aggregateLatestActivity(association.threads), + stale, + eligible: stale && sortedReasons.length === 0, + protectionReasons: sortedReasons, + scanErrors: errors.slice(0, WORKTREE_STORAGE_MAX_ERRORS), + }, + }; + }); + + const scanAll = Effect.fn("WorktreeStorage.scanAll")(function* ( + mode: ScanMode, + startIndex = 0, + ): Effect.fn.Return { + const context = yield* loadScanContext(); + const window = selectCandidateWindow(context.associations, startIndex); + const scans = yield* Effect.forEach( + window.selected, + (association) => scanCandidate(association, context, mode), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + return { + scans, + omittedCandidateCount: window.omittedCandidateCount, + inventoryErrors: context.inventoryErrors, + }; + }); + + const makeReport = Effect.fn("WorktreeStorage.makeReport")(function* (): Effect.fn.Return< + WorktreeStorageReport, + WorktreeStorageError + > { + const scannedAt = DateTime.formatIso(yield* DateTime.now); + const batch = yield* scanAll({ mode: "manual" }); + const scans = batch.scans; + const aggregates = new Map(); + for (const scan of scans) { + for (const project of scan.association.projects) { + const current = aggregates.get(project.id) ?? { + projectId: project.id, + projectTitle: project.title, + bytes: 0, + worktreeCount: 0, + staleWorktreeCount: 0, + eligibleWorktreeCount: 0, + }; + aggregates.set(project.id, { + ...current, + bytes: safeByteCount(current.bytes + scan.detail.bytes), + worktreeCount: current.worktreeCount + 1, + staleWorktreeCount: current.staleWorktreeCount + (scan.detail.stale ? 1 : 0), + eligibleWorktreeCount: current.eligibleWorktreeCount + (scan.detail.eligible ? 1 : 0), + }); + } + } + const projects = rankProjects([...aggregates.values()]); + const details = rankDetails(scans.map((scan) => scan.detail)); + const errors = [ + ...(batch.omittedCandidateCount > 0 + ? [ + scanError( + "candidate-budget", + `${batch.omittedCandidateCount} worktree candidates were omitted from this bounded scan.`, + ), + ] + : []), + ...batch.inventoryErrors, + ...scans.flatMap((scan) => scan.detail.scanErrors), + ].slice(0, WORKTREE_STORAGE_MAX_ERRORS); + return { + scannedAt, + totalBytes: safeByteCount(scans.reduce((total, scan) => total + scan.detail.bytes, 0)), + worktreeCount: scans.length, + staleWorktreeCount: scans.filter((scan) => scan.detail.stale).length, + eligibleWorktreeCount: scans.filter((scan) => scan.detail.eligible).length, + projects: projects.slice(0, WORKTREE_STORAGE_MAX_PROJECTS), + projectCount: projects.length, + details: details.slice(0, WORKTREE_STORAGE_MAX_DETAILS), + detailCount: details.length, + errors, + partial: + batch.omittedCandidateCount > 0 || + errors.length > 0 || + projects.length > WORKTREE_STORAGE_MAX_PROJECTS || + details.length > WORKTREE_STORAGE_MAX_DETAILS, + }; + }); + + const getReport = Effect.fn("WorktreeStorage.getReport")(function* () { + return yield* makeReport(); + }); + + const verifyPersistedThreadPathEvent = Effect.fn( + "WorktreeStorage.verifyPersistedThreadPathEvent", + )(function* (input: { + readonly sequence: number; + readonly threadId: ThreadId; + readonly worktreePath: string | null; + }) { + const event = yield* engine + .readEvents(input.sequence - 1, 1) + .pipe(Stream.runHead, Effect.map(Option.getOrNull)); + return ( + event !== null && + isAppliedThreadPathEvent({ + event, + sequence: input.sequence, + threadId: input.threadId, + worktreePath: input.worktreePath, + }) + ); + }); + + const reserveMatchingThreadPaths = Effect.fn("WorktreeStorage.reserveMatchingThreadPaths")( + function* (association: CandidateAssociation): Effect.fn.Return { + const errors: WorktreeStorageScanError[] = []; + const reserved: ReservedThreadPath[] = []; + for (const thread of association.threads) { + const uuidResult = yield* Effect.result(crypto.randomUUIDv4); + if (Result.isFailure(uuidResult)) { + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError("clear-thread-worktree", uuidResult.failure, association.worktreePath), + ); + } + continue; + } + const clearCommandId = CommandId.make(`server:worktree-prune:${uuidResult.success}`); + const result = yield* Effect.result( + engine.dispatch({ + type: "thread.meta.update", + commandId: clearCommandId, + threadId: ThreadId.make(thread.id), + worktreePath: null, + expectedWorktreePath: thread.worktreePath, + }), + ); + if (Result.isFailure(result) && errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push(scanError("clear-thread-worktree", result.failure, association.worktreePath)); + } else if (Result.isSuccess(result)) { + const appliedResult = yield* Effect.result( + verifyPersistedThreadPathEvent({ + sequence: result.success.sequence, + threadId: ThreadId.make(thread.id), + worktreePath: null, + }), + ); + if (Result.isSuccess(appliedResult) && appliedResult.success) { + reserved.push({ + thread, + restoreCommandId: CommandId.make(`${clearCommandId}:restore`), + }); + } else { + // If the exact event cannot be read, conservatively include the + // path in cleanup. The expected-null restore CAS cannot overwrite + // a concurrent rebound path. + if (Result.isFailure(appliedResult)) { + reserved.push({ + thread, + restoreCommandId: CommandId.make(`${clearCommandId}:restore`), + }); + } + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError( + "clear-thread-worktree-cas", + Result.isFailure(appliedResult) + ? appliedResult.failure + : "The persisted metadata event did not apply the expected worktree path.", + association.worktreePath, + ), + ); + } + } + } + } + return { threads: reserved, errors }; + }, + ); + + const restoreReservedThreadPaths = Effect.fn("WorktreeStorage.restoreReservedThreadPaths")( + function* ( + threads: ReadonlyArray, + ): Effect.fn.Return> { + const errors: WorktreeStorageScanError[] = []; + for (const reserved of threads) { + const { thread } = reserved; + if (thread.worktreePath === null) continue; + const result = yield* Effect.result( + engine.dispatch({ + type: "thread.meta.update", + commandId: reserved.restoreCommandId, + threadId: ThreadId.make(thread.id), + worktreePath: thread.worktreePath, + expectedWorktreePath: null, + }), + ); + if (Result.isFailure(result) && errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push(scanError("restore-thread-worktree", result.failure, thread.worktreePath)); + } else if (Result.isSuccess(result)) { + const appliedResult = yield* Effect.result( + verifyPersistedThreadPathEvent({ + sequence: result.success.sequence, + threadId: ThreadId.make(thread.id), + worktreePath: thread.worktreePath, + }), + ); + if ( + (Result.isFailure(appliedResult) || !appliedResult.success) && + errors.length < WORKTREE_STORAGE_MAX_ERRORS + ) { + errors.push( + scanError( + "restore-thread-worktree-cas", + Result.isFailure(appliedResult) + ? appliedResult.failure + : "The persisted metadata event did not restore the reserved worktree path.", + thread.worktreePath, + ), + ); + } + } + } + return errors; + }, + ); + + const pruneForMode = Effect.fn("WorktreeStorage.pruneForMode")(function* ( + mode: ScanMode, + ): Effect.fn.Return { + return yield* pruneLock.withPermits(1)( + Effect.gen(function* () { + const startedAt = DateTime.formatIso(yield* DateTime.now); + const cursor = yield* Ref.get(pruneCursor); + const initialBatch = yield* scanAll(mode, cursor); + const initial = initialBatch.scans; + yield* Ref.set( + pruneCursor, + cursor + Math.max(1, Math.min(MAX_CANDIDATES_PER_SCAN, initial.length)), + ); + const outcomes: WorktreeStoragePruneOutcome[] = []; + const errors: WorktreeStorageScanError[] = [ + ...(initialBatch.omittedCandidateCount > 0 + ? [ + scanError( + "candidate-budget", + `${initialBatch.omittedCandidateCount} worktree candidates were deferred to a later prune pass.`, + ), + ] + : []), + ...initialBatch.inventoryErrors, + ].slice(0, WORKTREE_STORAGE_MAX_ERRORS); + let reclaimedBytes = 0; + let removedCount = 0; + let skippedCount = 0; + let failedCount = 0; + + for (const initialScan of [...initial].sort((left, right) => + left.key.localeCompare(right.key), + )) { + const context = yield* loadScanContext(); + const association = context.associations.find((item) => item.key === initialScan.key); + if (association === undefined) { + skippedCount += 1; + outcomes.push({ + worktreePath: initialScan.association.worktreePath, + projectId: initialScan.detail.projectId, + bytes: initialScan.detail.bytes, + status: "skipped", + protectionReasons: ["locked-or-unknown"], + }); + continue; + } + const fresh = yield* scanCandidate(association, context, mode); + if ( + !fresh.detail.eligible || + fresh.mainWorktreePath === null || + fresh.removalPath === null + ) { + skippedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: fresh.detail.bytes, + status: "skipped", + protectionReasons: + fresh.detail.protectionReasons.length > 0 + ? fresh.detail.protectionReasons + : ["inspection-error"], + }); + errors.push( + ...fresh.detail.scanErrors.slice(0, WORKTREE_STORAGE_MAX_ERRORS - errors.length), + ); + continue; + } + + const reservation = yield* reserveMatchingThreadPaths(fresh.association).pipe( + Effect.uninterruptible, + ); + const restoreReservation = restoreReservedThreadPaths(reservation.threads).pipe( + Effect.tap((restoreErrors) => + Effect.sync(() => { + errors.push(...restoreErrors.slice(0, WORKTREE_STORAGE_MAX_ERRORS - errors.length)); + }), + ), + Effect.asVoid, + Effect.uninterruptible, + ); + const removed = yield* withReservationRestoration( + Effect.gen(function* () { + if (reservation.errors.length > 0) { + errors.push( + ...reservation.errors.slice(0, WORKTREE_STORAGE_MAX_ERRORS - errors.length), + ); + skippedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: fresh.detail.bytes, + status: "skipped", + protectionReasons: ["inspection-error"], + }); + return { value: null, physicalRemovalSucceeded: false } as const; + } + + const reservedContext = yield* loadScanContext(); + const reservedAssociation = reservedContext.associations.find( + (item) => item.key === fresh.key, + ); + const reservedThreadIds = new Set( + fresh.association.threads.map((thread) => thread.id), + ); + const reservedThreads = fresh.association.threads.flatMap((thread) => { + const current = reservedContext.threadsById.get(thread.id); + return current === undefined ? [] : [current]; + }); + const reservedThreadBecameLive = + [...reservedThreadIds].some((threadId) => + reservedContext.liveProviderThreadIds.has(threadId), + ) || + [...reservedThreadIds].some((threadId) => + reservedContext.liveTerminalThreadIds.has(threadId), + ); + if ( + reservedAssociation === undefined || + !reservationIsValid({ + candidateStillRegistered: true, + associationThreadCount: reservedAssociation.threads.length, + expectedThreadCount: fresh.association.threads.length, + currentThreadPaths: reservedThreads.map((thread) => thread.worktreePath), + becameLive: reservedThreadBecameLive, + }) + ) { + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError( + "verify-thread-reservation", + "A worktree reference changed while the prune reservation was being verified.", + fresh.detail.worktreePath, + ), + ); + } + skippedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: fresh.detail.bytes, + status: "skipped", + protectionReasons: ["locked-or-unknown"], + }); + return { value: null, physicalRemovalSucceeded: false } as const; + } + + const verified = yield* scanCandidate( + { + ...reservedAssociation, + projects: fresh.association.projects, + threads: reservedThreads, + }, + reservedContext, + mode, + ); + if ( + !verified.detail.eligible || + verified.mainWorktreePath === null || + verified.removalPath === null + ) { + errors.push( + ...verified.detail.scanErrors.slice( + 0, + WORKTREE_STORAGE_MAX_ERRORS - errors.length, + ), + ); + skippedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: verified.detail.bytes, + status: "skipped", + protectionReasons: + verified.detail.protectionReasons.length > 0 + ? verified.detail.protectionReasons + : ["inspection-error"], + }); + return { value: null, physicalRemovalSucceeded: false } as const; + } + const mainWorktreePath = verified.mainWorktreePath; + const removalPath = verified.removalPath; + + // Once Git removal begins, observe its bounded result before + // deciding whether the reservation must be restored. + return yield* Effect.gen(function* () { + const removeResult = yield* Effect.result( + runGit(mainWorktreePath, worktreeRemovalArgs(removalPath)), + ); + const removalSucceeded = + Result.isSuccess(removeResult) && removeResult.success.exitCode === 0; + if (shouldRestoreReservedPaths(removalSucceeded)) { + const cause = Result.isFailure(removeResult) + ? removeResult.failure + : removeResult.success.stderr || `git exited ${removeResult.success.exitCode}`; + failedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: fresh.detail.bytes, + status: "failed", + protectionReasons: [], + message: boundedMessage(cause), + }); + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push(scanError("git-worktree-remove", cause, fresh.detail.worktreePath)); + } + return { value: null, physicalRemovalSucceeded: false } as const; + } + + return { value: verified, physicalRemovalSucceeded: true } as const; + }).pipe(Effect.uninterruptible); + }), + restoreReservation, + ); + + if (removed !== null) { + removedCount += 1; + reclaimedBytes = safeByteCount(reclaimedBytes + removed.detail.bytes); + const project = fresh.association.projects[0]; + if (project !== undefined) { + yield* vcsStatus.refreshLocalStatus(project.workspaceRoot).pipe(Effect.ignore); + } + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: removed.detail.bytes, + status: "removed", + protectionReasons: [], + }); + } + } + + const completedAt = DateTime.formatIso(yield* DateTime.now); + const sortedOutcomes = outcomes.sort((left, right) => + left.worktreePath.localeCompare(right.worktreePath), + ); + return { + startedAt, + completedAt, + removedCount, + skippedCount, + failedCount, + reclaimedBytes, + outcomes: sortedOutcomes.slice(0, WORKTREE_STORAGE_MAX_OUTCOMES), + outcomeCount: sortedOutcomes.length, + errors: errors.slice(0, WORKTREE_STORAGE_MAX_ERRORS), + partial: + initialBatch.omittedCandidateCount > 0 || + errors.length > 0 || + failedCount > 0 || + sortedOutcomes.length > WORKTREE_STORAGE_MAX_OUTCOMES, + }; + }), + ); + }); + + const runConfiguredAutomaticPrune = Effect.fn("WorktreeStorage.runConfiguredAutomaticPrune")( + function* () { + const policy = (yield* settings.getSettings).worktreeAutoPrunePolicy; + const mode = automaticScanMode(policy, DateTime.toEpochMillis(yield* DateTime.now)); + if (mode === null) return; + yield* pruneForMode(mode).pipe( + Effect.tap((result) => + result.removedCount > 0 || result.failedCount > 0 + ? Effect.logInfo("Automatic worktree prune completed", { + removedCount: result.removedCount, + failedCount: result.failedCount, + skippedCount: result.skippedCount, + }) + : Effect.void, + ), + ); + }, + ); + + const settingsChanges = yield* settings.subscribeChanges; + const initialPolicy = (yield* settings.getSettings).worktreeAutoPrunePolicy; + const automaticEventTriggers = Stream.merge( + Stream.concat( + Stream.succeed(initialPolicy), + settingsChanges.pipe(Stream.map((next) => next.worktreeAutoPrunePolicy)), + ).pipe( + Stream.map(automaticPolicyKey), + Stream.changes, + Stream.drop(1), + Stream.filter((key) => key !== "off"), + Stream.map(() => undefined), + ), + engine.streamDomainEvents.pipe( + Stream.filter((event) => event.type === "thread.settled" || event.type === "thread.archived"), + Stream.mapEffect(() => settings.getSettings), + Stream.filter((next) => next.worktreeAutoPrunePolicy.mode === "on-settle"), + Stream.map(() => undefined), + ), + ).pipe(Stream.debounce(Duration.seconds(1))); + yield* automaticEventTriggers.pipe( + Stream.runForEach(() => + runConfiguredAutomaticPrune().pipe( + Effect.catch((cause) => Effect.logWarning("Automatic worktree prune failed", { cause })), + ), + ), + Effect.forkScoped, + ); + yield* Effect.forever( + Effect.sleep(AUTO_SWEEP_INTERVAL).pipe( + Effect.andThen( + settings.getSettings.pipe( + Effect.flatMap((current) => + shouldRunAutomaticFallback(current.worktreeAutoPrunePolicy) + ? runConfiguredAutomaticPrune() + : Effect.void, + ), + Effect.catch((cause) => + Effect.logWarning("Fallback automatic worktree prune failed", { cause }), + ), + ), + ), + ), + ).pipe(Effect.forkScoped); + + return { + getReport: getReport(), + pruneStale: pruneForMode({ mode: "manual" }), + } satisfies WorktreeStorageService; +}); + +export const layer = Layer.effect(WorktreeStorage, make); diff --git a/apps/server/src/worktree/directorySize.test.ts b/apps/server/src/worktree/directorySize.test.ts new file mode 100644 index 000000000000..906d8b123c27 --- /dev/null +++ b/apps/server/src/worktree/directorySize.test.ts @@ -0,0 +1,91 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + discoverWorktreeDirectoriesNoFollowPromise, + measureDirectoryNoFollowPromise, +} from "./directorySize.ts"; + +const temporaryDirectories: string[] = []; + +async function makeTemporaryDirectory() { + const directory = await NodeFSP.mkdtemp(NodePath.join(process.cwd(), ".worktree-storage-test-")); + temporaryDirectories.push(directory); + return directory; +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); + +describe("worktree directory sizing", () => { + it("does not follow directory symlinks", async () => { + const root = await makeTemporaryDirectory(); + const outside = await makeTemporaryDirectory(); + await NodeFSP.writeFile(NodePath.join(outside, "large.bin"), Buffer.alloc(1_000_000)); + await NodeFSP.symlink(outside, NodePath.join(root, "outside-link"), "dir"); + + const result = await measureDirectoryNoFollowPromise(root, { + maxEntries: 100, + maxDurationMs: 5_000, + maxFailures: 10, + }); + + expect(result.failures).toEqual([]); + expect(result.bytes).toBeLessThan(1_000_000); + }); + + it("returns a partial failure instead of walking beyond the entry budget", async () => { + const root = await makeTemporaryDirectory(); + await Promise.all( + Array.from({ length: 20 }, (_, index) => + NodeFSP.writeFile(NodePath.join(root, `entry-${index}`), "x"), + ), + ); + + const result = await measureDirectoryNoFollowPromise(root, { + maxEntries: 5, + maxDurationMs: 5_000, + maxFailures: 10, + }); + + expect(result.failures.map((failure) => failure.operation)).toContain("entry-budget"); + expect(result.bytes).toBeGreaterThan(0); + }); + + it("reports per-entry failures without rejecting the whole scan", async () => { + const missing = NodePath.join(await makeTemporaryDirectory(), "missing"); + const result = await measureDirectoryNoFollowPromise(missing, { + maxEntries: 10, + maxDurationMs: 5_000, + maxFailures: 10, + }); + + expect(result.bytes).toBe(0); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.operation).toBe("stat"); + }); + + it("discovers managed worktree roots without descending through symlinks", async () => { + const root = await makeTemporaryDirectory(); + const registered = NodePath.join(root, "repo", "branch"); + const outside = await makeTemporaryDirectory(); + await NodeFSP.mkdir(registered, { recursive: true }); + await NodeFSP.writeFile(NodePath.join(registered, ".git"), "gitdir: elsewhere"); + await NodeFSP.writeFile(NodePath.join(outside, ".git"), "gitdir: elsewhere"); + await NodeFSP.symlink(outside, NodePath.join(root, "linked-outside"), "dir"); + + const result = await discoverWorktreeDirectoriesNoFollowPromise(root, { + maxEntries: 100, + maxDurationMs: 5_000, + maxFailures: 10, + }); + + expect(result.paths).toEqual([registered]); + expect(result.failures).toEqual([]); + }); +}); diff --git a/apps/server/src/worktree/directorySize.ts b/apps/server/src/worktree/directorySize.ts new file mode 100644 index 000000000000..db0b4df3da1b --- /dev/null +++ b/apps/server/src/worktree/directorySize.ts @@ -0,0 +1,160 @@ +// @effect-diagnostics nodeBuiltinImport:off globalDate:off +/** + * Raw no-follow directory traversal isolated behind the worktree storage adapter. + * Effect's portable FileSystem stat follows links, while this safety boundary needs lstat. + */ +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; + +export interface DirectorySizeFailure { + readonly operation: "entry-budget" | "time-budget" | "read-directory" | "stat"; + readonly path: string; + readonly cause: unknown; +} + +export interface DirectorySizeScan { + readonly bytes: number; + readonly failures: ReadonlyArray; +} + +export interface DirectoryTraversalOptions { + readonly maxEntries: number; + readonly maxDurationMs: number; + readonly maxFailures: number; +} + +export interface WorktreeDirectoryDiscovery { + readonly paths: ReadonlyArray; + readonly failures: ReadonlyArray; +} + +/** Measures directory entries without following symlinks and stops at explicit work budgets. */ +export async function measureDirectoryNoFollowPromise( + rootPath: string, + options: DirectoryTraversalOptions, +): Promise { + const pending = [rootPath]; + const failures: DirectorySizeFailure[] = []; + const startedAtMs = Date.now(); + let bytes = 0; + let budgetReported = false; + + const reportBudget = (operation: "entry-budget" | "time-budget", cause: string) => { + if (!budgetReported && failures.length < options.maxFailures) { + failures.push({ operation, path: rootPath, cause }); + budgetReported = true; + } + }; + + for (let index = 0; index < pending.length; index += 1) { + if (index >= options.maxEntries) { + reportBudget( + "entry-budget", + `Worktree scan exceeded ${options.maxEntries} filesystem entries.`, + ); + break; + } + if (Date.now() - startedAtMs >= options.maxDurationMs) { + reportBudget("time-budget", `Worktree scan exceeded ${options.maxDurationMs} milliseconds.`); + break; + } + + const current = pending[index]; + if (current === undefined) continue; + try { + const stats = await NodeFSP.lstat(current); + bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + Math.max(0, stats.size)); + if (stats.isSymbolicLink() || !stats.isDirectory()) continue; + + try { + const names = await NodeFSP.readdir(current); + names.sort((left, right) => left.localeCompare(right)); + const remaining = Math.max(0, options.maxEntries - pending.length); + for (const name of names.slice(0, remaining)) { + pending.push(NodePath.join(current, name)); + } + if (names.length > remaining) { + reportBudget( + "entry-budget", + `Worktree scan exceeded ${options.maxEntries} filesystem entries.`, + ); + } + } catch (cause) { + if (failures.length < options.maxFailures) { + failures.push({ operation: "read-directory", path: current, cause }); + } + } + } catch (cause) { + if (failures.length < options.maxFailures) { + failures.push({ operation: "stat", path: current, cause }); + } + } + } + + return { bytes, failures }; +} + +/** Finds directory roots carrying a worktree `.git` entry without descending into them. */ +export async function discoverWorktreeDirectoriesNoFollowPromise( + rootPath: string, + options: DirectoryTraversalOptions, +): Promise { + const pending = [rootPath]; + const paths: string[] = []; + const failures: DirectorySizeFailure[] = []; + const startedAtMs = Date.now(); + let budgetReported = false; + + const reportBudget = (operation: "entry-budget" | "time-budget", cause: string) => { + if (!budgetReported && failures.length < options.maxFailures) { + failures.push({ operation, path: rootPath, cause }); + budgetReported = true; + } + }; + + for (let index = 0; index < pending.length; index += 1) { + if (index >= options.maxEntries) { + reportBudget( + "entry-budget", + `Worktree discovery exceeded ${options.maxEntries} filesystem entries.`, + ); + break; + } + if (Date.now() - startedAtMs >= options.maxDurationMs) { + reportBudget( + "time-budget", + `Worktree discovery exceeded ${options.maxDurationMs} milliseconds.`, + ); + break; + } + + const current = pending[index]; + if (current === undefined) continue; + try { + const stats = await NodeFSP.lstat(current); + if (stats.isSymbolicLink() || !stats.isDirectory()) continue; + const names = await NodeFSP.readdir(current); + names.sort((left, right) => left.localeCompare(right)); + if (current !== rootPath && names.includes(".git")) { + paths.push(current); + continue; + } + const remaining = Math.max(0, options.maxEntries - pending.length); + for (const name of names.slice(0, remaining)) { + pending.push(NodePath.join(current, name)); + } + if (names.length > remaining) { + reportBudget( + "entry-budget", + `Worktree discovery exceeded ${options.maxEntries} filesystem entries.`, + ); + } + } catch (cause) { + if (failures.length < options.maxFailures) { + failures.push({ operation: "read-directory", path: current, cause }); + } + } + } + + return { paths, failures }; +} diff --git a/apps/server/src/worktree/worktreeRemoval.test.ts b/apps/server/src/worktree/worktreeRemoval.test.ts new file mode 100644 index 000000000000..f6c616afaf98 --- /dev/null +++ b/apps/server/src/worktree/worktreeRemoval.test.ts @@ -0,0 +1,55 @@ +// @effect-diagnostics nodeBuiltinImport:off +import * as NodeChildProcess from "node:child_process"; +import * as NodeFSP from "node:fs/promises"; +import * as NodePath from "node:path"; +import { afterEach, describe, expect, it } from "vite-plus/test"; + +import { + decodeWorktreePorcelain, + parseWorktreePorcelain, + worktreeRemovalArgs, +} from "./WorktreeStorage.ts"; + +const temporaryDirectories: string[] = []; + +function git(cwd: string, args: ReadonlyArray) { + return NodeChildProcess.spawnSync("git", args, { cwd, encoding: "utf8" }); +} + +afterEach(async () => { + for (const directory of temporaryDirectories.splice(0)) { + await NodeFSP.rm(directory, { recursive: true, force: true }); + } +}); + +describe("non-force worktree removal", () => { + it("preserves dirty data in a temporary Git worktree", async () => { + const root = await NodeFSP.mkdtemp(NodePath.join(process.cwd(), ".worktree-prune-git-test-")); + temporaryDirectories.push(root); + const repository = NodePath.join(root, "repository"); + const worktree = NodePath.join(root, "managed", "feature"); + await NodeFSP.mkdir(repository, { recursive: true }); + + expect(git(repository, ["init", "-b", "main"]).status).toBe(0); + expect(git(repository, ["config", "user.email", "test@example.com"]).status).toBe(0); + expect(git(repository, ["config", "user.name", "T3 Test"]).status).toBe(0); + await NodeFSP.writeFile(NodePath.join(repository, "tracked.txt"), "tracked\n"); + expect(git(repository, ["add", "tracked.txt"]).status).toBe(0); + expect(git(repository, ["commit", "-m", "initial"]).status).toBe(0); + expect(git(repository, ["worktree", "add", "-b", "feature", worktree]).status).toBe(0); + await NodeFSP.writeFile(NodePath.join(worktree, "untracked.txt"), "keep me\n"); + + const listing = git(repository, ["worktree", "list", "--porcelain", "-z"]); + expect(listing.status).toBe(0); + expect("entries" in decodeWorktreePorcelain(listing.stdout)).toBe(true); + expect(parseWorktreePorcelain(listing.stdout).some((entry) => entry.path === worktree)).toBe( + true, + ); + + const removal = git(repository, worktreeRemovalArgs(worktree)); + expect(removal.status).not.toBe(0); + expect(await NodeFSP.readFile(NodePath.join(worktree, "untracked.txt"), "utf8")).toBe( + "keep me\n", + ); + }); +}); diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 11c659e28a70..b3d730020907 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -112,6 +112,7 @@ import * as ProcessResourceMonitor from "./diagnostics/ProcessResourceMonitor.ts import * as ResourceTelemetry from "./resourceTelemetry/ResourceTelemetry.ts"; import * as AnalyticsService from "./telemetry/AnalyticsService.ts"; import * as UsageService from "./usage/UsageService.ts"; +import * as WorktreeStorage from "./worktree/WorktreeStorage.ts"; import * as TraceDiagnostics from "./diagnostics/TraceDiagnostics.ts"; import * as PullRequestService from "./pullRequest/PullRequestService.ts"; import * as SourceControlDiscovery from "./sourceControl/SourceControlDiscovery.ts"; @@ -482,6 +483,7 @@ const makeWsRpcLayer = ( const processResourceMonitor = yield* ProcessResourceMonitor.ProcessResourceMonitor; const resourceTelemetry = yield* ResourceTelemetry.ResourceTelemetry; const usage = yield* UsageService.UsageService; + const worktreeStorage = yield* WorktreeStorage.WorktreeStorage; const relayClient = yield* RelayClient.RelayClient; const authorizationError = (requiredScope: AuthEnvironmentScope) => new EnvironmentAuthorizationError({ @@ -1628,6 +1630,14 @@ const makeWsRpcLayer = ( "rpc.aggregate": "server", }, ), + [WS_METHODS.worktreeStorageGetReport]: (_input) => + observeRpcEffect(WS_METHODS.worktreeStorageGetReport, worktreeStorage.getReport, { + "rpc.aggregate": "worktree-storage", + }), + [WS_METHODS.worktreeStoragePruneStale]: (_input) => + observeRpcEffect(WS_METHODS.worktreeStoragePruneStale, worktreeStorage.pruneStale, { + "rpc.aggregate": "worktree-storage", + }), [WS_METHODS.serverDiscoverSourceControl]: (_input) => observeRpcEffect( WS_METHODS.serverDiscoverSourceControl, diff --git a/apps/web/src/components/settings/SettingsSidebarNav.tsx b/apps/web/src/components/settings/SettingsSidebarNav.tsx index 734c2989d917..dadf61abda6a 100644 --- a/apps/web/src/components/settings/SettingsSidebarNav.tsx +++ b/apps/web/src/components/settings/SettingsSidebarNav.tsx @@ -12,6 +12,7 @@ import { BlocksIcon, BotIcon, GitBranchIcon, + HardDriveIcon, KeyboardIcon, Link2Icon, PaletteIcon, @@ -52,6 +53,7 @@ const SETTINGS_SECTION_ICONS: Readonly< "/settings/providers": BotIcon, "/settings/integrations": BlocksIcon, "/settings/source-control": GitBranchIcon, + "/settings/worktree-storage": HardDriveIcon, "/settings/connections": Link2Icon, "/settings/archived": ArchiveIcon, }; diff --git a/apps/web/src/components/settings/WorktreeStorageSettings.tsx b/apps/web/src/components/settings/WorktreeStorageSettings.tsx new file mode 100644 index 000000000000..34c0309fd4a5 --- /dev/null +++ b/apps/web/src/components/settings/WorktreeStorageSettings.tsx @@ -0,0 +1,684 @@ +import { + WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS, + WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS, + type EnvironmentId, + type WorktreeAutoPrunePolicy, + type WorktreeStorageDetail, + type WorktreeStorageProjectAggregate, +} from "@t3tools/contracts"; +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { + formatWorktreeStorageBytes, + planAcrossEnvironmentPrune, + rankWorktreeEntries, + rankWorktreeProjects, + resolveFrozenPrunePlan, + skippedPruneOutcome, + successfulPruneOutcome, + summarizePruneOutcomes, + worktreeDisplayName, + type EnvironmentPruneOutcome, +} from "@t3tools/client-runtime/state/worktree-storage"; +import { HardDriveIcon, RefreshCwIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react"; +import { useEffect, useMemo, useRef, useState } from "react"; + +import { serverEnvironment, worktreeStorageEnvironment } from "../../state/server"; +import { + useWorktreeStorage, + type EnvironmentWorktreeStorageStatus, +} from "../../state/worktree-storage"; +import { useAtomCommand } from "../../state/use-atom-command"; +import { formatRelativeTimeLabel } from "../../timestampFormat"; +import { describeWorktreeProtectionReason } from "../../worktreeStorage.logic"; +import { + AlertDialog, + AlertDialogClose, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogPopup, + AlertDialogTitle, +} from "../ui/alert-dialog"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { Input } from "../ui/input"; +import { Select, SelectItem, SelectPopup, SelectTrigger, SelectValue } from "../ui/select"; +import { toastManager } from "../ui/toast"; +import { SettingsPageContainer, SettingsRow, SettingsSection } from "./settingsLayout"; +import { searchableSetting } from "./settingsSearch"; + +const PROJECT_DISPLAY_LIMIT = 8; +const WORKTREE_DISPLAY_LIMIT_PER_PROJECT = 4; +const DEFAULT_INACTIVITY_DAYS = 30; + +interface FrozenEnvironmentRef { + readonly environmentId: EnvironmentId; + readonly label: string; +} + +interface PruneScope { + readonly type: "environment" | "across"; + readonly targets: readonly FrozenEnvironmentRef[]; + readonly skipped: readonly EnvironmentPruneOutcome[]; +} + +function environmentStatusLabel(environment: EnvironmentWorktreeStorageStatus): string { + switch (environment.state) { + case "ready": + return environment.isRefreshing ? "Refreshing…" : "Reported"; + case "loading": + return environment.connectionPhase === "connected" ? "Scanning…" : "Connecting…"; + case "offline": + return "Offline"; + case "unsupported": + return "Unsupported"; + case "error": + return "Unavailable"; + } +} + +function environmentDescription(environment: EnvironmentWorktreeStorageStatus): string { + if (environment.report) { + const freshness = formatRelativeTimeLabel(environment.report.scannedAt); + const partial = environment.report.partial ? " · Partial scan" : ""; + return `${environment.report.worktreeCount} worktrees · Scanned ${freshness || "recently"}${partial}`; + } + switch (environment.state) { + case "offline": + return "Offline. Its storage is unknown and is not counted as zero."; + case "unsupported": + return "This server version does not support worktree storage management."; + case "error": + return environment.error ?? "This system could not report worktree storage."; + case "loading": + return environment.connectionPhase === "connected" + ? "Scanning worktree storage on this system…" + : "Waiting for this system to connect."; + case "ready": + return "Worktree storage reported."; + } +} + +function policyLabel(policy: WorktreeAutoPrunePolicy): string { + switch (policy.mode) { + case "off": + return "Off"; + case "on-settle": + return "When threads settle"; + case "after-inactive-days": + return `After ${policy.inactivityDays} inactive ${policy.inactivityDays === 1 ? "day" : "days"}`; + } +} + +function EnvironmentPolicyControl({ + environment, + disabled, + onUpdate, +}: { + readonly environment: EnvironmentWorktreeStorageStatus; + readonly disabled: boolean; + readonly onUpdate: (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => void; +}) { + const policy = environment.policy; + const policyDays = + policy.mode === "after-inactive-days" ? policy.inactivityDays : DEFAULT_INACTIVITY_DAYS; + const [draftMode, setDraftMode] = useState(policy.mode); + const [draftDays, setDraftDays] = useState(String(policyDays)); + useEffect(() => { + setDraftMode(policy.mode); + setDraftDays(String(policyDays)); + }, [policy.mode, policyDays]); + + const days = Number(draftDays); + const daysValid = + Number.isInteger(days) && + days >= WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS && + days <= WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS; + const draftPolicy: WorktreeAutoPrunePolicy | null = + draftMode === "after-inactive-days" + ? daysValid + ? { mode: draftMode, inactivityDays: days } + : null + : { mode: draftMode }; + const hasChanges = + draftPolicy !== null && + (draftPolicy.mode !== policy.mode || + (draftPolicy.mode === "after-inactive-days" && + policy.mode === "after-inactive-days" && + draftPolicy.inactivityDays !== policy.inactivityDays)); + + return ( +
+ + {draftMode === "after-inactive-days" ? ( +
+ setDraftDays(event.currentTarget.value)} + aria-label={`Inactivity days for ${environment.label}`} + aria-invalid={!daysValid} + className="min-w-0 flex-1" + /> + days +
+ ) : null} + +
+ ); +} + +function WorktreeDetailRow({ detail }: { readonly detail: WorktreeStorageDetail }) { + const reasons = detail.protectionReasons.map(describeWorktreeProtectionReason); + return ( +
  • +
    +
    + + {worktreeDisplayName(detail.worktreePath)} + + + {detail.eligible ? "Eligible for stale pruning" : "Protected"} + +
    +

    + {reasons.length > 0 + ? reasons.join(" · ") + : detail.eligible + ? "Stale and cleared by server safety checks" + : "Protected by server safety checks"} + {detail.latestActivityAt + ? ` · Active ${formatRelativeTimeLabel(detail.latestActivityAt)}` + : ""} +

    +
    + + {formatWorktreeStorageBytes(detail.bytes)} + +
  • + ); +} + +function ProjectStorageDetails({ + project, + details, + rank, +}: { + readonly project: Pick< + WorktreeStorageProjectAggregate, + "projectTitle" | "bytes" | "worktreeCount" | "eligibleWorktreeCount" | "staleWorktreeCount" + >; + readonly details: readonly WorktreeStorageDetail[]; + readonly rank: number | null; +}) { + const rankedDetails = rankWorktreeEntries(details).slice(0, WORKTREE_DISPLAY_LIMIT_PER_PROJECT); + return ( +
  • +
    +
    +

    + {rank === null ? null : ( + {rank}. + )} + {project.projectTitle} +

    +

    + {project.worktreeCount} worktrees · {project.eligibleWorktreeCount} eligible ·{" "} + {project.staleWorktreeCount} stale +

    +
    + + {formatWorktreeStorageBytes(project.bytes)} + +
    + {rankedDetails.length > 0 ? ( +
      + {rankedDetails.map((detail) => ( + + ))} +
    + ) : null} + {details.length > rankedDetails.length ? ( +

    + Showing {rankedDetails.length} of {details.length} worktrees for this project. +

    + ) : null} +
  • + ); +} + +function EnvironmentInventory({ + environment, +}: { + readonly environment: EnvironmentWorktreeStorageStatus; +}) { + const report = environment.report; + if (!report) return null; + const projects = rankWorktreeProjects(report.projects).slice(0, PROJECT_DISPLAY_LIMIT); + const unassignedDetails = rankWorktreeEntries( + report.details.filter((detail) => detail.projectId === null), + ); + const unassignedBytes = unassignedDetails.reduce((sum, detail) => sum + detail.bytes, 0); + + return ( +
    + {report.partial || report.errors.length > 0 ? ( +

    + This scan is partial. Unreadable or unknown storage remains protected and may not be in + the total. +

    + ) : null} + {projects.length > 0 ? ( +
      + {projects.map((project, index) => ( + detail.projectId === project.projectId)} + /> + ))} +
    + ) : unassignedDetails.length === 0 ? ( +

    No managed worktrees were found.

    + ) : null} + {unassignedDetails.length > 0 ? ( +
    +

    + These managed worktrees are no longer linked to a registered project. They are always + protected from manual and automatic bulk pruning. The size shown is the sum of reported + unassigned details. +

    +
      + detail.eligible).length, + staleWorktreeCount: unassignedDetails.filter((detail) => detail.stale).length, + }} + rank={null} + details={unassignedDetails} + /> +
    +
    + ) : null} + {report.projects.length > projects.length || report.projectCount > report.projects.length ? ( +

    + Showing {projects.length} of {report.projectCount} projects, ranked by known bytes. +

    + ) : null} +
    + ); +} + +function pruneSummaryDescription(outcomes: readonly EnvironmentPruneOutcome[]): string { + const summary = summarizePruneOutcomes(outcomes); + const parts = [ + `${summary.removedCount} removed`, + `${summary.protectedCount} protected`, + `${summary.failedWorktreeCount} worktree failures`, + `${summary.partialEnvironmentCount} partial systems`, + `${summary.serverErrorCount} server errors`, + `${summary.unreportedOutcomeCount} outcome details omitted`, + `${summary.skippedEnvironmentCount} systems skipped`, + `${summary.failedEnvironmentCount} systems failed`, + ]; + return `${formatWorktreeStorageBytes(summary.freedBytes)} estimated reclaimed · ${parts.join(" · ")}`; +} + +export function WorktreeStorageSettings() { + const { environments, coverage, refresh } = useWorktreeStorage(); + const pruneStale = useAtomCommand(worktreeStorageEnvironment.pruneStale, { + reportFailure: false, + }); + const updateSettings = useAtomCommand(serverEnvironment.updateSettings, { + reportFailure: false, + }); + const [pruneScope, setPruneScope] = useState(null); + const [isPruning, setIsPruning] = useState(false); + const [savingPolicyEnvironmentId, setSavingPolicyEnvironmentId] = useState( + null, + ); + const [lastOutcomes, setLastOutcomes] = useState([]); + const mutationPending = useRef(false); + const plan = useMemo(() => planAcrossEnvironmentPrune(environments), [environments]); + + const updatePolicy = async (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => { + setSavingPolicyEnvironmentId(environmentId); + const result = await updateSettings({ + environmentId, + input: { patch: { worktreeAutoPrunePolicy: policy } }, + }); + setSavingPolicyEnvironmentId(null); + if (result._tag === "Success") { + toastManager.add({ type: "success", title: "Automatic prune policy updated" }); + } else if (!isAtomCommandInterrupted(result)) { + const error = squashAtomCommandFailure(result); + toastManager.add({ + type: "error", + title: "Could not update automatic prune policy", + description: + error instanceof Error ? error.message : "Try again when this system is connected.", + }); + } + }; + + const openPruneConfirmation = (type: PruneScope["type"], environmentId?: EnvironmentId) => { + const candidates = + environmentId === undefined + ? environments + : environments.filter((environment) => environment.environmentId === environmentId); + const frozenPlan = planAcrossEnvironmentPrune(candidates); + setPruneScope({ + type, + targets: frozenPlan.targets.map(({ environmentId: id, label }) => ({ + environmentId: id, + label, + })), + skipped: frozenPlan.skipped.map(skippedPruneOutcome), + }); + }; + + const runPrune = async () => { + if (pruneScope === null || mutationPending.current) return; + mutationPending.current = true; + setIsPruning(true); + + const confirmedIds = pruneScope.targets.map((environment) => environment.environmentId); + const currentPlan = resolveFrozenPrunePlan(environments, confirmedIds); + const targets = currentPlan.targets; + const currentIds = new Set(environments.map((environment) => environment.environmentId)); + const skipped = [ + ...pruneScope.skipped, + ...currentPlan.skipped.map(skippedPruneOutcome), + ...pruneScope.targets + .filter((environment) => !currentIds.has(environment.environmentId)) + .map( + (environment): EnvironmentPruneOutcome => ({ + ...environment, + status: "skipped", + reason: "unavailable", + }), + ), + ]; + const results = await Promise.all( + targets.map(async (environment): Promise => { + const result = await pruneStale({ environmentId: environment.environmentId, input: {} }); + if (result._tag === "Success") { + return successfulPruneOutcome(environment, result.value); + } + const cause = squashAtomCommandFailure(result); + return { + environmentId: environment.environmentId, + label: environment.label, + status: "failure", + error: cause instanceof Error ? cause.message : "Prune request failed.", + }; + }), + ); + const outcomes = [...results, ...skipped]; + const summary = summarizePruneOutcomes(outcomes); + setLastOutcomes(outcomes); + toastManager.add({ + type: summary.tone, + title: + summary.tone === "warning" + ? "Prune finished with exceptions" + : summary.tone === "error" + ? "Prune failed" + : summary.removedCount > 0 + ? `Pruned ${summary.removedCount} stale ${summary.removedCount === 1 ? "worktree" : "worktrees"}` + : "No stale worktrees were pruned", + description: pruneSummaryDescription(outcomes), + }); + setPruneScope(null); + setIsPruning(false); + mutationPending.current = false; + }; + + const confirmTargets = pruneScope?.targets ?? []; + const confirmSkipped = pruneScope?.skipped ?? []; + const canRefresh = environments.some( + (environment) => environment.connectionPhase === "connected" && environment.capable, + ); + + return ( + + } + headerAction={ + + } + > + 0 ? ` ${coverage.partialCount} ${coverage.partialCount === 1 ? "report is" : "reports are"} partial.` : ""} Missing or unknown storage is not counted as zero.` + } + status={ + coverage.complete + ? "Known checkout bytes; shared Git object storage is excluded." + : `${coverage.offlineCount} offline · ${coverage.unsupportedCount} unsupported · ${coverage.errorCount} failed · ${coverage.loadingCount} pending · ${coverage.partialCount} partial · Shared Git object storage is excluded.` + } + control={ + + {coverage.knownEnvironmentCount > 0 + ? formatWorktreeStorageBytes(coverage.totalKnownBytes) + : "Unknown"} + + } + /> + + + + {environments.length > 0 ? ( + environments.map((environment) => ( + + {formatWorktreeStorageBytes(environment.report.totalBytes)} + + ) : ( + + {environmentStatusLabel(environment)} + + ) + } + > + +
    +
    +

    Automatic pruning

    +

    + This policy belongs to {environment.label}. Protected worktrees are never + removed just because a policy is enabled. +

    +
    + void updatePolicy(environmentId, policy)} + /> +
    +
    + +
    +
    + )) + ) : ( + + )} +
    + + } + > + openPruneConfirmation("across")} + > + + Prune all stale worktrees across connected systems + + } + status={ + plan.skipped.length > 0 + ? `${plan.targets.length} connected and capable · ${plan.skipped.length} will be skipped` + : `${plan.targets.length} connected and capable` + } + /> + {lastOutcomes.length > 0 ? ( +
    +

    Last prune request

    +

    {pruneSummaryDescription(lastOutcomes)}

    +
      + {lastOutcomes.map((outcome) => ( +
    • + {outcome.label}:{" "} + {outcome.status === "success" + ? `${outcome.removedCount} removed, ${outcome.skippedCount} protected, ${outcome.failedCount} failed${outcome.partial ? ", partial result" : ""}${outcome.serverErrorCount > 0 ? `, ${outcome.serverErrorCount} server errors` : ""}${outcome.unreportedOutcomeCount > 0 ? `, ${outcome.unreportedOutcomeCount} outcome details omitted` : ""}` + : outcome.status === "skipped" + ? `Skipped (${outcome.reason})` + : `Failed (${outcome.error})`} +
    • + ))} +
    +
    + ) : null} +
    + + !open && !isPruning && setPruneScope(null)} + > + + + + {pruneScope?.type === "environment" + ? `Prune all stale worktrees on ${confirmTargets[0]?.label ?? "this system"}?` + : `Prune all stale worktrees across ${confirmTargets.length} connected ${confirmTargets.length === 1 ? "system" : "systems"}?`} + + + + Each listed system performs a fresh safety check. Dirty, active, or unknown + worktrees remain protected. T3 Code never queues this action for an offline system. + + {confirmTargets.length > 0 ? ( + + Included:{" "} + {confirmTargets.map((environment) => environment.label).join(", ")} + + ) : null} + {confirmSkipped.length > 0 ? ( + + Skipped:{" "} + {confirmSkipped.map((environment) => environment.label).join(", ")} + + ) : null} + + + + }> + Cancel + + + + + +
    + ); +} diff --git a/apps/web/src/components/settings/settingsSearch.test.ts b/apps/web/src/components/settings/settingsSearch.test.ts index 09fd7a9a6a0b..18ddb4d4e788 100644 --- a/apps/web/src/components/settings/settingsSearch.test.ts +++ b/apps/web/src/components/settings/settingsSearch.test.ts @@ -70,6 +70,21 @@ describe("searchSettings", () => { expect(new Set(ids).size).toBe(ids.length); }); + it("indexes the dedicated Worktree Storage route and actions", () => { + expect(searchSettings("worktree storage")[0]).toMatchObject({ + id: "worktree-storage-overview", + to: "/settings/worktree-storage", + }); + expect(searchSettings("prune stale")[0]).toMatchObject({ + id: "worktree-pruning", + to: "/settings/worktree-storage", + }); + expect(searchSettings("automatic worktree")[0]).toMatchObject({ + id: "automatic-worktree-pruning", + to: "/settings/worktree-storage", + }); + }); + it("serves anchor props to panels from the catalog", () => { expect(searchableSetting("word-wrap")).toEqual({ id: "word-wrap", title: "Word wrap" }); expect(searchableSetting("archive")).toEqual({ id: "archive", title: "Archived threads" }); diff --git a/apps/web/src/components/settings/settingsSearch.ts b/apps/web/src/components/settings/settingsSearch.ts index 5213cb55a503..918593adfc41 100644 --- a/apps/web/src/components/settings/settingsSearch.ts +++ b/apps/web/src/components/settings/settingsSearch.ts @@ -7,6 +7,7 @@ export type SettingsPath = | "/settings/providers" | "/settings/integrations" | "/settings/source-control" + | "/settings/worktree-storage" | "/settings/connections" | "/settings/archived"; @@ -31,6 +32,7 @@ export const SETTINGS_SECTION_LABELS: Readonly> = { "/settings/providers": "Providers", "/settings/integrations": "Integrations", "/settings/source-control": "Source Control", + "/settings/worktree-storage": "Worktree Storage", "/settings/connections": "Connections", "/settings/archived": "Archive", }; @@ -243,6 +245,22 @@ export const SETTINGS_SEARCH_ITEMS = [ title: "Source control", to: "/settings/source-control", }, + { + id: "worktree-storage-overview", + title: "Worktree storage", + to: "/settings/worktree-storage", + }, + { + id: "worktree-pruning", + title: "Prune stale worktrees", + to: "/settings/worktree-storage", + }, + { + id: "automatic-worktree-pruning", + title: "Automatic worktree pruning", + to: "/settings/worktree-storage", + targetId: "worktree-storage-overview", + }, { id: "remote-environments", title: "Remote environments", diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index f7c47ace6840..af693e85ac8e 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -15,6 +15,7 @@ import { Route as PairRouteImport } from './routes/pair' import { Route as ConnectRouteImport } from './routes/connect' import { Route as ChatRouteImport } from './routes/_chat' import { Route as ChatIndexRouteImport } from './routes/_chat.index' +import { Route as SettingsWorktreeStorageRouteImport } from './routes/settings.worktree-storage' import { Route as SettingsSourceControlRouteImport } from './routes/settings.source-control' import { Route as SettingsProvidersRouteImport } from './routes/settings.providers' import { Route as SettingsKeybindingsRouteImport } from './routes/settings.keybindings' @@ -59,6 +60,11 @@ const ChatIndexRoute = ChatIndexRouteImport.update({ path: '/', getParentRoute: () => ChatRoute, } as any) +const SettingsWorktreeStorageRoute = SettingsWorktreeStorageRouteImport.update({ + id: '/worktree-storage', + path: '/worktree-storage', + getParentRoute: () => SettingsRoute, +} as any) const SettingsSourceControlRoute = SettingsSourceControlRouteImport.update({ id: '/source-control', path: '/source-control', @@ -149,6 +155,7 @@ export interface FileRoutesByFullPath { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/worktree-storage': typeof SettingsWorktreeStorageRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute } @@ -169,6 +176,7 @@ export interface FileRoutesByTo { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/worktree-storage': typeof SettingsWorktreeStorageRoute '/': typeof ChatIndexRoute '/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -192,6 +200,7 @@ export interface FileRoutesById { '/settings/keybindings': typeof SettingsKeybindingsRoute '/settings/providers': typeof SettingsProvidersRoute '/settings/source-control': typeof SettingsSourceControlRoute + '/settings/worktree-storage': typeof SettingsWorktreeStorageRoute '/_chat/': typeof ChatIndexRoute '/_chat/$environmentId/$threadId': typeof ChatEnvironmentIdThreadIdRoute '/_chat/draft/$draftId': typeof ChatDraftDraftIdRoute @@ -216,6 +225,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/worktree-storage' | '/$environmentId/$threadId' | '/draft/$draftId' fileRoutesByTo: FileRoutesByTo @@ -236,6 +246,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/worktree-storage' | '/' | '/$environmentId/$threadId' | '/draft/$draftId' @@ -258,6 +269,7 @@ export interface FileRouteTypes { | '/settings/keybindings' | '/settings/providers' | '/settings/source-control' + | '/settings/worktree-storage' | '/_chat/' | '/_chat/$environmentId/$threadId' | '/_chat/draft/$draftId' @@ -317,6 +329,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ChatIndexRouteImport parentRoute: typeof ChatRoute } + '/settings/worktree-storage': { + id: '/settings/worktree-storage' + path: '/worktree-storage' + fullPath: '/settings/worktree-storage' + preLoaderRoute: typeof SettingsWorktreeStorageRouteImport + parentRoute: typeof SettingsRoute + } '/settings/source-control': { id: '/settings/source-control' path: '/source-control' @@ -444,6 +463,7 @@ interface SettingsRouteChildren { SettingsKeybindingsRoute: typeof SettingsKeybindingsRoute SettingsProvidersRoute: typeof SettingsProvidersRoute SettingsSourceControlRoute: typeof SettingsSourceControlRoute + SettingsWorktreeStorageRoute: typeof SettingsWorktreeStorageRoute } const SettingsRouteChildren: SettingsRouteChildren = { @@ -456,6 +476,7 @@ const SettingsRouteChildren: SettingsRouteChildren = { SettingsKeybindingsRoute: SettingsKeybindingsRoute, SettingsProvidersRoute: SettingsProvidersRoute, SettingsSourceControlRoute: SettingsSourceControlRoute, + SettingsWorktreeStorageRoute: SettingsWorktreeStorageRoute, } const SettingsRouteWithChildren = SettingsRoute._addFileChildren( diff --git a/apps/web/src/routes/settings.worktree-storage.tsx b/apps/web/src/routes/settings.worktree-storage.tsx new file mode 100644 index 000000000000..6ad61be8b409 --- /dev/null +++ b/apps/web/src/routes/settings.worktree-storage.tsx @@ -0,0 +1,7 @@ +import { createFileRoute } from "@tanstack/react-router"; + +import { WorktreeStorageSettings } from "../components/settings/WorktreeStorageSettings"; + +export const Route = createFileRoute("/settings/worktree-storage")({ + component: WorktreeStorageSettings, +}); diff --git a/apps/web/src/state/server.ts b/apps/web/src/state/server.ts index 1071d8209dfe..a349e35d8488 100644 --- a/apps/web/src/state/server.ts +++ b/apps/web/src/state/server.ts @@ -9,6 +9,7 @@ import { } from "@t3tools/contracts"; import { createServerEnvironmentAtoms } from "@t3tools/client-runtime/state/server"; import { createEnvironmentServerConfigsAtom } from "@t3tools/client-runtime/state/shell"; +import { createWorktreeStorageAtoms } from "@t3tools/client-runtime/state/worktree-storage"; import { DEFAULT_RESOLVED_KEYBINDINGS } from "@t3tools/shared/keybindings"; import * as Option from "effect/Option"; import { AsyncResult, Atom } from "effect/unstable/reactivity"; @@ -21,6 +22,7 @@ import { environmentSession } from "./session"; export const serverEnvironment = createServerEnvironmentAtoms(connectionAtomRuntime, { initialConfigValueAtom: environmentSession.initialConfigValueAtom, }); +export const worktreeStorageEnvironment = createWorktreeStorageAtoms(connectionAtomRuntime); export const environmentServerConfigsAtom = createEnvironmentServerConfigsAtom({ catalogValueAtom: environmentCatalog.catalogValueAtom, serverConfigValueAtom: serverEnvironment.configValueAtom, diff --git a/apps/web/src/state/worktree-storage.ts b/apps/web/src/state/worktree-storage.ts new file mode 100644 index 000000000000..788851f54a02 --- /dev/null +++ b/apps/web/src/state/worktree-storage.ts @@ -0,0 +1,117 @@ +import { useAtomValue } from "@effect/atom-react"; +import { + DEFAULT_WORKTREE_AUTO_PRUNE_POLICY, + type EnvironmentId, + type WorktreeAutoPrunePolicy, + type WorktreeStorageReport, +} from "@t3tools/contracts"; +import * as Option from "effect/Option"; +import { AsyncResult, Atom } from "effect/unstable/reactivity"; +import { useCallback, useMemo } from "react"; + +import { + computeWorktreeStorageCoverage, + rankWorktreeEnvironments, + type WorktreeStorageCoverage, + type WorktreeStorageEnvironmentState, + type WorktreeStorageEnvironmentSummary, +} from "@t3tools/client-runtime/state/worktree-storage"; +import { appAtomRegistry } from "../rpc/atomRegistry"; +import { environmentPresentations } from "./presentation"; +import { worktreeStorageEnvironment } from "./server"; + +export interface EnvironmentWorktreeStorageStatus extends WorktreeStorageEnvironmentSummary { + readonly environmentId: EnvironmentId; + readonly report: WorktreeStorageReport | null; + readonly policy: WorktreeAutoPrunePolicy; + readonly isRefreshing: boolean; + readonly error: string | null; +} + +const worktreeStorageAtom = Atom.make((get): readonly EnvironmentWorktreeStorageStatus[] => { + const presentations = get(environmentPresentations.presentationsAtom); + const statuses: EnvironmentWorktreeStorageStatus[] = []; + + for (const [environmentId, presentation] of presentations) { + const connectionPhase = presentation.connection.phase; + const capable = presentation.serverConfig?.environment.capabilities.worktreeStorage === true; + const policy = + presentation.serverConfig?.settings.worktreeAutoPrunePolicy ?? + DEFAULT_WORKTREE_AUTO_PRUNE_POLICY; + let state: WorktreeStorageEnvironmentState; + let report: WorktreeStorageReport | null = null; + let isRefreshing = false; + let error: string | null = null; + + if (connectionPhase !== "connected") { + state = + connectionPhase === "error" + ? "error" + : connectionPhase === "offline" + ? "offline" + : "loading"; + error = + connectionPhase === "error" + ? (presentation.connection.error ?? "This system could not be reached.") + : null; + } else if (presentation.serverConfig === null) { + state = "loading"; + } else if (!capable) { + state = "unsupported"; + } else { + const result = get(worktreeStorageEnvironment.report({ environmentId, input: {} })); + report = Option.getOrNull(AsyncResult.value(result)); + isRefreshing = result.waiting && report !== null; + if (result._tag === "Failure") { + state = "error"; + error = "This system could not report worktree storage."; + } else { + state = report === null ? "loading" : "ready"; + } + } + + statuses.push({ + environmentId, + label: presentation.entry.target.label, + connectionPhase, + capable, + state, + totalBytes: report?.totalBytes ?? null, + partial: report?.partial ?? false, + report, + policy, + isRefreshing, + error, + }); + } + + return statuses; +}).pipe(Atom.withLabel("web-worktree-storage")); + +export interface WorktreeStorageView { + readonly environments: readonly EnvironmentWorktreeStorageStatus[]; + readonly coverage: WorktreeStorageCoverage; + readonly isPending: boolean; + readonly refresh: () => void; +} + +export function useWorktreeStorage(): WorktreeStorageView { + const rawEnvironments = useAtomValue(worktreeStorageAtom); + const environments = useMemo(() => rankWorktreeEnvironments(rawEnvironments), [rawEnvironments]); + const coverage = useMemo(() => computeWorktreeStorageCoverage(environments), [environments]); + const refresh = useCallback(() => { + for (const environment of environments) { + if (environment.connectionPhase !== "connected" || !environment.capable) continue; + appAtomRegistry.refresh( + worktreeStorageEnvironment.report({ environmentId: environment.environmentId, input: {} }), + ); + } + }, [environments]); + + return { + environments, + coverage, + isPending: environments.every((environment) => environment.state === "loading"), + refresh, + }; +} diff --git a/apps/web/src/worktreeStorage.logic.test.ts b/apps/web/src/worktreeStorage.logic.test.ts new file mode 100644 index 000000000000..d2f6d72660dc --- /dev/null +++ b/apps/web/src/worktreeStorage.logic.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from "vite-plus/test"; + +import { + computeWorktreeStorageCoverage, + formatWorktreeStorageBytes, + planAcrossEnvironmentPrune, + rankWorktreeEntries, + rankWorktreeEnvironments, + rankWorktreeProjects, + resolveFrozenPrunePlan, + skippedPruneOutcome, + summarizePruneOutcomes, + worktreeDisplayName, + type WorktreeStorageEnvironmentSummary, +} from "@t3tools/client-runtime/state/worktree-storage"; +import { describeWorktreeProtectionReason } from "./worktreeStorage.logic"; + +function environment( + input: Partial & + Pick, +): WorktreeStorageEnvironmentSummary { + return { + connectionPhase: "connected", + capable: true, + state: "ready", + totalBytes: 0, + partial: false, + ...input, + }; +} + +describe("worktree storage aggregation", () => { + it("formats bytes and protection copy without exposing full paths", () => { + expect(formatWorktreeStorageBytes(0)).toBe("0 B"); + expect(formatWorktreeStorageBytes(1_572_864)).toBe("1.5 MB"); + expect(worktreeDisplayName("/managed/repo/feature-a")).toBe("feature-a"); + expect(worktreeDisplayName("C:\\managed\\repo\\feature-b")).toBe("feature-b"); + expect(describeWorktreeProtectionReason("dirty-or-untracked")).toBe( + "Dirty or untracked changes", + ); + expect(describeWorktreeProtectionReason("unowned-or-orphaned")).toBe( + "No longer linked to a registered project", + ); + }); + + it("qualifies totals instead of treating missing systems as zero", () => { + const coverage = computeWorktreeStorageCoverage([ + environment({ environmentId: "a", label: "Alpha", totalBytes: 120 }), + environment({ + environmentId: "b", + label: "Beta", + connectionPhase: "offline", + state: "offline", + totalBytes: null, + }), + environment({ + environmentId: "c", + label: "Charlie", + capable: false, + state: "unsupported", + totalBytes: null, + }), + environment({ + environmentId: "d", + label: "Delta", + state: "error", + totalBytes: null, + }), + ]); + + expect(coverage).toEqual({ + totalKnownBytes: 120, + knownEnvironmentCount: 1, + environmentCount: 4, + loadingCount: 0, + offlineCount: 1, + unsupportedCount: 1, + errorCount: 1, + partialCount: 0, + complete: false, + }); + }); + + it("qualifies totals when a system report is partial", () => { + expect( + computeWorktreeStorageCoverage([ + environment({ environmentId: "a", label: "Alpha", totalBytes: 120, partial: true }), + ]), + ).toMatchObject({ + totalKnownBytes: 120, + knownEnvironmentCount: 1, + partialCount: 1, + complete: false, + }); + }); + + it("ranks systems, projects, and bounded details by bytes with stable ties", () => { + expect( + rankWorktreeEnvironments([ + environment({ environmentId: "z", label: "Beta", totalBytes: 20 }), + environment({ environmentId: "b", label: "Alpha", totalBytes: 20 }), + environment({ environmentId: "a", label: "Alpha", totalBytes: 20 }), + environment({ environmentId: "unknown", label: "Unknown", totalBytes: null }), + ]).map((item) => item.environmentId), + ).toEqual(["a", "b", "z", "unknown"]); + + expect( + rankWorktreeProjects([ + { projectId: "z", projectTitle: "Beta", bytes: 20 }, + { projectId: "b", projectTitle: "Alpha", bytes: 20 }, + { projectId: "a", projectTitle: "Alpha", bytes: 20 }, + { projectId: "largest", projectTitle: "Largest", bytes: 30 }, + ]).map((item) => item.projectId), + ).toEqual(["largest", "a", "b", "z"]); + + expect( + rankWorktreeEntries([ + { worktreePath: "/z", projectTitle: "Beta", bytes: 20 }, + { worktreePath: "/b", projectTitle: "Alpha", bytes: 20 }, + { worktreePath: "/a", projectTitle: "Alpha", bytes: 20 }, + { worktreePath: "/largest", projectTitle: "Largest", bytes: 30 }, + ]).map((item) => item.worktreePath), + ).toEqual(["/largest", "/a", "/b", "/z"]); + }); +}); + +describe("cross-environment pruning", () => { + it("targets only currently connected capable systems", () => { + const environments = [ + environment({ environmentId: "ready", label: "Ready" }), + environment({ + environmentId: "offline", + label: "Offline", + connectionPhase: "offline", + state: "offline", + totalBytes: null, + }), + environment({ + environmentId: "old", + label: "Old server", + capable: false, + state: "unsupported", + totalBytes: null, + }), + environment({ + environmentId: "connecting", + label: "Connecting", + connectionPhase: "connecting", + state: "loading", + totalBytes: null, + }), + ]; + + const plan = planAcrossEnvironmentPrune(environments); + expect(plan.targets.map((item) => item.environmentId)).toEqual(["ready"]); + expect(plan.skipped.map(skippedPruneOutcome)).toEqual([ + { + environmentId: "offline", + label: "Offline", + status: "skipped", + reason: "offline", + }, + { + environmentId: "old", + label: "Old server", + status: "skipped", + reason: "unsupported", + }, + { + environmentId: "connecting", + label: "Connecting", + status: "skipped", + reason: "unavailable", + }, + ]); + }); + + it("never expands a confirmed prune to a newly connected system", () => { + const plan = resolveFrozenPrunePlan( + [ + environment({ environmentId: "confirmed", label: "Confirmed" }), + environment({ environmentId: "new", label: "New" }), + environment({ + environmentId: "disconnected", + label: "Disconnected", + connectionPhase: "offline", + state: "offline", + }), + ], + ["confirmed", "disconnected"], + ); + + expect(plan.targets.map((item) => item.environmentId)).toEqual(["confirmed"]); + expect(plan.skipped.map((item) => item.environmentId)).toEqual(["disconnected"]); + }); + + it("summarizes partial success without hiding protected or failed outcomes", () => { + expect( + summarizePruneOutcomes([ + { + environmentId: "a", + label: "Alpha", + status: "success", + removedCount: 3, + skippedCount: 2, + failedCount: 1, + freedBytes: 120, + partial: false, + serverErrorCount: 0, + unreportedOutcomeCount: 0, + }, + { + environmentId: "b", + label: "Beta", + status: "skipped", + reason: "offline", + }, + { + environmentId: "c", + label: "Charlie", + status: "failure", + error: "Connection closed", + }, + ]), + ).toEqual({ + succeededEnvironmentCount: 1, + skippedEnvironmentCount: 1, + failedEnvironmentCount: 1, + removedCount: 3, + protectedCount: 2, + failedWorktreeCount: 1, + freedBytes: 120, + partialEnvironmentCount: 0, + serverErrorCount: 0, + unreportedOutcomeCount: 0, + tone: "warning", + }); + }); + + it("warns when a successful server result is partial or omits outcome details", () => { + expect( + summarizePruneOutcomes([ + { + environmentId: "a", + label: "Alpha", + status: "success", + removedCount: 3, + skippedCount: 0, + failedCount: 0, + freedBytes: 120, + partial: true, + serverErrorCount: 2, + unreportedOutcomeCount: 5, + }, + ]), + ).toEqual({ + succeededEnvironmentCount: 1, + skippedEnvironmentCount: 0, + failedEnvironmentCount: 0, + removedCount: 3, + protectedCount: 0, + failedWorktreeCount: 0, + freedBytes: 120, + partialEnvironmentCount: 1, + serverErrorCount: 2, + unreportedOutcomeCount: 5, + tone: "warning", + }); + }); +}); diff --git a/apps/web/src/worktreeStorage.logic.ts b/apps/web/src/worktreeStorage.logic.ts new file mode 100644 index 000000000000..ef3353256edd --- /dev/null +++ b/apps/web/src/worktreeStorage.logic.ts @@ -0,0 +1,26 @@ +import type { WorktreeStorageProtectionReason } from "@t3tools/contracts"; + +const PROTECTION_REASON_LABELS: Readonly> = { + "outside-managed-root": "Outside managed storage", + "shared-across-projects": "Shared across projects", + "main-checkout": "Main checkout", + missing: "Missing on disk", + "locked-or-unknown": "Locked or unknown", + "unowned-or-orphaned": "No longer linked to a registered project", + "dirty-or-untracked": "Dirty or untracked changes", + "ahead-or-unpushed": "Ahead or unpushed commits", + "unsettled-thread": "Unsettled thread", + "recent-activity": "Recently active", + "active-turn-or-session": "Active turn or session", + "live-provider": "Provider is still running", + "live-terminal": "Terminal is still running", + "pending-approval": "Approval is pending", + "pending-input": "Input is pending", + "pending-plan": "Plan is pending", + "background-liveness": "Background work is running", + "inspection-error": "Safety check did not complete", +}; + +export function describeWorktreeProtectionReason(reason: WorktreeStorageProtectionReason): string { + return PROTECTION_REASON_LABELS[reason]; +} diff --git a/docs/README.md b/docs/README.md index 622d81064387..b483aa199af2 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ - [Remote access](./user/remote-access.md) - [Keeping app and server in sync](./user/updating.md) - [Source control integrations](./user/source-control.md) +- [Manage worktree storage](./user/worktree-storage.md) - [Background service (Linux)](./user/background-service.md) - Providers: [Codex](./user/providers-codex.md) · [Claude](./user/providers-claude.md) @@ -33,6 +34,7 @@ policy in [CONTRIBUTING.md](../CONTRIBUTING.md); agent rules in [AGENTS.md](../A - [Remote environments](./internals/remote.md) - [Server updates](./internals/server-updates.md) - [Resource telemetry](./internals/resource-telemetry.md) +- [Worktree storage management](./internals/worktree-storage.md) - [Environment auth](./internals/environment-auth.md) - [T3 Connect](./internals/t3-connect.md) - [CI gates](./internals/ci.md) diff --git a/docs/internals/worktree-storage.md b/docs/internals/worktree-storage.md new file mode 100644 index 000000000000..cab6a605f4c4 --- /dev/null +++ b/docs/internals/worktree-storage.md @@ -0,0 +1,66 @@ +# Worktree storage management + +Worktree storage management reports and prunes T3 Code-managed worktrees without turning one +server into an authority over another server's filesystem. Each environment scans and mutates only +its own worktree directory. Web and mobile clients build the across-systems view by sending the same +typed request to each connected environment that advertises the optional `worktreeStorage` +capability. + +## Accounting + +The server discovers linked worktree roots below its configured worktree directory without +following directory symlinks. It measures apparent checkout bytes with bounded entry, time, error, +candidate, and response budgets. Shared Git object storage is not attributed to a linked worktree, +so reported bytes are an estimate rather than a promise about space reclaimed by deletion. + +Reports carry explicit `partial`, error, total-count, and returned-count fields. Clients may sum the +known bytes across environments, but must keep offline, unsupported, failed, and partial systems +qualified instead of treating them as zero or complete. + +## Eligibility and protection + +The server derives candidates from current and archived thread projections plus inventory under the +managed root. Inventory-only worktrees are reported as unassigned but are always protected from +bulk pruning because the server has no durable activity record for them. + +Before removal, a candidate must remain all of the following: + +- canonically below the managed worktree root and registered by Git; +- distinct from the main checkout, attached, unlocked, and not marked prunable; +- clean, with no untracked files, ahead commits, or commits absent from a remote ref; +- associated only with stale threads and free of active turns, sessions, terminals, providers, + approvals, user input, plans, and background liveness; +- fully inspectable within every safety budget. + +Unknown state protects the candidate. Physical deletion uses `git worktree remove ` without +`--force`. + +## Destructive sequence + +Prunes are serialized within a server process. Every candidate is rescanned before the server +reserves its associated thread paths with an expected-path compare-and-swap. The service verifies +the exact persisted metadata event and reloads projections and live process summaries before a +final filesystem and Git inspection. + +The reservation scope restores cleared paths with an expected-null compare-and-swap on failure or +interruption. Once bounded Git removal starts, the server observes its result without interruption; +successful removal commits the cleared association, while failed removal restores it. Expected-path +metadata updates preserve the thread's durable activity timestamp so the reservation itself cannot +make an inactivity-qualified candidate recent. + +This is a conservative application-level transaction, not a global filesystem lease. T3-controlled +live activity is checked immediately before removal, and Git's non-force removal remains the final +dirty-data guard. Processes outside the server are not admission-locked; maintainers extending this +flow must preserve fail-closed inspection and must not replace non-force removal with forced +deletion. + +## Automatic policies + +Automatic pruning is hosted by each server and defaults to `off`. + +- `on-settle` runs when that policy is enabled or changed and when a thread settles or is archived. +- `after-inactive-days` runs when that policy is enabled or changed and on the bounded periodic + fallback sweep. + +Unrelated settings changes do not trigger a scan. Both automatic modes call the same serialized, +revalidating prune path as the manual RPC. diff --git a/docs/user/worktree-storage.md b/docs/user/worktree-storage.md new file mode 100644 index 000000000000..76f4e37f7f50 --- /dev/null +++ b/docs/user/worktree-storage.md @@ -0,0 +1,51 @@ +# Manage worktree storage + +Open **Settings → Worktree Storage** on web or desktop to review the disk space used by T3 Code +worktrees. On mobile, open **Settings → Worktree Storage** under Configuration. This is separate from +**Client Storage**, which manages offline caches stored on the mobile device. + +The total at the top includes only systems that successfully reported their storage. Offline, +unsupported, and failed systems remain unknown; T3 Code does not count them as zero. Systems and +projects are ranked by known bytes, with a bounded list of worktrees for detail. Select **Refresh +now** or the refresh button to request a new scan. Worktree storage is not polled continuously. + +Totals are known checkout or apparent bytes. Shared Git object storage is excluded, and filesystem +allocation means the disk space actually freed by pruning can differ from the estimate. + +## Protected worktrees + +T3 Code identifies stale worktrees on the system that owns them. Before removing anything, that +system performs fresh safety checks. Worktrees stay protected when they are dirty, active, locked, +ahead of their remote, waiting for input or approval, used by a live provider or terminal, outside +managed storage, or cannot be inspected safely. The Worktree Storage page shows these protection +reasons without presenting protected worktrees as removable. + +Managed worktrees that are no longer linked to a registered project appear under **Unassigned +managed worktrees**. Because T3 Code no longer has a durable activity record for them, both manual +and automatic bulk pruning always leave them protected; they are still included in the reported +storage total. + +## Prune all stale worktrees + +Use **Prune all stale worktrees on this system** to prune one connected system. Use **Prune all +stale worktrees across connected systems** to request pruning from every system that is connected +and supports the feature. + +An across-systems request does not queue work for offline systems. Each connected system performs +its own fresh prune, and T3 Code reports removed, protected, skipped, and failed results separately. +A partial success is not reported as a complete success. + +## Automatic pruning + +Automatic pruning is configured separately for each system. Changing one system's policy does not +change another system or create a device-wide setting. Available policies are: + +- **Off** — never prune automatically. +- **When threads settle** — check for safely removable stale worktrees when their threads settle. +- **After inactivity** — check after the selected number of inactive days, from 1 through 365. + +Choose a mode, enter the inactivity period when needed, then select **Apply** or **Apply policy**. +Changing the draft alone does not enable automatic pruning. + +Automatic policies use the same safety checks as a manual prune. Dirty, active, and unknown +worktrees remain protected. diff --git a/packages/client-runtime/package.json b/packages/client-runtime/package.json index abed33998966..ed0d2f5bb842 100644 --- a/packages/client-runtime/package.json +++ b/packages/client-runtime/package.json @@ -154,6 +154,10 @@ "./state/vcs": { "types": "./src/state/vcs.ts", "default": "./src/state/vcs.ts" + }, + "./state/worktree-storage": { + "types": "./src/state/worktreeStorage.ts", + "default": "./src/state/worktreeStorage.ts" } }, "scripts": { diff --git a/packages/client-runtime/src/state/worktreeStorage.ts b/packages/client-runtime/src/state/worktreeStorage.ts new file mode 100644 index 000000000000..fd23f4e2a469 --- /dev/null +++ b/packages/client-runtime/src/state/worktreeStorage.ts @@ -0,0 +1,36 @@ +import { WS_METHODS } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import { Atom } from "effect/unstable/reactivity"; + +import type { EnvironmentRegistry } from "../connection/registry.ts"; +import { + createAtomCommandScheduler, + createEnvironmentRpcCommand, + createEnvironmentRpcQueryAtomFamily, +} from "./runtime.ts"; + +export * from "./worktreeStorageDomain.ts"; + +export function createWorktreeStorageAtoms( + runtime: Atom.AtomRuntime, +) { + const report = createEnvironmentRpcQueryAtomFamily(runtime, { + label: "environment-data:worktree-storage:report", + tag: WS_METHODS.worktreeStorageGetReport, + staleTimeMs: 15_000, + }); + const scheduler = createAtomCommandScheduler(); + const pruneStale = createEnvironmentRpcCommand(runtime, { + label: "environment-data:worktree-storage:prune-stale", + tag: WS_METHODS.worktreeStoragePruneStale, + scheduler, + concurrency: { + mode: "singleFlight", + key: ({ environmentId }) => environmentId, + }, + onSuccess: ({ environmentId }, registry) => + Effect.sync(() => registry.refresh(report({ environmentId, input: {} }))), + }); + + return { report, pruneStale }; +} diff --git a/packages/client-runtime/src/state/worktreeStorageDomain.test.ts b/packages/client-runtime/src/state/worktreeStorageDomain.test.ts new file mode 100644 index 000000000000..72830bbbe028 --- /dev/null +++ b/packages/client-runtime/src/state/worktreeStorageDomain.test.ts @@ -0,0 +1,107 @@ +import type { WorktreeStoragePruneResult } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { + computeWorktreeStorageCoverage, + planAcrossEnvironmentPrune, + resolveFrozenPrunePlan, + successfulPruneOutcome, + summarizePruneOutcomes, + type WorktreeStorageEnvironmentSummary, +} from "./worktreeStorageDomain.ts"; + +function environment( + input: Partial & + Pick, +): WorktreeStorageEnvironmentSummary { + return { + connectionPhase: "connected", + capable: true, + state: "ready", + totalBytes: 0, + partial: false, + ...input, + }; +} + +describe("worktree storage domain", () => { + it("qualifies partial and missing environment coverage", () => { + expect( + computeWorktreeStorageCoverage([ + environment({ environmentId: "partial", label: "Partial", totalBytes: 120, partial: true }), + environment({ + environmentId: "offline", + label: "Offline", + connectionPhase: "offline", + state: "offline", + totalBytes: null, + }), + ]), + ).toEqual({ + totalKnownBytes: 120, + knownEnvironmentCount: 1, + environmentCount: 2, + loadingCount: 0, + offlineCount: 1, + unsupportedCount: 0, + errorCount: 0, + partialCount: 1, + complete: false, + }); + }); + + it("freezes destructive scope while allowing confirmed systems to become skipped", () => { + const environments = [ + environment({ environmentId: "confirmed", label: "Confirmed" }), + environment({ environmentId: "new", label: "New" }), + environment({ + environmentId: "disconnected", + label: "Disconnected", + connectionPhase: "offline", + state: "offline", + }), + ]; + + expect(planAcrossEnvironmentPrune(environments).targets).toHaveLength(2); + const resolved = resolveFrozenPrunePlan(environments, ["confirmed", "disconnected"]); + expect(resolved.targets.map((item) => item.environmentId)).toEqual(["confirmed"]); + expect(resolved.skipped.map((item) => item.environmentId)).toEqual(["disconnected"]); + }); + + it("preserves partial server results and omitted outcome counts in aggregation", () => { + const result: WorktreeStoragePruneResult = { + startedAt: "2026-08-23T12:00:00.000Z", + completedAt: "2026-08-23T12:00:01.000Z", + removedCount: 2, + skippedCount: 1, + failedCount: 0, + reclaimedBytes: 1_024, + partial: true, + errors: [{ operation: "scan", message: "One worktree could not be inspected" }], + outcomeCount: 3, + outcomes: [ + { + worktreePath: "/managed/feature", + projectId: null, + bytes: 1_024, + status: "removed", + protectionReasons: [], + }, + ], + }; + const outcome = successfulPruneOutcome( + environment({ environmentId: "ready", label: "Ready" }), + result, + ); + + expect(summarizePruneOutcomes([outcome])).toMatchObject({ + removedCount: 2, + protectedCount: 1, + freedBytes: 1_024, + partialEnvironmentCount: 1, + serverErrorCount: 1, + unreportedOutcomeCount: 2, + tone: "warning", + }); + }); +}); diff --git a/packages/client-runtime/src/state/worktreeStorageDomain.ts b/packages/client-runtime/src/state/worktreeStorageDomain.ts new file mode 100644 index 000000000000..10c1f02b61a0 --- /dev/null +++ b/packages/client-runtime/src/state/worktreeStorageDomain.ts @@ -0,0 +1,286 @@ +import type { WorktreeStoragePruneResult } from "@t3tools/contracts"; + +import type { EnvironmentConnectionPhase } from "../connection/presentation.ts"; + +export type WorktreeStorageEnvironmentState = + | "loading" + | "ready" + | "offline" + | "unsupported" + | "error"; + +export interface WorktreeStorageEnvironmentSummary { + readonly environmentId: string; + readonly label: string; + readonly connectionPhase: EnvironmentConnectionPhase; + readonly capable: boolean; + readonly state: WorktreeStorageEnvironmentState; + readonly totalBytes: number | null; + readonly partial: boolean; +} + +export interface WorktreeStorageCoverage { + readonly totalKnownBytes: number; + readonly knownEnvironmentCount: number; + readonly environmentCount: number; + readonly loadingCount: number; + readonly offlineCount: number; + readonly unsupportedCount: number; + readonly errorCount: number; + readonly partialCount: number; + readonly complete: boolean; +} + +export interface WorktreeStorageProjectLike { + readonly projectId: string | null; + readonly projectTitle: string | null; + readonly bytes: number; +} + +export interface WorktreeStorageEntryLike { + readonly worktreePath: string; + readonly projectTitle: string; + readonly bytes: number; +} + +export interface WorktreeStoragePrunePlan { + readonly targets: readonly T[]; + readonly skipped: readonly T[]; +} + +export type WorktreeStorageSkippedReason = "offline" | "unsupported" | "unavailable"; + +export type EnvironmentPruneOutcome = + | { + readonly environmentId: string; + readonly label: string; + readonly status: "success"; + readonly removedCount: number; + readonly skippedCount: number; + readonly failedCount: number; + readonly freedBytes: number; + readonly partial: boolean; + readonly serverErrorCount: number; + readonly unreportedOutcomeCount: number; + } + | { + readonly environmentId: string; + readonly label: string; + readonly status: "skipped"; + readonly reason: WorktreeStorageSkippedReason; + } + | { + readonly environmentId: string; + readonly label: string; + readonly status: "failure"; + readonly error: string; + }; + +export interface PruneOutcomeSummary { + readonly succeededEnvironmentCount: number; + readonly skippedEnvironmentCount: number; + readonly failedEnvironmentCount: number; + readonly removedCount: number; + readonly protectedCount: number; + readonly failedWorktreeCount: number; + readonly freedBytes: number; + readonly partialEnvironmentCount: number; + readonly serverErrorCount: number; + readonly unreportedOutcomeCount: number; + readonly tone: "success" | "warning" | "error"; +} + +const BYTE_UNITS = ["B", "KB", "MB", "GB", "TB"] as const; + +export function formatWorktreeStorageBytes(bytes: number): string { + if (bytes <= 0) return "0 B"; + const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), BYTE_UNITS.length - 1); + const value = bytes / 1024 ** unitIndex; + const maximumFractionDigits = unitIndex === 0 || value >= 100 ? 0 : value >= 10 ? 1 : 2; + return `${new Intl.NumberFormat(undefined, { maximumFractionDigits }).format(value)} ${BYTE_UNITS[unitIndex]}`; +} + +export function worktreeDisplayName(path: string): string { + return path.split(/[\\/]/).findLast((segment) => segment.length > 0) ?? "Worktree"; +} + +function compareText(left: string, right: string): number { + if (left < right) return -1; + if (left > right) return 1; + return 0; +} + +function compareNullableText(left: string | null, right: string | null): number { + return compareText(left ?? "", right ?? ""); +} + +/** Known byte totals rank first; labels and ids make equal-byte ordering deterministic. */ +export function rankWorktreeEnvironments( + environments: readonly T[], +): readonly T[] { + return environments.toSorted((left, right) => { + if (left.totalBytes === null && right.totalBytes !== null) return 1; + if (left.totalBytes !== null && right.totalBytes === null) return -1; + if (left.totalBytes !== null && right.totalBytes !== null) { + const byteOrder = right.totalBytes - left.totalBytes; + if (byteOrder !== 0) return byteOrder; + } + const labelOrder = compareText(left.label, right.label); + return labelOrder !== 0 ? labelOrder : compareText(left.environmentId, right.environmentId); + }); +} + +export function rankWorktreeProjects( + projects: readonly T[], +): readonly T[] { + return projects.toSorted((left, right) => { + const byteOrder = right.bytes - left.bytes; + if (byteOrder !== 0) return byteOrder; + const titleOrder = compareNullableText(left.projectTitle, right.projectTitle); + return titleOrder !== 0 ? titleOrder : compareNullableText(left.projectId, right.projectId); + }); +} + +export function rankWorktreeEntries( + entries: readonly T[], +): readonly T[] { + return entries.toSorted((left, right) => { + const byteOrder = right.bytes - left.bytes; + if (byteOrder !== 0) return byteOrder; + const titleOrder = compareText(left.projectTitle, right.projectTitle); + return titleOrder !== 0 ? titleOrder : compareText(left.worktreePath, right.worktreePath); + }); +} + +/** Missing and partial reports stay qualified; they never contribute a fabricated zero. */ +export function computeWorktreeStorageCoverage( + environments: readonly WorktreeStorageEnvironmentSummary[], +): WorktreeStorageCoverage { + const known = environments.filter( + (environment) => environment.state === "ready" && environment.totalBytes !== null, + ); + const count = (state: WorktreeStorageEnvironmentState) => + environments.filter((environment) => environment.state === state).length; + const partialCount = known.filter((environment) => environment.partial).length; + + return { + totalKnownBytes: known.reduce((sum, environment) => sum + (environment.totalBytes ?? 0), 0), + knownEnvironmentCount: known.length, + environmentCount: environments.length, + loadingCount: count("loading"), + offlineCount: count("offline"), + unsupportedCount: count("unsupported"), + errorCount: count("error"), + partialCount, + complete: known.length === environments.length && partialCount === 0, + }; +} + +/** Across-system pruning dispatches only to systems that can answer right now. */ +export function planAcrossEnvironmentPrune( + environments: readonly T[], +): WorktreeStoragePrunePlan { + const targets: T[] = []; + const skipped: T[] = []; + for (const environment of environments) { + if (environment.connectionPhase === "connected" && environment.capable) { + targets.push(environment); + } else { + skipped.push(environment); + } + } + return { targets, skipped }; +} + +/** A confirmed prune may narrow as systems disconnect, but never expands to new systems. */ +export function resolveFrozenPrunePlan( + environments: readonly T[], + confirmedEnvironmentIds: readonly string[], +): WorktreeStoragePrunePlan { + const confirmed = new Set(confirmedEnvironmentIds); + return planAcrossEnvironmentPrune( + environments.filter((environment) => confirmed.has(environment.environmentId)), + ); +} + +export function worktreeStorageSkippedReason( + environment: WorktreeStorageEnvironmentSummary, +): WorktreeStorageSkippedReason { + if (environment.state === "loading") return "unavailable"; + if (environment.connectionPhase === "offline") return "offline"; + if (environment.connectionPhase !== "connected") return "unavailable"; + return environment.capable ? "unavailable" : "unsupported"; +} + +export function skippedPruneOutcome( + environment: WorktreeStorageEnvironmentSummary, +): EnvironmentPruneOutcome { + return { + environmentId: environment.environmentId, + label: environment.label, + status: "skipped", + reason: worktreeStorageSkippedReason(environment), + }; +} + +export function successfulPruneOutcome( + environment: Pick, + result: WorktreeStoragePruneResult, +): EnvironmentPruneOutcome { + return { + environmentId: environment.environmentId, + label: environment.label, + status: "success", + removedCount: result.removedCount, + skippedCount: result.skippedCount, + failedCount: result.failedCount, + freedBytes: result.reclaimedBytes, + partial: result.partial, + serverErrorCount: result.errors.length, + unreportedOutcomeCount: Math.max(0, result.outcomeCount - result.outcomes.length), + }; +} + +export function summarizePruneOutcomes( + outcomes: readonly EnvironmentPruneOutcome[], +): PruneOutcomeSummary { + const successful = outcomes.filter((outcome) => outcome.status === "success"); + const skippedEnvironmentCount = outcomes.filter((outcome) => outcome.status === "skipped").length; + const failedEnvironmentCount = outcomes.filter((outcome) => outcome.status === "failure").length; + const removedCount = successful.reduce((sum, outcome) => sum + outcome.removedCount, 0); + const protectedCount = successful.reduce((sum, outcome) => sum + outcome.skippedCount, 0); + const failedWorktreeCount = successful.reduce((sum, outcome) => sum + outcome.failedCount, 0); + const freedBytes = successful.reduce((sum, outcome) => sum + outcome.freedBytes, 0); + const partialEnvironmentCount = successful.filter((outcome) => outcome.partial).length; + const serverErrorCount = successful.reduce((sum, outcome) => sum + outcome.serverErrorCount, 0); + const unreportedOutcomeCount = successful.reduce( + (sum, outcome) => sum + outcome.unreportedOutcomeCount, + 0, + ); + const hasWarning = + skippedEnvironmentCount > 0 || + failedEnvironmentCount > 0 || + failedWorktreeCount > 0 || + partialEnvironmentCount > 0 || + serverErrorCount > 0 || + unreportedOutcomeCount > 0; + + return { + succeededEnvironmentCount: successful.length, + skippedEnvironmentCount, + failedEnvironmentCount, + removedCount, + protectedCount, + failedWorktreeCount, + freedBytes, + partialEnvironmentCount, + serverErrorCount, + unreportedOutcomeCount, + tone: + failedEnvironmentCount === outcomes.length && outcomes.length > 0 + ? "error" + : hasWarning + ? "warning" + : "success", + }; +} diff --git a/packages/contracts/src/environment.test.ts b/packages/contracts/src/environment.test.ts index 3a4324625a00..56653fb34281 100644 --- a/packages/contracts/src/environment.test.ts +++ b/packages/contracts/src/environment.test.ts @@ -26,4 +26,14 @@ describe("ExecutionEnvironmentDescriptor", () => { }).capabilities.pullRequests, ).toBe(true); }); + + it("treats worktree storage as an optional version-skew capability", () => { + expect(decodeDescriptor(descriptor).capabilities.worktreeStorage).toBeUndefined(); + expect( + decodeDescriptor({ + ...descriptor, + capabilities: { ...descriptor.capabilities, worktreeStorage: true }, + }).capabilities.worktreeStorage, + ).toBe(true); + }); }); diff --git a/packages/contracts/src/environment.ts b/packages/contracts/src/environment.ts index 1777bcebc2f8..dd7c0488974a 100644 --- a/packages/contracts/src/environment.ts +++ b/packages/contracts/src/environment.ts @@ -80,6 +80,8 @@ export const ExecutionEnvironmentCapabilities = Schema.Struct({ this is false — no update would ever repaint it. Absent on older servers, which may still publish, so only an explicit false skips. */ agentActivityPublishing: Schema.optionalKey(Schema.Boolean), + /** Server exposes environment-local worktree storage reporting and safe stale pruning. */ + worktreeStorage: Schema.optionalKey(Schema.Boolean), }); export type ExecutionEnvironmentCapabilities = typeof ExecutionEnvironmentCapabilities.Type; diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index c6daef8687ba..833695563b8d 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -30,4 +30,5 @@ export * from "./preview.ts"; export * from "./previewAutomation.ts"; export * from "./resourceTelemetry.ts"; export * from "./usage.ts"; +export * from "./worktreeStorage.ts"; export * from "./rpc.ts"; diff --git a/packages/contracts/src/orchestration.ts b/packages/contracts/src/orchestration.ts index 1c27e6d3c6b4..76d5fec4ddb3 100644 --- a/packages/contracts/src/orchestration.ts +++ b/packages/contracts/src/orchestration.ts @@ -773,6 +773,7 @@ const ThreadMetaUpdateCommand = Schema.Struct({ branch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), expectedBranch: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), worktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), + expectedWorktreePath: Schema.optional(Schema.NullOr(TrimmedNonEmptyString)), }).check( Schema.makeFilter( (input) => diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 45bf581de084..014a221b2cfe 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -197,6 +197,12 @@ import { SourceControlRepositoryLookupInput, } from "./sourceControl.ts"; import { VcsError } from "./vcs.ts"; +import { + WorktreeStorageError, + WorktreeStoragePruneResult, + WorktreeStorageReport, + WorktreeStorageRequest, +} from "./worktreeStorage.ts"; export const WS_METHODS = { // Project registry methods @@ -278,6 +284,10 @@ export const WS_METHODS = { serverRetryResourceTelemetry: "server.retryResourceTelemetry", serverSignalProcess: "server.signalProcess", serverReportClientActivity: "server.reportClientActivity", + + // Environment-local worktree storage + worktreeStorageGetReport: "worktreeStorage.getReport", + worktreeStoragePruneStale: "worktreeStorage.pruneStale", serverReportHostPowerState: "server.reportHostPowerState", serverGetBackgroundPolicy: "server.getBackgroundPolicy", serverGetUsageSummary: "server.getUsageSummary", @@ -389,6 +399,19 @@ export const WsServerGetSettingsRpc = Rpc.make(WS_METHODS.serverGetSettings, { error: Schema.Union([ServerSettingsError, EnvironmentAuthorizationError]), }); +export const WsWorktreeStorageGetReportRpc = Rpc.make(WS_METHODS.worktreeStorageGetReport, { + payload: WorktreeStorageRequest, + success: WorktreeStorageReport, + error: Schema.Union([WorktreeStorageError, EnvironmentAuthorizationError]), +}); + +export const WsWorktreeStoragePruneStaleRpc = Rpc.make(WS_METHODS.worktreeStoragePruneStale, { + // Deliberately path-free. The server discovers and revalidates every candidate. + payload: WorktreeStorageRequest, + success: WorktreeStoragePruneResult, + error: Schema.Union([WorktreeStorageError, EnvironmentAuthorizationError]), +}); + export const WsServerUpdateSettingsRpc = Rpc.make(WS_METHODS.serverUpdateSettings, { payload: Schema.Struct({ patch: ServerSettingsPatch }), success: ServerSettings, @@ -1007,6 +1030,8 @@ export const WsRpcGroup = RpcGroup.make( WsServerRemoveKeybindingRpc, WsServerGetSettingsRpc, WsServerUpdateSettingsRpc, + WsWorktreeStorageGetReportRpc, + WsWorktreeStoragePruneStaleRpc, WsServerDiscoverSourceControlRpc, WsServerGetTraceDiagnosticsRpc, WsServerGetProcessDiagnosticsRpc, diff --git a/packages/contracts/src/settings.ts b/packages/contracts/src/settings.ts index 80e03b8c879e..e360a3c1e0f7 100644 --- a/packages/contracts/src/settings.ts +++ b/packages/contracts/src/settings.ts @@ -23,6 +23,7 @@ import { ProviderInstanceId, type ProviderDriverKind, } from "./providerInstance.ts"; +import { DEFAULT_WORKTREE_AUTO_PRUNE_POLICY, WorktreeAutoPrunePolicy } from "./worktreeStorage.ts"; // ── Client Settings (local-only) ─────────────────────────────── @@ -639,6 +640,9 @@ export const ServerSettings = Schema.Struct({ newWorktreesStartFromOrigin: Schema.Boolean.pipe( Schema.withDecodingDefault(Effect.succeed(true)), ), + worktreeAutoPrunePolicy: WorktreeAutoPrunePolicy.pipe( + Schema.withDecodingDefault(Effect.succeed(DEFAULT_WORKTREE_AUTO_PRUNE_POLICY)), + ), addProjectBaseDirectory: TrimmedString.pipe(Schema.withDecodingDefault(Effect.succeed(""))), textGenerationModelSelection: ModelSelection.pipe( Schema.withDecodingDefault( @@ -838,6 +842,7 @@ export const ServerSettingsPatch = Schema.Struct({ backgroundActivityProfile: Schema.optionalKey(BackgroundActivityProfile), defaultThreadEnvMode: Schema.optionalKey(ThreadEnvMode), newWorktreesStartFromOrigin: Schema.optionalKey(Schema.Boolean), + worktreeAutoPrunePolicy: Schema.optionalKey(WorktreeAutoPrunePolicy), addProjectBaseDirectory: Schema.optionalKey(TrimmedString), textGenerationModelSelection: Schema.optionalKey(ModelSelectionPatch), sourceControlWritingStyle: Schema.optionalKey( diff --git a/packages/contracts/src/worktreeStorage.test.ts b/packages/contracts/src/worktreeStorage.test.ts new file mode 100644 index 000000000000..b8e8b6a0d0a0 --- /dev/null +++ b/packages/contracts/src/worktreeStorage.test.ts @@ -0,0 +1,154 @@ +import * as Schema from "effect/Schema"; +import { describe, expect, it } from "vite-plus/test"; + +import { ProjectId, ThreadId } from "./baseSchemas.ts"; +import { WsWorktreeStoragePruneStaleRpc } from "./rpc.ts"; +import { ServerSettings, ServerSettingsPatch } from "./settings.ts"; +import { + DEFAULT_WORKTREE_AUTO_PRUNE_POLICY, + WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS, + WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS, + WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS, + WORKTREE_STORAGE_MAX_DETAILS, + WORKTREE_STORAGE_MAX_ERRORS, + WORKTREE_STORAGE_MAX_OUTCOMES, + WORKTREE_STORAGE_MAX_PROJECTS, + WorktreeAutoPrunePolicy, + WorktreeStoragePruneResult, + WorktreeStorageReport, +} from "./worktreeStorage.ts"; + +const decodePolicy = Schema.decodeUnknownSync(WorktreeAutoPrunePolicy); +const decodeSettings = Schema.decodeUnknownSync(ServerSettings); +const decodeSettingsPatch = Schema.decodeUnknownSync(ServerSettingsPatch); +const decodeReport = Schema.decodeUnknownSync(WorktreeStorageReport); +const decodePruneResult = Schema.decodeUnknownSync(WorktreeStoragePruneResult); +const decodePruneRequest = Schema.decodeUnknownSync(WsWorktreeStoragePruneStaleRpc.payloadSchema); + +describe("worktree storage contracts", () => { + it("defaults automatic pruning off for legacy settings", () => { + expect(decodeSettings({}).worktreeAutoPrunePolicy).toEqual(DEFAULT_WORKTREE_AUTO_PRUNE_POLICY); + }); + + it("accepts only bounded whole-day inactivity policies", () => { + for (const inactivityDays of [ + WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS, + WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS, + ]) { + expect(decodePolicy({ mode: "after-inactive-days", inactivityDays })).toEqual({ + mode: "after-inactive-days", + inactivityDays, + }); + } + for (const inactivityDays of [0, 1.5, WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS + 1]) { + expect(() => decodePolicy({ mode: "after-inactive-days", inactivityDays })).toThrow(); + expect(() => + decodeSettingsPatch({ + worktreeAutoPrunePolicy: { mode: "after-inactive-days", inactivityDays }, + }), + ).toThrow(); + } + }); + + it("caps report arrays and associated thread identifiers", () => { + const detail = { + projectId: ProjectId.make("project-1"), + projectTitle: "Project", + worktreePath: "/tmp/worktree", + bytes: 1, + associatedThreadCount: 1, + associatedThreadIds: [ThreadId.make("thread-1")], + latestActivityAt: null, + stale: true, + eligible: true, + protectionReasons: [], + scanErrors: [], + } as const; + const report = { + scannedAt: "2026-01-01T00:00:00.000Z", + totalBytes: 1, + worktreeCount: 1, + staleWorktreeCount: 1, + eligibleWorktreeCount: 1, + projects: [], + projectCount: 0, + details: [detail], + detailCount: 1, + errors: [], + partial: false, + } as const; + + expect(() => + decodeReport({ + ...report, + projects: Array.from({ length: WORKTREE_STORAGE_MAX_PROJECTS + 1 }, () => ({ + projectId: ProjectId.make("project-1"), + projectTitle: "Project", + bytes: 1, + worktreeCount: 1, + staleWorktreeCount: 1, + eligibleWorktreeCount: 1, + })), + }), + ).toThrow(); + expect(() => + decodeReport({ + ...report, + details: Array.from({ length: WORKTREE_STORAGE_MAX_DETAILS + 1 }, () => detail), + }), + ).toThrow(); + expect(() => + decodeReport({ + ...report, + details: [ + { + ...detail, + associatedThreadIds: Array.from( + { length: WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS + 1 }, + () => ThreadId.make("thread-1"), + ), + }, + ], + }), + ).toThrow(); + expect(() => + decodeReport({ + ...report, + errors: Array.from({ length: WORKTREE_STORAGE_MAX_ERRORS + 1 }, () => ({ + operation: "scan", + message: "failed", + })), + }), + ).toThrow(); + }); + + it("caps prune outcomes and keeps the prune payload path-free", () => { + const outcome = { + worktreePath: "/tmp/worktree", + projectId: ProjectId.make("project-1"), + bytes: 1, + status: "removed" as const, + protectionReasons: [], + }; + expect(() => + decodePruneResult({ + startedAt: "2026-01-01T00:00:00.000Z", + completedAt: "2026-01-01T00:00:01.000Z", + removedCount: 1, + skippedCount: 0, + failedCount: 0, + reclaimedBytes: 1, + outcomes: Array.from({ length: WORKTREE_STORAGE_MAX_OUTCOMES + 1 }, () => outcome), + outcomeCount: WORKTREE_STORAGE_MAX_OUTCOMES + 1, + errors: [], + partial: true, + }), + ).toThrow(); + + expect(() => + decodePruneRequest({ + worktreePath: "/tmp/user-controlled", + }), + ).toThrow(); + }); +}); diff --git a/packages/contracts/src/worktreeStorage.ts b/packages/contracts/src/worktreeStorage.ts new file mode 100644 index 000000000000..064e4a104109 --- /dev/null +++ b/packages/contracts/src/worktreeStorage.ts @@ -0,0 +1,167 @@ +import * as Schema from "effect/Schema"; + +import { + IsoDateTime, + NonNegativeInt, + ProjectId, + ThreadId, + TrimmedNonEmptyString, +} from "./baseSchemas.ts"; + +export const WORKTREE_STORAGE_MAX_PROJECTS = 100; +export const WORKTREE_STORAGE_MAX_DETAILS = 200; +export const WORKTREE_STORAGE_MAX_OUTCOMES = 200; +export const WORKTREE_STORAGE_MAX_ERRORS = 50; +export const WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS = 25; +export const WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS = 1; +export const WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS = 365; + +const WorktreeStorageByteCount = NonNegativeInt.check( + Schema.isLessThanOrEqualTo(Number.MAX_SAFE_INTEGER), +); +const WorktreeStoragePath = TrimmedNonEmptyString.check(Schema.isMaxLength(4_096)); +const WorktreeStorageMessage = TrimmedNonEmptyString.check(Schema.isMaxLength(1_024)); + +export const WorktreeAutoPruneInactivityDays = Schema.Int.check( + Schema.isBetween({ + minimum: WORKTREE_AUTO_PRUNE_MIN_INACTIVITY_DAYS, + maximum: WORKTREE_AUTO_PRUNE_MAX_INACTIVITY_DAYS, + }), +); +export type WorktreeAutoPruneInactivityDays = typeof WorktreeAutoPruneInactivityDays.Type; + +export const WorktreeAutoPrunePolicy = Schema.Union([ + Schema.Struct({ mode: Schema.Literal("off") }), + Schema.Struct({ mode: Schema.Literal("on-settle") }), + Schema.Struct({ + mode: Schema.Literal("after-inactive-days"), + inactivityDays: WorktreeAutoPruneInactivityDays, + }), +]); +export type WorktreeAutoPrunePolicy = typeof WorktreeAutoPrunePolicy.Type; + +export const DEFAULT_WORKTREE_AUTO_PRUNE_POLICY: WorktreeAutoPrunePolicy = { mode: "off" }; + +/** Path-free trigger payload. Unknown runtime fields are rejected instead of preserved. */ +export const WorktreeStorageRequest = Schema.Struct({}).check( + Schema.makeFilter( + (input) => Object.keys(input).length === 0 || "worktree storage requests accept no fields", + ), +); +export type WorktreeStorageRequest = typeof WorktreeStorageRequest.Type; + +export const WorktreeStorageProtectionReason = Schema.Literals([ + "outside-managed-root", + "shared-across-projects", + "main-checkout", + "missing", + "locked-or-unknown", + "dirty-or-untracked", + "ahead-or-unpushed", + "unsettled-thread", + "recent-activity", + "active-turn-or-session", + "live-provider", + "live-terminal", + "pending-approval", + "pending-input", + "pending-plan", + "background-liveness", + "unowned-or-orphaned", + "inspection-error", +]); +export type WorktreeStorageProtectionReason = typeof WorktreeStorageProtectionReason.Type; + +export const WorktreeStorageScanError = Schema.Struct({ + path: Schema.optionalKey(WorktreeStoragePath), + operation: TrimmedNonEmptyString.check(Schema.isMaxLength(128)), + message: WorktreeStorageMessage, +}); +export type WorktreeStorageScanError = typeof WorktreeStorageScanError.Type; + +export const WorktreeStorageDetail = Schema.Struct({ + projectId: Schema.NullOr(ProjectId), + projectTitle: TrimmedNonEmptyString, + worktreePath: WorktreeStoragePath, + bytes: WorktreeStorageByteCount, + associatedThreadCount: NonNegativeInt, + associatedThreadIds: Schema.Array(ThreadId).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_ASSOCIATED_THREAD_IDS), + ), + latestActivityAt: Schema.NullOr(IsoDateTime), + stale: Schema.Boolean, + eligible: Schema.Boolean, + protectionReasons: Schema.Array(WorktreeStorageProtectionReason), + scanErrors: Schema.Array(WorktreeStorageScanError).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_ERRORS), + ), +}); +export type WorktreeStorageDetail = typeof WorktreeStorageDetail.Type; + +export const WorktreeStorageProjectAggregate = Schema.Struct({ + projectId: ProjectId, + projectTitle: TrimmedNonEmptyString, + bytes: WorktreeStorageByteCount, + worktreeCount: NonNegativeInt, + staleWorktreeCount: NonNegativeInt, + eligibleWorktreeCount: NonNegativeInt, +}); +export type WorktreeStorageProjectAggregate = typeof WorktreeStorageProjectAggregate.Type; + +export const WorktreeStorageReport = Schema.Struct({ + scannedAt: IsoDateTime, + totalBytes: WorktreeStorageByteCount, + worktreeCount: NonNegativeInt, + staleWorktreeCount: NonNegativeInt, + eligibleWorktreeCount: NonNegativeInt, + projects: Schema.Array(WorktreeStorageProjectAggregate).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_PROJECTS), + ), + projectCount: NonNegativeInt, + details: Schema.Array(WorktreeStorageDetail).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_DETAILS), + ), + detailCount: NonNegativeInt, + errors: Schema.Array(WorktreeStorageScanError).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_ERRORS), + ), + partial: Schema.Boolean, +}); +export type WorktreeStorageReport = typeof WorktreeStorageReport.Type; + +export const WorktreeStoragePruneOutcome = Schema.Struct({ + worktreePath: WorktreeStoragePath, + projectId: Schema.NullOr(ProjectId), + bytes: WorktreeStorageByteCount, + status: Schema.Literals(["removed", "skipped", "failed"]), + protectionReasons: Schema.Array(WorktreeStorageProtectionReason), + message: Schema.optionalKey(WorktreeStorageMessage), +}); +export type WorktreeStoragePruneOutcome = typeof WorktreeStoragePruneOutcome.Type; + +export const WorktreeStoragePruneResult = Schema.Struct({ + startedAt: IsoDateTime, + completedAt: IsoDateTime, + removedCount: NonNegativeInt, + skippedCount: NonNegativeInt, + failedCount: NonNegativeInt, + reclaimedBytes: WorktreeStorageByteCount, + outcomes: Schema.Array(WorktreeStoragePruneOutcome).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_OUTCOMES), + ), + outcomeCount: NonNegativeInt, + errors: Schema.Array(WorktreeStorageScanError).check( + Schema.isMaxLength(WORKTREE_STORAGE_MAX_ERRORS), + ), + partial: Schema.Boolean, +}); +export type WorktreeStoragePruneResult = typeof WorktreeStoragePruneResult.Type; + +export class WorktreeStorageError extends Schema.TaggedErrorClass()( + "WorktreeStorageError", + { + operation: Schema.Literals(["report", "prune"]), + message: WorktreeStorageMessage, + cause: Schema.optional(Schema.Defect()), + }, +) {} From 33dd0084ded7224f9e5580a4cebafc219d25cae9 Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 15:43:37 -0400 Subject: [PATCH 02/15] fix(mobile): render worktree storage on Hermes --- .../src/state/worktreeStorageDomain.test.ts | 34 +++++++++++++++++++ .../src/state/worktreeStorageDomain.ts | 7 ++-- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/packages/client-runtime/src/state/worktreeStorageDomain.test.ts b/packages/client-runtime/src/state/worktreeStorageDomain.test.ts index 72830bbbe028..a3498b9eb44f 100644 --- a/packages/client-runtime/src/state/worktreeStorageDomain.test.ts +++ b/packages/client-runtime/src/state/worktreeStorageDomain.test.ts @@ -4,6 +4,9 @@ import { describe, expect, it } from "vite-plus/test"; import { computeWorktreeStorageCoverage, planAcrossEnvironmentPrune, + rankWorktreeEntries, + rankWorktreeEnvironments, + rankWorktreeProjects, resolveFrozenPrunePlan, successfulPruneOutcome, summarizePruneOutcomes, @@ -25,6 +28,37 @@ function environment( } describe("worktree storage domain", () => { + it("ranks immutable inputs without relying on Array.prototype.toSorted", () => { + const environments = [ + environment({ environmentId: "small", label: "Small", totalBytes: 10 }), + environment({ environmentId: "large", label: "Large", totalBytes: 20 }), + ]; + const projects = [ + { projectId: "small", projectTitle: "Small", bytes: 10 }, + { projectId: "large", projectTitle: "Large", bytes: 20 }, + ]; + const entries = [ + { worktreePath: "/small", projectTitle: "Small", bytes: 10 }, + { worktreePath: "/large", projectTitle: "Large", bytes: 20 }, + ]; + + expect(rankWorktreeEnvironments(environments).map((item) => item.environmentId)).toEqual([ + "large", + "small", + ]); + expect(rankWorktreeProjects(projects).map((item) => item.projectId)).toEqual([ + "large", + "small", + ]); + expect(rankWorktreeEntries(entries).map((item) => item.worktreePath)).toEqual([ + "/large", + "/small", + ]); + expect(environments.map((item) => item.environmentId)).toEqual(["small", "large"]); + expect(projects.map((item) => item.projectId)).toEqual(["small", "large"]); + expect(entries.map((item) => item.worktreePath)).toEqual(["/small", "/large"]); + }); + it("qualifies partial and missing environment coverage", () => { expect( computeWorktreeStorageCoverage([ diff --git a/packages/client-runtime/src/state/worktreeStorageDomain.ts b/packages/client-runtime/src/state/worktreeStorageDomain.ts index 10c1f02b61a0..fe8730781c5c 100644 --- a/packages/client-runtime/src/state/worktreeStorageDomain.ts +++ b/packages/client-runtime/src/state/worktreeStorageDomain.ts @@ -118,7 +118,8 @@ function compareNullableText(left: string | null, right: string | null): number export function rankWorktreeEnvironments( environments: readonly T[], ): readonly T[] { - return environments.toSorted((left, right) => { + // Hermes does not ship Array.prototype.toSorted, so sort a copy for mobile portability. + return [...environments].sort((left, right) => { if (left.totalBytes === null && right.totalBytes !== null) return 1; if (left.totalBytes !== null && right.totalBytes === null) return -1; if (left.totalBytes !== null && right.totalBytes !== null) { @@ -133,7 +134,7 @@ export function rankWorktreeEnvironments( projects: readonly T[], ): readonly T[] { - return projects.toSorted((left, right) => { + return [...projects].sort((left, right) => { const byteOrder = right.bytes - left.bytes; if (byteOrder !== 0) return byteOrder; const titleOrder = compareNullableText(left.projectTitle, right.projectTitle); @@ -144,7 +145,7 @@ export function rankWorktreeProjects( export function rankWorktreeEntries( entries: readonly T[], ): readonly T[] { - return entries.toSorted((left, right) => { + return [...entries].sort((left, right) => { const byteOrder = right.bytes - left.bytes; if (byteOrder !== 0) return byteOrder; const titleOrder = compareText(left.projectTitle, right.projectTitle); From d3baac4297ed6b83b2e60efa349e3a5e5b8cf7ac Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 16:06:53 -0400 Subject: [PATCH 03/15] fix(settings): harden worktree storage safety --- .../SettingsWorktreeStorage.logic.test.ts | 28 + .../settings/SettingsWorktreeStorage.logic.ts | 16 +- .../SettingsWorktreeStorageRouteScreen.tsx | 195 ++++--- .../project/ProjectSetupScriptRunner.test.ts | 1 + apps/server/src/server.test.ts | 13 +- apps/server/src/terminal/Manager.ts | 2 +- .../worktree/WorktreeStorage.service.test.ts | 84 ++- .../src/worktree/WorktreeStorage.test.ts | 26 + apps/server/src/worktree/WorktreeStorage.ts | 486 +++++++++++------- .../server/src/worktree/directorySize.test.ts | 53 +- apps/server/src/worktree/directorySize.ts | 159 ++++-- .../settings/WorktreeStorageSettings.tsx | 2 +- docs/user/worktree-storage.md | 2 + .../contracts/src/worktreeStorage.test.ts | 1 + packages/contracts/src/worktreeStorage.ts | 4 +- 15 files changed, 760 insertions(+), 312 deletions(-) diff --git a/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.test.ts b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.test.ts index b4b228dad37e..8b41fdb05ae5 100644 --- a/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.test.ts +++ b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from "vite-plus/test"; import { MOBILE_WORKTREE_STORAGE_ROUTE, + updatePendingEnvironmentIds, mobileProtectionLabel, summarizeMobilePrune, } from "./SettingsWorktreeStorage.logic"; @@ -36,6 +37,33 @@ function environment( } describe("mobile worktree storage presentation", () => { + it("tracks overlapping policy updates independently when they finish out of order", () => { + const firstEnvironmentId = "first" as EnvironmentId; + const secondEnvironmentId = "second" as EnvironmentId; + let pendingEnvironmentIds: ReadonlySet = new Set(); + + pendingEnvironmentIds = updatePendingEnvironmentIds( + pendingEnvironmentIds, + firstEnvironmentId, + true, + ); + pendingEnvironmentIds = updatePendingEnvironmentIds( + pendingEnvironmentIds, + secondEnvironmentId, + true, + ); + pendingEnvironmentIds = updatePendingEnvironmentIds( + pendingEnvironmentIds, + firstEnvironmentId, + false, + ); + + expect([...pendingEnvironmentIds]).toEqual([secondEnvironmentId]); + expect( + updatePendingEnvironmentIds(pendingEnvironmentIds, secondEnvironmentId, false).size, + ).toBe(0); + }); + it("keeps Worktree Storage distinct from Client Storage", () => { expect(MOBILE_WORKTREE_STORAGE_ROUTE).toEqual({ label: "Worktree Storage", diff --git a/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts index dd9b08cd92b5..263f9558a22e 100644 --- a/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts +++ b/apps/mobile/src/features/settings/SettingsWorktreeStorage.logic.ts @@ -1,4 +1,4 @@ -import type { WorktreeStorageProtectionReason } from "@t3tools/contracts"; +import type { EnvironmentId, WorktreeStorageProtectionReason } from "@t3tools/contracts"; import { formatWorktreeStorageBytes, type PruneOutcomeSummary, @@ -9,6 +9,20 @@ export const MOBILE_WORKTREE_STORAGE_ROUTE = { target: "SettingsWorktreeStorage", } as const; +export function updatePendingEnvironmentIds( + current: ReadonlySet, + environmentId: EnvironmentId, + pending: boolean, +): ReadonlySet { + const next = new Set(current); + if (pending) { + next.add(environmentId); + } else { + next.delete(environmentId); + } + return next; +} + const PROTECTION_LABELS: Readonly> = { "outside-managed-root": "outside managed storage", "shared-across-projects": "shared across projects", diff --git a/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx b/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx index 214edad72645..629cc6648ccb 100644 --- a/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx +++ b/apps/mobile/src/features/settings/SettingsWorktreeStorageRouteScreen.tsx @@ -49,7 +49,11 @@ import { } from "../../state/worktree-storage"; import { useAtomCommand } from "../../state/use-atom-command"; import { SettingsSection } from "./components/SettingsSection"; -import { mobileProtectionLabel, summarizeMobilePrune } from "./SettingsWorktreeStorage.logic"; +import { + mobileProtectionLabel, + summarizeMobilePrune, + updatePendingEnvironmentIds, +} from "./SettingsWorktreeStorage.logic"; const PROJECT_LIMIT = 6; const WORKTREE_LIMIT_PER_PROJECT = 3; @@ -267,7 +271,7 @@ function EnvironmentSection(props: { {report?.partial || (report?.errors.length ?? 0) > 0 ? ( - + Partial scan. Unknown storage stays protected and may not be included in the total. ) : null} @@ -387,25 +391,44 @@ export function SettingsWorktreeStorageRouteScreen() { reportFailure: false, }); const [pruning, setPruning] = useState(false); - const [savingPolicyEnvironmentId, setSavingPolicyEnvironmentId] = useState( - null, - ); + const [savingPolicyEnvironmentIds, setSavingPolicyEnvironmentIds] = useState< + ReadonlySet + >(() => new Set()); const [lastSummary, setLastSummary] = useState(null); const mutationPending = useRef(false); + const savingPolicyEnvironmentIdsRef = useRef>(new Set()); + const environmentsRef = useRef(environments); + environmentsRef.current = environments; const updatePolicy = async (environmentId: EnvironmentId, policy: WorktreeAutoPrunePolicy) => { - setSavingPolicyEnvironmentId(environmentId); - const result = await updateSettings({ + if (savingPolicyEnvironmentIdsRef.current.has(environmentId)) return; + const pending = updatePendingEnvironmentIds( + savingPolicyEnvironmentIdsRef.current, environmentId, - input: { patch: { worktreeAutoPrunePolicy: policy } }, - }); - setSavingPolicyEnvironmentId(null); - if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { - const cause = squashAtomCommandFailure(result); - Alert.alert( - "Policy update failed", - cause instanceof Error ? cause.message : "Try again when this system is connected.", + true, + ); + savingPolicyEnvironmentIdsRef.current = pending; + setSavingPolicyEnvironmentIds(pending); + try { + const result = await updateSettings({ + environmentId, + input: { patch: { worktreeAutoPrunePolicy: policy } }, + }); + if (result._tag === "Failure" && !isAtomCommandInterrupted(result)) { + const cause = squashAtomCommandFailure(result); + Alert.alert( + "Policy update failed", + cause instanceof Error ? cause.message : "Try again when this system is connected.", + ); + } + } finally { + const remaining = updatePendingEnvironmentIds( + savingPolicyEnvironmentIdsRef.current, + environmentId, + false, ); + savingPolicyEnvironmentIdsRef.current = remaining; + setSavingPolicyEnvironmentIds(remaining); } }; @@ -416,73 +439,79 @@ export function SettingsWorktreeStorageRouteScreen() { if (mutationPending.current) return; mutationPending.current = true; setPruning(true); - const currentPlan = resolveFrozenPrunePlan( - environments, - confirmedTargets.map((environment) => environment.environmentId), - ); - const targets = currentPlan.targets; - const currentIds = new Set(environments.map((environment) => environment.environmentId)); - const disconnectedTargets: FrozenMobileSkippedEnvironment[] = [ - ...currentPlan.skipped.map((environment) => ({ - environmentId: environment.environmentId, - label: environment.label, - reason: worktreeStorageSkippedReason(environment), - })), - ...confirmedTargets - .filter((environment) => !currentIds.has(environment.environmentId)) - .map((environment) => ({ ...environment, reason: "unavailable" as const })), - ]; - const skippedEnvironments = [...initiallySkipped, ...disconnectedTargets]; - const results = await Promise.all( - targets.map(async (environment) => ({ - environment, - result: await pruneStale({ environmentId: environment.environmentId, input: {} }), - })), - ); - const resultOutcomes = results.map((entry): EnvironmentPruneOutcome => { - if (entry.result._tag === "Success") { - return successfulPruneOutcome(entry.environment, entry.result.value); - } - const cause = squashAtomCommandFailure(entry.result); - return { - environmentId: entry.environment.environmentId, - label: entry.environment.label, - status: "failure", - error: cause instanceof Error ? cause.message : "Prune request failed.", - }; - }); - const skippedOutcomes: readonly EnvironmentPruneOutcome[] = skippedEnvironments.map( - (environment) => ({ ...environment, status: "skipped" }), - ); - const outcomes = [...resultOutcomes, ...skippedOutcomes]; - const aggregate = summarizePruneOutcomes(outcomes); - const summary = summarizeMobilePrune(aggregate); - const skippedLabels = outcomes - .filter((outcome) => outcome.status === "skipped") - .map((outcome) => `${outcome.label} (${outcome.reason})`); - const failedLabels = outcomes - .filter((outcome) => outcome.status === "failure") - .map((outcome) => outcome.label); - const partialLabels = outcomes - .filter((outcome) => outcome.status === "success" && outcome.partial) - .map((outcome) => outcome.label); - const resultDetails = [ - partialLabels.length > 0 ? `Partial: ${partialLabels.join(", ")}.` : null, - skippedLabels.length > 0 ? `Skipped: ${skippedLabels.join(", ")}.` : null, - failedLabels.length > 0 ? `Failed: ${failedLabels.join(", ")}.` : null, - ].filter((detail): detail is string => detail !== null); - const detailedSummary = [summary, ...resultDetails].join("\n"); - setLastSummary(detailedSummary); - setPruning(false); - mutationPending.current = false; - Alert.alert( - aggregate.tone !== "success" - ? "Prune finished with exceptions" - : aggregate.removedCount > 0 - ? "Prune finished" - : "No stale worktrees were pruned", - detailedSummary, - ); + try { + const currentEnvironments = environmentsRef.current; + const currentPlan = resolveFrozenPrunePlan( + currentEnvironments, + confirmedTargets.map((environment) => environment.environmentId), + ); + const targets = currentPlan.targets; + const currentIds = new Set( + currentEnvironments.map((environment) => environment.environmentId), + ); + const disconnectedTargets: FrozenMobileSkippedEnvironment[] = [ + ...currentPlan.skipped.map((environment) => ({ + environmentId: environment.environmentId, + label: environment.label, + reason: worktreeStorageSkippedReason(environment), + })), + ...confirmedTargets + .filter((environment) => !currentIds.has(environment.environmentId)) + .map((environment) => ({ ...environment, reason: "unavailable" as const })), + ]; + const skippedEnvironments = [...initiallySkipped, ...disconnectedTargets]; + const results = await Promise.all( + targets.map(async (environment) => ({ + environment, + result: await pruneStale({ environmentId: environment.environmentId, input: {} }), + })), + ); + const resultOutcomes = results.map((entry): EnvironmentPruneOutcome => { + if (entry.result._tag === "Success") { + return successfulPruneOutcome(entry.environment, entry.result.value); + } + const cause = squashAtomCommandFailure(entry.result); + return { + environmentId: entry.environment.environmentId, + label: entry.environment.label, + status: "failure", + error: cause instanceof Error ? cause.message : "Prune request failed.", + }; + }); + const skippedOutcomes: readonly EnvironmentPruneOutcome[] = skippedEnvironments.map( + (environment) => ({ ...environment, status: "skipped" }), + ); + const outcomes = [...resultOutcomes, ...skippedOutcomes]; + const aggregate = summarizePruneOutcomes(outcomes); + const summary = summarizeMobilePrune(aggregate); + const skippedLabels = outcomes + .filter((outcome) => outcome.status === "skipped") + .map((outcome) => `${outcome.label} (${outcome.reason})`); + const failedLabels = outcomes + .filter((outcome) => outcome.status === "failure") + .map((outcome) => outcome.label); + const partialLabels = outcomes + .filter((outcome) => outcome.status === "success" && outcome.partial) + .map((outcome) => outcome.label); + const resultDetails = [ + partialLabels.length > 0 ? `Partial: ${partialLabels.join(", ")}.` : null, + skippedLabels.length > 0 ? `Skipped: ${skippedLabels.join(", ")}.` : null, + failedLabels.length > 0 ? `Failed: ${failedLabels.join(", ")}.` : null, + ].filter((detail): detail is string => detail !== null); + const detailedSummary = [summary, ...resultDetails].join("\n"); + setLastSummary(detailedSummary); + Alert.alert( + aggregate.tone !== "success" + ? "Prune finished with exceptions" + : aggregate.removedCount > 0 + ? "Prune finished" + : "No stale worktrees were pruned", + detailedSummary, + ); + } finally { + setPruning(false); + mutationPending.current = false; + } }; const confirmPrune = (environmentId: EnvironmentId | null) => { @@ -575,7 +604,7 @@ export function SettingsWorktreeStorageRouteScreen() { key={environment.environmentId} environment={environment} pruning={pruning} - savingPolicy={savingPolicyEnvironmentId === environment.environmentId} + savingPolicy={savingPolicyEnvironmentIds.has(environment.environmentId)} onConfirmPrune={(environmentId) => confirmPrune(environmentId)} onUpdatePolicy={(environmentId, policy) => void updatePolicy(environmentId, policy)} /> diff --git a/apps/server/src/project/ProjectSetupScriptRunner.test.ts b/apps/server/src/project/ProjectSetupScriptRunner.test.ts index 5c5da4666b0d..6638b0131970 100644 --- a/apps/server/src/project/ProjectSetupScriptRunner.test.ts +++ b/apps/server/src/project/ProjectSetupScriptRunner.test.ts @@ -59,6 +59,7 @@ const makeTerminalManagerLayer = ( close: () => Effect.void, subscribe: () => Effect.succeed(() => undefined), subscribeMetadata: () => Effect.succeed(() => undefined), + listSummaries: Effect.succeed([]), }); const testLayer = ( diff --git a/apps/server/src/server.test.ts b/apps/server/src/server.test.ts index 02a367c08792..d901af45fbd4 100644 --- a/apps/server/src/server.test.ts +++ b/apps/server/src/server.test.ts @@ -133,6 +133,7 @@ import * as ServerEnvironment from "./environment/ServerEnvironment.ts"; import * as WorkspaceEntries from "./workspace/WorkspaceEntries.ts"; import * as WorkspaceFileSystem from "./workspace/WorkspaceFileSystem.ts"; import * as WorkspacePaths from "./workspace/WorkspacePaths.ts"; +import * as WorktreeStorage from "./worktree/WorktreeStorage.ts"; import * as GitVcsDriver from "./vcs/GitVcsDriver.ts"; import * as VcsDriver from "./vcs/VcsDriver.ts"; import * as VcsStatusBroadcaster from "./vcs/VcsStatusBroadcaster.ts"; @@ -405,6 +406,7 @@ const buildAppUnderTest = (options?: { ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"] >; terminalManager?: Partial; + worktreeStorage?: Partial; orchestrationEngine?: Partial; analyticsService?: Partial; projectionSnapshotQuery?: Partial; @@ -752,9 +754,14 @@ const buildAppUnderTest = (options?: { }), ), Layer.provide( - Layer.mock(TerminalManager.TerminalManager)({ - ...options?.layers?.terminalManager, - }), + Layer.mergeAll( + Layer.mock(TerminalManager.TerminalManager)({ + ...options?.layers?.terminalManager, + }), + Layer.mock(WorktreeStorage.WorktreeStorage)({ + ...options?.layers?.worktreeStorage, + }), + ), ), Layer.provide( Layer.mergeAll( diff --git a/apps/server/src/terminal/Manager.ts b/apps/server/src/terminal/Manager.ts index 41f6ba4542a5..7a391ec63428 100644 --- a/apps/server/src/terminal/Manager.ts +++ b/apps/server/src/terminal/Manager.ts @@ -196,7 +196,7 @@ export class TerminalManager extends Context.Service< ) => Effect.Effect<() => void>; /** Read current in-memory summaries for safety-sensitive host operations. */ - readonly listSummaries?: Effect.Effect>; + readonly listSummaries: Effect.Effect>; } >()("t3/terminal/Manager/TerminalManager") {} diff --git a/apps/server/src/worktree/WorktreeStorage.service.test.ts b/apps/server/src/worktree/WorktreeStorage.service.test.ts index 22e27f7281be..a17c6340dd94 100644 --- a/apps/server/src/worktree/WorktreeStorage.service.test.ts +++ b/apps/server/src/worktree/WorktreeStorage.service.test.ts @@ -25,6 +25,7 @@ import * as ServerConfig from "../config.ts"; import * as OrchestrationEngine from "../orchestration/Services/OrchestrationEngine.ts"; import * as ProjectionSnapshotQuery from "../orchestration/Services/ProjectionSnapshotQuery.ts"; import * as ProviderService from "../provider/Services/ProviderService.ts"; +import { PersistenceSqlError } from "../persistence/Errors.ts"; import * as ServerSettings from "../serverSettings.ts"; import * as TerminalManager from "../terminal/Manager.ts"; import * as VcsProcess from "../vcs/VcsProcess.ts"; @@ -102,6 +103,9 @@ function makeThread(worktreePath: string | null): OrchestrationThreadShell { const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(function* (input: { readonly remove: "success" | "failure" | "blocked-failure"; readonly rebindBeforeReservation?: boolean; + readonly failProjectionLoad?: boolean; + readonly statusStdout?: string; + readonly statusStdouts?: ReadonlyArray; }) { const root = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(process.cwd(), ".worktree-storage-service-test-")), @@ -125,6 +129,8 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio let lastEvent: OrchestrationEvent | null = null; let removeCallCount = 0; let worktreeListCallCount = 0; + let statusCallCount = 0; + let statusArgs: ReadonlyArray = []; let shouldRebind = input.rebindBeforeReservation === true; const removeStarted = yield* Deferred.make(); const releaseRemove = yield* Deferred.make(); @@ -176,12 +182,18 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio }); const projectionLayer = Layer.mock(ProjectionSnapshotQuery.ProjectionSnapshotQuery)({ getShellSnapshot: () => - Effect.sync(() => ({ - snapshotSequence: sequence, - projects: [project], - threads: [makeThread(threadPath)], - updatedAt: OLD, - })), + input.failProjectionLoad === true + ? Effect.fail( + new PersistenceSqlError({ + operation: "load-worktree-storage-test-snapshot", + }), + ) + : Effect.sync(() => ({ + snapshotSequence: sequence, + projects: [project], + threads: [makeThread(threadPath)], + updatedAt: OLD, + })), getArchivedShellSnapshot: () => Effect.sync(() => ({ snapshotSequence: sequence, @@ -204,7 +216,12 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio }), ); } - if (first === "status") return Effect.succeed(processOutput()); + if (first === "status") { + statusArgs = request.args; + const stdout = input.statusStdouts?.[statusCallCount] ?? input.statusStdout; + statusCallCount += 1; + return Effect.succeed(processOutput(stdout === undefined ? {} : { stdout })); + } if (first === "rev-parse") return Effect.succeed(processOutput({ exitCode: 1 })); if (first === "branch") { return Effect.succeed(processOutput({ stdout: "refs/remotes/origin/feature\n" })); @@ -267,6 +284,9 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio get worktreeListCallCount() { return worktreeListCallCount; }, + get statusArgs() { + return statusArgs; + }, }; }); @@ -303,6 +323,56 @@ it.effect("runs reservation, removal, restoration, and fresh report service flow ), ); +it.effect("labels scan-context failures with the requested operation", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ remove: "success", failProjectionLoad: true }); + const service = yield* harness.program; + + const reportError = yield* Effect.flip(service.getReport); + expect(reportError.operation).toBe("report"); + + const pruneError = yield* Effect.flip(service.pruneStale); + expect(pruneError.operation).toBe("prune"); + }), + ), +); + +it.effect("protects ignored files from non-force worktree removal", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ remove: "success", statusStdout: "!! secret\n" }); + const service = yield* harness.program; + + const result = yield* service.pruneStale; + + expect(result.removedCount).toBe(0); + expect(harness.removeCallCount).toBe(0); + expect(harness.statusArgs).toContain("--ignored=matching"); + }), + ), +); + +it.effect("rechecks ignored files immediately before worktree removal", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ + remove: "success", + statusStdouts: ["", "", "", "!! late-secret\n"], + }); + const service = yield* harness.program; + + const result = yield* service.pruneStale; + + expect(result.removedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(result.outcomes[0]?.protectionReasons).toContain("dirty-or-untracked"); + expect(harness.removeCallCount).toBe(0); + expect(harness.threadPath).toBe(harness.candidatePath); + }), + ), +); + it.effect("restores a reservation when pruning is interrupted during bounded removal", () => Effect.scoped( Effect.gen(function* () { diff --git a/apps/server/src/worktree/WorktreeStorage.test.ts b/apps/server/src/worktree/WorktreeStorage.test.ts index 2b1a0292bcd8..a38fc3ece480 100644 --- a/apps/server/src/worktree/WorktreeStorage.test.ts +++ b/apps/server/src/worktree/WorktreeStorage.test.ts @@ -24,6 +24,7 @@ import { automaticPolicyKey, automaticScanMode, decodeWorktreePorcelain, + gitOutputSafetyError, hasLivePathUse, isAppliedThreadPathEvent, isCanonicallyContained, @@ -294,9 +295,22 @@ it.layer(NodeServices.layer)("worktree storage safety decisions", (it) => { { path: "/prunable", locked: false, prunable: true, detached: false }, ]); expect("entries" in decodeWorktreePorcelain(validOutput)).toBe(true); + expect(decodeWorktreePorcelain("worktree /bare.git\0bare\0\0")).toEqual({ + entries: [{ path: "/bare.git", locked: false, prunable: false, detached: false }], + }); expect("error" in decodeWorktreePorcelain("worktree /detached\0HEAD def\0")).toBe(true); expect("error" in decodeWorktreePorcelain("worktree /detached\0HEAD def\0\0")).toBe(true); + expect( + "error" in + decodeWorktreePorcelain("worktree /bare.git\0bare\0HEAD abc\0branch refs/heads/main\0\0"), + ).toBe(true); + expect("error" in decodeWorktreePorcelain("worktree /checkout\0HEAD abc\0branch \0\0")).toBe( + true, + ); + expect("error" in decodeWorktreePorcelain("worktree /bare.git\0bare\0locked reason\0\0")).toBe( + true, + ); expect(worktreeListOutputError({ stdoutTruncated: true })).toContain("safety limit"); expect(worktreeListOutputError({ stdoutTruncated: false, stdoutInvalidUtf8: true })).toContain( "UTF-8", @@ -304,6 +318,18 @@ it.layer(NodeServices.layer)("worktree storage safety decisions", (it) => { expect( worktreeListOutputError({ stdoutTruncated: false, stdoutInvalidUtf8: false }), ).toBeNull(); + expect( + gitOutputSafetyError("Git status", { + stdoutTruncated: false, + stderrTruncated: true, + }), + ).toContain("safety limit"); + expect( + gitOutputSafetyError("Git ahead inspection", { + stdoutTruncated: false, + stdoutInvalidUtf8: true, + }), + ).toContain("UTF-8"); }); it("uses stable ranking tie-breakers and a non-force removal command", () => { diff --git a/apps/server/src/worktree/WorktreeStorage.ts b/apps/server/src/worktree/WorktreeStorage.ts index ae906d6a79ff..3f6ee74eddca 100644 --- a/apps/server/src/worktree/WorktreeStorage.ts +++ b/apps/server/src/worktree/WorktreeStorage.ts @@ -216,17 +216,52 @@ export function decodeWorktreePorcelain( const headFields = fields.filter((field) => field.startsWith("HEAD ")); const branchFields = fields.filter((field) => field.startsWith("branch ")); const detachedFields = fields.filter((field) => field === "detached"); + const bareFields = fields.filter((field) => field === "bare"); + const lockedFields = fields.filter( + (field) => field === "locked" || field.startsWith("locked "), + ); + const prunableFields = fields.filter( + (field) => field === "prunable" || field.startsWith("prunable "), + ); const worktreeField = worktreeFields[0]; const headField = headFields[0]; + const branchField = branchFields[0]; + const hasOnlyKnownFields = fields.every( + (field) => + field.startsWith("worktree ") || + field.startsWith("HEAD ") || + field.startsWith("branch ") || + field === "detached" || + field === "bare" || + field === "locked" || + field.startsWith("locked ") || + field === "prunable" || + field.startsWith("prunable "), + ); + const isBare = + fields.length === 2 && + bareFields.length === 1 && + headFields.length === 0 && + branchFields.length === 0 && + detachedFields.length === 0 && + lockedFields.length === 0 && + prunableFields.length === 0; + const isCheckout = + bareFields.length === 0 && + headFields.length === 1 && + headField !== undefined && + headField.slice("HEAD ".length).length > 0 && + branchFields.length + detachedFields.length === 1 && + (branchField === undefined || branchField.slice("branch ".length).length > 0) && + lockedFields.length <= 1 && + prunableFields.length <= 1; if ( fields[0]?.startsWith("worktree ") !== true || worktreeFields.length !== 1 || worktreeField === undefined || worktreeField.slice("worktree ".length).length === 0 || - headFields.length !== 1 || - headField === undefined || - headField.slice("HEAD ".length).length === 0 || - branchFields.length + detachedFields.length !== 1 + !hasOnlyKnownFields || + (!isBare && !isCheckout) ) { return { error: "Git worktree porcelain output contained a malformed record." }; } @@ -242,17 +277,31 @@ export function decodeWorktreePorcelain( : { entries }; } +export function gitOutputSafetyError( + operation: string, + output: { + readonly stdoutTruncated: boolean; + readonly stderrTruncated?: boolean; + readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; + }, +): string | null { + if (output.stdoutTruncated || output.stderrTruncated === true) { + return `${operation} output exceeded the safety limit.`; + } + if (output.stdoutInvalidUtf8 === true || output.stderrInvalidUtf8 === true) { + return `${operation} output was not valid UTF-8.`; + } + return null; +} + export function worktreeListOutputError(output: { readonly stdoutTruncated: boolean; + readonly stderrTruncated?: boolean; readonly stdoutInvalidUtf8?: boolean; + readonly stderrInvalidUtf8?: boolean; }): string | null { - if (output.stdoutTruncated) { - return "Git worktree porcelain output exceeded the safety limit."; - } - if (output.stdoutInvalidUtf8 === true) { - return "Git worktree porcelain output was not valid UTF-8."; - } - return null; + return gitOutputSafetyError("Git worktree porcelain", output); } export function isAppliedThreadPathEvent(input: { @@ -489,23 +538,8 @@ export interface WorktreeStorageService { readonly pruneStale: Effect.Effect; } -const unavailable = (operation: "report" | "prune") => - Effect.fail( - new WorktreeStorageError({ - operation, - message: "Worktree storage is not available on this environment.", - }), - ); - -/** Defaulting keeps older test/server layer compositions version-skew safe. */ -export class WorktreeStorage extends Context.Reference( +export class WorktreeStorage extends Context.Service()( "t3/worktree/WorktreeStorage", - { - defaultValue: () => ({ - getReport: unavailable("report"), - pruneStale: unavailable("prune"), - }), - }, ) {} export const make = Effect.gen(function* () { @@ -601,12 +635,25 @@ export const make = Effect.gen(function* () { } const statusResult = yield* Effect.result( - runGit(candidatePath, ["status", "--porcelain=v1", "--untracked-files=normal"]), + runGit(candidatePath, [ + "status", + "--porcelain=v1", + "--untracked-files=normal", + "--ignored=matching", + ]), ); - if (Result.isFailure(statusResult) || statusResult.success.exitCode !== 0) { + const statusOutputError = Result.isSuccess(statusResult) + ? gitOutputSafetyError("Git status", statusResult.success) + : null; + if ( + Result.isFailure(statusResult) || + statusResult.success.exitCode !== 0 || + statusOutputError !== null + ) { const cause = Result.isFailure(statusResult) ? statusResult.failure - : statusResult.success.stderr || `git exited ${statusResult.success.exitCode}`; + : (statusOutputError ?? + (statusResult.success.stderr || `git exited ${statusResult.success.exitCode}`)); reasons.add("inspection-error"); errors.push(scanError("git-status", cause, candidatePath)); } else if (statusResult.success.stdout.trim().length > 0) { @@ -619,37 +666,69 @@ export const make = Effect.gen(function* () { if (Result.isFailure(upstreamResult)) { reasons.add("inspection-error"); errors.push(scanError("git-upstream", upstreamResult.failure, candidatePath)); + } else if (gitOutputSafetyError("Git upstream inspection", upstreamResult.success) !== null) { + reasons.add("inspection-error"); + errors.push( + scanError( + "git-upstream", + gitOutputSafetyError("Git upstream inspection", upstreamResult.success), + candidatePath, + ), + ); } else if (upstreamResult.success.exitCode === 0) { const aheadResult = yield* Effect.result( runGit(candidatePath, ["rev-list", "--count", "@{upstream}..HEAD"]), ); - if (Result.isFailure(aheadResult) || aheadResult.success.exitCode !== 0) { + const aheadOutputError = Result.isSuccess(aheadResult) + ? gitOutputSafetyError("Git ahead inspection", aheadResult.success) + : null; + const aheadCount = Result.isSuccess(aheadResult) ? aheadResult.success.stdout.trim() : ""; + if ( + Result.isFailure(aheadResult) || + aheadResult.success.exitCode !== 0 || + aheadOutputError !== null || + !/^\d+$/.test(aheadCount) + ) { reasons.add("inspection-error"); errors.push( scanError( "git-ahead", Result.isFailure(aheadResult) ? aheadResult.failure - : aheadResult.success.stderr || `git exited ${aheadResult.success.exitCode}`, + : (aheadOutputError ?? + (aheadResult.success.stderr || + (!/^\d+$/.test(aheadCount) + ? "Git ahead inspection returned a malformed count." + : `git exited ${aheadResult.success.exitCode}`))), candidatePath, ), ); - } else if (Number.parseInt(aheadResult.success.stdout.trim(), 10) > 0) { + } else if (Number.parseInt(aheadCount, 10) > 0) { reasons.add("ahead-or-unpushed"); } } else { const remoteContainsResult = yield* Effect.result( runGit(candidatePath, ["branch", "-r", "--contains", "HEAD", "--format=%(refname)"]), ); + const remoteOutputError = Result.isSuccess(remoteContainsResult) + ? gitOutputSafetyError("Git remote containment inspection", remoteContainsResult.success) + : null; if ( Result.isFailure(remoteContainsResult) || remoteContainsResult.success.exitCode !== 0 || + remoteOutputError !== null || remoteContainsResult.success.stdout.trim().length === 0 ) { reasons.add("ahead-or-unpushed"); - if (Result.isFailure(remoteContainsResult)) { + if (Result.isFailure(remoteContainsResult) || remoteOutputError !== null) { errors.push( - scanError("git-remote-contains", remoteContainsResult.failure, candidatePath), + scanError( + "git-remote-contains", + Result.isFailure(remoteContainsResult) + ? remoteContainsResult.failure + : remoteOutputError, + candidatePath, + ), ); } } @@ -658,154 +737,151 @@ export const make = Effect.gen(function* () { return { reasons: [...reasons].sort(), errors, mainWorktreePath, targetWorktreePath }; }); - const loadScanContext = Effect.fn("WorktreeStorage.loadScanContext")( - function* (): Effect.fn.Return { - const snapshots = yield* Effect.all({ - active: projection.getShellSnapshot(), - archived: projection.getArchivedShellSnapshot(), - providerSessions: providers.listSessions(), - terminalSummaries: - terminals.listSummaries ?? Effect.fail("Terminal summary inspection is unavailable."), - inventory: Effect.promise(() => - discoverWorktreeDirectoriesNoFollowPromise(config.worktreesDir, { - maxEntries: 10_000, - maxDurationMs: 5_000, - maxFailures: WORKTREE_STORAGE_MAX_ERRORS, + const loadScanContext = Effect.fn("WorktreeStorage.loadScanContext")(function* ( + operation: "report" | "prune", + ): Effect.fn.Return { + const snapshots = yield* Effect.all({ + active: projection.getShellSnapshot(), + archived: projection.getArchivedShellSnapshot(), + providerSessions: providers.listSessions(), + terminalSummaries: terminals.listSummaries, + inventory: Effect.promise(() => + discoverWorktreeDirectoriesNoFollowPromise(config.worktreesDir, { + maxEntries: 10_000, + maxDurationMs: 5_000, + maxFailures: WORKTREE_STORAGE_MAX_ERRORS, + }), + ), + }).pipe( + Effect.mapError( + (cause) => + new WorktreeStorageError({ + operation, + message: "Failed to load current thread state for worktree inspection.", + cause, }), - ), - }).pipe( - Effect.mapError( - (cause) => - new WorktreeStorageError({ - operation: "report", - message: "Failed to load current thread state for worktree inspection.", - cause, - }), - ), - ); - const projectsById = new Map( - [...snapshots.active.projects, ...snapshots.archived.projects].map( - (project) => [project.id, project] as const, - ), - ); - const associations = new Map< - string, - { - worktreePath: string; - projects: Map; - threads: OrchestrationThreadShell[]; - } - >(); - const referencedThreads = yield* Effect.forEach( - [...snapshots.active.threads, ...snapshots.archived.threads], - (thread) => - thread.worktreePath === null - ? Effect.succeed(null) - : fileSystem.realPath(thread.worktreePath).pipe( - Effect.orElseSucceed(() => path.resolve(thread.worktreePath!)), - Effect.map((key) => ({ key, thread, worktreePath: thread.worktreePath! })), - ), - { concurrency: SIZE_SCAN_CONCURRENCY }, - ); - for (const referenced of referencedThreads) { - if (referenced === null) continue; - const { key, thread, worktreePath } = referenced; - const project = projectsById.get(thread.projectId); - const existing = associations.get(key); - if (existing === undefined) { - associations.set(key, { - worktreePath, - projects: new Map(project === undefined ? [] : ([[project.id, project]] as const)), - threads: [thread], - }); - } else { - if (project !== undefined) existing.projects.set(project.id, project); - existing.threads.push(thread); - } - } - const discoveredPaths = yield* Effect.forEach( - snapshots.inventory.paths, - (worktreePath) => - fileSystem.realPath(worktreePath).pipe( - Effect.orElseSucceed(() => path.resolve(worktreePath)), - Effect.map((key) => ({ key, worktreePath })), - ), - { concurrency: SIZE_SCAN_CONCURRENCY }, - ); - for (const discovered of discoveredPaths) { - if (!associations.has(discovered.key)) { - associations.set(discovered.key, { - worktreePath: discovered.worktreePath, - projects: new Map(), - threads: [], - }); - } + ), + ); + const projectsById = new Map( + [...snapshots.active.projects, ...snapshots.archived.projects].map( + (project) => [project.id, project] as const, + ), + ); + const associations = new Map< + string, + { + worktreePath: string; + projects: Map; + threads: OrchestrationThreadShell[]; } - const liveProviderSessions = snapshots.providerSessions.filter( - (session) => - session.status === "connecting" || - session.status === "ready" || - session.status === "running", - ); - const liveProviderThreadIds = new Set( - liveProviderSessions.map((session) => session.threadId), - ); - const liveProviderPaths = yield* Effect.forEach( - liveProviderSessions, - (session) => - session.cwd === undefined - ? Effect.succeed(null) - : fileSystem - .realPath(session.cwd) - .pipe(Effect.orElseSucceed(() => path.resolve(session.cwd!))), - { concurrency: SIZE_SCAN_CONCURRENCY }, - ); - const liveTerminals = snapshots.terminalSummaries.filter( - (terminal) => - terminal.status === "starting" || - terminal.status === "running" || - terminal.hasRunningSubprocess, - ); - const liveTerminalPaths = yield* Effect.forEach( - liveTerminals.flatMap((terminal) => [terminal.worktreePath, terminal.cwd]), - (terminalPath) => - terminalPath === null - ? Effect.succeed(null) - : fileSystem - .realPath(terminalPath) - .pipe(Effect.orElseSucceed(() => path.resolve(terminalPath))), - { concurrency: SIZE_SCAN_CONCURRENCY }, - ); - return { - associations: [...associations.entries()] - .map(([key, value]) => ({ - key, - worktreePath: value.worktreePath, - projects: [...value.projects.values()].sort((left, right) => - left.id.localeCompare(right.id), + >(); + const referencedThreads = yield* Effect.forEach( + [...snapshots.active.threads, ...snapshots.archived.threads], + (thread) => + thread.worktreePath === null + ? Effect.succeed(null) + : fileSystem.realPath(thread.worktreePath).pipe( + Effect.orElseSucceed(() => path.resolve(thread.worktreePath!)), + Effect.map((key) => ({ key, thread, worktreePath: thread.worktreePath! })), ), - threads: [...value.threads].sort((left, right) => left.id.localeCompare(right.id)), - })) - .sort((left, right) => left.key.localeCompare(right.key)), - threadsById: new Map( - [...snapshots.active.threads, ...snapshots.archived.threads].map( - (thread) => [thread.id, thread] as const, - ), - ), - liveProviderThreadIds, - liveProviderPaths: liveProviderPaths.filter( - (providerPath): providerPath is string => providerPath !== null, - ), - liveTerminalThreadIds: new Set(liveTerminals.map((terminal) => terminal.threadId)), - liveTerminalPaths: liveTerminalPaths.filter( - (worktreePath): worktreePath is string => worktreePath !== null, + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + for (const referenced of referencedThreads) { + if (referenced === null) continue; + const { key, thread, worktreePath } = referenced; + const project = projectsById.get(thread.projectId); + const existing = associations.get(key); + if (existing === undefined) { + associations.set(key, { + worktreePath, + projects: new Map(project === undefined ? [] : ([[project.id, project]] as const)), + threads: [thread], + }); + } else { + if (project !== undefined) existing.projects.set(project.id, project); + existing.threads.push(thread); + } + } + const discoveredPaths = yield* Effect.forEach( + snapshots.inventory.paths, + (worktreePath) => + fileSystem.realPath(worktreePath).pipe( + Effect.orElseSucceed(() => path.resolve(worktreePath)), + Effect.map((key) => ({ key, worktreePath })), ), - inventoryErrors: snapshots.inventory.failures.map((failure) => - scanError(`inventory-${failure.operation}`, failure.cause, failure.path), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + for (const discovered of discoveredPaths) { + if (!associations.has(discovered.key)) { + associations.set(discovered.key, { + worktreePath: discovered.worktreePath, + projects: new Map(), + threads: [], + }); + } + } + const liveProviderSessions = snapshots.providerSessions.filter( + (session) => + session.status === "connecting" || + session.status === "ready" || + session.status === "running", + ); + const liveProviderThreadIds = new Set(liveProviderSessions.map((session) => session.threadId)); + const liveProviderPaths = yield* Effect.forEach( + liveProviderSessions, + (session) => + session.cwd === undefined + ? Effect.succeed(null) + : fileSystem + .realPath(session.cwd) + .pipe(Effect.orElseSucceed(() => path.resolve(session.cwd!))), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + const liveTerminals = snapshots.terminalSummaries.filter( + (terminal) => + terminal.status === "starting" || + terminal.status === "running" || + terminal.hasRunningSubprocess, + ); + const liveTerminalPaths = yield* Effect.forEach( + liveTerminals.flatMap((terminal) => [terminal.worktreePath, terminal.cwd]), + (terminalPath) => + terminalPath === null + ? Effect.succeed(null) + : fileSystem + .realPath(terminalPath) + .pipe(Effect.orElseSucceed(() => path.resolve(terminalPath))), + { concurrency: SIZE_SCAN_CONCURRENCY }, + ); + return { + associations: [...associations.entries()] + .map(([key, value]) => ({ + key, + worktreePath: value.worktreePath, + projects: [...value.projects.values()].sort((left, right) => + left.id.localeCompare(right.id), + ), + threads: [...value.threads].sort((left, right) => left.id.localeCompare(right.id)), + })) + .sort((left, right) => left.key.localeCompare(right.key)), + threadsById: new Map( + [...snapshots.active.threads, ...snapshots.archived.threads].map( + (thread) => [thread.id, thread] as const, ), - }; - }, - ); + ), + liveProviderThreadIds, + liveProviderPaths: liveProviderPaths.filter( + (providerPath): providerPath is string => providerPath !== null, + ), + liveTerminalThreadIds: new Set(liveTerminals.map((terminal) => terminal.threadId)), + liveTerminalPaths: liveTerminalPaths.filter( + (worktreePath): worktreePath is string => worktreePath !== null, + ), + inventoryErrors: snapshots.inventory.failures.map((failure) => + scanError(`inventory-${failure.operation}`, failure.cause, failure.path), + ), + }; + }); const scanCandidate = Effect.fn("WorktreeStorage.scanCandidate")(function* ( association: CandidateAssociation, @@ -918,10 +994,11 @@ export const make = Effect.gen(function* () { }); const scanAll = Effect.fn("WorktreeStorage.scanAll")(function* ( + operation: "report" | "prune", mode: ScanMode, startIndex = 0, ): Effect.fn.Return { - const context = yield* loadScanContext(); + const context = yield* loadScanContext(operation); const window = selectCandidateWindow(context.associations, startIndex); const scans = yield* Effect.forEach( window.selected, @@ -940,7 +1017,7 @@ export const make = Effect.gen(function* () { WorktreeStorageError > { const scannedAt = DateTime.formatIso(yield* DateTime.now); - const batch = yield* scanAll({ mode: "manual" }); + const batch = yield* scanAll("report", { mode: "manual" }); const scans = batch.scans; const aggregates = new Map(); for (const scan of scans) { @@ -1141,7 +1218,7 @@ export const make = Effect.gen(function* () { Effect.gen(function* () { const startedAt = DateTime.formatIso(yield* DateTime.now); const cursor = yield* Ref.get(pruneCursor); - const initialBatch = yield* scanAll(mode, cursor); + const initialBatch = yield* scanAll("prune", mode, cursor); const initial = initialBatch.scans; yield* Ref.set( pruneCursor, @@ -1167,7 +1244,7 @@ export const make = Effect.gen(function* () { for (const initialScan of [...initial].sort((left, right) => left.key.localeCompare(right.key), )) { - const context = yield* loadScanContext(); + const context = yield* loadScanContext("prune"); const association = context.associations.find((item) => item.key === initialScan.key); if (association === undefined) { skippedCount += 1; @@ -1232,7 +1309,7 @@ export const make = Effect.gen(function* () { return { value: null, physicalRemovalSucceeded: false } as const; } - const reservedContext = yield* loadScanContext(); + const reservedContext = yield* loadScanContext("prune"); const reservedAssociation = reservedContext.associations.find( (item) => item.key === fresh.key, ); @@ -1316,6 +1393,49 @@ export const make = Effect.gen(function* () { const mainWorktreePath = verified.mainWorktreePath; const removalPath = verified.removalPath; + const finalStatusResult = yield* Effect.result( + runGit(removalPath, [ + "status", + "--porcelain=v1", + "--untracked-files=normal", + "--ignored=matching", + ]), + ); + const finalStatusOutputError = Result.isSuccess(finalStatusResult) + ? gitOutputSafetyError("Final Git status", finalStatusResult.success) + : null; + if ( + Result.isFailure(finalStatusResult) || + finalStatusResult.success.exitCode !== 0 || + finalStatusOutputError !== null || + finalStatusResult.success.stdout.trim().length > 0 + ) { + const cause = Result.isFailure(finalStatusResult) + ? finalStatusResult.failure + : (finalStatusOutputError ?? + (finalStatusResult.success.stderr || + (finalStatusResult.success.stdout.trim().length > 0 + ? "The worktree gained local or ignored files before removal." + : `git exited ${finalStatusResult.success.exitCode}`))); + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push(scanError("git-status-final", cause, fresh.detail.worktreePath)); + } + skippedCount += 1; + outcomes.push({ + worktreePath: fresh.detail.worktreePath, + projectId: fresh.detail.projectId, + bytes: verified.detail.bytes, + status: "skipped", + protectionReasons: + Result.isSuccess(finalStatusResult) && + finalStatusOutputError === null && + finalStatusResult.success.exitCode === 0 + ? ["dirty-or-untracked"] + : ["inspection-error"], + }); + return { value: null, physicalRemovalSucceeded: false } as const; + } + // Once Git removal begins, observe its bounded result before // deciding whether the reservation must be restored. return yield* Effect.gen(function* () { diff --git a/apps/server/src/worktree/directorySize.test.ts b/apps/server/src/worktree/directorySize.test.ts index 906d8b123c27..e7e9ad2f2e77 100644 --- a/apps/server/src/worktree/directorySize.test.ts +++ b/apps/server/src/worktree/directorySize.test.ts @@ -1,7 +1,7 @@ // @effect-diagnostics nodeBuiltinImport:off import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; -import { afterEach, describe, expect, it } from "vite-plus/test"; +import { afterEach, describe, expect, it, vi } from "vite-plus/test"; import { discoverWorktreeDirectoriesNoFollowPromise, @@ -17,6 +17,7 @@ async function makeTemporaryDirectory() { } afterEach(async () => { + vi.useRealTimers(); for (const directory of temporaryDirectories.splice(0)) { await NodeFSP.rm(directory, { recursive: true, force: true }); } @@ -88,4 +89,54 @@ describe("worktree directory sizing", () => { expect(result.paths).toEqual([registered]); expect(result.failures).toEqual([]); }); + + it("reports discovery lstat failures as stat operations", async () => { + const missing = NodePath.join(await makeTemporaryDirectory(), "missing"); + const result = await discoverWorktreeDirectoriesNoFollowPromise(missing, { + maxEntries: 10, + maxDurationMs: 5_000, + maxFailures: 10, + }); + + expect(result.paths).toEqual([]); + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.operation).toBe("stat"); + }); + + const deadlineCases = [ + ["measurement", "lstat", measureDirectoryNoFollowPromise], + ["measurement", "readdir", measureDirectoryNoFollowPromise], + ["discovery", "lstat", discoverWorktreeDirectoriesNoFollowPromise], + ["discovery", "readdir", discoverWorktreeDirectoriesNoFollowPromise], + ] as const; + + it.each(deadlineCases)( + "bounds a stalled %s %s operation by the time budget", + async (_scanKind, stalledOperation, scan) => { + vi.useFakeTimers(); + const never = new Promise(() => undefined); + const fileSystem = { + lstat: () => + stalledOperation === "lstat" + ? never + : Promise.resolve({ + size: 1, + isSymbolicLink: () => false, + isDirectory: () => true, + }), + readdir: () => (stalledOperation === "readdir" ? never : Promise.resolve([])), + }; + const resultPromise = scan( + "/stalled", + { maxEntries: 10, maxDurationMs: 100, maxFailures: 10 }, + fileSystem, + ); + + await vi.advanceTimersByTimeAsync(100); + + const result = await resultPromise; + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.operation).toBe("time-budget"); + }, + ); }); diff --git a/apps/server/src/worktree/directorySize.ts b/apps/server/src/worktree/directorySize.ts index db0b4df3da1b..3b9b9437cd9a 100644 --- a/apps/server/src/worktree/directorySize.ts +++ b/apps/server/src/worktree/directorySize.ts @@ -1,10 +1,11 @@ -// @effect-diagnostics nodeBuiltinImport:off globalDate:off +// @effect-diagnostics nodeBuiltinImport:off globalDate:off globalTimers:off /** * Raw no-follow directory traversal isolated behind the worktree storage adapter. * Effect's portable FileSystem stat follows links, while this safety boundary needs lstat. */ import * as NodeFSP from "node:fs/promises"; import * as NodePath from "node:path"; +import * as NodeTimers from "node:timers"; export interface DirectorySizeFailure { readonly operation: "entry-budget" | "time-budget" | "read-directory" | "stat"; @@ -28,14 +29,49 @@ export interface WorktreeDirectoryDiscovery { readonly failures: ReadonlyArray; } +interface DirectoryTraversalStats { + readonly size: number; + readonly isSymbolicLink: () => boolean; + readonly isDirectory: () => boolean; +} + +interface DirectoryTraversalFileSystem { + readonly lstat: (path: string) => Promise; + readonly readdir: (path: string) => Promise>; +} + +const deadlineExceeded = Symbol("deadlineExceeded"); + +async function runBeforeDeadline
    ( + operation: () => Promise, + deadlineAtMs: number, +): Promise { + const remainingMs = deadlineAtMs - Date.now(); + if (remainingMs <= 0) return deadlineExceeded; + + let timeout: ReturnType | undefined; + try { + return await Promise.race([ + operation(), + new Promise((resolve) => { + timeout = NodeTimers.setTimeout(() => resolve(deadlineExceeded), remainingMs); + }), + ]); + } finally { + if (timeout !== undefined) NodeTimers.clearTimeout(timeout); + } +} + /** Measures directory entries without following symlinks and stops at explicit work budgets. */ export async function measureDirectoryNoFollowPromise( rootPath: string, options: DirectoryTraversalOptions, + fileSystem: DirectoryTraversalFileSystem = NodeFSP, ): Promise { const pending = [rootPath]; const failures: DirectorySizeFailure[] = []; const startedAtMs = Date.now(); + const deadlineAtMs = startedAtMs + options.maxDurationMs; let bytes = 0; let budgetReported = false; @@ -46,7 +82,7 @@ export async function measureDirectoryNoFollowPromise( } }; - for (let index = 0; index < pending.length; index += 1) { + traversal: for (let index = 0; index < pending.length; index += 1) { if (index >= options.maxEntries) { reportBudget( "entry-budget", @@ -61,33 +97,59 @@ export async function measureDirectoryNoFollowPromise( const current = pending[index]; if (current === undefined) continue; + let stats: DirectoryTraversalStats; try { - const stats = await NodeFSP.lstat(current); - bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + Math.max(0, stats.size)); - if (stats.isSymbolicLink() || !stats.isDirectory()) continue; - - try { - const names = await NodeFSP.readdir(current); - names.sort((left, right) => left.localeCompare(right)); - const remaining = Math.max(0, options.maxEntries - pending.length); - for (const name of names.slice(0, remaining)) { - pending.push(NodePath.join(current, name)); - } - if (names.length > remaining) { - reportBudget( - "entry-budget", - `Worktree scan exceeded ${options.maxEntries} filesystem entries.`, - ); - } - } catch (cause) { - if (failures.length < options.maxFailures) { - failures.push({ operation: "read-directory", path: current, cause }); - } + const result = await runBeforeDeadline(() => fileSystem.lstat(current), deadlineAtMs); + if (result === deadlineExceeded) { + reportBudget( + "time-budget", + `Worktree scan exceeded ${options.maxDurationMs} milliseconds.`, + ); + break; } + stats = result; } catch (cause) { if (failures.length < options.maxFailures) { failures.push({ operation: "stat", path: current, cause }); } + continue; + } + + bytes = Math.min(Number.MAX_SAFE_INTEGER, bytes + Math.max(0, stats.size)); + if (stats.isSymbolicLink() || !stats.isDirectory()) continue; + + try { + const result = await runBeforeDeadline(() => fileSystem.readdir(current), deadlineAtMs); + if (result === deadlineExceeded) { + reportBudget( + "time-budget", + `Worktree scan exceeded ${options.maxDurationMs} milliseconds.`, + ); + break traversal; + } + if (Date.now() >= deadlineAtMs) { + reportBudget( + "time-budget", + `Worktree scan exceeded ${options.maxDurationMs} milliseconds.`, + ); + break traversal; + } + const remaining = Math.max(0, options.maxEntries - pending.length); + const selectedNames = result.slice(0, remaining); + selectedNames.sort((left, right) => left.localeCompare(right)); + for (const name of selectedNames) { + pending.push(NodePath.join(current, name)); + } + if (result.length > remaining) { + reportBudget( + "entry-budget", + `Worktree scan exceeded ${options.maxEntries} filesystem entries.`, + ); + } + } catch (cause) { + if (failures.length < options.maxFailures) { + failures.push({ operation: "read-directory", path: current, cause }); + } } } @@ -98,11 +160,13 @@ export async function measureDirectoryNoFollowPromise( export async function discoverWorktreeDirectoriesNoFollowPromise( rootPath: string, options: DirectoryTraversalOptions, + fileSystem: DirectoryTraversalFileSystem = NodeFSP, ): Promise { const pending = [rootPath]; const paths: string[] = []; const failures: DirectorySizeFailure[] = []; const startedAtMs = Date.now(); + const deadlineAtMs = startedAtMs + options.maxDurationMs; let budgetReported = false; const reportBudget = (operation: "entry-budget" | "time-budget", cause: string) => { @@ -112,7 +176,7 @@ export async function discoverWorktreeDirectoriesNoFollowPromise( } }; - for (let index = 0; index < pending.length; index += 1) { + traversal: for (let index = 0; index < pending.length; index += 1) { if (index >= options.maxEntries) { reportBudget( "entry-budget", @@ -130,20 +194,53 @@ export async function discoverWorktreeDirectoriesNoFollowPromise( const current = pending[index]; if (current === undefined) continue; + let stats: DirectoryTraversalStats; + try { + const result = await runBeforeDeadline(() => fileSystem.lstat(current), deadlineAtMs); + if (result === deadlineExceeded) { + reportBudget( + "time-budget", + `Worktree discovery exceeded ${options.maxDurationMs} milliseconds.`, + ); + break; + } + stats = result; + } catch (cause) { + if (failures.length < options.maxFailures) { + failures.push({ operation: "stat", path: current, cause }); + } + continue; + } + + if (stats.isSymbolicLink() || !stats.isDirectory()) continue; + try { - const stats = await NodeFSP.lstat(current); - if (stats.isSymbolicLink() || !stats.isDirectory()) continue; - const names = await NodeFSP.readdir(current); - names.sort((left, right) => left.localeCompare(right)); - if (current !== rootPath && names.includes(".git")) { + const result = await runBeforeDeadline(() => fileSystem.readdir(current), deadlineAtMs); + if (result === deadlineExceeded) { + reportBudget( + "time-budget", + `Worktree discovery exceeded ${options.maxDurationMs} milliseconds.`, + ); + break traversal; + } + if (Date.now() >= deadlineAtMs) { + reportBudget( + "time-budget", + `Worktree discovery exceeded ${options.maxDurationMs} milliseconds.`, + ); + break traversal; + } + if (current !== rootPath && result.includes(".git")) { paths.push(current); continue; } const remaining = Math.max(0, options.maxEntries - pending.length); - for (const name of names.slice(0, remaining)) { + const selectedNames = result.slice(0, remaining); + selectedNames.sort((left, right) => left.localeCompare(right)); + for (const name of selectedNames) { pending.push(NodePath.join(current, name)); } - if (names.length > remaining) { + if (result.length > remaining) { reportBudget( "entry-budget", `Worktree discovery exceeded ${options.maxEntries} filesystem entries.`, diff --git a/apps/web/src/components/settings/WorktreeStorageSettings.tsx b/apps/web/src/components/settings/WorktreeStorageSettings.tsx index 34c0309fd4a5..0dfc2ccb6534 100644 --- a/apps/web/src/components/settings/WorktreeStorageSettings.tsx +++ b/apps/web/src/components/settings/WorktreeStorageSettings.tsx @@ -298,7 +298,7 @@ function EnvironmentInventory({ return (
    {report.partial || report.errors.length > 0 ? ( -

    +

    This scan is partial. Unreadable or unknown storage remains protected and may not be in the total.

    diff --git a/docs/user/worktree-storage.md b/docs/user/worktree-storage.md index 76f4e37f7f50..18c81c659d3f 100644 --- a/docs/user/worktree-storage.md +++ b/docs/user/worktree-storage.md @@ -43,6 +43,8 @@ change another system or create a device-wide setting. Available policies are: - **Off** — never prune automatically. - **When threads settle** — check for safely removable stale worktrees when their threads settle. - **After inactivity** — check after the selected number of inactive days, from 1 through 365. + This policy does not require the thread to be settled; worktrees with live sessions, pending work, + or any other protection reason still remain protected. Choose a mode, enter the inactivity period when needed, then select **Apply** or **Apply policy**. Changing the draft alone does not enable automatic pruning. diff --git a/packages/contracts/src/worktreeStorage.test.ts b/packages/contracts/src/worktreeStorage.test.ts index b8e8b6a0d0a0..62e9514936fc 100644 --- a/packages/contracts/src/worktreeStorage.test.ts +++ b/packages/contracts/src/worktreeStorage.test.ts @@ -150,5 +150,6 @@ describe("worktree storage contracts", () => { worktreePath: "/tmp/user-controlled", }), ).toThrow(); + expect(() => decodePruneRequest([])).toThrow(); }); }); diff --git a/packages/contracts/src/worktreeStorage.ts b/packages/contracts/src/worktreeStorage.ts index 064e4a104109..3a4f6a952ad7 100644 --- a/packages/contracts/src/worktreeStorage.ts +++ b/packages/contracts/src/worktreeStorage.ts @@ -45,7 +45,9 @@ export const DEFAULT_WORKTREE_AUTO_PRUNE_POLICY: WorktreeAutoPrunePolicy = { mod /** Path-free trigger payload. Unknown runtime fields are rejected instead of preserved. */ export const WorktreeStorageRequest = Schema.Struct({}).check( Schema.makeFilter( - (input) => Object.keys(input).length === 0 || "worktree storage requests accept no fields", + (input) => + (!Array.isArray(input) && Object.keys(input).length === 0) || + "worktree storage requests accept no fields", ), ); export type WorktreeStorageRequest = typeof WorktreeStorageRequest.Type; From 5d41e97fc05074e385f2fc962717c7525f85cbee Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 16:08:21 -0400 Subject: [PATCH 04/15] fix(server): enforce worktree scan deadlines --- .../server/src/worktree/directorySize.test.ts | 30 +++++++++++++++++++ apps/server/src/worktree/directorySize.ts | 14 +++++++++ 2 files changed, 44 insertions(+) diff --git a/apps/server/src/worktree/directorySize.test.ts b/apps/server/src/worktree/directorySize.test.ts index e7e9ad2f2e77..249ab62e399a 100644 --- a/apps/server/src/worktree/directorySize.test.ts +++ b/apps/server/src/worktree/directorySize.test.ts @@ -18,6 +18,7 @@ async function makeTemporaryDirectory() { afterEach(async () => { vi.useRealTimers(); + vi.restoreAllMocks(); for (const directory of temporaryDirectories.splice(0)) { await NodeFSP.rm(directory, { recursive: true, force: true }); } @@ -139,4 +140,33 @@ describe("worktree directory sizing", () => { expect(result.failures[0]?.operation).toBe("time-budget"); }, ); + + it.each([ + ["measurement", measureDirectoryNoFollowPromise], + ["discovery", discoverWorktreeDirectoriesNoFollowPromise], + ] as const)("reports a late %s lstat result at the deadline", async (_scanKind, scan) => { + vi.spyOn(Date, "now") + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValueOnce(0) + .mockReturnValue(100); + const fileSystem = { + lstat: () => + Promise.resolve({ + size: 1, + isSymbolicLink: () => false, + isDirectory: () => false, + }), + readdir: () => Promise.resolve([]), + }; + const resultPromise = scan( + "/late", + { maxEntries: 10, maxDurationMs: 100, maxFailures: 10 }, + fileSystem, + ); + + const result = await resultPromise; + expect(result.failures).toHaveLength(1); + expect(result.failures[0]?.operation).toBe("time-budget"); + }); }); diff --git a/apps/server/src/worktree/directorySize.ts b/apps/server/src/worktree/directorySize.ts index 3b9b9437cd9a..3356d77513a6 100644 --- a/apps/server/src/worktree/directorySize.ts +++ b/apps/server/src/worktree/directorySize.ts @@ -107,6 +107,13 @@ export async function measureDirectoryNoFollowPromise( ); break; } + if (Date.now() >= deadlineAtMs) { + reportBudget( + "time-budget", + `Worktree scan exceeded ${options.maxDurationMs} milliseconds.`, + ); + break; + } stats = result; } catch (cause) { if (failures.length < options.maxFailures) { @@ -204,6 +211,13 @@ export async function discoverWorktreeDirectoriesNoFollowPromise( ); break; } + if (Date.now() >= deadlineAtMs) { + reportBudget( + "time-budget", + `Worktree discovery exceeded ${options.maxDurationMs} milliseconds.`, + ); + break; + } stats = result; } catch (cause) { if (failures.length < options.maxFailures) { From 9f424c29a679457fd7a6682f48f8f4583f1e69df Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 16:15:33 -0400 Subject: [PATCH 05/15] fix(server): restore every worktree reservation --- .../worktree/WorktreeStorage.service.test.ts | 102 +++++++++- apps/server/src/worktree/WorktreeStorage.ts | 179 ++++++++++-------- 2 files changed, 193 insertions(+), 88 deletions(-) diff --git a/apps/server/src/worktree/WorktreeStorage.service.test.ts b/apps/server/src/worktree/WorktreeStorage.service.test.ts index a17c6340dd94..c5ce068790ce 100644 --- a/apps/server/src/worktree/WorktreeStorage.service.test.ts +++ b/apps/server/src/worktree/WorktreeStorage.service.test.ts @@ -71,9 +71,12 @@ function makeProject(mainPath: string): OrchestrationProjectShell { }; } -function makeThread(worktreePath: string | null): OrchestrationThreadShell { +function makeThread( + worktreePath: string | null, + id: ThreadId = ThreadId.make("thread-service"), +): OrchestrationThreadShell { return { - id: ThreadId.make("thread-service"), + id, projectId: ProjectId.make("project-service"), title: "Thread", modelSelection: { @@ -106,6 +109,9 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio readonly failProjectionLoad?: boolean; readonly statusStdout?: string; readonly statusStdouts?: ReadonlyArray; + readonly secondThread?: boolean; + readonly defectOnFirstRestore?: boolean; + readonly defectOnSecondClear?: boolean; }) { const root = yield* Effect.promise(() => NodeFSP.mkdtemp(NodePath.join(process.cwd(), ".worktree-storage-service-test-")), @@ -125,6 +131,9 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio ); let threadPath: string | null = candidatePath; + let secondThreadPath: string | null = input.secondThread === true ? candidatePath : null; + const firstThreadId = ThreadId.make("thread-service"); + const secondThreadId = ThreadId.make("thread-service-2"); let sequence = 0; let lastEvent: OrchestrationEvent | null = null; let removeCallCount = 0; @@ -142,14 +151,35 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio if (command.type !== "thread.meta.update") { throw new Error(`Unexpected command: ${command.type}`); } - if (shouldRebind && command.worktreePath === null) { + const isSecondThread = command.threadId === secondThreadId; + if ( + input.defectOnFirstRestore === true && + command.threadId === firstThreadId && + command.commandId.endsWith(":restore") + ) { + throw new Error("first restore defect"); + } + if ( + input.defectOnSecondClear === true && + command.threadId === secondThreadId && + command.worktreePath === null + ) { + throw new Error("second clear defect"); + } + if (shouldRebind && !isSecondThread && command.worktreePath === null) { shouldRebind = false; threadPath = reboundPath; } + const currentThreadPath = isSecondThread ? secondThreadPath : threadPath; const applied = - command.expectedWorktreePath === undefined || command.expectedWorktreePath === threadPath; + command.expectedWorktreePath === undefined || + command.expectedWorktreePath === currentThreadPath; if (applied && command.worktreePath !== undefined) { - threadPath = command.worktreePath; + if (isSecondThread) { + secondThreadPath = command.worktreePath; + } else { + threadPath = command.worktreePath; + } } sequence += 1; lastEvent = { @@ -191,7 +221,12 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio : Effect.sync(() => ({ snapshotSequence: sequence, projects: [project], - threads: [makeThread(threadPath)], + threads: [ + makeThread(threadPath, firstThreadId), + ...(input.secondThread === true + ? [makeThread(secondThreadPath, secondThreadId)] + : []), + ], updatedAt: OLD, })), getArchivedShellSnapshot: () => @@ -201,7 +236,14 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio threads: [], updatedAt: OLD, })), - getThreadShellById: () => Effect.sync(() => Option.some(makeThread(threadPath))), + getThreadShellById: (threadId) => + Effect.sync(() => + threadId === firstThreadId + ? Option.some(makeThread(threadPath, firstThreadId)) + : input.secondThread === true && threadId === secondThreadId + ? Option.some(makeThread(secondThreadPath, secondThreadId)) + : Option.none(), + ), }); const vcsLayer = Layer.mock(VcsProcess.VcsProcess)({ run: (request) => { @@ -278,6 +320,9 @@ const makeHarness = Effect.fn("WorktreeStorage.serviceTest.makeHarness")(functio get threadPath() { return threadPath; }, + get secondThreadPath() { + return secondThreadPath; + }, get removeCallCount() { return removeCallCount; }, @@ -388,3 +433,46 @@ it.effect("restores a reservation when pruning is interrupted during bounded rem }), ), ); + +it.effect("continues restoring later reservations after an earlier restore defects", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ + remove: "failure", + secondThread: true, + defectOnFirstRestore: true, + }); + const service = yield* harness.program; + + const result = yield* service.pruneStale; + + expect(result.failedCount).toBe(1); + expect(result.errors.some((error) => error.operation === "restore-thread-worktree")).toBe( + true, + ); + expect(harness.threadPath).toBeNull(); + expect(harness.secondThreadPath).toBe(harness.candidatePath); + }), + ), +); + +it.effect("restores earlier reservations when a later reservation defects", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeHarness({ + remove: "success", + secondThread: true, + defectOnSecondClear: true, + }); + const service = yield* harness.program; + + const result = yield* service.pruneStale; + + expect(result.removedCount).toBe(0); + expect(result.skippedCount).toBe(1); + expect(harness.removeCallCount).toBe(0); + expect(harness.threadPath).toBe(harness.candidatePath); + expect(harness.secondThreadPath).toBe(harness.candidatePath); + }), + ), +); diff --git a/apps/server/src/worktree/WorktreeStorage.ts b/apps/server/src/worktree/WorktreeStorage.ts index 3f6ee74eddca..f2b4411c0199 100644 --- a/apps/server/src/worktree/WorktreeStorage.ts +++ b/apps/server/src/worktree/WorktreeStorage.ts @@ -21,6 +21,7 @@ import { type WorktreeStorageScanError, } from "@t3tools/contracts"; import * as Context from "effect/Context"; +import * as Cause from "effect/Cause"; import * as Crypto from "effect/Crypto"; import * as DateTime from "effect/DateTime"; import * as Duration from "effect/Duration"; @@ -533,14 +534,13 @@ function aggregateLatestActivity(threads: ReadonlyArray; - readonly pruneStale: Effect.Effect; -} - -export class WorktreeStorage extends Context.Service()( - "t3/worktree/WorktreeStorage", -) {} +export class WorktreeStorage extends Context.Service< + WorktreeStorage, + { + readonly getReport: Effect.Effect; + readonly pruneStale: Effect.Effect; + } +>()("t3/worktree/WorktreeStorage") {} export const make = Effect.gen(function* () { const config = yield* ServerConfig.ServerConfig; @@ -1102,17 +1102,25 @@ export const make = Effect.gen(function* () { const errors: WorktreeStorageScanError[] = []; const reserved: ReservedThreadPath[] = []; for (const thread of association.threads) { - const uuidResult = yield* Effect.result(crypto.randomUUIDv4); - if (Result.isFailure(uuidResult)) { + const uuidExit = yield* Effect.exit(crypto.randomUUIDv4); + if (Exit.isFailure(uuidExit)) { if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { errors.push( - scanError("clear-thread-worktree", uuidResult.failure, association.worktreePath), + scanError( + "clear-thread-worktree", + Cause.squash(uuidExit.cause), + association.worktreePath, + ), ); } - continue; + break; } - const clearCommandId = CommandId.make(`server:worktree-prune:${uuidResult.success}`); - const result = yield* Effect.result( + const clearCommandId = CommandId.make(`server:worktree-prune:${uuidExit.value}`); + const reservedThread = { + thread, + restoreCommandId: CommandId.make(`${clearCommandId}:restore`), + } satisfies ReservedThreadPath; + const dispatchExit = yield* Effect.exit( engine.dispatch({ type: "thread.meta.update", commandId: clearCommandId, @@ -1121,44 +1129,50 @@ export const make = Effect.gen(function* () { expectedWorktreePath: thread.worktreePath, }), ); - if (Result.isFailure(result) && errors.length < WORKTREE_STORAGE_MAX_ERRORS) { - errors.push(scanError("clear-thread-worktree", result.failure, association.worktreePath)); - } else if (Result.isSuccess(result)) { - const appliedResult = yield* Effect.result( - verifyPersistedThreadPathEvent({ - sequence: result.success.sequence, - threadId: ThreadId.make(thread.id), - worktreePath: null, - }), - ); - if (Result.isSuccess(appliedResult) && appliedResult.success) { - reserved.push({ - thread, - restoreCommandId: CommandId.make(`${clearCommandId}:restore`), - }); - } else { - // If the exact event cannot be read, conservatively include the - // path in cleanup. The expected-null restore CAS cannot overwrite - // a concurrent rebound path. - if (Result.isFailure(appliedResult)) { - reserved.push({ - thread, - restoreCommandId: CommandId.make(`${clearCommandId}:restore`), - }); - } - if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { - errors.push( - scanError( - "clear-thread-worktree-cas", - Result.isFailure(appliedResult) - ? appliedResult.failure - : "The persisted metadata event did not apply the expected worktree path.", - association.worktreePath, - ), - ); - } + if (Exit.isFailure(dispatchExit)) { + // A defect may occur after the command was durably applied. The + // expected-null restore CAS makes conservative cleanup safe. + reserved.push(reservedThread); + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError( + "clear-thread-worktree", + Cause.squash(dispatchExit.cause), + association.worktreePath, + ), + ); } + break; } + const appliedExit = yield* Effect.exit( + verifyPersistedThreadPathEvent({ + sequence: dispatchExit.value.sequence, + threadId: ThreadId.make(thread.id), + worktreePath: null, + }), + ); + if (Exit.isSuccess(appliedExit) && appliedExit.value) { + reserved.push(reservedThread); + continue; + } + if (Exit.isFailure(appliedExit)) { + // If the exact event cannot be read, conservatively include the + // path in cleanup. The expected-null restore CAS cannot overwrite + // a concurrent rebound path. + reserved.push(reservedThread); + } + if (errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError( + "clear-thread-worktree-cas", + Exit.isFailure(appliedExit) + ? Cause.squash(appliedExit.cause) + : "The persisted metadata event did not apply the expected worktree path.", + association.worktreePath, + ), + ); + } + break; } return { threads: reserved, errors }; }, @@ -1172,39 +1186,42 @@ export const make = Effect.gen(function* () { for (const reserved of threads) { const { thread } = reserved; if (thread.worktreePath === null) continue; - const result = yield* Effect.result( - engine.dispatch({ - type: "thread.meta.update", - commandId: reserved.restoreCommandId, - threadId: ThreadId.make(thread.id), - worktreePath: thread.worktreePath, - expectedWorktreePath: null, - }), - ); - if (Result.isFailure(result) && errors.length < WORKTREE_STORAGE_MAX_ERRORS) { - errors.push(scanError("restore-thread-worktree", result.failure, thread.worktreePath)); - } else if (Result.isSuccess(result)) { - const appliedResult = yield* Effect.result( - verifyPersistedThreadPathEvent({ - sequence: result.success.sequence, + const restoreExit = yield* Effect.exit( + Effect.gen(function* () { + const result = yield* engine.dispatch({ + type: "thread.meta.update", + commandId: reserved.restoreCommandId, threadId: ThreadId.make(thread.id), worktreePath: thread.worktreePath, - }), + expectedWorktreePath: null, + }); + return yield* verifyPersistedThreadPathEvent({ + sequence: result.sequence, + threadId: ThreadId.make(thread.id), + worktreePath: thread.worktreePath, + }); + }), + ); + if (Exit.isFailure(restoreExit) && errors.length < WORKTREE_STORAGE_MAX_ERRORS) { + errors.push( + scanError( + "restore-thread-worktree", + Cause.squash(restoreExit.cause), + thread.worktreePath, + ), + ); + } else if ( + Exit.isSuccess(restoreExit) && + !restoreExit.value && + errors.length < WORKTREE_STORAGE_MAX_ERRORS + ) { + errors.push( + scanError( + "restore-thread-worktree-cas", + "The persisted metadata event did not restore the reserved worktree path.", + thread.worktreePath, + ), ); - if ( - (Result.isFailure(appliedResult) || !appliedResult.success) && - errors.length < WORKTREE_STORAGE_MAX_ERRORS - ) { - errors.push( - scanError( - "restore-thread-worktree-cas", - Result.isFailure(appliedResult) - ? appliedResult.failure - : "The persisted metadata event did not restore the reserved worktree path.", - thread.worktreePath, - ), - ); - } } } return errors; @@ -1577,7 +1594,7 @@ export const make = Effect.gen(function* () { return { getReport: getReport(), pruneStale: pruneForMode({ mode: "manual" }), - } satisfies WorktreeStorageService; + } satisfies WorktreeStorage["Service"]; }); export const layer = Layer.effect(WorktreeStorage, make); From abbc7455a270992a92bcd260df61384416dededd Mon Sep 17 00:00:00 2001 From: amanthanvi Date: Sun, 23 Aug 2026 16:20:49 -0400 Subject: [PATCH 06/15] fix(web): keep worktree actions responsive --- .../settings/WorktreeStorageSettings.tsx | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/apps/web/src/components/settings/WorktreeStorageSettings.tsx b/apps/web/src/components/settings/WorktreeStorageSettings.tsx index 0dfc2ccb6534..5b0aa8163876 100644 --- a/apps/web/src/components/settings/WorktreeStorageSettings.tsx +++ b/apps/web/src/components/settings/WorktreeStorageSettings.tsx @@ -25,6 +25,7 @@ import { import { HardDriveIcon, RefreshCwIcon, ShieldCheckIcon, Trash2Icon } from "lucide-react"; import { useEffect, useMemo, useRef, useState } from "react"; +import { cn } from "../../lib/utils"; import { serverEnvironment, worktreeStorageEnvironment } from "../../state/server"; import { useWorktreeStorage, @@ -478,6 +479,12 @@ export function WorktreeStorageSettings() { const canRefresh = environments.some( (environment) => environment.connectionPhase === "connected" && environment.capable, ); + const isRefreshPending = environments.some( + (environment) => + environment.connectionPhase === "connected" && + environment.capable && + (environment.state === "loading" || environment.isRefreshing), + ); return ( @@ -486,13 +493,13 @@ export function WorktreeStorageSettings() { icon={} headerAction={ } > @@ -570,6 +577,7 @@ export function WorktreeStorageSettings() {
    ) : null}