From 6d4ad86e3375b888587827b1e5cf06d162761493 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 06:39:03 -0400 Subject: [PATCH] feat: event_status_reverted event kind + projector handler (T114.2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- chat/state/events.py | 23 ++++++++++ tests/test_events_state.py | 88 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) diff --git a/chat/state/events.py b/chat/state/events.py index b2f0b1c..13b2424 100644 --- a/chat/state/events.py +++ b/chat/state/events.py @@ -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, " diff --git a/tests/test_events_state.py b/tests/test_events_state.py index 6ced284..6259bc0 100644 --- a/tests/test_events_state.py +++ b/tests/test_events_state.py @@ -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"