9.5 KiB
M6.2 — Postgres schema for agent-manager sessions
| Field | Value |
|---|---|
| Phase | M6 — agent-manager migration |
| Size | M — 1–3 days |
| Status | ⬜ Not started |
| Flags | — |
| Spec | inlined below |
| Blocks | M6.1 |
Goal
The sqlite schema agent-manager's internal/store/store.go builds up
through 14 ALTER TABLE migrations, collapsed into one Postgres-native
schema — with the sqlite workarounds (integer booleans, dual-encoded
timestamps) removed rather than carried over.
Facts (inlined — no spec read needed)
Current schema, read directly from internal/store/store.go (6 tables, no
FK constraints anywhere — every relationship is enforced in Go, not SQL):
erDiagram
GROUPS ||--o{ SESSIONS : "group_name (app-level, no FK)"
SESSIONS ||--o{ SESSIONS : "parent_id (self-ref, app-level, no FK)"
SESSIONS ||--o| REVIEW_TARGETS : "session_id (app-level, no FK)"
SESSIONS ||--o{ REVIEW_BASES : "session_id (app-level, no FK)"
SESSIONS ||--o| REVIEW_SCOPES : "session_id (app-level, no FK)"
SETTINGS {
text key PK
text value
}
SESSIONS {
text id PK
text name
text tool
text cwd
text group_name "app-level FK -> GROUPS.name"
text status
int archived "bool 0/1 in sqlite"
int created_at "unix nanos, dual-encoded in sqlite"
int last_status_at "unix nanos"
text agent_session_id
text pending_inputs "JSON array blob"
int pending_claimed "bool 0/1"
text launch_prompt
int sort_order
int acked "bool 0/1"
text snapshot
text worktree_repo
text worktree_branch
int agent_launched_at "unix nanos"
text retired_agent_session_id
text parent_id "app-level FK -> SESSIONS.id, self"
}
GROUPS {
text name PK
int sort_order
text path
int archived "bool 0/1"
text worktree
}
REVIEW_TARGETS {
text session_id PK
text repo_root
}
REVIEW_BASES {
text session_id PK
text repo_root PK
text base_ref
}
REVIEW_SCOPES {
text session_id PK
text scope
}
Target Postgres DDL:
CREATE TABLE groups (
name TEXT PRIMARY KEY,
sort_order INTEGER NOT NULL DEFAULT 0,
path TEXT NOT NULL DEFAULT '',
archived BOOLEAN NOT NULL DEFAULT FALSE,
worktree TEXT NOT NULL DEFAULT ''
);
CREATE TABLE sessions (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
tool TEXT NOT NULL,
cwd TEXT NOT NULL,
group_name TEXT NOT NULL REFERENCES groups(name),
status TEXT NOT NULL,
archived BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMPTZ NOT NULL,
last_status_at TIMESTAMPTZ NOT NULL,
agent_session_id TEXT NOT NULL DEFAULT '',
pending_inputs JSONB NOT NULL DEFAULT '[]',
pending_claimed BOOLEAN NOT NULL DEFAULT FALSE,
launch_prompt TEXT NOT NULL DEFAULT '',
sort_order INTEGER NOT NULL DEFAULT 0,
acked BOOLEAN NOT NULL DEFAULT FALSE,
snapshot TEXT NOT NULL DEFAULT '',
worktree_repo TEXT NOT NULL DEFAULT '',
worktree_branch TEXT NOT NULL DEFAULT '',
agent_launched_at TIMESTAMPTZ,
retired_agent_session_id TEXT NOT NULL DEFAULT '',
parent_id TEXT REFERENCES sessions(id)
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE review_targets (
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
repo_root TEXT NOT NULL
);
CREATE TABLE review_bases (
session_id TEXT NOT NULL REFERENCES sessions(id) ON DELETE CASCADE,
repo_root TEXT NOT NULL,
base_ref TEXT NOT NULL,
PRIMARY KEY (session_id, repo_root)
);
CREATE TABLE review_scopes (
session_id TEXT PRIMARY KEY REFERENCES sessions(id) ON DELETE CASCADE,
scope TEXT NOT NULL
);
Decision: add real FKs, not app-level-only. Current Go code manually
cascades review_targets/review_bases/review_scopes deletes before
deleting a sessions row (Delete, DeleteChild in store.go) — ON DELETE CASCADE on those three removes that manual bookkeeping entirely.
group_name -> groups(name) and parent_id -> sessions(id) get FKs too,
since Postgres makes them free and they catch the exact class of bug
validParent/ensureGroup exist in Go to prevent by hand. This is a
behavior change from sqlite (constraint violation now possible on write
paths that previously just wrote garbage) — M6.3 must add error handling
for FK violations at the ~4 call sites that write group_name/
parent_id without having already validated the reference through
validParent/ensureGroup.
Booleans — BOOLEAN, not INTEGER. Removes boolToInt()/!= 0 at
every read/write site in store.go.
Timestamps — TIMESTAMPTZ, not the encodeTime/decodeTime
nanosecond-or-legacy-seconds hack (store.go:1369-1396). That hack exists
only because sqlite has no native timestamp type and old rows needed a
seconds/nanos disambiguation heuristic (secondsCeiling). A fresh
Postgres database has no legacy rows — the whole function pair is deleted,
not ported. agent_launched_at becomes nullable (NULL = "never
restarted") instead of the sqlite sentinel 0.
pending_inputs — JSONB, not TEXT holding a JSON string. Same
json.Marshal/Unmarshal round-trip in Go either way, but JSONB lets
Postgres validate the shape on write instead of accepting malformed JSON
that only fails on the next read.
Steps
- Write
agent-managermigration files (this repo'ssqlx-migration convention doesn't apply — agent-manager is a separate Go repo; use whatever migration tool itsadd-headless-spawnbranch already has, or a plain.sqlfile run once atOpen()if it has none — check before introducing a new dependency). CREATE TABLEin FK-dependency order:groups, thensessions(referencesgroupsand itself), thensettings,review_targets/review_bases/review_scopes.- No indexes beyond the primary keys are needed yet —
ListSessionsfilters onarchivedand orders bygroup_name, sort_order, created_at; add a composite index only if M6.6's gate shows it's slow, not preemptively. - Confirm
CREATE EXTENSIONis not needed anywhere (plain relational schema, no pgvector) — unlike this project's ownmemory-db.
Acceptance
- Schema applies to a clean
agent-manager-dbdatabase. archived/acked/pending_claimedareBOOLEAN, notINTEGER.created_at/last_status_at/agent_launched_atareTIMESTAMPTZ.- Deleting a
sessionsrow cascadesreview_targets/review_bases/review_scopeswithout the Go code doing it manually. - Inserting a session with an unknown
group_nameis rejected by the FK, not silently written.
Verify
Harness: disposable Postgres, same image as M6.1's Cluster
(ghcr.io/cloudnative-pg/postgresql:16.2) — or a local postgres:16
container for fast iteration, since agent-manager's own test suite doesn't
need the real cluster.
Integration test — extend agent-manager's existing store tests
(internal/store/*_test.go already has timeenc_test.go; that file is
deleted in M6.3, its coverage folded into this schema's tests) with:
a1_migrate_clean— apply to an empty database, assert all 6 tables exist.a2_bool_columns_are_boolean—information_schema.columnsreportsbooleanforarchived,acked,pending_claimed.a3_timestamp_columns_are_timestamptz— same, forcreated_at,last_status_at,agent_launched_at.a4_review_cascade— insert a session + areview_targetsrow for it, delete the session, assertreview_targetsis empty without a separateDELETE FROM review_targetscall.a5_group_fk_rejects_unknown— insert a session with agroup_namethat has no matchinggroupsrow, assert it's rejected.a6_parent_fk_self_ref— insert two sessions where the second'sparent_idpoints at the first, assert it succeeds; point it at a nonexistent id, assert it's rejected.
Command: go test ./internal/store/... -run TestSchema
False pass:
- Testing the schema against sqlite (leftover
modernc.org/sqliteimport) instead of real Postgres.BOOLEAN/TIMESTAMPTZ/JSONB/FK-cascade behavior all differ or are silently accepted-but-ignored by sqlite — every assertion above becomes meaningless against the wrong engine.
Traps
- Keeping
ON DELETE CASCADEoffgroup_name/parent_idFKs by accident (only adding it to thereview_*ones). A group delete or session delete then hits an FK violation instead of the graceful "session has terminals of its own; move them out first" errorPlaceSessionalready gives — check M6.3 preserves that message instead of leaking a raw constraint-violation error to the CLI. - Making
parent_idNOT NULL DEFAULT ''(matching the sqlite default) instead of nullable.'' REFERENCES sessions(id)is never satisfiable except by a literal empty-string-id row, which doesn't exist — every top-level session's insert then fails the FK. Must beNULLfor "no parent."
Background: M6.1 · internal/store/store.go (agent-manager, add-headless-spawn branch)