Files
osmedeus/internal/parser/parser.go
T
j3ssie 0269cf4e26 feat: update Next.js build assets and add cloud setup E2E tests
- Update Next.js generated chunk hashes and build IDs reflecting latest dashboard build
- Update CSS stylesheet references in workflow upload page metadata
- Add comprehensive cloud setup E2E test suite (cloud_setup_test.go) with SSH password/key auth, post-command variable expansion, and Ansible integration
- Fix API priority levels to include 'medium' priority in test coverage
- Add agent-sdk test workflows (minimal, config, codex, multi-agent, session variants)
- Update E2E test utilities with runCLIInBase helper for multi-step cloud config tests
- Fix stderr/stdout capture in dependencies_target_types_test assertions
2026-04-04 13:57:34 +08:00

527 lines
15 KiB
Go

package parser
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"os"
"github.com/goccy/go-yaml"
"github.com/j3ssie/osmedeus/v5/internal/core"
)
// Parser handles workflow YAML parsing
type Parser struct{}
// NewParser creates a new workflow parser
func NewParser() *Parser {
return &Parser{}
}
// Parse parses a workflow from a file path
func (p *Parser) Parse(path string) (*core.Workflow, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("failed to read workflow file: %w", err)
}
workflow, err := p.ParseContent(data)
if err != nil {
return nil, err
}
// Set metadata
workflow.FilePath = path
workflow.Checksum = p.calculateChecksum(data)
return workflow, nil
}
// ParseContent parses workflow content from bytes
func (p *Parser) ParseContent(content []byte) (*core.Workflow, error) {
var workflow core.Workflow
if err := yaml.Unmarshal(content, &workflow); err != nil {
// Use go-yaml's built-in formatter for detailed error with line numbers
// FormatError(err, colored, includeSource) returns formatted error with position info
formatted := yaml.FormatError(err, false, true)
return nil, fmt.Errorf("YAML parse error:\n%s", formatted)
}
return &workflow, nil
}
// Validate validates a parsed workflow
func (p *Parser) Validate(w *core.Workflow) error {
// Validate kind
if w.Kind != core.KindModule && w.Kind != core.KindFlow {
return &ValidationError{
Field: "kind",
Message: fmt.Sprintf("invalid kind: %s, must be 'module' or 'flow'", w.Kind),
}
}
// Validate name
if w.Name == "" {
return &ValidationError{
Field: "name",
Message: "workflow name is required",
}
}
// Validate preferences if present
if err := p.validatePreferences(w.Preferences); err != nil {
return err
}
// Validate override if present
if err := p.validateOverride(w); err != nil {
return err
}
// Validate based on kind
if w.IsModule() {
return p.validateModule(w)
}
return p.validateFlow(w)
}
// validateModule validates module-specific fields
func (p *Parser) validateModule(w *core.Workflow) error {
// Modules must have at least one step, unless they extend another workflow
// (in which case they will inherit steps after resolution)
if len(w.Steps) == 0 && w.Extends == "" {
return &ValidationError{
Field: "steps",
Message: "module must have at least one step",
}
}
// Validate each step
for i, step := range w.Steps {
if err := p.validateStep(&step, i); err != nil {
return err
}
}
return nil
}
// validateFlow validates flow-specific fields
func (p *Parser) validateFlow(w *core.Workflow) error {
// Flows must have at least one module, unless they extend another workflow
// (in which case they will inherit modules after resolution)
if len(w.Modules) == 0 && w.Extends == "" {
return &ValidationError{
Field: "modules",
Message: "flow must have at least one module reference",
}
}
// Validate each module reference
for i, mod := range w.Modules {
if mod.Name == "" {
return &ValidationError{
Field: fmt.Sprintf("modules[%d].name", i),
Message: "module reference name is required",
}
}
// Path is required only for external modules (not inline modules)
if mod.Path == "" && !mod.IsInline() {
return &ValidationError{
Field: fmt.Sprintf("modules[%d].path", i),
Message: "module reference path is required (or define inline steps)",
}
}
// Inline modules must have at least one step
if mod.IsInline() && len(mod.Steps) == 0 {
return &ValidationError{
Field: fmt.Sprintf("modules[%d].steps", i),
Message: "inline module must have at least one step",
}
}
}
return nil
}
// validatePreferences validates workflow preferences
func (p *Parser) validatePreferences(prefs *core.Preferences) error {
if prefs == nil {
return nil
}
// Validate heuristics_check value if set
if prefs.HeuristicsCheck != nil {
validValues := map[string]bool{"none": true, "basic": true, "advanced": true}
if !validValues[*prefs.HeuristicsCheck] {
return &ValidationError{
Field: "preferences.heuristics_check",
Message: fmt.Sprintf("invalid value: %s, must be 'none', 'basic', or 'advanced'", *prefs.HeuristicsCheck),
}
}
}
return nil
}
// validateOverride validates the override section of a workflow
func (p *Parser) validateOverride(w *core.Workflow) error {
if w.Override == nil {
return nil
}
// Validate steps override mode
if w.Override.Steps != nil {
if !core.IsValidOverrideMode(w.Override.Steps.Mode) {
return &ValidationError{
Field: "override.steps.mode",
Message: fmt.Sprintf("invalid mode: %s, must be 'replace', 'prepend', 'append', or 'merge'", w.Override.Steps.Mode),
}
}
// Validate that steps override is only used with module workflows
if w.IsFlow() {
return &ValidationError{
Field: "override.steps",
Message: "steps override can only be used with module workflows",
}
}
// Validate steps in override
for i, step := range w.Override.Steps.Steps {
if err := p.validateStep(&step, i); err != nil {
return &ValidationError{
Field: fmt.Sprintf("override.steps.steps[%d]", i),
Message: err.Error(),
}
}
}
// Validate replacement steps
for i, step := range w.Override.Steps.Replace {
if err := p.validateStep(&step, i); err != nil {
return &ValidationError{
Field: fmt.Sprintf("override.steps.replace[%d]", i),
Message: err.Error(),
}
}
}
}
// Validate modules override mode
if w.Override.Modules != nil {
if !core.IsValidOverrideMode(w.Override.Modules.Mode) {
return &ValidationError{
Field: "override.modules.mode",
Message: fmt.Sprintf("invalid mode: %s, must be 'replace', 'prepend', 'append', or 'merge'", w.Override.Modules.Mode),
}
}
// Validate that modules override is only used with flow workflows
if w.IsModule() {
return &ValidationError{
Field: "override.modules",
Message: "modules override can only be used with flow workflows",
}
}
}
// Validate preferences in override
if w.Override.Preferences != nil {
if err := p.validatePreferences(w.Override.Preferences); err != nil {
return &ValidationError{
Field: "override.preferences",
Message: err.Error(),
}
}
}
return nil
}
// validateStep validates a step definition
func (p *Parser) validateStep(step *core.Step, index int) error {
if step.Name == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].name", index),
Message: "step name is required",
}
}
if step.Type == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].type", index),
Message: "step type is required",
}
}
// Validate step type
switch step.Type {
case core.StepTypeBash:
if step.Command == "" && len(step.Commands) == 0 && len(step.ParallelCommands) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "bash step must have command, commands, or parallel_commands",
}
}
case core.StepTypeFunction:
if step.Function == "" && len(step.Functions) == 0 && len(step.ParallelFunctions) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "function step must have function, functions, or parallel_functions",
}
}
case core.StepTypeParallel:
if len(step.ParallelSteps) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "parallel step must have parallel_steps",
}
}
case core.StepTypeForeach:
if step.Input == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].input", index),
Message: "foreach step must have input",
}
}
if step.Variable == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].variable", index),
Message: "foreach step must have variable name",
}
}
if step.Step == nil {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].step", index),
Message: "foreach step must have inner step",
}
}
case core.StepTypeRemoteBash:
// Validate step_runner is set and valid
if step.StepRunner == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].step_runner", index),
Message: "remote-bash step must have step_runner set to 'docker' or 'ssh'",
}
}
if step.StepRunner != core.RunnerTypeDocker && step.StepRunner != core.RunnerTypeSSH {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].step_runner", index),
Message: fmt.Sprintf("invalid step_runner: %s (must be 'docker' or 'ssh')", step.StepRunner),
}
}
// Validate has command
if step.Command == "" && len(step.Commands) == 0 && len(step.ParallelCommands) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "remote-bash step must have command, commands, or parallel_commands",
}
}
case core.StepTypeHTTP:
// Validate HTTP step has URL
if step.URL == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].url", index),
Message: "http step must have url",
}
}
case core.StepTypeLLM:
// Validate LLM step has messages or embedding_input
if len(step.Messages) == 0 && len(step.EmbeddingInput) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "llm step must have messages or embedding_input",
}
}
// Validate embedding step has is_embedding flag when using embedding_input
if len(step.EmbeddingInput) > 0 && !step.IsEmbedding {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].is_embedding", index),
Message: "llm step with embedding_input should have is_embedding: true",
}
}
// Validate message roles if messages are provided
for i, msg := range step.Messages {
if msg.Role == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages[%d].role", index, i),
Message: "message role is required",
}
}
validRoles := map[core.LLMMessageRole]bool{
core.LLMRoleSystem: true,
core.LLMRoleUser: true,
core.LLMRoleAssistant: true,
core.LLMRoleTool: true,
}
if !validRoles[msg.Role] {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages[%d].role", index, i),
Message: fmt.Sprintf("invalid message role: %s (must be system, user, assistant, or tool)", msg.Role),
}
}
}
case core.StepTypeAgent:
// Validate agent step has query or queries (not both)
if step.Query == "" && len(step.Queries) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].query", index),
Message: "agent step must have 'query' or 'queries'",
}
}
if step.Query != "" && len(step.Queries) > 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "agent step cannot have both 'query' and 'queries'",
}
}
// Validate queries are not empty strings
for i, q := range step.Queries {
if q == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].queries[%d]", index, i),
Message: "query in queries list must not be empty",
}
}
}
// Validate agent step has max_iterations
if step.MaxIterations <= 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].max_iterations", index),
Message: "agent step must have max_iterations > 0",
}
}
// Validate agent step has agent_tools
if len(step.AgentTools) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools", index),
Message: "agent step must have at least one agent_tool",
}
}
// Validate individual agent tools
seen := make(map[string]bool)
for i, tool := range step.AgentTools {
if tool.IsPreset() {
// Validate preset tool exists
if _, ok := core.GetPresetTool(tool.Preset); !ok {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools[%d].preset", index, i),
Message: fmt.Sprintf("unknown preset tool: %s", tool.Preset),
}
}
if seen[tool.Preset] {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools[%d].preset", index, i),
Message: fmt.Sprintf("duplicate tool name: %s", tool.Preset),
}
}
seen[tool.Preset] = true
} else {
// Custom tool must have name and description
if tool.Name == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools[%d].name", index, i),
Message: "custom agent tool requires 'name' field",
}
}
if tool.Description == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools[%d].description", index, i),
Message: "custom agent tool requires 'description' field",
}
}
if seen[tool.Name] {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].agent_tools[%d].name", index, i),
Message: fmt.Sprintf("duplicate tool name: %s", tool.Name),
}
}
seen[tool.Name] = true
}
}
case core.StepTypeAgentACP:
// Validate agent-acp step has agent name or custom command
if step.Agent == "" && (step.ACPConfig == nil || step.ACPConfig.Command == "") {
return &ValidationError{
Field: fmt.Sprintf("steps[%d]", index),
Message: "agent-acp step must have 'agent' field or 'acp_config.command'",
}
}
// Validate has at least one message
if len(step.Messages) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages", index),
Message: "agent-acp step must have at least one message",
}
}
// Validate messages have content
for i, msg := range step.Messages {
if msg.Content == nil || msg.Content == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages[%d].content", index, i),
Message: "message content must not be empty",
}
}
}
case core.StepTypeAgentSDK:
// Validate agent-sdk step has at least one message
if len(step.Messages) == 0 {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages", index),
Message: "agent-sdk step must have at least one message",
}
}
// Validate messages have content
for i, msg := range step.Messages {
if msg.Content == nil || msg.Content == "" {
return &ValidationError{
Field: fmt.Sprintf("steps[%d].messages[%d].content", index, i),
Message: "message content must not be empty",
}
}
}
default:
return &ValidationError{
Field: fmt.Sprintf("steps[%d].type", index),
Message: fmt.Sprintf("invalid step type: %s", step.Type),
}
}
return nil
}
// calculateChecksum calculates SHA256 checksum of content
func (p *Parser) calculateChecksum(content []byte) string {
hash := sha256.Sum256(content)
return hex.EncodeToString(hash[:])
}
// ValidationError represents a validation error
type ValidationError struct {
Field string
Message string
}
func (e *ValidationError) Error() string {
return fmt.Sprintf("validation error: %s - %s", e.Field, e.Message)
}
// DefaultParser is the global parser instance
var DefaultParser = NewParser()
// Parse parses a workflow file using the default parser
func Parse(path string) (*core.Workflow, error) {
return DefaultParser.Parse(path)
}
// ParseContent parses workflow content using the default parser
func ParseContent(content []byte) (*core.Workflow, error) {
return DefaultParser.ParseContent(content)
}
// Validate validates a workflow using the default parser
func Validate(w *core.Workflow) error {
return DefaultParser.Validate(w)
}