// Spawn-metadata sidecar (Task 260). Disk is the only source of truth for
// row metadata. The sidecar carries everything the manager learned at
// `/spawn` time that the JSONL transcript itself does not record:
// `senderId`, `role`, `channel`, `url`, `startedAt`, `permissionMode`,
// `model`, `hidden`, `specialist`. The watcher reads it when building
// rows; the http-server writes it on `/spawn`; url-capture updates the
// `url` field once the `/remote-control` banner lands.
//
// Location: `<projectsDir>/<sessionId>.meta.json` for live rows;
// `<projectsDir>/archive/<sessionId>.meta.json` for archived rows. The
// pair (sidecar + JSONL) moves together on `/archive` and `/unarchive`.
//
// Atomic write: write to `<path>.tmp.<pid>.<ts>`, then `rename` to the
// final path. POSIX guarantees rename atomicity on the same filesystem,
// so a crash mid-write leaves the prior file (if any) intact; the tmp
// file is the only leak, and a stale `.tmp.*` is harmless because the
// watcher and route code only read the canonical path.
//
// Single-writer pattern. Only two write sites exist today: the spawn
// route (one `writeSidecar` per `/spawn`) and url-capture (one
// `updateSidecar({url})` per session). No concurrent writers race; a
// future caller that adds a third writer must reason about the
// read-modify-write window in `updateSidecar`.
import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
import { dirname } from 'node:path';
function shortId(sessionId) {
    return sessionId.slice(0, 8);
}
/** Read a sidecar from disk. Returns `outcome: 'missing'` when the file
 *  is absent; `outcome: 'parse-failed'` when it exists but cannot be
 *  parsed as the expected shape. The caller (watcher row builder) stamps
 *  null fields on the row in both cases and the boot-seed log line
 *  surfaces the mismatch. */
export function readSidecar(path, logger) {
    if (!existsSync(path)) {
        logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=missing bytes=0`);
        return { sidecar: null, outcome: 'missing', bytes: 0 };
    }
    let raw;
    try {
        raw = readFileSync(path, 'utf8');
    }
    catch {
        logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=parse-failed bytes=0`);
        return { sidecar: null, outcome: 'parse-failed', bytes: 0 };
    }
    const bytes = Buffer.byteLength(raw, 'utf8');
    if (raw.length === 0) {
        logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=parse-failed bytes=0`);
        return { sidecar: null, outcome: 'parse-failed', bytes: 0 };
    }
    let parsed;
    try {
        parsed = JSON.parse(raw);
    }
    catch {
        logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=parse-failed bytes=${bytes}`);
        return { sidecar: null, outcome: 'parse-failed', bytes };
    }
    const sidecar = parseSidecar(parsed);
    if (!sidecar) {
        logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=parse-failed bytes=${bytes}`);
        return { sidecar: null, outcome: 'parse-failed', bytes };
    }
    logger(`[session-sidecar] read sessionId=${basenameShort(path)} outcome=ok bytes=${bytes}`);
    return { sidecar, outcome: 'ok', bytes };
}
/** Write a sidecar atomically: tmp file in the same directory, then
 *  rename. Throws if the directory cannot be created or the rename
 *  fails — the spawn route translates this to a loud 500 rather than
 *  launching a metadata-less PTY. */
export function writeSidecar(path, sidecar, logger) {
    const dir = dirname(path);
    mkdirSync(dir, { recursive: true });
    const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
    const json = JSON.stringify(sidecar, null, 2);
    const bytes = Buffer.byteLength(json, 'utf8');
    try {
        writeFileSync(tmp, json, 'utf8');
        renameSync(tmp, path);
    }
    catch (err) {
        // Best-effort tmp cleanup; the throw still propagates.
        try {
            if (existsSync(tmp))
                unlinkSync(tmp);
        }
        catch { /* ignore */ }
        throw err;
    }
    logger(`[session-sidecar] write sessionId=${basenameShort(path)} senderId=${shortId(sidecar.senderId)} role=${sidecar.role} channel=${sidecar.channel} bytes=${bytes} path=${path}`);
}
/** Read-modify-write update. Returns true when the field was written;
 *  false when the sidecar is missing or unparseable (caller may choose
 *  to log; we don't write a fresh sidecar from a partial update because
 *  the other fields would be invented). Single-writer assumption applies
 *  — concurrent updates may race and lose the latest value. */
export function updateSidecar(path, patch, logger) {
    const { sidecar } = readSidecar(path, logger);
    if (!sidecar)
        return false;
    const next = { ...sidecar, ...patch };
    const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
    const json = JSON.stringify(next, null, 2);
    const bytes = Buffer.byteLength(json, 'utf8');
    try {
        writeFileSync(tmp, json, 'utf8');
        renameSync(tmp, path);
    }
    catch (err) {
        try {
            if (existsSync(tmp))
                unlinkSync(tmp);
        }
        catch { /* ignore */ }
        throw err;
    }
    for (const [k, v] of Object.entries(patch)) {
        const display = v === null ? 'null' : typeof v === 'string' ? v : String(v);
        logger(`[session-sidecar] update sessionId=${basenameShort(path)} field=${k} value=${display} bytes=${bytes}`);
    }
    return true;
}
/** Task 550 — append a bridge ULID suffix to `bridgeIds` on the sidecar.
 *  Read-modify-write; the atomic rename pattern matches the rest of this
 *  module. Returns `'ok'` when written, `'duplicate'` when the suffix was
 *  already present (no write performed), and `'no-sidecar'` when the
 *  sidecar is missing or unparseable (caller logs and skips — legacy
 *  rows without a sidecar are out of scope per the task brief).
 *  Single-writer assumption is shared with url-capture's updateSidecar:
 *  both are read-modify-write and could race, but they touch disjoint
 *  fields, so the rename-loses-write window is the existing limitation
 *  rather than a new one. */
export function appendBridgeId(path, bridgeSuffix, logger) {
    const { sidecar } = readSidecar(path, logger);
    if (!sidecar)
        return 'no-sidecar';
    if (sidecar.bridgeIds.includes(bridgeSuffix))
        return 'duplicate';
    const next = { ...sidecar, bridgeIds: [...sidecar.bridgeIds, bridgeSuffix] };
    const tmp = `${path}.tmp.${process.pid}.${Date.now()}`;
    const json = JSON.stringify(next, null, 2);
    const bytes = Buffer.byteLength(json, 'utf8');
    try {
        writeFileSync(tmp, json, 'utf8');
        renameSync(tmp, path);
    }
    catch (err) {
        try {
            if (existsSync(tmp))
                unlinkSync(tmp);
        }
        catch { /* ignore */ }
        throw err;
    }
    logger(`[session-sidecar] append-bridge sessionId=${basenameShort(path)} bridgeId=${bridgeSuffix} count=${next.bridgeIds.length} bytes=${bytes}`);
    return 'ok';
}
const ROLES = ['admin', 'public'];
const CHANNELS = ['browser', 'whatsapp', 'telegram', 'webchat', 'email'];
function parseSidecar(raw) {
    if (!raw || typeof raw !== 'object')
        return null;
    const r = raw;
    if (typeof r.senderId !== 'string' || r.senderId.length === 0)
        return null;
    if (typeof r.role !== 'string' || !ROLES.includes(r.role))
        return null;
    if (typeof r.channel !== 'string' || !CHANNELS.includes(r.channel))
        return null;
    if (typeof r.startedAt !== 'string' || r.startedAt.length === 0)
        return null;
    if (typeof r.hidden !== 'boolean')
        return null;
    const url = r.url === null ? null : typeof r.url === 'string' ? r.url : undefined;
    if (url === undefined)
        return null;
    const permissionMode = r.permissionMode === null ? null : typeof r.permissionMode === 'string' ? r.permissionMode : undefined;
    if (permissionMode === undefined)
        return null;
    // Task 278 — `effectivePermissionMode` is optional on disk so pre-278
    // sidecars (no key) load with null. Missing key, JSON null, and
    // unparseable shape all collapse to null; the field is purely
    // observational and a missing one is the pre-existing behaviour.
    const effectivePermissionMode = r.effectivePermissionMode === undefined || r.effectivePermissionMode === null
        ? null
        : typeof r.effectivePermissionMode === 'string'
            ? r.effectivePermissionMode
            : null;
    const model = r.model === null ? null : typeof r.model === 'string' ? r.model : undefined;
    if (model === undefined)
        return null;
    const specialist = r.specialist === null ? null : typeof r.specialist === 'string' ? r.specialist : undefined;
    if (specialist === undefined)
        return null;
    // Task 451 — `scopeUnitToken` is optional on disk so pre-451 sidecars
    // (no key) load with null. Missing key, JSON null, and unparseable shape
    // all collapse to null; boot-seed treats null as "no reattach hint" and
    // falls through to the sessionId-direct path or orphan classification.
    const scopeUnitToken = r.scopeUnitToken === undefined || r.scopeUnitToken === null
        ? null
        : typeof r.scopeUnitToken === 'string'
            ? r.scopeUnitToken
            : null;
    // Task 550 — `bridgeIds` is optional on disk so pre-550 sidecars (no key)
    // load with `[]`. Non-string entries are dropped silently; the file may
    // have been hand-edited or pre-dates the strict-string contract.
    const bridgeIds = Array.isArray(r.bridgeIds)
        ? r.bridgeIds.filter((b) => typeof b === 'string' && b.length > 0)
        : [];
    return {
        senderId: r.senderId,
        role: r.role,
        channel: r.channel,
        url,
        startedAt: r.startedAt,
        permissionMode,
        effectivePermissionMode,
        model,
        hidden: r.hidden,
        specialist,
        scopeUnitToken,
        bridgeIds,
    };
}
function basenameShort(path) {
    const idx = path.lastIndexOf('/');
    const name = idx === -1 ? path : path.slice(idx + 1);
    const sid = name.replace(/\.meta\.json$/, '');
    return shortId(sid);
}
//# sourceMappingURL=session-sidecar.js.map