// Task 166 — structured observability for every memory MCP write-path tool.
//
// Single shared helper that emits one greppable line per write attempt to
// `server.log` via the loopback `/api/admin/log-ingest` route. Without this,
// the memory MCP is two-haystack, one-direction observable: success paths
// emit nothing, and failure paths only reach
// `~/.<brand>/logs/mcp-memory-stderr-<date>.log` as free-form strings the
// operator cannot grep by enumerated cause.
//
// Line shape, success:
//   [mcp:memory] <tool> account=<id> session=<convId> labels=<csv> name=<v|—> result=ok elementId=<id|—> ms=<n>
// Line shape, failure:
//   [mcp:memory] <tool> account=<id> session=<convId> labels=<csv> name=<v|—> result=error reason=<slug> detail=<≤120 chars> ms=<n>
//
// The helper is fire-and-forget on the await: POST failures fall back to
// stderr (`[mcp:memory] log-ingest-failed status=<http> err=<short>`) and
// the structured line is also written to stderr so the operator never loses
// the event even when the manager's HTTP route is unreachable. Helper
// never throws.
const TAG = "mcp:memory";
const LOOPBACK_HOST = "127.0.0.1";
const ENDPOINT_PATH = "/api/admin/log-ingest";
const MAX_DETAIL = 120;
/** Format the structured line that will be sent over the wire. */
export function formatWriteEventLine(ev) {
    const acct = ev.accountId ?? "—";
    const sess = ev.session ?? process.env.SESSION_NODE_ID ?? "—";
    const labels = ev.labels && ev.labels.length > 0 ? ev.labels.join(",") : "—";
    const name = ev.name && ev.name.length > 0 ? ev.name : "—";
    const head = `${ev.tool} account=${acct} session=${sess} labels=${labels} name=${name}`;
    if (ev.result === "ok") {
        return `${head} result=ok elementId=${ev.elementId ?? "—"} ms=${ev.durationMs}`;
    }
    const detail = truncateDetail(ev.detail);
    return `${head} result=error reason=${ev.reason} detail=${detail} ms=${ev.durationMs}`;
}
/** POST the formatted line to the loopback ingest route. Falls back to stderr. */
export async function emitWriteEvent(ev) {
    const line = formatWriteEventLine(ev);
    const port = process.env.PLATFORM_PORT;
    // If PLATFORM_PORT is unset, the MCP wasn't spawned by the session
    // manager (e.g. direct CLI invocation). Write the event to stderr so
    // the line still lands somewhere — mcp-memory-stderr-<date>.log catches it.
    if (!port) {
        process.stderr.write(`[${TAG}] ${line}\n`);
        return;
    }
    const url = `http://${LOOPBACK_HOST}:${port}${ENDPOINT_PATH}`;
    const body = JSON.stringify({
        tag: TAG,
        level: ev.result === "error" ? "error" : "info",
        line,
    });
    try {
        const res = await fetch(url, {
            method: "POST",
            headers: { "content-type": "application/json" },
            body,
        });
        if (!res.ok) {
            process.stderr.write(`[${TAG}] log-ingest-failed status=${res.status} err=non-2xx\n`);
            process.stderr.write(`[${TAG}] ${line}\n`);
        }
    }
    catch (err) {
        const msg = err instanceof Error ? err.message : String(err);
        const short = msg.slice(0, 80);
        process.stderr.write(`[${TAG}] log-ingest-failed status=0 err=${short}\n`);
        process.stderr.write(`[${TAG}] ${line}\n`);
    }
}
/**
 * Wrap a tool body so a structured event is emitted exactly once on every
 * exit path. The wrapper measures durationMs, classifies thrown errors via
 * `classifyReason`, and lets caller-supplied `extract` enrich the success
 * line with labels / name / elementId that are only known after the write.
 */
export async function withWriteEvent(base, body, extract) {
    const start = Date.now();
    try {
        const result = await body();
        const enriched = extract ? extract(result) : {};
        await emitWriteEvent({
            tool: base.tool,
            result: "ok",
            accountId: base.accountId,
            session: base.session,
            labels: enriched.labels ?? base.labels,
            name: enriched.name ?? base.name,
            elementId: enriched.elementId,
            durationMs: Date.now() - start,
        });
        return result;
    }
    catch (err) {
        await emitWriteEvent({
            tool: base.tool,
            result: "error",
            reason: classifyReason(err),
            detail: detailFromError(err),
            accountId: base.accountId,
            session: base.session,
            labels: base.labels,
            name: base.name,
            durationMs: Date.now() - start,
        });
        throw err;
    }
}
/**
 * Map a thrown value to one of the enumerated WriteEventReason slugs.
 * The match shapes are derived from the actual `throw new Error(...)`
 * messages in the write-path tools as of 2026-05-19 (see Task 166 §In scope 2).
 * Anything unmatched lands on `neo4j-driver-error`; never silently
 * fall through to a synthesized "unknown" slug.
 */
export function classifyReason(err) {
    if (!(err instanceof Error))
        return "neo4j-driver-error";
    const msg = err.message;
    const code = err.code;
    if (typeof code === "string" && code === "Neo.ClientError.Schema.ConstraintValidationFailed") {
        return "constraint-violation";
    }
    if (/^Node already exists/i.test(msg))
        return "conflicting-element-id";
    if (/account isolation violation/i.test(msg))
        return "account-isolation";
    if (/producedByTaskId .* did not resolve to a :Task/i.test(msg))
        return "producedby-task-not-found";
    // schema validation errors — schema-validator emits "Unknown label" or
    // "not Schema.org-conformant" inside the rethrown message.
    if (/Unknown label/i.test(msg))
        return "schema-unknown-label";
    if (/Schema\.org/i.test(msg) && /property/i.test(msg))
        return "schema-property-naming";
    // graph-write-gate "Write blocked" / "Update blocked" — checkGraphWriteGate
    if (/^(Write|Update) blocked/i.test(msg))
        return "gate-blocked";
    // graph-write target-node resolution failures
    if (/targetNodeId .* (does not resolve|did not resolve|not found)/i.test(msg)) {
        return "invalid-target-node-id";
    }
    // input-validation throws from tool entry points
    if (/is required/i.test(msg) ||
        /must be /i.test(msg) ||
        /at least one /i.test(msg) ||
        /no properties to update/i.test(msg) ||
        /all provided properties are restricted/i.test(msg) ||
        /not found or not owned/i.test(msg) ||
        /empty|missing/i.test(msg) && /accountId|scope|labels|properties/i.test(msg)) {
        return "invalid-input";
    }
    return "neo4j-driver-error";
}
/**
 * Pick a human-readable name for the structured log line out of a properties
 * bag. Follows the task-spec preference: `name → title → email`, returning
 * undefined when none are present so the line renders `name=—`.
 */
export function pickName(properties) {
    if (!properties)
        return undefined;
    for (const key of ["name", "title", "email"]) {
        const v = properties[key];
        if (typeof v === "string" && v.length > 0)
            return v;
    }
    return undefined;
}
function detailFromError(err) {
    if (err instanceof Error)
        return truncateDetail(err.message);
    return truncateDetail(String(err));
}
function truncateDetail(s) {
    const flat = s.replace(/\s+/g, " ").trim();
    if (flat.length <= MAX_DETAIL)
        return flat;
    return flat.slice(0, MAX_DETAIL - 1) + "…";
}
//# sourceMappingURL=log-ingest.js.map