/**
 * MachinePoolRegistry — the live machine-pool view (Multi-Machine Session Pool
 * §L2). Assembles a `MachineCapacity` per machine from the machine registry
 * (nickname + hardware), MachineHeartbeat (liveness), `os` (load), and
 * SessionManager diagnostics (sessions/memory), and runs the clock-skew
 * quarantine FSM. It is the input to placement (§L4) and the data behind
 * `GET /pool` + the Machines dashboard tab.
 *
 * Correctness rules from §L2 (do not weaken):
 *  - Liveness + placement freshness key on `routerReceivedAt` (the ROUTER's own
 *    clock at heartbeat arrival), NEVER the machine's self-reported timestamp —
 *    a fast-clocked machine must not appear fresher than it is.
 *  - A machine whose self-reported vs router-observed timestamps diverge beyond
 *    tolerance is quarantined via an explicit FSM (2-divergent-beats-out,
 *    2-clean-beats-in), not silently degraded.
 *
 * The pure pieces (`captureHardware`, `clockSkewTransition`) are exported so they
 * are unit-testable without I/O.
 */
import os from 'node:os';
// ── Hardware capture (pure-ish: reads `os`, no other I/O) ────────────
/** Capture this machine's static hardware properties (§L2). */
export function captureHardware(instarVersion) {
    const cpus = os.cpus();
    return {
        platform: os.platform(),
        arch: os.arch(),
        cpuModel: cpus[0]?.model?.trim() || 'unknown',
        cpuCores: cpus.length,
        totalMemBytes: os.totalmem(),
        hostname: os.hostname(),
        ...(instarVersion ? { instarVersion } : {}),
    };
}
export const INITIAL_CLOCK_SKEW_STATE = { status: 'ok', removedCleanCount: 0 };
/**
 * Pure transition of the clock-skew FSM (§L2 transition table). `divergent` =
 * `|selfReportedLastSeen − routerReceivedAt| > clockSkewToleranceMs` for this beat.
 * Returns the next state + the side-effect the caller must enact (removal +
 * Attention happen on 'removed'; re-admission on 're-admitted').
 */
export function clockSkewTransition(current, divergent) {
    switch (current.status) {
        case 'ok':
            return divergent
                ? { next: { status: 'divergence-detected-once', removedCleanCount: 0 }, sideEffect: 'logged' }
                : { next: { status: 'ok', removedCleanCount: 0 }, sideEffect: 'none' };
        case 'divergence-detected-once':
            return divergent
                ? { next: { status: 'suspect-clock-removed', removedCleanCount: 0 }, sideEffect: 'removed' }
                : { next: { status: 'ok', removedCleanCount: 0 }, sideEffect: 'reset' };
        case 'suspect-clock-removed':
            if (divergent) {
                return { next: { status: 'suspect-clock-removed', removedCleanCount: 0 }, sideEffect: 'none' };
            }
            // in-tolerance: re-admit only on the 2nd consecutive clean beat.
            if (current.removedCleanCount + 1 >= 2) {
                return { next: { status: 'ok', removedCleanCount: 0 }, sideEffect: 're-admitted' };
            }
            return {
                next: { status: 'suspect-clock-removed', removedCleanCount: current.removedCleanCount + 1 },
                sideEffect: 'none',
            };
        default:
            return { next: INITIAL_CLOCK_SKEW_STATE, sideEffect: 'none' };
    }
}
/** Whether a machine in this clock-skew state may receive placement. */
export function isPlacementEligibleByClock(status) {
    return status !== 'suspect-clock-removed';
}
/**
 * Tracks live per-machine state (router-observed timestamp + clock-skew FSM) and
 * assembles the MachineCapacity list. In-memory; rebuilt from heartbeats — the
 * durable machine identity/registry is the source of nickname/hardware.
 */
export class MachinePoolRegistry {
    d;
    observed = new Map();
    constructor(deps) {
        this.d = deps;
    }
    now() {
        return (this.d.now ?? Date.now)();
    }
    /** Record a heartbeat arrival: stamp router-clock receipt + run the clock-skew FSM. */
    recordHeartbeat(obs) {
        const nowMs = this.now();
        const prev = this.observed.get(obs.machineId);
        let divergent = false;
        if (obs.selfReportedLastSeen) {
            const selfMs = Date.parse(obs.selfReportedLastSeen);
            if (Number.isFinite(selfMs)) {
                divergent = Math.abs(selfMs - nowMs) > this.d.clockSkewToleranceMs;
            }
        }
        const { next, sideEffect } = clockSkewTransition(prev?.skew ?? INITIAL_CLOCK_SKEW_STATE, divergent);
        // Posture handling (GUARD-POSTURE-ENDPOINT-SPEC §2.3): a beat WITH a block
        // stamps a fresh receiver-side receipt time and persists durably; a beat
        // WITHOUT one (older peer / lighter beat) carries the previous block
        // forward UNCHANGED — including its original receipt time, so the
        // rendered age stays honest.
        let posture = prev?.posture;
        if (obs.guardPosture) {
            posture = { block: obs.guardPosture, receivedAtMs: nowMs };
            this.d.postureStore?.record(obs.machineId, obs.guardPosture, nowMs);
        }
        // seamlessnessFlags carry-forward (the SAME pattern as `posture` above): a
        // LIGHT/liveness beat that OMITS seamlessnessFlags (e.g. refreshPool's 30s
        // sparse `{machineId, selfReportedLastSeen}` echo) must NOT erase a peer's
        // last pulled capability — otherwise the HTTP-pulled stateSyncReceive advert
        // is wiped every 30s and the flag-coherence gate falsely reads "peer cannot
        // receive", blocking cross-machine replication (root-caused live Laptop↔Mini,
        // 2026-06-14). A GENUINE rich beat ALWAYS builds the object (server.ts
        // ~L14071) — a withdrawn sub-capability flips its boolean to false INSIDE a
        // present object, never omitting it — so carry-forward preserves a real
        // capability across our own synthetic beats and never strands a withdrawn
        // one. Field-specific (not a generic deep-merge) by design: merging stale
        // fail-OPEN fields like quotaState forward would regress placement. A fully
        // offline peer still ages out via routerReceivedAtMs → online:false.
        const obsToStore = obs.seamlessnessFlags === undefined && prev?.obs.seamlessnessFlags !== undefined
            ? { ...obs, seamlessnessFlags: prev.obs.seamlessnessFlags }
            : obs;
        this.observed.set(obs.machineId, { routerReceivedAtMs: nowMs, obs: obsToStore, skew: next, posture });
        if (sideEffect === 'removed') {
            this.d.onClockQuarantine?.(obs.machineId, `clock divergence > ${this.d.clockSkewToleranceMs}ms on 2 consecutive heartbeats — removed from placement`);
            this.d.logger?.(`[pool] machine ${obs.machineId} clock-skew quarantined`);
        }
        else if (sideEffect === 're-admitted') {
            this.d.logger?.(`[pool] machine ${obs.machineId} clock recovered — re-admitted to placement`);
        }
        return sideEffect;
    }
    /** Current clock-skew status for a machine (defaults to ok if unseen). */
    clockSkewStatus(machineId) {
        return this.observed.get(machineId)?.skew.status ?? 'ok';
    }
    /** Whether a machine is eligible for placement (online + clock ok). */
    isPlacementEligible(machineId) {
        const cap = this.getCapacity(machineId);
        return !!cap && cap.online && isPlacementEligibleByClock(cap.clockSkewStatus);
    }
    /** Assemble the MachineCapacity for one machine (null if unknown to the registry). */
    getCapacity(machineId) {
        const known = this.d.listMachines().find((m) => m.machineId === machineId);
        if (!known)
            return null;
        return this.assemble(known);
    }
    /** Assemble the full pool view (§L2) — the data behind GET /pool. */
    getCapacities() {
        return this.d.listMachines().map((m) => this.assemble(m));
    }
    assemble(known) {
        const live = this.observed.get(known.machineId);
        const nowMs = this.now();
        const online = !!live && nowMs - live.routerReceivedAtMs < this.d.failoverThresholdMs;
        return {
            machineId: known.machineId,
            nickname: known.nickname,
            online,
            selfReportedLastSeen: live?.obs.selfReportedLastSeen,
            routerReceivedAt: live ? new Date(live.routerReceivedAtMs).toISOString() : undefined,
            loadAvg: live?.obs.loadAvg,
            memPressure: live?.obs.memPressure,
            activeSessionCount: live?.obs.activeSessionCount,
            maxSessions: live?.obs.maxSessions,
            capabilities: known.capabilities,
            modelsAvailable: known.modelsAvailable,
            agentsResident: known.agentsResident,
            hardware: known.hardware,
            clockSkewStatus: live?.skew.status ?? 'ok',
            quotaState: live?.obs.quotaState,
            servesChannels: live?.obs.servesChannels,
            inboundQueue: live?.obs.inboundQueue,
            // WS1.1: capability advertisement passthrough. Memory-only (lost on a
            // local restart, like posture's in-memory half — no durable disk
            // fallback): a peer that goes dark stops advertising and ages out of
            // `online`, the safe direction. recordHeartbeat carries the last pulled
            // value forward across our OWN sparse liveness beats (see its
            // carry-forward note), so a 30s `{machineId,selfReportedLastSeen}` echo no
            // longer erases an HTTP-pulled stateSyncReceive advert. A genuine
            // withdrawal flips a boolean inside a still-present object and DOES
            // propagate.
            seamlessnessFlags: live?.obs.seamlessnessFlags,
            // Guard posture: live observation first, durable last-known second —
            // a machine with no posture EVER received carries neither field
            // (renders "guards: unknown", never "0 on / 0 off").
            ...(() => {
                const p = live?.posture ?? this.d.postureStore?.get(known.machineId) ?? null;
                if (!p)
                    return {};
                const block = 'block' in p ? p.block : p.posture;
                return {
                    guardPosture: block,
                    guardPostureReceivedAt: new Date(p.receivedAtMs).toISOString(),
                };
            })(),
        };
    }
}
//# sourceMappingURL=MachinePoolRegistry.js.map