Skip to content

Commit d6294a0

Browse files
tildesrcclaude
andcommitted
feat(persistence): repository port + in-memory & SQLite adapters
Slice 1, PR 2 of 4 — the persistence boundary. - panopticon.core.repository: the Repository ABC (the port, ADR 0001/0006) plus shared integrity checks (non-empty consistent history; append-only history). - panopticon.taskservice.repository_memory.InMemoryRepository: dict-backed adapter (stores deep copies) for tests and the walking-skeleton runner. - panopticon.taskservice.repository_sqlite.SqliteRepository: SQLite adapter with user_version-based forward migrations; SQLite specifics stay encapsulated. - Contract tests parametrized over BOTH backends — proving the port is genuinely backend-agnostic and that both enforce identical integrity rules. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 2fac904 commit d6294a0

5 files changed

Lines changed: 551 additions & 0 deletions

File tree

src/panopticon/core/repository.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
"""The repository port: the abstraction over persisted task state.
2+
3+
A backend-agnostic interface (ADR 0001/0006). The task service is its sole owner and the
4+
single writer; adapters implement it (in-memory and SQLite in this slice; Postgres later).
5+
6+
Integrity rules every adapter must enforce — the "transition enforcement at the boundary":
7+
8+
* ``create_task`` — the task id is unique, its ``repo_id`` exists, its history is
9+
non-empty, and ``state`` equals the last history entry's ``to_state``.
10+
* ``save_task`` — the task exists, its history is **append-only** (the stored history is a
11+
prefix of the supplied one), and ``state`` equals the last history entry's ``to_state``.
12+
13+
The *legality* of a transition (which state may follow which) is decided by the engine
14+
before save; the repository guarantees the persisted record stays internally consistent.
15+
"""
16+
17+
from __future__ import annotations
18+
19+
from abc import ABC, abstractmethod
20+
from collections.abc import Sequence
21+
22+
from panopticon.core.models import HistoryEntry, Repo, Task
23+
24+
25+
class RepositoryError(Exception):
26+
"""Base class for repository failures."""
27+
28+
29+
class NotFound(RepositoryError):
30+
"""Raised when an entity referenced by id does not exist."""
31+
32+
33+
class AlreadyExists(RepositoryError):
34+
"""Raised when creating an entity whose id is already taken."""
35+
36+
37+
class IntegrityError(RepositoryError):
38+
"""Raised when a write would violate an integrity rule (e.g. non-append-only history)."""
39+
40+
41+
class Repository(ABC):
42+
"""Persistence boundary for repos and tasks."""
43+
44+
# -- repos --------------------------------------------------------------------
45+
46+
@abstractmethod
47+
def create_repo(self, repo: Repo) -> None:
48+
"""Persist a new repo. Raises :class:`AlreadyExists` if its id is taken."""
49+
50+
@abstractmethod
51+
def get_repo(self, repo_id: str) -> Repo | None:
52+
"""Return the repo, or ``None`` if it does not exist."""
53+
54+
@abstractmethod
55+
def list_repos(self) -> list[Repo]:
56+
"""Return all repos."""
57+
58+
# -- tasks --------------------------------------------------------------------
59+
60+
@abstractmethod
61+
def create_task(self, task: Task) -> None:
62+
"""Persist a new task and its initial history. Enforces the create-time rules."""
63+
64+
@abstractmethod
65+
def get_task(self, task_id: str) -> Task | None:
66+
"""Return the task (with full history), or ``None`` if it does not exist."""
67+
68+
@abstractmethod
69+
def list_tasks(self) -> list[Task]:
70+
"""Return all tasks (with full history)."""
71+
72+
@abstractmethod
73+
def save_task(self, task: Task) -> None:
74+
"""Persist an updated task. Enforces the append-only and consistency rules."""
75+
76+
77+
# -- Shared integrity checks (adapters call these so the rules live in one place) --------
78+
79+
80+
def validate_task_invariants(task: Task) -> None:
81+
"""Check a task is internally consistent: non-empty history, state matches its tail."""
82+
if not task.history:
83+
raise IntegrityError(f"task {task.id!r} has empty history")
84+
if task.state != task.history[-1].to_state:
85+
raise IntegrityError(
86+
f"task {task.id!r}: state {task.state!r} != last history to_state "
87+
f"{task.history[-1].to_state!r}"
88+
)
89+
90+
91+
def validate_append_only(
92+
stored: Sequence[HistoryEntry], incoming: Sequence[HistoryEntry]
93+
) -> None:
94+
"""Check ``incoming`` history only extends ``stored`` (no edits, no shrink)."""
95+
if len(incoming) < len(stored):
96+
raise IntegrityError("history shrank (not append-only)")
97+
if list(stored) != list(incoming[: len(stored)]):
98+
raise IntegrityError("existing history was modified (not append-only)")
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
"""The task service: the deterministic control plane.
2+
3+
Owns the repository (the sole DB authority, ADR 0006), hosts the workflow registry, and
4+
drives task lifecycle. This package must remain LLM-free (enforced by a determinism test).
5+
"""
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
"""In-memory repository adapter.
2+
3+
Useful for tests and the walking-skeleton runner, and — together with the SQLite
4+
adapter — proof that the repository port is genuinely backend-agnostic (ADR 0006).
5+
Stores deep copies so callers can't mutate persisted state by holding a reference.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import copy
11+
12+
from panopticon.core.models import Repo, Task
13+
from panopticon.core.repository import (
14+
AlreadyExists,
15+
NotFound,
16+
Repository,
17+
validate_append_only,
18+
validate_task_invariants,
19+
)
20+
21+
22+
class InMemoryRepository(Repository):
23+
"""A dict-backed :class:`~panopticon.core.repository.Repository`."""
24+
25+
def __init__(self) -> None:
26+
self._repos: dict[str, Repo] = {}
27+
self._tasks: dict[str, Task] = {}
28+
29+
# -- repos --------------------------------------------------------------------
30+
31+
def create_repo(self, repo: Repo) -> None:
32+
if repo.id in self._repos:
33+
raise AlreadyExists(f"repo {repo.id!r} already exists")
34+
self._repos[repo.id] = copy.deepcopy(repo)
35+
36+
def get_repo(self, repo_id: str) -> Repo | None:
37+
repo = self._repos.get(repo_id)
38+
return copy.deepcopy(repo) if repo is not None else None
39+
40+
def list_repos(self) -> list[Repo]:
41+
return [copy.deepcopy(r) for r in self._repos.values()]
42+
43+
# -- tasks --------------------------------------------------------------------
44+
45+
def create_task(self, task: Task) -> None:
46+
if task.id in self._tasks:
47+
raise AlreadyExists(f"task {task.id!r} already exists")
48+
if task.repo_id not in self._repos:
49+
raise NotFound(f"repo {task.repo_id!r} does not exist")
50+
validate_task_invariants(task)
51+
self._tasks[task.id] = copy.deepcopy(task)
52+
53+
def get_task(self, task_id: str) -> Task | None:
54+
task = self._tasks.get(task_id)
55+
return copy.deepcopy(task) if task is not None else None
56+
57+
def list_tasks(self) -> list[Task]:
58+
return [copy.deepcopy(t) for t in self._tasks.values()]
59+
60+
def save_task(self, task: Task) -> None:
61+
stored = self._tasks.get(task.id)
62+
if stored is None:
63+
raise NotFound(f"task {task.id!r} does not exist")
64+
validate_task_invariants(task)
65+
validate_append_only(stored.history, task.history)
66+
self._tasks[task.id] = copy.deepcopy(task)
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
"""SQLite repository adapter (ADR 0001: SQLite first, behind the backend-agnostic port).
2+
3+
Schema is versioned via ``PRAGMA user_version`` and migrated forward on open. SQLite-
4+
specific concerns stay encapsulated here; callers see only the :class:`Repository` port.
5+
"""
6+
7+
from __future__ import annotations
8+
9+
import sqlite3
10+
from collections.abc import Iterable
11+
from pathlib import Path
12+
from typing import cast
13+
14+
from panopticon.core.models import Ball, HistoryEntry, Repo, Task
15+
from panopticon.core.repository import (
16+
AlreadyExists,
17+
NotFound,
18+
Repository,
19+
validate_append_only,
20+
validate_task_invariants,
21+
)
22+
23+
# Forward-only schema migrations. Index i in this list corresponds to user_version i+1.
24+
MIGRATIONS: tuple[str, ...] = (
25+
# v1 — initial schema
26+
"""
27+
CREATE TABLE repo (
28+
id TEXT PRIMARY KEY,
29+
name TEXT NOT NULL,
30+
default_base TEXT NOT NULL
31+
);
32+
CREATE TABLE task (
33+
id TEXT PRIMARY KEY,
34+
repo_id TEXT NOT NULL REFERENCES repo(id),
35+
workflow TEXT NOT NULL,
36+
state TEXT NOT NULL,
37+
ball TEXT NOT NULL,
38+
slug TEXT
39+
);
40+
CREATE TABLE history (
41+
task_id TEXT NOT NULL REFERENCES task(id),
42+
seq INTEGER NOT NULL,
43+
at TEXT NOT NULL,
44+
from_state TEXT,
45+
to_state TEXT NOT NULL,
46+
via TEXT,
47+
note TEXT,
48+
PRIMARY KEY (task_id, seq)
49+
);
50+
""",
51+
)
52+
53+
54+
class SqliteRepository(Repository):
55+
"""A SQLite-backed :class:`~panopticon.core.repository.Repository`."""
56+
57+
def __init__(self, path: str | Path = ":memory:") -> None:
58+
self._conn = sqlite3.connect(str(path))
59+
self._conn.row_factory = sqlite3.Row
60+
self._conn.execute("PRAGMA foreign_keys = ON")
61+
self._migrate()
62+
63+
def close(self) -> None:
64+
self._conn.close()
65+
66+
def _migrate(self) -> None:
67+
version = cast(int, self._conn.execute("PRAGMA user_version").fetchone()[0])
68+
for i in range(version, len(MIGRATIONS)):
69+
self._conn.executescript(MIGRATIONS[i])
70+
self._conn.execute(f"PRAGMA user_version = {i + 1}")
71+
self._conn.commit()
72+
73+
# -- repos --------------------------------------------------------------------
74+
75+
def create_repo(self, repo: Repo) -> None:
76+
try:
77+
self._conn.execute(
78+
"INSERT INTO repo (id, name, default_base) VALUES (?, ?, ?)",
79+
(repo.id, repo.name, repo.default_base),
80+
)
81+
except sqlite3.IntegrityError as exc:
82+
raise AlreadyExists(f"repo {repo.id!r} already exists") from exc
83+
self._conn.commit()
84+
85+
def get_repo(self, repo_id: str) -> Repo | None:
86+
row = self._conn.execute(
87+
"SELECT id, name, default_base FROM repo WHERE id = ?", (repo_id,)
88+
).fetchone()
89+
return _row_to_repo(row) if row is not None else None
90+
91+
def list_repos(self) -> list[Repo]:
92+
rows = self._conn.execute("SELECT id, name, default_base FROM repo ORDER BY id")
93+
return [_row_to_repo(r) for r in rows]
94+
95+
# -- tasks --------------------------------------------------------------------
96+
97+
def create_task(self, task: Task) -> None:
98+
validate_task_invariants(task)
99+
if self._task_exists(task.id):
100+
raise AlreadyExists(f"task {task.id!r} already exists")
101+
if self.get_repo(task.repo_id) is None:
102+
raise NotFound(f"repo {task.repo_id!r} does not exist")
103+
with self._conn: # transaction
104+
self._conn.execute(
105+
"INSERT INTO task (id, repo_id, workflow, state, ball, slug) "
106+
"VALUES (?, ?, ?, ?, ?, ?)",
107+
(task.id, task.repo_id, task.workflow, task.state, task.ball.value, task.slug),
108+
)
109+
self._insert_history(task.id, 0, task.history)
110+
111+
def get_task(self, task_id: str) -> Task | None:
112+
row = self._conn.execute(
113+
"SELECT id, repo_id, workflow, state, ball, slug FROM task WHERE id = ?",
114+
(task_id,),
115+
).fetchone()
116+
if row is None:
117+
return None
118+
return _row_to_task(row, self._load_history(task_id))
119+
120+
def list_tasks(self) -> list[Task]:
121+
rows = self._conn.execute(
122+
"SELECT id, repo_id, workflow, state, ball, slug FROM task ORDER BY id"
123+
).fetchall()
124+
return [_row_to_task(r, self._load_history(cast(str, r["id"]))) for r in rows]
125+
126+
def save_task(self, task: Task) -> None:
127+
validate_task_invariants(task)
128+
stored = self._load_history(task.id)
129+
if not self._task_exists(task.id):
130+
raise NotFound(f"task {task.id!r} does not exist")
131+
validate_append_only(stored, task.history)
132+
with self._conn: # transaction
133+
self._conn.execute(
134+
"UPDATE task SET state = ?, ball = ?, slug = ? WHERE id = ?",
135+
(task.state, task.ball.value, task.slug, task.id),
136+
)
137+
self._insert_history(task.id, len(stored), task.history[len(stored) :])
138+
139+
# -- helpers ------------------------------------------------------------------
140+
141+
def _task_exists(self, task_id: str) -> bool:
142+
return (
143+
self._conn.execute("SELECT 1 FROM task WHERE id = ?", (task_id,)).fetchone()
144+
is not None
145+
)
146+
147+
def _insert_history(
148+
self, task_id: str, start_seq: int, entries: Iterable[HistoryEntry]
149+
) -> None:
150+
self._conn.executemany(
151+
"INSERT INTO history (task_id, seq, at, from_state, to_state, via, note) "
152+
"VALUES (?, ?, ?, ?, ?, ?, ?)",
153+
[
154+
(task_id, start_seq + i, e.at, e.from_state, e.to_state, e.via, e.note)
155+
for i, e in enumerate(entries)
156+
],
157+
)
158+
159+
def _load_history(self, task_id: str) -> list[HistoryEntry]:
160+
rows = self._conn.execute(
161+
"SELECT at, from_state, to_state, via, note FROM history "
162+
"WHERE task_id = ? ORDER BY seq",
163+
(task_id,),
164+
).fetchall()
165+
return [
166+
HistoryEntry(
167+
at=cast(str, r["at"]),
168+
from_state=cast("str | None", r["from_state"]),
169+
to_state=cast(str, r["to_state"]),
170+
via=cast("str | None", r["via"]),
171+
note=cast("str | None", r["note"]),
172+
)
173+
for r in rows
174+
]
175+
176+
177+
def _row_to_repo(row: sqlite3.Row) -> Repo:
178+
return Repo(
179+
id=cast(str, row["id"]),
180+
name=cast(str, row["name"]),
181+
default_base=cast(str, row["default_base"]),
182+
)
183+
184+
185+
def _row_to_task(row: sqlite3.Row, history: list[HistoryEntry]) -> Task:
186+
return Task(
187+
id=cast(str, row["id"]),
188+
repo_id=cast(str, row["repo_id"]),
189+
workflow=cast(str, row["workflow"]),
190+
state=cast(str, row["state"]),
191+
ball=Ball(cast(str, row["ball"])),
192+
slug=cast("str | None", row["slug"]),
193+
history=history,
194+
)

0 commit comments

Comments
 (0)