feat: events table + lifecycle handlers (T49)

This commit is contained in:
Joseph Doherty
2026-04-26 20:04:36 -04:00
parent e4fd888b53
commit b6888ff36a
3 changed files with 376 additions and 0 deletions
+14
View File
@@ -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);
+127
View File
@@ -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
]
+235
View File
@@ -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"]