ScuttleBot

scuttlebot / internal / api / policies.go
Source Blame History 474 lines
5ac549c… lmata 1 package api
5ac549c… lmata 2
5ac549c… lmata 3 import (
5ac549c… lmata 4 "encoding/json"
5ac549c… lmata 5 "fmt"
5ac549c… lmata 6 "net/http"
5ac549c… lmata 7 "os"
5ac549c… lmata 8 "sync"
0e78954… lmata 9
0e78954… lmata 10 "github.com/conflicthq/scuttlebot/internal/store"
5ac549c… lmata 11 )
5ac549c… lmata 12
5ac549c… lmata 13 // BehaviorConfig defines a pre-registered system bot behavior.
5ac549c… lmata 14 type BehaviorConfig struct {
5ac549c… lmata 15 ID string `json:"id"`
5ac549c… lmata 16 Name string `json:"name"`
5ac549c… lmata 17 Description string `json:"description"`
5ac549c… lmata 18 Nick string `json:"nick"`
5ac549c… lmata 19 Enabled bool `json:"enabled"`
5ac549c… lmata 20 JoinAllChannels bool `json:"join_all_channels"`
5ac549c… lmata 21 ExcludeChannels []string `json:"exclude_channels"`
5ac549c… lmata 22 RequiredChannels []string `json:"required_channels"`
5ac549c… lmata 23 // Config holds bot-specific configuration. The schema is defined per bot
5ac549c… lmata 24 // in the UI; the backend stores and returns it opaquely.
5ac549c… lmata 25 Config map[string]any `json:"config,omitempty"`
5ac549c… lmata 26 }
5ac549c… lmata 27
5ac549c… lmata 28 // AgentPolicy defines requirements applied to all registering agents.
5ac549c… lmata 29 type AgentPolicy struct {
d924aea… lmata 30 RequireCheckin bool `json:"require_checkin"`
d924aea… lmata 31 CheckinChannel string `json:"checkin_channel"`
d924aea… lmata 32 RequiredChannels []string `json:"required_channels"`
d924aea… lmata 33 OnlineTimeoutSecs int `json:"online_timeout_secs,omitempty"`
d924aea… lmata 34 ReapAfterDays int `json:"reap_after_days,omitempty"`
5ac549c… lmata 35 }
5ac549c… lmata 36
5ac549c… lmata 37 // LoggingPolicy configures message logging.
5ac549c… lmata 38 type LoggingPolicy struct {
5ac549c… lmata 39 Enabled bool `json:"enabled"`
5ac549c… lmata 40 Dir string `json:"dir"` // directory to write log files into
5ac549c… lmata 41 Format string `json:"format"` // "jsonl" | "csv" | "text"
5ac549c… lmata 42 Rotation string `json:"rotation"` // "none" | "daily" | "weekly" | "size"
5ac549c… lmata 43 MaxSizeMB int `json:"max_size_mb"` // size rotation threshold (MiB); 0 = unlimited
5ac549c… lmata 44 PerChannel bool `json:"per_channel"` // separate file per channel
5ac549c… lmata 45 MaxAgeDays int `json:"max_age_days"` // prune rotated files older than N days; 0 = keep all
5ac549c… lmata 46 }
5ac549c… lmata 47
a027855… noreply 48 // ChannelDisplayConfig holds per-channel rendering preferences.
a027855… noreply 49 type ChannelDisplayConfig struct {
a027855… noreply 50 MirrorDetail string `json:"mirror_detail,omitempty"` // "full", "compact", "minimal"
a027855… noreply 51 RenderMode string `json:"render_mode,omitempty"` // "rich", "text"
a027855… noreply 52 }
a027855… noreply 53
5ac549c… lmata 54 // BridgePolicy configures bridge-specific UI/relay behavior.
5ac549c… lmata 55 type BridgePolicy struct {
5ac549c… lmata 56 // WebUserTTLMinutes controls how long HTTP bridge sender nicks remain
5ac549c… lmata 57 // visible in the channel user list after their last post.
5ac549c… lmata 58 WebUserTTLMinutes int `json:"web_user_ttl_minutes"`
a027855… noreply 59 // ChannelDisplay holds per-channel rendering config.
a027855… noreply 60 ChannelDisplay map[string]ChannelDisplayConfig `json:"channel_display,omitempty"`
5ac549c… lmata 61 }
5ac549c… lmata 62
5ac549c… lmata 63 // PolicyLLMBackend stores an LLM backend configuration in the policy store.
5ac549c… lmata 64 // This allows backends to be added and edited from the web UI rather than
5ac549c… lmata 65 // requiring a change to scuttlebot.yaml.
5ac549c… lmata 66 //
5ac549c… lmata 67 // API keys are write-only — GET responses replace them with "***" when set.
5ac549c… lmata 68 type PolicyLLMBackend struct {
5ac549c… lmata 69 Name string `json:"name"`
5ac549c… lmata 70 Backend string `json:"backend"`
5ac549c… lmata 71 APIKey string `json:"api_key,omitempty"`
5ac549c… lmata 72 BaseURL string `json:"base_url,omitempty"`
5ac549c… lmata 73 Model string `json:"model,omitempty"`
5ac549c… lmata 74 Region string `json:"region,omitempty"`
5ac549c… lmata 75 AWSKeyID string `json:"aws_key_id,omitempty"`
5ac549c… lmata 76 AWSSecretKey string `json:"aws_secret_key,omitempty"`
5ac549c… lmata 77 Allow []string `json:"allow,omitempty"`
5ac549c… lmata 78 Block []string `json:"block,omitempty"`
5ac549c… lmata 79 Default bool `json:"default,omitempty"`
5ac549c… lmata 80 }
5ac549c… lmata 81
900677e… noreply 82 // ROETemplate is a rules-of-engagement template.
900677e… noreply 83 type ROETemplate struct {
900677e… noreply 84 Name string `json:"name"`
900677e… noreply 85 Description string `json:"description,omitempty"`
900677e… noreply 86 Channels []string `json:"channels,omitempty"`
900677e… noreply 87 Permissions []string `json:"permissions,omitempty"`
900677e… noreply 88 RateLimit struct {
900677e… noreply 89 MessagesPerSecond float64 `json:"messages_per_second,omitempty"`
900677e… noreply 90 Burst int `json:"burst,omitempty"`
900677e… noreply 91 } `json:"rate_limit,omitempty"`
900677e… noreply 92 }
900677e… noreply 93
5ac549c… lmata 94 // Policies is the full mutable settings blob, persisted to policies.json.
5ac549c… lmata 95 type Policies struct {
6d94dfd… noreply 96 Behaviors []BehaviorConfig `json:"behaviors"`
6d94dfd… noreply 97 AgentPolicy AgentPolicy `json:"agent_policy"`
6d94dfd… noreply 98 Bridge BridgePolicy `json:"bridge"`
6d94dfd… noreply 99 Logging LoggingPolicy `json:"logging"`
6d94dfd… noreply 100 LLMBackends []PolicyLLMBackend `json:"llm_backends,omitempty"`
6d94dfd… noreply 101 ROETemplates []ROETemplate `json:"roe_templates,omitempty"`
6d94dfd… noreply 102 OnJoinMessages map[string]string `json:"on_join_messages,omitempty"` // channel → message template
5ac549c… lmata 103 }
5ac549c… lmata 104
5ac549c… lmata 105 // defaultBehaviors lists every built-in bot with conservative defaults (disabled).
5ac549c… lmata 106 var defaultBehaviors = []BehaviorConfig{
5ac549c… lmata 107 {
5ac549c… lmata 108 ID: "auditbot",
5ac549c… lmata 109 Name: "Auditor",
5ac549c… lmata 110 Description: "Immutable append-only audit trail of agent actions and credential lifecycle events.",
5ac549c… lmata 111 Nick: "auditbot",
5ac549c… lmata 112 JoinAllChannels: true,
5ac549c… lmata 113 },
5ac549c… lmata 114 {
5ac549c… lmata 115 ID: "scribe",
5ac549c… lmata 116 Name: "Scribe",
5ac549c… lmata 117 Description: "Records all channel messages to a structured log store.",
5ac549c… lmata 118 Nick: "scribe",
5ac549c… lmata 119 JoinAllChannels: true,
5ac549c… lmata 120 },
5ac549c… lmata 121 {
3420a83… lmata 122 ID: "herald",
3420a83… lmata 123 Name: "Herald",
3420a83… lmata 124 Description: "Routes event notifications from external systems to IRC channels.",
3420a83… lmata 125 Nick: "herald",
3420a83… lmata 126 JoinAllChannels: true,
5ac549c… lmata 127 },
5ac549c… lmata 128 {
5ac549c… lmata 129 ID: "oracle",
5ac549c… lmata 130 Name: "Oracle",
5ac549c… lmata 131 Description: "On-demand channel summarisation via DM using an LLM.",
5ac549c… lmata 132 Nick: "oracle",
5ac549c… lmata 133 JoinAllChannels: true,
5ac549c… lmata 134 },
5ac549c… lmata 135 {
5ac549c… lmata 136 ID: "warden",
5ac549c… lmata 137 Name: "Warden",
5ac549c… lmata 138 Description: "Enforces channel moderation — detects floods and malformed messages, escalates warn → mute → kick.",
5ac549c… lmata 139 Nick: "warden",
5ac549c… lmata 140 JoinAllChannels: true,
5ac549c… lmata 141 },
5ac549c… lmata 142 {
5ac549c… lmata 143 ID: "scroll",
5ac549c… lmata 144 Name: "Scroll",
5ac549c… lmata 145 Description: "Replays channel history to users via DM on request.",
5ac549c… lmata 146 Nick: "scroll",
5ac549c… lmata 147 JoinAllChannels: true,
5ac549c… lmata 148 },
5ac549c… lmata 149 {
5ac549c… lmata 150 ID: "systembot",
5ac549c… lmata 151 Name: "Systembot",
5ac549c… lmata 152 Description: "Logs IRC system events (joins, parts, quits, mode changes) to a store.",
5ac549c… lmata 153 Nick: "systembot",
5ac549c… lmata 154 JoinAllChannels: true,
5ac549c… lmata 155 },
5ac549c… lmata 156 {
5ac549c… lmata 157 ID: "snitch",
5ac549c… lmata 158 Name: "Snitch",
5ac549c… lmata 159 Description: "Watches for erratic behaviour and alerts operators via DM or a dedicated channel.",
5ac549c… lmata 160 Nick: "snitch",
5ac549c… lmata 161 JoinAllChannels: true,
5ac549c… lmata 162 },
5ac549c… lmata 163 {
5ac549c… lmata 164 ID: "sentinel",
5ac549c… lmata 165 Name: "Sentinel",
5ac549c… lmata 166 Description: "LLM-powered channel observer. Detects policy violations and posts structured incident reports to a mod channel. Never takes enforcement action.",
5ac549c… lmata 167 Nick: "sentinel",
5ac549c… lmata 168 JoinAllChannels: true,
5ac549c… lmata 169 },
5ac549c… lmata 170 {
5ac549c… lmata 171 ID: "steward",
5ac549c… lmata 172 Name: "Steward",
5ac549c… lmata 173 Description: "Acts on sentinel incident reports — issues warnings, mutes, or kicks based on severity. Operators can also issue direct commands via DM.",
5ac549c… lmata 174 Nick: "steward",
5ac549c… lmata 175 JoinAllChannels: true,
6d94dfd… noreply 176 },
039edb2… noreply 177 {
039edb2… noreply 178 ID: "shepherd",
039edb2… noreply 179 Name: "Shepherd",
039edb2… noreply 180 Description: "Goal-directed agent coordinator. Assigns work, tracks progress, checks in on agents, generates plans using LLM. Configurable with any LLM provider.",
039edb2… noreply 181 Nick: "shepherd",
039edb2… noreply 182 },
6d94dfd… noreply 183 }
6d94dfd… noreply 184
6d94dfd… noreply 185 // BotCommand describes a single command a bot responds to.
6d94dfd… noreply 186 type BotCommand struct {
6d94dfd… noreply 187 Command string `json:"command"`
6d94dfd… noreply 188 Usage string `json:"usage"`
6d94dfd… noreply 189 Description string `json:"description"`
6d94dfd… noreply 190 }
6d94dfd… noreply 191
6d94dfd… noreply 192 // botCommands maps bot ID to its available commands.
6d94dfd… noreply 193 var botCommands = map[string][]BotCommand{
6d94dfd… noreply 194 "oracle": {
6d94dfd… noreply 195 {Command: "summarize", Usage: "summarize #channel [last=N] [format=toon|json]", Description: "Summarize recent channel activity using an LLM."},
6d94dfd… noreply 196 },
6d94dfd… noreply 197 "scroll": {
6d94dfd… noreply 198 {Command: "replay", Usage: "replay #channel [last=N] [since=<unix_ms>]", Description: "Replay recent channel history via DM."},
6d94dfd… noreply 199 },
6d94dfd… noreply 200 "steward": {
6d94dfd… noreply 201 {Command: "mute", Usage: "mute <nick> [duration]", Description: "Mute a nick in the current channel."},
6d94dfd… noreply 202 {Command: "unmute", Usage: "unmute <nick>", Description: "Remove mute from a nick."},
6d94dfd… noreply 203 {Command: "kick", Usage: "kick <nick> [reason]", Description: "Kick a nick from the current channel."},
6d94dfd… noreply 204 {Command: "warn", Usage: "warn <nick> <message>", Description: "Send a warning notice to a nick."},
6d94dfd… noreply 205 },
6d94dfd… noreply 206 "warden": {
6d94dfd… noreply 207 {Command: "status", Usage: "status", Description: "Show warden rate-limit status for all tracked nicks."},
6d94dfd… noreply 208 },
6d94dfd… noreply 209 "snitch": {
6d94dfd… noreply 210 {Command: "status", Usage: "status", Description: "Show snitch monitoring status and alert history."},
6d94dfd… noreply 211 },
6d94dfd… noreply 212 "herald": {
6d94dfd… noreply 213 {Command: "announce", Usage: "announce #channel <message>", Description: "Post an announcement to a channel."},
039edb2… noreply 214 },
039edb2… noreply 215 "shepherd": {
039edb2… noreply 216 {Command: "goal", Usage: "GOAL <description>", Description: "Set a goal for the current channel."},
039edb2… noreply 217 {Command: "goals", Usage: "GOALS", Description: "List all active goals."},
039edb2… noreply 218 {Command: "done", Usage: "DONE <goal-id>", Description: "Mark a goal as completed."},
039edb2… noreply 219 {Command: "status", Usage: "STATUS", Description: "Report progress on current goals (LLM-enhanced)."},
039edb2… noreply 220 {Command: "assign", Usage: "ASSIGN <nick> <task>", Description: "Manually assign a task to an agent."},
039edb2… noreply 221 {Command: "checkin", Usage: "CHECKIN", Description: "Trigger a check-in round with assigned agents."},
039edb2… noreply 222 {Command: "plan", Usage: "PLAN", Description: "Generate a work plan from goals using LLM."},
0e78954… lmata 223 },
5ac549c… lmata 224 }
5ac549c… lmata 225
0e78954… lmata 226 // PolicyStore persists Policies to a JSON file or database.
5ac549c… lmata 227 type PolicyStore struct {
5ac549c… lmata 228 mu sync.RWMutex
5ac549c… lmata 229 path string
5ac549c… lmata 230 data Policies
5ac549c… lmata 231 defaultBridgeTTLMinutes int
5ac549c… lmata 232 onChange func(Policies)
0e78954… lmata 233 db *store.Store // when non-nil, supersedes path
5ac549c… lmata 234 }
5ac549c… lmata 235
5ac549c… lmata 236 func NewPolicyStore(path string, defaultBridgeTTLMinutes int) (*PolicyStore, error) {
5ac549c… lmata 237 if defaultBridgeTTLMinutes <= 0 {
5ac549c… lmata 238 defaultBridgeTTLMinutes = 5
5ac549c… lmata 239 }
5ac549c… lmata 240 ps := &PolicyStore{
5ac549c… lmata 241 path: path,
5ac549c… lmata 242 defaultBridgeTTLMinutes: defaultBridgeTTLMinutes,
5ac549c… lmata 243 }
5ac549c… lmata 244 ps.data.Behaviors = defaultBehaviors
5ac549c… lmata 245 ps.data.Bridge.WebUserTTLMinutes = defaultBridgeTTLMinutes
5ac549c… lmata 246 if err := ps.load(); err != nil {
5ac549c… lmata 247 return nil, err
5ac549c… lmata 248 }
5ac549c… lmata 249 return ps, nil
5ac549c… lmata 250 }
5ac549c… lmata 251
5ac549c… lmata 252 func (ps *PolicyStore) load() error {
5ac549c… lmata 253 raw, err := os.ReadFile(ps.path)
5ac549c… lmata 254 if os.IsNotExist(err) {
5ac549c… lmata 255 return nil
5ac549c… lmata 256 }
5ac549c… lmata 257 if err != nil {
5ac549c… lmata 258 return fmt.Errorf("policies: read %s: %w", ps.path, err)
5ac549c… lmata 259 }
0e78954… lmata 260 return ps.applyRaw(raw)
0e78954… lmata 261 }
0e78954… lmata 262
0e78954… lmata 263 // SetStore switches the policy store to database-backed persistence. The
0e78954… lmata 264 // current in-memory defaults are merged with any saved policies in the store.
0e78954… lmata 265 func (ps *PolicyStore) SetStore(db *store.Store) error {
0e78954… lmata 266 raw, err := db.PolicyGet()
0e78954… lmata 267 if err != nil {
0e78954… lmata 268 return fmt.Errorf("policies: load from db: %w", err)
0e78954… lmata 269 }
0e78954… lmata 270 ps.mu.Lock()
0e78954… lmata 271 defer ps.mu.Unlock()
0e78954… lmata 272 ps.db = db
0e78954… lmata 273 if raw == nil {
0e78954… lmata 274 return nil // no saved policies yet; keep defaults
0e78954… lmata 275 }
0e78954… lmata 276 return ps.applyRaw(raw)
0e78954… lmata 277 }
0e78954… lmata 278
0e78954… lmata 279 // applyRaw merges a JSON blob into the in-memory policy state.
0e78954… lmata 280 // Caller must hold ps.mu if called after initialisation.
0e78954… lmata 281 func (ps *PolicyStore) applyRaw(raw []byte) error {
5ac549c… lmata 282 var p Policies
5ac549c… lmata 283 if err := json.Unmarshal(raw, &p); err != nil {
5ac549c… lmata 284 return fmt.Errorf("policies: parse: %w", err)
5ac549c… lmata 285 }
5ac549c… lmata 286 ps.normalize(&p)
5ac549c… lmata 287 // Merge saved behaviors over defaults so new built-ins appear automatically.
5ac549c… lmata 288 saved := make(map[string]BehaviorConfig, len(p.Behaviors))
5ac549c… lmata 289 for _, b := range p.Behaviors {
5ac549c… lmata 290 saved[b.ID] = b
5ac549c… lmata 291 }
5ac549c… lmata 292 for i, def := range ps.data.Behaviors {
5ac549c… lmata 293 if sv, ok := saved[def.ID]; ok {
5ac549c… lmata 294 ps.data.Behaviors[i] = sv
5ac549c… lmata 295 }
5ac549c… lmata 296 }
5ac549c… lmata 297 ps.data.AgentPolicy = p.AgentPolicy
5ac549c… lmata 298 ps.data.Bridge = p.Bridge
5ac549c… lmata 299 ps.data.Logging = p.Logging
5ac549c… lmata 300 ps.data.LLMBackends = p.LLMBackends
6d94dfd… noreply 301 ps.data.ROETemplates = p.ROETemplates
6d94dfd… noreply 302 ps.data.OnJoinMessages = p.OnJoinMessages
5ac549c… lmata 303 return nil
5ac549c… lmata 304 }
5ac549c… lmata 305
5ac549c… lmata 306 func (ps *PolicyStore) save() error {
5ac549c… lmata 307 raw, err := json.MarshalIndent(ps.data, "", " ")
5ac549c… lmata 308 if err != nil {
5ac549c… lmata 309 return err
0e78954… lmata 310 }
0e78954… lmata 311 if ps.db != nil {
0e78954… lmata 312 return ps.db.PolicySet(raw)
5ac549c… lmata 313 }
5ac549c… lmata 314 return os.WriteFile(ps.path, raw, 0600)
5ac549c… lmata 315 }
5ac549c… lmata 316
5ac549c… lmata 317 func (ps *PolicyStore) Get() Policies {
5ac549c… lmata 318 ps.mu.RLock()
5ac549c… lmata 319 defer ps.mu.RUnlock()
5ac549c… lmata 320 return ps.data
5ac549c… lmata 321 }
5ac549c… lmata 322
5ac549c… lmata 323 // OnChange registers a callback invoked (in a new goroutine) after each
5ac549c… lmata 324 // successful Set(). The callback receives the new Policies snapshot.
5ac549c… lmata 325 func (ps *PolicyStore) OnChange(fn func(Policies)) {
5ac549c… lmata 326 ps.mu.Lock()
5ac549c… lmata 327 defer ps.mu.Unlock()
5ac549c… lmata 328 ps.onChange = fn
5ac549c… lmata 329 }
5ac549c… lmata 330
5ac549c… lmata 331 func (ps *PolicyStore) Set(p Policies) error {
5ac549c… lmata 332 ps.mu.Lock()
5ac549c… lmata 333 defer ps.mu.Unlock()
5ac549c… lmata 334 ps.normalize(&p)
5ac549c… lmata 335 ps.data = p
5ac549c… lmata 336 if err := ps.save(); err != nil {
5ac549c… lmata 337 return err
5ac549c… lmata 338 }
5ac549c… lmata 339 if ps.onChange != nil {
5ac549c… lmata 340 snap := ps.data
5ac549c… lmata 341 fn := ps.onChange
5ac549c… lmata 342 go fn(snap)
5ac549c… lmata 343 }
5ac549c… lmata 344 return nil
5ac549c… lmata 345 }
5ac549c… lmata 346
c45e13f… lmata 347 // Merge applies a partial Policies update over the current state. Only
c45e13f… lmata 348 // non-zero fields in the patch overwrite existing values. Behaviors are
c45e13f… lmata 349 // merged by ID — existing behaviors keep their defaults for fields not
c45e13f… lmata 350 // present in the patch.
c45e13f… lmata 351 func (ps *PolicyStore) Merge(patch Policies) error {
c45e13f… lmata 352 ps.mu.Lock()
c45e13f… lmata 353 defer ps.mu.Unlock()
c45e13f… lmata 354
c45e13f… lmata 355 if len(patch.Behaviors) > 0 {
c45e13f… lmata 356 incoming := make(map[string]BehaviorConfig, len(patch.Behaviors))
c45e13f… lmata 357 for _, b := range patch.Behaviors {
c45e13f… lmata 358 incoming[b.ID] = b
c45e13f… lmata 359 }
c45e13f… lmata 360 for i, existing := range ps.data.Behaviors {
c45e13f… lmata 361 if patched, ok := incoming[existing.ID]; ok {
c45e13f… lmata 362 // Merge: keep existing defaults, overlay patch fields.
c45e13f… lmata 363 if patched.Name != "" {
c45e13f… lmata 364 existing.Name = patched.Name
c45e13f… lmata 365 }
c45e13f… lmata 366 if patched.Description != "" {
c45e13f… lmata 367 existing.Description = patched.Description
c45e13f… lmata 368 }
c45e13f… lmata 369 if patched.Nick != "" {
c45e13f… lmata 370 existing.Nick = patched.Nick
c45e13f… lmata 371 }
c45e13f… lmata 372 existing.Enabled = patched.Enabled
c45e13f… lmata 373 existing.JoinAllChannels = patched.JoinAllChannels
c45e13f… lmata 374 if patched.ExcludeChannels != nil {
c45e13f… lmata 375 existing.ExcludeChannels = patched.ExcludeChannels
c45e13f… lmata 376 }
c45e13f… lmata 377 if patched.RequiredChannels != nil {
c45e13f… lmata 378 existing.RequiredChannels = patched.RequiredChannels
c45e13f… lmata 379 }
c45e13f… lmata 380 if patched.Config != nil {
c45e13f… lmata 381 existing.Config = patched.Config
c45e13f… lmata 382 }
c45e13f… lmata 383 ps.data.Behaviors[i] = existing
c45e13f… lmata 384 }
c45e13f… lmata 385 }
c45e13f… lmata 386 }
c45e13f… lmata 387
c45e13f… lmata 388 // Merge agent_policy if any field is set.
c45e13f… lmata 389 if patch.AgentPolicy.CheckinChannel != "" || patch.AgentPolicy.RequireCheckin || patch.AgentPolicy.RequiredChannels != nil {
c45e13f… lmata 390 if patch.AgentPolicy.CheckinChannel != "" {
c45e13f… lmata 391 ps.data.AgentPolicy.CheckinChannel = patch.AgentPolicy.CheckinChannel
c45e13f… lmata 392 }
c45e13f… lmata 393 ps.data.AgentPolicy.RequireCheckin = patch.AgentPolicy.RequireCheckin
c45e13f… lmata 394 if patch.AgentPolicy.RequiredChannels != nil {
c45e13f… lmata 395 ps.data.AgentPolicy.RequiredChannels = patch.AgentPolicy.RequiredChannels
c45e13f… lmata 396 }
c45e13f… lmata 397 }
c45e13f… lmata 398
c45e13f… lmata 399 // Merge bridge if set.
c45e13f… lmata 400 if patch.Bridge.WebUserTTLMinutes > 0 {
c45e13f… lmata 401 ps.data.Bridge.WebUserTTLMinutes = patch.Bridge.WebUserTTLMinutes
c45e13f… lmata 402 }
c45e13f… lmata 403
c45e13f… lmata 404 // Merge logging if any field is set.
c45e13f… lmata 405 if patch.Logging.Dir != "" || patch.Logging.Enabled {
c45e13f… lmata 406 ps.data.Logging = patch.Logging
c45e13f… lmata 407 }
c45e13f… lmata 408
c45e13f… lmata 409 // Merge LLM backends if provided.
c45e13f… lmata 410 if patch.LLMBackends != nil {
c45e13f… lmata 411 ps.data.LLMBackends = patch.LLMBackends
6d94dfd… noreply 412 }
6d94dfd… noreply 413
6d94dfd… noreply 414 // Merge ROE templates if provided.
6d94dfd… noreply 415 if patch.ROETemplates != nil {
6d94dfd… noreply 416 ps.data.ROETemplates = patch.ROETemplates
6d94dfd… noreply 417 }
6d94dfd… noreply 418
6d94dfd… noreply 419 // Merge on-join messages if provided.
6d94dfd… noreply 420 if patch.OnJoinMessages != nil {
6d94dfd… noreply 421 ps.data.OnJoinMessages = patch.OnJoinMessages
c45e13f… lmata 422 }
c45e13f… lmata 423
c45e13f… lmata 424 ps.normalize(&ps.data)
c45e13f… lmata 425 if err := ps.save(); err != nil {
c45e13f… lmata 426 return err
c45e13f… lmata 427 }
c45e13f… lmata 428 if ps.onChange != nil {
c45e13f… lmata 429 snap := ps.data
c45e13f… lmata 430 fn := ps.onChange
c45e13f… lmata 431 go fn(snap)
c45e13f… lmata 432 }
c45e13f… lmata 433 return nil
c45e13f… lmata 434 }
c45e13f… lmata 435
5ac549c… lmata 436 func (ps *PolicyStore) normalize(p *Policies) {
5ac549c… lmata 437 if p.Bridge.WebUserTTLMinutes <= 0 {
5ac549c… lmata 438 p.Bridge.WebUserTTLMinutes = ps.defaultBridgeTTLMinutes
5ac549c… lmata 439 }
5ac549c… lmata 440 }
5ac549c… lmata 441
5ac549c… lmata 442 // --- HTTP handlers ---
5ac549c… lmata 443
5ac549c… lmata 444 func (s *Server) handleGetPolicies(w http.ResponseWriter, r *http.Request) {
5ac549c… lmata 445 writeJSON(w, http.StatusOK, s.policies.Get())
5ac549c… lmata 446 }
5ac549c… lmata 447
5ac549c… lmata 448 func (s *Server) handlePutPolicies(w http.ResponseWriter, r *http.Request) {
5ac549c… lmata 449 var p Policies
5ac549c… lmata 450 if err := json.NewDecoder(r.Body).Decode(&p); err != nil {
5ac549c… lmata 451 writeError(w, http.StatusBadRequest, "invalid request body")
5ac549c… lmata 452 return
5ac549c… lmata 453 }
5ac549c… lmata 454 if err := s.policies.Set(p); err != nil {
5ac549c… lmata 455 s.log.Error("save policies", "err", err)
c45e13f… lmata 456 writeError(w, http.StatusInternalServerError, "save failed")
c45e13f… lmata 457 return
c45e13f… lmata 458 }
c45e13f… lmata 459 writeJSON(w, http.StatusOK, s.policies.Get())
c45e13f… lmata 460 }
c45e13f… lmata 461
c45e13f… lmata 462 func (s *Server) handlePatchPolicies(w http.ResponseWriter, r *http.Request) {
c45e13f… lmata 463 var patch Policies
c45e13f… lmata 464 if err := json.NewDecoder(r.Body).Decode(&patch); err != nil {
c45e13f… lmata 465 writeError(w, http.StatusBadRequest, "invalid request body")
c45e13f… lmata 466 return
c45e13f… lmata 467 }
c45e13f… lmata 468 if err := s.policies.Merge(patch); err != nil {
c45e13f… lmata 469 s.log.Error("merge policies", "err", err)
5ac549c… lmata 470 writeError(w, http.StatusInternalServerError, "save failed")
5ac549c… lmata 471 return
5ac549c… lmata 472 }
5ac549c… lmata 473 writeJSON(w, http.StatusOK, s.policies.Get())
5ac549c… lmata 474 }

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button