/**
 * Server-side Y.Doc manager for collaborative editing.
 *
 * Maintains one Y.Doc per actively edited entity. The server is the single
 * seeding authority — it loads entity content from disk into the Y.Doc when
 * the first client connects. Clients start with empty Y.Docs and receive
 * content via the Yjs sync protocol. This prevents the double-seeding bug
 * where two clients independently insert the same content.
 *
 * Lifecycle:
 * 1. First client opens entity → server creates Y.Doc, seeds from disk
 * 2. Server sends sync-step-1 to client → client responds → both converge
 * 3. Client edits → binary update to server → server applies + relays to peers
 * 4. Last client leaves → server destroys Y.Doc (frees memory)
 */
import * as Y from 'yjs';
import * as syncProtocol from 'y-protocols/sync.js';
import * as awarenessProtocol from 'y-protocols/awareness.js';
import * as encoding from 'lib0/encoding';
import * as decoding from 'lib0/decoding';
import { createHash } from 'node:crypto';
import { applyTextDiff } from './yjs-text-diff.js';
function sha256(s) {
    return createHash('sha256').update(s, 'utf-8').digest('hex');
}
const MSG_SYNC = 0;
const MSG_AWARENESS = 1;
/**
 * MSG_CHUNK wraps a fragment of a larger logical message. Chunked frames
 * have layout: [4B msgId][2B seq][2B total][1B origMsgType][chunkPayload].
 * Used to keep individual WS frames under Safari/WebKit's binary-frame
 * threshold (~4KB after deflate), where larger frames get truncated and
 * blow up Yjs's varint decoder with `RangeError: Length out of range of
 * buffer`. The client reassembles chunks and dispatches as if the
 * original `origMsgType` frame had arrived intact.
 */
const MSG_CHUNK = 2;
/** Threshold above which an outbound payload is split into MSG_CHUNK
 *  frames. 4KB stays well under the smallest reported Safari WebSocket
 *  binary-frame failure size, while keeping the common keystroke/awareness
 *  traffic on the single-frame fast path. */
const MAX_CHUNK_PAYLOAD = 4096;
/**
 * Grace period before destroying an idle Y.Doc (ms).
 * Keep long enough that async server-side processing (LLM meeting summary,
 * agent mutations, etc.) can push updates into the Y.Doc via
 * applyAgentContent while the client is transiently disconnected.
 */
const DOC_IDLE_TIMEOUT = 120_000;
/**
 * After a client connects to a fresh (server-restart or first-open) Y.Doc,
 * wait this long for the client's sync-step-2 to arrive before seeding
 * from disk. If the client has state (reconnect after server restart),
 * their sync populates the Y.Doc and we skip the disk seed — preventing
 * the CRDT merge that would otherwise produce duplicated content when
 * two origins insert the same bytes.
 */
const SEED_WAIT_MS = 500;
/**
 * Origins for which server-initiated `ydoc.transact` updates are relayed
 * to all connected clients. The broadcast observer registered in `doLoad`
 * filters by this set; updates from other origins (including `ws-client`,
 * which `handleMessage` already relays peer-to-peer) are skipped to avoid
 * double-sends and infinite-loop hazards.
 */
const BROADCAST_ORIGINS = new Set([
    'server-sync-disk-merge', // reconcileFromDisk (boot / git-pull / dev-tools)
    'server-agent-write', // applyAgentContent post-commit broadcast
]);
/**
 * Thrown by `assertWritable` when broadcast is disabled AND a doc has
 * active clients AND the operator has not explicitly opted into the
 * degraded mode via `STUDIOGRAPH_ALLOW_DEGRADED_WRITES`. Surfaces a hard
 * error to the caller (MCP / agent / event processor) instead of silently
 * mutating the server's Y.Doc out of sync with connected editors.
 */
export class BroadcastDisabledLiveClientsError extends Error {
    docName;
    code = 'BROADCAST_DISABLED_LIVE_CLIENTS';
    constructor(docName) {
        super(`Refusing body write to ${docName}: YJS broadcast is disabled (STUDIOGRAPH_YJS_BROADCAST=0) ` +
            `but ${docName} has active editors. Server-side writes would diverge from connected clients. ` +
            `Set STUDIOGRAPH_ALLOW_DEGRADED_WRITES=1 to override at the cost of editor data loss.`);
        this.docName = docName;
        this.name = 'BroadcastDisabledLiveClientsError';
    }
}
/**
 * Live-merge note: there used to be a `LiveEditorPendingError` thrown by
 * `assertWritable` when a connected editor had unsaved typing, refusing the
 * server-write outright. It was retired when server-writes switched to
 * CRDT-merging into the live Y.Doc via `applyAgentContent` (the user watches
 * the change land, with a "Agent edited this note" marker). The accepted
 * residual — a whole-body target reverting the user's un-autosaved edits in
 * rewritten regions — is removed later by the edit-region tool.
 */
export class YjsManager {
    workspaceManager;
    persistence;
    docs = new Map();
    /**
     * In-flight `getOrCreate` promises, keyed by docName. Concurrent calls for
     * the same docName await the same promise instead of building two Y.Docs
     * (which would race on the persistence file and split client traffic).
     */
    loadingPromises = new Map();
    /**
     * Whether the update-broadcast observer is registered. Read once at
     * construction from `STUDIOGRAPH_YJS_BROADCAST` (default ON). Flipping the
     * env var requires a restart to take effect — dynamic re-check would let
     * connected editors silently diverge when the flag is toggled mid-session.
     */
    broadcastEnabled;
    /**
     * Operator opt-in to accept degraded-mode data loss (broadcast off +
     * server-side writes while clients are connected). Off by default —
     * forcing operators to make the data-loss trade-off explicit.
     */
    allowDegradedWrites;
    /**
     * Per-doc lock chain (Phase 2). Each `withDocLock(docName, fn)` appends
     * to this chain so server-side writes for the same doc serialize. Client
     * `handleMessage` updates do NOT acquire the lock — instead they queue
     * into {@link msgSyncQueue} while the lock is held and apply on release.
     */
    docLocks = new Map();
    /**
     * Per-doc MSG_SYNC buffer. While `withDocLock` is held for a doc, inbound
     * client sync frames for that doc are pushed here instead of applied
     * directly. On lock release, frames are drained in arrival order via the
     * normal `handleMessage` path with origin `'ws-client'`.
     *
     * This is the Codex pass-3 #1 fix: without queueing, a client insert
     * arriving mid-lock would land in `entry.ydoc` BEFORE `applyAgentContent`
     * runs, and the agent's full-body diff would delete that just-typed char.
     * With queueing, the client's insert lands on the post-agent ytext as a
     * fresh CRDT op at its original anchor.
     */
    msgSyncQueue = new Map();
    constructor(workspaceManager, persistence) {
        this.workspaceManager = workspaceManager;
        this.persistence = persistence;
        this.broadcastEnabled = process.env.STUDIOGRAPH_YJS_BROADCAST !== '0';
        this.allowDegradedWrites = process.env.STUDIOGRAPH_ALLOW_DEGRADED_WRITES === '1';
    }
    /** Whether the update-broadcast observer is active. Exposed for diagnostics. */
    isBroadcastEnabled() {
        return this.broadcastEnabled;
    }
    /**
     * Pre-write guard for server-write body paths. Throws when broadcast is
     * disabled, the doc has active clients, and the operator hasn't opted into
     * degraded-mode writes — server-side writes would silently diverge from
     * connected editors (→ `BroadcastDisabledLiveClientsError`).
     *
     * It used to ALSO refuse a server-write when a connected editor had unsaved
     * typing (`LiveEditorPendingError`). That refusal was retired with the
     * live-merge model: server-writes now CRDT-merge into the live Y.Doc via
     * `applyAgentContent` instead of being blocked. The residual — a whole-body
     * target reverting the user's un-autosaved edits in regions the agent
     * rewrote — is accepted (the future edit-region tool removes it). The user
     * sees the merge land live plus a "Agent edited this note" marker.
     *
     * Called from `writeBody` via the `assertWritable` hook, gated to
     * `source === 'server-write'`. Autosave is the client's own write and is
     * never refused.
     */
    assertWritable(docName) {
        const entry = this.docs.get(docName);
        // Broadcast-off + live clients guard: with the broadcast observer
        // unregistered, a server-side merge can't reach connected editors, so they
        // would diverge. Refuse unless the operator explicitly accepts that.
        if (!this.broadcastEnabled && !this.allowDegradedWrites) {
            if (entry && entry.clients.size > 0) {
                throw new BroadcastDisabledLiveClientsError(docName);
            }
        }
    }
    /**
     * Acquire a per-doc lock and run `fn` to completion. While the lock is
     * held, inbound client MSG_SYNC frames for the same docName are queued
     * (see {@link msgSyncQueue}) and applied after the lock releases.
     *
     * Contract: server-side writes serialize against other server-side
     * writes for the same doc. Client typing does NOT block — frames are
     * buffered and applied in arrival order on release. The Codex pass-3 #1
     * trace proved this is necessary: without queueing, a client keystroke
     * arriving mid-lock would be deleted by the agent's subsequent full-body
     * diff (the keystroke lives in ytext but not in the agent's target).
     *
     * Per-doc only — other docs' handleMessage traffic is unaffected.
     *
     * Failures inside `fn` propagate to the caller; the lock releases either
     * way so the queued sync drain still runs.
     */
    async withDocLock(docName, fn) {
        const prev = this.docLocks.get(docName) ?? Promise.resolve();
        let resolveSelf;
        const self = new Promise(resolve => { resolveSelf = resolve; });
        this.docLocks.set(docName, self);
        try {
            await prev.catch(() => { });
            try {
                return await fn();
            }
            finally {
                // Drain queued MSG_SYNC frames in arrival order BEFORE releasing
                // the lock. This guarantees no other server-write holder sees a
                // half-drained queue.
                this.drainMsgSyncQueue(docName);
            }
        }
        finally {
            resolveSelf(undefined);
            // Only clear if we're still the tail — a later lock-acquirer may
            // have appended after us.
            if (this.docLocks.get(docName) === self) {
                this.docLocks.delete(docName);
            }
        }
    }
    /** Whether a server-write lock is currently held for the doc. */
    isLocked(docName) {
        return this.docLocks.has(docName);
    }
    /**
     * Buffer an inbound MSG_SYNC frame for later application. Called from
     * `handleMessage` when the doc is locked.
     */
    enqueueMsgSync(docName, sender, payload) {
        let q = this.msgSyncQueue.get(docName);
        if (!q) {
            q = [];
            this.msgSyncQueue.set(docName, q);
        }
        q.push({ sender, payload });
    }
    /**
     * Drain any queued MSG_SYNC frames for `docName`, applying them via the
     * normal handleMessage path. Called from `withDocLock` on release.
     */
    drainMsgSyncQueue(docName) {
        const q = this.msgSyncQueue.get(docName);
        if (!q || q.length === 0)
            return;
        this.msgSyncQueue.delete(docName);
        for (const { sender, payload } of q) {
            // Re-enter handleMessage. Since the lock is being released by the
            // caller above (resolveSelf runs after this drain), the lock check
            // inside handleMessage MUST NOT see this doc as locked. We use a
            // direct call to the apply-sync logic instead of handleMessage to
            // avoid the re-entrant lock check.
            try {
                this.applyClientSync(docName, sender, payload);
            }
            catch (err) {
                console.warn(`[yjs] queued MSG_SYNC drain for ${docName} failed`, err);
            }
        }
    }
    /**
     * Apply a client sync message directly, bypassing the lock check. Used
     * by `drainMsgSyncQueue`. Mirrors the MSG_SYNC branch of
     * {@link handleMessage} — keep in sync if that branch changes.
     */
    applyClientSync(docName, sender, payload) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        const decoder = decoding.createDecoder(payload);
        const encoder = encoding.createEncoder();
        syncProtocol.readSyncMessage(decoder, encoder, entry.ydoc, 'ws-client');
        if (!entry.seeded && entry.ydoc.getText('content').length > 0) {
            entry.seeded = true;
            if (entry.seedTimer) {
                clearTimeout(entry.seedTimer);
                entry.seedTimer = undefined;
            }
            const parsed = this.parseDocName(docName);
            if (parsed)
                entry.mdRevisionHash = sha256(this.readDiskContent(parsed));
            this.broadcastDocReady(docName);
        }
        if (encoding.length(encoder) > 0) {
            this.sendToClient(sender, docName, MSG_SYNC, encoding.toUint8Array(encoder));
        }
        for (const client of entry.clients) {
            if (client === sender || client.readyState !== 1)
                continue;
            this.sendToClient(client, docName, MSG_SYNC, payload);
        }
    }
    /**
     * Apply a server-write target body to the live Y.Doc.
     *
     * Called from `graph.update`'s server-write branch AFTER disk + commit
     * have succeeded. The disk-first ordering is deliberate: clients only see
     * server-initiated changes that are durable. If applying here fails,
     * disk and commit are unaffected — but live clients are out of sync.
     *
     * Returns `{ applied: 'no-doc' | 'unseeded' | 'ok' }`. For the first two,
     * the caller already wrote disk; no live editor needs the broadcast.
     *
     * On `'ok'`: sets `entry.mdRevisionHash = sha256(target)` BEFORE the
     * transact wraps, applies the diff with origin `'server-agent-write'`
     * (broadcast-allowlisted), returns success.
     *
     * On failure (rare — applyTextDiff is just delete/insert ops), sends a
     * `doc_conflict` JSON frame to all clients and rethrows. Conflict UI
     * is the user's signal that disk and Y.Doc diverged.
     */
    applyAgentContent(docName, target) {
        const entry = this.docs.get(docName);
        if (!entry)
            return { applied: 'no-doc' };
        if (!entry.seeded)
            return { applied: 'unseeded' };
        try {
            const ytext = entry.ydoc.getText('content');
            // Set the hash BEFORE the transact. applyTextDiff makes ytext === target
            // after the transact, so sha256(target) is the post-state hash. The
            // persistence observer fires during the transact and reads the live
            // hash via the resolver — it will see the new hash either way (Phase
            // 1c flush-time resolver), but pinning it before is defense in depth.
            entry.mdRevisionHash = sha256(target);
            applyTextDiff(entry.ydoc, ytext, target, 'server-agent-write');
            return { applied: 'ok' };
        }
        catch (err) {
            // applyTextDiff itself doesn't throw for valid strings, but if Yjs
            // somehow rejects the transact we surface conflict UI to clients so
            // they don't keep typing into a divergent Y.Doc and back-stomp disk.
            this.sendDocConflict(docName, 'apply-agent-content-failed');
            throw err;
        }
    }
    /**
     * Send a `doc_conflict` JSON frame to every connected client of a doc.
     * Editor handles by entering readonly conflict UI: "another writer made
     * a change we couldn't sync to your editor; reload to continue."
     *
     * Used by `applyAgentContent` failure path. Distinct from `doc_ready`
     * (the heartbeat) — conflict implies user must take action.
     */
    sendDocConflict(docName, reason) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        const msg = JSON.stringify({ type: 'doc_conflict', docName, reason });
        for (const client of entry.clients) {
            if (client.readyState !== 1)
                continue;
            try {
                client.send(msg);
            }
            catch { /* non-fatal */ }
        }
    }
    /**
     * Parse a docName ("repo/entityType/entityId") into its components.
     */
    parseDocName(docName) {
        const parts = docName.split('/');
        if (parts.length < 3)
            return null;
        return { repo: parts[0], entityType: parts[1], entityId: parts.slice(2).join('/') };
    }
    /**
     * Get or create a Y.Doc for an entity.
     *
     * Async because persistence loads from disk. Concurrent calls for the same
     * docName await the same in-flight promise so we don't build two Y.Docs.
     *
     * If a persisted snapshot exists, applies it to the fresh Y.Doc and marks
     * `seeded = true` immediately — reconnecting clients then see a server
     * state vector that already includes their pre-restart ops, so sync-step-2
     * is a no-op and the duplication race is closed.
     *
     * If no snapshot exists (first-ever open of this entity, or persistence
     * disabled), leaves `seeded = false`. The existing deferred-seed path in
     * addClient + handleMessage will populate the Y.Doc from the .md file.
     */
    async getOrCreate(docName) {
        const existing = this.docs.get(docName);
        if (existing)
            return existing;
        const inFlight = this.loadingPromises.get(docName);
        if (inFlight)
            return inFlight;
        const promise = this.doLoad(docName);
        this.loadingPromises.set(docName, promise);
        try {
            return await promise;
        }
        finally {
            this.loadingPromises.delete(docName);
        }
    }
    async doLoad(docName) {
        const parsed = this.parseDocName(docName);
        if (!parsed)
            return null;
        // Repo must exist before we allocate anything.
        if (!this.workspaceManager.getGraph(parsed.repo))
            return null;
        const ydoc = new Y.Doc();
        const awareness = new awarenessProtocol.Awareness(ydoc);
        let seeded = false;
        let mdRevisionHash = '';
        if (this.persistence) {
            try {
                const loaded = await this.persistence.load(docName);
                if (loaded) {
                    // Tag the load with a known origin so the persistence-write observer
                    // (registered below) doesn't re-encode and re-write what we just read.
                    Y.applyUpdate(ydoc, loaded.state, 'persistence-load');
                    seeded = true;
                    mdRevisionHash = loaded.meta.mdRevisionHash;
                    // Reconcile snapshot vs current .md. If the .md was rewritten while
                    // the server was down (agent script, git pull, manual edit), the
                    // snapshot is stale relative to disk — smart-merge brings the Y.Doc
                    // forward, preserving op continuity in unchanged regions.
                    const diskContent = this.readDiskContent(parsed);
                    const diskHash = sha256(diskContent);
                    if (diskHash !== mdRevisionHash) {
                        applyTextDiff(ydoc, ydoc.getText('content'), diskContent, 'startup-reconcile');
                        mdRevisionHash = diskHash;
                    }
                }
            }
            catch (err) {
                console.warn(`[yjs] persistence.load(${docName}) failed; falling back to .md seed`, err);
            }
        }
        const entry = {
            ydoc,
            awareness,
            clients: new Set(),
            wsClientIds: new Map(),
            seeded,
            mdRevisionHash,
        };
        // Snapshot writer: any non-load update schedules a debounced flush.
        // The hash resolver is invoked at FLUSH time, not schedule time — a
        // server-side transact fires this listener before the calling code
        // resumes to bump `entry.mdRevisionHash`, so capturing the hash at
        // schedule time would persist the pre-mutation value. Resolver pattern
        // (see HashResolver in yjs-persistence) reads the post-state at flush.
        if (this.persistence) {
            const persistence = this.persistence;
            ydoc.on('update', (_update, origin) => {
                if (origin === 'persistence-load')
                    return;
                const e = this.docs.get(docName);
                if (!e)
                    return;
                persistence.scheduleWrite(docName, ydoc, () => {
                    const live = this.docs.get(docName);
                    return live ? live.mdRevisionHash : '';
                });
            });
        }
        // Update-broadcast observer (Phase 1a). Relays server-initiated transacts
        // to every connected client. MUST be registered AFTER the persistence
        // observer above so persistence's snapshot scheduling fires first — the
        // flush-time hash resolver makes order non-load-bearing for correctness
        // but pinning it documents intent.
        //
        // Origin filter: only `server-sync-disk-merge` (existing path) and
        // `server-agent-write` (Phase 2 path) broadcast. `ws-client` updates are
        // already relayed peer-to-peer by `handleMessage`; broadcasting them
        // here would double-send. `persistence-load`, `server-seed`, and
        // `startup-reconcile` either have no connected clients yet or are
        // already broadcast manually.
        //
        // Encoding: use the listener's `update` bytes directly via
        // `syncProtocol.writeUpdate`. Do NOT call `Y.encodeStateAsUpdate(ydoc, _)`
        // — full-state re-encode would be wasteful and the second arg is a
        // state vector, not an update.
        //
        // Gated by `STUDIOGRAPH_YJS_BROADCAST` (default on). When off, the
        // observer is not registered; server-side writes refuse to mutate live
        // docs via `assertWritable` instead (fail-closed).
        if (this.broadcastEnabled) {
            ydoc.on('update', (update, origin) => {
                if (!BROADCAST_ORIGINS.has(origin))
                    return;
                const e = this.docs.get(docName);
                if (!e)
                    return;
                const encoder = encoding.createEncoder();
                syncProtocol.writeUpdate(encoder, update);
                const payload = encoding.toUint8Array(encoder);
                for (const client of e.clients) {
                    if (client.readyState !== 1)
                        continue;
                    try {
                        this.sendToClient(client, docName, MSG_SYNC, payload);
                    }
                    catch { /* per-client send failures must not break the relay loop */ }
                }
            });
        }
        this.docs.set(docName, entry);
        return entry;
    }
    readDiskContent(parsed) {
        try {
            const graph = this.workspaceManager.getGraph(parsed.repo);
            if (!graph)
                return '';
            const entity = graph.get(parsed.entityType, parsed.entityId);
            return entity?.document?.content ?? '';
        }
        catch {
            return '';
        }
    }
    /**
     * Send a JSON `doc_ready` notice to every connected client of `docName`,
     * carrying the authoritative ytext length the client should match before
     * unlocking its editor. This is the seed-success heartbeat the client
     * uses to confirm its local apply actually landed — without it, a frame
     * that arrived but failed to decode (e.g. Safari/WebKit fragmentation
     * issue) would silently leave the editor unlocked on an empty Y.Doc and
     * the next keystroke + autosave would overwrite the disk file.
     */
    broadcastDocReady(docName) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        const expectedLength = entry.ydoc.getText('content').length;
        const msg = JSON.stringify({ type: 'doc_ready', docName, expectedLength });
        for (const client of entry.clients) {
            if (client.readyState !== 1)
                continue;
            try {
                client.send(msg);
            }
            catch { /* non-fatal */ }
        }
    }
    /**
     * Send `doc_ready` to a single client. Used in addClient when the entry
     * is already seeded — the new joiner needs the heartbeat to unlock its
     * editor without waiting for the next state transition.
     */
    sendDocReadyToClient(docName, ws) {
        const entry = this.docs.get(docName);
        if (!entry || ws.readyState !== 1)
            return;
        const expectedLength = entry.ydoc.getText('content').length;
        try {
            ws.send(JSON.stringify({ type: 'doc_ready', docName, expectedLength }));
        }
        catch { /* non-fatal */ }
    }
    /**
     * If the Y.Doc is still empty (no client sync has populated it), load
     * content from disk and mark the entry as seeded. Broadcast the seeded
     * state to all connected clients as a sync-step-2 so they see it.
     *
     * Runs ~SEED_WAIT_MS after a client connects. Short-circuits if any
     * earlier sync already flipped the `seeded` flag.
     */
    maybeSeedFromDisk(docName) {
        const entry = this.docs.get(docName);
        if (!entry || entry.seeded)
            return;
        const ytext = entry.ydoc.getText('content');
        if (ytext.length > 0) {
            // A client sync populated the doc during the wait — nothing to do.
            entry.seeded = true;
            this.broadcastDocReady(docName);
            return;
        }
        const parsed = this.parseDocName(docName);
        if (!parsed)
            return;
        const diskContent = this.readDiskContent(parsed);
        if (diskContent.length === 0) {
            entry.seeded = true;
            entry.mdRevisionHash = sha256('');
            this.broadcastDocReady(docName);
            return;
        }
        entry.ydoc.transact(() => {
            ytext.insert(0, diskContent);
        }, 'server-seed');
        entry.seeded = true;
        entry.mdRevisionHash = sha256(diskContent);
        // Broadcast the newly-seeded state to every connected client so they
        // see the content. Writing sync-step-2 from the now-populated Y.Doc
        // sends the delta (the characters we just inserted).
        const encoder = encoding.createEncoder();
        syncProtocol.writeSyncStep2(encoder, entry.ydoc);
        const payload = encoding.toUint8Array(encoder);
        for (const client of entry.clients) {
            if (client.readyState !== 1)
                continue;
            this.sendToClient(client, docName, MSG_SYNC, payload);
        }
        // Heartbeat: tell clients the authoritative length so they only unlock
        // their editor if their local apply matches. Without this, a client that
        // received the broadcast but failed to decode it (Safari fragmentation
        // bug) would unlock on an empty Y.Doc and overwrite disk on next save.
        this.broadcastDocReady(docName);
    }
    /**
     * Add a client to a document. Async because `getOrCreate` may need to load a
     * persisted snapshot from disk.
     *
     * Protocol (ordered carefully to make first paint resilient):
     *   1. Server: load doc (snapshot or fresh) — `getOrCreate`.
     *   2. Server: send sync-step-1 + sync-step-2 (full state) + awareness
     *      FIRST. The client's editor populates and unlocks the moment the
     *      sync-step-2 lands; it doesn't need to wait for the ack to render.
     *   3. Server: send `collab:joined` ack JSON LAST. This tells the client
     *      it's safe to send its own sync-step-1 + awareness, the load-race
     *      protection codex flagged. Sending content before the ack means a
     *      delayed/dropped ack still leaves the editor populated — only the
     *      client's outbound state vector is gated.
     *   4. Client: receives sync-step-2 → editor renders. Then `collab:joined`
     *      → client sends its own sync-step-1 + awareness.
     *   5. Server: applies client sync, relays to peers.
     */
    async addClient(docName, ws) {
        const entry = await this.getOrCreate(docName);
        if (!entry) {
            // Couldn't find or create the doc (unknown repo, etc). Don't ack —
            // client will time out waiting and remain in its readonly seed-gate state.
            return;
        }
        // Cancel idle destruction timer — a client is joining.
        if (entry.idleTimer) {
            clearTimeout(entry.idleTimer);
            entry.idleTimer = undefined;
        }
        entry.clients.add(ws);
        // Send sync-step-1 (our state vector) so the client can reply with what
        // we're missing.
        const encoder = encoding.createEncoder();
        syncProtocol.writeSyncStep1(encoder, entry.ydoc);
        this.sendToClient(ws, docName, MSG_SYNC, encoding.toUint8Array(encoder));
        // Send sync-step-2 (our full state) so the client gets our content
        // without a round trip — this is what unlocks the editor on the client.
        const encoder2 = encoding.createEncoder();
        syncProtocol.writeSyncStep2(encoder2, entry.ydoc);
        this.sendToClient(ws, docName, MSG_SYNC, encoding.toUint8Array(encoder2));
        // Existing peer cursors.
        const awarenessStates = entry.awareness.getStates();
        if (awarenessStates.size > 0) {
            const clients = Array.from(awarenessStates.keys());
            const update = awarenessProtocol.encodeAwarenessUpdate(entry.awareness, clients);
            this.sendToClient(ws, docName, MSG_AWARENESS, update);
        }
        // If we're joining an already-seeded doc (a peer is already editing),
        // the new client needs the doc_ready heartbeat right away so it can
        // unlock once the sync-step-2 we just sent lands locally. Without this,
        // a late joiner would stay readonly forever waiting for a transition
        // that already happened.
        if (entry.seeded) {
            this.sendDocReadyToClient(docName, ws);
        }
        // Ack the join AFTER content + heartbeat. The ack is what tells the
        // client it's safe to send its own sync-step-1 + awareness. Sending
        // content before the ack means editor first-paint can survive a
        // delayed or lost ack. (doc_ready above unlocks the editor; this ack
        // unlocks outbound traffic.)
        try {
            ws.send(JSON.stringify({ type: 'collab:joined', docName }));
        }
        catch { /* WS might have closed mid-load — non-fatal */ }
        // Deferred disk seed. If the client has its own state (reconnect after
        // server restart but before persistence existed, or persistence disabled),
        // their sync-step-2 will populate the Y.Doc before this timer fires and
        // maybeSeedFromDisk becomes a no-op. If they don't (first-ever open),
        // we seed and broadcast.
        if (!entry.seeded && !entry.seedTimer) {
            entry.seedTimer = setTimeout(() => {
                entry.seedTimer = undefined;
                this.maybeSeedFromDisk(docName);
            }, SEED_WAIT_MS);
        }
    }
    /**
     * Remove a client from a document. Destroys Y.Doc when last client leaves.
     */
    removeClient(docName, ws) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        // Remove awareness states owned by this WS and broadcast removal to remaining clients
        const clientIds = entry.wsClientIds.get(ws);
        if (clientIds && clientIds.size > 0) {
            const ids = Array.from(clientIds);
            // Remove states (sets them to null internally) then encode the removal update
            awarenessProtocol.removeAwarenessStates(entry.awareness, ids, 'ws-close');
            // After removal, states are null — encodeAwarenessUpdate will encode null states
            // which tells peers to remove those cursors. Meta still has the clock.
            try {
                const update = awarenessProtocol.encodeAwarenessUpdate(entry.awareness, ids);
                for (const client of entry.clients) {
                    if (client === ws || client.readyState !== 1)
                        continue;
                    this.sendToClient(client, docName, MSG_AWARENESS, update);
                }
            }
            catch { /* meta missing for some clients — states are already removed locally */ }
        }
        entry.wsClientIds.delete(ws);
        entry.clients.delete(ws);
        if (entry.clients.size === 0) {
            // Don't destroy immediately — wait for grace period so reconnecting
            // clients can resume the same Y.Doc without re-seeding (which causes
            // content duplication due to mismatched Yjs state).
            entry.idleTimer = setTimeout(() => {
                // Only destroy if still idle (no new clients joined)
                if (entry.clients.size === 0) {
                    if (entry.seedTimer) {
                        clearTimeout(entry.seedTimer);
                        entry.seedTimer = undefined;
                    }
                    this.docs.delete(docName);
                    // Drain pending persistence writes BEFORE destroying the Y.Doc — a
                    // debounced flush could still be holding a reference and we want
                    // its bytes to land on disk. Destroy is async, but we don't await
                    // here (the timer callback is fire-and-forget); the persistence
                    // layer's per-doc chain serializes writes anyway.
                    if (this.persistence) {
                        this.persistence.drain(docName).finally(() => {
                            entry.awareness.destroy();
                            entry.ydoc.destroy();
                        });
                    }
                    else {
                        entry.awareness.destroy();
                        entry.ydoc.destroy();
                    }
                }
            }, DOC_IDLE_TIMEOUT);
        }
    }
    /**
     * Remove a client from ALL documents (called on WS close).
     */
    removeClientFromAll(ws) {
        for (const [docName, entry] of this.docs) {
            if (entry.clients.has(ws)) {
                this.removeClient(docName, ws);
            }
        }
    }
    /**
     * Handle a binary Yjs message from a client.
     * Applies sync messages to the server Y.Doc and relays to peers.
     * Awareness messages are relayed without server processing.
     */
    handleMessage(docName, sender, msgType, payload) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        if (msgType === MSG_SYNC) {
            // Phase 2: if a server-side write holds the per-doc lock, queue the
            // client sync frame instead of applying it. The Codex pass-3 #1 trace
            // showed that applying a client insert mid-lock leaves the just-typed
            // chars in ytext, and the subsequent applyAgentContent diff against
            // the agent's target deletes them. Queueing defers application until
            // AFTER applyAgentContent — the insert then lands as a fresh CRDT op
            // at its original anchor on top of the new agent content, producing
            // "AAA → client X → agent BBB → final BBBX" instead of "BBB".
            if (this.isLocked(docName)) {
                this.enqueueMsgSync(docName, sender, payload);
                return;
            }
            // Apply sync message to server Y.Doc. The client is gated readonly
            // until it receives our doc_ready (with expectedLength matching the
            // seeded ytext.length), so a keystroke can't race the deferred-seed
            // window into the server. Reconnect clients with retained state push
            // it via sync-step-2 here, which marks `seeded = true` below and the
            // deferred-seed timer becomes a no-op.
            const decoder = decoding.createDecoder(payload);
            const encoder = encoding.createEncoder();
            syncProtocol.readSyncMessage(decoder, encoder, entry.ydoc, 'ws-client');
            // If a client's sync populated the Y.Doc, suppress the deferred
            // disk seed — otherwise we'd double-insert the same content via
            // the CRDT. Record the current disk hash so the persistence snapshot
            // has a baseline for future divergence detection.
            if (!entry.seeded && entry.ydoc.getText('content').length > 0) {
                entry.seeded = true;
                if (entry.seedTimer) {
                    clearTimeout(entry.seedTimer);
                    entry.seedTimer = undefined;
                }
                const parsed = this.parseDocName(docName);
                if (parsed)
                    entry.mdRevisionHash = sha256(this.readDiskContent(parsed));
                // Tell every client (including the sender) the new authoritative
                // length so their editors can unlock on a verified seed.
                this.broadcastDocReady(docName);
            }
            // If the sync produced a response, send it back to the sender
            if (encoding.length(encoder) > 0) {
                this.sendToClient(sender, docName, MSG_SYNC, encoding.toUint8Array(encoder));
            }
            // Relay the original message to all OTHER clients
            for (const client of entry.clients) {
                if (client === sender || client.readyState !== 1)
                    continue;
                this.sendToClient(client, docName, MSG_SYNC, payload);
            }
        }
        else if (msgType === MSG_AWARENESS) {
            // Track which awareness clientIDs belong to this WS
            try {
                const decoder = decoding.createDecoder(payload);
                const len = decoding.readVarUint(decoder);
                for (let i = 0; i < len; i++) {
                    const clientId = decoding.readVarUint(decoder);
                    let ids = entry.wsClientIds.get(sender);
                    if (!ids) {
                        ids = new Set();
                        entry.wsClientIds.set(sender, ids);
                    }
                    ids.add(clientId);
                    // Skip the rest of this entry (clock + state JSON)
                    decoding.readVarUint(decoder); // clock
                    const stateJson = decoding.readVarString(decoder);
                    // If state is null/empty, client is leaving — remove from tracking
                    if (stateJson === 'null' || stateJson.length === 0)
                        ids.delete(clientId);
                }
            }
            catch { /* best-effort tracking, don't break relay */ }
            // Apply awareness to server's awareness instance
            awarenessProtocol.applyAwarenessUpdate(entry.awareness, payload, sender);
            // Relay to all OTHER clients
            for (const client of entry.clients) {
                if (client === sender || client.readyState !== 1)
                    continue;
                this.sendToClient(client, docName, MSG_AWARENESS, payload);
            }
        }
    }
    /**
     * Encode and send a framed binary message to a client. Payloads above
     * `MAX_CHUNK_PAYLOAD` are split into MSG_CHUNK frames; smaller ones go
     * through the single-frame fast path unchanged.
     */
    sendToClient(ws, docName, msgType, payload) {
        if (payload.length <= MAX_CHUNK_PAYLOAD) {
            this.sendSingleFrame(ws, docName, msgType, payload);
            return;
        }
        this.sendChunkedFrames(ws, docName, msgType, payload);
    }
    sendSingleFrame(ws, docName, msgType, payload) {
        const nameBytes = Buffer.from(docName, 'utf8');
        const frame = Buffer.alloc(2 + nameBytes.length + 1 + payload.length);
        frame.writeUInt16BE(nameBytes.length, 0);
        nameBytes.copy(frame, 2);
        frame[2 + nameBytes.length] = msgType;
        Buffer.from(payload).copy(frame, 2 + nameBytes.length + 1);
        try {
            ws.send(frame);
        }
        catch { /* non-fatal */ }
    }
    sendChunkedFrames(ws, docName, msgType, payload) {
        const total = Math.ceil(payload.length / MAX_CHUNK_PAYLOAD);
        if (total > 0xFFFF) {
            // Logical message larger than 65535 chunks (~256MB at 4KB chunks).
            // Falling back to a single frame is no worse than the pre-chunking
            // behavior, and we'd rather not silently truncate.
            console.warn(`[yjs] payload too large to chunk (${payload.length} bytes, ${total} chunks); sending single frame`);
            this.sendSingleFrame(ws, docName, msgType, payload);
            return;
        }
        // 4-byte random message ID — collision risk is negligible within the
        // ~30s reassembly window the client tolerates.
        const msgId = Buffer.from([
            Math.floor(Math.random() * 256),
            Math.floor(Math.random() * 256),
            Math.floor(Math.random() * 256),
            Math.floor(Math.random() * 256),
        ]);
        const nameBytes = Buffer.from(docName, 'utf8');
        for (let seq = 0; seq < total; seq++) {
            const start = seq * MAX_CHUNK_PAYLOAD;
            const end = Math.min(start + MAX_CHUNK_PAYLOAD, payload.length);
            const chunk = payload.subarray(start, end);
            // Frame: [2B nameLen][docName][1B MSG_CHUNK][4B msgId][2B seq][2B total][1B origMsgType][chunk]
            const headerLen = 2 + nameBytes.length + 1 + 4 + 2 + 2 + 1;
            const frame = Buffer.alloc(headerLen + chunk.length);
            let off = 0;
            frame.writeUInt16BE(nameBytes.length, off);
            off += 2;
            nameBytes.copy(frame, off);
            off += nameBytes.length;
            frame[off] = MSG_CHUNK;
            off += 1;
            msgId.copy(frame, off);
            off += 4;
            frame.writeUInt16BE(seq, off);
            off += 2;
            frame.writeUInt16BE(total, off);
            off += 2;
            frame[off] = msgType;
            off += 1;
            Buffer.from(chunk).copy(frame, off);
            try {
                ws.send(frame);
            }
            catch { /* non-fatal */ }
        }
    }
    /**
     * The docNames of every Y.Doc currently loaded (a client has it open, or
     * it's within the post-disconnect idle grace period). Cheap in-memory map
     * read — no disk I/O. Used by `graph.pullAndReconcile` to gate the
     * post-pull reconcile loop to only the entities a user actually has open,
     * so a repo-wide pull doesn't parse every changed file when nobody's
     * editing. Includes unseeded docs (the caller still matches them by
     * docName; `reconcileFromDisk` no-ops harmlessly on those).
     */
    getActiveDocNames() {
        return [...this.docs.keys()];
    }
    /**
     * Get current Y.Doc text content for a document (for debugging/autosave).
     */
    getText(docName) {
        const entry = this.docs.get(docName);
        if (!entry)
            return null;
        return entry.ydoc.getText('content').toString();
    }
    /**
     * Authoritative live body for a document, or null when there's nothing
     * trustworthy to read. Unlike {@link getText}, this returns null unless the
     * entry exists AND is seeded — an unseeded Y.Doc carries Y.Text='' before
     * its first client sync, and reporting that as the body would masquerade an
     * empty doc as a real (empty) entry.
     *
     * This is the read side of the live-vs-disk gap: a user's freshly-placed
     * image/asset embed lives in the Y.Doc and only reaches disk after the
     * autosave debounce + commit. `graph.get` reads disk, so callers that need
     * to see what the user is actually looking at (get_entity, the asset-drop
     * guard) consult this first and fall back to disk when it returns null.
     */
    peekLiveBody(docName) {
        const entry = this.docs.get(docName);
        if (!entry || !entry.seeded)
            return null;
        return entry.ydoc.getText('content').toString();
    }
    /**
     * Reconcile an active Y.Doc with the latest .md file on disk. EXPLICIT
     * external-disk-change primitive — call this from named sites only:
     * startup boot, git-pull event handlers, dev-tools "reload from disk".
     *
     * NOT called from the normal write path. Server-side body writes (MCP,
     * agent, event processor) route through {@link applyAgentContent}, which
     * applies the target body under the per-doc lock AFTER disk + commit
     * land. Reconcile here is for changes the server didn't originate.
     *
     * Uses Yjs-aware text diffing (see {@link applyTextDiff}). Chars in
     * UNCHANGED regions retain their original `(clientID, clock)`. Cursor
     * anchors via `Y.createRelativePositionFromTypeIndex` in unchanged regions
     * stay valid. Y.UndoManager stacks referencing those chars stay coherent.
     *
     * KNOWN LIMIT: chars in CHANGED regions are still discarded. If a user has
     * typed `world ` into a "Hello " doc but autosave hasn't fired yet, the
     * Y.Doc has "Hello world " while disk still has "Hello ". When an external
     * write lands "Hi " on disk and we reconcile, the diff becomes
     * `[delete "Hello "][insert "Hi "][equal "world "]`. The user's "world "
     * survives because it's an unchanged region; their typing in the head is
     * gone because that's the changed region. This is the irreducible residue
     * of "make ytext match disk" semantics for external sources.
     *
     * No-ops when no Y.Doc is currently active for docName — clients that open
     * later will pick up the .md content via fresh seeding in `getOrCreate`.
     */
    reconcileFromDisk(docName) {
        const entry = this.docs.get(docName);
        if (!entry) {
            console.log(`[yjs] reconcileFromDisk: no active doc for ${docName} — client will seed from disk on next visit`);
            return;
        }
        const parsed = this.parseDocName(docName);
        if (!parsed)
            return;
        const diskContent = this.readDiskContent(parsed);
        const ytext = entry.ydoc.getText('content');
        if (ytext.toString() === diskContent) {
            // Still want to keep mdRevisionHash in sync even on a no-op so the
            // persistence layer's divergence-detection logic doesn't false-fire.
            entry.mdRevisionHash = sha256(diskContent);
            return;
        }
        console.log(`[yjs] reconcileFromDisk: smart-merging ${docName} (${diskContent.length} chars, ${entry.clients.size} clients)`);
        applyTextDiff(entry.ydoc, ytext, diskContent, 'server-sync-disk-merge');
        entry.mdRevisionHash = sha256(diskContent);
        // Refresh clients' expected-length heartbeat so editors that were locked
        // due to a previous failed seed can unlock on the broadcast update.
        this.broadcastDocReady(docName);
    }
    /**
     * Record that the .md body for an entity was just written to disk. Called
     * from `graph.update`'s `onBodyWritten` callback.
     *
     * Updates `entry.mdRevisionHash` if the doc is currently loaded so the
     * next persistence snapshot captures the post-write state. No-op when
     * the doc isn't loaded — the next `doLoad` will compute the hash from
     * the disk content on demand.
     */
    recordDiskBody(docName, body) {
        const entry = this.docs.get(docName);
        if (!entry)
            return;
        entry.mdRevisionHash = sha256(body);
    }
    /**
     * Destroy all Y.Docs (server shutdown). Drains pending persistence writes
     * before tearing down so a debounced snapshot can't be lost on shutdown.
     */
    async destroy() {
        if (this.persistence)
            await this.persistence.destroy();
        for (const [, entry] of this.docs) {
            if (entry.idleTimer)
                clearTimeout(entry.idleTimer);
            if (entry.seedTimer)
                clearTimeout(entry.seedTimer);
            entry.awareness.destroy();
            entry.ydoc.destroy();
        }
        this.docs.clear();
    }
}
//# sourceMappingURL=yjs-manager.js.map