Skip to content

refactor(file-safety): delegate safeWriteJson to safeWriteText atomic publish (S3 v2-2, epic #1375) - #45

Open
easonLiangWorldedtech wants to merge 1 commit into
feat/fws-v2-1-atomic-publish-corefrom
feat/fws-v2-2-safewritejson
Open

refactor(file-safety): delegate safeWriteJson to safeWriteText atomic publish (S3 v2-2, epic #1375)#45
easonLiangWorldedtech wants to merge 1 commit into
feat/fws-v2-1-atomic-publish-corefrom
feat/fws-v2-2-safewritejson

Conversation

@easonLiangWorldedtech

Copy link
Copy Markdown
Owner

What

v2-2 of the S3 file-safety rebuild (epic #1375): rewrite safeWriteJson to delegate its backup/commit/rollback dance to the atomic text publish primitive (safeWriteText, v2-1 / #43).

The staging/backup/rollback logic that was inlined in safeWriteJson (temp write → backup rename → commit rename → rollback) is replaced by one call:

await safeWriteText(absoluteFilePath, "", { tempPath, backup: true })

content is intentionally not passed — the staged temp file is the content source (the tempPath contract of the primitive).

Why

  • One atomic-publish implementation. safeWriteJson (used by task persistence, MCP settings, model caches, import/export, …) no longer carries a second, hand-rolled copy of the crash-safety dance.
  • Inherited semantics for free (each is a strict improvement over the old inline logic):
    • file mode of the existing target is preserved on publish (old code published the temp file's umask mode);
    • fsync of the staged file before the rename (durable before commit);
    • Windows DACL save/restore on the staging span (old code had no DACL handling — a renamed-in file could lose its ACL);
    • symlink targets resolve to their referent and the referent is updated in place (old code replaced the symlink itself with a regular file);
    • failed rollback/cleanup is silent inside the primitive so the original error is never masked; safeWriteJson keeps its own loud console.error at the outer boundary (loud-failing, per the epic).
  • Streaming is kept. The JSON is still serialized with json-stream-stringify into the temp file (large task-history payloads stay out of memory); the staged temp is created beside the resolved target so the commit rename is same-volume (EXDEV-safe).

Scope

  • src/utils/safeWriteJson.ts (252 → 182 lines): public API unchanged — safeWriteJson(filePath, data, { prettyPrint?, merge? }), SafeWriteJsonOptions, LOCK_STALE_MS. Kept as-is: the proper-lockfile advisory lock (same retry/staleness settings), the merge-under-lock read-modify-write, the directory create+access preamble, the loud outer catch. Replaced: the inline temp/backup/commit/rollback block and its catch-block rollback/cleanup. New: resolvePublishTarget for symlink resolution, deterministic staged-temp name (. <name>.new_<ts>_<rand>.tmp beside the resolved target), and explicit unlink of a partial temp when the stream itself fails.
  • src/utils/__tests__/safeWriteJson.test.ts (545 → 597 lines): 21 tests retained; 2 adapted to the delegated semantics (backup-cleanup failure is now silent — the write still succeeds; rollback failure now leaves the original content orphaned under the generated backup name and the target path gone); 2 new tests pin the delegation contract: one pins Date.now/Math.random and asserts the exact staged temp path; the other spies the safeWriteText boundary (the spy delegates to the real implementation) and asserts the exact call — resolved target path, the empty content string (dead when tempPath is supplied — the staged file is the content source, and the pin is what keeps that argument covered), the staged tempPath, and backup: true.
  • src/eslint-suppressions.json: allowed no-explicit-any counts reduced to match the rewrite (4 → 3 in safeWriteJson.ts, 27 → 26 in the spec) — two any annotations were removed by the refactor; counts never increase.

Gates (local, pre-push)

  • vitest targeted: utils/__tests__/safeWriteJson.test.ts + services/file-safety/__tests__/safeWriteText.spec.ts → 54/54; all 17 safeWriteJson consumer suites (delegation-concurrent, importExport, FileContextTracker, TaskHistoryStore ×3, taskMessages, flushPendingToolResultsToHistory, generateImageTool, cache-manager, McpHub, ClineProvider ×6) → 465/465.
  • eslint --prune-suppressions --max-warnings=0 on the touched files: clean; full pnpm lint 11/11 packages (suppression counts synced above).
  • pnpm check-types: 11/11.
  • Stryker (scripts/stryker-diff.mjs ci, base 29110ed66 = v2-1 head, head 2ecc5d446): 6 changed-line mutants, 0 Survived / 0 NoCoverage (StringLiteral 2, MethodExpression 1, OptionalChaining 1, ObjectLiteral 1, BooleanLiteral 1 — all killed). The one survivor from the pre-pin run (the empty "" content literal — equivalent by the tempPath contract) is now killed by the boundary pin test.

Line budget

Standalone diff vs the PR base (v2-1 head 29110ed66): 215 lines (99+/116- across 3 files) — merge-tree measured. Comfortably under the 400-line soft design target.

Stacking / merge order

This PR is based on feat/fws-v2-1-atomic-publish-core (#43) because it imports safeWriteText from it. Merge #43 first, then this branch rebases cleanly onto main and the PR base switches to main — the diff is identical either way.

@github-actions

github-actions Bot commented Sep 5, 2026

Copy link
Copy Markdown

Review status

Thanks for contributing. This comment tracks the review sequence and the next action.

Current step: Required CI passed. Waiting for automated review of the latest commit.

If automated review does not start, a maintainer must restart it.

Review-state labels are managed by this workflow; do not edit them manually.

@github-actions github-actions Bot added coderabbit-review-active Required CI passed; CodeRabbit review is active and removed coderabbit-review-active Required CI passed; CodeRabbit review is active labels Sep 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

awaiting-coderabbit Waiting for CodeRabbit to approve the latest commit coderabbit-review-active Required CI passed; CodeRabbit review is active

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants