/**
 * NegotiatorLease — types + pure helpers for the Threadline single-negotiator
 * lock (Robustness Phase 1, THREADLINE-SINGLE-NEGOTIATOR-SPEC.md, CMT-1362).
 *
 * The lease names exactly ONE session as the owner of a conversation's outbound
 * voice (G1 — "one voice"). It is stored as optional fields on the existing
 * `Conversation` record so it inherits the ConversationStore's single-writer CAS
 * + atomic-write guarantees; the acquire/renew transaction lives on
 * ConversationStore (`acquireOrRenewLease`). This module holds only the durable
 * SHAPE, the config resolver, and the pure decision helpers — no I/O, no
 * authority. The send-gate orchestration lives in {@link ./NegotiatorGate}.
 *
 * SIGNAL-vs-AUTHORITY: the lease is the ONLY blocking authority in Phase 1, and
 * it blocks on a STRUCTURAL ownership check (who holds the voice), never on what
 * a message MEANS. The commitment-class lexicon in ContentClassifier is a
 * signal-only nudge with no authority (FD-4/FD-10).
 */
import fs from 'node:fs';
import path from 'node:path';
import { SafeFsExecutor } from '../core/SafeFsExecutor.js';
import { resolveDevAgentGate } from '../core/devAgentGate.js';
export const SINGLE_NEGOTIATOR_DEFAULTS = {
    enabled: false,
    dryRun: true,
    leaseTtlMs: 90_000,
    holdingNoticeMinIntervalMs: 300_000,
    dryRunRetentionDays: 7,
};
/**
 * Resolve the `threadline.singleNegotiator` config block defensively. Unknown /
 * missing fields fall back to the safe (dark, dry-run) defaults. `dryRun`
 * defaults TRUE when enabled (FD-7) — enforce (withholding a real send) is only
 * ever reached by an explicit `dryRun: false`.
 *
 * `enabled` resolves through the developmentAgent dark-feature gate
 * ({@link resolveDevAgentGate}): the config OMITS `enabled` so the lease runs
 * LIVE on a development agent (in dry-run — it logs would-hold verdicts but
 * withholds nothing, gathering the FD-7 false-positive telemetry) and DARK on
 * the fleet. An explicit `enabled` in config always wins. Pass the agent config
 * (only `developmentAgent` is read); omit it and the gate resolves fleet-dark.
 */
export function resolveSingleNegotiatorConfig(raw, devGateConfig) {
    const r = (raw ?? {});
    const posNum = (v, d) => typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : d;
    return {
        enabled: resolveDevAgentGate(typeof r.enabled === 'boolean' ? r.enabled : undefined, devGateConfig ?? {}),
        // Default true when the key is absent; only an explicit `false` disarms it.
        dryRun: r.dryRun === undefined ? true : r.dryRun !== false,
        leaseTtlMs: posNum(r.leaseTtlMs, SINGLE_NEGOTIATOR_DEFAULTS.leaseTtlMs),
        holdingNoticeMinIntervalMs: posNum(r.holdingNoticeMinIntervalMs, SINGLE_NEGOTIATOR_DEFAULTS.holdingNoticeMinIntervalMs),
        dryRunRetentionDays: posNum(r.dryRunRetentionDays, SINGLE_NEGOTIATOR_DEFAULTS.dryRunRetentionDays),
    };
}
/** Build the fixed-template holding notice. No model-authored text (FD-11). */
export function buildHoldingNotice(agentName, lease) {
    const text = `[threadline] ${agentName} is already handling this conversation in another ` +
        `session (owner ${lease.ownerSessionName}, epoch ${lease.epoch}); that session ` +
        `will respond. This is an automated holding notice — it carries no commitment.`;
    return {
        kind: 'holding-notice',
        owner: { sessionName: lease.ownerSessionName, machineId: lease.ownerMachineId },
        epoch: lease.epoch,
        text,
    };
}
/**
 * Pure decision: may a holding notice be emitted now for (thread, epoch)? Honors
 * BOTH the durable per-epoch limit AND the global min-interval floor (FD-3),
 * which together bound an epoch-cycling flood.
 */
export function shouldEmitHoldingNotice(opts) {
    const { epoch, lastHoldingNoticeEpoch, lastHoldingNoticeAt, minIntervalMs, now } = opts;
    // Per-epoch limit: at most one notice per (thread, epoch).
    if (lastHoldingNoticeEpoch === epoch)
        return false;
    // Global min-interval floor: at most one notice per thread per window.
    if (lastHoldingNoticeAt) {
        const last = new Date(lastHoldingNoticeAt).getTime();
        if (Number.isFinite(last) && now - last < minIntervalMs)
            return false;
    }
    return true;
}
/**
 * Pure detector: given holder observations (one per machine that believes it is
 * the live holder of a conversation), return the conversationIds observed on
 * MORE THAN ONE machine. Under the single-holder invariant (FD-2) this is always
 * empty; a non-empty result is a split-brain / routing-bug / clock-skew signal
 * that must raise a HIGH-priority alert rather than silently re-opening the
 * cross-machine incident path. Fed by the existing holder-election observations;
 * genuinely-concurrent multi-machine processing of one conversation is the F2
 * surface, explicitly Phase 3.
 */
export function detectDuplicateLiveHolders(observations) {
    const byConversation = new Map();
    for (const o of observations) {
        if (!o || !o.conversationId || !o.machineId)
            continue;
        let set = byConversation.get(o.conversationId);
        if (!set) {
            set = new Set();
            byConversation.set(o.conversationId, set);
        }
        set.add(o.machineId);
    }
    const dupes = [];
    for (const [conversationId, machineIds] of byConversation) {
        if (machineIds.size > 1)
            dupes.push({ conversationId, machineIds: [...machineIds].sort() });
    }
    return dupes;
}
/**
 * Append a negotiator observability record. Daily-rotated by filename so the
 * retention sweep (dryRunRetentionDays) can prune by date. Best-effort — logging
 * never endangers the send.
 */
export function appendNegotiatorLog(logDir, entry) {
    try {
        fs.mkdirSync(logDir, { recursive: true });
        // YYYY-MM-DD suffix → trivially prunable by the retention sweep.
        const day = entry.ts.slice(0, 10);
        const file = path.join(logDir, `threadline-negotiator-${day}.jsonl`);
        fs.appendFileSync(file, JSON.stringify(entry) + '\n');
    }
    catch {
        // @silent-fallback-ok: observability log is best-effort; never break the send.
    }
}
/**
 * Tally the negotiator observability log over the most recent `days` files.
 * Bounded by reading at most `days` day-files (default 7). Best-effort — a
 * missing/corrupt line is skipped, never thrown.
 */
export function readNegotiatorCounts(logDir, days = 7) {
    const counts = { allowOwn: 0, wouldHold: 0, hold: 0, failOpen: 0, casExhausted: 0 };
    try {
        if (!fs.existsSync(logDir))
            return counts;
        const files = fs
            .readdirSync(logDir)
            .filter((n) => /^threadline-negotiator-\d{4}-\d{2}-\d{2}\.jsonl$/.test(n))
            .sort()
            .slice(-Math.max(1, days));
        for (const name of files) {
            let raw;
            try {
                raw = fs.readFileSync(path.join(logDir, name), 'utf-8');
            }
            catch {
                continue;
            }
            for (const line of raw.split('\n')) {
                if (!line.trim())
                    continue;
                try {
                    const e = JSON.parse(line);
                    switch (e.action) {
                        case 'allow-own':
                            counts.allowOwn++;
                            break;
                        case 'would-hold':
                            counts.wouldHold++;
                            break;
                        case 'hold':
                            counts.hold++;
                            break;
                        case 'fail-open':
                            counts.failOpen++;
                            break;
                        case 'cas-exhausted':
                            counts.casExhausted++;
                            break;
                    }
                }
                catch { /* skip corrupt line */ }
            }
        }
    }
    catch { /* best-effort */ }
    return counts;
}
/**
 * Prune negotiator dry-run logs older than `retentionDays`. Idempotent;
 * best-effort. Returns the number of files removed (for tests/observability).
 */
export function pruneNegotiatorLogs(logDir, retentionDays, now = Date.now()) {
    let removed = 0;
    try {
        if (!fs.existsSync(logDir))
            return 0;
        const cutoff = now - retentionDays * 24 * 60 * 60 * 1000;
        for (const name of fs.readdirSync(logDir)) {
            const m = name.match(/^threadline-negotiator-(\d{4}-\d{2}-\d{2})\.jsonl$/);
            if (!m)
                continue;
            const day = new Date(`${m[1]}T00:00:00Z`).getTime();
            if (Number.isFinite(day) && day < cutoff) {
                try {
                    SafeFsExecutor.safeUnlinkSync(path.join(logDir, name), { operation: 'NegotiatorLease.pruneNegotiatorLogs' });
                    removed++;
                }
                catch { /* best-effort */ }
            }
        }
    }
    catch { /* best-effort */ }
    return removed;
}
//# sourceMappingURL=NegotiatorLease.js.map