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:
@@ -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, "
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user