Skip to content

Conversation

@ammar-agent
Copy link
Collaborator

Summary

Workspaces now use stable, unique IDs (10 hex chars) instead of deriving IDs from paths. This simplifies workspace renames from 150 lines of complex migration logic to ~60 lines of metadata updates.

Changes

Core Implementation:

  • Generate stable IDs at workspace creation using crypto.randomBytes(5).toString('hex')
  • Separate id (stable, immutable) from name (mutable, user-facing)
  • Add symlinks for UX: ~/.cmux/src/<project>/<name><id>
  • Automatic migration for legacy workspaces on startup

Workspace Rename Simplified:

  • Before: Move session dir, migrate message IDs, move worktree, complex rollback (150 lines)
  • After: Update metadata name field and symlink (60 lines)
  • Workspace ID never changes during rename

File Structure:

# New workspace
~/.cmux/src/cmux/a1b2c3d4e5/          # Worktree (stable ID)
~/.cmux/src/cmux/feature-branch → a1b2c3d4e5  # Symlink
~/.cmux/sessions/a1b2c3d4e5/          # Session data

# Legacy workspace (unchanged)
~/.cmux/src/cmux/stable-ids/          # Worktree
~/.cmux/sessions/cmux-stable-ids/     # Session data

Implementation Details

Type Changes:

  • Added WorkspaceMetadata.name field (separate from id)
  • Added WorkspaceMetadata.createdAt timestamp (optional for backward compat)

Config Module:

  • generateStableId(): Create 10-char hex IDs
  • createWorkspaceSymlink(), updateWorkspaceSymlink(), removeWorkspaceSymlink(): Manage symlinks
  • getAllWorkspaceMetadata(): Eager migration for legacy workspaces

Git Operations:

  • Added workspaceId parameter to CreateWorktreeOptions
  • Use stable ID for worktree directory name

IPC Handlers:

  • Workspace creation generates stable ID before creating worktree
  • Rename updates metadata + symlink only (ID unchanged)
  • Remove cleans up symlinks

Frontend:

  • Pass metadata.name to WorkspaceListItem for display
  • Removed path parsing logic

Testing

  • 511 unit tests pass
  • 8 rename integration tests pass
  • 5 remove integration tests pass (including new symlink cleanup test)
  • 9 config unit tests (new)

Simplifications Applied

  1. Removed unused WorkspaceMetadataUI type alias (-3 lines)
  2. Simplified updateWorkspaceSymlink by reusing existing methods (-16 lines)
  3. Improved removeWorkspaceSymlink robustness:
    • Fixed TOCTOU vulnerability (atomic check + operation)
    • Use lstat to avoid following symlinks
    • Handle ENOENT gracefully
  4. Added symlink cleanup on workspace removal (+10 lines, bug fix)

Net change: +368 lines total (+536 additions, -168 deletions), +150 product code

Benefits

  • Instant renames: No file moves, just metadata update
  • Simpler code: Removed 90 lines of complex migration/rollback logic
  • Better UX: Symlinks let users navigate by readable names
  • Stable references: Chat history, config stay valid across renames
  • Future-proof: Enables workspace aliases, templates, cross-project refs

Generated with cmux

@ammar-agent ammar-agent force-pushed the stable-ids branch 16 times, most recently from 968ea5f to 0384d7f Compare October 15, 2025 04:02
@ammario ammario marked this pull request as ready for review October 15, 2025 15:05
ammar-agent added a commit that referenced this pull request Oct 15, 2025
## Problem

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

## Root Cause

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

## Solution

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

## Testing

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.
Copy link

@chatgpt-codex-connector chatgpt-codex-connector bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

ammar-agent added a commit that referenced this pull request Oct 15, 2025
## Problem

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

## Root Cause

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

## Solution

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

## Testing

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.
@ammar-agent ammar-agent force-pushed the stable-ids branch 2 times, most recently from f216cd5 to 4d4d8a1 Compare October 15, 2025 15:36
ammar-agent added a commit that referenced this pull request Oct 15, 2025
## Problem

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

## Root Cause

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

## Solution

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

## Testing

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.
ammar-agent added a commit that referenced this pull request Oct 15, 2025
Move workspace metadata from scattered metadata.json files to centralized
config.json. This establishes config as the single source of truth for
workspace data and fixes missing projectPath errors in git status checks.

**New config structure:**
- Workspace entries now include: id, name, createdAt (optional)
- Legacy path-only entries still supported (backward compat)
- Automatic migration on app startup: reads metadata.json, writes to config

**Benefits:**
- Single source of truth (no scattered session files)
- No more missing projectPath errors
- Simpler architecture
- Backward compatible

**Changes:**
- Workspace interface: Added optional id/name/createdAt fields
- getAllWorkspaceMetadata(): Prefers config, falls back to metadata files
- Workspace create/rename: Now writes full metadata to config
- Migration: Automatic on first load, writes back to config

**Migration strategy:**
- Config entries with id/name: Used directly (new format)
- Config entries with path only: Read from metadata.json, migrate to config
- No metadata file: Generate legacy ID, save to config
- Config saved once if any migrations occurred

Metadata.json files kept for backward compat with older cmux versions.

Fixes #259 (git status not appearing due to missing projectPath)
ammar-agent added a commit that referenced this pull request Oct 15, 2025
## Problem

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

## Root Cause

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

## Solution

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

## Testing

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.
ammar-agent added a commit that referenced this pull request Oct 15, 2025
Move workspace metadata from scattered metadata.json files to centralized
config.json. This establishes config as the single source of truth for
workspace data and fixes missing projectPath errors in git status checks.

**New config structure:**
- Workspace entries now include: id, name, createdAt (optional)
- Legacy path-only entries still supported (backward compat)
- Automatic migration on app startup: reads metadata.json, writes to config

**Benefits:**
- Single source of truth (no scattered session files)
- No more missing projectPath errors
- Simpler architecture
- Backward compatible

**Changes:**
- Workspace interface: Added optional id/name/createdAt fields
- getAllWorkspaceMetadata(): Prefers config, falls back to metadata files
- Workspace create/rename: Now writes full metadata to config
- Migration: Automatic on first load, writes back to config

**Migration strategy:**
- Config entries with id/name: Used directly (new format)
- Config entries with path only: Read from metadata.json, migrate to config
- No metadata file: Generate legacy ID, save to config
- Config saved once if any migrations occurred

Metadata.json files kept for backward compat with older cmux versions.

Fixes #259 (git status not appearing due to missing projectPath)
ammar-agent added a commit that referenced this pull request Oct 16, 2025
## Problem

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

## Root Cause

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

## Solution

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

## Testing

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.
ammar-agent added a commit that referenced this pull request Oct 16, 2025
Move workspace metadata from scattered metadata.json files to centralized
config.json. This establishes config as the single source of truth for
workspace data and fixes missing projectPath errors in git status checks.

**New config structure:**
- Workspace entries now include: id, name, createdAt (optional)
- Legacy path-only entries still supported (backward compat)
- Automatic migration on app startup: reads metadata.json, writes to config

**Benefits:**
- Single source of truth (no scattered session files)
- No more missing projectPath errors
- Simpler architecture
- Backward compatible

**Changes:**
- Workspace interface: Added optional id/name/createdAt fields
- getAllWorkspaceMetadata(): Prefers config, falls back to metadata files
- Workspace create/rename: Now writes full metadata to config
- Migration: Automatic on first load, writes back to config

**Migration strategy:**
- Config entries with id/name: Used directly (new format)
- Config entries with path only: Read from metadata.json, migrate to config
- No metadata file: Generate legacy ID, save to config
- Config saved once if any migrations occurred

Metadata.json files kept for backward compat with older cmux versions.

Fixes #259 (git status not appearing due to missing projectPath)
ammar-agent added a commit that referenced this pull request Oct 16, 2025
Workspaces now use stable, unique IDs (10 hex chars) instead of deriving IDs from paths. This simplifies workspace renames from 150 lines of complex migration logic to ~60 lines of metadata updates.

**Stable ID Generation:**
- Generate at workspace creation: `crypto.randomBytes(5).toString('hex')`
- Separate `id` (stable, immutable) from `name` (mutable, user-facing)
- Add symlinks for UX: `~/.cmux/src/<project>/<name>` → `<id>`

**Type System:**
- `WorkspaceMetadata`: Backend type with stable ID, no path field
- `WorkspaceMetadataWithPaths`: Frontend type with computed paths
- IPC layer enriches metadata with `stableWorkspacePath` and `namedWorkspacePath`

**Workspace Operations:**
- Create: Generate stable ID before creating worktree
- Rename: Update metadata + symlink only (ID unchanged, ~60 lines)
- Remove: Clean up worktree, session data, and symlinks

**Frontend Integration:**
- Build `pathToMetadata` map for lookups (handles both stable and legacy)
- Use map lookups instead of parsing workspace IDs from paths
- Support both new stable-ID workspaces and legacy name-based workspaces

```
~/.cmux/src/cmux/a1b2c3d4e5/          # Worktree (stable ID)
~/.cmux/src/cmux/feature-branch → a1b2c3d4e5  # Symlink
~/.cmux/sessions/a1b2c3d4e5/          # Session data

~/.cmux/src/cmux/stable-ids/          # Worktree
~/.cmux/sessions/cmux-stable-ids/     # Session data
```

- **Instant renames**: No file moves, just metadata update
- **Simpler code**: Removed 90 lines of complex migration/rollback logic
- **Better UX**: Symlinks let users navigate by readable names
- **Stable references**: Chat history, config stay valid across renames
- **Future-proof**: Enables workspace aliases, templates, cross-project refs

- ✅ 511 unit tests pass
- ✅ 8 rename integration tests pass
- ✅ 5 remove integration tests pass
- ✅ 13 E2E tests pass
- ✅ 9 new config unit tests

Fix stuck loading state for deleted/invalid workspaces

When a workspace is deleted or doesn't exist:
- Previously: selectedWorkspace persisted in localStorage, causing eternal "Loading workspace..."
- Now: Validate workspace exists on mount and clear invalid selection

Added validation effect that:
- Checks if selected workspace ID exists in workspaceMetadata
- Clears selection if workspace was deleted
- Also clears URL hash to prevent re-selection on reload

Fixes edge cases:
- Workspace deleted while app was closed
- URL with invalid workspace ID (#workspace=invalid-id)
- Workspace removed from another instance

Remove rename blocking during streaming

With stable IDs, workspace rename no longer requires moving files or
changing workspace ID. Rename only updates:
- metadata.name (display name)
- Symlink (~/.cmux/src/project/name → workspaceId)

Session directory (~/.cmux/sessions/workspaceId) remains unchanged,
so active streams can continue writing safely.

Changes:
- Remove isStreaming check from WORKSPACE_RENAME handler
- Remove "should block rename during active stream" test
- Simplifies UX: no more "Press Esc first" error

Benefits:
- Users can organize workspaces without interrupting work
- One less artificial limitation
- Cleaner, simpler code (-38 lines)

Fix integration test race condition with AI SDK dynamic imports

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.

Fix formatting

Refactor: eliminate pathToMetadata code smell

1. Rename type: WorkspaceMetadataWithPaths → FrontendWorkspaceMetadata
2. sortedWorkspacesByProject returns metadata arrays directly
3. Removed duplicate pathToMetadata maps from App.tsx and ProjectSidebar
4. WorkspaceListItem accepts metadata object (6 props → 1)
5. Updated keyboard navigation to work with metadata

- Net: -23 lines (removed duplicate logic)
- Clearer data flow: pass data, not lookup maps
- Simpler component API: metadata object vs 6 props

16 files changed, 124 insertions(+), 147 deletions(-)

Fix lint errors: remove unused imports and params
ammar-agent added a commit that referenced this pull request Oct 16, 2025
Codex P0: Fix missing name/projectPath fields in old metadata

Old installations have metadata.json with only id/projectName/workspacePath.
When getAllWorkspaceMetadata() loads these files, enrichMetadataWithPaths()
fails because getWorkspacePaths() requires metadata.name and metadata.projectPath.

Solution: Detect missing fields when loading metadata and migrate in-place:
- Add name field (from workspace basename)
- Add projectPath field (from config)
- Save migrated metadata to disk

This prevents the empty workspace list bug where legacy workspaces disappear
from the UI after upgrading to stable IDs.

Add detailed error logging for missing projectPath in executeBash

Helps diagnose git status failures by showing full metadata when
projectPath is missing, revealing why the migration didn't apply.

🤖 Centralize workspace metadata in config.json

Move workspace metadata from scattered metadata.json files to centralized
config.json. This establishes config as the single source of truth for
workspace data and fixes missing projectPath errors in git status checks.

**New config structure:**
- Workspace entries now include: id, name, createdAt (optional)
- Legacy path-only entries still supported (backward compat)
- Automatic migration on app startup: reads metadata.json, writes to config

**Benefits:**
- Single source of truth (no scattered session files)
- No more missing projectPath errors
- Simpler architecture
- Backward compatible

**Changes:**
- Workspace interface: Added optional id/name/createdAt fields
- getAllWorkspaceMetadata(): Prefers config, falls back to metadata files
- Workspace create/rename: Now writes full metadata to config
- Migration: Automatic on first load, writes back to config

**Migration strategy:**
- Config entries with id/name: Used directly (new format)
- Config entries with path only: Read from metadata.json, migrate to config
- No metadata file: Generate legacy ID, save to config
- Config saved once if any migrations occurred

Metadata.json files kept for backward compat with older cmux versions.

Fixes #259 (git status not appearing due to missing projectPath)

🤖 Fix formatting

🤖 Remove redundant path field from ProjectConfig

Project path was duplicated as both the Map key and the ProjectConfig.path
field. Removed the redundant field and updated all code to use the Map key.

**Changes:**
- Created src/types/project.ts with lightweight ProjectConfig types
- ProjectConfig now only has workspaces array (path is the Map key)
- Updated IPC handler to return [projectPath, projectConfig] tuples
- Updated frontend hooks to construct Map from tuples
- Preload imports from types/project.ts (not heavy config.ts)

**Benefits:**
- Eliminates data duplication (single source of truth)
- Lighter preload imports (types-only, no runtime code)
- Cleaner config structure
- -10 lines of code (removed redundant path assignments)

🤖 Fix formatting

🤖 Fix getWorkspaceMetadata to use centralized config

getWorkspaceMetadata() was reading directly from metadata.json files,
bypassing the migration logic in getAllWorkspaceMetadata(). This caused
git status checks to fail with missing projectPath errors.

**Root Cause:**
- getAllWorkspaceMetadata() applies migration and returns complete data
- getWorkspaceMetadata() read files directly, got old format without projectPath
- Git status calls getWorkspaceMetadata(), failed validation

**Solution:**
- getWorkspaceMetadata() now calls getAllWorkspaceMetadata() and finds by ID
- Single source of truth: all metadata access goes through config
- Migration logic applied consistently everywhere

**Why this works:**
- getAllWorkspaceMetadata() reads config first (already migrated)
- Falls back to metadata files only for legacy entries
- Applies migration on the fly if needed
- Returns complete WorkspaceMetadata with all required fields

This ensures git status (and all other metadata consumers) always get
complete, validated metadata regardless of the underlying storage format.

🤖 Add debug logging to diagnose missing projectPath

🤖 Use log.info instead of console.error

🤖 Add more debug logging in WORKSPACE_EXECUTE_BASH handler
Workspaces now use stable, unique IDs (10 hex chars) instead of deriving IDs from paths. This simplifies workspace renames from 150 lines of complex migration logic to ~60 lines of metadata updates.

**Stable ID Generation:**
- Generate at workspace creation: `crypto.randomBytes(5).toString('hex')`
- Separate `id` (stable, immutable) from `name` (mutable, user-facing)
- Add symlinks for UX: `~/.cmux/src/<project>/<name>` → `<id>`

**Type System:**
- `WorkspaceMetadata`: Backend type with stable ID, no path field
- `WorkspaceMetadataWithPaths`: Frontend type with computed paths
- IPC layer enriches metadata with `stableWorkspacePath` and `namedWorkspacePath`

**Workspace Operations:**
- Create: Generate stable ID before creating worktree
- Rename: Update metadata + symlink only (ID unchanged, ~60 lines)
- Remove: Clean up worktree, session data, and symlinks

**Frontend Integration:**
- Build `pathToMetadata` map for lookups (handles both stable and legacy)
- Use map lookups instead of parsing workspace IDs from paths
- Support both new stable-ID workspaces and legacy name-based workspaces

```
~/.cmux/src/cmux/a1b2c3d4e5/          # Worktree (stable ID)
~/.cmux/src/cmux/feature-branch → a1b2c3d4e5  # Symlink
~/.cmux/sessions/a1b2c3d4e5/          # Session data

~/.cmux/src/cmux/stable-ids/          # Worktree
~/.cmux/sessions/cmux-stable-ids/     # Session data
```

- **Instant renames**: No file moves, just metadata update
- **Simpler code**: Removed 90 lines of complex migration/rollback logic
- **Better UX**: Symlinks let users navigate by readable names
- **Stable references**: Chat history, config stay valid across renames
- **Future-proof**: Enables workspace aliases, templates, cross-project refs

- ✅ 511 unit tests pass
- ✅ 8 rename integration tests pass
- ✅ 5 remove integration tests pass
- ✅ 13 E2E tests pass
- ✅ 9 new config unit tests

Fix stuck loading state for deleted/invalid workspaces

When a workspace is deleted or doesn't exist:
- Previously: selectedWorkspace persisted in localStorage, causing eternal "Loading workspace..."
- Now: Validate workspace exists on mount and clear invalid selection

Added validation effect that:
- Checks if selected workspace ID exists in workspaceMetadata
- Clears selection if workspace was deleted
- Also clears URL hash to prevent re-selection on reload

Fixes edge cases:
- Workspace deleted while app was closed
- URL with invalid workspace ID (#workspace=invalid-id)
- Workspace removed from another instance

Remove rename blocking during streaming

With stable IDs, workspace rename no longer requires moving files or
changing workspace ID. Rename only updates:
- metadata.name (display name)
- Symlink (~/.cmux/src/project/name → workspaceId)

Session directory (~/.cmux/sessions/workspaceId) remains unchanged,
so active streams can continue writing safely.

Changes:
- Remove isStreaming check from WORKSPACE_RENAME handler
- Remove "should block rename during active stream" test
- Simplifies UX: no more "Press Esc first" error

Benefits:
- Users can organize workspaces without interrupting work
- One less artificial limitation
- Cleaner, simpler code (-38 lines)

Fix integration test race condition with AI SDK dynamic imports

Integration tests were failing in CI with:
"Failed to create model: ReferenceError: You are trying to `import` a
file outside of the scope of the test code."

This only occurred when multiple tests ran concurrently in CI, not locally.

AI SDK providers use dynamic imports for lazy loading (to optimize startup
time from 6-13s → 3-6s). Under high concurrency in CI (8 workers × 11 test
files × concurrent tests within files), Jest/Bun's module resolution has a
race condition where multiple simultaneous dynamic imports of the same
module can fail.

Preload AI SDK providers once during test setup, similar to how we preload
tokenizer modules. This ensures subsequent dynamic imports hit the module
cache instead of racing.

- Added `preloadAISDKProviders()` function to aiService.ts
- Called during `setupWorkspace()` alongside `loadTokenizerModules()`
- Preserves lazy loading in production (startup optimization)
- Eliminates race condition in concurrent test environment

- ✅ Tests pass locally with concurrent execution
- ✅ No impact on production startup time (preload only in tests)
- ✅ No changes to test behavior, only timing/reliability

Fixes the flaky integration test failures in PR #259.

Fix formatting

Refactor: eliminate pathToMetadata code smell

1. Rename type: WorkspaceMetadataWithPaths → FrontendWorkspaceMetadata
2. sortedWorkspacesByProject returns metadata arrays directly
3. Removed duplicate pathToMetadata maps from App.tsx and ProjectSidebar
4. WorkspaceListItem accepts metadata object (6 props → 1)
5. Updated keyboard navigation to work with metadata

- Net: -23 lines (removed duplicate logic)
- Clearer data flow: pass data, not lookup maps
- Simpler component API: metadata object vs 6 props

16 files changed, 124 insertions(+), 147 deletions(-)

Fix lint errors: remove unused imports and params
Codex P0: Fix missing name/projectPath fields in old metadata

Old installations have metadata.json with only id/projectName/workspacePath.
When getAllWorkspaceMetadata() loads these files, enrichMetadataWithPaths()
fails because getWorkspacePaths() requires metadata.name and metadata.projectPath.

Solution: Detect missing fields when loading metadata and migrate in-place:
- Add name field (from workspace basename)
- Add projectPath field (from config)
- Save migrated metadata to disk

This prevents the empty workspace list bug where legacy workspaces disappear
from the UI after upgrading to stable IDs.

Add detailed error logging for missing projectPath in executeBash

Helps diagnose git status failures by showing full metadata when
projectPath is missing, revealing why the migration didn't apply.

🤖 Centralize workspace metadata in config.json

Move workspace metadata from scattered metadata.json files to centralized
config.json. This establishes config as the single source of truth for
workspace data and fixes missing projectPath errors in git status checks.

**New config structure:**
- Workspace entries now include: id, name, createdAt (optional)
- Legacy path-only entries still supported (backward compat)
- Automatic migration on app startup: reads metadata.json, writes to config

**Benefits:**
- Single source of truth (no scattered session files)
- No more missing projectPath errors
- Simpler architecture
- Backward compatible

**Changes:**
- Workspace interface: Added optional id/name/createdAt fields
- getAllWorkspaceMetadata(): Prefers config, falls back to metadata files
- Workspace create/rename: Now writes full metadata to config
- Migration: Automatic on first load, writes back to config

**Migration strategy:**
- Config entries with id/name: Used directly (new format)
- Config entries with path only: Read from metadata.json, migrate to config
- No metadata file: Generate legacy ID, save to config
- Config saved once if any migrations occurred

Metadata.json files kept for backward compat with older cmux versions.

Fixes #259 (git status not appearing due to missing projectPath)

🤖 Fix formatting

🤖 Remove redundant path field from ProjectConfig

Project path was duplicated as both the Map key and the ProjectConfig.path
field. Removed the redundant field and updated all code to use the Map key.

**Changes:**
- Created src/types/project.ts with lightweight ProjectConfig types
- ProjectConfig now only has workspaces array (path is the Map key)
- Updated IPC handler to return [projectPath, projectConfig] tuples
- Updated frontend hooks to construct Map from tuples
- Preload imports from types/project.ts (not heavy config.ts)

**Benefits:**
- Eliminates data duplication (single source of truth)
- Lighter preload imports (types-only, no runtime code)
- Cleaner config structure
- -10 lines of code (removed redundant path assignments)

🤖 Fix formatting

🤖 Fix getWorkspaceMetadata to use centralized config

getWorkspaceMetadata() was reading directly from metadata.json files,
bypassing the migration logic in getAllWorkspaceMetadata(). This caused
git status checks to fail with missing projectPath errors.

**Root Cause:**
- getAllWorkspaceMetadata() applies migration and returns complete data
- getWorkspaceMetadata() read files directly, got old format without projectPath
- Git status calls getWorkspaceMetadata(), failed validation

**Solution:**
- getWorkspaceMetadata() now calls getAllWorkspaceMetadata() and finds by ID
- Single source of truth: all metadata access goes through config
- Migration logic applied consistently everywhere

**Why this works:**
- getAllWorkspaceMetadata() reads config first (already migrated)
- Falls back to metadata files only for legacy entries
- Applies migration on the fly if needed
- Returns complete WorkspaceMetadata with all required fields

This ensures git status (and all other metadata consumers) always get
complete, validated metadata regardless of the underlying storage format.

🤖 Add debug logging to diagnose missing projectPath

🤖 Use log.info instead of console.error

🤖 Add more debug logging in WORKSPACE_EXECUTE_BASH handler
Use config.findWorkspace() to get actual workspace path instead of computing it.
This fixes ENOENT errors for legacy workspaces where the directory name doesn't
match the computed path.

**Root Cause:**
- Legacy workspaces: ID is 'cmux-compact-flag-bug', dir is 'compact-flag-bug'
- getWorkspacePath(projectPath, id) computed: src/cmux/cmux-compact-flag-bug ❌
- Actual path in config: src/cmux/compact-flag-bug ✅

**Solution:**
- Use findWorkspace() which returns path directly from config
- Config stores actual filesystem paths (source of truth)
- Works for both legacy and new workspace formats

**Changes:**
- WORKSPACE_EXECUTE_BASH: Use findWorkspace() instead of getWorkspacePath()
- Removed debug logging from getWorkspaceMetadata and ipcMain handler
- Removed projectPath validation (no longer needed since using config path)

Fixes git status ENOENT errors.

🤖 Fix all workspace path resolution + reduce git status error spam

**Fixes path resolution everywhere:**
- aiService.sendMessage: Use findWorkspace() for AI tool operations
- ipcMain.removeWorkspaceInternal: Use findWorkspace() for deletion
- ipcMain.enrichMetadataWithPaths: Use findWorkspace() for frontend paths

**Reduces console spam:**
- GitStatusStore: Only log OUTPUT TRUNCATED/OVERFLOW as debug level
- Common in large repos, not an actionable error

**Root cause:**
getWorkspacePath() computes paths that don't match legacy workspace directories:
- Computed: ~/.cmux/src/cmux/cmux-compact-flag-bug
- Actual: ~/.cmux/src/cmux/compact-flag-bug

**Solution:**
Always use findWorkspace() to get actual paths from config (source of truth).
Only use getWorkspacePath() for NEW workspace creation.

**Changes:**
- 3 more call sites fixed to use findWorkspace()
- Added warning comment to getWorkspacePath()
- GitStatusStore filters out truncation spam

🤖 Replace ambiguous workspacePath with explicit namedWorkspacePath

Replaced all uses of workspacePath with namedWorkspacePath throughout the codebase
to be explicit that we're using the user-friendly path (symlink for new workspaces).

**Benefits:**
- Clear distinction between stableWorkspacePath (for operations) and namedWorkspacePath (for display)
- No more ambiguity about which path is being used
- Window title now shows workspace name instead of ID
- Terminal opens to named path (user-friendly)
- Rename updates UI immediately with enriched metadata

**Files changed:**
- WorkspaceSelection interface: workspacePath → namedWorkspacePath
- AIView props: workspacePath → namedWorkspacePath
- App.tsx: Window title uses workspace name, all selections use namedWorkspacePath
- All command palette sources updated
- All hooks updated

This anti-pattern cleanup ensures clarity everywhere paths are used.

🤖 Add safety checks for undefined namedWorkspacePath

Handle case where selectedWorkspace has old format without namedWorkspacePath
(from localStorage before rebuild). Uses optional chaining and fallback to workspaceId.

Fixes: Cannot read properties of undefined (reading 'split')

🤖 Subscribe to workspace metadata updates in useWorkspaceManagement

Fixed rename not updating UI immediately by subscribing to WORKSPACE_METADATA
IPC events. Previously only reloaded metadata after explicit operations.

**Root cause:**
- workspaceMetadata was only loaded on mount and after create/delete/rename
- But rename emits WORKSPACE_METADATA event that wasn't being listened to
- So metadata map in App.tsx was stale until manual reload

**Solution:**
- Subscribe to workspace.subscribeToMetadata() in useEffect
- Update workspaceMetadata map when event received
- Unsubscribe on cleanup

**Result:**
- Rename updates UI immediately (sidebar, window title, paths)
- No manual refresh needed

🤖 Fix type annotation for metadata event

🤖 Fix: Use onMetadata not subscribeToMetadata

🤖 Add debug logging for workspace metadata loading

🤖 Fix: onMetadata already sends all initial state, remove duplicate load

onMetadata is designed to send all current metadata immediately upon subscription,
so we don't need the manual loadWorkspaceMetadata() call. The duplicate load was
causing a race condition where one could overwrite the other.

Removed loadWorkspaceMetadata() from useEffect and rely solely on onMetadata
subscription for both initial state and updates.

Fix: Use workspace ID for metadata lookup, not path

The sortedWorkspacesByProject was building a path-based lookup map,
but workspaceMetadata is keyed by workspace ID. This caused all
workspaces to be filtered out when building the sorted list.

Now we directly look up by ws.id from the config.

Remove debug logging from useWorkspaceManagement

Fix: Detect workspace renames in sortedWorkspacesByProject

The comparison function only checked workspace IDs, so when a
workspace was renamed (ID stays same, name changes), it didn't
detect the change and the UI didn't update.

Now checks both id and name to properly detect renames.

Batch workspace metadata updates on initial load

When subscribing to metadata, backend sends one event per workspace
synchronously. This was causing N re-renders for N workspaces.

Now we batch these updates using queueMicrotask - all synchronous
events are collected and flushed in a single state update after the
current task completes. This reduces 19 re-renders to 1 on startup.

Fix: Restore workspace from URL hash after metadata loads

The restore effect ran on mount before workspaceMetadata was loaded,
so it always failed to find the workspace. Now it waits for metadata
to be loaded and only restores once.

Use named workspace paths for all user-facing operations

Agent bash calls and UI bash execute should use the friendly named
workspace path (symlink or legacy dir name), not the internal stable
ID path. This matches what users see in the UI.

Backwards compatible: legacy workspaces have no symlinks, so the
named path IS the actual directory path.

Fix: Update old localStorage entries with missing namedWorkspacePath

Old selectedWorkspace entries from localStorage may be missing the
namedWorkspacePath field. The validation effect now detects this and
updates the workspace with current metadata, preventing errors in
command palette and other components that expect the field.

Fix crash on workspace delete and hash restore priority

- Add null check in workspace sort comparison to prevent crash when workspace is deleted
- Fix hash restore: now takes priority over localStorage by running once on mount
- Add guard to prevent duplicate 'Updating workspace' log messages

Fix critical bug: findWorkspace now checks config.id first

Root cause: findWorkspace() was reading metadata.json files instead of
checking workspace.id from config, causing all workspaces to fail enrichment.

Changes:
- findWorkspace() now checks workspace.id from config first (primary source)
- Falls back to metadata.json only for unmigrated legacy workspaces
- Remove metadata.json writes from workspace create/rename (config is source of truth)
- Keep metadata.json read for backward compat during migration

This fixes:
- "Workspace no longer exists" errors
- Title showing ID instead of name
- Terminal opening in project path instead of workspace path

All caused by enrichMetadataWithPaths() failing when findWorkspace() returned null.

Simplify: getAllWorkspaceMetadata returns complete data with paths

Architectural simplification to eliminate O(n²) complexity and prevent bugs:

BEFORE:
- getAllWorkspaceMetadata() returned WorkspaceMetadata (no paths)
- Subscription handler sent incomplete data to frontend → UI broke
- enrichMetadataWithPaths() had to search config again to find paths
- Each caller had to remember to call enrichment → easy to forget

AFTER:
- getAllWorkspaceMetadata() returns FrontendWorkspaceMetadata with paths
- Paths computed once during the initial loop (we already have the data!)
- No enrichment step needed - data is always complete
- Subscription handler sends complete data → frontend always gets paths

Changes:
- Added addPathsToMetadata() helper to avoid duplication (DRY)
- Updated all 5 path-adding locations to use helper
- Removed enrichMetadataWithPaths() (~20 LOC deleted)
- Updated all callers to use complete metadata directly

Net result: -45 LOC, O(n) instead of O(n²), impossible to forget enrichment

Add debug logging for metadata subscription

To diagnose why workspaces aren't loading on reload

Simplify metadata loading with proper loading state

BEFORE:
- Subscription sent metadata piece-by-piece on subscribe
- Batching logic to avoid re-renders
- Race condition: restore effect ran before batch completed
- Checked workspaceMetadata.size === 0 which passed, then validation cleared selection

AFTER:
- Call workspace.list() once on mount to get all metadata
- Set loading: true until complete
- All effects wait for metadataLoading === false
- Subscription only used for updates (create/rename/delete)

Eliminates race conditions and simplifies the loading flow.

Fix: Use namedWorkspacePath for terminal in command palette

Bug: Command palette 'Open Workspace in Terminal' was passing workspace ID
instead of path, causing terminal to open in wrong directory.

Fix:
- Changed field name from 'workspacePath' to 'workspaceId' (matches actual value)
- Look up workspace metadata to get namedWorkspacePath
- Pass namedWorkspacePath to openTerminal (user-friendly symlink path)

Unify terminal opening: always use workspace ID, not paths

BEFORE:
- AIView: Called openTerminal(namedWorkspacePath) directly
- Command palette 'Open Current': Called callback(namedWorkspacePath)
- Command palette 'Open Any': Called callback(workspaceId) then looked up path
- App.tsx callback: Took workspacePath parameter

Multiple code paths, easy to make mistakes with paths.

AFTER:
- All callers pass workspace ID to openWorkspaceInTerminal()
- Single code path in App.tsx looks up metadata and extracts namedWorkspacePath
- Consistent: workspace ID is the universal identifier

This eliminates the risk of passing wrong paths (stable vs named).

🤖 Fix macOS terminal opening and clean up legacy code

- Fix: macOS Ghostty now called directly with --working-directory flag
  Previously used 'open -a Ghostty /path' which doesn't set cwd
- Remove unused legacy functions from gitService.ts:
  - createWorktree (duplicate, real one in git.ts)
  - moveWorktree (unused)
  - listWorktrees (unused)
  - isGitRepository (unused)
  - getMainWorktreeFromWorktree (duplicate)
- Update gitService.test.ts to import createWorktree from git.ts
- Add logging for successful terminal opens

Revert terminal opening changes - original code was correct

The 'open -a AppName /directory' command DOES set the cwd correctly.
My 'fix' broke it by calling ghostty CLI directly which spawns a background
process instead of opening a GUI window.

🤖 Use cwd option instead of passing directory as arg to terminal

Set cwd in spawn options rather than passing workspacePath as argument.
This is cleaner and more consistent with Linux terminal handling.

🤖 Fix macOS terminal opening with proper --args syntax

Problem: Terminals weren't opening at all on macOS
Root cause: Using cwd option doesn't work with 'open' command

Solution:
- Ghostty: Use 'open -a Ghostty --args --working-directory=$path'
  The --args flag passes remaining arguments to the app itself
- Terminal.app: Pass directory path directly (original approach works)

Verified manually that both approaches open terminal in correct directory.

🤖 macOS: match main for Ghostty terminal opens (avoid regression)

Revert Ghostty invocation to exactly mirror main: use
  open -a Ghostty <workspacePath>

No flags or --args. This avoids any behavior change on macOS and
fixes the regression where Ghostty windows didn’t appear.

Generated with .

🤖 Log full terminal command invocation in backend

Add log.info() before spawning terminal processes on all platforms:
- macOS: logs 'open -a Ghostty <path>' or 'open -a Terminal <path>'
- Windows: logs 'cmd /c start cmd /K cd /D <path>'
- Linux: logs '<terminal> <args>' with optional cwd info

This helps debug terminal launching issues by showing exactly what
command is being executed.

Generated with `cmux`.
Terminals always resolve symlinks, showing stable IDs instead of names.
Instead of managing symlinks, use workspace names as real directory names
and track stable IDs in config.

Changes:
- Workspaces created with name as directory (not stable ID)
- Rename uses `git worktree move` to rename directory + update config
- Removed all symlink creation/management code
- Both stableWorkspacePath and namedWorkspacePath now return same path
- Config stores ID mapping: workspace.id tracks stable identity

Benefits:
- Simpler code (no symlink management)
- Better UX (terminals naturally show friendly names)
- Still stable (IDs tracked in config, renames don't break identity)
- No existing users (can make breaking changes)

_Generated with `cmux`_

🤖 Fix lint errors from workspace rename changes

🤖 Block workspace rename during active streaming

Prevents race conditions when renaming while AI stream is active:
- Bash tool processes would have stale cwd references
- System message would contain incorrect workspace path
- Git worktree move could conflict with active file operations

Changes:
- Check isStreaming() before allowing rename
- Return clear error message to user
- Add integration test verifying blocking behavior

Rename succeeds immediately after stream completes.

_Generated with `cmux`_

Fix App.tsx to use correct NewWorkspaceModal props after rebase

Fix lint errors: add void to async call and workspaceMetadata dependency

Fix executeBash test to check for workspace name instead of ID

Fix gitService tests to detect default branch instead of hardcoding 'main'

🤖 Fix: path checks use workspace name for directory lookup (stable-ids arch)

- AgentSession.ensureMetadata compared against getWorkspacePath(projectPath, id)\n  but directories are name-based. Use name instead.\n- Clarify config comment about getWorkspacePath usage.\n\nGenerated with
The migration logic in getAllWorkspaceMetadata() was trying to load
metadata.json using workspace basename first (e.g., 'demo-review'),
then falling back to legacy ID format (e.g., 'demo-repo-demo-review').

However:
- New workspaces have metadata in config (id + name fields), not metadata.json
- E2E tests and legacy workspaces use the legacy ID format (project-workspace)

The first check (workspace basename) would never succeed for valid cases:
- It's unnecessary for new workspaces (they skip metadata.json lookup)
- It fails for E2E tests/legacy workspaces (they use legacy ID format)

This caused all E2E tests to timeout waiting for workspace list items to appear,
because workspaces weren't being discovered during config migration.

Fix: Remove the redundant first check and go straight to legacy ID format.
This makes E2E tests work while maintaining backward compatibility with
existing workspaces.

🤖 Fix formatting in config.ts
After architecture inversion, workspace directories use names (not stable IDs),
so stableWorkspacePath and namedWorkspacePath are identical. Remove the duplicate
field to simplify the type system.

Changes:
- Remove stableWorkspacePath from FrontendWorkspaceMetadata
- Update Config.getWorkspacePaths() to only return namedWorkspacePath
- Remove stableWorkspacePath from all test fixtures
- Fix config.test.ts to test legacy ID format correctly
- Remove stableWorkspacePath from createWorkspace and renameWorkspace tests
- Fix E2E demoProject config: ProjectConfig only has 'workspaces', not 'path'
- Primary fix: Handle null metadata in onMetadata subscription (delete from map)
- Defense: Add null check to filter in sortedWorkspacesByProject
- Safety: Add null guard in metadata comparison function

Prevents crash when deleting workspace by ensuring null metadata
never pollutes the state Map or reaches comparison logic.
@ammario ammario merged commit a95d027 into main Oct 16, 2025
8 checks passed
@ammario ammario deleted the stable-ids branch October 16, 2025 15:55
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants