"""Tracer: every event lands in JSONL and SQLite AS IT HAPPENS.

Files are the raw record; sssf.db is the queryable mirror the UI polls.
No push transport — the flow is always: agents -> sqlite -> web ui.
WAL mode so the UI can read while ADW processes write.
"""

from __future__ import annotations

import json
import socket
import sqlite3
import time
import uuid
from pathlib import Path

from .data_types import AgentConfig, EventRecord, GateReport, Phase
from .git_helper import derive_repo_name
from .run_slug import derive_slug_base, pick_unique_slug
from .utils import ensure_dir, new_id, now_iso

SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
  adw_id        TEXT PRIMARY KEY,
  adw_name      TEXT,                -- ADW script(s) run, e.g. "adw_plan + adw_build_test"
  repo          TEXT,                -- canonical target repository root for this run
  request       TEXT,
  status        TEXT,
  engineer      TEXT,
  host          TEXT,
  started_at    TEXT, ended_at TEXT,
  total_tokens  INTEGER DEFAULT 0, total_cost REAL DEFAULT 0,
  archived      INTEGER DEFAULT 0   -- review triage, set by the UI; never by a run
);
CREATE TABLE IF NOT EXISTS phases (
  phase_id      TEXT PRIMARY KEY,
  adw_id        TEXT REFERENCES sessions,
  seq           INTEGER,
  name TEXT, kind TEXT, owner TEXT, description TEXT,
  status        TEXT DEFAULT 'fail',
  attempt       INTEGER DEFAULT 0, retries INTEGER DEFAULT 0,
  error         TEXT,
  started_at    TEXT, ended_at TEXT
);
CREATE TABLE IF NOT EXISTS events (
  event_id      TEXT PRIMARY KEY,
  adw_id        TEXT REFERENCES sessions,
  phase_id      TEXT REFERENCES phases,
  parent_id     TEXT,
  type          TEXT,
  name          TEXT,
  payload_json  TEXT,
  tokens        INTEGER,
  started_at    TEXT, ended_at TEXT
);
CREATE TABLE IF NOT EXISTS envelopes (
  envelope_id   TEXT PRIMARY KEY,
  adw_id        TEXT REFERENCES sessions,
  phase_id      TEXT REFERENCES phases,
  agent         TEXT,
  output_type   TEXT,
  payload_json  TEXT,
  valid         INTEGER,
  attempt       INTEGER,
  created_at    TEXT
);
CREATE TABLE IF NOT EXISTS gate_results (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  adw_id        TEXT REFERENCES sessions,
  phase_id      TEXT REFERENCES phases,
  attempt       INTEGER,
  gate          TEXT,
  passed        INTEGER,
  violations_json TEXT,
  checks_json   TEXT,               -- [{item, ok, note}] — WHAT the gate verified
  created_at    TEXT
);
CREATE TABLE IF NOT EXISTS phase_diffs (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  adw_id        TEXT NOT NULL,
  phase_id      TEXT NOT NULL,
  attempt       INTEGER,
  files_json    TEXT,
  insertions    INTEGER,
  deletions     INTEGER,
  diff_text     TEXT,
  truncated     INTEGER,
  created_at    TEXT NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_phase_diffs_adw_phase ON phase_diffs(adw_id, phase_id);
CREATE TABLE IF NOT EXISTS processes (
  id            INTEGER PRIMARY KEY AUTOINCREMENT,
  adw_id        TEXT REFERENCES sessions,
  kind          TEXT,                -- 'adw' (the workflow process) | 'agent' (a coding-agent child)
  name          TEXT,                -- '' for the adw, the agent name for a child
  pid           INTEGER,
  start_ticks   INTEGER,
  command       TEXT,                -- what the pid was, so a recycled pid is not killed by mistake
  started_at    TEXT, ended_at TEXT  -- ended_at NULL = believed alive
);
CREATE TABLE IF NOT EXISTS agent_sessions (
  adw_id        TEXT REFERENCES sessions,
  agent         TEXT,
  coding_agent  TEXT, model TEXT, color TEXT,
  session_id    TEXT,
  context_tokens INTEGER,           -- window occupancy after the agent's last turn
  context_window INTEGER,           -- the model's ceiling; 0/NULL = unknown
  created_at    TEXT, last_used_at TEXT,
  PRIMARY KEY (adw_id, agent)
);
CREATE TABLE IF NOT EXISTS agent_attempts (
  attempt_id    TEXT PRIMARY KEY,
  adw_id        TEXT REFERENCES sessions,
  phase_id      TEXT REFERENCES phases,
  parent_id     TEXT,
  agent         TEXT,
  session_id    TEXT,
  command       TEXT,
  system_prompt TEXT,
  user_prompt   TEXT,
  host          TEXT,
  account       TEXT,
  returncode    INTEGER,
  signal        INTEGER,
  timed_out     INTEGER NOT NULL DEFAULT 0,
  stderr_path   TEXT,
  tokens        INTEGER,
  usage_json    TEXT,
  error         TEXT,
  started_at    TEXT NOT NULL,
  ended_at      TEXT
);
CREATE TABLE IF NOT EXISTS tool_calls (
  tool_call_id  TEXT PRIMARY KEY,
  attempt_id    TEXT NOT NULL,
  seq           INTEGER NOT NULL,
  tool_name     TEXT,
  args_json     TEXT,
  started_at    TEXT,
  ended_at      TEXT,
  duration_ms   INTEGER,
  ok            INTEGER,
  result_excerpt TEXT
);
CREATE INDEX IF NOT EXISTS idx_tool_calls_attempt_seq ON tool_calls(attempt_id, seq);
CREATE TABLE IF NOT EXISTS decisions (
  decision_id  TEXT PRIMARY KEY,
  adw_id       TEXT NOT NULL,
  phase        TEXT,
  question     TEXT NOT NULL,
  options      TEXT NOT NULL DEFAULT '[]',
  free_text    INTEGER NOT NULL DEFAULT 0,
  context      TEXT NOT NULL DEFAULT '',
  status       TEXT NOT NULL DEFAULT 'pending',
  answer_value TEXT,
  answer_text  TEXT,
  answered_by  TEXT,
  created_at   TEXT NOT NULL,
  answered_at  TEXT
);
CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status);
"""

# Columns added after a schema shipped. CREATE TABLE IF NOT EXISTS never
# revisits an existing table, so additive changes need an explicit ALTER.
MIGRATIONS = [("agent_sessions", "color", "TEXT"),
              ("gate_results", "checks_json", "TEXT"),
              ("sessions", "adw_name", "TEXT"),
              ("agent_sessions", "context_tokens", "INTEGER"),
              ("agent_sessions", "context_window", "INTEGER"),
              ("sessions", "archived", "INTEGER DEFAULT 0"),
              ("sessions", "repo", "TEXT"),
              ("agent_attempts", "host", "TEXT"),
              ("agent_attempts", "account", "TEXT"),
              ("agent_attempts", "model", "TEXT"),
              ("agent_attempts", "system_prompt", "TEXT"),
              ("agent_attempts", "user_prompt", "TEXT"),
              ("sessions", "host", "TEXT"),
              ("processes", "start_ticks", "INTEGER"),
              ("agent_attempts", "timeout_kind", "TEXT"),
              ("sessions", "run_slug", "TEXT"),
              ("sessions", "repo_name", "TEXT"),
              ("sessions", "preset", "TEXT")]

RUN_SLUG_UNIQUE_INDEX = (
    "CREATE UNIQUE INDEX IF NOT EXISTS idx_sessions_run_slug"
    " ON sessions(run_slug) WHERE run_slug IS NOT NULL"
)
RUN_SLUG_INDEX_NAME = "idx_sessions_run_slug"


def verify_run_slug_index(conn: sqlite3.Connection) -> None:
    """Fail closed unless idx_sessions_run_slug is unique and partial on run_slug."""
    row = conn.execute(
        "SELECT name, [unique], partial FROM pragma_index_list('sessions')"
        " WHERE name=?",
        (RUN_SLUG_INDEX_NAME,),
    ).fetchone()
    if row is None:
        raise RuntimeError(f"{RUN_SLUG_INDEX_NAME} missing")
    _name, is_unique, is_partial = row
    if not is_unique:
        raise RuntimeError(f"{RUN_SLUG_INDEX_NAME} is not unique")
    if not is_partial:
        raise RuntimeError(f"{RUN_SLUG_INDEX_NAME} is not partial")
    columns = [
        info[2]
        for info in conn.execute(
            f"PRAGMA index_info({RUN_SLUG_INDEX_NAME})",
        ).fetchall()
    ]
    if columns != ["run_slug"]:
        raise RuntimeError(
            f"{RUN_SLUG_INDEX_NAME} indexes {columns!r}, expected ['run_slug']",
        )
    sql_row = conn.execute(
        "SELECT sql FROM sqlite_master WHERE type='index' AND name=?",
        (RUN_SLUG_INDEX_NAME,),
    ).fetchone()
    sql = (sql_row[0] if sql_row else "").lower()
    if "run_slug is not null" not in sql:
        raise RuntimeError(
            f"{RUN_SLUG_INDEX_NAME} missing run_slug IS NOT NULL predicate",
        )


def _ensure_run_slug_unique_index(conn: sqlite3.Connection) -> None:
    existing = conn.execute(
        "SELECT 1 FROM pragma_index_list('sessions') WHERE name=?",
        (RUN_SLUG_INDEX_NAME,),
    ).fetchone()
    if existing is not None:
        verify_run_slug_index(conn)
        return

    deadline = time.monotonic() + 5.0
    while True:
        try:
            conn.execute(RUN_SLUG_UNIQUE_INDEX)
            break
        except sqlite3.OperationalError as exc:
            if "database is locked" not in str(exc).lower():
                raise
            if time.monotonic() >= deadline:
                raise
            time.sleep(0.001)
    verify_run_slug_index(conn)

TOOL_RESULT_EXCERPT_CHARS = 2000
TOOL_RESULT_TRUNCATION_SUFFIX = "\n… [truncated]"

DECISIONS_SCHEMA = """
CREATE TABLE IF NOT EXISTS decisions (
  decision_id  TEXT PRIMARY KEY,
  adw_id       TEXT NOT NULL,
  phase        TEXT,
  question     TEXT NOT NULL,
  options      TEXT NOT NULL DEFAULT '[]',
  free_text    INTEGER NOT NULL DEFAULT 0,
  context      TEXT NOT NULL DEFAULT '',
  status       TEXT NOT NULL DEFAULT 'pending',
  answer_value TEXT,
  answer_text  TEXT,
  answered_by  TEXT,
  created_at   TEXT NOT NULL,
  answered_at  TEXT
);
CREATE INDEX IF NOT EXISTS idx_decisions_status ON decisions(status);
"""


def _ensure_wal_mode(conn: sqlite3.Connection) -> None:
    """Switch to WAL; busy_timeout must already be set on ``conn``.

    First-time WAL activation takes an exclusive lock. Concurrent Tracer
    constructors on a legacy delete-mode file can still raise SQLITE_BUSY on
    this pragma even with busy_timeout, so retry only that locked case until
    the journal is wal or the 5s budget expires.
    """
    deadline = time.monotonic() + 5.0
    while True:
        try:
            mode = conn.execute("PRAGMA journal_mode=WAL;").fetchone()[0]
        except sqlite3.OperationalError as exc:
            if "database is locked" not in str(exc).lower():
                raise
        else:
            if mode.lower() == "wal":
                return
        if time.monotonic() >= deadline:
            raise sqlite3.OperationalError("database is locked")
        time.sleep(0.001)


def connect_db(db_path: str | Path) -> sqlite3.Connection:
    """Open SQLite with the observability WAL pragmas on every connection."""
    conn = sqlite3.connect(str(db_path), isolation_level=None, timeout=5.0)
    conn.execute("PRAGMA busy_timeout=5000;")
    _ensure_wal_mode(conn)
    conn.execute("PRAGMA synchronous=NORMAL;")
    return conn


class Tracer:
    def __init__(self, db_path: str | Path, events_jsonl: str | Path):
        ensure_dir(Path(db_path).parent)
        self.db_path = str(db_path)
        self.events_jsonl = Path(events_jsonl)
        ensure_dir(self.events_jsonl.parent)
        self.conn = connect_db(self.db_path)
        self.conn.executescript(SCHEMA)
        self._migrate()

    def _migrate(self) -> None:
        """Additive column migrations, so a db from an older SSSF still opens."""
        for table, column, decl in MIGRATIONS:
            columns = {row[1] for row in self.conn.execute(f"PRAGMA table_info({table})")}
            if column not in columns:
                try:
                    self.conn.execute(f"ALTER TABLE {table} ADD COLUMN {column} {decl}")
                except sqlite3.OperationalError as exc:
                    # Two Tracers can open the same legacy db at once; the loser
                    # of the race hits "duplicate column name" after the winner
                    # already applied this migration.
                    if "duplicate column name" not in str(exc).lower():
                        raise
        self._migrate_decisions_table()
        self._migrate_run_slug_index()

    def _migrate_run_slug_index(self) -> None:
        if "run_slug" not in {
            row[1] for row in self.conn.execute("PRAGMA table_info(sessions)")
        }:
            return
        _ensure_run_slug_unique_index(self.conn)

    def _migrate_decisions_table(self) -> None:
        """Ensure the decisions table exists on dbs opened before it shipped."""
        try:
            self.conn.executescript(DECISIONS_SCHEMA)
        except sqlite3.OperationalError as exc:
            # Concurrent Tracer opens can race on first CREATE; IF NOT EXISTS
            # makes the winner/loser both safe except for transient locks.
            if "database is locked" not in str(exc).lower():
                raise

    def _decision_row_to_dict(self, row: tuple) -> dict:
        return {
            "decision_id": row[0],
            "adw_id": row[1],
            "phase": row[2],
            "question": row[3],
            "options": json.loads(row[4]),
            "free_text": bool(row[5]),
            "context": row[6],
            "status": row[7],
            "answer_value": row[8],
            "answer_text": row[9],
            "answered_by": row[10],
            "created_at": row[11],
            "answered_at": row[12],
        }

    _DECISION_COLS = (
        "decision_id, adw_id, phase, question, options, free_text, context,"
        " status, answer_value, answer_text, answered_by, created_at, answered_at"
    )

    # ── decisions (human-in-the-loop) ───────────────────────────────────────
    def decision_request(
        self,
        adw_id: str,
        question: str,
        options: list[dict],
        free_text: bool = False,
        context: str = "",
        phase: str | None = None,
    ) -> str:
        if not free_text and not options:
            raise ValueError("options required when free_text is False")
        decision_id = uuid.uuid4().hex
        ts = now_iso()
        self.conn.execute(
            "INSERT INTO decisions (decision_id, adw_id, phase, question, options,"
            " free_text, context, status, created_at) VALUES (?,?,?,?,?,?,?,?,?)",
            (decision_id, adw_id, phase, question, json.dumps(options),
             int(free_text), context, "pending", ts),
        )
        self.event(EventRecord(
            adw_id=adw_id,
            type="decision_requested",
            name="decision",
            payload={"decision_id": decision_id, "question": question},
        ))
        return decision_id

    def decision_get(self, decision_id: str) -> dict | None:
        row = self.conn.execute(
            f"SELECT {self._DECISION_COLS} FROM decisions WHERE decision_id=?",
            (decision_id,),
        ).fetchone()
        if row is None:
            return None
        return self._decision_row_to_dict(row)

    def decisions_pending(self, adw_id: str | None = None) -> list[dict]:
        if adw_id is None:
            rows = self.conn.execute(
                f"SELECT {self._DECISION_COLS} FROM decisions"
                " WHERE status='pending' ORDER BY created_at",
            ).fetchall()
        else:
            rows = self.conn.execute(
                f"SELECT {self._DECISION_COLS} FROM decisions"
                " WHERE status='pending' AND adw_id=? ORDER BY created_at",
                (adw_id,),
            ).fetchall()
        return [self._decision_row_to_dict(row) for row in rows]

    def decision_answer(
        self,
        decision_id: str,
        value: str | None = None,
        text: str | None = None,
        answered_by: str = "",
    ) -> bool:
        row = self.conn.execute(
            "SELECT options, free_text, status FROM decisions WHERE decision_id=?",
            (decision_id,),
        ).fetchone()
        if row is None:
            return False
        options_json, free_text_int, status = row
        if status != "pending":
            return False

        options: list[dict] = json.loads(options_json)
        free_text = bool(free_text_int)

        if value is None and text is None:
            raise ValueError("answer requires value or text")

        answer_value = value
        answer_text = text
        if value is not None and text is not None:
            answer_text = text if free_text else None

        if answer_text is not None and not free_text:
            raise ValueError("text answer not allowed when free_text is False")

        if answer_value is not None and options:
            valid = {opt["value"] for opt in options}
            if answer_value not in valid:
                raise ValueError(f"invalid choice: {answer_value!r}")

        if answer_value is None:
            if not free_text:
                raise ValueError("value required when free_text is False")
            if not answer_text:
                raise ValueError("text required for free_text decision")

        ts = now_iso()
        cur = self.conn.execute(
            "UPDATE decisions SET status='answered', answer_value=?, answer_text=?,"
            " answered_by=?, answered_at=? WHERE decision_id=? AND status='pending'",
            (answer_value, answer_text, answered_by, ts, decision_id),
        )
        if cur.rowcount == 0:
            return False

        answered = self.decision_get(decision_id)
        assert answered is not None
        self.event(EventRecord(
            adw_id=answered["adw_id"],
            type="decision_answered",
            name="decision",
            payload={
                "decision_id": decision_id,
                "answer_value": answer_value,
                "answer_text": answer_text,
            },
        ))
        return True

    def decisions_cancel(self, adw_id: str) -> int:
        rows = self.conn.execute(
            "SELECT decision_id, question FROM decisions"
            " WHERE adw_id=? AND status='pending'",
            (adw_id,),
        ).fetchall()
        if not rows:
            return 0

        ts = now_iso()
        self.conn.execute(
            "UPDATE decisions SET status='canceled' WHERE adw_id=? AND status='pending'",
            (adw_id,),
        )
        for decision_id, question in rows:
            self.event(EventRecord(
                adw_id=adw_id,
                type="decision_canceled",
                name="decision",
                payload={"decision_id": decision_id, "question": question},
            ))
        return len(rows)

    # ── events ──────────────────────────────────────────────────────────────
    def event(self, record: EventRecord) -> str:
        event_id = f"evt_{new_id(12)}"
        ts = now_iso()
        line = {"event_id": event_id, "ts": ts, **record.model_dump()}
        with self.events_jsonl.open("a") as f:
            f.write(json.dumps(line) + "\n")
        if (record.type == "tool_call_start"
                and record.payload.get("tool_call_id")
                and record.payload.get("attempt_id")):
            self.tool_call_start(
                str(record.payload.get("tool_call_id") or ""),
                str(record.payload.get("attempt_id") or ""),
                int(record.payload.get("seq") or 0),
                record.payload.get("tool"), record.payload.get("args"),
                record.started_at or ts,
            )
        elif (record.type == "tool_call"
              and record.payload.get("tool_call_id")
              and record.payload.get("attempt_id")):
            self.tool_call_finish(
                str(record.payload.get("tool_call_id") or ""),
                ended_at=record.ended_at, duration_ms=record.payload.get("duration_ms"),
                ok=record.payload.get("ok"), result=record.payload.get("result_snippet"),
            )
        elif record.type == "agent_attempt_end":
            attempt = record.payload.get("attempt", {}) or {}
            if attempt.get("attemptId"):
                self.conn.execute(
                    "UPDATE agent_attempts SET timeout_kind=? WHERE attempt_id=?",
                    (attempt.get("timeoutKind"), attempt["attemptId"]),
                )
        self.conn.execute(
            "INSERT INTO events (event_id, adw_id, phase_id, parent_id, type, name,"
            " payload_json, tokens, started_at, ended_at) VALUES (?,?,?,?,?,?,?,?,?,?)",
            (event_id, record.adw_id, record.phase_id, record.parent_id, record.type,
             record.name, json.dumps(record.payload), record.tokens,
             record.started_at or ts, record.ended_at),
        )
        return event_id

    # ── sessions ────────────────────────────────────────────────────────────
    def session_start(self, adw_id: str, engineer: str, adw_name: str | None = None,
                      repo: str | Path | None = None, preset: str | None = None,
                      slug_hint: str | None = None,
                      slug_hint_is_path: bool = False,
                      adw_script_stem: str | None = None) -> None:
        repo_path = str(Path(repo).resolve()) if repo is not None else None
        repo_name = derive_repo_name(repo_path) if repo_path is not None else None
        host = socket.gethostname()
        ts = now_iso()
        self.conn.execute("BEGIN IMMEDIATE")
        try:
            existing = self.conn.execute(
                "SELECT run_slug FROM sessions WHERE adw_id = ?",
                (adw_id,),
            ).fetchone()
            if existing is not None:
                self.conn.execute(
                    "UPDATE sessions SET status='running',"
                    " repo=COALESCE(repo, ?),"
                    " host=COALESCE(host, ?),"
                    " repo_name=COALESCE(repo_name, ?),"
                    " preset=COALESCE(preset, ?)"
                    " WHERE adw_id=?",
                    (repo_path, host, repo_name, preset, adw_id),
                )
            else:
                base = derive_slug_base(
                    slug_hint, adw_script_stem, adw_id, hint_is_path=slug_hint_is_path,
                )
                run_slug = pick_unique_slug(self.conn, base)
                self.conn.execute(
                    "INSERT INTO sessions"
                    " (adw_id, status, engineer, started_at, repo, host, run_slug,"
                    " repo_name, preset)"
                    " VALUES (?,?,?,?,?,?,?,?,?)",
                    (adw_id, "running", engineer, ts, repo_path, host, run_slug,
                     repo_name, preset),
                )
            self.conn.execute("COMMIT")
        except Exception:
            self.conn.execute("ROLLBACK")
            raise
        if not adw_name:
            return
        # A joined session chains ADWs — record each distinct one, in run order.
        row = self.conn.execute("SELECT adw_name FROM sessions WHERE adw_id=?",
                                (adw_id,)).fetchone()
        names = row[0].split(" + ") if row and row[0] else []
        if adw_name not in names:
            names.append(adw_name)
            self.conn.execute("UPDATE sessions SET adw_name=? WHERE adw_id=?",
                              (" + ".join(names), adw_id))

    def session_request(self, adw_id: str, request: str) -> None:
        self.conn.execute("UPDATE sessions SET request=? WHERE adw_id=?",
                          (request[:500], adw_id))

    def session_finish(self, adw_id: str, ok: bool) -> None:
        self.conn.execute(
            "UPDATE sessions SET status=?, ended_at=? WHERE adw_id=?",
            ("success" if ok else "fail", now_iso(), adw_id),
        )
        self.processes_end_all(adw_id)   # nothing of this run is alive any more

    def session_add_usage(self, adw_id: str, tokens: int, cost: float) -> None:
        self.conn.execute(
            "UPDATE sessions SET total_tokens=total_tokens+?, total_cost=total_cost+? WHERE adw_id=?",
            (tokens, cost, adw_id),
        )

    # ── processes (adw_id → pid, so a hung run can be found and killed) ─────
    def process_start(self, adw_id: str, kind: str, name: str, pid: int,
                      command: str, *, start_ticks: int | None = None) -> None:
        """Record a live process for this run.

        A coding agent that hangs produces no events at all, which is exactly
        when you need its pid — and `ps` cannot tell you which adw_id it
        belongs to. Writing it here makes the trace the answer to "what is this
        run running, and how do I stop it".
        """
        self.conn.execute(
            "INSERT INTO processes (adw_id, kind, name, pid, start_ticks, command, started_at)"
            " VALUES (?,?,?,?,?,?,?)",
            (adw_id, kind, name, pid, start_ticks, command, now_iso()),
        )

    def process_end(self, adw_id: str, pid: int) -> None:
        """Mark the newest live row for this pid as finished."""
        self.conn.execute(
            "UPDATE processes SET ended_at=? WHERE id = ("
            "  SELECT id FROM processes WHERE adw_id=? AND pid=? AND ended_at IS NULL"
            "  ORDER BY id DESC LIMIT 1)",
            (now_iso(), adw_id, pid),
        )

    def processes_end_all(self, adw_id: str) -> None:
        """Close out every live row for a run — called when the session ends."""
        self.conn.execute(
            "UPDATE processes SET ended_at=? WHERE adw_id=? AND ended_at IS NULL",
            (now_iso(), adw_id),
        )

    # ── agent attempts (one row per subprocess invocation) ─────────────────
    def agent_attempt_start(self, adw_id: str, phase_id: str, agent: str,
                            session_id: str, command: str,
                            *, host: str, account: str | None, provider: str | None = None, model: str,
                            system_prompt: str | None = None,
                            user_prompt: str | None = None,
                            parent_id: str = "") -> str:
        """Create durable evidence before an agent child is spawned."""
        attempt_id = f"att_{new_id(12)}"
        self.conn.execute(
            "INSERT INTO agent_attempts (attempt_id,adw_id,phase_id,parent_id,agent,"
            "session_id,command,system_prompt,user_prompt,host,account,model,started_at)"
            " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
            (attempt_id, adw_id, phase_id, parent_id, agent, session_id,
             command, system_prompt, user_prompt, host, account, model, now_iso()),
        )
        return attempt_id

    def agent_attempt_finish(self, attempt_id: str, *, returncode: int | None,
                             signal: int | None, timed_out: bool,
                             stderr_path: str | None, tokens: int | None,
                             usage: dict | None, error: str | None = None,
                             timeout_kind: str | None = None,
                             provider_failure: dict | None = None) -> None:
        """Finalize an attempt without requiring any artifact to exist."""
        usage_json = None
        if usage is not None or provider_failure is not None:
            payload = dict(usage or {})
            if provider_failure is not None:
                payload["provider_failure"] = provider_failure
            usage_json = json.dumps(payload)
        self.conn.execute(
            "UPDATE agent_attempts SET returncode=?,signal=?,timed_out=?,timeout_kind=?,"
            "stderr_path=?,tokens=?,usage_json=?,error=?,ended_at=? WHERE attempt_id=?",
            (returncode, signal, int(timed_out), timeout_kind, stderr_path, tokens,
             usage_json, error, now_iso(),
             attempt_id),
        )

    # ── tool calls (one row per invocation, committed while the child runs) ─
    def tool_call_start(self, tool_call_id: str, attempt_id: str, seq: int,
                        tool_name: str | None, args: dict | None,
                        started_at: str | None = None) -> None:
        self.conn.execute(
            "INSERT INTO tool_calls (tool_call_id,attempt_id,seq,tool_name,args_json,started_at)"
            " VALUES (?,?,?,?,?,?)",
            (tool_call_id, attempt_id, seq, tool_name,
             json.dumps(args) if args is not None else None, started_at or now_iso()),
        )

    def tool_call_finish(self, tool_call_id: str, *, ended_at: str | None,
                         duration_ms: int | None, ok: bool | None,
                         result: str | None) -> None:
        excerpt = result
        if excerpt is not None and len(excerpt) > TOOL_RESULT_EXCERPT_CHARS:
            keep = TOOL_RESULT_EXCERPT_CHARS - len(TOOL_RESULT_TRUNCATION_SUFFIX)
            excerpt = excerpt[:keep].rstrip() + TOOL_RESULT_TRUNCATION_SUFFIX
        self.conn.execute(
            "UPDATE tool_calls SET ended_at=?,duration_ms=?,ok=?,result_excerpt=?"
            " WHERE tool_call_id=?",
            (ended_at or now_iso(), duration_ms,
             None if ok is None else int(ok), excerpt, tool_call_id),
        )

    # ── phases ──────────────────────────────────────────────────────────────
    def max_phase_seq(self, adw_id: str) -> int:
        """Highest seq already recorded for this session; 0 when it is new.

        A joined run continues the sequence instead of restarting at 1 — which
        would collide with the first run's phases on both `seq` (breaking
        ordering) and `phase_id` (silently overwriting a row through the
        phase_upsert conflict clause).
        """
        row = self.conn.execute("SELECT MAX(seq) FROM phases WHERE adw_id = ?",
                                (adw_id,)).fetchone()
        return row[0] if row and row[0] is not None else 0

    def phase_upsert(self, phase: Phase) -> None:
        p = phase.params
        self.conn.execute(
            "INSERT INTO phases (phase_id, adw_id, seq, name, kind, owner, description,"
            " status, attempt, retries, error, started_at, ended_at)"
            " VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)"
            " ON CONFLICT(phase_id) DO UPDATE SET status=excluded.status,"
            " attempt=excluded.attempt, error=excluded.error, ended_at=excluded.ended_at",
            (phase.phase_id, phase.adw_id, phase.seq, p.name, p.kind, p.owner,
             p.description, phase.status, phase.attempt, p.retries, phase.error,
             phase.started_at, phase.ended_at),
        )

    # ── envelopes / gates / agent sessions ──────────────────────────────────
    def envelope_row(self, phase: Phase, agent: str, output_type: str,
                     payload_json: str, valid: bool, attempt: int) -> None:
        self.conn.execute(
            "INSERT INTO envelopes (envelope_id, adw_id, phase_id, agent, output_type,"
            " payload_json, valid, attempt, created_at) VALUES (?,?,?,?,?,?,?,?,?)",
            (f"env_{new_id(12)}", phase.adw_id, phase.phase_id, agent, output_type,
             payload_json, int(valid), attempt, now_iso()),
        )

    def gate_row(self, phase: Phase, gate: str, report: GateReport, attempt: int,
                 *, violations: list[str] | None = None,
                 checks: list[dict] | None = None) -> None:
        """The report carries both the verdict and the evidence behind it."""
        self.conn.execute(
            "INSERT INTO gate_results (adw_id, phase_id, attempt, gate, passed,"
            " violations_json, checks_json, created_at) VALUES (?,?,?,?,?,?,?,?)",
            (phase.adw_id, phase.phase_id, attempt, gate, int(report.passed),
             json.dumps(report.violations if violations is None else violations),
             json.dumps([c.model_dump() for c in report.checks] if checks is None else checks),
             now_iso()),
        )

    def phase_diff_row(self, phase: Phase, diff: dict) -> None:
        self.conn.execute(
            "INSERT INTO phase_diffs (adw_id,phase_id,attempt,files_json,insertions,"
            "deletions,diff_text,truncated,created_at) VALUES (?,?,?,?,?,?,?,?,?)",
            (phase.adw_id, phase.phase_id, phase.attempt, json.dumps(diff["files"]),
             diff["insertions"], diff["deletions"], diff["diff_text"],
             int(diff["truncated"]), now_iso()),
        )

    def agent_session_row(self, adw_id: str, agent: AgentConfig, session_id: str,
                          context_tokens: int = 0, context_window: int = 0) -> None:
        """The agent's config row is the source of truth for its label and color.

        Context is carried here rather than derived from events because the lane
        wants one number per agent — the latest — and a session that runs the
        same agent twice overwrites it, exactly like model and session_id.
        """
        ts = now_iso()
        self.conn.execute(
            "INSERT INTO agent_sessions (adw_id, agent, coding_agent, model, color,"
            " session_id, context_tokens, context_window, created_at, last_used_at)"
            " VALUES (?,?,?,?,?,?,?,?,?,?)"
            " ON CONFLICT(adw_id, agent) DO UPDATE SET model=excluded.model,"
            " color=excluded.color, session_id=excluded.session_id,"
            " context_tokens=excluded.context_tokens,"
            " context_window=excluded.context_window,"
            " last_used_at=excluded.last_used_at",
            (adw_id, agent.name, agent.coding_agent, agent.model, agent.color,
             session_id, context_tokens, context_window, ts, ts),
        )
