Skip to content

Commit bcc40cc

Browse files
committed
fix(mcp): count the open-file dialog's read of a project file
The dialog reads the file directly and the editor saves back what it read, so an MCP edit landing between the two was dropped. The read now counts against saveProjectIfUnchanged, behind the project's queued saves.
1 parent 3c6d94e commit bcc40cc

4 files changed

Lines changed: 37 additions & 1 deletion

File tree

‎electron/ai-edition/document-service.test.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -860,6 +860,21 @@ describe("DocumentService", () => {
860860
expect((await service.getProject(created.project.id)).project.title).toBe("Synced");
861861
});
862862

863+
it("counts the open-file dialog reading the project's own file as a read", async () => {
864+
const created = await service.createProject("Browsed");
865+
const { document, version } = await service.getProjectForUpdate(created.project.id);
866+
await service.beforeProjectFileRead(path.join(tempDir, `${created.project.id}.openscreen`));
867+
expect(await service.saveProjectIfUnchanged(renamed(document, "Edited"), version)).toBeNull();
868+
});
869+
870+
it("ignores the open-file dialog reading a file outside the projects folder", async () => {
871+
const created = await service.createProject("Elsewhere");
872+
const { document, version } = await service.getProjectForUpdate(created.project.id);
873+
await service.beforeProjectFileRead(path.join(mediaDir, `${created.project.id}.openscreen`));
874+
const saved = await service.saveProjectIfUnchanged(renamed(document, "Edited"), version);
875+
expect(saved?.project.title).toBe("Edited");
876+
});
877+
863878
it("writes nothing over a project deleted since", async () => {
864879
const created = await service.createProject("Deleted");
865880
const { document, version } = await service.getProjectForUpdate(created.project.id);

‎electron/ai-edition/document-service.ts‎

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -329,6 +329,24 @@ export class DocumentService {
329329
return { document, version: { generation, raw } };
330330
}
331331

332+
/**
333+
* For a read of a project file that bypasses getProject: the open-file dialog,
334+
* which then saves back what it read. If the file is one of this service's
335+
* projects, that counts as a read of it, behind its queued saves, exactly as in
336+
* getProject. Any other file is not this service's business.
337+
*/
338+
async beforeProjectFileRead(filePath: string): Promise<void> {
339+
const resolved = path.resolve(filePath);
340+
if (path.relative(path.resolve(this.projectsRoot), path.dirname(resolved)) !== "") return;
341+
const extension = [PROJECT_FILE_EXTENSION, LEGACY_PROJECT_FILE_EXTENSION].find((ext) =>
342+
resolved.endsWith(ext),
343+
);
344+
if (!extension) return;
345+
const projectId = path.basename(resolved, extension);
346+
this.touch(projectId);
347+
await this.writeQueues.get(projectId);
348+
}
349+
332350
private touch(projectId: string): number {
333351
const generation = (this.generations.get(projectId) ?? 0) + 1;
334352
this.generations.set(projectId, generation);

‎electron/ipc/handlers.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4718,6 +4718,8 @@ export function registerIpcHandlers(
47184718
}
47194719

47204720
const filePath = result.filePaths[0];
4721+
// The editor saves back what it opened here: see beforeProjectFileRead.
4722+
await aiEditionDocuments.beforeProjectFileRead(filePath);
47214723
const content = await fs.readFile(filePath, "utf-8");
47224724
const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR);
47234725
currentProjectPath = filePath;
@@ -4764,6 +4766,7 @@ export function registerIpcHandlers(
47644766
if (!stats?.isFile()) {
47654767
return { success: false, message: "File not found" };
47664768
}
4769+
await aiEditionDocuments.beforeProjectFileRead(filePath);
47674770
const content = await fs.readFile(filePath, "utf-8");
47684771
const project = await relinkProjectMedia(JSON.parse(content), DEFAULT_RECORDINGS_DIR);
47694772
currentProjectPath = filePath;

‎technical-documentation/architecture/mcp-server.md‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ Only the webContents that registered on `ai-edition.mcp-host` is asked, and only
5252

5353
Read with `DocumentService.getProjectForUpdate` (`getProject`, migrated and relinked like any open, plus a version) and, if the tool changed it, saved with `saveProjectIfUnchanged` — the same instance every other save in the app goes through, so its per-project write queue still holds. There is no revision to guard with, so the save is refused with "NOT applied … re-read, then retry" when, since the read:
5454

55-
1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead. The client's retry, with the same `projectId`, then goes through the editor.
55+
1. **Anything read the project.** This is the editor opening it: from that read on it holds a copy without the MCP edit, and its next save would drop the edit without a word. A snapshot cannot see this in time, since the editor installs what it read only after the read returns. `DocumentService` counts every read and save of a project instead, the open-file dialog's direct read of a project file included (`beforeProjectFileRead`). The client's retry, with the same `projectId`, then goes through the editor.
5656
2. **Anything saved it, or its file changed.** The file must still be, byte for byte, the one the tool ran against. This also covers a writer outside the app (a sync tool, a restored copy) and a delete.
5757

5858
The check runs inside the project's write queue, and `getProject` waits behind that queue, so nothing in the app reads or saves the project between the check and the write.

0 commit comments

Comments
 (0)