From 2f8913a56b7f3bbf4121ca55d7c23c532744bf08 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:07:42 +0300 Subject: [PATCH 01/24] feat: add api_tokens migration (036) Create api_tokens table for client token authentication with bcrypt hash storage, prefix-based lookup index, and usage tracking columns. --- internal/db/gorm/migrations.go | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/internal/db/gorm/migrations.go b/internal/db/gorm/migrations.go index e903ff4d..c1c8d9a7 100644 --- a/internal/db/gorm/migrations.go +++ b/internal/db/gorm/migrations.go @@ -1208,6 +1208,38 @@ func runMigrations(db *gorm.DB, embeddingDims int) error { return tx.Exec(`ALTER TABLE observations DROP COLUMN IF EXISTS rejected`).Error }, }, + // Migration 036: API tokens table for client token authentication. + // Stores bcrypt-hashed client tokens with prefix-based lookup for the auth middleware. + { + ID: "036_api_tokens", + Migrate: func(tx *gorm.DB) error { + sqls := []string{ + `CREATE TABLE IF NOT EXISTS api_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL UNIQUE, + token_hash TEXT NOT NULL, + token_prefix TEXT NOT NULL, + scope TEXT NOT NULL DEFAULT 'read-write', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_used_at TIMESTAMPTZ, + request_count BIGINT NOT NULL DEFAULT 0, + error_count BIGINT NOT NULL DEFAULT 0, + revoked BOOLEAN NOT NULL DEFAULT false, + revoked_at TIMESTAMPTZ + )`, + `CREATE INDEX IF NOT EXISTS idx_api_tokens_prefix ON api_tokens (token_prefix) WHERE NOT revoked`, + } + for _, s := range sqls { + if err := tx.Exec(s).Error; err != nil { + return fmt.Errorf("migration 036: %w", err) + } + } + return nil + }, + Rollback: func(tx *gorm.DB) error { + return tx.Exec("DROP TABLE IF EXISTS api_tokens").Error + }, + }, }) if err := m.Migrate(); err != nil { return fmt.Errorf("run gormigrate migrations: %w", err) From 8ceeeef33f9170e0934234fe3fe5a63a6729f3ba Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:08:52 +0300 Subject: [PATCH 02/24] feat: add TokenStore for client API tokens GORM-based store with Create, List, FindByPrefix, Revoke, IncrementStats, IncrementErrorCount, GetByID, and BatchIncrementStats methods. APIToken model added to models.go. --- internal/db/gorm/models.go | 18 +++++ internal/db/gorm/token_store.go | 136 ++++++++++++++++++++++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 internal/db/gorm/token_store.go diff --git a/internal/db/gorm/models.go b/internal/db/gorm/models.go index 0a3f7f37..ef740dbd 100644 --- a/internal/db/gorm/models.go +++ b/internal/db/gorm/models.go @@ -359,3 +359,21 @@ type Project struct { } func (Project) TableName() string { return "projects" } + +// APIToken represents a client API token for agent authentication. +// Tokens are stored as bcrypt hashes with a prefix for fast lookup. +type APIToken struct { + ID string `gorm:"type:uuid;primaryKey;default:gen_random_uuid()"` + Name string `gorm:"type:text;not null;uniqueIndex"` + TokenHash string `gorm:"type:text;not null"` + TokenPrefix string `gorm:"type:text;not null;index"` + Scope string `gorm:"type:text;not null;default:read-write"` + CreatedAt time.Time `gorm:"not null;default:now()"` + LastUsedAt *time.Time `gorm:"column:last_used_at"` + RequestCount int64 `gorm:"not null;default:0"` + ErrorCount int64 `gorm:"not null;default:0"` + Revoked bool `gorm:"not null;default:false"` + RevokedAt *time.Time `gorm:"column:revoked_at"` +} + +func (APIToken) TableName() string { return "api_tokens" } diff --git a/internal/db/gorm/token_store.go b/internal/db/gorm/token_store.go new file mode 100644 index 00000000..8f641c01 --- /dev/null +++ b/internal/db/gorm/token_store.go @@ -0,0 +1,136 @@ +// Package gorm provides GORM-based database operations for engram. +package gorm + +import ( + "context" + "time" + + "gorm.io/gorm" +) + +// TokenStore provides API token database operations using GORM. +type TokenStore struct { + db *gorm.DB +} + +// NewTokenStore creates a new token store. +func NewTokenStore(store *Store) *TokenStore { + return &TokenStore{db: store.DB} +} + +// Create stores a new API token record. +func (s *TokenStore) Create(ctx context.Context, name, tokenHash, tokenPrefix, scope string) (*APIToken, error) { + token := &APIToken{ + Name: name, + TokenHash: tokenHash, + TokenPrefix: tokenPrefix, + Scope: scope, + } + + if err := s.db.WithContext(ctx).Create(token).Error; err != nil { + return nil, err + } + + return token, nil +} + +// List returns all tokens (including revoked, for audit trail). +// Token hashes are included in the DB model but callers should +// exclude them from API responses. +func (s *TokenStore) List(ctx context.Context) ([]APIToken, error) { + var tokens []APIToken + err := s.db.WithContext(ctx). + Order("created_at DESC"). + Find(&tokens).Error + return tokens, err +} + +// FindByPrefix looks up a non-revoked token by its prefix for auth middleware. +func (s *TokenStore) FindByPrefix(ctx context.Context, prefix string) (*APIToken, error) { + var token APIToken + err := s.db.WithContext(ctx). + Where("token_prefix = ? AND NOT revoked", prefix). + First(&token).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &token, nil +} + +// Revoke marks a token as revoked. +func (s *TokenStore) Revoke(ctx context.Context, id string) error { + now := time.Now() + result := s.db.WithContext(ctx). + Model(&APIToken{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "revoked": true, + "revoked_at": &now, + }) + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return result.Error +} + +// IncrementStats increments request_count and updates last_used_at for a token. +func (s *TokenStore) IncrementStats(ctx context.Context, id string) error { + return s.db.WithContext(ctx). + Model(&APIToken{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "request_count": gorm.Expr("request_count + 1"), + "last_used_at": time.Now(), + }).Error +} + +// IncrementErrorCount increments the error_count for a token. +func (s *TokenStore) IncrementErrorCount(ctx context.Context, id string) error { + return s.db.WithContext(ctx). + Model(&APIToken{}). + Where("id = ?", id). + Update("error_count", gorm.Expr("error_count + 1")).Error +} + +// GetByID retrieves a token by ID with full stats. +func (s *TokenStore) GetByID(ctx context.Context, id string) (*APIToken, error) { + var token APIToken + err := s.db.WithContext(ctx).First(&token, "id = ?", id).Error + if err == gorm.ErrRecordNotFound { + return nil, nil + } + if err != nil { + return nil, err + } + return &token, nil +} + +// BatchIncrementStats increments request_count and updates last_used_at +// for multiple tokens in a single UPDATE statement. Used by the buffered +// stats flusher to reduce DB round-trips. +func (s *TokenStore) BatchIncrementStats(ctx context.Context, counts map[string]int) error { + if len(counts) == 0 { + return nil + } + + // Process each token — GORM does not support CASE-based batch updates + // cleanly, but since flush intervals are 5s with typically few distinct + // tokens, individual UPDATEs within a transaction are acceptable. + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + for id, count := range counts { + if err := tx.Model(&APIToken{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "request_count": gorm.Expr("request_count + ?", count), + "last_used_at": now, + }).Error; err != nil { + return err + } + } + return nil + }) +} From b72754f0bfed6e92ba9c12ce89ad3360fb02448d Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:10:15 +0300 Subject: [PATCH 03/24] feat: add auth handlers (login, logout, tokens CRUD) Handlers for POST /api/auth/login (master token validation + HMAC cookie), POST /api/auth/logout (cookie clear), GET /api/auth/me (status check), GET/POST /api/auth/tokens (list/create), DELETE /api/auth/tokens/:id (revoke). All handlers include swaggo annotations. --- internal/worker/handlers_auth.go | 370 +++++++++++++++++++++++++++++++ 1 file changed, 370 insertions(+) create mode 100644 internal/worker/handlers_auth.go diff --git a/internal/worker/handlers_auth.go b/internal/worker/handlers_auth.go new file mode 100644 index 00000000..3fbaa6d8 --- /dev/null +++ b/internal/worker/handlers_auth.go @@ -0,0 +1,370 @@ +// Package worker provides authentication HTTP handlers for the dashboard. +package worker + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "crypto/subtle" + "encoding/base64" + "encoding/hex" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" +) + +// sessionPayload is the data stored inside the signed session cookie. +type sessionPayload struct { + Role string `json:"role"` + Exp int64 `json:"exp"` +} + +// sessionCookieName is the name of the session cookie. +const sessionCookieName = "engram_session" + +// sessionMaxAge is the session cookie lifetime (30 days). +const sessionMaxAge = 30 * 24 * 3600 + +// tokenRawPrefix is the prefix for generated client API tokens. +const tokenRawPrefix = "eng_" + +// tokenPrefixLen is the number of hex chars after "eng_" used for prefix lookup. +const tokenPrefixLen = 8 + +// loginRequest is the JSON body for POST /api/auth/login. +type loginRequest struct { + Token string `json:"token"` +} + +// tokenCreateRequest is the JSON body for POST /api/auth/tokens. +type tokenCreateRequest struct { + Name string `json:"name"` + Scope string `json:"scope"` +} + +// handleAuthLogin godoc +// @Summary Login with master token +// @Description Validates the master admin token and returns an HMAC-signed session cookie. +// @Tags Auth +// @Accept json +// @Produce json +// @Param body body loginRequest true "Login credentials" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {string} string "bad request" +// @Failure 401 {string} string "unauthorized" +// @Router /api/auth/login [post] +func (s *Service) handleAuthLogin(w http.ResponseWriter, r *http.Request) { + var req loginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + masterToken := s.tokenAuth.Token() + if masterToken == "" { + http.Error(w, "authentication not configured", http.StatusInternalServerError) + return + } + + if subtle.ConstantTimeCompare([]byte(req.Token), []byte(masterToken)) != 1 { + log.Warn().Str("remote_addr", r.RemoteAddr).Msg("auth: failed login attempt") + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + // Create signed session cookie + cookieKey := s.tokenAuth.CookieKey() + payload := sessionPayload{ + Role: "admin", + Exp: time.Now().Unix() + int64(sessionMaxAge), + } + + payloadBytes, err := json.Marshal(payload) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + sig := computeHMAC(payloadBytes, cookieKey) + cookieValue := base64.RawURLEncoding.EncodeToString(payloadBytes) + "." + base64.RawURLEncoding.EncodeToString(sig) + + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: cookieValue, + Path: "/", + MaxAge: sessionMaxAge, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + + writeJSON(w, map[string]any{ + "authenticated": true, + "role": "admin", + }) +} + +// handleAuthLogout godoc +// @Summary Logout +// @Description Clears the session cookie. +// @Tags Auth +// @Produce json +// @Success 200 {object} map[string]interface{} +// @Router /api/auth/logout [post] +func (s *Service) handleAuthLogout(w http.ResponseWriter, r *http.Request) { + http.SetCookie(w, &http.Cookie{ + Name: sessionCookieName, + Value: "", + Path: "/", + MaxAge: -1, + HttpOnly: true, + SameSite: http.SameSiteStrictMode, + }) + + writeJSON(w, map[string]any{ + "authenticated": false, + }) +} + +// handleAuthMe godoc +// @Summary Check authentication status +// @Description Returns the current authentication state and role. +// @Tags Auth +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} map[string]interface{} +// @Failure 401 {string} string "unauthorized" +// @Router /api/auth/me [get] +func (s *Service) handleAuthMe(w http.ResponseWriter, r *http.Request) { + // The request has already passed auth middleware, so we know it's authenticated. + // Determine role from context — cookie/master = admin, client token = scoped. + role := getAuthRole(r) + writeJSON(w, map[string]any{ + "authenticated": true, + "role": role, + }) +} + +// handleListTokens godoc +// @Summary List API tokens +// @Description Returns all API tokens (excluding hashes) for admin management. +// @Tags Auth +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} map[string]interface{} +// @Failure 500 {string} string "internal error" +// @Router /api/auth/tokens [get] +func (s *Service) handleListTokens(w http.ResponseWriter, r *http.Request) { + s.initMu.RLock() + tokenStore := s.tokenStore + s.initMu.RUnlock() + + if tokenStore == nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + + tokens, err := tokenStore.List(r.Context()) + if err != nil { + log.Error().Err(err).Msg("auth: failed to list tokens") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + // Exclude token_hash from response + type tokenResponse struct { + ID string `json:"id"` + Name string `json:"name"` + TokenPrefix string `json:"token_prefix"` + Scope string `json:"scope"` + CreatedAt time.Time `json:"created_at"` + LastUsedAt *time.Time `json:"last_used_at,omitempty"` + RequestCount int64 `json:"request_count"` + ErrorCount int64 `json:"error_count"` + Revoked bool `json:"revoked"` + RevokedAt *time.Time `json:"revoked_at,omitempty"` + } + + resp := make([]tokenResponse, len(tokens)) + for i, t := range tokens { + resp[i] = tokenResponse{ + ID: t.ID, + Name: t.Name, + TokenPrefix: t.TokenPrefix, + Scope: t.Scope, + CreatedAt: t.CreatedAt, + LastUsedAt: t.LastUsedAt, + RequestCount: t.RequestCount, + ErrorCount: t.ErrorCount, + Revoked: t.Revoked, + RevokedAt: t.RevokedAt, + } + } + + writeJSON(w, map[string]any{ + "tokens": resp, + }) +} + +// handleCreateToken godoc +// @Summary Create a new API token +// @Description Generates a new client API token with the specified name and scope. The raw token is returned only once. +// @Tags Auth +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body tokenCreateRequest true "Token creation parameters" +// @Success 200 {object} map[string]interface{} +// @Failure 400 {string} string "bad request" +// @Failure 409 {string} string "conflict" +// @Failure 500 {string} string "internal error" +// @Router /api/auth/tokens [post] +func (s *Service) handleCreateToken(w http.ResponseWriter, r *http.Request) { + s.initMu.RLock() + tokenStore := s.tokenStore + s.initMu.RUnlock() + + if tokenStore == nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + + var req tokenCreateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid request body", http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + + scope := req.Scope + if scope == "" { + scope = "read-write" + } + if scope != "read-write" && scope != "read-only" { + http.Error(w, "scope must be 'read-write' or 'read-only'", http.StatusBadRequest) + return + } + + // Generate raw token: eng_ + 32 hex chars (16 random bytes) + randomBytes := make([]byte, 16) + if _, err := rand.Read(randomBytes); err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + rawToken := tokenRawPrefix + hex.EncodeToString(randomBytes) + prefix := rawToken[len(tokenRawPrefix) : len(tokenRawPrefix)+tokenPrefixLen] + + // Hash with bcrypt + hash, err := bcrypt.GenerateFromPassword([]byte(rawToken), bcrypt.DefaultCost) + if err != nil { + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + token, err := tokenStore.Create(r.Context(), req.Name, string(hash), prefix, scope) + if err != nil { + // Check for unique constraint violation (duplicate name) + if isDuplicateKeyError(err) { + http.Error(w, "token name already exists", http.StatusConflict) + return + } + log.Error().Err(err).Msg("auth: failed to create token") + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "id": token.ID, + "name": token.Name, + "token": rawToken, + "scope": token.Scope, + }) +} + +// handleRevokeToken godoc +// @Summary Revoke an API token +// @Description Revokes the specified API token, preventing further authentication. +// @Tags Auth +// @Produce json +// @Security ApiKeyAuth +// @Param id path string true "Token ID (UUID)" +// @Success 200 {object} map[string]interface{} +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "internal error" +// @Router /api/auth/tokens/{id} [delete] +func (s *Service) handleRevokeToken(w http.ResponseWriter, r *http.Request) { + s.initMu.RLock() + tokenStore := s.tokenStore + s.initMu.RUnlock() + + if tokenStore == nil { + http.Error(w, "not ready", http.StatusServiceUnavailable) + return + } + + id := chi.URLParam(r, "id") + if id == "" { + http.Error(w, "token id required", http.StatusBadRequest) + return + } + + if err := tokenStore.Revoke(r.Context(), id); err != nil { + log.Error().Err(err).Str("token_id", id).Msg("auth: failed to revoke token") + http.Error(w, "not found", http.StatusNotFound) + return + } + + writeJSON(w, map[string]any{ + "revoked": true, + }) +} + +// authRoleKey is the context key for the authenticated role. +type authRoleKey struct{} + +// getAuthRole extracts the auth role from the request context. +// Returns "admin" for master token or session cookie auth, or the scope for client tokens. +func getAuthRole(r *http.Request) string { + if role, ok := r.Context().Value(authRoleKey{}).(string); ok { + return role + } + return "admin" // default if middleware didn't set it (backward compat) +} + +// computeHMAC computes an HMAC-SHA256 signature. +func computeHMAC(data, key []byte) []byte { + mac := hmac.New(sha256.New, key) + mac.Write(data) + return mac.Sum(nil) +} + +// isDuplicateKeyError checks if the error is a unique constraint violation. +func isDuplicateKeyError(err error) bool { + if err == nil { + return false + } + msg := err.Error() + // PostgreSQL unique violation error code 23505 + return containsDuplicateKey(msg) +} + +// containsDuplicateKey checks error message for duplicate key indicators. +func containsDuplicateKey(msg string) bool { + for _, s := range []string{"duplicate key", "23505", "UNIQUE constraint"} { + if len(msg) >= len(s) { + for i := 0; i <= len(msg)-len(s); i++ { + if msg[i:i+len(s)] == s { + return true + } + } + } + } + return false +} From 4146791c0b7d331c71cb0f59e3b9692ded4fc034 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:12:02 +0300 Subject: [PATCH 04/24] feat: update middleware for token hierarchy + session cookies TokenAuth now supports three auth methods: master token (admin), client API tokens (eng_* prefix with bcrypt verification and scope enforcement), and HMAC-SHA256 signed session cookies (dashboard). Read-only tokens are restricted to GET + whitelisted POST endpoints. HMAC cookie key derived deterministically from master token via SHA-256. --- internal/worker/middleware.go | 209 +++++++++++++++++++++++++++++++--- internal/worker/service.go | 1 + 2 files changed, 197 insertions(+), 13 deletions(-) diff --git a/internal/worker/middleware.go b/internal/worker/middleware.go index be840943..c330b937 100644 --- a/internal/worker/middleware.go +++ b/internal/worker/middleware.go @@ -3,9 +3,13 @@ package worker import ( "context" + "crypto/hmac" "crypto/rand" + "crypto/sha256" "crypto/subtle" + "encoding/base64" "encoding/hex" + "encoding/json" "fmt" "net/http" "os" @@ -14,7 +18,9 @@ import ( "sync" "time" + gormdb "github.com/thebtf/engram/internal/db/gorm" "github.com/rs/zerolog/log" + "golang.org/x/crypto/bcrypt" ) // requestIDKey is the context key for request IDs. @@ -92,12 +98,26 @@ func MaxBodySize(maxBytes int64) func(http.Handler) http.Handler { } } +// readOnlyAllowedPosts is the set of POST endpoints that read-only client tokens may call. +// These are search/analytics endpoints that use POST for request bodies but do not mutate state. +var readOnlyAllowedPosts = map[string]bool{ + "/api/context/search": true, + "/api/context/inject": true, + "/api/decisions/search": true, + "/api/analytics/search-misses": true, +} + // TokenAuth provides token-based authentication for the worker HTTP API. -// The token is supplied via ENGRAM_API_TOKEN and must be provided in -// the X-Auth-Token header or Authorization: Bearer header. +// Supports three auth methods: +// 1. Master token (ENGRAM_API_TOKEN env var) via X-Auth-Token or Authorization: Bearer header -> admin +// 2. Client API tokens (eng_* prefix, bcrypt-hashed in DB) via same headers -> scoped access +// 3. HMAC-signed session cookie (engram_session) -> admin (dashboard) type TokenAuth struct { ExemptPaths map[string]bool token string + cookieKey []byte + tokenStore *gormdb.TokenStore + statsCh chan string // buffered channel for async stats increment mu sync.RWMutex enabled bool } @@ -108,14 +128,25 @@ type TokenAuth struct { func NewTokenAuth(token string) (*TokenAuth, error) { authDisabled := strings.EqualFold(strings.TrimSpace(os.Getenv("ENGRAM_AUTH_DISABLED")), "true") + // Derive HMAC cookie key from master token using SHA-256 (deterministic, no extra config). + var cookieKey []byte + if token != "" { + h := sha256.Sum256([]byte(token)) + cookieKey = h[:] + } + ta := &TokenAuth{ - enabled: token != "" && !authDisabled, - token: token, + enabled: token != "" && !authDisabled, + token: token, + cookieKey: cookieKey, + statsCh: make(chan string, 256), ExemptPaths: map[string]bool{ - "/health": true, - "/api/health": true, - "/api/ready": true, - "/api/version": true, + "/health": true, + "/api/health": true, + "/api/ready": true, + "/api/version": true, + "/api/auth/login": true, + "/api/auth/logout": true, }, } @@ -134,6 +165,13 @@ func (ta *TokenAuth) Token() string { return ta.token } +// CookieKey returns the HMAC key used for signing session cookies. +func (ta *TokenAuth) CookieKey() []byte { + ta.mu.RLock() + defer ta.mu.RUnlock() + return ta.cookieKey +} + // IsEnabled returns whether token authentication is enabled. func (ta *TokenAuth) IsEnabled() bool { ta.mu.RLock() @@ -141,13 +179,29 @@ func (ta *TokenAuth) IsEnabled() bool { return ta.enabled } +// SetTokenStore sets the token store for client token lookups. +// Called after DB initialization completes. +func (ta *TokenAuth) SetTokenStore(store *gormdb.TokenStore) { + ta.mu.Lock() + defer ta.mu.Unlock() + ta.tokenStore = store +} + +// StatsCh returns the buffered channel for async token stats increment. +func (ta *TokenAuth) StatsCh() chan string { + return ta.statsCh +} + // Middleware returns HTTP middleware that enforces token authentication. +// Auth priority: header token (master or client) > session cookie > 401. func (ta *TokenAuth) Middleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { ta.mu.RLock() enabled := ta.enabled - token := ta.token + masterToken := ta.token exempt := ta.ExemptPaths[r.URL.Path] + cookieKey := ta.cookieKey + store := ta.tokenStore ta.mu.RUnlock() // Skip auth if disabled or path is exempt @@ -156,26 +210,155 @@ func (ta *TokenAuth) Middleware(next http.Handler) http.Handler { return } - // Check for token in header + // Also exempt static assets and docs + if strings.HasPrefix(r.URL.Path, "/assets/") || strings.HasPrefix(r.URL.Path, "/api/docs") { + next.ServeHTTP(w, r) + return + } + + // 1. Check for token in header providedToken := r.Header.Get("X-Auth-Token") if providedToken == "" { - // Also check Authorization header with Bearer scheme auth := r.Header.Get("Authorization") if bearer, found := strings.CutPrefix(auth, "Bearer "); found { providedToken = bearer } } - if subtle.ConstantTimeCompare([]byte(providedToken), []byte(token)) != 1 { + if providedToken != "" { + // 1a. Check if it matches the master token -> admin + if subtle.ConstantTimeCompare([]byte(providedToken), []byte(masterToken)) == 1 { + ctx := context.WithValue(r.Context(), authRoleKey{}, "admin") + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + + // 1b. Check if it's a client token (eng_* prefix) + if strings.HasPrefix(providedToken, "eng_") && store != nil { + if ta.authenticateClientToken(w, r, next, providedToken, store) { + return + } + // authenticateClientToken wrote the error response if it failed + return + } + + // Token provided but doesn't match anything log.Warn().Str("path", r.URL.Path).Str("remote_addr", r.RemoteAddr).Msg("auth: rejected request with invalid token") http.Error(w, "unauthorized", http.StatusUnauthorized) return } - next.ServeHTTP(w, r) + // 2. Check session cookie + if cookieKey != nil { + cookie, err := r.Cookie(sessionCookieName) + if err == nil && cookie.Value != "" { + if ta.authenticateSessionCookie(cookie.Value, cookieKey) { + ctx := context.WithValue(r.Context(), authRoleKey{}, "admin") + next.ServeHTTP(w, r.WithContext(ctx)) + return + } + } + } + + // 3. No valid auth + log.Warn().Str("path", r.URL.Path).Str("remote_addr", r.RemoteAddr).Msg("auth: rejected unauthenticated request") + http.Error(w, "unauthorized", http.StatusUnauthorized) }) } +// authenticateClientToken validates a client API token and enforces scope. +// Returns true if the request was handled (either forwarded or rejected). +func (ta *TokenAuth) authenticateClientToken(w http.ResponseWriter, r *http.Request, next http.Handler, rawToken string, store *gormdb.TokenStore) bool { + // Extract prefix: chars 4-12 (first 8 hex chars after "eng_") + if len(rawToken) < 12 { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return true + } + prefix := rawToken[4:12] + + token, err := store.FindByPrefix(r.Context(), prefix) + if err != nil { + log.Error().Err(err).Msg("auth: token store lookup failed") + http.Error(w, "internal error", http.StatusInternalServerError) + return true + } + if token == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return true + } + + // Verify bcrypt hash + if err := bcrypt.CompareHashAndPassword([]byte(token.TokenHash), []byte(rawToken)); err != nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return true + } + + // Check revoked + if token.Revoked { + http.Error(w, "token revoked", http.StatusUnauthorized) + return true + } + + // Enforce read-only scope + if token.Scope == "read-only" { + method := r.Method + if method == "POST" || method == "PUT" || method == "DELETE" || method == "PATCH" { + if !readOnlyAllowedPosts[r.URL.Path] { + http.Error(w, "forbidden: read-only token", http.StatusForbidden) + return true + } + } + } + + // Increment stats asynchronously + select { + case ta.statsCh <- token.ID: + default: + // Channel full — skip stats update rather than block auth + } + + ctx := context.WithValue(r.Context(), authRoleKey{}, token.Scope) + next.ServeHTTP(w, r.WithContext(ctx)) + return true +} + +// authenticateSessionCookie validates an HMAC-signed session cookie. +func (ta *TokenAuth) authenticateSessionCookie(cookieValue string, key []byte) bool { + // Cookie format: base64url(payload).base64url(hmac) + parts := strings.SplitN(cookieValue, ".", 2) + if len(parts) != 2 { + return false + } + + payloadBytes, err := base64.RawURLEncoding.DecodeString(parts[0]) + if err != nil { + return false + } + + sigBytes, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + return false + } + + // Verify HMAC + expected := computeHMAC(payloadBytes, key) + if !hmac.Equal(sigBytes, expected) { + return false + } + + // Check expiration + var payload sessionPayload + if err := json.Unmarshal(payloadBytes, &payload); err != nil { + return false + } + + if time.Now().Unix() > payload.Exp { + return false + } + + return true +} + // ExpensiveOperationLimiter provides stricter rate limiting for expensive operations. // It wraps the base per-client rate limiter with additional per-operation limits. type ExpensiveOperationLimiter struct { diff --git a/internal/worker/service.go b/internal/worker/service.go index e7b3a176..cd575106 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -154,6 +154,7 @@ type Service struct { retrievalStats map[string]*RetrievalStats sessionStore *gorm.SessionStore rawEventStore *gorm.RawEventStore + tokenStore *gorm.TokenStore ingestDedup *deduplicationCache cancel context.CancelFunc cachedObsCounts map[string]cachedCount From d10c8074e493d8aa78b7dbbe850fb187cb6c0d9c Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:13:36 +0300 Subject: [PATCH 05/24] feat: register auth routes and wire TokenStore Add public routes (login, logout) and authenticated routes (me, tokens CRUD) to setupRoutes(). Create TokenStore in initializeAsync() and wire it into TokenAuth middleware after DB initialization. --- internal/worker/service.go | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/internal/worker/service.go b/internal/worker/service.go index cd575106..5966c318 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -721,6 +721,9 @@ func (s *Service) initializeAsync() { log.Info().Msg("SDK processor initialized") } + // Create token store and wire into auth middleware + tokenStore := gorm.NewTokenStore(store) + // Create raw event store and ingest deduplication cache rawEventStore := gorm.NewRawEventStore(store) @@ -729,6 +732,7 @@ func (s *Service) initializeAsync() { s.store = store s.sessionStore = sessionStore s.rawEventStore = rawEventStore + s.tokenStore = tokenStore s.observationStore = observationStore s.summaryStore = summaryStore s.promptStore = promptStore @@ -748,6 +752,11 @@ func (s *Service) initializeAsync() { s.reranker = reranker s.initMu.Unlock() + // Wire token store into auth middleware for client token lookups + if s.tokenAuth != nil { + s.tokenAuth.SetTokenStore(tokenStore) + } + // Initialize similarity telemetry (optional — requires vector client) if vectorClient != nil && store != nil && observationStore != nil { simTelemetry := telemetry.NewSimilarityTelemetry(store, observationStore, vectorClient, log.Logger) @@ -1567,6 +1576,10 @@ func (s *Service) setupRoutes() { s.router.Get("/", serveIndex) s.router.Get("/assets/*", serveAssets) + // Auth routes (public — login/logout do not require auth) + s.router.Post("/api/auth/login", s.handleAuthLogin) + s.router.Post("/api/auth/logout", s.handleAuthLogout) + // Health check (both root and API-prefixed for compatibility) // Returns 200 immediately so hooks can connect quickly during init // Also returns version for stale worker detection @@ -1734,6 +1747,12 @@ func (s *Service) setupRoutes() { // Telemetry r.Get("/api/telemetry/similarity", s.handleGetSimilarityTelemetry) + + // Auth routes (require auth — admin only) + r.Get("/api/auth/me", s.handleAuthMe) + r.Get("/api/auth/tokens", s.handleListTokens) + r.Post("/api/auth/tokens", s.handleCreateToken) + r.Delete("/api/auth/tokens/{id}", s.handleRevokeToken) }) } From 37fb2b4bb63dfaa0d9320f8ed3d36a74fc67148a Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:14:31 +0300 Subject: [PATCH 06/24] feat: add buffered token stats increment Background goroutine reads token IDs from a buffered channel and flushes accumulated request counts to the database every 5 seconds, reducing per-request UPDATE overhead for client token authentication. --- internal/worker/service.go | 3 ++ internal/worker/token_stats.go | 73 ++++++++++++++++++++++++++++++++++ 2 files changed, 76 insertions(+) create mode 100644 internal/worker/token_stats.go diff --git a/internal/worker/service.go b/internal/worker/service.go index 5966c318..6f9ccabc 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -757,6 +757,9 @@ func (s *Service) initializeAsync() { s.tokenAuth.SetTokenStore(tokenStore) } + // Start buffered token stats flusher (batches DB writes every 5s) + s.startTokenStatsFlusher(s.ctx) + // Initialize similarity telemetry (optional — requires vector client) if vectorClient != nil && store != nil && observationStore != nil { simTelemetry := telemetry.NewSimilarityTelemetry(store, observationStore, vectorClient, log.Logger) diff --git a/internal/worker/token_stats.go b/internal/worker/token_stats.go new file mode 100644 index 00000000..772ac6df --- /dev/null +++ b/internal/worker/token_stats.go @@ -0,0 +1,73 @@ +// Package worker provides the buffered token stats flusher. +package worker + +import ( + "context" + "time" + + "github.com/rs/zerolog/log" +) + +// tokenStatsFlushInterval is how often accumulated token usage counts are flushed to the database. +const tokenStatsFlushInterval = 5 * time.Second + +// startTokenStatsFlusher starts a background goroutine that reads token IDs from +// the stats channel and batches IncrementStats calls to the database every 5 seconds. +// This avoids per-request UPDATE overhead for high-throughput client token auth. +func (s *Service) startTokenStatsFlusher(ctx context.Context) { + if s.tokenAuth == nil { + return + } + + ch := s.tokenAuth.StatsCh() + if ch == nil { + return + } + + s.wg.Add(1) + go func() { + defer s.wg.Done() + + pending := make(map[string]int) // token ID -> request count + ticker := time.NewTicker(tokenStatsFlushInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + // Final flush on shutdown + s.flushTokenStats(pending) + return + + case tokenID := <-ch: + pending[tokenID]++ + + case <-ticker.C: + s.flushTokenStats(pending) + // Reset the map + for k := range pending { + delete(pending, k) + } + } + } + }() +} + +// flushTokenStats writes accumulated token usage counts to the database. +func (s *Service) flushTokenStats(counts map[string]int) { + if len(counts) == 0 { + return + } + + s.initMu.RLock() + store := s.tokenStore + s.initMu.RUnlock() + + if store == nil { + return + } + + if err := store.BatchIncrementStats(context.Background(), counts); err != nil { + log.Warn().Err(err).Int("tokens", len(counts)).Msg("auth: failed to flush token stats") + } +} From af6ae683c8ee8730fcfdc63002c89a02c2d05a10 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:32:56 +0300 Subject: [PATCH 07/24] feat: add vault REST endpoints (AD-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New handlers in handlers_vault.go: - GET /api/vault/credentials — list credentials (no values) - GET /api/vault/credentials/{name} — get with decrypted value - POST /api/vault/credentials — store credential - DELETE /api/vault/credentials/{name} — delete credential - GET /api/vault/status — vault encryption status All handlers delegate to existing crypto.Vault and ObservationStore. Includes swaggo annotations for OpenAPI docs. --- internal/worker/handlers_vault.go | 318 ++++++++++++++++++++++++++++++ 1 file changed, 318 insertions(+) create mode 100644 internal/worker/handlers_vault.go diff --git a/internal/worker/handlers_vault.go b/internal/worker/handlers_vault.go new file mode 100644 index 00000000..bb6ee9f3 --- /dev/null +++ b/internal/worker/handlers_vault.go @@ -0,0 +1,318 @@ +// Package worker provides vault credential REST handlers for the dashboard. +package worker + +import ( + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + "github.com/thebtf/engram/internal/config" + "github.com/thebtf/engram/internal/crypto" + "github.com/thebtf/engram/pkg/models" +) + +// storeCredentialRequest is the JSON body for POST /api/vault/credentials. +type storeCredentialRequest struct { + Name string `json:"name"` + Value string `json:"value"` + Scope string `json:"scope"` + Project string `json:"project"` + Tags []string `json:"tags,omitempty"` +} + +// handleListCredentials godoc +// @Summary List vault credentials +// @Description Returns all stored credentials with metadata (name, scope, created) but NOT decrypted values. +// @Tags Vault +// @Produce json +// @Security ApiKeyAuth +// @Param project query string false "Filter by project" +// @Success 200 {array} object +// @Failure 500 {string} string "internal error" +// @Router /api/vault/credentials [get] +func (s *Service) handleListCredentials(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + project := r.URL.Query().Get("project") + + creds, err := s.observationStore.ListCredentials(r.Context(), project) + if err != nil { + log.Error().Err(err).Msg("list credentials failed") + http.Error(w, "list credentials: "+err.Error(), http.StatusInternalServerError) + return + } + + type credItem struct { + Concepts []string `json:"concepts,omitempty"` + Name string `json:"name"` + Scope string `json:"scope"` + ID int64 `json:"id"` + } + items := make([]credItem, 0, len(creds)) + for _, c := range creds { + name := "" + if c.Title.Valid { + name = c.Title.String + } else if c.Narrative.Valid { + name = c.Narrative.String + } + items = append(items, credItem{ + ID: c.ID, + Name: name, + Scope: string(c.Scope), + Concepts: []string(c.Concepts), + }) + } + + writeJSON(w, items) +} + +// handleGetCredential godoc +// @Summary Get credential with decrypted value +// @Description Retrieves and decrypts a credential by name. Verifies key fingerprint before decryption. +// @Tags Vault +// @Produce json +// @Security ApiKeyAuth +// @Param name path string true "Credential name" +// @Param project query string false "Project scope" +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "internal error" +// @Router /api/vault/credentials/{name} [get] +func (s *Service) handleGetCredential(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + name := chi.URLParam(r, "name") + if name == "" { + http.Error(w, "credential name is required", http.StatusBadRequest) + return + } + project := r.URL.Query().Get("project") + + v, err := s.getVault() + if err != nil { + http.Error(w, "vault not available: "+err.Error(), http.StatusInternalServerError) + return + } + + cred, err := s.observationStore.GetCredential(r.Context(), name, project) + if err != nil { + log.Error().Err(err).Str("name", name).Msg("get credential failed") + http.Error(w, "get credential: "+err.Error(), http.StatusInternalServerError) + return + } + if cred == nil { + http.Error(w, "credential not found", http.StatusNotFound) + return + } + + // Verify key fingerprint before decryption to detect key mismatch early. + if cred.EncryptionKeyFingerprint.Valid && cred.EncryptionKeyFingerprint.String != "" { + if !v.MatchesFingerprint(cred.EncryptionKeyFingerprint.String) { + http.Error(w, "encryption key mismatch: credential was encrypted with a different key", http.StatusConflict) + return + } + } + + plaintext, err := v.Decrypt(cred.EncryptedSecret) + if err != nil { + log.Error().Err(err).Str("name", name).Msg("decrypt credential failed") + http.Error(w, "decrypt credential: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "name": name, + "value": plaintext, + "scope": string(cred.Scope), + }) +} + +// handleStoreCredential godoc +// @Summary Store a new credential +// @Description Encrypts and stores a credential observation using AES-256-GCM vault encryption. +// @Tags Vault +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body storeCredentialRequest true "Credential to store" +// @Success 201 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/vault/credentials [post] +func (s *Service) handleStoreCredential(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + var req storeCredentialRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + + if req.Name == "" { + http.Error(w, "name is required", http.StatusBadRequest) + return + } + if req.Value == "" { + http.Error(w, "value is required", http.StatusBadRequest) + return + } + if req.Scope == "" { + req.Scope = "project" + } + switch req.Scope { + case "project", "global": + // valid + default: + http.Error(w, "invalid scope: must be \"project\" or \"global\"", http.StatusBadRequest) + return + } + if req.Scope == "project" && req.Project == "" { + http.Error(w, "project is required for project-scoped credentials", http.StatusBadRequest) + return + } + + v, err := s.getVault() + if err != nil { + http.Error(w, "vault not available: "+err.Error(), http.StatusInternalServerError) + return + } + + ciphertext, err := v.Encrypt(req.Value) + if err != nil { + log.Error().Err(err).Msg("encrypt credential failed") + http.Error(w, "encrypt credential: "+err.Error(), http.StatusInternalServerError) + return + } + + scope := models.ObservationScope(req.Scope) + obs := &models.ParsedObservation{ + Type: models.ObsTypeCredential, + SourceType: models.SourceManual, + Title: req.Name, + Narrative: req.Name, + Concepts: req.Tags, + Scope: scope, + EncryptedSecret: ciphertext, + EncryptionKeyFingerprint: v.Fingerprint(), + } + + const vaultSessionID = "credential:vault" + id, _, err := s.observationStore.StoreObservation(r.Context(), vaultSessionID, req.Project, obs, 0, 0) + if err != nil { + log.Error().Err(err).Msg("store credential observation failed") + http.Error(w, "store credential: "+err.Error(), http.StatusInternalServerError) + return + } + + w.WriteHeader(http.StatusCreated) + writeJSON(w, map[string]any{ + "id": id, + "name": req.Name, + "scope": string(scope), + "message": "Credential stored successfully", + }) +} + +// handleDeleteCredential godoc +// @Summary Delete a credential +// @Description Removes a credential by name and optional project/scope filter. +// @Tags Vault +// @Produce json +// @Security ApiKeyAuth +// @Param name path string true "Credential name" +// @Param project query string false "Project scope" +// @Param scope query string false "Scope filter (project or global)" default(project) +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/vault/credentials/{name} [delete] +func (s *Service) handleDeleteCredential(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + name := chi.URLParam(r, "name") + if name == "" { + http.Error(w, "credential name is required", http.StatusBadRequest) + return + } + + project := r.URL.Query().Get("project") + scope := r.URL.Query().Get("scope") + if scope == "" { + scope = "project" + } + switch scope { + case "project", "global": + // valid + default: + http.Error(w, "invalid scope: must be \"project\" or \"global\"", http.StatusBadRequest) + return + } + if scope == "project" && project == "" { + http.Error(w, "project is required for project-scoped credentials", http.StatusBadRequest) + return + } + + if err := s.observationStore.DeleteCredential(r.Context(), name, project, scope); err != nil { + log.Error().Err(err).Str("name", name).Msg("delete credential failed") + http.Error(w, "delete credential: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "deleted": true, + "name": name, + }) +} + +// handleVaultStatus godoc +// @Summary Get vault encryption status +// @Description Returns vault key configuration status, fingerprint, and credential count. +// @Tags Vault +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} object +// @Failure 500 {string} string "internal error" +// @Router /api/vault/status [get] +func (s *Service) handleVaultStatus(w http.ResponseWriter, r *http.Request) { + cfg := config.Get() + keyConfigured := crypto.VaultExists(cfg) + fingerprint := "" + keySource := "" + + if keyConfigured { + if v, err := s.getVault(); err == nil && v != nil { + fingerprint = v.Fingerprint() + keySource = v.KeySource() + } + } + + count := 0 + if s.observationStore != nil { + if n, err := s.observationStore.CountCredentials(r.Context()); err == nil { + count = int(n) + } + } + + writeJSON(w, map[string]any{ + "key_configured": keyConfigured, + "key_source": keySource, + "fingerprint": fingerprint, + "credential_count": count, + "backup_reminder": "Back up vault.key (or set ENGRAM_ENCRYPTION_KEY) — losing this key makes stored credentials unrecoverable", + }) +} From f6db53f697a29c5bc1afc0a510a0ea539cf357f8 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:33:36 +0300 Subject: [PATCH 08/24] feat: add tag REST endpoints (AD-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New handlers in handlers_tags.go: - POST /api/observations/{id}/tags — add/remove/set tags - GET /api/observations/by-tag/{tag} — list observations by tag Logic mirrors MCP tag_observation and get_observations_by_tag handlers. Includes swaggo annotations for OpenAPI docs. --- internal/worker/handlers_tags.go | 204 +++++++++++++++++++++++++++++++ 1 file changed, 204 insertions(+) create mode 100644 internal/worker/handlers_tags.go diff --git a/internal/worker/handlers_tags.go b/internal/worker/handlers_tags.go new file mode 100644 index 00000000..33933363 --- /dev/null +++ b/internal/worker/handlers_tags.go @@ -0,0 +1,204 @@ +// Package worker provides tag management REST handlers for the dashboard. +package worker + +import ( + "encoding/json" + "net/http" + "strconv" + + "github.com/go-chi/chi/v5" + "github.com/rs/zerolog/log" + "github.com/thebtf/engram/internal/db/gorm" + "github.com/thebtf/engram/internal/search" +) + +// tagObservationRequest is the JSON body for POST /api/observations/{id}/tags. +type tagObservationRequest struct { + Action string `json:"action"` // "add", "remove", or "set" + Tags []string `json:"tags"` +} + +// handleTagObservation godoc +// @Summary Add, remove, or set tags on an observation +// @Description Modifies the concept tags on an observation. Action must be "add", "remove", or "set". +// @Tags Tags +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param id path int true "Observation ID" +// @Param body body tagObservationRequest true "Tag operation" +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 404 {string} string "not found" +// @Failure 500 {string} string "internal error" +// @Router /api/observations/{id}/tags [post] +func (s *Service) handleTagObservation(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + idStr := chi.URLParam(r, "id") + id, ok := parseIDParam(w, idStr, "observation") + if !ok { + return + } + + var req tagObservationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + + if len(req.Tags) == 0 { + http.Error(w, "tags is required", http.StatusBadRequest) + return + } + switch req.Action { + case "add", "remove", "set": + // valid + default: + http.Error(w, "action must be 'add', 'remove', or 'set'", http.StatusBadRequest) + return + } + + // Get current observation + obs, err := s.observationStore.GetObservationByID(r.Context(), id) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("get observation for tagging failed") + http.Error(w, "get observation: "+err.Error(), http.StatusInternalServerError) + return + } + if obs == nil { + http.Error(w, "observation not found", http.StatusNotFound) + return + } + + // Compute new tags + var newTags []string + switch req.Action { + case "set": + newTags = req.Tags + case "add": + tagSet := make(map[string]bool) + for _, t := range obs.Concepts { + tagSet[t] = true + newTags = append(newTags, t) + } + for _, t := range req.Tags { + if !tagSet[t] { + tagSet[t] = true + newTags = append(newTags, t) + } + } + case "remove": + removeSet := make(map[string]bool) + for _, t := range req.Tags { + removeSet[t] = true + } + for _, t := range obs.Concepts { + if !removeSet[t] { + newTags = append(newTags, t) + } + } + } + + update := &gorm.ObservationUpdate{ + Concepts: &newTags, + } + updatedObs, err := s.observationStore.UpdateObservation(r.Context(), id, update) + if err != nil { + log.Error().Err(err).Int64("id", id).Msg("update observation tags failed") + http.Error(w, "update observation: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "id": id, + "action": req.Action, + "tags_applied": req.Tags, + "current_tags": updatedObs.Concepts, + }) +} + +// handleGetObservationsByTag godoc +// @Summary List observations by tag +// @Description Returns observations that have the specified concept tag. +// @Tags Tags +// @Produce json +// @Security ApiKeyAuth +// @Param tag path string true "Tag to search for" +// @Param project query string false "Filter by project" +// @Param limit query int false "Number of results (default 50, max 200)" +// @Param offset query int false "Pagination offset" +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/observations/by-tag/{tag} [get] +func (s *Service) handleGetObservationsByTag(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + tag := chi.URLParam(r, "tag") + if tag == "" { + http.Error(w, "tag is required", http.StatusBadRequest) + return + } + + project := r.URL.Query().Get("project") + if err := ValidateProjectName(project); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + limit := 50 + if val := r.URL.Query().Get("limit"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 && parsed <= 200 { + limit = parsed + } + } + + if s.searchMgr == nil { + http.Error(w, "search manager not available", http.StatusServiceUnavailable) + return + } + + searchParams := search.SearchParams{ + Query: tag, + Type: "observations", + Project: project, + Limit: limit, + Concepts: tag, + } + + result, err := s.searchMgr.UnifiedSearch(r.Context(), searchParams) + if err != nil { + log.Error().Err(err).Str("tag", tag).Msg("search by tag failed") + http.Error(w, "search: "+err.Error(), http.StatusInternalServerError) + return + } + + // Filter results to only include observations with the exact tag + var filtered []search.SearchResult + for _, res := range result.Results { + if res.Type != "observation" { + continue + } + if concepts, ok := res.Metadata["concepts"].([]any); ok { + for _, c := range concepts { + if cs, ok := c.(string); ok && cs == tag { + filtered = append(filtered, res) + break + } + } + } + } + + writeJSON(w, map[string]any{ + "tag": tag, + "observations": filtered, + "count": len(filtered), + }) +} From b149125e1d883a43c4c8ad5a841905e54f0b8a59 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:34:14 +0300 Subject: [PATCH 09/24] feat: add session REST endpoints (AD-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New handlers in handlers_sessions_rest.go: - GET /api/sessions-index — list indexed sessions - GET /api/sessions-index/search — full-text search transcripts Uses /api/sessions-index path to avoid conflict with existing /api/sessions live session management routes. Includes swaggo annotations for OpenAPI docs. --- internal/worker/handlers_sessions_rest.go | 162 ++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 internal/worker/handlers_sessions_rest.go diff --git a/internal/worker/handlers_sessions_rest.go b/internal/worker/handlers_sessions_rest.go new file mode 100644 index 00000000..b8467381 --- /dev/null +++ b/internal/worker/handlers_sessions_rest.go @@ -0,0 +1,162 @@ +// Package worker provides indexed session REST handlers for the dashboard. +package worker + +import ( + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" + "github.com/thebtf/engram/internal/sessions" +) + +// handleListIndexedSessions godoc +// @Summary List indexed sessions +// @Description Returns indexed Claude Code sessions with optional project and workstation filters. +// @Tags Sessions +// @Produce json +// @Security ApiKeyAuth +// @Param project query string false "Filter by project ID" +// @Param workstation query string false "Filter by workstation ID" +// @Param limit query int false "Number of results (default 20)" +// @Param offset query int false "Pagination offset" +// @Success 200 {array} object +// @Failure 500 {string} string "internal error" +// @Router /api/sessions-index [get] +func (s *Service) handleListIndexedSessions(w http.ResponseWriter, r *http.Request) { + if s.sessionIdxStore == nil { + http.Error(w, "session indexing not configured", http.StatusServiceUnavailable) + return + } + + limit := 20 + if val := r.URL.Query().Get("limit"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 { + limit = parsed + } + } + offset := 0 + if val := r.URL.Query().Get("offset"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed >= 0 { + offset = parsed + } + } + + opts := sessions.ListOptions{ + WorkstationID: r.URL.Query().Get("workstation"), + ProjectID: r.URL.Query().Get("project"), + Limit: limit, + Offset: offset, + } + + list, err := s.sessionIdxStore.ListSessions(r.Context(), opts) + if err != nil { + log.Error().Err(err).Msg("list indexed sessions failed") + http.Error(w, "list sessions: "+err.Error(), http.StatusInternalServerError) + return + } + + type sessionItem struct { + ID string `json:"id"` + WorkstationID string `json:"workstation_id"` + ProjectID string `json:"project_id"` + ProjectPath string `json:"project_path,omitempty"` + ExchangeCount int `json:"exchange_count"` + GitBranch string `json:"git_branch,omitempty"` + LastMsgAt string `json:"last_msg_at,omitempty"` + } + + out := make([]sessionItem, 0, len(list)) + for _, sess := range list { + item := sessionItem{ + ID: sess.ID, + WorkstationID: sess.WorkstationID, + ProjectID: sess.ProjectID, + ExchangeCount: sess.ExchangeCount, + } + if sess.ProjectPath.Valid { + item.ProjectPath = sess.ProjectPath.String + } + if sess.GitBranch.Valid { + item.GitBranch = sess.GitBranch.String + } + if sess.LastMsgAt.Valid { + item.LastMsgAt = sess.LastMsgAt.Time.UTC().Format(time.RFC3339) + } + out = append(out, item) + } + + writeJSON(w, out) +} + +// handleSearchIndexedSessions godoc +// @Summary Full-text search across session transcripts +// @Description Searches indexed session content using PostgreSQL full-text search. +// @Tags Sessions +// @Produce json +// @Security ApiKeyAuth +// @Param query query string true "Search query" +// @Param project query string false "Filter by project" +// @Param limit query int false "Number of results (default 10)" +// @Success 200 {array} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/sessions-index/search [get] +func (s *Service) handleSearchIndexedSessions(w http.ResponseWriter, r *http.Request) { + if s.sessionIdxStore == nil { + http.Error(w, "session indexing not configured", http.StatusServiceUnavailable) + return + } + + query := r.URL.Query().Get("query") + if query == "" { + http.Error(w, "query parameter is required", http.StatusBadRequest) + return + } + + limit := 10 + if val := r.URL.Query().Get("limit"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 { + limit = parsed + } + } + + results, err := s.sessionIdxStore.SearchSessions(r.Context(), query, limit) + if err != nil { + log.Error().Err(err).Str("query", query).Msg("search indexed sessions failed") + http.Error(w, "search sessions: "+err.Error(), http.StatusInternalServerError) + return + } + + type sessionResult struct { + ID string `json:"id"` + WorkstationID string `json:"workstation_id"` + ProjectPath string `json:"project_path,omitempty"` + ExchangeCount int `json:"exchange_count"` + Rank float64 `json:"rank"` + Snippet string `json:"snippet,omitempty"` + } + + out := make([]sessionResult, 0, len(results)) + for _, res := range results { + sr := sessionResult{ + ID: res.Session.ID, + WorkstationID: res.Session.WorkstationID, + ExchangeCount: res.Session.ExchangeCount, + Rank: res.Rank, + } + if res.Session.ProjectPath.Valid { + sr.ProjectPath = res.Session.ProjectPath.String + } + if res.Session.Content.Valid && len(res.Session.Content.String) > 0 { + snippet := res.Session.Content.String + if len(snippet) > 200 { + snippet = snippet[:200] + } + sr.Snippet = snippet + } + out = append(out, sr) + } + + writeJSON(w, out) +} From e5b65c397453222239cc95d694d43a3f81024f39 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:34:48 +0300 Subject: [PATCH 10/24] feat: add maintenance REST endpoints (AD-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New handlers in handlers_maintenance.go: - POST /api/maintenance/consolidation — trigger consolidation cycle - POST /api/maintenance/run — trigger full maintenance - GET /api/maintenance/stats — maintenance statistics Delegates to existing consolidationScheduler and maintenanceService. Includes swaggo annotations for OpenAPI docs. --- internal/worker/handlers_maintenance.go | 113 ++++++++++++++++++++++++ 1 file changed, 113 insertions(+) create mode 100644 internal/worker/handlers_maintenance.go diff --git a/internal/worker/handlers_maintenance.go b/internal/worker/handlers_maintenance.go new file mode 100644 index 00000000..e3e0b84e --- /dev/null +++ b/internal/worker/handlers_maintenance.go @@ -0,0 +1,113 @@ +// Package worker provides maintenance REST handlers for the dashboard. +package worker + +import ( + "encoding/json" + "net/http" + + "github.com/rs/zerolog/log" +) + +// consolidationRequest is the JSON body for POST /api/maintenance/consolidation. +type consolidationRequest struct { + Cycle string `json:"cycle"` // "all", "decay", "associations", or "forgetting" +} + +// handleTriggerConsolidation godoc +// @Summary Trigger consolidation cycle +// @Description Runs a consolidation cycle (decay, associations, forgetting, or all). +// @Tags Maintenance +// @Accept json +// @Produce json +// @Security ApiKeyAuth +// @Param body body consolidationRequest false "Consolidation options (defaults to all)" +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/maintenance/consolidation [post] +func (s *Service) handleTriggerConsolidation(w http.ResponseWriter, r *http.Request) { + if s.consolidationScheduler == nil { + http.Error(w, "consolidation scheduler not available", http.StatusServiceUnavailable) + return + } + + var req consolidationRequest + // Body is optional; default to "all" + if r.Body != nil && r.ContentLength > 0 { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest) + return + } + } + if req.Cycle == "" { + req.Cycle = "all" + } + + var err error + switch req.Cycle { + case "all": + err = s.consolidationScheduler.RunAll(r.Context()) + case "decay": + err = s.consolidationScheduler.RunDecay(r.Context()) + case "associations": + err = s.consolidationScheduler.RunAssociations(r.Context()) + case "forgetting": + err = s.consolidationScheduler.RunForgetting(r.Context()) + default: + http.Error(w, "unknown cycle: "+req.Cycle+" (use 'all', 'decay', 'associations', or 'forgetting')", http.StatusBadRequest) + return + } + + if err != nil { + log.Error().Err(err).Str("cycle", req.Cycle).Msg("consolidation cycle failed") + http.Error(w, "consolidation "+req.Cycle+" cycle failed: "+err.Error(), http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{ + "status": "completed", + "cycle": req.Cycle, + }) +} + +// handleRunMaintenance godoc +// @Summary Trigger full maintenance run +// @Description Triggers a full maintenance cycle (cleanup, optimize) in the background. +// @Tags Maintenance +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} object +// @Failure 500 {string} string "internal error" +// @Router /api/maintenance/run [post] +func (s *Service) handleRunMaintenance(w http.ResponseWriter, r *http.Request) { + if s.maintenanceService == nil { + http.Error(w, "maintenance service not available", http.StatusServiceUnavailable) + return + } + + s.maintenanceService.RunNow(r.Context()) + + writeJSON(w, map[string]any{ + "status": "triggered", + "message": "Maintenance run started in background", + }) +} + +// handleGetMaintenanceStats godoc +// @Summary Get maintenance statistics +// @Description Returns maintenance service statistics including last run time, duration, and configuration. +// @Tags Maintenance +// @Produce json +// @Security ApiKeyAuth +// @Success 200 {object} object +// @Failure 500 {string} string "internal error" +// @Router /api/maintenance/stats [get] +func (s *Service) handleGetMaintenanceStats(w http.ResponseWriter, _ *http.Request) { + if s.maintenanceService == nil { + http.Error(w, "maintenance service not available", http.StatusServiceUnavailable) + return + } + + stats := s.maintenanceService.Stats() + writeJSON(w, stats) +} From 7e392bb42fd739b2992ab6e644d95b4376712d4b Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:35:24 +0300 Subject: [PATCH 11/24] feat: add analytics trends REST endpoint (AD-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New handler in handlers_analytics.go: - GET /api/analytics/trends — temporal trends (obs per period) Supports daily/weekly/hourly grouping with project filter. Logic mirrors MCP get_temporal_trends handler. Includes swaggo annotations for OpenAPI docs. --- internal/worker/handlers_analytics.go | 163 ++++++++++++++++++++++++++ 1 file changed, 163 insertions(+) create mode 100644 internal/worker/handlers_analytics.go diff --git a/internal/worker/handlers_analytics.go b/internal/worker/handlers_analytics.go new file mode 100644 index 00000000..d71f11c8 --- /dev/null +++ b/internal/worker/handlers_analytics.go @@ -0,0 +1,163 @@ +// Package worker provides analytics REST handlers for the dashboard. +package worker + +import ( + "fmt" + "net/http" + "strconv" + "time" + + "github.com/rs/zerolog/log" +) + +// handleGetTrends godoc +// @Summary Get temporal trends +// @Description Analyzes observation creation patterns over time, grouped by day, week, or hour_of_day. +// @Tags Analytics +// @Produce json +// @Security ApiKeyAuth +// @Param project query string false "Filter by project" +// @Param period query string false "Group by period: daily, weekly, hourly (default daily)" +// @Param days query int false "Number of days to analyze (default 30, max 365)" +// @Success 200 {object} object +// @Failure 400 {string} string "bad request" +// @Failure 500 {string} string "internal error" +// @Router /api/analytics/trends [get] +func (s *Service) handleGetTrends(w http.ResponseWriter, r *http.Request) { + if s.observationStore == nil { + http.Error(w, "observation store not available", http.StatusServiceUnavailable) + return + } + + project := r.URL.Query().Get("project") + if err := ValidateProjectName(project); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + // Map period param to group_by value used in the logic + period := r.URL.Query().Get("period") + groupBy := "day" + switch period { + case "weekly": + groupBy = "week" + case "hourly": + groupBy = "hour_of_day" + case "daily", "": + groupBy = "day" + default: + http.Error(w, "invalid period: must be 'daily', 'weekly', or 'hourly'", http.StatusBadRequest) + return + } + + days := 30 + if val := r.URL.Query().Get("days"); val != "" { + if parsed, err := strconv.Atoi(val); err == nil && parsed > 0 && parsed <= 365 { + days = parsed + } + } + + // Get observations for analysis (rough estimate of limit) + obs, err := s.observationStore.GetRecentObservations(r.Context(), project, days*50) + if err != nil { + log.Error().Err(err).Msg("get observations for trends failed") + http.Error(w, "get observations: "+err.Error(), http.StatusInternalServerError) + return + } + + // Calculate time range + now := time.Now() + startTime := now.AddDate(0, 0, -days) + startEpoch := startTime.UnixMilli() + + // Group observations by time bucket + buckets := make(map[string]int) + typeDistribution := make(map[string]int) + conceptCounts := make(map[string]int) + totalInRange := 0 + + for _, o := range obs { + if o.CreatedAtEpoch < startEpoch { + continue + } + totalInRange++ + + created := time.UnixMilli(o.CreatedAtEpoch) + var key string + switch groupBy { + case "week": + year, week := created.ISOWeek() + key = fmt.Sprintf("%d-W%02d", year, week) + case "hour_of_day": + key = fmt.Sprintf("%02d:00", created.Hour()) + default: // day + key = created.Format("2006-01-02") + } + buckets[key]++ + + // Track type distribution + typeDistribution[string(o.Type)]++ + + // Track top concepts + for _, c := range o.Concepts { + conceptCounts[c]++ + } + } + + // Find peak period + peakPeriod := "" + peakCount := 0 + for k, v := range buckets { + if v > peakCount { + peakCount = v + peakPeriod = k + } + } + + // Sort and get top concepts + type conceptEntry struct { + name string + count int + } + var topConcepts []conceptEntry + for name, count := range conceptCounts { + topConcepts = append(topConcepts, conceptEntry{name, count}) + } + for i := 0; i < len(topConcepts) && i < 10; i++ { + for j := i + 1; j < len(topConcepts); j++ { + if topConcepts[j].count > topConcepts[i].count { + topConcepts[i], topConcepts[j] = topConcepts[j], topConcepts[i] + } + } + } + if len(topConcepts) > 10 { + topConcepts = topConcepts[:10] + } + topConceptsMap := make([]map[string]any, len(topConcepts)) + for i, c := range topConcepts { + topConceptsMap[i] = map[string]any{"concept": c.name, "count": c.count} + } + + dailyAvg := float64(0) + if days > 0 { + dailyAvg = float64(totalInRange) / float64(days) + } + + writeJSON(w, map[string]any{ + "period": map[string]any{ + "start": startTime.Format("2006-01-02"), + "end": now.Format("2006-01-02"), + "days": days, + "group_by": groupBy, + }, + "summary": map[string]any{ + "total_observations": totalInRange, + "daily_average": dailyAvg, + "peak_period": peakPeriod, + "peak_count": peakCount, + }, + "distribution": buckets, + "type_distribution": typeDistribution, + "top_concepts": topConceptsMap, + }) +} From de66b70347b62ac1c1943a19d71f92f93903fb74 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 02:36:00 +0300 Subject: [PATCH 12/24] feat: register vault/tag/session/maintenance/analytics routes Wire all new REST handlers into the requireReady + auth route group: - /api/vault/* (5 endpoints) - /api/observations/{id}/tags, /api/observations/by-tag/{tag} - /api/sessions-index, /api/sessions-index/search - /api/maintenance/* (3 endpoints) - /api/analytics/trends --- internal/worker/service.go | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/internal/worker/service.go b/internal/worker/service.go index 6f9ccabc..7a157cc2 100644 --- a/internal/worker/service.go +++ b/internal/worker/service.go @@ -1756,6 +1756,29 @@ func (s *Service) setupRoutes() { r.Get("/api/auth/tokens", s.handleListTokens) r.Post("/api/auth/tokens", s.handleCreateToken) r.Delete("/api/auth/tokens/{id}", s.handleRevokeToken) + + // Vault routes + r.Get("/api/vault/credentials", s.handleListCredentials) + r.Get("/api/vault/credentials/{name}", s.handleGetCredential) + r.Post("/api/vault/credentials", s.handleStoreCredential) + r.Delete("/api/vault/credentials/{name}", s.handleDeleteCredential) + r.Get("/api/vault/status", s.handleVaultStatus) + + // Tag routes + r.Post("/api/observations/{id}/tags", s.handleTagObservation) + r.Get("/api/observations/by-tag/{tag}", s.handleGetObservationsByTag) + + // Indexed session routes (separate from live session management) + r.Get("/api/sessions-index", s.handleListIndexedSessions) + r.Get("/api/sessions-index/search", s.handleSearchIndexedSessions) + + // Maintenance routes + r.Post("/api/maintenance/consolidation", s.handleTriggerConsolidation) + r.Post("/api/maintenance/run", s.handleRunMaintenance) + r.Get("/api/maintenance/stats", s.handleGetMaintenanceStats) + + // Analytics routes + r.Get("/api/analytics/trends", s.handleGetTrends) }) } From 41b8682ea11e0ba082bc5246a31454ffed6acf5d Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 03:10:40 +0300 Subject: [PATCH 13/24] feat: install vue-router 4, Fira fonts --- ui/package.json | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/ui/package.json b/ui/package.json index 20e78b4a..0ccf4b4c 100644 --- a/ui/package.json +++ b/ui/package.json @@ -10,9 +10,12 @@ "type-check": "vue-tsc --noEmit" }, "dependencies": { + "@fontsource/fira-code": "^5.2.5", + "@fontsource/fira-sans": "^5.2.5", "vis-data": "^7.1.9", "vis-network": "^9.1.9", - "vue": "^3.5.13" + "vue": "^3.5.13", + "vue-router": "^4.5.0" }, "devDependencies": { "@fortawesome/fontawesome-free": "^6.7.2", From a35064126d235dfbd3c01602ff46f81ea0ac7981 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 03:18:42 +0300 Subject: [PATCH 14/24] =?UTF-8?q?feat:=20add=20dashboard=20frontend=20layo?= =?UTF-8?q?ut=20=E2=80=94=20router,=20sidebar,=20auth,=20placeholder=20vie?= =?UTF-8?q?ws?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ui/src/App.vue | 133 +++++---------- ui/src/components/layout/AppHeader.vue | 180 +++++++++++++++++++++ ui/src/components/layout/AppSidebar.vue | 146 +++++++++++++++++ ui/src/components/layout/ConfirmDialog.vue | 64 ++++++++ ui/src/components/layout/EmptyState.vue | 16 ++ ui/src/components/layout/Pagination.vue | 104 ++++++++++++ ui/src/composables/index.ts | 1 + ui/src/composables/useAuth.ts | 45 ++++++ ui/src/main.ts | 11 +- ui/src/router/index.ts | 95 +++++++++++ ui/src/views/AnalyticsView.vue | 7 + ui/src/views/GraphView.vue | 7 + ui/src/views/HomeView.vue | 71 ++++++++ ui/src/views/LoginView.vue | 89 ++++++++++ ui/src/views/LogsView.vue | 7 + ui/src/views/ObservationDetailView.vue | 7 + ui/src/views/ObservationsView.vue | 7 + ui/src/views/PatternsView.vue | 7 + ui/src/views/SearchView.vue | 7 + ui/src/views/SessionsView.vue | 7 + ui/src/views/SystemView.vue | 7 + ui/src/views/TokensView.vue | 7 + ui/src/views/VaultView.vue | 7 + ui/tailwind.config.js | 16 +- 24 files changed, 951 insertions(+), 97 deletions(-) create mode 100644 ui/src/components/layout/AppHeader.vue create mode 100644 ui/src/components/layout/AppSidebar.vue create mode 100644 ui/src/components/layout/ConfirmDialog.vue create mode 100644 ui/src/components/layout/EmptyState.vue create mode 100644 ui/src/components/layout/Pagination.vue create mode 100644 ui/src/composables/useAuth.ts create mode 100644 ui/src/router/index.ts create mode 100644 ui/src/views/AnalyticsView.vue create mode 100644 ui/src/views/GraphView.vue create mode 100644 ui/src/views/HomeView.vue create mode 100644 ui/src/views/LoginView.vue create mode 100644 ui/src/views/LogsView.vue create mode 100644 ui/src/views/ObservationDetailView.vue create mode 100644 ui/src/views/ObservationsView.vue create mode 100644 ui/src/views/PatternsView.vue create mode 100644 ui/src/views/SearchView.vue create mode 100644 ui/src/views/SessionsView.vue create mode 100644 ui/src/views/SystemView.vue create mode 100644 ui/src/views/TokensView.vue create mode 100644 ui/src/views/VaultView.vue diff --git a/ui/src/App.vue b/ui/src/App.vue index 0c51221c..1faff666 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,110 +1,53 @@ diff --git a/ui/src/components/layout/AppHeader.vue b/ui/src/components/layout/AppHeader.vue new file mode 100644 index 00000000..9445b985 --- /dev/null +++ b/ui/src/components/layout/AppHeader.vue @@ -0,0 +1,180 @@ + + + diff --git a/ui/src/components/layout/AppSidebar.vue b/ui/src/components/layout/AppSidebar.vue new file mode 100644 index 00000000..77dd54cf --- /dev/null +++ b/ui/src/components/layout/AppSidebar.vue @@ -0,0 +1,146 @@ + + + diff --git a/ui/src/components/layout/ConfirmDialog.vue b/ui/src/components/layout/ConfirmDialog.vue new file mode 100644 index 00000000..279e06f6 --- /dev/null +++ b/ui/src/components/layout/ConfirmDialog.vue @@ -0,0 +1,64 @@ + + + + + diff --git a/ui/src/components/layout/EmptyState.vue b/ui/src/components/layout/EmptyState.vue new file mode 100644 index 00000000..7e962132 --- /dev/null +++ b/ui/src/components/layout/EmptyState.vue @@ -0,0 +1,16 @@ + + + diff --git a/ui/src/components/layout/Pagination.vue b/ui/src/components/layout/Pagination.vue new file mode 100644 index 00000000..bb8a92fc --- /dev/null +++ b/ui/src/components/layout/Pagination.vue @@ -0,0 +1,104 @@ + + + diff --git a/ui/src/composables/index.ts b/ui/src/composables/index.ts index 73c5dc37..021ea4a4 100644 --- a/ui/src/composables/index.ts +++ b/ui/src/composables/index.ts @@ -1,3 +1,4 @@ +export { useAuth } from './useAuth' export { useSSE } from './useSSE' export { useStats } from './useStats' export { useTimeline } from './useTimeline' diff --git a/ui/src/composables/useAuth.ts b/ui/src/composables/useAuth.ts new file mode 100644 index 00000000..218ba2ec --- /dev/null +++ b/ui/src/composables/useAuth.ts @@ -0,0 +1,45 @@ +import { ref, computed } from 'vue' + +// Singleton state shared across all useAuth() calls +const authenticated = ref(false) +const loading = ref(true) + +export function useAuth() { + async function checkAuth(): Promise { + loading.value = true + try { + const res = await fetch('/api/auth/me') + authenticated.value = res.ok + } catch { + authenticated.value = false + } finally { + loading.value = false + } + } + + async function login(token: string): Promise { + const res = await fetch('/api/auth/login', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ token }), + }) + authenticated.value = res.ok + return res.ok + } + + async function logout(): Promise { + try { + await fetch('/api/auth/logout', { method: 'POST' }) + } finally { + authenticated.value = false + } + } + + return { + authenticated: computed(() => authenticated.value), + loading: computed(() => loading.value), + checkAuth, + login, + logout, + } +} diff --git a/ui/src/main.ts b/ui/src/main.ts index 7af34da2..cb912e52 100644 --- a/ui/src/main.ts +++ b/ui/src/main.ts @@ -1,6 +1,15 @@ import { createApp } from 'vue' import App from './App.vue' +import router from './router' import './assets/main.css' import '@fortawesome/fontawesome-free/css/all.min.css' +import '@fontsource/fira-sans/400.css' +import '@fontsource/fira-sans/500.css' +import '@fontsource/fira-sans/600.css' +import '@fontsource/fira-sans/700.css' +import '@fontsource/fira-code/400.css' +import '@fontsource/fira-code/500.css' -createApp(App).mount('#app') +const app = createApp(App) +app.use(router) +app.mount('#app') diff --git a/ui/src/router/index.ts b/ui/src/router/index.ts new file mode 100644 index 00000000..98b3e189 --- /dev/null +++ b/ui/src/router/index.ts @@ -0,0 +1,95 @@ +import { createRouter, createWebHashHistory } from 'vue-router' +import { useAuth } from '@/composables/useAuth' + +const routes = [ + { + path: '/login', + name: 'login', + component: () => import('@/views/LoginView.vue'), + meta: { public: true }, + }, + { + path: '/', + name: 'home', + component: () => import('@/views/HomeView.vue'), + }, + { + path: '/observations', + name: 'observations', + component: () => import('@/views/ObservationsView.vue'), + }, + { + path: '/observations/:id', + name: 'observation-detail', + component: () => import('@/views/ObservationDetailView.vue'), + }, + { + path: '/search', + name: 'search', + component: () => import('@/views/SearchView.vue'), + }, + { + path: '/vault', + name: 'vault', + component: () => import('@/views/VaultView.vue'), + }, + { + path: '/logs', + name: 'logs', + component: () => import('@/views/LogsView.vue'), + }, + { + path: '/graph', + name: 'graph', + component: () => import('@/views/GraphView.vue'), + }, + { + path: '/patterns', + name: 'patterns', + component: () => import('@/views/PatternsView.vue'), + }, + { + path: '/sessions', + name: 'sessions', + component: () => import('@/views/SessionsView.vue'), + }, + { + path: '/analytics', + name: 'analytics', + component: () => import('@/views/AnalyticsView.vue'), + }, + { + path: '/system', + name: 'system', + component: () => import('@/views/SystemView.vue'), + }, + { + path: '/tokens', + name: 'tokens', + component: () => import('@/views/TokensView.vue'), + }, +] + +const router = createRouter({ + history: createWebHashHistory(), + routes, +}) + +// Navigation guard: redirect to login when not authenticated +router.beforeEach(async (to) => { + const { authenticated, loading, checkAuth } = useAuth() + + if (loading.value) { + await checkAuth() + } + + if (!to.meta.public && !authenticated.value) { + return { name: 'login' } + } + + if (to.name === 'login' && authenticated.value) { + return { name: 'home' } + } +}) + +export default router diff --git a/ui/src/views/AnalyticsView.vue b/ui/src/views/AnalyticsView.vue new file mode 100644 index 00000000..40505d97 --- /dev/null +++ b/ui/src/views/AnalyticsView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/GraphView.vue b/ui/src/views/GraphView.vue new file mode 100644 index 00000000..997a4828 --- /dev/null +++ b/ui/src/views/GraphView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/HomeView.vue b/ui/src/views/HomeView.vue new file mode 100644 index 00000000..2aaaa0a1 --- /dev/null +++ b/ui/src/views/HomeView.vue @@ -0,0 +1,71 @@ + + + diff --git a/ui/src/views/LoginView.vue b/ui/src/views/LoginView.vue new file mode 100644 index 00000000..2f2caa60 --- /dev/null +++ b/ui/src/views/LoginView.vue @@ -0,0 +1,89 @@ + + + diff --git a/ui/src/views/LogsView.vue b/ui/src/views/LogsView.vue new file mode 100644 index 00000000..7c177852 --- /dev/null +++ b/ui/src/views/LogsView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/ObservationDetailView.vue b/ui/src/views/ObservationDetailView.vue new file mode 100644 index 00000000..1f5b9988 --- /dev/null +++ b/ui/src/views/ObservationDetailView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/ObservationsView.vue b/ui/src/views/ObservationsView.vue new file mode 100644 index 00000000..97dcf0e9 --- /dev/null +++ b/ui/src/views/ObservationsView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/PatternsView.vue b/ui/src/views/PatternsView.vue new file mode 100644 index 00000000..058049d2 --- /dev/null +++ b/ui/src/views/PatternsView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/SearchView.vue b/ui/src/views/SearchView.vue new file mode 100644 index 00000000..ec877c82 --- /dev/null +++ b/ui/src/views/SearchView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/SessionsView.vue b/ui/src/views/SessionsView.vue new file mode 100644 index 00000000..d323ecc3 --- /dev/null +++ b/ui/src/views/SessionsView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/SystemView.vue b/ui/src/views/SystemView.vue new file mode 100644 index 00000000..b6dc7144 --- /dev/null +++ b/ui/src/views/SystemView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/TokensView.vue b/ui/src/views/TokensView.vue new file mode 100644 index 00000000..b6104f72 --- /dev/null +++ b/ui/src/views/TokensView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/src/views/VaultView.vue b/ui/src/views/VaultView.vue new file mode 100644 index 00000000..cc755dae --- /dev/null +++ b/ui/src/views/VaultView.vue @@ -0,0 +1,7 @@ + diff --git a/ui/tailwind.config.js b/ui/tailwind.config.js index 3f1f1629..2d57e226 100644 --- a/ui/tailwind.config.js +++ b/ui/tailwind.config.js @@ -7,6 +7,10 @@ export default { darkMode: 'class', theme: { extend: { + fontFamily: { + sans: ['Fira Sans', 'system-ui', 'sans-serif'], + mono: ['Fira Code', 'monospace'], + }, colors: { claude: { 50: '#fef7ee', @@ -19,7 +23,17 @@ export default { 700: '#b94109', 800: '#93350e', 900: '#772d0f', - } + }, + data: { + 400: '#60A5FA', + 500: '#3B82F6', + 600: '#2563EB', + }, + accent: { + 400: '#FBBF24', + 500: '#F59E0B', + 600: '#D97706', + }, }, animation: { 'pulse-slow': 'pulse 2s infinite', From 78074addabd96b29e5cc8e1628d6f259524f0c4e Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 03:50:57 +0300 Subject: [PATCH 15/24] feat: add observation composables (useObservation, usePagination) Add composables for observation CRUD (useObservation), generic pagination (usePagination), and search state (useSearch). Add API functions for observation detail, update, archive, feedback, and search endpoints. Add SearchResult types and rejected[] field to Observation type. --- ui/src/composables/index.ts | 3 + ui/src/composables/useObservation.ts | 122 ++++++++++++++++++++++++ ui/src/composables/usePagination.ts | 87 +++++++++++++++++ ui/src/composables/useSearch.ts | 91 ++++++++++++++++++ ui/src/types/index.ts | 1 + ui/src/types/observation.ts | 1 + ui/src/types/search.ts | 26 +++++ ui/src/utils/api.ts | 136 ++++++++++++++++++++++++++- 8 files changed, 466 insertions(+), 1 deletion(-) create mode 100644 ui/src/composables/useObservation.ts create mode 100644 ui/src/composables/usePagination.ts create mode 100644 ui/src/composables/useSearch.ts create mode 100644 ui/src/types/search.ts diff --git a/ui/src/composables/index.ts b/ui/src/composables/index.ts index 021ea4a4..a7d6af64 100644 --- a/ui/src/composables/index.ts +++ b/ui/src/composables/index.ts @@ -5,3 +5,6 @@ export { useTimeline } from './useTimeline' export { useUpdate } from './useUpdate' export { useHealth } from './useHealth' export { useGraphMetrics } from './useGraphMetrics' +export { useObservation } from './useObservation' +export { usePagination } from './usePagination' +export { useSearch } from './useSearch' diff --git a/ui/src/composables/useObservation.ts b/ui/src/composables/useObservation.ts new file mode 100644 index 00000000..c6a70fd1 --- /dev/null +++ b/ui/src/composables/useObservation.ts @@ -0,0 +1,122 @@ +import { ref, onUnmounted } from 'vue' +import type { Observation } from '@/types' +import { + fetchObservationById, + updateObservation, + archiveObservations, + submitObservationFeedback, +} from '@/utils/api' + +export function useObservation(observationId?: number) { + const observation = ref(null) + const loading = ref(false) + const saving = ref(false) + const error = ref(null) + + let abortController: AbortController | null = null + + async function load(id?: number) { + const targetId = id ?? observationId + if (!targetId) return + + abortController?.abort() + abortController = new AbortController() + + loading.value = true + error.value = null + + try { + observation.value = await fetchObservationById(targetId, abortController.signal) + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + error.value = err instanceof Error ? err.message : 'Failed to load observation' + console.error('[useObservation] Load error:', err) + } finally { + loading.value = false + } + } + + async function save(updates: { + title?: string + subtitle?: string + narrative?: string + scope?: string + facts?: string[] + concepts?: string[] + }) { + const id = observation.value?.id + if (!id) return + + saving.value = true + error.value = null + + try { + const result = await updateObservation(id, updates) + observation.value = result.observation + return result.observation + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to save observation' + console.error('[useObservation] Save error:', err) + throw err + } finally { + saving.value = false + } + } + + async function archive(reason?: string) { + const id = observation.value?.id + if (!id) return + + saving.value = true + error.value = null + + try { + const result = await archiveObservations([id], reason) + if (result.failed?.length > 0) { + throw new Error(`Failed to archive observation ${id}`) + } + return true + } catch (err) { + error.value = err instanceof Error ? err.message : 'Failed to archive observation' + console.error('[useObservation] Archive error:', err) + throw err + } finally { + saving.value = false + } + } + + async function feedback(value: number) { + const id = observation.value?.id + if (!id) return + + try { + const result = await submitObservationFeedback(id, value) + if (observation.value && result.score !== undefined) { + observation.value = { + ...observation.value, + user_feedback: value, + importance_score: result.score, + } + } + return result + } catch (err) { + console.error('[useObservation] Feedback error:', err) + throw err + } + } + + onUnmounted(() => { + abortController?.abort() + }) + + return { + observation, + loading, + saving, + error, + load, + save, + archive, + feedback, + } +} diff --git a/ui/src/composables/usePagination.ts b/ui/src/composables/usePagination.ts new file mode 100644 index 00000000..22470b0d --- /dev/null +++ b/ui/src/composables/usePagination.ts @@ -0,0 +1,87 @@ +import { ref, computed, onUnmounted, type Ref } from 'vue' + +interface PaginatedResponse { + items: T[] + total: number +} + +interface UsePaginationOptions { + /** Items per page (default 20) */ + pageSize?: number + /** Fetch function that receives limit, offset, and abort signal */ + fetchFn: (limit: number, offset: number, signal: AbortSignal) => Promise> +} + +export function usePagination(options: UsePaginationOptions) { + const pageSize = options.pageSize ?? 20 + + const items: Ref = ref([]) + const total = ref(0) + const offset = ref(0) + const loading = ref(false) + const error = ref(null) + + let abortController: AbortController | null = null + + const currentPage = computed(() => Math.floor(offset.value / pageSize) + 1) + const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize))) + const hasMore = computed(() => offset.value + items.value.length < total.value) + + async function fetchPage() { + abortController?.abort() + abortController = new AbortController() + + loading.value = true + error.value = null + + try { + const result = await options.fetchFn(pageSize, offset.value, abortController.signal) + items.value = result.items + total.value = result.total + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + error.value = err instanceof Error ? err.message : 'Failed to load data' + console.error('[usePagination] Fetch error:', err) + } finally { + loading.value = false + } + } + + function goToPage(page: number) { + const newOffset = (page - 1) * pageSize + if (newOffset >= 0 && newOffset !== offset.value) { + offset.value = newOffset + fetchPage() + } + } + + function setOffset(newOffset: number) { + offset.value = newOffset + fetchPage() + } + + function reset() { + offset.value = 0 + fetchPage() + } + + onUnmounted(() => { + abortController?.abort() + }) + + return { + items, + total, + offset, + loading, + error, + currentPage, + totalPages, + hasMore, + pageSize, + fetchPage, + goToPage, + setOffset, + reset, + } +} diff --git a/ui/src/composables/useSearch.ts b/ui/src/composables/useSearch.ts new file mode 100644 index 00000000..9d25eeaf --- /dev/null +++ b/ui/src/composables/useSearch.ts @@ -0,0 +1,91 @@ +import { ref, onUnmounted } from 'vue' +import type { SearchResultObservation } from '@/types' +import { searchObservations, searchDecisions } from '@/utils/api' + +export function useSearch() { + const query = ref('') + const project = ref('') + const results = ref([]) + const totalCount = ref(0) + const loading = ref(false) + const error = ref(null) + const decisionMode = ref(false) + const intent = ref('') + + let abortController: AbortController | null = null + + async function search(opts?: { query?: string; project?: string; decisionMode?: boolean }) { + const q = opts?.query ?? query.value + const p = opts?.project ?? project.value + const isDecision = opts?.decisionMode ?? decisionMode.value + + if (!q.trim()) { + results.value = [] + totalCount.value = 0 + return + } + + // For context search, project is optional; for decisions, it is required + if (isDecision && !p) { + error.value = 'Project is required for decision search' + return + } + + abortController?.abort() + abortController = new AbortController() + + loading.value = true + error.value = null + + try { + if (isDecision) { + const response = await searchDecisions( + { query: q, project: p, limit: 50 }, + abortController.signal + ) + results.value = response.observations + totalCount.value = response.total_count + intent.value = '' + } else { + const response = await searchObservations( + { query: q, project: p || 'all', limit: 50 }, + abortController.signal + ) + results.value = response.observations + totalCount.value = response.observations.length + intent.value = response.intent || '' + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + error.value = err instanceof Error ? err.message : 'Search failed' + console.error('[useSearch] Error:', err) + } finally { + loading.value = false + } + } + + function clear() { + query.value = '' + results.value = [] + totalCount.value = 0 + error.value = null + intent.value = '' + } + + onUnmounted(() => { + abortController?.abort() + }) + + return { + query, + project, + results, + totalCount, + loading, + error, + decisionMode, + intent, + search, + clear, + } +} diff --git a/ui/src/types/index.ts b/ui/src/types/index.ts index 2af5b4e6..eec05af7 100644 --- a/ui/src/types/index.ts +++ b/ui/src/types/index.ts @@ -3,3 +3,4 @@ export * from './prompt' export * from './summary' export * from './api' export * from './relation' +export * from './search' diff --git a/ui/src/types/observation.ts b/ui/src/types/observation.ts index 5cfe4be1..128f009a 100644 --- a/ui/src/types/observation.ts +++ b/ui/src/types/observation.ts @@ -36,6 +36,7 @@ export interface Observation { retrieval_count: number last_retrieved_at_epoch?: number score_updated_at_epoch?: number + rejected?: string[] } export const OBSERVATION_TYPES: ObservationType[] = ['bugfix', 'feature', 'refactor', 'discovery', 'decision', 'change'] diff --git a/ui/src/types/search.ts b/ui/src/types/search.ts new file mode 100644 index 00000000..7c29b634 --- /dev/null +++ b/ui/src/types/search.ts @@ -0,0 +1,26 @@ +import type { Observation } from './observation' + +export interface SearchResultObservation extends Observation { + similarity?: number +} + +export interface ContextSearchResponse { + project: string + query: string + intent: string + expansions: Array<{ + query: string + weight: number + source: string + }> + observations: SearchResultObservation[] + threshold: number + max_results: number +} + +export interface DecisionSearchResponse { + project: string + query: string + observations: SearchResultObservation[] + total_count: number +} diff --git a/ui/src/utils/api.ts b/ui/src/utils/api.ts index cf8d6b86..bfc106a1 100644 --- a/ui/src/utils/api.ts +++ b/ui/src/utils/api.ts @@ -1,4 +1,4 @@ -import type { Observation, UserPrompt, SessionSummary, Stats, FeedItem, ObservationFeedItem, PromptFeedItem, SummaryFeedItem, RelationWithDetails, RelationGraph, RelationStats, GraphStats, VectorMetrics } from '@/types' +import type { Observation, UserPrompt, SessionSummary, Stats, FeedItem, ObservationFeedItem, PromptFeedItem, SummaryFeedItem, RelationWithDetails, RelationGraph, RelationStats, GraphStats, VectorMetrics, ContextSearchResponse, DecisionSearchResponse } from '@/types' const API_BASE = '/api' const DEFAULT_TIMEOUT = 10000 // 10 seconds @@ -297,3 +297,137 @@ export async function fetchGraphStats(signal?: AbortSignal): Promise export async function fetchVectorMetrics(signal?: AbortSignal): Promise { return fetchWithRetry(`${API_BASE}/vector/metrics`, { signal }) } + +// POST JSON helper with retry logic +async function postJson(url: string, body: unknown, options: FetchOptions = {}): Promise { + const { timeout = DEFAULT_TIMEOUT, signal, retries = MAX_RETRIES } = options + let lastError: Error | null = null + + for (let attempt = 0; attempt < retries; attempt++) { + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => timeoutController.abort(), timeout) + const combinedSignal = signal + ? combineAbortSignals(signal, timeoutController.signal) + : timeoutController.signal + + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: combinedSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.json() + } catch (err) { + lastError = err instanceof Error ? err : new Error(String(err)) + if (lastError.name === 'AbortError') { + if (signal?.aborted) throw lastError + throw new Error('Request timed out') + } + if (lastError.message.includes('HTTP 4')) throw lastError + if (attempt < retries - 1) { + const delay = Math.min(RETRY_DELAY * Math.pow(2, attempt), 5000) + await new Promise(r => setTimeout(r, delay)) + } + } finally { + clearTimeout(timeoutId) + } + } + throw lastError! +} + +// PUT JSON helper (single attempt, no retry for mutations) +async function putJson(url: string, body: unknown, options: FetchOptions = {}): Promise { + const { timeout = DEFAULT_TIMEOUT, signal } = options + const timeoutController = new AbortController() + const timeoutId = setTimeout(() => timeoutController.abort(), timeout) + const combinedSignal = signal + ? combineAbortSignals(signal, timeoutController.signal) + : timeoutController.signal + + try { + const response = await fetch(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: combinedSignal, + }) + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`) + } + return response.json() + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + if (signal?.aborted) throw err + throw new Error('Request timed out') + } + throw err + } finally { + clearTimeout(timeoutId) + } +} + +// Observation CRUD +export async function fetchObservationById(id: number, signal?: AbortSignal): Promise { + return fetchWithRetry(`${API_BASE}/observations/${id}`, { signal }) +} + +export async function fetchObservationsPaginated( + params: { limit?: number; offset?: number; project?: string }, + signal?: AbortSignal +): Promise { + const searchParams = new URLSearchParams() + if (params.limit) searchParams.append('limit', String(params.limit)) + if (params.offset) searchParams.append('offset', String(params.offset)) + if (params.project) searchParams.append('project', params.project) + return fetchWithRetry(`${API_BASE}/observations?${searchParams}`, { signal }) +} + +export async function updateObservation( + id: number, + updates: { + title?: string + subtitle?: string + narrative?: string + scope?: string + facts?: string[] + concepts?: string[] + }, + signal?: AbortSignal +): Promise<{ observation: Observation; message: string }> { + return putJson(`${API_BASE}/observations/${id}`, updates, { signal }) +} + +export async function archiveObservations( + ids: number[], + reason?: string, + signal?: AbortSignal +): Promise<{ archived: number[]; failed: number[]; errors?: string[] }> { + return postJson(`${API_BASE}/observations/archive`, { ids, reason }, { signal }) +} + +export async function submitObservationFeedback( + id: number, + feedback: number, + signal?: AbortSignal +): Promise<{ score?: number }> { + return postJson(`${API_BASE}/observations/${id}/feedback`, { feedback }, { signal }) +} + +// Search +export async function searchObservations( + params: { query: string; project: string; limit?: number }, + signal?: AbortSignal +): Promise { + return postJson(`${API_BASE}/context/search`, params, { signal }) +} + +export async function searchDecisions( + params: { query: string; project: string; limit?: number }, + signal?: AbortSignal +): Promise { + return postJson(`${API_BASE}/decisions/search`, params, { signal }) +} From 4ddc9f81bb2cf5daefb7bd474b188f5dbb31b9ae Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 03:53:57 +0300 Subject: [PATCH 16/24] feat: implement ObservationsView with pagination and filters Replace placeholder with paginated observation list using server-side pagination (20 items/page). Add project dropdown, type filter pills, and concept filter. Each row is clickable to navigate to detail view. --- ui/src/views/ObservationsView.vue | 295 +++++++++++++++++++++++++++++- 1 file changed, 291 insertions(+), 4 deletions(-) diff --git a/ui/src/views/ObservationsView.vue b/ui/src/views/ObservationsView.vue index 97dcf0e9..59082fc7 100644 --- a/ui/src/views/ObservationsView.vue +++ b/ui/src/views/ObservationsView.vue @@ -1,7 +1,294 @@ + + From 42fec94fdb653e307fa59e293a9f4651756ba6f0 Mon Sep 17 00:00:00 2001 From: Kirill Turanskiy Date: Thu, 19 Mar 2026 03:57:56 +0300 Subject: [PATCH 17/24] feat: implement ObservationDetailView with inline edit Replace placeholder with full observation detail view including: - Read-only display of all fields (narrative, facts, concepts, files, metadata) - Rejected alternatives section for decision observations - Inline edit mode with ObservationEditor component (explicit save/discard) - beforeunload warning for unsaved changes - Archive with confirmation dialog - Feedback buttons (thumbs up/down) --- .../observation/ObservationEditor.vue | 198 +++++++++ ui/src/views/ObservationDetailView.vue | 383 +++++++++++++++++- 2 files changed, 577 insertions(+), 4 deletions(-) create mode 100644 ui/src/components/observation/ObservationEditor.vue diff --git a/ui/src/components/observation/ObservationEditor.vue b/ui/src/components/observation/ObservationEditor.vue new file mode 100644 index 00000000..0ef71c52 --- /dev/null +++ b/ui/src/components/observation/ObservationEditor.vue @@ -0,0 +1,198 @@ + + +