diff --git a/cmd/server/main.go b/cmd/server/main.go index f05db7d..3b94ec6 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -176,7 +176,7 @@ func main() { // without ever invoking shell-out. tmuxSession := os.Getenv("PALIADIN_TMUX_SESSION") 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)", services.PaliadinOwnerEmail) // Wire ApprovalService into the entity services so Create / Update / diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 213b4c1..0f0e673 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -69,10 +69,12 @@ type Services struct { Pin *services.PinService CardLayout *services.CardLayoutService - // Paliadin is wired only when PALIADIN_ENABLED=true at boot - // (PoC; m's laptop only). On prod it stays nil and all /paliadin* - // routes 404 because Register() skips registering them. - Paliadin *services.PaliadinService + // Paliadin is wired when DATABASE_URL is set. The concrete backend + // is picked in cmd/server/main.go based on PALIADIN_REMOTE_HOST + // (remote → mRiver via SSH) or local tmux availability. Stays nil + // 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) { diff --git a/internal/handlers/paliadin.go b/internal/handlers/paliadin.go index 68c0bd5..c521bca 100644 --- a/internal/handlers/paliadin.go +++ b/internal/handlers/paliadin.go @@ -39,10 +39,11 @@ func newDetachedContext(timeout time.Duration) (context.Context, context.CancelF return context.WithTimeout(context.Background(), timeout) } -// paliadinSvc is the live PaliadinService instance. nil when -// DATABASE_URL was unset (the service depends on the audit table). -// Set by Register() at boot. -var paliadinSvc *services.PaliadinService +// paliadinSvc is the live Paliadin backend. nil when DATABASE_URL was +// unset (the service depends on the audit table). Set by Register() at +// boot. The concrete type is decided in cmd/server/main.go: local-tmux +// PoC, remote-via-SSH (mRiver), or a disabled stub. +var paliadinSvc services.Paliadin // requirePaliadinOwner gates every paliadin handler to the single // owner email (services.PaliadinOwnerEmail = m). Anyone else gets a diff --git a/internal/services/paliadin.go b/internal/services/paliadin.go index 1641197..afc9019 100644 --- a/internal/services/paliadin.go +++ b/internal/services/paliadin.go @@ -1,23 +1,23 @@ 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. -// Prompts go in via `tmux send-keys -l`; responses come back via a -// per-turn file the system prompt instructs Claude to write -// (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. +// Designs: +// - docs/design-paliadin-2026-05-07.md (PoC architecture) +// - docs/design-paliadin-tailscale-ssh-2026-05-07.md (remote routing) // -// The architecture is lifted (with adaptation to Go) from -// ~/dev/mVoice/server.py:250-380, which has been driving the goldi voice -// surface in production since 2026-Q1. -// -// 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. +// Both implementations share the audit-table I/O (paliadinDB) and the +// trailer parser. The conversation state (turn ordering, response file +// polling) is split: Local owns the tmux pane directly; Remote delegates +// to the paliadin-shim on mRiver and reads the file there. import ( "bytes" @@ -50,12 +50,36 @@ import ( // path to enabling Paliadin. const PaliadinOwnerEmail = "matthias.siebels@hoganlovells.com" -// PaliadinService manages the tmux-claude PoC. -type PaliadinService struct { - db *sqlx.DB +// Paliadin is the interface every Paliadin backend implements. Two +// production implementations: LocalPaliadinService (local tmux+claude) +// 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 responseDir string - users *UserService // Cached pane target ("session:window-idx") once the voice window is // 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 // 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 err := s.db.QueryRowxContext(ctx, `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 } -// NewPaliadinService wires the service. Call only when PALIADIN_ENABLED=true. -func NewPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *PaliadinService { +// NewLocalPaliadinService wires the local-tmux PoC backend. Falls back +// to default tmux session + response dir when env vars are empty. +func NewLocalPaliadinService(db *sqlx.DB, users *UserService, tmuxSession, responseDir string) *LocalPaliadinService { if tmuxSession == "" { tmuxSession = "paliad-paliadin" } if responseDir == "" { responseDir = "/tmp/paliadin" } - return &PaliadinService{ - db: db, + return &LocalPaliadinService{ + paliadinDB: paliadinDB{db: db, users: users}, tmuxSession: tmuxSession, 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". // 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() 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 // 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() target := s.paneTarget 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. // 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 { limit = 50 } @@ -302,7 +326,7 @@ type PaliadinPromptCount struct { // Stats computes the dashboard aggregate. global_admin sees everything; // everyone else sees their own slice (PoC has only m, but the policy // 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{ ByClassifier: map[string]int{}, 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 // 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() defer s.mu.Unlock() @@ -468,7 +492,7 @@ func (s *PaliadinService) ensurePane(ctx context.Context) (string, error) { 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, "-F", "#{window_index}") if err != nil { @@ -485,14 +509,14 @@ func (s *PaliadinService) findChatWindow(ctx context.Context) string { 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 { return false } 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) for time.Now().Before(deadline) { 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) } -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 // our prompt's special characters don't get interpreted. 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 // fresh mtime is a corner case the caller already de-duplicates by // 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) for time.Now().Before(deadline) { select { @@ -687,7 +711,7 @@ func countChips(s string) int { // audit-row writers. // ============================================================================= -func (s *PaliadinService) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { +func (s *paliadinDB) insertTurnRow(ctx context.Context, t *PaliadinTurn) error { q := ` INSERT INTO paliad.paliadin_turns ( 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 } -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, meta trailerMeta, chipCount int) error { rowsSeen := make(pq.Int64Array, 0, len(meta.RowsSeen)) @@ -724,7 +748,7 @@ func (s *PaliadinService) completeTurn(ctx context.Context, turnID uuid.UUID, 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() q := ` UPDATE paliad.paliadin_turns @@ -735,7 +759,7 @@ func (s *PaliadinService) markTurnError(ctx context.Context, turnID uuid.UUID, c 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() q := ` UPDATE paliad.paliadin_turns