// src/engine/safety/cross-process-lock.ts — Cross-process spec lockfile (SPEC-719)
//
// Provides disk-backed mutual exclusion across Node.js processes for spec.md mutations.
// Stale lock detection uses three signals: PID death, heartbeat expiry, TTL expiry.
//
// NFS / Network FS caveat: lockfile correctness assumes local POSIX filesystem semantics.
// SMB / NFSv3 may produce false acquires because O_EXCL is not guaranteed atomic on
// network filesystems. For local-disk POSIX (HFS+, APFS, ext4, tmpfs) this is reliable.
//
// Lockfiles live at: <projectPath>/data/.locks/planu/<specId>.lock
// File mode: 0o600 (owner read/write only)
import { open, unlink, readFile, mkdir, chmod } from 'node:fs/promises';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { hostname as osHostname } from 'node:os';
import { randomUUID } from 'node:crypto';
import { writeJsonSafe } from './atomic-writer.js';
import { enrichWithStaleness } from './lock-staleness.js';
// ---------------------------------------------------------------------------
// Constants
// ---------------------------------------------------------------------------
const DEFAULT_TTL_MS = 600_000; // 10 minutes
const MIN_TTL_MS = 60_000; // 1 minute
const MAX_TTL_MS = 86_400_000; // 24 hours
const HEARTBEAT_INTERVAL_MS = 30_000; // 30 seconds
export { HEARTBEAT_INTERVAL_MS };
// ---------------------------------------------------------------------------
// Errors
// ---------------------------------------------------------------------------
/** Thrown by withSpecLock when the lock is held by another live session. */
export class LockBusyError extends Error {
    code = 'LOCK_BUSY';
    holder;
    constructor(holder) {
        super(`Spec ${holder.specId} is locked by session ${holder.sessionId} (pid ${String(holder.pid)}) on ${holder.hostname}`);
        this.name = 'LockBusyError';
        this.holder = holder;
    }
}
// ---------------------------------------------------------------------------
// Internal helpers
// ---------------------------------------------------------------------------
function locksDir(projectPath) {
    return join(projectPath, 'data', '.locks', 'planu');
}
function lockPath(projectPath, specId) {
    return join(locksDir(projectPath), `${specId}.lock`);
}
function resolveSessionId(opts) {
    return opts?.sessionId ?? process.env.PLANU_SESSION_ID ?? randomUUID();
}
function clampTtl(ttlMs) {
    const ttl = ttlMs ?? DEFAULT_TTL_MS;
    return Math.min(Math.max(ttl, MIN_TTL_MS), MAX_TTL_MS);
}
async function readLockFile(path) {
    try {
        const raw = await readFile(path, 'utf-8');
        return JSON.parse(raw);
    }
    catch {
        return null;
    }
}
/** Write a lockfile atomically and set 0o600 mode. */
async function writeLockFile(path, metadata) {
    await mkdir(join(path, '..'), { recursive: true });
    await writeJsonSafe(path, metadata, { verify: true, backupCount: 0 });
    // Set strict mode: owner read/write only
    await chmod(path, 0o600);
}
/** Try to create a new lockfile exclusively (O_EXCL). Returns true on success. */
async function tryExclusiveCreate(path, metadata) {
    await mkdir(join(path, '..'), { recursive: true });
    let fd = null;
    try {
        // 'wx' = O_WRONLY | O_CREAT | O_EXCL — fails if file already exists
        fd = await open(path, 'wx', 0o600);
        await fd.writeFile(JSON.stringify(metadata, null, 2), 'utf-8');
        return true;
    }
    catch (err) {
        if (err instanceof Error && 'code' in err && err.code === 'EEXIST') {
            return false;
        }
        throw err;
    }
    finally {
        await fd?.close().catch(() => {
            // best-effort close
        });
    }
}
// ---------------------------------------------------------------------------
// LockHandle implementation
// ---------------------------------------------------------------------------
function buildHandle(metadata, path) {
    return {
        metadata,
        lockPath: path,
        async heartbeat() {
            const current = await readLockFile(path);
            if (current?.sessionId !== metadata.sessionId) {
                console.warn('[planu:lock] heartbeat_skipped_foreign_holder', {
                    specId: metadata.specId,
                    expectedSession: metadata.sessionId,
                    foundSession: current?.sessionId ?? null,
                });
                return false;
            }
            const updated = {
                ...current,
                lastHeartbeatAt: new Date().toISOString(),
            };
            try {
                await writeLockFile(path, updated);
                return true;
            }
            catch (err) {
                console.warn('[planu:lock] heartbeat_failed', {
                    specId: metadata.specId,
                    error: err instanceof Error ? err.message : String(err),
                });
                return false;
            }
        },
        async isStillHeld() {
            const current = await readLockFile(path);
            return current?.sessionId === metadata.sessionId;
        },
    };
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
 * Try to acquire a cross-process lock for `specId`.
 *
 * Returns a LockHandle on success, or null if the lock is held by another live session.
 * Stale locks (PID dead, heartbeat expired, or TTL expired) are reclaimed automatically.
 *
 * Implementation:
 * - Attempt `fs.open(lockPath, 'wx')` (O_EXCL) — succeeds only when no lockfile exists.
 * - On EEXIST, read the existing lockfile and check staleness.
 * - If stale: overwrite atomically via SPEC-718 writeJsonSafe and return a new handle.
 * - If live: log a structured warning and return null.
 *
 * @remarks
 * NFS / SMB caveat: O_EXCL is not guaranteed atomic on network filesystems.
 * This implementation is reliable on local POSIX filesystems (APFS, HFS+, ext4, tmpfs).
 */
export async function acquireLock(projectPath, specId, opts) {
    const path = lockPath(projectPath, specId);
    const now = Date.now();
    const sessionId = resolveSessionId(opts);
    const ttlMs = clampTtl(opts?.ttlMs);
    const nowIso = new Date(now).toISOString();
    const metadata = {
        specId,
        pid: process.pid,
        sessionId,
        hostname: osHostname(),
        acquiredAt: nowIso,
        lastHeartbeatAt: nowIso,
        ttlMs,
        ...(opts?.reason !== undefined ? { reason: opts.reason } : {}),
    };
    // Attempt exclusive creation first
    const created = await tryExclusiveCreate(path, metadata);
    if (created) {
        return buildHandle(metadata, path);
    }
    // File exists — check staleness
    const existing = await readLockFile(path);
    if (existing === null) {
        // Lockfile vanished between EEXIST and read — retry once
        const retried = await tryExclusiveCreate(path, metadata);
        if (retried) {
            return buildHandle(metadata, path);
        }
        return null;
    }
    const info = enrichWithStaleness(existing, osHostname(), now);
    if (!info.stale) {
        // Lock is held by a live session
        console.warn('[planu:lock] lock_busy', {
            specId,
            holderSession: existing.sessionId,
            holderPid: existing.pid,
            holderAcquiredAt: existing.acquiredAt,
            staleReason: null,
        });
        return null;
    }
    // Stale lock — reclaim via atomic write
    console.warn('[planu:lock] lock_reclaimed', {
        specId,
        previousSession: existing.sessionId,
        previousPid: existing.pid,
        previousAcquiredAt: existing.acquiredAt,
        staleReason: info.staleReason,
        newSession: sessionId,
        newPid: process.pid,
    });
    await writeLockFile(path, metadata);
    return buildHandle(metadata, path);
}
/**
 * Release a lock if it is still held by the given handle.
 * Idempotent: calling twice on the same handle is safe.
 * Will NOT unlink a lockfile that is now held by a different session.
 */
export async function releaseLock(handle) {
    const path = handle.lockPath;
    if (!existsSync(path)) {
        return; // already gone — idempotent
    }
    const current = await readLockFile(path);
    if (current === null) {
        return; // race: file deleted between existsSync and read
    }
    if (current.sessionId !== handle.metadata.sessionId) {
        console.warn('[planu:lock] release_skipped_foreign_holder', {
            specId: handle.metadata.specId,
            expectedSession: handle.metadata.sessionId,
            foundSession: current.sessionId,
        });
        return;
    }
    try {
        await unlink(path);
    }
    catch (err) {
        if (err instanceof Error && 'code' in err && err.code === 'ENOENT') {
            return; // already unlinked — idempotent
        }
        throw err;
    }
}
/**
 * Read the current lockfile state for a spec.
 * Returns null when no lockfile exists.
 * Enriches the result with computed staleness fields.
 */
export async function isLocked(projectPath, specId) {
    const path = lockPath(projectPath, specId);
    const raw = await readLockFile(path);
    if (raw === null) {
        return null;
    }
    return enrichWithStaleness(raw, osHostname(), Date.now());
}
/**
 * List all cross-process locks for a project, filtering out stale ones.
 * Non-existent `.locks/` directory returns an empty array.
 */
export async function listActiveCrossProcessLocks(projectPath) {
    const dir = locksDir(projectPath);
    if (!existsSync(dir)) {
        return [];
    }
    const { readdir } = await import('node:fs/promises');
    let entries;
    try {
        entries = await readdir(dir);
    }
    catch {
        return [];
    }
    const now = Date.now();
    const localHost = osHostname();
    const results = [];
    await Promise.all(entries
        .filter((f) => f.endsWith('.lock'))
        .map(async (filename) => {
        const raw = await readLockFile(join(dir, filename));
        if (raw === null) {
            return;
        }
        const info = enrichWithStaleness(raw, localHost, now);
        if (!info.stale) {
            results.push(info);
        }
    }));
    return results;
}
/**
 * High-level wrapper: acquire → run fn → release.
 * Automatically sets up a 30s heartbeat and tears it down on completion.
 * Throws LockBusyError if the lock cannot be acquired.
 *
 * @example
 * ```ts
 * await withSpecLock(projectPath, specId, async (_handle) => {
 *   await mutatSpec();
 * }, { reason: 'update_status(approved)' });
 * ```
 */
export async function withSpecLock(projectPath, specId, fn, opts) {
    const handle = await acquireLock(projectPath, specId, opts);
    if (handle === null) {
        // Read existing lock to include in error
        const existing = await readLockFile(lockPath(projectPath, specId));
        const holder = existing ?? {
            specId,
            pid: 0,
            sessionId: 'unknown',
            hostname: 'unknown',
            acquiredAt: new Date().toISOString(),
            lastHeartbeatAt: new Date().toISOString(),
            ttlMs: DEFAULT_TTL_MS,
        };
        throw new LockBusyError(holder);
    }
    // Set up heartbeat interval
    const hbTimer = setInterval(() => {
        void handle.heartbeat().catch(() => {
            // best-effort — logged inside heartbeat()
        });
    }, HEARTBEAT_INTERVAL_MS);
    try {
        return await fn(handle);
    }
    finally {
        clearInterval(hbTimer);
        await releaseLock(handle).catch((err) => {
            console.warn('[planu:lock] release_error_in_finally', {
                specId,
                error: err instanceof Error ? err.message : String(err),
            });
        });
    }
}
//# sourceMappingURL=cross-process-lock.js.map