feat(agent/lib/flow): YAML flow library — Parse + ParseFile + LoadEmbedded
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
This commit is contained in:
parent
eed51d72b8
commit
8858545f63
2 changed files with 353 additions and 0 deletions
159
pkg/lib/flow/flow.go
Normal file
159
pkg/lib/flow/flow.go
Normal file
|
|
@ -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
|
||||
}
|
||||
194
pkg/lib/flow/flow_test.go
Normal file
194
pkg/lib/flow/flow_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue