mirror of
https://github.com/j3ssie/osmedeus.git
synced 2026-08-23 16:12:29 +02:00
316 lines
8.5 KiB
Go
316 lines
8.5 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 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 {
|
|
if len(w.Steps) == 0 {
|
|
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 {
|
|
if len(w.Modules) == 0 {
|
|
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",
|
|
}
|
|
}
|
|
if mod.Path == "" {
|
|
return &ValidationError{
|
|
Field: fmt.Sprintf("modules[%d].path", i),
|
|
Message: "module reference path is required",
|
|
}
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// 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),
|
|
}
|
|
}
|
|
}
|
|
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)
|
|
}
|