feat: Implement unified escalation system (gt-i9r20)
Add severity-based routing for escalations with config-driven targets. Changes: - EscalationConfig type with severity routes and external channels - beads/beads_escalation.go: Escalation bead operations (create/ack/close/list) - Refactored gt escalate command with subcommands: - list: Show open escalations - ack: Acknowledge an escalation - close: Resolve with reason - stale: Find unacknowledged escalations past threshold - show: Display escalation details - Added TypeEscalationAcked and TypeEscalationClosed event types Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1265,3 +1265,127 @@ func GetRigPrefix(townRoot, rigName string) string {
|
||||
prefix := entry.BeadsConfig.Prefix
|
||||
return strings.TrimSuffix(prefix, "-")
|
||||
}
|
||||
|
||||
// EscalationConfigPath returns the standard path for escalation config in a town.
|
||||
func EscalationConfigPath(townRoot string) string {
|
||||
return filepath.Join(townRoot, "config", "escalation.json")
|
||||
}
|
||||
|
||||
// LoadEscalationConfig loads and validates an escalation configuration file.
|
||||
func LoadEscalationConfig(path string) (*EscalationConfig, error) {
|
||||
data, err := os.ReadFile(path) //nolint:gosec // G304: path is constructed internally, not from user input
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("%w: %s", ErrNotFound, path)
|
||||
}
|
||||
return nil, fmt.Errorf("reading escalation config: %w", err)
|
||||
}
|
||||
|
||||
var config EscalationConfig
|
||||
if err := json.Unmarshal(data, &config); err != nil {
|
||||
return nil, fmt.Errorf("parsing escalation config: %w", err)
|
||||
}
|
||||
|
||||
if err := validateEscalationConfig(&config); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// LoadOrCreateEscalationConfig loads the escalation config, creating a default if not found.
|
||||
func LoadOrCreateEscalationConfig(path string) (*EscalationConfig, error) {
|
||||
config, err := LoadEscalationConfig(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrNotFound) {
|
||||
return NewEscalationConfig(), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// SaveEscalationConfig saves an escalation configuration to a file.
|
||||
func SaveEscalationConfig(path string, config *EscalationConfig) error {
|
||||
if err := validateEscalationConfig(config); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
|
||||
return fmt.Errorf("creating directory: %w", err)
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encoding escalation config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, data, 0644); err != nil { //nolint:gosec // G306: escalation config doesn't contain secrets
|
||||
return fmt.Errorf("writing escalation config: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEscalationConfig validates an EscalationConfig.
|
||||
func validateEscalationConfig(c *EscalationConfig) error {
|
||||
if c.Type != "escalation" && c.Type != "" {
|
||||
return fmt.Errorf("%w: expected type 'escalation', got '%s'", ErrInvalidType, c.Type)
|
||||
}
|
||||
if c.Version > CurrentEscalationVersion {
|
||||
return fmt.Errorf("%w: got %d, max supported %d", ErrInvalidVersion, c.Version, CurrentEscalationVersion)
|
||||
}
|
||||
|
||||
// Validate stale_threshold if specified
|
||||
if c.StaleThreshold != "" {
|
||||
if _, err := time.ParseDuration(c.StaleThreshold); err != nil {
|
||||
return fmt.Errorf("invalid stale_threshold: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize nil maps
|
||||
if c.SeverityRoutes == nil {
|
||||
c.SeverityRoutes = make(map[string]EscalationRoute)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetStaleThreshold returns the stale threshold as a time.Duration.
|
||||
// Returns 1 hour if not configured or invalid.
|
||||
func (c *EscalationConfig) GetStaleThreshold() time.Duration {
|
||||
if c.StaleThreshold == "" {
|
||||
return time.Hour
|
||||
}
|
||||
d, err := time.ParseDuration(c.StaleThreshold)
|
||||
if err != nil {
|
||||
return 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 {
|
||||
return route
|
||||
}
|
||||
// Fallback to default target
|
||||
return EscalationRoute{
|
||||
Targets: []string{c.DefaultTarget},
|
||||
UseExternal: false,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -773,3 +773,109 @@ func NewMessagingConfig() *MessagingConfig {
|
||||
NudgeChannels: make(map[string][]string),
|
||||
}
|
||||
}
|
||||
|
||||
// EscalationConfig represents the escalation system configuration (config/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"`
|
||||
|
||||
// DefaultTarget is the address to send escalations when no severity-specific target is set.
|
||||
// Example: "mayor/"
|
||||
DefaultTarget string `json:"default_target,omitempty"`
|
||||
|
||||
// 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 string `json:"stale_threshold,omitempty"`
|
||||
|
||||
// ExternalChannels configures optional external notification channels (email, SMS, etc.)
|
||||
ExternalChannels *ExternalChannelsConfig `json:"external_channels,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.
|
||||
}
|
||||
|
||||
// CurrentEscalationVersion is the current schema version for EscalationConfig.
|
||||
const CurrentEscalationVersion = 1
|
||||
|
||||
// Escalation severity level constants.
|
||||
const (
|
||||
SeverityCritical = "critical" // P0: immediate attention required
|
||||
SeverityHigh = "high" // P1: urgent, needs attention soon
|
||||
SeverityNormal = "normal" // P2: standard escalation (default)
|
||||
SeverityLow = "low" // P3: informational, can wait
|
||||
)
|
||||
|
||||
// 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,
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user