-
Notifications
You must be signed in to change notification settings - Fork 1
feat: add pluggable PersistenceBackend protocol with SQLite implementation #179
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 2 commits
e49eb86
6e2dd43
a010a76
eb4374f
0fee5d1
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
|
|
@@ -12,7 +12,7 @@ | |||||
| 4. [Company Structure](#4-company-structure) | ||||||
| 5. [Communication Architecture](#5-communication-architecture) — 5.6 Conflict Resolution, 5.7 Meeting Protocol | ||||||
| 6. [Task & Workflow Engine](#6-task--workflow-engine) — 6.5 Execution Loop, 6.6 Crash Recovery, **6.7 Graceful Shutdown**, **6.8 Workspace Isolation**, **6.9 Task Decomposability & Coordination Topology** | ||||||
| 7. [Memory & Persistence](#7-memory--persistence) — 7.4 Shared Org Memory (Research Directions) | ||||||
| 7. [Memory & Persistence](#7-memory--persistence) — 7.4 Shared Org Memory (Research Directions), **7.5 Operational Data Persistence** | ||||||
| 8. [HR & Workforce Management](#8-hr--workforce-management) | ||||||
| 9. [Model Provider Layer](#9-model-provider-layer) | ||||||
| 10. [Cost & Budget Management](#10-cost--budget-management) | ||||||
|
|
@@ -1332,6 +1332,143 @@ org_memory: | |||||
| > **Extensibility:** All backends implement the `OrgMemoryBackend` protocol (`query(context) → list[OrgFact]`, `write(fact, author)`, `list_policies()`). The MVP ships with Backend 1; Backends 2 and 3 are research directions that may be explored if the default approach proves insufficient. The selected memory layer backend Mem0 (ADR-001) provides optional graph memory via Neo4j/FalkorDB, which could reduce implementation effort for Backends 2-3. | ||||||
| > **Write access control:** Core policies are human-only. ADRs and procedures can be written by senior+ agents. All writes are versioned and auditable. This prevents agents from corrupting shared organizational knowledge while allowing senior agents to document decisions. | ||||||
|
|
||||||
| ### 7.5 Operational Data Persistence | ||||||
|
|
||||||
| Agent memory (§7.1–7.4) is handled by the `MemoryBackend` protocol (Mem0 initial, custom stack future — ADR-001). **Operational data** — tasks, cost records, messages, audit logs — is a separate concern managed by a pluggable `PersistenceBackend` protocol. Application code depends only on repository protocols; the storage engine is an implementation detail swappable via config. | ||||||
|
|
||||||
| ```text | ||||||
| ┌──────────────────────────────────────────────────────────────────┐ | ||||||
| │ Application Code │ | ||||||
| │ engine/ budget/ communication/ security/ │ | ||||||
| │ │ │ │ │ │ | ||||||
| │ ▼ ▼ ▼ ▼ │ | ||||||
| │ ┌──────┐ ┌──────┐ ┌──────────┐ ┌──────────┐ │ | ||||||
| │ │ Task │ │ Cost │ │ Message │ │ Audit │ ← Repository │ | ||||||
| │ │ Repo │ │ Repo │ │ Repo │ │ Repo │ Protocols │ | ||||||
| │ └──┬───┘ └──┬───┘ └────┬─────┘ └────┬─────┘ │ | ||||||
| │ └────────┴──────────┴────────────┘ │ | ||||||
| │ │ │ | ||||||
| │ ┌───────────────────┴───────────────────────────────────────┐ │ | ||||||
| │ │ PersistenceBackend (protocol) │ │ | ||||||
| │ │ connect() · disconnect() · health_check() · migrate() │ │ | ||||||
| │ └───────────────────┬───────────────────────────────────────┘ │ | ||||||
| │ │ │ | ||||||
| │ ┌───────────────────┴───────────────────────────────────────┐ │ | ||||||
| │ │ SQLitePersistenceBackend (initial) │ │ | ||||||
| │ │ PostgresPersistenceBackend (future) │ │ | ||||||
| │ │ MariaDBPersistenceBackend (future) │ │ | ||||||
| │ └───────────────────────────────────────────────────────────┘ │ | ||||||
| └──────────────────────────────────────────────────────────────────┘ | ||||||
| ``` | ||||||
|
|
||||||
| #### Protocol Design | ||||||
|
|
||||||
| ```python | ||||||
| @runtime_checkable | ||||||
| class PersistenceBackend(Protocol): | ||||||
| """Lifecycle management for operational data storage.""" | ||||||
|
|
||||||
| async def connect(self) -> None: ... | ||||||
| async def disconnect(self) -> None: ... | ||||||
| async def health_check(self) -> bool: ... | ||||||
| async def migrate(self) -> None: ... | ||||||
|
|
||||||
| @property | ||||||
| def is_connected(self) -> bool: ... | ||||||
| @property | ||||||
| def backend_name(self) -> str: ... | ||||||
|
|
||||||
| @property | ||||||
| def tasks(self) -> TaskRepository: ... | ||||||
| @property | ||||||
| def cost_records(self) -> CostRecordRepository: ... | ||||||
| @property | ||||||
| def messages(self) -> MessageRepository: ... | ||||||
| ``` | ||||||
|
|
||||||
| Each entity type has its own repository protocol: | ||||||
|
|
||||||
| ```python | ||||||
| @runtime_checkable | ||||||
| class TaskRepository(Protocol): | ||||||
| """CRUD + query interface for Task persistence.""" | ||||||
|
|
||||||
| async def save(self, task: Task) -> None: ... | ||||||
| async def get(self, task_id: str) -> Task | None: ... | ||||||
| async def list_tasks(self, *, status: TaskStatus | None = None, assigned_to: str | None = None, project: str | None = None) -> tuple[Task, ...]: ... | ||||||
| async def delete(self, task_id: str) -> bool: ... | ||||||
|
|
||||||
| @runtime_checkable | ||||||
| class CostRecordRepository(Protocol): | ||||||
| """CRUD + aggregation interface for CostRecord persistence.""" | ||||||
|
|
||||||
| async def save(self, record: CostRecord) -> None: ... | ||||||
| async def query(self, *, agent_id: str | None = None, task_id: str | None = None) -> tuple[CostRecord, ...]: ... | ||||||
| async def aggregate(self, *, agent_id: str | None = None) -> float: ... | ||||||
|
|
||||||
| @runtime_checkable | ||||||
| class MessageRepository(Protocol): | ||||||
| """CRUD + query interface for Message persistence.""" | ||||||
|
|
||||||
| async def save(self, message: Message) -> None: ... | ||||||
| async def get_history(self, channel: str, *, limit: int | None = None) -> tuple[Message, ...]: ... | ||||||
| ``` | ||||||
|
|
||||||
| #### Configuration | ||||||
|
|
||||||
| ```yaml | ||||||
| persistence: | ||||||
| backend: "sqlite" # sqlite, postgresql, mariadb (future) | ||||||
| sqlite: | ||||||
| path: "/data/ai-company.db" # database file path (mounted volume in Docker) | ||||||
| wal_mode: true # WAL for concurrent read performance | ||||||
| journal_size_limit: 67108864 # 64 MB WAL journal limit | ||||||
| # postgresql: # future | ||||||
| # url: "postgresql://user:pass@host:5432/ai_company" | ||||||
| # pool_size: 10 | ||||||
| # mariadb: # future | ||||||
| # url: "mariadb://user:pass@host:3306/ai_company" | ||||||
| # pool_size: 10 | ||||||
| ``` | ||||||
|
|
||||||
| #### Entities Persisted | ||||||
|
|
||||||
| | Entity | Source Module | Repository | Key Queries | | ||||||
| |--------|-------------|------------|-------------| | ||||||
| | `Task` | `core/task.py` | `TaskRepository` | by status, by assignee, by project | | ||||||
| | `CostRecord` | `budget/cost_record.py` | `CostRecordRepository` | by agent, by task, aggregations | | ||||||
| | `Message` | `communication/message.py` | `MessageRepository` | by channel, by sender, time range | | ||||||
| | Audit entries | `security/` | `AuditRepository` | by agent, by action type, time range | | ||||||
|
||||||
| | Audit entries | `security/` | `AuditRepository` | by agent, by action type, time range | | |
| | Audit entries (planned) | `security/` (planned) | `AuditRepository` (planned) | planned: by agent, by action type, time range | |
Copilot
AI
Mar 8, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
DESIGN_SPEC §7.5 says versioned migration scripts are tracked in persistence/migrations/, but this PR’s implementation places migrations under ai_company/persistence/sqlite/migrations.py (and there is no persistence/migrations/ package). Please update this bullet to match the actual structure (e.g., per-backend migrations modules/directories) to avoid sending implementers to a non-existent path.
| - Versioned migration scripts tracked in `persistence/migrations/` | |
| - Versioned migration logic is implemented per-backend (e.g. `ai_company/persistence/sqlite/migrations.py` for SQLite), rather than in a shared `persistence/migrations/` package |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,57 @@ | ||
| """Persistence event constants for structured logging. | ||
|
|
||
| Constants follow the ``persistence.<entity>.<action>`` naming convention | ||
| and are passed as the first argument to ``logger.info()``/``logger.debug()`` | ||
| calls in the persistence layer. | ||
| """ | ||
|
|
||
| from typing import Final | ||
|
|
||
| PERSISTENCE_BACKEND_CONNECTING: Final[str] = "persistence.backend.connecting" | ||
| PERSISTENCE_BACKEND_CONNECTED: Final[str] = "persistence.backend.connected" | ||
| PERSISTENCE_BACKEND_CONNECTION_FAILED: Final[str] = ( | ||
| "persistence.backend.connection_failed" | ||
| ) | ||
| PERSISTENCE_BACKEND_ALREADY_CONNECTED: Final[str] = ( | ||
| "persistence.backend.already_connected" | ||
| ) | ||
| PERSISTENCE_BACKEND_DISCONNECTING: Final[str] = "persistence.backend.disconnecting" | ||
| PERSISTENCE_BACKEND_DISCONNECTED: Final[str] = "persistence.backend.disconnected" | ||
| PERSISTENCE_BACKEND_DISCONNECT_ERROR: Final[str] = ( | ||
| "persistence.backend.disconnect_error" | ||
| ) | ||
| PERSISTENCE_BACKEND_HEALTH_CHECK: Final[str] = "persistence.backend.health_check" | ||
| PERSISTENCE_BACKEND_CREATED: Final[str] = "persistence.backend.created" | ||
| PERSISTENCE_BACKEND_UNKNOWN: Final[str] = "persistence.backend.unknown" | ||
| PERSISTENCE_BACKEND_WAL_MODE_FAILED: Final[str] = "persistence.backend.wal_mode_failed" | ||
|
|
||
| PERSISTENCE_MIGRATION_STARTED: Final[str] = "persistence.migration.started" | ||
| PERSISTENCE_MIGRATION_COMPLETED: Final[str] = "persistence.migration.completed" | ||
| PERSISTENCE_MIGRATION_SKIPPED: Final[str] = "persistence.migration.skipped" | ||
| PERSISTENCE_MIGRATION_FAILED: Final[str] = "persistence.migration.failed" | ||
|
|
||
| PERSISTENCE_TASK_SAVED: Final[str] = "persistence.task.saved" | ||
| PERSISTENCE_TASK_SAVE_FAILED: Final[str] = "persistence.task.save_failed" | ||
| PERSISTENCE_TASK_FETCHED: Final[str] = "persistence.task.fetched" | ||
| PERSISTENCE_TASK_FETCH_FAILED: Final[str] = "persistence.task.fetch_failed" | ||
| PERSISTENCE_TASK_LISTED: Final[str] = "persistence.task.listed" | ||
| PERSISTENCE_TASK_LIST_FAILED: Final[str] = "persistence.task.list_failed" | ||
| PERSISTENCE_TASK_DELETED: Final[str] = "persistence.task.deleted" | ||
| PERSISTENCE_TASK_DELETE_FAILED: Final[str] = "persistence.task.delete_failed" | ||
|
|
||
| PERSISTENCE_COST_RECORD_SAVED: Final[str] = "persistence.cost_record.saved" | ||
| PERSISTENCE_COST_RECORD_SAVE_FAILED: Final[str] = "persistence.cost_record.save_failed" | ||
| PERSISTENCE_COST_RECORD_QUERIED: Final[str] = "persistence.cost_record.queried" | ||
| PERSISTENCE_COST_RECORD_QUERY_FAILED: Final[str] = ( | ||
| "persistence.cost_record.query_failed" | ||
| ) | ||
| PERSISTENCE_COST_RECORD_AGGREGATED: Final[str] = "persistence.cost_record.aggregated" | ||
| PERSISTENCE_COST_RECORD_AGGREGATE_FAILED: Final[str] = ( | ||
| "persistence.cost_record.aggregate_failed" | ||
| ) | ||
|
|
||
| PERSISTENCE_MESSAGE_SAVED: Final[str] = "persistence.message.saved" | ||
| PERSISTENCE_MESSAGE_SAVE_FAILED: Final[str] = "persistence.message.save_failed" | ||
| PERSISTENCE_MESSAGE_DUPLICATE: Final[str] = "persistence.message.duplicate" | ||
| PERSISTENCE_MESSAGE_HISTORY_FETCHED: Final[str] = "persistence.message.history_fetched" | ||
| PERSISTENCE_MESSAGE_HISTORY_FAILED: Final[str] = "persistence.message.history_failed" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| """Pluggable persistence layer for operational data (DESIGN_SPEC §7.5). | ||
|
|
||
| Re-exports the protocol, repository protocols, config models, factory, | ||
| and error hierarchy so consumers can import from ``ai_company.persistence`` | ||
| directly. | ||
| """ | ||
|
|
||
| from ai_company.persistence.config import PersistenceConfig, SQLiteConfig | ||
| from ai_company.persistence.errors import ( | ||
| DuplicateRecordError, | ||
| MigrationError, | ||
| PersistenceConnectionError, | ||
| PersistenceError, | ||
| QueryError, | ||
| RecordNotFoundError, | ||
| ) | ||
| from ai_company.persistence.factory import create_backend | ||
| from ai_company.persistence.protocol import PersistenceBackend | ||
| from ai_company.persistence.repositories import ( | ||
| CostRecordRepository, | ||
| MessageRepository, | ||
| TaskRepository, | ||
| ) | ||
|
|
||
| __all__ = [ | ||
| "CostRecordRepository", | ||
| "DuplicateRecordError", | ||
| "MessageRepository", | ||
| "MigrationError", | ||
| "PersistenceBackend", | ||
| "PersistenceConfig", | ||
| "PersistenceConnectionError", | ||
| "PersistenceError", | ||
| "QueryError", | ||
| "RecordNotFoundError", | ||
| "SQLiteConfig", | ||
| "TaskRepository", | ||
| "create_backend", | ||
| ] |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
MessageRepositoryprotocol definition forget_historyseems less capable than what is described in the "Entities Persisted" table (line 1440). The protocol only allows filtering bychannelandlimit, while the table also mentions filtering bysenderandtime range. It would be good to align the protocol with the documented query capabilities for consistency, or update the table to reflect the current protocol's scope.