Skip to content

Commit 6b39f0b

Browse files
coordtclaude
andcommitted
Phase 2 Task 5: implement SQLite memory store
Add MemoryStore with action_log and memory_summary tables (WAL mode). log_action(), get_memory_summary(), upsert_memory_summary() covered by 13 tests using real temp-file DBs — no mocks. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 829f47f commit 6b39f0b

2 files changed

Lines changed: 333 additions & 1 deletion

File tree

foreman/memory.py

Lines changed: 148 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,148 @@
1-
"""SQLite-backed memory: action_log and memory_summary."""
1+
"""SQLite-backed memory: action_log and memory_summary.
2+
3+
All reads and writes use the stdlib ``sqlite3`` module directly — no ORM,
4+
no mocks. Tests must use a real temp-file or in-memory database.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import json
10+
import sqlite3
11+
from typing import TYPE_CHECKING, Optional
12+
13+
if TYPE_CHECKING:
14+
from pathlib import Path
15+
16+
from foreman.protocol import ActionItem, DecisionType
17+
18+
_SCHEMA = """
19+
PRAGMA journal_mode=WAL;
20+
21+
CREATE TABLE IF NOT EXISTS action_log (
22+
id INTEGER PRIMARY KEY,
23+
repo TEXT NOT NULL,
24+
issue_id INTEGER NOT NULL,
25+
task_type TEXT NOT NULL,
26+
decision TEXT NOT NULL,
27+
rationale TEXT,
28+
actions TEXT,
29+
timestamp DATETIME DEFAULT CURRENT_TIMESTAMP
30+
);
31+
32+
CREATE TABLE IF NOT EXISTS memory_summary (
33+
repo TEXT NOT NULL,
34+
issue_id INTEGER NOT NULL,
35+
summary TEXT NOT NULL,
36+
updated_at DATETIME,
37+
PRIMARY KEY (repo, issue_id)
38+
);
39+
"""
40+
41+
42+
class MemoryStore:
43+
"""Persistent action memory backed by a SQLite database.
44+
45+
Creates the database file and schema on first use. The connection is kept
46+
open for the lifetime of the instance; call :meth:`close` (or use as a
47+
context manager) when done.
48+
49+
Args:
50+
db_path: Filesystem path to the SQLite database file.
51+
Intermediate directories are created automatically.
52+
"""
53+
54+
def __init__(self, db_path: Path) -> None:
55+
self.db_path = db_path
56+
db_path.parent.mkdir(parents=True, exist_ok=True)
57+
self._conn = sqlite3.connect(str(db_path))
58+
self._conn.executescript(_SCHEMA)
59+
self._conn.commit()
60+
61+
# ------------------------------------------------------------------
62+
# Action log
63+
# ------------------------------------------------------------------
64+
65+
def log_action(
66+
self,
67+
repo: str,
68+
issue_id: int,
69+
task_type: str,
70+
decision: DecisionType,
71+
rationale: Optional[str],
72+
actions: list[ActionItem],
73+
) -> None:
74+
"""Append a decision record to ``action_log``.
75+
76+
Args:
77+
repo: Repository in ``owner/repo`` format.
78+
issue_id: GitHub issue number.
79+
task_type: Task type string (e.g. ``issue.triage``).
80+
decision: The agent's decision.
81+
rationale: Human-readable explanation, or ``None``.
82+
actions: Ordered list of actions the harness will execute.
83+
"""
84+
actions_json = json.dumps([a.model_dump() for a in actions])
85+
self._conn.execute(
86+
"""
87+
INSERT INTO action_log (repo, issue_id, task_type, decision, rationale, actions)
88+
VALUES (?, ?, ?, ?, ?, ?)
89+
""",
90+
(repo, issue_id, task_type, decision.value, rationale, actions_json),
91+
)
92+
self._conn.commit()
93+
94+
# ------------------------------------------------------------------
95+
# Memory summary
96+
# ------------------------------------------------------------------
97+
98+
def get_memory_summary(self, repo: str, issue_id: int) -> Optional[str]:
99+
"""Return the stored summary for a (repo, issue_id) pair, or ``None``.
100+
101+
Args:
102+
repo: Repository in ``owner/repo`` format.
103+
issue_id: GitHub issue number.
104+
105+
Returns:
106+
The LLM-generated summary string, or ``None`` if absent.
107+
"""
108+
row = self._conn.execute(
109+
"SELECT summary FROM memory_summary WHERE repo = ? AND issue_id = ?",
110+
(repo, issue_id),
111+
).fetchone()
112+
return row[0] if row else None
113+
114+
def upsert_memory_summary(self, repo: str, issue_id: int, summary: str) -> None:
115+
"""Insert or replace the memory summary for a (repo, issue_id) pair.
116+
117+
Args:
118+
repo: Repository in ``owner/repo`` format.
119+
issue_id: GitHub issue number.
120+
summary: LLM-generated summary of prior actions on this issue.
121+
"""
122+
self._conn.execute(
123+
"""
124+
INSERT INTO memory_summary (repo, issue_id, summary, updated_at)
125+
VALUES (?, ?, ?, CURRENT_TIMESTAMP)
126+
ON CONFLICT (repo, issue_id) DO UPDATE SET
127+
summary = excluded.summary,
128+
updated_at = excluded.updated_at
129+
""",
130+
(repo, issue_id, summary),
131+
)
132+
self._conn.commit()
133+
134+
# ------------------------------------------------------------------
135+
# Lifecycle
136+
# ------------------------------------------------------------------
137+
138+
def close(self) -> None:
139+
"""Close the database connection."""
140+
self._conn.close()
141+
142+
def __enter__(self) -> MemoryStore:
143+
"""Return self for use as a context manager."""
144+
return self
145+
146+
def __exit__(self, *_: object) -> None:
147+
"""Close the connection on context exit."""
148+
self.close()

tests/test_memory.py

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
"""Tests for foreman/memory.py — MemoryStore (action_log + memory_summary)."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from pathlib import Path
7+
8+
import pytest
9+
10+
from foreman.memory import MemoryStore
11+
from foreman.protocol import ActionItem, DecisionType
12+
13+
14+
class TestMemoryStoreInit:
15+
"""Tests for MemoryStore initialisation and schema creation."""
16+
17+
def test_creates_db_file(self, tmp_path: Path) -> None:
18+
"""MemoryStore creates the SQLite DB file at the given path."""
19+
db_path = tmp_path / "memory.db"
20+
MemoryStore(db_path)
21+
assert db_path.exists()
22+
23+
def test_creates_parent_directories(self, tmp_path: Path) -> None:
24+
"""MemoryStore creates intermediate directories if they don't exist."""
25+
db_path = tmp_path / "nested" / "dir" / "memory.db"
26+
MemoryStore(db_path)
27+
assert db_path.exists()
28+
29+
def test_action_log_table_exists(self, tmp_path: Path) -> None:
30+
"""action_log table is created with the correct schema."""
31+
import sqlite3
32+
33+
db_path = tmp_path / "memory.db"
34+
store = MemoryStore(db_path)
35+
store.close()
36+
with sqlite3.connect(db_path) as conn:
37+
rows = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='action_log'").fetchall()
38+
assert rows, "action_log table should exist"
39+
40+
def test_memory_summary_table_exists(self, tmp_path: Path) -> None:
41+
"""memory_summary table is created with the correct schema."""
42+
import sqlite3
43+
44+
db_path = tmp_path / "memory.db"
45+
store = MemoryStore(db_path)
46+
store.close()
47+
with sqlite3.connect(db_path) as conn:
48+
rows = conn.execute(
49+
"SELECT name FROM sqlite_master WHERE type='table' AND name='memory_summary'"
50+
).fetchall()
51+
assert rows, "memory_summary table should exist"
52+
53+
54+
class TestLogAction:
55+
"""Tests for MemoryStore.log_action()."""
56+
57+
@pytest.fixture()
58+
def store(self, tmp_path: Path) -> MemoryStore:
59+
"""Return a fresh MemoryStore backed by a temp-file DB."""
60+
return MemoryStore(tmp_path / "memory.db")
61+
62+
def test_log_action_inserts_row(self, store: MemoryStore) -> None:
63+
"""log_action writes a record to action_log."""
64+
import sqlite3
65+
66+
store.log_action(
67+
repo="owner/repo",
68+
issue_id=42,
69+
task_type="issue.triage",
70+
decision=DecisionType.label_and_respond,
71+
rationale="Matches bug pattern.",
72+
actions=[ActionItem(type="add_label", label="bug")],
73+
)
74+
store.close()
75+
76+
with sqlite3.connect(store.db_path) as conn:
77+
rows = conn.execute("SELECT * FROM action_log").fetchall()
78+
assert len(rows) == 1
79+
80+
def test_log_action_stores_correct_values(self, store: MemoryStore) -> None:
81+
"""log_action stores repo, issue_id, task_type, decision, rationale, and actions."""
82+
import sqlite3
83+
84+
actions = [ActionItem(type="add_label", label="bug"), ActionItem(type="comment", body="Hi")]
85+
store.log_action(
86+
repo="owner/repo",
87+
issue_id=7,
88+
task_type="issue.triage",
89+
decision=DecisionType.label_and_respond,
90+
rationale="Looks like a bug.",
91+
actions=actions,
92+
)
93+
store.close()
94+
95+
with sqlite3.connect(store.db_path) as conn:
96+
row = conn.execute(
97+
"SELECT repo, issue_id, task_type, decision, rationale, actions FROM action_log"
98+
).fetchone()
99+
100+
assert row[0] == "owner/repo"
101+
assert row[1] == 7
102+
assert row[2] == "issue.triage"
103+
assert row[3] == "label_and_respond"
104+
assert row[4] == "Looks like a bug."
105+
parsed_actions = json.loads(row[5])
106+
assert len(parsed_actions) == 2
107+
assert parsed_actions[0]["type"] == "add_label"
108+
109+
def test_log_action_multiple_entries(self, store: MemoryStore) -> None:
110+
"""Multiple log_action calls produce multiple rows."""
111+
import sqlite3
112+
113+
for issue_id in range(3):
114+
store.log_action(
115+
repo="owner/repo",
116+
issue_id=issue_id,
117+
task_type="issue.triage",
118+
decision=DecisionType.skip,
119+
rationale="No action.",
120+
actions=[],
121+
)
122+
store.close()
123+
124+
with sqlite3.connect(store.db_path) as conn:
125+
count = conn.execute("SELECT COUNT(*) FROM action_log").fetchone()[0]
126+
assert count == 3
127+
128+
def test_log_action_null_rationale(self, store: MemoryStore) -> None:
129+
"""log_action accepts None rationale."""
130+
import sqlite3
131+
132+
store.log_action(
133+
repo="owner/repo",
134+
issue_id=1,
135+
task_type="issue.triage",
136+
decision=DecisionType.skip,
137+
rationale=None,
138+
actions=[],
139+
)
140+
store.close()
141+
142+
with sqlite3.connect(store.db_path) as conn:
143+
row = conn.execute("SELECT rationale FROM action_log").fetchone()
144+
assert row[0] is None
145+
146+
147+
class TestMemorySummary:
148+
"""Tests for MemoryStore.get_memory_summary() and upsert_memory_summary()."""
149+
150+
@pytest.fixture()
151+
def store(self, tmp_path: Path) -> MemoryStore:
152+
"""Return a fresh MemoryStore backed by a temp-file DB."""
153+
return MemoryStore(tmp_path / "memory.db")
154+
155+
def test_get_summary_returns_none_when_absent(self, store: MemoryStore) -> None:
156+
"""get_memory_summary returns None when no summary exists for the issue."""
157+
result = store.get_memory_summary("owner/repo", 42)
158+
assert result is None
159+
160+
def test_upsert_creates_summary(self, store: MemoryStore) -> None:
161+
"""upsert_memory_summary creates a new summary record."""
162+
store.upsert_memory_summary("owner/repo", 42, "Issue labeled as bug.")
163+
result = store.get_memory_summary("owner/repo", 42)
164+
assert result == "Issue labeled as bug."
165+
166+
def test_upsert_updates_existing_summary(self, store: MemoryStore) -> None:
167+
"""upsert_memory_summary replaces an existing summary."""
168+
store.upsert_memory_summary("owner/repo", 42, "First summary.")
169+
store.upsert_memory_summary("owner/repo", 42, "Updated summary.")
170+
result = store.get_memory_summary("owner/repo", 42)
171+
assert result == "Updated summary."
172+
173+
def test_summaries_are_per_issue(self, store: MemoryStore) -> None:
174+
"""Each (repo, issue_id) pair has its own independent summary."""
175+
store.upsert_memory_summary("owner/repo", 1, "Summary for issue 1.")
176+
store.upsert_memory_summary("owner/repo", 2, "Summary for issue 2.")
177+
assert store.get_memory_summary("owner/repo", 1) == "Summary for issue 1."
178+
assert store.get_memory_summary("owner/repo", 2) == "Summary for issue 2."
179+
180+
def test_summaries_are_per_repo(self, store: MemoryStore) -> None:
181+
"""The same issue number in different repos has independent summaries."""
182+
store.upsert_memory_summary("org1/repo", 5, "Org1 summary.")
183+
store.upsert_memory_summary("org2/repo", 5, "Org2 summary.")
184+
assert store.get_memory_summary("org1/repo", 5) == "Org1 summary."
185+
assert store.get_memory_summary("org2/repo", 5) == "Org2 summary."

0 commit comments

Comments
 (0)