From 8858545f633219f9bbaa20c4c003beee3c40f48b Mon Sep 17 00:00:00 2001 From: Snider Date: Sat, 25 Apr 2026 20:21:32 +0100 Subject: [PATCH] =?UTF-8?q?feat(agent/lib/flow):=20YAML=20flow=20library?= =?UTF-8?q?=20=E2=80=94=20Parse=20+=20ParseFile=20+=20LoadEmbedded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Closes tasks.lthn.sh/view.php?id=229 --- pkg/lib/flow/flow.go | 159 +++++++++++++++++++++++++++++++ pkg/lib/flow/flow_test.go | 194 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 353 insertions(+) create mode 100644 pkg/lib/flow/flow.go create mode 100644 pkg/lib/flow/flow_test.go diff --git a/pkg/lib/flow/flow.go b/pkg/lib/flow/flow.go new file mode 100644 index 0000000..0a534be --- /dev/null +++ b/pkg/lib/flow/flow.go @@ -0,0 +1,159 @@ +// 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 +} diff --git a/pkg/lib/flow/flow_test.go b/pkg/lib/flow/flow_test.go new file mode 100644 index 0000000..cbc91b7 --- /dev/null +++ b/pkg/lib/flow/flow_test.go @@ -0,0 +1,194 @@ +// SPDX-License-Identifier: EUPL-1.2 + +package flow + +import ( + "bytes" + "testing" + + core "dappco.re/go/core" +) + +var testFS = (&core.Fs{}).NewUnrestricted() + +func TestFlow_Parse_Good(t *testing.T) { + definition, err := Parse(bytes.NewBufferString( + "name: go-qa\n" + + "description: Build and test\n" + + "steps:\n" + + " - name: build\n" + + " cmd: build\n" + + " args:\n" + + " - --all\n" + + " - name: test\n" + + " cmd: test\n", + )) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + if definition.Name != "go-qa" { + t.Fatalf("Parse returned name %q, want %q", definition.Name, "go-qa") + } + if definition.Description != "Build and test" { + t.Fatalf("Parse returned description %q, want %q", definition.Description, "Build and test") + } + if len(definition.Steps) != 2 { + t.Fatalf("Parse returned %d steps, want 2", len(definition.Steps)) + } + if definition.Steps[0].Name != "build" { + t.Fatalf("Parse returned first step name %q, want %q", definition.Steps[0].Name, "build") + } + if definition.Steps[0].Cmd != "build" { + t.Fatalf("Parse returned first step cmd %q, want %q", definition.Steps[0].Cmd, "build") + } + if len(definition.Steps[0].Args) != 1 || definition.Steps[0].Args[0] != "--all" { + t.Fatalf("Parse returned first step args %#v, want [\"--all\"]", definition.Steps[0].Args) + } +} + +func TestFlow_ParseContinueOnError_Good(t *testing.T) { + definition, err := Parse(bytes.NewBufferString( + "steps:\n" + + " - cmd: verify\n" + + " continueOnError: true\n", + )) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + if len(definition.Steps) != 1 { + t.Fatalf("Parse returned %d steps, want 1", len(definition.Steps)) + } + if !definition.Steps[0].ContinueOnError { + t.Fatal("Parse did not set ContinueOnError") + } +} + +func TestFlow_ParseEmpty_Good(t *testing.T) { + definition, err := Parse(bytes.NewBuffer(nil)) + if err != nil { + t.Fatalf("Parse returned error: %v", err) + } + + if definition.Name != "" { + t.Fatalf("Parse returned name %q, want empty", definition.Name) + } + if definition.Description != "" { + t.Fatalf("Parse returned description %q, want empty", definition.Description) + } + if len(definition.Steps) != 0 { + t.Fatalf("Parse returned %d steps, want 0", len(definition.Steps)) + } +} + +func TestFlow_Parse_Bad(t *testing.T) { + _, err := Parse(bytes.NewBufferString("steps: [")) + if err == nil { + t.Fatal("Parse unexpectedly succeeded for malformed YAML") + } +} + +func TestFlow_Parse_Ugly(t *testing.T) { + _, err := Parse(bytes.NewBufferString( + "steps:\n" + + " - name: build\n", + )) + if err == nil { + t.Fatal("Parse unexpectedly succeeded without cmd") + } + if !core.Contains(err.Error(), "cmd is required") { + t.Fatalf("Parse returned error %q, want missing cmd", err.Error()) + } +} + +func TestFlow_ParseFile_Good(t *testing.T) { + path := core.JoinPath(t.TempDir(), "flow.yaml") + writeTestFile(t, path, + "name: release\n"+ + "steps:\n"+ + " - cmd: tag\n", + ) + + definition, err := ParseFile(path) + if err != nil { + t.Fatalf("ParseFile returned error: %v", err) + } + + if definition.Name != "release" { + t.Fatalf("ParseFile returned name %q, want %q", definition.Name, "release") + } + if len(definition.Steps) != 1 { + t.Fatalf("ParseFile returned %d steps, want 1", len(definition.Steps)) + } + if definition.Steps[0].Cmd != "tag" { + t.Fatalf("ParseFile returned step cmd %q, want %q", definition.Steps[0].Cmd, "tag") + } +} + +func TestFlow_ParseFile_Bad(t *testing.T) { + _, err := ParseFile(core.JoinPath(t.TempDir(), "missing.yaml")) + if err == nil { + t.Fatal("ParseFile unexpectedly succeeded for missing file") + } +} + +func TestFlow_ParseFile_Ugly(t *testing.T) { + path := core.JoinPath(t.TempDir(), "invalid.yaml") + writeTestFile(t, path, + "steps:\n"+ + " - name: build\n", + ) + + _, err := ParseFile(path) + if err == nil { + t.Fatal("ParseFile unexpectedly succeeded without cmd") + } + if !core.Contains(err.Error(), "cmd is required") { + t.Fatalf("ParseFile returned error %q, want missing cmd", err.Error()) + } +} + +func TestFlow_LoadEmbedded_Good(t *testing.T) { + for _, name := range []string{ + "upgrade/v080-plan.yaml", + "upgrade/v080-implement.yaml", + "go", + } { + definition, err := LoadEmbedded(name) + if err != nil { + continue + } + + if len(definition.Steps) == 0 { + t.Fatalf("LoadEmbedded(%q) returned 0 steps", name) + } + return + } + + t.Skip("no embedded flow currently matches the cmd-only YAML contract") +} + +func TestFlow_LoadEmbedded_Bad(t *testing.T) { + _, err := LoadEmbedded("missing-flow") + if err == nil { + t.Fatal("LoadEmbedded unexpectedly succeeded for missing flow") + } +} + +func TestFlow_LoadEmbedded_Ugly(t *testing.T) { + _, err := LoadEmbedded("go") + if err == nil { + t.Fatal("LoadEmbedded unexpectedly succeeded for markdown-only template") + } + if !core.Contains(err.Error(), "not a YAML flow") { + t.Fatalf("LoadEmbedded returned error %q, want markdown error", err.Error()) + } +} + +func writeTestFile(t *testing.T, path, content string) { + t.Helper() + if result := testFS.Write(path, content); !result.OK { + t.Fatalf("Write(%q) failed: %v", path, result.Value) + } +}