Skip to content

Commit 3bb7f93

Browse files
committed
Migrate legacy workspace metadata on load
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
1 parent 3d5e5db commit 3bb7f93

File tree

10 files changed

+209
-92
lines changed

10 files changed

+209
-92
lines changed

src/config.test.ts

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,6 @@ describe("Config", () => {
145145
// Add workspace to config without metadata file
146146
config.editConfig((cfg) => {
147147
cfg.projects.set(projectPath, {
148-
path: projectPath,
149148
workspaces: [{ path: workspacePath }],
150149
});
151150
return cfg;
@@ -161,17 +160,14 @@ describe("Config", () => {
161160
expect(metadata.projectName).toBe("project");
162161
expect(metadata.projectPath).toBe(projectPath);
163162

164-
// Verify metadata file was created
165-
const sessionDir = config.getSessionDir("project-feature-branch");
166-
const metadataPath = path.join(sessionDir, "metadata.json");
167-
expect(fs.existsSync(metadataPath)).toBe(true);
168-
169-
const savedMetadata = JSON.parse(fs.readFileSync(metadataPath, "utf-8")) as {
170-
id: string;
171-
name: string;
172-
};
173-
expect(savedMetadata.id).toBe("project-feature-branch");
174-
expect(savedMetadata.name).toBe("feature-branch");
163+
// Verify metadata was migrated to config
164+
const configData = config.loadConfigOrDefault();
165+
const projectConfig = configData.projects.get(projectPath);
166+
expect(projectConfig).toBeDefined();
167+
expect(projectConfig!.workspaces).toHaveLength(1);
168+
const workspace = projectConfig!.workspaces[0];
169+
expect(workspace.id).toBe("project-feature-branch");
170+
expect(workspace.name).toBe("feature-branch");
175171
});
176172

177173
it("should use existing metadata file if present", () => {
@@ -198,20 +194,29 @@ describe("Config", () => {
198194
// Add workspace to config
199195
config.editConfig((cfg) => {
200196
cfg.projects.set(projectPath, {
201-
path: projectPath,
202197
workspaces: [{ path: workspacePath }],
203198
});
204199
return cfg;
205200
});
206201

207-
// Get all metadata (should use existing metadata)
202+
// Get all metadata (should use existing metadata and migrate to config)
208203
const allMetadata = config.getAllWorkspaceMetadata();
209204

210205
expect(allMetadata).toHaveLength(1);
211206
const metadata = allMetadata[0];
212207
expect(metadata.id).toBe(workspaceId);
213208
expect(metadata.name).toBe("my-feature");
214209
expect(metadata.createdAt).toBe("2025-01-01T00:00:00.000Z");
210+
211+
// Verify metadata was migrated to config
212+
const configData = config.loadConfigOrDefault();
213+
const projectConfig = configData.projects.get(projectPath);
214+
expect(projectConfig).toBeDefined();
215+
expect(projectConfig!.workspaces).toHaveLength(1);
216+
const workspace = projectConfig!.workspaces[0];
217+
expect(workspace.id).toBe(workspaceId);
218+
expect(workspace.name).toBe("my-feature");
219+
expect(workspace.createdAt).toBe("2025-01-01T00:00:00.000Z");
215220
});
216221
});
217222
});

src/config.ts

Lines changed: 91 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,10 @@ import * as jsonc from "jsonc-parser";
66
import writeFileAtomic from "write-file-atomic";
77
import type { WorkspaceMetadata } from "./types/workspace";
88
import type { Secret, SecretsConfig } from "./types/secrets";
9+
import type { Workspace, ProjectConfig, ProjectsConfig } from "./types/project";
910

10-
export interface Workspace {
11-
path: string; // Absolute path to workspace worktree (format: ~/.cmux/src/{projectName}/{workspaceId})
12-
// NOTE: The workspace ID is the basename of this path (stable random ID like 'a1b2c3d4e5').
13-
// Use config.getWorkspacePath(projectPath, workspaceId) to construct paths consistently.
14-
}
15-
16-
export interface ProjectConfig {
17-
path: string;
18-
workspaces: Workspace[];
19-
}
20-
21-
export interface ProjectsConfig {
22-
projects: Map<string, ProjectConfig>;
23-
}
11+
// Re-export project types from dedicated types file (for preload usage)
12+
export type { Workspace, ProjectConfig, ProjectsConfig };
2413

2514
export interface ProviderConfig {
2615
apiKey?: string;
@@ -292,20 +281,19 @@ export class Config {
292281

293282
/**
294283
* Get all workspace metadata by loading config and metadata files.
295-
* Performs eager migration for legacy workspaces on startup.
296284
*
297-
* Migration strategy:
298-
* - For each workspace in config, try to load metadata.json from session dir
299-
* - If metadata exists, use it (already migrated or new workspace)
300-
* - If metadata doesn't exist, this is a legacy workspace:
301-
* - Generate legacy ID from path (for backward compatibility)
302-
* - Extract name from workspace path
303-
* - Create and save metadata.json
304-
* - Create symlink if name differs from ID (new workspaces only)
285+
* NEW BEHAVIOR: Config is the primary source of truth
286+
* - If workspace has id/name/createdAt in config, use those directly
287+
* - If workspace only has path, fall back to reading metadata.json
288+
* - Migrate old workspaces by copying metadata from files to config
289+
*
290+
* This centralizes workspace metadata in config.json and eliminates the need
291+
* for scattered metadata.json files (kept for backward compat with older versions).
305292
*/
306293
getAllWorkspaceMetadata(): WorkspaceMetadata[] {
307294
const config = this.loadConfigOrDefault();
308295
const workspaceMetadata: WorkspaceMetadata[] = [];
296+
let configModified = false;
309297

310298
for (const [projectPath, projectConfig] of config.projects) {
311299
const projectName = this.getProjectName(projectPath);
@@ -316,42 +304,95 @@ export class Config {
316304
workspace.path.split("/").pop() ?? workspace.path.split("\\").pop() ?? "unknown";
317305

318306
try {
319-
// Try to load metadata using workspace basename as ID (works for new workspaces with stable IDs)
307+
// NEW FORMAT: If workspace has metadata in config, use it directly
308+
if (workspace.id && workspace.name) {
309+
const metadata: WorkspaceMetadata = {
310+
id: workspace.id,
311+
name: workspace.name,
312+
projectName,
313+
projectPath,
314+
createdAt: workspace.createdAt,
315+
};
316+
workspaceMetadata.push(metadata);
317+
continue; // Skip metadata file lookup
318+
}
319+
320+
// LEGACY FORMAT: Fall back to reading metadata.json
321+
// Try workspace basename first (for new stable ID workspaces)
320322
let metadataPath = path.join(this.getSessionDir(workspaceBasename), "metadata.json");
323+
let metadataFound = false;
321324

322325
if (fs.existsSync(metadataPath)) {
323326
const data = fs.readFileSync(metadataPath, "utf-8");
324-
const metadata = JSON.parse(data) as WorkspaceMetadata;
327+
let metadata = JSON.parse(data) as WorkspaceMetadata;
328+
329+
// Ensure required fields are present
330+
if (!metadata.name || !metadata.projectPath) {
331+
metadata = {
332+
...metadata,
333+
name: metadata.name ?? workspaceBasename,
334+
projectPath: metadata.projectPath ?? projectPath,
335+
projectName: metadata.projectName ?? projectName,
336+
};
337+
}
338+
339+
// Migrate to config for next load
340+
workspace.id = metadata.id;
341+
workspace.name = metadata.name;
342+
workspace.createdAt = metadata.createdAt;
343+
configModified = true;
344+
325345
workspaceMetadata.push(metadata);
326-
} else {
327-
// Try legacy ID format (project-workspace)
346+
metadataFound = true;
347+
}
348+
349+
// Try legacy ID format (project-workspace)
350+
if (!metadataFound) {
328351
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
329352
metadataPath = path.join(this.getSessionDir(legacyId), "metadata.json");
330353

331354
if (fs.existsSync(metadataPath)) {
332355
const data = fs.readFileSync(metadataPath, "utf-8");
333-
const metadata = JSON.parse(data) as WorkspaceMetadata;
334-
workspaceMetadata.push(metadata);
335-
} else {
336-
// No metadata found - create it for legacy workspace
337-
const metadata: WorkspaceMetadata = {
338-
id: legacyId, // Use legacy ID format for backward compatibility
339-
name: workspaceBasename,
340-
projectName,
341-
projectPath, // Add full project path
342-
// No createdAt for legacy workspaces (unknown)
343-
};
344-
345-
// Save metadata for future loads
346-
const sessionDir = this.getSessionDir(legacyId);
347-
if (!fs.existsSync(sessionDir)) {
348-
fs.mkdirSync(sessionDir, { recursive: true });
356+
let metadata = JSON.parse(data) as WorkspaceMetadata;
357+
358+
// Ensure required fields are present
359+
if (!metadata.name || !metadata.projectPath) {
360+
metadata = {
361+
...metadata,
362+
name: metadata.name ?? workspaceBasename,
363+
projectPath: metadata.projectPath ?? projectPath,
364+
projectName: metadata.projectName ?? projectName,
365+
};
349366
}
350-
fs.writeFileSync(metadataPath, JSON.stringify(metadata, null, 2));
367+
368+
// Migrate to config for next load
369+
workspace.id = metadata.id;
370+
workspace.name = metadata.name;
371+
workspace.createdAt = metadata.createdAt;
372+
configModified = true;
351373

352374
workspaceMetadata.push(metadata);
375+
metadataFound = true;
353376
}
354377
}
378+
379+
// No metadata found anywhere - create basic metadata
380+
if (!metadataFound) {
381+
const legacyId = this.generateWorkspaceId(projectPath, workspace.path);
382+
const metadata: WorkspaceMetadata = {
383+
id: legacyId,
384+
name: workspaceBasename,
385+
projectName,
386+
projectPath,
387+
};
388+
389+
// Save to config for next load
390+
workspace.id = metadata.id;
391+
workspace.name = metadata.name;
392+
configModified = true;
393+
394+
workspaceMetadata.push(metadata);
395+
}
355396
} catch (error) {
356397
console.error(`Failed to load/migrate workspace metadata:`, error);
357398
// Fallback to basic metadata if migration fails
@@ -360,12 +401,17 @@ export class Config {
360401
id: legacyId,
361402
name: workspaceBasename,
362403
projectName,
363-
projectPath, // Add full project path
404+
projectPath,
364405
});
365406
}
366407
}
367408
}
368409

410+
// Save config if we migrated any workspaces
411+
if (configModified) {
412+
this.saveConfig(config);
413+
}
414+
369415
return workspaceMetadata;
370416
}
371417

src/hooks/useProjectManagement.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ export function useProjectManagement() {
1717
const projectsList = await window.api.projects.list();
1818
console.log("Received projects:", projectsList);
1919

20-
const projectsMap = new Map<string, ProjectConfig>(projectsList.map((p) => [p.path, p]));
20+
const projectsMap = new Map<string, ProjectConfig>(projectsList);
2121
console.log("Created projects map, size:", projectsMap.size);
2222
setProjects(projectsMap);
2323
} catch (error) {

src/hooks/useWorkspaceManagement.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function useWorkspaceManagement({
4848
if (result.success) {
4949
// Backend has already updated the config - reload projects to get updated state
5050
const projectsList = await window.api.projects.list();
51-
const loadedProjects = new Map(projectsList.map((p) => [p.path, p]));
51+
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
5252
onProjectsUpdate(loadedProjects);
5353

5454
// Reload workspace metadata to get the new workspace ID
@@ -75,7 +75,7 @@ export function useWorkspaceManagement({
7575
if (result.success) {
7676
// Backend has already updated the config - reload projects to get updated state
7777
const projectsList = await window.api.projects.list();
78-
const loadedProjects = new Map(projectsList.map((p) => [p.path, p]));
78+
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
7979
onProjectsUpdate(loadedProjects);
8080

8181
// Reload workspace metadata
@@ -100,7 +100,7 @@ export function useWorkspaceManagement({
100100
if (result.success) {
101101
// Backend has already updated the config - reload projects to get updated state
102102
const projectsList = await window.api.projects.list();
103-
const loadedProjects = new Map(projectsList.map((p) => [p.path, p]));
103+
const loadedProjects = new Map<string, ProjectConfig>(projectsList);
104104
onProjectsUpdate(loadedProjects);
105105

106106
// Reload workspace metadata

src/preload.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { contextBridge, ipcRenderer } from "electron";
2222
import type { IPCApi, WorkspaceChatMessage } from "./types/ipc";
2323
import type { FrontendWorkspaceMetadata } from "./types/workspace";
24+
import type { ProjectConfig } from "./types/project";
2425
import { IPC_CHANNELS, getChatChannel } from "./constants/ipc-constants";
2526

2627
// Build the API implementation using the shared interface
@@ -36,7 +37,8 @@ const api: IPCApi = {
3637
projects: {
3738
create: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_CREATE, projectPath),
3839
remove: (projectPath) => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_REMOVE, projectPath),
39-
list: () => ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST),
40+
list: (): Promise<Array<[string, ProjectConfig]>> =>
41+
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST),
4042
listBranches: (projectPath: string) =>
4143
ipcRenderer.invoke(IPC_CHANNELS.PROJECT_LIST_BRANCHES, projectPath),
4244
secrets: {

src/services/aiService.ts

Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -182,32 +182,25 @@ export class AIService extends EventEmitter {
182182

183183
async getWorkspaceMetadata(workspaceId: string): Promise<Result<WorkspaceMetadata>> {
184184
try {
185-
const metadataPath = this.getMetadataPath(workspaceId);
186-
const data = await fs.readFile(metadataPath, "utf-8");
187-
188-
// Parse and validate with Zod schema (handles any type safely)
189-
const validated = WorkspaceMetadataSchema.parse(JSON.parse(data));
190-
191-
return Ok(validated);
192-
} catch (error) {
193-
if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") {
194-
// Fallback: Try to reconstruct metadata from config (for forward compatibility)
195-
// This handles workspaces created on newer branches that don't have metadata.json
196-
const allMetadata = this.config.getAllWorkspaceMetadata();
197-
const metadataFromConfig = allMetadata.find((m) => m.id === workspaceId);
198-
199-
if (metadataFromConfig) {
200-
// Found in config - save it to metadata.json for future use
201-
await this.saveWorkspaceMetadata(workspaceId, metadataFromConfig);
202-
return Ok(metadataFromConfig);
203-
}
204-
205-
// If metadata doesn't exist anywhere, workspace is not properly initialized
185+
// Get all workspace metadata (which includes migration logic)
186+
// This ensures we always get complete metadata with all required fields
187+
const allMetadata = this.config.getAllWorkspaceMetadata();
188+
log.info(`[getWorkspaceMetadata] Looking for ${workspaceId} in ${allMetadata.length} workspaces`);
189+
log.info(`[getWorkspaceMetadata] All IDs: ${allMetadata.map(m => m.id).join(", ")}`);
190+
const metadata = allMetadata.find((m) => m.id === workspaceId);
191+
192+
if (!metadata) {
193+
log.info(`[getWorkspaceMetadata] NOT FOUND: ${workspaceId}`);
206194
return Err(
207195
`Workspace metadata not found for ${workspaceId}. Workspace may not be properly initialized.`
208196
);
209197
}
198+
199+
log.info(`[getWorkspaceMetadata] Found metadata for ${workspaceId}:`, JSON.stringify(metadata));
200+
return Ok(metadata);
201+
} catch (error) {
210202
const message = error instanceof Error ? error.message : String(error);
203+
log.info(`[getWorkspaceMetadata] Error:`, error);
211204
return Err(`Failed to read workspace metadata: ${message}`);
212205
}
213206
}

0 commit comments

Comments
 (0)