Skip to content

Commit 3f984b9

Browse files
committed
feat: disk-based content store with instance heartbeat and GC
Move AISnapshotStore content from unbounded in-memory _contentStore to disk-backed storage under <appSupportDir>/instanceData/<instanceId>/aiSnap/. Content stays in _memoryBuffer during an AI turn and flushes to disk at finalizeResponse() time. Reads check memory first, then fall back to disk. Add per-instance heartbeat (60s interval) and garbage collection that removes stale instance directories (>20 min without heartbeat) on startup.
1 parent 7f8b5f1 commit 3f984b9

2 files changed

Lines changed: 205 additions & 23 deletions

File tree

src/core-ai/AISnapshotStore.js

Lines changed: 199 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,10 @@
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
*/
2630
define(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;

src/core-ai/editApplyVerification.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,14 @@ _snapshots[2] = after R2 edits
2323
## State Variables
2424

2525
### AISnapshotStore (pure data layer)
26+
- `_memoryBuffer`: `hash → content` map holding content in memory during an AI turn; flushed to disk at `finalizeResponse()` time
27+
- `_writtenHashes`: `Set` of hashes confirmed written to disk (reads skip disk check for these)
2628
- `_snapshots[]`: flat array of `{ filePath: hash|null }` snapshots. `getSnapshotCount() > 0` replaces the old `_initialSnapshotCreated` flag.
2729
- `_pendingBeforeSnap`: per-file pre-edit tracking during current response (dedup guard for first-edit-per-file + file list for `finalizeResponse`)
30+
- `_instanceDir` / `_aiSnapDir`: per-instance disk paths under `<appSupportDir>/instanceData/<instanceId>/`
31+
- `_diskReady` / `_diskReadyDeferred`: gate for disk I/O; resolved once `_aiSnapDir` is created
32+
- Heartbeat: writes `Date.now()` to `_instanceDir + "heartbeat"` every 60s; stopped on `beforeunload`
33+
- GC: on module init, scans sibling instance dirs; removes any with missing or stale (>20 min) heartbeat
2834

2935
### AIChatPanel (UI state)
3036
- `_undoApplied`: whether undo/restore has been clicked on any card (UI control for button labels)

0 commit comments

Comments
 (0)