refactor(t-paliad-151): extract Paliadin interface; rename PaliadinService → LocalPaliadinService

Phase B step 1 of the Tailscale-SSH route to mRiver. Splits the existing
local-tmux PoC into a Paliadin interface with two implementations; the
remote-SSH backend lands in a follow-up commit (paliadin_remote.go).

Surface:
- Paliadin interface — RunTurn, ResetSession, ListRecentTurns, Stats,
  IsOwner. The handler at internal/handlers/paliadin.go now talks to
  this instead of the concrete struct.
- paliadinDB — embedded base type carrying the audit-table I/O
  (insertTurnRow, completeTurn, markTurnError, markTurnAbandonedOrError)
  plus the read-side queries (IsOwner, ListRecentTurns, Stats). Both
  Local and Remote impls inherit these by embedding paliadinDB so the
  remote path doesn't have to duplicate any DB code.
- LocalPaliadinService — the renamed PoC backend. Identical behaviour
  to the previous PaliadinService; only the type name and method
  receivers change. Method receivers split: tmux-specific operations
  (RunTurn, ResetSession, ensurePane, sendToPane, pollForResponse, etc.)
  stay on *LocalPaliadinService; DB-only operations promote to
  *paliadinDB.

Wiring:
- internal/handlers/handlers.go — Paliadin field becomes the interface
  type; Register() unchanged.
- cmd/server/main.go — calls NewLocalPaliadinService instead of
  NewPaliadinService. The remote-vs-local switch on PALIADIN_REMOTE_HOST
  lands in B5.

Tests in paliadin_test.go all green — they test package-level functions
(splitTrailer, countChips, approxTokenCount, sanitiseForTmux,
PaliadinOwnerEmail) and don't touch the renamed struct. No behaviour
change on the local-tmux path.

Refs m/paliad#12
This commit is contained in:
m
2026-05-08 02:14:12 +02:00
parent f62bf9f8fb
commit 56a3dc961e
4 changed files with 75 additions and 48 deletions

View File

@@ -176,7 +176,7 @@ func main() {
// without ever invoking shell-out. // without ever invoking shell-out.
tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION") tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION")
responseDir := os.Getenv("PALIADIN_RESPONSE_DIR") responseDir := os.Getenv("PALIADIN_RESPONSE_DIR")
svcBundle.Paliadin = services.NewPaliadinService(pool, users, tmuxSession, responseDir) svcBundle.Paliadin = services.NewLocalPaliadinService(pool, users, tmuxSession, responseDir)
log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)", log.Printf("paliadin: wired (owner=%s; gate is per-request, not per-deploy)",
services.PaliadinOwnerEmail) services.PaliadinOwnerEmail)
// Wire ApprovalService into the entity services so Create / Update / // Wire ApprovalService into the entity services so Create / Update /

View File

@@ -69,10 +69,12 @@ type Services struct {
Pin *services.PinService Pin *services.PinService
CardLayout *services.CardLayoutService CardLayout *services.CardLayoutService
// Paliadin is wired only when PALIADIN_ENABLED=true at boot // Paliadin is wired when DATABASE_URL is set. The concrete backend
// (PoC; m's laptop only). On prod it stays nil and all /paliadin* // is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST
// routes 404 because Register() skips registering them. // (remote → mRiver via SSH) or local tmux availability. Stays nil
Paliadin *services.PaliadinService // without DATABASE_URL; in that case the per-request handler gate
// 404s anyway.
Paliadin services.Paliadin
} }
func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) { func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc *Services) {

View File

@@ -39,10 +39,11 @@ func newDetachedContext(timeout time.Duration) (context.Context, context.CancelF
return context.WithTimeout(context.Background(), timeout) return context.WithTimeout(context.Background(), timeout)
} }
// paliadinSvc is the live PaliadinService instance. nil when // paliadinSvc is the live Paliadin backend. nil when DATABASE_URL was
// DATABASE_URL was unset (the service depends on the audit table). // unset (the service depends on the audit table). Set by Register() at
// Set by Register() at boot. // boot. The concrete type is decided in cmd/server/main.go: local-tmux
var paliadinSvc *services.PaliadinService // PoC, remote-via-SSH (mRiver), or a disabled stub.
var paliadinSvc services.Paliadin
// requirePaliadinOwner gates every paliadin handler to the single // requirePaliadinOwner gates every paliadin handler to the single
// owner email (services.PaliadinOwnerEmail = m). Anyone else gets a // owner email (services.PaliadinOwnerEmail = m). Anyone else gets a

View File

@@ -1,23 +1,23 @@
package services package services
// PaliadinService — Phase 0 PoC of the in-app AI buddy (t-paliad-146). // Paliadin — the in-app AI buddy. Two implementations of the same
// interface, picked at boot time (see cmd/server/main.go):
// //
// Design: docs/design-paliadin-2026-05-07.md §0.5 (PoC track). // - LocalPaliadinService — talks to a `claude` CLI in a local tmux
// session. The PoC path (t-paliad-146); used on m's laptop.
// - RemotePaliadinService — shells out to ssh on mRiver where the
// long-lived tmux+claude pane lives. The prod path (t-paliad-151);
// used by the paliad.de Dokploy container, which has no `claude`
// CLI of its own.
// //
// Architecture: a long-lived `claude` process inside a tmux session. // Designs:
// Prompts go in via `tmux send-keys -l`; responses come back via a // - docs/design-paliadin-2026-05-07.md (PoC architecture)
// per-turn file the system prompt instructs Claude to write // - docs/design-paliadin-tailscale-ssh-2026-05-07.md (remote routing)
// (Write(/tmp/paliadin/{turn_id}.txt)). The service polls that file,
// strips the [paliadin-meta] trailer block, parses the metadata, writes
// an audit row, and emits the response back to the SSE handler.
// //
// The architecture is lifted (with adaptation to Go) from // Both implementations share the audit-table I/O (paliadinDB) and the
// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice // trailer parser. The conversation state (turn ordering, response file
// surface in production since 2026-Q1. // polling) is split: Local owns the tmux pane directly; Remote delegates
// // to the paliadin-shim on mRiver and reads the file there.
// PoC ONLY runs on m's laptop (PALIADIN_ENABLED=false on prod default).
// Hardcoded single-user, single-tmux-window scope. Do not attempt to
// deploy this to the Dokploy container — there is no `claude` CLI there.
import ( import (
"bytes" "bytes"
@@ -50,12 +50,36 @@ import (
// path to enabling Paliadin. // path to enabling Paliadin.
const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com"
// PaliadinService manages the tmux-claude PoC. // Paliadin is the interface every Paliadin backend implements. Two
type PaliadinService struct { // production implementations: LocalPaliadinService (local tmux+claude)
db *sqlx.DB // and RemotePaliadinService (ssh+paliadin-shim on mRiver). A
// DisabledPaliadinService stub is constructed when neither is available
// so callers don't have to nil-check on every entry point.
type Paliadin interface {
RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error)
ResetSession(ctx context.Context) error
ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error)
Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error)
IsOwner(ctx context.Context, userID uuid.UUID) (bool, error)
}
// paliadinDB is the audit-table read/write surface shared by every
// Paliadin implementation. Embedded in LocalPaliadinService and
// RemotePaliadinService so they inherit IsOwner / ListRecentTurns /
// Stats and the per-turn row writers without duplication.
type paliadinDB struct {
db *sqlx.DB
users *UserService
}
// LocalPaliadinService runs the local tmux+claude PoC (t-paliad-146).
// Hardcoded single-user, single-tmux-window scope. Used on m's laptop;
// not deployed to prod (the Dokploy container has no `claude` CLI —
// see RemotePaliadinService for that path).
type LocalPaliadinService struct {
paliadinDB
tmuxSession string tmuxSession string
responseDir string responseDir string
users *UserService
// Cached pane target ("session:window-idx") once the voice window is // Cached pane target ("session:window-idx") once the voice window is
// either discovered or created. Reset to "" if the pane dies. // either discovered or created. Reset to "" if the pane dies.
@@ -74,7 +98,7 @@ type PaliadinService struct {
// //
// Returns (false, nil) for any other user — including unknown UUIDs and // Returns (false, nil) for any other user — including unknown UUIDs and
// users without an email row. Errors only on DB failure. // users without an email row. Errors only on DB failure.
func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) { func (s *paliadinDB) IsOwner(ctx context.Context, userID uuid.UUID) (bool, error) {
var email string var email string
err := s.db.QueryRowxContext(ctx, err := s.db.QueryRowxContext(ctx,
`SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email) `SELECT email FROM paliad.users WHERE id = $1`, userID).Scan(&email)
@@ -87,19 +111,19 @@ func (s *PaliadinService) IsOwner(ctx context.Context, userID uuid.UUID) (bool,
return strings.EqualFold(email, PaliadinOwnerEmail), nil return strings.EqualFold(email, PaliadinOwnerEmail), nil
} }
// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true. // NewLocalPaliadinService wires the local-tmux PoC backend. Falls back
func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService { // to default tmux session + response dir when env vars are empty.
func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService {
if tmuxSession == "" { if tmuxSession == "" {
tmuxSession = "paliad-paliadin" tmuxSession = "paliad-paliadin"
} }
if responseDir == "" { if responseDir == "" {
responseDir = "/tmp/paliadin" responseDir = "/tmp/paliadin"
} }
return &PaliadinService{ return &LocalPaliadinService{
db: db, paliadinDB: paliadinDB{db: db, users: users},
tmuxSession: tmuxSession, tmuxSession: tmuxSession,
responseDir: responseDir, responseDir: responseDir,
users: users,
} }
} }
@@ -156,7 +180,7 @@ var ErrTmuxUnavailable = errors.New("paliadin: tmux unavailable")
// //
// PoC: serialised. The package-level turnMu enforces "one at a time". // PoC: serialised. The package-level turnMu enforces "one at a time".
// m is the only user, so this is fine. // m is the only user, so this is fine.
func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) { func (s *LocalPaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnResult, error) {
s.turnMu.Lock() s.turnMu.Lock()
defer s.turnMu.Unlock() defer s.turnMu.Unlock()
@@ -238,7 +262,7 @@ func (s *PaliadinService) RunTurn(ctx context.Context, req TurnRequest) (*TurnRe
// ResetSession sends `/clear` to the Claude pane so the next turn starts // ResetSession sends `/clear` to the Claude pane so the next turn starts
// from a clean conversation. Used by the "New conversation" button. // from a clean conversation. Used by the "New conversation" button.
func (s *PaliadinService) ResetSession(ctx context.Context) error { func (s *LocalPaliadinService) ResetSession(ctx context.Context) error {
s.mu.Lock() s.mu.Lock()
target := s.paneTarget target := s.paneTarget
s.mu.Unlock() s.mu.Unlock()
@@ -254,7 +278,7 @@ func (s *PaliadinService) ResetSession(ctx context.Context) error {
// ListRecentTurns reads the last N turns visible to the caller. // ListRecentTurns reads the last N turns visible to the caller.
// global_admin sees everything; everyone else sees their own. // global_admin sees everything; everyone else sees their own.
func (s *PaliadinService) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) { func (s *paliadinDB) ListRecentTurns(ctx context.Context, callerID uuid.UUID, limit int) ([]PaliadinTurn, error) {
if limit <= 0 || limit > 200 { if limit <= 0 || limit > 200 {
limit = 50 limit = 50
} }
@@ -302,7 +326,7 @@ type PaliadinPromptCount struct {
// Stats computes the dashboard aggregate. global_admin sees everything; // Stats computes the dashboard aggregate. global_admin sees everything;
// everyone else sees their own slice (PoC has only m, but the policy // everyone else sees their own slice (PoC has only m, but the policy
// matches RLS on the table). // matches RLS on the table).
func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) { func (s *paliadinDB) Stats(ctx context.Context, callerID uuid.UUID) (*PaliadinStats, error) {
stats := &PaliadinStats{ stats := &PaliadinStats{
ByClassifier: map[string]int{}, ByClassifier: map[string]int{},
DailyCounts: []PaliadinDailyCount{}, DailyCounts: []PaliadinDailyCount{},
@@ -404,7 +428,7 @@ func (s *PaliadinService) Stats(ctx context.Context, callerID uuid.UUID) (*Palia
// ensurePane returns the tmux target ("session:window-idx") of the live // ensurePane returns the tmux target ("session:window-idx") of the live
// Claude pane, creating both session and window if missing. // Claude pane, creating both session and window if missing.
func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) { func (s *LocalPaliadinService) ensurePane(ctx context.Context) (string, error) {
s.mu.Lock() s.mu.Lock()
defer s.mu.Unlock() defer s.mu.Unlock()
@@ -468,7 +492,7 @@ func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) {
return target, nil return target, nil
} }
func (s *PaliadinService) findChatWindow(ctx context.Context) string { func (s *LocalPaliadinService) findChatWindow(ctx context.Context) string {
out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession, out, err := runTmuxOut(ctx, "list-windows", "-t", s.tmuxSession,
"-F", "#{window_index}") "-F", "#{window_index}")
if err != nil { if err != nil {
@@ -485,14 +509,14 @@ func (s *PaliadinService) findChatWindow(ctx context.Context) string {
return "" return ""
} }
func (s *PaliadinService) paneAlive(ctx context.Context, target string) bool { func (s *LocalPaliadinService) paneAlive(ctx context.Context, target string) bool {
if err := runTmux(ctx, "has-session", "-t", target); err != nil { if err := runTmux(ctx, "has-session", "-t", target); err != nil {
return false return false
} }
return true return true
} }
func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error { func (s *LocalPaliadinService) waitForPaneReady(ctx context.Context, target string, timeout time.Duration) error {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
select { select {
@@ -509,7 +533,7 @@ func (s *PaliadinService) waitForPaneReady(ctx context.Context, target string, t
return fmt.Errorf("pane %s not ready within %s", target, timeout) return fmt.Errorf("pane %s not ready within %s", target, timeout)
} }
func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) error { func (s *LocalPaliadinService) sendToPane(ctx context.Context, target, msg string) error {
// `-l` sends the message literally (no key parsing) — necessary so // `-l` sends the message literally (no key parsing) — necessary so
// our prompt's special characters don't get interpreted. // our prompt's special characters don't get interpreted.
if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil { if err := runTmux(ctx, "send-keys", "-t", target, "-l", msg); err != nil {
@@ -527,7 +551,7 @@ func (s *PaliadinService) sendToPane(ctx context.Context, target, msg string) er
// over from earlier turns) as a non-event — the file existing without a // over from earlier turns) as a non-event — the file existing without a
// fresh mtime is a corner case the caller already de-duplicates by // fresh mtime is a corner case the caller already de-duplicates by
// having a unique turn_id per request. // having a unique turn_id per request.
func (s *PaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) { func (s *LocalPaliadinService) pollForResponse(ctx context.Context, path string, timeout time.Duration) (string, error) {
deadline := time.Now().Add(timeout) deadline := time.Now().Add(timeout)
for time.Now().Before(deadline) { for time.Now().Before(deadline) {
select { select {
@@ -687,7 +711,7 @@ func countChips(s string) int {
// audit-row writers. // audit-row writers.
// ============================================================================= // =============================================================================
func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error {
q := ` q := `
INSERT INTO paliad.paliadin_turns ( INSERT INTO paliad.paliadin_turns (
turn_id, user_id, session_id, started_at, user_message, page_origin turn_id, user_id, session_id, started_at, user_message, page_origin
@@ -698,7 +722,7 @@ func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) er
return err return err
} }
func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID, func (s *paliadinDB) completeTurn(ctx context.Context, turnID uuid.UUID,
finishedAt time.Time, durationMS int, response string, tokens int, finishedAt time.Time, durationMS int, response string, tokens int,
meta trailerMeta, chipCount int) error { meta trailerMeta, chipCount int) error {
rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen)) rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen))
@@ -724,7 +748,7 @@ func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID,
return err return err
} }
func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error { func (s *paliadinDB) markTurnError(ctx context.Context, turnID uuid.UUID, code string) error {
finished := time.Now().UTC() finished := time.Now().UTC()
q := ` q := `
UPDATE paliad.paliadin_turns UPDATE paliad.paliadin_turns
@@ -735,7 +759,7 @@ func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, c
return err return err
} }
func (s *PaliadinService) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error { func (s *paliadinDB) markTurnAbandonedOrError(ctx context.Context, turnID uuid.UUID, code string, abandoned bool) error {
finished := time.Now().UTC() finished := time.Now().UTC()
q := ` q := `
UPDATE paliad.paliadin_turns UPDATE paliad.paliadin_turns