feat: event_status_reverted event kind + projector handler (T114.2)

Adds the inverse projection used by T114.3's regenerate rollback. The
new ``event_status_reverted`` event kind carries
``{event_id, prior_status}`` and the handler unconditionally sets
``events.status = prior_status`` for the row.

Unlike the forward transitions (event_started / event_completed /
event_cancelled), this handler does NOT guard against terminal
statuses — its entire purpose is to reverse a transition, including
walking back from a terminal status to a non-terminal one. Without
that, rolling back an event_completed (status='completed' is terminal
for the forward handlers) would silently no-op and leave the row in
the post-superseded state.

The handler registers via the existing ``@on(kind)`` decorator pattern
in chat/eventlog/projector.py, so future replays of an event_log that
contains event_status_reverted rows pick it up automatically.

Test exercises completed→active, active→planned, and cancelled→active
round-trips.
This commit is contained in:
Joseph Doherty
2026-04-27 06:39:03 -04:00
parent 7370f68bdf
commit 6d4ad86e33
2 changed files with 111 additions and 0 deletions
+23
View File
@@ -67,6 +67,29 @@ def _apply_event_expired(conn: Connection, e: Event) -> None:
)
@on("event_status_reverted")
def _apply_event_status_reverted(conn: Connection, e: Event) -> None:
"""T114.2: Revert an event row's status to ``prior_status``.
Emitted by ``regenerate_assistant_turn`` when a superseded turn had
triggered a lifecycle transition (event_started / event_completed /
event_cancelled). The rollback step needs an inverse projection that
sets the row's status back to whatever it was *before* the now-
superseded transition fired.
Unlike the forward transitions (which guard against terminal-status
overwrites) this handler is unconditional — the entire purpose is to
reverse a transition, including reverting from a terminal status
(completed/cancelled) back to a non-terminal one.
"""
p = e.payload
conn.execute(
"UPDATE events SET status = ?, updated_at = datetime('now') "
"WHERE event_id = ?",
(p["prior_status"], p["event_id"]),
)
def get_event(conn: Connection, event_id: str) -> dict | None:
row = conn.execute(
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
+88
View File
@@ -233,3 +233,91 @@ def test_list_active_events_filters_to_planned_and_active(tmp_path):
cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled")
assert [e["event_id"] for e in cancelled] == ["evt_canx"]
def test_event_status_reverted_returns_to_prior_status(tmp_path):
"""T114.2: ``event_status_reverted`` rolls a row back to ``prior_status``.
Unlike the forward transitions, this projector handler is
unconditional — its sole purpose is to undo a transition, including
reverting from a terminal status (completed/cancelled) back to a
non-terminal one.
Three round-trips covered:
- completed → active (rollback of an event_completed)
- active → planned (rollback of an event_started)
- cancelled → active (rollback of an event_cancelled)
"""
db = tmp_path / "t.db"
apply_migrations(db)
with open_db(db) as conn:
_seed_chat(conn)
append_event(
conn,
kind="event_planned",
payload={
"event_id": "evt_revert",
"chat_id": "chat_bot_a",
"kind": "date_at_park",
"props": {},
"planned_for": "2026-04-30T18:00:00+00:00",
},
)
append_event(
conn,
kind="event_started",
payload={
"event_id": "evt_revert",
"started_at": "2026-04-30T18:01:00+00:00",
},
)
append_event(
conn,
kind="event_completed",
payload={
"event_id": "evt_revert",
"completed_at": "2026-04-30T20:00:00+00:00",
},
)
project(conn)
ev = get_event(conn, "evt_revert")
assert ev is not None
assert ev["status"] == "completed"
# Revert from completed → active.
append_and_apply(
conn,
kind="event_status_reverted",
payload={"event_id": "evt_revert", "prior_status": "active"},
)
ev = get_event(conn, "evt_revert")
assert ev["status"] == "active"
# Revert from active → planned.
append_and_apply(
conn,
kind="event_status_reverted",
payload={"event_id": "evt_revert", "prior_status": "planned"},
)
ev = get_event(conn, "evt_revert")
assert ev["status"] == "planned"
# Forward to cancelled, then revert from cancelled → active.
append_and_apply(
conn,
kind="event_cancelled",
payload={
"event_id": "evt_revert",
"completed_at": "2026-04-30T20:30:00+00:00",
},
)
ev = get_event(conn, "evt_revert")
assert ev["status"] == "cancelled"
append_and_apply(
conn,
kind="event_status_reverted",
payload={"event_id": "evt_revert", "prior_status": "active"},
)
ev = get_event(conn, "evt_revert")
assert ev["status"] == "active"