Implement A9 state-change logging in the state store
Retrofit for the conventions merged in #4 (issue #3): - OpLog: JSON-lines emitter with the fixed envelope (ts, agent, event, task_id, cause); task_id set by the orchestrator at unit-of-work start - StateStore owns emission: transition, intent begin/commit/abort, and schema migrations each emit their event right after the durable write commits; callers pass the cause - reconcile_on_boot emits boot.reconcile with action and verdicts, and resolves intents with reconciliation-verdict causes - Failed resolutions emit nothing (a line exists iff the write committed) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ba848281ec
commit
18d4fcd701
7 files changed
+216
-32
No files matched your search
@@ -0,0 +1,33 @@
|
||||
"""Structured operational logging (spec A9).
|
||||
|
||||
One JSON object per line to stderr (journald captures it when running as a
|
||||
service). Every event carries the fixed envelope: ts, agent, event, task_id,
|
||||
cause. Logs are diagnostic, never load-bearing — nothing may read them back
|
||||
to derive state.
|
||||
"""
|
||||
|
||||
import json
|
||||
import sys
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any, TextIO
|
||||
|
||||
|
||||
class OpLog:
|
||||
def __init__(self, agent: str, stream: TextIO | None = None) -> None:
|
||||
self.agent = agent
|
||||
# Correlation ID for the enclosing unit of work; the orchestrator sets
|
||||
# it when a unit starts and clears it when the unit ends (A9).
|
||||
self.task_id: str | None = None
|
||||
self._stream = stream if stream is not None else sys.stderr
|
||||
|
||||
def emit(self, event: str, *, cause: str, **fields: Any) -> None:
|
||||
record: dict[str, Any] = {
|
||||
"ts": datetime.now(UTC).isoformat(),
|
||||
"agent": self.agent,
|
||||
"event": event,
|
||||
"task_id": self.task_id,
|
||||
"cause": cause,
|
||||
**fields,
|
||||
}
|
||||
self._stream.write(json.dumps(record) + "\n")
|
||||
self._stream.flush()
|
||||
@@ -39,15 +39,23 @@ def reconcile_on_boot(
|
||||
continue
|
||||
verdict = reconciler(intent)
|
||||
if verdict is True:
|
||||
store.commit_intent(intent.id)
|
||||
store.commit_intent(
|
||||
intent.id, cause="reconciliation verdict: effect verified"
|
||||
)
|
||||
notes.append(f"{label}: external effect verified, marked committed")
|
||||
elif verdict is False:
|
||||
store.abort_intent(intent.id)
|
||||
store.abort_intent(
|
||||
intent.id, cause="reconciliation verdict: never took effect"
|
||||
)
|
||||
notes.append(f"{label}: never took effect, marked aborted")
|
||||
else:
|
||||
unresolved = True
|
||||
notes.append(f"{label}: unverifiable")
|
||||
return BootReport(
|
||||
report = BootReport(
|
||||
action="await_input" if unresolved else "resume",
|
||||
notes=notes,
|
||||
)
|
||||
store.oplog.emit(
|
||||
"boot.reconcile", cause="startup", action=report.action, verdicts=notes
|
||||
)
|
||||
return report
|
||||
@@ -18,6 +18,8 @@ from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Self
|
||||
|
||||
from nisse_dev.oplog import OpLog
|
||||
|
||||
CURRENT_SCHEMA_VERSION = 1
|
||||
|
||||
Migration = Callable[[sqlite3.Connection], None]
|
||||
@@ -83,7 +85,11 @@ class StateStore:
|
||||
*,
|
||||
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")
|
||||
@@ -115,6 +121,12 @@ class StateStore:
|
||||
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:
|
||||
@@ -162,11 +174,13 @@ class StateStore:
|
||||
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)
|
||||
@@ -179,30 +193,45 @@ class StateStore:
|
||||
""",
|
||||
(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]) -> int:
|
||||
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) -> None:
|
||||
self._resolve_intent(intent_id, "committed")
|
||||
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) -> None:
|
||||
self._resolve_intent(intent_id, "aborted")
|
||||
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) -> None:
|
||||
cursor = self._conn.execute(
|
||||
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'",
|
||||
"WHERE id = ? AND status = 'pending' RETURNING kind",
|
||||
(status, _now(), intent_id),
|
||||
)
|
||||
if cursor.rowcount != 1:
|
||||
).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(
|
||||
|
||||
Reference in new issue
Block a user