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.
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 /

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {
// 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