feat: branching service (branch_from_event + switch + metadata) (T94)
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""Branching service (T94, Phase 4).
|
||||
|
||||
Wraps branches state with validation + event emission. Phase 4 ships
|
||||
the data model and creation/switching APIs; the read-side filter
|
||||
(event readers consulting is_active) is a Phase 4.5+ follow-up — for
|
||||
now branches are metadata-only and the existing event readers remain
|
||||
branch-agnostic. The drawer UI (T98) drives create/switch via these
|
||||
helpers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
from sqlite3 import Connection
|
||||
|
||||
from chat.eventlog.log import append_and_apply
|
||||
from chat.state.branches import get_branch, list_branches, active_branch # noqa: F401
|
||||
|
||||
|
||||
def branch_from_event(
|
||||
conn: Connection,
|
||||
*,
|
||||
name: str,
|
||||
origin_event_id: int,
|
||||
chat_id: str | None = None,
|
||||
) -> int:
|
||||
"""Create a new named branch forking from origin_event_id.
|
||||
|
||||
Emits a branch_created event. Returns the new branch's row id.
|
||||
Raises ValueError if name already exists or origin_event_id doesn't
|
||||
correspond to a real event."""
|
||||
if not name or not name.strip():
|
||||
raise ValueError("branch name must be non-empty")
|
||||
|
||||
if get_branch(conn, name) is not None:
|
||||
raise ValueError(f"branch {name!r} already exists")
|
||||
|
||||
# Validate origin_event_id is a real event id (or 0 for the bootstrap case
|
||||
# which only main uses).
|
||||
if origin_event_id < 0:
|
||||
raise ValueError(f"origin_event_id must be >= 0, got {origin_event_id}")
|
||||
if origin_event_id > 0:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM event_log WHERE id = ?", (origin_event_id,)
|
||||
).fetchone()
|
||||
if row is None:
|
||||
raise ValueError(
|
||||
f"origin_event_id {origin_event_id} does not exist in event_log"
|
||||
)
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="branch_created",
|
||||
payload={
|
||||
"name": name,
|
||||
"origin_event_id": origin_event_id,
|
||||
"head_event_id": origin_event_id, # head starts at origin
|
||||
"chat_id": chat_id,
|
||||
},
|
||||
)
|
||||
|
||||
branch = get_branch(conn, name)
|
||||
if branch is None:
|
||||
# Should be unreachable if append_and_apply worked.
|
||||
raise RuntimeError(f"branch {name!r} not found after creation")
|
||||
return branch["id"]
|
||||
|
||||
|
||||
def switch_active_branch(conn: Connection, *, name: str) -> None:
|
||||
"""Make the named branch active. Emits branch_switched."""
|
||||
if get_branch(conn, name) is None:
|
||||
raise ValueError(f"branch {name!r} does not exist")
|
||||
|
||||
append_and_apply(
|
||||
conn,
|
||||
kind="branch_switched",
|
||||
payload={"name": name},
|
||||
)
|
||||
|
||||
|
||||
def list_branches_with_metadata(
|
||||
conn: Connection, chat_id: str | None = None
|
||||
) -> list[dict]:
|
||||
"""List branches with computed event_count metadata.
|
||||
|
||||
event_count = head_event_id - origin_event_id + 1 (when both are set)
|
||||
OR head_event_id (when origin is 0, e.g., main branch)
|
||||
OR 0 (when head <= origin, which is the bootstrap state)
|
||||
"""
|
||||
branches = list_branches(conn, chat_id)
|
||||
enriched = []
|
||||
for b in branches:
|
||||
origin = b["origin_event_id"]
|
||||
head = b["head_event_id"]
|
||||
if head < origin:
|
||||
event_count = 0
|
||||
elif origin == 0:
|
||||
event_count = head
|
||||
else:
|
||||
event_count = head - origin + 1
|
||||
enriched.append({**b, "event_count": event_count})
|
||||
return enriched
|
||||
|
||||
|
||||
__all__ = [
|
||||
"branch_from_event",
|
||||
"switch_active_branch",
|
||||
"list_branches_with_metadata",
|
||||
]
|
||||
Reference in New Issue
Block a user