Skip to content

Commit b884a05

Browse files
committed
feat: lazy hashing for read-tracked files with resilience fixes
Store raw content in _readFiles instead of eagerly hashing on every recordFileRead call. Content is only hashed when promoted to a snapshot (on FS delete/rename), saving CPU and memory for the common read-only case. Also fixes three resilience issues: - _readContent: use hasOwnProperty for _memoryBuffer lookup so empty string content is not missed by the falsy check - _closeAndDeleteFile: treat "file already gone" as success instead of rejecting, so restores don't report spurious errors - _createOrUpdateFile: await CMD_OPEN completion before resolving to prevent race conditions with subsequent restore operations
1 parent e3d8b27 commit b884a05

2 files changed

Lines changed: 169 additions & 25 deletions

File tree

src/core-ai/AISnapshotStore.js

Lines changed: 169 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@ define(function (require, exports, module) {
3232
const DocumentManager = require("document/DocumentManager"),
3333
CommandManager = require("command/CommandManager"),
3434
Commands = require("command/Commands"),
35-
FileSystem = require("filesystem/FileSystem");
35+
FileSystem = require("filesystem/FileSystem"),
36+
ProjectManager = require("project/ProjectManager");
3637

3738
// --- Constants ---
3839
const HEARTBEAT_INTERVAL_MS = 60 * 1000;
@@ -53,6 +54,9 @@ define(function (require, exports, module) {
5354
const _writtenHashes = new Set(); // hashes confirmed on disk
5455
let _snapshots = []; // flat: _snapshots[i] = { filePath: hash|null }
5556
let _pendingBeforeSnap = {}; // built during current response: filePath → hash|null
57+
const _pendingDeleted = new Set(); // file paths deleted during current response
58+
const _readFiles = {}; // filePath → raw content string (files AI has read)
59+
let _isTracking = false; // true while AI is streaming
5660

5761
// --- Path utility ---
5862

@@ -130,7 +134,7 @@ define(function (require, exports, module) {
130134

131135
function _readContent(hash) {
132136
// Check memory buffer first (content may not have flushed yet)
133-
if (_memoryBuffer[hash]) {
137+
if (_memoryBuffer.hasOwnProperty(hash)) {
134138
return Promise.resolve(_memoryBuffer[hash]);
135139
}
136140
// Read from disk
@@ -146,6 +150,15 @@ define(function (require, exports, module) {
146150
});
147151
}
148152

153+
function _readFileFromDisk(vfsPath) {
154+
return new Promise(function (resolve, reject) {
155+
const file = FileSystem.getFileForPath(vfsPath);
156+
file.read(function (err, data) {
157+
if (err) { reject(err); } else { resolve(data); }
158+
});
159+
});
160+
}
161+
149162
// --- File operations ---
150163

151164
/**
@@ -180,31 +193,34 @@ define(function (require, exports, module) {
180193
const vfsPath = realToVfsPath(filePath);
181194
const file = FileSystem.getFileForPath(vfsPath);
182195

183-
const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
184-
if (openDoc) {
185-
if (openDoc.isDirty) {
186-
openDoc.setText("");
187-
}
188-
CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true })
189-
.always(function () {
190-
file.unlink(function (err) {
191-
if (err) {
192-
result.reject(err);
193-
} else {
196+
function _unlinkFile() {
197+
file.unlink(function (err) {
198+
if (err) {
199+
// File already gone — desired state achieved, treat as success
200+
file.exists(function (_existErr, exists) {
201+
if (!exists) {
194202
result.resolve();
203+
} else {
204+
result.reject(err);
195205
}
196206
});
197-
});
198-
} else {
199-
file.unlink(function (err) {
200-
if (err) {
201-
result.reject(err);
202207
} else {
203208
result.resolve();
204209
}
205210
});
206211
}
207212

213+
const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
214+
if (openDoc) {
215+
if (openDoc.isDirty) {
216+
openDoc.setText("");
217+
}
218+
CommandManager.execute(Commands.FILE_CLOSE, { file: file, _forceClose: true })
219+
.always(_unlinkFile);
220+
} else {
221+
_unlinkFile();
222+
}
223+
208224
return result.promise();
209225
}
210226

@@ -224,8 +240,10 @@ define(function (require, exports, module) {
224240
try {
225241
doc.setText(content);
226242
saveDocToDisk(doc).always(function () {
227-
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath });
228-
result.resolve();
243+
CommandManager.execute(Commands.CMD_OPEN, { fullPath: vfsPath })
244+
.always(function () {
245+
result.resolve();
246+
});
229247
});
230248
} catch (err) {
231249
result.reject(err);
@@ -306,6 +324,37 @@ define(function (require, exports, module) {
306324
}
307325
}
308326

327+
/**
328+
* Record a file the AI has read, storing its content hash for potential
329+
* delete/rename tracking. If the file is later deleted, this content
330+
* can be promoted into a snapshot for restore.
331+
* @param {string} filePath - real filesystem path
332+
* @param {string} content - file content at read time
333+
*/
334+
function recordFileRead(filePath, content) {
335+
_readFiles[filePath] = content;
336+
}
337+
338+
/**
339+
* Record that a file has been deleted during this response.
340+
* If the file hasn't been tracked yet, its previousContent is stored
341+
* and back-filled into existing snapshots.
342+
* @param {string} filePath - real filesystem path
343+
* @param {string} previousContent - content before deletion
344+
*/
345+
function recordFileDeletion(filePath, previousContent) {
346+
if (!_pendingBeforeSnap.hasOwnProperty(filePath)) {
347+
const hash = storeContent(previousContent);
348+
_pendingBeforeSnap[filePath] = hash;
349+
_snapshots.forEach(function (snap) {
350+
if (snap[filePath] === undefined) {
351+
snap[filePath] = hash;
352+
}
353+
});
354+
}
355+
_pendingDeleted.add(filePath);
356+
}
357+
309358
/**
310359
* Create the initial snapshot (snapshot 0) capturing file state before any
311360
* AI edits. Called once per session on the first edit.
@@ -321,24 +370,44 @@ define(function (require, exports, module) {
321370
* Builds an "after" snapshot from current document content for edited files,
322371
* pushes it, and resets transient tracking variables.
323372
* Flushes in-memory content to disk for long-term storage.
324-
* @return {number} the after-snapshot index, or -1 if no edits happened
373+
*
374+
* Priority for each file:
375+
* 1. If in _pendingDeleted → null
376+
* 2. If doc is open → storeContent(openDoc.getText())
377+
* 3. Fallback: read from disk → storeContent(content)
378+
* 4. If disk read fails (file gone) → null
379+
*
380+
* @return {Promise<number>} the after-snapshot index, or -1 if no edits happened
325381
*/
326-
function finalizeResponse() {
382+
async function finalizeResponse() {
327383
let afterIndex = -1;
328384
if (Object.keys(_pendingBeforeSnap).length > 0) {
329-
// Build "after" snapshot = last snapshot + current content of edited files
330385
const afterSnap = Object.assign({}, _snapshots[_snapshots.length - 1]);
331-
Object.keys(_pendingBeforeSnap).forEach(function (fp) {
386+
const editedPaths = Object.keys(_pendingBeforeSnap);
387+
for (let i = 0; i < editedPaths.length; i++) {
388+
const fp = editedPaths[i];
389+
if (_pendingDeleted.has(fp)) {
390+
afterSnap[fp] = null;
391+
continue;
392+
}
332393
const vfsPath = realToVfsPath(fp);
333394
const openDoc = DocumentManager.getOpenDocumentForPath(vfsPath);
334395
if (openDoc) {
335396
afterSnap[fp] = storeContent(openDoc.getText());
397+
} else {
398+
try {
399+
const content = await _readFileFromDisk(vfsPath);
400+
afterSnap[fp] = storeContent(content);
401+
} catch (e) {
402+
afterSnap[fp] = null;
403+
}
336404
}
337-
});
405+
}
338406
_snapshots.push(afterSnap);
339407
afterIndex = _snapshots.length - 1;
340408
}
341409
_pendingBeforeSnap = {};
410+
_pendingDeleted.clear();
342411
_flushToDisk();
343412
return afterIndex;
344413
}
@@ -357,6 +426,74 @@ define(function (require, exports, module) {
357426
onComplete(errorCount);
358427
}
359428

429+
// --- FS event tracking ---
430+
431+
function _onProjectFileChanged(_event, entry, addedInProject, removedInProject) {
432+
if (!removedInProject || !removedInProject.length) { return; }
433+
removedInProject.forEach(function (removedEntry) {
434+
if (!removedEntry.isFile) { return; }
435+
const fp = removedEntry.fullPath;
436+
// Check if AI has edited this file (already in snapshots)
437+
const isEdited = _pendingBeforeSnap.hasOwnProperty(fp) ||
438+
_snapshots.some(function (snap) { return snap.hasOwnProperty(fp); });
439+
if (isEdited) {
440+
_pendingDeleted.add(fp);
441+
return;
442+
}
443+
// Check if AI has read this file (raw content available)
444+
if (_readFiles.hasOwnProperty(fp)) {
445+
// Promote from read-tracked to snapshot-tracked, then mark deleted
446+
const hash = storeContent(_readFiles[fp]);
447+
_pendingBeforeSnap[fp] = hash;
448+
_snapshots.forEach(function (snap) {
449+
if (snap[fp] === undefined) {
450+
snap[fp] = hash;
451+
}
452+
});
453+
_pendingDeleted.add(fp);
454+
}
455+
});
456+
}
457+
458+
function _onProjectFileRenamed(_event, oldPath, newPath) {
459+
// Update _pendingBeforeSnap
460+
if (_pendingBeforeSnap.hasOwnProperty(oldPath)) {
461+
_pendingBeforeSnap[newPath] = _pendingBeforeSnap[oldPath];
462+
delete _pendingBeforeSnap[oldPath];
463+
}
464+
// Update _pendingDeleted
465+
if (_pendingDeleted.has(oldPath)) {
466+
_pendingDeleted.delete(oldPath);
467+
_pendingDeleted.add(newPath);
468+
}
469+
// Update all snapshots
470+
_snapshots.forEach(function (snap) {
471+
if (snap.hasOwnProperty(oldPath)) {
472+
snap[newPath] = snap[oldPath];
473+
delete snap[oldPath];
474+
}
475+
});
476+
// Update _readFiles
477+
if (_readFiles.hasOwnProperty(oldPath)) {
478+
_readFiles[newPath] = _readFiles[oldPath];
479+
delete _readFiles[oldPath];
480+
}
481+
}
482+
483+
function startTracking() {
484+
if (_isTracking) { return; }
485+
_isTracking = true;
486+
ProjectManager.on("projectFileChanged", _onProjectFileChanged);
487+
ProjectManager.on("projectFileRenamed", _onProjectFileRenamed);
488+
}
489+
490+
function stopTracking() {
491+
if (!_isTracking) { return; }
492+
_isTracking = false;
493+
ProjectManager.off("projectFileChanged", _onProjectFileChanged);
494+
ProjectManager.off("projectFileRenamed", _onProjectFileRenamed);
495+
}
496+
360497
/**
361498
* @return {number} number of snapshots
362499
*/
@@ -373,6 +510,9 @@ define(function (require, exports, module) {
373510
_writtenHashes.clear();
374511
_snapshots = [];
375512
_pendingBeforeSnap = {};
513+
_pendingDeleted.clear();
514+
Object.keys(_readFiles).forEach(function (k) { delete _readFiles[k]; });
515+
stopTracking();
376516

377517
// Delete and recreate aiSnap directory
378518
if (_diskReady && _aiSnapDir) {
@@ -472,9 +612,13 @@ define(function (require, exports, module) {
472612
exports.saveDocToDisk = saveDocToDisk;
473613
exports.storeContent = storeContent;
474614
exports.recordFileBeforeEdit = recordFileBeforeEdit;
615+
exports.recordFileRead = recordFileRead;
616+
exports.recordFileDeletion = recordFileDeletion;
475617
exports.createInitialSnapshot = createInitialSnapshot;
476618
exports.finalizeResponse = finalizeResponse;
477619
exports.restoreToSnapshot = restoreToSnapshot;
478620
exports.getSnapshotCount = getSnapshotCount;
621+
exports.startTracking = startTracking;
622+
exports.stopTracking = stopTracking;
479623
exports.reset = reset;
480624
});
File renamed without changes.

0 commit comments

Comments
 (0)