11import { dirname } from "path" ;
2- import { mkdir , readFile , writeFile } from "fs/promises" ;
2+ import { mkdir , readFile } from "fs/promises" ;
33import { existsSync } from "fs" ;
44import {
55 type ExtensionMetadata ,
66 type ExtensionMetadataFile ,
77 getExtensionMetadataPath ,
88} from "@/utils/extensionMetadata" ;
9+ import { writeFileAtomically } from "@/utils/atomicWrite" ;
910
1011/**
11- * Service for managing workspace metadata used by VS Code extension integration.
12+ * Stateless service for managing workspace metadata used by VS Code extension integration.
1213 *
1314 * This service tracks:
1415 * - recency: Unix timestamp (ms) of last user interaction
@@ -17,8 +18,10 @@ import {
1718 *
1819 * File location: ~/.cmux/extensionMetadata.json
1920 *
20- * Uses atomic writes to prevent corruption. Read-heavy workload (extension reads,
21- * main app writes on user interactions).
21+ * Design:
22+ * - Stateless: reads from disk on every operation, no in-memory cache
23+ * - Atomic writes: uses write-file-atomic to prevent corruption
24+ * - Read-heavy workload: extension reads, main app writes on user interactions
2225 */
2326
2427export interface WorkspaceMetadata extends ExtensionMetadata {
@@ -28,44 +31,33 @@ export interface WorkspaceMetadata extends ExtensionMetadata {
2831
2932export class ExtensionMetadataService {
3033 private readonly filePath : string ;
31- private data : ExtensionMetadataFile ;
3234
33- private constructor ( filePath : string , data : ExtensionMetadataFile ) {
34- this . filePath = filePath ;
35- this . data = data ;
35+ constructor ( filePath ?: string ) {
36+ this . filePath = filePath ?? getExtensionMetadataPath ( ) ;
3637 }
3738
3839 /**
39- * Create a new ExtensionMetadataService instance .
40- * Use this static factory method instead of the constructor .
40+ * Initialize the service by ensuring directory exists and clearing stale streaming flags .
41+ * Call this once on app startup .
4142 */
42- static async create ( filePath ?: string ) : Promise < ExtensionMetadataService > {
43- const path = filePath ?? getExtensionMetadataPath ( ) ;
44-
43+ async initialize ( ) : Promise < void > {
4544 // Ensure directory exists
46- const dir = dirname ( path ) ;
45+ const dir = dirname ( this . filePath ) ;
4746 if ( ! existsSync ( dir ) ) {
4847 await mkdir ( dir , { recursive : true } ) ;
4948 }
5049
51- // Load existing data or initialize
52- const data = await ExtensionMetadataService . loadData ( path ) ;
53-
54- const service = new ExtensionMetadataService ( path , data ) ;
55-
5650 // Clear stale streaming flags (from crashes)
57- await service . clearStaleStreaming ( ) ;
58-
59- return service ;
51+ await this . clearStaleStreaming ( ) ;
6052 }
6153
62- private static async loadData ( filePath : string ) : Promise < ExtensionMetadataFile > {
63- if ( ! existsSync ( filePath ) ) {
54+ private async load ( ) : Promise < ExtensionMetadataFile > {
55+ if ( ! existsSync ( this . filePath ) ) {
6456 return { version : 1 , workspaces : { } } ;
6557 }
6658
6759 try {
68- const content = await readFile ( filePath , "utf-8" ) ;
60+ const content = await readFile ( this . filePath , "utf-8" ) ;
6961 const parsed = JSON . parse ( content ) as ExtensionMetadataFile ;
7062
7163 // Validate structure
@@ -83,10 +75,10 @@ export class ExtensionMetadataService {
8375 }
8476 }
8577
86- private async save ( ) : Promise < void > {
78+ private async save ( data : ExtensionMetadataFile ) : Promise < void > {
8779 try {
88- const content = JSON . stringify ( this . data , null , 2 ) ;
89- await writeFile ( this . filePath , content , "utf-8" ) ;
80+ const content = JSON . stringify ( data , null , 2 ) ;
81+ await writeFileAtomically ( this . filePath , content ) ;
9082 } catch ( error ) {
9183 console . error ( "[ExtensionMetadataService] Failed to save metadata:" , error ) ;
9284 }
@@ -97,44 +89,51 @@ export class ExtensionMetadataService {
9789 * Call this on user messages or other interactions.
9890 */
9991 async updateRecency ( workspaceId : string , timestamp : number = Date . now ( ) ) : Promise < void > {
100- if ( ! this . data . workspaces [ workspaceId ] ) {
101- this . data . workspaces [ workspaceId ] = {
92+ const data = await this . load ( ) ;
93+
94+ if ( ! data . workspaces [ workspaceId ] ) {
95+ data . workspaces [ workspaceId ] = {
10296 recency : timestamp ,
10397 streaming : false ,
10498 lastModel : null ,
10599 } ;
106100 } else {
107- this . data . workspaces [ workspaceId ] . recency = timestamp ;
101+ data . workspaces [ workspaceId ] . recency = timestamp ;
108102 }
109- await this . save ( ) ;
103+
104+ await this . save ( data ) ;
110105 }
111106
112107 /**
113108 * Set the streaming status for a workspace.
114109 * Call this when streams start/end.
115110 */
116111 async setStreaming ( workspaceId : string , streaming : boolean , model ?: string ) : Promise < void > {
112+ const data = await this . load ( ) ;
117113 const now = Date . now ( ) ;
118- if ( ! this . data . workspaces [ workspaceId ] ) {
119- this . data . workspaces [ workspaceId ] = {
114+
115+ if ( ! data . workspaces [ workspaceId ] ) {
116+ data . workspaces [ workspaceId ] = {
120117 recency : now ,
121118 streaming,
122119 lastModel : model ?? null ,
123120 } ;
124121 } else {
125- this . data . workspaces [ workspaceId ] . streaming = streaming ;
122+ data . workspaces [ workspaceId ] . streaming = streaming ;
126123 if ( model ) {
127- this . data . workspaces [ workspaceId ] . lastModel = model ;
124+ data . workspaces [ workspaceId ] . lastModel = model ;
128125 }
129126 }
130- await this . save ( ) ;
127+
128+ await this . save ( data ) ;
131129 }
132130
133131 /**
134132 * Get metadata for a single workspace.
135133 */
136- getMetadata ( workspaceId : string ) : WorkspaceMetadata | null {
137- const entry = this . data . workspaces [ workspaceId ] ;
134+ async getMetadata ( workspaceId : string ) : Promise < WorkspaceMetadata | null > {
135+ const data = await this . load ( ) ;
136+ const entry = data . workspaces [ workspaceId ] ;
138137 if ( ! entry ) return null ;
139138
140139 return {
@@ -148,11 +147,12 @@ export class ExtensionMetadataService {
148147 * Get all workspace metadata, ordered by recency.
149148 * Used by VS Code extension to sort workspace list.
150149 */
151- getAllMetadata ( ) : Map < string , WorkspaceMetadata > {
150+ async getAllMetadata ( ) : Promise < Map < string , WorkspaceMetadata > > {
151+ const data = await this . load ( ) ;
152152 const map = new Map < string , WorkspaceMetadata > ( ) ;
153153
154154 // Convert to array, sort by recency, then create map
155- const entries = Object . entries ( this . data . workspaces ) ;
155+ const entries = Object . entries ( data . workspaces ) ;
156156 entries . sort ( ( a , b ) => b [ 1 ] . recency - a [ 1 ] . recency ) ;
157157
158158 for ( const [ workspaceId , entry ] of entries ) {
@@ -171,9 +171,11 @@ export class ExtensionMetadataService {
171171 * Call this when a workspace is deleted.
172172 */
173173 async deleteWorkspace ( workspaceId : string ) : Promise < void > {
174- if ( this . data . workspaces [ workspaceId ] ) {
175- delete this . data . workspaces [ workspaceId ] ;
176- await this . save ( ) ;
174+ const data = await this . load ( ) ;
175+
176+ if ( data . workspaces [ workspaceId ] ) {
177+ delete data . workspaces [ workspaceId ] ;
178+ await this . save ( data ) ;
177179 }
178180 }
179181
@@ -182,15 +184,18 @@ export class ExtensionMetadataService {
182184 * Call this on app startup to clean up stale streaming states from crashes.
183185 */
184186 async clearStaleStreaming ( ) : Promise < void > {
187+ const data = await this . load ( ) ;
185188 let modified = false ;
186- for ( const entry of Object . values ( this . data . workspaces ) ) {
189+
190+ for ( const entry of Object . values ( data . workspaces ) ) {
187191 if ( entry . streaming ) {
188192 entry . streaming = false ;
189193 modified = true ;
190194 }
191195 }
196+
192197 if ( modified ) {
193- await this . save ( ) ;
198+ await this . save ( data ) ;
194199 }
195200 }
196201}
0 commit comments