Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

- Dictionary-shaped MCP inputs now use client-compatible wire representations ([#491](https://github.com/getsentry/XcodeBuildMCP/issues/491)). The `env` and `testRunnerEnv` inputs on build, launch, test, and session-default tools are arrays of `{ "key": "...", "value": "..." }` entries, while `xcode_ide_call_tool.arguments` is a JSON object string. XcodeBuildMCP converts these values to their existing internal objects only after MCP input validation.

### Fixed

- Reduced managed test-product retention to three bundles for one day and added `testProductsMaxCount` and `testProductsMaxAgeDays` project configuration, with matching environment overrides, to substantially reduce disk growth from repeated test runs ([#524](https://github.com/getsentry/XcodeBuildMCP/issues/524)).

## [2.7.0]

### New! Xcode 27 Device Hub simulator support
Expand Down
2 changes: 2 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ customWorkflows:
experimentalWorkflowDiscovery: false
disableSessionDefaults: false
incrementalBuildsEnabled: false
testProductsMaxCount: 3 # managed paths only; caller-provided paths are never pruned
testProductsMaxAgeDays: 1
debug: false
sentryDisabled: false
filePathRenderStyle: 'list' # text output file artifacts: list or tree
Expand Down
14 changes: 14 additions & 0 deletions src/utils/__tests__/config-store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ describe('config-store', () => {
const config = getConfig();
expect(config.debug).toBe(false);
expect(config.incrementalBuildsEnabled).toBe(false);
expect(config.testProductsMaxCount).toBe(3);
expect(config.testProductsMaxAgeDays).toBe(1);
expect(config.dapRequestTimeoutMs).toBe(30000);
expect(config.dapLogEvents).toBe(false);
expect(config.launchJsonWaitMs).toBe(8000);
Expand All @@ -49,6 +51,8 @@ describe('config-store', () => {
XCODEBUILDMCP_DEBUG: 'true',
XCODEBUILDMCP_SENTRY_DISABLED: 'true',
INCREMENTAL_BUILDS_ENABLED: '1',
XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT: '4',
XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS: '0.5',
XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '12345',
XCODEBUILDMCP_DAP_LOG_EVENTS: 'true',
XBMCP_LAUNCH_JSON_WAIT_MS: '9000',
Expand All @@ -65,6 +69,8 @@ describe('config-store', () => {
expect(config.debug).toBe(true);
expect(config.sentryDisabled).toBe(true);
expect(config.incrementalBuildsEnabled).toBe(true);
expect(config.testProductsMaxCount).toBe(4);
expect(config.testProductsMaxAgeDays).toBe(0.5);
expect(config.dapRequestTimeoutMs).toBe(12345);
expect(config.dapLogEvents).toBe(true);
expect(config.launchJsonWaitMs).toBe(9000);
Expand All @@ -79,13 +85,17 @@ describe('config-store', () => {
const yaml = [
'schemaVersion: 1',
'debug: false',
'testProductsMaxCount: 8',
'testProductsMaxAgeDays: 2',
'dapRequestTimeoutMs: 4000',
'filePathRenderStyle: tree',
'axeSourcePath: /file/AXe',
'',
].join('\n');
const env = {
XCODEBUILDMCP_DEBUG: 'true',
XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT: '9',
XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS: '3',
XCODEBUILDMCP_DAP_REQUEST_TIMEOUT_MS: '999',
XCODEBUILDMCP_FILE_PATH_RENDER_STYLE: 'list',
XCODEBUILDMCP_AXE_SOURCE_PATH: '/env/AXe',
Expand All @@ -96,6 +106,8 @@ describe('config-store', () => {
fs: createFs(yaml),
overrides: {
debug: true,
testProductsMaxCount: 7,
testProductsMaxAgeDays: 1.5,
dapRequestTimeoutMs: 12345,
filePathRenderStyle: 'list',
axeSourcePath: '/override/AXe',
Expand All @@ -105,6 +117,8 @@ describe('config-store', () => {

const config = getConfig();
expect(config.debug).toBe(true);
expect(config.testProductsMaxCount).toBe(7);
expect(config.testProductsMaxAgeDays).toBe(1.5);
expect(config.dapRequestTimeoutMs).toBe(12345);
expect(config.filePathRenderStyle).toBe('list');
expect(config.axeSourcePath).toBe('/override/AXe');
Expand Down
15 changes: 15 additions & 0 deletions src/utils/__tests__/project-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -278,6 +278,21 @@ describe('project-config', () => {
}
});

it('should reject negative test-products retention limits', async () => {
const yaml = [
'schemaVersion: 1',
'testProductsMaxCount: -1',
'testProductsMaxAgeDays: -1',
'',
].join('\n');
const { fs } = createFsFixture({ exists: true, readFile: yaml });

const result = await loadProjectConfig({ fs, cwd });

expect(result.found).toBe(false);
expect('error' in result).toBe(true);
});

it('should return an error result when YAML does not parse to an object', async () => {
const { fs } = createFsFixture({ exists: true, readFile: '- item' });

Expand Down
4 changes: 2 additions & 2 deletions src/utils/__tests__/test-products-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ describe('test products lifecycle', () => {
await rm(root, { recursive: true, force: true });
});

it('prunes managed products after three days while preserving caller-owned paths', async () => {
it('prunes managed products after the default retention age while preserving caller-owned paths', async () => {
const now = Date.UTC(2026, 4, 6, 12);
const oldManaged = path.join(root, managedName('old'));
const recentManaged = path.join(root, managedName('recent'));
Expand All @@ -50,7 +50,7 @@ describe('test products lifecycle', () => {
`${path.basename(root)}-external-caller.xctestproducts`,
);
writeTestProducts(oldManaged, now - TEST_PRODUCTS_MAX_AGE_MS - 1, true);
writeTestProducts(recentManaged, now - 2 * DAY_MS, true);
writeTestProducts(recentManaged, now - TEST_PRODUCTS_MAX_AGE_MS / 2, true);
writeTestProducts(callerOwned, now - 10 * DAY_MS, true);
writeTestProducts(externalCallerOwned, now - 10 * DAY_MS, true);

Expand Down
47 changes: 46 additions & 1 deletion src/utils/__tests__/workspace-filesystem-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import { existsSync, mkdirSync, mkdtempSync, writeFileSync, utimesSync } from 'n
import { rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import * as path from 'node:path';
import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts';
import { createMockFileSystemExecutor } from '../../test-utils/mock-executors.ts';
import {
cleanupOwnedWorkspaceFilesystemArtifacts,
getManagedResultBundleOwnerPid,
Expand All @@ -23,13 +25,13 @@ import {
import { getResultBundleCompletionMarkerPath } from '../result-bundle-path.ts';
import { getTestProductsCompletionMarkerPath } from '../test-products-path.ts';
import { TEST_PRODUCTS_MAX_COUNT } from '../test-products-lifecycle.ts';
import { writeDaemonRegistryEntry } from '../../daemon/daemon-registry.ts';
import { setRuntimeInstanceForTests } from '../runtime-instance.ts';
import {
clearAllSimulatorLaunchOsLogSessionsForTests,
registerSimulatorLaunchOsLogSession,
} from '../log-capture/simulator-launch-oslog-sessions.ts';
import { setSimulatorLaunchOsLogRecordActiveOverrideForTests } from '../log-capture/simulator-launch-oslog-registry.ts';
import { __resetConfigStoreForTests, initConfigStore } from '../config-store.ts';

let appDir: string;
const DEAD_OWNER_PID = 999_999_999;
Expand Down Expand Up @@ -87,6 +89,7 @@ describe('workspace filesystem lifecycle', () => {
pid: process.pid,
workspaceKey: 'workspace-a',
});
__resetConfigStoreForTests();
resetWorkspaceFilesystemLifecycleStateForTests();
});

Expand All @@ -95,6 +98,7 @@ describe('workspace filesystem lifecycle', () => {
setSimulatorLaunchOsLogRecordActiveOverrideForTests(null);
await clearAllSimulatorLaunchOsLogSessionsForTests();
setRuntimeInstanceForTests(null);
__resetConfigStoreForTests();
setXcodeBuildMCPAppDirOverrideForTests(null);
await rm(appDir, { recursive: true, force: true });
});
Expand Down Expand Up @@ -370,6 +374,47 @@ describe('workspace filesystem lifecycle', () => {
expect(existsSync(getTestProductsCompletionMarkerPath(products[0]!))).toBe(false);
});

it('applies configured age and count limits to managed test products', async () => {
const now = Date.UTC(2026, 4, 2, 12);
const configPath = path.join('/repo', '.xcodebuildmcp', 'config.yaml');
await initConfigStore({
cwd: '/repo',
fs: createMockFileSystemExecutor({
existsSync: (targetPath) => targetPath === configPath,
readFile: async () =>
[
'schemaVersion: 1',
'testProductsMaxCount: 1',
'testProductsMaxAgeDays: 1',
'',
].join('\n'),
}),
});
const layout = getWorkspaceFilesystemLayout('workspace-a');
const oldest = path.join(layout.testProducts, managedTestProductsName('test_oldest'));
const middle = path.join(layout.testProducts, managedTestProductsName('test_middle'));
const newest = path.join(layout.testProducts, managedTestProductsName('test_newest'));
writeTestProductsWithMtime(oldest, now - 2 * 24 * 60 * 60 * 1000);
writeTestProductsWithMtime(middle, now - 2 * 60 * 60 * 1000);
writeTestProductsWithMtime(newest, now - 60 * 60 * 1000);
for (const productsPath of [oldest, middle, newest]) {
writeFileSync(getTestProductsCompletionMarkerPath(productsPath), 'completed');
}

const result = await runWorkspaceFilesystemLifecycleSweep({
workspaceKey: 'workspace-a',
trigger: 'manual',
now,
force: true,
minVisibleMs: 0,
});

expect(result).toMatchObject({ scanned: 3, deleted: 2 });
expect(existsSync(oldest)).toBe(false);
expect(existsSync(middle)).toBe(false);
expect(existsSync(newest)).toBe(true);
});

it('protects live managed result bundles until their completion marker exists', async () => {
const now = Date.UTC(2026, 4, 2, 12);
const layout = getWorkspaceFilesystemLayout('workspace-a');
Expand Down
43 changes: 43 additions & 0 deletions src/utils/config-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,10 @@ import type { DebuggerBackendKind } from './debugger/types.ts';
import type { FilePathRenderStyle, UiDebuggerGuardMode } from './runtime-config-types.ts';
import { isFilePathRenderStyle } from './file-path-render-style.ts';
import { normalizeSessionDefaultsProfileName } from './session-defaults-profile.ts';
import {
TEST_PRODUCTS_MAX_AGE_DAYS,
TEST_PRODUCTS_MAX_COUNT,
} from './test-products-lifecycle.ts';

export type RuntimeConfigOverrides = Partial<{
enabledWorkflows: string[];
Expand All @@ -24,6 +28,8 @@ export type RuntimeConfigOverrides = Partial<{
filePathRenderStyle: FilePathRenderStyle;
uiDebuggerGuardMode: UiDebuggerGuardMode;
incrementalBuildsEnabled: boolean;
testProductsMaxCount: number;
testProductsMaxAgeDays: number;
dapRequestTimeoutMs: number;
dapLogEvents: boolean;
launchJsonWaitMs: number;
Expand Down Expand Up @@ -51,6 +57,8 @@ export type ResolvedRuntimeConfig = {
filePathRenderStyle?: FilePathRenderStyle;
uiDebuggerGuardMode: UiDebuggerGuardMode;
incrementalBuildsEnabled: boolean;
testProductsMaxCount?: number;
testProductsMaxAgeDays?: number;
dapRequestTimeoutMs: number;
dapLogEvents: boolean;
launchJsonWaitMs: number;
Expand Down Expand Up @@ -87,6 +95,8 @@ const DEFAULT_CONFIG: ResolvedRuntimeConfig = {
showTestTiming: false,
uiDebuggerGuardMode: 'error',
incrementalBuildsEnabled: false,
testProductsMaxCount: TEST_PRODUCTS_MAX_COUNT,
testProductsMaxAgeDays: TEST_PRODUCTS_MAX_AGE_DAYS,
dapRequestTimeoutMs: 30_000,
dapLogEvents: false,
launchJsonWaitMs: 8000,
Expand Down Expand Up @@ -128,6 +138,13 @@ function parseNonNegativeInt(value: string | undefined): number | undefined {
return Math.floor(parsed);
}

function parseNonNegativeNumber(value: string | undefined): number | undefined {
if (!value) return undefined;
const parsed = Number(value);
if (!Number.isFinite(parsed) || parsed < 0) return undefined;
return parsed;
}

function parseEnabledWorkflows(value: string | undefined): string[] | undefined {
if (value == null) return undefined;
const normalized = value
Expand Down Expand Up @@ -226,6 +243,18 @@ function readEnvConfig(env: NodeJS.ProcessEnv): RuntimeConfigOverrides {

setIfDefined(config, 'incrementalBuildsEnabled', parseBoolean(env.INCREMENTAL_BUILDS_ENABLED));

setIfDefined(
config,
'testProductsMaxCount',
parseNonNegativeInt(env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_COUNT),
);

setIfDefined(
config,
'testProductsMaxAgeDays',
parseNonNegativeNumber(env.XCODEBUILDMCP_TEST_PRODUCTS_MAX_AGE_DAYS),
);

const axePath = env.XCODEBUILDMCP_AXE_PATH ?? env.AXE_PATH;
if (axePath) config.axePath = axePath;

Expand Down Expand Up @@ -535,6 +564,20 @@ function resolveConfig(opts: {
envConfig,
fallback: DEFAULT_CONFIG.incrementalBuildsEnabled,
}),
testProductsMaxCount: resolveFromLayers({
key: 'testProductsMaxCount',
overrides: opts.overrides,
fileConfig: opts.fileConfig,
envConfig,
fallback: DEFAULT_CONFIG.testProductsMaxCount,
}),
testProductsMaxAgeDays: resolveFromLayers({
key: 'testProductsMaxAgeDays',
overrides: opts.overrides,
fileConfig: opts.fileConfig,
envConfig,
fallback: DEFAULT_CONFIG.testProductsMaxAgeDays,
}),
dapRequestTimeoutMs: resolveFromLayers({
key: 'dapRequestTimeoutMs',
overrides: opts.overrides,
Expand Down
2 changes: 2 additions & 0 deletions src/utils/runtime-config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ export const runtimeConfigFileSchema = z
filePathRenderStyle: z.enum(['tree', 'list']).optional(),
uiDebuggerGuardMode: z.enum(['error', 'warn', 'off']).optional(),
incrementalBuildsEnabled: z.boolean().optional(),
testProductsMaxCount: z.number().int().nonnegative().optional(),
testProductsMaxAgeDays: z.number().nonnegative().optional(),
dapRequestTimeoutMs: z.number().int().positive().optional(),
dapLogEvents: z.boolean().optional(),
launchJsonWaitMs: z.number().int().nonnegative().optional(),
Expand Down
6 changes: 4 additions & 2 deletions src/utils/test-products-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
isXcodeBuildMCPManagedTestProductsName,
} from './test-products-path.ts';

export const TEST_PRODUCTS_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
export const TEST_PRODUCTS_MAX_COUNT = 100;
export const TEST_PRODUCTS_DAY_MS = 24 * 60 * 60 * 1000;
export const TEST_PRODUCTS_MAX_AGE_DAYS = 1;
export const TEST_PRODUCTS_MAX_AGE_MS = TEST_PRODUCTS_MAX_AGE_DAYS * TEST_PRODUCTS_DAY_MS;
export const TEST_PRODUCTS_MAX_COUNT = 3;

interface RetainedTestProducts {
path: string;
Expand Down
21 changes: 19 additions & 2 deletions src/utils/workspace-filesystem-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,13 @@ import { getRuntimeInstance, getRuntimeInstanceIfConfigured } from './runtime-in
import { tryAcquireFsLock, type AcquiredFsLock } from './fs-lock.ts';
import { isPidAlive } from './process-liveness.ts';
import { getResultBundleCompletionMarkerPath } from './result-bundle-path.ts';
import { pruneManagedTestProductsDirectory } from './test-products-lifecycle.ts';
import { getConfig } from './config-store.ts';
import {
pruneManagedTestProductsDirectory,
TEST_PRODUCTS_DAY_MS,
TEST_PRODUCTS_MAX_AGE_DAYS,
TEST_PRODUCTS_MAX_COUNT,
} from './test-products-lifecycle.ts';

export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS = 3 * 24 * 60 * 60 * 1000;
export const WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES = 10_000;
Expand Down Expand Up @@ -63,6 +69,8 @@ export interface WorkspaceFilesystemLifecycleOptions {
now?: number;
maxAgeMs?: number;
maxFiles?: number;
testProductsMaxAgeMs?: number;
testProductsMaxCount?: number;
cooldownMs?: number;
force?: boolean;
minVisibleMs?: number;
Expand Down Expand Up @@ -100,6 +108,8 @@ interface ResolvedWorkspaceFilesystemLifecycleOptions {
now: number;
maxAgeMs: number;
maxFiles: number;
testProductsMaxAgeMs: number;
testProductsMaxCount: number;
cooldownMs: number;
force: boolean;
minVisibleMs: number;
Expand Down Expand Up @@ -159,6 +169,7 @@ function resolveOptions(
const workspaceKey = resolveWorkspaceKey(options);
const layout = options.logDir ? null : getWorkspaceFilesystemLayout(workspaceKey);
const logDir = options.logDir ?? layout?.logs;
const config = getConfig();
if (!logDir) {
throw new Error('Workspace filesystem lifecycle requires a log directory');
}
Expand All @@ -180,6 +191,11 @@ function resolveOptions(
now: options.now ?? Date.now(),
maxAgeMs: options.maxAgeMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_AGE_MS,
maxFiles: options.maxFiles ?? WORKSPACE_FILESYSTEM_LIFECYCLE_LOG_MAX_FILES,
testProductsMaxAgeMs:
options.testProductsMaxAgeMs ??
(config.testProductsMaxAgeDays ?? TEST_PRODUCTS_MAX_AGE_DAYS) * TEST_PRODUCTS_DAY_MS,
testProductsMaxCount:
options.testProductsMaxCount ?? config.testProductsMaxCount ?? TEST_PRODUCTS_MAX_COUNT,
cooldownMs: options.cooldownMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_COOLDOWN_MS,
force: options.force ?? false,
minVisibleMs: options.minVisibleMs ?? WORKSPACE_FILESYSTEM_LIFECYCLE_MIN_VISIBLE_MS,
Expand Down Expand Up @@ -643,7 +659,8 @@ export async function runWorkspaceFilesystemLifecycleSweep(
testProductsDir: resolved.testProductsDir,
now: resolved.now,
minVisibleMs: resolved.minVisibleMs,
maxAgeMs: resolved.maxAgeMs,
maxAgeMs: resolved.testProductsMaxAgeMs,
maxCount: resolved.testProductsMaxCount,
})
: { scanned: 0, deleted: 0 };
await touchCleanupMarker(resolved.markerPath, resolved.now);
Expand Down