From b6888ff36a28783a448a8ed0df6ccff7daeaeb59 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Sun, 26 Apr 2026 20:04:36 -0400 Subject: [PATCH] feat: events table + lifecycle handlers (T49) --- chat/db/migrations/0009_events.sql | 14 ++ chat/state/events.py | 127 ++++++++++++++++ tests/test_events_state.py | 235 +++++++++++++++++++++++++++++ 3 files changed, 376 insertions(+) create mode 100644 chat/db/migrations/0009_events.sql create mode 100644 chat/state/events.py create mode 100644 tests/test_events_state.py diff --git a/chat/db/migrations/0009_events.sql b/chat/db/migrations/0009_events.sql new file mode 100644 index 0000000..0467b1d --- /dev/null +++ b/chat/db/migrations/0009_events.sql @@ -0,0 +1,14 @@ +CREATE TABLE events ( + id INTEGER PRIMARY KEY, + event_id TEXT NOT NULL UNIQUE, + chat_id TEXT NOT NULL, + kind TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'planned', + props_json TEXT NOT NULL DEFAULT '{}', + planned_for TEXT, + started_at TEXT, + completed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +CREATE INDEX events_chat_idx ON events(chat_id, status); diff --git a/chat/state/events.py b/chat/state/events.py new file mode 100644 index 0000000..b2f0b1c --- /dev/null +++ b/chat/state/events.py @@ -0,0 +1,127 @@ +from __future__ import annotations +import json +from sqlite3 import Connection + +from chat.eventlog.projector import on +from chat.eventlog.log import Event + + +_TERMINAL_STATUSES = {"completed", "cancelled", "expired"} + + +@on("event_planned") +def _apply_event_planned(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "INSERT OR IGNORE INTO events " + "(event_id, chat_id, kind, status, props_json, planned_for) " + "VALUES (?, ?, ?, 'planned', ?, ?)", + ( + p["event_id"], + p["chat_id"], + p["kind"], + json.dumps(p.get("props", {})), + p.get("planned_for"), + ), + ) + + +@on("event_started") +def _apply_event_started(conn: Connection, e: Event) -> None: + p = e.payload + # Idempotent: only transition from non-terminal status. + conn.execute( + "UPDATE events SET status = 'active', started_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("started_at"), p["event_id"]), + ) + + +@on("event_completed") +def _apply_event_completed(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'completed', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), p["event_id"]), + ) + + +@on("event_cancelled") +def _apply_event_cancelled(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'cancelled', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), p["event_id"]), + ) + + +@on("event_expired") +def _apply_event_expired(conn: Connection, e: Event) -> None: + p = e.payload + conn.execute( + "UPDATE events SET status = 'expired', completed_at = ?, updated_at = datetime('now') " + "WHERE event_id = ? AND status NOT IN ('completed','cancelled','expired')", + (p.get("completed_at"), 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, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE event_id = ?", + (event_id,), + ).fetchone() + if not row: + return None + return { + "event_id": row[0], + "chat_id": row[1], + "kind": row[2], + "status": row[3], + "props": json.loads(row[4]), + "planned_for": row[5], + "started_at": row[6], + "completed_at": row[7], + "created_at": row[8], + "updated_at": row[9], + } + + +def list_active_events(conn: Connection, chat_id: str) -> list[dict]: + rows = conn.execute( + "SELECT event_id, chat_id, kind, status, props_json, planned_for, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE chat_id = ? AND status IN ('planned','active') " + "ORDER BY id ASC", + (chat_id,), + ).fetchall() + return [ + { + "event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3], + "props": json.loads(r[4]), + "planned_for": r[5], "started_at": r[6], "completed_at": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] + + +def list_events_in_status(conn: Connection, chat_id: str, status: str) -> list[dict]: + rows = conn.execute( + "SELECT event_id, chat_id, kind, status, props_json, planned_for, " + "started_at, completed_at, created_at, updated_at " + "FROM events WHERE chat_id = ? AND status = ? ORDER BY id ASC", + (chat_id, status), + ).fetchall() + return [ + { + "event_id": r[0], "chat_id": r[1], "kind": r[2], "status": r[3], + "props": json.loads(r[4]), + "planned_for": r[5], "started_at": r[6], "completed_at": r[7], + "created_at": r[8], "updated_at": r[9], + } + for r in rows + ] diff --git a/tests/test_events_state.py b/tests/test_events_state.py new file mode 100644 index 0000000..6ced284 --- /dev/null +++ b/tests/test_events_state.py @@ -0,0 +1,235 @@ +from __future__ import annotations + +from chat.db.connection import open_db +from chat.db.migrate import apply_migrations +from chat.eventlog.log import append_and_apply, append_event +from chat.eventlog.projector import project +import chat.state.entities # registers handlers +import chat.state.world # registers handlers +import chat.state.group_node # registers handlers +import chat.state.events # registers handlers +from chat.state.events import ( + get_event, + list_active_events, + list_events_in_status, +) + + +def _bot_payload(bot_id: str, name: str) -> dict: + return { + "id": bot_id, + "name": name, + "persona": "thoughtful, observant", + "voice_samples": [], + "traits": [], + "backstory": "", + "initial_relationship_to_you": "coworker", + "kickoff_prose": "", + } + + +def _chat_payload(chat_id: str = "chat_bot_a") -> dict: + return { + "id": chat_id, + "host_bot_id": "bot_a", + "guest_bot_id": "bot_b", + "initial_time": "2026-04-26T20:00:00+00:00", + "narrative_anchor": "Day 1 evening", + "weather": "clear", + } + + +def _seed_chat(conn) -> None: + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_a", "BotA")) + append_event(conn, kind="bot_authored", payload=_bot_payload("bot_b", "BotB")) + append_event(conn, kind="chat_created", payload=_chat_payload()) + + +def test_event_planned_creates_row(tmp_path): + 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_abc", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {"location": "park"}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["event_id"] == "evt_abc" + assert ev["chat_id"] == "chat_bot_a" + assert ev["kind"] == "date_at_park" + assert ev["status"] == "planned" + assert ev["props"]["location"] == "park" + assert ev["planned_for"] == "2026-04-30T18:00:00+00:00" + assert ev["started_at"] is None + assert ev["completed_at"] is None + + +def test_event_started_then_completed_updates_status(tmp_path): + 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_abc", + "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_abc", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_completed", + payload={ + "event_id": "evt_abc", + "completed_at": "2026-04-30T20:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["status"] == "completed" + assert ev["started_at"] == "2026-04-30T18:01:00+00:00" + assert ev["completed_at"] == "2026-04-30T20:00:00+00:00" + + +def test_event_cancelled_terminal_subsequent_transitions_ignored(tmp_path): + 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_abc", + "chat_id": "chat_bot_a", + "kind": "date_at_park", + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_cancelled", + payload={ + "event_id": "evt_abc", + "completed_at": "2026-04-30T17:00:00+00:00", + }, + ) + project(conn) + + ev = get_event(conn, "evt_abc") + assert ev is not None + assert ev["status"] == "cancelled" + assert ev["completed_at"] == "2026-04-30T17:00:00+00:00" + + # Subsequent event_started must be no-oped because status is terminal. + # Use append_and_apply so we apply ONLY this new event without + # replaying earlier non-idempotent handlers (e.g. chat_created). + append_and_apply( + conn, + kind="event_started", + payload={ + "event_id": "evt_abc", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + + ev2 = get_event(conn, "evt_abc") + assert ev2 is not None + assert ev2["status"] == "cancelled" + assert ev2["started_at"] is None + # completed_at unchanged from the cancelled transition + assert ev2["completed_at"] == "2026-04-30T17:00:00+00:00" + + +def test_list_active_events_filters_to_planned_and_active(tmp_path): + db = tmp_path / "t.db" + apply_migrations(db) + with open_db(db) as conn: + _seed_chat(conn) + # Four events: one planned, one active, one completed, one cancelled. + for ev_id, kind in [ + ("evt_planned", "date_at_park"), + ("evt_active", "movie_night"), + ("evt_done", "dinner"), + ("evt_canx", "trip"), + ]: + append_event( + conn, + kind="event_planned", + payload={ + "event_id": ev_id, + "chat_id": "chat_bot_a", + "kind": kind, + "props": {}, + "planned_for": "2026-04-30T18:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_started", + payload={ + "event_id": "evt_active", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_started", + payload={ + "event_id": "evt_done", + "started_at": "2026-04-30T18:01:00+00:00", + }, + ) + append_event( + conn, + kind="event_completed", + payload={ + "event_id": "evt_done", + "completed_at": "2026-04-30T20:00:00+00:00", + }, + ) + append_event( + conn, + kind="event_cancelled", + payload={ + "event_id": "evt_canx", + "completed_at": "2026-04-30T17:00:00+00:00", + }, + ) + project(conn) + + active = list_active_events(conn, "chat_bot_a") + active_ids = {e["event_id"] for e in active} + assert active_ids == {"evt_planned", "evt_active"} + + completed = list_events_in_status(conn, "chat_bot_a", "completed") + assert [e["event_id"] for e in completed] == ["evt_done"] + + cancelled = list_events_in_status(conn, "chat_bot_a", "cancelled") + assert [e["event_id"] for e in cancelled] == ["evt_canx"]