feat(escalate): align config schema with design doc

- Change EscalationConfig to use Routes map with action strings
- Rename severity "normal" to "medium" per design doc
- Move config from config/ to settings/escalation.json
- Add --source flag for escalation source tracking
- Add Source field to EscalationFields
- Add executeExternalActions() for email/sms/slack with warnings
- Add default escalation config creation in gt install
- Add comprehensive unit tests for config loading
- Update help text with correct severity levels and paths

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
mayor
2026-01-12 02:29:56 -08:00
committed by beads/crew/emma
parent b9ecb7b82e
commit 9779ae3190
7 changed files with 882 additions and 163 deletions

View File

@@ -1364,7 +1364,7 @@ func GetRigPrefix(townRoot, rigName string) string {
// EscalationConfigPath returns the standard path for escalation config in a town.
func EscalationConfigPath(townRoot string) string {
return filepath.Join(townRoot, "config", "escalation.json")
return filepath.Join(townRoot, "settings", "escalation.json")
}
// LoadEscalationConfig loads and validates an escalation configuration file.
@@ -1440,48 +1440,53 @@ func validateEscalationConfig(c *EscalationConfig) error {
}
// Initialize nil maps
if c.SeverityRoutes == nil {
c.SeverityRoutes = make(map[string]EscalationRoute)
if c.Routes == nil {
c.Routes = make(map[string][]string)
}
// Validate severity route keys
validSeverities := map[string]bool{
SeverityCritical: true,
SeverityHigh: true,
SeverityNormal: true,
SeverityLow: true,
}
for severity := range c.SeverityRoutes {
if !validSeverities[severity] {
return fmt.Errorf("%w: unknown severity '%s' (valid: critical, high, normal, low)", ErrMissingField, severity)
for severity := range c.Routes {
if !IsValidSeverity(severity) {
return fmt.Errorf("%w: unknown severity '%s' (valid: low, medium, high, critical)", ErrMissingField, severity)
}
}
// Validate max_reescalations is non-negative
if c.MaxReescalations < 0 {
return fmt.Errorf("%w: max_reescalations must be non-negative", ErrMissingField)
}
return nil
}
// GetStaleThreshold returns the stale threshold as a time.Duration.
// Returns 1 hour if not configured or invalid.
// Returns 4 hours if not configured or invalid.
func (c *EscalationConfig) GetStaleThreshold() time.Duration {
if c.StaleThreshold == "" {
return time.Hour
return 4 * time.Hour
}
d, err := time.ParseDuration(c.StaleThreshold)
if err != nil {
return time.Hour
return 4 * time.Hour
}
return d
}
// GetRouteForSeverity returns the escalation route for a given severity.
// Falls back to DefaultTarget if no specific route is configured.
func (c *EscalationConfig) GetRouteForSeverity(severity string) EscalationRoute {
if route, ok := c.SeverityRoutes[severity]; ok {
// GetRouteForSeverity returns the escalation route actions for a given severity.
// Falls back to ["bead", "mail:mayor"] if no specific route is configured.
func (c *EscalationConfig) GetRouteForSeverity(severity string) []string {
if route, ok := c.Routes[severity]; ok {
return route
}
// Fallback to default target
return EscalationRoute{
Targets: []string{c.DefaultTarget},
UseExternal: false,
}
// Fallback to default route
return []string{"bead", "mail:mayor"}
}
// GetMaxReescalations returns the maximum number of re-escalations allowed.
// Returns 2 if not configured.
func (c *EscalationConfig) GetMaxReescalations() int {
if c.MaxReescalations <= 0 {
return 2
}
return c.MaxReescalations
}

View File

@@ -1954,3 +1954,370 @@ func TestRoleAgentsRoundTrip(t *testing.T) {
}
})
}
// Escalation config tests
func TestEscalationConfigRoundTrip(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
original := &EscalationConfig{
Type: "escalation",
Version: CurrentEscalationVersion,
Routes: map[string][]string{
SeverityLow: {"bead"},
SeverityMedium: {"bead", "mail:mayor"},
SeverityHigh: {"bead", "mail:mayor", "email:human"},
SeverityCritical: {"bead", "mail:mayor", "email:human", "sms:human"},
},
Contacts: EscalationContacts{
HumanEmail: "test@example.com",
HumanSMS: "+15551234567",
},
StaleThreshold: "2h",
MaxReescalations: 3,
}
if err := SaveEscalationConfig(path, original); err != nil {
t.Fatalf("SaveEscalationConfig: %v", err)
}
loaded, err := LoadEscalationConfig(path)
if err != nil {
t.Fatalf("LoadEscalationConfig: %v", err)
}
if loaded.Type != original.Type {
t.Errorf("Type = %q, want %q", loaded.Type, original.Type)
}
if loaded.Version != original.Version {
t.Errorf("Version = %d, want %d", loaded.Version, original.Version)
}
if loaded.StaleThreshold != original.StaleThreshold {
t.Errorf("StaleThreshold = %q, want %q", loaded.StaleThreshold, original.StaleThreshold)
}
if loaded.MaxReescalations != original.MaxReescalations {
t.Errorf("MaxReescalations = %d, want %d", loaded.MaxReescalations, original.MaxReescalations)
}
if loaded.Contacts.HumanEmail != original.Contacts.HumanEmail {
t.Errorf("Contacts.HumanEmail = %q, want %q", loaded.Contacts.HumanEmail, original.Contacts.HumanEmail)
}
if loaded.Contacts.HumanSMS != original.Contacts.HumanSMS {
t.Errorf("Contacts.HumanSMS = %q, want %q", loaded.Contacts.HumanSMS, original.Contacts.HumanSMS)
}
// Check routes
for severity, actions := range original.Routes {
loadedActions := loaded.Routes[severity]
if len(loadedActions) != len(actions) {
t.Errorf("Routes[%s] len = %d, want %d", severity, len(loadedActions), len(actions))
continue
}
for i, action := range actions {
if loadedActions[i] != action {
t.Errorf("Routes[%s][%d] = %q, want %q", severity, i, loadedActions[i], action)
}
}
}
}
func TestEscalationConfigDefaults(t *testing.T) {
t.Parallel()
cfg := NewEscalationConfig()
if cfg.Type != "escalation" {
t.Errorf("Type = %q, want %q", cfg.Type, "escalation")
}
if cfg.Version != CurrentEscalationVersion {
t.Errorf("Version = %d, want %d", cfg.Version, CurrentEscalationVersion)
}
if cfg.StaleThreshold != "4h" {
t.Errorf("StaleThreshold = %q, want %q", cfg.StaleThreshold, "4h")
}
if cfg.MaxReescalations != 2 {
t.Errorf("MaxReescalations = %d, want %d", cfg.MaxReescalations, 2)
}
// Check default routes
if len(cfg.Routes) != 4 {
t.Errorf("Routes count = %d, want 4", len(cfg.Routes))
}
if len(cfg.Routes[SeverityLow]) != 1 || cfg.Routes[SeverityLow][0] != "bead" {
t.Errorf("Routes[low] = %v, want [bead]", cfg.Routes[SeverityLow])
}
if len(cfg.Routes[SeverityCritical]) != 4 {
t.Errorf("Routes[critical] len = %d, want 4", len(cfg.Routes[SeverityCritical]))
}
}
func TestEscalationConfigValidation(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
wantErr bool
errMsg string
}{
{
name: "valid config",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
Routes: map[string][]string{
SeverityLow: {"bead"},
},
},
wantErr: false,
},
{
name: "invalid type",
config: &EscalationConfig{
Type: "wrong-type",
Version: 1,
},
wantErr: true,
errMsg: "invalid config type",
},
{
name: "unsupported version",
config: &EscalationConfig{
Type: "escalation",
Version: 999,
},
wantErr: true,
errMsg: "unsupported config version",
},
{
name: "invalid stale threshold",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
StaleThreshold: "not-a-duration",
},
wantErr: true,
errMsg: "invalid stale_threshold",
},
{
name: "invalid severity key",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
Routes: map[string][]string{
"invalid-severity": {"bead"},
},
},
wantErr: true,
errMsg: "unknown severity",
},
{
name: "negative max reescalations",
config: &EscalationConfig{
Type: "escalation",
Version: 1,
MaxReescalations: -1,
},
wantErr: true,
errMsg: "max_reescalations must be non-negative",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := validateEscalationConfig(tt.config)
if tt.wantErr {
if err == nil {
t.Errorf("validateEscalationConfig() expected error containing %q, got nil", tt.errMsg)
} else if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("validateEscalationConfig() error = %v, want error containing %q", err, tt.errMsg)
}
} else {
if err != nil {
t.Errorf("validateEscalationConfig() unexpected error: %v", err)
}
}
})
}
}
func TestEscalationConfigGetStaleThreshold(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
expected time.Duration
}{
{
name: "default when empty",
config: &EscalationConfig{},
expected: 4 * time.Hour,
},
{
name: "2 hours",
config: &EscalationConfig{
StaleThreshold: "2h",
},
expected: 2 * time.Hour,
},
{
name: "30 minutes",
config: &EscalationConfig{
StaleThreshold: "30m",
},
expected: 30 * time.Minute,
},
{
name: "invalid duration falls back to default",
config: &EscalationConfig{
StaleThreshold: "invalid",
},
expected: 4 * time.Hour,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.GetStaleThreshold()
if got != tt.expected {
t.Errorf("GetStaleThreshold() = %v, want %v", got, tt.expected)
}
})
}
}
func TestEscalationConfigGetRouteForSeverity(t *testing.T) {
t.Parallel()
cfg := &EscalationConfig{
Routes: map[string][]string{
SeverityLow: {"bead"},
SeverityMedium: {"bead", "mail:mayor"},
},
}
tests := []struct {
severity string
expected []string
}{
{SeverityLow, []string{"bead"}},
{SeverityMedium, []string{"bead", "mail:mayor"}},
{SeverityHigh, []string{"bead", "mail:mayor"}}, // fallback for missing
{SeverityCritical, []string{"bead", "mail:mayor"}}, // fallback for missing
}
for _, tt := range tests {
t.Run(tt.severity, func(t *testing.T) {
got := cfg.GetRouteForSeverity(tt.severity)
if len(got) != len(tt.expected) {
t.Errorf("GetRouteForSeverity(%s) len = %d, want %d", tt.severity, len(got), len(tt.expected))
return
}
for i, action := range tt.expected {
if got[i] != action {
t.Errorf("GetRouteForSeverity(%s)[%d] = %q, want %q", tt.severity, i, got[i], action)
}
}
})
}
}
func TestEscalationConfigGetMaxReescalations(t *testing.T) {
t.Parallel()
tests := []struct {
name string
config *EscalationConfig
expected int
}{
{
name: "default when zero",
config: &EscalationConfig{},
expected: 2,
},
{
name: "custom value",
config: &EscalationConfig{
MaxReescalations: 5,
},
expected: 5,
},
{
name: "default when negative (should not happen after validation)",
config: &EscalationConfig{
MaxReescalations: -1,
},
expected: 2,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.config.GetMaxReescalations()
if got != tt.expected {
t.Errorf("GetMaxReescalations() = %d, want %d", got, tt.expected)
}
})
}
}
func TestLoadOrCreateEscalationConfig(t *testing.T) {
t.Parallel()
t.Run("creates default when not found", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
cfg, err := LoadOrCreateEscalationConfig(path)
if err != nil {
t.Fatalf("LoadOrCreateEscalationConfig: %v", err)
}
if cfg.Type != "escalation" {
t.Errorf("Type = %q, want %q", cfg.Type, "escalation")
}
if len(cfg.Routes) != 4 {
t.Errorf("Routes count = %d, want 4", len(cfg.Routes))
}
})
t.Run("loads existing config", func(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "settings", "escalation.json")
// Create a config first
original := &EscalationConfig{
Type: "escalation",
Version: 1,
StaleThreshold: "1h",
Routes: map[string][]string{
SeverityLow: {"bead"},
},
}
if err := SaveEscalationConfig(path, original); err != nil {
t.Fatalf("SaveEscalationConfig: %v", err)
}
// Load it
cfg, err := LoadOrCreateEscalationConfig(path)
if err != nil {
t.Fatalf("LoadOrCreateEscalationConfig: %v", err)
}
if cfg.StaleThreshold != "1h" {
t.Errorf("StaleThreshold = %q, want %q", cfg.StaleThreshold, "1h")
}
})
}
func TestEscalationConfigPath(t *testing.T) {
t.Parallel()
path := EscalationConfigPath("/home/user/gt")
expected := "/home/user/gt/settings/escalation.json"
if path != expected {
t.Errorf("EscalationConfigPath = %q, want %q", path, expected)
}
}

View File

@@ -789,70 +789,42 @@ func NewMessagingConfig() *MessagingConfig {
}
}
// EscalationConfig represents the escalation system configuration (config/escalation.json).
// EscalationConfig represents escalation routing configuration (settings/escalation.json).
// This defines severity-based routing for escalations to different channels.
type EscalationConfig struct {
Type string `json:"type"` // "escalation"
Version int `json:"version"` // schema version
// Enabled controls whether the escalation system is active.
Enabled bool `json:"enabled"`
// Routes maps severity levels to action lists.
// Actions are executed in order for each escalation.
// Action formats:
// - "bead" → Create escalation bead (always first, implicit)
// - "mail:<target>" → Send gt mail to target (e.g., "mail:mayor")
// - "email:human" → Send email to contacts.human_email
// - "sms:human" → Send SMS to contacts.human_sms
// - "slack" → Post to contacts.slack_webhook
// - "log" → Write to escalation log file
Routes map[string][]string `json:"routes"`
// DefaultTarget is the address to send escalations when no severity-specific target is set.
// Example: "mayor/"
DefaultTarget string `json:"default_target,omitempty"`
// Contacts contains contact information for external notification actions.
Contacts EscalationContacts `json:"contacts"`
// SeverityRoutes maps severity levels to notification targets.
// Keys: "critical", "high", "normal", "low"
// Values: EscalationRoute with target addresses and optional external channels
SeverityRoutes map[string]EscalationRoute `json:"severity_routes,omitempty"`
// StaleThreshold is the duration after which an unacknowledged escalation is considered stale.
// Format: Go duration string (e.g., "1h", "30m", "24h")
// Default: "1h"
// StaleThreshold is how long before an unacknowledged escalation
// is considered stale and gets re-escalated.
// Format: Go duration string (e.g., "4h", "30m", "24h")
// Default: "4h"
StaleThreshold string `json:"stale_threshold,omitempty"`
// ExternalChannels configures optional external notification channels (email, SMS, etc.)
ExternalChannels *ExternalChannelsConfig `json:"external_channels,omitempty"`
// MaxReescalations limits how many times an escalation can be
// re-escalated. Default: 2 (low→medium→high, then stops)
MaxReescalations int `json:"max_reescalations,omitempty"`
}
// EscalationRoute defines where escalations of a given severity are routed.
type EscalationRoute struct {
// Targets are the internal addresses to notify (e.g., "mayor/", "gastown/witness")
Targets []string `json:"targets"`
// UseExternal enables external channel notifications for this severity.
// If true, checks ExternalChannels config for enabled channels.
UseExternal bool `json:"use_external,omitempty"`
// Channels overrides which external channels to use for this severity.
// If empty and UseExternal is true, uses all enabled channels.
// Example: ["email"] to only use email for high severity
Channels []string `json:"channels,omitempty"`
}
// ExternalChannelsConfig configures external notification channels.
type ExternalChannelsConfig struct {
// Email configuration for email notifications
Email *EmailChannelConfig `json:"email,omitempty"`
// SMS configuration for SMS notifications (future)
SMS *SMSChannelConfig `json:"sms,omitempty"`
}
// EmailChannelConfig configures email notifications.
type EmailChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // email addresses
SMTPServer string `json:"smtp_server,omitempty"`
FromAddr string `json:"from_addr,omitempty"`
}
// SMSChannelConfig configures SMS notifications (placeholder for future).
type SMSChannelConfig struct {
Enabled bool `json:"enabled"`
Recipients []string `json:"recipients,omitempty"` // phone numbers
Provider string `json:"provider,omitempty"` // twilio, etc.
// EscalationContacts contains contact information for external notification channels.
type EscalationContacts struct {
HumanEmail string `json:"human_email,omitempty"` // email address for email:human action
HumanSMS string `json:"human_sms,omitempty"` // phone number for sms:human action
SlackWebhook string `json:"slack_webhook,omitempty"` // webhook URL for slack action
}
// CurrentEscalationVersion is the current schema version for EscalationConfig.
@@ -862,35 +834,53 @@ const CurrentEscalationVersion = 1
const (
SeverityCritical = "critical" // P0: immediate attention required
SeverityHigh = "high" // P1: urgent, needs attention soon
SeverityNormal = "normal" // P2: standard escalation (default)
SeverityMedium = "medium" // P2: standard escalation (default)
SeverityLow = "low" // P3: informational, can wait
)
// ValidSeverities returns the list of valid severity levels in order of priority.
func ValidSeverities() []string {
return []string{SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical}
}
// IsValidSeverity checks if a severity level is valid.
func IsValidSeverity(severity string) bool {
switch severity {
case SeverityLow, SeverityMedium, SeverityHigh, SeverityCritical:
return true
default:
return false
}
}
// NextSeverity returns the next higher severity level for re-escalation.
// Returns the same level if already at critical.
func NextSeverity(severity string) string {
switch severity {
case SeverityLow:
return SeverityMedium
case SeverityMedium:
return SeverityHigh
case SeverityHigh:
return SeverityCritical
default:
return SeverityCritical
}
}
// NewEscalationConfig creates a new EscalationConfig with sensible defaults.
func NewEscalationConfig() *EscalationConfig {
return &EscalationConfig{
Type: "escalation",
Version: CurrentEscalationVersion,
Enabled: true,
DefaultTarget: "mayor/",
StaleThreshold: "1h",
SeverityRoutes: map[string]EscalationRoute{
SeverityCritical: {
Targets: []string{"mayor/"},
UseExternal: true, // Critical should notify externally by default
},
SeverityHigh: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityNormal: {
Targets: []string{"mayor/"},
UseExternal: false,
},
SeverityLow: {
Targets: []string{"mayor/"},
UseExternal: false,
},
Type: "escalation",
Version: CurrentEscalationVersion,
Routes: map[string][]string{
SeverityLow: {"bead"},
SeverityMedium: {"bead", "mail:mayor"},
SeverityHigh: {"bead", "mail:mayor", "email:human"},
SeverityCritical: {"bead", "mail:mayor", "email:human", "sms:human"},
},
Contacts: EscalationContacts{},
StaleThreshold: "4h",
MaxReescalations: 2,
}
}