package config import ( "fmt" "strconv" "strings" "time" ) // Config is the full harness.toml surface. Defaults live in Default(); // apply() overlays the parsed document; Validate() enforces invariants. type Config struct { Vertical string WorkRoot string ReportDir string Loop LoopConfig Redmine RedmineConfig LiteLLM LiteLLMConfig Models ModelsConfig Bash BashConfig Demo DemoConfig Events EventsConfig Serve ServeConfig KeyProxy KeyProxyConfig Gitea GiteaConfig Quota QuotaConfig Resources ResourcesConfig } // LoopConfig is both the per-turn bound and the `harness loop` daemon // cadence + state location. type LoopConfig struct { MaxRounds int PollIntervalSecs int // loop: Redmine scan interval (default 120) StateDir string // loop: append-only loop.jsonl + dedup index } type RedmineConfig struct { URL string KeyRef string ScopeQuery string ScopeQueryID int ClassField string DefaultClass string Limit int // StatusMap: issue status name -> status name to set after a REPORT // lands (loop path only; e.g. [redmine.status_map] "In Progress" = // "Done"). Empty map = leave status alone. StatusMap map[string]string } type LiteLLMConfig struct { BaseURL string KeyRef string TimeoutSecs int MaxRetries int } type ModelsConfig struct { // Tiers maps tier aliases (mopac-study, ...) to concrete proxy models. Tiers map[string]string // Classes maps task classes (study, code, ...) to tier aliases. Classes map[string]string // DefaultTier is used when a task carries no class. DefaultTier string } type BashConfig struct { Enabled bool Allow []string Deny []string DefaultAllow bool TimeoutSecs int MaxOutputBytes int } type DemoConfig struct { ID string Subject string Prompt string Class string } // ServeConfig is the `harness serve` OpenAI-compatible front door (the // OpenWebUI connection). Own port — coexists with [events]. type ServeConfig struct { Listen string // bind address (default ":8090"; publish via docker -p) VKeyRef string // bearer vkey ref; the key OWUI connections present EnabledModels []string // optional subset of the catalog (mopac-); empty = all classes } // KeyProxyConfig is the ukrrs/mopac-keyproxy resolve hop: mpk: key refs // POST here. All hosts/paths live in this config, never in code. type KeyProxyConfig struct { URL string // hop base url (e.g. http://127.0.0.1:8082) TokenRef string // local ref (env:/file:/literal:) holding the bearer token CacheTTLSecs int // in-memory mpk resolution cache (default 60) } // GiteaConfig is the optional REPORT-commit step (off by default). When // commit_reports is true the loop commits each REPORT file to the // configured repo right after writing it. type GiteaConfig struct { URL string KeyRef string Owner string Repo string Branch string CommitReports bool } // QuotaConfig is the z.ai coding-plan quota gate: bucket limits, polling, // back-pressure thresholds, the peak-hour schedule, and the optional // shared-state hop (redis container) for multi-instance coordination. // The quota gate NEVER hard-fails the loop; when it cannot see quota state // it degrades to permissive (allow) with a logged caveat. type QuotaConfig struct { Enabled bool // Account labels this instance's z.ai plan in the shared state so the // nine harness instances across two hosts do not cross-contaminate. Account string // Plan credit buckets (docs: Lite 2k/10k, Pro 12k/60k, Max 28k/140k). Plan5hCredits float64 PlanWeeklyCredits float64 // UsageURL + KeyRef: the z.ai usage/limits endpoint (bearer auth; the // key is a ref, resolved at call time, never logged). Empty = no // upstream polling; the gate runs on locally estimated consumption. UsageURL string KeyRef string PollIntervalSecs int // Back-pressure thresholds, percent of bucket used. defer_at: heavy // classes defer, flash-tier continue. block_at: everything defers. DeferAtPct float64 BlockAtPct float64 // Peak window (z.ai charges 3x-ish during peak; off-peak is 50% off). // Defaults verified against the z.ai docs: peak = Mon-Fri 14:00-18:00 // Singapore (UTC+8) == 01:00-05:00 America/Chicago in winter. PeakStart string // "HH:MM" in Timezone PeakEnd string Timezone string // IANA name; default America/Chicago (CST) PeakWeekdaysOnly bool // PeakClasses: task classes allowed to run inside the peak window // (the LLM-lite / flash tier); every other class defers with a reason. PeakClasses []string // RedisURL: shared quota state for all instances of this account // (docker container; no host packages). Empty = local-only estimate. RedisURL string } // ResourcesConfig is the read-only system resource gate (Redmine 491): // load average, memory available, work-root disk free, IO pressure. The // loop defers dispatch while the host is busy; enforcement via cgroups is // a deploy-time concern (see the runbook in README). type ResourcesConfig struct { Enabled bool MaxLoadAvg float64 // 1m load average MinMemAvailableMB float64 MinDiskFreeMB float64 // work_root filesystem MaxIODelayPct float64 // /proc/pressure/io "some avg60" percent // ProcRoot/SysRoot are seams for tests; default /proc and /sys. ProcRoot string SysRoot string } // EventsConfig is the `harness events` webhook receiver surface. type EventsConfig struct { Listen string // bind address (publish via docker -p) StateDir string // append-only events.jsonl + dedup index Redmine EventSourceConfig Discourse EventSourceConfig Gitea EventSourceConfig } // EventSourceConfig is one provider's webhook verification setup. Secrets // are refs only (env:/file:/literal:); values never live in this file, are // never logged, and never reach the event log. type EventSourceConfig struct { SecretRef string SecretHeader string // shared-secret header name; gitea ignores it (HMAC) } // Default returns the built-in defaults for every field. func Default() *Config { return &Config{ WorkRoot: ".", ReportDir: "reports", Loop: LoopConfig{ MaxRounds: 8, PollIntervalSecs: 120, StateDir: "state/loop", }, Redmine: RedmineConfig{ ClassField: "Class", DefaultClass: "primary", Limit: 50, StatusMap: map[string]string{}, }, LiteLLM: LiteLLMConfig{TimeoutSecs: 120, MaxRetries: 2}, Models: ModelsConfig{ Tiers: map[string]string{}, Classes: map[string]string{}, DefaultTier: "mopac-primary", }, Bash: BashConfig{Enabled: true, TimeoutSecs: 60, MaxOutputBytes: 100_000}, Events: EventsConfig{ Listen: ":4100", StateDir: "state/events", Redmine: EventSourceConfig{ SecretHeader: "X-Redmine-Webhook-Secret", }, Discourse: EventSourceConfig{ SecretHeader: "X-Discourse-Webhook-Secret", }, }, Serve: ServeConfig{Listen: ":8090"}, KeyProxy: KeyProxyConfig{CacheTTLSecs: 60}, Quota: QuotaConfig{ Enabled: false, // opt-in per instance until verified live Plan5hCredits: 28000, // Max plan PlanWeeklyCredits: 140000, PollIntervalSecs: 300, DeferAtPct: 85, BlockAtPct: 95, PeakStart: "01:00", PeakEnd: "05:00", Timezone: "America/Chicago", PeakWeekdaysOnly: true, PeakClasses: []string{"study", "read"}, }, Resources: ResourcesConfig{ Enabled: false, MaxLoadAvg: 6.0, MinMemAvailableMB: 2048, MinDiskFreeMB: 5120, MaxIODelayPct: 90.0, ProcRoot: "/proc", SysRoot: "/sys", }, Demo: DemoConfig{ ID: "demo-1", Subject: "MVP demo: GLM self-description", Prompt: "tell me about yourself", Class: "primary", }, } } func (c *Config) apply(doc TOMLDoc) error { if v, ok := doc.String("vertical"); ok { c.Vertical = v } if v, ok := doc.String("work_root"); ok { c.WorkRoot = v } if v, ok := doc.String("report_dir"); ok { c.ReportDir = v } if v, ok := doc.Table("loop").Int("max_rounds"); ok { c.Loop.MaxRounds = int(v) } if v, ok := doc.Table("loop").Int("poll_interval_secs"); ok { c.Loop.PollIntervalSecs = int(v) } if v, ok := doc.Table("loop").String("state_dir"); ok { c.Loop.StateDir = v } rm := doc.Table("redmine") if v, ok := rm.String("url"); ok { c.Redmine.URL = v } if v, ok := rm.String("key_ref"); ok { c.Redmine.KeyRef = v } if v, ok := rm.String("scope_query"); ok { c.Redmine.ScopeQuery = v } if v, ok := rm.Int("scope_query_id"); ok { c.Redmine.ScopeQueryID = int(v) } if v, ok := rm.String("class_field"); ok { c.Redmine.ClassField = v } if v, ok := rm.String("default_class"); ok { c.Redmine.DefaultClass = v } if v, ok := rm.Int("limit"); ok { c.Redmine.Limit = int(v) } for _, k := range doc.Table("redmine", "status_map").Keys() { if v, ok := doc.Table("redmine", "status_map").String(k); ok { if c.Redmine.StatusMap == nil { c.Redmine.StatusMap = map[string]string{} } c.Redmine.StatusMap[k] = v } } lt := doc.Table("litellm") if v, ok := lt.String("base_url"); ok { c.LiteLLM.BaseURL = v } if v, ok := lt.String("key_ref"); ok { c.LiteLLM.KeyRef = v } if v, ok := lt.Int("timeout_secs"); ok { c.LiteLLM.TimeoutSecs = int(v) } if v, ok := lt.Int("max_retries"); ok { c.LiteLLM.MaxRetries = int(v) } md := doc.Table("models") for _, k := range md.Keys() { if k == "default_tier" { continue } if v, ok := md.String(k); ok { c.Models.Tiers[k] = v } } if v, ok := md.String("default_tier"); ok { c.Models.DefaultTier = v } cls := doc.Table("models", "classes") for _, k := range cls.Keys() { if v, ok := cls.String(k); ok { c.Models.Classes[k] = v } } bt := doc.Table("tools", "bash") if v, ok := bt.Bool("enabled"); ok { c.Bash.Enabled = v } if v, ok := bt.StringList("allow"); ok { c.Bash.Allow = v } if v, ok := bt.StringList("deny"); ok { c.Bash.Deny = v } if v, ok := bt.String("default"); ok { switch v { case "allow": c.Bash.DefaultAllow = true case "deny": c.Bash.DefaultAllow = false default: return fmt.Errorf("[tools.bash]: default must be \"allow\" or \"deny\", got %q", v) } } if v, ok := bt.Int("timeout_secs"); ok { c.Bash.TimeoutSecs = int(v) } if v, ok := bt.Int("max_output_bytes"); ok { c.Bash.MaxOutputBytes = int(v) } dm := doc.Table("demo") if v, ok := dm.String("id"); ok { c.Demo.ID = v } if v, ok := dm.String("subject"); ok { c.Demo.Subject = v } if v, ok := dm.String("prompt"); ok { c.Demo.Prompt = v } if v, ok := dm.String("class"); ok { c.Demo.Class = v } kp := doc.Table("keyproxy") if v, ok := kp.String("url"); ok { c.KeyProxy.URL = v } if v, ok := kp.String("token_ref"); ok { c.KeyProxy.TokenRef = v } if v, ok := kp.Int("cache_ttl_secs"); ok { c.KeyProxy.CacheTTLSecs = int(v) } gt := doc.Table("gitea") if v, ok := gt.String("url"); ok { c.Gitea.URL = v } if v, ok := gt.String("key_ref"); ok { c.Gitea.KeyRef = v } if v, ok := gt.String("owner"); ok { c.Gitea.Owner = v } if v, ok := gt.String("repo"); ok { c.Gitea.Repo = v } if v, ok := gt.String("branch"); ok { c.Gitea.Branch = v } if v, ok := gt.Bool("commit_reports"); ok { c.Gitea.CommitReports = v } ev := doc.Table("events") if v, ok := ev.String("listen"); ok { c.Events.Listen = v } if v, ok := ev.String("state_dir"); ok { c.Events.StateDir = v } applyEventSource(&c.Events.Redmine, doc.Table("events", "redmine")) applyEventSource(&c.Events.Discourse, doc.Table("events", "discourse")) applyEventSource(&c.Events.Gitea, doc.Table("events", "gitea")) sv := doc.Table("serve") if v, ok := sv.String("listen"); ok { c.Serve.Listen = v } if v, ok := sv.String("vkey_ref"); ok { c.Serve.VKeyRef = v } if v, ok := sv.StringList("enabled_models"); ok { c.Serve.EnabledModels = v } qt := doc.Table("quota") if v, ok := qt.Bool("enabled"); ok { c.Quota.Enabled = v } if v, ok := qt.String("account"); ok { c.Quota.Account = v } if v, ok := qt.Float("plan_5h_credits"); ok { c.Quota.Plan5hCredits = v } if v, ok := qt.Float("plan_weekly_credits"); ok { c.Quota.PlanWeeklyCredits = v } if v, ok := qt.String("usage_url"); ok { c.Quota.UsageURL = v } if v, ok := qt.String("key_ref"); ok { c.Quota.KeyRef = v } if v, ok := qt.Int("poll_interval_secs"); ok { c.Quota.PollIntervalSecs = int(v) } if v, ok := qt.Float("defer_at_pct"); ok { c.Quota.DeferAtPct = v } if v, ok := qt.Float("block_at_pct"); ok { c.Quota.BlockAtPct = v } if v, ok := qt.String("peak_start"); ok { c.Quota.PeakStart = v } if v, ok := qt.String("peak_end"); ok { c.Quota.PeakEnd = v } if v, ok := qt.String("timezone"); ok { c.Quota.Timezone = v } if v, ok := qt.Bool("peak_weekdays_only"); ok { c.Quota.PeakWeekdaysOnly = v } if v, ok := qt.StringList("peak_classes"); ok { c.Quota.PeakClasses = v } if v, ok := qt.String("redis_url"); ok { c.Quota.RedisURL = v } rs := doc.Table("resources") if v, ok := rs.Bool("enabled"); ok { c.Resources.Enabled = v } if v, ok := rs.Float("max_load_avg"); ok { c.Resources.MaxLoadAvg = v } if v, ok := rs.Float("min_mem_available_mb"); ok { c.Resources.MinMemAvailableMB = v } if v, ok := rs.Float("min_disk_free_mb"); ok { c.Resources.MinDiskFreeMB = v } if v, ok := rs.Float("max_io_delay_pct"); ok { c.Resources.MaxIODelayPct = v } if v, ok := rs.String("proc_root"); ok { c.Resources.ProcRoot = v } if v, ok := rs.String("sys_root"); ok { c.Resources.SysRoot = v } return nil } func applyEventSource(dst *EventSourceConfig, src TOMLDoc) { if v, ok := src.String("secret_ref"); ok { dst.SecretRef = v } if v, ok := src.String("secret_header"); ok { dst.SecretHeader = v } } // Validate enforces the invariants the conductor relies on. func (c *Config) Validate() error { if c.Vertical == "" { return fmt.Errorf("[core]: vertical is required") } if c.Loop.MaxRounds < 1 { return fmt.Errorf("[loop]: max_rounds must be >= 1") } if c.LiteLLM.BaseURL == "" { return fmt.Errorf("[litellm]: base_url is required") } if c.LiteLLM.KeyRef == "" { return fmt.Errorf("[litellm]: key_ref is required (env:NAME, file:PATH, or literal:VALUE)") } if err := CheckKeyRef(c.LiteLLM.KeyRef); err != nil { return fmt.Errorf("[litellm]: %w", err) } if c.LiteLLM.TimeoutSecs < 1 || c.LiteLLM.MaxRetries < 0 { return fmt.Errorf("[litellm]: bad timeout_secs/max_retries") } // Redmine is optional (demo-only configs); if any piece is set, the rest // of the minimum set must be too. rc := c.Redmine if rc.URL != "" || rc.KeyRef != "" || rc.ScopeQuery != "" || rc.ScopeQueryID != 0 { if rc.URL == "" { return fmt.Errorf("[redmine]: url is required when redmine is configured") } if rc.KeyRef == "" { return fmt.Errorf("[redmine]: key_ref is required when redmine is configured") } if err := CheckKeyRef(rc.KeyRef); err != nil { return fmt.Errorf("[redmine]: %w", err) } if rc.ScopeQuery == "" && rc.ScopeQueryID == 0 { return fmt.Errorf("[redmine]: scope_query or scope_query_id is required") } } if len(c.Models.Tiers) == 0 { return fmt.Errorf("[models]: at least one tier alias is required") } if _, ok := c.Models.Tiers[c.Models.DefaultTier]; !ok { return fmt.Errorf("[models]: default_tier %q has no entry in [models]", c.Models.DefaultTier) } for class, tier := range c.Models.Classes { if _, ok := c.Models.Tiers[tier]; !ok { return fmt.Errorf("[models.classes]: class %q points at unknown tier %q", class, tier) } } if c.Bash.TimeoutSecs < 1 || c.Bash.MaxOutputBytes < 1 { return fmt.Errorf("[tools.bash]: bad timeout_secs/max_output_bytes") } if c.Demo.Prompt == "" { return fmt.Errorf("[demo]: prompt is required") } // Events are optional (`once` configs need none); refs that ARE set must // be well-formed so the receiver fails at startup, not mid-webhook. for name, src := range map[string]EventSourceConfig{ "redmine": c.Events.Redmine, "discourse": c.Events.Discourse, "gitea": c.Events.Gitea, } { if src.SecretRef == "" { continue } if err := CheckKeyRef(src.SecretRef); err != nil { return fmt.Errorf("[events.%s]: %w", name, err) } } if c.Events.Listen == "" || c.Events.StateDir == "" { return fmt.Errorf("[events]: listen and state_dir must not be empty") } // Serve is optional (`once`/`loop` configs need none); a vkey that IS // set must be well-formed, and enabled_models entries must name real // catalog models (mopac-) so the front door fails at startup. if c.Serve.VKeyRef != "" { if err := CheckKeyRef(c.Serve.VKeyRef); err != nil { return fmt.Errorf("[serve]: %w", err) } } for _, m := range c.Serve.EnabledModels { class := strings.TrimPrefix(m, "mopac-") if !strings.HasPrefix(m, "mopac-") || class == "" || c.Models.Classes[class] == "" { return fmt.Errorf("[serve]: enabled_models entry %q is not servable (must be mopac- from [models.classes])", m) } } if c.Serve.Listen == "" { return fmt.Errorf("[serve]: listen must not be empty") } if c.Loop.PollIntervalSecs < 1 { return fmt.Errorf("[loop]: poll_interval_secs must be >= 1") } if c.Loop.StateDir == "" { return fmt.Errorf("[loop]: state_dir must not be empty") } // Keyproxy is optional; if any piece is set, url + token_ref must be. if c.KeyProxy.URL != "" || c.KeyProxy.TokenRef != "" { if c.KeyProxy.URL == "" { return fmt.Errorf("[keyproxy]: url is required when keyproxy is configured") } if c.KeyProxy.TokenRef == "" { return fmt.Errorf("[keyproxy]: token_ref is required when keyproxy is configured (the bearer token for /v1/resolve)") } if err := CheckKeyRef(c.KeyProxy.TokenRef); err != nil { return fmt.Errorf("[keyproxy]: %w", err) } if strings.HasPrefix(c.KeyProxy.TokenRef, "mpk:") { return fmt.Errorf("[keyproxy]: token_ref must be a local ref (env:/file:/literal:), not mpk:") } if c.KeyProxy.CacheTTLSecs < 1 { return fmt.Errorf("[keyproxy]: bad cache_ttl_secs") } } // Gitea REPORT commit is optional; when on, the minimum set must be set. if c.Gitea.CommitReports { if c.Gitea.URL == "" || c.Gitea.KeyRef == "" || c.Gitea.Owner == "" || c.Gitea.Repo == "" { return fmt.Errorf("[gitea]: url, key_ref, owner and repo are required when commit_reports = true") } if err := CheckKeyRef(c.Gitea.KeyRef); err != nil { return fmt.Errorf("[gitea]: %w", err) } } // The quota gate is optional; when on, buckets, thresholds and the peak // window must be coherent so the gate never divides by zero or wraps // nonsense windows. usage_url without key_ref is an error (bearer-only). if c.Quota.Enabled { if c.Quota.Plan5hCredits <= 0 || c.Quota.PlanWeeklyCredits <= 0 { return fmt.Errorf("[quota]: plan_5h_credits and plan_weekly_credits must be > 0") } if c.Quota.PollIntervalSecs < 1 { return fmt.Errorf("[quota]: poll_interval_secs must be >= 1") } if !(c.Quota.DeferAtPct > 0 && c.Quota.DeferAtPct < 100) || !(c.Quota.BlockAtPct > 0 && c.Quota.BlockAtPct <= 100) { return fmt.Errorf("[quota]: defer_at_pct and block_at_pct must be in (0,100]") } if c.Quota.DeferAtPct > c.Quota.BlockAtPct { return fmt.Errorf("[quota]: defer_at_pct (%.0f) must be <= block_at_pct (%.0f)", c.Quota.DeferAtPct, c.Quota.BlockAtPct) } if _, err := ParseHHMM(c.Quota.PeakStart); err != nil { return fmt.Errorf("[quota]: peak_start: %w", err) } if _, err := ParseHHMM(c.Quota.PeakEnd); err != nil { return fmt.Errorf("[quota]: peak_end: %w", err) } if _, err := time.LoadLocation(c.Quota.Timezone); err != nil { return fmt.Errorf("[quota]: unknown timezone %q", c.Quota.Timezone) } if c.Quota.Account == "" { return fmt.Errorf("[quota]: account is required when the quota gate is enabled (shared-state bucket label)") } if c.Quota.UsageURL != "" && c.Quota.KeyRef == "" { return fmt.Errorf("[quota]: key_ref is required when usage_url is set (bearer auth)") } if c.Quota.KeyRef != "" { if err := CheckKeyRef(c.Quota.KeyRef); err != nil { return fmt.Errorf("[quota]: %w", err) } } } // The resource gate is optional; when on, thresholds must be sane. if c.Resources.Enabled { if c.Resources.MaxLoadAvg <= 0 || c.Resources.MinMemAvailableMB <= 0 || c.Resources.MinDiskFreeMB <= 0 { return fmt.Errorf("[resources]: thresholds must be > 0") } if !(c.Resources.MaxIODelayPct > 0 && c.Resources.MaxIODelayPct <= 100) { return fmt.Errorf("[resources]: max_io_delay_pct must be in (0,100]") } } return nil } // ParseHHMM parses a wall-clock time of day, "HH:MM" (24h). func ParseHHMM(s string) (time.Duration, error) { parts := strings.Split(s, ":") if len(parts) != 2 || len(parts[0]) != 2 || len(parts[1]) != 2 { return 0, fmt.Errorf("want HH:MM, got %q", s) } h, err := strconv.Atoi(parts[0]) if err != nil || h < 0 || h > 23 { return 0, fmt.Errorf("bad hour in %q", s) } m, err := strconv.Atoi(parts[1]) if err != nil || m < 0 || m > 59 { return 0, fmt.Errorf("bad minute in %q", s) } return time.Duration(h)*time.Hour + time.Duration(m)*time.Minute, nil }