CI / checks (push) Skipped
CI / checks (pull_request) Successful in 1m22s
Development Plan M0.1. Every section reference in code, tests, specs/, the example config, the tuning guide, and the CLAUDE.md and README files now cites docs/Architecture.md (P, L, D, O, X, Q) instead of the old spec's A-H, and docs/Architecture-Build-Spec.md is deleted (git keeps it). The plan's old-to-new table goes with it. Mostly mechanical, with a per-site choice where one old section split: A4 became L2 where it meant stop/abandon/the loop and L3 where it meant approvals; A10 became L6 in the context-budget code and L7 elsewhere; B2 became D4 for outbound Gitea and publishing and D5 for review intake; 'A3 step N' became the matching step of L8's boot sequence. Comments describing behaviour the architecture drops (planning always escalating, thresholds tuned at an old build step) now describe the current code and point at the Development Plan. docs/Architecture.md gains one line the rewrite missed: intake outages delay but never fail work (old B2), now in L5, where the poller and Matrix code cite it. Comments and docs only; no behaviour change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPQ14L3LikyefCMFBqEbKE
159 lines
5.6 KiB
Python
159 lines
5.6 KiB
Python
"""State-change log conventions (architecture L12): every durable mutation emits one
|
|
JSON-lines event with the fixed envelope, from the store's own write path."""
|
|
|
|
import io
|
|
import json
|
|
import sqlite3
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from nisse_dev.oplog import OpLog
|
|
from nisse_dev.reconcile import reconcile_on_boot
|
|
from nisse_dev.state import StateStore
|
|
|
|
ENVELOPE_FIELDS = {"ts", "agent", "event", "task_id", "cause"}
|
|
|
|
|
|
@pytest.fixture
|
|
def stream() -> io.StringIO:
|
|
return io.StringIO()
|
|
|
|
|
|
@pytest.fixture
|
|
def store(tmp_path: Path, stream: io.StringIO) -> StateStore:
|
|
return StateStore(tmp_path / "state.db", oplog=OpLog("nisse-dev", stream))
|
|
|
|
|
|
def events(stream: io.StringIO) -> list[dict]:
|
|
return [json.loads(line) for line in stream.getvalue().splitlines()]
|
|
|
|
|
|
def test_every_event_carries_the_envelope(
|
|
store: StateStore, stream: io.StringIO
|
|
) -> None:
|
|
store.transition("PLANNING_ARCH", cause="operator command: use demo")
|
|
intent_id = store.begin_intent("push_branch", {}, cause="milestone execution")
|
|
store.commit_intent(intent_id, cause="push verified")
|
|
for event in events(stream):
|
|
assert ENVELOPE_FIELDS <= event.keys()
|
|
assert event["agent"] == "nisse-dev"
|
|
|
|
|
|
def test_transition_logs_from_to_and_cause(
|
|
store: StateStore, stream: io.StringIO
|
|
) -> None:
|
|
store.transition("PLANNING_ARCH", cause="operator command: use demo", project="d")
|
|
store.transition("AWAIT_ARCH_SIGNOFF", cause="plan submitted", project="d")
|
|
first, second = events(stream)
|
|
assert first["event"] == "state.transition"
|
|
assert (first["from"], first["to"]) == (None, "PLANNING_ARCH")
|
|
assert (second["from"], second["to"]) == ("PLANNING_ARCH", "AWAIT_ARCH_SIGNOFF")
|
|
assert second["cause"] == "plan submitted"
|
|
|
|
|
|
def test_intent_lifecycle_events(store: StateStore, stream: io.StringIO) -> None:
|
|
intent_id = store.begin_intent("push_branch", {"b": "m2"}, cause="milestone")
|
|
store.abort_intent(intent_id, cause="discarded")
|
|
begin, abort = events(stream)
|
|
assert begin["event"] == "intent.begin"
|
|
assert abort["event"] == "intent.abort"
|
|
assert begin["intent_id"] == abort["intent_id"] == intent_id
|
|
assert begin["kind"] == abort["kind"] == "push_branch"
|
|
|
|
|
|
def test_task_id_threads_through_events(store: StateStore, stream: io.StringIO) -> None:
|
|
store.transition("IDLE", cause="boot")
|
|
store.oplog.task_id = "task-42"
|
|
store.transition("PLANNING_ARCH", cause="operator command")
|
|
before, during = events(stream)
|
|
assert before["task_id"] is None
|
|
assert during["task_id"] == "task-42"
|
|
|
|
|
|
def test_migration_emits_event(tmp_path: Path, stream: io.StringIO) -> None:
|
|
db = tmp_path / "state.db"
|
|
with StateStore(db, schema_version=1):
|
|
pass
|
|
|
|
def add_notes(conn: sqlite3.Connection) -> None:
|
|
conn.execute("CREATE TABLE notes (id INTEGER PRIMARY KEY)")
|
|
|
|
with StateStore(
|
|
db,
|
|
schema_version=2,
|
|
migrations={1: add_notes},
|
|
oplog=OpLog("nisse-dev", stream),
|
|
):
|
|
pass
|
|
[event] = events(stream)
|
|
assert event["event"] == "schema.migrate"
|
|
assert (event["from_version"], event["to_version"]) == (1, 2)
|
|
|
|
|
|
def test_boot_reconcile_emits_action_and_verdicts(
|
|
store: StateStore, stream: io.StringIO
|
|
) -> None:
|
|
store.begin_intent("push_branch", {}, cause="milestone")
|
|
reconcile_on_boot(store, {"push_branch": lambda intent: True})
|
|
boot = [e for e in events(stream) if e["event"] == "boot.reconcile"]
|
|
assert len(boot) == 1
|
|
assert boot[0]["action"] == "resume"
|
|
assert "effect verified" in boot[0]["verdicts"][0]
|
|
commit = [e for e in events(stream) if e["event"] == "intent.commit"]
|
|
assert commit[0]["cause"] == "reconciliation verdict: effect verified"
|
|
|
|
|
|
def test_literal_log_lines_for_human_readability(
|
|
store: StateStore, stream: io.StringIO, monkeypatch: pytest.MonkeyPatch
|
|
) -> None:
|
|
"""Asserts the exact bytes written per line, so the on-disk format is
|
|
visible here rather than only in a running system."""
|
|
|
|
class _FrozenDatetime:
|
|
@staticmethod
|
|
def now(tz: object) -> object:
|
|
from datetime import UTC, datetime
|
|
|
|
return datetime(2026, 9, 5, 12, 0, 0, tzinfo=UTC)
|
|
|
|
monkeypatch.setattr("nisse_dev.oplog.datetime", _FrozenDatetime)
|
|
|
|
store.transition(
|
|
"PLANNING_ARCH", cause="operator command: use demo", project="demo"
|
|
)
|
|
store.oplog.task_id = "demo-m2"
|
|
intent_id = store.begin_intent(
|
|
"push_branch", {"branch": "milestone-2"}, cause="milestone 2 execution"
|
|
)
|
|
store.commit_intent(intent_id, cause="push verified by Gitea query")
|
|
|
|
assert stream.getvalue().splitlines() == [
|
|
(
|
|
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
|
' "event": "state.transition", "task_id": null,'
|
|
' "cause": "operator command: use demo",'
|
|
' "from": null, "to": "PLANNING_ARCH", "project": "demo"}'
|
|
),
|
|
(
|
|
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
|
' "event": "intent.begin", "task_id": "demo-m2",'
|
|
' "cause": "milestone 2 execution", "intent_id": 1,'
|
|
' "kind": "push_branch"}'
|
|
),
|
|
(
|
|
'{"ts": "2026-09-05T12:00:00+00:00", "agent": "nisse-dev",'
|
|
' "event": "intent.commit", "task_id": "demo-m2",'
|
|
' "cause": "push verified by Gitea query",'
|
|
' "intent_id": 1, "kind": "push_branch"}'
|
|
),
|
|
]
|
|
|
|
|
|
def test_failed_resolution_emits_nothing(
|
|
store: StateStore, stream: io.StringIO
|
|
) -> None:
|
|
with pytest.raises(KeyError):
|
|
store.commit_intent(9999, cause="test")
|
|
assert events(stream) == []
|