diff --git a/apps/ade-cli/README.md b/apps/ade-cli/README.md index 905f6134a..f80234d6b 100644 --- a/apps/ade-cli/README.md +++ b/apps/ade-cli/README.md @@ -354,6 +354,9 @@ ade lane drift resolve --lane lane-id --switch-back # put the worktree ba ade lane drift resolve --lane lane-id --keep-head # re-point the lane (and its name) at the live HEAD branch ade lane drift resolve --lane lane-id --keep-head --expected-head hotfix-auth --force # --expected-head guards a stale read; --force acknowledges active work ade lanes reparent lane-child --parent lane-parent --stack-base-branch main +ade lanes reclaim-preview lane-id --text # show reclaimable space and anything that needs review +ade lanes archive-and-reclaim lane-id --confirm RECLAIM # preserve lane history/branch/chat; remove ADE-managed local files +ade lanes unarchive lane-id # restore the lane; recreate its managed worktree when needed ade lanes delete lane-id --force --delete-branch ade lanes create-from-linear --issue-id ENG-431 --start-chat --provider codex --model ade lanes batch-create-from-linear --linear-issues-json '[{"id":"...","identifier":"ENG-431"},{"id":"...","identifier":"ENG-440"}]' diff --git a/apps/ade-cli/src/bootstrap.ts b/apps/ade-cli/src/bootstrap.ts index b33f1c54c..4b912a23b 100644 --- a/apps/ade-cli/src/bootstrap.ts +++ b/apps/ade-cli/src/bootstrap.ts @@ -47,6 +47,7 @@ import { createLaneEnvironmentService } from "../../desktop/src/main/services/la import { createLaneTemplateService } from "../../desktop/src/main/services/lanes/laneTemplateService"; import { createPortAllocationService } from "../../desktop/src/main/services/lanes/portAllocationService"; import { createLaneProxyService } from "../../desktop/src/main/services/lanes/laneProxyService"; +import { releaseLaneRuntimeResources } from "../../desktop/src/main/services/lanes/laneRuntimeLifecycle"; import { createOAuthRedirectService } from "../../desktop/src/main/services/lanes/oauthRedirectService"; import { createRuntimeDiagnosticsService } from "../../desktop/src/main/services/lanes/runtimeDiagnosticsService"; import { createRebaseSuggestionService } from "../../desktop/src/main/services/lanes/rebaseSuggestionService"; @@ -1570,6 +1571,11 @@ export async function createAdeRuntime(args: { || ptyService.isTranscriptPathActive(filePath) || Boolean(iosSimulatorService?.isBuildPathActive(filePath)), projectId, + laneService, + projectConfigService, + releaseLaneRuntimeResources: (laneId) => { + releaseLaneRuntimeResources({ portAllocationService, laneProxyService }, laneId); + }, // One bounded `ade_feature_used` per completed maintenance run at the daemon // boundary (deduped to 20 h by the service). captureAnalytics: (input) => { diff --git a/apps/ade-cli/src/cli.test.ts b/apps/ade-cli/src/cli.test.ts index b75d007b6..28679f38b 100644 --- a/apps/ade-cli/src/cli.test.ts +++ b/apps/ade-cli/src/cli.test.ts @@ -983,6 +983,62 @@ describe("ADE CLI", () => { }); }); + it("builds lane reclaim preview and confirmed reclaim commands", () => { + const preview = expectExecutePlan( + buildCliPlan(["lanes", "reclaim-preview", "lane-123"]), + ); + expect(preview.label).toBe("lane reclaim preview"); + expect(preview.steps).toEqual([ + { + key: "result", + method: "ade/actions/call", + params: { + name: "run_ade_action", + arguments: { + domain: "lane", + action: "getReclaimRisk", + args: { laneId: "lane-123" }, + }, + }, + unwrapToolResult: true, + }, + ]); + + const reclaim = expectExecutePlan( + buildCliPlan([ + "lanes", + "archive-and-reclaim", + "lane-123", + "--confirm", + "RECLAIM", + "--force-dirty", + ]), + ); + expect(reclaim.label).toBe("lane archive and reclaim"); + expect(reclaim.steps).toEqual([ + { + key: "result", + method: "ade/actions/call", + params: { + name: "run_ade_action", + arguments: { + domain: "lane", + action: "archiveAndReclaim", + args: { + laneId: "lane-123", + confirmation: "RECLAIM", + forceDirty: true, + }, + }, + }, + unwrapToolResult: true, + }, + ]); + expect(() => + buildCliPlan(["lanes", "archive-and-reclaim", "lane-123"]), + ).toThrow(/--confirm RECLAIM/); + }); + it("builds sync status and pairing PIN commands", () => { const status = buildCliPlan([ "sync", diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index b08e0d544..18e97eca6 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -1485,7 +1485,10 @@ const HELP_BY_COMMAND: Record = { Child lanes carry the parent's unmerged work $ ade lanes import --branch Register an existing branch/worktree $ ade lanes archive Archive a lane in ADE - $ ade lanes unarchive Restore an archived lane + $ ade lanes reclaim-preview Show reclaimable space and safety warnings + $ ade lanes archive-and-reclaim --confirm RECLAIM + Archive the lane, then remove its ADE-managed local files + $ ade lanes unarchive Restore an archived lane and recreate its worktree if needed $ ade lanes delete --force Delete a lane and clean up its worktree $ ade lanes attach --path --name Attach an external worktree $ ade lanes reparent --parent Move lane onto a new parent (runs git rebase) @@ -4177,6 +4180,56 @@ function buildLanePlan(args: string[]): CliPlan { ], }; } + if ( + sub === "reclaim-preview" || + sub === "reclaim-risk" || + sub === "preview-reclaim" + ) { + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + return { + kind: "execute", + label: "lane reclaim preview", + steps: [ + actionStep( + "result", + "lane", + "getReclaimRisk", + collectGenericObjectArgs(args, { laneId }), + ), + ], + }; + } + if (sub === "archive-and-reclaim" || sub === "reclaim") { + const laneId = requireValue( + readLaneId(args) ?? firstPositional(args), + "laneId", + ); + const confirmation = readValue(args, ["--confirm", "--confirmation"]); + if (confirmation !== "RECLAIM") { + throw new CliUsageError( + 'archive-and-reclaim requires --confirm RECLAIM. Run "ade lanes reclaim-preview " first.', + ); + } + return { + kind: "execute", + label: "lane archive and reclaim", + steps: [ + actionStep( + "result", + "lane", + "archiveAndReclaim", + collectGenericObjectArgs(args, { + laneId, + confirmation: "RECLAIM", + forceDirty: readFlag(args, ["--force-dirty"]), + }), + ), + ], + }; + } if (sub === "delete" || sub === "rm") { const laneId = requireValue( readLaneId(args) ?? firstPositional(args), diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts index a477ceb02..dba76f7fe 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.test.ts @@ -40,6 +40,8 @@ function createService(options?: { isCloudRelayEnabled?: () => boolean; linearCredentialService?: Record; linearOAuthService?: Record; + laneEnvironmentService?: Record; + portAllocationService?: Record; getLinearIssueTracker?: () => Record | null; usageTrackingService?: Record; productAnalyticsService?: Record; @@ -96,6 +98,8 @@ function createService(options?: { ...(options?.isCloudRelayEnabled ? { isCloudRelayEnabled: options.isCloudRelayEnabled } : {}), ...(options?.linearCredentialService ? { linearCredentialService: options.linearCredentialService } : {}), ...(options?.linearOAuthService ? { linearOAuthService: options.linearOAuthService } : {}), + ...(options?.laneEnvironmentService ? { laneEnvironmentService: options.laneEnvironmentService } : {}), + ...(options?.portAllocationService ? { portAllocationService: options.portAllocationService } : {}), ...(options?.getLinearIssueTracker ? { getLinearIssueTracker: options.getLinearIssueTracker } : {}), ...(options?.usageTrackingService ? { usageTrackingService: options.usageTrackingService } : {}), ...(options?.productAnalyticsService ? { productAnalyticsService: options.productAnalyticsService } : {}), @@ -2468,6 +2472,63 @@ describe("lanes.suggestName", () => { }); }); +describe("lanes.unarchive", () => { + it("recreates the lane environment while preserving the mobile response", async () => { + const lane = { + id: "lane-1", + name: "Lane one", + laneType: "worktree", + worktreePath: "/repo/.ade/worktrees/lane-1", + }; + const unarchive = vi.fn().mockResolvedValue({ + lane, + worktreeRecreated: true, + }); + const list = vi.fn().mockResolvedValue([lane]); + const envInitConfig = { dependencies: ["npm install"] }; + const lease = { + laneId: "lane-1", + rangeStart: 4100, + rangeEnd: 4199, + status: "active", + }; + const getLease = vi.fn().mockReturnValue(null); + const acquire = vi.fn().mockReturnValue(lease); + const getEffective = vi.fn().mockReturnValue({ + laneEnvInit: null, + laneOverlayPolicies: [], + }); + const resolveEnvInitConfig = vi.fn().mockReturnValue(envInitConfig); + const initLaneEnvironment = vi.fn().mockResolvedValue({ state: "ready" }); + const { service } = createService({ + laneService: { unarchive, list }, + projectConfigService: { + getEffective, + }, + laneEnvironmentService: { + resolveEnvInitConfig, + initLaneEnvironment, + }, + portAllocationService: { + getLease, + acquire, + }, + }); + + await expect( + service.execute(makePayload("lanes.unarchive", { laneId: "lane-1" })), + ).resolves.toEqual({ ok: true }); + expect(unarchive).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(list).toHaveBeenCalledWith({ includeArchived: false, includeStatus: false }); + expect(acquire).toHaveBeenCalledWith("lane-1"); + expect(acquire.mock.invocationCallOrder[0]).toBeLessThan(getEffective.mock.invocationCallOrder[0]!); + expect(acquire.mock.invocationCallOrder[0]).toBeLessThan(resolveEnvInitConfig.mock.invocationCallOrder[0]!); + const overrides = { portRange: { start: 4100, end: 4199 } }; + expect(resolveEnvInitConfig).toHaveBeenCalledWith(null, overrides); + expect(initLaneEnvironment).toHaveBeenCalledWith(lane, envInitConfig, overrides); + }); +}); + describe("lanes.refreshSnapshots conditional responses", () => { function createLaneListService() { const lanes = [{ id: "lane-1", name: "Lane one", status: { dirty: false, ahead: 0, behind: 0 } }]; diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 4ffae1804..119c10480 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -259,6 +259,7 @@ import type { createGithubService } from "../../../../desktop/src/main/services/ import type { createOperationService } from "../../../../desktop/src/main/services/history/operationService"; import type { createAutoRebaseService } from "../../../../desktop/src/main/services/lanes/autoRebaseService"; import type { createLaneEnvironmentService } from "../../../../desktop/src/main/services/lanes/laneEnvironmentService"; +import { restoreRecreatedLaneRuntime } from "../../../../desktop/src/main/services/lanes/laneRuntimeLifecycle"; import type { createLaneService } from "../../../../desktop/src/main/services/lanes/laneService"; import type { createLaneTemplateService } from "../../../../desktop/src/main/services/lanes/laneTemplateService"; import type { createPortAllocationService } from "../../../../desktop/src/main/services/lanes/portAllocationService"; @@ -3441,6 +3442,28 @@ async function deleteLaneWithRuntimeCleanup( return { ok: true }; } +async function unarchiveLaneWithRuntimeSetup( + args: SyncRemoteCommandServiceArgs, + payload: Record, +): Promise<{ ok: true }> { + const archiveArgs = parseArchiveLaneArgs(payload, "lanes.unarchive"); + const result = await args.laneService.unarchive(archiveArgs); + if (!result.worktreeRecreated) { + return { ok: true }; + } + try { + await restoreRecreatedLaneRuntime(args, archiveArgs.laneId); + } catch (error) { + // Keep the established mobile command response stable. The worktree was + // restored successfully; environment setup can be retried separately. + args.logger.warn("sync_remote.lane_env_setup.post_unarchive_failed", { + laneId: archiveArgs.laneId, + err: String(error), + }); + } + return { ok: true }; +} + async function resolveChatCreateArgs( service: ReturnType, payload: T, @@ -3801,10 +3824,8 @@ function registerLaneRemoteCommands({ args, register }: RemoteCommandRegistratio await args.laneService.archive(parseArchiveLaneArgs(payload, "lanes.archive")); return { ok: true }; }); - register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => { - await args.laneService.unarchive(parseArchiveLaneArgs(payload, "lanes.unarchive")); - return { ok: true }; - }); + register("lanes.unarchive", { viewerAllowed: true, queueable: true }, async (payload) => + unarchiveLaneWithRuntimeSetup(args, payload)); register("lanes.delete", { viewerAllowed: true, queueable: true }, async (payload) => deleteLaneWithRuntimeCleanup(args, payload)); register("lanes.getStackChain", { viewerAllowed: true }, async (payload) => diff --git a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts index bcf39f348..7fbc199c5 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/appInput.test.ts @@ -23,6 +23,7 @@ import { formatGoalBannerLine, formatGitConflictReport, formatLaneDeleteRisk, + formatLaneReclaimPreview, formFieldUsesPromptInput, isChatFlushEdge, isChatSessionAnimating, @@ -1563,6 +1564,41 @@ describe("formatLaneDeleteRisk", () => { }); }); +describe("formatLaneReclaimPreview", () => { + it("states what ADE removes, keeps, and requires before reclaiming dirty work", () => { + const preview = formatLaneReclaimPreview({ + laneId: "lane-1", + laneName: "Feature lane", + branchRef: "feat/x", + worktreePath: "/project/.ade/worktrees/feature", + dirty: true, + hasUnpushedCommits: false, + unpushedCommitCount: 0, + remoteBranchExists: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + envInitialized: false, + worktreeBytes: 1024 ** 3, + generatedBytes: 256 * 1024 ** 2, + reclaimableBytes: 1.25 * 1024 ** 3, + worktreeAvailable: true, + blockedReasons: [{ + code: "dirty_worktree", + message: "This lane has uncommitted files.", + disposition: "confirmation_required", + }], + lastFailure: null, + retryCount: 0, + }); + + expect(preview).toContain("Estimated space: 1.3 GB"); + expect(preview).toContain("Keeps: the lane, branch, chats, and metadata."); + expect(preview).toContain("/lane archive-and-reclaim lane-1 RECLAIM force-dirty"); + expect(preview).toContain("Nothing has been removed."); + }); +}); + describe("model picker escape handling", () => { const picker = { kind: "model-picker" as const, diff --git a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts index 5d16d5074..826c7eabf 100644 --- a/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts +++ b/apps/ade-cli/src/tuiClient/__tests__/commands.test.ts @@ -73,10 +73,26 @@ describe("commands", () => { expect(unarchive?.name).toBe("/lane unarchive"); expect(unarchive?.args).toBe("feat/x"); + const preview = parseCommand("/lane reclaim-preview feat/x"); + expect(preview?.name).toBe("/lane reclaim-preview"); + expect(preview?.args).toBe("feat/x"); + + const reclaim = parseCommand("/lane archive-and-reclaim feat/x RECLAIM"); + expect(reclaim?.name).toBe("/lane archive-and-reclaim"); + expect(reclaim?.args).toBe("feat/x RECLAIM"); + // /lane delete must still match (longest-name-first ordering). expect(parseCommand("/lane delete")?.name).toBe("/lane delete"); expect(paletteCommands("/lane").map((c) => c.name)).toEqual( - expect.arrayContaining(["/lane rename", "/lane archive", "/lane unarchive", "/lane archived", "/lane delete"]), + expect.arrayContaining([ + "/lane rename", + "/lane archive", + "/lane reclaim-preview", + "/lane archive-and-reclaim", + "/lane unarchive", + "/lane archived", + "/lane delete", + ]), ); }); diff --git a/apps/ade-cli/src/tuiClient/app.tsx b/apps/ade-cli/src/tuiClient/app.tsx index 73cf51ca9..94e40e9e5 100644 --- a/apps/ade-cli/src/tuiClient/app.tsx +++ b/apps/ade-cli/src/tuiClient/app.tsx @@ -41,7 +41,14 @@ import type { } from "../../../desktop/src/shared/types/chat"; import type { AiSettingsStatus, OpenCodeRuntimeSnapshot } from "../../../desktop/src/shared/types/config"; import type { DiffLineStats, GitConflictState } from "../../../desktop/src/shared/types/git"; -import type { LaneDeleteRisk, LaneLinearIssue, LaneSummary } from "../../../desktop/src/shared/types/lanes"; +import type { + ArchiveAndReclaimLaneResult, + LaneDeleteRisk, + LaneLinearIssue, + LaneReclaimRisk, + LaneSummary, + RestoreLaneResult, +} from "../../../desktop/src/shared/types/lanes"; import type { FeedbackPreparedDraft, FeedbackSubmission } from "../../../desktop/src/shared/types/feedback"; import type { ProjectSecretsListResult, ProjectSecretValueResult } from "../../../desktop/src/shared/types/projectSecrets"; import type { SearchQueryResult, SearchResultItem } from "../../../desktop/src/shared/types/search"; @@ -523,6 +530,45 @@ export function formatLaneDeleteRisk(risk: LaneDeleteRisk): string { return parts.length ? `⚠ ${parts.join(" · ")}` : "Clean — no unpushed work or running sessions."; } +function formatStorageBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const unitIndex = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + const value = bytes / (1024 ** unitIndex); + const precision = value >= 10 || unitIndex === 0 ? 0 : 1; + return `${value.toFixed(precision)} ${units[unitIndex]}`; +} + +export function formatLaneReclaimPreview(risk: LaneReclaimRisk): string { + const hardBlocked = risk.blockedReasons.some((reason) => reason.disposition === "blocked"); + const dirtyWarning = risk.blockedReasons.some((reason) => reason.code === "dirty_worktree"); + const reasons = risk.blockedReasons.length + ? [ + "", + hardBlocked ? "Why ADE cannot reclaim this lane:" : "Review before continuing:", + ...risk.blockedReasons.map((reason) => ` • ${reason.message}`), + ] + : ["", "Safety check: Ready to reclaim."]; + const nextCommand = hardBlocked + ? "Nothing has been removed." + : [ + "Nothing has been removed.", + `Run /lane archive-and-reclaim ${risk.laneId} RECLAIM${dirtyWarning ? " force-dirty" : ""} to continue.`, + ].join("\n"); + return [ + `Estimated space: ${formatStorageBytes(risk.reclaimableBytes)}`, + ` Worktree: ${formatStorageBytes(risk.worktreeBytes)}`, + ` Generated data: ${formatStorageBytes(risk.generatedBytes)}`, + "", + "Removes: ADE's managed local worktree and generated data.", + "Keeps: the lane, branch, chats, and metadata.", + "Restore later with /lane unarchive .", + ...reasons, + "", + nextCommand, + ].join("\n"); +} + export type ModelPickerEscapeAction = | { kind: "clear-search"; pane: Extract } | { kind: "return-new-chat" } @@ -7524,7 +7570,7 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, } try { await conn.action("lane", "archive", { laneId: targetId }); - addNotice(`Archived lane ${lane.name}.`, "success"); + addNotice(`Archived lane ${lane.name}. Local files remain.`, "success"); // If we archived the lane we were on, fall back to another live lane. if (activeLaneIdRef.current === targetId) { const fallback = lanes.find((entry) => entry.id !== targetId && !entry.archivedAt) ?? null; @@ -7554,8 +7600,10 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, addNotice(`No archived lane matched "${term}".`, "error"); return; } - await conn.action("lane", "unarchive", { laneId: match.id }); - addNotice(`Unarchived lane ${match.name}.`, "success"); + const result = await conn.action("lane", "unarchive", { laneId: match.id }); + const restoredFiles = result.worktreeRecreated ? " ADE recreated its local worktree." : ""; + const setupWarning = result.setupWarning ? ` Setup needs attention: ${result.setupWarning}` : ""; + addNotice(`Restored lane ${match.name}.${restoredFiles}${setupWarning}`, result.setupWarning ? "error" : "success"); await refreshState(); selectActiveLaneId(match.id); } catch (err) { @@ -9921,6 +9969,69 @@ export function AdeCodeApp({ project, forceEmbedded, requireSocket, socketPath, await archiveLane(); return; } + if (name === "/lane reclaim-preview" || name === "/lane archive-and-reclaim") { + const allLanes = await listLanes(conn, { includeArchived: true }); + const tokens = args.trim().split(/\s+/).filter(Boolean); + const confirmed = name === "/lane archive-and-reclaim" && tokens.includes("RECLAIM"); + const forceDirty = tokens.includes("force-dirty") || tokens.includes("--force-dirty"); + const reference = tokens + .filter((token) => token !== "RECLAIM" && token !== "force-dirty" && token !== "--force-dirty") + .join(" "); + const target = reference + ? resolveLaneReference(allLanes, reference) + : allLanes.find((entry) => entry.id === laneId) ?? null; + if (!target) { + setRightPane({ + kind: "details", + title: "Archive & Reclaim", + body: reference + ? `No lane matched "${reference}". Use an exact lane name or id.` + : "No active lane is selected.", + }); + return; + } + const risk = await conn.action("lane", "getReclaimRisk", { laneId: target.id }); + const preview = formatLaneReclaimPreview(risk); + const hardBlocked = risk.blockedReasons.some((reason) => reason.disposition === "blocked"); + const dirtyNeedsConfirmation = risk.dirty && !forceDirty; + if (!confirmed || hardBlocked || dirtyNeedsConfirmation) { + setRightPane({ kind: "details", title: `Archive & Reclaim · ${target.name}`, body: preview }); + if (hardBlocked) { + addNotice("ADE cannot reclaim this lane. Review the reason in the details pane.", "error"); + } else if (dirtyNeedsConfirmation && confirmed) { + addNotice("This lane has uncommitted files. Add force-dirty only if those file changes may be lost.", "error"); + } + return; + } + const result = await conn.action("lane", "archiveAndReclaim", { + laneId: target.id, + confirmation: "RECLAIM", + forceDirty, + }); + const warningLines = result.warnings.length + ? ["", "Needs attention:", ...result.warnings.map((warning) => ` • ${warning}`)] + : []; + setRightPane({ + kind: "details", + title: `Archive & Reclaim · ${target.name}`, + body: [ + `Reclaimed ${formatStorageBytes(result.reclaimedBytes)}.`, + "The lane, branch, chats, and metadata were kept.", + `Restore it with /lane unarchive ${target.id}.`, + ...warningLines, + ].join("\n"), + }); + addNotice( + `Archived ${target.name} and reclaimed ${formatStorageBytes(result.reclaimedBytes)}.`, + result.warnings.length ? "error" : "success", + ); + if (activeLaneIdRef.current === target.id) { + const fallback = allLanes.find((entry) => entry.id !== target.id && !entry.archivedAt) ?? null; + selectActiveLaneId(fallback?.id ?? null); + } + await refreshState(); + return; + } if (name === "/lane unarchive") { await unarchiveLane(args); return; diff --git a/apps/ade-cli/src/tuiClient/commands.ts b/apps/ade-cli/src/tuiClient/commands.ts index 209396a6a..fdcee18d1 100644 --- a/apps/ade-cli/src/tuiClient/commands.ts +++ b/apps/ade-cli/src/tuiClient/commands.ts @@ -101,6 +101,8 @@ export const BUILTIN_COMMANDS: BuiltinCommand[] = [ { name: "/reparent", description: "Move the active lane under another lane", placement: "right", argumentHint: " [stack-base-ref]", category: "Lanes" }, { name: "/lane rename", description: "Rename the active lane", placement: "right", argumentHint: "[name]", category: "Lanes" }, { name: "/lane archive", description: "Archive the active lane", placement: "right", category: "Lanes" }, + { name: "/lane reclaim-preview", description: "Preview space ADE can reclaim from a lane", placement: "right", argumentHint: "[lane-id|name]", category: "Lanes" }, + { name: "/lane archive-and-reclaim", description: "Archive a lane and remove its ADE-managed local files", placement: "right", argumentHint: "[lane-id|name] [RECLAIM] [force-dirty]", category: "Lanes" }, { name: "/lane unarchive", description: "Unarchive a lane by id or name", placement: "right", argumentHint: "", category: "Lanes" }, { name: "/lane archived", description: "List archived lanes", placement: "right", category: "Lanes" }, { name: "/lane delete", description: "Delete the active lane after confirmation", placement: "right", category: "Lanes" }, diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index 65b75d8dc..ede8d9a79 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -61,6 +61,7 @@ import { createLaneTemplateService } from "./services/lanes/laneTemplateService" import { createLaneWorktreeLockService } from "./services/lanes/laneWorktreeLockService"; import { createPortAllocationService } from "./services/lanes/portAllocationService"; import { createLaneProxyService } from "./services/lanes/laneProxyService"; +import { releaseLaneRuntimeResources } from "./services/lanes/laneRuntimeLifecycle"; import { createOAuthRedirectService } from "./services/lanes/oauthRedirectService"; import { createRuntimeDiagnosticsService } from "./services/lanes/runtimeDiagnosticsService"; import { createSessionService } from "./services/sessions/sessionService"; @@ -3759,6 +3760,11 @@ app.whenReady().then(async () => { || ptyService.isTranscriptPathActive(filePath) || iosSimulatorService.isBuildPathActive(filePath), projectId, + laneService, + projectConfigService, + releaseLaneRuntimeResources: (laneId) => { + releaseLaneRuntimeResources({ portAllocationService, laneProxyService }, laneId); + }, captureAnalytics: (input) => { productAnalyticsService.capture(input); }, diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index 1ea1afd0b..6c61b2fe3 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -457,6 +457,138 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { }); }); + it("documents lane reclaim contracts for safe CLI action discovery", () => { + expect(getAdeActionInputContract("lane", "getReclaimRisk")).toMatchObject({ + description: expect.stringContaining("estimated bytes"), + input: expect.stringContaining("laneId"), + }); + expect(getAdeActionInputContract("lane", "archiveAndReclaim")).toMatchObject({ + description: expect.stringContaining("lane, branch, chat, and metadata remain"), + input: expect.stringContaining('"RECLAIM"'), + example: expect.stringContaining("--confirm RECLAIM"), + }); + expect(getAdeActionInputContract("lane", "unarchive")).toMatchObject({ + description: expect.stringContaining("recreate"), + }); + }); + + it("requires exact reclaim confirmation at the action boundary", async () => { + const archiveAndReclaim = vi.fn(async (args: unknown) => ({ args })); + const onLaneArchived = vi.fn(); + const services = getAdeActionDomainServices({ + laneService: { archiveAndReclaim }, + automationService: { onLaneArchived }, + } as never); + const laneActions = services.lane as { + archiveAndReclaim: (args?: { + laneId?: string; + confirmation?: string; + forceDirty?: boolean; + }) => Promise; + }; + + await expect(laneActions.archiveAndReclaim({ laneId: "lane-1" })).rejects.toThrow( + /requires confirmation: "RECLAIM"/i, + ); + await expect(laneActions.archiveAndReclaim({ + laneId: "lane-1", + confirmation: "reclaim", + })).rejects.toThrow(/requires confirmation: "RECLAIM"/i); + expect(archiveAndReclaim).not.toHaveBeenCalled(); + expect(onLaneArchived).not.toHaveBeenCalled(); + + await laneActions.archiveAndReclaim({ + laneId: "lane-1", + confirmation: "RECLAIM", + forceDirty: true, + }); + expect(archiveAndReclaim).toHaveBeenCalledWith( + { laneId: "lane-1", confirmation: "RECLAIM", forceDirty: true }, + { + onArchived: expect.any(Function), + teardownEnv: undefined, + }, + ); + }); + + it("releases lane runtime resources after archive even when reclaim later fails", async () => { + const removeRoute = vi.fn(); + const release = vi.fn(); + const onLaneArchived = vi.fn(); + const lane = { + id: "lane-1", + name: "Feature", + branchRef: "refs/heads/feature", + folder: "feature", + }; + const archiveAndReclaim = vi.fn(async ( + _args: unknown, + options?: { onArchived?: () => void }, + ) => { + options?.onArchived?.(); + throw new Error("disk is busy"); + }); + const services = getAdeActionDomainServices({ + laneService: { + archiveAndReclaim, + list: vi.fn(async () => [lane]), + }, + automationService: { onLaneArchived }, + laneProxyService: { removeRoute }, + portAllocationService: { + getLease: () => ({ status: "active" }), + release, + }, + } as never); + const laneActions = services.lane as { + archiveAndReclaim: (args: { + laneId: string; + confirmation: "RECLAIM"; + }) => Promise; + }; + + await expect(laneActions.archiveAndReclaim({ + laneId: "lane-1", + confirmation: "RECLAIM", + })).rejects.toThrow(/disk is busy/i); + + expect(removeRoute).toHaveBeenCalledWith("lane-1"); + expect(release).toHaveBeenCalledWith("lane-1"); + expect(onLaneArchived).toHaveBeenCalledTimes(1); + expect(onLaneArchived).toHaveBeenCalledWith({ + laneId: "lane-1", + laneName: "Feature", + branchRef: "refs/heads/feature", + folder: "feature", + }); + }); + + it("dispatches lane archived automation once after an ADE action archive commits", async () => { + const archive = vi.fn(); + const onLaneArchived = vi.fn(); + const services = getAdeActionDomainServices({ + laneService: { + archive, + list: vi.fn(async () => [{ + id: "lane-1", + name: "Feature", + branchRef: "refs/heads/feature", + folder: null, + }]), + }, + automationService: { onLaneArchived }, + } as never); + const laneActions = services.lane as { + archive: (args: { laneId: string }) => Promise; + }; + + await laneActions.archive({ laneId: "lane-1" }); + + expect(archive).toHaveBeenCalledWith({ laneId: "lane-1" }); + expect(onLaneArchived).toHaveBeenCalledTimes(1); + expect(onLaneArchived.mock.invocationCallOrder[0]).toBeGreaterThan(archive.mock.invocationCallOrder[0]!); + }); + it("normalizes chat action argument shapes for model discovery, summaries, transcript reads, and sends", async () => { const createSession = vi.fn(async (args?: unknown) => ({ sessionId: "chat-new", args })); const getAvailableModels = vi.fn(async (args: { provider?: string }) => [{ id: args.provider ?? "any" }]); @@ -1815,7 +1947,13 @@ describe("runtime lane snapshot actions", () => { cleanupLaneEnvironment, }, portAllocationService: { - getLease: vi.fn(() => null), + getLease: vi.fn(() => ({ + laneId: lane.id, + rangeStart: 4100, + rangeEnd: 4199, + status: "active", + leasedAt: TEST_NOW, + })), release, }, logger: { diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index c0e365666..11d63be14 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -58,6 +58,7 @@ import { import type { AiConfig, ApplyLaneTemplateArgs, + ArchiveAndReclaimLaneArgs, DeleteLaneArgs, FileChangeEvent, FilesWatchArgs, @@ -92,6 +93,11 @@ import type { } from "../../../shared/types"; import { getModelById } from "../../../shared/modelRegistry"; import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; +import { + ensureActiveLanePortLease, + releaseLaneRuntimeResources, + restoreRecreatedLaneRuntime, +} from "../lanes/laneRuntimeLifecycle"; import { mergeAiConfig } from "../config/projectConfigService"; import { appendDiffTruncationNotice, MAX_DIFF_SIDE_TEXT_BYTES } from "../diffs/diffService"; import { runGit } from "../git/git"; @@ -291,6 +297,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { - await resolveLane(runtime, laneId); - const portAllocationService = runtime.portAllocationService; - if (!portAllocationService) return null; - return portAllocationService.getLease(laneId) ?? portAllocationService.acquire(laneId); -} - async function ensureLanePreviewInfo(runtime: AdeRuntime, laneId: string): Promise { const laneProxyService = runtime.laneProxyService; const portAllocationService = runtime.portAllocationService; @@ -2233,6 +2251,22 @@ async function ensureLanePreviewInfo(runtime: AdeRuntime, laneId: string): Promi function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { const laneService = runtime.laneService as unknown as OpaqueService; + const findLaneForArchive = async (laneId: string) => { + if (typeof runtime.laneService.list !== "function") return null; + return runtime.laneService + .list({ includeArchived: true, includeStatus: false }) + .then((lanes) => lanes.find((lane) => lane.id === laneId) ?? null) + .catch(() => null); + }; + const notifyLaneArchived = (lane: Awaited>): void => { + if (!lane) return; + runtime.automationService?.onLaneArchived?.({ + laneId: lane.id, + laneName: lane.name, + branchRef: lane.branchRef, + folder: lane.folder ?? null, + }); + }; return { ...laneService, listSnapshots: async (args?: ListLanesArgs): Promise => { @@ -2285,6 +2319,16 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { ...(record.acknowledgeActiveWork === true ? { acknowledgeActiveWork: true } : {}), }); }, + archive: async (args?: { laneId?: string }): Promise => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const lane = await findLaneForArchive(laneId); + runtime.laneService.archive({ laneId }); + try { + releaseLaneRuntimeResources(runtime, laneId); + } finally { + notifyLaneArchived(lane); + } + }, delete: async (args?: DeleteLaneArgs): Promise => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); const laneEnvironmentService = runtime.laneEnvironmentService; @@ -2303,7 +2347,52 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { } : undefined; await runtime.laneService.delete({ ...(args ?? {}), laneId }, { teardownEnv }); - runtime.portAllocationService?.release(laneId); + releaseLaneRuntimeResources(runtime, laneId); + }, + archiveAndReclaim: async (args?: ArchiveAndReclaimLaneArgs) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + if (args?.confirmation !== "RECLAIM") { + throw new Error('archiveAndReclaim requires confirmation: "RECLAIM".'); + } + const lane = await findLaneForArchive(laneId); + const laneEnvironmentService = runtime.laneEnvironmentService; + const envContext = laneEnvironmentService + ? await resolveLaneOverlayContext(runtime, laneId).catch(() => null) + : null; + const teardownEnv = laneEnvironmentService && envContext?.envInitConfig + ? async () => { + await laneEnvironmentService.cleanupLaneEnvironment(envContext.lane, envContext.envInitConfig); + } + : undefined; + const result = await runtime.laneService.archiveAndReclaim( + { + laneId, + confirmation: "RECLAIM", + ...(args.forceDirty === true ? { forceDirty: true } : {}), + }, + { + onArchived: () => { + try { + releaseLaneRuntimeResources(runtime, laneId); + } finally { + notifyLaneArchived(lane); + } + }, + teardownEnv, + }, + ); + return result; + }, + unarchive: async (args?: { laneId?: string }) => { + const laneId = requireNonEmptyString(args?.laneId, "laneId"); + const result = await runtime.laneService.unarchive({ laneId }); + if (!result.worktreeRecreated) return result; + try { + await restoreRecreatedLaneRuntime(runtime, laneId); + return result; + } catch (error) { + return { ...result, setupWarning: getErrorMessage(error) }; + } }, dismissRebaseSuggestion: async (args?: { laneId?: string }) => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); @@ -2372,19 +2461,19 @@ function buildLaneDomainService(runtime: AdeRuntime): OpaqueService { }, portGetLease: async (args?: { laneId?: string }) => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); - await ensureLanePortLease(runtime, laneId); + await ensureActiveLanePortLease(runtime, laneId); return runtime.portAllocationService?.getLease(laneId) ?? null; }, portListLeases: () => runtime.portAllocationService?.listLeases() ?? [], portAcquire: async (args?: { laneId?: string }) => { - const lease = await ensureLanePortLease(runtime, requireNonEmptyString(args?.laneId, "laneId")); + const lease = await ensureActiveLanePortLease(runtime, requireNonEmptyString(args?.laneId, "laneId")); if (!lease) throw new Error("Port allocation service not available."); return lease; }, portRelease: async (args?: { laneId?: string }) => { const laneId = requireNonEmptyString(args?.laneId, "laneId"); await resolveLane(runtime, laneId); - runtime.portAllocationService?.release(laneId); + releaseLaneRuntimeResources(runtime, laneId); }, portListConflicts: () => runtime.portAllocationService?.listConflicts() ?? [], portRecoverOrphans: async () => { diff --git a/apps/desktop/src/main/services/config/projectConfigService.test.ts b/apps/desktop/src/main/services/config/projectConfigService.test.ts index a460fc903..edfac5919 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.test.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.test.ts @@ -154,6 +154,57 @@ describe("projectConfigService - providers permissions", () => { }); }); +describe("projectConfigService - lane storage rules", () => { + it("persists all cleanup fields and migrates the old delete setting to review retention", () => { + const { root, adeDir } = makeProjectFixture("ade-project-config-lane-storage-"); + fs.writeFileSync(path.join(adeDir, "local.yaml"), YAML.stringify({ + version: 1, + laneCleanup: { + maxActiveLanes: 4, + cleanupIntervalHours: 6, + autoArchiveAfterHours: 72, + autoDeleteArchivedAfterHours: 168, + deleteRemoteBranchOnCleanup: true, + }, + })); + const service = createProjectConfigService({ + projectRoot: root, + adeDir, + projectId: "project-storage", + db: makeDb(), + logger: makeLogger(), + }); + + expect(service.get().effective.laneCleanup).toEqual({ + maxActiveLanes: 4, + cleanupIntervalHours: 6, + autoArchiveAfterHours: 72, + reclaimArchivedAfterHours: 168, + }); + + const snapshot = service.get(); + service.save({ + shared: snapshot.shared, + local: { + ...snapshot.local, + laneCleanup: { + maxActiveLanes: 3, + cleanupIntervalHours: 12, + autoArchiveAfterHours: 48, + reclaimArchivedAfterHours: 240, + }, + }, + }); + const written = YAML.parse(fs.readFileSync(path.join(adeDir, "local.yaml"), "utf8")); + expect(written.laneCleanup).toEqual({ + maxActiveLanes: 3, + cleanupIntervalHours: 12, + autoArchiveAfterHours: 48, + reclaimArchivedAfterHours: 240, + }); + }); +}); + describe("projectConfigService - lane env init", () => { it("preserves extended overlay fields and merged lane env init in effective config", () => { const { root, adeDir } = makeProjectFixture("ade-project-config-lane-init-"); diff --git a/apps/desktop/src/main/services/config/projectConfigService.ts b/apps/desktop/src/main/services/config/projectConfigService.ts index 64074ef85..e15254ee1 100644 --- a/apps/desktop/src/main/services/config/projectConfigService.ts +++ b/apps/desktop/src/main/services/config/projectConfigService.ts @@ -40,6 +40,7 @@ import type { LaneOverlayOverrides, LaneOverlayPolicy, LaneMountPointConfig, + LaneCleanupConfig, LaneTemplate, LaneType, ProjectConfigCandidate, @@ -1194,6 +1195,27 @@ function coerceLaneTemplate(value: unknown): ConfigLaneTemplate | null { }; } +function coerceLaneCleanupConfig(value: unknown): LaneCleanupConfig | undefined { + if (!isRecord(value)) return undefined; + const readNonNegativeInteger = (candidate: unknown): number | undefined => { + const numeric = asNumber(candidate); + if (numeric == null || !Number.isFinite(numeric)) return undefined; + return Math.max(0, Math.floor(numeric)); + }; + const maxActiveLanes = readNonNegativeInteger(value.maxActiveLanes); + const cleanupIntervalHours = readNonNegativeInteger(value.cleanupIntervalHours); + const autoArchiveAfterHours = readNonNegativeInteger(value.autoArchiveAfterHours); + const reclaimArchivedAfterHours = + readNonNegativeInteger(value.reclaimArchivedAfterHours) + ?? readNonNegativeInteger(value.autoDeleteArchivedAfterHours); + const out: LaneCleanupConfig = {}; + if (maxActiveLanes != null) out.maxActiveLanes = maxActiveLanes; + if (cleanupIntervalHours != null) out.cleanupIntervalHours = cleanupIntervalHours; + if (autoArchiveAfterHours != null) out.autoArchiveAfterHours = autoArchiveAfterHours; + if (reclaimArchivedAfterHours != null) out.reclaimArchivedAfterHours = reclaimArchivedAfterHours; + return Object.keys(out).length ? out : undefined; +} + const AI_TASK_KEYS: AiTaskRoutingKey[] = [ "planning", "implementation", @@ -2017,6 +2039,7 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { ? value.laneTemplates.map(coerceLaneTemplate).filter((x): x is ConfigLaneTemplate => x != null) : undefined; const defaultLaneTemplate = typeof value.defaultLaneTemplate === "string" ? value.defaultLaneTemplate.trim() || undefined : undefined; + const laneCleanup = coerceLaneCleanupConfig(value.laneCleanup); const github = coerceGithubConfig(value.github); @@ -2051,6 +2074,7 @@ function coerceConfigFile(value: unknown): ProjectConfigFile { ...(laneEnvInit ? { laneEnvInit } : {}), ...(laneTemplates?.length ? { laneTemplates } : {}), ...(defaultLaneTemplate ? { defaultLaneTemplate } : {}), + ...(laneCleanup ? { laneCleanup } : {}), ...(environments.length ? { environments } : {}), ...(github ? { github } : {}), ...(git ? { git } : {}), @@ -2102,6 +2126,7 @@ function toCanonicalYaml(config: ProjectConfigFile): string { ...(config.laneEnvInit ? { laneEnvInit: config.laneEnvInit } : {}), ...(config.laneTemplates?.length ? { laneTemplates: config.laneTemplates } : {}), ...(config.defaultLaneTemplate ? { defaultLaneTemplate: config.defaultLaneTemplate } : {}), + ...(config.laneCleanup ? { laneCleanup: config.laneCleanup } : {}), ...(config.environments ? { environments: config.environments } : {}), ...(config.github ? { github: config.github } : {}), ...(config.git ? { git: config.git } : {}), @@ -2130,6 +2155,7 @@ function hasSharedConfigContent(config: ProjectConfigFile): boolean { || config.laneEnvInit || (config.laneTemplates?.length ?? 0) > 0 || config.defaultLaneTemplate + || config.laneCleanup || (config.providers && Object.keys(config.providers).length > 0) || config.linearSync || config.ui @@ -2393,6 +2419,12 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF ...(local.git ?? {}) } : undefined; + const mergedLaneCleanup = shared.laneCleanup || local.laneCleanup + ? { + ...(shared.laneCleanup ?? {}), + ...(local.laneCleanup ?? {}), + } + : undefined; const mergedAi = mergeAiConfig(shared.ai, local.ai); const mergedLinearSync = mergeLinearSync(shared.linearSync, local.linearSync); @@ -2440,6 +2472,7 @@ function resolveEffectiveConfig(shared: ProjectConfigFile, local: ProjectConfigF } : {}), ...(defaultLaneTemplate ? { defaultLaneTemplate } : {}), + ...(mergedLaneCleanup ? { laneCleanup: mergedLaneCleanup } : {}), ...(environments.length ? { environments } : {}), providerMode, ...(mergedGithub ? { github: mergedGithub } : {}), @@ -2559,6 +2592,18 @@ function validateEffectiveConfig( } } + if (effective.laneCleanup) { + for (const [key, value] of Object.entries(effective.laneCleanup)) { + if (value == null || typeof value === "boolean") continue; + if (!Number.isFinite(value) || value < 0 || !Number.isInteger(value)) { + issues.push({ + path: `effective.laneCleanup.${key}`, + message: `${key} must be a non-negative whole number`, + }); + } + } + } + const iconPath = effective.project?.iconPath?.trim(); if (iconPath) { if (!isPathWithinProjectRoot(projectRoot, iconPath, { allowMissing: false })) { diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 016e2349d..fb5cc95ab 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -123,6 +123,8 @@ import type { ClearLocalAdeDataArgs, ClearLocalAdeDataResult, ArchiveLaneArgs, + ArchiveAndReclaimLaneArgs, + ArchiveAndReclaimLaneResult, AutomationIngressEventRecord, AutomationIngressStatus, AutomationScheduledCleanup, @@ -177,6 +179,8 @@ import type { LaneBranchSwitchArgs, LaneBranchSwitchPreview, LaneBranchSwitchResult, + LaneReclaimRisk, + RestoreLaneResult, ResolveLaneBranchDriftArgs, ResolveLaneBranchDriftResult, DeleteLaneArgs, @@ -606,6 +610,11 @@ import type { createLaneEnvironmentService } from "../lanes/laneEnvironmentServi import type { createLaneTemplateService } from "../lanes/laneTemplateService"; import type { createPortAllocationService } from "../lanes/portAllocationService"; import type { createLaneProxyService } from "../lanes/laneProxyService"; +import { + ensureActiveLanePortLease, + releaseLaneRuntimeResources, + restoreRecreatedLaneRuntime, +} from "../lanes/laneRuntimeLifecycle"; import type { createOAuthRedirectService } from "../lanes/oauthRedirectService"; import type { createRuntimeDiagnosticsService } from "../lanes/runtimeDiagnosticsService"; import type { createRebaseSuggestionService } from "../lanes/rebaseSuggestionService"; @@ -1017,6 +1026,16 @@ function notifyLaneCreated(ctx: AppContext, lane: LaneSummary): void { }); } +function notifyLaneArchived(ctx: AppContext, lane: LaneSummary | null): void { + if (!lane) return; + ctx.automationService?.onLaneArchived?.({ + laneId: lane.id, + laneName: lane.name, + branchRef: lane.branchRef, + folder: lane.folder ?? null, + }); +} + function clampLayout(layout: DockLayout): DockLayout { const out: DockLayout = {}; for (const [k, v] of Object.entries(layout)) { @@ -1346,16 +1365,6 @@ function applyLeaseToOverrides(overrides: LaneOverlayOverrides, lease: PortLease }; } -async function ensureLanePortLease(ctx: AppContext, laneId: string): Promise { - if (!ctx.portAllocationService) return null; - requireAppContextServices(ctx, ["laneService"] as const); - const activeLane = (await ctx.laneService.list({ includeArchived: false, includeStatus: false })).find((entry) => entry.id === laneId); - if (!activeLane) throw new Error(`Lane not found: ${laneId}`); - const existing = ctx.portAllocationService.getLease(laneId); - if (existing?.status === "active") return existing; - return ctx.portAllocationService.acquire(laneId); -} - async function buildLinearConnectionStatus( ctx: AppContext, tokenStored: boolean, @@ -5713,7 +5722,7 @@ export function registerIpc({ branchName: arg.branchName, linearIssue: arg.linearIssue ?? null, }); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5721,7 +5730,7 @@ export function registerIpc({ ipcMain.handle(IPC.lanesCreateChild, async (_event, arg: CreateChildLaneArgs): Promise => { const ctx = ensureLaneContext(); const lane = await ctx.laneService.createChild(arg); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5729,7 +5738,7 @@ export function registerIpc({ ipcMain.handle(IPC.lanesCreateFromUnstaged, async (_event, arg: CreateLaneFromUnstagedArgs): Promise => { const ctx = ensureLaneContext(); const lane = await ctx.laneService.createFromUnstaged(arg); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5737,7 +5746,7 @@ export function registerIpc({ ipcMain.handle(IPC.lanesImportBranch, async (_event, arg: ImportBranchLaneArgs): Promise => { const ctx = ensureLaneContext(); const lane = await ctx.laneService.importBranch(arg); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5766,7 +5775,7 @@ export function registerIpc({ const ctx = ensureLaneContext(); const lane = await ctx.laneService.attach(arg); invalidateProjectPathInspectionCache(); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5779,7 +5788,7 @@ export function registerIpc({ ipcMain.handle(IPC.lanesAdoptAttached, async (_event, arg: AdoptAttachedLaneArgs): Promise => { const ctx = ensureLaneContext(); const lane = await ctx.laneService.adoptAttached(arg); - await ensureLanePortLease(ctx, lane.id); + await ensureActiveLanePortLease(ctx, lane.id); notifyLaneCreated(ctx, lane); return lane; }); @@ -5806,14 +5815,51 @@ export function registerIpc({ .then((lanes) => lanes.find((entry) => entry.id === arg.laneId) ?? null) .catch(() => null); ctx.laneService.archive(arg); - ctx.portAllocationService?.release(arg.laneId); - if (lane) { - ctx.automationService?.onLaneArchived?.({ - laneId: lane.id, - laneName: lane.name, - branchRef: lane.branchRef, - folder: lane.folder ?? null, + try { + releaseLaneRuntimeResources(ctx, arg.laneId); + } finally { + notifyLaneArchived(ctx, lane); + } + }); + + ipcMain.handle( + IPC.lanesArchiveAndReclaim, + async (_event, arg: ArchiveAndReclaimLaneArgs): Promise => { + const ctx = ensureLaneContext(); + const lane = await ctx.laneService + .list({ includeArchived: true, includeStatus: false }) + .then((lanes) => lanes.find((entry) => entry.id === arg.laneId) ?? null) + .catch(() => null); + const envContext = ctx.laneEnvironmentService + ? await resolveLaneOverlayContext(ctx, arg.laneId).catch(() => null) + : null; + const teardownEnv = ctx.laneEnvironmentService && envContext?.envInitConfig + ? async () => { + await ctx.laneEnvironmentService!.cleanupLaneEnvironment(envContext.lane, envContext.envInitConfig); + } + : undefined; + return await ctx.laneService.archiveAndReclaim(arg, { + onArchived: () => { + try { + releaseLaneRuntimeResources(ctx, arg.laneId); + } finally { + notifyLaneArchived(ctx, lane); + } + }, + teardownEnv, }); + }, + ); + + ipcMain.handle(IPC.lanesUnarchive, async (_event, arg: ArchiveLaneArgs): Promise => { + const ctx = ensureLaneContext(); + const result = await ctx.laneService.unarchive(arg); + if (!result.worktreeRecreated) return result; + try { + await restoreRecreatedLaneRuntime(ctx, arg.laneId); + return result; + } catch (error) { + return { ...result, setupWarning: getErrorMessage(error) }; } }); @@ -5834,7 +5880,7 @@ export function registerIpc({ } : undefined; await ctx.laneService.delete(arg, { teardownEnv }); - ctx.portAllocationService?.release(arg.laneId); + releaseLaneRuntimeResources(ctx, arg.laneId); }); ipcMain.handle(IPC.lanesDeleteCancel, async (_event, arg: { laneId: string }) => { @@ -5852,6 +5898,13 @@ export function registerIpc({ return await ctx.laneService.getDeleteRisk(arg.laneId); }); + ipcMain.handle(IPC.lanesGetReclaimRisk, async ( + _event, + arg: { laneId: string }, + ): Promise => { + return ensureLaneContext().laneService.getReclaimRisk(arg.laneId); + }); + ipcMain.handle(IPC.lanesGetStackChain, async (_event, arg: { laneId: string }): Promise => { const ctx = ensureLaneContext(); return await ctx.laneService.getStackChain(arg.laneId); @@ -6050,8 +6103,8 @@ export function registerIpc({ // --- Port Allocation (Phase 5 W3) --- ipcMain.handle(IPC.lanesPortGetLease, async (_event, args: { laneId: string }) => { - const ctx = getCtx(); - await ensureLanePortLease(ctx, args.laneId); + const ctx = ensureLaneContext(); + await ensureActiveLanePortLease(ctx, args.laneId); return ctx.portAllocationService?.getLease(args.laneId) ?? null; }); @@ -6061,9 +6114,9 @@ export function registerIpc({ }); ipcMain.handle(IPC.lanesPortAcquire, async (_event, args: { laneId: string }) => { - const ctx = getCtx(); + const ctx = ensureLaneContext(); if (!ctx.portAllocationService) throw new Error("Port allocation service not available"); - return (await ensureLanePortLease(ctx, args.laneId))!; + return (await ensureActiveLanePortLease(ctx, args.laneId))!; }); ipcMain.handle(IPC.lanesPortRelease, async (_event, args: { laneId: string }) => { @@ -6073,7 +6126,7 @@ export function registerIpc({ throw new Error(`Lane not found: ${args.laneId}`); } }); - ctx.portAllocationService?.release(args.laneId); + releaseLaneRuntimeResources(ctx, args.laneId); }); ipcMain.handle(IPC.lanesPortListConflicts, async () => { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index aae09b0f5..e35e68b3c 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -1460,6 +1460,58 @@ describe("registerIpc sync bridge", () => { vi.useRealTimers(); }); + it("dispatches lane archived automation once after IPC reclaim archives, even when removal later fails", async () => { + const onLaneArchived = vi.fn(); + const archiveAndReclaim = vi.fn() + .mockImplementationOnce(async ( + _args: unknown, + options?: { onArchived?: () => void }, + ) => { + expect(onLaneArchived).not.toHaveBeenCalled(); + options?.onArchived?.(); + throw new Error("disk is busy"); + }) + .mockRejectedValueOnce(new Error("confirmation required")); + registerIpc({ + getCtx: () => ({ + logger: { warn: vi.fn(), info: vi.fn(), error: vi.fn(), debug: vi.fn() }, + laneService: { + list: vi.fn(async () => [{ + id: "lane-1", + name: "Feature", + branchRef: "refs/heads/feature", + folder: "feature", + }]), + archiveAndReclaim, + }, + automationService: { onLaneArchived }, + }) as any, + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + }); + + const handler = ipcHandlers.get(IPC.lanesArchiveAndReclaim); + await expect(handler?.(eventForSender(), { + laneId: "lane-1", + confirmation: "RECLAIM", + })).rejects.toThrow(/disk is busy/i); + expect(onLaneArchived).toHaveBeenCalledTimes(1); + expect(onLaneArchived).toHaveBeenCalledWith({ + laneId: "lane-1", + laneName: "Feature", + branchRef: "refs/heads/feature", + folder: "feature", + }); + + await expect(handler?.(eventForSender(), { + laneId: "lane-1", + confirmation: "invalid", + })).rejects.toThrow(/confirmation required/i); + expect(onLaneArchived).toHaveBeenCalledTimes(1); + }); + it("routes account Attention through the machine runtime without a project binding", async () => { const snapshot = { contractVersion: ATTENTION_CONTRACT_VERSION, diff --git a/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.test.ts b/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.test.ts new file mode 100644 index 000000000..e1d1d05e5 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it, vi } from "vitest"; +import type { LaneSummary, PortLease } from "../../../shared/types"; +import { + ensureActiveLanePortLease, + releaseLaneRuntimeResources, + restoreRecreatedLaneRuntime, +} from "./laneRuntimeLifecycle"; + +const lane = { + id: "lane-1", + name: "Lane one", + laneType: "worktree", + worktreePath: "/repo/.ade/worktrees/lane-1", +} as LaneSummary; + +const lease: PortLease = { + laneId: lane.id, + rangeStart: 4100, + rangeEnd: 4199, + status: "active", + leasedAt: "2026-07-28T00:00:00.000Z", +}; + +function laneService() { + return { + list: vi.fn(async () => [lane]), + }; +} + +describe("lane runtime lifecycle", () => { + it("acquires an active lease for a restored lane", async () => { + const acquire = vi.fn(() => lease); + const result = await ensureActiveLanePortLease({ + laneService: laneService(), + portAllocationService: { + getLease: vi.fn(() => null), + acquire, + release: vi.fn(), + }, + }, lane.id); + + expect(result).toEqual(lease); + expect(acquire).toHaveBeenCalledWith(lane.id); + }); + + it("restores the port lease before initializing the lane environment", async () => { + const order: string[] = []; + const initLaneEnvironment = vi.fn(async () => { + order.push("environment"); + }); + await restoreRecreatedLaneRuntime({ + laneService: laneService(), + projectConfigService: { + getEffective: () => ({ + laneEnvInit: { envFiles: [] }, + laneOverlayPolicies: [], + }), + }, + laneEnvironmentService: { + resolveEnvInitConfig: (config) => config, + initLaneEnvironment, + }, + portAllocationService: { + getLease: vi.fn(() => null), + acquire: vi.fn(() => { + order.push("lease"); + return lease; + }), + release: vi.fn(), + }, + }, lane.id); + + expect(order).toEqual(["lease", "environment"]); + expect(initLaneEnvironment).toHaveBeenCalledWith( + lane, + { envFiles: [] }, + { portRange: { start: 4100, end: 4199 } }, + ); + }); + + it("removes the proxy route and releases an active lease", () => { + const removeRoute = vi.fn(); + const release = vi.fn(); + releaseLaneRuntimeResources({ + laneProxyService: { removeRoute }, + portAllocationService: { + getLease: vi.fn(() => lease), + acquire: vi.fn(() => lease), + release, + }, + }, lane.id); + + expect(removeRoute).toHaveBeenCalledWith(lane.id); + expect(release).toHaveBeenCalledWith(lane.id); + }); + + it("releases an active lease before reporting a proxy route failure", () => { + const routeError = new Error("proxy route is busy"); + const release = vi.fn(); + + expect(() => releaseLaneRuntimeResources({ + laneProxyService: { + removeRoute: vi.fn(() => { + throw routeError; + }), + }, + portAllocationService: { + getLease: vi.fn(() => lease), + acquire: vi.fn(() => lease), + release, + }, + }, lane.id)).toThrow(routeError); + + expect(release).toHaveBeenCalledWith(lane.id); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.ts b/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.ts new file mode 100644 index 000000000..e39eb1394 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneRuntimeLifecycle.ts @@ -0,0 +1,118 @@ +import type { + LaneEnvInitConfig, + LaneOverlayOverrides, + LaneOverlayPolicy, + LaneSummary, + PortLease, +} from "../../../shared/types"; +import { matchLaneOverlayPolicies } from "../config/laneOverlayMatcher"; + +type LaneRuntimeLifecycleDependencies = { + laneService: { + list: (options: { + includeArchived: boolean; + includeStatus: boolean; + }) => Promise; + }; + projectConfigService?: { + getEffective: () => { + laneEnvInit?: LaneEnvInitConfig; + laneOverlayPolicies?: LaneOverlayPolicy[]; + }; + } | null; + laneEnvironmentService?: { + resolveEnvInitConfig: ( + config: LaneEnvInitConfig | undefined, + overrides: LaneOverlayOverrides, + ) => LaneEnvInitConfig | undefined; + initLaneEnvironment: ( + lane: LaneSummary, + config: LaneEnvInitConfig, + overrides: LaneOverlayOverrides, + ) => Promise; + } | null; + portAllocationService?: { + getLease: (laneId: string) => PortLease | null; + acquire: (laneId: string) => PortLease; + release: (laneId: string) => void; + } | null; + laneProxyService?: { + removeRoute: (laneId: string) => unknown; + } | null; +}; + +async function resolveActiveLane( + dependencies: Pick, + laneId: string, +): Promise { + const lanes = await dependencies.laneService.list({ + includeArchived: false, + includeStatus: false, + }); + const lane = lanes.find((entry) => entry.id === laneId); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return lane; +} + +export async function ensureActiveLanePortLease( + dependencies: Pick, + laneId: string, +): Promise { + await resolveActiveLane(dependencies, laneId); + const allocator = dependencies.portAllocationService; + if (!allocator) return null; + const existing = allocator.getLease(laneId); + if (existing?.status === "active") return existing; + const acquired = allocator.acquire(laneId); + if (acquired.status !== "active") { + throw new Error(`Could not acquire an active port lease for lane ${laneId}`); + } + return acquired; +} + +export function releaseLaneRuntimeResources( + dependencies: Pick, + laneId: string, +): void { + let routeError: unknown; + let routeFailed = false; + try { + dependencies.laneProxyService?.removeRoute(laneId); + } catch (error) { + routeFailed = true; + routeError = error; + } + try { + const allocator = dependencies.portAllocationService; + if (allocator?.getLease(laneId)?.status === "active") { + allocator.release(laneId); + } + } catch (releaseError) { + if (routeFailed) throw routeError; + throw releaseError; + } + if (routeFailed) throw routeError; +} + +export async function restoreRecreatedLaneRuntime( + dependencies: LaneRuntimeLifecycleDependencies, + laneId: string, +): Promise { + const lane = await resolveActiveLane(dependencies, laneId); + const lease = await ensureActiveLanePortLease(dependencies, laneId); + const environmentService = dependencies.laneEnvironmentService; + const projectConfigService = dependencies.projectConfigService; + if (!environmentService || !projectConfigService) return; + + const config = projectConfigService.getEffective(); + const overlayOverrides = matchLaneOverlayPolicies(lane, config.laneOverlayPolicies ?? []); + const overrides = lease && !overlayOverrides.portRange + ? { + ...overlayOverrides, + portRange: { start: lease.rangeStart, end: lease.rangeEnd }, + } + : overlayOverrides; + const envInitConfig = environmentService.resolveEnvInitConfig(config.laneEnvInit, overrides); + if (!envInitConfig) return; + await environmentService.initLaneEnvironment(lane, envInitConfig, overrides); +} diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 460171ae0..9926d4570 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -5180,7 +5180,7 @@ describe("laneService rename", () => { }); service.archive({ laneId: "lane-archived" }); - service.unarchive({ laneId: "lane-active" }); + await service.unarchive({ laneId: "lane-active" }); expect(onLifecycleEvent).not.toHaveBeenCalled(); expect(db.get<{ status: string }>("select status from lanes where id = ?", ["lane-archived"])?.status).toBe("archived"); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index f9ad7c719..b0672f418 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -20,8 +20,11 @@ import { import type { createOperationService } from "../history/operationService"; import type { Logger } from "../logging/logger"; import { createWorktreeResidualCleanup } from "./worktreeResidualCleanup"; +import { createLaneWorktreeLockService } from "./laneWorktreeLockService"; import type { AdoptAttachedLaneArgs, + ArchiveAndReclaimLaneArgs, + ArchiveAndReclaimLaneResult, AttachLaneArgs, CreateChildLaneArgs, CreateLaneArgs, @@ -31,6 +34,7 @@ import type { LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, + LaneReclaimRisk, LaneDeleteStep, LaneDeleteStepName, LaneIcon, @@ -51,6 +55,7 @@ import type { ListLanesArgs, ReparentLaneArgs, ReparentLaneResult, + RestoreLaneResult, ResolveLaneBranchDriftArgs, ResolveLaneBranchDriftResult, RebaseAbortArgs, @@ -212,6 +217,69 @@ async function removeWorktreeDirectoryWithRecovery(targetPath: string): Promise< await fs.promises.rm(targetPath, { recursive: true, force: true }); } +async function managedTreeBytes(targetPath: string): Promise { + let total = 0; + const pending = [targetPath]; + while (pending.length > 0) { + const current = pending.pop()!; + let stat: fs.Stats; + try { + stat = await fs.promises.lstat(current); + } catch { + continue; + } + if (stat.isSymbolicLink()) continue; + if (!stat.isDirectory()) { + total += Math.max(0, stat.size); + continue; + } + let names: string[]; + try { + names = await fs.promises.readdir(current); + } catch { + continue; + } + for (const name of names) pending.push(path.join(current, name)); + } + return total; +} + +async function hasSymlinkInManagedPath(rootPath: string, targetPath: string): Promise { + const root = normAbs(rootPath); + const target = normAbs(targetPath); + const relative = path.relative(root, target); + if (relative.startsWith("..") || path.isAbsolute(relative)) return true; + const segments = relative ? relative.split(path.sep) : []; + let candidate = root; + const candidates = [candidate]; + for (const segment of segments) { + candidate = path.join(candidate, segment); + candidates.push(candidate); + } + for (const pathToCheck of candidates) { + try { + if ((await fs.promises.lstat(pathToCheck)).isSymbolicLink()) return true; + } catch { + // Missing descendants cannot redirect traversal. Existing ancestors are + // still checked before any recursive removal or recreation. + } + } + return false; +} + +function lanePackDirectory(projectRoot: string, laneId: string): { + adeDir: string; + lanePackDir: string; +} { + const layout = resolveAdeLayout(projectRoot); + const lanesDir = normAbs(path.join(layout.packsDir, "lanes")); + const lanePackDir = normAbs(path.join(lanesDir, laneId)); + if (path.dirname(lanePackDir) !== lanesDir) { + throw new Error("The generated lane data path is outside ADE's managed storage."); + } + return { adeDir: layout.adeDir, lanePackDir }; +} + function cloneLaneStatus(status: LaneStatus): LaneStatus { return { dirty: status.dirty, @@ -260,6 +328,23 @@ function normAbs(p: string): string { return path.resolve(p); } +function stablePathThroughExistingAncestor(targetPath: string): string { + const absolute = path.resolve(targetPath); + let existingAncestor = absolute; + const missingSegments: string[] = []; + while (!fs.existsSync(existingAncestor)) { + const parent = path.dirname(existingAncestor); + if (parent === existingAncestor) return absolute; + missingSegments.unshift(path.basename(existingAncestor)); + existingAncestor = parent; + } + try { + return path.join(fs.realpathSync.native(existingAncestor), ...missingSegments); + } catch { + return absolute; + } +} + const STALE_WORKTREE_ROOT_MESSAGE = "Lane worktree is missing or no longer points at its Git worktree root."; async function isExpectedGitWorktreeRoot(worktreePath: string): Promise { @@ -283,6 +368,14 @@ async function isExpectedGitWorktreeRoot(worktreePath: string): Promise } } +async function readWorktreeDirty(worktreePath: string, timeoutMs = 8_000): Promise { + const result = await runGit(["status", "--porcelain=v1"], { cwd: worktreePath, timeoutMs }); + if (result.exitCode !== 0) { + throw new Error("ADE could not verify whether the lane has uncommitted files."); + } + return result.stdout.trim().length > 0; +} + async function assertExpectedGitWorktreeRoot(worktreePath: string): Promise { if (!(await isExpectedGitWorktreeRoot(worktreePath))) { throw new Error(STALE_WORKTREE_ROOT_MESSAGE); @@ -888,6 +981,8 @@ async function listGitStashes(worktreePath: string): Promise { } const LANE_DELETE_PROGRESS_HISTORY_TTL_MS = 60_000; +const STORAGE_LIFECYCLE_LOCK_LEASE_MS = 15 * 60_000; +const STORAGE_LIFECYCLE_LOCK_HEARTBEAT_MS = 60_000; function branchNameForDelete(branchRef: string, remoteName = "origin"): string { const trimmed = branchRef.trim().replace(/^refs\/heads\//, ""); @@ -1833,6 +1928,21 @@ export function createLaneService({ return parseGitWorktreePorcelain(worktreeStdout(result)); }; + const registeredWorktreeForLane = async ( + worktreePath: string, + branchRef: string, + ): Promise => { + const target = normAbs(worktreePath); + const expectedBranch = normalizeBranchKey(branchRef); + if (!expectedBranch) return null; + const worktrees = await listGitWorktrees(); + return worktrees.find((worktree) => + !worktree.isBare + && worktree.path === target + && normalizeBranchKey(worktree.branch) === expectedBranch + ) ?? null; + }; + const residualWorktreeCleanup = createWorktreeResidualCleanup({ db, projectId, @@ -2794,9 +2904,67 @@ export function createLaneService({ }; const deleteProgressByLaneId = new Map(); + const laneReclaimInFlight = new Set(); + const laneStorageWorktreeLocks = createLaneWorktreeLockService({ db, logger }); let gitWorktreeMutationQueue: Promise = Promise.resolve(); const gitWorktreeMutationOwner = new AsyncLocalStorage(); + const acquireStorageLifecycleLock = (args: { + laneId: string; + worktreePath: string; + ownerLabel: string; + }) => { + const operationToken = randomUUID(); + const acquired = laneStorageWorktreeLocks.acquire({ + ...args, + worktreePath: stablePathThroughExistingAncestor(args.worktreePath), + ownerKind: "storage_lifecycle", + ownerSessionId: operationToken, + token: operationToken, + leaseMs: STORAGE_LIFECYCLE_LOCK_LEASE_MS, + }); + let released = false; + const heartbeatTimer = setInterval(() => { + if (released) return; + try { + const lock = laneStorageWorktreeLocks.heartbeat( + acquired.token, + STORAGE_LIFECYCLE_LOCK_LEASE_MS, + ); + if (!lock) { + logger.warn("lane.storage.lock_heartbeat_lost", { + laneId: args.laneId, + ownerLabel: args.ownerLabel, + }); + } + } catch (error) { + logger.warn("lane.storage.lock_heartbeat_failed", { + laneId: args.laneId, + ownerLabel: args.ownerLabel, + error: error instanceof Error ? error.message : String(error), + }); + } + }, STORAGE_LIFECYCLE_LOCK_HEARTBEAT_MS); + heartbeatTimer.unref?.(); + return { + ...acquired, + release: (): void => { + if (released) return; + released = true; + clearInterval(heartbeatTimer); + try { + laneStorageWorktreeLocks.release({ token: acquired.token }); + } catch (error) { + logger.warn("lane.storage.lock_release_failed", { + laneId: args.laneId, + ownerLabel: args.ownerLabel, + error: error instanceof Error ? error.message : String(error), + }); + } + }, + }; + }; + const runGitWorktreeMutation = async (work: () => Promise): Promise => { if (gitWorktreeMutationOwner.getStore()) { return work(); @@ -2894,9 +3062,36 @@ export function createLaneService({ db.run("delete from lane_worktree_locks where lane_id = ?", [laneId]); db.run("delete from lane_linear_issues where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from lane_linear_issue_links where lane_id = ? and project_id = ?", [laneId, projectId]); + db.run("delete from local_lane_storage_state where lane_id = ? and project_id = ?", [laneId, projectId]); db.run("delete from lanes where id = ? and project_id = ?", [laneId, projectId]); }; + const commitLaneRestoreState = (laneId: string, worktreePath?: string): void => { + db.run("begin immediate"); + try { + if (worktreePath) { + db.run( + "update lanes set worktree_path = ?, status = 'active', archived_at = null where id = ? and project_id = ?", + [worktreePath, laneId, projectId], + ); + } else { + db.run( + "update lanes set status = 'active', archived_at = null where id = ? and project_id = ?", + [laneId, projectId], + ); + } + db.run("delete from local_lane_storage_state where project_id = ? and lane_id = ?", [projectId, laneId]); + db.run("commit"); + } catch (error) { + try { + db.run("rollback"); + } catch { + // Preserve the original activation error. + } + throw error; + } + }; + async function cleanupCreatedWorktreeLaneAfterCreateFailure(args: { laneId: string; branchRef: string; @@ -5088,6 +5283,325 @@ export function createLaneService({ invalidateLaneListCache(); }, + async getReclaimRisk(laneId: string): Promise { + const row = getLaneRow(laneId); + if (!row) throw new Error(`Lane not found: ${laneId}`); + const deleteRisk = await laneServiceApi.getDeleteRisk(laneId); + const normalizedRoot = normAbs(worktreesDir); + const normalizedWorktree = normAbs(row.worktree_path); + const managedPath = row.lane_type === "worktree" && path.dirname(normalizedWorktree) === normalizedRoot; + const worktreeExists = managedPath && fs.existsSync(normalizedWorktree); + const symlinkPath = managedPath + ? await hasSymlinkInManagedPath(normalizedRoot, normalizedWorktree) + : false; + let registeredWorktree: GitWorktreeInfo | null = null; + if (worktreeExists && !symlinkPath) { + try { + registeredWorktree = await registeredWorktreeForLane(normalizedWorktree, row.branch_ref); + } catch { + // Ownership must be proven before reclaim; a failed registry read is + // treated as blocked instead of trusting the saved database path. + } + } + const worktreeAvailable = Boolean(registeredWorktree); + const { adeDir: packAdeDir, lanePackDir } = lanePackDirectory(projectRoot, laneId); + const packSymlinkPath = await hasSymlinkInManagedPath(packAdeDir, lanePackDir); + const [worktreeBytes, generatedBytes] = await Promise.all([ + worktreeAvailable && !symlinkPath ? managedTreeBytes(normalizedWorktree) : Promise.resolve(0), + packSymlinkPath ? Promise.resolve(0) : managedTreeBytes(lanePackDir), + ]); + const branchName = row.branch_ref ? branchNameForDelete(row.branch_ref, "origin") : ""; + let unmerged = false; + if (branchName) { + const merged = await runGit( + ["merge-base", "--is-ancestor", branchName, row.base_ref], + { cwd: projectRoot, timeoutMs: 8_000 }, + ); + unmerged = merged.exitCode !== 0; + } + const blockedReasons: LaneReclaimRisk["blockedReasons"] = []; + if (row.lane_type === "primary") { + blockedReasons.push({ + code: "primary_lane", + message: "The primary lane is always kept on disk.", + disposition: "blocked", + }); + } + if (row.lane_type === "attached") { + blockedReasons.push({ + code: "attached_lane", + message: "This folder is attached from outside ADE, so ADE will not remove it.", + disposition: "blocked", + }); + } + if (row.lane_type === "worktree" && !managedPath) { + blockedReasons.push({ + code: "worktree_outside_managed_root", + message: "The saved folder is outside ADE's managed worktree folder.", + disposition: "blocked", + }); + } + if (symlinkPath || packSymlinkPath) { + blockedReasons.push({ + code: "symlink_path", + message: packSymlinkPath + ? "The generated data folder or one of its managed parent folders is a link." + : "The folder or its managed root is a link.", + disposition: "blocked", + }); + } + if (worktreeExists && !symlinkPath && !registeredWorktree) { + blockedReasons.push({ + code: "worktree_not_registered", + message: "This folder is not the lane worktree registered to this project and will not be removed.", + disposition: "blocked", + }); + } + if (deleteRisk.activeChatCount + deleteRisk.activePtyCount + deleteRisk.activeWatcherCount > 0) { + blockedReasons.push({ + code: "active_work", + message: "Running chats, terminals, or file watchers will be stopped after you confirm.", + disposition: "confirmation_required", + }); + } + if (deleteRisk.dirty) { + blockedReasons.push({ + code: "dirty_worktree", + message: "This lane has uncommitted files. Reclaiming can permanently remove those file changes.", + disposition: "confirmation_required", + }); + } + if (unmerged || deleteRisk.hasUnpushedCommits) { + blockedReasons.push({ + code: "unmerged_work", + message: "The branch has work that is not merged or pushed. The branch is kept, but review it before reclaiming.", + disposition: "confirmation_required", + }); + } + const state = db.get<{ attempts: number; last_error: string | null }>( + "select attempts, last_error from local_lane_storage_state where project_id = ? and lane_id = ?", + [projectId, laneId], + ); + return { + ...deleteRisk, + laneName: row.name, + worktreePath: row.worktree_path, + worktreeBytes, + generatedBytes, + reclaimableBytes: worktreeBytes + generatedBytes, + worktreeAvailable, + blockedReasons, + lastFailure: state?.last_error ?? null, + retryCount: state?.attempts ?? 0, + }; + }, + + async archiveAndReclaim( + args: ArchiveAndReclaimLaneArgs, + runtimeOpts?: { + onArchived?: () => void; + teardownEnv?: () => Promise; + }, + ): Promise { + if (args.confirmation !== "RECLAIM") { + throw new Error('Type "RECLAIM" to confirm removing this lane folder.'); + } + const row = getLaneRow(args.laneId); + if (!row) throw new Error(`Lane not found: ${args.laneId}`); + if (laneReclaimInFlight.has(args.laneId)) { + throw new Error("Archive & Reclaim is already running for this lane."); + } + if (deleteProgressByLaneId.get(args.laneId)?.overallStatus === "running") { + throw new Error("This lane is already being deleted."); + } + if (row.lane_type === "primary") throw new Error("The primary lane cannot be reclaimed."); + if (row.lane_type === "attached") { + throw new Error("Attached folders are not ADE-managed and cannot be reclaimed."); + } + const normalizedRoot = normAbs(worktreesDir); + const normalizedWorktree = normAbs(row.worktree_path); + if (path.dirname(normalizedWorktree) !== normalizedRoot) { + throw new Error("The lane folder is outside ADE's managed worktree folder."); + } + if (await hasSymlinkInManagedPath(normalizedRoot, normalizedWorktree)) { + throw new Error("ADE will not reclaim a folder through a symbolic link."); + } + + const risk = await laneServiceApi.getReclaimRisk(args.laneId); + const hardBlock = risk.blockedReasons.find((reason) => reason.disposition === "blocked"); + if (hardBlock) throw new Error(hardBlock.message); + if (risk.dirty && args.forceDirty !== true) { + throw new Error("This lane has uncommitted files. Confirm that those changes may be lost."); + } + if (laneReclaimInFlight.has(args.laneId)) { + throw new Error("Archive & Reclaim is already running for this lane."); + } + if (deleteProgressByLaneId.get(args.laneId)?.overallStatus === "running") { + throw new Error("This lane is already being deleted."); + } + const storageLock = acquireStorageLifecycleLock({ + laneId: args.laneId, + worktreePath: normalizedWorktree, + ownerLabel: `Archive & Reclaim: ${row.name}`, + }); + laneReclaimInFlight.add(args.laneId); + const warnings: string[] = []; + const now = new Date().toISOString(); + const startedAtMs = Date.now(); + try { + const { adeDir: packAdeDir, lanePackDir } = lanePackDirectory(projectRoot, args.laneId); + if (await hasSymlinkInManagedPath(packAdeDir, lanePackDir)) { + throw new Error("ADE will not reclaim generated data through a symbolic link."); + } + laneServiceApi.archive({ laneId: args.laneId }); + runtimeOpts?.onArchived?.(); + db.run( + `insert into local_lane_storage_state( + lane_id, project_id, worktree_path, reclaim_state, last_known_bytes, + attempts, last_error, reclaimed_at, updated_at + ) values(?, ?, ?, 'kept', ?, 0, null, null, ?) + on conflict(lane_id) do update set + worktree_path = excluded.worktree_path, + last_known_bytes = excluded.last_known_bytes, + last_error = null, + updated_at = excluded.updated_at`, + [args.laneId, projectId, row.worktree_path, risk.reclaimableBytes, now], + ); + teardownDeps?.autoRebaseService?.cancelForLane(args.laneId); + await teardownDeps?.rebaseSuggestionService?.dismiss({ laneId: args.laneId }); + await teardownDeps?.agentChatService?.disposeForLane(args.laneId); + teardownDeps?.ptyService?.disposeForLane(args.laneId); + teardownDeps?.fileWatcherService?.stopAllForWorkspace(args.laneId); + if (runtimeOpts?.teardownEnv) { + try { + await runtimeOpts.teardownEnv(); + } catch (error) { + warnings.push(`Environment cleanup did not finish: ${error instanceof Error ? error.message : String(error)}`); + } + } + + let worktreeRemoved = !fs.existsSync(normalizedWorktree); + if (!worktreeRemoved) { + const initialStat = await fs.promises.lstat(normalizedWorktree); + if (!initialStat.isDirectory() || initialStat.isSymbolicLink()) { + throw new Error("The lane worktree is no longer a safe managed folder."); + } + await runGitWorktreeMutation(async () => { + if (await hasSymlinkInManagedPath(normalizedRoot, normalizedWorktree)) { + throw new Error("ADE will not reclaim a folder through a symbolic link."); + } + if (!await registeredWorktreeForLane(normalizedWorktree, row.branch_ref)) { + throw new Error("This folder is no longer the lane worktree registered to this project."); + } + const beforeRemove = await fs.promises.lstat(normalizedWorktree); + if ( + !beforeRemove.isDirectory() + || beforeRemove.isSymbolicLink() + || beforeRemove.dev !== initialStat.dev + || beforeRemove.ino !== initialStat.ino + ) { + throw new Error("The lane worktree changed while ADE was preparing cleanup."); + } + const dirtyBeforeRemove = await readWorktreeDirty(normalizedWorktree); + if (dirtyBeforeRemove && args.forceDirty !== true) { + throw new Error("This lane gained uncommitted files during cleanup. Confirm that those changes may be lost."); + } + const remove = await runGit( + ["worktree", "remove", "--force", normalizedWorktree], + { cwd: projectRoot, timeoutMs: 120_000 }, + ); + if (remove.exitCode !== 0 || fs.existsSync(normalizedWorktree)) { + let residualStat: fs.Stats | null = null; + try { + residualStat = await fs.promises.lstat(normalizedWorktree); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + if (residualStat) { + if ( + !residualStat.isDirectory() + || residualStat.isSymbolicLink() + || residualStat.dev !== initialStat.dev + || residualStat.ino !== initialStat.ino + ) { + throw new Error("The lane worktree changed during cleanup; ADE left the folder in place."); + } + await removeWorktreeDirectoryWithRecovery(normalizedWorktree); + } + } + const prune = await runGit(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); + if (prune.exitCode !== 0) { + warnings.push((prune.stderr || prune.stdout || "Git worktree cleanup did not finish.").trim()); + } + }); + worktreeRemoved = !fs.existsSync(normalizedWorktree); + } + if (!worktreeRemoved) throw new Error("The managed worktree folder could not be removed."); + + if (await hasSymlinkInManagedPath(packAdeDir, lanePackDir)) { + throw new Error("ADE will not reclaim generated data through a symbolic link."); + } + try { + await fs.promises.rm(lanePackDir, { recursive: true, force: true }); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + const remainingBytes = await managedTreeBytes(lanePackDir); + db.run( + `update local_lane_storage_state + set last_known_bytes = ?, updated_at = ? + where project_id = ? and lane_id = ?`, + [remainingBytes, new Date().toISOString(), projectId, args.laneId], + ); + throw new Error(`Generated lane data could not be removed: ${message}`); + } + db.run( + `update local_lane_storage_state + set reclaim_state = 'reclaimed', reclaimed_at = ?, last_error = null, updated_at = ? + where project_id = ? and lane_id = ?`, + [now, now, projectId, args.laneId], + ); + invalidateLaneListCache(); + broadcastLifecycleEvent({ + type: "lane-reclaimed", + laneId: args.laneId, + laneName: row.name, + color: row.color, + }); + logger.info("lane.storage.reclaim.completed", { + laneId: args.laneId, + reclaimedBytes: risk.reclaimableBytes, + forceDirty: args.forceDirty === true, + warningCount: warnings.length, + durationMs: Date.now() - startedAtMs, + }); + return { + laneId: args.laneId, + reclaimedBytes: risk.reclaimableBytes, + worktreeRemoved, + generatedDataRemoved: true, + warnings, + }; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + db.run( + `update local_lane_storage_state + set reclaim_state = 'failed', attempts = attempts + 1, last_error = ?, updated_at = ? + where project_id = ? and lane_id = ?`, + [message, new Date().toISOString(), projectId, args.laneId], + ); + logger.warn("lane.storage.reclaim.failed", { + laneId: args.laneId, + forceDirty: args.forceDirty === true, + errorType: error instanceof Error ? error.name : "UnknownError", + durationMs: Date.now() - startedAtMs, + }); + throw error; + } finally { + laneReclaimInFlight.delete(args.laneId); + storageLock.release(); + } + }, + archive({ laneId }: { laneId: string }): void { const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); @@ -5119,18 +5633,100 @@ export function createLaneService({ }); }, - unarchive({ laneId }: { laneId: string }): void { + async unarchive({ laneId }: { laneId: string }): Promise { const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); - if (row.status !== "archived") return; - db.run("update lanes set status = 'active', archived_at = null where id = ? and project_id = ?", [laneId, projectId]); - invalidateLaneListCache(); - broadcastLifecycleEvent({ - type: "lane-unarchived", + if (laneReclaimInFlight.has(laneId)) { + throw new Error("Wait for Archive & Reclaim to finish before restoring this lane."); + } + if (row.status !== "archived") { + const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + return { lane, worktreeRecreated: false }; + } + if (row.lane_type !== "worktree") { + commitLaneRestoreState(laneId); + invalidateLaneListCache(); + const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + broadcastLifecycleEvent({ + type: "lane-unarchived", + laneId, + laneName: row.name, + color: row.color, + }); + return { lane, worktreeRecreated: false }; + } + + const normalizedRoot = normAbs(worktreesDir); + const savedPath = normAbs(row.worktree_path); + const canonicalPath = path.join(normalizedRoot, `${slugify(row.name)}-${laneId.slice(0, 8)}`); + const savedPathIsManaged = path.dirname(savedPath) === normalizedRoot; + const targetPath = savedPathIsManaged ? savedPath : canonicalPath; + const storageLock = acquireStorageLifecycleLock({ laneId, - laneName: row.name, - color: row.color, + worktreePath: targetPath, + ownerLabel: `Restore lane: ${row.name}`, }); + try { + if (savedPathIsManaged && fs.existsSync(savedPath)) { + const registered = await registeredWorktreeForLane(savedPath, row.branch_ref).catch(() => null); + if (!registered) { + throw new Error("The saved lane folder is occupied by a different or unregistered Git worktree."); + } + commitLaneRestoreState(laneId); + invalidateLaneListCache(); + const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + broadcastLifecycleEvent({ + type: "lane-unarchived", + laneId, + laneName: row.name, + color: row.color, + }); + return { lane, worktreeRecreated: false }; + } + if (await hasSymlinkInManagedPath(normalizedRoot, targetPath)) { + throw new Error("ADE will not restore a lane through a symbolic link."); + } + if (fs.existsSync(targetPath)) { + throw new Error("The restore folder already exists but is not this lane's Git worktree."); + } + + const branchName = branchNameForDelete(row.branch_ref, "origin"); + await runGitWorktreeMutation(async () => { + const localBranch = await runGit( + ["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], + { cwd: projectRoot, timeoutMs: 8_000 }, + ); + const addArgs = localBranch.exitCode === 0 + ? ["worktree", "add", targetPath, branchName] + : ["worktree", "add", "-b", branchName, targetPath, `origin/${branchName}`]; + await runGitOrThrow(addArgs, { cwd: projectRoot, timeoutMs: 120_000 }); + }); + try { + commitLaneRestoreState(laneId, targetPath); + } catch (error) { + await runGitWorktreeMutation(async () => { + await runGit(["worktree", "remove", "--force", targetPath], { cwd: projectRoot, timeoutMs: 120_000 }); + await fs.promises.rm(targetPath, { recursive: true, force: true }); + await runGit(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }); + }); + throw error; + } + invalidateLaneListCache(); + const lane = await laneServiceApi.getSummary(laneId, { includeStatus: true }); + if (!lane) throw new Error(`Lane not found: ${laneId}`); + broadcastLifecycleEvent({ + type: "lane-restored", + laneId, + laneName: row.name, + color: row.color, + }); + return { lane, worktreeRecreated: true }; + } finally { + storageLock.release(); + } }, listDeleteProgress(): LaneDeleteProgress[] { @@ -5158,6 +5754,9 @@ export function createLaneService({ } = args; const row = getLaneRow(laneId); if (!row) throw new Error(`Lane not found: ${laneId}`); + if (laneReclaimInFlight.has(laneId)) { + throw new Error("Archive & Reclaim is already running for this lane."); + } if (deleteProgressByLaneId.get(laneId)?.overallStatus === "running") { throw new Error(`Lane delete is already running: ${laneId}`); } @@ -5287,16 +5886,30 @@ export function createLaneService({ broadcastDeleteEvent(progress); }; + let storageLock: ReturnType; + try { + storageLock = acquireStorageLifecycleLock({ + laneId, + worktreePath: row.worktree_path, + ownerLabel: `Delete: ${row.name}`, + }); + } catch (error) { + finishDeleteOperation("failed", { error: error instanceof Error ? error.message : String(error) }); + throw error; + } broadcastDeleteEvent(progress); try { + const { adeDir: packAdeDir, lanePackDir } = lanePackDirectory(projectRoot, laneId); + if (await hasSymlinkInManagedPath(packAdeDir, lanePackDir)) { + throw new Error("ADE will not delete generated lane data through a symbolic link."); + } if (hasWorktree) { await runStep("git_status", async () => { if (!(await isExpectedGitWorktreeRoot(row.worktree_path))) { return { detail: fs.existsSync(row.worktree_path) ? "stale worktree directory" : "missing worktree directory" }; } - const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 8_000 }); - const dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; + const dirty = await readWorktreeDirty(row.worktree_path); if (dirty && !force) { throw new Error("Lane has uncommitted changes. Enable force delete after confirming warnings."); } @@ -5472,7 +6085,9 @@ export function createLaneService({ } await runStep("pack_dir_remove", async () => { - const lanePackDir = path.join(resolveAdeLayout(projectRoot).packsDir, "lanes", laneId); + if (await hasSymlinkInManagedPath(packAdeDir, lanePackDir)) { + throw new Error("ADE will not delete generated lane data through a symbolic link."); + } try { await fs.promises.rm(lanePackDir, { recursive: true, force: true }); return { detail: lanePackDir }; @@ -5531,6 +6146,8 @@ export function createLaneService({ finalize("failed"); finishDeleteOperation("failed", { error: error instanceof Error ? error.message : String(error) }); throw error; + } finally { + storageLock.release(); } }, @@ -5545,8 +6162,7 @@ export function createLaneService({ const worktreeUsable = worktreeExists && await isExpectedGitWorktreeRoot(row.worktree_path); let dirty = false; if (worktreeUsable) { - const dirtyRes = await runGit(["status", "--porcelain=v1"], { cwd: row.worktree_path, timeoutMs: 6_000 }); - dirty = dirtyRes.exitCode === 0 && dirtyRes.stdout.trim().length > 0; + dirty = await readWorktreeDirty(row.worktree_path, 6_000); } let hasUnpushedCommits = false; let unpushedCommitCount = 0; diff --git a/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts b/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts new file mode 100644 index 000000000..cb6d01557 --- /dev/null +++ b/apps/desktop/src/main/services/lanes/laneStorageLifecycle.test.ts @@ -0,0 +1,617 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { openKvDb, type AdeDb } from "../state/kvDb"; +import { createLaneService } from "./laneService"; + +vi.mock("../git/git", () => ({ + getHeadSha: vi.fn(), + runGit: vi.fn(), + runGitOrThrow: vi.fn(), +})); + +import { runGit, runGitOrThrow } from "../git/git"; + +const roots: string[] = []; +const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }; + +function gitResult(exitCode = 0, stdout = "", stderr = "") { + return { exitCode, stdout, stderr }; +} + +function seed(db: AdeDb, root: string, options: { status?: "active" | "archived"; worktreePath?: string } = {}) { + const projectId = "project-storage"; + const worktreesDir = path.join(root, ".ade", "worktrees"); + fs.mkdirSync(worktreesDir, { recursive: true }); + const worktreePath = options.worktreePath ?? path.join(worktreesDir, "feature-12345678"); + const now = "2026-07-01T00:00:00.000Z"; + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, root, "Fixture", "main", now, now], + ); + db.run( + `insert into lanes( + id, project_id, name, description, lane_type, base_ref, branch_ref, worktree_path, + attached_root_path, is_edit_protected, parent_lane_id, color, icon, tags_json, status, created_at, archived_at + ) values (?, ?, ?, null, 'worktree', 'main', 'feature/storage', ?, null, 0, null, null, null, null, ?, ?, ?)`, + [ + "12345678-lane", + projectId, + "Storage feature", + worktreePath, + options.status ?? "active", + now, + options.status === "archived" ? now : null, + ], + ); + return { projectId, worktreesDir, worktreePath }; +} + +function installGitStub(args: { + dirty?: boolean; + statusOutputs?: string[]; + onRemove?: () => Promise | void; + onAdd?: (target: string) => Promise | void; +} = {}) { + let statusCall = 0; + vi.mocked(runGit).mockImplementation(async (command: string[], options?: { cwd?: string }) => { + if (command[0] === "rev-parse" && command.includes("--show-toplevel")) { + if (!options?.cwd || !fs.existsSync(options.cwd)) return gitResult(1, "", "missing"); + return gitResult(0, `${options?.cwd ?? ""}\n`); + } + if (command[0] === "status") { + const configured = args.statusOutputs; + const stdout = configured?.[Math.min(statusCall, configured.length - 1)] + ?? (args.dirty ? " M changed.txt\n" : ""); + statusCall += 1; + return gitResult(0, stdout); + } + if (command[0] === "rev-list" && command[1] === "--count") return gitResult(0, "0\n"); + if (command[0] === "ls-remote") return gitResult(0, "abc\trefs/heads/feature/storage\n"); + if (command[0] === "merge-base") return gitResult(0); + if (command[0] === "worktree" && command[1] === "remove") { + await args.onRemove?.(); + fs.rmSync(command.at(-1)!, { recursive: true, force: true }); + return gitResult(0); + } + if (command[0] === "worktree" && command[1] === "prune") return gitResult(0); + if (command[0] === "show-ref") return gitResult(0); + if (command[0] === "rev-list" && command[1] === "--left-right") return gitResult(0, "0\t0\n"); + if (command[0] === "rev-parse" && command.includes("@{upstream}")) return gitResult(1); + if (command[0] === "rev-parse" && command.includes("--git-dir")) return gitResult(1); + return gitResult(0); + }); + vi.mocked(runGitOrThrow).mockImplementation(async (command: string[], options?: { cwd?: string }) => { + if (command[0] === "worktree" && command[1] === "list") { + const worktreesDir = path.join(options?.cwd ?? "", ".ade", "worktrees"); + const names = fs.existsSync(worktreesDir) ? fs.readdirSync(worktreesDir) : []; + const stdout = names + .map((name) => `worktree ${path.join(worktreesDir, name)}\nbranch refs/heads/feature/storage\n`) + .join("\n"); + return stdout as never; + } + if (command[0] === "worktree" && command[1] === "add") { + const target = command[2] === "-b" ? command[4] : command[2]; + fs.mkdirSync(target, { recursive: true }); + await args.onAdd?.(target); + } + return gitResult(0) as never; + }); +} + +async function fixture(options: { status?: "active" | "archived"; worktreePath?: string } = {}) { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-storage-lifecycle-")); + roots.push(root); + const db = await openKvDb(path.join(root, ".ade", "ade.db"), logger as any); + const seeded = seed(db, root, options); + const service = createLaneService({ + db, + projectRoot: root, + projectId: seeded.projectId, + defaultBaseRef: "main", + worktreesDir: seeded.worktreesDir, + logger: logger as any, + }); + return { root, db, service, ...seeded }; +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); +}); + +afterEach(() => { + vi.restoreAllMocks(); + while (roots.length > 0) fs.rmSync(roots.pop()!, { recursive: true, force: true }); +}); + +describe("lane storage lifecycle", () => { + it("does not reclaim a dirty lane without the explicit dirty confirmation", async () => { + const { db, service, worktreePath } = await fixture(); + const onArchived = vi.fn(); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "changed.txt"), "keep me"); + installGitStub({ dirty: true }); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }, { onArchived })).rejects.toThrow(/uncommitted files/i); + + expect(onArchived).not.toHaveBeenCalled(); + expect(fs.existsSync(worktreePath)).toBe(true); + expect(db.get<{ status: string }>("select status from lanes where id = ?", ["12345678-lane"])?.status).toBe("active"); + db.close(); + }); + + it("rejects reclaim when a generated-data ancestor is a symbolic link", async () => { + const { root, db, service, worktreePath } = await fixture(); + const outsidePacks = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-packs-outside-")); + roots.push(outsidePacks); + const packsDir = path.join(root, ".ade", "artifacts", "packs"); + const linkedLanesDir = path.join(packsDir, "lanes"); + const outsideLanePack = path.join(outsidePacks, "12345678-lane"); + fs.mkdirSync(packsDir, { recursive: true }); + fs.mkdirSync(outsideLanePack, { recursive: true }); + fs.writeFileSync(path.join(outsideLanePack, "keep.txt"), "outside project"); + fs.symlinkSync(outsidePacks, linkedLanesDir, "dir"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "keep.txt"), "lane work"); + installGitStub(); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + })).rejects.toThrow(/generated data.*link/i); + + expect(fs.readFileSync(path.join(outsideLanePack, "keep.txt"), "utf8")).toBe("outside project"); + expect(fs.existsSync(worktreePath)).toBe(true); + expect(db.get<{ status: string }>("select status from lanes where id = ?", ["12345678-lane"])?.status) + .toBe("active"); + db.close(); + }); + + it("rejects delete when a generated-data ancestor is a symbolic link", async () => { + const { root, db, service, worktreePath } = await fixture(); + const outsidePacks = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-delete-packs-outside-")); + roots.push(outsidePacks); + const packsDir = path.join(root, ".ade", "artifacts", "packs"); + const linkedLanesDir = path.join(packsDir, "lanes"); + const outsideLanePack = path.join(outsidePacks, "12345678-lane"); + fs.mkdirSync(packsDir, { recursive: true }); + fs.mkdirSync(outsideLanePack, { recursive: true }); + fs.writeFileSync(path.join(outsideLanePack, "keep.txt"), "outside project"); + fs.symlinkSync(outsidePacks, linkedLanesDir, "dir"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "keep.txt"), "lane work"); + installGitStub(); + + await expect(service.delete({ + laneId: "12345678-lane", + deleteBranch: false, + })).rejects.toThrow(/generated lane data.*symbolic link/i); + + expect(fs.readFileSync(path.join(outsideLanePack, "keep.txt"), "utf8")).toBe("outside project"); + expect(fs.existsSync(worktreePath)).toBe(true); + expect(db.get<{ id: string }>("select id from lanes where id = ?", ["12345678-lane"])?.id) + .toBe("12345678-lane"); + db.close(); + }); + + it("rechecks dirtiness immediately before removal and requires the explicit dirty override", async () => { + const { db, service, worktreePath } = await fixture(); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "changed-late.txt"), "changed during teardown"); + installGitStub({ statusOutputs: ["", " M changed-late.txt\n"] }); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + })).rejects.toThrow(/gained uncommitted files during cleanup/i); + + expect(fs.existsSync(worktreePath)).toBe(true); + expect(logger.warn).toHaveBeenCalledWith( + "lane.storage.reclaim.failed", + expect.objectContaining({ + laneId: "12345678-lane", + forceDirty: false, + errorType: "Error", + }), + ); + expect(JSON.stringify(vi.mocked(logger.warn).mock.calls)).not.toContain(worktreePath); + + const retry = await service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + forceDirty: true, + }); + expect(retry.worktreeRemoved).toBe(true); + expect(fs.existsSync(worktreePath)).toBe(false); + db.close(); + }); + + it("reclaims only managed files while preserving the lane, branch, and chat", async () => { + const { db, service, worktreePath } = await fixture(); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "generated.bin"), Buffer.alloc(128)); + db.run( + "insert into claude_sessions(session_id, lane_id, title, created_at, updated_at) values (?, ?, ?, ?, ?)", + ["chat-1", "12345678-lane", "Keep this chat", "2026-07-01T00:00:00.000Z", "2026-07-01T00:00:00.000Z"], + ); + installGitStub(); + + const result = await service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }); + + expect(result.reclaimedBytes).toBeGreaterThanOrEqual(128); + expect(fs.existsSync(worktreePath)).toBe(false); + expect(db.get("select status, branch_ref from lanes where id = ?", ["12345678-lane"])).toMatchObject({ + status: "archived", + branch_ref: "feature/storage", + }); + expect(db.get("select session_id from claude_sessions where session_id = ?", ["chat-1"])).toMatchObject({ session_id: "chat-1" }); + expect(logger.info).toHaveBeenCalledWith( + "lane.storage.reclaim.completed", + expect.objectContaining({ + laneId: "12345678-lane", + reclaimedBytes: expect.any(Number), + forceDirty: false, + warningCount: 0, + }), + ); + expect(JSON.stringify(vi.mocked(logger.info).mock.calls)).not.toContain(worktreePath); + db.close(); + }); + + it("restores a reclaimed lane at a safe managed path when the saved database path is stale", async () => { + const stalePath = path.join(os.tmpdir(), "old-machine", "feature"); + const { db, service, worktreesDir } = await fixture({ status: "archived", worktreePath: stalePath }); + installGitStub(); + + const result = await service.unarchive({ laneId: "12345678-lane" }); + + const expectedPath = path.join(worktreesDir, "storage-feature-12345678"); + expect(result.worktreeRecreated).toBe(true); + expect(result.lane.worktreePath).toBe(expectedPath); + expect(fs.existsSync(expectedPath)).toBe(true); + expect(db.get("select status, worktree_path from lanes where id = ?", ["12345678-lane"])).toMatchObject({ + status: "active", + worktree_path: expectedPath, + }); + db.close(); + }); + + it("does not reclaim a managed path that Git does not register to this lane", async () => { + const { db, service, worktreePath } = await fixture({ status: "archived" }); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "unrelated.txt"), "keep this"); + installGitStub(); + vi.mocked(runGitOrThrow).mockImplementation(async (command: string[]) => { + if (command[0] === "worktree" && command[1] === "list") { + return "worktree /somewhere/else\nbranch refs/heads/feature/storage\n" as never; + } + return gitResult(0) as never; + }); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + })).rejects.toThrow(/not the lane worktree registered/i); + + expect(fs.existsSync(worktreePath)).toBe(true); + expect(db.get<{ status: string }>("select status from lanes where id = ?", ["12345678-lane"])?.status).toBe("archived"); + db.close(); + }); + + it("does not restore against an unregistered repository occupying the saved path", async () => { + const { db, service, worktreePath } = await fixture({ status: "archived" }); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "unrelated.txt"), "keep this"); + installGitStub(); + vi.mocked(runGitOrThrow).mockImplementation(async (command: string[]) => { + if (command[0] === "worktree" && command[1] === "list") return "" as never; + return gitResult(0) as never; + }); + + await expect(service.unarchive({ laneId: "12345678-lane" })).rejects.toThrow(/occupied by a different/i); + + expect(fs.readFileSync(path.join(worktreePath, "unrelated.txt"), "utf8")).toBe("keep this"); + expect(db.get<{ status: string }>("select status from lanes where id = ?", ["12345678-lane"])?.status).toBe("archived"); + db.close(); + }); + + it("rolls back lane activation and removes the recreated worktree when local state cleanup fails", async () => { + const stalePath = path.join(os.tmpdir(), "old-machine", "transactional-restore"); + const { db, service, projectId, worktreesDir } = await fixture({ status: "archived", worktreePath: stalePath }); + const now = new Date().toISOString(); + db.run( + `insert into local_lane_storage_state( + lane_id, project_id, worktree_path, reclaim_state, last_known_bytes, attempts, updated_at + ) values (?, ?, ?, 'reclaimed', 10, 0, ?)`, + ["12345678-lane", projectId, stalePath, now], + ); + db.run(` + create trigger fail_lane_storage_state_delete + before delete on local_lane_storage_state + begin + select raise(abort, 'storage state cleanup failed'); + end + `); + installGitStub(); + + await expect(service.unarchive({ laneId: "12345678-lane" })) + .rejects.toThrow(/storage state cleanup failed/i); + + const expectedPath = path.join(worktreesDir, "storage-feature-12345678"); + expect(fs.existsSync(expectedPath)).toBe(false); + expect(db.get("select status, worktree_path from lanes where id = ?", ["12345678-lane"])).toMatchObject({ + status: "archived", + worktree_path: stalePath, + }); + expect(db.get("select reclaim_state from local_lane_storage_state where lane_id = ?", ["12345678-lane"])) + .toMatchObject({ reclaim_state: "reclaimed" }); + db.close(); + }); + + it("uses a distinct lock token for each restore attempt", async () => { + const stalePath = path.join(os.tmpdir(), "old-machine", "concurrent-restore"); + const { db, service } = await fixture({ status: "archived", worktreePath: stalePath }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let addStarted!: () => void; + const started = new Promise((resolve) => { addStarted = resolve; }); + installGitStub({ + onAdd: async () => { + addStarted(); + await gate; + }, + }); + + const first = service.unarchive({ laneId: "12345678-lane" }); + await started; + await expect(service.unarchive({ laneId: "12345678-lane" })) + .rejects.toThrow(/blocked by restore lane/i); + release(); + await first; + db.close(); + }); + + it("records a failed reclaim for safe retry", async () => { + const { db, service, worktreePath } = await fixture(); + const onArchived = vi.fn(); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "file.bin"), "data"); + installGitStub(); + vi.mocked(runGit).mockImplementation(async (command: string[], options?: { cwd?: string }) => { + if (command[0] === "worktree" && command[1] === "remove") return gitResult(1, "", "remove failed"); + if (command[0] === "worktree" && command[1] === "prune") return gitResult(0); + if (command[0] === "rev-parse" && command.includes("--show-toplevel")) return gitResult(0, `${options?.cwd}\n`); + if (command[0] === "status") return gitResult(0, ""); + if (command[0] === "rev-list") return gitResult(0, "0\n"); + if (command[0] === "ls-remote") return gitResult(0, ""); + if (command[0] === "merge-base") return gitResult(0); + return gitResult(0); + }); + const originalRm = fs.promises.rm.bind(fs.promises); + vi.spyOn(fs.promises, "rm").mockImplementation(async (target, options) => { + if (path.resolve(String(target)) === path.resolve(worktreePath)) { + expect(onArchived).toHaveBeenCalledTimes(1); + throw new Error("disk is busy"); + } + return originalRm(target, options); + }); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }, { onArchived })).rejects.toThrow(/disk is busy/i); + + expect(onArchived).toHaveBeenCalledTimes(1); + expect(db.get("select reclaim_state, attempts, last_error from local_lane_storage_state where lane_id = ?", ["12345678-lane"])) + .toMatchObject({ reclaim_state: "failed", attempts: 1, last_error: "disk is busy" }); + expect(db.get<{ status: string }>("select status from lanes where id = ?", ["12345678-lane"])?.status).toBe("archived"); + db.close(); + }); + + it("accepts a failed git removal when the worktree is already gone", async () => { + const { db, service, worktreePath } = await fixture({ status: "archived" }); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "file.bin"), "data"); + installGitStub(); + vi.mocked(runGit).mockImplementation(async (command: string[], options?: { cwd?: string }) => { + if (command[0] === "worktree" && command[1] === "remove") { + fs.rmSync(worktreePath, { recursive: true, force: true }); + return gitResult(1, "", "Git lost its registration after removing the folder"); + } + if (command[0] === "worktree" && command[1] === "prune") return gitResult(0); + if (command[0] === "rev-parse" && command.includes("--show-toplevel")) return gitResult(0, `${options?.cwd}\n`); + if (command[0] === "status") return gitResult(0, ""); + if (command[0] === "rev-list") return gitResult(0, "0\n"); + if (command[0] === "ls-remote") return gitResult(0, ""); + if (command[0] === "merge-base") return gitResult(0); + return gitResult(0); + }); + + const result = await service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }); + + expect(result.worktreeRemoved).toBe(true); + expect(db.get("select reclaim_state from local_lane_storage_state where lane_id = ?", ["12345678-lane"])) + .toMatchObject({ reclaim_state: "reclaimed" }); + db.close(); + }); + + it("records generated-data deletion failures and retries the remaining files", async () => { + const { root, db, service, worktreePath } = await fixture({ status: "archived" }); + const lanePackDir = path.join(root, ".ade", "artifacts", "packs", "lanes", "12345678-lane"); + fs.mkdirSync(worktreePath, { recursive: true }); + fs.writeFileSync(path.join(worktreePath, "file.bin"), "worktree"); + fs.mkdirSync(lanePackDir, { recursive: true }); + fs.writeFileSync(path.join(lanePackDir, "generated.bin"), "generated"); + installGitStub(); + + const originalRm = fs.promises.rm.bind(fs.promises); + let failedOnce = false; + vi.spyOn(fs.promises, "rm").mockImplementation(async (target, options) => { + if (!failedOnce && path.resolve(String(target)) === path.resolve(lanePackDir)) { + failedOnce = true; + throw new Error("pack is busy"); + } + return originalRm(target, options); + }); + + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + })).rejects.toThrow(/pack is busy/i); + + expect(fs.existsSync(worktreePath)).toBe(false); + expect(fs.existsSync(lanePackDir)).toBe(true); + expect(db.get("select reclaim_state, attempts, last_known_bytes from local_lane_storage_state where lane_id = ?", ["12345678-lane"])) + .toMatchObject({ reclaim_state: "failed", attempts: 1, last_known_bytes: 9 }); + + const retry = await service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }); + expect(retry.generatedDataRemoved).toBe(true); + expect(fs.existsSync(lanePackDir)).toBe(false); + expect(db.get("select reclaim_state, last_error from local_lane_storage_state where lane_id = ?", ["12345678-lane"])) + .toMatchObject({ reclaim_state: "reclaimed", last_error: null }); + db.close(); + }); + + it("rejects a concurrent reclaim of the same lane", async () => { + const { root, db, service, projectId, worktreesDir, worktreePath } = await fixture({ status: "archived" }); + const secondService = createLaneService({ + db, + projectRoot: root, + projectId, + defaultBaseRef: "main", + worktreesDir, + logger: logger as any, + }); + fs.mkdirSync(worktreePath, { recursive: true }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let removeStarted!: () => void; + const started = new Promise((resolve) => { removeStarted = resolve; }); + installGitStub({ onRemove: async () => { removeStarted(); await gate; } }); + + const first = service.archiveAndReclaim({ laneId: "12345678-lane", confirmation: "RECLAIM" }); + await started; + await expect(service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + })).rejects.toThrow(/already running/i); + await expect(service.unarchive({ laneId: "12345678-lane" })).rejects.toThrow(/wait for archive & reclaim/i); + await expect(secondService.unarchive({ laneId: "12345678-lane" })) + .rejects.toThrow(/blocked by archive & reclaim.*before changing or restoring this lane/i); + await expect(secondService.delete({ laneId: "12345678-lane", deleteBranch: false })) + .rejects.toThrow(/blocked by archive & reclaim.*before changing or restoring this lane/i); + expect(secondService.listDeleteProgress()).toEqual([]); + release(); + await first; + db.close(); + }); + + it("renews a lifecycle lock while reclaim runs longer than its initial lease", async () => { + vi.useFakeTimers(); + try { + const { root, db, service, projectId, worktreesDir, worktreePath } = await fixture({ status: "archived" }); + const secondService = createLaneService({ + db, + projectRoot: root, + projectId, + defaultBaseRef: "main", + worktreesDir, + logger: logger as any, + }); + fs.mkdirSync(worktreePath, { recursive: true }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let removeStarted!: () => void; + const started = new Promise((resolve) => { removeStarted = resolve; }); + installGitStub({ onRemove: async () => { removeStarted(); await gate; } }); + + const reclaim = service.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }); + await started; + await vi.advanceTimersByTimeAsync(16 * 60_000); + + const lock = db.get<{ heartbeat_at: string; expires_at: string }>( + "select heartbeat_at, expires_at from lane_worktree_locks where lane_id = ?", + ["12345678-lane"], + ); + expect(Date.parse(lock?.expires_at ?? "")).toBeGreaterThan(Date.now()); + await expect(secondService.unarchive({ laneId: "12345678-lane" })) + .rejects.toThrow(/blocked by archive & reclaim/i); + + release(); + await reclaim; + expect(db.get<{ count: number }>( + "select count(1) as count from lane_worktree_locks where lane_id = ?", + ["12345678-lane"], + )?.count).toBe(0); + db.close(); + } finally { + vi.useRealTimers(); + } + }); + + it("holds the persistent lifecycle lock while deleting a lane", async () => { + const { root, db, service, projectId, worktreesDir, worktreePath } = await fixture(); + const onArchived = vi.fn(); + const secondService = createLaneService({ + db, + projectRoot: root, + projectId, + defaultBaseRef: "main", + worktreesDir, + logger: logger as any, + }); + fs.mkdirSync(worktreePath, { recursive: true }); + let release!: () => void; + const gate = new Promise((resolve) => { release = resolve; }); + let removeStarted!: () => void; + const started = new Promise((resolve) => { removeStarted = resolve; }); + installGitStub({ onRemove: async () => { removeStarted(); await gate; } }); + + const deletion = service.delete({ laneId: "12345678-lane", deleteBranch: false }); + await started; + await expect(secondService.archiveAndReclaim({ + laneId: "12345678-lane", + confirmation: "RECLAIM", + }, { onArchived })).rejects.toThrow(/blocked by delete.*before changing or restoring this lane/i); + expect(onArchived).not.toHaveBeenCalled(); + release(); + await deletion; + db.close(); + }); + + it("removes machine-local reclaim metadata when a lane is fully deleted", async () => { + const { db, service, projectId, worktreePath } = await fixture({ status: "archived" }); + const now = new Date().toISOString(); + db.run( + `insert into local_lane_storage_state( + lane_id, project_id, worktree_path, reclaim_state, last_known_bytes, attempts, updated_at + ) values (?, ?, ?, 'reclaimed', 10, 1, ?)`, + ["12345678-lane", projectId, worktreePath, now], + ); + installGitStub(); + + await service.delete({ laneId: "12345678-lane", deleteBranch: false }); + + expect(db.get("select lane_id from local_lane_storage_state where lane_id = ?", ["12345678-lane"])).toBeNull(); + expect(db.get("select id from lanes where id = ?", ["12345678-lane"])).toBeNull(); + db.close(); + }); +}); diff --git a/apps/desktop/src/main/services/lanes/laneWorktreeLockService.ts b/apps/desktop/src/main/services/lanes/laneWorktreeLockService.ts index 84497c29a..fd463650d 100644 --- a/apps/desktop/src/main/services/lanes/laneWorktreeLockService.ts +++ b/apps/desktop/src/main/services/lanes/laneWorktreeLockService.ts @@ -98,9 +98,12 @@ function describeOwner(lock: LaneWorktreeLockInfo): string { export function formatLaneWorktreeLockBlocker(lock: LaneWorktreeLockInfo): LaneWorktreeLockBlocker { const owner = describeOwner(lock); + const nextAction = lock.ownerKind === "storage_lifecycle" + ? "Wait for it to finish before changing or restoring this lane." + : "Wait for it to finish or stop it before starting another PR task."; return { lock, - message: `Blocked by ${owner} on this lane. Wait for it to finish or stop it before starting another PR task.`, + message: `Blocked by ${owner} on this lane. ${nextAction}`, }; } diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index c7756f7ec..70b573090 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -762,6 +762,8 @@ const LOCAL_ONLY_CRR_EXCLUDED_TABLES = new Set([ "usage_events", "test_suites", "local_worktree_residual_cleanups", + "local_lane_storage_state", + "local_storage_lifecycle_runs", ]); function listEligibleCrrTables(db: DatabaseSyncType): string[] { @@ -3578,6 +3580,33 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { db.run("create unique index if not exists idx_local_worktree_residual_cleanups_path on local_worktree_residual_cleanups(project_id, worktree_path)"); db.run("create index if not exists idx_local_worktree_residual_cleanups_updated on local_worktree_residual_cleanups(project_id, updated_at)"); + // Machine-local lane storage lifecycle state. Worktree paths, reclaim + // failures, and retry counts describe this checkout only and must never + // replicate to another desktop or phone. + db.run(` + create table if not exists local_lane_storage_state ( + lane_id text primary key, + project_id text not null, + worktree_path text not null, + reclaim_state text not null default 'kept', + last_known_bytes integer not null default 0, + attempts integer not null default 0, + last_error text, + reclaimed_at text, + updated_at text not null + ) + `); + db.run("create index if not exists idx_local_lane_storage_state_project on local_lane_storage_state(project_id, updated_at desc)"); + db.run(` + create table if not exists local_storage_lifecycle_runs ( + project_id text primary key, + last_scan_at text, + next_scan_at text, + archived_automatically integer not null default 0, + updated_at text not null + ) + `); + // Machine-local runtime guard for PR automation. This table intentionally // has no PRIMARY KEY so cr-sqlite does not register it as a CRR table. db.run(` diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts index e28ed7da2..9c754be92 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.test.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.test.ts @@ -73,6 +73,24 @@ function seedLane(db: AdeDb, args: { ); } +async function makeStorageFixture() { + const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-extra-project-")); + const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-storage-extra-home-")); + fs.mkdirSync(path.join(projectRoot, ".ade"), { recursive: true }); + const db = await openKvDb(path.join(projectRoot, ".ade", "ade.db"), logger); + seedProject(db, projectRoot); + return { + projectRoot, + adeHome, + db, + cleanup() { + db.close(); + fs.rmSync(projectRoot, { recursive: true, force: true }); + fs.rmSync(adeHome, { recursive: true, force: true }); + }, + }; +} + describe("storageInsightsService", () => { let projectRoot: string; let adeHome: string; @@ -485,7 +503,7 @@ describe("storageInsightsService", () => { subscribe: () => () => {}, }; - it("runs the maintenance doctor: compresses, reaps safe staging/backups/build data, and journals it", async () => { + it("runs the maintenance doctor: compresses history, lists filesystem review candidates, and journals it", async () => { // Compression candidate (>14d old inactive transcript). const oldTranscript = path.join(projectRoot, ".ade", "transcripts", "chat", "old.jsonl"); fs.mkdirSync(path.dirname(oldTranscript), { recursive: true }); @@ -533,13 +551,13 @@ describe("storageInsightsService", () => { }); const report = await service.runMaintenanceNow(); - // Reaping outcomes. - expect(fs.existsSync(adeTmpStale)).toBe(false); + // Filesystem candidates remain until a user previews and confirms them. + expect(fs.existsSync(adeTmpStale)).toBe(true); expect(fs.existsSync(adeTmpFresh)).toBe(true); - expect(fs.existsSync(stageStale)).toBe(false); - expect(fs.existsSync(backupOlder)).toBe(false); + expect(fs.existsSync(stageStale)).toBe(true); + expect(fs.existsSync(backupOlder)).toBe(true); expect(fs.existsSync(backupNewer)).toBe(true); - expect(fs.existsSync(derived)).toBe(false); + expect(fs.existsSync(derived)).toBe(true); // Compression outcome (transparent gzip). expect(fs.existsSync(`${oldTranscript}.gz`)).toBe(true); @@ -549,13 +567,14 @@ describe("storageInsightsService", () => { const byLedger = Object.fromEntries(report.actions.map((action) => [action.ledgerId, action])); expect(byLedger["fs.transcripts"]!.itemsAffected).toBe(1); expect(byLedger["fs.tmp"]!.itemsAffected).toBe(1); + expect(byLedger["fs.tmp"]!.skippedReason).toBe("review_required"); expect(byLedger["fs.tmp_staging"]!.itemsAffected).toBe(1); expect(byLedger["fs.recovery_backups"]!.itemsAffected).toBe(1); expect(byLedger["fs.ios_derived_data"]!.itemsAffected).toBe(1); // DB hooks absent (no maintenance handle) → unsupported skips, not errors. expect(byLedger["db.automation_ingress_events"]!.skippedReason).toBe("unsupported"); expect(byLedger["db.automation_ingress_events"]!.error).toBeNull(); - expect(report.reclaimedBytes).toBeGreaterThanOrEqual(40 + 50 + 60 + 70); + expect(report.reclaimedBytes).toBeGreaterThan(0); // Journal written to .ade/cache and surfaced through snapshot extras. const journalPath = path.join(projectRoot, ".ade", "cache", "storage-doctor-journal.json"); @@ -829,6 +848,296 @@ describe("storageMaintenanceJournal", () => { fs.rmSync(root, { recursive: true, force: true }); } }); + it("serializes confirmations so the same directory is removed at most once", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const orphan = path.join(projectRoot, ".ade", "worktrees", "concurrent-orphan"); + writeSized(path.join(orphan, "nested", "file.bin"), 64); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + const target: StorageCleanupTarget = { kind: "orphaned_worktree", path: orphan }; + const preview = await service.cleanupPreview([target]); + + const [first, second] = await Promise.all([ + service.cleanup([target], { preview }), + service.cleanup([target], { preview }), + ]); + + expect(first.removed.length + second.removed.length).toBe(1); + expect(fs.existsSync(orphan)).toBe(false); + expect([...first.failed, ...second.failed].some((failure) => /no longer exists/i.test(failure.reason))).toBe(true); + service.dispose(); + fixture.cleanup(); + }); + + it("routes archived lane folders through Archive & Reclaim safety checks", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const worktree = path.join(projectRoot, ".ade", "worktrees", "archived-guard"); + writeSized(path.join(worktree, "file.bin"), 32); + seedLane(db, { + id: "lane-archived-guard", + name: "Archived", + worktreePath: worktree, + archivedAt: "2026-07-01T00:00:00.000Z", + }); + const service = createStorageInsightsService({ projectRoot, adeHome, db, logger }); + + const preview = await service.cleanupPreview([ + { kind: "archived_lane_worktree", laneId: "lane-archived-guard", path: worktree }, + ]); + + expect(preview.items).toHaveLength(0); + expect(preview.blocked[0]?.reason).toMatch(/Archive & Reclaim/i); + expect(fs.existsSync(worktree)).toBe(true); + service.dispose(); + fixture.cleanup(); + }); + + it("does not auto-archive lanes with an active worktree lease, including a lease acquired during the scan", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const lockedPath = path.join(projectRoot, ".ade", "worktrees", "lane-locked"); + const racedPath = path.join(projectRoot, ".ade", "worktrees", "lane-raced"); + seedLane(db, { id: "lane-locked", name: "Locked lane", worktreePath: lockedPath }); + seedLane(db, { id: "lane-raced", name: "Raced lane", worktreePath: racedPath }); + const expiresAt = new Date(Date.now() + 60_000).toISOString(); + const insertLock = (laneId: string, worktreePath: string, token: string) => { + const now = new Date().toISOString(); + db.run( + `insert into lane_worktree_locks( + worktree_key, worktree_path, lane_id, owner_kind, owner_pr_id, + owner_session_id, owner_proposal_id, owner_label, token, + created_at, heartbeat_at, expires_at + ) values(?, ?, ?, 'storage_lifecycle', null, null, null, ?, ?, ?, ?, ?)`, + [worktreePath, worktreePath, laneId, `Storage work for ${laneId}`, token, now, now, expiresAt], + ); + }; + insertLock("lane-locked", lockedPath, "lock-before-scan"); + const archive = vi.fn(); + const getReclaimRisk = vi.fn(async (laneId: string) => { + if (laneId === "lane-raced") insertLock(laneId, racedPath, "lock-during-scan"); + return { + laneId, + dirty: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + blockedReasons: [], + }; + }); + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + projectId: "project-1", + laneService: { + list: vi.fn(async () => [ + { + id: "lane-locked", + laneType: "worktree" as const, + isEditProtected: false, + status: { dirty: false }, + }, + { + id: "lane-raced", + laneType: "worktree" as const, + isEditProtected: false, + status: { dirty: false }, + }, + ]), + getReclaimRisk, + archive, + }, + projectConfigService: { + get: () => ({ + effective: { + laneCleanup: { + cleanupIntervalHours: 1, + autoArchiveAfterHours: 1, + }, + }, + }), + }, + }); + + await service.runLifecycleScanNow(); + + expect(getReclaimRisk).toHaveBeenCalledTimes(1); + expect(getReclaimRisk).toHaveBeenCalledWith("lane-raced"); + expect(archive).not.toHaveBeenCalled(); + service.dispose(); + fixture.cleanup(); + }); + + it("treats a lane with no valid activity timestamps as recently active", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const worktreePath = path.join(projectRoot, ".ade", "worktrees", "lane-invalid-activity"); + seedLane(db, { id: "lane-invalid-activity", name: "Invalid activity", worktreePath }); + db.run("update lanes set created_at = 'not-a-date' where id = ?", ["lane-invalid-activity"]); + const archive = vi.fn(); + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + projectId: "project-1", + laneService: { + list: vi.fn(async () => [{ + id: "lane-invalid-activity", + laneType: "worktree" as const, + isEditProtected: false, + status: { dirty: false }, + }]), + getReclaimRisk: vi.fn(async () => ({ + dirty: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + blockedReasons: [], + })), + archive, + }, + projectConfigService: { + get: () => ({ + effective: { + laneCleanup: { + cleanupIntervalHours: 1, + autoArchiveAfterHours: 1, + }, + }, + }), + }, + }); + + await service.runLifecycleScanNow(); + + expect(archive).not.toHaveBeenCalled(); + service.dispose(); + fixture.cleanup(); + }); + + it("runs manual maintenance when the lane lifecycle scan fails", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + projectId: "project-1", + stagingTmpDir: path.join(adeHome, "no-real-staging"), + laneService: { + list: vi.fn(async () => { + throw new Error("lane scan failed"); + }), + getReclaimRisk: vi.fn(), + archive: vi.fn(), + }, + projectConfigService: { + get: () => ({ + effective: { + laneCleanup: { + cleanupIntervalHours: 1, + }, + }, + }), + }, + }); + + const report = await service.runMaintenanceNow(); + + expect(report.trigger).toBe("manual"); + expect(report.actions.length).toBeGreaterThan(0); + expect(logger.warn).toHaveBeenCalledWith("storage.lifecycle_scan_failed", expect.objectContaining({ + projectRoot, + trigger: "manual", + error: "lane scan failed", + })); + service.dispose(); + fixture.cleanup(); + }); + + it("enforces active-lane limits on safe lanes and only marks retained files for review", async () => { + const fixture = await makeStorageFixture(); + const { projectRoot, adeHome, db } = fixture; + const activeIds = ["lane-a", "lane-b", "lane-c"]; + for (const id of activeIds) { + const worktreePath = path.join(projectRoot, ".ade", "worktrees", id); + writeSized(path.join(worktreePath, "file.bin"), 16); + seedLane(db, { id, name: id, worktreePath }); + } + const retainedPath = path.join(projectRoot, ".ade", "worktrees", "lane-retained"); + writeSized(path.join(retainedPath, "file.bin"), 24); + seedLane(db, { + id: "lane-retained", + name: "Retained lane", + worktreePath: retainedPath, + archivedAt: "2026-07-01T00:00:00.000Z", + }); + const archive = vi.fn(({ laneId }: { laneId: string }) => { + db.run( + "update lanes set status = 'archived', archived_at = ? where id = ?", + [new Date().toISOString(), laneId], + ); + }); + const laneService = { + list: vi.fn(async () => db.all<{ id: string; name: string; worktree_path: string; is_edit_protected: number }>( + "select id, name, worktree_path, is_edit_protected from lanes where status != 'archived'", + ).map((row) => ({ + id: row.id, + name: row.name, + laneType: "worktree" as const, + worktreePath: row.worktree_path, + isEditProtected: row.is_edit_protected === 1, + status: { dirty: false }, + }))), + getReclaimRisk: vi.fn(async (laneId: string) => ({ + laneId, + dirty: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + blockedReasons: [], + })), + archive, + }; + const projectConfigService = { + get: () => ({ + effective: { + laneCleanup: { + maxActiveLanes: 1, + cleanupIntervalHours: 6, + reclaimArchivedAfterHours: 1, + }, + }, + }), + }; + const releaseLaneRuntimeResources = vi.fn(); + const service = createStorageInsightsService({ + projectRoot, + adeHome, + db, + logger, + projectId: "project-1", + laneService, + projectConfigService, + releaseLaneRuntimeResources, + }); + + await service.runLifecycleScanNow(); + await service.runLifecycleScanNow(); + + expect(archive).toHaveBeenCalledTimes(2); + expect(releaseLaneRuntimeResources.mock.calls.map(([laneId]) => laneId)) + .toEqual(archive.mock.calls.map(([args]) => args.laneId)); + expect(db.get("select reclaim_state from local_lane_storage_state where lane_id = ?", ["lane-retained"])) + .toMatchObject({ reclaim_state: "ready_for_review" }); + expect(fs.existsSync(retainedPath)).toBe(true); + service.dispose(); + fixture.cleanup(); + }); }); describe("storageLedger", () => { @@ -889,7 +1198,7 @@ describe("storageLedger", () => { it("derives category policy chips from the ledger policy values", () => { const chips = deriveCategoryPolicyChips(); expect(chips.chats_history).toBe("Compressed after 14 days"); - expect(chips.build_release).toBe("Auto-cleans after 7 days"); + expect(chips.build_release).toBe("Review after 7 days"); expect(chips.recovery_backups).toBe("Keeps the latest backup"); expect(chips.caches).toBeTruthy(); expect(chips.lanes_worktrees).toBeTruthy(); diff --git a/apps/desktop/src/main/services/storage/storageInsightsService.ts b/apps/desktop/src/main/services/storage/storageInsightsService.ts index 33ae54296..f61edcaf2 100644 --- a/apps/desktop/src/main/services/storage/storageInsightsService.ts +++ b/apps/desktop/src/main/services/storage/storageInsightsService.ts @@ -23,6 +23,7 @@ import type { ProductAnalyticsCapture, ProductAnalyticsCaptureResult, } from "../../../shared/types/productAnalytics"; +import type { LaneCleanupConfig } from "../../../shared/types/config"; import { runGit } from "../git/git"; import type { Logger } from "../logging/logger"; import type { AdeDb } from "../state/kvDb"; @@ -63,11 +64,35 @@ export type StorageDoctorAnalyticsCapture = ( input: ProductAnalyticsCapture, ) => ProductAnalyticsCaptureResult | void; +type LaneLifecycleBackend = { + list: (options: { includeArchived: boolean; includeStatus: boolean }) => Promise>; + getReclaimRisk: (laneId: string) => Promise<{ + dirty: boolean; + activeChatCount: number; + activePtyCount: number; + activeWatcherCount: number; + blockedReasons: Array<{ code: string }>; + }>; + archive: (args: { laneId: string }) => void; +}; + +type LaneCleanupConfigReader = { + get: () => { effective: { laneCleanup?: LaneCleanupConfig } }; +}; + type LaneRow = { id: string; name: string; worktree_path: string; archived_at: string | null; + created_at: string; + lane_type: string; + is_edit_protected: number; }; type WalkState = { @@ -113,6 +138,9 @@ export type StorageInsightsServiceOptions = { * `os.tmpdir()`; overridable so tests never touch the real system temp dir. */ stagingTmpDir?: string; + laneService?: LaneLifecycleBackend | null; + projectConfigService?: LaneCleanupConfigReader | null; + releaseLaneRuntimeResources?: ((laneId: string) => void | Promise) | null; }; export function isObsoleteRecoveryBackup( @@ -301,8 +329,167 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti const journalPath = path.join(layout.cacheDir, MAINTENANCE_JOURNAL_FILENAME); let sweepFlight: Promise | null = null; let maintenanceFlight: Promise | null = null; + let lifecycleFlight: Promise | null = null; + let cleanupQueue: Promise = Promise.resolve(); let firstSweepTimer: ReturnType | null = null; let dailySweepTimer: ReturnType | null = null; + let lifecycleTimer: ReturnType | null = null; + + const lifecyclePolicy = () => { + const cleanup = options.projectConfigService?.get().effective.laneCleanup ?? {}; + return { + maxActiveLanes: Math.max(0, Math.floor(cleanup.maxActiveLanes ?? 0)), + cleanupIntervalHours: Math.max(0, Math.floor(cleanup.cleanupIntervalHours ?? 0)), + autoArchiveAfterHours: Math.max(0, Math.floor(cleanup.autoArchiveAfterHours ?? 0)), + reclaimArchivedAfterHours: Math.max(0, Math.floor( + cleanup.reclaimArchivedAfterHours ?? cleanup.autoDeleteArchivedAfterHours ?? 0, + )), + }; + }; + + const latestLaneActivityMs = (laneId: string, createdAt: string): number => { + const values = [Date.parse(createdAt)]; + const chat = options.db.get<{ value: string | null }>( + "select max(updated_at) as value from claude_sessions where lane_id = ?", + [laneId], + )?.value; + const terminal = options.db.get<{ value: string | null }>( + "select max(coalesce(last_output_at, ended_at, started_at)) as value from terminal_sessions where lane_id = ?", + [laneId], + )?.value; + const operation = options.db.get<{ value: string | null }>( + "select max(coalesce(ended_at, started_at)) as value from operations where project_id = ? and lane_id = ?", + [options.projectId ?? "", laneId], + )?.value; + for (const value of [chat, terminal, operation]) { + if (!value) continue; + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) values.push(parsed); + } + const validValues = values.filter(Number.isFinite); + return validValues.length > 0 ? Math.max(...validValues) : Date.now(); + }; + + const hasActiveLaneWorktreeLock = (laneId: string): boolean => { + const now = Date.now(); + const locks = options.db.all<{ expires_at: string }>( + "select expires_at from lane_worktree_locks where lane_id = ?", + [laneId], + ); + return locks.some((lock) => { + const expiresAt = Date.parse(lock.expires_at); + return Number.isFinite(expiresAt) && expiresAt > now; + }); + }; + + const runLifecycleScan = (): Promise => { + if (lifecycleFlight) return lifecycleFlight; + lifecycleFlight = (async () => { + const laneService = options.laneService; + const projectId = options.projectId?.trim(); + if (!laneService || !options.projectConfigService || !projectId) return; + const policy = lifecyclePolicy(); + if (policy.cleanupIntervalHours <= 0) return; + const previous = options.db.get<{ last_scan_at: string | null }>( + "select last_scan_at from local_storage_lifecycle_runs where project_id = ?", + [projectId], + ); + const lastScanMs = previous?.last_scan_at ? Date.parse(previous.last_scan_at) : 0; + const intervalMs = policy.cleanupIntervalHours * 60 * 60_000; + if (Number.isFinite(lastScanMs) && lastScanMs > 0 && Date.now() - lastScanMs < intervalMs) return; + + const rows = listLaneRows(); + const active = await laneService.list({ includeArchived: false, includeStatus: true }); + const candidates: Array<{ laneId: string; lastActivityMs: number; dueByAge: boolean }> = []; + for (const lane of active) { + if (lane.laneType !== "worktree" || lane.isEditProtected || lane.status.dirty) continue; + if (hasActiveLaneWorktreeLock(lane.id)) continue; + const row = rows.find((entry) => entry.id === lane.id); + if (!row) continue; + const inPrGroup = options.db.get<{ group_id: string }>( + `select m.group_id from pr_group_members m + join pr_groups g on g.id = m.group_id + where m.lane_id = ? and g.project_id = ? + limit 1`, + [lane.id, projectId], + ); + if (inPrGroup) continue; + const risk = await laneService.getReclaimRisk(lane.id); + if (risk.dirty || risk.activeChatCount > 0 || risk.activePtyCount > 0 || risk.activeWatcherCount > 0) continue; + if (risk.blockedReasons.some((reason) => reason.code === "unmerged_work")) continue; + const lastActivityMs = latestLaneActivityMs(lane.id, row.created_at); + const dueByAge = policy.autoArchiveAfterHours > 0 + && Date.now() - lastActivityMs >= policy.autoArchiveAfterHours * 60 * 60_000; + candidates.push({ laneId: lane.id, lastActivityMs, dueByAge }); + } + candidates.sort((left, right) => left.lastActivityMs - right.lastActivityMs); + const nonPrimaryActive = active.filter((lane) => lane.laneType !== "primary"); + let stillNeedsArchive = policy.maxActiveLanes > 0 + ? Math.max(0, nonPrimaryActive.length - policy.maxActiveLanes) + : 0; + let archivedAutomatically = 0; + for (const candidate of candidates) { + if (!candidate.dueByAge && stillNeedsArchive <= 0) continue; + try { + if (hasActiveLaneWorktreeLock(candidate.laneId)) continue; + laneService.archive({ laneId: candidate.laneId }); + await options.releaseLaneRuntimeResources?.(candidate.laneId); + archivedAutomatically += 1; + if (stillNeedsArchive > 0) stillNeedsArchive -= 1; + } catch (error) { + options.logger.warn("storage.lifecycle_auto_archive_skipped", { + laneId: candidate.laneId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + if (policy.reclaimArchivedAfterHours > 0) { + const archived = listLaneRows().filter((row) => row.archived_at); + for (const row of archived) { + const archivedMs = Date.parse(row.archived_at ?? ""); + if (!Number.isFinite(archivedMs)) continue; + if (Date.now() - archivedMs < policy.reclaimArchivedAfterHours * 60 * 60_000) continue; + if (!isDirectChild(layout.worktreesDir, row.worktree_path)) continue; + const stat = await lstatOrNull(row.worktree_path); + if (!stat || stat.isSymbolicLink()) continue; + const now = new Date().toISOString(); + options.db.run( + `insert into local_lane_storage_state( + lane_id, project_id, worktree_path, reclaim_state, last_known_bytes, + attempts, last_error, reclaimed_at, updated_at + ) values(?, ?, ?, 'ready_for_review', 0, 0, null, null, ?) + on conflict(lane_id) do update set + reclaim_state = case + when local_lane_storage_state.reclaim_state = 'reclaimed' then 'reclaimed' + else 'ready_for_review' + end, + worktree_path = excluded.worktree_path, + updated_at = excluded.updated_at`, + [row.id, projectId, row.worktree_path, now], + ); + } + } + + const now = new Date().toISOString(); + const nextScanAt = new Date(Date.now() + intervalMs).toISOString(); + options.db.run( + `insert into local_storage_lifecycle_runs( + project_id, last_scan_at, next_scan_at, archived_automatically, updated_at + ) values(?, ?, ?, ?, ?) + on conflict(project_id) do update set + last_scan_at = excluded.last_scan_at, + next_scan_at = excluded.next_scan_at, + archived_automatically = local_storage_lifecycle_runs.archived_automatically + excluded.archived_automatically, + updated_at = excluded.updated_at`, + [projectId, now, nextScanAt, archivedAutomatically, now], + ); + cachedSnapshot = null; + })().finally(() => { + lifecycleFlight = null; + }); + return lifecycleFlight; + }; const overlapsIosDerivedData = (targetPath: string): boolean => isSameOrWithin(targetPath, iosDerivedDataDir) || isSameOrWithin(iosDerivedDataDir, targetPath); @@ -350,7 +537,7 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti }; const listLaneRows = (): LaneRow[] => options.db.all( - "select id, name, worktree_path, archived_at from lanes", + "select id, name, worktree_path, archived_at, created_at, lane_type, is_edit_protected from lanes", ); const laneForPath = (targetPath: string, laneId?: string, rows: LaneRow[] = listLaneRows()): LaneRow | null => { @@ -452,6 +639,15 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti } const lanes = listLaneRows(); + const laneStorageStates = new Map(options.db.all<{ + lane_id: string; + reclaim_state: StorageItem["reclaimState"]; + last_known_bytes: number; + last_error: string | null; + }>( + "select lane_id, reclaim_state, last_known_bytes, last_error from local_lane_storage_state where project_id = ?", + [options.projectId ?? ""], + ).map((entry) => [entry.lane_id, entry] as const)); const worktreeNames = await readdirOrEmpty(layout.worktreesDir); for (const name of worktreeNames) { const worktreePath = path.join(layout.worktreesDir, name); @@ -473,7 +669,40 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti ? { detail: "Left over from a deleted lane" } : {}), }); - add("lanes_worktrees", entry?.item); + if (entry) { + const storageState = row ? laneStorageStates.get(row.id) : null; + entry.item.ownership = "ADE-managed"; + entry.item.reclaimableBytes = laneStatus === "active" ? 0 : entry.item.bytes; + entry.item.ageHours = entry.item.lastModifiedAt + ? Math.max(0, (Date.now() - Date.parse(entry.item.lastModifiedAt)) / (60 * 60_000)) + : null; + if (row) entry.item.laneId = row.id; + if (storageState?.reclaim_state) entry.item.reclaimState = storageState.reclaim_state; + if (storageState?.last_error) entry.item.blockedReasons = [`Last cleanup failed: ${storageState.last_error}`]; + add("lanes_worktrees", entry.item); + } + } + for (const row of lanes.filter((lane) => lane.archived_at && !fs.existsSync(lane.worktree_path))) { + const storageState = laneStorageStates.get(row.id); + add("lanes_worktrees", { + id: `lanes_worktrees:reclaimed:${row.id}`, + label: row.name, + path: row.worktree_path, + bytes: 0, + fileCount: 0, + lastModifiedAt: row.archived_at, + safety: "protected", + detail: "Lane kept. Its local folder has been reclaimed and will be recreated when restored.", + laneStatus: "archived", + ownership: "ADE-managed", + blockedReasons: storageState?.last_error ? [`Last cleanup failed: ${storageState.last_error}`] : [], + reclaimableBytes: storageState?.reclaim_state === "failed" + ? Math.max(0, storageState.last_known_bytes) + : 0, + ageHours: Math.max(0, (Date.now() - Date.parse(row.archived_at!)) / (60 * 60_000)), + laneId: row.id, + reclaimState: storageState?.reclaim_state ?? "reclaimed", + }); } const tempNames = await readdirOrEmpty(stagingTmpRoot); @@ -618,6 +847,22 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti buildCategory("recovery_backups", categoryItems.get("recovery_backups") ?? [], "review_first", state), buildCategory("database", categoryItems.get("database") ?? [], "protected", state), ]; + for (const category of categories) { + for (const item of category.items) { + item.ownership ??= item.path.startsWith(stagingTmpRoot) + ? "System temporary" + : item.path.startsWith(layout.adeDir) + ? "ADE-managed" + : "Project-owned"; + item.reclaimableBytes ??= item.safety === "protected" ? 0 : item.bytes; + item.ageHours ??= item.lastModifiedAt + ? Math.max(0, (Date.now() - Date.parse(item.lastModifiedAt)) / (60 * 60_000)) + : null; + if (!item.blockedReasons && item.safety !== "safe_to_remove" && item.detail) { + item.blockedReasons = [item.detail]; + } + } + } const journal = readMaintenanceJournal(journalPath); const dbBreakdown = computeDbBreakdown(deriveSyncBookkeepingAction(journal)); // Estimate only work the same backend validator will authorize. Raw @@ -666,6 +911,30 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti scanDurationMs: Date.now() - startedAt, truncated: state.truncated, extras, + lifecycle: (() => { + const policy = lifecyclePolicy(); + const run = options.projectId + ? options.db.get<{ + last_scan_at: string | null; + next_scan_at: string | null; + archived_automatically: number; + }>( + "select last_scan_at, next_scan_at, archived_automatically from local_storage_lifecycle_runs where project_id = ?", + [options.projectId], + ) + : null; + const reviewReadyCount = [...laneStorageStates.values()] + .filter((entry) => entry.reclaim_state === "ready_for_review" || entry.reclaim_state === "failed") + .length; + return { + lastScanAt: run?.last_scan_at ?? null, + nextScanAt: run?.next_scan_at ?? null, + scanInProgress: lifecycleFlight != null, + policy, + archivedAutomatically: run?.archived_automatically ?? 0, + reviewReadyCount, + }; + })(), }; if (state.truncated) { options.logger.warn("storage.scan_truncated", { projectRoot, entries: state.entries, entryLimit: state.entryLimit, budgetMs: state.budgetMs }); @@ -698,6 +967,12 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti if (target.kind === "archived_lane_worktree" && (!lane || !lane.archived_at)) { return { valid: null, reason: lane ? "Active lane files are protected." : "This lane is not archived." }; } + if (target.kind === "archived_lane_worktree") { + return { + valid: null, + reason: "Use Archive & Reclaim so ADE can check running, dirty, and unmerged work before removing this lane folder.", + }; + } label = lane?.name ?? path.basename(targetPath); } else if (target.kind === "stale_tmp_staging") { // Two staging roots share this kind: `ade-*` dirs in the system temp root, @@ -794,7 +1069,7 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti await runGit(["worktree", "prune"], { cwd: projectRoot, timeoutMs: 30_000 }).catch(() => null); }; - const cleanup = async ( + const performCleanup = async ( targets: StorageCleanupTarget[], opts: { preview: StorageCleanupPreview }, ): Promise => { @@ -869,26 +1144,23 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti return result; }; + const cleanup = ( + targets: StorageCleanupTarget[], + opts: { preview: StorageCleanupPreview }, + ): Promise => { + const run = cleanupQueue.then(() => performCleanup(targets, opts)); + cleanupQueue = run.then( + () => undefined, + () => undefined, + ); + return run; + }; + const compressNow = async (): Promise => { const result = await runCompressionSweep(); return { filesCompressed: result.filesCompressed, savedBytes: result.savedBytes }; }; - // Reap a set of candidate targets through the same validate/preview/cleanup - // pipeline the manual flow uses. Only targets the validator lets into the - // preview are removed, so protected/review_first/fresh items are never touched. - const reapTargets = async ( - targets: StorageCleanupTarget[], - ): Promise<{ itemsAffected: number; bytesReclaimed: number }> => { - if (targets.length === 0) return { itemsAffected: 0, bytesReclaimed: 0 }; - const preview = await cleanupPreview(targets); - if (preview.items.length === 0) return { itemsAffected: 0, bytesReclaimed: 0 }; - const previewed = new Set(preview.items.map((item) => path.resolve(item.path))); - const removable = targets.filter((target) => previewed.has(path.resolve(target.path))); - const result = await cleanup(removable, { preview }); - return { itemsAffected: result.removed.length, bytesReclaimed: result.freedBytes }; - }; - const collectSystemStaging = async (): Promise => { const names = await readdirOrEmpty(stagingTmpRoot); const targets: StorageCleanupTarget[] = []; @@ -992,11 +1264,18 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti return { itemsAffected: summary.filesCompressed, bytesReclaimed: summary.savedBytes }; }); - // b/c. Auto-reap safe_to_remove staging, obsolete backups, and iOS build data. - await runStep(actions, "fs.tmp_staging", "delete", async () => reapTargets(await collectSystemStaging())); - await runStep(actions, "fs.tmp", "delete", async () => reapTargets(await collectProjectStaging())); - await runStep(actions, "fs.recovery_backups", "delete", async () => reapTargets(await collectObsoleteBackups())); - await runStep(actions, "fs.ios_derived_data", "delete", async () => reapTargets(await collectIosDerivedData())); + // Filesystem cleanup is review-only. The doctor may identify candidates, + // but it never removes a directory without a fresh user preview and + // confirmation in Settings > Storage. + const recordReviewCandidates = async (targets: StorageCleanupTarget[]) => ({ + itemsAffected: targets.length, + bytesReclaimed: 0, + skippedReason: targets.length > 0 ? "review_required" : "nothing_due", + }); + await runStep(actions, "fs.tmp_staging", "delete", async () => recordReviewCandidates(await collectSystemStaging())); + await runStep(actions, "fs.tmp", "delete", async () => recordReviewCandidates(await collectProjectStaging())); + await runStep(actions, "fs.recovery_backups", "delete", async () => recordReviewCandidates(await collectObsoleteBackups())); + await runStep(actions, "fs.ios_derived_data", "delete", async () => recordReviewCandidates(await collectIosDerivedData())); // d. DB retention hooks. `maintenance` is attached by WS-A's kvDb; until it // lands the hooks are undefined and each records an "unsupported" skip. @@ -1081,12 +1360,40 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti return maintenanceFlight; }; - const runMaintenanceNow = (): Promise => runMaintenanceSweep("manual"); + const runMaintenanceNow = async (): Promise => { + try { + await runLifecycleScan(); + } catch (error) { + options.logger.warn("storage.lifecycle_scan_failed", { + projectRoot, + trigger: "manual", + error: error instanceof Error ? error.message : String(error), + }); + } + return runMaintenanceSweep("manual"); + }; // The daemon-backed fallback instance (isPathActive without diskPressure) must // never schedule automatic maintenance; only the real daemon instance, which // supplies both signals, arms the doctor's post-boot + daily timers. if (options.isPathActive && options.diskPressure) { + if (options.laneService && options.projectConfigService && options.projectId) { + void runLifecycleScan().catch((error) => { + options.logger.warn("storage.lifecycle_scan_failed", { + projectRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + lifecycleTimer = setInterval(() => { + void runLifecycleScan().catch((error) => { + options.logger.warn("storage.lifecycle_scan_failed", { + projectRoot, + error: error instanceof Error ? error.message : String(error), + }); + }); + }, 60_000); + lifecycleTimer.unref?.(); + } firstSweepTimer = setTimeout(() => { firstSweepTimer = null; void runMaintenanceSweep("post_boot").catch((error) => { @@ -1113,9 +1420,19 @@ export function createStorageInsightsService(options: StorageInsightsServiceOpti const dispose = (): void => { if (firstSweepTimer) clearTimeout(firstSweepTimer); if (dailySweepTimer) clearInterval(dailySweepTimer); + if (lifecycleTimer) clearInterval(lifecycleTimer); firstSweepTimer = null; dailySweepTimer = null; + lifecycleTimer = null; }; - return { getSnapshot, cleanupPreview, cleanup, compressNow, runMaintenanceNow, dispose }; + return { + getSnapshot, + cleanupPreview, + cleanup, + compressNow, + runMaintenanceNow, + runLifecycleScanNow: runLifecycleScan, + dispose, + }; } diff --git a/apps/desktop/src/main/services/storage/storageLedger.ts b/apps/desktop/src/main/services/storage/storageLedger.ts index fa54f0def..0bc473ea7 100644 --- a/apps/desktop/src/main/services/storage/storageLedger.ts +++ b/apps/desktop/src/main/services/storage/storageLedger.ts @@ -81,7 +81,7 @@ export const STORAGE_LEDGER: readonly StorageLedgerEntry[] = [ description: "Project release staging under .ade/tmp (TestFlight and release verification scratch).", policyClass: "derived", policy: { maxAgeDays: 7 }, - enforcement: "doctor", + enforcement: "manual", }, { id: "fs.tmp_staging", @@ -89,15 +89,15 @@ export const STORAGE_LEDGER: readonly StorageLedgerEntry[] = [ description: "System temp staging (ade-* directories) left by build and release operations.", policyClass: "derived", policy: { maxAgeDays: 7 }, - enforcement: "doctor", + enforcement: "manual", }, { id: "fs.recovery_backups", kind: "file_family", - description: "Database recovery backups. The doctor keeps the newest good copy and reaps obsolete ones.", + description: "Database recovery backups. ADE keeps the newest good copy and lists older verified copies for review.", policyClass: "operational", policy: { keepLatest: 1, maxAgeDays: 7 }, - enforcement: "doctor", + enforcement: "manual", }, { id: "fs.cache", @@ -105,7 +105,7 @@ export const STORAGE_LEDGER: readonly StorageLedgerEntry[] = [ description: "Rebuildable caches under .ade/cache. Recreated on demand.", policyClass: "derived", policy: {}, - enforcement: "doctor", + enforcement: "manual", }, { id: "fs.ios_derived_data", @@ -113,7 +113,7 @@ export const STORAGE_LEDGER: readonly StorageLedgerEntry[] = [ description: "iOS simulator DerivedData build cache. Recreated the next time you build.", policyClass: "derived", policy: {}, - enforcement: "doctor", + enforcement: "manual", }, { id: "fs.storage_doctor_journal", @@ -201,7 +201,7 @@ export function deriveCategoryPolicyChips(): Partial { expect(isMeaningfulUsageAction("chat.createPromptStash")).toBe(true); expect(usageActionFromIpcChannel("ade.pty.create")).toBe("work.startCliSession"); expect(usageActionFromRpcDomain("lane", "create")).toBe("lanes.create"); + expect(isMeaningfulUsageAction(usageActionFromRpcDomain("lane", "archiveAndReclaim"))).toBe(true); expect(usageActionFromRpcDomain("pr", "createQueuePrs")).toBe("prs.createQueue"); expect(usageActionFromRpcDomain("file", "writeWorkspaceText")).toBe("files.writeText"); expect(usageActionFromRpcDomain("pty", "write")).toBe("pty.write"); diff --git a/apps/desktop/src/preload/global.d.ts b/apps/desktop/src/preload/global.d.ts index 45b8f64b9..f3dcd1631 100644 --- a/apps/desktop/src/preload/global.d.ts +++ b/apps/desktop/src/preload/global.d.ts @@ -57,6 +57,8 @@ import type { LaneBranchSwitchResult, ResolveLaneBranchDriftArgs, ResolveLaneBranchDriftResult, + ArchiveAndReclaimLaneArgs, + ArchiveAndReclaimLaneResult, DeleteLaneArgs, DevToolsCheckResult, DiffChanges, @@ -525,6 +527,8 @@ import type { LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, + LaneReclaimRisk, + RestoreLaneResult, LaneEnvInitProgress, LaneEnvInitEvent, LaneOverlayOverrides, @@ -1349,12 +1353,15 @@ declare global { reparent: (args: ReparentLaneArgs) => Promise; updateAppearance: (args: UpdateLaneAppearanceArgs) => Promise; archive: (args: ArchiveLaneArgs) => Promise; + archiveAndReclaim: (args: ArchiveAndReclaimLaneArgs) => Promise; + unarchive: (args: ArchiveLaneArgs) => Promise; delete: (args: DeleteLaneArgs, pin?: OpenProjectBinding | null) => Promise; cancelDelete: (args: { laneId: string; }) => Promise<{ cancelled: boolean; reason?: string }>; listDeleteProgress: () => Promise; getDeleteRisk: (args: { laneId: string }) => Promise; + getReclaimRisk: (args: { laneId: string }) => Promise; onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => () => void; onLifecycleEvent: (cb: (ev: LaneLifecycleEvent) => void) => () => void; getStackChain: (laneId: string) => Promise; diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 27efb649f..4988f1283 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1191,6 +1191,50 @@ describe("preload OAuth bridge", () => { expect(invoke).not.toHaveBeenCalledWith(IPC.lanesList, {}); }); + it("passes the lane record to the runtime reclaim preview on a bound project", async () => { + const binding = { + kind: "local", + key: "local:/repo", + rootPath: "/repo", + displayName: "Project", + }; + const risk = { laneId: "lane-1", reclaimableBytes: 42 }; + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) return { result: risk }; + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((_name: string, value: unknown) => { + (globalThis as any).__adeBridge = value; + }); + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect(bridge.lanes.getReclaimRisk({ laneId: "lane-1" })).resolves.toEqual(risk); + expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + rootPath: "/repo", + request: { + domain: "lane", + action: "getReclaimRisk", + args: { laneId: "lane-1" }, + }, + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.lanesGetReclaimRisk, expect.anything()); + }); + it("does not fall back chat create when local runtime callAction times out", async () => { const binding = { kind: "local", diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index 2ea1fffb0..ec05f9911 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -174,6 +174,8 @@ import type { LaneBranchSwitchResult, ResolveLaneBranchDriftArgs, ResolveLaneBranchDriftResult, + ArchiveAndReclaimLaneArgs, + ArchiveAndReclaimLaneResult, DeleteLaneArgs, DevToolsCheckResult, DiffChanges, @@ -542,6 +544,8 @@ import type { LaneLifecycleEvent, LaneDeleteProgress, LaneDeleteRisk, + LaneReclaimRisk, + RestoreLaneResult, LaneEnvInitProgress, LaneEnvInitEvent, LaneOverlayOverrides, @@ -4994,6 +4998,30 @@ contextBridge.exposeInMainWorld("ade", { ); clearGitReadCaches(); }, + archiveAndReclaim: async ( + args: ArchiveAndReclaimLaneArgs, + ): Promise => { + clearGitReadCaches(); + const result = await callProjectRuntimeActionOr( + "lane", + "archiveAndReclaim", + { args }, + () => ipcRenderer.invoke(IPC.lanesArchiveAndReclaim, args), + ); + clearGitReadCaches(); + return result; + }, + unarchive: async (args: ArchiveLaneArgs): Promise => { + clearGitReadCaches(); + const result = await callProjectRuntimeActionOr( + "lane", + "unarchive", + { args }, + () => ipcRenderer.invoke(IPC.lanesUnarchive, args), + ); + clearGitReadCaches(); + return result; + }, delete: async (args: DeleteLaneArgs, pin?: OpenProjectBinding | null): Promise => { clearGitReadCaches(); if (pin) { @@ -5025,6 +5053,13 @@ contextBridge.exposeInMainWorld("ade", { { arg: args.laneId }, () => ipcRenderer.invoke(IPC.lanesGetDeleteRisk, args), ), + getReclaimRisk: async (args: { laneId: string }): Promise => + callProjectRuntimeActionOr( + "lane", + "getReclaimRisk", + { args }, + () => ipcRenderer.invoke(IPC.lanesGetReclaimRisk, args), + ), onDeleteEvent: (cb: (ev: LaneDeleteEvent) => void) => { const listener = ( _event: Electron.IpcRendererEvent, diff --git a/apps/desktop/src/renderer/browserMock.ts b/apps/desktop/src/renderer/browserMock.ts index 652823bff..5e5ed3265 100644 --- a/apps/desktop/src/renderer/browserMock.ts +++ b/apps/desktop/src/renderer/browserMock.ts @@ -4401,6 +4401,14 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { reparent: resolvedArg({}), updateAppearance: resolvedArg(undefined), archive: resolvedArg(undefined), + archiveAndReclaim: resolvedArg({ + laneId: "", + reclaimedBytes: 0, + worktreeRemoved: true, + generatedDataRemoved: true, + warnings: [], + }), + unarchive: resolvedArg({ lane: MOCK_LANES[0], worktreeRecreated: false }), delete: resolvedArg(undefined), listDeleteProgress: resolved([]), cancelDelete: resolvedArg({ @@ -4419,6 +4427,27 @@ if (typeof window !== "undefined" && shouldInstallBrowserMock(window)) { activeWatcherCount: 0, envInitialized: false, }), + getReclaimRisk: resolvedArg({ + laneId: "", + laneName: "Lane", + branchRef: null, + worktreePath: "", + dirty: false, + hasUnpushedCommits: false, + unpushedCommitCount: 0, + remoteBranchExists: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + envInitialized: false, + worktreeBytes: 0, + generatedBytes: 0, + reclaimableBytes: 0, + worktreeAvailable: false, + blockedReasons: [], + lastFailure: null, + retryCount: 0, + }), onDeleteEvent: noop, onLifecycleEvent: noop, getStackChain: resolvedArg([]), diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx index 3d43d40e6..ca36e5d98 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.test.tsx @@ -2,7 +2,7 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { LaneDeleteRisk, LaneSummary } from "../../../shared/types"; +import type { LaneDeleteRisk, LaneReclaimRisk, LaneSummary } from "../../../shared/types"; import { ManageLaneDialog, EMPTY_LANE_DELETE_SELECTION, @@ -23,6 +23,18 @@ const deleteRisk: LaneDeleteRisk = { activeWatcherCount: 0, envInitialized: true, }; +const reclaimRisk: LaneReclaimRisk = { + ...deleteRisk, + laneName: "Manage tabs", + worktreePath: "/tmp/ade/manage-tabs", + worktreeBytes: 2 * 1024 ** 3, + generatedBytes: 100 * 1024 ** 2, + reclaimableBytes: 2 * 1024 ** 3 + 100 * 1024 ** 2, + worktreeAvailable: true, + blockedReasons: [], + lastFailure: null, + retryCount: 0, +}; function makeLane(overrides: Partial = {}): LaneSummary { return { @@ -92,6 +104,14 @@ describe("ManageLaneDialog tabs", () => { (globalThis.window as any).ade = { lanes: { getDeleteRisk: vi.fn().mockResolvedValue(deleteRisk), + getReclaimRisk: vi.fn().mockResolvedValue(reclaimRisk), + archiveAndReclaim: vi.fn().mockResolvedValue({ + laneId: "lane-1", + reclaimedBytes: reclaimRisk.reclaimableBytes, + worktreeRemoved: true, + generatedDataRemoved: true, + warnings: [], + }), onDeleteEvent: vi.fn(() => vi.fn()), updateAppearance: vi.fn().mockResolvedValue(undefined), reparent: vi.fn().mockResolvedValue(undefined), @@ -153,6 +173,68 @@ describe("ManageLaneDialog tabs", () => { }); }); + it("explains Archive & Reclaim and requires the typed confirmation", async () => { + render(); + fireEvent.click(screen.getByRole("tab", { name: "Archive" })); + + expect(await screen.findByText(/files stay on disk/i)).toBeTruthy(); + expect(screen.getByText(/restoring the lane recreates its worktree/i)).toBeTruthy(); + const reclaimButton = screen.getByRole("button", { name: /reclaim 2\.1 GB/i }); + expect((reclaimButton as HTMLButtonElement).disabled).toBe(true); + + fireEvent.change(screen.getByRole("textbox"), { target: { value: "RECLAIM" } }); + expect((reclaimButton as HTMLButtonElement).disabled).toBe(false); + }); + + it("keeps reclaim disabled until the risk preview is available", async () => { + let resolveRisk!: (risk: LaneReclaimRisk) => void; + (globalThis.window as any).ade.lanes.getReclaimRisk.mockReturnValue( + new Promise((resolve) => { + resolveRisk = resolve; + }), + ); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Archive" })); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "RECLAIM" } }); + + const measuring = screen.getByRole("button", { name: /measuring/i }); + expect((measuring as HTMLButtonElement).disabled).toBe(true); + + resolveRisk(reclaimRisk); + expect(await screen.findByRole("button", { name: /reclaim 2\.1 GB/i })).toBeTruthy(); + }); + + it("requires an explicit acknowledgement before discarding dirty changes", async () => { + (globalThis.window as any).ade.lanes.getReclaimRisk.mockResolvedValue({ + ...reclaimRisk, + dirty: true, + blockedReasons: [{ + code: "dirty_worktree", + disposition: "confirmation_required", + message: "This lane has uncommitted changes.", + }], + }); + render(); + fireEvent.click(screen.getByRole("tab", { name: "Archive" })); + fireEvent.change(screen.getByRole("textbox"), { target: { value: "RECLAIM" } }); + + const checkbox = await screen.findByRole("checkbox", { name: /discard uncommitted changes/i }); + const blockedButton = screen.getByRole("button", { name: /confirm discarded changes/i }); + expect((blockedButton as HTMLButtonElement).disabled).toBe(true); + fireEvent.click(checkbox); + const reclaimButton = screen.getByRole("button", { name: /reclaim 2\.1 GB/i }); + expect((reclaimButton as HTMLButtonElement).disabled).toBe(false); + fireEvent.click(reclaimButton); + + await waitFor(() => { + expect((globalThis.window as any).ade.lanes.archiveAndReclaim).toHaveBeenCalledWith({ + laneId: "lane-1", + confirmation: "RECLAIM", + forceDirty: true, + }); + }); + }); + it("disables the delete button when nothing is selected", async () => { render(); diff --git a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx index 4c986015f..ddb6831b7 100644 --- a/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/ManageLaneDialog.tsx @@ -11,6 +11,7 @@ import { Cpu, Eye, Cube, + FolderDashed, CheckCircle, Check, Cloud, @@ -24,6 +25,7 @@ import { BranchIcon, LaneIcon } from "../ui/vcsIcons"; import type { LaneDeleteProgress, LaneDeleteRisk, + LaneReclaimRisk, LaneDeleteStep, LaneDeleteStepName, LaneSummary @@ -54,6 +56,19 @@ import { import { LaneColorPicker } from "./LaneColorPicker"; import { colorsInUse, laneColorName } from "./laneColorPalette"; +function formatBytesCompact(bytes: number): string { + const safe = Math.max(0, bytes); + if (safe < 1024) return `${safe} B`; + const units = ["KB", "MB", "GB", "TB"]; + let value = safe / 1024; + let unit = units[0]; + for (let index = 1; index < units.length && value >= 1024; index += 1) { + value /= 1024; + unit = units[index]; + } + return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`; +} + const STEP_LABELS: Record = { git_status: "Checking dirty state", cancel_auto_rebase: "Cancelling auto-rebase", @@ -397,6 +412,11 @@ export function ManageLaneDialog({ : "Removes the working folder and ADE registration."; const [deleteRisk, setDeleteRisk] = useState(null); + const [reclaimRisk, setReclaimRisk] = useState(null); + const [reclaimConfirm, setReclaimConfirm] = useState(""); + const [discardDirtyConfirmed, setDiscardDirtyConfirmed] = useState(false); + const [reclaimBusy, setReclaimBusy] = useState(false); + const [reclaimError, setReclaimError] = useState(null); const [deleteProgress, setDeleteProgress] = useState(null); const [activeTab, setActiveTab] = useState("delete"); @@ -420,6 +440,10 @@ export function ManageLaneDialog({ useEffect(() => { if (!open) { setDeleteRisk(null); + setReclaimRisk(null); + setReclaimConfirm(""); + setDiscardDirtyConfirmed(false); + setReclaimError(null); setDeleteProgress(null); return; } @@ -433,10 +457,19 @@ export function ManageLaneDialog({ return; } let cancelled = false; - void window.ade.lanes - .getDeleteRisk({ laneId: singleLaneId }) - .then((risk) => { - if (!cancelled) setDeleteRisk(risk); + const getReclaimRisk = window.ade.lanes.getReclaimRisk; + void Promise.all([ + window.ade.lanes.getDeleteRisk({ laneId: singleLaneId }), + typeof getReclaimRisk === "function" + ? getReclaimRisk({ laneId: singleLaneId }) + : Promise.resolve(null), + ]) + .then(([deleteResult, reclaimResult]) => { + if (!cancelled) { + setDeleteRisk(deleteResult); + setReclaimRisk(reclaimResult); + setDiscardDirtyConfirmed(false); + } }) .catch(() => { // best-effort — pre-flight is informational only @@ -460,6 +493,31 @@ export function ManageLaneDialog({ const hasDeleteSelection = laneDeleteSelectionHasAny(deleteSelection); const showStaticBusy = laneActionBusy && !deleteProgress; + const reclaimBlocked = reclaimRisk?.blockedReasons.some((reason) => reason.disposition === "blocked") ?? false; + + const archiveAndReclaim = async () => { + if ( + !singleLane + || !reclaimRisk + || reclaimConfirm !== "RECLAIM" + || (reclaimRisk.dirty && !discardDirtyConfirmed) + ) return; + setReclaimBusy(true); + setReclaimError(null); + try { + await window.ade.lanes.archiveAndReclaim({ + laneId: singleLane.id, + confirmation: "RECLAIM", + ...(reclaimRisk.dirty && discardDirtyConfirmed ? { forceDirty: true } : {}), + }); + await onAppearanceChanged?.(); + onOpenChange(false); + } catch (error) { + setReclaimError(error instanceof Error ? error.message : "Could not reclaim this lane"); + } finally { + setReclaimBusy(false); + } + }; // Local/remote branch deletion can't happen while the worktree still has the // branch checked out, and any delete here tears the worktree down regardless — @@ -565,23 +623,94 @@ export function ManageLaneDialog({ {activeTab === "archive" ? ( -
- - - -
-

- {isBatch ? `Hide ${lanes.length} lanes from ADE` : "Hide this lane from ADE"} -

-

- Files stay on disk until you delete them. +

+ Choose whether to keep the local files or reclaim their disk space. Both choices keep the lane, branch, chats, and metadata. +

+
+
+
+ + Archive +
+

+ Hides the lane from active work. Its worktree and generated files stay on disk.

+
+ +
-
-
- + {!isBatch && singleLane?.laneType === "worktree" ? ( +
+
+
+ + Archive & reclaim +
+ + {reclaimRisk ? formatBytesCompact(reclaimRisk.reclaimableBytes) : "Measuring…"} + +
+

+ Removes the local worktree and ADE-generated lane data. Restoring the lane recreates its worktree. +

+ {reclaimRisk?.blockedReasons.map((reason) => ( +
+ + {reason.message} +
+ ))} + {reclaimRisk?.dirty ? ( + + ) : null} + + {reclaimError ?
{reclaimError}
: null} +
+ +
+
+ ) : null}
) : null} @@ -591,6 +720,12 @@ export function ManageLaneDialog({

Stops lane activity and removes what you pick below. Cannot be undone.

+ {!isBatch && reclaimRisk ? ( +
+ Estimated local files removed: {formatBytesCompact(reclaimRisk.reclaimableBytes)}. + Branch deletion is separate below. +
+ ) : null} {hasAnyDirty ? (
diff --git a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx index 91954a94a..92a84cc5d 100644 --- a/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx +++ b/apps/desktop/src/renderer/components/settings/LaneBehaviorSection.tsx @@ -1,44 +1,19 @@ import { useEffect, useState, type CSSProperties } from "react"; import { useNavigate } from "react-router-dom"; -import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, outlineButton, primaryButton, recessedStyle } from "../lanes/laneDesignTokens"; -import type { LaneCleanupConfig, NewLaneBaseSource } from "../../../shared/types"; +import { COLORS, MONO_FONT, LABEL_STYLE, cardStyle, outlineButton, primaryButton } from "../lanes/laneDesignTokens"; +import type { NewLaneBaseSource } from "../../../shared/types"; import { DEFAULT_NEW_LANE_BASE_SOURCE, effectiveNewLaneBaseSource } from "../lanes/newLaneBaseSource"; function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -const inputStyle: CSSProperties = { - height: 36, - width: "100%", - background: COLORS.recessedBg, - border: `1px solid ${COLORS.outlineBorder}`, - padding: "0 12px", - fontSize: 12, - color: COLORS.textPrimary, - fontFamily: MONO_FONT, - borderRadius: 8, - outline: "none", - transition: "border-color 150ms ease", -}; - -const miniLabel: CSSProperties = { - fontSize: 10, - fontWeight: 600, - fontFamily: SANS_FONT, - textTransform: "uppercase" as const, - letterSpacing: "0.5px", - color: COLORS.textMuted, - marginBottom: 6, -}; - export function LaneBehaviorSection() { const navigate = useNavigate(); const [autoRebaseDraft, setAutoRebaseDraft] = useState(false); const [newLaneBaseSource, setNewLaneBaseSource] = useState(DEFAULT_NEW_LANE_BASE_SOURCE); const [initialNewLaneBaseSource, setInitialNewLaneBaseSource] = useState(DEFAULT_NEW_LANE_BASE_SOURCE); - const [cleanup, setCleanup] = useState({}); const [busy, setBusy] = useState(false); const [notice, setNotice] = useState(null); const [error, setError] = useState(null); @@ -56,9 +31,6 @@ export function LaneBehaviorSection() { setNewLaneBaseSource(initialSource); setInitialNewLaneBaseSource(initialSource); - const effectiveCleanup = snapshot.effective.laneCleanup ?? {}; - const localCleanup = snapshot.local.laneCleanup ?? {}; - setCleanup({ ...effectiveCleanup, ...localCleanup }); }; useEffect(() => { @@ -89,7 +61,6 @@ export function LaneBehaviorSection() { local: { ...snapshot.local, git: nextGit, - laneCleanup: cleanup, }, }); await refresh(); @@ -111,13 +82,11 @@ export function LaneBehaviorSection() { borderRadius: 8, }); - const cleanupActive = !!(cleanup.maxActiveLanes || cleanup.autoArchiveAfterHours); - return (
LANE BEHAVIOR
- Auto-rebase, cleanup limits, and lane lifecycle. + Choose how new lanes start and how stacked lanes stay current. Storage rules now live in Storage.
{notice ?
{notice}
: null} @@ -177,109 +146,6 @@ export function LaneBehaviorSection() {
- {/* Cleanup & limits */} -
-
-
Cleanup & limits
-
- Prevent lane sprawl by automatically archiving or removing inactive lanes. -
-
- - {/* Primary controls: max lanes + auto-archive. These are the ones that matter most. */} -
-
-
Max active lanes
- setCleanup({ ...cleanup, maxActiveLanes: e.target.value ? Number(e.target.value) : undefined })} - placeholder="Unlimited" - /> -
- When exceeded, the oldest inactive lane is archived. -
-
- -
-
Auto-archive after inactivity
- setCleanup({ ...cleanup, autoArchiveAfterHours: e.target.value ? Number(e.target.value) : undefined })} - placeholder="Never" - /> -
- Hours of inactivity before a lane is auto-archived. -
-
-
- - {/* Secondary controls — only relevant if cleanup is active */} - {cleanupActive && ( -
-
Additional cleanup options
-
-
-
Check every (hours)
- setCleanup({ ...cleanup, cleanupIntervalHours: e.target.value ? Number(e.target.value) : undefined })} - placeholder="6" - /> -
- How often to scan for stale lanes. -
-
-
-
Delete archived after (hours)
- setCleanup({ ...cleanup, autoDeleteArchivedAfterHours: e.target.value ? Number(e.target.value) : undefined })} - placeholder="Never" - /> -
- Permanently remove archived lanes after this period. -
-
-
-
-
-
Delete remote branch on cleanup
-
- Also remove the remote branch when a lane is auto-deleted. -
-
- setCleanup({ ...cleanup, deleteRemoteBranchOnCleanup: v })} - /> -
-
- )} -
- {/* Save + Open Rebase/Merge tab */}
@@ -424,18 +458,30 @@ function LanesCard({ laneIdByKey, archivedAtByKey, onRequestCleanup, + onReclaim, + onRestore, }: { category: StorageCategorySnapshot; policyChip?: string; laneIdByKey: Map; archivedAtByKey: Map; onRequestCleanup: (request: CleanupRequest) => void; + onReclaim: (laneId: string) => void; + onRestore: (laneId: string) => void; }) { const { active, archived, orphaned } = groupLaneItems(category.items); const hasActionable = archived.length > 0 || orphaned.length > 0; const [expanded, setExpanded] = React.useState(hasActionable); const removeRow = (item: StorageItem): React.ReactNode => { + if (item.laneStatus === "archived") { + const laneId = item.laneId ?? laneIdByKey.get(baseName(item.path)); + if (!laneId) return null; + if (item.bytes === 0) { + return } onClick={() => onRestore(laneId)} />; + } + return } onClick={() => onReclaim(laneId)} />; + } const target = buildCleanupTarget("lanes_worktrees", item, laneIdByKey); if (!target) return null; return ( @@ -444,11 +490,8 @@ function LanesCard({ icon={} onClick={() => onRequestCleanup({ - title: item.laneStatus === "archived" ? "Remove archived lane files" : "Remove leftover lane files", - intro: - item.laneStatus === "archived" - ? "These files belong to a lane you archived. Removing them frees space and does not delete the lane's branch or history." - : "These files were left behind by a lane that no longer exists. They are safe to remove.", + title: "Remove leftover lane files", + intro: "These files were left behind by a lane that no longer exists. ADE will verify the managed path again before removal.", targets: [target], }) } @@ -641,6 +684,283 @@ function DatabaseCard({ ); } +function StoragePolicyPanel({ + value, + effectiveValue, + busy, + onChange, + onSave, +}: { + value: LaneCleanupConfig; + effectiveValue: LaneCleanupConfig; + busy: boolean; + onChange: (value: LaneCleanupConfig) => void; + onSave: () => void; +}) { + const field = ( + key: keyof Pick, + label: string, + help: string, + placeholder: string, + ) => { + const inherited = effectiveValue[key]; + const effectiveHelp = value[key] == null && inherited != null + ? `${help} Current inherited value: ${inherited}.` + : help; + return ( + + ); + }; + return ( +
+
+

Lane storage rules

+

+ ADE can archive lanes when they are safely idle. It never removes lane folders in the background. +

+
+
+ {field("maxActiveLanes", "Maximum active lanes", "0 means no limit. Only clean, merged, idle lanes can be archived.", "No limit")} + {field("autoArchiveAfterHours", "Archive after inactivity", "Hours without lane activity before ADE may archive it. 0 means never.", "Never")} + {field("cleanupIntervalHours", "Check every", "Hours between safety scans. A scan only archives eligible lanes and updates this review list.", "Disabled")} + {field("reclaimArchivedAfterHours", "Review archived files after", "Hours before archived lane folders are marked ready for your review. ADE still waits for confirmation.", "Never")} +
+
+ What counts as safe? +
+ The lane must be ADE-managed, clean, merged, not protected, not part of a PR group, and have no running chat, terminal, or watcher. + Attached folders and the primary lane are always left alone. +
+
+
+ +
+
+ ); +} + +function ReclaimConfirmDialog({ + risk, + busy, + value, + discardDirtyConfirmed, + onChange, + onDiscardDirtyChange, + onClose, + onConfirm, +}: { + risk: LaneReclaimRisk; + busy: boolean; + value: string; + discardDirtyConfirmed: boolean; + onChange: (value: string) => void; + onDiscardDirtyChange: (checked: boolean) => void; + onClose: () => void; + onConfirm: () => void; +}) { + const warnings = risk.blockedReasons; + const blocked = warnings.some((reason) => reason.disposition === "blocked"); + const dirtyNotConfirmed = risk.dirty && !discardDirtyConfirmed; + return ( + +
+
+

Archive & reclaim “{risk.laneName}”

+

+ Keeps the lane, branch, chats, and metadata. Removes its local worktree and generated lane data. + Restoring the lane recreates the worktree. +

+
+ +
+
+
+ Estimated space: {formatBytes(risk.reclaimableBytes)} +
+
+ Worktree {formatBytes(risk.worktreeBytes)} · generated data {formatBytes(risk.generatedBytes)} +
+
+ {warnings.length > 0 ? ( +
+ {warnings.map((warning) => ( +
+ + {warning.message} +
+ ))} +
+ ) : null} + {risk.dirty ? ( + + ) : null} + +
+ + +
+
+ ); +} + +function StorageReviewPanel({ + snapshot, + laneIdByKey, + onCleanup, + onReclaim, + onRestore, +}: { + snapshot: StorageSnapshot; + laneIdByKey: Map; + onCleanup: (request: CleanupRequest) => void; + onReclaim: (laneId: string) => void; + onRestore: (laneId: string) => void; +}) { + const rows = snapshot.categories.flatMap((category) => + category.items + .filter((item) => + (category.id === "lanes_worktrees" && item.laneStatus !== "active") + || category.id === "build_release", + ) + .map((item) => ({ categoryId: category.id, item })), + ); + return ( +
+
+

Review files before cleanup

+

+ Sizes are estimates. ADE checks every path again after you confirm and reports anything it could not remove. +

+
+ {rows.length === 0 ? ( +
Nothing needs review right now.
+ ) : ( +
+ + + + {["Item", "Type", "Owner", "Age", "Can reclaim", "Why blocked", ""].map((label) => ( + + ))} + + + + {rows.map(({ categoryId, item }) => { + const laneId = item.laneId ?? laneIdByKey.get(baseName(item.path)); + const target = buildCleanupTarget(categoryId, item, laneIdByKey); + const reclaimFailed = item.reclaimState === "failed"; + const reclaimed = item.laneStatus === "archived" && item.bytes === 0 && !reclaimFailed; + return ( + + + + + + + + + + ); + })} + +
{label}
+ {item.label} +
{item.path}
+
{item.laneStatus === "archived" ? "Archived lane" : item.laneStatus === "orphaned" ? "Leftover worktree" : "Build output"}{item.ownership ?? "ADE-managed"}{formatAgeHours(item.ageHours)}{formatBytes(item.reclaimableBytes ?? item.bytes)}{item.blockedReasons?.join(" ") || "Ready for review"} + {reclaimed && laneId ? ( + } onClick={() => onRestore(laneId)} /> + ) : item.laneStatus === "archived" && laneId ? ( + } onClick={() => onReclaim(laneId)} /> + ) : target ? ( + } + onClick={() => onCleanup({ + title: item.laneStatus === "orphaned" ? "Remove leftover worktree" : "Remove generated files", + intro: item.laneStatus === "orphaned" + ? "This ADE-managed worktree is not owned by a lane. ADE will verify it again before removal." + : "This is generated or temporary data. ADE will verify that it is not active before removal.", + targets: [target], + })} + /> + ) : null} +
+
+ )} +
+ ); +} + // --------------------------------------------------------------------------- // Section // --------------------------------------------------------------------------- @@ -660,6 +980,13 @@ export function StorageSection() { const [safeOpen, setSafeOpen] = React.useState(false); const [compressing, setCompressing] = React.useState(false); const [maintenanceBusy, setMaintenanceBusy] = React.useState(false); + const [policy, setPolicy] = React.useState({}); + const [effectivePolicy, setEffectivePolicy] = React.useState({}); + const [policyBusy, setPolicyBusy] = React.useState(false); + const [reclaimRisk, setReclaimRisk] = React.useState(null); + const [reclaimConfirm, setReclaimConfirm] = React.useState(""); + const [discardDirtyConfirmed, setDiscardDirtyConfirmed] = React.useState(false); + const [reclaimBusy, setReclaimBusy] = React.useState(false); const [toast, setToast] = React.useState(null); const toastTimer = React.useRef | null>(null); @@ -678,10 +1005,11 @@ export function StorageSection() { else setLoading(true); setError(null); try { - const [snap, pressure, lanes] = await Promise.all([ + const [snap, pressure, lanes, config] = await Promise.all([ window.ade.storage.getSnapshot({ forceRefresh: opts.force }), window.ade.storage.getPressure().catch(() => null), window.ade.lanes?.list?.({ includeArchived: true }).catch(() => []) ?? Promise.resolve([]), + window.ade.projectConfig.get(), ]); const ids = new Map(); const archivedAt = new Map(); @@ -694,6 +1022,12 @@ export function StorageSection() { setPressureState(pressure?.state); setLaneIdByKey(ids); setArchivedAtByKey(archivedAt); + setPolicy({ + ...(config.local.laneCleanup ?? {}), + autoDeleteArchivedAfterHours: undefined, + deleteRemoteBranchOnCleanup: undefined, + }); + setEffectivePolicy(config.effective.laneCleanup ?? {}); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { @@ -749,6 +1083,67 @@ export function StorageSection() { } }, [runMaintenanceNow, maintenanceBusy, showToast, load, loadDiagnostics]); + const savePolicy = React.useCallback(async () => { + setPolicyBusy(true); + try { + const current = await window.ade.projectConfig.get(); + await window.ade.projectConfig.save({ + shared: current.shared, + local: { ...current.local, laneCleanup: policy }, + }); + showToast("Storage rules saved."); + void load({ force: true, silent: true }); + } catch (err) { + showToast(err instanceof Error ? err.message : "Could not save storage rules"); + } finally { + setPolicyBusy(false); + } + }, [load, policy, showToast]); + + const openReclaim = React.useCallback(async (laneId: string) => { + try { + const risk = await window.ade.lanes.getReclaimRisk({ laneId }); + setReclaimConfirm(""); + setDiscardDirtyConfirmed(false); + setReclaimRisk(risk); + } catch (err) { + showToast(err instanceof Error ? err.message : "Could not review this lane"); + } + }, [showToast]); + + const confirmReclaim = React.useCallback(async () => { + if (!reclaimRisk || reclaimConfirm !== "RECLAIM" || (reclaimRisk.dirty && !discardDirtyConfirmed)) return; + setReclaimBusy(true); + try { + const result = await window.ade.lanes.archiveAndReclaim({ + laneId: reclaimRisk.laneId, + confirmation: "RECLAIM", + ...(reclaimRisk.dirty && discardDirtyConfirmed ? { forceDirty: true } : {}), + }); + showToast(`Reclaimed about ${formatBytes(result.reclaimedBytes)}. The lane, branch, and chats were kept.`); + setReclaimRisk(null); + setReclaimConfirm(""); + setDiscardDirtyConfirmed(false); + void load({ force: true, silent: true }); + } catch (err) { + showToast(err instanceof Error ? err.message : "Could not reclaim this lane"); + } finally { + setReclaimBusy(false); + } + }, [discardDirtyConfirmed, load, reclaimConfirm, reclaimRisk, showToast]); + + const restoreLane = React.useCallback(async (laneId: string) => { + try { + const result = await window.ade.lanes.unarchive({ laneId }); + showToast(result.setupWarning + ? `Lane restored. Setup needs attention: ${result.setupWarning}` + : result.worktreeRecreated ? "Lane restored and its worktree was recreated." : "Lane restored."); + void load({ force: true, silent: true }); + } catch (err) { + showToast(err instanceof Error ? err.message : "Could not restore this lane"); + } + }, [load, showToast]); + const onCleaned = React.useCallback((_result: StorageCleanupResult) => { void load({ force: true, silent: true }); void loadDiagnostics(); @@ -824,6 +1219,22 @@ export function StorageSection() { onCleanSafely={safeConfig ? () => setSafeOpen(true) : null} /> + void savePolicy()} + /> + + void openReclaim(laneId)} + onRestore={(laneId) => void restoreLane(laneId)} + /> +
{CATEGORY_ORDER.map((categoryId) => { const category = byId.get(categoryId); @@ -838,6 +1249,8 @@ export function StorageSection() { laneIdByKey={laneIdByKey} archivedAtByKey={archivedAtByKey} onRequestCleanup={setCleanup} + onReclaim={(laneId) => void openReclaim(laneId)} + onRestore={(laneId) => void restoreLane(laneId)} /> ); } @@ -881,7 +1294,7 @@ export function StorageSection() {
- ADE never automatically deletes your chats, project files, or active lanes. Old backups are removed automatically once a newer one is verified. + ADE never removes lane folders, build output, or leftovers in the background. It can archive a safe idle lane, but files stay until you review and confirm cleanup.
) : null} @@ -929,6 +1342,24 @@ export function StorageSection() { onCleaned={onCleaned} /> ) : null} + + {reclaimRisk ? ( + { + if (!reclaimBusy) { + setReclaimRisk(null); + setDiscardDirtyConfirmed(false); + } + }} + onConfirm={() => void confirmReclaim()} + /> + ) : null}
); diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx new file mode 100644 index 000000000..9d3d19b61 --- /dev/null +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.test.tsx @@ -0,0 +1,58 @@ +/* @vitest-environment jsdom */ + +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render } from "@testing-library/react"; +import { StorageDialogFrame } from "./StorageCleanupDialog"; + +afterEach(cleanup); + +describe("StorageDialogFrame", () => { + it("lets only the topmost dialog close on Escape", () => { + const closeBack = vi.fn(); + const closeFront = vi.fn(); + const { rerender } = render( + <> + + + + + + + , + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(closeFront).toHaveBeenCalledTimes(1); + expect(closeBack).not.toHaveBeenCalled(); + + rerender( + + + , + ); + fireEvent.keyDown(window, { key: "Escape" }); + + expect(closeBack).toHaveBeenCalledTimes(1); + }); + + it("does not close an underlying dialog when the topmost dialog cannot close", () => { + const closeBack = vi.fn(); + const closeFront = vi.fn(); + render( + <> + + + + + + + , + ); + + fireEvent.keyDown(window, { key: "Escape" }); + + expect(closeFront).not.toHaveBeenCalled(); + expect(closeBack).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx index 365e0dc88..8647555e1 100644 --- a/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx +++ b/apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx @@ -13,14 +13,14 @@ import { dangerButton, primaryButton, } from "../../lanes/laneDesignTokens"; -import { baseName, formatBytes, maintenanceOutcome, type SafeCleanupGroup } from "./storageView"; +import { baseName, formatBytes, type SafeCleanupGroup } from "./storageView"; /** - * Optional "safe cleanup" plan. When present the dialog runs the broad - * maintenance flow (grouped preview + plain-language "what happens" + a single - * primary confirm) instead of the per-target removal flow. When `runMaintenance` - * is available it drives the daemon doctor; otherwise the dialog falls back to - * the per-target `cleanup` over `targets` (legacy runtimes). + * Optional "safe cleanup" plan. When present the dialog shows a grouped, + * plain-language review and a single primary confirmation. Filesystem targets + * are still previewed and removed through the preview-bound cleanup contract; + * `runMaintenance`, when available, handles the separate compression and + * database work after that removal. */ export type SafeCleanupPlanConfig = { groups: SafeCleanupGroup[]; @@ -59,6 +59,105 @@ const panelStyle: React.CSSProperties = { overflow: "hidden", }; +const FOCUSABLE_SELECTOR = [ + "button:not([disabled])", + "input:not([disabled])", + "select:not([disabled])", + "textarea:not([disabled])", + "a[href]", + "[tabindex]:not([tabindex='-1'])", +].join(","); + +const mountedDialogFrames: HTMLElement[] = []; + +export function StorageDialogFrame({ + title, + canClose = true, + onClose, + panelStyleOverride, + children, +}: { + title: string; + canClose?: boolean; + onClose: () => void; + panelStyleOverride?: React.CSSProperties; + children: React.ReactNode; +}) { + const dialogRef = React.useRef(null); + const returnFocusRef = React.useRef( + document.activeElement instanceof HTMLElement ? document.activeElement : null, + ); + const closeRef = React.useRef(onClose); + const canCloseRef = React.useRef(canClose); + closeRef.current = onClose; + canCloseRef.current = canClose; + + React.useEffect(() => { + const dialog = dialogRef.current; + if (dialog) mountedDialogFrames.push(dialog); + const frame = window.requestAnimationFrame(() => { + const firstFocusable = dialogRef.current?.querySelector(FOCUSABLE_SELECTOR); + (firstFocusable ?? dialogRef.current)?.focus(); + }); + const handler = (event: KeyboardEvent) => { + if (mountedDialogFrames.at(-1) !== dialogRef.current) return; + if (event.key === "Escape" && canCloseRef.current) { + event.preventDefault(); + event.stopPropagation(); + closeRef.current(); + return; + } + if (event.key !== "Tab") return; + const focusable = Array.from( + dialogRef.current?.querySelectorAll(FOCUSABLE_SELECTOR) ?? [], + ); + if (focusable.length === 0) { + event.preventDefault(); + dialogRef.current?.focus(); + return; + } + const first = focusable[0]!; + const last = focusable.at(-1)!; + const focusIsOutside = !dialogRef.current?.contains(document.activeElement); + if (event.shiftKey && (document.activeElement === first || focusIsOutside)) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && (document.activeElement === last || focusIsOutside)) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", handler, true); + return () => { + window.cancelAnimationFrame(frame); + window.removeEventListener("keydown", handler, true); + const index = dialog ? mountedDialogFrames.lastIndexOf(dialog) : -1; + if (index >= 0) mountedDialogFrames.splice(index, 1); + returnFocusRef.current?.focus(); + }; + }, []); + + return ( +
{ + if (event.target === event.currentTarget && canClose) onClose(); + }} + > +
+ {children} +
+
+ ); +} + function Row({ label, path, @@ -153,30 +252,22 @@ export function StorageCleanupDialog({ const [report, setReport] = React.useState(null); const [error, setError] = React.useState(null); - // When the maintenance doctor is available we skip the filesystem preview and - // go straight to the itemized plan; the doctor is comprehensive on its own. const maintenanceMode = Boolean(plan); - const skipPreview = Boolean(plan?.runMaintenance); // The preview is initialized once per open. We deliberately do NOT re-run when // `targets` changes identity: a successful cleanup reloads the parent snapshot, // which recomputes the (derived) safe-cleanup targets — re-running here would // reset a "done" dialog back to "review". Read the latest values via a ref. - const initRef = React.useRef({ targets, skipPreview }); - initRef.current = { targets, skipPreview }; + const initRef = React.useRef({ targets }); + initRef.current = { targets }; React.useEffect(() => { if (!open) return; let active = true; - const { targets: openTargets, skipPreview: openSkip } = initRef.current; + const { targets: openTargets } = initRef.current; setResult(null); setReport(null); setError(null); - if (openSkip) { - setPreview(null); - setStage("review"); - return; - } setStage("loading"); setPreview(null); void window.ade.storage @@ -197,45 +288,30 @@ export function StorageCleanupDialog({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [open]); - React.useEffect(() => { - if (!open) return; - const handler = (event: KeyboardEvent) => { - if (event.key === "Escape" && stage !== "removing") { - event.stopPropagation(); - onClose(); - } - }; - window.addEventListener("keydown", handler, true); - return () => window.removeEventListener("keydown", handler, true); - }, [open, stage, onClose]); - const confirm = React.useCallback(async () => { setStage("removing"); setError(null); try { - // Maintenance path: run the daemon doctor and report what it reclaimed. - if (plan?.runMaintenance) { - const nextReport = await plan.runMaintenance(); - const freedBytes = typeof nextReport?.reclaimedBytes === "number" && Number.isFinite(nextReport.reclaimedBytes) - ? nextReport.reclaimedBytes - : 0; - const synthesized: StorageCleanupResult = { removed: [], failed: [], freedBytes }; - setReport(nextReport ?? null); - setResult(synthesized); - setStage("done"); - onCleaned(synthesized); - plan.onMaintenanceDone?.(nextReport); - return; - } - // Fallback / per-target path: remove exactly what was previewed. - if (!preview || preview.items.length === 0) { + if (!preview) { setStage("review"); return; } - const next = await window.ade.storage.cleanup(targets, { preview }); + const filesystemResult = preview.items.length > 0 + ? await window.ade.storage.cleanup(targets, { preview }) + : { removed: [], failed: [], freedBytes: 0 }; + const nextReport = plan?.runMaintenance ? await plan.runMaintenance() : null; + const maintenanceBytes = typeof nextReport?.reclaimedBytes === "number" && Number.isFinite(nextReport.reclaimedBytes) + ? Math.max(0, nextReport.reclaimedBytes) + : 0; + const next: StorageCleanupResult = { + ...filesystemResult, + freedBytes: filesystemResult.freedBytes + maintenanceBytes, + }; + setReport(nextReport); setResult(next); setStage("done"); onCleaned(next); + if (nextReport) plan?.onMaintenanceDone?.(nextReport); } catch (err) { setError(err instanceof Error ? err.message : String(err)); setStage("error"); @@ -245,22 +321,15 @@ export function StorageCleanupDialog({ if (!open) return null; const removableCount = preview?.items.length ?? 0; - // In maintenance mode with the doctor available we always allow confirming - // (the estimate is daemon-provided and the primary action is gated upstream). const confirmDisabled = stage === "removing" || stage === "loading" || - (skipPreview ? false : removableCount === 0); + (removableCount === 0 && !plan?.runMaintenance); const confirmLabel = plan?.confirmLabel ?? (removableCount > 0 ? `Remove ${removableCount === 1 ? "1 item" : `${removableCount} items`}` : "Remove"); - const reportOutcome = report ? maintenanceOutcome(report) : null; + const cleanupFailed = Boolean(result?.failed.length) + || Boolean(report?.actions.some((action) => Boolean(action.error))); return ( -
{ - if (event.target === event.currentTarget && stage !== "removing") onClose(); - }} - > -
+
This will free about {formatBytes(plan.estimatedBytes)}.
+ {preview && preview.blocked.length > 0 ? ( +
+
+ + {preview.blocked.length === 1 ? "1 item will be kept" : `${preview.blocked.length} items will be kept`} +
+ {preview.blocked.map((entry) => ( + + ))} +
+ ) : null} ) : null} @@ -439,11 +519,13 @@ export function StorageCleanupDialog({ fontFamily: SANS_FONT, fontSize: 14, fontWeight: 650, - color: reportOutcome?.failed ? COLORS.danger : COLORS.success, + color: cleanupFailed ? COLORS.danger : COLORS.success, }} > - {maintenanceMode && reportOutcome - ? `${reportOutcome.message}.` + {cleanupFailed + ? result.freedBytes > 0 + ? `Freed ${formatBytes(result.freedBytes)}, but some cleanup steps couldn't finish.` + : "Some cleanup steps couldn't finish." : result.freedBytes > 0 ? `Freed ${formatBytes(result.freedBytes)}.` : "Nothing needed removing."} @@ -516,7 +598,6 @@ export function StorageCleanupDialog({ )} - - + ); } diff --git a/apps/desktop/src/renderer/components/settings/storage/storageView.test.ts b/apps/desktop/src/renderer/components/settings/storage/storageView.test.ts index 34c3d32ba..421a593f4 100644 --- a/apps/desktop/src/renderer/components/settings/storage/storageView.test.ts +++ b/apps/desktop/src/renderer/components/settings/storage/storageView.test.ts @@ -298,22 +298,24 @@ describe("buildSafeCleanupPlan", () => { it("groups compressible history, database rows, and filesystem targets", () => { const plan = buildSafeCleanupPlan({ categories, extras }, new Map()); - expect(plan.estimatedBytes).toBe(420 * MB); + expect(plan.estimatedBytes).toBe(720 * MB); expect(plan.groups.map((g) => g.heading)).toEqual([ "Chats & terminal history", "Project database", "Temporary & rebuildable files", "Obsolete recovery backups", ]); - expect(plan.fsTargets).toHaveLength(2); - expect(plan.fsBytes).toBe(500 * MB); + expect(plan.fsTargets).toHaveLength(3); + expect(plan.fsBytes).toBe(560 * MB); expect(plan.fsGroup?.rows).toHaveLength(2); expect(plan.groups.find((group) => group.heading === "Temporary & rebuildable files")?.rows) - .toEqual([{ label: "iOS build data", size: "200 MB" }]); + .toEqual([ + { label: "npm", size: "300 MB" }, + { label: "iOS build data", size: "200 MB" }, + ]); expect(plan.groups.find((group) => group.heading === "Obsolete recovery backups")?.rows) .toEqual([{ label: "Obsolete recovery backup", size: "60.0 MB" }]); expect(plan.groups.flatMap((group) => group.rows).some((row) => row.label === "Newest recovery backup")).toBe(false); - expect(plan.estimatedBytes).not.toBe(500 * MB); expect(plan.whatHappens.at(-1)).toContain("never touched"); expect(plan.whatHappens.at(-1)).toContain("newest backup is always kept"); }); diff --git a/apps/desktop/src/renderer/components/settings/storage/storageView.ts b/apps/desktop/src/renderer/components/settings/storage/storageView.ts index 800b423ef..206c5a569 100644 --- a/apps/desktop/src/renderer/components/settings/storage/storageView.ts +++ b/apps/desktop/src/renderer/components/settings/storage/storageView.ts @@ -575,33 +575,25 @@ export function buildSafeCleanupPlan( whatHappens.push("Clean up data ADE keeps but no longer needs."); } - // Filesystem-safe targets for the legacy fallback. The doctor has a narrower - // filesystem policy, so only targets it actually reaps are shown in its plan. + // Filesystem-safe targets are removed through the preview-bound cleanup API + // in every runtime. The maintenance doctor separately handles compression and + // database retention. const fsTargets: StorageCleanupTarget[] = []; const fsRows: Array<{ label: string; size: string }> = []; - const maintenanceFsRows: Array<{ label: string; size: string }> = []; let fsBytes = 0; - let maintenanceFsBytes = 0; for (const category of snapshot.categories) { if (category.id !== "caches" && category.id !== "build_release") continue; for (const entry of cleanableEntries(category.id, category, laneIdByKey)) { fsTargets.push(entry.target); fsRows.push({ label: entry.item.label, size: formatBytes(entry.item.bytes) }); fsBytes += entry.item.bytes; - const doctorDeletesTarget = entry.target.kind === "stale_tmp_staging" - || (entry.target.kind === "rebuildable_cache" - && /[\\/]\.ade[\\/]cache[\\/]ios-simulator[\\/]DerivedData[\\/]?$/.test(entry.target.path)); - if (doctorDeletesTarget) { - maintenanceFsRows.push({ label: entry.item.label, size: formatBytes(entry.item.bytes) }); - maintenanceFsBytes += entry.item.bytes; - } } } const fsGroup: SafeCleanupGroup | null = fsRows.length > 0 ? { heading: "Temporary & rebuildable files", rows: fsRows } : null; - if (maintenanceFsRows.length > 0) { - groups.push({ heading: "Temporary & rebuildable files", rows: maintenanceFsRows }); + if (fsGroup) { + groups.push(fsGroup); whatHappens.push("Remove temporary and rebuildable files ADE recreates on demand."); } @@ -624,6 +616,10 @@ export function buildSafeCleanupPlan( ); const obsoleteBackupBytes = obsoleteBackups.reduce((sum, item) => sum + item.bytes, 0); if (obsoleteBackups.length > 0) { + for (const item of obsoleteBackups) { + fsTargets.push({ kind: "recovery_backup", path: item.path }); + fsBytes += item.bytes; + } groups.push({ heading: "Obsolete recovery backups", rows: obsoleteBackups.map((item) => ({ label: item.label, size: formatBytes(item.bytes) })), @@ -633,13 +629,10 @@ export function buildSafeCleanupPlan( whatHappens.push("Your chats, projects, and active lanes are never touched, and your newest backup is always kept."); - // The daemon estimate includes every filesystem target offered by the legacy - // cleanup API. Remove targets the doctor will not touch, then add disclosed - // obsolete backups (which the daemon intentionally excludes from its CTA sum). - const estimatedBytes = Math.max( - 0, - daemonEstimatedBytes - fsBytes + maintenanceFsBytes + obsoleteBackupBytes, - ); + // The daemon estimate covers the validated staging/cache targets. Recovery + // backups are intentionally excluded there because cleanup always keeps the + // newest one, so add only the obsolete backups disclosed above. + const estimatedBytes = Math.max(0, daemonEstimatedBytes + obsoleteBackupBytes); return { fsTargets, fsBytes, groups, fsGroup, whatHappens, estimatedBytes }; } diff --git a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts index 9126e4192..8bfba030b 100644 --- a/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts +++ b/apps/desktop/src/renderer/webclient/adapter/__tests__/adapter.test.ts @@ -275,6 +275,62 @@ describe("createAdeWebAdapter", () => { adapter.dispose(); }); + it("emits the restored lane from a successful web unarchive", async () => { + fake.descriptors = descriptors(["lanes.unarchive"]); + const lane = { + id: "lane-restored", + name: "Restored lane", + branchRef: "feature/restored", + color: "#5eead4", + }; + fake.commandResults.set("lanes.unarchive", { lane, worktreeRecreated: true }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const lifecycleEvents: unknown[] = []; + adapter.ade.lanes.onLifecycleEvent((event) => lifecycleEvents.push(event)); + + await expect(adapter.ade.lanes.unarchive({ laneId: lane.id })).resolves.toEqual({ + lane, + worktreeRecreated: true, + }); + expect(lifecycleEvents).toEqual([{ + type: "lane-restored", + laneId: lane.id, + laneName: lane.name, + color: lane.color, + lane, + }]); + + adapter.dispose(); + }); + + it("emits an unarchived lane when web restore does not recreate its worktree", async () => { + fake.descriptors = descriptors(["lanes.unarchive"]); + const lane = { + id: "lane-unarchived", + name: "Unarchived lane", + branchRef: "feature/unarchived", + color: "#5eead4", + }; + fake.commandResults.set("lanes.unarchive", { lane, worktreeRecreated: false }); + const adapter = createAdeWebAdapter(fake.asClient()); + adapter.bindProject(project, "project-1"); + const lifecycleEvents: unknown[] = []; + adapter.ade.lanes.onLifecycleEvent((event) => lifecycleEvents.push(event)); + + await adapter.ade.lanes.unarchive({ laneId: lane.id }); + + expect(lifecycleEvents).toEqual([{ + type: "lane-unarchived", + laneId: lane.id, + laneName: lane.name, + color: lane.color, + lane, + }]); + + adapter.dispose(); + }); + it("bounds initial web chat hydration while preserving paged scroll-back", async () => { fake.descriptors = descriptors(["chat.getChatEventHistory"]); fake.commandResults.set("chat.getChatEventHistory", { diff --git a/apps/desktop/src/renderer/webclient/adapter/lanes.ts b/apps/desktop/src/renderer/webclient/adapter/lanes.ts index 8f5a1f770..8aad07bb2 100644 --- a/apps/desktop/src/renderer/webclient/adapter/lanes.ts +++ b/apps/desktop/src/renderer/webclient/adapter/lanes.ts @@ -1,7 +1,9 @@ import type { + ArchiveAndReclaimLaneResult, LaneLifecycleEvent, LaneListSnapshot, ResolveLaneBranchDriftResult, + RestoreLaneResult, } from "../../../shared/types"; import type { AdapterInfra, AdeNamespace } from "./types"; @@ -90,6 +92,26 @@ export function createLanesNamespace(infra: AdapterInfra): AdeNamespace<"lanes"> laneName: stringField(record, "laneName") || stringField(record, "name") || "Lane", }); }, + archiveAndReclaim: (args: unknown) => + commands.call("lanes.archiveAndReclaim", asRecord(args), { + fallback: () => { + throw new Error("Archive & Reclaim is unavailable on the connected ADE host."); + }, + idempotent: false, + }), + unarchive: async (args: unknown) => { + const result = await commands.call("lanes.unarchive", asRecord(args), { + fallback: () => { + throw new Error("Restoring a reclaimed lane is unavailable on the connected ADE host."); + }, + idempotent: false, + }); + emitLifecycle(lifecycleFromLane( + result.worktreeRecreated ? "lane-restored" : "lane-unarchived", + result.lane, + )); + return result; + }, delete: async (args: unknown) => { await call("lanes.delete", args, undefined, false); const record = asRecord(args); @@ -114,6 +136,28 @@ export function createLanesNamespace(infra: AdapterInfra): AdeNamespace<"lanes"> activeWatcherCount: 0, envInitialized: false, }), + getReclaimRisk: (args: unknown) => + call("lanes.getReclaimRisk", args, { + laneId: stringField(asRecord(args), "laneId"), + laneName: "Lane", + branchRef: null, + worktreePath: "", + dirty: false, + hasUnpushedCommits: false, + unpushedCommitCount: 0, + remoteBranchExists: false, + activeChatCount: 0, + activePtyCount: 0, + activeWatcherCount: 0, + envInitialized: false, + worktreeBytes: 0, + generatedBytes: 0, + reclaimableBytes: 0, + worktreeAvailable: false, + blockedReasons: [], + lastFailure: null, + retryCount: 0, + }), getStackChain: (laneId: string) => call("lanes.getStackChain", { laneId }, []), getChildren: (laneId: string) => call("lanes.getChildren", { laneId }, []), attachLinearIssueToSession: (args: unknown) => call("lanes.attachLinearIssueToSession", args, [], false), diff --git a/apps/desktop/src/shared/ipc.ts b/apps/desktop/src/shared/ipc.ts index dc4de3c1c..8fadb1062 100644 --- a/apps/desktop/src/shared/ipc.ts +++ b/apps/desktop/src/shared/ipc.ts @@ -141,12 +141,15 @@ export const IPC = { lanesReparent: "ade.lanes.reparent", lanesUpdateAppearance: "ade.lanes.updateAppearance", lanesArchive: "ade.lanes.archive", + lanesArchiveAndReclaim: "ade.lanes.archiveAndReclaim", + lanesUnarchive: "ade.lanes.unarchive", lanesDelete: "ade.lanes.delete", lanesDeleteCancel: "ade.lanes.delete.cancel", lanesDeleteEvent: "ade.lanes.delete.event", lanesLifecycleEvent: "ade.lanes.lifecycle.event", lanesListDeleteProgress: "ade.lanes.delete.progress.list", lanesGetDeleteRisk: "ade.lanes.delete.risk", + lanesGetReclaimRisk: "ade.lanes.reclaim.risk", lanesGetStackChain: "ade.lanes.getStackChain", lanesGetChildren: "ade.lanes.getChildren", lanesAttachLinearIssueToSession: "ade.lanes.attachLinearIssueToSession", diff --git a/apps/desktop/src/shared/types/config.ts b/apps/desktop/src/shared/types/config.ts index 497e98cff..16061e6f7 100644 --- a/apps/desktop/src/shared/types/config.ts +++ b/apps/desktop/src/shared/types/config.ts @@ -178,13 +178,15 @@ export type LaneSetupScriptConfig = { export type LaneCleanupConfig = { /** Maximum number of active (non-archived) lanes. Oldest by access time are auto-archived. 0 = unlimited. */ maxActiveLanes?: number; - /** How often (in hours) to scan for stale lanes and run cleanup. 0 = disabled. */ + /** How often (in hours) to scan for stale lanes. 0 = disabled. */ cleanupIntervalHours?: number; /** Auto-archive lanes that have been inactive for this many hours. 0 = never. */ autoArchiveAfterHours?: number; - /** Auto-delete archived lanes after this many hours. 0 = never. */ + /** Mark archived lane files ready for review after this many hours. 0 = never. */ + reclaimArchivedAfterHours?: number; + /** @deprecated Read as reclaimArchivedAfterHours for older local.yaml files. */ autoDeleteArchivedAfterHours?: number; - /** Also delete the remote branch when auto-deleting. */ + /** @deprecated Cleanup never deletes remote branches automatically. */ deleteRemoteBranchOnCleanup?: boolean; }; diff --git a/apps/desktop/src/shared/types/lanes.ts b/apps/desktop/src/shared/types/lanes.ts index eaee19b03..4c9d005f9 100644 --- a/apps/desktop/src/shared/types/lanes.ts +++ b/apps/desktop/src/shared/types/lanes.ts @@ -381,6 +381,54 @@ export type ArchiveLaneArgs = { laneId: string; }; +export type LaneReclaimBlockReason = + | "primary_lane" + | "attached_lane" + | "worktree_outside_managed_root" + | "worktree_not_registered" + | "symlink_path" + | "active_work" + | "dirty_worktree" + | "unmerged_work"; + +export type LaneReclaimRisk = LaneDeleteRisk & { + laneName: string; + worktreePath: string; + worktreeBytes: number; + generatedBytes: number; + reclaimableBytes: number; + worktreeAvailable: boolean; + blockedReasons: Array<{ + code: LaneReclaimBlockReason; + message: string; + disposition: "blocked" | "confirmation_required"; + }>; + lastFailure: string | null; + retryCount: number; +}; + +export type ArchiveAndReclaimLaneArgs = { + laneId: string; + /** Required for every reclaim so a stale or accidental action cannot remove files. */ + confirmation: "RECLAIM"; + /** Allows confirmed removal of uncommitted files. Never used by scheduled cleanup. */ + forceDirty?: boolean; +}; + +export type ArchiveAndReclaimLaneResult = { + laneId: string; + reclaimedBytes: number; + worktreeRemoved: boolean; + generatedDataRemoved: boolean; + warnings: string[]; +}; + +export type RestoreLaneResult = { + lane: LaneSummary; + worktreeRecreated: boolean; + setupWarning?: string | null; +}; + export type DeleteLaneArgs = { laneId: string; deleteBranch?: boolean; @@ -451,6 +499,8 @@ export type LaneLifecycleEvent = { | "lane-renamed" | "lane-archived" | "lane-unarchived" + | "lane-reclaimed" + | "lane-restored" | "lane-deleted"; laneId: string; laneName: string; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 282966dac..7406e31e2 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -1655,7 +1655,8 @@ export type PipelineMergeMethod = MergeMethod | "repo_default"; export type LaneWorktreeLockOwnerKind = | "conflict_resolution" | "integration_resolution" - | "git_mutation"; + | "git_mutation" + | "storage_lifecycle"; export type LaneWorktreeLockInfo = { worktreeKey: string; diff --git a/apps/desktop/src/shared/types/storage.ts b/apps/desktop/src/shared/types/storage.ts index e6502dc27..45fb30a4a 100644 --- a/apps/desktop/src/shared/types/storage.ts +++ b/apps/desktop/src/shared/types/storage.ts @@ -42,6 +42,12 @@ export type StorageItem = { safety: StorageSafety; detail?: string; laneStatus?: "active" | "archived" | "orphaned"; + ownership?: "ADE-managed" | "Project-owned" | "System temporary"; + blockedReasons?: string[]; + reclaimableBytes?: number; + ageHours?: number | null; + laneId?: string; + reclaimState?: "kept" | "ready_for_review" | "reclaimed" | "failed"; }; export type StorageCategorySnapshot = { @@ -70,6 +76,21 @@ export type StorageSnapshot = { // Optional so pre-overhaul snapshots (and the daemon-backed fallback // instance) remain valid; renderer must treat absence as "not available". extras?: StorageSnapshotExtras; + lifecycle?: StorageLifecycleSnapshot; +}; + +export type StorageLifecycleSnapshot = { + lastScanAt: string | null; + nextScanAt: string | null; + scanInProgress: boolean; + policy: { + maxActiveLanes: number; + cleanupIntervalHours: number; + autoArchiveAfterHours: number; + reclaimArchivedAfterHours: number; + }; + archivedAutomatically: number; + reviewReadyCount: number; }; export type StorageCleanupTarget = diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index bbd1ac305..63377aa87 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -56,9 +56,10 @@ Desktop fallback services (`apps/desktop/src/main/services/lanes/`): | File | Responsibility | |------|---------------| -| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. It also emits one-shot `LaneLifecycleEvent` notifications after successful create/attach/rename/archive/unarchive/delete transitions so renderer surfaces can toast completed lifecycle changes and invalidate lane-list reads without polling; `attach` rejects already-linked paths/branches with coded `lane_already_linked` errors so callers can branch on the code instead of message text. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `upsertLaneStateSnapshot` guards its `lane_state_snapshots` write with a `where` clause that only touches the row when a field actually changed (`dirty`/`ahead`/`behind`/`remote_behind`/`rebase_in_progress`, and `agent_summary_json` only when the caller passed an `agentSummary`), so a status recompute that yields identical values no longer authors a redundant CRR row — which otherwise fans an empty update out to every synced device and triggers a full mobile lane-list reload for nothing. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). **Branch drift:** `getBranchDrift({ laneId })` is the on-demand fresh read (`git symbolic-ref --quiet --short HEAD`) for callers that need an answer immediately before acting, and `resolveBranchDrift(args)` is the single entry point for both resolutions. The service object is built as a named `laneServiceApi` so drift resolution can delegate to sibling methods (`switchBranch`, rename) instead of duplicating their transaction and rollback handling. See [Branch drift](#branch-drift). | +| `laneService.ts` | Lane CRUD, worktree creation/removal, status computation, stack chain traversal, rebase runs, reparent, startup repair routines, branch switching, lane + session Linear issue linkage, and the multi-step lane teardown pipeline (`getDeleteRisk`, `delete`, `cancelDelete`) that streams `LaneDeleteProgress` events as it stops processes/PTYs/watchers, cancels auto-rebase, runs `git worktree remove` / `git branch -D` / optional `git push --delete origin`, verifies residual worktree files are gone before DB cleanup, records retryable residual-cleanup debt when manual deletion fails, and cleans the pack directory + DB rows. The lane-aware storage lifecycle adds `getReclaimRisk`, `archiveAndReclaim`, and restore-aware `unarchive`: reclaim proves that the saved path and branch are the exact worktree registered by this project, rejects symlinks, rechecks directory identity immediately before removal, records machine-local retry state, and preserves the branch, chats, lane row, and metadata. Restore reuses only that exact registered worktree or recreates a canonical managed one from the preserved branch. It also emits one-shot `LaneLifecycleEvent` notifications after successful create/attach/rename/archive/reclaim/unarchive/restore/delete transitions so renderer surfaces can toast completed lifecycle changes and invalidate lane-list reads without polling; `attach` rejects already-linked paths/branches with coded `lane_already_linked` errors so callers can branch on the code instead of message text. Lane creation is wrapped so that any failure after the worktree is on disk routes through `cleanupCreatedWorktreeLaneAfterCreateFailure`, which removes the orphaned checkout rather than leaving a worktree no lane row references. Independent deletes can progress through teardown concurrently; only the `git_worktree_remove` step enters the shared worktree-mutation guard, so lane creation is not held behind unrelated stop/cleanup steps but still avoids concurrent edits to Git's worktree registry. Deletes run to completion once started, so `cancelDelete` reports that no active delete can be cancelled. `list()` also runs the residual-worktree cleanup retry sweep before duplicate/stale worktree repair so previous delete warnings can self-heal without blocking lane row cleanup. `getSummary(laneId, { includeStatus })` is the scoped summary path used by mobile detail commands so opening a lane does not rebuild the full lane list; `refreshSnapshots` honors `includeStatus` for light runtime-bucket refreshes. `upsertLaneStateSnapshot` guards its `lane_state_snapshots` write with a `where` clause that only touches the row when a field actually changed (`dirty`/`ahead`/`behind`/`remote_behind`/`rebase_in_progress`, and `agent_summary_json` only when the caller passed an `agentSummary`), so a status recompute that yields identical values no longer authors a redundant CRR row — which otherwise fans an empty update out to every synced device and triggers a full mobile lane-list reload for nothing. `reparent` accepts an optional `stackBaseBranchRef` to pick a specific branch to stack onto (resolved in the project repo with `origin/` preferred); when both the parent link and the resolved base branch are unchanged the call short-circuits without touching git. Branch switching rolls git checkout back to the previous branch when the database update fails. **Linear issue linkage:** `linkLinearIssues` / `unlinkLinearIssues` manage lane-scoped links in `lane_linear_issue_links` (never touching the primary `lane_linear_issues` row); `attachLinearIssueToSession` / `detachLinearIssueFromSession` / `listLinearIssuesForSession` / `listLinearIssuesForLaneSessions` manage session-scoped links in `session_linear_issues`. `attachLinearIssueToSession` resolves the session's lane from `claude_sessions` / `terminal_sessions` and mirrors each issue into the lane's `chat_attach` links when a lane exists, without ever promoting the lane's primary issue. See [Linear integration](../linear-integration/README.md#session-scoped-issue-attachment-and-cli-context-injection). **Branch drift:** `getBranchDrift({ laneId })` is the on-demand fresh read (`git symbolic-ref --quiet --short HEAD`) for callers that need an answer immediately before acting, and `resolveBranchDrift(args)` is the single entry point for both resolutions. The service object is built as a named `laneServiceApi` so drift resolution can delegate to sibling methods (`switchBranch`, rename) instead of duplicating their transaction and rollback handling. See [Branch drift](#branch-drift). | | `laneBranchDrift.ts` | Pure helpers for lane branch drift (HEAD no longer on `lanes.branch_ref`). `parseWorktreeStatusPorcelainV2(stdout)` returns `{ dirty, headBranchRef }` from the `git status --porcelain=v2 --branch` output that `computeLaneStatus` already collects — header lines start with `# `, entry lines never do, so the split is unambiguous, and a detached HEAD (reported by git as the literal `(detached)`) parses to `null`. `detectLaneBranchDrift({ expectedBranchRef, headBranchRef })` returns a `LaneBranchDrift` or `null`; either side being unknown counts as no drift. `laneNameAdvertisesBranch(laneName, branchRef)` is true when the lane's display name merely restates the branch it tracks — the whole ref (`ade/fix-auth`) or its last segment (`fix-auth`) — and gates the rename that `keep-head` performs. | | `worktreeResidualCleanup.ts` | Machine-local retry worker for managed worktree directories that survive lane deletion. It stores cleanup debt in `local_worktree_residual_cleanups`, retries during `laneService.list()`, drops unsafe records, skips registered Git worktrees, active lane paths, and pending creations, removes old empty untracked directories under the managed worktrees directory, and leaves unknown non-empty directories alone unless they were explicitly recorded from the delete path. | +| `laneWorktreeLockService.ts` | Database-backed lease for any operation that mutates a lane worktree. PR conflict/integration work and storage reclaim/restore share the same lock table, so two processes cannot remove, restore, or edit the same worktree concurrently. Expired leases are swept; active blockers carry an owner label for clear UI errors. | | `autoRebaseService.ts` | Auto-rebase worker for stacked lanes, attention state, head-change handlers. Consults `resolvePrRebaseMode` to determine whether a lane with a linked PR should auto-rebase (`pr_target` strategy) or only surface manual attention (`lane_base` strategy). `listStatuses({ includeAll: true })` returns stored statuses without recomputing lane git status for PR workflow views. | | `rebaseSuggestionService.ts` | Emits rebase suggestions when a parent lane advances, dismiss/defer lifecycle. Each suggestion may include up to 20 `RebaseTargetCommit` entries showing the behind commits the rebase would pull in. | | `laneEnvironmentService.ts` | Environment init pipeline: env files, docker services, dependencies, mount points, copy paths (Phase 5 W1) | @@ -413,9 +414,24 @@ a lane parented to primary would always show zero behind. current branch. When both the parent link and the resolved base ref are unchanged, reparent short-circuits without touching git so a redundant apply is a no-op rather than a stack rebase. -7. **Archive** — `archive` sets `archived_at` and `status = 'archived'` - but keeps the worktree on disk, then emits a `lane-archived` - lifecycle event. `unarchive` reverses the database state. +7. **Archive / reclaim / restore** — `archive` sets `archived_at` and + `status = 'archived'` but keeps the worktree and generated files on disk, + then emits a `lane-archived` lifecycle event. `archiveAndReclaim` is the + separate, typed-confirmation path: it preserves the lane row, branch, + chats, and metadata while stopping lane-owned processes and removing only + the ADE-managed worktree and lane pack data. Before sizing or removing a + folder, ADE requires this project's Git worktree registry to match both the + exact saved path and expected branch. It then rechecks symlink and directory + identity immediately before deletion. It refuses primary or attached lanes, + unmanaged paths, symlinks, and dirty work unless the confirmation explicitly + includes the dirty-work override. Reclaim and restore also hold a shared + database-backed worktree lease, so another ADE process or PR workflow cannot + mutate the same folder concurrently. Failed removal is recorded as + machine-local retry state. `unarchive` restores the active state and, when + reclaimed files are missing or the database path is stale, safely recreates + a canonical managed worktree from the preserved local or remote branch. + Restore rejects occupied, linked, or differently registered paths instead + of overwriting them. 8. **Delete** — `delete({ laneId, deleteBranch?, deleteRemoteBranch?, remoteBranchName?, force? })` runs an explicit teardown pipeline and emits `lanes.delete.event` per step. Steps execute in order: @@ -638,6 +654,9 @@ Lane management (selected): | `ade.lanes.attach` | `(args: AttachLaneArgs) => LaneSummary` | | `ade.lanes.importBranch` | `(args: { branchRef: string }) => LaneSummary` | | `ade.lanes.rename` / `.updateAppearance` / `.reparent` / `.archive` / `.delete` | lane edit operations; `.delete` is also surfaced as `lane.delete` through the generic ADE action registry | +| `ade.lanes.reclaim.risk` | `(args: { laneId }) => LaneReclaimRisk` — exact ownership/safety preflight with estimated worktree and generated-data bytes, active/dirty/unmerged warnings, hard blockers, and retry state. Also surfaced as `lane.getReclaimRisk`. | +| `ade.lanes.archiveAndReclaim` | `(args: { laneId, confirmation: "RECLAIM", forceDirty? }) => ArchiveAndReclaimLaneResult` — preserves the lane/branch/chats/metadata while removing its verified managed worktree and generated lane data. Also surfaced as `lane.archiveAndReclaim`. | +| `ade.lanes.unarchive` | `(args: { laneId }) => RestoreLaneResult` — reactivates an archived lane, safely recreating its managed worktree when needed. Also surfaced as `lane.unarchive`. | | `ade.lanes.linkLinearIssues` | `(args: { laneId, issues, role?, source?, includeInPr?, closeOnMerge?, evidence? }) => LaneLinearIssueLink[]` — link one or more Linear issues to an existing lane post-creation. Also surfaced as `lane.linkLinearIssues` through the ADE action registry and the `ade lanes link-linear-issue` CLI command. | | `ade.lanes.unlinkLinearIssues` | `(args: { laneId, issueId? }) => boolean` — lane-level detach counterpart to `linkLinearIssues`. Omit `issueId` to remove every non-primary link; never touches the lane's primary issue (stored in `lane_linear_issues`). | | `ade.lanes.attachLinearIssueToSession` | `(args: { chatSessionId, issues, role?, source?, includeInPr?, closeOnMerge?, evidence? }) => SessionLinearIssueLink[]` — attach Linear issues to a chat or CLI session (works even when the session has no lane). Persists into `session_linear_issues`; when the session resolves to a lane, also mirrors each issue into `lane_linear_issue_links` (source `chat_attach`) without promoting the lane's primary issue. | @@ -647,7 +666,7 @@ Lane management (selected): | `ade.lanes.delete.risk` | `(args: { laneId }) => LaneDeleteRisk` — preflight read for the manage dialog: dirty state, unpushed commit count, remote-branch existence, active PTYs/watchers, env-init flag. | | `ade.lanes.delete.cancel` | `(args: { laneId }) => { cancelled, reason? }` — cooperative cancel during the early teardown steps. After `git_worktree_remove` starts the lane is unrecoverable and cancel is a no-op. | | `ade.lanes.delete.event` (push) | `LaneDeleteEvent` carrying `LaneDeleteProgress` — `steps[]` with per-step status (`pending` / `running` / `completed` / `failed` / `skipped`) plus `overallStatus` (`running` / `completed` / `failed` / `cancelled`) and `cancellable`. | -| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, `lane-archived`, `lane-unarchived`, or `lane-deleted` event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | +| `ade.lanes.lifecycle.event` (push) | `LaneLifecycleEvent` - one-shot `lane-created`, `lane-renamed`, `lane-archived`, `lane-reclaimed`, `lane-unarchived`, `lane-restored`, or `lane-deleted` event. Local desktop paths emit this IPC channel directly; runtime-backed paths push `lane_lifecycle_event`, and preload merges both sources behind `window.ade.lanes.onLifecycleEvent`. | | `ade.lanes.delete.progress.list` | replay of the in-memory `LaneDeleteProgress` map for currently running deletes. Completed delete results are delivered through the live event stream; a remount after completion refreshes the lane list instead of replaying historical progress. | | `ade.lanes.getBranchDrift` | `(args: { laneId: string }) => LaneBranchDrift \| null` — fresh HEAD read for callers about to act on the branch; `null` for archived lanes, an unavailable worktree, a detached HEAD, or no drift. See [Branch drift](#branch-drift). | | `ade.lanes.resolveBranchDrift` | `(args: ResolveLaneBranchDriftArgs) => ResolveLaneBranchDriftResult` — `switch-back` checks the worktree back onto the recorded branch; `keep-head` adopts the live HEAD (and renames a branch-advertising lane name) in one transaction. | diff --git a/docs/features/onboarding-and-settings/README.md b/docs/features/onboarding-and-settings/README.md index 505607ff9..07ec8a452 100644 --- a/docs/features/onboarding-and-settings/README.md +++ b/docs/features/onboarding-and-settings/README.md @@ -381,11 +381,14 @@ Renderer — settings: - `apps/desktop/src/renderer/components/settings/StorageSection.tsx` plus `settings/storage/StorageCleanupDialog.tsx`, `StorageDiagnostics.tsx`, `StorageMaintenanceJournal.tsx`, `storageView.ts`, and `storageUiConstants.ts` - — Settings > Storage. Renders the current volume-pressure state, a category - breakdown of ADE's on-disk footprint with per-category policy chips, and - per-category removable items; a "Clean up safely" primary action that runs the - storage doctor (`runMaintenanceNow`, falling back to preview-confirmed - cleanup-by-target on older daemons); a project-database breakdown card with + — Settings > Storage. Uses plain language to explain lane cleanup rules, + automatic safety checks, and the difference between Archive (files stay), + Archive & Reclaim (lane/branch/chat stay; managed files are removed), and + Delete. Its review table lists archived lanes, orphaned worktrees, + DerivedData, and build output with size, age, ownership, blocked reasons, and + reclaim estimates. Destructive actions are previewed and confirmed. The page + also renders the current volume-pressure state, category breakdown and policy + chips; a storage-doctor action; a project-database breakdown card with per-row prune/compact actions; the "Health & diagnostics" strip (`StorageDiagnostics`, anchored `#diagnostics`); and the collapsible recent-cleanups journal (`StorageMaintenanceJournal`). `storageView.ts` holds the pure @@ -761,8 +764,8 @@ changing rather than which service backs it: | Appearance | `AppearanceSection.tsx` (renders `ChatAppearancePreview`) | Theme, code-block copy-button position, chat font size, transcript density, chrome tint, shell geometry, the user-message minimap toggle, and the default-on prompt-stash bookmark visibility. Hiding the bookmark does not disable Cmd/Ctrl+S. Persisted to `localStorage` under `ade.userPreferences.v1`. | | AI Connections | `ProvidersSection.tsx`, `OAuthConnectModal.tsx` | Two groups: **Coding Agents** cards (Claude Code, Codex CLI, Cursor, Droid) and **OpenCode — Universal Model Access** (models.dev catalog freshness + Subscriptions/OAuth & Kimi, API Provider Keys incl. Moonshot AI, a searchable ~160-provider chip cloud, Local Model Servers, and Advanced custom providers/model slugs). Subscription connects run through `OAuthConnectModal`; custom providers/slugs persist to `ai.customProviders` / `ai.customModelSlugs`. When Claude is installed but unauthenticated, the shared `Login to Claude` CTA opens a primary-lane terminal running `claude auth login` and navigates to Work. Legacy `?tab=providers` lands here. | | Background Jobs | `AiFeaturesSection.tsx` | AI-powered automations: summaries, PR descriptions, commit messages, auto-naming, plus project-wide scheduled-work recovery. **Pause all scheduled work** keeps Claude wakeups, cron tasks, and loops armed while suppressing `nextWakeAt`; on resume each overdue schedule runs once before cron work returns to its normal cadence. **Active scheduled work** lists KV-backed durable jobs from every chat with per-job Cancel and an explicit unavailable/error state. Legacy `?tab=automations` lands here. Each feature row has an independent reasoning-effort override (`ReasoningEffortPicker` with `useFamilyDefaults={false}`). | -| Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and lane lifecycle policy | -| Storage | `StorageSection.tsx`, `storage/StorageCleanupDialog.tsx`, `storage/StorageDiagnostics.tsx`, `storage/StorageMaintenanceJournal.tsx`, `storage/storageView.ts` | Disk-usage dashboard: current volume pressure, ADE storage broken down by category (lanes/worktrees, chats & terminal history, caches, build & release, proof & attachments, recovery backups, database) with policy chips, a project-database breakdown, a "Clean up safely" action that runs the storage doctor, a Health & diagnostics strip, and the recent-cleanups journal — plus preview-confirmed cleanup and a manual "compress old history" action. Reads `window.ade.storage.getPressure` / `getSnapshot` and `window.ade.app.getRuntimeHealth`, and mutates through `runMaintenanceNow` / `compressNow` / `cleanupPreview` / `cleanup`. Deep links from `?tab=storage` and `?tab=disk` (via `TAB_ALIASES`); the top-bar load pill deep-links to `?tab=storage#diagnostics` (`?tab=diagnostics` also aliases here). See [Storage and recovery](../storage-and-recovery/README.md). | +| Lane Templates | `LaneTemplatesSection.tsx`, `LaneBehaviorSection.tsx` | Lane init recipes and creation/rebase behavior | +| Storage | `StorageSection.tsx`, `storage/StorageCleanupDialog.tsx`, `storage/StorageDiagnostics.tsx`, `storage/StorageMaintenanceJournal.tsx`, `storage/storageView.ts` | Disk-usage and lane-storage dashboard. Explains all cleanup rules in plain language and reviews archived lanes, orphaned worktrees, DerivedData, and build output with ownership, age, blocked reasons, and reclaim estimates. Archive & Reclaim is typed-confirmation only and explains what remains and how restore recreates the worktree. Also includes categories and policy chips, database breakdown, storage doctor, Health & diagnostics, recent cleanups, preview-confirmed generic cleanup, and manual history compression. Reads `window.ade.storage.*`, `window.ade.projectConfig.*`, `window.ade.lanes.*`, and `window.ade.app.getRuntimeHealth`. Deep links from `?tab=storage` and `?tab=disk` (via `TAB_ALIASES`); the top-bar load pill deep-links to `?tab=storage#diagnostics` (`?tab=diagnostics` also aliases here). See [Storage and recovery](../storage-and-recovery/README.md). | | Stats | `AdeUsageSection.tsx`, `ActivityModule.tsx`, `providerColors.ts` | Usage page with live Limits plus a sectioned Activity dashboard: overview stat tiles, an activity/tokens/code/clients module, and split AI-usage and GitHub-vs-local Code & PRs panels, with project/machine scope and day/week/month/year/all ranges. Fast cached local-provider, project-DB, GitHub, and cross-client activity. Deep links from `?tab=usage` and `?tab=stats` land here. | > Live provider quota windows and automation guardrails live in the top-bar Usage popup (`HeaderUsageControl.tsx` → `UsageQuotaPanel.tsx` + collapsible `BudgetCapEditor`) and Settings > Usage > Limits. The Activity tab is the retrospective cross-client dashboard. diff --git a/docs/features/storage-and-recovery/README.md b/docs/features/storage-and-recovery/README.md index 6794b7574..f321e7cf8 100644 --- a/docs/features/storage-and-recovery/README.md +++ b/docs/features/storage-and-recovery/README.md @@ -4,7 +4,7 @@ | Path | Role | |---|---| -| `apps/desktop/src/main/services/state/kvDb.ts` | Opens the project database (enabling `journal_mode = WAL` + `synchronous = NORMAL` at open), runs the interrupted-rebuild recovery pass, classifies database-open errors, creates the headroom-gated migration backup, and exports `rebuildTableInTransaction` / `recoverInterruptedTableRebuilds`. Attaches the optional `maintenance` (`DbMaintenanceApi`) handle — the prune / compact / vacuum hooks the storage doctor invokes. | +| `apps/desktop/src/main/services/state/kvDb.ts` | Opens the project database (enabling `journal_mode = WAL` + `synchronous = NORMAL` at open), runs the interrupted-rebuild recovery pass, classifies database-open errors, creates the headroom-gated migration backup, and exports `rebuildTableInTransaction` / `recoverInterruptedTableRebuilds`. Attaches the optional `maintenance` (`DbMaintenanceApi`) handle — the prune / compact / vacuum hooks the storage doctor invokes. The machine-local `local_lane_storage_state` and `local_storage_lifecycle_runs` tables retain reclaim retry/estimate and scan timing state; both are excluded from CRR sync because paths and cleanup results belong only to this checkout. | | `apps/desktop/src/main/services/state/dbMaintenanceApi.ts` | The `DbMaintenanceApi` interface consumed by the storage doctor, plus the single source of truth for the DB retention/count bounds (`INGRESS_EVENT_RETENTION_MS` = 7 days, `INGRESS_EVENT_MAX_ROWS_PER_PROJECT` = 2,000, `REVIEW_ARTIFACT_RETENTION_DAYS` = 30, `PR_SNAPSHOT_RETENTION_DAYS` = 60) imported by the ingress writer, the kvDb hooks, and the storage ledger so the policy can never drift across enforcement sites. | | `apps/desktop/src/main/services/state/durableFile.ts` | Atomic temp-write-and-rename persistence, one-generation `.lkg` JSON backup, validation, and primary/previous recovery reads. | | `apps/desktop/src/main/services/chat/agentChatService.ts` | Persists chat metadata and transcripts, records provider-pointer transitions to the bounded thread-pointer ledger, reconciles missing pointers from ledger/resume command/transcript, gates new turns on disk pressure (`canPerform("chat_turn")`), and implements explicit `recoverContinuity` modes. | @@ -22,7 +22,8 @@ | `apps/desktop/src/main/services/runtime/projectRecoveryService.ts` | Brain-independent diagnosis and ordered repair: space, ownership, database validation, migration recovery, service restart, endpoint/project verification, and chat reconciliation. | | `apps/desktop/src/main/services/storage/diskPressure.ts` | Samples all ADE storage roots, classifies pressure with recovery hysteresis, and gates write-producing operation classes via `canPerform(kind)`. Exports the `DiskPressureMonitor` type and refusal-message copy. | | `apps/desktop/src/main/services/storage/volume.ts` | `readVolumeSpace(dir)` (statfs free/total bytes) and `isNoSpaceError(err)` (ENOSPC/EDQUOT and disk-full message detection), shared by the pressure monitor and the database-open error classifier. | -| `apps/desktop/src/main/services/storage/storageInsightsService.ts` | Builds categorized storage snapshots and preview-confirmed cleanup plans without following symlinks or deleting protected state. Also runs the **storage doctor**: `runMaintenanceNow()` plus the post-boot (10 min) + daily (24 h) timers that compress history, auto-reap safe staging / obsolete backups / iOS DerivedData, and invoke the kvDb DB-maintenance hooks, writing each run to the maintenance journal and emitting one deduped `ade_feature_used` analytics event. Populates the snapshot's optional `extras` (db breakdown, journal, policy chips, safe-reclaimable total). | +| `apps/desktop/src/main/services/storage/storageInsightsService.ts` | Builds categorized storage snapshots and preview-confirmed cleanup plans without following symlinks or deleting protected state. It also runs the lane-lifecycle scan at the configured interval: safely archives excess or inactive lanes, marks old archived worktrees for review, and never removes lane files in the background. The **storage doctor** compresses history and maintains the database; filesystem candidates such as staging, backups, DerivedData, and build output remain review-first. Every run is journaled and emits one deduped `ade_feature_used` analytics event. Populates the snapshot's optional `extras` plus lifecycle policy/status and per-item ownership, age, blocked reasons, and reclaim estimates. | +| `apps/desktop/src/main/services/lanes/laneService.ts` | Owns the lane-aware `getReclaimRisk`, `archiveAndReclaim`, and restore-aware `unarchive` operations. It proves exact path-and-branch ownership against this project's Git worktree registry, rejects symlinks, rechecks directory identity before removal, shares the database-backed lane worktree lease with PR workflows, and stores retryable reclaim failures locally. | | `apps/desktop/src/main/services/storage/storageLedger.ts` | The **storage ledger** (`STORAGE_LEDGER`): the declared policy for every persistent table and directory ADE writes — its privacy class (`user_data` / `derived` / `operational`) and how it is bounded (`write_time` / `doctor` / `both` / `manual`). `LEDGER_LAYOUT_COVERAGE` maps every `ADE_LAYOUT_DEFINITIONS` directory to a ledger id (or `null` for intentionally-unmanaged config/credentials) so a coverage test fails CI if a new tracked directory ships without a declared policy. `deriveCategoryPolicyChips()` renders the Settings policy chips from the ledger. | | `apps/desktop/src/main/services/storage/storageDbBreakdown.ts` | Pure helpers turning raw `dbstat` rows into the coarse project-database breakdown (`classifyDbTable` / `mapDbBreakdown`: webhooks, sync bookkeeping, review artifacts, PR cache, core) and `deriveSyncBookkeepingAction` — which reads the journal so the sync-bookkeeping row offers "Compact now" only after a run proves compaction ran without a `has_peers` skip, and stays "waiting to compact" otherwise. | | `apps/desktop/src/main/services/storage/storageMaintenanceJournal.ts` | Read/write helpers for the storage-doctor journal — a plain rebuildable JSON file (`storage-doctor-journal.json` under `.ade/cache`, no DB/CRR) capping the last 30 runs, written via temp-file-then-rename so a crash never leaves a torn journal. | @@ -31,13 +32,13 @@ | `apps/desktop/src/renderer/components/app/ProjectRecoveryScreen.tsx` | Full-project recovery surface for typed open failures, diagnosis, repair progress, next action, and technical details. `ProjectTabHost` in `App.tsx` renders it full-viewport whenever `projectTransitionError` carries a `code` and `rootPath`. | | `apps/desktop/src/renderer/components/app/ProjectTransitionErrorAlert.tsx` | Fallback dismissible banner for project open/switch failures that lack a code/rootPath (un-coded string errors); it renders nothing once a coded error hands the surface to `ProjectRecoveryScreen`. | | `apps/desktop/src/renderer/components/chat/ChatContinuityRecoveryCard.tsx` | In-transcript choices to retry the original thread, rebuild from ADE history, or start a separate chat. `AgentChatMessageList` renders it in place of a plain notice chip when a `system_notice` event's `detail.kind` is `"continuity_recovery"`. | -| `apps/desktop/src/renderer/components/settings/StorageSection.tsx` | Storage dashboard: a "Clean up safely" primary action (runs the doctor, falls back to cleanup-by-target on older daemons), the Health & diagnostics strip, category totals with policy chips, a project-database breakdown card with per-row prune/compact actions, the recent-cleanups journal, cleanup preview/confirmation, and manual history compression. | +| `apps/desktop/src/renderer/components/settings/StorageSection.tsx` | Storage dashboard: plain-language lane cleanup rules, last/next safety-scan status, and a review table for archived lanes, orphaned worktrees, DerivedData, and build output with ownership, age, blocked reasons, and reclaim estimates. Archive & Reclaim has a typed confirmation and explains exactly what stays and what restore recreates. The page also keeps the category totals, Health & diagnostics strip, project-database breakdown, cleanup preview, recent-cleanups journal, and manual history compression. | | `apps/desktop/src/renderer/components/settings/storage/StorageDiagnostics.tsx` | The "Health & diagnostics" strip: four tiles — database size (with a journal-fed sparkline + trend arrow), background-service resident memory, slow responses in 24 h (from `getRuntimeHealth`), and last cleanup — plus the overall health chip. Deep-linked as `#diagnostics` from the top-bar load pill. | | `apps/desktop/src/renderer/components/settings/storage/StorageMaintenanceJournal.tsx` | Collapsible "Recent cleanups" panel rendering the last runs from the maintenance journal, one humanized line per action. | | `apps/desktop/src/renderer/components/settings/storage/storageUiConstants.ts` | Shared presentational constants (`STORAGE_BRAND`, `PANEL_STYLE`) for the section shell and the split-out diagnostics/journal components, so they share styling without a circular import. | | `apps/desktop/src/renderer/components/settings/storage/StorageCleanupDialog.tsx` | Preview-confirmed cleanup dialog: lists selected removable items with sizes, surfaces blocked paths and reasons, and only enables Remove once a fresh preview is in hand. Also hosts the itemized "Clean up safely" plan and its `runMaintenance` path. | | `apps/desktop/src/renderer/components/settings/storage/storageView.ts` | Pure, DOM-free presentation + policy helpers. Category metadata/order/hues, safety labels, and `buildCleanupTarget` / `cleanableEntries` / `groupLaneItems` map a snapshot item to a typed `StorageCleanupTarget`. The overhaul adds the diagnostics/maintenance view-model: `dbBreakdownRows`, `buildSafeCleanupPlan`, journal/db-size-sparkline/trend helpers, `daemonMemoryBytes`, `healthChip`, `formatSlowActions`, and `categoryPolicyChip` — each degrading to a sensible "not available" value so the UI renders against an older daemon that never sends `extras`. | -| `apps/desktop/src/shared/types/storage.ts` | Shared storage contracts: disk-pressure types, `StorageCategoryId`, `StorageSafety`, `StorageItem`/`StorageCategorySnapshot`/`StorageSnapshot`, and the `StorageCleanupTarget`/`StorageCleanupPreview`/`StorageCleanupResult` DTOs. The overhaul adds the ledger/maintenance surface: `StorageLedgerEntry`/`StoragePolicyClass`, `MaintenanceAction`/`MaintenanceRunReport`/`MaintenanceTrigger`, `DbBreakdownEntry`, `StorageSnapshotExtras` (the optional `extras` on a snapshot), and `RuntimeHealthSnapshot`. | +| `apps/desktop/src/shared/types/storage.ts` | Shared storage contracts: disk-pressure types, `StorageCategoryId`, `StorageSafety`, `StorageItem`/`StorageCategorySnapshot`/`StorageSnapshot`, and the `StorageCleanupTarget`/`StorageCleanupPreview`/`StorageCleanupResult` DTOs. `StorageItem` carries ownership, age, blocked reasons, reclaim estimate/state, and lane ownership for the review screen; `StorageLifecycleSnapshot` carries the effective four-rule policy plus last/next scan and review counts. The ledger/maintenance surface includes `StorageLedgerEntry`/`StoragePolicyClass`, `MaintenanceAction`/`MaintenanceRunReport`/`MaintenanceTrigger`, `DbBreakdownEntry`, `StorageSnapshotExtras`, and `RuntimeHealthSnapshot`. | | `apps/desktop/src/shared/types/recovery.ts` | Typed recovery contracts: the `AdeRecoveryErrorCode` union + `toAdeRecoveryErrorCode`, `AdeLastFailureReport`, `ProjectRecoveryDiagnosis`, the ordered `RepairStepId` list + `ProjectRepairReport`, and `mapKvDbOpenErrorCode`. | | `apps/desktop/src/shared/codedError.ts` | `codedError(message, code)`, `encodeCodedErrorMessage`, and the `parseCodedErrorMessage`/`stripElectronErrorWrapper`/`extractCodeFromMessage` decoders that let the renderer recover a `code` through the Electron IPC error-wrapping. Re-exported to the renderer via `apps/desktop/src/renderer/lib/codedError.ts`. | @@ -182,8 +183,8 @@ compressed transcript before reopening it for append. | Category | Contents | Default safety | |---|---|---| | Chats and history | Chat/terminal JSONL, logs, terminal snapshots | `compressible` | -| Lanes and worktrees | Active, archived, and orphaned managed worktrees | Active `protected`; archived/orphaned `review_first` | -| Build and release | ADE temp staging and iOS DerivedData | Old/rebuildable `safe_to_remove`; current staging `review_first` | +| Lanes and worktrees | Active, archived, reclaimed, and orphaned managed worktrees | Active `protected`; archived lanes use Archive & Reclaim; orphans `review_first` | +| Build and release | ADE temp staging, build output, and iOS DerivedData | `review_first` | | Caches | Rebuildable cache and update staging | `safe_to_remove`; chat session records `protected` | | Proof and attachments | Artifacts, recordings, attachments | `review_first` | | Recovery backups | Database migration/recovery backups | `review_first`, or `safe_to_remove` only when old, healthy, and unrelated to a fresh database-open failure | @@ -194,7 +195,30 @@ Safety values are contracts: `safe_to_remove` is reconstructible, requires explicit user confirmation, and `protected` is never a cleanup target. Cleanup is preview-confirmed and revalidates path, inode/metadata, size, lane ownership, age, and safety before deletion. The scanner and cleanup -validator use `lstat` and reject links or link ancestors. +validator use `lstat` and reject links or link ancestors. Archived lane +worktrees cannot enter the generic cleanup pipeline: they must use the +lane-aware Archive & Reclaim path so ADE can preserve metadata and recreate the +worktree during restore. That lane-aware path additionally requires the exact +saved path and expected branch to be registered as this project's Git +worktree, rechecks symlink and directory identity immediately before removal, +and holds the same database-backed worktree lease used by PR workflows. + +### Lane lifecycle rules + +Settings > Storage owns the four project cleanup rules: + +- maximum active lanes; +- archive inactive lanes after a configured age; +- run the safety scan at a configured interval; +- flag archived worktrees for reclaim review after a configured age. + +The backend, not the renderer, enforces these settings. The daemon checks once +per minute whether the configured interval has elapsed; `0` disables scheduled +scans. A scheduled scan can archive only a clean, merged/pushed, unattached +managed lane with no running chat, PTY, watcher, protected edit operation, or +linked pull-request group. Blocked lanes remain active. The retention rule +never deletes files: it marks the archived lane `ready_for_review` so a person +can inspect the estimate and explicitly confirm Archive & Reclaim. ### History compression @@ -220,17 +244,14 @@ button). Only the real daemon instance — the one constructed with both fallback instance never schedules maintenance and only acts if `runMaintenanceNow` is called directly. -Each run is a fixed sequence of independently try/caught steps (one failing step -never aborts the run), reusing the same validate/preview/cleanup pipeline the -manual flow uses so nothing outside the safe set is ever removed: - -1. Compress inactive chat/terminal history (`fs.transcripts`). -2. Reap stale `safe_to_remove` `ade-*` staging in the system temp root - (`fs.tmp_staging`) and direct children of the project-relative `.ade/tmp` - release-staging dir (`fs.tmp`). -3. Reap obsolete recovery backups (`fs.recovery_backups`), always sparing the - single newest good copy, and the iOS simulator DerivedData cache - (`fs.ios_derived_data`). +Each run is a fixed sequence of independently try/caught steps, so one failing +step never aborts the run. Filesystem removal stays outside the automatic +sweep: generic files remain preview-confirmed, and archived lane worktrees must +go through the lane-aware typed-confirmation path. + +1. Run the lane lifecycle safety scan. +2. Compress inactive chat/terminal history (`fs.transcripts`). +3. Record filesystem review candidates without deleting them. 4. Invoke the kvDb DB-maintenance hooks: prune `automation_ingress_events`, `review_run_artifacts`, and `pull_request_snapshots`; compact cr-sqlite sync bookkeeping; and vacuum when the freelist is fragmented. diff --git a/docs/logging.md b/docs/logging.md index 0c5202471..af30db409 100644 --- a/docs/logging.md +++ b/docs/logging.md @@ -108,6 +108,15 @@ identifier. Reads, menu opens, restores, and deletes are not product events. The existing `ade_feature_used` limits cap this at 30 accepted events per minute and 140 per UTC day without raising the shared 200-event ceiling. +Lane “Archive & Reclaim” records the existing coarse `ade_feature_used` +mutation fact with `feature: "lanes"` and +`action: "lanes.archiveAndReclaim"` through the same durable `usage_events` +ledger. +It records only the successful user action—not lane names, paths, sizes, +blocked reasons, retries, or scheduled review scans. The existing +`ade_feature_used` limits cap it at 30 accepted events per minute and 140 per +UTC day without raising the shared 200-event ceiling. + ### Native iOS Native UI analytics lives in `apps/ios/ADE/Services/ProductAnalytics.swift`. It uses a separate anonymous installation identity and separate `ade_mobile_*` event namespace so phone engagement cannot inflate desktop activation or retention.