Add server-side workflows with YAML files, task freeze and lead tools

This commit is contained in:
Ian Gulliver
2026-10-15 21:17:58 -07:00
parent 2f02f5ac3c
commit 2ce10a6c65
19 changed files with 1293 additions and 199 deletions
+107
View File
@@ -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
}
+83
View File
@@ -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
}