// Package operator defines the headless operator scenario schema and the // dry-run validation used before the API/worker runtime exists. Keeping the // scenario format and its checks here lets the CLI verify operator workflows // as plain YAML fixtures, in line with ALT's headless-first operations gate. package operator import ( "bytes" "fmt" "os" "time" "gopkg.in/yaml.v3" ) // Action enumerates the operator step actions the headless validator // understands. The set is intentionally small: it grows as later subtasks add // real API/worker request runners. Any action outside this set is rejected at // dry-run time so scenario files cannot reference unimplemented behaviour. type Action string const ( // ActionHello validates a basic API connection handshake. ActionHello Action = "hello" ) // validActions is the closed set used for strict dry-run validation. var validActions = map[Action]bool{ ActionHello: true, } // Duration wraps time.Duration so scenario YAML can express timeouts as Go // duration strings (for example "5s") rather than raw nanoseconds. type Duration time.Duration // UnmarshalYAML decodes a duration string such as "5s" into a Duration. func (d *Duration) UnmarshalYAML(value *yaml.Node) error { var s string if err := value.Decode(&s); err != nil { return fmt.Errorf("timeout must be a duration string: %w", err) } parsed, err := time.ParseDuration(s) if err != nil { return fmt.Errorf("invalid timeout %q: %w", s, err) } *d = Duration(parsed) return nil } // Expect captures the assertions a step makes against its response. The fields // are descriptive only at this stage; later subtasks compare them with real // API/worker responses. type Expect struct { Status string `yaml:"status"` Capabilities []string `yaml:"capabilities"` } // Step is a single operator action within a scenario. type Step struct { ID string `yaml:"id"` Action Action `yaml:"action"` Expect Expect `yaml:"expect"` } // Scenario is the top-level operator scenario document. type Scenario struct { Name string `yaml:"name"` Timeout Duration `yaml:"timeout"` Steps []Step `yaml:"steps"` } // LoadScenario reads a scenario file and returns it after strict decode and // dry-run validation. func LoadScenario(path string) (*Scenario, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read scenario: %w", err) } return ParseScenario(data) } // ParseScenario strictly decodes scenario YAML and validates it. Unknown YAML // fields are rejected so typos in fixtures surface immediately. func ParseScenario(data []byte) (*Scenario, error) { dec := yaml.NewDecoder(bytes.NewReader(data)) dec.KnownFields(true) var s Scenario if err := dec.Decode(&s); err != nil { return nil, fmt.Errorf("decode scenario: %w", err) } if err := s.Validate(); err != nil { return nil, err } return &s, nil } // Validate runs the dry-run checks: a named scenario, at least one step, and // every step having a unique id and a known action. func (s *Scenario) Validate() error { if s.Name == "" { return fmt.Errorf("scenario name is required") } if len(s.Steps) == 0 { return fmt.Errorf("scenario %q has no steps", s.Name) } seen := make(map[string]bool, len(s.Steps)) for i, step := range s.Steps { if step.ID == "" { return fmt.Errorf("step %d: id is required", i) } if seen[step.ID] { return fmt.Errorf("step %q: duplicate id", step.ID) } seen[step.ID] = true if !validActions[step.Action] { return fmt.Errorf("step %q: unknown action %q", step.ID, step.Action) } } return nil }