195 lines
9.4 KiB
Markdown
195 lines
9.4 KiB
Markdown
---
|
|
phase: 06-lab-advisor
|
|
plan: "01"
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- internal/store/store.go
|
|
- internal/store/migrations.go
|
|
- internal/store/conversations.go
|
|
- internal/store/store_test.go
|
|
- go.mod
|
|
- go.sum
|
|
autonomous: true
|
|
requirements: [ADV-03]
|
|
|
|
must_haves:
|
|
truths:
|
|
- "PostgreSQL connection is established and verified at startup"
|
|
- "conversations table exists with id, started_at, model columns"
|
|
- "messages table exists with id, conversation_id, role, content, created_at columns"
|
|
- "Store can create a conversation, append messages, and retrieve full thread"
|
|
- "Store can list all conversations ordered by started_at DESC"
|
|
artifacts:
|
|
- path: "internal/store/store.go"
|
|
provides: "Store struct, NewStore constructor, DB connection via pgx/v5"
|
|
exports: [Store, NewStore]
|
|
- path: "internal/store/migrations.go"
|
|
provides: "RunMigrations — idempotent CREATE TABLE IF NOT EXISTS for conversations + messages"
|
|
exports: [RunMigrations]
|
|
- path: "internal/store/conversations.go"
|
|
provides: "CreateConversation, AddMessage, GetConversation, ListConversations"
|
|
exports: [CreateConversation, AddMessage, GetConversation, ListConversations]
|
|
- path: "internal/store/store_test.go"
|
|
provides: "Integration tests against real PostgreSQL"
|
|
key_links:
|
|
- from: "internal/store/conversations.go"
|
|
to: "PostgreSQL homelabby DB"
|
|
via: "pgx/v5 pgxpool"
|
|
pattern: "pgxpool.New"
|
|
- from: "internal/store/migrations.go"
|
|
to: "conversations, messages tables"
|
|
via: "CREATE TABLE IF NOT EXISTS"
|
|
pattern: "CREATE TABLE IF NOT EXISTS conversations"
|
|
---
|
|
|
|
<objective>
|
|
Create the internal/store package that manages chat persistence in PostgreSQL.
|
|
RunMigrations creates conversations and messages tables idempotently; the Store
|
|
struct exposes typed methods for the advisor handler to use.
|
|
|
|
Purpose: ADV-03 requires conversation history to persist across browser sessions.
|
|
SQLite is already used for other local state but PostgreSQL is the designated store
|
|
here (already running at 10.5.0.109, connection string in HWLAB_DATABASE_URL env).
|
|
|
|
Output: internal/store package with pgx/v5, auto-migrating tables, typed CRUD methods,
|
|
integration tests.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
|
|
# Module path: git.georgsen.dk/hwlab
|
|
# PostgreSQL DSN is in env var HWLAB_DATABASE_URL (set in .env):
|
|
# postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby
|
|
# pgx/v5 driver: github.com/jackc/pgx/v5
|
|
# Pattern: use pgxpool for the connection pool (pgxpool.New), not single conn
|
|
# Pattern: all other packages use database/sql + sqlx — advisor uses pgx directly
|
|
# (pgx/v5 is preferred for new code, avoids cgo unlike mattn/go-sqlite3)
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Add pgx/v5 dep and create Store with RunMigrations</name>
|
|
<files>internal/store/store.go, internal/store/migrations.go, go.mod, go.sum</files>
|
|
<behavior>
|
|
- NewStore("invalid-dsn") returns non-nil error
|
|
- NewStore(validDSN) returns *Store with open pool, no error
|
|
- Store.Close() does not panic
|
|
- RunMigrations(ctx, pool) on empty DB creates conversations and messages tables
|
|
- RunMigrations(ctx, pool) called twice is idempotent (no error, no duplicate tables)
|
|
- conversations schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), started_at TIMESTAMPTZ NOT NULL DEFAULT now(), model TEXT NOT NULL DEFAULT ''
|
|
- messages schema: id UUID PRIMARY KEY DEFAULT gen_random_uuid(), conversation_id UUID NOT NULL REFERENCES conversations(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user','assistant','system')), content TEXT NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
</behavior>
|
|
<action>
|
|
Run `go get github.com/jackc/pgx/v5` to add the dependency.
|
|
|
|
Create internal/store/store.go:
|
|
- Package store
|
|
- Import pgxpool "github.com/jackc/pgx/v5/pgxpool"
|
|
- Store struct holds *pgxpool.Pool
|
|
- NewStore(ctx context.Context, dsn string) (*Store, error) — calls pgxpool.New(ctx, dsn), pings pool, returns error on failure
|
|
- Close() — calls pool.Close()
|
|
- Expose Pool() *pgxpool.Pool accessor for use by repository methods
|
|
|
|
Create internal/store/migrations.go:
|
|
- RunMigrations(ctx context.Context, pool *pgxpool.Pool) error
|
|
- Execute the two CREATE TABLE IF NOT EXISTS statements in sequence using pool.Exec
|
|
- conversations first (messages references it)
|
|
- Return first non-nil error encountered
|
|
|
|
Write tests first (RED), confirm they fail, then implement (GREEN).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -run TestNewStore -v</automated>
|
|
</verify>
|
|
<done>TestNewStore passes; go build ./... succeeds; pgx/v5 present in go.mod</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Conversation and message CRUD methods</name>
|
|
<files>internal/store/conversations.go, internal/store/store_test.go</files>
|
|
<behavior>
|
|
- CreateConversation(ctx, model string) returns (conversationID string, err error)
|
|
- AddMessage(ctx, conversationID, role, content string) returns (messageID string, err error)
|
|
- AddMessage with invalid role returns error (CHECK constraint violation from DB)
|
|
- GetConversation(ctx, conversationID string) returns (*Conversation, error) where Conversation has ID, StartedAt, Model, Messages []Message
|
|
- GetConversation on unknown ID returns nil, ErrNotFound (sentinel)
|
|
- ListConversations(ctx) returns []ConversationSummary ordered by started_at DESC (no messages, just id + started_at + model + message_count)
|
|
- Full round-trip: create conv → add 2 messages → GetConversation → Messages has 2 items in order
|
|
</behavior>
|
|
<action>
|
|
Create internal/store/conversations.go:
|
|
|
|
Types:
|
|
- Message struct { ID, ConversationID, Role, Content string; CreatedAt time.Time }
|
|
- Conversation struct { ID, Model string; StartedAt time.Time; Messages []Message }
|
|
- ConversationSummary struct { ID, Model string; StartedAt time.Time; MessageCount int }
|
|
- var ErrNotFound = errors.New("store: not found")
|
|
|
|
Methods on *Store:
|
|
- CreateConversation(ctx, model) — INSERT INTO conversations(model) VALUES($1) RETURNING id
|
|
- AddMessage(ctx, conversationID, role, content) — INSERT INTO messages(conversation_id, role, content) VALUES($1,$2,$3) RETURNING id
|
|
- GetConversation(ctx, id) — SELECT from conversations JOIN messages ORDER BY messages.created_at ASC; if 0 rows return nil, ErrNotFound
|
|
- ListConversations(ctx) — SELECT c.id, c.started_at, c.model, COUNT(m.id) AS message_count FROM conversations c LEFT JOIN messages m ON m.conversation_id = c.id GROUP BY c.id ORDER BY c.started_at DESC
|
|
|
|
Write tests in internal/store/store_test.go using build tag `//go:build integration`
|
|
so they run only with -tags integration. Tests connect to the real PostgreSQL.
|
|
Each test creates its own conversation and cleans up (DELETE FROM conversations WHERE id = $1).
|
|
|
|
Write tests first (RED), confirm compile error or test failure, then implement (GREEN).
|
|
</action>
|
|
<verify>
|
|
<automated>cd /home/mikkel/homelabby && HWLAB_DATABASE_URL="postgresql://homelabby:homelabby_2024_secure@10.5.0.109:5432/homelabby" go test ./internal/store/... -tags integration -v</automated>
|
|
</verify>
|
|
<done>All store integration tests pass; GetConversation returns ErrNotFound for unknown ID; ListConversations returns summaries with message counts; go build ./... passes</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| App → PostgreSQL | DSN from env; pool executes parameterized queries |
|
|
| Messages table | role column has CHECK constraint; content is free text |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-06-01-01 | Injection | conversations.go — all queries | mitigate | All queries use $1/$2 parameterized args via pgx; no string interpolation in SQL |
|
|
| T-06-01-02 | Information Disclosure | store.go — DSN in logs | mitigate | NewStore logs only connection error text, not the DSN; DSN sourced from env, not config file |
|
|
| T-06-01-03 | Tampering | messages.role column | mitigate | DB-level CHECK (role IN ('user','assistant','system')) — invalid role returns pgx constraint error |
|
|
| T-06-01-04 | Denial of Service | ListConversations with unbounded growth | accept | Single-operator homelab tool; conversation count bounded in practice; add LIMIT 100 as soft guard |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `go build ./...` passes
|
|
- `go test ./internal/store/...` passes (non-integration tests)
|
|
- `go test ./internal/store/... -tags integration` passes against live PostgreSQL
|
|
- `psql $HWLAB_DATABASE_URL -c "\d conversations"` shows the schema
|
|
- `psql $HWLAB_DATABASE_URL -c "\d messages"` shows the schema with FK to conversations
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- pgx/v5 added to go.mod
|
|
- internal/store package compiles with no errors
|
|
- RunMigrations creates both tables idempotently
|
|
- Full CRUD round-trip (create conv, add messages, get conv, list convs) passes integration tests
|
|
- ErrNotFound returned for unknown conversation ID
|
|
</success_criteria>
|
|
|
|
<output>
|
|
After completion, create .planning/phases/06-lab-advisor/06-01-SUMMARY.md
|
|
</output>
|