diff --git a/src/resources/extensions/sf/journal.ts b/src/resources/extensions/sf/journal.ts index 9e24e7b8c..62b0de025 100644 --- a/src/resources/extensions/sf/journal.ts +++ b/src/resources/extensions/sf/journal.ts @@ -12,10 +12,19 @@ * - Silent failure: journal writes never throw — absence of events is the failure signal */ -import { appendFileSync, mkdirSync, readdirSync, readFileSync } from "node:fs"; +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readdirSync, + readFileSync, +} from "node:fs"; import { join } from "node:path"; import { sfRoot } from "./paths.js"; import { isStaleWrite } from "./auto/turn-epoch.js"; +import { withFileLockSync } from "./file-lock.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; @@ -102,7 +111,19 @@ export function emitJournalEvent(basePath: string, entry: JournalEntry): void { mkdirSync(journalDir, { recursive: true }); const dateStr = entry.ts.slice(0, 10); const filePath = join(journalDir, `${dateStr}.jsonl`); - appendFileSync(filePath, JSON.stringify(entry) + "\n"); + // Ensure file exists so proper-lockfile can acquire a lock against it. + if (!existsSync(filePath)) closeSync(openSync(filePath, "a")); + // onLocked: "skip" — journal writes are best-effort. POSIX O_APPEND + // atomicity still protects small entries; the lock mainly serializes + // larger writes and gives cross-process exclusivity on platforms where + // O_APPEND semantics are weaker (Windows). + withFileLockSync( + filePath, + () => { + appendFileSync(filePath, JSON.stringify(entry) + "\n"); + }, + { onLocked: "skip" }, + ); } catch { // Silent failure — journal must never break auto-mode } diff --git a/src/resources/extensions/sf/uok/audit.ts b/src/resources/extensions/sf/uok/audit.ts index 7ec4b1362..2068e680d 100644 --- a/src/resources/extensions/sf/uok/audit.ts +++ b/src/resources/extensions/sf/uok/audit.ts @@ -1,9 +1,10 @@ -import { appendFileSync, mkdirSync } from "node:fs"; +import { appendFileSync, closeSync, existsSync, mkdirSync, openSync } from "node:fs"; import { join } from "node:path"; import { randomUUID } from "node:crypto"; import { sfRoot } from "../paths.js"; import { isDbAvailable, insertAuditEvent } from "../sf-db.js"; +import { withFileLockSync } from "../file-lock.js"; import type { AuditEventEnvelope } from "./contracts.js"; function auditLogPath(basePath: string): string { @@ -37,7 +38,21 @@ export function buildAuditEnvelope(args: { export function emitUokAuditEvent(basePath: string, event: AuditEventEnvelope): void { try { ensureAuditDir(basePath); - appendFileSync(auditLogPath(basePath), `${JSON.stringify(event)}\n`, "utf-8"); + const path = auditLogPath(basePath); + // proper-lockfile requires the target file to exist before locking. + // Touch it via open(O_APPEND|O_CREAT) so the first writer wins the race + // atomically at the kernel level. + if (!existsSync(path)) closeSync(openSync(path, "a")); + // onLocked: "skip" — audit writes are best-effort; under heavy contention + // POSIX O_APPEND atomicity still protects small line writes, so skipping + // the lock rather than stalling orchestration is the correct tradeoff. + withFileLockSync( + path, + () => { + appendFileSync(path, `${JSON.stringify(event)}\n`, "utf-8"); + }, + { onLocked: "skip" }, + ); } catch { // Best-effort: audit writes must never break orchestration. } diff --git a/src/resources/extensions/sf/workflow-logger.ts b/src/resources/extensions/sf/workflow-logger.ts index 4068fb6c0..105852596 100644 --- a/src/resources/extensions/sf/workflow-logger.ts +++ b/src/resources/extensions/sf/workflow-logger.ts @@ -16,9 +16,17 @@ // the start of each unit to prevent log bleed between units running in the same // Node process. -import { appendFileSync, readFileSync, existsSync, mkdirSync } from "node:fs"; +import { + appendFileSync, + closeSync, + existsSync, + mkdirSync, + openSync, + readFileSync, +} from "node:fs"; import { join } from "node:path"; +import { withFileLockSync } from "./file-lock.js"; import { appendNotification } from "./notification-store.js"; import { buildAuditEnvelope, emitUokAuditEvent } from "./uok/audit.js"; import { isAuditEnvelopeEnabled } from "./uok/audit-toggle.js"; @@ -308,8 +316,18 @@ function _push( try { const auditDir = join(_auditBasePath, ".sf"); mkdirSync(auditDir, { recursive: true }); + const auditPath = join(auditDir, "audit-log.jsonl"); const sanitized = _sanitizeForAudit(entry); - appendFileSync(join(auditDir, "audit-log.jsonl"), JSON.stringify(sanitized) + "\n", "utf-8"); + // Ensure file exists so proper-lockfile can acquire a lock against it. + if (!existsSync(auditPath)) closeSync(openSync(auditPath, "a")); + // onLocked: "skip" — never block error logging on lock contention. + withFileLockSync( + auditPath, + () => { + appendFileSync(auditPath, JSON.stringify(sanitized) + "\n", "utf-8"); + }, + { onLocked: "skip" }, + ); } catch (auditErr) { // Best-effort — never let audit write failures bubble up _writeStderr(`[sf:audit] failed to persist log entry: ${(auditErr as Error).message}\n`);