/*
RFC-0258: atomic file-write primitive for workspace-shared kernel-command
outputs. Every kernel command that writes a file outside its target app
directory (workspace root, docs/, packages/ontology/) MUST write through
writeFileAtomic instead of node:fs/promises writeFile, so that a reader
polling the target path never observes a torn/partial file when Turborepo
runs multiple app builds in parallel.
- Do not provide cross-process mutual exclusion (locks) — outputs are deterministic (RFC-0087), so convergent atomic writes are sufficient.
- Do not fall back to a non-atomic write when retries are exhausted — fail loudly instead.
- Writes go through temp-file plus rename — readers never observe partial content.
- Canonical implementation lives in @warpgogol/forge — this is a compatibility re-export.
- RFC-0258: initial implementation.
- RFC-1097: step 6 — compass.migrate codemod run
Mechanical v1 to v2 header migration across the workspace: 942 files rewritten — CHANGE_SUMMARY windows collapsed into history, forbidden v1 blocks stripped, KEY_DECISIONS seeded from @ai-invariant comments (5 files) or TODO placeholders (103 files), blocks reordered to canonical order.
- RFC-1097: sweep — werkstatt-engine clean
Sweep batch 4: 73 Compass headers on headerless engine files (certification, component-runtime, isolation, evolution, testing), real KEY_DECISIONS on 75 files (kernel, cache, dht, swim, gitmesh, runtime), ~80 purpose expansions (CONTRACT-02/PURPOSE-02), non-goals on 13 CONTRACT-03 files, CS-07 history literal fix repo-wide (253 files). Policy: .template.ts/.template.astro excludedPaths. werkstatt-engine now 0 diagnostics.
*/
import { writeFile, rename as fsRename, unlink } from "node:fs/promises";
import { existsSync } from "node:fs";
import { dirname, join, basename } from "node:path";
import { randomBytes } from "node:crypto";
export interface WriteFileAtomicOptions {
/** Max rename retries on transient EPERM/EBUSY. Default 10, linear 50ms backoff. */
retries?: number;
}
// Indirection seam so unit tests can simulate a transient EPERM/EBUSY
// on the first rename attempt without monkey-patching the frozen node:fs/promises
// module namespace (ESM named exports are read-only bindings). Production code
// always goes through fsRename; only tests in this package call the setter.
let renameImpl: typeof fsRename = fsRename;
/** @internal test-only seam — do not call from production code. */
export function __setRenameImplForTests(impl: typeof fsRename | undefined): void {
renameImpl = impl ?? fsRename;
}
const DEFAULT_RETRIES = 10;
const RETRY_BACKOFF_MS = 50;
const RETRYABLE_CODES = new Set(["EPERM", "EBUSY"]);
function isRetryableError(error: unknown): boolean {
if (!error || typeof error !== "object") return false;
const code = (error as NodeJS.ErrnoException).code;
return typeof code === "string" && RETRYABLE_CODES.has(code);
}
function sleep(ms: number): Promise {
return new Promise((resolve) => setTimeout(resolve, ms));
}
function makeTempPath(filePath: string): string {
const dir = dirname(filePath);
const name = basename(filePath);
const random = Array.from(randomBytes(6), (b) => b.toString(16).padStart(2, "0")).join("");
return join(dir, `${name}.${random}.tmp`);
}
/**
* Write `content` to `filePath` atomically: write to a temp file in the same
* directory, then rename over the target. Rename within one directory is
* atomic on POSIX; a bounded retry loop absorbs transient EPERM/EBUSY
* (e.g. a reader briefly holding the file handle during overwrite).
*
* On failure (write or all rename retries exhausted), the temp file is
* unlinked in a finally block and the original error is rethrown — this
* helper never falls back to a non-atomic write.
*/
export async function writeFileAtomic(
filePath: string,
content: string | Uint8Array,
options?: WriteFileAtomicOptions,
): Promise {
const retries = options?.retries ?? DEFAULT_RETRIES;
const tempPath = makeTempPath(filePath);
let tempWritten = false;
try {
await writeFile(tempPath, content);
tempWritten = true;
let attempt = 0;
// Bounded retry loop: attempt 0 is the first try, then up to `retries` more.
for (;;) {
try {
await renameImpl(tempPath, filePath);
return;
} catch (error) {
if (attempt >= retries || !isRetryableError(error)) {
throw error;
}
attempt += 1;
// EPERM on rename-over-existing: try unlinking the target
// first (it may have been briefly locked by a reader that has since
// released it), then retry the rename on the next iteration.
if (existsSync(filePath)) {
try {
await unlink(filePath);
} catch {
// If unlink also fails, the retry loop will try again.
}
}
await sleep(RETRY_BACKOFF_MS * attempt);
}
}
} finally {
if (tempWritten) {
try {
// If rename succeeded, the temp file no longer exists — unlink is a no-op error we swallow.
await unlink(tempPath);
} catch {
// Expected in the success path (temp file already renamed away) — ignore.
}
}
}
}