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:
|
def get_event(conn: Connection, event_id: str) -> dict | None:
|
||||||
row = conn.execute(
|
row = conn.execute(
|
||||||
"SELECT event_id, chat_id, kind, status, props_json, planned_for, "
|
"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")
|
cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled")
|
||||||
assert [e["event_id"] for e in cancelled] == ["evt_canx"]
|
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