Skip to content

Commit 00a9d5b

Browse files
tildesrcclaude
andcommitted
feat(persistence): repository interface + in-memory & SQLite adapters
Slice 1, PR 2 of 4 — the persistence boundary. - panopticon.core.repository: the Repository ABC (the interface, 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 migrations; persists the per-turn settled responsibilities in a child table tied to each history row. - Contract tests parametrized over BOTH backends — proving the interface 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 0658a01 commit 00a9d5b

5 files changed

Lines changed: 614 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 interface: 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 interface 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)

0 commit comments

Comments
 (0)