2222 * AI Snapshot Store — content-addressable store and snapshot/restore logic
2323 * for tracking file states across AI responses. Extracted from AIChatPanel
2424 * to separate data/logic concerns from the DOM/UI layer.
25+ *
26+ * Content is stored in memory during an AI turn and flushed to disk at
27+ * finalizeResponse() time. Reads check memory first, then fall back to disk.
28+ * A per-instance heartbeat + GC mechanism cleans up stale instance data.
2529 */
2630define ( function ( require , exports , module ) {
2731
@@ -30,8 +34,23 @@ define(function (require, exports, module) {
3034 Commands = require ( "command/Commands" ) ,
3135 FileSystem = require ( "filesystem/FileSystem" ) ;
3236
37+ // --- Constants ---
38+ const HEARTBEAT_INTERVAL_MS = 60 * 1000 ;
39+ const STALE_THRESHOLD_MS = 20 * 60 * 1000 ;
40+
41+ // --- Disk store state ---
42+ let _instanceDir ; // "<appSupportDir>/instanceData/<instanceId>/"
43+ let _aiSnapDir ; // "<appSupportDir>/instanceData/<instanceId>/aiSnap/"
44+ let _heartbeatIntervalId = null ;
45+ let _diskReady = false ;
46+ let _diskReadyResolve ;
47+ const _diskReadyPromise = new Promise ( function ( resolve ) {
48+ _diskReadyResolve = resolve ;
49+ } ) ;
50+
3351 // --- Private state ---
34- const _contentStore = { } ; // hash → content string (content-addressable dedup)
52+ const _memoryBuffer = { } ; // hash → content (in-memory during AI turn)
53+ const _writtenHashes = new Set ( ) ; // hashes confirmed on disk
3554 let _snapshots = [ ] ; // flat: _snapshots[i] = { filePath: hash|null }
3655 let _pendingBeforeSnap = { } ; // built during current response: filePath → hash|null
3756
@@ -65,10 +84,68 @@ define(function (require, exports, module) {
6584
6685 function storeContent ( content ) {
6786 const hash = _hashContent ( content ) ;
68- _contentStore [ hash ] = content ;
87+ if ( ! _writtenHashes . has ( hash ) && ! _memoryBuffer [ hash ] ) {
88+ _memoryBuffer [ hash ] = content ;
89+ }
6990 return hash ;
7091 }
7192
93+ // --- Disk store ---
94+
95+ function _initDiskStore ( ) {
96+ const appSupportDir = Phoenix . VFS . getAppSupportDir ( ) ;
97+ const instanceId = Phoenix . PHOENIX_INSTANCE_ID ;
98+ _instanceDir = appSupportDir + "instanceData/" + instanceId + "/" ;
99+ _aiSnapDir = _instanceDir + "aiSnap/" ;
100+ Phoenix . VFS . ensureExistsDirAsync ( _aiSnapDir )
101+ . then ( function ( ) {
102+ _diskReady = true ;
103+ _diskReadyResolve ( ) ;
104+ } )
105+ . catch ( function ( err ) {
106+ console . error ( "[AISnapshotStore] Failed to init disk store:" , err ) ;
107+ // _diskReadyPromise stays pending — heartbeat/GC never fire
108+ } ) ;
109+ }
110+
111+ function _flushToDisk ( ) {
112+ if ( ! _diskReady ) {
113+ return ;
114+ }
115+ const hashes = Object . keys ( _memoryBuffer ) ;
116+ hashes . forEach ( function ( hash ) {
117+ const content = _memoryBuffer [ hash ] ;
118+ const file = FileSystem . getFileForPath ( _aiSnapDir + hash ) ;
119+ file . write ( content , { blind : true } , function ( err ) {
120+ if ( err ) {
121+ console . error ( "[AISnapshotStore] Flush failed for hash " + hash + ":" , err ) ;
122+ // Keep in _memoryBuffer so reads still work
123+ } else {
124+ _writtenHashes . add ( hash ) ;
125+ delete _memoryBuffer [ hash ] ;
126+ }
127+ } ) ;
128+ } ) ;
129+ }
130+
131+ function _readContent ( hash ) {
132+ // Check memory buffer first (content may not have flushed yet)
133+ if ( _memoryBuffer [ hash ] ) {
134+ return Promise . resolve ( _memoryBuffer [ hash ] ) ;
135+ }
136+ // Read from disk
137+ return new Promise ( function ( resolve , reject ) {
138+ const file = FileSystem . getFileForPath ( _aiSnapDir + hash ) ;
139+ file . read ( function ( err , data ) {
140+ if ( err ) {
141+ reject ( err ) ;
142+ } else {
143+ resolve ( data ) ;
144+ }
145+ } ) ;
146+ } ) ;
147+ }
148+
72149 // --- File operations ---
73150
74151 /**
@@ -181,27 +258,30 @@ define(function (require, exports, module) {
181258
182259 /**
183260 * Apply a snapshot to files. hash=null means delete the file.
261+ * Reads content from memory buffer first, then disk.
184262 * @param {Object } snapshot - { filePath: hash|null }
185- * @return {$. Promise } resolves with errorCount
263+ * @return {Promise<number> } resolves with errorCount
186264 */
187- function _applySnapshot ( snapshot ) {
188- const result = new $ . Deferred ( ) ;
265+ async function _applySnapshot ( snapshot ) {
189266 const filePaths = Object . keys ( snapshot ) ;
190- const promises = [ ] ;
191- let errorCount = 0 ;
192- filePaths . forEach ( function ( fp ) {
267+ if ( filePaths . length === 0 ) {
268+ return 0 ;
269+ }
270+ const promises = filePaths . map ( function ( fp ) {
193271 const hash = snapshot [ fp ] ;
194- const p = hash === null
195- ? _closeAndDeleteFile ( fp )
196- : _createOrUpdateFile ( fp , _contentStore [ hash ] ) ;
197- p . fail ( function ( ) { errorCount ++ ; } ) ;
198- promises . push ( p ) ;
272+ if ( hash === null ) {
273+ return _closeAndDeleteFile ( fp ) ;
274+ }
275+ return _readContent ( hash ) . then ( function ( content ) {
276+ return _createOrUpdateFile ( fp , content ) ;
277+ } ) ;
199278 } ) ;
200- if ( promises . length === 0 ) {
201- return result . resolve ( 0 ) . promise ( ) ;
202- }
203- $ . when . apply ( $ , promises ) . always ( function ( ) { result . resolve ( errorCount ) ; } ) ;
204- return result . promise ( ) ;
279+ const results = await Promise . allSettled ( promises ) ;
280+ let errorCount = 0 ;
281+ results . forEach ( function ( r ) {
282+ if ( r . status === "rejected" ) { errorCount ++ ; }
283+ } ) ;
284+ return errorCount ;
205285 }
206286
207287 // --- Public API ---
@@ -240,6 +320,7 @@ define(function (require, exports, module) {
240320 * Finalize snapshot state when a response completes.
241321 * Builds an "after" snapshot from current document content for edited files,
242322 * pushes it, and resets transient tracking variables.
323+ * Flushes in-memory content to disk for long-term storage.
243324 * @return {number } the after-snapshot index, or -1 if no edits happened
244325 */
245326 function finalizeResponse ( ) {
@@ -258,6 +339,7 @@ define(function (require, exports, module) {
258339 afterIndex = _snapshots . length - 1 ;
259340 }
260341 _pendingBeforeSnap = { } ;
342+ _flushToDisk ( ) ;
261343 return afterIndex ;
262344 }
263345
@@ -266,14 +348,13 @@ define(function (require, exports, module) {
266348 * @param {number } index - index into _snapshots
267349 * @param {Function } onComplete - callback(errorCount)
268350 */
269- function restoreToSnapshot ( index , onComplete ) {
351+ async function restoreToSnapshot ( index , onComplete ) {
270352 if ( index < 0 || index >= _snapshots . length ) {
271353 onComplete ( 0 ) ;
272354 return ;
273355 }
274- _applySnapshot ( _snapshots [ index ] ) . done ( function ( errorCount ) {
275- onComplete ( errorCount ) ;
276- } ) ;
356+ const errorCount = await _applySnapshot ( _snapshots [ index ] ) ;
357+ onComplete ( errorCount ) ;
277358 }
278359
279360 /**
@@ -285,13 +366,108 @@ define(function (require, exports, module) {
285366
286367 /**
287368 * Clear all snapshot state. Called when starting a new session.
369+ * Also deletes and recreates the aiSnap directory on disk.
288370 */
289371 function reset ( ) {
290- Object . keys ( _contentStore ) . forEach ( function ( k ) { delete _contentStore [ k ] ; } ) ;
372+ Object . keys ( _memoryBuffer ) . forEach ( function ( k ) { delete _memoryBuffer [ k ] ; } ) ;
373+ _writtenHashes . clear ( ) ;
291374 _snapshots = [ ] ;
292375 _pendingBeforeSnap = { } ;
376+
377+ // Delete and recreate aiSnap directory
378+ if ( _diskReady && _aiSnapDir ) {
379+ const dir = FileSystem . getDirectoryForPath ( _aiSnapDir ) ;
380+ dir . unlink ( function ( err ) {
381+ if ( err ) {
382+ console . error ( "[AISnapshotStore] Failed to delete aiSnap dir:" , err ) ;
383+ }
384+ Phoenix . VFS . ensureExistsDirAsync ( _aiSnapDir ) . catch ( function ( e ) {
385+ console . error ( "[AISnapshotStore] Failed to recreate aiSnap dir:" , e ) ;
386+ } ) ;
387+ } ) ;
388+ }
389+ }
390+
391+ // --- Heartbeat ---
392+
393+ function _writeHeartbeat ( ) {
394+ if ( ! _diskReady || ! _instanceDir ) {
395+ return ;
396+ }
397+ const file = FileSystem . getFileForPath ( _instanceDir + "heartbeat" ) ;
398+ file . write ( String ( Date . now ( ) ) , { blind : true } , function ( err ) {
399+ if ( err ) {
400+ console . error ( "[AISnapshotStore] Heartbeat write failed:" , err ) ;
401+ }
402+ } ) ;
403+ }
404+
405+ function _startHeartbeat ( ) {
406+ _diskReadyPromise . then ( function ( ) {
407+ _writeHeartbeat ( ) ;
408+ _heartbeatIntervalId = setInterval ( _writeHeartbeat , HEARTBEAT_INTERVAL_MS ) ;
409+ } ) ;
293410 }
294411
412+ function _stopHeartbeat ( ) {
413+ if ( _heartbeatIntervalId !== null ) {
414+ clearInterval ( _heartbeatIntervalId ) ;
415+ _heartbeatIntervalId = null ;
416+ }
417+ }
418+
419+ // --- Garbage Collection ---
420+
421+ function _runGarbageCollection ( ) {
422+ _diskReadyPromise . then ( function ( ) {
423+ const appSupportDir = Phoenix . VFS . getAppSupportDir ( ) ;
424+ const instanceDataBaseDir = appSupportDir + "instanceData/" ;
425+ const ownId = Phoenix . PHOENIX_INSTANCE_ID ;
426+ const baseDir = FileSystem . getDirectoryForPath ( instanceDataBaseDir ) ;
427+ baseDir . getContents ( function ( err , entries ) {
428+ if ( err ) {
429+ console . error ( "[AISnapshotStore] GC: failed to list instanceData:" , err ) ;
430+ return ;
431+ }
432+ const now = Date . now ( ) ;
433+ entries . forEach ( function ( entry ) {
434+ if ( ! entry . isDirectory || entry . name === ownId ) {
435+ return ;
436+ }
437+ const heartbeatFile = FileSystem . getFileForPath (
438+ instanceDataBaseDir + entry . name + "/heartbeat"
439+ ) ;
440+ heartbeatFile . read ( function ( readErr , data ) {
441+ let isStale = false ;
442+ if ( readErr ) {
443+ // No heartbeat file — treat as stale
444+ isStale = true ;
445+ } else {
446+ const ts = parseInt ( data , 10 ) ;
447+ if ( isNaN ( ts ) || ( now - ts ) > STALE_THRESHOLD_MS ) {
448+ isStale = true ;
449+ }
450+ }
451+ if ( isStale ) {
452+ entry . unlink ( function ( unlinkErr ) {
453+ if ( unlinkErr ) {
454+ console . error ( "[AISnapshotStore] GC: failed to remove stale dir "
455+ + entry . name + ":" , unlinkErr ) ;
456+ }
457+ } ) ;
458+ }
459+ } ) ;
460+ } ) ;
461+ } , true ) ; // true = filterNothing
462+ } ) ;
463+ }
464+
465+ // --- Module init ---
466+ _initDiskStore ( ) ;
467+ _startHeartbeat ( ) ;
468+ _runGarbageCollection ( ) ;
469+ window . addEventListener ( "beforeunload" , _stopHeartbeat ) ;
470+
295471 exports . realToVfsPath = realToVfsPath ;
296472 exports . saveDocToDisk = saveDocToDisk ;
297473 exports . storeContent = storeContent ;
0 commit comments