Repository navigation
feat(ui): complete postgres phase1 persistence #69
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 all commits
3810a64
9e7dbf6
827c4a1
7baf6e7
039aff5
d7ff944
492caa9
c109c4a
48f6699
e0ae8e5
2142e91
4452045
2c36291
167f4d5
1c58e90
b112863
6b9ee98
581903d
6819b0d
8fc6d23
627ac94
f33080f
97bbf42
ec9846b
4156432
20262b2
6225db9
ef19d73
deb44f7
855b080
d95224d
9431277
9038e93
75a5c82
d995dd3
ef6bf9a
6b32664
54e4ee6
26ab735
d03eedd
3455afc
0a49231
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 |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| Goal: | ||
| Complete Phase 1 PostgreSQL persistence for the HTTP/UI session stack by adding PG checkpoint storage and config-driven default persistence wiring for session metadata, tapes, and checkpoints. | ||
|
|
||
| Scope: | ||
| - Add `PGCheckpointStore` in `agentkit.storage.pg` with matching protocol tests. | ||
| - Add a synchronous UI PostgreSQL session metadata store and one app-layer persistence factory for default SessionManager/HTTP wiring. | ||
| - Update config defaults/tests so HTTP startup and SessionManager defaults can use file or PostgreSQL storage through one shared path. | ||
|
|
||
| Out of scope: | ||
| - Phase 2 ownership, fencing, approval routing, or cross-instance event routing. | ||
| - New migration tooling or schema-management framework beyond the repo's existing inline schema creation pattern. | ||
|
|
||
| Context: | ||
| - ADRs: | ||
| - `docs/adr/0012-complete-phase1-postgresql-http-session-persistence.md` | ||
| - `docs/adr/0001-checkpoint-captures-serialized-tape-and-plugin-state.md` | ||
| - `docs/adr/0003-http-sessions-use-one-stable-tape-timeline.md` | ||
| - `docs/adr/0005-checkpoint-restore-uses-truncate-rollback.md` | ||
| - Relevant files: | ||
| - `src/agentkit/storage/pg.py` | ||
| - `src/coding_agent/ui/session_store.py` | ||
| - `src/coding_agent/ui/session_manager.py` | ||
| - `src/coding_agent/ui/http_server.py` | ||
| - `src/coding_agent/agent.toml` | ||
| - `tests/agentkit/storage/test_pg.py` | ||
| - `tests/ui/test_session_persistence.py` | ||
| - `tests/ui/test_session_manager_public_api.py` | ||
| - `tests/ui/test_http_server.py` | ||
|
|
||
| Target tests: | ||
| - `uv run pytest tests/agentkit/storage/test_pg.py -k "checkpoint or session or tape" -v` | ||
| - `uv run pytest tests/ui/test_session_persistence.py tests/ui/test_session_manager_public_api.py tests/ui/test_http_server.py -k "pg or checkpoint or persistence" -v` | ||
| - `uv run pytest tests/ -v` | ||
|
|
||
| Loop policy: | ||
| - Engineer implements the smallest correct change and runs the target tests. | ||
| - Reviewer reviews only the resulting diff and affected tests. | ||
| - Reviewer reports only P1/P2 findings. | ||
| - Engineer fixes only accepted P1/P2 findings and reruns the same target tests. | ||
| - Verifier reruns the exact target tests and reports pass/fail only. | ||
|
|
||
| Stop conditions: | ||
| - At most one review/fix/retest cycle. | ||
| - Escalate architectural redirection or scope expansion to the human. | ||
| - Ignore non-blocking optimization suggestions. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| # ADR-0012: Complete Phase 1 PostgreSQL HTTP session persistence | ||
|
|
||
| **Status**: Proposed | ||
| **Date**: 2026-04-17 | ||
|
|
||
| ## Context | ||
|
|
||
| The repository already includes reusable PostgreSQL persistence primitives for session metadata and tapes in `agentkit.storage.pg`, and the HTTP/UI session stack already has stable tape and checkpoint semantics backed by tests. But the Phase 1 goal for HTTP session persistence is still incomplete on `main`. | ||
|
|
||
| `SessionManager` still defaults to the local JSONL tape store and filesystem checkpoint store, `http_server.py` still constructs `SessionManager()` directly with those defaults, and the UI session metadata layer still only provides in-memory and Redis-backed implementations. There is also no PostgreSQL `CheckpointStore` implementation in `agentkit`, which means the current HTTP checkpoint path cannot persist checkpoint snapshots/catalogs in PostgreSQL even when tape persistence uses PostgreSQL. | ||
|
|
||
| Phase 1 is still explicitly single-instance and keeps active runtime state (`task`, `approval_event`, `event_queues`, `runtime_pipeline`, `runtime_ctx`, `runtime_adapter`) local-authoritative. This ADR only closes the durable storage gap for restart-safe metadata, stable tape history, and checkpoint snapshots/catalogs. | ||
|
|
||
| ## Decision | ||
|
|
||
| Complete Phase 1 by adding PostgreSQL-backed checkpoint persistence in `agentkit` and one config-driven app-layer persistence path for the HTTP/UI session stack. | ||
|
|
||
| Specifically: | ||
|
|
||
| - Add `PGCheckpointStore` to `agentkit.storage.pg` implementing the existing `CheckpointStore` protocol. | ||
| - Add a synchronous PostgreSQL-backed UI session metadata store in `coding_agent.ui.session_store` rather than widening `SessionManager` to the async `agentkit` session protocol. | ||
| - Add a small app-layer persistence factory in `coding_agent.ui` that builds the default session metadata store, tape store, checkpoint store, and shutdown hook from the existing flat `[storage]` config in `src/coding_agent/agent.toml`. | ||
| - Make both `SessionManager()` defaults and HTTP server startup use that same persistence factory instead of hard-coded JSONL/filesystem/in-memory defaults. | ||
| - Keep runtime execution ownership, approval routing, and event routing out of scope for this ADR and Phase 1. | ||
|
|
||
| ## Alternatives Rejected | ||
|
|
||
| - Reuse the async `agentkit.PGSessionStore` directly inside `SessionManager` — rejected because it would widen a synchronous UI/session-manager boundary into a larger refactor. | ||
| - Keep checkpoints on the filesystem while only moving tapes to PostgreSQL — rejected because checkpoint restore belongs to the same stable tape timeline and Phase 1 needs durable checkpoint snapshots/catalogs. | ||
| - Add PostgreSQL support by sprinkling separate wiring logic across `SessionManager`, `http_server.py`, and REPL startup — rejected because Phase 1 needs one config-driven default path, not divergent per-entrypoint behavior. | ||
| - Expand into Phase 2 owner/lease/fencing work while touching persistence — rejected because Phase 1 remains single-instance and local-authoritative for active runtime state. | ||
|
|
||
| ## Acceptance Criteria | ||
|
|
||
| - [ ] PostgreSQL checkpoint persistence is covered by tests in `tests/agentkit/storage/test_pg.py`, including round-trip snapshot persistence and ordered listing by tape. | ||
| - [ ] Config-driven persistence bundle construction from `[storage]` configuration is covered by tests in `tests/coding_agent/plugins/test_storage_factory.py`. | ||
| - [ ] `SessionManager` default persistence wiring is covered by tests in `tests/ui/test_session_manager_public_api.py` and `tests/ui/test_session_persistence.py`. | ||
| - [ ] HTTP server startup uses the configured persistence bundle for the global session manager, covered by tests in `tests/ui/test_http_server.py`. | ||
| - [ ] `uv run pytest tests/agentkit/storage/test_pg.py tests/coding_agent/plugins/test_storage_factory.py tests/ui/test_session_persistence.py tests/ui/test_session_manager_public_api.py tests/ui/test_http_server.py -v` | ||
|
|
||
| ## References | ||
|
|
||
| - `src/agentkit/storage/pg.py` | ||
| - `src/agentkit/storage/protocols.py` | ||
| - `src/agentkit/checkpoint/service.py` | ||
| - `src/coding_agent/ui/session_store.py` | ||
| - `src/coding_agent/ui/session_manager.py` | ||
| - `src/coding_agent/ui/http_server.py` | ||
| - `src/coding_agent/agent.toml` | ||
| - `tests/agentkit/storage/test_pg.py` | ||
| - `tests/ui/test_session_persistence.py` | ||
| - `tests/ui/test_session_manager_public_api.py` | ||
| - `tests/ui/test_http_server.py` |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,8 +4,11 @@ | |||||||||||||||||
| import importlib | ||||||||||||||||||
| import json | ||||||||||||||||||
| from collections.abc import Awaitable, Callable | ||||||||||||||||||
| from datetime import datetime | ||||||||||||||||||
| from typing import Final, Protocol, cast | ||||||||||||||||||
|
|
||||||||||||||||||
| from agentkit.checkpoint.models import CheckpointMeta, CheckpointSnapshot | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class AsyncPGPool(Protocol): | ||||||||||||||||||
| async def execute(self, query: str, *args: object) -> str: ... | ||||||||||||||||||
|
|
@@ -31,6 +34,17 @@ async def acquire(self) -> LockConnection: ... | |||||||||||||||||
| async def release(self, connection: LockConnection) -> None: ... | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class CodecConnection(Protocol): | ||||||||||||||||||
| async def set_type_codec( | ||||||||||||||||||
| self, | ||||||||||||||||||
| typename: str, | ||||||||||||||||||
| *, | ||||||||||||||||||
| encoder: Callable[[object], str], | ||||||||||||||||||
| decoder: Callable[[str], object], | ||||||||||||||||||
| schema: str, | ||||||||||||||||||
| ) -> None: ... | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| PoolFactory = Callable[..., Awaitable[AsyncPGPool]] | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -59,6 +73,7 @@ async def get_pool(self) -> AsyncPGPool: | |||||||||||||||||
| dsn=self._dsn, | ||||||||||||||||||
| min_size=self._min_size, | ||||||||||||||||||
| max_size=self._max_size, | ||||||||||||||||||
| init=_init_connection_codecs, | ||||||||||||||||||
| ) | ||||||||||||||||||
| return self._pool | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
@@ -110,7 +125,7 @@ async def _ensure_schema(self) -> AsyncPGPool: | |||||||||||||||||
|
|
||||||||||||||||||
| async def save_session(self, session_id: str, data: dict[str, object]) -> None: | ||||||||||||||||||
| pool = await self._ensure_schema() | ||||||||||||||||||
| _ = await pool.execute(self._UPSERT_SQL, session_id, json.dumps(data)) | ||||||||||||||||||
| _ = await pool.execute(self._UPSERT_SQL, session_id, data) | ||||||||||||||||||
|
|
||||||||||||||||||
| async def load_session(self, session_id: str) -> dict[str, object] | None: | ||||||||||||||||||
| pool = await self._ensure_schema() | ||||||||||||||||||
|
|
@@ -150,13 +165,21 @@ class PGTapeStore: | |||||||||||||||||
| PRIMARY KEY (tape_id, seq) | ||||||||||||||||||
| ) | ||||||||||||||||||
| """ | ||||||||||||||||||
| _MAX_SEQ_SQL: Final[str] = ( | ||||||||||||||||||
| "SELECT MAX(seq) AS max_seq FROM agent_tapes WHERE tape_id = $1" | ||||||||||||||||||
| ) | ||||||||||||||||||
| _INSERT_SQL: Final[str] = """ | ||||||||||||||||||
| WITH advisory_lock AS ( | ||||||||||||||||||
| SELECT pg_advisory_xact_lock(hashtext($1)) | ||||||||||||||||||
| ), | ||||||||||||||||||
| max_seq AS ( | ||||||||||||||||||
| SELECT COALESCE(MAX(seq), -1) AS value | ||||||||||||||||||
| FROM agent_tapes | ||||||||||||||||||
| WHERE tape_id = $1 | ||||||||||||||||||
| ) | ||||||||||||||||||
| INSERT INTO agent_tapes (tape_id, seq, entry) | ||||||||||||||||||
| SELECT $1, seq, entry::jsonb | ||||||||||||||||||
| FROM unnest($2::int[], $3::text[]) AS batch(seq, entry) | ||||||||||||||||||
| SELECT | ||||||||||||||||||
| $1, | ||||||||||||||||||
| (SELECT value FROM max_seq) + batch.ordinality, | ||||||||||||||||||
| batch.entry::jsonb | ||||||||||||||||||
| FROM advisory_lock, unnest($2::text[]) WITH ORDINALITY AS batch(entry, ordinality) | ||||||||||||||||||
| """ | ||||||||||||||||||
| _LOAD_SQL: Final[str] = ( | ||||||||||||||||||
| "SELECT entry FROM agent_tapes WHERE tape_id = $1 ORDER BY seq" | ||||||||||||||||||
|
|
@@ -182,25 +205,8 @@ async def save(self, tape_id: str, entries: list[dict[str, object]]) -> None: | |||||||||||||||||
| return | ||||||||||||||||||
|
|
||||||||||||||||||
| pool = await self._ensure_schema() | ||||||||||||||||||
| row = await pool.fetchrow(self._MAX_SEQ_SQL, tape_id) | ||||||||||||||||||
| max_seq = -1 | ||||||||||||||||||
| if row is not None: | ||||||||||||||||||
| raw_max_seq = row.get("max_seq") | ||||||||||||||||||
| if raw_max_seq is None: | ||||||||||||||||||
| max_seq = -1 | ||||||||||||||||||
| elif isinstance(raw_max_seq, int): | ||||||||||||||||||
| max_seq = raw_max_seq | ||||||||||||||||||
| else: | ||||||||||||||||||
| raise TypeError("postgres tape row must include int max_seq or None") | ||||||||||||||||||
|
|
||||||||||||||||||
| seq_values = [max_seq + offset for offset, _ in enumerate(entries, start=1)] | ||||||||||||||||||
| payload_values = [json.dumps(entry) for entry in entries] | ||||||||||||||||||
| _ = await pool.execute( | ||||||||||||||||||
| self._INSERT_SQL, | ||||||||||||||||||
| tape_id, | ||||||||||||||||||
| seq_values, | ||||||||||||||||||
| payload_values, | ||||||||||||||||||
| ) | ||||||||||||||||||
| _ = await pool.execute(self._INSERT_SQL, tape_id, payload_values) | ||||||||||||||||||
|
|
||||||||||||||||||
| async def load(self, tape_id: str) -> list[dict[str, object]]: | ||||||||||||||||||
| pool = await self._ensure_schema() | ||||||||||||||||||
|
|
@@ -231,6 +237,94 @@ async def truncate(self, tape_id: str, keep: int) -> None: | |||||||||||||||||
| _ = await pool.execute(self._TRUNCATE_SQL, tape_id, keep) | ||||||||||||||||||
|
|
||||||||||||||||||
|
|
||||||||||||||||||
| class PGCheckpointStore: | ||||||||||||||||||
| _CREATE_TABLE_SQL: Final[str] = """ | ||||||||||||||||||
| CREATE TABLE IF NOT EXISTS agent_checkpoints ( | ||||||||||||||||||
| checkpoint_id TEXT PRIMARY KEY, | ||||||||||||||||||
| tape_id TEXT NOT NULL, | ||||||||||||||||||
| meta JSONB NOT NULL, | ||||||||||||||||||
| entries JSONB NOT NULL, | ||||||||||||||||||
| plugin_states JSONB NOT NULL, | ||||||||||||||||||
| extra JSONB NOT NULL | ||||||||||||||||||
| ) | ||||||||||||||||||
| """ | ||||||||||||||||||
| _INSERT_SQL: Final[str] = """ | ||||||||||||||||||
| INSERT INTO agent_checkpoints ( | ||||||||||||||||||
| checkpoint_id, | ||||||||||||||||||
| tape_id, | ||||||||||||||||||
| meta, | ||||||||||||||||||
| entries, | ||||||||||||||||||
| plugin_states, | ||||||||||||||||||
| extra | ||||||||||||||||||
| ) | ||||||||||||||||||
| VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb) | ||||||||||||||||||
|
||||||||||||||||||
| VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb) | |
| VALUES ($1, $2, $3::jsonb, $4::jsonb, $5::jsonb, $6::jsonb) | |
| ON CONFLICT (checkpoint_id) DO UPDATE SET | |
| tape_id = EXCLUDED.tape_id, | |
| meta = EXCLUDED.meta, | |
| entries = EXCLUDED.entries, | |
| plugin_states = EXCLUDED.plugin_states, | |
| extra = EXCLUDED.extra |
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.
PGPool now registers json/jsonb codecs via init=_init_connection_codecs. With these codecs enabled, code that passes pre-serialized JSON strings into $n::jsonb parameters will be double-encoded (json.dumps(str) -> quoted JSON string), and fetches will decode back to Python str instead of dict/list. Either stop json.dumps(...) for JSONB parameters and pass Python objects directly, or adjust/remove the custom codecs so they accept already-encoded JSON text.