New pkg/lib/flow package per RFC §Flow System:
types.Flow{Name, Description, Steps}, types.Step{Name, Cmd, Args,
ContinueOnError}.
Parse(reader io.Reader) (Flow, error): YAML decoder
ParseFile(path string) (Flow, error): reads via core.Fs, then Parse
LoadEmbedded(name string) (Flow, error): bundled flow templates;
.md files only treated as flows when they contain YAML frontmatter
Validation: steps may be absent (empty Steps slice OK); any declared
step must define cmd.
Pairs with #160 (run/flow command at pkg/agentic/flow.go) — that
consumes types from this library for sequential step execution.
Tests cover: valid YAML, continueOnError, empty input, malformed
YAML, missing cmd, temp-file ParseFile, missing embedded files,
markdown-template failure (current state — embedded markdown is
content not YAML).
Co-authored-by: Codex <noreply@openai.com>
Closes tasks.lthn.sh/view.php?id=229
159 lines
3.6 KiB
Go
159 lines
3.6 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package flow
|
|
|
|
import (
|
|
"bytes"
|
|
"embed"
|
|
"io"
|
|
|
|
core "dappco.re/go/core"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var fs = (&core.Fs{}).NewUnrestricted()
|
|
|
|
//go:embed *.md upgrade
|
|
var embeddedFiles embed.FS
|
|
|
|
type Flow struct {
|
|
Name string `yaml:"name"`
|
|
Description string `yaml:"description"`
|
|
Steps []Step `yaml:"steps"`
|
|
}
|
|
|
|
type Step struct {
|
|
Name string `yaml:"name"`
|
|
Cmd string `yaml:"cmd"`
|
|
Args []string `yaml:"args"`
|
|
ContinueOnError bool `yaml:"continueOnError"`
|
|
}
|
|
|
|
func Parse(reader io.Reader) (Flow, error) {
|
|
if reader == nil {
|
|
return Flow{}, core.E("flow.Parse", "reader is nil", nil)
|
|
}
|
|
|
|
decoder := yaml.NewDecoder(reader)
|
|
|
|
var definition Flow
|
|
if err := decoder.Decode(&definition); err != nil {
|
|
if err == io.EOF {
|
|
return Flow{}, nil
|
|
}
|
|
return Flow{}, core.E("flow.Parse", "decode flow YAML", err)
|
|
}
|
|
|
|
if err := validate(definition); err != nil {
|
|
return Flow{}, err
|
|
}
|
|
|
|
return definition, nil
|
|
}
|
|
|
|
func ParseFile(path string) (Flow, error) {
|
|
readResult := fs.Read(path)
|
|
if !readResult.OK {
|
|
if err, ok := readResult.Value.(error); ok {
|
|
return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), err)
|
|
}
|
|
return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), nil)
|
|
}
|
|
|
|
content, ok := readResult.Value.(string)
|
|
if !ok {
|
|
return Flow{}, core.E("flow.ParseFile", core.Concat("read ", path), nil)
|
|
}
|
|
|
|
return Parse(bytes.NewBufferString(content))
|
|
}
|
|
|
|
func LoadEmbedded(name string) (Flow, error) {
|
|
name = normaliseEmbeddedName(name)
|
|
if name == "" {
|
|
return Flow{}, core.E("flow.LoadEmbedded", "name is required", nil)
|
|
}
|
|
|
|
if core.HasPrefix(name, "/") || core.Contains(name, "..") {
|
|
return Flow{}, core.E("flow.LoadEmbedded", core.Concat("invalid embedded flow name: ", name), nil)
|
|
}
|
|
|
|
for _, candidate := range embeddedCandidates(name) {
|
|
content, err := embeddedFiles.ReadFile(candidate)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
|
|
if isMarkdown(candidate) {
|
|
frontMatter, ok := markdownFrontMatter(content)
|
|
if !ok {
|
|
return Flow{}, core.E("flow.LoadEmbedded", core.Concat("embedded markdown is not a YAML flow: ", candidate), nil)
|
|
}
|
|
return Parse(bytes.NewReader(frontMatter))
|
|
}
|
|
|
|
return Parse(bytes.NewReader(content))
|
|
}
|
|
|
|
return Flow{}, core.E("flow.LoadEmbedded", core.Concat("embedded flow not found: ", name), nil)
|
|
}
|
|
|
|
func validate(definition Flow) error {
|
|
for index, step := range definition.Steps {
|
|
if core.Trim(step.Cmd) != "" {
|
|
continue
|
|
}
|
|
|
|
name := core.Trim(step.Name)
|
|
if name == "" {
|
|
name = core.Concat("step-", core.Sprintf("%d", index+1))
|
|
}
|
|
|
|
return core.E("flow.validate", core.Concat("step \"", name, "\" cmd is required"), nil)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normaliseEmbeddedName(name string) string {
|
|
name = core.Trim(name)
|
|
name = core.TrimPrefix(name, "./")
|
|
name = core.TrimPrefix(name, "pkg/lib/flow/")
|
|
name = core.TrimPrefix(name, "flow/")
|
|
return name
|
|
}
|
|
|
|
func embeddedCandidates(name string) []string {
|
|
if hasFlowExtension(name) {
|
|
return []string{name}
|
|
}
|
|
|
|
return []string{
|
|
name + ".yaml",
|
|
name + ".yml",
|
|
name + ".md",
|
|
}
|
|
}
|
|
|
|
func hasFlowExtension(name string) bool {
|
|
return core.HasSuffix(name, ".yaml") || core.HasSuffix(name, ".yml") || core.HasSuffix(name, ".md")
|
|
}
|
|
|
|
func isMarkdown(name string) bool {
|
|
return core.HasSuffix(name, ".md")
|
|
}
|
|
|
|
func markdownFrontMatter(content []byte) ([]byte, bool) {
|
|
content = bytes.ReplaceAll(content, []byte("\r\n"), []byte("\n"))
|
|
if !bytes.HasPrefix(content, []byte("---\n")) {
|
|
return nil, false
|
|
}
|
|
|
|
content = content[len("---\n"):]
|
|
index := bytes.Index(content, []byte("\n---\n"))
|
|
if index < 0 {
|
|
return nil, false
|
|
}
|
|
|
|
return content[:index], true
|
|
}
|