feat: bot reset with hard confirm and event-driven purge
This commit is contained in:
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlite3 import Connection
|
||||||
|
|
||||||
|
from chat.eventlog.log import append_and_apply
|
||||||
|
from chat.state.entities import get_bot
|
||||||
|
|
||||||
|
|
||||||
|
def reset_bot(conn: Connection, bot_id: str, *, confirm_name: str) -> None:
|
||||||
|
"""Reset a bot's runtime state via a ``bot_reset`` event.
|
||||||
|
|
||||||
|
Validates that ``confirm_name`` matches the bot's stored ``name``
|
||||||
|
exactly (case-sensitive, no trim). Raises:
|
||||||
|
|
||||||
|
- ``ValueError("bot {bot_id} not found")`` when the bot is missing.
|
||||||
|
- ``ValueError("confirm_name does not match bot name")`` on mismatch.
|
||||||
|
"""
|
||||||
|
bot = get_bot(conn, bot_id)
|
||||||
|
if bot is None:
|
||||||
|
raise ValueError(f"bot {bot_id} not found")
|
||||||
|
if confirm_name != bot["name"]:
|
||||||
|
raise ValueError("confirm_name does not match bot name")
|
||||||
|
append_and_apply(conn, kind="bot_reset", payload={"bot_id": bot_id})
|
||||||
@@ -31,6 +31,46 @@ def _apply_you_authored(conn: Connection, e: Event) -> None:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@on("bot_reset")
|
||||||
|
def _apply_bot_reset(conn: Connection, e: Event) -> None:
|
||||||
|
"""Purge per-bot runtime state while preserving the bot's identity row.
|
||||||
|
|
||||||
|
Wipes chats hosted by this bot (with cascading chat-scoped tables),
|
||||||
|
memories owned by this bot, edges involving this bot, and the bot's own
|
||||||
|
activity row. The ``bots`` row itself is preserved so identity,
|
||||||
|
initial-relationship, and kickoff prose remain authored.
|
||||||
|
"""
|
||||||
|
bot_id = e.payload["bot_id"]
|
||||||
|
|
||||||
|
chat_ids = [
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute(
|
||||||
|
"SELECT id FROM chats WHERE host_bot_id = ?", (bot_id,)
|
||||||
|
).fetchall()
|
||||||
|
]
|
||||||
|
for chat_id in chat_ids:
|
||||||
|
conn.execute("DELETE FROM scenes WHERE chat_id = ?", (chat_id,))
|
||||||
|
conn.execute("DELETE FROM containers WHERE chat_id = ?", (chat_id,))
|
||||||
|
conn.execute("DELETE FROM chat_state WHERE chat_id = ?", (chat_id,))
|
||||||
|
conn.execute("DELETE FROM chats WHERE id = ?", (chat_id,))
|
||||||
|
|
||||||
|
# Activity for this bot's entity row (independent of chat_id since the
|
||||||
|
# ``activity`` table is keyed on entity_id).
|
||||||
|
conn.execute("DELETE FROM activity WHERE entity_id = ?", (bot_id,))
|
||||||
|
|
||||||
|
# Memories authored by this bot.
|
||||||
|
conn.execute("DELETE FROM memories WHERE owner_id = ?", (bot_id,))
|
||||||
|
|
||||||
|
# Edges in either direction involving this bot.
|
||||||
|
conn.execute(
|
||||||
|
"DELETE FROM edges WHERE source_id = ? OR target_id = ?",
|
||||||
|
(bot_id, bot_id),
|
||||||
|
)
|
||||||
|
# NOTE: bots row itself is preserved (identity, kickoff_prose intact).
|
||||||
|
# NOTE: "you" activity (entity_id="you") may linger from a deleted chat;
|
||||||
|
# acceptable for v1 — Phase 1.5 cleanup if needed.
|
||||||
|
|
||||||
|
|
||||||
def get_bot(conn: Connection, bot_id: str) -> dict | None:
|
def get_bot(conn: Connection, bot_id: str) -> dict | None:
|
||||||
row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone()
|
row = conn.execute("SELECT * FROM bots WHERE id = ?", (bot_id,)).fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
|
|||||||
@@ -8,7 +8,18 @@
|
|||||||
{% if bots %}
|
{% if bots %}
|
||||||
<ul class="bot-list">
|
<ul class="bot-list">
|
||||||
{% for bot in bots %}
|
{% for bot in bots %}
|
||||||
<li><a href="/bots/{{ bot.id }}">{{ bot.name }}</a></li>
|
<li>
|
||||||
|
<a href="/bots/{{ bot.id }}">{{ bot.name }}</a>
|
||||||
|
<details class="bot-row-reset">
|
||||||
|
<summary>Reset</summary>
|
||||||
|
<form method="post" action="/bots/{{ bot.id }}/reset" class="inline-edit">
|
||||||
|
<label>Type "{{ bot.name }}" to confirm:
|
||||||
|
<input type="text" name="confirm_name" required>
|
||||||
|
</label>
|
||||||
|
<button type="submit">Reset bot</button>
|
||||||
|
</form>
|
||||||
|
</details>
|
||||||
|
</li>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</ul>
|
||||||
{% else %}
|
{% else %}
|
||||||
|
|||||||
@@ -118,3 +118,22 @@ async def bot_create(
|
|||||||
append_event(conn, kind="bot_authored", payload=payload)
|
append_event(conn, kind="bot_authored", payload=payload)
|
||||||
project(conn)
|
project(conn)
|
||||||
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
|
return RedirectResponse(url=f"/bots/{payload['id']}/kickoff", status_code=303)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/bots/{bot_id}/reset")
|
||||||
|
async def reset_bot_route(
|
||||||
|
bot_id: str,
|
||||||
|
request: Request,
|
||||||
|
confirm_name: str = Form(""),
|
||||||
|
conn=Depends(get_conn),
|
||||||
|
):
|
||||||
|
from chat.services.reset import reset_bot
|
||||||
|
|
||||||
|
try:
|
||||||
|
reset_bot(conn, bot_id, confirm_name=confirm_name)
|
||||||
|
except ValueError as e:
|
||||||
|
msg = str(e).lower()
|
||||||
|
if "not found" in msg:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
return RedirectResponse(url="/bots", status_code=303)
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from chat.app import app
|
||||||
|
from chat.db.connection import open_db
|
||||||
|
from chat.eventlog.log import append_event
|
||||||
|
from chat.eventlog.projector import project
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(tmp_path, monkeypatch):
|
||||||
|
cfg = tmp_path / "config.toml"
|
||||||
|
cfg.write_text('featherless_api_key = "test"\n')
|
||||||
|
monkeypatch.setenv("CHAT_CONFIG_PATH", str(cfg))
|
||||||
|
db = tmp_path / "test.db"
|
||||||
|
monkeypatch.setenv("CHAT_DB_PATH", str(db))
|
||||||
|
with TestClient(app) as c:
|
||||||
|
if hasattr(app.state, "background_worker"):
|
||||||
|
app.state.background_worker.enabled = False
|
||||||
|
yield c
|
||||||
|
|
||||||
|
|
||||||
|
def _seed_bot_with_state(db: Path) -> None:
|
||||||
|
"""Seed a bot plus a chat, container, scene, edge, memory, and activity row."""
|
||||||
|
with open_db(db) as conn:
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="bot_authored",
|
||||||
|
payload={
|
||||||
|
"id": "bot_a",
|
||||||
|
"name": "BotA",
|
||||||
|
"persona": "thoughtful, observant",
|
||||||
|
"voice_samples": [],
|
||||||
|
"traits": ["shy"],
|
||||||
|
"backstory": "",
|
||||||
|
"initial_relationship_to_you": "coworker",
|
||||||
|
"kickoff_prose": "you stay late",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="chat_created",
|
||||||
|
payload={
|
||||||
|
"id": "chat_bot_a",
|
||||||
|
"host_bot_id": "bot_a",
|
||||||
|
"initial_time": "2026-04-26T20:00:00+00:00",
|
||||||
|
"narrative_anchor": "Day 1",
|
||||||
|
"weather": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="container_created",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"name": "office",
|
||||||
|
"type": "workplace",
|
||||||
|
"properties": {},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="scene_opened",
|
||||||
|
payload={
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"container_id": 1,
|
||||||
|
"started_at": "2026-04-26T20:00:00+00:00",
|
||||||
|
"participants": ["you", "bot_a"],
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="edge_update",
|
||||||
|
payload={
|
||||||
|
"source_id": "bot_a",
|
||||||
|
"target_id": "you",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"affinity_delta": 5,
|
||||||
|
"trust_delta": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="memory_written",
|
||||||
|
payload={
|
||||||
|
"owner_id": "bot_a",
|
||||||
|
"chat_id": "chat_bot_a",
|
||||||
|
"pov_summary": "Talked about her sister",
|
||||||
|
"witness_you": 1,
|
||||||
|
"witness_host": 1,
|
||||||
|
"witness_guest": 0,
|
||||||
|
"significance": 2,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
append_event(
|
||||||
|
conn,
|
||||||
|
kind="activity_change",
|
||||||
|
payload={
|
||||||
|
"entity_id": "bot_a",
|
||||||
|
"posture": "sitting",
|
||||||
|
"action": {"verb": "writing"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
project(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_purges_state_but_preserves_identity(client, tmp_path):
|
||||||
|
_seed_bot_with_state(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/bots/bot_a/reset",
|
||||||
|
data={"confirm_name": "BotA"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 303
|
||||||
|
assert response.headers["location"] == "/bots"
|
||||||
|
|
||||||
|
with open_db(tmp_path / "test.db") as conn:
|
||||||
|
# Identity preserved.
|
||||||
|
bot = conn.execute(
|
||||||
|
"SELECT id, name, kickoff_prose, initial_relationship_to_you "
|
||||||
|
"FROM bots WHERE id = 'bot_a'"
|
||||||
|
).fetchone()
|
||||||
|
assert bot is not None
|
||||||
|
assert bot[1] == "BotA"
|
||||||
|
assert bot[2] == "you stay late"
|
||||||
|
assert bot[3] == "coworker"
|
||||||
|
|
||||||
|
# State purged.
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM chats WHERE host_bot_id = 'bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM scenes WHERE chat_id = 'chat_bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM containers WHERE chat_id = 'chat_bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM chat_state WHERE chat_id = 'chat_bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM memories WHERE owner_id = 'bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM edges WHERE source_id = 'bot_a' OR target_id = 'bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM activity WHERE entity_id = 'bot_a'"
|
||||||
|
).fetchone()[0] == 0
|
||||||
|
|
||||||
|
# Event log records the bot_reset event.
|
||||||
|
assert conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM event_log WHERE kind = 'bot_reset'"
|
||||||
|
).fetchone()[0] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_400_when_confirm_name_mismatch(client, tmp_path):
|
||||||
|
_seed_bot_with_state(tmp_path / "test.db")
|
||||||
|
response = client.post(
|
||||||
|
"/bots/bot_a/reset",
|
||||||
|
data={"confirm_name": "WrongName"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
def test_reset_404_when_bot_missing(client):
|
||||||
|
response = client.post(
|
||||||
|
"/bots/no_such/reset",
|
||||||
|
data={"confirm_name": "Anything"},
|
||||||
|
follow_redirects=False,
|
||||||
|
)
|
||||||
|
assert response.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
def test_bot_list_renders_reset_form(client, tmp_path):
|
||||||
|
_seed_bot_with_state(tmp_path / "test.db")
|
||||||
|
response = client.get("/bots")
|
||||||
|
assert response.status_code == 200
|
||||||
|
assert "Reset" in response.text
|
||||||
|
assert "confirm_name" in response.text
|
||||||
Reference in New Issue
Block a user