From a720f1719cd18163d2ef1ae34f101c91e025ae0c Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Wed, 15 Apr 2026 09:57:24 -0700 Subject: [PATCH 1/4] feat(session): auto-scope DerivedData per workspace/project path When derivedDataPath is not explicitly set but workspacePath or projectPath is known, the session store computes a workspace-scoped subdirectory under DerivedData using a name+hash scheme (e.g. MyApp-4ee6552f04c9). This prevents concurrent sessions building different clones of the same project from colliding on a shared build directory. Closes #340 --- .../__tests__/session_clear_defaults.test.ts | 3 +- src/utils/__tests__/session-store.test.ts | 34 +++++++++++++++++++ src/utils/session-store.ts | 17 +++++++++- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts index a5fa8a14..e28f5d67 100644 --- a/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts +++ b/src/mcp/tools/session-management/__tests__/session_clear_defaults.test.ts @@ -44,7 +44,8 @@ describe('session-clear-defaults tool', () => { const current = sessionStore.getAll(); expect(current.scheme).toBeUndefined(); expect(current.deviceId).toBeUndefined(); - expect(current.derivedDataPath).toBeUndefined(); + // derivedDataPath is computed from projectPath when not explicitly set + expect(current.derivedDataPath).toContain('proj-'); expect(current.projectPath).toBe('/path/to/proj.xcodeproj'); expect(current.simulatorName).toBe('iPhone 17'); expect(current.useLatestOS).toBe(true); diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts index 7d45c6a2..92502f6b 100644 --- a/src/utils/__tests__/session-store.test.ts +++ b/src/utils/__tests__/session-store.test.ts @@ -121,4 +121,38 @@ describe('SessionStore', () => { const stored = sessionStore.getAll(); expect(stored.env).toEqual({ API_KEY: 'secret' }); }); + + it('computes a workspace-scoped derivedDataPath when workspacePath is set', () => { + sessionStore.setDefaults({ workspacePath: '/Users/dev/clone-1/MyApp.xcworkspace' }); + + const defaults = sessionStore.getAll(); + expect(defaults.derivedDataPath).toMatch(/MyApp-[a-f0-9]{12}$/); + }); + + it('computes a project-scoped derivedDataPath when projectPath is set', () => { + sessionStore.setDefaults({ projectPath: '/Users/dev/clone-2/MyApp.xcodeproj' }); + + const defaults = sessionStore.getAll(); + expect(defaults.derivedDataPath).toMatch(/MyApp-[a-f0-9]{12}$/); + }); + + it('does not override an explicitly set derivedDataPath', () => { + sessionStore.setDefaults({ + workspacePath: '/Users/dev/clone-1/MyApp.xcworkspace', + derivedDataPath: '/custom/path', + }); + + expect(sessionStore.getAll().derivedDataPath).toBe('/custom/path'); + }); + + it('produces different hashes for different workspace paths', () => { + sessionStore.setDefaults({ workspacePath: '/clone-1/MyApp.xcworkspace' }); + const path1 = sessionStore.getAll().derivedDataPath; + + sessionStore.clearAll(); + sessionStore.setDefaults({ workspacePath: '/clone-2/MyApp.xcworkspace' }); + const path2 = sessionStore.getAll().derivedDataPath; + + expect(path1).not.toBe(path2); + }); }); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index 2d1cdf1b..e09852de 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -1,3 +1,6 @@ +import * as crypto from 'node:crypto'; +import * as path from 'node:path'; +import { DERIVED_DATA_DIR } from './log-paths.ts'; import { log } from './logger.ts'; export type SessionDefaults = { @@ -133,7 +136,19 @@ class SessionStore { getAllForProfile(profile: string | null): SessionDefaults { const defaults = profile === null ? this.globalDefaults : (this.profiles[profile] ?? {}); - return this.cloneDefaults(defaults); + const result = this.cloneDefaults(defaults); + + if (!result.derivedDataPath) { + const anchor = result.workspacePath ?? result.projectPath; + if (anchor) { + const resolved = path.resolve(anchor); + const hash = crypto.createHash('sha256').update(resolved).digest('hex').slice(0, 12); + const name = path.basename(resolved, path.extname(resolved)); + result.derivedDataPath = path.join(DERIVED_DATA_DIR, `${name}-${hash}`); + } + } + + return result; } listProfiles(): string[] { From 0cbb2e3fac8589644f566da13c198e558a9db0f8 Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Wed, 15 Apr 2026 10:05:40 -0700 Subject: [PATCH 2/4] fix(session): prevent computed derivedDataPath from leaking into stored state Internal read-then-write operations (setDefaultsForProfile, clearForProfile) now use getRawForProfile to avoid persisting the computed derivedDataPath. This ensures the value is recomputed when workspacePath or projectPath changes. --- src/utils/__tests__/session-store.test.ts | 11 +++++++++++ src/utils/session-store.ts | 12 ++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/utils/__tests__/session-store.test.ts b/src/utils/__tests__/session-store.test.ts index 92502f6b..4488394c 100644 --- a/src/utils/__tests__/session-store.test.ts +++ b/src/utils/__tests__/session-store.test.ts @@ -155,4 +155,15 @@ describe('SessionStore', () => { expect(path1).not.toBe(path2); }); + + it('recomputes derivedDataPath when workspacePath changes', () => { + sessionStore.setDefaults({ workspacePath: '/clone-1/MyApp.xcworkspace' }); + const path1 = sessionStore.getAll().derivedDataPath; + + sessionStore.setDefaults({ workspacePath: '/clone-2/MyApp.xcworkspace' }); + const path2 = sessionStore.getAll().derivedDataPath; + + expect(path1).not.toBe(path2); + expect(path2).toMatch(/MyApp-[a-f0-9]{12}$/); + }); }); diff --git a/src/utils/session-store.ts b/src/utils/session-store.ts index e09852de..4082167b 100644 --- a/src/utils/session-store.ts +++ b/src/utils/session-store.ts @@ -79,7 +79,7 @@ class SessionStore { } setDefaultsForProfile(profile: string | null, partial: Partial): void { - const previous = this.getAllForProfile(profile); + const previous = this.getRawForProfile(profile); const next = { ...previous, ...partial }; this.setDefaultsForResolvedProfile(profile, next); this.revision += 1; @@ -117,7 +117,7 @@ class SessionStore { return; } - const next = this.getAllForProfile(profile); + const next = this.getRawForProfile(profile); for (const k of keys) delete next[k]; this.setDefaultsForResolvedProfile(profile, next); @@ -135,8 +135,7 @@ class SessionStore { } getAllForProfile(profile: string | null): SessionDefaults { - const defaults = profile === null ? this.globalDefaults : (this.profiles[profile] ?? {}); - const result = this.cloneDefaults(defaults); + const result = this.getRawForProfile(profile); if (!result.derivedDataPath) { const anchor = result.workspacePath ?? result.projectPath; @@ -151,6 +150,11 @@ class SessionStore { return result; } + private getRawForProfile(profile: string | null): SessionDefaults { + const defaults = profile === null ? this.globalDefaults : (this.profiles[profile] ?? {}); + return this.cloneDefaults(defaults); + } + listProfiles(): string[] { return Object.keys(this.profiles).sort((a, b) => a.localeCompare(b)); } From 911dedda04e28e43df20225c3d2f7e38e99314ad Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Thu, 16 Apr 2026 07:11:04 -0700 Subject: [PATCH 3/4] fix(pipeline): display workspace-scoped derivedDataPath in build header The header fallback now reads derivedDataPath from the session store so the displayed path matches the actual build location when using workspace-scoped DerivedData. --- src/utils/xcodebuild-pipeline.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/utils/xcodebuild-pipeline.ts b/src/utils/xcodebuild-pipeline.ts index f2c0d367..5f9752fa 100644 --- a/src/utils/xcodebuild-pipeline.ts +++ b/src/utils/xcodebuild-pipeline.ts @@ -8,6 +8,7 @@ import { createXcodebuildRunState } from './xcodebuild-run-state.ts'; import type { XcodebuildRunState } from './xcodebuild-run-state.ts'; import { displayPath } from './build-preflight.ts'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; +import { sessionStore } from './session-store.ts'; import { formatDeviceId } from './device-name-resolver.ts'; import { createLogCapture, createParserDebugCapture } from './xcodebuild-log-capture.ts'; import { log as appLog } from './logging/index.ts'; @@ -158,7 +159,8 @@ function buildHeaderParams( // Always show Derived Data even if not explicitly provided if (!result.some((r) => r.label === 'Derived Data')) { - result.push({ label: 'Derived Data', value: displayPath(resolveEffectiveDerivedDataPath()) }); + const effectivePath = resolveEffectiveDerivedDataPath(sessionStore.get('derivedDataPath')); + result.push({ label: 'Derived Data', value: displayPath(effectivePath) }); } return result; From f8e50363f5cbe8515f8c836276dc548d2762d2ad Mon Sep 17 00:00:00 2001 From: Cody Vandermyn Date: Thu, 16 Apr 2026 08:03:49 -0700 Subject: [PATCH 4/4] fix(preflight): display workspace-scoped derivedDataPath in pre-build summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same issue as the pipeline header — formatToolPreflight falls back to the flat default when derivedDataPath is not in the preflight params. Now reads from the session store as a fallback. --- src/utils/build-preflight.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/utils/build-preflight.ts b/src/utils/build-preflight.ts index 2f3c2d4f..9c393ed5 100644 --- a/src/utils/build-preflight.ts +++ b/src/utils/build-preflight.ts @@ -1,6 +1,7 @@ import path from 'node:path'; import os from 'node:os'; import { resolveEffectiveDerivedDataPath } from './derived-data-path.ts'; +import { sessionStore } from './session-store.ts'; export interface ToolPreflightParams { operation: @@ -94,7 +95,7 @@ export function formatToolPreflight(params: ToolPreflightParams): string { } lines.push( - ` Derived Data: ${displayPath(resolveEffectiveDerivedDataPath(params.derivedDataPath))}`, + ` Derived Data: ${displayPath(resolveEffectiveDerivedDataPath(params.derivedDataPath ?? sessionStore.get('derivedDataPath')))}`, ); if (params.arch) {