diff --git a/CHANGELOG.md b/CHANGELOG.md index 506e4462c..1779b0b31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Added +- Added `xcodebuildmcp purge` to report and explicitly clean XcodeBuildMCP-managed workspace storage, including opt-in DerivedData cleanup with dry-run and confirmation safeguards. - Added `extraArgs` as a first-class session-default value. Repo config or runtime defaults can now carry common `xcodebuild` flags (for example `-skipPackagePluginValidation` or `-disableAutomaticPackageResolution`) so they don't need repeating on every build or test call. Per-call `extraArgs` replace matching configured flags or build settings and append after non-matching defaults, while an explicit empty array (`extraArgs: []`) clears the defaults for a single call. The session management tools show, set, sync, and clear `extraArgs` alongside the other defaults. ## [2.6.2] diff --git a/config.example.yaml b/config.example.yaml index a1b517bf8..94af77fb7 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -22,7 +22,7 @@ sessionDefaults: useLatestOS: true arch: 'arm64' suppressWarnings: false - derivedDataPath: './.derivedData' + derivedDataPath: './.derivedData' # optional; overrides isolated XcodeBuildMCP-managed DerivedData preferXcodebuild: false platform: 'iOS' bundleId: 'io.sentry.myapp' diff --git a/src/cli/commands/__tests__/purge-interactive.test.ts b/src/cli/commands/__tests__/purge-interactive.test.ts new file mode 100644 index 000000000..34da97988 --- /dev/null +++ b/src/cli/commands/__tests__/purge-interactive.test.ts @@ -0,0 +1,209 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import type { Prompter } from '../../interactive/prompts.ts'; +import { + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt, + purgeInteractiveWorkspacePrompt, +} from '../purge-interactive.ts'; +import { runPurgeCommand } from '../purge.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../../../utils/log-paths.ts'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.UTC(2026, 5, 8, 12); +const currentWorkspaceKey = 'DemoApp-123456789abc'; + +let appDir: string; + +function managedLogName(name = 'build_sim'): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const mtime = new Date(mtimeMs); + utimesSync(filePath, mtime, mtime); +} + +function captureOutput(): { chunks: string[]; write: (text: string) => void } { + const chunks: string[] = []; + return { + chunks, + write: (text) => { + chunks.push(text); + }, + }; +} + +describe('purge interactive command', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-interactive-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + }); + + it('groups purgeable unknown workspace keys under one Unknown workspaces row', async () => { + const known = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unknownOne = getWorkspaceFilesystemLayout('20260524T184215Z'); + const unknownTwo = getWorkspaceFilesystemLayout('20260524T204326Z-49434c797e95'); + writeFileWithMtime(path.join(known.logs, managedLogName('known')), 'known', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(unknownOne.logs, managedLogName('unknown-one')), + 'one', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unknownTwo.logs, managedLogName('unknown-two')), + 'two', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + const rootLabels: string[] = []; + const prompter: Prompter = { + selectOne: async (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootLabels.push(...opts.options.map((option) => option.label ?? '')); + return opts.options.find((option) => option.value === 'cancel')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(rootLabels.some((label) => label.startsWith('› Unknown workspaces - '))).toBe(true); + expect(rootLabels.some((label) => label.startsWith('20260524T184215Z - '))).toBe(false); + expect(rootLabels.some((label) => label.startsWith('20260524T204326Z-49434c797e95 - '))).toBe( + false, + ); + expect(output.chunks.join('')).toContain('Purgeable:'); + }); + + it('reports interactive overview counts from visible purgeable groups only', async () => { + const purgeable = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const hidden = getWorkspaceFilesystemLayout('HiddenApp-abcdefabcdef'); + writeFileWithMtime( + path.join(purgeable.logs, managedLogName('known')), + 'known', + now - 10 * DAY_MS, + ); + mkdirSync(hidden.filesystemLifecycle.lockDir, { recursive: true }); + writeFileWithMtime(hidden.filesystemLifecycle.markerPath, 'marker', now - 10 * DAY_MS); + const output = captureOutput(); + const prompter: Prompter = { + selectOne: async (opts: { options: Array<{ value: T }> }) => + opts.options.find((option) => option.value === 'cancel')!.value, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('1 projects / 1 workspaces'); + expect(output.chunks.join('')).not.toContain('2 workspaces'); + }); + + it('opens the workspace class menu for a single-workspace project', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('known')); + const derivedDataPath = path.join(layout.derivedData, 'DemoApp-a'); + writeFileWithMtime(logPath, 'known', now - 10 * DAY_MS); + writeFileWithMtime(derivedDataPath, 'derived', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let rootVisits = 0; + let workspaceVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + messages.push(opts.message); + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootVisits += 1; + if (rootVisits > 1) + return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find((option) => option.label?.startsWith('› DemoApp - '))!.value; + } + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { + workspaceVisits += 1; + if (workspaceVisits > 1) + return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find((option) => option.value === 'logs')!.value; + } + return opts.options.find((option) => option.value === 'cancel')!.value; + }, + selectMany: async () => [], + confirm: async (opts) => { + messages.push(opts.message); + return true; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(messages).toContain(PURGE_INTERACTIVE_ROOT_PROMPT); + expect(messages).toContain(purgeInteractiveWorkspacePrompt(currentWorkspaceKey)); + expect(messages.some((message) => /^Delete .*\?$/u.test(message))).toBe(true); + expect(messages).not.toContain(purgeInteractiveProjectPrompt('DemoApp')); + expect(output.chunks.join('')).toContain('Deleted 1 item;'); + expect(existsSync(logPath)).toBe(false); + expect(existsSync(derivedDataPath)).toBe(true); + }); + + it('omits global destructive actions from the root menu', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + writeFileWithMtime(path.join(layout.logs, managedLogName('known')), 'known', now - 10 * DAY_MS); + const output = captureOutput(); + const rootLabels: string[] = []; + const prompter: Prompter = { + selectOne: async (opts: { + message: string; + options: Array<{ value: T; label?: string }>; + }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootLabels.push(...opts.options.map((option) => option.label ?? '')); + return opts.options.find((option) => option.value === 'cancel')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(rootLabels).toContain('Cancel'); + expect(rootLabels.some((label) => label.startsWith('› DemoApp - '))).toBe(true); + expect(rootLabels).not.toContain('Delete all projects'); + expect(rootLabels).not.toContain('Select multiple projects to delete'); + expect(rootLabels).not.toContain('Delete all DerivedData folders'); + }); +}); diff --git a/src/cli/commands/__tests__/purge-report-scope.test.ts b/src/cli/commands/__tests__/purge-report-scope.test.ts new file mode 100644 index 000000000..afc585e4d --- /dev/null +++ b/src/cli/commands/__tests__/purge-report-scope.test.ts @@ -0,0 +1,81 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { runPurgeCommand } from '../purge.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../../../utils/log-paths.ts'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.UTC(2026, 5, 8, 12); +const currentWorkspaceKey = 'DemoApp-123456789abc'; + +let appDir: string; + +function managedLogName(name: string): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const mtime = new Date(mtimeMs); + utimesSync(filePath, mtime, mtime); +} + +function captureOutput(): { chunks: string[]; write: (text: string) => void } { + const chunks: string[] = []; + return { + chunks, + write: (text) => { + chunks.push(text); + }, + }; +} + +describe('purge report scope', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-report-scope-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + }); + + it('keeps explicit report defaults aligned with the current workspace planning scope', async () => { + const current = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime( + path.join(current.logs, managedLogName('current')), + 'current', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { report: true, json: true }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + selectedScope: { type: string; workspaceKey?: string }; + totals: { bytes: number }; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.selectedScope).toEqual({ type: 'workspace', workspaceKey: currentWorkspaceKey }); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + expect(parsed.totals.bytes).toBe(Buffer.byteLength('current')); + }); +}); diff --git a/src/cli/commands/__tests__/purge-ui.test.ts b/src/cli/commands/__tests__/purge-ui.test.ts new file mode 100644 index 000000000..ac29e64de --- /dev/null +++ b/src/cli/commands/__tests__/purge-ui.test.ts @@ -0,0 +1,84 @@ +import * as path from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { executionToJson, planToJson } from '../purge-ui.ts'; +import type { + PurgeStorageExecutionResult, + PurgeStoragePlan, + PurgeStorageReport, +} from '../../../utils/purge-storage.ts'; + +function minimalReport(): PurgeStorageReport { + return { + appRoot: process.cwd(), + workspacesDir: path.join(process.cwd(), 'tmp', 'workspaces'), + workspaces: [], + families: [], + totals: { bytes: 0, fileCount: 0, directoryCount: 0 }, + warnings: [], + }; +} + +describe('purge UI JSON', () => { + it('sanitizes skipped reasons in plan JSON', () => { + const rawPath = path.join(process.cwd(), 'tmp', 'purge-json', 'active.log'); + const displayFriendlyPath = path.join('tmp', 'purge-json', 'active.log'); + const plan: PurgeStoragePlan = { + action: 'dry-run', + scope: { type: 'workspace', workspaceKey: 'DemoApp-123456789abc' }, + classes: ['logs'], + report: minimalReport(), + selectedWorkspaceKeys: ['DemoApp-123456789abc'], + candidates: [], + skipped: [ + { + workspaceKey: 'DemoApp-123456789abc', + storageClass: 'logs', + path: rawPath, + reason: `refused to delete ${rawPath}`, + }, + ], + totals: { bytes: 0, fileCount: 0, directoryCount: 0, candidateCount: 0 }, + warnings: [], + }; + + const output = planToJson(plan) as { skipped: Array<{ path: string; reason: string }> }; + + expect(JSON.stringify(output)).not.toContain(rawPath); + expect(output.skipped[0]).toMatchObject({ + path: displayFriendlyPath, + reason: `refused to delete ${displayFriendlyPath}`, + }); + }); + + it('sanitizes skipped reasons in execution JSON', () => { + const rawPath = path.join(process.cwd(), 'tmp', 'purge-json', 'active.log'); + const displayFriendlyPath = path.join('tmp', 'purge-json', 'active.log'); + const result: PurgeStorageExecutionResult = { + action: 'delete', + scope: { type: 'workspace', workspaceKey: 'DemoApp-123456789abc' }, + classes: ['logs'], + selectedWorkspaceKeys: ['DemoApp-123456789abc'], + deleted: [], + skipped: [ + { + workspaceKey: 'DemoApp-123456789abc', + storageClass: 'logs', + path: rawPath, + reason: `refused to delete ${rawPath}`, + }, + ], + totals: { bytes: 0, deletedCount: 0, skippedCount: 1 }, + warnings: [], + }; + + const output = executionToJson(result) as { + skipped: Array<{ path: string; reason: string }>; + }; + + expect(JSON.stringify(output)).not.toContain(rawPath); + expect(output.skipped[0]).toMatchObject({ + path: displayFriendlyPath, + reason: `refused to delete ${displayFriendlyPath}`, + }); + }); +}); diff --git a/src/cli/commands/__tests__/purge.test.ts b/src/cli/commands/__tests__/purge.test.ts new file mode 100644 index 000000000..f82d56a1c --- /dev/null +++ b/src/cli/commands/__tests__/purge.test.ts @@ -0,0 +1,667 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, utimesSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + PromptCancelledError, + PromptInterruptedError, + type Prompter, +} from '../../interactive/prompts.ts'; +import { + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt, + purgeInteractiveWorkspacePrompt, +} from '../purge-interactive.ts'; +import { runPurgeCommand } from '../purge.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../../../utils/log-paths.ts'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const now = Date.UTC(2026, 5, 8, 12); +const currentWorkspaceKey = 'DemoApp-123456789abc'; + +let appDir: string; + +function managedLogName(name = 'build_sim'): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const mtime = new Date(mtimeMs); + utimesSync(filePath, mtime, mtime); +} + +function captureOutput(): { chunks: string[]; write: (text: string) => void } { + const chunks: string[] = []; + return { + chunks, + write: (text) => { + chunks.push(text); + }, + }; +} + +describe('purge command', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-cli-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(() => { + setXcodeBuildMCPAppDirOverrideForTests(null); + rmSync(appDir, { recursive: true, force: true }); + }); + + it('defaults to report mode when not running in a TTY', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await runPurgeCommand({}, { currentWorkspaceKey, isTTY: false, now, write: output.write }); + + expect(output.chunks.join('')).toContain('XcodeBuildMCP storage report'); + expect(output.chunks.join('')).toContain(currentWorkspaceKey); + expect(existsSync(logPath)).toBe(true); + }); + + it('prints deterministic JSON report output for the current workspace by default', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime(path.join(layout.logs, managedLogName('old')), 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { json: true }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + action: string; + deletionHappened: boolean; + selectedScope: { type: string; workspaceKey?: string }; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.action).toBe('report'); + expect(parsed.deletionHappened).toBe(false); + expect(parsed.selectedScope).toEqual({ type: 'workspace', workspaceKey: currentWorkspaceKey }); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + }); + + it('reports all workspaces when all scope is explicit', async () => { + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const target = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime( + path.join(target.logs, managedLogName('target')), + 'target', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { report: true, scope: 'all', json: true }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + selectedScope: { type: string }; + workspaces: Array<{ workspaceKey: string }>; + }; + expect(parsed.selectedScope).toEqual({ type: 'all' }); + expect(parsed.workspaces.map((workspace) => workspace.workspaceKey).sort()).toEqual([ + currentWorkspaceKey, + unrelatedWorkspaceKey, + ]); + }); + + it('dry-runs selected purge candidates without deleting them', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await runPurgeCommand( + { dryRun: true, scope: 'all', classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('Purge dry run'); + expect(output.chunks.join('')).toContain('Candidates: 1'); + expect(existsSync(logPath)).toBe(true); + }); + + it('scopes dry-run enumeration to the requested workspace', async () => { + const unrelatedWorkspaceKey = 'OtherApp-abcdefabcdef'; + const target = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const unrelated = getWorkspaceFilesystemLayout(unrelatedWorkspaceKey); + writeFileWithMtime( + path.join(target.logs, managedLogName('target')), + 'target', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unrelated.logs, managedLogName('unrelated')), + 'unrelated', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + + await runPurgeCommand( + { + dryRun: true, + scope: 'workspace', + workspaceKey: currentWorkspaceKey, + classes: 'logs', + json: true, + }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ); + + const parsed = JSON.parse(output.chunks.join('')) as { + selectedWorkspaceKeys: string[]; + candidates: Array<{ workspaceKey: string }>; + report: { workspaces: Array<{ workspaceKey: string }> }; + }; + expect(parsed.selectedWorkspaceKeys).toEqual([currentWorkspaceKey]); + expect(parsed.candidates.map((candidate) => candidate.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + expect(parsed.report.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + currentWorkspaceKey, + ]); + }); + + it('rejects non-interactive delete without the exact confirmation phrase', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + + await expect( + runPurgeCommand( + { delete: true, scope: 'all', classes: 'logs', confirm: 'delete' }, + { currentWorkspaceKey, isTTY: false, now, write: output.write }, + ), + ).rejects.toThrow('Destructive purge requires --confirm delete-xcodebuildmcp-storage'); + expect(existsSync(logPath)).toBe(true); + }); + + it('requires an explicit non-interactive mode when TTY purge flags are supplied', async () => { + await expect( + runPurgeCommand( + { classes: 'logs' }, + { currentWorkspaceKey, isTTY: true, now, write: captureOutput().write }, + ), + ).rejects.toThrow( + 'Purge flags require an explicit mode: --report, --dry-run, or --delete. Supplied: --classes.', + ); + }); + + it('rejects planning-only flags in report mode', async () => { + await expect( + runPurgeCommand( + { report: true, classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--report cannot be combined with --classes.'); + + await expect( + runPurgeCommand( + { report: true, olderThan: '7d' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--report cannot be combined with --older-than.'); + }); + + it('rejects delete confirmation in dry-run mode', async () => { + await expect( + runPurgeCommand( + { dryRun: true, classes: 'logs', confirm: 'delete-xcodebuildmcp-storage' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--dry-run cannot be combined with --confirm.'); + }); + + it('rejects blank workspace and family scope values', async () => { + await expect( + runPurgeCommand( + { dryRun: true, workspaceKey: '', classes: 'logs' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--workspace-key must not be empty.'); + + await expect( + runPurgeCommand( + { report: true, family: ' ' }, + { currentWorkspaceKey, isTTY: false, now, write: captureOutput().write }, + ), + ).rejects.toThrow('--family must not be empty.'); + }); + + it('runs the interactive project to workspace to folder flow for one workspace', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const thirdWorkspaceKey = 'DemoApp-bbbbbbbbbbbb'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const third = getWorkspaceFilesystemLayout(thirdWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + const thirdLog = path.join(third.logs, managedLogName('third')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + writeFileWithMtime(thirdLog, 'third', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let deleted = false; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find((option) => option.value === 'logs')!.value; + } + return opts.options[0].value; + }, + selectMany: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + return opts.options + .filter((option) => option.value === 'logs') + .map((option) => option.value); + }, + confirm: async (opts) => { + messages.push(opts.message); + deleted = true; + return true; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(messages).toContain(PURGE_INTERACTIVE_ROOT_PROMPT); + expect(messages).toContain(purgeInteractiveProjectPrompt('DemoApp')); + expect(messages).toContain(purgeInteractiveWorkspacePrompt(currentWorkspaceKey)); + expect(messages).not.toContain('Select project'); + expect(messages).not.toContain('Select workspace in DemoApp'); + expect(messages).not.toContain(`Select folders in ${currentWorkspaceKey}`); + expect(text).toContain('Ready to delete'); + expect(text).not.toContain('Warnings:'); + expect(existsSync(firstLog)).toBe(false); + expect(existsSync(secondLog)).toBe(true); + expect(existsSync(thirdLog)).toBe(true); + }); + + it('runs the interactive workspace DerivedData action without deleting logs', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstDerivedData = path.join(first.derivedData, 'DemoApp-a'); + const secondDerivedData = path.join(second.derivedData, 'DemoApp-b'); + const logPath = path.join(first.logs, managedLogName('old')); + writeFileWithMtime(firstDerivedData, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondDerivedData, 'second', now - 10 * DAY_MS); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + let deleted = false; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + if (deleted) return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { + if (deleted) return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find((option) => option.value === 'derivedData')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => { + deleted = true; + return true; + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(text).toContain('Includes DerivedData.'); + expect(existsSync(firstDerivedData)).toBe(false); + expect(existsSync(secondDerivedData)).toBe(true); + expect(existsSync(logPath)).toBe(true); + }); + + it('backs out of nested interactive menus without deleting storage', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + const output = captureOutput(); + const messages: string[] = []; + let rootVisits = 0; + let projectVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + messages.push(opts.message); + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootVisits += 1; + if (rootVisits > 1) + return opts.options.find((option) => option.value === 'cancel')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + projectVisits += 1; + if (projectVisits > 1) + return opts.options.find((option) => option.value === 'back')!.value; + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'workspaceKey' in option.value && + option.value.workspaceKey === currentWorkspaceKey, + )!.value; + } + if (opts.message === purgeInteractiveWorkspacePrompt(currentWorkspaceKey)) { + return opts.options.find((option) => option.value === 'back')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => { + throw new Error('selectMany should not be reached'); + }, + confirm: async () => { + throw new Error('confirm should not be reached'); + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(messages).toEqual([ + PURGE_INTERACTIVE_ROOT_PROMPT, + purgeInteractiveProjectPrompt('DemoApp'), + purgeInteractiveWorkspacePrompt(currentWorkspaceKey), + purgeInteractiveProjectPrompt('DemoApp'), + PURGE_INTERACTIVE_ROOT_PROMPT, + ]); + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(firstLog)).toBe(true); + expect(existsSync(secondLog)).toBe(true); + }); + + it('treats prompt cancellation as back in nested menus and cancel at the root', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(second.logs, managedLogName('second')), + 'second', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootVisits += 1; + if (rootVisits > 1) throw new PromptCancelledError(); + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + throw new PromptCancelledError(); + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(logPath)).toBe(true); + }); + + it('quits cleanly when an interactive prompt is interrupted from a nested menu', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + writeFileWithMtime( + path.join(second.logs, managedLogName('second')), + 'second', + now - 10 * DAY_MS, + ); + const output = captureOutput(); + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + throw new PromptInterruptedError(); + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(output.chunks.join('')).toContain('No storage deleted.'); + expect(existsSync(logPath)).toBe(true); + }); + + it('hides workspaces that only contain lock and lifecycle marker files in interactive mode', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + mkdirSync(layout.filesystemLifecycle.lockDir, { recursive: true }); + writeFileWithMtime(layout.filesystemLifecycle.markerPath, 'cleanup-marker', now - 10 * DAY_MS); + const output = captureOutput(); + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + const demoApp = opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + ); + expect(demoApp).toBeUndefined(); + return opts.options.find((option) => option.value === 'cancel')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(text).toContain('0 projects / 0 workspaces'); + expect(text).not.toContain('Largest projects:'); + }); + + it('does not confirm an interactive delete when selected storage disappears before planning', async () => { + const layout = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const logPath = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(logPath, 'old', now - 10 * DAY_MS); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootVisits += 1; + if (rootVisits === 1) { + rmSync(logPath, { force: true }); + return opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + )!.value; + } + return opts.options.find((option) => option.value === 'cancel')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => { + throw new Error('confirm should not be reached for an empty purge plan'); + }, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + const text = output.chunks.join(''); + expect(rootVisits).toBe(2); + expect(text).toContain('No matching purgeable storage found for that selection.'); + expect(text).not.toContain('Ready to delete'); + expect(text).not.toContain('Delete 0 B?'); + }); + + it('refreshes interactive project options after deleting a project', async () => { + const first = getWorkspaceFilesystemLayout(currentWorkspaceKey); + const secondWorkspaceKey = 'DemoApp-abcdefabcdef'; + const second = getWorkspaceFilesystemLayout(secondWorkspaceKey); + const firstLog = path.join(first.logs, managedLogName('first')); + const secondLog = path.join(second.logs, managedLogName('second')); + writeFileWithMtime(firstLog, 'first', now - 10 * DAY_MS); + writeFileWithMtime(secondLog, 'second', now - 10 * DAY_MS); + const output = captureOutput(); + let rootVisits = 0; + const prompter: Prompter = { + selectOne: async (opts: { message: string; options: Array<{ value: T }> }) => { + if (opts.message === PURGE_INTERACTIVE_ROOT_PROMPT) { + rootVisits += 1; + const demoApp = opts.options.find( + (option) => + typeof option.value === 'object' && + option.value !== null && + 'name' in option.value && + option.value.name === 'DemoApp', + ); + if (rootVisits === 1) { + return demoApp!.value; + } + expect(demoApp).toBeUndefined(); + return opts.options.find((option) => option.value === 'cancel')!.value; + } + if (opts.message === purgeInteractiveProjectPrompt('DemoApp')) { + return opts.options.find((option) => option.value === 'deleteAllWorkspaces')!.value; + } + return opts.options[0].value; + }, + selectMany: async () => [], + confirm: async () => true, + }; + + await runPurgeCommand( + {}, + { currentWorkspaceKey, prompter, isTTY: true, now, write: output.write }, + ); + + expect(rootVisits).toBe(2); + expect(existsSync(firstLog)).toBe(false); + expect(existsSync(secondLog)).toBe(false); + }); +}); diff --git a/src/cli/commands/purge-interactive-model.ts b/src/cli/commands/purge-interactive-model.ts new file mode 100644 index 000000000..513908726 --- /dev/null +++ b/src/cli/commands/purge-interactive-model.ts @@ -0,0 +1,146 @@ +import { + PURGE_STORAGE_DELETABLE_CLASSES, + enumeratePurgeStorage, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStorageReport, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; + +export interface WorkspaceGroupSummary { + id: string; + name: string; + workspaces: PurgeWorkspaceSummary[]; + bytes: number; + recognized: boolean; +} + +const UNKNOWN_WORKSPACES_GROUP_ID = 'unknown-workspaces'; +const UNKNOWN_WORKSPACES_GROUP_NAME = 'Unknown workspaces'; +const TIMESTAMP_FALLBACK_FAMILY_PATTERN = /^\d{8}T\d{6}Z$/u; + +function isRecognizedProjectWorkspace(workspace: PurgeWorkspaceSummary): boolean { + return ( + workspace.recognized && + workspace.family !== null && + !TIMESTAMP_FALLBACK_FAMILY_PATTERN.test(workspace.family) + ); +} + +export interface PurgeInteractiveState { + report: PurgeStorageReport; + groups: WorkspaceGroupSummary[]; + purgeableBytesByWorkspace: Map; + purgeableBytesByWorkspaceClass: Map>; +} + +export function workspacePurgeableBytes( + workspace: PurgeWorkspaceSummary, + purgeableBytesByWorkspace: ReadonlyMap, +): number { + return purgeableBytesByWorkspace.get(workspace.workspaceKey) ?? 0; +} + +export function workspaceClassPurgeableBytes( + workspace: PurgeWorkspaceSummary, + storageClass: PurgeStorageDeletableClass, + purgeableBytesByWorkspaceClass: ReadonlyMap< + string, + ReadonlyMap + >, +): number { + return purgeableBytesByWorkspaceClass.get(workspace.workspaceKey)?.get(storageClass) ?? 0; +} + +async function calculatePurgeableBytesByWorkspace( + report: PurgeStorageReport, + now: number, +): Promise<{ + bytesByWorkspace: Map; + bytesByWorkspaceClass: Map>; +}> { + const plan = await planPurgeStorage({ + report, + scope: { + type: 'workspaces', + workspaceKeys: report.workspaces.map((workspace) => workspace.workspaceKey), + }, + classes: [...PURGE_STORAGE_DELETABLE_CLASSES], + now, + derivedDataExplicit: true, + }); + const bytesByWorkspace = new Map(); + const bytesByWorkspaceClass = new Map>(); + for (const candidate of plan.candidates) { + bytesByWorkspace.set( + candidate.workspaceKey, + (bytesByWorkspace.get(candidate.workspaceKey) ?? 0) + candidate.bytes, + ); + const classBytes = + bytesByWorkspaceClass.get(candidate.workspaceKey) ?? + new Map(); + classBytes.set( + candidate.storageClass, + (classBytes.get(candidate.storageClass) ?? 0) + candidate.bytes, + ); + bytesByWorkspaceClass.set(candidate.workspaceKey, classBytes); + } + return { bytesByWorkspace, bytesByWorkspaceClass }; +} + +export function groupWorkspaces( + workspaces: PurgeWorkspaceSummary[], + purgeableBytesByWorkspace: ReadonlyMap, +): WorkspaceGroupSummary[] { + const grouped = new Map(); + for (const workspace of workspaces) { + const bytes = workspacePurgeableBytes(workspace, purgeableBytesByWorkspace); + if (bytes <= 0) { + continue; + } + const recognizedProject = isRecognizedProjectWorkspace(workspace); + const groupId = recognizedProject ? `project:${workspace.family}` : UNKNOWN_WORKSPACES_GROUP_ID; + const group = grouped.get(groupId) ?? { + id: groupId, + name: recognizedProject ? workspace.family! : UNKNOWN_WORKSPACES_GROUP_NAME, + workspaces: [], + bytes: 0, + recognized: recognizedProject, + }; + group.workspaces.push(workspace); + group.bytes += bytes; + grouped.set(groupId, group); + } + + return Array.from(grouped.values()) + .map((group) => ({ + ...group, + workspaces: [...group.workspaces].sort( + (left, right) => + workspacePurgeableBytes(right, purgeableBytesByWorkspace) - + workspacePurgeableBytes(left, purgeableBytesByWorkspace), + ), + })) + .sort((left, right) => right.bytes - left.bytes || left.name.localeCompare(right.name)); +} + +export async function loadInteractiveState(now: number): Promise { + const report = await enumeratePurgeStorage({ now }); + const { bytesByWorkspace, bytesByWorkspaceClass } = await calculatePurgeableBytesByWorkspace( + report, + now, + ); + return { + report, + groups: groupWorkspaces(report.workspaces, bytesByWorkspace), + purgeableBytesByWorkspace: bytesByWorkspace, + purgeableBytesByWorkspaceClass: bytesByWorkspaceClass, + }; +} + +export async function refreshInteractiveState( + state: PurgeInteractiveState, + now: number, +): Promise { + Object.assign(state, await loadInteractiveState(now)); +} diff --git a/src/cli/commands/purge-interactive.ts b/src/cli/commands/purge-interactive.ts new file mode 100644 index 000000000..8411c6a3c --- /dev/null +++ b/src/cli/commands/purge-interactive.ts @@ -0,0 +1,414 @@ +import { + PURGE_STORAGE_DELETABLE_CLASSES, + executePurgeStoragePlan, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; +import { + isPromptCancelledError, + isPromptInterruptedError, + type Prompter, + type SelectOption, +} from '../interactive/prompts.ts'; +import { + loadInteractiveState, + refreshInteractiveState, + workspaceClassPurgeableBytes, + workspacePurgeableBytes, + type PurgeInteractiveState, + type WorkspaceGroupSummary, +} from './purge-interactive-model.ts'; +import { + SKIPPED_FOR_SAFETY_HINT, + formatBytes, + renderInteractiveOverview, + storageClassLabel, +} from './purge-ui.ts'; + +export const PURGE_INTERACTIVE_ROOT_PROMPT = 'Select a project to clean'; + +export function purgeInteractiveProjectPrompt(projectName: string): string { + return `Project ${projectName}`; +} + +export function purgeInteractiveWorkspacePrompt(workspaceKey: string): string { + return `Workspace ${workspaceKey}`; +} + +// Interactive purge is a confirmation-gated human flow. Bulk actions intentionally +// include DerivedData; non-TTY modes keep stricter defaults for scripts and agents. +const FULL_WORKSPACE_CLASSES: PurgeStorageDeletableClass[] = [...PURGE_STORAGE_DELETABLE_CLASSES]; + +type RootSelection = 'cancel' | WorkspaceGroupSummary; +type ProjectAction = 'deleteAllWorkspaces' | 'selectWorkspaces' | 'back'; +type ProjectSelection = ProjectAction | PurgeWorkspaceSummary; +type WorkspaceAction = 'deleteAllFolders' | 'back'; +type WorkspaceSelection = WorkspaceAction | PurgeStorageDeletableClass; + +interface PurgeInteractiveDependencies { + currentWorkspaceKey: string; + prompter: Prompter; + now: number; + write: (text: string) => void; +} + +function groupLabel(group: WorkspaceGroupSummary): string { + return `› ${group.name} - ${formatBytes(group.bytes)} - ${group.workspaces.length} workspace${group.workspaces.length === 1 ? '' : 's'}`; +} + +function workspaceLabel( + workspace: PurgeWorkspaceSummary, + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): string { + const current = workspace.workspaceKey === currentWorkspaceKey ? ' - current' : ''; + return `${workspace.workspaceKey} - ${formatBytes(workspacePurgeableBytes(workspace, state.purgeableBytesByWorkspace))}${current}`; +} + +function isProjectAction(selection: ProjectSelection): selection is ProjectAction { + return typeof selection === 'string'; +} + +function isWorkspaceAction(selection: WorkspaceSelection): selection is WorkspaceAction { + return selection === 'deleteAllFolders' || selection === 'back'; +} + +function rootOptions(groups: WorkspaceGroupSummary[]): SelectOption[] { + return [...groupOptions(groups), { value: 'cancel', label: 'Cancel' }]; +} + +function projectOptions( + group: WorkspaceGroupSummary, + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): SelectOption[] { + return [ + ...workspaceOptions(group.workspaces, currentWorkspaceKey, state), + { value: 'deleteAllWorkspaces', label: `Delete all workspaces in project ${group.name}` }, + { value: 'selectWorkspaces', label: 'Select multiple workspaces to delete' }, + { value: 'back', label: 'Back' }, + ]; +} + +function workspaceActionOptions( + workspace: PurgeWorkspaceSummary, + state: PurgeInteractiveState, +): SelectOption[] { + return [ + ...folderOptions(workspace, state), + { + value: 'deleteAllFolders', + label: `Delete all folders in workspace ${workspace.workspaceKey}`, + }, + { value: 'back', label: 'Back' }, + ]; +} + +function groupOptions(groups: WorkspaceGroupSummary[]): SelectOption[] { + return groups.map((group) => ({ value: group, label: groupLabel(group) })); +} + +function workspaceOptions( + workspaces: PurgeWorkspaceSummary[], + currentWorkspaceKey: string, + state: PurgeInteractiveState, +): SelectOption[] { + return workspaces.map((workspace) => ({ + value: workspace, + label: workspaceLabel(workspace, currentWorkspaceKey, state), + })); +} + +function interactiveFolderLabel(storageClass: PurgeStorageDeletableClass): string { + if (storageClass === 'stateTransients') { + return 'State'; + } + return storageClassLabel(storageClass); +} + +function folderOptions( + workspace: PurgeWorkspaceSummary, + state: PurgeInteractiveState, +): SelectOption[] { + return PURGE_STORAGE_DELETABLE_CLASSES.map((storageClass) => ({ + value: storageClass, + label: `${interactiveFolderLabel(storageClass)} - ${formatBytes(workspaceClassPurgeableBytes(workspace, storageClass, state.purgeableBytesByWorkspaceClass))}`, + })).filter( + (option) => + workspaceClassPurgeableBytes(workspace, option.value, state.purgeableBytesByWorkspaceClass) > + 0, + ); +} + +function selectedWorkspaceKeys(workspaces: PurgeWorkspaceSummary[]): string[] { + return workspaces + .map((workspace) => workspace.workspaceKey) + .sort((left, right) => left.localeCompare(right)); +} + +async function planForScope(params: { + report: PurgeStorageReport; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + now: number; +}): Promise { + return planPurgeStorage({ + report: params.report, + scope: params.scope, + classes: params.classes, + now: params.now, + derivedDataExplicit: params.classes.includes('derivedData'), + }); +} + +function classTotals(plan: PurgeStoragePlan): Map { + const totals = new Map(); + for (const storageClass of plan.classes) { + totals.set(storageClass, 0); + } + for (const candidate of plan.candidates) { + totals.set(candidate.storageClass, (totals.get(candidate.storageClass) ?? 0) + candidate.bytes); + } + return totals; +} + +function candidateWorkspaceCount(plan: PurgeStoragePlan): number { + return new Set(plan.candidates.map((candidate) => candidate.workspaceKey)).size; +} + +function renderFinalSummary(plan: PurgeStoragePlan): string { + const totals = classTotals(plan); + const workspaceCount = candidateWorkspaceCount(plan); + let output = '\nReady to delete\n'; + output += `Scope: ${workspaceCount} workspace${workspaceCount === 1 ? '' : 's'} with candidates\n`; + output += 'Folders:\n'; + for (const [storageClass, bytes] of totals.entries()) { + output += ` ${storageClassLabel(storageClass)} ${formatBytes(bytes)}\n`; + } + output += `Estimated reclaim: ${formatBytes(plan.totals.bytes)}\n`; + if (plan.classes.includes('derivedData')) { + output += 'Includes DerivedData.\n'; + } + if (plan.report.warnings.length > 0 || plan.skipped.length > 0) { + output += `Note: ${SKIPPED_FOR_SAFETY_HINT}\n`; + } + return `${output}\n`; +} + +async function confirmAndDelete( + plan: PurgeStoragePlan, + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, +): Promise { + if (plan.candidates.length === 0) { + deps.write('No matching purgeable storage found for that selection.\n'); + await refreshInteractiveState(state, deps.now); + return false; + } + + deps.write(renderFinalSummary(plan)); + let confirmed: boolean; + try { + confirmed = await deps.prompter.confirm({ + message: `Delete ${formatBytes(plan.totals.bytes)}?`, + defaultValue: false, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + deps.write('No storage deleted.\n'); + return false; + } + throw error; + } + if (!confirmed) { + deps.write('No storage deleted.\n'); + return false; + } + + const result = await executePurgeStoragePlan(plan, { now: deps.now }); + deps.write( + `Deleted ${result.totals.deletedCount} item${result.totals.deletedCount === 1 ? '' : 's'}; freed ${formatBytes(result.totals.bytes)}.\n`, + ); + if (result.totals.skippedCount > 0) { + deps.write(`${SKIPPED_FOR_SAFETY_HINT}\n`); + } + if (result.totals.deletedCount > 0) { + deps.write('Refreshing storage view...\n'); + await refreshInteractiveState(state, deps.now); + } + return true; +} + +async function confirmAndDeleteScope( + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, + scope: PurgeStorageScope, + classes: PurgeStorageDeletableClass[], +): Promise { + const plan = await planForScope({ + report: state.report, + scope, + classes, + now: deps.now, + }); + await confirmAndDelete(plan, deps, state); +} + +async function confirmAndDeleteWorkspaces( + deps: PurgeInteractiveDependencies, + state: PurgeInteractiveState, + workspaceKeys: string[], + classes: PurgeStorageDeletableClass[], +): Promise { + await confirmAndDeleteScope(deps, state, { type: 'workspaces', workspaceKeys }, classes); +} + +async function handleWorkspace(params: { + deps: PurgeInteractiveDependencies; + state: PurgeInteractiveState; + workspaceKey: string; +}): Promise { + const { deps, state, workspaceKey } = params; + while (true) { + const workspace = state.report.workspaces.find((entry) => entry.workspaceKey === workspaceKey); + if (!workspace || workspacePurgeableBytes(workspace, state.purgeableBytesByWorkspace) <= 0) { + return; + } + let selection: WorkspaceSelection; + try { + selection = await deps.prompter.selectOne({ + message: purgeInteractiveWorkspacePrompt(workspace.workspaceKey), + options: workspaceActionOptions(workspace, state), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + return; + } + throw error; + } + + if (!isWorkspaceAction(selection)) { + await confirmAndDeleteWorkspaces(deps, state, [workspace.workspaceKey], [selection]); + continue; + } + + if (selection === 'back') { + return; + } + + await confirmAndDeleteWorkspaces(deps, state, [workspace.workspaceKey], FULL_WORKSPACE_CLASSES); + } +} + +async function handleProject(params: { + deps: PurgeInteractiveDependencies; + state: PurgeInteractiveState; + groupId: string; +}): Promise { + const { deps, state, groupId } = params; + while (true) { + const group = state.groups.find((entry) => entry.id === groupId); + if (!group) { + return; + } + if (group.workspaces.length === 1) { + await handleWorkspace({ deps, state, workspaceKey: group.workspaces[0].workspaceKey }); + return; + } + let selection: ProjectSelection; + try { + selection = await deps.prompter.selectOne({ + message: purgeInteractiveProjectPrompt(group.name), + options: projectOptions(group, deps.currentWorkspaceKey, state), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + return; + } + throw error; + } + + if (!isProjectAction(selection)) { + await handleWorkspace({ deps, state, workspaceKey: selection.workspaceKey }); + continue; + } + + if (selection === 'back') { + return; + } + + if (selection === 'deleteAllWorkspaces') { + await confirmAndDeleteWorkspaces( + deps, + state, + selectedWorkspaceKeys(group.workspaces), + FULL_WORKSPACE_CLASSES, + ); + continue; + } + + let workspaces: PurgeWorkspaceSummary[]; + try { + workspaces = await deps.prompter.selectMany({ + message: `Select workspaces in ${group.name}`, + options: workspaceOptions(group.workspaces, deps.currentWorkspaceKey, state), + getKey: (workspace) => workspace.workspaceKey, + minSelected: 1, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + continue; + } + throw error; + } + await confirmAndDeleteWorkspaces( + deps, + state, + selectedWorkspaceKeys(workspaces), + FULL_WORKSPACE_CLASSES, + ); + } +} + +export async function runInteractivePurge(deps: PurgeInteractiveDependencies): Promise { + const state = await loadInteractiveState(deps.now); + deps.write(renderInteractiveOverview(state.groups, state.report.warnings.length)); + + try { + while (true) { + let selection: RootSelection; + try { + selection = await deps.prompter.selectOne({ + message: PURGE_INTERACTIVE_ROOT_PROMPT, + options: rootOptions(state.groups), + initialIndex: 0, + }); + } catch (error) { + if (isPromptCancelledError(error)) { + deps.write('No storage deleted.\n'); + return; + } + throw error; + } + + if (selection === 'cancel') { + deps.write('No storage deleted.\n'); + return; + } + + await handleProject({ deps, state, groupId: selection.id }); + } + } catch (error) { + if (isPromptInterruptedError(error)) { + deps.write('No storage deleted.\n'); + return; + } + throw error; + } +} diff --git a/src/cli/commands/purge-ui.ts b/src/cli/commands/purge-ui.ts new file mode 100644 index 000000000..78bf51ecf --- /dev/null +++ b/src/cli/commands/purge-ui.ts @@ -0,0 +1,349 @@ +import { displayPath } from '../../utils/build-preflight.ts'; +import { + PURGE_STORAGE_DELETABLE_CLASSES, + type PurgeStorageClass, + type PurgeStorageDeletableClass, + type PurgeStorageExecutionResult, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceSummary, +} from '../../utils/purge-storage.ts'; + +const CLASS_LABELS: Record = { + derivedData: 'DerivedData', + logs: 'Logs', + resultBundles: 'Result bundles', + stateTransients: 'State transients', + locks: 'Locks', +}; + +const BAR_WIDTH = 18; + +export const SKIPPED_FOR_SAFETY_HINT = + 'Some paths were skipped for safety. Run `xcodebuildmcp purge --report --json` for diagnostics.'; + +export function formatBytes(bytes: number): string { + if (bytes < 1024) { + return `${bytes} B`; + } + + const units = ['KB', 'MB', 'GB', 'TB'] as const; + let value = bytes / 1024; + let unitIndex = 0; + while (value >= 1024 && unitIndex < units.length - 1) { + value /= 1024; + unitIndex += 1; + } + + return `${value >= 10 ? value.toFixed(1) : value.toFixed(2)} ${units[unitIndex]}`; +} + +export function storageClassLabel(storageClass: PurgeStorageClass): string { + return CLASS_LABELS[storageClass]; +} + +export function renderInteractiveOverview( + groups: Array<{ name: string; bytes: number; workspaces: readonly unknown[] }>, + warningsCount: number, +): string { + const totalBytes = groups.reduce((total, group) => total + group.bytes, 0); + const workspaceCount = groups.reduce((total, group) => total + group.workspaces.length, 0); + let output = 'XcodeBuildMCP storage\n\n'; + output += `Purgeable: ${formatBytes(totalBytes)} across ${groups.length} projects / ${workspaceCount} workspaces\n`; + if (groups.length > 0) { + output += '\nLargest projects:\n'; + for (const group of groups.slice(0, 5)) { + output += ` ${group.name} ${formatBytes(group.bytes)} ${group.workspaces.length} workspace${group.workspaces.length === 1 ? '' : 's'}\n`; + } + } + if (warningsCount > 0) { + output += `\nNote: ${SKIPPED_FOR_SAFETY_HINT}\n`; + } + output += '\n'; + return output; +} + +export function scopeLabel(scope: PurgeStorageScope): string { + switch (scope.type) { + case 'all': + return 'all recognized workspaces'; + case 'family': + return `family ${scope.family}`; + case 'workspace': + return `workspace ${scope.workspaceKey}`; + case 'workspaces': + return `${scope.workspaceKeys.length} selected workspaces`; + } +} + +function usageBar(bytes: number, maxBytes: number): string { + if (maxBytes <= 0) { + return `[${'-'.repeat(BAR_WIDTH)}]`; + } + + const filled = Math.max(1, Math.round((bytes / maxBytes) * BAR_WIDTH)); + return `[${'#'.repeat(filled)}${'-'.repeat(BAR_WIDTH - filled)}]`; +} + +function line(parts: string[]): string { + return `${parts.join(' ')}\n`; +} + +function warningMarker(workspace: PurgeWorkspaceSummary): string { + if (!workspace.recognized) { + return 'unknown key'; + } + if (workspace.warnings.length > 0) { + return 'warnings'; + } + return ''; +} + +const ABSOLUTE_PATH_PATTERN = /\/[^\s'":]+/g; + +function displayWarning(warning: string): string { + return warning.replace(ABSOLUTE_PATH_PATTERN, (match) => displayPath(match)); +} + +function renderWarningsSection(warnings: string[]): string { + if (warnings.length === 0) { + return ''; + } + return `\nWarnings:\n${warnings.map((warning) => `- ${displayWarning(warning)}`).join('\n')}\n`; +} + +export function renderWorkspaceUsage( + report: PurgeStorageReport, + currentWorkspaceKey: string, +): string { + const maxBytes = Math.max(0, ...report.workspaces.map((workspace) => workspace.totals.bytes)); + const rows = [...report.workspaces].sort((left, right) => { + if (left.workspaceKey === currentWorkspaceKey) return -1; + if (right.workspaceKey === currentWorkspaceKey) return 1; + return right.totals.bytes - left.totals.bytes; + }); + + let output = line(['Workspace', 'Family', 'Size', 'Usage', 'Notes']); + output += line(['---------', '------', '----', '-----', '-----']); + for (const workspace of rows) { + const name = + workspace.workspaceKey === currentWorkspaceKey + ? `${workspace.workspaceKey} (current)` + : workspace.workspaceKey; + output += line([ + name, + workspace.family ?? '-', + formatBytes(workspace.totals.bytes).padStart(9), + usageBar(workspace.totals.bytes, maxBytes), + warningMarker(workspace), + ]); + } + return output; +} + +export function renderFamilyUsage(report: PurgeStorageReport): string { + if (report.families.length === 0) { + return 'No recognized workspace families found.\n'; + } + + const maxBytes = Math.max(0, ...report.families.map((family) => family.bytes)); + let output = line(['Family', 'Workspaces', 'Size', 'Usage']); + output += line(['------', '----------', '----', '-----']); + for (const family of [...report.families].sort((left, right) => right.bytes - left.bytes)) { + output += line([ + family.family, + String(family.workspaceKeys.length).padStart(10), + formatBytes(family.bytes).padStart(9), + usageBar(family.bytes, maxBytes), + ]); + } + return output; +} + +export function renderClassUsage( + workspaces: PurgeWorkspaceSummary[], + classes: readonly PurgeStorageClass[], +): string { + const totals = new Map(); + for (const storageClass of classes) { + totals.set(storageClass, 0); + } + + for (const workspace of workspaces) { + for (const storageClass of classes) { + totals.set( + storageClass, + (totals.get(storageClass) ?? 0) + workspace.classes[storageClass].bytes, + ); + } + } + + const maxBytes = Math.max(0, ...Array.from(totals.values())); + let output = line(['Class', 'Size', 'Usage']); + output += line(['-----', '----', '-----']); + for (const storageClass of classes) { + const bytes = totals.get(storageClass) ?? 0; + output += line([ + storageClassLabel(storageClass), + formatBytes(bytes).padStart(9), + usageBar(bytes, maxBytes), + ]); + } + return output; +} + +export function workspacesForScope( + report: PurgeStorageReport, + scope: PurgeStorageScope, +): PurgeWorkspaceSummary[] { + switch (scope.type) { + case 'all': + return report.workspaces.filter((workspace) => workspace.recognized); + case 'family': + return report.workspaces.filter( + (workspace) => workspace.recognized && workspace.family === scope.family, + ); + case 'workspace': + return report.workspaces.filter((workspace) => workspace.workspaceKey === scope.workspaceKey); + case 'workspaces': { + const workspaceKeys = new Set(scope.workspaceKeys); + return report.workspaces.filter((workspace) => workspaceKeys.has(workspace.workspaceKey)); + } + } +} + +export function renderReportText( + report: PurgeStorageReport, + currentWorkspaceKey: string, + selectedScope: PurgeStorageScope, +): string { + const selectedWorkspaces = workspacesForScope(report, selectedScope); + let output = 'XcodeBuildMCP storage report\n'; + output += `App root: ${displayPath(report.appRoot)}\n`; + output += `Total: ${formatBytes(report.totals.bytes)} (${report.totals.fileCount} files, ${report.totals.directoryCount} directories)\n`; + output += `Selected scope: ${scopeLabel(selectedScope)} (${selectedWorkspaces.length} workspaces)\n\n`; + output += 'Workspace usage:\n'; + output += renderWorkspaceUsage(report, currentWorkspaceKey); + output += '\nFamily usage:\n'; + output += renderFamilyUsage(report); + output += '\nSelected scope by class:\n'; + output += renderClassUsage(selectedWorkspaces, PURGE_STORAGE_DELETABLE_CLASSES); + output += renderWarningsSection(report.warnings); + + return output; +} + +export function renderPlanText(plan: PurgeStoragePlan): string { + let output = 'Purge dry run\n'; + output += `Scope: ${scopeLabel(plan.scope)}\n`; + output += `Classes: ${plan.classes.map(storageClassLabel).join(', ')}\n`; + output += `Candidates: ${plan.totals.candidateCount}\n`; + output += `Reclaimable: ${formatBytes(plan.totals.bytes)} (${plan.totals.fileCount} files, ${plan.totals.directoryCount} directories)\n`; + if (plan.skipped.length > 0) { + output += `Skipped: ${plan.skipped.length}\n`; + } + if (plan.candidates.length > 0) { + output += '\nCandidates:\n'; + for (const candidate of plan.candidates.slice(0, 20)) { + output += `- ${storageClassLabel(candidate.storageClass)} ${formatBytes(candidate.bytes)} ${displayPath(candidate.path)}\n`; + } + if (plan.candidates.length > 20) { + output += `- ... ${plan.candidates.length - 20} more\n`; + } + } + output += renderWarningsSection(plan.warnings); + return output; +} + +export function renderExecutionText(result: PurgeStorageExecutionResult): string { + let output = 'Purge delete result\n'; + output += `Scope: ${scopeLabel(result.scope)}\n`; + output += `Classes: ${result.classes.map(storageClassLabel).join(', ')}\n`; + output += `Deleted: ${result.totals.deletedCount}\n`; + output += `Freed: ${formatBytes(result.totals.bytes)}\n`; + output += `Skipped: ${result.totals.skippedCount}\n`; + output += renderWarningsSection(result.warnings); + return output; +} + +function jsonWorkspace(workspace: PurgeWorkspaceSummary): object { + return { + workspaceKey: workspace.workspaceKey, + path: displayPath(workspace.path), + recognized: workspace.recognized, + family: workspace.family, + hash: workspace.hash, + totals: workspace.totals, + classes: Object.fromEntries( + Object.entries(workspace.classes).map(([storageClass, census]) => [ + storageClass, + { + ...census, + path: displayPath(census.path), + warnings: census.warnings.map(displayWarning), + }, + ]), + ), + warnings: workspace.warnings.map(displayWarning), + }; +} + +export function reportToJson(report: PurgeStorageReport, selectedScope: PurgeStorageScope): object { + return { + action: 'report', + deletionHappened: false, + appRoot: displayPath(report.appRoot), + workspacesDir: displayPath(report.workspacesDir), + selectedScope, + totals: report.totals, + families: report.families, + workspaces: report.workspaces.map(jsonWorkspace), + warnings: report.warnings.map(displayWarning), + }; +} + +export function planToJson(plan: PurgeStoragePlan): object { + return { + action: 'dry-run', + deletionHappened: false, + selectedScope: plan.scope, + classes: plan.classes, + selectedWorkspaceKeys: plan.selectedWorkspaceKeys, + totals: plan.totals, + candidates: plan.candidates.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + sidecarPaths: candidate.sidecarPaths.map(displayPath), + })), + skipped: plan.skipped.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + reason: displayWarning(candidate.reason), + })), + warnings: plan.warnings.map(displayWarning), + report: reportToJson(plan.report, plan.scope), + }; +} + +export function executionToJson(result: PurgeStorageExecutionResult): object { + return { + action: 'delete', + deletionHappened: result.deleted.length > 0, + selectedScope: result.scope, + classes: result.classes, + selectedWorkspaceKeys: result.selectedWorkspaceKeys, + totals: result.totals, + deleted: result.deleted.map((entry) => ({ ...entry, path: displayPath(entry.path) })), + skipped: result.skipped.map((candidate) => ({ + ...candidate, + path: displayPath(candidate.path), + reason: displayWarning(candidate.reason), + })), + warnings: result.warnings.map(displayWarning), + }; +} + +export function defaultClassKeys(): PurgeStorageDeletableClass[] { + return ['logs', 'resultBundles', 'stateTransients']; +} diff --git a/src/cli/commands/purge.ts b/src/cli/commands/purge.ts new file mode 100644 index 000000000..e76cc75d8 --- /dev/null +++ b/src/cli/commands/purge.ts @@ -0,0 +1,384 @@ +import type { Argv } from 'yargs'; +import { + PURGE_STORAGE_DELETABLE_CLASSES, + enumeratePurgeStorage, + executePurgeStoragePlan, + planPurgeStorage, + type PurgeStorageDeletableClass, + type PurgeStoragePlan, + type PurgeStorageScope, +} from '../../utils/purge-storage.ts'; +import { createPrompter, isInteractiveTTY, type Prompter } from '../interactive/prompts.ts'; +import { + defaultClassKeys, + executionToJson, + planToJson, + renderExecutionText, + renderPlanText, + renderReportText, + reportToJson, +} from './purge-ui.ts'; +import { runInteractivePurge } from './purge-interactive.ts'; + +const DELETE_CONFIRMATION = 'delete-xcodebuildmcp-storage'; +const DAY_MS = 24 * 60 * 60 * 1000; + +type PurgeMode = 'report' | 'dry-run' | 'delete' | 'interactive'; +type CliScope = 'current' | 'workspace' | 'family' | 'all'; + +export interface PurgeCommandDependencies { + currentWorkspaceKey: string; + prompter?: Prompter; + isTTY?: boolean; + now?: number; + write?: (text: string) => void; +} + +export interface PurgeCommandArguments { + report?: boolean; + dryRun?: boolean; + delete?: boolean; + scope?: CliScope; + workspaceKey?: string; + family?: string; + classes?: string | string[]; + olderThan?: string; + confirm?: string; + json?: boolean; +} + +function writeJson(write: (text: string) => void, value: object): void { + write(`${JSON.stringify(value, null, 2)}\n`); +} + +function determineMode(args: PurgeCommandArguments, isTTY: boolean): PurgeMode { + const selected = [args.report, args.dryRun, args.delete].filter(Boolean).length; + if (selected > 1) { + throw new Error('Choose only one purge mode: --report, --dry-run, or --delete.'); + } + if (args.report) return 'report'; + if (args.dryRun) return 'dry-run'; + if (args.delete) return 'delete'; + return isTTY && !args.json ? 'interactive' : 'report'; +} + +function suppliedModeSpecificFlagNames(args: PurgeCommandArguments): string[] { + const supplied: string[] = []; + if (args.scope !== undefined) supplied.push('--scope'); + if (args.workspaceKey !== undefined) supplied.push('--workspace-key'); + if (args.family !== undefined) supplied.push('--family'); + if (args.classes !== undefined) supplied.push('--classes'); + if (args.olderThan !== undefined) supplied.push('--older-than'); + if (args.confirm !== undefined) supplied.push('--confirm'); + return supplied; +} + +function validateModeSpecificArgs(mode: PurgeMode, args: PurgeCommandArguments): void { + const explicitMode = args.report === true || args.dryRun === true || args.delete === true; + const suppliedFlags = suppliedModeSpecificFlagNames(args); + if (mode === 'interactive') { + if (!explicitMode && suppliedFlags.length > 0) { + throw new Error( + `Purge flags require an explicit mode: --report, --dry-run, or --delete. Supplied: ${suppliedFlags.join(', ')}.`, + ); + } + return; + } + + if (mode === 'report') { + if (args.classes !== undefined) { + throw new Error('--report cannot be combined with --classes.'); + } + if (args.olderThan !== undefined) { + throw new Error('--report cannot be combined with --older-than.'); + } + if (args.confirm !== undefined) { + throw new Error('--report cannot be combined with --confirm.'); + } + return; + } + + if (mode === 'dry-run' && args.confirm !== undefined) { + throw new Error('--dry-run cannot be combined with --confirm.'); + } +} + +function parseOlderThan(value: string | undefined): number | undefined { + if (value == null || value.trim().length === 0) { + return undefined; + } + + const match = value.trim().match(/^(\d+)\s*d$/u); + if (!match) { + throw new Error('--older-than must use a day value such as 1d, 7d, 14d, or 30d.'); + } + + const days = Number(match[1]); + if (!Number.isSafeInteger(days) || days <= 0) { + throw new Error('--older-than must be a positive day value.'); + } + return days * DAY_MS; +} + +function parseClasses(value: string | string[] | undefined): { + classes: PurgeStorageDeletableClass[]; + explicit: boolean; +} { + if (value == null) { + return { classes: defaultClassKeys(), explicit: false }; + } + + const rawValues = (Array.isArray(value) ? value : [value]) + .flatMap((entry) => entry.split(',')) + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); + + if (rawValues.length === 0) { + throw new Error('--classes must include at least one storage class.'); + } + + const validClasses = new Set(PURGE_STORAGE_DELETABLE_CLASSES); + const parsed: PurgeStorageDeletableClass[] = []; + for (const rawValue of rawValues) { + if (rawValue === 'all') { + parsed.push(...defaultClassKeys()); + continue; + } + if (!validClasses.has(rawValue)) { + throw new Error( + `Unknown purge storage class '${rawValue}'. Use one of: ${PURGE_STORAGE_DELETABLE_CLASSES.join(', ')}.`, + ); + } + parsed.push(rawValue as PurgeStorageDeletableClass); + } + + return { classes: Array.from(new Set(parsed)), explicit: true }; +} + +function normalizedScopeValue(value: string | undefined, flagName: string): string | undefined { + if (value === undefined) { + return undefined; + } + const trimmed = value.trim(); + if (trimmed.length === 0) { + throw new Error(`${flagName} must not be empty.`); + } + return trimmed; +} + +function resolveScope(args: PurgeCommandArguments, currentWorkspaceKey: string): PurgeStorageScope { + const workspaceKey = normalizedScopeValue(args.workspaceKey, '--workspace-key'); + const family = normalizedScopeValue(args.family, '--family'); + + if (args.scope === 'current' && (workspaceKey !== undefined || family !== undefined)) { + throw new Error('--scope current cannot be combined with --workspace-key or --family.'); + } + + if (args.scope === 'workspace' && family !== undefined) { + throw new Error('--scope workspace cannot be combined with --family.'); + } + + if (args.scope === 'all') { + if (workspaceKey !== undefined || family !== undefined) { + throw new Error('--scope all cannot be combined with --workspace-key or --family.'); + } + return { type: 'all' }; + } + + if (args.scope === 'family' || family !== undefined) { + if (family === undefined) { + throw new Error('--scope family requires --family .'); + } + if (workspaceKey !== undefined) { + throw new Error('--family cannot be combined with --workspace-key.'); + } + return { type: 'family', family }; + } + + if (args.scope === 'workspace' || workspaceKey !== undefined) { + return { type: 'workspace', workspaceKey: workspaceKey ?? currentWorkspaceKey }; + } + + if (args.scope === 'current') { + return { type: 'workspace', workspaceKey: currentWorkspaceKey }; + } + + return { type: 'all' }; +} + +function resolvePlanningScope( + args: PurgeCommandArguments, + currentWorkspaceKey: string, +): PurgeStorageScope { + if (args.scope == null && args.workspaceKey == null && args.family == null) { + return { type: 'workspace', workspaceKey: currentWorkspaceKey }; + } + return resolveScope(args, currentWorkspaceKey); +} + +function validateDelete(args: PurgeCommandArguments, classesWereExplicit: boolean): void { + if (args.confirm !== DELETE_CONFIRMATION) { + throw new Error(`Destructive purge requires --confirm ${DELETE_CONFIRMATION}.`); + } + if (!classesWereExplicit) { + throw new Error('Destructive purge requires explicit --classes.'); + } +} + +interface ResolvedPlanOptions { + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + olderThanMs?: number; + derivedDataExplicit: boolean; + classesWereExplicit: boolean; +} + +function planOptionsForArgs( + args: PurgeCommandArguments, + currentWorkspaceKey: string, +): ResolvedPlanOptions { + const parsedClasses = parseClasses(args.classes); + const scope = resolvePlanningScope(args, currentWorkspaceKey); + return { + scope, + classes: parsedClasses.classes, + olderThanMs: parseOlderThan(args.olderThan), + derivedDataExplicit: parsedClasses.classes.includes('derivedData') && parsedClasses.explicit, + classesWereExplicit: parsedClasses.explicit, + }; +} + +async function buildPlan(options: ResolvedPlanOptions, now: number): Promise { + return planPurgeStorage({ + scope: options.scope, + classes: options.classes, + now, + olderThanMs: options.olderThanMs, + derivedDataExplicit: options.derivedDataExplicit, + }); +} + +export async function runPurgeCommand( + args: PurgeCommandArguments, + deps: PurgeCommandDependencies, +): Promise { + const resolvedDeps: Required = { + currentWorkspaceKey: deps.currentWorkspaceKey, + prompter: deps.prompter ?? createPrompter(), + isTTY: deps.isTTY ?? isInteractiveTTY(), + now: deps.now ?? Date.now(), + write: deps.write ?? ((text): void => void process.stdout.write(text)), + }; + + const mode = determineMode(args, resolvedDeps.isTTY); + validateModeSpecificArgs(mode, args); + if (mode === 'interactive') { + await runInteractivePurge(resolvedDeps); + return; + } + + if (mode === 'report') { + const selectedScope = resolvePlanningScope(args, resolvedDeps.currentWorkspaceKey); + const report = await enumeratePurgeStorage({ + now: resolvedDeps.now, + scope: selectedScope, + }); + if (args.json) { + writeJson(resolvedDeps.write, reportToJson(report, selectedScope)); + } else { + resolvedDeps.write(renderReportText(report, resolvedDeps.currentWorkspaceKey, selectedScope)); + } + return; + } + + const planArgs = planOptionsForArgs(args, resolvedDeps.currentWorkspaceKey); + if (mode === 'delete') { + validateDelete(args, planArgs.classesWereExplicit); + } + + const plan = await buildPlan(planArgs, resolvedDeps.now); + if (mode === 'dry-run') { + if (args.json) { + writeJson(resolvedDeps.write, planToJson(plan)); + } else { + resolvedDeps.write(renderPlanText(plan)); + } + return; + } + + const result = await executePurgeStoragePlan(plan, { now: resolvedDeps.now }); + if (args.json) { + writeJson(resolvedDeps.write, executionToJson(result)); + } else { + resolvedDeps.write(renderExecutionText(result)); + } +} + +export function registerPurgeCommand(app: Argv, opts: { currentWorkspaceKey: string }): void { + app.command( + 'purge', + 'Report and clean XcodeBuildMCP workspace storage', + (yargs) => + yargs + .option('report', { + type: 'boolean', + describe: 'Report storage usage without planning or deleting', + }) + .option('dry-run', { + type: 'boolean', + describe: 'Plan purge candidates without deleting anything', + }) + .option('delete', { + type: 'boolean', + describe: 'Delete planned purge candidates', + }) + .option('scope', { + type: 'string', + choices: ['current', 'workspace', 'family', 'all'] as const, + describe: 'Storage scope to report, plan, or delete', + }) + .option('workspace-key', { + type: 'string', + describe: 'Explicit workspace key for --scope workspace', + }) + .option('family', { + type: 'string', + describe: 'Workspace family basename prefix for --scope family', + }) + .option('classes', { + type: 'string', + describe: + 'Comma-separated storage classes: derivedData,logs,resultBundles,stateTransients. "all" excludes DerivedData.', + }) + .option('older-than', { + type: 'string', + describe: 'Only purge entries older than a day value such as 1d, 7d, 14d, or 30d', + }) + .option('confirm', { + type: 'string', + describe: `Required for --delete: ${DELETE_CONFIRMATION}`, + }) + .option('json', { + type: 'boolean', + default: false, + describe: 'Output deterministic JSON', + }), + async (argv) => { + await runPurgeCommand( + { + report: argv.report as boolean | undefined, + dryRun: argv.dryRun as boolean | undefined, + delete: argv.delete as boolean | undefined, + scope: argv.scope as CliScope | undefined, + workspaceKey: argv.workspaceKey as string | undefined, + family: argv.family as string | undefined, + classes: argv.classes as string | undefined, + olderThan: argv.olderThan as string | undefined, + confirm: argv.confirm as string | undefined, + json: argv.json as boolean | undefined, + }, + { currentWorkspaceKey: opts.currentWorkspaceKey }, + ); + }, + ); +} diff --git a/src/cli/interactive/prompts.ts b/src/cli/interactive/prompts.ts index 62f615ec7..0ce48f7f5 100644 --- a/src/cli/interactive/prompts.ts +++ b/src/cli/interactive/prompts.ts @@ -1,5 +1,27 @@ import * as clack from '@clack/prompts'; +export class PromptCancelledError extends Error { + constructor() { + super('Prompt cancelled.'); + this.name = 'PromptCancelledError'; + } +} + +export class PromptInterruptedError extends Error { + constructor() { + super('Prompt interrupted.'); + this.name = 'PromptInterruptedError'; + } +} + +export function isPromptCancelledError(error: unknown): error is PromptCancelledError { + return error instanceof PromptCancelledError; +} + +export function isPromptInterruptedError(error: unknown): error is PromptInterruptedError { + return error instanceof PromptInterruptedError; +} + export interface SelectOption { value: T; label: string; @@ -58,13 +80,50 @@ function createNonInteractivePrompter(): Prompter { }; } -function handleCancel(result: unknown): void { +type PromptCancelKind = 'cancel' | 'interrupt'; + +interface KeypressInfo { + name?: string; + sequence?: string; + ctrl?: boolean; +} + +async function runClackPrompt( + prompt: Promise, +): Promise<{ result: T; cancelKind: PromptCancelKind }> { + let cancelKind: PromptCancelKind = 'cancel'; + const onKeypress = (_text: string, key: KeypressInfo = {}): void => { + if (key.sequence === '\u0003' || (key.name === 'c' && key.ctrl === true)) { + cancelKind = 'interrupt'; + } + }; + + process.stdin.prependListener('keypress', onKeypress); + try { + return { result: await prompt, cancelKind }; + } finally { + process.stdin.off('keypress', onKeypress); + } +} + +function handleCancel(result: unknown, cancelKind: PromptCancelKind): void { if (clack.isCancel(result)) { - clack.cancel('Setup cancelled.'); - throw new Error('Setup cancelled.'); + clack.cancel(cancelKind === 'interrupt' ? 'Interrupted.' : 'Cancelled.'); + if (cancelKind === 'interrupt') { + throw new PromptInterruptedError(); + } + throw new PromptCancelledError(); } } +function toClackOptions(options: SelectOption[]): clack.Option[] { + return options.map((option) => ({ + value: option.value, + label: option.label, + ...(option.description ? { hint: option.description } : {}), + })) as unknown as clack.Option[]; +} + function createTtyPrompter(): Prompter { return { async selectOne(opts: { @@ -78,19 +137,15 @@ function createTtyPrompter(): Prompter { const initialIndex = clampIndex(opts.initialIndex ?? 0, opts.options.length); - const promptOptions = opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.description ? { hint: option.description } : {}), - })) as unknown as clack.Option[]; - - const result = await clack.select({ - message: opts.message, - options: promptOptions, - initialValue: opts.options[initialIndex].value, - }); + const { result, cancelKind } = await runClackPrompt( + clack.select({ + message: opts.message, + options: toClackOptions(opts.options), + initialValue: opts.options[initialIndex].value, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as T; }, @@ -110,30 +165,28 @@ function createTtyPrompter(): Prompter { .filter((option) => initialKeys.has(opts.getKey(option.value))) .map((option) => option.value); - const promptOptions = opts.options.map((option) => ({ - value: option.value, - label: option.label, - ...(option.description ? { hint: option.description } : {}), - })) as unknown as clack.Option[]; - - const result = await clack.multiselect({ - message: opts.message, - options: promptOptions, - initialValues, - required: (opts.minSelected ?? 0) > 0, - }); + const { result, cancelKind } = await runClackPrompt( + clack.multiselect({ + message: opts.message, + options: toClackOptions(opts.options), + initialValues, + required: (opts.minSelected ?? 0) > 0, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as T[]; }, async confirm(opts: { message: string; defaultValue: boolean }): Promise { - const result = await clack.confirm({ - message: opts.message, - initialValue: opts.defaultValue, - }); + const { result, cancelKind } = await runClackPrompt( + clack.confirm({ + message: opts.message, + initialValue: opts.defaultValue, + }), + ); - handleCancel(result); + handleCancel(result, cancelKind); return result as boolean; }, }; diff --git a/src/cli/yargs-app.ts b/src/cli/yargs-app.ts index 874e3b0f7..47240e5fb 100644 --- a/src/cli/yargs-app.ts +++ b/src/cli/yargs-app.ts @@ -5,6 +5,7 @@ import type { ResolvedRuntimeConfig } from '../utils/config-store.ts'; import { registerDaemonCommands } from './commands/daemon.ts'; import { registerInitCommand } from './commands/init.ts'; import { registerMcpCommand } from './commands/mcp.ts'; +import { registerPurgeCommand } from './commands/purge.ts'; import { registerSetupCommand } from './commands/setup.ts'; import { registerToolsCommand } from './commands/tools.ts'; import { registerUpgradeCommand } from './commands/upgrade.ts'; @@ -83,6 +84,7 @@ export function buildYargsApp(opts: YargsAppOptions): ReturnType { registerSetupCommand(app); registerUpgradeCommand(app); registerToolsCommand(app); + registerPurgeCommand(app, { currentWorkspaceKey: opts.workspaceKey }); registerToolCommands(app, opts.catalog, { workspaceRoot: opts.workspaceRoot, runtimeConfig: opts.runtimeConfig, diff --git a/src/smoke-tests/__tests__/cli-surface.test.ts b/src/smoke-tests/__tests__/cli-surface.test.ts index 86bc3322c..aff550619 100644 --- a/src/smoke-tests/__tests__/cli-surface.test.ts +++ b/src/smoke-tests/__tests__/cli-surface.test.ts @@ -56,6 +56,13 @@ describe('CLI Surface (e2e)', () => { expect(output).toContain('simulator:'); expect(output).toContain('build'); }); + + it('purge --help shows storage management options', () => { + const output = run('purge --help'); + expect(output).toContain('Report and clean XcodeBuildMCP workspace storage'); + expect(output).toContain('--dry-run'); + expect(output).toContain('--delete'); + }); }); describe('workflow subcommands', () => { diff --git a/src/utils/__tests__/fs-lock.test.ts b/src/utils/__tests__/fs-lock.test.ts new file mode 100644 index 000000000..f5dca2238 --- /dev/null +++ b/src/utils/__tests__/fs-lock.test.ts @@ -0,0 +1,230 @@ +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; +import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { afterEach, describe, expect, it } from 'vitest'; +import { FS_LOCK_OWNER_FILE, tryAcquireFsLock, type FsLockOwner } from '../fs-lock.ts'; +import { guardDirForLockDir } from '../fs-lock-shared.ts'; +import { tryAcquireFsLockSync } from '../fs-lock-sync.ts'; + +const PURPOSE = 'filesystem-lifecycle'; +const LEASE_MS = 10_000; +const DEAD_PID = 999_999_999; + +let tempDirs: string[] = []; + +function makeOwner(overrides: Partial = {}): FsLockOwner { + const now = Date.UTC(2026, 4, 2, 12); + return { + token: 'stale-owner-token', + pid: DEAD_PID, + purpose: PURPOSE, + acquiredAtMs: now - 2 * LEASE_MS, + expiresAtMs: now - LEASE_MS, + ...overrides, + }; +} + +function ownerPath(lockDir: string): string { + return path.join(lockDir, FS_LOCK_OWNER_FILE); +} + +function lockDirFor(appDir: string): string { + return path.join(appDir, 'workspace-a', 'locks', 'filesystem-lifecycle.lock'); +} + +async function makeTempDir(): Promise { + const tempDir = await mkdtemp(path.join(tmpdir(), 'xcodebuildmcp-fs-lock-')); + tempDirs.push(tempDir); + return tempDir; +} + +function makeTempDirSync(): string { + const tempDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-fs-lock-')); + tempDirs.push(tempDir); + return tempDir; +} + +async function writeOwner(lockDir: string, owner: FsLockOwner): Promise { + await mkdir(lockDir, { recursive: true }); + await writeFile(ownerPath(lockDir), `${JSON.stringify(owner)}\n`, 'utf8'); +} + +function writeOwnerSync(lockDir: string, owner: FsLockOwner): void { + mkdirSync(lockDir, { recursive: true }); + writeFileSync(ownerPath(lockDir), `${JSON.stringify(owner)}\n`, 'utf8'); +} + +async function readOwner(lockDir: string): Promise { + return JSON.parse(await readFile(ownerPath(lockDir), 'utf8')) as FsLockOwner; +} + +function readOwnerSync(lockDir: string): FsLockOwner { + return JSON.parse(readFileSync(ownerPath(lockDir), 'utf8')) as FsLockOwner; +} + +afterEach(async () => { + const dirs = tempDirs; + tempDirs = []; + await Promise.all(dirs.map((tempDir) => rm(tempDir, { recursive: true, force: true }))); +}); + +describe('tryAcquireFsLock', () => { + it('acquires and releases a normal lock', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).not.toBeNull(); + expect(await readOwner(lockDir)).toMatchObject({ + token: lock?.owner.token, + pid: process.pid, + purpose: PURPOSE, + }); + + await lock?.release(); + + await expect(readFile(ownerPath(lockDir), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('does not create the main lock while a guard is held', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + await mkdir(guardDirForLockDir(lockDir), { recursive: true }); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).toBeNull(); + await expect(readFile(ownerPath(lockDir), 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }); + }); + + it('recovers an expired stale guard before acquiring the main lock', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + await writeOwner( + guardDirForLockDir(lockDir), + makeOwner({ purpose: `${PURPOSE}:guard`, expiresAtMs: now - 1 }), + ); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + expect(await readOwner(lockDir)).toMatchObject({ purpose: PURPOSE, token: lock?.owner.token }); + await lock?.release(); + }); + + it('recovers an expired stale main lock after acquiring the guard', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const staleOwner = makeOwner({ token: 'expired-main-lock', expiresAtMs: now - 1 }); + await writeOwner(lockDir, staleOwner); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + const currentOwner = await readOwner(lockDir); + expect(currentOwner.token).toBe(lock?.owner.token); + expect(currentOwner.token).not.toBe(staleOwner.token); + await lock?.release(); + }); + + it('does not recover an expired lock owned by a live process', async () => { + const tempDir = await makeTempDir(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const liveOwner = makeOwner({ + pid: process.pid, + token: 'live-main-lock', + expiresAtMs: now - 1, + }); + await writeOwner(lockDir, liveOwner); + + const lock = await tryAcquireFsLock({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).toBeNull(); + expect(await readOwner(lockDir)).toMatchObject(liveOwner); + }); +}); + +describe('tryAcquireFsLockSync', () => { + it('acquires and releases a normal lock', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).not.toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject({ + token: lock?.owner.token, + pid: process.pid, + purpose: PURPOSE, + }); + + lock?.release(); + + expect(() => readFileSync(ownerPath(lockDir), 'utf8')).toThrow(); + }); + + it('does not create the main lock while a guard is held', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + mkdirSync(guardDirForLockDir(lockDir), { recursive: true }); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS }); + + expect(lock).toBeNull(); + expect(() => readFileSync(ownerPath(lockDir), 'utf8')).toThrow(); + }); + + it('recovers an expired stale guard before acquiring the main lock', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + writeOwnerSync( + guardDirForLockDir(lockDir), + makeOwner({ purpose: `${PURPOSE}:guard`, expiresAtMs: now - 1 }), + ); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject({ purpose: PURPOSE, token: lock?.owner.token }); + lock?.release(); + }); + + it('recovers an expired stale main lock after acquiring the guard', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const staleOwner = makeOwner({ token: 'expired-main-lock', expiresAtMs: now - 1 }); + writeOwnerSync(lockDir, staleOwner); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).not.toBeNull(); + const currentOwner = readOwnerSync(lockDir); + expect(currentOwner.token).toBe(lock?.owner.token); + expect(currentOwner.token).not.toBe(staleOwner.token); + lock?.release(); + }); + + it('does not recover an expired lock owned by a live process', () => { + const tempDir = makeTempDirSync(); + const lockDir = lockDirFor(tempDir); + const now = Date.UTC(2026, 4, 2, 12); + const liveOwner = makeOwner({ + pid: process.pid, + token: 'live-main-lock', + expiresAtMs: now - 1, + }); + writeOwnerSync(lockDir, liveOwner); + + const lock = tryAcquireFsLockSync({ lockDir, purpose: PURPOSE, leaseMs: LEASE_MS, now }); + + expect(lock).toBeNull(); + expect(readOwnerSync(lockDir)).toMatchObject(liveOwner); + }); +}); diff --git a/src/utils/__tests__/log-paths.test.ts b/src/utils/__tests__/log-paths.test.ts index d437d3d18..e5e4c4ebf 100644 --- a/src/utils/__tests__/log-paths.test.ts +++ b/src/utils/__tests__/log-paths.test.ts @@ -11,6 +11,15 @@ describe('log paths', () => { setXcodeBuildMCPAppDirOverrideForTests(null); }); + it('rejects relative path segment workspace keys', () => { + expect(() => getWorkspaceFilesystemLayout('.')).toThrow( + 'Workspace key cannot be a relative path segment', + ); + expect(() => getWorkspaceFilesystemLayout('..')).toThrow( + 'Workspace key cannot be a relative path segment', + ); + }); + it('builds the workspace-first filesystem layout', () => { const appDir = path.join('/tmp', 'xcodebuildmcp-app'); setXcodeBuildMCPAppDirOverrideForTests(appDir); diff --git a/src/utils/__tests__/purge-storage.test.ts b/src/utils/__tests__/purge-storage.test.ts new file mode 100644 index 000000000..b16577383 --- /dev/null +++ b/src/utils/__tests__/purge-storage.test.ts @@ -0,0 +1,581 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + existsSync, + lstatSync, + mkdirSync, + mkdtempSync, + rmSync, + symlinkSync, + utimesSync, + writeFileSync, +} from 'node:fs'; +import { rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import * as path from 'node:path'; +import { + enumeratePurgeStorage, + executePurgeStoragePlan, + parseWorkspaceFamilyKey, + planPurgeStorage, + type PurgeStoragePlan, +} from '../purge-storage.ts'; +import { + getWorkspaceFilesystemLayout, + setXcodeBuildMCPAppDirOverrideForTests, +} from '../log-paths.ts'; +import { getResultBundleCompletionMarkerPath } from '../result-bundle-path.ts'; + +const DAY_MS = 24 * 60 * 60 * 1000; +const DEAD_PID = 999_999_999; + +let appDir: string; + +function writeFileWithMtime(filePath: string, content: string, mtimeMs: number): void { + mkdirSync(path.dirname(filePath), { recursive: true }); + writeFileSync(filePath, content); + const mtime = new Date(mtimeMs); + utimesSync(filePath, mtime, mtime); +} + +function writeDirectoryWithMtime(dir: string, mtimeMs: number): void { + mkdirSync(dir, { recursive: true }); + writeFileWithMtime(path.join(dir, 'file.txt'), 'content', mtimeMs); + const mtime = new Date(mtimeMs); + utimesSync(dir, mtime, mtime); +} + +function managedLogName(name = 'build_sim'): string { + return `${name}_2026-05-02T12-00-00-000Z_pid123_abcdef12.log`; +} + +function managedSimulatorLogName(helperPid: number): string { + return `simulator_2026-05-02T12-00-00-000Z_helperpid${helperPid}_ownerpid123_abcdef12.log`; +} + +function managedResultBundleName(name = 'test', pid = DEAD_PID): string { + return `${name}_2026-05-02T12-00-00-000Z_pid${pid}_abcdef12.xcresult`; +} + +describe('purge storage', () => { + beforeEach(() => { + appDir = mkdtempSync(path.join(tmpdir(), 'xcodebuildmcp-purge-storage-')); + setXcodeBuildMCPAppDirOverrideForTests(appDir); + }); + + afterEach(async () => { + setXcodeBuildMCPAppDirOverrideForTests(null); + await rm(appDir, { recursive: true, force: true }); + }); + + it('parses only exact lowercase workspace family keys', () => { + expect(parseWorkspaceFamilyKey('DemoApp-123456789abc')).toEqual({ + family: 'DemoApp', + hash: '123456789abc', + }); + expect(parseWorkspaceFamilyKey('DemoApp-Main-123456789abc')).toEqual({ + family: 'DemoApp-Main', + hash: '123456789abc', + }); + expect(parseWorkspaceFamilyKey('DemoApp-123456789ABC')).toBeNull(); + expect(parseWorkspaceFamilyKey('DemoApp')).toBeNull(); + }); + + it('enumerates workspace storage and groups only recognized family keys', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const first = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const second = getWorkspaceFilesystemLayout('DemoApp-abcdefabcdef'); + const separateFamily = getWorkspaceFilesystemLayout('DemoApp-Main-123456789abc'); + const unknown = getWorkspaceFilesystemLayout('manual-workspace'); + writeFileWithMtime(path.join(first.logs, managedLogName()), 'old', now); + writeFileWithMtime(path.join(second.derivedData, 'Build', 'artifact'), 'derived', now); + mkdirSync(separateFamily.root, { recursive: true }); + mkdirSync(unknown.root, { recursive: true }); + + const report = await enumeratePurgeStorage({ now }); + + expect(report.workspaces.map((workspace) => workspace.workspaceKey)).toEqual([ + 'DemoApp-123456789abc', + 'DemoApp-abcdefabcdef', + 'DemoApp-Main-123456789abc', + 'manual-workspace', + ]); + expect(report.families.map((family) => [family.family, family.workspaceKeys])).toEqual([ + ['DemoApp', ['DemoApp-123456789abc', 'DemoApp-abcdefabcdef']], + ['DemoApp-Main', ['DemoApp-Main-123456789abc']], + ]); + expect( + report.workspaces.find((workspace) => workspace.workspaceKey === 'manual-workspace'), + ).toMatchObject({ + recognized: false, + family: null, + hash: null, + }); + expect(report.totals.bytes).toBeGreaterThan(0); + }); + + it('skips symlinks during recursive census scans', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const outside = path.join(appDir, 'outside'); + writeFileWithMtime(path.join(outside, 'secret.txt'), 'outside', now); + mkdirSync(layout.derivedData, { recursive: true }); + symlinkSync(outside, path.join(layout.derivedData, 'linked-outside')); + + const report = await enumeratePurgeStorage({ now }); + const workspace = report.workspaces[0]; + + expect(workspace.classes.derivedData.fileCount).toBe(0); + expect(workspace.classes.derivedData.scanComplete).toBe(false); + expect(workspace.classes.derivedData.warnings.join('\n')).toContain('symbolic link skipped'); + }); + + it('skips recent managed logs during planning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const recentLog = path.join(layout.logs, managedLogName('recent')); + writeFileWithMtime(recentLog, 'recent', now - 1000); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped).toHaveLength(1); + expect(plan.skipped[0].path).toBe(recentLog); + expect(plan.skipped[0].reason).toContain('lifecycle visibility window'); + expect(existsSync(recentLog)).toBe(true); + }); + + it('skips helper-owned managed logs during planning', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const helperLog = path.join(layout.logs, managedSimulatorLogName(process.pid)); + writeFileWithMtime(helperLog, 'helper', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped).toHaveLength(1); + expect(plan.skipped[0].path).toBe(helperLog); + expect(plan.skipped[0].reason).toContain('active helper process'); + expect(existsSync(helperLog)).toBe(true); + }); + + it('plans managed logs and result bundles without selecting unknown files', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + const unknownLog = path.join(layout.logs, 'manual.log'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + writeFileWithMtime(unknownLog, 'unknown', now - 10 * DAY_MS); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + writeFileWithMtime(getResultBundleCompletionMarkerPath(bundle), 'done', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs', 'resultBundles'], + now, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + managedLogName('old'), + managedResultBundleName('old'), + ]); + expect(plan.candidates.some((candidate) => candidate.path === unknownLog)).toBe(false); + }); + + it('requires DerivedData to be explicitly enabled before planning DerivedData candidates', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + writeDirectoryWithMtime(path.join(layout.derivedData, 'Project-a'), now - 10 * DAY_MS); + + await expect( + planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + }), + ).rejects.toThrow('DerivedData purge requires derivedDataExplicit: true'); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(1); + expect(path.basename(plan.candidates[0].path)).toBe('Project-a'); + }); + + it('applies the older-than filter but otherwise deletes recent artifacts', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldDerivedData = path.join(layout.derivedData, 'OldProject'); + const newDerivedData = path.join(layout.derivedData, 'NewProject'); + const recentDerivedData = path.join(layout.derivedData, 'RecentProject'); + writeDirectoryWithMtime(oldDerivedData, now - 30 * DAY_MS); + writeDirectoryWithMtime(newDerivedData, now - 2 * DAY_MS); + writeDirectoryWithMtime(recentDerivedData, now - 1000); + + const retentionPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(retentionPlan.candidates.map((candidate) => path.basename(candidate.path))).toEqual([ + 'OldProject', + ]); + expect(retentionPlan.skipped.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'NewProject', + 'RecentProject', + ]); + + const fullPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + + expect(fullPlan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'NewProject', + 'OldProject', + 'RecentProject', + ]); + expect(fullPlan.skipped).toHaveLength(0); + }); + + it('excludes unknown workspace keys from all and family scopes but allows explicit workspace scope', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const recognized = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const unknown = getWorkspaceFilesystemLayout('manual-workspace'); + writeFileWithMtime( + path.join(recognized.logs, managedLogName('known')), + 'known', + now - 10 * DAY_MS, + ); + writeFileWithMtime( + path.join(unknown.logs, managedLogName('unknown')), + 'unknown', + now - 10 * DAY_MS, + ); + + const allPlan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + const familyPlan = await planPurgeStorage({ + scope: { type: 'family', family: 'manual-workspace' }, + classes: ['logs'], + now, + }); + const explicitPlan = await planPurgeStorage({ + scope: { type: 'workspace', workspaceKey: 'manual-workspace' }, + classes: ['logs'], + now, + }); + + expect(allPlan.selectedWorkspaceKeys).toEqual(['DemoApp-123456789abc']); + expect(familyPlan.selectedWorkspaceKeys).toEqual([]); + expect(explicitPlan.selectedWorkspaceKeys).toEqual(['manual-workspace']); + expect(explicitPlan.candidates).toHaveLength(1); + }); + + it('plans only allowlisted stale state transients', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const staleCallTool = path.join( + layout.state, + 'xcode-ide', + 'call-tool', + 'ownerpid999999999_stale', + ); + const liveCallTool = path.join( + layout.state, + 'xcode-ide', + 'call-tool', + `ownerpid${process.pid}_live`, + ); + const staleRegistry = path.join(layout.simulatorLaunchOsLogRegistryDir, 'stale.json'); + const liveRegistry = path.join(layout.simulatorLaunchOsLogRegistryDir, 'live.json'); + writeDirectoryWithMtime(staleCallTool, now - 10 * DAY_MS); + writeDirectoryWithMtime(liveCallTool, now - 10 * DAY_MS); + writeFileWithMtime( + staleRegistry, + JSON.stringify({ owner: { pid: DEAD_PID }, helperPid: DEAD_PID }), + now - 10 * DAY_MS, + ); + writeFileWithMtime( + liveRegistry, + JSON.stringify({ owner: { pid: process.pid }, helperPid: DEAD_PID }), + now - 10 * DAY_MS, + ); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['stateTransients'], + now, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path)).sort()).toEqual([ + 'ownerpid999999999_stale', + 'stale.json', + ]); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual(['live.json']); + }); + + it('revalidates helper-owned managed logs before deletion', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const workspaceKey = 'DemoApp-123456789abc'; + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const helperLog = path.join(layout.logs, managedSimulatorLogName(process.pid)); + writeFileWithMtime(helperLog, 'helper', now - 10 * DAY_MS); + const stat = lstatSync(helperLog); + const report = await enumeratePurgeStorage({ now }); + const plan: PurgeStoragePlan = { + action: 'dry-run', + scope: { type: 'all' }, + classes: ['logs'], + report, + selectedWorkspaceKeys: [workspaceKey], + candidates: [ + { + workspaceKey, + storageClass: 'logs', + path: helperLog, + kind: 'file', + bytes: stat.size, + fileCount: 1, + directoryCount: 0, + mtimeMs: stat.mtimeMs, + reason: 'managed log', + sidecarPaths: [], + }, + ], + skipped: [], + totals: { + bytes: stat.size, + fileCount: 1, + directoryCount: 0, + candidateCount: 1, + }, + warnings: [], + }; + + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('active helper process'); + expect(existsSync(helperLog)).toBe(true); + }); + + it('deletes planned candidates and managed result-bundle completion markers', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + const marker = getResultBundleCompletionMarkerPath(bundle); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + writeFileWithMtime(marker, 'done', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(existsSync(bundle)).toBe(false); + expect(existsSync(marker)).toBe(false); + }); + + it('reports deleted result bundles even when a sidecar cannot be unlinked', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const bundle = path.join(layout.resultBundles, managedResultBundleName('old')); + const marker = getResultBundleCompletionMarkerPath(bundle); + writeDirectoryWithMtime(bundle, now - 10 * DAY_MS); + mkdirSync(marker, { recursive: true }); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(result.warnings.join('\n')).toContain('Sidecar'); + expect(existsSync(bundle)).toBe(false); + expect(existsSync(marker)).toBe(true); + }); + + it('deletes orphan result-bundle completion temp markers', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const tempMarker = path.join( + layout.resultBundles, + `${managedResultBundleName('old')}.xcodebuildmcp-completed.1234_abcd1234.tmp`, + ); + writeFileWithMtime(tempMarker, 'tmp', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['resultBundles'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(plan.candidates.map((candidate) => candidate.path)).toEqual([tempMarker]); + expect(result.totals.deletedCount).toBe(1); + expect(result.totals.skippedCount).toBe(0); + expect(existsSync(tempMarker)).toBe(false); + }); + + it('skips deletion when the per-workspace lifecycle lock is held', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + mkdirSync(layout.filesystemLifecycle.lockDir, { recursive: true }); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.warnings.join('\n')).toContain('filesystem lock is held'); + expect(existsSync(oldLog)).toBe(true); + }); + + it('reports planned candidates that disappear before deletion as skipped', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const oldLog = path.join(layout.logs, managedLogName('old')); + writeFileWithMtime(oldLog, 'old', now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['logs'], + now, + }); + rmSync(oldLog, { force: true }); + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('disappeared'); + }); + + it('revalidates containment and symlink ancestors under the lock before deleting', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const project = path.join(layout.derivedData, 'Project-a'); + const outsideDerivedData = path.join(appDir, 'outside-derived-data'); + const outsideProject = path.join(outsideDerivedData, 'Project-a'); + writeDirectoryWithMtime(project, now - 10 * DAY_MS); + writeDirectoryWithMtime(outsideProject, now - 10 * DAY_MS); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + derivedDataExplicit: true, + }); + rmSync(layout.derivedData, { recursive: true, force: true }); + symlinkSync(outsideDerivedData, layout.derivedData); + + const result = await executePurgeStoragePlan(plan, { now }); + + expect(result.totals.deletedCount).toBe(0); + expect(result.totals.skippedCount).toBe(1); + expect(result.skipped[0].reason).toContain('symbolic link'); + expect(existsSync(outsideProject)).toBe(true); + }); + + it('uses newest file mtime for non-empty directory retention instead of directory metadata mtime', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const freshDirOldFile = path.join(layout.derivedData, 'FreshDirOldFile'); + writeFileWithMtime(path.join(freshDirOldFile, 'old.txt'), 'old', now - 30 * DAY_MS); + const freshMtime = new Date(now - 1000); + utimesSync(freshDirOldFile, freshMtime, freshMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates.map((candidate) => path.basename(candidate.path))).toEqual([ + 'FreshDirOldFile', + ]); + expect(plan.skipped).toHaveLength(0); + }); + + it('uses directory mtime as the older-than fallback for empty directory candidates', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const emptyRecentDir = path.join(layout.derivedData, 'EmptyRecentDir'); + mkdirSync(emptyRecentDir, { recursive: true }); + const freshMtime = new Date(now - 1000); + utimesSync(emptyRecentDir, freshMtime, freshMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual([ + 'EmptyRecentDir', + ]); + }); + + it('uses newest file mtime to keep non-empty directory candidates with fresh files', async () => { + const now = Date.UTC(2026, 4, 2, 12); + const layout = getWorkspaceFilesystemLayout('DemoApp-123456789abc'); + const staleDirFreshFile = path.join(layout.derivedData, 'StaleDirFreshFile'); + mkdirSync(staleDirFreshFile, { recursive: true }); + writeFileWithMtime(path.join(staleDirFreshFile, 'fresh.txt'), 'fresh', now - 1000); + const oldMtime = new Date(now - 30 * DAY_MS); + utimesSync(staleDirFreshFile, oldMtime, oldMtime); + + const plan = await planPurgeStorage({ + scope: { type: 'all' }, + classes: ['derivedData'], + now, + olderThanMs: 7 * DAY_MS, + derivedDataExplicit: true, + }); + + expect(plan.candidates).toHaveLength(0); + expect(plan.skipped.map((candidate) => path.basename(candidate.path))).toEqual([ + 'StaleDirFreshFile', + ]); + }); +}); diff --git a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts index 1cd8b5893..0d45666c8 100644 --- a/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts +++ b/src/utils/__tests__/workspace-filesystem-lifecycle.test.ts @@ -7,6 +7,11 @@ import { tmpdir } from 'node:os'; import * as path from 'node:path'; import { cleanupOwnedWorkspaceFilesystemArtifacts, + getManagedResultBundleOwnerPid, + getXcodeIdeCallToolTransientOwnerPid, + isStaleXcodeIdeCallToolTransientDirectoryName, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, resetWorkspaceFilesystemLifecycleStateForTests, runWorkspaceFilesystemLifecycleSweep, scheduleWorkspaceFilesystemLifecycleSweep, @@ -81,6 +86,22 @@ describe('workspace filesystem lifecycle', () => { await rm(appDir, { recursive: true, force: true }); }); + it('exposes cleanup classification helpers without broadening managed artifact matches', () => { + const logName = managedXcodebuildLogName(); + const resultBundleName = managedResultBundleName('test', 123); + + expect(isXcodeBuildMCPManagedLogName(logName)).toBe(true); + expect(isXcodeBuildMCPManagedLogName('manual.log')).toBe(false); + expect(isXcodeBuildMCPManagedResultBundleName(resultBundleName)).toBe(true); + expect(isXcodeBuildMCPManagedResultBundleName('manual.xcresult')).toBe(false); + expect(getManagedResultBundleOwnerPid(resultBundleName)).toBe(123); + expect(getXcodeIdeCallToolTransientOwnerPid('ownerpid999999999_stale')).toBe(999999999); + expect(isStaleXcodeIdeCallToolTransientDirectoryName('ownerpid999999999_stale')).toBe(true); + expect(isStaleXcodeIdeCallToolTransientDirectoryName(`ownerpid${process.pid}_live`)).toBe( + false, + ); + }); + it('prunes only known workspace log files and never scans DerivedData', async () => { const now = Date.UTC(2026, 4, 2, 12); const layout = getWorkspaceFilesystemLayout('workspace-a'); diff --git a/src/utils/fs-lock-sync.ts b/src/utils/fs-lock-sync.ts index b10b18e55..146d40134 100644 --- a/src/utils/fs-lock-sync.ts +++ b/src/utils/fs-lock-sync.ts @@ -103,10 +103,10 @@ function tryRecoverExpiredLockDir( function createLock(lockDir: string, owner: FsLockOwner): AcquiredFsLockSync { mkdirSync(lockDir, { mode: 0o700 }); try { - writeFileSync(join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); + const ownerPath = join(lockDir, FS_LOCK_OWNER_FILE); + const tempPath = `${ownerPath}.${randomUUID()}.tmp`; + writeFileSync(tempPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 }); + renameSync(tempPath, ownerPath); } catch (error) { rmSync(lockDir, { recursive: true, force: true }); throw error; diff --git a/src/utils/fs-lock.ts b/src/utils/fs-lock.ts index 8d0660f30..09f9c4554 100644 --- a/src/utils/fs-lock.ts +++ b/src/utils/fs-lock.ts @@ -128,10 +128,10 @@ async function tryRecoverExpiredLockDir( async function createLock(lockDir: string, owner: FsLockOwner): Promise { await fs.mkdir(lockDir, { mode: 0o700 }); try { - await fs.writeFile(path.join(lockDir, FS_LOCK_OWNER_FILE), `${JSON.stringify(owner)}\n`, { - encoding: 'utf8', - mode: 0o600, - }); + const ownerPath = path.join(lockDir, FS_LOCK_OWNER_FILE); + const tempPath = `${ownerPath}.${randomUUID()}.tmp`; + await fs.writeFile(tempPath, `${JSON.stringify(owner)}\n`, { encoding: 'utf8', mode: 0o600 }); + await fs.rename(tempPath, ownerPath); } catch (error) { await removeLockDir(lockDir); throw error; @@ -168,7 +168,7 @@ async function tryAcquireGuard( return await createLock(guardDir, guardOwner); } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { - return null; + throw error; } const recovered = await tryRecoverExpiredLockDir(guardDir, guardOwner.purpose, now, leaseMs); if (!recovered) { @@ -176,8 +176,11 @@ async function tryAcquireGuard( } try { return await createLock(guardDir, guardOwner); - } catch { - return null; + } catch (retryError) { + if ((retryError as NodeJS.ErrnoException).code === 'EEXIST') { + return null; + } + throw retryError; } } } @@ -194,39 +197,34 @@ export async function tryAcquireFsLock( expiresAtMs: now + options.leaseMs, }; + await fs.mkdir(path.dirname(options.lockDir), { recursive: true, mode: 0o700 }); + const guard = await tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); + if (!guard) { + return null; + } + try { - await fs.mkdir(path.dirname(options.lockDir), { recursive: true, mode: 0o700 }); - const guard = await tryAcquireGuard(options.lockDir, options.purpose, options.leaseMs, now); - if (!guard) { - return null; - } + for (let attempt = 0; attempt < 2; attempt += 1) { + try { + return await createLock(options.lockDir, owner); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST') { + throw error; + } - try { - for (let attempt = 0; attempt < 2; attempt += 1) { - try { - return await createLock(options.lockDir, owner); - } catch (error) { - const code = (error as NodeJS.ErrnoException).code; - if (code !== 'EEXIST') { - return null; - } - - const recovered = await tryRecoverExpiredLockDir( - options.lockDir, - options.purpose, - now, - options.leaseMs, - ); - if (!recovered) { - return null; - } + const recovered = await tryRecoverExpiredLockDir( + options.lockDir, + options.purpose, + now, + options.leaseMs, + ); + if (!recovered) { + return null; } } - } finally { - await guard.release(); } - } catch { - return null; + } finally { + await guard.release(); } return null; diff --git a/src/utils/log-paths.ts b/src/utils/log-paths.ts index 234042b01..b6faf7ff8 100644 --- a/src/utils/log-paths.ts +++ b/src/utils/log-paths.ts @@ -44,6 +44,9 @@ function normalizeWorkspaceKey(workspaceKey: string): string { if (normalized.includes('/') || normalized.includes('\\')) { throw new Error(`Workspace key cannot contain path separators: ${workspaceKey}`); } + if (normalized === '.' || normalized === '..') { + throw new Error(`Workspace key cannot be a relative path segment: ${workspaceKey}`); + } return normalized; } diff --git a/src/utils/purge-storage.ts b/src/utils/purge-storage.ts new file mode 100644 index 000000000..4b2bc814a --- /dev/null +++ b/src/utils/purge-storage.ts @@ -0,0 +1,23 @@ +export { + PURGE_STORAGE_CLASSES, + PURGE_STORAGE_DELETABLE_CLASSES, + type EnumeratePurgeStorageOptions, + type ExecutePurgeStoragePlanOptions, + type PlanPurgeStorageOptions, + type PurgeStorageCandidate, + type PurgeStorageClass, + type PurgeStorageClassCensus, + type PurgeStorageDeletableClass, + type PurgeStorageDeletedEntry, + type PurgeStorageExecutionResult, + type PurgeStoragePlan, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeStorageSkippedCandidate, + type PurgeWorkspaceFamilySummary, + type PurgeWorkspaceSummary, + type WorkspaceFamilyKeyParts, +} from './purge-storage/types.ts'; +export { enumeratePurgeStorage, parseWorkspaceFamilyKey } from './purge-storage/enumerate.ts'; +export { planPurgeStorage } from './purge-storage/planning.ts'; +export { executePurgeStoragePlan } from './purge-storage/execution.ts'; diff --git a/src/utils/purge-storage/enumerate.ts b/src/utils/purge-storage/enumerate.ts new file mode 100644 index 000000000..fdc330fef --- /dev/null +++ b/src/utils/purge-storage/enumerate.ts @@ -0,0 +1,224 @@ +import type { Dirent } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { getWorkspaceFilesystemLayout, getWorkspacesDir } from '../log-paths.ts'; +import { + PURGE_STORAGE_CLASSES, + type EnumeratePurgeStorageOptions, + type PurgeStorageClass, + type PurgeStorageClassCensus, + type PurgeStorageReport, + type PurgeStorageScope, + type PurgeWorkspaceFamilySummary, + type PurgeWorkspaceSummary, + type WorkspaceFamilyKeyParts, +} from './types.ts'; +import { isEnoent, scanPath, warningForPath, zeroAccumulator } from './scan.ts'; + +const WORKSPACE_FAMILY_KEY_PATTERN = /^(.+)-([a-f0-9]{12})$/u; + +export function parseWorkspaceFamilyKey(workspaceKey: string): WorkspaceFamilyKeyParts | null { + const match = workspaceKey.match(WORKSPACE_FAMILY_KEY_PATTERN); + if (!match) { + return null; + } + return { family: match[1], hash: match[2] }; +} + +function classPathForWorkspace(workspaceKey: string, storageClass: PurgeStorageClass): string { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + switch (storageClass) { + case 'derivedData': + return layout.derivedData; + case 'logs': + return layout.logs; + case 'resultBundles': + return layout.resultBundles; + case 'stateTransients': + return layout.state; + case 'locks': + return layout.locks; + } +} + +async function scanClass( + workspaceKey: string, + storageClass: PurgeStorageClass, +): Promise { + const classPath = classPathForWorkspace(workspaceKey, storageClass); + let exists = true; + try { + await fs.lstat(classPath); + } catch (error) { + if (isEnoent(error)) { + exists = false; + } + } + + const scan = exists ? await scanPath(classPath) : zeroAccumulator(); + return { + storageClass, + path: classPath, + exists, + bytes: scan.bytes, + fileCount: scan.fileCount, + directoryCount: scan.directoryCount, + latestMtimeMs: scan.latestMtimeMs, + scanComplete: scan.scanComplete, + cleanupEligible: storageClass !== 'locks', + warnings: scan.warnings, + }; +} + +function classRecordFromEntries( + entries: [PurgeStorageClass, PurgeStorageClassCensus][], +): Record { + return Object.fromEntries(entries) as Record; +} + +function sumStorageTotals( + items: T[], + select: (item: T) => { bytes: number; fileCount: number; directoryCount: number }, +): { bytes: number; fileCount: number; directoryCount: number } { + return items.reduce( + (totals, item) => { + const value = select(item); + return { + bytes: totals.bytes + value.bytes, + fileCount: totals.fileCount + value.fileCount, + directoryCount: totals.directoryCount + value.directoryCount, + }; + }, + { bytes: 0, fileCount: 0, directoryCount: 0 }, + ); +} + +async function summarizeWorkspace(workspaceKey: string): Promise { + const parsed = parseWorkspaceFamilyKey(workspaceKey); + const workspaceRoot = getWorkspaceFilesystemLayout(workspaceKey).root; + const classEntries = await Promise.all( + PURGE_STORAGE_CLASSES.map(async (storageClass) => { + const census = await scanClass(workspaceKey, storageClass); + return [storageClass, census] satisfies [PurgeStorageClass, PurgeStorageClassCensus]; + }), + ); + const classes = classRecordFromEntries(classEntries); + const classValues = Object.values(classes); + const warnings = classValues.flatMap((census) => census.warnings); + if (!parsed) { + warnings.push( + `${workspaceRoot}: unknown workspace key; only explicit workspace scope can purge it`, + ); + } + + return { + workspaceKey, + path: workspaceRoot, + recognized: parsed !== null, + family: parsed?.family ?? null, + hash: parsed?.hash ?? null, + classes, + totals: sumStorageTotals(classValues, (census) => census), + warnings, + }; +} + +function buildFamilySummaries(workspaces: PurgeWorkspaceSummary[]): PurgeWorkspaceFamilySummary[] { + const families = new Map(); + for (const workspace of workspaces) { + if (!workspace.recognized || workspace.family === null) { + continue; + } + const family = families.get(workspace.family) ?? { + family: workspace.family, + workspaceKeys: [], + bytes: 0, + }; + family.workspaceKeys.push(workspace.workspaceKey); + family.bytes += workspace.totals.bytes; + families.set(workspace.family, family); + } + + return Array.from(families.values()).sort((left, right) => + left.family.localeCompare(right.family), + ); +} + +function workspaceKeyMatchesScope(workspaceKey: string, scope: PurgeStorageScope): boolean { + switch (scope.type) { + case 'workspace': + return workspaceKey === scope.workspaceKey; + case 'workspaces': + return scope.workspaceKeys.includes(workspaceKey); + case 'family': { + const parsed = parseWorkspaceFamilyKey(workspaceKey); + return parsed !== null && parsed.family === scope.family; + } + case 'all': + return parseWorkspaceFamilyKey(workspaceKey) !== null; + } +} + +function workspaceKeysForScope( + workspaceKeys: string[], + scope: PurgeStorageScope | undefined, +): string[] { + if (scope === undefined) { + return workspaceKeys; + } + return workspaceKeys.filter((workspaceKey) => workspaceKeyMatchesScope(workspaceKey, scope)); +} + +async function listWorkspaceKeys(warnings: string[]): Promise { + const workspacesDir = getWorkspacesDir(); + let entries: Dirent[]; + try { + entries = await fs.readdir(workspacesDir, { withFileTypes: true }); + } catch (error) { + if (isEnoent(error)) { + return []; + } + warnings.push(error instanceof Error ? error.message : String(error)); + return []; + } + + const workspaceKeys: string[] = []; + for (const entry of entries) { + const entryPath = path.join(workspacesDir, entry.name); + if (entry.name.includes('/') || entry.name.includes('\\')) { + warnings.push( + warningForPath(entryPath, 'workspace key contains a path separator and was skipped'), + ); + continue; + } + if (entry.isSymbolicLink()) { + warnings.push(warningForPath(entryPath, 'workspace symbolic link skipped')); + continue; + } + if (!entry.isDirectory()) { + continue; + } + workspaceKeys.push(entry.name); + } + return workspaceKeys.sort((left, right) => left.localeCompare(right)); +} + +export async function enumeratePurgeStorage( + options: EnumeratePurgeStorageOptions = {}, +): Promise { + const warnings: string[] = []; + const workspacesDir = getWorkspacesDir(); + const workspaceKeys = workspaceKeysForScope(await listWorkspaceKeys(warnings), options.scope); + const workspaces = await Promise.all(workspaceKeys.map(summarizeWorkspace)); + const families = buildFamilySummaries(workspaces); + warnings.push(...workspaces.flatMap((workspace) => workspace.warnings)); + + return { + appRoot: path.dirname(workspacesDir), + workspacesDir, + workspaces, + families, + totals: sumStorageTotals(workspaces, (workspace) => workspace.totals), + warnings, + }; +} diff --git a/src/utils/purge-storage/execution.ts b/src/utils/purge-storage/execution.ts new file mode 100644 index 000000000..6e89c52d3 --- /dev/null +++ b/src/utils/purge-storage/execution.ts @@ -0,0 +1,394 @@ +import type { Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { tryAcquireFsLock, type AcquiredFsLock } from '../fs-lock.ts'; +import { getWorkspaceFilesystemLayout, getWorkspacesDir } from '../log-paths.ts'; +import { isPidAlive } from '../process-liveness.ts'; +import { + getResultBundleCompletionMarkerPath, + isResultBundleCompletionMarkerTempName, +} from '../result-bundle-path.ts'; +import { + WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + collectWorkspaceLifecycleProtectedLogPaths, + getWorkspaceLifecycleProtectedLogReason, + isStaleXcodeIdeCallToolTransientDirectoryName, + isWorkspaceLifecycleProtectedResultBundleDirectory, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, + xcodeIdeCallToolTransientRoot, + type WorkspaceLifecycleLogProtectionReason, +} from '../workspace-filesystem-lifecycle.ts'; +import { readRegistryRecord } from './registry-record.ts'; +import { describeFsError, errorMessage, isEnoent } from './scan.ts'; +import type { + ExecutePurgeStoragePlanOptions, + PurgeStorageCandidate, + PurgeStorageDeletableClass, + PurgeStorageDeletedEntry, + PurgeStorageExecutionResult, + PurgeStoragePlan, + PurgeStorageSkippedCandidate, +} from './types.ts'; + +const PURGE_LOCK_PURPOSE = 'filesystem-lifecycle'; + +function pathIsInside(parentPath: string, childPath: string): boolean { + const relative = path.relative(parentPath, childPath); + return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative)); +} + +function lifecycleLogProtectionReasonText(reason: WorkspaceLifecycleLogProtectionReason): string { + switch (reason) { + case 'protectedPath': + return 'protected by active lifecycle owner'; + case 'recent': + return 'protected by lifecycle visibility window'; + case 'liveHelperPid': + return 'protected by active helper process'; + } +} + +function deletionRootForClass( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, +): string { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + switch (storageClass) { + case 'derivedData': + return layout.derivedData; + case 'logs': + return layout.logs; + case 'resultBundles': + return layout.resultBundles; + case 'stateTransients': + return layout.state; + } +} + +async function pathContainsSymlink(filePath: string, rootPath: string): Promise { + const relative = path.relative(rootPath, filePath); + if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) { + return true; + } + + let current = rootPath; + for (const segment of relative.split(path.sep)) { + current = path.join(current, segment); + try { + const stat = await fs.lstat(current); + if (stat.isSymbolicLink()) { + return true; + } + } catch { + return true; + } + } + return false; +} + +async function validateStateTransientCandidate( + candidate: PurgeStorageCandidate, +): Promise { + const layout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + const name = path.basename(candidate.path); + const callToolRoot = xcodeIdeCallToolTransientRoot(candidate.workspaceKey); + if ( + path.dirname(candidate.path) === callToolRoot && + isStaleXcodeIdeCallToolTransientDirectoryName(name) + ) { + return null; + } + if ( + path.dirname(candidate.path) !== layout.simulatorLaunchOsLogRegistryDir || + !name.endsWith('.json') + ) { + return 'state transient candidate is outside allowlisted transient roots'; + } + + const result = await readRegistryRecord(candidate.path); + if (result.status === 'unreadable') { + return 'state transient candidate registry record is unreadable; protected'; + } + if ( + result.status === 'record' && + (isPidAlive(result.record.ownerPid) || isPidAlive(result.record.helperPid)) + ) { + return 'state transient candidate is protected by active OSLog owner'; + } + return null; +} + +async function validateClassSpecificDeletionCandidate( + candidate: PurgeStorageCandidate, + now: number, +): Promise { + const name = path.basename(candidate.path); + switch (candidate.storageClass) { + case 'derivedData': + return null; + case 'logs': { + if (!isXcodeBuildMCPManagedLogName(name)) { + return 'log candidate is not a managed log'; + } + const protectionReason = getWorkspaceLifecycleProtectedLogReason( + { path: candidate.path, name, mtimeMs: candidate.mtimeMs }, + { + now, + minVisibleMs: WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + protectedPaths: await collectWorkspaceLifecycleProtectedLogPaths({ + workspaceKey: candidate.workspaceKey, + }), + }, + ); + if (protectionReason) { + return `log candidate is ${lifecycleLogProtectionReasonText(protectionReason)}`; + } + return null; + } + case 'resultBundles': + if (isResultBundleCompletionMarkerTempName(name)) { + return candidate.kind === 'file' + ? null + : 'result bundle temp marker candidate is not a file'; + } + if (!isXcodeBuildMCPManagedResultBundleName(name)) { + return 'result bundle candidate is not managed'; + } + if ( + await isWorkspaceLifecycleProtectedResultBundleDirectory( + { name, path: candidate.path, mtimeMs: candidate.mtimeMs }, + { now, minVisibleMs: 0 }, + ) + ) { + return 'result bundle candidate is protected by active lifecycle owner'; + } + return null; + case 'stateTransients': + return validateStateTransientCandidate(candidate); + } +} + +async function validateDeletionPath( + candidate: PurgeStorageCandidate, + now: number, +): Promise { + const workspaceLayout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + const root = deletionRootForClass(candidate.workspaceKey, candidate.storageClass); + if (candidate.path === root || !pathIsInside(root, candidate.path)) { + return 'candidate is outside the intended class root'; + } + if (!pathIsInside(workspaceLayout.root, candidate.path)) { + return 'candidate is outside the workspace root'; + } + if (await pathContainsSymlink(candidate.path, getWorkspacesDir())) { + return 'candidate path contains a symbolic link or disappeared'; + } + const stableError = await validateCandidateStillMatchesPlan(candidate); + if (stableError) { + return stableError; + } + return validateClassSpecificDeletionCandidate(candidate, now); +} + +async function validateCandidateStillMatchesPlan( + candidate: PurgeStorageCandidate, +): Promise { + let stat: Stats; + try { + stat = await fs.lstat(candidate.path); + } catch (error) { + return isEnoent(error) ? 'path disappeared during deletion' : String(error); + } + if (stat.isSymbolicLink()) { + return 'candidate path contains a symbolic link or disappeared'; + } + if (candidate.kind === 'directory' && !stat.isDirectory()) { + return 'candidate kind changed since planning'; + } + if (candidate.kind === 'file' && !stat.isFile()) { + return 'candidate kind changed since planning'; + } + if (stat.mtimeMs !== candidate.mtimeMs) { + return 'candidate changed since planning'; + } + return null; +} + +async function validateSidecarPath( + candidate: PurgeStorageCandidate, + sidecarPath: string, +): Promise { + if (candidate.storageClass !== 'resultBundles') { + return 'sidecar path is not associated with a result bundle'; + } + if (sidecarPath !== getResultBundleCompletionMarkerPath(candidate.path)) { + return 'sidecar path does not match the candidate completion marker'; + } + const layout = getWorkspaceFilesystemLayout(candidate.workspaceKey); + if (!pathIsInside(layout.resultBundles, sidecarPath)) { + return 'sidecar path is outside result-bundles'; + } + try { + await fs.lstat(sidecarPath); + } catch (error) { + return isEnoent(error) ? null : errorMessage(error); + } + if (await pathContainsSymlink(sidecarPath, getWorkspacesDir())) { + return 'sidecar path contains a symbolic link or disappeared'; + } + return null; +} + +async function deleteCandidate(candidate: PurgeStorageCandidate): Promise { + const warnings: string[] = []; + if (candidate.kind === 'directory') { + await fs.rm(candidate.path, { recursive: true, force: false }); + } else { + await fs.unlink(candidate.path); + } + + for (const sidecarPath of candidate.sidecarPaths) { + const sidecarError = await validateSidecarPath(candidate, sidecarPath); + if (sidecarError) { + continue; + } + await fs.unlink(sidecarPath).catch((error: unknown) => { + if (!isEnoent(error)) { + warnings.push(`Sidecar ${sidecarPath} was not deleted: ${errorMessage(error)}`); + } + }); + } + return warnings; +} + +function groupCandidatesByWorkspace( + candidates: PurgeStorageCandidate[], +): Map { + const candidatesByWorkspace = new Map(); + for (const candidate of candidates) { + const existing = candidatesByWorkspace.get(candidate.workspaceKey) ?? []; + existing.push(candidate); + candidatesByWorkspace.set(candidate.workspaceKey, existing); + } + return candidatesByWorkspace; +} + +function skipWorkspaceCandidates(params: { + workspaceKey: string; + candidates: PurgeStorageCandidate[]; + message: string; + skipped: PurgeStorageSkippedCandidate[]; +}): void { + for (const candidate of params.candidates) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: params.message, + }); + } +} + +async function executeWorkspaceCandidates(params: { + workspaceKey: string; + candidates: PurgeStorageCandidate[]; + deleted: PurgeStorageDeletedEntry[]; + skipped: PurgeStorageSkippedCandidate[]; + warnings: string[]; + now: number; +}): Promise { + for (const candidate of params.candidates) { + const validationError = await validateDeletionPath(candidate, params.now); + if (validationError) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: validationError, + }); + continue; + } + + try { + params.warnings.push(...(await deleteCandidate(candidate))); + params.deleted.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + bytes: candidate.bytes, + }); + } catch (error) { + params.skipped.push({ + workspaceKey: params.workspaceKey, + storageClass: candidate.storageClass, + path: candidate.path, + reason: describeFsError(error, 'path disappeared during deletion'), + }); + } + } +} + +export async function executePurgeStoragePlan( + plan: PurgeStoragePlan, + options: ExecutePurgeStoragePlanOptions = {}, +): Promise { + const deleted: PurgeStorageDeletedEntry[] = []; + const skipped: PurgeStorageSkippedCandidate[] = [...plan.skipped]; + const warnings: string[] = [...plan.warnings]; + + const now = options.now ?? Date.now(); + for (const [workspaceKey, candidates] of groupCandidatesByWorkspace(plan.candidates).entries()) { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + let lock: AcquiredFsLock | null; + try { + lock = await tryAcquireFsLock({ + lockDir: layout.filesystemLifecycle.lockDir, + purpose: PURGE_LOCK_PURPOSE, + leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + now, + }); + } catch (error) { + const message = `Workspace ${workspaceKey} skipped because its filesystem lock could not be acquired: ${errorMessage(error)}`; + warnings.push(message); + skipWorkspaceCandidates({ workspaceKey, candidates, message, skipped }); + continue; + } + + if (!lock) { + const message = `Workspace ${workspaceKey} skipped because its filesystem lock is held`; + warnings.push(message); + skipWorkspaceCandidates({ workspaceKey, candidates, message, skipped }); + continue; + } + + try { + await executeWorkspaceCandidates({ + workspaceKey, + candidates, + deleted, + skipped, + warnings, + now, + }); + } finally { + await lock.release(); + } + } + + return { + action: 'delete', + scope: plan.scope, + classes: plan.classes, + selectedWorkspaceKeys: plan.selectedWorkspaceKeys, + deleted, + skipped, + totals: { + bytes: deleted.reduce((total, entry) => total + entry.bytes, 0), + deletedCount: deleted.length, + skippedCount: skipped.length, + }, + warnings, + }; +} diff --git a/src/utils/purge-storage/planning.ts b/src/utils/purge-storage/planning.ts new file mode 100644 index 000000000..f6bec9a69 --- /dev/null +++ b/src/utils/purge-storage/planning.ts @@ -0,0 +1,538 @@ +import type { Dirent, Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import { getWorkspaceFilesystemLayout } from '../log-paths.ts'; +import { isPidAlive } from '../process-liveness.ts'; +import { + getResultBundleCompletionMarkerPath, + isResultBundleCompletionMarkerTempName, +} from '../result-bundle-path.ts'; +import { + WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + collectWorkspaceLifecycleProtectedLogPaths, + getWorkspaceLifecycleProtectedLogReason, + isStaleXcodeIdeCallToolTransientDirectoryName, + isWorkspaceLifecycleProtectedResultBundleDirectory, + isXcodeBuildMCPManagedLogName, + isXcodeBuildMCPManagedResultBundleName, + xcodeIdeCallToolTransientRoot, + type WorkspaceLifecycleLogProtectionReason, +} from '../workspace-filesystem-lifecycle.ts'; +import { enumeratePurgeStorage } from './enumerate.ts'; +import { readRegistryRecord } from './registry-record.ts'; +import { describeFsError, errorMessage, isEnoent, scanPath } from './scan.ts'; +import type { + PlanPurgeStorageOptions, + PurgeStorageCandidate, + PurgeStorageDeletableClass, + PurgeStoragePlan, + PurgeStorageReport, + PurgeStorageScope, + PurgeStorageSkippedCandidate, + PurgeWorkspaceSummary, + RequiredPlanContext, +} from './types.ts'; + +function assertValidPlanOptions(options: PlanPurgeStorageOptions): void { + if (options.classes.includes('derivedData') && options.derivedDataExplicit !== true) { + throw new Error('DerivedData purge requires derivedDataExplicit: true'); + } +} + +function selectedWorkspacesForScope( + report: PurgeStorageReport, + scope: PurgeStorageScope, + warnings: string[], +): PurgeWorkspaceSummary[] { + switch (scope.type) { + case 'workspace': { + const workspace = report.workspaces.find( + (entry) => entry.workspaceKey === scope.workspaceKey, + ); + if (!workspace) { + warnings.push(`Workspace ${scope.workspaceKey} was not found`); + return []; + } + return [workspace]; + } + case 'workspaces': { + const workspaceKeys = new Set(scope.workspaceKeys); + const selected = report.workspaces.filter((workspace) => + workspaceKeys.has(workspace.workspaceKey), + ); + for (const workspaceKey of workspaceKeys) { + if (!selected.some((workspace) => workspace.workspaceKey === workspaceKey)) { + warnings.push(`Workspace ${workspaceKey} was not found`); + } + } + return selected; + } + case 'family': + return report.workspaces.filter( + (workspace) => workspace.recognized && workspace.family === scope.family, + ); + case 'all': + return report.workspaces.filter((workspace) => workspace.recognized); + } +} + +function shouldKeepForRetention(params: { + mtimeMs: number; + now: number; + olderThanMs?: number; +}): string | null { + if (params.olderThanMs !== undefined && params.now - params.mtimeMs <= params.olderThanMs) { + return `newer than retention filter (${params.olderThanMs}ms)`; + } + return null; +} + +function retentionMtimeForCandidate(params: { + isDirectory: boolean; + statMtimeMs: number; + latestFileMtimeMs: number | null; + latestDirectoryMtimeMs: number | null; +}): number { + if (!params.isDirectory) { + return params.statMtimeMs; + } + return params.latestFileMtimeMs ?? params.latestDirectoryMtimeMs ?? params.statMtimeMs; +} + +function lifecycleLogProtectionReasonText(reason: WorkspaceLifecycleLogProtectionReason): string { + switch (reason) { + case 'protectedPath': + return 'protected by active lifecycle owner'; + case 'recent': + return 'protected by lifecycle visibility window'; + case 'liveHelperPid': + return 'protected by active helper process'; + } +} + +async function candidateFromPath(params: { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + candidatePath: string; + reason: string; + now: number; + olderThanMs?: number; + sidecarPaths?: string[]; +}): Promise { + const skip = (reason: string): PurgeStorageSkippedCandidate => ({ + workspaceKey: params.workspaceKey, + storageClass: params.storageClass, + path: params.candidatePath, + reason, + }); + + let stat: Stats; + try { + stat = await fs.lstat(params.candidatePath); + } catch (error) { + return skip(describeFsError(error, 'path disappeared during planning')); + } + + if (stat.isSymbolicLink()) { + return skip('symbolic link skipped'); + } + + if (!stat.isFile() && !stat.isDirectory()) { + return skip('non-regular filesystem entry skipped'); + } + + const scan = await scanPath(params.candidatePath); + const retentionMtimeMs = retentionMtimeForCandidate({ + isDirectory: stat.isDirectory(), + statMtimeMs: stat.mtimeMs, + latestFileMtimeMs: scan.latestFileMtimeMs, + latestDirectoryMtimeMs: scan.latestDirectoryMtimeMs, + }); + + const retentionReason = shouldKeepForRetention({ + mtimeMs: retentionMtimeMs, + now: params.now, + olderThanMs: params.olderThanMs, + }); + if (retentionReason) { + return skip(retentionReason); + } + + return { + workspaceKey: params.workspaceKey, + storageClass: params.storageClass, + path: params.candidatePath, + kind: stat.isDirectory() ? 'directory' : 'file', + bytes: scan.bytes, + fileCount: scan.fileCount, + directoryCount: scan.directoryCount, + mtimeMs: stat.mtimeMs, + reason: params.reason, + sidecarPaths: params.sidecarPaths ?? [], + }; +} + +function isCandidate( + candidate: PurgeStorageCandidate | PurgeStorageSkippedCandidate, +): candidate is PurgeStorageCandidate { + return 'bytes' in candidate; +} + +async function readManagedDir(dir: string): Promise<{ entries: Dirent[]; error: string | null }> { + let stat: Stats; + try { + stat = await fs.lstat(dir); + } catch (error) { + return { entries: [], error: isEnoent(error) ? null : errorMessage(error) }; + } + if (!stat.isDirectory() || stat.isSymbolicLink()) { + return { entries: [], error: null }; + } + try { + return { entries: await fs.readdir(dir, { withFileTypes: true }), error: null }; + } catch (error) { + return { entries: [], error: isEnoent(error) ? null : errorMessage(error) }; + } +} + +function unreadableDirSkip( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, + dir: string, + error: string, +): PurgeStorageSkippedCandidate { + return { + workspaceKey, + storageClass, + path: dir, + reason: `directory unreadable; skipped (${error})`, + }; +} + +async function collectDerivedDataCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const derivedDataDir = getWorkspaceFilesystemLayout(workspaceKey).derivedData; + const { entries, error } = await readManagedDir(derivedDataDir); + const candidates = await Promise.all( + entries.map((entry) => + candidateFromPath({ + workspaceKey, + storageClass: 'derivedData', + candidatePath: path.join(derivedDataDir, entry.name), + reason: 'explicit DerivedData purge', + ...options, + }), + ), + ); + return error + ? [unreadableDirSkip(workspaceKey, 'derivedData', derivedDataDir, error), ...candidates] + : candidates; +} + +async function collectLogCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const { entries, error } = await readManagedDir(layout.logs); + const protectedPaths = await collectWorkspaceLifecycleProtectedLogPaths({ workspaceKey }); + const candidates = entries + .filter((entry) => entry.isFile() && isXcodeBuildMCPManagedLogName(entry.name)) + .map((entry) => ({ name: entry.name, path: path.join(layout.logs, entry.name) })); + + const planned = await Promise.all( + candidates.map(async (candidate) => { + let stat: Stats; + try { + stat = await fs.lstat(candidate.path); + } catch (error) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: describeFsError(error, 'path disappeared during planning'), + }; + } + + if (!stat.isFile()) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: 'non-regular filesystem entry skipped', + }; + } + + const protectionReason = getWorkspaceLifecycleProtectedLogReason( + { path: candidate.path, name: candidate.name, mtimeMs: stat.mtimeMs }, + { + now: options.now, + minVisibleMs: WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS, + protectedPaths, + }, + ); + if (protectionReason) { + return { + workspaceKey, + storageClass: 'logs' as const, + path: candidate.path, + reason: lifecycleLogProtectionReasonText(protectionReason), + }; + } + + return candidateFromPath({ + workspaceKey, + storageClass: 'logs', + candidatePath: candidate.path, + reason: 'managed log', + ...options, + }); + }), + ); + return error + ? [unreadableDirSkip(workspaceKey, 'logs', layout.logs, error), ...planned] + : planned; +} + +async function collectResultBundleCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const layout = getWorkspaceFilesystemLayout(workspaceKey); + const { entries, error } = await readManagedDir(layout.resultBundles); + const candidates = entries + .filter((entry) => entry.isDirectory() && isXcodeBuildMCPManagedResultBundleName(entry.name)) + .map((entry) => ({ + name: entry.name, + bundlePath: path.join(layout.resultBundles, entry.name), + })); + const tempMarkers = entries + .filter((entry) => entry.isFile() && isResultBundleCompletionMarkerTempName(entry.name)) + .map((entry) => path.join(layout.resultBundles, entry.name)); + + const planned: Array = error + ? [unreadableDirSkip(workspaceKey, 'resultBundles', layout.resultBundles, error)] + : []; + for (const tempMarker of tempMarkers) { + planned.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'resultBundles', + candidatePath: tempMarker, + reason: 'orphan result bundle completion temp marker', + ...options, + }), + ); + } + for (const candidate of candidates) { + let stat: Stats; + try { + stat = await fs.lstat(candidate.bundlePath); + } catch (error) { + planned.push({ + workspaceKey, + storageClass: 'resultBundles', + path: candidate.bundlePath, + reason: describeFsError(error, 'path disappeared during planning'), + }); + continue; + } + + if ( + await isWorkspaceLifecycleProtectedResultBundleDirectory( + { name: candidate.name, path: candidate.bundlePath, mtimeMs: stat.mtimeMs }, + { now: options.now, minVisibleMs: 0 }, + ) + ) { + planned.push({ + workspaceKey, + storageClass: 'resultBundles', + path: candidate.bundlePath, + reason: 'protected by active lifecycle owner', + }); + continue; + } + + planned.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'resultBundles', + candidatePath: candidate.bundlePath, + reason: 'managed result bundle', + sidecarPaths: [getResultBundleCompletionMarkerPath(candidate.bundlePath)], + ...options, + }), + ); + } + return planned; +} + +async function collectSimulatorOsLogRegistryCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const registryDir = getWorkspaceFilesystemLayout(workspaceKey).simulatorLaunchOsLogRegistryDir; + const { entries, error } = await readManagedDir(registryDir); + const candidates: Array = error + ? [unreadableDirSkip(workspaceKey, 'stateTransients', registryDir, error)] + : []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith('.json')) { + continue; + } + const candidatePath = path.join(registryDir, entry.name); + const result = await readRegistryRecord(candidatePath); + if (result.status === 'unreadable') { + candidates.push({ + workspaceKey, + storageClass: 'stateTransients', + path: candidatePath, + reason: `registry record unreadable; protected (${result.reason})`, + }); + continue; + } + if ( + result.status === 'record' && + (isPidAlive(result.record.ownerPid) || isPidAlive(result.record.helperPid)) + ) { + candidates.push({ + workspaceKey, + storageClass: 'stateTransients', + path: candidatePath, + reason: 'protected by active OSLog owner', + }); + continue; + } + candidates.push( + await candidateFromPath({ + workspaceKey, + storageClass: 'stateTransients', + candidatePath, + reason: 'stale simulator OSLog registry record', + ...options, + }), + ); + } + + return candidates; +} + +async function collectStateTransientCandidates( + workspaceKey: string, + options: RequiredPlanContext, +): Promise> { + const callToolRoot = xcodeIdeCallToolTransientRoot(workspaceKey); + const { entries: callToolEntries, error } = await readManagedDir(callToolRoot); + const callToolCandidates = await Promise.all( + callToolEntries + .filter( + (entry) => entry.isDirectory() && isStaleXcodeIdeCallToolTransientDirectoryName(entry.name), + ) + .map((entry) => + candidateFromPath({ + workspaceKey, + storageClass: 'stateTransients', + candidatePath: path.join(callToolRoot, entry.name), + reason: 'stale xcode-ide call-tool transient', + ...options, + }), + ), + ); + + return [ + ...(error ? [unreadableDirSkip(workspaceKey, 'stateTransients', callToolRoot, error)] : []), + ...callToolCandidates, + ...(await collectSimulatorOsLogRegistryCandidates(workspaceKey, options)), + ]; +} + +async function collectCandidatesForWorkspaceClass( + workspaceKey: string, + storageClass: PurgeStorageDeletableClass, + options: RequiredPlanContext, +): Promise> { + switch (storageClass) { + case 'derivedData': + return collectDerivedDataCandidates(workspaceKey, options); + case 'logs': + return collectLogCandidates(workspaceKey, options); + case 'resultBundles': + return collectResultBundleCandidates(workspaceKey, options); + case 'stateTransients': + return collectStateTransientCandidates(workspaceKey, options); + } +} + +function buildPlanContext(options: PlanPurgeStorageOptions): RequiredPlanContext { + const context: RequiredPlanContext = { + now: options.now ?? Date.now(), + }; + if (options.olderThanMs !== undefined) { + context.olderThanMs = options.olderThanMs; + } + return context; +} + +export async function planPurgeStorage( + options: PlanPurgeStorageOptions, +): Promise { + assertValidPlanOptions(options); + const report = + options.report ?? (await enumeratePurgeStorage({ now: options.now, scope: options.scope })); + const warnings = [...report.warnings]; + if (options.classes.includes('derivedData')) { + warnings.push( + 'DerivedData purge has no active-build signal; safety relies on explicit selection, age filters, workspace locks, and containment checks', + ); + } + const selectedWorkspaces = selectedWorkspacesForScope(report, options.scope, warnings); + const context = buildPlanContext(options); + + if (options.scope.type === 'all') { + warnings.push('Scope "all" targets every recognized workspace on this machine.'); + } else if (options.scope.type === 'family') { + warnings.push( + `Scope "family ${options.scope.family}" targets every workspace whose project basename is "${options.scope.family}", which may include unrelated projects that share that name.`, + ); + } + + const candidates: PurgeStorageCandidate[] = []; + const skipped: PurgeStorageSkippedCandidate[] = []; + for (const workspace of selectedWorkspaces) { + for (const storageClass of options.classes) { + const planned = await collectCandidatesForWorkspaceClass( + workspace.workspaceKey, + storageClass, + context, + ); + for (const item of planned) { + if (isCandidate(item)) { + candidates.push(item); + } else { + skipped.push(item); + } + } + } + } + + const plan: PurgeStoragePlan = { + action: 'dry-run', + scope: options.scope, + classes: options.classes, + report, + selectedWorkspaceKeys: selectedWorkspaces.map((workspace) => workspace.workspaceKey), + candidates, + skipped, + totals: { + bytes: candidates.reduce((total, candidate) => total + candidate.bytes, 0), + fileCount: candidates.reduce((total, candidate) => total + candidate.fileCount, 0), + directoryCount: candidates.reduce((total, candidate) => total + candidate.directoryCount, 0), + candidateCount: candidates.length, + }, + warnings, + }; + return plan; +} diff --git a/src/utils/purge-storage/registry-record.ts b/src/utils/purge-storage/registry-record.ts new file mode 100644 index 000000000..2fa192e93 --- /dev/null +++ b/src/utils/purge-storage/registry-record.ts @@ -0,0 +1,49 @@ +import * as fs from 'node:fs/promises'; +import { errorMessage, isEnoent } from './scan.ts'; +import type { RegistryRecord } from './types.ts'; + +export type RegistryReadResult = + | { status: 'record'; record: RegistryRecord } + | { status: 'absent' } + | { status: 'unreadable'; reason: string }; + +export async function readRegistryRecord(filePath: string): Promise { + let content: string; + try { + content = await fs.readFile(filePath, 'utf8'); + } catch (error) { + if (isEnoent(error)) { + return { status: 'absent' }; + } + return { status: 'unreadable', reason: errorMessage(error) }; + } + + const record = parseRegistryRecord(content); + if (!record) { + return { status: 'unreadable', reason: 'malformed registry record' }; + } + return { status: 'record', record }; +} + +export function parseRegistryRecord(content: string): RegistryRecord | null { + try { + const parsed = JSON.parse(content) as unknown; + if (typeof parsed !== 'object' || parsed === null) { + return null; + } + const record = parsed as { owner?: { pid?: unknown }; helperPid?: unknown }; + if ( + typeof record.owner?.pid !== 'number' || + !Number.isInteger(record.owner.pid) || + record.owner.pid <= 0 || + typeof record.helperPid !== 'number' || + !Number.isInteger(record.helperPid) || + record.helperPid <= 0 + ) { + return null; + } + return { ownerPid: record.owner.pid, helperPid: record.helperPid }; + } catch { + return null; + } +} diff --git a/src/utils/purge-storage/scan.ts b/src/utils/purge-storage/scan.ts new file mode 100644 index 000000000..7d24e8abd --- /dev/null +++ b/src/utils/purge-storage/scan.ts @@ -0,0 +1,108 @@ +import type { Dirent, Stats } from 'node:fs'; +import * as fs from 'node:fs/promises'; +import * as path from 'node:path'; +import type { ScanAccumulator } from './types.ts'; + +export function isEnoent(error: unknown): boolean { + return (error as NodeJS.ErrnoException).code === 'ENOENT'; +} + +export function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} + +export function describeFsError(error: unknown, enoentMessage: string): string { + return isEnoent(error) ? enoentMessage : errorMessage(error); +} + +export function warningForPath(filePath: string, message: string): string { + return `${filePath}: ${message}`; +} + +export function zeroAccumulator(): ScanAccumulator { + return { + bytes: 0, + fileCount: 0, + directoryCount: 0, + latestMtimeMs: null, + latestFileMtimeMs: null, + latestDirectoryMtimeMs: null, + scanComplete: true, + warnings: [], + }; +} + +function updateLatestMtime(current: number | null, next: number): number { + return current === null ? next : Math.max(current, next); +} + +function appendScanWarning(accumulator: ScanAccumulator, warning: string): void { + accumulator.scanComplete = false; + accumulator.warnings.push(warning); +} + +function scanErrorMessage(filePath: string, error: unknown): string { + return warningForPath(filePath, describeFsError(error, 'path disappeared during scan')); +} + +export async function scanPath(filePath: string): Promise { + const accumulator = zeroAccumulator(); + await scanPathInto(filePath, accumulator, new Set()); + return accumulator; +} + +async function scanPathInto( + filePath: string, + accumulator: ScanAccumulator, + countedInodes: Set, +): Promise { + let stat: Stats; + try { + stat = await fs.lstat(filePath); + } catch (error) { + appendScanWarning(accumulator, scanErrorMessage(filePath, error)); + return; + } + + accumulator.latestMtimeMs = updateLatestMtime(accumulator.latestMtimeMs, stat.mtimeMs); + + if (stat.isSymbolicLink()) { + appendScanWarning(accumulator, warningForPath(filePath, 'symbolic link skipped')); + return; + } + + if (stat.isDirectory()) { + accumulator.latestDirectoryMtimeMs = updateLatestMtime( + accumulator.latestDirectoryMtimeMs, + stat.mtimeMs, + ); + accumulator.directoryCount += 1; + let entries: Dirent[]; + try { + entries = await fs.readdir(filePath, { withFileTypes: true }); + } catch (error) { + appendScanWarning(accumulator, scanErrorMessage(filePath, error)); + return; + } + for (const entry of entries) { + await scanPathInto(path.join(filePath, entry.name), accumulator, countedInodes); + } + return; + } + + if (stat.isFile()) { + accumulator.latestFileMtimeMs = updateLatestMtime(accumulator.latestFileMtimeMs, stat.mtimeMs); + accumulator.fileCount += 1; + if (stat.nlink > 1) { + const inodeKey = `${stat.dev}:${stat.ino}`; + if (countedInodes.has(inodeKey)) { + return; + } + countedInodes.add(inodeKey); + } + accumulator.bytes += stat.size; + return; + } + + appendScanWarning(accumulator, warningForPath(filePath, 'non-regular filesystem entry skipped')); +} diff --git a/src/utils/purge-storage/types.ts b/src/utils/purge-storage/types.ts new file mode 100644 index 000000000..993901034 --- /dev/null +++ b/src/utils/purge-storage/types.ts @@ -0,0 +1,173 @@ +export const PURGE_STORAGE_CLASSES = [ + 'derivedData', + 'logs', + 'resultBundles', + 'stateTransients', + 'locks', +] as const; + +export const PURGE_STORAGE_DELETABLE_CLASSES = [ + 'derivedData', + 'logs', + 'resultBundles', + 'stateTransients', +] as const; + +export type PurgeStorageClass = (typeof PURGE_STORAGE_CLASSES)[number]; +export type PurgeStorageDeletableClass = (typeof PURGE_STORAGE_DELETABLE_CLASSES)[number]; + +export type PurgeStorageScope = + | { type: 'all' } + | { type: 'family'; family: string } + | { type: 'workspace'; workspaceKey: string } + | { type: 'workspaces'; workspaceKeys: string[] }; + +export interface WorkspaceFamilyKeyParts { + family: string; + hash: string; +} + +export interface PurgeStorageClassCensus { + storageClass: PurgeStorageClass; + path: string; + exists: boolean; + bytes: number; + fileCount: number; + directoryCount: number; + latestMtimeMs: number | null; + scanComplete: boolean; + cleanupEligible: boolean; + warnings: string[]; +} + +export interface PurgeWorkspaceSummary { + workspaceKey: string; + path: string; + recognized: boolean; + family: string | null; + hash: string | null; + classes: Record; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + }; + warnings: string[]; +} + +export interface PurgeWorkspaceFamilySummary { + family: string; + workspaceKeys: string[]; + bytes: number; +} + +export interface PurgeStorageReport { + appRoot: string; + workspacesDir: string; + workspaces: PurgeWorkspaceSummary[]; + families: PurgeWorkspaceFamilySummary[]; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + }; + warnings: string[]; +} + +export interface EnumeratePurgeStorageOptions { + now?: number; + scope?: PurgeStorageScope; +} + +export interface PlanPurgeStorageOptions { + report?: PurgeStorageReport; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + now?: number; + olderThanMs?: number; + derivedDataExplicit?: boolean; +} + +export interface PurgeStorageCandidate { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + kind: 'file' | 'directory'; + bytes: number; + fileCount: number; + directoryCount: number; + mtimeMs: number; + reason: string; + sidecarPaths: string[]; +} + +export interface PurgeStorageSkippedCandidate { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + reason: string; +} + +export interface PurgeStoragePlan { + action: 'dry-run'; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + report: PurgeStorageReport; + selectedWorkspaceKeys: string[]; + candidates: PurgeStorageCandidate[]; + skipped: PurgeStorageSkippedCandidate[]; + totals: { + bytes: number; + fileCount: number; + directoryCount: number; + candidateCount: number; + }; + warnings: string[]; +} + +export interface ExecutePurgeStoragePlanOptions { + now?: number; +} + +export interface PurgeStorageDeletedEntry { + workspaceKey: string; + storageClass: PurgeStorageDeletableClass; + path: string; + bytes: number; +} + +export interface PurgeStorageExecutionResult { + action: 'delete'; + scope: PurgeStorageScope; + classes: PurgeStorageDeletableClass[]; + selectedWorkspaceKeys: string[]; + deleted: PurgeStorageDeletedEntry[]; + skipped: PurgeStorageSkippedCandidate[]; + totals: { + bytes: number; + deletedCount: number; + skippedCount: number; + }; + warnings: string[]; +} + +export interface ScanAccumulator { + bytes: number; + fileCount: number; + directoryCount: number; + latestMtimeMs: number | null; + latestFileMtimeMs: number | null; + latestDirectoryMtimeMs: number | null; + scanComplete: boolean; + warnings: string[]; +} + +export interface RegistryRecord { + ownerPid: number; + helperPid: number; +} + +export interface RequiredPlanContext { + now: number; + olderThanMs?: number; +} diff --git a/src/utils/result-bundle-path.ts b/src/utils/result-bundle-path.ts index e0ccb22ed..30574729b 100644 --- a/src/utils/result-bundle-path.ts +++ b/src/utils/result-bundle-path.ts @@ -6,7 +6,11 @@ import { formatLogTimestamp, shortRandomSuffix } from './log-naming.ts'; import { getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; import { workspaceKeyForRoot } from './workspace-identity.ts'; -const RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX = '.xcodebuildmcp-completed'; +export const RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX = '.xcodebuildmcp-completed'; + +export function isResultBundleCompletionMarkerTempName(name: string): boolean { + return name.includes(`${RESULT_BUNDLE_COMPLETION_MARKER_SUFFIX}.`) && name.endsWith('.tmp'); +} function resolveWorkspaceKey(): string { return getRuntimeInstanceIfConfigured()?.workspaceKey ?? workspaceKeyForRoot(process.cwd()); @@ -44,7 +48,15 @@ export function markResultBundlePathCompleted(resultBundlePath: string | undefin if (!fs.existsSync(resultBundlePath) || !fs.statSync(resultBundlePath).isDirectory()) { return; } - fs.writeFileSync(getResultBundleCompletionMarkerPath(resultBundlePath), `${Date.now()}\n`); + const markerPath = getResultBundleCompletionMarkerPath(resultBundlePath); + const tempPath = `${markerPath}.${process.pid}_${shortRandomSuffix()}.tmp`; + fs.writeFileSync(tempPath, `${Date.now()}\n`); + try { + fs.renameSync(tempPath, markerPath); + } catch (renameError) { + fs.rmSync(tempPath, { force: true }); + throw renameError; + } } catch (error) { const message = error instanceof Error ? error.message : String(error); log('warn', `Unable to mark result bundle completed at ${resultBundlePath}: ${message}`); diff --git a/src/utils/workspace-filesystem-lifecycle.ts b/src/utils/workspace-filesystem-lifecycle.ts index 43faefd40..0364bb3c6 100644 --- a/src/utils/workspace-filesystem-lifecycle.ts +++ b/src/utils/workspace-filesystem-lifecycle.ts @@ -12,7 +12,7 @@ import { } from './log-capture/simulator-launch-oslog-sessions.ts'; import { log } from './logging/index.ts'; import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-instance.ts'; -import { tryAcquireFsLock } from './fs-lock.ts'; +import { tryAcquireFsLock, type AcquiredFsLock } from './fs-lock.ts'; import { isPidAlive } from './process-liveness.ts'; import { getResultBundleCompletionMarkerPath } from './result-bundle-path.ts'; @@ -113,6 +113,30 @@ interface RetainedLogFile { mtimeMs: number; } +export interface WorkspaceLifecycleProtectedLogPathsOptions { + workspaceKey: string; + protectedLogPaths?: string[]; +} + +export interface WorkspaceLifecycleRetainedArtifact { + path: string; + name: string; + mtimeMs: number; +} + +export type WorkspaceLifecycleLogProtectionReason = 'protectedPath' | 'recent' | 'liveHelperPid'; + +export interface WorkspaceLifecycleLogProtectionOptions { + now: number; + minVisibleMs: number; + protectedPaths: ReadonlySet; +} + +export interface WorkspaceLifecycleResultBundleProtectionOptions { + now: number; + minVisibleMs: number; +} + function resolveWorkspaceKey(options: WorkspaceFilesystemLifecycleOptions): string { if (options.workspaceKey) { return options.workspaceKey; @@ -193,18 +217,18 @@ function hasLiveHelperPidInName(fileName: string): boolean { return false; } -function isXcodeBuildMCPManagedLogName(fileName: string): boolean { +export function isXcodeBuildMCPManagedLogName(fileName: string): boolean { if (fileName === 'daemon.log') { return true; } return XCODEBUILD_LOG_NAME_PATTERN.test(fileName) || SIMULATOR_LOG_NAME_PATTERN.test(fileName); } -function isXcodeBuildMCPManagedResultBundleName(fileName: string): boolean { +export function isXcodeBuildMCPManagedResultBundleName(fileName: string): boolean { return RESULT_BUNDLE_NAME_PATTERN.test(fileName); } -function getManagedResultBundleOwnerPid(fileName: string): number | null { +export function getManagedResultBundleOwnerPid(fileName: string): number | null { const pid = Number(fileName.match(RESULT_BUNDLE_OWNER_PID_PATTERN)?.[1]); return Number.isInteger(pid) && pid > 0 ? pid : null; } @@ -218,24 +242,40 @@ async function deleteFile(filePath: string): Promise { } } +export function getWorkspaceLifecycleProtectedLogReason( + file: WorkspaceLifecycleRetainedArtifact, + options: WorkspaceLifecycleLogProtectionOptions, +): WorkspaceLifecycleLogProtectionReason | null { + if (options.protectedPaths.has(file.path)) { + return 'protectedPath'; + } + if (options.now - file.mtimeMs < options.minVisibleMs) { + return 'recent'; + } + if (hasLiveHelperPidInName(file.name)) { + return 'liveHelperPid'; + } + return null; +} + function isProtectedLogFile( file: RetainedLogFile, options: ResolvedWorkspaceFilesystemLifecycleOptions, protectedPaths: Set, ): boolean { - if (protectedPaths.has(file.path)) { - return true; - } - if (options.now - file.mtimeMs < options.minVisibleMs) { - return true; - } - return hasLiveHelperPidInName(file.name); + return ( + getWorkspaceLifecycleProtectedLogReason(file, { + now: options.now, + minVisibleMs: options.minVisibleMs, + protectedPaths, + }) !== null + ); } -async function collectProtectedLogPaths( - options: ResolvedWorkspaceFilesystemLifecycleOptions, +export async function collectWorkspaceLifecycleProtectedLogPaths( + options: WorkspaceLifecycleProtectedLogPathsOptions, ): Promise> { - const protectedPaths = new Set(options.protectedLogPaths); + const protectedPaths = new Set(options.protectedLogPaths ?? []); try { for (const osLogPath of await listSimulatorLaunchOsLogProtectedPaths({ @@ -255,6 +295,15 @@ async function collectProtectedLogPaths( return protectedPaths; } +async function collectProtectedLogPaths( + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise> { + return collectWorkspaceLifecycleProtectedLogPaths({ + workspaceKey: options.workspaceKey, + protectedLogPaths: options.protectedLogPaths, + }); +} + async function pruneKnownLogDirectory( options: ResolvedWorkspaceFilesystemLifecycleOptions, protectedPaths: Set, @@ -319,9 +368,9 @@ async function hasResultBundleCompletionMarker(bundlePath: string): Promise { if (options.now - bundle.mtimeMs < options.minVisibleMs) { return true; @@ -335,6 +384,16 @@ async function isProtectedResultBundleDirectory( return false; } +async function isProtectedResultBundleDirectory( + bundle: RetainedLogFile, + options: ResolvedWorkspaceFilesystemLifecycleOptions, +): Promise { + return isWorkspaceLifecycleProtectedResultBundleDirectory(bundle, { + now: options.now, + minVisibleMs: options.minVisibleMs, + }); +} + async function pruneKnownResultBundleDirectory( options: ResolvedWorkspaceFilesystemLifecycleOptions, ): Promise<{ scanned: number; deleted: number }> { @@ -449,13 +508,23 @@ function runDaemonCleanup(options: ResolvedWorkspaceFilesystemLifecycleOptions): cleanupWorkspaceDaemonFiles(options.workspaceKey, options.daemonCleanup); } -function xcodeIdeCallToolTransientRoot(workspaceKey: string): string { +export function xcodeIdeCallToolTransientRoot(workspaceKey: string): string { return path.join( getWorkspaceFilesystemLayout(workspaceKey).state, XCODE_IDE_CALL_TOOL_TRANSIENT_DIR, ); } +export function getXcodeIdeCallToolTransientOwnerPid(directoryName: string): number | null { + const ownerPid = Number(directoryName.match(XCODE_IDE_CALL_TOOL_OWNER_DIR_PATTERN)?.[1]); + return Number.isInteger(ownerPid) && ownerPid > 0 ? ownerPid : null; +} + +export function isStaleXcodeIdeCallToolTransientDirectoryName(directoryName: string): boolean { + const ownerPid = getXcodeIdeCallToolTransientOwnerPid(directoryName); + return ownerPid !== null && !isPidAlive(ownerPid); +} + async function cleanupXcodeIdeCallToolTransientArtifacts( workspaceKey: string, errors: string[], @@ -482,9 +551,8 @@ async function cleanupXcodeIdeCallToolTransientArtifacts( if (!entry.isDirectory()) { continue; } - const ownerPid = Number(entry.name.match(XCODE_IDE_CALL_TOOL_OWNER_DIR_PATTERN)?.[1]); const ownedByCurrentRuntime = options.includeCurrentOwner && currentOwnerDir === entry.name; - const ownedByDeadRuntime = Number.isInteger(ownerPid) && ownerPid > 0 && !isPidAlive(ownerPid); + const ownedByDeadRuntime = isStaleXcodeIdeCallToolTransientDirectoryName(entry.name); if (!ownedByCurrentRuntime && !ownedByDeadRuntime) { continue; } @@ -539,12 +607,18 @@ export async function runWorkspaceFilesystemLifecycleSweep( return zeroResult(resolved, true, false, stopped, errors); } - const lock = await tryAcquireFsLock({ - lockDir: resolved.lockDir, - purpose: resolved.lockPurpose, - leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, - now: resolved.now, - }); + let lock: AcquiredFsLock | null; + try { + lock = await tryAcquireFsLock({ + lockDir: resolved.lockDir, + purpose: resolved.lockPurpose, + leaseMs: WORKSPACE_FILESYSTEM_LIFECYCLE_LOCK_LEASE_MS, + now: resolved.now, + }); + } catch (error) { + errors.push(error instanceof Error ? error.message : String(error)); + return zeroResult(resolved, false, true, stopped, errors); + } if (!lock) { return zeroResult(resolved, false, true, stopped, errors); }