Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,42 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- **Atomic writes for desktop filesystem storage.** Every `services/fs/*Store.ts` writer
(project, active-project marker, settings, API keys, snapshots, Codex, RAG vectors, binder
assets, images) previously wrote directly to its final path — a crash or power loss mid-write
could leave the file truncated/corrupted with no recovery path. New `writeTextFileAtomic`/
`writeFileAtomic` helpers (`services/fs/fsCore.ts`) write to a temp sibling first, then
atomically rename over the final path, so a reader only ever sees the old complete file or the
new complete file, never a partial one. Also fixes a latent capability gap: `fs:allow-read-file`/
`fs:allow-write-file`/`fs:allow-rename` were never declared in
`src-tauri/capabilities/default.json`, even though `assetFsStore.ts` already called the binary
`readFile`/`writeFile` commands they gate. **Review-loop follow-up fixes to the same change:** a
failed temp-file write (not just a failed rename) now also cleans up its orphaned temp file;
same-path writes now serialize in call order via a per-path queue, closing a race where two
overlapping saves could have an older write's rename land after a newer one and silently roll
the file back; `crypto.randomUUID()` now has a `getRandomValues()`-based fallback for WebKit
versions that predate it, matching the existing pattern in
`encryptionMigrationOrchestrator.ts#createMigrationOperationId`. A new startup sweep
(`cleanupOrphanedTempFiles`, run once per session, fire-and-forget) recursively removes
`.tmp-*` siblings left behind by a write interrupted by an actual process kill (crash/power
loss) rather than a caught JS error — neither `atomicRename`'s nor the temp-write failure
handler's in-session cleanup can ever see that case, since execution stops before either runs.
**Second round of review-loop follow-up fixes:** the startup sweep being fire-and-forget meant a
save started immediately after `initialize()` could create a brand-new (legitimate, not
orphaned) temp file that the still-running sweep would then delete out from under it — a
module-level `activeTempPaths` set, populated for the duration of each write, now tells the
sweep to skip any temp file currently being written rather than delaying every session's
startup by awaiting the sweep. The per-path write queue also didn't coalesce: a burst of
same-path writes arriving faster than disk I/O completes (e.g. rapid autosave retries on a slow
or temporarily locked disk) appended every call to a growing promise chain, writing every stale
intermediate version to disk instead of just the latest one. The queue now keeps at most the
currently-running write and a single latest-queued write per path — a write arriving while
another is already queued (not yet started) supersedes it in place instead of appending another
link, bounding both memory and wasted I/O regardless of how many same-path writes arrive in a
burst.

## [1.27.0] — 2026-08-13

### Added
Expand Down
219 changes: 202 additions & 17 deletions services/fs/assetFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,120 @@

import { logger } from '../logger';
import type { BinderAssetMeta, BinderAssetPayload } from '../storageBackend';
import { retryFs, sanitizePathSegment } from './fsCore';
import { retryFs, sanitizePathSegment, writeFileAtomic, writeTextFileAtomic } from './fsCore';
import { FsSnapshotStore } from './snapshotFsStore';

interface BinderAssetManifest {
version: 1;
dataFile: string;
meta: BinderAssetMeta;
}

function isBinderAssetMeta(value: unknown): value is BinderAssetMeta {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<BinderAssetMeta>;
return (
typeof candidate.mimeType === 'string' &&
typeof candidate.originalFileName === 'string' &&
typeof candidate.byteSize === 'number' &&
Number.isFinite(candidate.byteSize) &&
candidate.byteSize >= 0
);
}

function createBinderRevision(): string {
if (typeof crypto.randomUUID === 'function') return crypto.randomUUID();
return Array.from(crypto.getRandomValues(new Uint8Array(16)), (byte) =>
byte.toString(16).padStart(2, '0'),
).join('');
}

function isBinderAssetManifest(value: unknown): value is BinderAssetManifest {
if (!value || typeof value !== 'object') return false;
const candidate = value as Partial<BinderAssetManifest>;
return (
candidate.version === 1 &&
typeof candidate.dataFile === 'string' &&
isBinderAssetMeta(candidate.meta)
);
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

const BINDER_REVISION_FILE_PATTERN = /^(.+)\.([0-9a-f]{32}|[0-9a-f-]{36})\.bin$/i;

export class FsAssetStore extends FsSnapshotStore {
private readonly binderOperationTails = new Map<string, Promise<void>>();

private enqueueBinderOperation<T>(
projectId: string,
assetId: string,
operation: () => Promise<T>,
): Promise<T> {
const key = `${projectId}\u0000${assetId}`;
const previous = this.binderOperationTails.get(key) ?? Promise.resolve();
Comment on lines +57 to +58

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The binder operation queue uses raw IDs while the filesystem paths use sanitized IDs. For example, IDs that differ only by path-invalid characters can map to the same safeAsset (and similarly for project IDs), yet they receive different queue keys. Concurrent saves or deletes can consequently publish or remove revisions for one another. Key the queue by the same sanitized path identity used by binderAssetPaths. [race condition]

Severity Level: Major ⚠️
- ❌ Colliding binder IDs can overwrite asset metadata.
- ❌ Concurrent deletion can remove another asset’s revision.
- ⚠️ Imported or externally supplied IDs can trigger collisions.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/assetFsStore.ts
**Line:** 57:58
**Comment:**
	*Race Condition: The binder operation queue uses raw IDs while the filesystem paths use sanitized IDs. For example, IDs that differ only by path-invalid characters can map to the same `safeAsset` (and similarly for project IDs), yet they receive different queue keys. Concurrent saves or deletes can consequently publish or remove revisions for one another. Key the queue by the same sanitized path identity used by `binderAssetPaths`.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

const result = previous.catch(() => {}).then(operation);
const tail = result.then(
() => undefined,
() => undefined,
);
this.binderOperationTails.set(key, tail);
void tail.then(() => {
if (this.binderOperationTails.get(key) === tail) this.binderOperationTails.delete(key);
});
return result;
}

override async initialize(): Promise<void> {
await super.initialize();
await this.cleanupOrphanedBinderRevisions();
}

private async cleanupOrphanedBinderRevisions(): Promise<void> {
try {
const apis = await this.getApis();
const appDataPath = await this.ensureAppDataPath();
const projectsPath = await apis.join(appDataPath, 'projects');
if (!(await apis.exists(projectsPath))) return;
const projects = await retryFs(() => apis.readDir(projectsPath));
for (const project of projects) {
if (!project.name || !project.isDirectory) continue;
const binderPath = await apis.join(projectsPath, project.name, 'binder');
if (!(await apis.exists(binderPath))) continue;
const entries = await retryFs(() => apis.readDir(binderPath));
const committedFiles = new Set<string>();
const protectedAssets = new Set<string>();
for (const entry of entries) {
const metaName = entry.name;
if (!metaName?.endsWith('.meta.json')) continue;
const safeAsset = metaName.replace(/\.meta\.json$/, '');
const metaFile = await apis.join(binderPath, metaName);
try {
const raw = JSON.parse(await retryFs(() => apis.readTextFile(metaFile))) as unknown;
if (raw && typeof raw === 'object' && ('version' in raw || 'dataFile' in raw)) {
const manifest = await this.readBinderManifest(apis, metaFile, safeAsset);
if (manifest) committedFiles.add(manifest.dataFile);
else protectedAssets.add(safeAsset);
}
} catch {
protectedAssets.add(safeAsset);
}
}
for (const entry of entries) {
const match = entry.name?.match(BINDER_REVISION_FILE_PATTERN);
if (!match) continue;
const [, safeAsset] = match;
if (!safeAsset || committedFiles.has(entry.name!) || protectedAssets.has(safeAsset))
Comment on lines +107 to +110

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid deleting legacy revision-shaped binder files

On startup, a legacy asset whose sanitized ID ends in a dot plus 32 hex characters or a 36-character UUID-like value is misclassified here as an orphaned revision; for example, scan.0123456789abcdef0123456789abcdef.bin is treated as a revision of scan. The metadata pass does not add plain legacy manifests to either protection set, so the sweep permanently removes the referenced binary and subsequent getBinderAsset calls return null. Preserve exact legacy <asset>.bin siblings whenever a legacy <asset>.meta.json exists.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

continue;
const revisionFile = await apis.join(binderPath, entry.name!);
await retryFs(() => apis.remove(revisionFile)).catch((error) => {
logger.warn('Failed to remove orphaned binder asset revision:', error);
});
}
}
} catch (error) {
logger.warn('Failed to clean up orphaned binder asset revisions:', error);
}
}

// --- Image Store Methods ---

async saveImage(id: string, base64Data: string): Promise<void> {
Expand All @@ -22,8 +132,8 @@ export class FsAssetStore extends FsSnapshotStore {
}

const imageFile = await apis.join(imagesPath, `${sanitizePathSegment(id, 'image')}.png`);
const cleanBase64 = base64Data.replace(/^data:image\/png;base64,/, '');
await retryFs(() => apis.writeTextFile(imageFile, cleanBase64));
// QNBS-v3: preserve the original data URL so JPEG/WebP uploads keep their MIME type; legacy raw base64 remains readable below.
await writeTextFileAtomic(apis, imageFile, base64Data);
Comment on lines +135 to +136

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The filesystem backend treats an empty string as image content, but delete flows use saveImage(id, '') as the deletion sentinel. This creates an empty .png file, and getImage converts it into data:image/png;base64,, so deleted images remain persisted and can still be returned to callers. Handle the empty input by deleting the image file instead of atomically writing it. [api mismatch]

Severity Level: Major ⚠️
- ❌ World deletion leaves an image file persisted.
- ⚠️ Deleted entities can resolve empty image data.
- ⚠️ Repeated deletions accumulate stale image files.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/assetFsStore.ts
**Line:** 135:136
**Comment:**
	*Api Mismatch: The filesystem backend treats an empty string as image content, but delete flows use `saveImage(id, '')` as the deletion sentinel. This creates an empty `.png` file, and `getImage` converts it into `data:image/png;base64,`, so deleted images remain persisted and can still be returned to callers. Handle the empty input by deleting the image file instead of atomically writing it.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

async getImage(id: string): Promise<string | null> {
Expand All @@ -41,7 +151,9 @@ export class FsAssetStore extends FsSnapshotStore {
}

const base64Data = await retryFs(() => apis.readTextFile(imageFile));
return `data:image/png;base64,${base64Data}`;
return base64Data.startsWith('data:image/')
? base64Data
: `data:image/png;base64,${base64Data}`;
} catch (error) {
logger.error('Failed to load image:', error);
return null;
Expand Down Expand Up @@ -75,33 +187,96 @@ export class FsAssetStore extends FsSnapshotStore {
const dir = await apis.join(appDataPath, 'projects', safeId, 'binder');
const binFile = await apis.join(dir, `${safeAsset}.bin`);
const metaFile = await apis.join(dir, `${safeAsset}.meta.json`);
return { apis, dir, binFile, metaFile };
return { apis, dir, binFile, metaFile, safeAsset };
}

private async readBinderManifest(
apis: Awaited<ReturnType<FsAssetStore['binderAssetPaths']>>['apis'],
metaFile: string,
safeAsset: string,
): Promise<BinderAssetManifest | null> {
try {
const parsed = JSON.parse(await retryFs(() => apis.readTextFile(metaFile))) as unknown;
if (!isBinderAssetManifest(parsed)) return null;
if (
!parsed.dataFile.startsWith(`${safeAsset}.`) ||
!parsed.dataFile.endsWith('.bin') ||
parsed.dataFile.includes('/') ||
parsed.dataFile.includes('\\')
) {
throw new Error('Binder asset manifest references an invalid data file');
}
return parsed;
} catch {
return null;
}
}

async saveBinderAsset(
projectId: string,
assetId: string,
data: ArrayBuffer,
meta: BinderAssetMeta,
): Promise<void> {
return this.enqueueBinderOperation(projectId, assetId, () =>
this.saveBinderAssetLocked(projectId, assetId, data, meta),
);
}

private async saveBinderAssetLocked(
projectId: string,
assetId: string,
data: ArrayBuffer,
meta: BinderAssetMeta,
): Promise<void> {
const apis = await this.getApis();
const { dir, binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
const { dir, binFile, metaFile, safeAsset } = await this.binderAssetPaths(projectId, assetId);
if (!(await apis.exists(dir))) await apis.mkdir(dir, { recursive: true });
const metaOut: BinderAssetMeta = { ...meta, byteSize: data.byteLength };
await retryFs(() => apis.writeFile(binFile, new Uint8Array(data)));
await retryFs(() => apis.writeTextFile(metaFile, JSON.stringify(metaOut)));
const prior = await this.readBinderManifest(apis, metaFile, safeAsset);
const dataFileName = `${safeAsset}.${createBinderRevision()}.bin`;
const dataFile = await apis.join(dir, dataFileName);
await writeFileAtomic(apis, dataFile, new Uint8Array(data));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Remove uncommitted binder revisions

If this binary write succeeds but the following manifest write fails (for example, ENOSPC) or the process exits between them, the new <asset>.<revision>.bin remains permanently even though no manifest references it. The existing cleanup only removes the previous revision after a successful manifest publication, and the repository has no sweep for unreferenced binder revisions, so repeated failed large imports consume disk with files that cannot be listed or deleted through the binder APIs; remove the new revision when publication rejects and reclaim unreferenced revisions during startup.

Useful? React with 👍 / 👎.

// QNBS-v3: publishing this manifest is the binder pair's commit point, so readers never combine new bytes with stale metadata.
try {
await writeTextFileAtomic(
apis,
metaFile,
JSON.stringify({ version: 1, dataFile: dataFileName, meta: metaOut }),
);
Comment on lines +242 to +246

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The writer publishes a revision manifest without ensuring that metaOut satisfies the same shape required by isBinderAssetManifest. Legacy binder callers and existing stored metadata can use fields such as name and mime; the resulting manifest is then rejected by readBinderManifest, while the data was written only to the revision filename rather than the legacy .bin path. Subsequent reads return null and repeated saves cannot identify the prior revision. Normalize legacy metadata before writing the manifest or support both metadata shapes consistently. [api mismatch]

Severity Level: Major ⚠️
- ❌ Legacy-shaped binder assets become unreadable after saving.
- ❌ Binder previews and downloads receive null payloads.
- ⚠️ Current production thunk metadata uses the newer shape.

Fix in Cursor Fix in VSCode Claude

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** services/fs/assetFsStore.ts
**Line:** 245:249
**Comment:**
	*Api Mismatch: The writer publishes a revision manifest without ensuring that `metaOut` satisfies the same shape required by `isBinderAssetManifest`. Legacy binder callers and existing stored metadata can use fields such as `name` and `mime`; the resulting manifest is then rejected by `readBinderManifest`, while the data was written only to the revision filename rather than the legacy `.bin` path. Subsequent reads return null and repeated saves cannot identify the prior revision. Normalize legacy metadata before writing the manifest or support both metadata shapes consistently.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

} catch (error) {
// QNBS-v3: the revision is unreachable until its manifest commits, so failed publication must not leak a new binary on every retry.
await retryFs(() => apis.remove(dataFile)).catch((cleanupError) => {
Comment on lines +247 to +249

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not delete a revision after a post-commit error

When the native manifest replacement succeeds but the subsequent parent-directory sync_all() fails, durable_write returns an error even though the new manifest is already visible. This catch then assumes publication never happened and deletes dataFile, leaving the committed manifest pointing at a missing revision and making the binder attachment unreadable; distinguish pre-publication failures from post-commit durability errors, or roll back the manifest before removing the revision. Because desktop project data has no server-side recovery source, this failure destroys the locally stored attachment.

AGENTS.md reference: AGENTS.md:L9-L9

Useful? React with 👍 / 👎.

logger.warn('Failed to remove unpublished binder asset revision:', cleanupError);
});
throw error;
}
if (prior) {
const priorFile = await apis.join(dir, prior.dataFile);
if (priorFile !== dataFile && (await apis.exists(priorFile))) {
await retryFs(() => apis.remove(priorFile)).catch((error) => {
logger.warn('Failed to remove superseded binder asset revision:', error);
});
}
} else if (await apis.exists(binFile)) {
await retryFs(() => apis.remove(binFile)).catch((error) => {
logger.warn('Failed to remove superseded legacy binder asset:', error);
});
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

async getBinderAsset(projectId: string, assetId: string): Promise<BinderAssetPayload | null> {
try {
const apis = await this.getApis();
const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
if (!(await apis.exists(binFile)) || !(await apis.exists(metaFile))) return null;
const [bytes, metaRaw] = await Promise.all([
retryFs(() => apis.readFile(binFile)),
retryFs(() => apis.readTextFile(metaFile)),
]);
const meta = JSON.parse(metaRaw) as BinderAssetMeta;
const { binFile, metaFile, dir, safeAsset } = await this.binderAssetPaths(projectId, assetId);
if (!(await apis.exists(metaFile))) return null;
const manifest = await this.readBinderManifest(apis, metaFile, safeAsset);
const dataFile = manifest ? await apis.join(dir, manifest.dataFile) : binFile;
if (!(await apis.exists(dataFile))) return null;
const bytes = await retryFs(() => apis.readFile(dataFile));
const meta = manifest
? manifest.meta
: (JSON.parse(await retryFs(() => apis.readTextFile(metaFile))) as BinderAssetMeta);
const copy = bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength);
return { data: copy, meta };
} catch (error) {
Expand All @@ -111,10 +286,20 @@ export class FsAssetStore extends FsSnapshotStore {
}

async deleteBinderAsset(projectId: string, assetId: string): Promise<void> {
return this.enqueueBinderOperation(projectId, assetId, () =>
this.deleteBinderAssetLocked(projectId, assetId),
);
}

private async deleteBinderAssetLocked(projectId: string, assetId: string): Promise<void> {
try {
const apis = await this.getApis();
const { binFile, metaFile } = await this.binderAssetPaths(projectId, assetId);
if (await apis.exists(binFile)) await retryFs(() => apis.remove(binFile));
const { binFile, metaFile, dir, safeAsset } = await this.binderAssetPaths(projectId, assetId);
const manifest = await this.readBinderManifest(apis, metaFile, safeAsset);
const dataFile = manifest ? await apis.join(dir, manifest.dataFile) : binFile;
if (await apis.exists(dataFile)) await retryFs(() => apis.remove(dataFile));
if (dataFile !== binFile && (await apis.exists(binFile)))
await retryFs(() => apis.remove(binFile));
if (await apis.exists(metaFile)) await retryFs(() => apis.remove(metaFile));
} catch (error) {
logger.warn('deleteBinderAsset failed:', error);
Expand Down
14 changes: 11 additions & 3 deletions services/fs/codexFsStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,13 @@

import type { StoryCodex } from '../../types';
import { logger } from '../logger';
import { compressData, decompressData, retryFs, sanitizePathSegment } from './fsCore';
import {
compressData,
decompressData,
retryFs,
sanitizePathSegment,
writeTextFileAtomic,
} from './fsCore';
import { FsSettingsStore } from './settingsFsStore';

export class FsCodexStore extends FsSettingsStore {
Expand All @@ -19,7 +25,8 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const codexFile = await apis.join(codexDir, 'codex.snap');
await retryFs(() => apis.writeTextFile(codexFile, compressData(codex)));
// QNBS-v3: atomic write — a crash/power-loss mid-write must never leave codex.snap truncated.
await writeTextFileAtomic(apis, codexFile, compressData(codex));
Comment thread
qodo-code-review[bot] marked this conversation as resolved.
}

async getStoryCodex(projectId: string): Promise<StoryCodex | null> {
Expand Down Expand Up @@ -58,7 +65,8 @@ export class FsCodexStore extends FsSettingsStore {
const codexDir = await apis.join(appDataPath, 'projects', safeId, 'codex');
if (!(await apis.exists(codexDir))) await apis.mkdir(codexDir, { recursive: true });
const vectorsFile = await apis.join(codexDir, 'vectors.snap');
await retryFs(() => apis.writeTextFile(vectorsFile, compressData(vectors)));
// QNBS-v3: atomic write — same crash-safety rationale as saveStoryCodex above.
await writeTextFileAtomic(apis, vectorsFile, compressData(vectors));
}

async getRagVectors(projectId: string): Promise<unknown[]> {
Expand Down
Loading
Loading