/*
RFC-0362: Atomic write helpers — temp-file + rename for files, staging dir + rename for directories.
- Do not implement distributed transaction coordination.
- RFC-0362: initial atomic write helpers (atomicWriteFile, atomicMoveDir).
- 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 path from "node:path";
import { createDefaultIO, type WorkspaceIO } from "@warpgogol/werkstatt-shared/kernel/workspace-io";
export async function atomicWriteFile(
targetPath: string,
content: string,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
const dir = path.dirname(targetPath);
const tmpPath = `${targetPath}.tmp-${process.pid}-${Date.now()}`;
if (!(await io.exists(dir))) {
await io.mkdir(dir);
}
await io.writeFile(tmpPath, content);
try {
await io.rename(tmpPath, targetPath);
} catch (err) {
try {
await io.rm(tmpPath);
} catch {
// ignore
}
throw err;
}
}
const RENAME_RETRYABLE_CODES = new Set(["EPERM", "EACCES", "EBUSY", "ENOTEMPTY"]);
async function renameWithRetry(
src: string,
dest: string,
maxRetries = 5,
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let lastErr: unknown;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
await io.rename(src, dest);
return;
} catch (err) {
lastErr = err;
const code = (err as NodeJS.ErrnoException).code;
if (!code || !RENAME_RETRYABLE_CODES.has(code)) throw err;
if (attempt < maxRetries) {
const delay = 50 * Math.pow(2, attempt);
await new Promise((r) => setTimeout(r, delay));
}
}
}
throw lastErr;
}
export async function atomicMoveDir(
stagingDir: string,
targetDir: string,
options?: { replace?: boolean },
io: WorkspaceIO = createDefaultIO().io,
): Promise {
let trashDir: string | undefined;
if (await io.exists(targetDir)) {
if (!options?.replace) {
throw new Error(
`[werkstatt.atomic] target '${targetDir}' already exists — use --replace to overwrite`,
);
}
// On Windows, fs.rm often fails with EBUSY on directories containing
// symlinks/junctions (e.g. pnpm node_modules). Renaming usually succeeds
// where deletion fails. Strategy: rename old target to trash, then rename
// staging into place, then best-effort delete trash.
trashDir = `${targetDir}.trash-${process.pid}-${Date.now()}`;
if (await io.exists(trashDir)) {
await io.rm(trashDir, { recursive: true }).catch(() => {});
}
await renameWithRetry(targetDir, trashDir);
}
const parentDir = path.dirname(targetDir);
if (!(await io.exists(parentDir))) {
await io.mkdir(parentDir);
}
await renameWithRetry(stagingDir, targetDir);
// Best-effort cleanup of the old directory
if (trashDir && (await io.exists(trashDir))) {
await io.rm(trashDir, { recursive: true }).catch(() => {
// Ignore — trash will be cleaned up on next run or manually.
});
}
}
export function resolveStagingDir(
workspaceRoot: string,
targetDir: string,
operationId: string,
): string {
const relativeTarget = path.relative(workspaceRoot, targetDir);
const safeName = relativeTarget.replace(/[^a-zA-Z0-9/_-]/g, "_");
return path.join(workspaceRoot, `${safeName}.staging-${operationId}`);
}