From 80bd3f5b518ea09aa847c632cf841ac7437737bc Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 3 Apr 2026 21:04:18 +0200 Subject: [PATCH 1/7] feat(guardrails): add persisted guardrail management --- internal/admin/dashboard/dashboard_test.go | 23 ++ .../admin/dashboard/static/css/dashboard.css | 193 +++++++++ .../admin/dashboard/static/js/dashboard.js | 81 ++-- .../js/modules/dashboard-layout.test.js | 10 +- .../dashboard/static/js/modules/guardrails.js | 365 ++++++++++++++++++ .../static/js/modules/guardrails.test.js | 64 +++ .../static/js/modules/timezone-layout.test.js | 7 +- internal/admin/dashboard/templates/index.html | 193 ++++++++- .../admin/dashboard/templates/layout.html | 1 + internal/admin/handler.go | 168 +++++++- internal/admin/handler_guardrails_test.go | 227 +++++++++++ internal/app/app.go | 205 +++++----- internal/app/app_test.go | 23 +- internal/executionplans/compiler.go | 6 +- internal/guardrails/catalog.go | 8 + internal/guardrails/definitions.go | 243 ++++++++++++ internal/guardrails/factory.go | 110 ++++++ internal/guardrails/service.go | 236 +++++++++++ internal/guardrails/service_test.go | 183 +++++++++ internal/guardrails/store.go | 94 +++++ internal/guardrails/store_mongodb.go | 170 ++++++++ internal/guardrails/store_postgresql.go | 147 +++++++ internal/guardrails/store_sqlite.go | 156 ++++++++ internal/guardrails/store_sqlite_test.go | 106 +++++ internal/guardrails/system_prompt.go | 9 + internal/server/http.go | 4 + 26 files changed, 2872 insertions(+), 160 deletions(-) create mode 100644 internal/admin/dashboard/static/js/modules/guardrails.js create mode 100644 internal/admin/dashboard/static/js/modules/guardrails.test.js create mode 100644 internal/admin/handler_guardrails_test.go create mode 100644 internal/guardrails/catalog.go create mode 100644 internal/guardrails/definitions.go create mode 100644 internal/guardrails/factory.go create mode 100644 internal/guardrails/service.go create mode 100644 internal/guardrails/service_test.go create mode 100644 internal/guardrails/store.go create mode 100644 internal/guardrails/store_mongodb.go create mode 100644 internal/guardrails/store_postgresql.go create mode 100644 internal/guardrails/store_sqlite.go create mode 100644 internal/guardrails/store_sqlite_test.go diff --git a/internal/admin/dashboard/dashboard_test.go b/internal/admin/dashboard/dashboard_test.go index 158a16c2..c8ccfa0c 100644 --- a/internal/admin/dashboard/dashboard_test.go +++ b/internal/admin/dashboard/dashboard_test.go @@ -176,6 +176,29 @@ func TestStatic_ServesExecutionPlansModuleJS(t *testing.T) { } } +func TestStatic_ServesGuardrailsModuleJS(t *testing.T) { + h, err := New() + if err != nil { + t.Fatalf("New() returned error: %v", err) + } + + e := echo.New() + req := httptest.NewRequest(http.MethodGet, "/admin/static/js/modules/guardrails.js", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + + if err := h.Static(c); err != nil { + t.Fatalf("Static() returned error: %v", err) + } + + if rec.Code != http.StatusOK { + t.Errorf("expected 200, got %d", rec.Code) + } + if rec.Body.Len() == 0 { + t.Error("expected non-empty body for guardrails module JS file") + } +} + func TestStatic_ServesFavicon(t *testing.T) { h, err := New() if err != nil { diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 0636a569..71a49c39 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -792,6 +792,14 @@ body { color: var(--success); } +.alert-inline-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; +} + /* Table */ .table-toolbar { display: flex; @@ -2343,6 +2351,166 @@ body.conversation-drawer-open { width: 100%; } +.page-subtitle { + margin-top: 6px; + color: var(--text-muted); + font-size: 14px; +} + +.settings-subnav { + display: inline-flex; + gap: 8px; + padding: 6px; + margin-bottom: 20px; + border: 1px solid var(--border); + border-radius: 999px; + background: color-mix(in srgb, var(--bg-surface) 80%, transparent); +} + +.settings-subnav-btn { + border: 0; + border-radius: 999px; + padding: 10px 14px; + background: transparent; + color: var(--text-muted); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease; +} + +.settings-subnav-btn:hover { + color: var(--text); + background: color-mix(in srgb, var(--accent) 12%, transparent); +} + +.settings-subnav-btn.active { + color: var(--text); + background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 20%, transparent), color-mix(in srgb, var(--accent-hover) 16%, transparent)); + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--accent) 18%, var(--border)); +} + +.settings-guardrails-hero { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 20px; + padding: 24px; + margin-bottom: 20px; + border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); + border-radius: var(--radius); + background: + radial-gradient(circle at top right, color-mix(in srgb, var(--accent-hover) 18%, transparent), transparent 42%), + radial-gradient(circle at bottom left, color-mix(in srgb, var(--accent) 16%, transparent), transparent 40%), + var(--bg-surface); +} + +.settings-kicker { + margin: 0 0 10px; + color: var(--accent); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-guardrails-hero h3 { + margin-bottom: 8px; +} + +.settings-guardrails-hero p:last-child { + margin-bottom: 0; + color: var(--text-muted); +} + +.settings-guardrails-meta { + display: flex; + gap: 12px; +} + +.settings-guardrails-stat { + min-width: 110px; + padding: 14px 16px; + border: 1px solid color-mix(in srgb, var(--accent) 14%, var(--border)); + border-radius: 16px; + background: color-mix(in srgb, var(--bg-surface-hover) 76%, transparent); +} + +.settings-guardrails-stat-label { + display: block; + margin-bottom: 8px; + color: var(--text-muted); + font-size: 11px; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.settings-guardrails-stat strong { + font-size: 24px; + font-weight: 700; +} + +.settings-guardrails-layout { + display: grid; + grid-template-columns: 1fr; + gap: 20px; + align-items: start; +} + +.settings-guardrails-layout.is-editor-open { + grid-template-columns: minmax(0, 1.35fr) minmax(320px, 0.9fr); +} + +.settings-guardrails-list, +.settings-guardrails-editor { + min-width: 0; +} + +.settings-guardrail-type-pill { + display: inline-flex; + align-items: center; + padding: 6px 10px; + border: 1px solid color-mix(in srgb, var(--accent) 18%, var(--border)); + border-radius: 999px; + background: color-mix(in srgb, var(--accent) 12%, transparent); + color: var(--text); + font-size: 12px; + font-weight: 600; + white-space: nowrap; +} + +.settings-guardrail-summary { + color: var(--text); + font-size: 14px; + line-height: 1.45; +} + +.settings-guardrail-description { + margin-top: 6px; + color: var(--text-muted); + font-size: 12px; +} + +.settings-guardrail-textarea { + min-height: 150px; + width: 100%; + resize: vertical; + padding: 12px 14px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + line-height: 1.55; +} + +.settings-guardrail-textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); +} + /* Responsive */ @media (max-width: 768px) { .sidebar { width: 60px; flex-basis: 60px; } @@ -2376,6 +2544,31 @@ body.conversation-drawer-open { gap: 8px; } + .settings-subnav { + display: flex; + width: 100%; + } + + .settings-subnav-btn { + flex: 1 1 0; + } + + .settings-guardrails-hero { + flex-direction: column; + } + + .settings-guardrails-meta { + width: 100%; + } + + .settings-guardrails-stat { + flex: 1 1 0; + } + + .settings-guardrails-layout { + grid-template-columns: 1fr; + } + /* Category tabs mobile */ .category-tabs { gap: 4px; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 43fa8714..1f25febd 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -30,6 +30,7 @@ function dashboard() { apiKey: '', theme: 'system', sidebarCollapsed: false, + settingsSubpage: 'general', // Date picker datePickerOpen: false, @@ -118,6 +119,41 @@ function dashboard() { return { page, sub }; }, + _normalizeSettingsSubpage(subpage) { + return subpage === 'guardrails' ? 'guardrails' : 'general'; + }, + + _settingsPath(subpage) { + return subpage === 'guardrails' + ? '/admin/dashboard/settings/guardrails' + : '/admin/dashboard/settings'; + }, + + _applyRoute(page, sub) { + this.page = page; + this.settingsSubpage = page === 'settings' + ? this._normalizeSettingsSubpage(sub) + : 'general'; + + if (page === 'usage' && sub === 'costs') this.usageMode = 'costs'; + if (page === 'usage' && sub !== 'costs') this.usageMode = 'tokens'; + if (page === 'audit-logs') this.fetchAuditLog(true); + if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); + if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { + this.fetchExecutionPlansPage(); + } + if (page === 'settings') { + if (this.settingsSubpage === 'general' && typeof this.ensureTimezoneOptions === 'function') { + this.ensureTimezoneOptions(); + } + if (this.settingsSubpage === 'guardrails' && typeof this.fetchGuardrailsPage === 'function') { + this.fetchGuardrailsPage(); + } + } + if (page === 'overview') this.renderChart(); + if (page === 'usage') this.fetchUsagePage(); + }, + init() { if (typeof this.initTimeZoneState === 'function') { this.initTimeZoneState(); @@ -128,28 +164,11 @@ function dashboard() { this.applyTheme(); const { page, sub } = this._parseRoute(window.location.pathname); - this.page = page; - if (page === 'usage' && sub === 'costs') this.usageMode = 'costs'; - if (page === 'audit-logs') this.fetchAuditLog(true); - if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (page === 'settings' && typeof this.ensureTimezoneOptions === 'function') this.ensureTimezoneOptions(); + this._applyRoute(page, sub); window.addEventListener('popstate', () => { const { page: p, sub: s } = this._parseRoute(window.location.pathname); - this.page = p; - if (p === 'usage') { - this.usageMode = s === 'costs' ? 'costs' : 'tokens'; - this.fetchUsagePage(); - } - if (p === 'overview') this.renderChart(); - if (p === 'audit-logs') this.fetchAuditLog(true); - if (p === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (p === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { - this.fetchExecutionPlansPage(); - } - if (p === 'settings' && typeof this.ensureTimezoneOptions === 'function') { - this.ensureTimezoneOptions(); - } + this._applyRoute(p, s); }); window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => { @@ -168,15 +187,19 @@ function dashboard() { }, navigate(page) { - this.page = page; - if (page === 'usage') this.usageMode = 'tokens'; + if (page === 'settings') { + this.navigateSettings('general'); + return; + } + history.pushState(null, '', '/admin/dashboard/' + page); - if (page === 'overview') this.renderChart(); - if (page === 'usage') this.fetchUsagePage(); - if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') this.fetchExecutionPlansPage(); - if (page === 'audit-logs') this.fetchAuditLog(true); - if (page === 'auth-keys' && typeof this.fetchAuthKeys === 'function') this.fetchAuthKeys(); - if (page === 'settings' && typeof this.ensureTimezoneOptions === 'function') this.ensureTimezoneOptions(); + this._applyRoute(page, null); + }, + + navigateSettings(subpage) { + const normalized = this._normalizeSettingsSubpage(subpage); + history.pushState(null, '', this._settingsPath(normalized)); + this._applyRoute('settings', normalized); }, setTheme(t) { @@ -489,6 +512,10 @@ function dashboard() { typeof dashboardAuthKeysModule === 'function' ? dashboardAuthKeysModule : null, 'dashboardAuthKeysModule' ), + resolveModuleFactory( + typeof dashboardGuardrailsModule === 'function' ? dashboardGuardrailsModule : null, + 'dashboardGuardrailsModule' + ), resolveModuleFactory( typeof dashboardExecutionPlansModule === 'function' ? dashboardExecutionPlansModule : null, 'dashboardExecutionPlansModule' diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 7b3cfd67..94c8274e 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -72,7 +72,7 @@ test('dashboard layout pins Chart.js to 4.5.0', () => { ); assert.match( template, - / + diff --git a/internal/admin/handler.go b/internal/admin/handler.go index fa2d3430..cc15aa8f 100644 --- a/internal/admin/handler.go +++ b/internal/admin/handler.go @@ -3,11 +3,13 @@ package admin import ( "context" + "encoding/json" "errors" "log/slog" "net/http" "net/url" "slices" + "sort" "strconv" "strings" "time" @@ -32,7 +34,8 @@ type Handler struct { authKeys *authkeys.Service aliases *aliases.Service plans *executionplans.Service - guardrails *guardrails.Registry + guardrails guardrails.Catalog + guardrailDefs *guardrails.Service runtimeConfig DashboardConfigResponse } @@ -89,12 +92,20 @@ func WithExecutionPlans(service *executionplans.Service) Option { } // WithGuardrailsRegistry enables listing valid guardrail references for plan authoring. -func WithGuardrailsRegistry(registry *guardrails.Registry) Option { +func WithGuardrailsRegistry(registry guardrails.Catalog) Option { return func(h *Handler) { h.guardrails = registry } } +// WithGuardrailService enables full guardrail definition administration endpoints. +func WithGuardrailService(service *guardrails.Service) Option { + return func(h *Handler) { + h.guardrails = service + h.guardrailDefs = service + } +} + // WithDashboardRuntimeConfig enables the allowlisted dashboard runtime config endpoint. func WithDashboardRuntimeConfig(values DashboardConfigResponse) Option { return func(h *Handler) { @@ -694,6 +705,13 @@ type upsertAliasRequest struct { Enabled *bool `json:"enabled,omitempty"` } +type upsertGuardrailRequest struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + UserPath string `json:"user_path,omitempty"` + Config json.RawMessage `json:"config"` +} + type createExecutionPlanRequest struct { ScopeProvider string `json:"scope_provider,omitempty"` ScopeModel string `json:"scope_model,omitempty"` @@ -723,6 +741,10 @@ func (h *Handler) authKeysUnavailableError() error { return featureUnavailableError("auth keys feature is unavailable") } +func (h *Handler) guardrailsUnavailableError() error { + return featureUnavailableError("guardrails feature is unavailable") +} + func (h *Handler) executionPlansUnavailableError() error { return featureUnavailableError("execution plans feature is unavailable") } @@ -757,6 +779,16 @@ func authKeyWriteError(err error) error { return err } +func guardrailWriteError(err error) error { + if err == nil { + return nil + } + if guardrails.IsValidationError(err) { + return core.NewInvalidRequestError(err.Error(), err) + } + return err +} + func deactivateByID( c *echo.Context, unavailableErr error, @@ -918,6 +950,99 @@ func (h *Handler) DeleteAlias(c *echo.Context) error { return c.NoContent(http.StatusNoContent) } +// ListGuardrailTypes handles GET /admin/api/v1/guardrails/types +func (h *Handler) ListGuardrailTypes(c *echo.Context) error { + if h.guardrailDefs == nil { + return handleError(c, h.guardrailsUnavailableError()) + } + return c.JSON(http.StatusOK, h.guardrailDefs.TypeDefinitions()) +} + +// ListGuardrails handles GET /admin/api/v1/guardrails +func (h *Handler) ListGuardrails(c *echo.Context) error { + if h.guardrailDefs == nil { + return handleError(c, h.guardrailsUnavailableError()) + } + views := h.guardrailDefs.ListViews() + if views == nil { + views = []guardrails.View{} + } + return c.JSON(http.StatusOK, views) +} + +// UpsertGuardrail handles PUT /admin/api/v1/guardrails/{name} +func (h *Handler) UpsertGuardrail(c *echo.Context) error { + if h.guardrailDefs == nil { + return handleError(c, h.guardrailsUnavailableError()) + } + + name := strings.TrimSpace(c.Param("name")) + if name == "" { + return handleError(c, core.NewInvalidRequestError("guardrail name is required", nil)) + } + + var req upsertGuardrailRequest + if err := c.Bind(&req); err != nil { + return handleError(c, core.NewInvalidRequestError("invalid request body: "+err.Error(), err)) + } + + userPath, err := normalizeUserPathQueryParam("user_path", req.UserPath) + if err != nil { + return handleError(c, err) + } + + if err := h.guardrailDefs.Upsert(c.Request().Context(), guardrails.Definition{ + Name: name, + Type: req.Type, + Description: req.Description, + UserPath: userPath, + Config: req.Config, + }); err != nil { + return handleError(c, guardrailWriteError(err)) + } + if err := h.refreshExecutionPlansAfterGuardrailChange(c.Request().Context()); err != nil { + return handleError(c, err) + } + + definition, ok := h.guardrailDefs.Get(name) + if !ok { + return c.NoContent(http.StatusNoContent) + } + return c.JSON(http.StatusOK, guardrails.ViewFromDefinition(*definition)) +} + +// DeleteGuardrail handles DELETE /admin/api/v1/guardrails/{name} +func (h *Handler) DeleteGuardrail(c *echo.Context) error { + if h.guardrailDefs == nil { + return handleError(c, h.guardrailsUnavailableError()) + } + + name := strings.TrimSpace(c.Param("name")) + if name == "" { + return handleError(c, core.NewInvalidRequestError("guardrail name is required", nil)) + } + + referencingWorkflows, err := h.activeWorkflowGuardrailReferences(c.Request().Context(), name) + if err != nil { + return handleError(c, err) + } + if len(referencingWorkflows) > 0 { + return handleError(c, core.NewInvalidRequestError("guardrail is used by active workflows: "+strings.Join(referencingWorkflows, ", "), nil)) + } + + if err := h.guardrailDefs.Delete(c.Request().Context(), name); err != nil { + if errors.Is(err, guardrails.ErrNotFound) { + return handleError(c, core.NewNotFoundError("guardrail not found: "+name)) + } + return handleError(c, guardrailWriteError(err)) + } + if err := h.refreshExecutionPlansAfterGuardrailChange(c.Request().Context()); err != nil { + return handleError(c, err) + } + + return c.NoContent(http.StatusNoContent) +} + // ListExecutionPlans handles GET /admin/api/v1/execution-plans func (h *Handler) ListExecutionPlans(c *echo.Context) error { if h.plans == nil { @@ -1021,6 +1146,45 @@ func (h *Handler) DeactivateExecutionPlan(c *echo.Context) error { return deactivateByID(c, unavailableErr, "execution plan", executionplans.ErrNotFound, "workflow not found: ", deactivate, executionPlanWriteError) } +func (h *Handler) refreshExecutionPlansAfterGuardrailChange(ctx context.Context) error { + if h.plans == nil { + return nil + } + if err := h.plans.Refresh(ctx); err != nil { + return err + } + return nil +} + +func (h *Handler) activeWorkflowGuardrailReferences(ctx context.Context, name string) ([]string, error) { + if h.plans == nil { + return nil, nil + } + + name = strings.TrimSpace(name) + if name == "" { + return nil, nil + } + + views, err := h.plans.ListViews(ctx) + if err != nil { + return nil, err + } + + references := make([]string, 0) + for _, view := range views { + for _, step := range view.Payload.Guardrails { + if strings.TrimSpace(step.Ref) != name { + continue + } + references = append(references, view.ScopeDisplay) + break + } + } + sort.Strings(references) + return references, nil +} + func (h *Handler) validateExecutionPlanGuardrails(payload executionplans.Payload) error { if !payload.Features.Guardrails || len(payload.Guardrails) == 0 { return nil diff --git a/internal/admin/handler_guardrails_test.go b/internal/admin/handler_guardrails_test.go new file mode 100644 index 00000000..1544728d --- /dev/null +++ b/internal/admin/handler_guardrails_test.go @@ -0,0 +1,227 @@ +package admin + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/labstack/echo/v5" + + "gomodel/internal/executionplans" + "gomodel/internal/guardrails" +) + +type guardrailTestStore struct { + definitions map[string]guardrails.Definition +} + +func newGuardrailTestStore(definitions ...guardrails.Definition) *guardrailTestStore { + store := &guardrailTestStore{definitions: make(map[string]guardrails.Definition, len(definitions))} + for _, definition := range definitions { + store.definitions[definition.Name] = definition + } + return store +} + +func (s *guardrailTestStore) List(context.Context) ([]guardrails.Definition, error) { + result := make([]guardrails.Definition, 0, len(s.definitions)) + for _, definition := range s.definitions { + result = append(result, definition) + } + return result, nil +} + +func (s *guardrailTestStore) Get(_ context.Context, name string) (*guardrails.Definition, error) { + definition, ok := s.definitions[name] + if !ok { + return nil, guardrails.ErrNotFound + } + copy := definition + return ©, nil +} + +func (s *guardrailTestStore) Upsert(_ context.Context, definition guardrails.Definition) error { + s.definitions[definition.Name] = definition + return nil +} + +func (s *guardrailTestStore) Delete(_ context.Context, name string) error { + if _, ok := s.definitions[name]; !ok { + return guardrails.ErrNotFound + } + delete(s.definitions, name) + return nil +} + +func (s *guardrailTestStore) Close() error { return nil } + +func rawGuardrailConfig(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return raw +} + +func newGuardrailService(t *testing.T, definitions ...guardrails.Definition) *guardrails.Service { + t.Helper() + + service, err := guardrails.NewService(newGuardrailTestStore(definitions...)) + if err != nil { + t.Fatalf("guardrails.NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("guardrails.Refresh() error = %v", err) + } + return service +} + +func newGuardrailHandler(t *testing.T, definitions ...guardrails.Definition) *Handler { + t.Helper() + return NewHandler(nil, nil, WithGuardrailService(newGuardrailService(t, definitions...))) +} + +func TestListGuardrails(t *testing.T) { + h := newGuardrailHandler(t, guardrails.Definition{ + Name: "policy-system", + Type: "system_prompt", + Config: rawGuardrailConfig(t, map[string]any{ + "mode": "inject", + "content": "be precise", + }), + }) + + c, rec := newHandlerContext("/admin/api/v1/guardrails") + if err := h.ListGuardrails(c); err != nil { + t.Fatalf("ListGuardrails() error = %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var body []guardrails.View + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(body) != 1 || body[0].Name != "policy-system" { + t.Fatalf("body = %#v, want one policy-system guardrail", body) + } + if body[0].Summary == "" { + t.Fatal("Summary = empty, want populated summary") + } +} + +func TestListGuardrailTypes(t *testing.T) { + h := newGuardrailHandler(t) + c, rec := newHandlerContext("/admin/api/v1/guardrails/types") + + if err := h.ListGuardrailTypes(c); err != nil { + t.Fatalf("ListGuardrailTypes() error = %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + var body []guardrails.TypeDefinition + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("json.Unmarshal() error = %v", err) + } + if len(body) == 0 || body[0].Type != "system_prompt" { + t.Fatalf("body = %#v, want system_prompt type definition", body) + } +} + +func TestUpsertGuardrail(t *testing.T) { + h := newGuardrailHandler(t) + e := echo.New() + + req := httptest.NewRequest(http.MethodPut, "/admin/api/v1/guardrails/policy-system", bytes.NewBufferString(`{ + "type":"system_prompt", + "description":"Default policy", + "user_path":"team/alpha", + "config":{"mode":"override","content":"Respond carefully."} + }`)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/admin/api/v1/guardrails/:name") + c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}}) + + if err := h.UpsertGuardrail(c); err != nil { + t.Fatalf("UpsertGuardrail() error = %v", err) + } + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + + guardrail, ok := h.guardrailDefs.Get("policy-system") + if !ok || guardrail == nil { + t.Fatal("Get(policy-system) = missing, want saved guardrail") + } + if guardrail.Type != "system_prompt" { + t.Fatalf("guardrail.Type = %q, want system_prompt", guardrail.Type) + } + if guardrail.UserPath != "/team/alpha" { + t.Fatalf("guardrail.UserPath = %q, want /team/alpha", guardrail.UserPath) + } +} + +func TestDeleteGuardrailRejectsActiveWorkflowReference(t *testing.T) { + guardrailService := newGuardrailService(t, guardrails.Definition{ + Name: "policy-system", + Type: "system_prompt", + Config: rawGuardrailConfig(t, map[string]any{ + "mode": "inject", + "content": "be precise", + }), + }) + planStore := &executionPlanTestStore{ + versions: []executionplans.Version{ + { + ID: "global-plan", + Scope: executionplans.Scope{}, + ScopeKey: "global", + Version: 1, + Active: true, + Name: "global", + Payload: executionplans.Payload{ + SchemaVersion: 1, + Features: executionplans.FeatureFlags{Cache: true, Audit: true, Usage: true, Guardrails: true}, + Guardrails: []executionplans.GuardrailStep{{Ref: "policy-system", Step: 10}}, + }, + PlanHash: "hash-global", + }, + }, + } + planService, err := executionplans.NewService(planStore, executionplans.NewCompiler(guardrailService)) + if err != nil { + t.Fatalf("executionplans.NewService() error = %v", err) + } + if err := planService.Refresh(context.Background()); err != nil { + t.Fatalf("planService.Refresh() error = %v", err) + } + + h := NewHandler(nil, nil, WithGuardrailService(guardrailService), WithExecutionPlans(planService)) + e := echo.New() + req := httptest.NewRequest(http.MethodDelete, "/admin/api/v1/guardrails/policy-system", nil) + rec := httptest.NewRecorder() + c := e.NewContext(req, rec) + c.SetPath("/admin/api/v1/guardrails/:name") + c.SetPathValues(echo.PathValues{{Name: "name", Value: "policy-system"}}) + + if err := h.DeleteGuardrail(c); err != nil { + t.Fatalf("DeleteGuardrail() error = %v", err) + } + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400", rec.Code) + } + + envelope := decodeExecutionPlanErrorEnvelope(t, rec.Body.Bytes()) + if envelope.Error.Message != "guardrail is used by active workflows: global" { + t.Fatalf("error message = %q, want active workflow reference", envelope.Error.Message) + } +} diff --git a/internal/app/app.go b/internal/app/app.go index 1e1e51be..a16729d1 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -4,6 +4,7 @@ package app import ( "context" + "encoding/json" "errors" "fmt" "log/slog" @@ -40,6 +41,7 @@ type App struct { batch *batch.Result aliases *aliases.Result authKeys *authkeys.Result + guardrails *guardrails.Result executionPlans *executionplans.Result server *server.Server @@ -166,60 +168,73 @@ func New(ctx context.Context, cfg Config) (*App, error) { } app.aliases = aliasResult + refreshInterval := executionPlanRefreshInterval(appCfg) + + // Initialize reusable guardrail definitions using shared storage when already available. + var guardrailResult *guardrails.Result + sharedGuardrailStorage := firstSharedStorage(auditResult.Storage, usageResult.Storage, batchResult.Storage, aliasResult.Storage) + if sharedGuardrailStorage != nil { + guardrailResult, err = guardrails.NewWithSharedStorage(ctx, sharedGuardrailStorage, refreshInterval) + } else { + guardrailResult, err = guardrails.New(ctx, appCfg, refreshInterval) + } + if err != nil { + closeErr := errors.Join(app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("failed to initialize guardrails: %w (also: close error: %v)", err, closeErr) + } + return nil, fmt.Errorf("failed to initialize guardrails: %w", err) + } + app.guardrails = guardrailResult + + seedGuardrails, err := configGuardrailDefinitions(appCfg.Guardrails) + if err != nil { + closeErr := errors.Join(app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("failed to prepare guardrail definitions: %w (also: close error: %v)", err, closeErr) + } + return nil, fmt.Errorf("failed to prepare guardrail definitions: %w", err) + } + if err := guardrailResult.Service.EnsureSeedDefinitions(ctx, seedGuardrails); err != nil { + closeErr := errors.Join(app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + if closeErr != nil { + return nil, fmt.Errorf("failed to seed guardrails: %w (also: close error: %v)", err, closeErr) + } + return nil, fmt.Errorf("failed to seed guardrails: %w", err) + } + // Build runtime execution dependencies. Policy is passed explicitly into the // server; the live provider dependency remains the bare router. var provider core.RoutableProvider = app.providers.Router var translatedRequestPatcher server.TranslatedRequestPatcher var batchRequestPreparers []server.BatchRequestPreparer featureCaps := runtimeExecutionFeatureCaps(appCfg) - var guardrailRegistry *guardrails.Registry - if featureCaps.Guardrails { - guardrailRegistry, err = buildGuardrailRegistry(appCfg.Guardrails) - if err != nil { - var ( - aliasCloseErr error - batchCloseErr error - ) - if app.aliases != nil { - aliasCloseErr = app.aliases.Close() - } - if app.batch != nil { - batchCloseErr = app.batch.Close() - } - closeErr := errors.Join(aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) - if closeErr != nil { - return nil, fmt.Errorf("failed to build guardrails: %w (also: close error: %v)", err, closeErr) - } - return nil, fmt.Errorf("failed to build guardrails: %w", err) - } - } var executionPlanResult *executionplans.Result - sharedExecutionPlanStorage := firstSharedStorage(auditResult.Storage, usageResult.Storage, batchResult.Storage, aliasResult.Storage) - executionPlanCompiler := executionplans.NewCompilerWithFeatureCaps(guardrailRegistry, featureCaps) - refreshInterval := executionPlanRefreshInterval(appCfg) + sharedExecutionPlanStorage := firstSharedStorage(auditResult.Storage, usageResult.Storage, batchResult.Storage, aliasResult.Storage, guardrailResult.Storage) + executionPlanCompiler := executionplans.NewCompilerWithFeatureCaps(guardrailResult.Service, featureCaps) if sharedExecutionPlanStorage != nil { executionPlanResult, err = executionplans.NewWithSharedStorage(ctx, sharedExecutionPlanStorage, executionPlanCompiler, refreshInterval) } else { executionPlanResult, err = executionplans.New(ctx, appCfg, executionPlanCompiler, refreshInterval) } if err != nil { - closeErr := errors.Join(app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to initialize execution plans: %w (also: close error: %v)", err, closeErr) } return nil, fmt.Errorf("failed to initialize execution plans: %w", err) } - defaultExecutionPlan := defaultExecutionPlanInput(appCfg) + defaultExecutionPlan := defaultExecutionPlanInput(appCfg, guardrailResult.Service.Names()) if err := executionPlanResult.Service.EnsureDefaultGlobal(ctx, defaultExecutionPlan); err != nil { - closeErr := errors.Join(executionPlanResult.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(executionPlanResult.Close(), app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to seed execution plans: %w (also: close error: %v)", err, closeErr) } return nil, fmt.Errorf("failed to seed execution plans: %w", err) } if err := executionPlanResult.Service.Refresh(ctx); err != nil { - closeErr := errors.Join(executionPlanResult.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(executionPlanResult.Close(), app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to load execution plans: %w (also: close error: %v)", err, closeErr) } @@ -233,6 +248,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { usageResult.Storage, batchResult.Storage, aliasResult.Storage, + guardrailResult.Storage, executionPlanResult.Storage, ) if sharedAuthKeyStorage != nil { @@ -241,7 +257,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { authKeyResult, err = authkeys.New(ctx, appCfg) } if err != nil { - closeErr := errors.Join(executionPlanResult.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(executionPlanResult.Close(), app.guardrails.Close(), app.aliases.Close(), app.batch.Close(), app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to initialize auth keys: %w (also: close error: %v)", err, closeErr) } @@ -254,14 +270,14 @@ func New(ctx context.Context, cfg Config) (*App, error) { app.logStartupInfo() if featureCaps.Guardrails { - if guardrailRegistry != nil && guardrailRegistry.Len() > 0 { + if app.guardrails != nil && app.guardrails.Service != nil && app.guardrails.Service.Len() > 0 { translatedRequestPatcher = guardrails.NewPlannedRequestPatcher(executionPlanResult.Service) if appCfg.Guardrails.EnableForBatchProcessing { batchRequestPreparers = append(batchRequestPreparers, guardrails.NewPlannedBatchPreparer(provider, executionPlanResult.Service)) } slog.Info( "guardrails enabled", - "count", guardrailRegistry.Len(), + "count", app.guardrails.Service.Len(), "enable_for_batch_processing", appCfg.Guardrails.EnableForBatchProcessing, ) } @@ -273,8 +289,6 @@ func New(ctx context.Context, cfg Config) (*App, error) { } batchRequestPreparer := server.ComposeBatchRequestPreparers(providerAsNativeFileRouter(provider), batchRequestPreparers...) - guardrailsHash := computeGuardrailsHashFromConfig(appCfg.Guardrails) - // Create server allowPassthroughV1Alias := appCfg.Server.AllowPassthroughV1Alias serverCfg := &server.Config{ @@ -291,7 +305,6 @@ func New(ctx context.Context, cfg Config) (*App, error) { FallbackResolver: fallback.NewResolver(appCfg.Fallback, providerResult.Registry), ExecutionPolicyResolver: executionPlanResult.Service, TranslatedRequestPatcher: translatedRequestPatcher, - GuardrailsHash: guardrailsHash, BatchRequestPreparer: batchRequestPreparer, ExposedModelLister: app.aliases.Service, PassthroughSemanticEnrichers: cfg.Factory.PassthroughSemanticEnrichers(), @@ -318,7 +331,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { authKeyResult.Service, app.aliases.Service, executionPlanResult.Service, - guardrailRegistry, + app.guardrails.Service, dashboardRuntimeConfig(appCfg, usageEnabledForDashboard), adminCfg.UIEnabled, ) @@ -354,6 +367,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { if err != nil { var ( executionPlansCloseErr error + guardrailsCloseErr error authKeysCloseErr error aliasCloseErr error batchCloseErr error @@ -361,6 +375,9 @@ func New(ctx context.Context, cfg Config) (*App, error) { if app.executionPlans != nil { executionPlansCloseErr = app.executionPlans.Close() } + if app.guardrails != nil { + guardrailsCloseErr = app.guardrails.Close() + } if app.authKeys != nil { authKeysCloseErr = app.authKeys.Close() } @@ -370,7 +387,7 @@ func New(ctx context.Context, cfg Config) (*App, error) { if app.batch != nil { batchCloseErr = app.batch.Close() } - closeErr := errors.Join(executionPlansCloseErr, authKeysCloseErr, aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) + closeErr := errors.Join(executionPlansCloseErr, guardrailsCloseErr, authKeysCloseErr, aliasCloseErr, batchCloseErr, app.usage.Close(), app.audit.Close(), app.providers.Close()) if closeErr != nil { return nil, fmt.Errorf("failed to initialize response cache: %w (also: close error: %v)", err, closeErr) } @@ -526,7 +543,15 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 5. Close managed auth keys subsystem. + // 5. Close reusable guardrails subsystem. + if a.guardrails != nil { + if err := a.guardrails.Close(); err != nil { + slog.Error("guardrails close error", "error", err) + errs = append(errs, fmt.Errorf("guardrails close: %w", err)) + } + } + + // 6. Close managed auth keys subsystem. if a.authKeys != nil { if err := a.authKeys.Close(); err != nil { slog.Error("auth keys close error", "error", err) @@ -534,7 +559,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 6. Close batch store (flushes pending entries) + // 7. Close batch store (flushes pending entries) if a.batch != nil { if err := a.batch.Close(); err != nil { slog.Error("batch store close error", "error", err) @@ -542,7 +567,7 @@ func (a *App) Shutdown(ctx context.Context) error { } } - // 7. Close usage tracking (flushes pending entries) + // 8. Close usage tracking (flushes pending entries) if a.usage != nil { if err := a.usage.Close(); err != nil { slog.Error("usage logger close error", "error", err) @@ -627,7 +652,7 @@ func initAdmin( authKeyService *authkeys.Service, aliasService *aliases.Service, executionPlanService *executionplans.Service, - guardrailRegistry *guardrails.Registry, + guardrailService *guardrails.Service, runtimeConfig admin.DashboardConfigResponse, uiEnabled bool, ) (*admin.Handler, *dashboard.Handler, error) { @@ -667,7 +692,7 @@ func initAdmin( admin.WithAuthKeys(authKeyService), admin.WithAliases(aliasService), admin.WithExecutionPlans(executionPlanService), - admin.WithGuardrailsRegistry(guardrailRegistry), + admin.WithGuardrailService(guardrailService), admin.WithDashboardRuntimeConfig(runtimeConfig), ) @@ -683,93 +708,59 @@ func initAdmin( return adminHandler, dashHandler, nil } -func buildGuardrailRegistry(cfg config.GuardrailsConfig) (*guardrails.Registry, error) { - registry := guardrails.NewRegistry() +func configGuardrailDefinitions(cfg config.GuardrailsConfig) ([]guardrails.Definition, error) { + if !cfg.Enabled { + return nil, nil + } + definitions := make([]guardrails.Definition, 0, len(cfg.Rules)) for i, rule := range cfg.Rules { - g, err := buildGuardrail(rule) + rawConfig, err := json.Marshal(map[string]any{ + "mode": rule.SystemPrompt.Mode, + "content": rule.SystemPrompt.Content, + }) if err != nil { - return nil, fmt.Errorf("guardrail rule #%d (%q): %w", i, rule.Name, err) - } - if err := registry.Register(g, responsecache.GuardrailRuleDescriptor{ - Type: rule.Type, - Order: rule.Order, - Mode: effectiveSystemPromptMode(rule.SystemPrompt.Mode), - Content: rule.SystemPrompt.Content, - }); err != nil { - return nil, fmt.Errorf("register guardrail %q: %w", rule.Name, err) - } - slog.Info("guardrail registered", "name", rule.Name, "type", rule.Type, "order", rule.Order) - } - - return registry, nil -} - -// buildGuardrail creates a single Guardrail instance from a rule config. -func buildGuardrail(rule config.GuardrailRuleConfig) (guardrails.Guardrail, error) { - if rule.Name == "" { - return nil, fmt.Errorf("name is required") - } - - switch rule.Type { - case "system_prompt": - mode := guardrails.SystemPromptMode(effectiveSystemPromptMode(rule.SystemPrompt.Mode)) - return guardrails.NewSystemPromptGuardrail(rule.Name, mode, rule.SystemPrompt.Content) - - default: - return nil, fmt.Errorf("unknown guardrail type: %q", rule.Type) - } -} - -// computeGuardrailsHashFromConfig computes a stable hash of all configured guardrail rules. -// The hash is stored in the Echo context post-patch so the semantic cache can include it -// in params_hash, ensuring automatic cache invalidation when guardrail policy changes. -func computeGuardrailsHashFromConfig(cfg config.GuardrailsConfig) string { - if !cfg.Enabled || len(cfg.Rules) == 0 { - return "" - } - rules := make([]responsecache.GuardrailRuleDescriptor, len(cfg.Rules)) - for i, r := range cfg.Rules { - rules[i] = responsecache.GuardrailRuleDescriptor{ - Name: r.Name, - Type: r.Type, - Order: r.Order, - Mode: effectiveSystemPromptMode(r.SystemPrompt.Mode), - Content: r.SystemPrompt.Content, + return nil, fmt.Errorf("guardrail rule #%d (%q): marshal config: %w", i, rule.Name, err) } + definitions = append(definitions, guardrails.Definition{ + Name: rule.Name, + Type: rule.Type, + Config: rawConfig, + }) } - return responsecache.ComputeGuardrailsHash(rules) -} - -func effectiveSystemPromptMode(mode string) string { - resolved := guardrails.SystemPromptMode(mode) - if resolved == "" { - return string(guardrails.SystemPromptInject) - } - return string(resolved) + return definitions, nil } -func defaultExecutionPlanInput(cfg *config.Config) executionplans.CreateInput { +func defaultExecutionPlanInput(cfg *config.Config, availableGuardrails []string) executionplans.CreateInput { fallbackEnabled := fallbackFeatureEnabledGlobally(cfg) payload := executionplans.Payload{ SchemaVersion: 1, Features: executionplans.FeatureFlags{ - Cache: responseCacheConfigured(cfg.Cache.Response), - Audit: cfg.Logging.Enabled, - Usage: cfg.Usage.Enabled, - Guardrails: cfg.Guardrails.Enabled && len(cfg.Guardrails.Rules) > 0, - Fallback: &fallbackEnabled, + Cache: responseCacheConfigured(cfg.Cache.Response), + Audit: cfg.Logging.Enabled, + Usage: cfg.Usage.Enabled, + Fallback: &fallbackEnabled, }, } - if payload.Features.Guardrails { + available := make(map[string]struct{}, len(availableGuardrails)) + for _, name := range availableGuardrails { + available[strings.TrimSpace(name)] = struct{}{} + } + if cfg.Guardrails.Enabled && len(cfg.Guardrails.Rules) > 0 { payload.Guardrails = make([]executionplans.GuardrailStep, 0, len(cfg.Guardrails.Rules)) for _, rule := range cfg.Guardrails.Rules { + if len(available) > 0 { + if _, ok := available[strings.TrimSpace(rule.Name)]; !ok { + continue + } + } payload.Guardrails = append(payload.Guardrails, executionplans.GuardrailStep{ Ref: rule.Name, Step: rule.Order, }) } } + payload.Features.Guardrails = len(payload.Guardrails) > 0 return executionplans.CreateInput{ Scope: executionplans.Scope{}, diff --git a/internal/app/app_test.go b/internal/app/app_test.go index e5d4ead9..d2117c4f 100644 --- a/internal/app/app_test.go +++ b/internal/app/app_test.go @@ -30,7 +30,7 @@ func TestDefaultExecutionPlanInput_SetsFallbackFeature(t *testing.T) { }, } - input := defaultExecutionPlanInput(cfg) + input := defaultExecutionPlanInput(cfg, nil) if input.Payload.Features.Fallback == nil { t.Fatal("defaultExecutionPlanInput().Payload.Features.Fallback = nil, want non-nil") } @@ -39,6 +39,27 @@ func TestDefaultExecutionPlanInput_SetsFallbackFeature(t *testing.T) { } } +func TestConfigGuardrailDefinitions_DisabledIgnoresInvalidRules(t *testing.T) { + definitions, err := configGuardrailDefinitions(config.GuardrailsConfig{ + Enabled: false, + Rules: []config.GuardrailRuleConfig{ + { + Name: "draft-rule", + Type: "future_guardrail_type", + SystemPrompt: config.SystemPromptSettings{ + Content: "", + }, + }, + }, + }) + if err != nil { + t.Fatalf("configGuardrailDefinitions() error = %v, want nil", err) + } + if len(definitions) != 0 { + t.Fatalf("len(configGuardrailDefinitions()) = %d, want 0", len(definitions)) + } +} + func TestDashboardRuntimeConfig_ExposesFallbackMode(t *testing.T) { cfg := &config.Config{ Fallback: config.FallbackConfig{ diff --git a/internal/executionplans/compiler.go b/internal/executionplans/compiler.go index 468d68ca..976b1fb8 100644 --- a/internal/executionplans/compiler.go +++ b/internal/executionplans/compiler.go @@ -8,18 +8,18 @@ import ( ) type compiler struct { - registry *guardrails.Registry + registry guardrails.Catalog featureCaps core.ExecutionFeatures } // NewCompiler creates the default execution-plan compiler for the v1 payload. -func NewCompiler(registry *guardrails.Registry) Compiler { +func NewCompiler(registry guardrails.Catalog) Compiler { return NewCompilerWithFeatureCaps(registry, core.DefaultExecutionFeatures()) } // NewCompilerWithFeatureCaps creates the default execution-plan compiler for the // v1 payload with process-level feature caps applied at compile time. -func NewCompilerWithFeatureCaps(registry *guardrails.Registry, featureCaps core.ExecutionFeatures) Compiler { +func NewCompilerWithFeatureCaps(registry guardrails.Catalog, featureCaps core.ExecutionFeatures) Compiler { return &compiler{ registry: registry, featureCaps: featureCaps, diff --git a/internal/guardrails/catalog.go b/internal/guardrails/catalog.go new file mode 100644 index 00000000..4dd85bcc --- /dev/null +++ b/internal/guardrails/catalog.go @@ -0,0 +1,8 @@ +package guardrails + +// Catalog resolves named guardrail references into executable pipelines. +type Catalog interface { + Len() int + Names() []string + BuildPipeline(steps []StepReference) (*Pipeline, string, error) +} diff --git a/internal/guardrails/definitions.go b/internal/guardrails/definitions.go new file mode 100644 index 00000000..51dab6cc --- /dev/null +++ b/internal/guardrails/definitions.go @@ -0,0 +1,243 @@ +package guardrails + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "time" + + "gomodel/internal/core" + "gomodel/internal/responsecache" +) + +// Definition is one persisted reusable guardrail instance. +type Definition struct { + Name string `json:"name" bson:"name"` + Type string `json:"type" bson:"type"` + Description string `json:"description,omitempty" bson:"description,omitempty"` + UserPath string `json:"user_path,omitempty" bson:"user_path,omitempty"` + Config json.RawMessage `json:"config" bson:"config"` + CreatedAt time.Time `json:"created_at" bson:"created_at"` + UpdatedAt time.Time `json:"updated_at" bson:"updated_at"` +} + +// View is the admin-facing representation of a persisted guardrail. +type View struct { + Definition + Summary string `json:"summary,omitempty"` +} + +// ViewFromDefinition projects one guardrail definition into its admin-facing view. +func ViewFromDefinition(def Definition) View { + return View{ + Definition: cloneDefinition(def), + Summary: summarizeDefinition(def), + } +} + +// TypeOption is one allowed option for a typed guardrail config field. +type TypeOption struct { + Value string `json:"value"` + Label string `json:"label"` +} + +// TypeField describes one UI field for a guardrail type. +type TypeField struct { + Key string `json:"key"` + Label string `json:"label"` + Input string `json:"input"` + Required bool `json:"required"` + Help string `json:"help,omitempty"` + Placeholder string `json:"placeholder,omitempty"` + Options []TypeOption `json:"options,omitempty"` +} + +// TypeDefinition describes one supported guardrail type and its config schema. +type TypeDefinition struct { + Type string `json:"type"` + Label string `json:"label"` + Description string `json:"description,omitempty"` + Defaults json.RawMessage `json:"defaults"` + Fields []TypeField `json:"fields"` +} + +type systemPromptDefinitionConfig struct { + Mode string `json:"mode"` + Content string `json:"content"` +} + +func normalizeDefinition(def Definition) (Definition, error) { + def.Name = strings.TrimSpace(def.Name) + def.Type = strings.TrimSpace(def.Type) + def.Description = strings.TrimSpace(def.Description) + userPath, err := core.NormalizeUserPath(def.UserPath) + if err != nil { + return Definition{}, newValidationError("invalid user_path", err) + } + def.UserPath = userPath + + if def.Name == "" { + return Definition{}, newValidationError("guardrail name is required", nil) + } + if def.Type == "" { + return Definition{}, newValidationError("guardrail type is required", nil) + } + + switch def.Type { + case "system_prompt": + cfg, err := decodeSystemPromptDefinitionConfig(def.Config) + if err != nil { + return Definition{}, err + } + raw, err := json.Marshal(cfg) + if err != nil { + return Definition{}, newValidationError("marshal guardrail config", err) + } + def.Config = raw + default: + return Definition{}, newValidationError(`unknown guardrail type: "`+def.Type+`"`, nil) + } + + return def, nil +} + +func cloneDefinition(def Definition) Definition { + cloned := def + if len(def.Config) > 0 { + cloned.Config = append(json.RawMessage(nil), def.Config...) + } + return cloned +} + +func cloneTypeDefinitions(defs []TypeDefinition) []TypeDefinition { + if len(defs) == 0 { + return []TypeDefinition{} + } + cloned := make([]TypeDefinition, 0, len(defs)) + for _, def := range defs { + copyDef := def + if len(def.Defaults) > 0 { + copyDef.Defaults = append(json.RawMessage(nil), def.Defaults...) + } + if len(def.Fields) > 0 { + copyDef.Fields = append([]TypeField(nil), def.Fields...) + for i := range copyDef.Fields { + if len(copyDef.Fields[i].Options) > 0 { + copyDef.Fields[i].Options = append([]TypeOption(nil), copyDef.Fields[i].Options...) + } + } + } + cloned = append(cloned, copyDef) + } + return cloned +} + +func decodeSystemPromptDefinitionConfig(raw json.RawMessage) (systemPromptDefinitionConfig, error) { + if len(bytes.TrimSpace(raw)) == 0 { + raw = []byte(`{}`) + } + + var cfg systemPromptDefinitionConfig + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&cfg); err != nil { + return systemPromptDefinitionConfig{}, newValidationError("invalid system_prompt config: "+err.Error(), err) + } + if decoder.More() { + return systemPromptDefinitionConfig{}, newValidationError("invalid system_prompt config: trailing data", nil) + } + + cfg.Mode = effectiveSystemPromptMode(cfg.Mode) + cfg.Content = strings.TrimSpace(cfg.Content) + if cfg.Content == "" { + return systemPromptDefinitionConfig{}, newValidationError("system_prompt content is required", nil) + } + return cfg, nil +} + +func buildDefinition(def Definition) (Guardrail, responsecache.GuardrailRuleDescriptor, error) { + switch def.Type { + case "system_prompt": + cfg, err := decodeSystemPromptDefinitionConfig(def.Config) + if err != nil { + return nil, responsecache.GuardrailRuleDescriptor{}, err + } + mode := SystemPromptMode(cfg.Mode) + instance, err := NewSystemPromptGuardrail(def.Name, mode, cfg.Content) + if err != nil { + return nil, responsecache.GuardrailRuleDescriptor{}, newValidationError("build system_prompt guardrail: "+err.Error(), err) + } + return instance, responsecache.GuardrailRuleDescriptor{ + Name: def.Name, + Type: def.Type, + Mode: string(mode), + Content: cfg.Content, + }, nil + default: + return nil, responsecache.GuardrailRuleDescriptor{}, newValidationError(`unknown guardrail type: "`+def.Type+`"`, nil) + } +} + +func summarizeDefinition(def Definition) string { + switch def.Type { + case "system_prompt": + cfg, err := decodeSystemPromptDefinitionConfig(def.Config) + if err != nil { + return "" + } + content := strings.Join(strings.Fields(cfg.Content), " ") + const maxLen = 72 + if len(content) > maxLen { + content = content[:maxLen-3] + "..." + } + if content == "" { + return cfg.Mode + } + return fmt.Sprintf("%s • %s", cfg.Mode, content) + default: + return "" + } +} + +// TypeDefinitions returns the UI-facing definitions for supported guardrail types. +func TypeDefinitions() []TypeDefinition { + return cloneTypeDefinitions([]TypeDefinition{ + { + Type: "system_prompt", + Label: "System Prompt", + Description: "Injects, overrides, or decorates the system message before the request reaches the provider.", + Defaults: mustMarshalRaw(systemPromptDefinitionConfig{Mode: string(SystemPromptInject), Content: ""}), + Fields: []TypeField{ + { + Key: "mode", + Label: "Mode", + Input: "select", + Required: true, + Help: "Choose whether the prompt is injected only when absent, overrides existing system prompts, or decorates the first one.", + Options: []TypeOption{ + {Value: string(SystemPromptInject), Label: "Inject"}, + {Value: string(SystemPromptOverride), Label: "Override"}, + {Value: string(SystemPromptDecorator), Label: "Decorator"}, + }, + }, + { + Key: "content", + Label: "Content", + Input: "textarea", + Required: true, + Help: "The system prompt text applied by this guardrail.", + Placeholder: "You are a precise assistant. Follow the compliance policy...", + }, + }, + }, + }) +} + +func mustMarshalRaw(value any) json.RawMessage { + raw, err := json.Marshal(value) + if err != nil { + panic(err) + } + return raw +} diff --git a/internal/guardrails/factory.go b/internal/guardrails/factory.go new file mode 100644 index 00000000..fed59e25 --- /dev/null +++ b/internal/guardrails/factory.go @@ -0,0 +1,110 @@ +package guardrails + +import ( + "context" + "database/sql" + "errors" + "fmt" + "sync" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "go.mongodb.org/mongo-driver/v2/mongo" + + "gomodel/config" + "gomodel/internal/storage" +) + +// Result holds the initialized guardrail service and any owned resources. +type Result struct { + Service *Service + Store Store + Storage storage.Storage + + stopRefresh func() + closeOnce sync.Once + closeErr error +} + +// Close releases resources held by the guardrails subsystem. +func (r *Result) Close() error { + if r == nil { + return nil + } + r.closeOnce.Do(func() { + if r.stopRefresh != nil { + r.stopRefresh() + r.stopRefresh = nil + } + + var errs []error + if r.Store != nil { + if err := r.Store.Close(); err != nil { + errs = append(errs, fmt.Errorf("store close: %w", err)) + } + } + if r.Storage != nil { + if err := r.Storage.Close(); err != nil { + errs = append(errs, fmt.Errorf("storage close: %w", err)) + } + } + if len(errs) > 0 { + r.closeErr = fmt.Errorf("close errors: %w", errors.Join(errs...)) + } + }) + return r.closeErr +} + +// New creates a guardrails subsystem with its own storage connection. +func New(ctx context.Context, cfg *config.Config, refreshInterval time.Duration) (*Result, error) { + if cfg == nil { + return nil, fmt.Errorf("config is required") + } + storeConn, err := storage.New(ctx, cfg.Storage.BackendConfig()) + if err != nil { + return nil, fmt.Errorf("failed to create storage: %w", err) + } + result, err := newResult(ctx, storeConn, refreshInterval) + if err != nil { + _ = storeConn.Close() + return nil, err + } + result.Storage = storeConn + return result, nil +} + +// NewWithSharedStorage creates a guardrails subsystem using an existing storage connection. +func NewWithSharedStorage(ctx context.Context, shared storage.Storage, refreshInterval time.Duration) (*Result, error) { + if shared == nil { + return nil, fmt.Errorf("shared storage is required") + } + return newResult(ctx, shared, refreshInterval) +} + +func newResult(ctx context.Context, storeConn storage.Storage, refreshInterval time.Duration) (*Result, error) { + store, err := createStore(ctx, storeConn) + if err != nil { + return nil, err + } + service, err := NewService(store) + if err != nil { + return nil, err + } + if err := service.Refresh(ctx); err != nil { + return nil, err + } + return &Result{ + Service: service, + Store: store, + stopRefresh: service.StartBackgroundRefresh(refreshInterval), + }, nil +} + +func createStore(ctx context.Context, store storage.Storage) (Store, error) { + return storage.ResolveBackend[Store]( + store, + func(db *sql.DB) (Store, error) { return NewSQLiteStore(db) }, + func(pool *pgxpool.Pool) (Store, error) { return NewPostgreSQLStore(ctx, pool) }, + func(db *mongo.Database) (Store, error) { return NewMongoDBStore(db) }, + ) +} diff --git a/internal/guardrails/service.go b/internal/guardrails/service.go new file mode 100644 index 00000000..8d5041ff --- /dev/null +++ b/internal/guardrails/service.go @@ -0,0 +1,236 @@ +package guardrails + +import ( + "context" + "fmt" + "sort" + "sync" + "time" +) + +type serviceSnapshot struct { + definitions map[string]Definition + order []string + registry *Registry +} + +// Service keeps reusable guardrails cached in memory and refreshes them from storage. +type Service struct { + store Store + + mu sync.RWMutex + snapshot serviceSnapshot +} + +// NewService creates a guardrail service backed by the provided store. +func NewService(store Store) (*Service, error) { + if store == nil { + return nil, fmt.Errorf("store is required") + } + return &Service{ + store: store, + snapshot: serviceSnapshot{ + definitions: map[string]Definition{}, + order: []string{}, + registry: NewRegistry(), + }, + }, nil +} + +// Refresh reloads guardrails from storage and atomically swaps the in-memory snapshot. +func (s *Service) Refresh(ctx context.Context) error { + definitions, err := s.store.List(ctx) + if err != nil { + return fmt.Errorf("list guardrails: %w", err) + } + + next := serviceSnapshot{ + definitions: make(map[string]Definition, len(definitions)), + order: make([]string, 0, len(definitions)), + registry: NewRegistry(), + } + for _, definition := range definitions { + normalized, err := normalizeDefinition(definition) + if err != nil { + return fmt.Errorf("load guardrail %q: %w", definition.Name, err) + } + instance, descriptor, err := buildDefinition(normalized) + if err != nil { + return fmt.Errorf("load guardrail %q: %w", normalized.Name, err) + } + if err := next.registry.Register(instance, descriptor); err != nil { + return fmt.Errorf("register guardrail %q: %w", normalized.Name, err) + } + next.definitions[normalized.Name] = normalized + next.order = append(next.order, normalized.Name) + } + sort.Strings(next.order) + + s.mu.Lock() + s.snapshot = next + s.mu.Unlock() + return nil +} + +// EnsureSeedDefinitions seeds the store with definitions only when it is empty. +func (s *Service) EnsureSeedDefinitions(ctx context.Context, definitions []Definition) error { + if s == nil || len(definitions) == 0 { + return nil + } + + existing, err := s.store.List(ctx) + if err != nil { + return fmt.Errorf("list guardrails: %w", err) + } + if len(existing) > 0 { + return nil + } + + for _, definition := range definitions { + normalized, err := normalizeDefinition(definition) + if err != nil { + return err + } + if err := s.store.Upsert(ctx, normalized); err != nil { + return fmt.Errorf("seed guardrail %q: %w", normalized.Name, err) + } + } + return s.Refresh(ctx) +} + +// List returns all cached guardrail definitions sorted by name. +func (s *Service) List() []Definition { + s.mu.RLock() + defer s.mu.RUnlock() + + result := make([]Definition, 0, len(s.snapshot.order)) + for _, name := range s.snapshot.order { + result = append(result, cloneDefinition(s.snapshot.definitions[name])) + } + return result +} + +// ListViews returns all cached guardrail definitions with lightweight summaries. +func (s *Service) ListViews() []View { + definitions := s.List() + views := make([]View, 0, len(definitions)) + for _, definition := range definitions { + views = append(views, ViewFromDefinition(definition)) + } + return views +} + +// Get returns one cached guardrail by name. +func (s *Service) Get(name string) (*Definition, bool) { + name = normalizeDefinitionName(name) + if name == "" { + return nil, false + } + + s.mu.RLock() + defer s.mu.RUnlock() + definition, ok := s.snapshot.definitions[name] + if !ok { + return nil, false + } + copy := cloneDefinition(definition) + return ©, true +} + +// Upsert validates and stores a guardrail definition, then refreshes the snapshot. +func (s *Service) Upsert(ctx context.Context, definition Definition) error { + normalized, err := normalizeDefinition(definition) + if err != nil { + return err + } + if err := s.store.Upsert(ctx, normalized); err != nil { + return fmt.Errorf("upsert guardrail: %w", err) + } + if err := s.Refresh(ctx); err != nil { + return fmt.Errorf("refresh guardrails: %w", err) + } + return nil +} + +// Delete removes a guardrail definition from storage and refreshes the snapshot. +func (s *Service) Delete(ctx context.Context, name string) error { + name = normalizeDefinitionName(name) + if name == "" { + return newValidationError("guardrail name is required", nil) + } + if err := s.store.Delete(ctx, name); err != nil { + return fmt.Errorf("delete guardrail: %w", err) + } + if err := s.Refresh(ctx); err != nil { + return fmt.Errorf("refresh guardrails: %w", err) + } + return nil +} + +// TypeDefinitions returns the supported guardrail type schemas. +func (s *Service) TypeDefinitions() []TypeDefinition { + return TypeDefinitions() +} + +// Len returns the number of loaded guardrails. +func (s *Service) Len() int { + s.mu.RLock() + defer s.mu.RUnlock() + return len(s.snapshot.order) +} + +// Names returns the loaded guardrail names in sorted order. +func (s *Service) Names() []string { + s.mu.RLock() + defer s.mu.RUnlock() + return append([]string(nil), s.snapshot.order...) +} + +// BuildPipeline resolves named steps through the current in-memory guardrail registry. +func (s *Service) BuildPipeline(steps []StepReference) (*Pipeline, string, error) { + if len(steps) == 0 { + return nil, "", nil + } + + s.mu.RLock() + registry := s.snapshot.registry + s.mu.RUnlock() + if registry == nil { + return nil, "", fmt.Errorf("guardrail catalog is not loaded") + } + return registry.BuildPipeline(steps) +} + +// StartBackgroundRefresh periodically reloads guardrails from storage until stopped. +func (s *Service) StartBackgroundRefresh(interval time.Duration) func() { + if interval <= 0 { + interval = time.Minute + } + + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan struct{}) + var once sync.Once + + go func() { + defer close(done) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + refreshCtx, refreshCancel := context.WithTimeout(ctx, 30*time.Second) + _ = s.Refresh(refreshCtx) + refreshCancel() + } + } + }() + + return func() { + once.Do(func() { + cancel() + <-done + }) + } +} diff --git a/internal/guardrails/service_test.go b/internal/guardrails/service_test.go new file mode 100644 index 00000000..a4609fb6 --- /dev/null +++ b/internal/guardrails/service_test.go @@ -0,0 +1,183 @@ +package guardrails + +import ( + "context" + "encoding/json" + "testing" +) + +type testStore struct { + definitions map[string]Definition +} + +func newTestStore(definitions ...Definition) *testStore { + store := &testStore{definitions: make(map[string]Definition, len(definitions))} + for _, definition := range definitions { + store.definitions[definition.Name] = definition + } + return store +} + +func (s *testStore) List(context.Context) ([]Definition, error) { + result := make([]Definition, 0, len(s.definitions)) + for _, definition := range s.definitions { + result = append(result, definition) + } + return result, nil +} + +func (s *testStore) Get(_ context.Context, name string) (*Definition, error) { + definition, ok := s.definitions[name] + if !ok { + return nil, ErrNotFound + } + copy := definition + return ©, nil +} + +func (s *testStore) Upsert(_ context.Context, definition Definition) error { + s.definitions[definition.Name] = definition + return nil +} + +func (s *testStore) Delete(_ context.Context, name string) error { + if _, ok := s.definitions[name]; !ok { + return ErrNotFound + } + delete(s.definitions, name) + return nil +} + +func (s *testStore) Close() error { return nil } + +func rawConfig(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("json.Marshal() error = %v", err) + } + return raw +} + +func TestServiceRefreshBuildsPipelineFromDefinitions(t *testing.T) { + store := newTestStore( + Definition{ + Name: "safety", + Type: "system_prompt", + Config: rawConfig(t, map[string]any{ + "mode": "inject", + "content": "be safe", + }), + }, + ) + + service, err := NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + if err := service.Refresh(context.Background()); err != nil { + t.Fatalf("Refresh() error = %v", err) + } + + if got := service.Names(); len(got) != 1 || got[0] != "safety" { + t.Fatalf("Names() = %v, want [safety]", got) + } + + pipeline, hash, err := service.BuildPipeline([]StepReference{{Ref: "safety", Step: 10}}) + if err != nil { + t.Fatalf("BuildPipeline() error = %v", err) + } + if pipeline == nil || pipeline.Len() != 1 { + t.Fatalf("pipeline = %#v, want one entry", pipeline) + } + if hash == "" { + t.Fatal("BuildPipeline() hash = empty, want non-empty") + } + + msgs, err := pipeline.Process(context.Background(), []Message{{Role: "user", Content: "hi"}}) + if err != nil { + t.Fatalf("Process() error = %v", err) + } + if len(msgs) != 2 || msgs[0].Role != "system" || msgs[0].Content != "be safe" { + t.Fatalf("Process() messages = %#v, want injected system prompt", msgs) + } +} + +func TestServiceEnsureSeedDefinitionsOnlySeedsEmptyStore(t *testing.T) { + store := newTestStore() + service, err := NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + seed := []Definition{ + { + Name: "policy", + Type: "system_prompt", + Config: rawConfig(t, map[string]any{ + "mode": "override", + "content": "policy text", + }), + }, + } + + if err := service.EnsureSeedDefinitions(context.Background(), seed); err != nil { + t.Fatalf("EnsureSeedDefinitions() error = %v", err) + } + if got := service.Names(); len(got) != 1 || got[0] != "policy" { + t.Fatalf("Names() after seed = %v, want [policy]", got) + } + + store.definitions["custom"] = Definition{ + Name: "custom", + Type: "system_prompt", + Config: rawConfig(t, map[string]any{ + "mode": "inject", + "content": "custom", + }), + } + if err := service.EnsureSeedDefinitions(context.Background(), []Definition{ + { + Name: "ignored", + Type: "system_prompt", + Config: rawConfig(t, map[string]any{ + "mode": "inject", + "content": "ignored", + }), + }, + }); err != nil { + t.Fatalf("EnsureSeedDefinitions() second call error = %v", err) + } + if _, ok := store.definitions["ignored"]; ok { + t.Fatal("EnsureSeedDefinitions() seeded into non-empty store, want no-op") + } +} + +func TestServiceUpsertNormalizesUserPath(t *testing.T) { + store := newTestStore() + service, err := NewService(store) + if err != nil { + t.Fatalf("NewService() error = %v", err) + } + + err = service.Upsert(context.Background(), Definition{ + Name: "policy", + Type: "system_prompt", + UserPath: "team/alpha", + Config: rawConfig(t, map[string]any{ + "mode": "inject", + "content": "policy text", + }), + }) + if err != nil { + t.Fatalf("Upsert() error = %v", err) + } + + definition, ok := service.Get("policy") + if !ok || definition == nil { + t.Fatal("Get(policy) = missing, want stored guardrail") + } + if definition.UserPath != "/team/alpha" { + t.Fatalf("definition.UserPath = %q, want /team/alpha", definition.UserPath) + } +} diff --git a/internal/guardrails/store.go b/internal/guardrails/store.go new file mode 100644 index 00000000..d14224f7 --- /dev/null +++ b/internal/guardrails/store.go @@ -0,0 +1,94 @@ +package guardrails + +import ( + "context" + "database/sql" + "errors" + "strings" +) + +// ErrNotFound indicates a requested guardrail was not found. +var ErrNotFound = errors.New("guardrail not found") + +// ValidationError indicates invalid guardrail input or state. +type ValidationError struct { + Message string + Err error +} + +func (e *ValidationError) Error() string { + if e == nil { + return "" + } + return e.Message +} + +func (e *ValidationError) Unwrap() error { + if e == nil { + return nil + } + return e.Err +} + +func newValidationError(message string, err error) error { + return &ValidationError{Message: message, Err: err} +} + +// IsValidationError reports whether err is a validation error. +func IsValidationError(err error) bool { + _, ok := errors.AsType[*ValidationError](err) + return ok +} + +// Store defines persistence operations for reusable guardrail definitions. +type Store interface { + List(ctx context.Context) ([]Definition, error) + Get(ctx context.Context, name string) (*Definition, error) + Upsert(ctx context.Context, definition Definition) error + Delete(ctx context.Context, name string) error + Close() error +} + +type definitionScanner interface { + Scan(dest ...any) error +} + +type definitionRows interface { + definitionScanner + Next() bool + Err() error +} + +func normalizeDefinitionName(name string) string { + return strings.TrimSpace(name) +} + +func collectDefinitions(rows definitionRows, scan func(definitionScanner) (Definition, error)) ([]Definition, error) { + result := make([]Definition, 0) + for rows.Next() { + definition, err := scan(rows) + if err != nil { + return nil, err + } + result = append(result, definition) + } + if err := rows.Err(); err != nil { + return nil, err + } + return result, nil +} + +func nullableString(value string) any { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return value +} + +func nullableStringValue(value sql.NullString) string { + if !value.Valid { + return "" + } + return strings.TrimSpace(value.String) +} diff --git a/internal/guardrails/store_mongodb.go b/internal/guardrails/store_mongodb.go new file mode 100644 index 00000000..5412bbff --- /dev/null +++ b/internal/guardrails/store_mongodb.go @@ -0,0 +1,170 @@ +package guardrails + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +type mongoDefinitionDocument struct { + Name string `bson:"_id"` + Type string `bson:"type"` + Description string `bson:"description,omitempty"` + UserPath string `bson:"user_path,omitempty"` + Config bson.M `bson:"config"` + CreatedAt time.Time `bson:"created_at"` + UpdatedAt time.Time `bson:"updated_at"` +} + +type mongoDefinitionIDFilter struct { + ID string `bson:"_id"` +} + +// MongoDBStore stores guardrail definitions in MongoDB. +type MongoDBStore struct { + collection *mongo.Collection +} + +// NewMongoDBStore creates collection indexes if needed. +func NewMongoDBStore(database *mongo.Database) (*MongoDBStore, error) { + if database == nil { + return nil, fmt.Errorf("database is required") + } + coll := database.Collection("guardrail_definitions") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + + indexes := []mongo.IndexModel{ + {Keys: bson.D{{Key: "type", Value: 1}}}, + {Keys: bson.D{{Key: "updated_at", Value: -1}}}, + } + if _, err := coll.Indexes().CreateMany(ctx, indexes); err != nil { + return nil, fmt.Errorf("create guardrail indexes: %w", err) + } + return &MongoDBStore{collection: coll}, nil +} + +func (s *MongoDBStore) List(ctx context.Context) ([]Definition, error) { + cursor, err := s.collection.Find(ctx, bson.M{}, options.Find().SetSort(bson.D{{Key: "_id", Value: 1}})) + if err != nil { + return nil, fmt.Errorf("list guardrails: %w", err) + } + defer cursor.Close(ctx) + + result := make([]Definition, 0) + for cursor.Next(ctx) { + var doc mongoDefinitionDocument + if err := cursor.Decode(&doc); err != nil { + return nil, fmt.Errorf("decode guardrail: %w", err) + } + definition, err := definitionFromMongo(doc) + if err != nil { + return nil, err + } + result = append(result, definition) + } + if err := cursor.Err(); err != nil { + return nil, fmt.Errorf("iterate guardrails: %w", err) + } + return result, nil +} + +func (s *MongoDBStore) Get(ctx context.Context, name string) (*Definition, error) { + var doc mongoDefinitionDocument + err := s.collection.FindOne(ctx, mongoDefinitionIDFilter{ID: normalizeDefinitionName(name)}).Decode(&doc) + if err != nil { + if errors.Is(err, mongo.ErrNoDocuments) { + return nil, ErrNotFound + } + return nil, fmt.Errorf("get guardrail: %w", err) + } + definition, err := definitionFromMongo(doc) + if err != nil { + return nil, err + } + return &definition, nil +} + +func (s *MongoDBStore) Upsert(ctx context.Context, definition Definition) error { + definition, err := normalizeDefinition(definition) + if err != nil { + return err + } + now := time.Now().UTC() + if definition.CreatedAt.IsZero() { + definition.CreatedAt = now + } + definition.UpdatedAt = now + + configDoc, err := mongoConfigFromRaw(definition.Config) + if err != nil { + return fmt.Errorf("upsert guardrail: %w", err) + } + + update := bson.M{ + "$set": bson.M{ + "type": definition.Type, + "description": definition.Description, + "user_path": definition.UserPath, + "config": configDoc, + "updated_at": definition.UpdatedAt, + }, + "$setOnInsert": bson.M{ + "created_at": definition.CreatedAt, + }, + } + _, err = s.collection.UpdateOne(ctx, mongoDefinitionIDFilter{ID: definition.Name}, update, options.UpdateOne().SetUpsert(true)) + if err != nil { + return fmt.Errorf("upsert guardrail: %w", err) + } + return nil +} + +func (s *MongoDBStore) Delete(ctx context.Context, name string) error { + result, err := s.collection.DeleteOne(ctx, mongoDefinitionIDFilter{ID: normalizeDefinitionName(name)}) + if err != nil { + return fmt.Errorf("delete guardrail: %w", err) + } + if result.DeletedCount == 0 { + return ErrNotFound + } + return nil +} + +func (s *MongoDBStore) Close() error { + return nil +} + +func mongoConfigFromRaw(raw json.RawMessage) (bson.M, error) { + if len(bytes.TrimSpace(raw)) == 0 { + return bson.M{}, nil + } + var doc bson.M + if err := json.Unmarshal(raw, &doc); err != nil { + return nil, fmt.Errorf("decode guardrail config: %w", err) + } + return doc, nil +} + +func definitionFromMongo(doc mongoDefinitionDocument) (Definition, error) { + raw, err := json.Marshal(doc.Config) + if err != nil { + return Definition{}, fmt.Errorf("encode guardrail config %q: %w", doc.Name, err) + } + return Definition{ + Name: doc.Name, + Type: doc.Type, + Description: doc.Description, + UserPath: doc.UserPath, + Config: raw, + CreatedAt: doc.CreatedAt.UTC(), + UpdatedAt: doc.UpdatedAt.UTC(), + }, nil +} diff --git a/internal/guardrails/store_postgresql.go b/internal/guardrails/store_postgresql.go new file mode 100644 index 00000000..9eec0822 --- /dev/null +++ b/internal/guardrails/store_postgresql.go @@ -0,0 +1,147 @@ +package guardrails + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +// PostgreSQLStore stores guardrail definitions in PostgreSQL. +type PostgreSQLStore struct { + pool *pgxpool.Pool +} + +// NewPostgreSQLStore creates the guardrail table and indexes if needed. +func NewPostgreSQLStore(ctx context.Context, pool *pgxpool.Pool) (*PostgreSQLStore, error) { + if ctx == nil { + return nil, fmt.Errorf("context is required") + } + if pool == nil { + return nil, fmt.Errorf("connection pool is required") + } + + statements := []string{ + `CREATE TABLE IF NOT EXISTS guardrail_definitions ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_path TEXT, + config JSONB NOT NULL, + created_at BIGINT NOT NULL, + updated_at BIGINT NOT NULL + )`, + `ALTER TABLE guardrail_definitions ADD COLUMN IF NOT EXISTS user_path TEXT`, + `CREATE INDEX IF NOT EXISTS idx_guardrail_definitions_type ON guardrail_definitions(type)`, + `CREATE INDEX IF NOT EXISTS idx_guardrail_definitions_updated_at ON guardrail_definitions(updated_at DESC)`, + } + for _, statement := range statements { + if _, err := pool.Exec(ctx, statement); err != nil { + return nil, fmt.Errorf("initialize guardrail definitions table: %w", err) + } + } + + return &PostgreSQLStore{pool: pool}, nil +} + +func (s *PostgreSQLStore) List(ctx context.Context) ([]Definition, error) { + rows, err := s.pool.Query(ctx, ` + SELECT name, type, description, user_path, config, created_at, updated_at + FROM guardrail_definitions + ORDER BY name ASC + `) + if err != nil { + return nil, fmt.Errorf("list guardrails: %w", err) + } + defer rows.Close() + return collectDefinitions(rows, scanPostgreSQLDefinition) +} + +func (s *PostgreSQLStore) Get(ctx context.Context, name string) (*Definition, error) { + row := s.pool.QueryRow(ctx, ` + SELECT name, type, description, user_path, config, created_at, updated_at + FROM guardrail_definitions + WHERE name = $1 + `, normalizeDefinitionName(name)) + definition, err := scanPostgreSQLDefinition(row) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNotFound + } + return nil, err + } + return &definition, nil +} + +func (s *PostgreSQLStore) Upsert(ctx context.Context, definition Definition) error { + definition, err := normalizeDefinition(definition) + if err != nil { + return err + } + + now := time.Now().UTC().Unix() + if definition.CreatedAt.IsZero() { + definition.CreatedAt = time.Unix(now, 0).UTC() + } + definition.UpdatedAt = time.Unix(now, 0).UTC() + + _, err = s.pool.Exec(ctx, ` + INSERT INTO guardrail_definitions (name, type, description, user_path, config, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT(name) DO UPDATE SET + type = excluded.type, + description = excluded.description, + user_path = excluded.user_path, + config = excluded.config, + updated_at = excluded.updated_at + `, definition.Name, definition.Type, definition.Description, nullableString(definition.UserPath), definition.Config, definition.CreatedAt.Unix(), definition.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert guardrail: %w", err) + } + return nil +} + +func (s *PostgreSQLStore) Delete(ctx context.Context, name string) error { + tag, err := s.pool.Exec(ctx, `DELETE FROM guardrail_definitions WHERE name = $1`, normalizeDefinitionName(name)) + if err != nil { + return fmt.Errorf("delete guardrail: %w", err) + } + if tag.RowsAffected() == 0 { + return ErrNotFound + } + return nil +} + +func (s *PostgreSQLStore) Close() error { + return nil +} + +func scanPostgreSQLDefinition(scanner definitionScanner) (Definition, error) { + var ( + definition Definition + userPath sql.NullString + configJSON []byte + createdAtUnix int64 + updatedAtUnix int64 + ) + if err := scanner.Scan( + &definition.Name, + &definition.Type, + &definition.Description, + &userPath, + &configJSON, + &createdAtUnix, + &updatedAtUnix, + ); err != nil { + return Definition{}, err + } + definition.UserPath = nullableStringValue(userPath) + definition.Config = append([]byte(nil), configJSON...) + definition.CreatedAt = time.Unix(createdAtUnix, 0).UTC() + definition.UpdatedAt = time.Unix(updatedAtUnix, 0).UTC() + return definition, nil +} diff --git a/internal/guardrails/store_sqlite.go b/internal/guardrails/store_sqlite.go new file mode 100644 index 00000000..ee6b4f90 --- /dev/null +++ b/internal/guardrails/store_sqlite.go @@ -0,0 +1,156 @@ +package guardrails + +import ( + "context" + "database/sql" + "fmt" + "strings" + "time" +) + +// SQLiteStore stores guardrail definitions in SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates the guardrail table and indexes if needed. +func NewSQLiteStore(db *sql.DB) (*SQLiteStore, error) { + if db == nil { + return nil, fmt.Errorf("database connection is required") + } + + statements := []string{ + `CREATE TABLE IF NOT EXISTS guardrail_definitions ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + user_path TEXT, + config JSON NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + )`, + `ALTER TABLE guardrail_definitions ADD COLUMN user_path TEXT`, + `CREATE INDEX IF NOT EXISTS idx_guardrail_definitions_type ON guardrail_definitions(type)`, + `CREATE INDEX IF NOT EXISTS idx_guardrail_definitions_updated_at ON guardrail_definitions(updated_at DESC)`, + } + for _, statement := range statements { + if _, err := db.Exec(statement); err != nil { + if statement == `ALTER TABLE guardrail_definitions ADD COLUMN user_path TEXT` && isSQLiteDuplicateColumnError(err) { + continue + } + return nil, fmt.Errorf("initialize guardrail definitions table: %w", err) + } + } + + return &SQLiteStore{db: db}, nil +} + +func (s *SQLiteStore) List(ctx context.Context) ([]Definition, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT name, type, description, user_path, config, created_at, updated_at + FROM guardrail_definitions + ORDER BY name ASC + `) + if err != nil { + return nil, fmt.Errorf("list guardrails: %w", err) + } + defer rows.Close() + return collectDefinitions(rows, scanSQLiteDefinition) +} + +func (s *SQLiteStore) Get(ctx context.Context, name string) (*Definition, error) { + row := s.db.QueryRowContext(ctx, ` + SELECT name, type, description, user_path, config, created_at, updated_at + FROM guardrail_definitions + WHERE name = ? + `, normalizeDefinitionName(name)) + definition, err := scanSQLiteDefinition(row) + if err != nil { + if err == sql.ErrNoRows { + return nil, ErrNotFound + } + return nil, err + } + return &definition, nil +} + +func (s *SQLiteStore) Upsert(ctx context.Context, definition Definition) error { + definition, err := normalizeDefinition(definition) + if err != nil { + return err + } + + now := time.Now().UTC().Unix() + if definition.CreatedAt.IsZero() { + definition.CreatedAt = time.Unix(now, 0).UTC() + } + definition.UpdatedAt = time.Unix(now, 0).UTC() + + _, err = s.db.ExecContext(ctx, ` + INSERT INTO guardrail_definitions (name, type, description, user_path, config, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(name) DO UPDATE SET + type = excluded.type, + description = excluded.description, + user_path = excluded.user_path, + config = excluded.config, + updated_at = excluded.updated_at + `, definition.Name, definition.Type, definition.Description, nullableString(definition.UserPath), string(definition.Config), definition.CreatedAt.Unix(), definition.UpdatedAt.Unix()) + if err != nil { + return fmt.Errorf("upsert guardrail: %w", err) + } + return nil +} + +func (s *SQLiteStore) Delete(ctx context.Context, name string) error { + result, err := s.db.ExecContext(ctx, `DELETE FROM guardrail_definitions WHERE name = ?`, normalizeDefinitionName(name)) + if err != nil { + return fmt.Errorf("delete guardrail: %w", err) + } + rowsAffected, err := result.RowsAffected() + if err != nil { + return fmt.Errorf("delete guardrail rows affected: %w", err) + } + if rowsAffected == 0 { + return ErrNotFound + } + return nil +} + +func (s *SQLiteStore) Close() error { + return nil +} + +func scanSQLiteDefinition(scanner definitionScanner) (Definition, error) { + var ( + definition Definition + userPath sql.NullString + configJSON string + createdAtUnix int64 + updatedAtUnix int64 + ) + if err := scanner.Scan( + &definition.Name, + &definition.Type, + &definition.Description, + &userPath, + &configJSON, + &createdAtUnix, + &updatedAtUnix, + ); err != nil { + return Definition{}, err + } + definition.UserPath = nullableStringValue(userPath) + definition.Config = append([]byte(nil), configJSON...) + definition.CreatedAt = time.Unix(createdAtUnix, 0).UTC() + definition.UpdatedAt = time.Unix(updatedAtUnix, 0).UTC() + return definition, nil +} + +func isSQLiteDuplicateColumnError(err error) bool { + if err == nil { + return false + } + message := strings.ToLower(err.Error()) + return strings.Contains(message, "duplicate column") || strings.Contains(message, "already exists") +} diff --git a/internal/guardrails/store_sqlite_test.go b/internal/guardrails/store_sqlite_test.go new file mode 100644 index 00000000..6a985811 --- /dev/null +++ b/internal/guardrails/store_sqlite_test.go @@ -0,0 +1,106 @@ +package guardrails + +import ( + "context" + "database/sql" + "testing" + + _ "modernc.org/sqlite" +) + +func TestNewSQLiteStore_AddsMissingUserPathColumn(t *testing.T) { + t.Parallel() + + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + + _, err = db.Exec(` + CREATE TABLE guardrail_definitions ( + name TEXT PRIMARY KEY, + type TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + config JSON NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + `) + if err != nil { + t.Fatalf("create guardrail_definitions table: %v", err) + } + + store, err := NewSQLiteStore(db) + if err != nil { + t.Fatalf("NewSQLiteStore() error = %v", err) + } + if store == nil { + t.Fatal("NewSQLiteStore() = nil, want store") + } + + rows, err := db.Query(`PRAGMA table_info('guardrail_definitions')`) + if err != nil { + t.Fatalf("PRAGMA table_info() error = %v", err) + } + defer rows.Close() + + hasUserPathColumn := false + for rows.Next() { + var cid int + var name string + var columnType string + var notNull int + var defaultValue sql.NullString + var primaryKey int + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + t.Fatalf("rows.Scan() error = %v", err) + } + if name == "user_path" { + hasUserPathColumn = true + } + } + if err := rows.Err(); err != nil { + t.Fatalf("rows.Err() = %v", err) + } + if !hasUserPathColumn { + t.Fatal("user_path column missing after initialization") + } +} + +func TestSQLiteStore_UpsertAndListRoundTripsUserPath(t *testing.T) { + t.Parallel() + + db, err := sql.Open("sqlite", ":memory:") + if err != nil { + t.Fatalf("sql.Open() error = %v", err) + } + defer db.Close() + + store, err := NewSQLiteStore(db) + if err != nil { + t.Fatalf("NewSQLiteStore() error = %v", err) + } + + err = store.Upsert(context.Background(), Definition{ + Name: "policy-system", + Type: "system_prompt", + Description: "Default policy", + UserPath: "/team/alpha", + Config: rawConfig(t, map[string]any{"mode": "inject", "content": "be careful"}), + }) + if err != nil { + t.Fatalf("Upsert() error = %v", err) + } + + definitions, err := store.List(context.Background()) + if err != nil { + t.Fatalf("List() error = %v", err) + } + if len(definitions) != 1 { + t.Fatalf("len(definitions) = %d, want 1", len(definitions)) + } + if definitions[0].UserPath != "/team/alpha" { + t.Fatalf("definitions[0].UserPath = %q, want /team/alpha", definitions[0].UserPath) + } +} diff --git a/internal/guardrails/system_prompt.go b/internal/guardrails/system_prompt.go index f3d4bf6e..8087deb6 100644 --- a/internal/guardrails/system_prompt.go +++ b/internal/guardrails/system_prompt.go @@ -3,6 +3,7 @@ package guardrails import ( "context" "fmt" + "strings" ) // SystemPromptMode defines how the system prompt guardrail modifies messages. @@ -50,6 +51,14 @@ func NewSystemPromptGuardrail(name string, mode SystemPromptMode, content string }, nil } +func effectiveSystemPromptMode(mode string) string { + resolved := SystemPromptMode(strings.TrimSpace(mode)) + if resolved == "" { + return string(SystemPromptInject) + } + return string(resolved) +} + // Name returns this instance's name. func (g *SystemPromptGuardrail) Name() string { return g.name diff --git a/internal/server/http.go b/internal/server/http.go index fb86fee8..6186c2dc 100644 --- a/internal/server/http.go +++ b/internal/server/http.go @@ -292,6 +292,10 @@ func New(provider core.RoutableProvider, cfg *Config) *Server { adminAPI.GET("/aliases", cfg.AdminHandler.ListAliases) adminAPI.PUT("/aliases/:name", cfg.AdminHandler.UpsertAlias) adminAPI.DELETE("/aliases/:name", cfg.AdminHandler.DeleteAlias) + adminAPI.GET("/guardrails/types", cfg.AdminHandler.ListGuardrailTypes) + adminAPI.GET("/guardrails", cfg.AdminHandler.ListGuardrails) + adminAPI.PUT("/guardrails/:name", cfg.AdminHandler.UpsertGuardrail) + adminAPI.DELETE("/guardrails/:name", cfg.AdminHandler.DeleteGuardrail) adminAPI.GET("/execution-plans", cfg.AdminHandler.ListExecutionPlans) adminAPI.GET("/execution-plans/guardrails", cfg.AdminHandler.ListExecutionPlanGuardrails) adminAPI.GET("/execution-plans/:id", cfg.AdminHandler.GetExecutionPlan) From 05441058ef280e6e21636b9865f03889ff9672bf Mon Sep 17 00:00:00 2001 From: "Jakub A. W" Date: Fri, 3 Apr 2026 22:51:01 +0200 Subject: [PATCH 2/7] refactor(dashboard): simplify guardrails and workflow UI --- .../admin/dashboard/static/css/dashboard.css | 216 +++---------- .../admin/dashboard/static/js/dashboard.js | 25 +- .../js/modules/dashboard-layout.test.js | 5 +- .../js/modules/execution-plans-layout.test.js | 103 +++--- .../static/js/modules/execution-plans.js | 15 +- .../static/js/modules/execution-plans.test.js | 26 +- .../static/js/modules/timezone-layout.test.js | 15 +- .../templates/execution-plan-chart.html | 82 ++--- internal/admin/dashboard/templates/index.html | 299 +++++++++--------- .../admin/dashboard/templates/layout.html | 4 + 10 files changed, 325 insertions(+), 465 deletions(-) diff --git a/internal/admin/dashboard/static/css/dashboard.css b/internal/admin/dashboard/static/css/dashboard.css index 71a49c39..5688bb9e 100644 --- a/internal/admin/dashboard/static/css/dashboard.css +++ b/internal/admin/dashboard/static/css/dashboard.css @@ -2744,45 +2744,20 @@ body.conversation-drawer-open { display: flex; align-items: center; width: 100%; -} - -/* Left/right halves — equal width keeps AI centered */ -.ep-left, -.ep-right { - display: flex; - align-items: center; - flex: 1; min-width: 0; } -.ep-right { - justify-content: flex-end; -} - -/* Optional step wrapper (connector + node shown/hidden together) */ -.ep-step { - display: flex; - align-items: center; - flex-shrink: 0; -} - /* ─── Connectors ─── */ .ep-conn { - flex-shrink: 0; + flex: 1 1 0; + min-width: 13px; position: relative; - width: 32px; + width: auto; height: 2px; background: color-mix(in srgb, var(--accent) 44%, var(--border)); } -/* Growing connector — fills remaining half to keep AI centered */ -.ep-conn-grow { - flex: 1; - min-width: 16px; - width: auto; -} - .ep-conn::after { content: ""; position: absolute; @@ -2821,8 +2796,8 @@ body.conversation-drawer-open { flex-direction: column; align-items: center; justify-content: center; - gap: 5px; - padding: 10px 14px; + gap: 4px; + padding: 8px 12px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--bg-surface); @@ -2922,154 +2897,90 @@ body.conversation-drawer-open { color: var(--text-muted); } -.ep-node-endpoint-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); -} - -.ep-node-endpoint-success .ep-node-icon-endpoint { - color: var(--success); -} +/* ─── Shared node variants ─── */ -.ep-node-endpoint-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -/* ─── Guardrails node ─── */ - -.ep-node-guardrails { +.ep-node-feature { border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); } -.ep-node-guardrails .ep-node-icon { +.ep-node-feature .ep-node-icon { background: color-mix(in srgb, var(--accent) 16%, var(--bg)); color: var(--accent); } -.ep-node-guardrails .ep-node-label { +.ep-node-feature .ep-node-label { color: var(--accent); } -.ep-node-guardrails .ep-node-sub { +.ep-node-feature .ep-node-sub { color: color-mix(in srgb, var(--accent) 70%, var(--text-muted)); } -/* ─── Cache node ─── */ - -.ep-node-cache { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); -} - -.ep-node-cache .ep-node-icon { - background: color-mix(in srgb, var(--accent) 16%, var(--bg)); - color: var(--accent); -} - -.ep-node-cache .ep-node-label { - color: var(--accent); -} - -/* Cache — runtime: exact hit */ -.ep-node-cache-hit { +.ep-node-success { border-color: color-mix(in srgb, var(--success) 52%, var(--border)); background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); } -.ep-node-cache-hit .ep-node-icon { +.ep-node-success .ep-node-icon { background: color-mix(in srgb, var(--success) 18%, var(--bg)); color: var(--success); } -.ep-node-cache-hit .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -.ep-node-cache-hit .ep-node-badge { - background: color-mix(in srgb, var(--success) 14%, var(--bg)); - border-color: color-mix(in srgb, var(--success) 38%, var(--border)); +.ep-node-success .ep-node-icon-endpoint { color: var(--success); } -/* Cache — runtime: semantic hit */ -.ep-node-cache-semantic { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); -} - -.ep-node-cache-semantic .ep-node-icon { - background: color-mix(in srgb, var(--success) 18%, var(--bg)); - color: var(--success); +.ep-node-success .ep-node-label { + color: color-mix(in srgb, var(--success) 85%, var(--text)); } -.ep-node-cache-semantic .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); +.ep-node-success .ep-node-sub { + color: color-mix(in srgb, var(--success) 74%, var(--text-muted)); } -.ep-node-cache-semantic .ep-node-badge { +.ep-node-success .ep-node-badge { background: color-mix(in srgb, var(--success) 14%, var(--bg)); border-color: color-mix(in srgb, var(--success) 38%, var(--border)); color: var(--success); } -/* Cache — runtime: miss badge only */ -.ep-node-cache-miss .ep-node-badge { - color: var(--text-muted); - border-color: var(--border); -} - -/* ─── Auth node ─── */ - -.ep-node-auth { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); -} - -.ep-node-auth .ep-node-icon { - background: color-mix(in srgb, var(--accent) 16%, var(--bg)); - color: var(--accent); +.ep-node-error { + border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); + background: color-mix(in srgb, var(--danger) 9%, var(--bg-surface)); } -.ep-node-auth .ep-node-label { - color: var(--accent); +.ep-node-error .ep-node-icon { + background: color-mix(in srgb, var(--danger) 14%, var(--bg)); + color: var(--danger); } -.ep-node-auth .ep-node-sub { - color: color-mix(in srgb, var(--accent) 70%, var(--text-muted)); +.ep-node-error .ep-node-icon-endpoint { + color: var(--danger); } -.ep-node-auth-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); +.ep-node-error .ep-node-label { + color: color-mix(in srgb, var(--danger) 85%, var(--text)); } -.ep-node-auth-success .ep-node-icon { - background: color-mix(in srgb, var(--success) 18%, var(--bg)); - color: var(--success); +.ep-node-error .ep-node-sub { + color: color-mix(in srgb, var(--danger) 72%, var(--text-muted)); } -.ep-node-auth-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -.ep-node-auth-success .ep-node-sub { - color: color-mix(in srgb, var(--success) 70%, var(--text-muted)); +.ep-node-skipped { + position: relative; + opacity: 0.28; } -.ep-node-auth-error { - border-color: color-mix(in srgb, var(--danger) 52%, var(--border)); - background: color-mix(in srgb, var(--danger) 9%, var(--bg-surface)); -} +/* ─── Cache node ─── */ -.ep-node-auth-error .ep-node-icon { - background: color-mix(in srgb, var(--danger) 14%, var(--bg)); - color: var(--danger); +/* Cache — runtime: miss badge only */ +.ep-node-cache-miss .ep-node-badge { + color: var(--text-muted); + border-color: var(--border); } -.ep-node-auth-error .ep-node-label { - color: color-mix(in srgb, var(--danger) 85%, var(--text)); -} +/* ─── Auth node ─── */ /* ─── AI node ─── */ @@ -3080,25 +2991,6 @@ body.conversation-drawer-open { gap: 6px; } -.ep-node-ai-success { - border-color: color-mix(in srgb, var(--success) 52%, var(--border)); - background: color-mix(in srgb, var(--success) 9%, var(--bg-surface)); -} - -.ep-node-ai-success .ep-node-label { - color: color-mix(in srgb, var(--success) 85%, var(--text)); -} - -.ep-node-ai-success .ep-node-sub { - color: color-mix(in srgb, var(--success) 78%, var(--text-muted)); -} - -/* AI skipped — cache hit absorbed the request */ -.ep-node-ai-skipped { - position: relative; - opacity: 0.28; -} - /* ─── Async section ─── */ /* @@ -3171,6 +3063,7 @@ body.conversation-drawer-open { /* Dashed left-pointing connector between async nodes */ .ep-conn-async { + flex: 0 0 24px; background: repeating-linear-gradient( to left, color-mix(in srgb, var(--text-muted) 45%, var(--border)) 0, @@ -3179,7 +3072,6 @@ body.conversation-drawer-open { transparent 9px ); width: 24px; - flex-shrink: 0; } /* Left-pointing arrowhead (overrides ep-conn::after right-pointing default) */ @@ -3216,34 +3108,6 @@ body.conversation-drawer-open { font-weight: 700; } -.ep-node-async-audit { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); -} - -.ep-node-async-audit .ep-node-icon { - background: color-mix(in srgb, var(--accent) 16%, var(--bg)); - color: var(--accent); -} - -.ep-node-async-audit .ep-node-label { - color: var(--accent); -} - -.ep-node-async-usage { - border-color: color-mix(in srgb, var(--accent) 46%, var(--border)); - background: color-mix(in srgb, var(--accent) 8%, var(--bg-surface)); -} - -.ep-node-async-usage .ep-node-icon { - background: color-mix(in srgb, var(--accent) 16%, var(--bg)); - color: var(--accent); -} - -.ep-node-async-usage .ep-node-label { - color: var(--accent); -} - /* "ASYNC" label inline on the right of the branch */ .ep-async-label { display: inline-flex; diff --git a/internal/admin/dashboard/static/js/dashboard.js b/internal/admin/dashboard/static/js/dashboard.js index 1f25febd..a92c5bae 100644 --- a/internal/admin/dashboard/static/js/dashboard.js +++ b/internal/admin/dashboard/static/js/dashboard.js @@ -114,19 +114,20 @@ function dashboard() { if (page === 'audit') { page = 'audit-logs'; } - page = (['overview', 'usage', 'models', 'workflows', 'audit-logs', 'auth-keys', 'settings'].includes(page)) ? page : 'overview'; const sub = parts[1] || null; + if (page === 'settings' && sub === 'guardrails') { + return { page: 'guardrails', sub: null }; + } + page = (['overview', 'usage', 'models', 'workflows', 'audit-logs', 'guardrails', 'auth-keys', 'settings'].includes(page)) ? page : 'overview'; return { page, sub }; }, _normalizeSettingsSubpage(subpage) { - return subpage === 'guardrails' ? 'guardrails' : 'general'; + return 'general'; }, _settingsPath(subpage) { - return subpage === 'guardrails' - ? '/admin/dashboard/settings/guardrails' - : '/admin/dashboard/settings'; + return '/admin/dashboard/settings'; }, _applyRoute(page, sub) { @@ -142,13 +143,13 @@ function dashboard() { if (page === 'workflows' && typeof this.fetchExecutionPlansPage === 'function') { this.fetchExecutionPlansPage(); } + if (page === 'guardrails' && typeof this.fetchGuardrailsPage === 'function') { + this.fetchGuardrailsPage(); + } if (page === 'settings') { - if (this.settingsSubpage === 'general' && typeof this.ensureTimezoneOptions === 'function') { + if (typeof this.ensureTimezoneOptions === 'function') { this.ensureTimezoneOptions(); } - if (this.settingsSubpage === 'guardrails' && typeof this.fetchGuardrailsPage === 'function') { - this.fetchGuardrailsPage(); - } } if (page === 'overview') this.renderChart(); if (page === 'usage') this.fetchUsagePage(); @@ -202,6 +203,12 @@ function dashboard() { this._applyRoute('settings', normalized); }, + guardrailsPageVisible() { + return typeof this.executionPlanRuntimeBooleanFlag === 'function' + ? this.executionPlanRuntimeBooleanFlag('GUARDRAILS_ENABLED', false) + : false; + }, + setTheme(t) { this.theme = t; localStorage.setItem('gomodel_theme', t); diff --git a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js index 94c8274e..f79e0389 100644 --- a/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js +++ b/internal/admin/dashboard/static/js/modules/dashboard-layout.test.js @@ -29,6 +29,7 @@ test('sidebar and main content share the flex layout without manual content offs assert.match(template, /