/**
 * src/autonomy/journal.ts
 *
 * Append-only ledger of every autonomous action Muassis takes. Drives the
 * "what did the agent actually do today / this week" report and provides
 * the audit trail for delegated authority.
 */
import { randomUUID, createHash } from 'node:crypto';
import { supabase } from '../utils/supabase.js';
import { createChildLogger } from '../utils/logger.js';
const log = createChildLogger('autonomy:journal');
/**
 * Compute the SHA-256 hash for a journal entry. Chains forward by
 * incorporating the previous entry's hash. A valid chain means:
 *   entry_hash[i] = sha256(prev_hash[i] || canonical(entry[i]))
 *
 * Any silent mutation of an old entry breaks every downstream hash.
 * The first-ever entry has prev_hash = '<genesis>'.
 */
function entryHash(prevHash, entry) {
    // Canonical form: keys in stable alphabetical order, no whitespace
    const canonicalKeys = ['id', 'tenant_id', 'kind', 'actor', 'action', 'target', 'outcome', 'reason', 'metadata', 'related_goal_id', 'cost_usd', 'created_at'];
    const obj = {};
    for (const k of canonicalKeys)
        obj[k] = entry[k] ?? null;
    return createHash('sha256').update(prevHash + '|' + JSON.stringify(obj)).digest('hex');
}
// (Round-4 fix) In-memory cache of last entry_hash per tenant. SQLite
// millisecond-precision timestamps can tie when writes happen fast,
// causing ORDER BY created_at DESC to be non-deterministic. The
// cache is authoritative inside this process — it's set after every
// successful insert (under the per-tenant write mutex), so reads are
// always consistent. On gateway restart we re-prime from the DB.
const LAST_HASH_CACHE = new Map();
async function getLastHash(tenantId) {
    const cached = LAST_HASH_CACHE.get(tenantId);
    if (cached !== undefined)
        return cached;
    // Cold start — read from DB. Order by created_at DESC then by id DESC
    // for tie-breaking determinism. Even if ties remain, this is the cold
    // path that runs once per tenant per process boot.
    const { data } = await supabase
        .from('journal_entries')
        .select('entry_hash')
        .eq('tenant_id', tenantId)
        .order('created_at', { ascending: false })
        .limit(1);
    const row = data?.[0];
    const hash = row?.entry_hash ?? '<genesis>';
    LAST_HASH_CACHE.set(tenantId, hash);
    return hash;
}
// (Round-4 fix) Per-tenant write mutex. Two concurrent record() calls used
// to both fetch the same prev_hash, both compute their own hash, both insert
// rows pointing at the same prev → chain forks → verifyChain fails. The
// mutex serializes the read-prev → compute-hash → insert sequence per
// tenant. Cross-tenant writes still parallelize.
const writeLocks = new Map();
async function withWriteLock(tenantId, fn) {
    const prev = writeLocks.get(tenantId) ?? Promise.resolve();
    let release = () => undefined;
    const lock = new Promise((resolve) => { release = resolve; });
    writeLocks.set(tenantId, prev.then(() => lock));
    await prev;
    try {
        return await fn();
    }
    finally {
        release();
        // Clean up the entry if it's the current one (so the map doesn't leak)
        if (writeLocks.get(tenantId) === prev.then(() => lock))
            writeLocks.delete(tenantId);
    }
}
export async function record(opts) {
    return withWriteLock(opts.tenantId, async () => {
        const id = randomUUID();
        const now = new Date().toISOString();
        const baseRow = {
            id,
            tenant_id: opts.tenantId,
            kind: opts.kind,
            actor: opts.actor ?? 'muassis',
            action: opts.action,
            target: opts.target ?? null,
            outcome: opts.outcome ?? 'success',
            reason: opts.reason ?? null,
            metadata: opts.metadata ?? {},
            related_goal_id: opts.relatedGoalId ?? null,
            cost_usd: opts.costUsd ?? 0,
            created_at: now,
        };
        // Hash chain (under tenant lock — see comment above).
        const prevHash = await getLastHash(opts.tenantId).catch(() => '<genesis>');
        const myHash = entryHash(prevHash, baseRow);
        const row = { ...baseRow, prev_hash: prevHash, entry_hash: myHash };
        const { error } = await supabase.from('journal_entries').insert(row);
        if (error) {
            log.warn({ error: error.message, kind: opts.kind }, 'journal record failed');
            // Don't update cache on insert failure
        }
        else {
            // (Round-4) Update the cache with our entry_hash so the next record()
            // reads the right tip even if SQLite's ORDER BY can't distinguish ties.
            LAST_HASH_CACHE.set(opts.tenantId, myHash);
            // (v3.0.5) Mirror EVERY journal entry into the KG so the graph view
            // shows the agent's complete working memory in real time. Tool
            // calls, escalations, lessons, subagent runs, catchups, research
            // findings — ALL become nodes connected to the entities they touch.
            // Fire-and-forget — never let a KG mirror failure block the journal.
            void mirrorJournalToKG(row).catch((err) => {
                log.debug({ err: err.message, kind: row.kind }, 'KG mirror failed (non-fatal)');
            });
        }
        return row;
    });
}
/**
 * (v3.0.5) Mirror a journal event into the knowledge graph.
 *
 * EVERY journal entry becomes a kg_entity. The kind/type mapping makes
 * them filterable in the Memory tab's graph view. Where possible, we
 * also create kg_relationships to connect this event to the entities
 * it touches (customer, tool, goal, etc.) — that's what gives the
 * graph its connection density.
 *
 * Mapping:
 *   journal.kind='tool'             → kg.kind='action',   type=tool_name
 *   journal.kind='catchup_digest'   → kg.kind='lesson',   type='Catchup'
 *   journal.kind='subagent_result'  → kg.kind='action',   type='Subagent'
 *   journal.kind='heartbeat'        → kg.kind='action',   type='Heartbeat'
 *   journal.kind='delivery_health'  → kg.kind='action',   type='DeliveryHealth'
 *   journal.kind='kg_extraction_failed' → skip (noise)
 *   journal.kind='escalation'       → kg.kind='decision', type='Escalation'
 *   journal.kind='research'         → kg.kind='research', type=topic
 *   journal.kind='lesson'           → kg.kind='lesson',   type=skill_or_topic
 *   anything else                   → kg.kind='action',   type=kind
 */
async function mirrorJournalToKG(entry) {
    // Skip pure-noise kinds — they'd flood the graph without adding info.
    if (entry.kind === 'kg_extraction_failed')
        return;
    const KIND_MAP = {
        tool: { kgKind: 'action', defaultType: 'ToolCall' },
        subagent_result: { kgKind: 'action', defaultType: 'Subagent' },
        heartbeat: { kgKind: 'action', defaultType: 'Heartbeat' },
        delivery_health: { kgKind: 'action', defaultType: 'DeliveryHealth' },
        catchup_digest: { kgKind: 'lesson', defaultType: 'Catchup' },
        escalation: { kgKind: 'decision', defaultType: 'Escalation' },
        research: { kgKind: 'research', defaultType: 'Research' },
        lesson: { kgKind: 'lesson', defaultType: 'Lesson' },
        skill_install: { kgKind: 'skill', defaultType: 'InstalledSkill' },
        goal_created: { kgKind: 'goal', defaultType: 'Goal' },
        goal_progress: { kgKind: 'goal', defaultType: 'GoalUpdate' },
    };
    const mapping = KIND_MAP[entry.kind] || { kgKind: 'action', defaultType: entry.kind };
    // Type: prefer the action name (= tool name for tool calls), fall back to default
    const type = entry.action || mapping.defaultType;
    // Name: human-readable label. Use action + target if available, else a
    // truncated reason, else the action alone.
    const name = (() => {
        if (entry.action && entry.target)
            return `${entry.action} → ${entry.target.slice(0, 60)}`;
        if (entry.action && entry.reason)
            return `${entry.action}: ${entry.reason.slice(0, 60)}`;
        if (entry.action)
            return entry.action;
        return `${entry.kind} · ${entry.created_at.slice(0, 16)}`;
    })();
    const properties = {
        journal_id: entry.id,
        outcome: entry.outcome,
        occurred_at: entry.created_at,
        ...(entry.reason ? { summary: entry.reason } : {}),
        ...(entry.metadata && Object.keys(entry.metadata).length > 0 ? { metadata: entry.metadata } : {}),
    };
    try {
        const { randomUUID } = await import('node:crypto');
        const nowIso = new Date().toISOString();
        const nameNorm = name.toLowerCase();
        // De-dup by (tenant, kind, name_normalized) — same event twice doesn't
        // create two nodes; instead bumps episode_count.
        const { data: existing } = await supabase
            .from('kg_entities')
            .select('id, episode_count, properties')
            .eq('tenant_id', entry.tenant_id)
            .eq('kind', mapping.kgKind)
            .eq('name_normalized', nameNorm)
            .maybeSingle();
        if (existing) {
            const oldProps = existing.properties ?? {};
            await supabase
                .from('kg_entities')
                .update({
                properties: { ...oldProps, ...properties, last_journal_id: entry.id, last_seen: nowIso },
                episode_count: (existing.episode_count ?? 0) + 1,
                updated_at: nowIso,
            })
                .eq('id', existing.id);
        }
        else {
            const newId = randomUUID();
            await supabase.from('kg_entities').insert({
                id: newId,
                tenant_id: entry.tenant_id,
                kind: mapping.kgKind,
                customer_id: null,
                type,
                name,
                name_normalized: nameNorm,
                properties: { ...properties, first_seen: nowIso, last_seen: nowIso },
                source_files: [],
                episode_count: 1,
                created_at: nowIso,
                updated_at: nowIso,
            });
            // Link to related_goal_id if present — gives the graph a real edge
            if (entry.related_goal_id) {
                try {
                    await supabase.from('kg_relationships').insert({
                        id: randomUUID(),
                        tenant_id: entry.tenant_id,
                        source_id: newId,
                        target_id: entry.related_goal_id,
                        type: 'related_to',
                        created_at: nowIso,
                    });
                }
                catch { /* relationship insert failure is non-fatal */ }
            }
        }
    }
    catch (err) {
        log.debug({ err: err.message, kind: entry.kind }, 'KG mirror exception');
    }
}
/**
 * Walk the chain from genesis and verify every hash. Returns the first
 * mismatch (if any) or null if intact. Used by the integrity audit endpoint.
 */
export async function verifyChain(tenantId) {
    const { data } = await supabase
        .from('journal_entries')
        .select('id, tenant_id, kind, actor, action, target, outcome, reason, metadata, related_goal_id, cost_usd, created_at, prev_hash, entry_hash')
        .eq('tenant_id', tenantId)
        .order('created_at', { ascending: true });
    const rows = (data ?? []);
    // (Round-4) WALK the chain by prev_hash links instead of trusting the
    // SQL ordering. Same-millisecond writes can tie on `created_at`, putting
    // rows in the wrong order in the array even though the chain is valid.
    // We build a hash → row map and follow the links from <genesis> forward.
    const byPrev = new Map();
    let chainable = 0;
    for (const r of rows) {
        if (r.prev_hash == null || r.entry_hash == null)
            continue;
        chainable++;
        if (byPrev.has(r.prev_hash)) {
            // Two rows pointing to the same prev — chain forked. Audit fail.
            return { ok: false, totalEntries: rows.length, brokenAt: r.id };
        }
        byPrev.set(r.prev_hash, r);
    }
    let expectedPrev = '<genesis>';
    let walked = 0;
    while (true) {
        const r = byPrev.get(expectedPrev);
        if (!r)
            break;
        const expectedHash = entryHash(r.prev_hash, r);
        if (expectedHash !== r.entry_hash)
            return { ok: false, totalEntries: rows.length, brokenAt: r.id };
        walked++;
        expectedPrev = r.entry_hash;
    }
    // If we walked fewer rows than there are chainable rows, the chain has
    // orphaned rows that aren't reachable from genesis.
    if (walked !== chainable) {
        return { ok: false, totalEntries: rows.length, brokenAt: 'orphaned-rows' };
    }
    return { ok: true, totalEntries: rows.length, brokenAt: null };
}
export async function list(opts) {
    let q = supabase
        .from('journal_entries')
        .select('*')
        .eq('tenant_id', opts.tenantId)
        .order('created_at', { ascending: false })
        .limit(opts.limit ?? 50);
    if (opts.since)
        q = q.gte('created_at', opts.since);
    if (opts.kind)
        q = q.eq('kind', opts.kind);
    if (opts.outcome)
        q = q.eq('outcome', opts.outcome);
    const { data, error } = await q;
    if (error) {
        log.warn({ error: error.message }, 'journal list failed');
        return [];
    }
    return (data ?? []);
}
export async function rollup(tenantId, since) {
    const entries = await list({ tenantId, since, limit: 500 });
    const result = {
        total: entries.length,
        byOutcome: { success: 0, failure: 0, pending: 0, escalated: 0, declined: 0 },
        byKind: {},
        costUsd: 0,
        topActions: [],
    };
    const actionCounts = new Map();
    for (const e of entries) {
        result.byOutcome[e.outcome] = (result.byOutcome[e.outcome] ?? 0) + 1;
        result.byKind[e.kind] = (result.byKind[e.kind] ?? 0) + 1;
        result.costUsd += e.cost_usd ?? 0;
        actionCounts.set(e.action, (actionCounts.get(e.action) ?? 0) + 1);
    }
    result.topActions = [...actionCounts.entries()]
        .map(([action, count]) => ({ action, count }))
        .sort((a, b) => b.count - a.count)
        .slice(0, 10);
    return result;
}
const RFC3339_DAY_MS = 24 * 3600 * 1000;
export async function rollupSince(tenantId, hours) {
    const since = new Date(Date.now() - hours * 3600 * 1000).toISOString();
    return rollup(tenantId, since);
}
export async function todayRollup(tenantId) {
    const start = new Date();
    start.setUTCHours(0, 0, 0, 0);
    return rollup(tenantId, start.toISOString());
}
export async function weekRollup(tenantId) {
    const start = new Date(Date.now() - 7 * RFC3339_DAY_MS).toISOString();
    return rollup(tenantId, start);
}
//# sourceMappingURL=journal.js.map