Build-order step 4, sequenced before the LLM client (step 3) because the client interface consumes the canonical log. - canonical.py: typed events for the full A10 table (user_message through compaction); canonical tool-call IDs minted locally, provider IDs never a join key; strict payload validation on load - StateStore: canonical_events table via schema v2 — the first real migration, exercised in a test against a hand-built v1 database — with append_event/read_events on the same fsync-per-commit path - render.py: request bodies rendered from the log at call time; first adapter targets the OpenAI-compatible shape of LiteLLM's normalized route; compaction events replace their id range at render time while staying in the durable log - Crash integration test now also verifies transcript appends survive process death Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
305 lines
9.5 KiB
Python
305 lines
9.5 KiB
Python
"""Durable state store (spec A2).
|
|
|
|
SQLite in WAL mode with ``synchronous=FULL``: every commit is fsynced, so a
|
|
power cut mid-write can never produce torn state. Every state transition and
|
|
intent-log write is a single immediately-committed statement — in-memory state
|
|
that hasn't hit disk is assumed lost.
|
|
|
|
The intent log records a unit of work *before* it starts, so a restart knows
|
|
what was in flight (see ``nisse_dev.reconcile`` for how pending intents are
|
|
resolved against external truth on boot).
|
|
"""
|
|
|
|
import json
|
|
import sqlite3
|
|
from collections.abc import Callable, Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import UTC, datetime
|
|
from pathlib import Path
|
|
from typing import Any, Self
|
|
|
|
from nisse_dev.canonical import (
|
|
CanonicalEvent,
|
|
StoredEvent,
|
|
event_type,
|
|
from_payload,
|
|
to_payload,
|
|
)
|
|
from nisse_dev.oplog import OpLog
|
|
|
|
CURRENT_SCHEMA_VERSION = 2
|
|
|
|
Migration = Callable[[sqlite3.Connection], None]
|
|
|
|
_CANONICAL_EVENTS_DDL = """
|
|
CREATE TABLE canonical_events (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
ts TEXT NOT NULL,
|
|
type TEXT NOT NULL,
|
|
payload TEXT NOT NULL
|
|
)
|
|
"""
|
|
|
|
|
|
def _migrate_v1_add_canonical_events(conn: sqlite3.Connection) -> None:
|
|
conn.execute(_CANONICAL_EVENTS_DDL)
|
|
|
|
|
|
# Maps version N to the migration that takes the schema from N to N+1.
|
|
MIGRATIONS: Mapping[int, Migration] = {1: _migrate_v1_add_canonical_events}
|
|
|
|
|
|
class StateSchemaError(RuntimeError):
|
|
pass
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class AgentState:
|
|
state: str
|
|
project: str | None
|
|
data: dict[str, Any]
|
|
updated_at: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class Intent:
|
|
id: int
|
|
kind: str
|
|
payload: dict[str, Any]
|
|
status: str
|
|
created_at: str
|
|
resolved_at: str | None
|
|
|
|
|
|
_SCHEMA = [
|
|
"CREATE TABLE meta (key TEXT PRIMARY KEY, value TEXT NOT NULL)",
|
|
"""
|
|
CREATE TABLE agent_state (
|
|
id INTEGER PRIMARY KEY CHECK (id = 1),
|
|
state TEXT NOT NULL,
|
|
project TEXT,
|
|
data TEXT NOT NULL DEFAULT '{}',
|
|
updated_at TEXT NOT NULL
|
|
)
|
|
""",
|
|
"""
|
|
CREATE TABLE intent_log (
|
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
kind TEXT NOT NULL,
|
|
payload TEXT NOT NULL,
|
|
status TEXT NOT NULL DEFAULT 'pending',
|
|
created_at TEXT NOT NULL,
|
|
resolved_at TEXT
|
|
)
|
|
""",
|
|
_CANONICAL_EVENTS_DDL,
|
|
]
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(UTC).isoformat()
|
|
|
|
|
|
class StateStore:
|
|
def __init__(
|
|
self,
|
|
path: str | Path,
|
|
*,
|
|
schema_version: int = CURRENT_SCHEMA_VERSION,
|
|
migrations: Mapping[int, Migration] = MIGRATIONS,
|
|
oplog: OpLog | None = None,
|
|
) -> None:
|
|
# Log emission is owned by the store (A9): the same code path that
|
|
# performs a durable write emits its log line, right after commit.
|
|
self.oplog = oplog if oplog is not None else OpLog("nisse-dev")
|
|
self._conn = sqlite3.connect(path, isolation_level=None)
|
|
self._conn.row_factory = sqlite3.Row
|
|
self._conn.execute("PRAGMA journal_mode=WAL")
|
|
self._conn.execute("PRAGMA synchronous=FULL")
|
|
self._conn.execute("PRAGMA busy_timeout=5000")
|
|
self._target_version = schema_version
|
|
self._init_or_migrate(migrations)
|
|
|
|
def _init_or_migrate(self, migrations: Mapping[int, Migration]) -> None:
|
|
fresh = (
|
|
self._conn.execute(
|
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='meta'"
|
|
).fetchone()
|
|
is None
|
|
)
|
|
if fresh:
|
|
self._in_transaction(self._create_schema)
|
|
return
|
|
stored = self.schema_version
|
|
if stored > self._target_version:
|
|
raise StateSchemaError(
|
|
f"state schema version {stored} is newer than this code "
|
|
f"supports ({self._target_version}); refusing to open"
|
|
)
|
|
for version in range(stored, self._target_version):
|
|
migrate = migrations.get(version)
|
|
if migrate is None:
|
|
raise StateSchemaError(
|
|
f"no migration registered from schema version {version}"
|
|
)
|
|
self._in_transaction(lambda m=migrate, v=version: self._apply(m, v))
|
|
self.oplog.emit(
|
|
"schema.migrate",
|
|
cause="startup",
|
|
from_version=version,
|
|
to_version=version + 1,
|
|
)
|
|
|
|
def _create_schema(self) -> None:
|
|
for statement in _SCHEMA:
|
|
self._conn.execute(statement)
|
|
self._conn.execute(
|
|
"INSERT INTO meta (key, value) VALUES ('schema_version', ?)",
|
|
(str(self._target_version),),
|
|
)
|
|
|
|
def _apply(self, migrate: Migration, from_version: int) -> None:
|
|
migrate(self._conn)
|
|
self._conn.execute(
|
|
"UPDATE meta SET value = ? WHERE key = 'schema_version'",
|
|
(str(from_version + 1),),
|
|
)
|
|
|
|
def _in_transaction(self, work: Callable[[], None]) -> None:
|
|
self._conn.execute("BEGIN IMMEDIATE")
|
|
try:
|
|
work()
|
|
except BaseException:
|
|
self._conn.execute("ROLLBACK")
|
|
raise
|
|
self._conn.execute("COMMIT")
|
|
|
|
@property
|
|
def schema_version(self) -> int:
|
|
row = self._conn.execute(
|
|
"SELECT value FROM meta WHERE key = 'schema_version'"
|
|
).fetchone()
|
|
return int(row["value"])
|
|
|
|
def get_state(self) -> AgentState | None:
|
|
row = self._conn.execute("SELECT * FROM agent_state WHERE id = 1").fetchone()
|
|
if row is None:
|
|
return None
|
|
return AgentState(
|
|
state=row["state"],
|
|
project=row["project"],
|
|
data=json.loads(row["data"]),
|
|
updated_at=row["updated_at"],
|
|
)
|
|
|
|
def transition(
|
|
self,
|
|
state: str,
|
|
*,
|
|
cause: str,
|
|
project: str | None = None,
|
|
data: dict[str, Any] | None = None,
|
|
) -> None:
|
|
"""Record the new state-machine position; the full position is passed
|
|
every time (no partial updates), committed and fsynced before return."""
|
|
previous = self.get_state()
|
|
self._conn.execute(
|
|
"""
|
|
INSERT INTO agent_state (id, state, project, data, updated_at)
|
|
VALUES (1, ?, ?, ?, ?)
|
|
ON CONFLICT(id) DO UPDATE SET
|
|
state = excluded.state,
|
|
project = excluded.project,
|
|
data = excluded.data,
|
|
updated_at = excluded.updated_at
|
|
""",
|
|
(state, project, json.dumps(data or {}), _now()),
|
|
)
|
|
self.oplog.emit(
|
|
"state.transition",
|
|
cause=cause,
|
|
**{
|
|
"from": previous.state if previous else None,
|
|
"to": state,
|
|
"project": project,
|
|
},
|
|
)
|
|
|
|
def begin_intent(self, kind: str, payload: dict[str, Any], *, cause: str) -> int:
|
|
"""Durably record that a unit of work is about to start."""
|
|
cursor = self._conn.execute(
|
|
"INSERT INTO intent_log (kind, payload, created_at) VALUES (?, ?, ?)",
|
|
(kind, json.dumps(payload), _now()),
|
|
)
|
|
assert cursor.lastrowid is not None
|
|
self.oplog.emit(
|
|
"intent.begin", cause=cause, intent_id=cursor.lastrowid, kind=kind
|
|
)
|
|
return cursor.lastrowid
|
|
|
|
def commit_intent(self, intent_id: int, *, cause: str) -> None:
|
|
self._resolve_intent(intent_id, "committed", "intent.commit", cause)
|
|
|
|
def abort_intent(self, intent_id: int, *, cause: str) -> None:
|
|
self._resolve_intent(intent_id, "aborted", "intent.abort", cause)
|
|
|
|
def _resolve_intent(
|
|
self, intent_id: int, status: str, event: str, cause: str
|
|
) -> None:
|
|
row = self._conn.execute(
|
|
"UPDATE intent_log SET status = ?, resolved_at = ? "
|
|
"WHERE id = ? AND status = 'pending' RETURNING kind",
|
|
(status, _now(), intent_id),
|
|
).fetchone()
|
|
if row is None:
|
|
raise KeyError(f"no pending intent with id {intent_id}")
|
|
self.oplog.emit(event, cause=cause, intent_id=intent_id, kind=row["kind"])
|
|
|
|
def pending_intents(self) -> list[Intent]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM intent_log WHERE status = 'pending' ORDER BY id"
|
|
).fetchall()
|
|
return [
|
|
Intent(
|
|
id=row["id"],
|
|
kind=row["kind"],
|
|
payload=json.loads(row["payload"]),
|
|
status=row["status"],
|
|
created_at=row["created_at"],
|
|
resolved_at=row["resolved_at"],
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def append_event(self, event: CanonicalEvent) -> int:
|
|
"""Append to the canonical transcript (A10); committed and fsynced
|
|
before return, like every other durable write."""
|
|
cursor = self._conn.execute(
|
|
"INSERT INTO canonical_events (ts, type, payload) VALUES (?, ?, ?)",
|
|
(_now(), event_type(event), json.dumps(to_payload(event))),
|
|
)
|
|
assert cursor.lastrowid is not None
|
|
return cursor.lastrowid
|
|
|
|
def read_events(self, after: int = 0) -> list[StoredEvent]:
|
|
rows = self._conn.execute(
|
|
"SELECT * FROM canonical_events WHERE id > ? ORDER BY id", (after,)
|
|
).fetchall()
|
|
return [
|
|
StoredEvent(
|
|
id=row["id"],
|
|
ts=row["ts"],
|
|
event=from_payload(row["type"], json.loads(row["payload"])),
|
|
)
|
|
for row in rows
|
|
]
|
|
|
|
def close(self) -> None:
|
|
self._conn.close()
|
|
|
|
def __enter__(self) -> Self:
|
|
return self
|
|
|
|
def __exit__(self, *exc_info: object) -> None:
|
|
self.close()
|