Add server-side workflows with YAML files, task freeze and lead tools
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v3"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
var keyOrder = []string{"description", "stages", "name", "vendor", "model", "prompt"}
|
||||
|
||||
func Parse(data []byte) (api.WorkflowDefinition, error) {
|
||||
raw := any(nil)
|
||||
err := yaml.Unmarshal(data, &raw)
|
||||
if err != nil {
|
||||
return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
j, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
|
||||
def := api.WorkflowDefinition{}
|
||||
dec := json.NewDecoder(bytes.NewReader(j))
|
||||
dec.DisallowUnknownFields()
|
||||
err = dec.Decode(&def)
|
||||
if err != nil {
|
||||
return api.WorkflowDefinition{}, fmt.Errorf("%w: %v", ErrInvalid, err)
|
||||
}
|
||||
return def, Validate(def)
|
||||
}
|
||||
|
||||
func Format(def api.WorkflowDefinition) ([]byte, error) {
|
||||
j, err := json.Marshal(def)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
doc := yaml.Node{}
|
||||
err = yaml.Unmarshal(j, &doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
restyle(&doc)
|
||||
|
||||
out := &bytes.Buffer{}
|
||||
enc := yaml.NewEncoder(out)
|
||||
enc.SetIndent(2)
|
||||
err = enc.Encode(&doc)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = enc.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func Filename(name string) string {
|
||||
return strings.Map(func(r rune) rune {
|
||||
if strings.ContainsRune(`/\:*?"<>|`, r) {
|
||||
return '_'
|
||||
}
|
||||
return r
|
||||
}, name) + ".yaml"
|
||||
}
|
||||
|
||||
func restyle(n *yaml.Node) {
|
||||
switch n.Kind {
|
||||
case yaml.ScalarNode:
|
||||
n.Style = 0
|
||||
if n.Tag == "!!str" && strings.Contains(n.Value, "\n") {
|
||||
n.Style = yaml.LiteralStyle
|
||||
}
|
||||
case yaml.MappingNode:
|
||||
n.Style = 0
|
||||
pairs := [][2]*yaml.Node{}
|
||||
for i := 0; i+1 < len(n.Content); i += 2 {
|
||||
pairs = append(pairs, [2]*yaml.Node{n.Content[i], n.Content[i+1]})
|
||||
}
|
||||
slices.SortStableFunc(pairs, func(a, b [2]*yaml.Node) int {
|
||||
return keyRank(a[0].Value) - keyRank(b[0].Value)
|
||||
})
|
||||
n.Content = n.Content[:0]
|
||||
for _, p := range pairs {
|
||||
n.Content = append(n.Content, p[0], p[1])
|
||||
}
|
||||
case yaml.SequenceNode:
|
||||
n.Style = 0
|
||||
}
|
||||
for _, c := range n.Content {
|
||||
restyle(c)
|
||||
}
|
||||
}
|
||||
|
||||
func keyRank(key string) int {
|
||||
i := slices.Index(keyOrder, key)
|
||||
if i < 0 {
|
||||
return len(keyOrder)
|
||||
}
|
||||
return i
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package workflow
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"g.fc.run/misc/byre/internal/api"
|
||||
)
|
||||
|
||||
var ErrInvalid = errors.New("invalid workflow")
|
||||
|
||||
const maxNameRunes = 100
|
||||
|
||||
func VendorOf(model string) api.AgentSessionVendor {
|
||||
if strings.HasPrefix(model, "gpt-") {
|
||||
return api.Codex
|
||||
}
|
||||
return api.Claude
|
||||
}
|
||||
|
||||
func CleanName(name string) (string, error) {
|
||||
name = strings.TrimSpace(name)
|
||||
problem := nameProblem(name)
|
||||
if problem != "" {
|
||||
return "", fmt.Errorf("%w: workflow name %s", ErrInvalid, problem)
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
|
||||
func nameProblem(name string) string {
|
||||
switch {
|
||||
case strings.TrimSpace(name) == "":
|
||||
return "is empty"
|
||||
case strings.TrimSpace(name) != name:
|
||||
return fmt.Sprintf("%q has leading or trailing spaces", name)
|
||||
case utf8.RuneCountInString(name) > maxNameRunes:
|
||||
return fmt.Sprintf("%q is longer than %d characters", name, maxNameRunes)
|
||||
case strings.ContainsFunc(name, unicode.IsControl):
|
||||
return fmt.Sprintf("%q isn't a single line", name)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Validate(def api.WorkflowDefinition) error {
|
||||
problems := []string{}
|
||||
if len(def.Stages) == 0 {
|
||||
problems = append(problems, "it has no stages")
|
||||
}
|
||||
|
||||
seen := map[string]bool{}
|
||||
for i, s := range def.Stages {
|
||||
where := fmt.Sprintf("stage %d (%q)", i+1, s.Name)
|
||||
p := nameProblem(s.Name)
|
||||
if p != "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: name %s", where, p))
|
||||
}
|
||||
if seen[s.Name] {
|
||||
problems = append(problems, fmt.Sprintf("%s: another stage has the same name", where))
|
||||
}
|
||||
seen[s.Name] = true
|
||||
|
||||
switch {
|
||||
case !s.Vendor.Valid():
|
||||
problems = append(problems, fmt.Sprintf("%s: vendor must be claude or codex, not %q", where, s.Vendor))
|
||||
case strings.TrimSpace(s.Model) == "":
|
||||
problems = append(problems, fmt.Sprintf("%s: model is required", where))
|
||||
case string(VendorOf(s.Model)) != string(s.Vendor):
|
||||
problems = append(problems, fmt.Sprintf("%s: %s is a %s model, not %s", where, s.Model, VendorOf(s.Model), s.Vendor))
|
||||
}
|
||||
|
||||
if strings.TrimSpace(s.Prompt) == "" {
|
||||
problems = append(problems, fmt.Sprintf("%s: prompt is empty", where))
|
||||
}
|
||||
}
|
||||
|
||||
if len(problems) > 0 {
|
||||
return fmt.Errorf("%w: %s", ErrInvalid, strings.Join(problems, "; "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user