relay-flow 0.0.1 → 0.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,282 @@
1
+ package workflow_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/workflow"
12
+ )
13
+
14
+ // 3.34: workflow storage/service per specs/workflow-repo-management
15
+ // "Workflow definitions are persisted atomically" and "Workflow replacement
16
+ // has no concurrent versions" / "Workflow removal protects active runs".
17
+
18
+ const storeValid = `
19
+ name: basicFlow
20
+ repos: [payments]
21
+ nodes:
22
+ start:
23
+ onSuccess:
24
+ - target: coding
25
+ coding:
26
+ type: agent
27
+ agent: build
28
+ description: work
29
+ onSuccess:
30
+ - target: end
31
+ onFailure:
32
+ - target: coding
33
+ end: {}
34
+ `
35
+
36
+ type fakeActiveRuns struct{ active map[string]bool }
37
+
38
+ func (f *fakeActiveRuns) HasActiveWorkflow(_ context.Context, name string) (bool, error) {
39
+ return f.active[name], nil
40
+ }
41
+
42
+ func (f *fakeActiveRuns) set(name string, on bool) { f.active[name] = on }
43
+
44
+ type fakeRepoLookup struct{ repos map[string]bool }
45
+
46
+ func (f fakeRepoLookup) Exists(name string) bool { return f.repos[name] }
47
+
48
+ func newStore(t *testing.T) *workflow.Store {
49
+ t.Helper()
50
+ return &workflow.Store{Dir: t.TempDir()}
51
+ }
52
+
53
+ // newService builds a Service via its constructor over a temp store and the
54
+ // fake ActiveRuns/RepoLookup consumer interfaces.
55
+ func newService(t *testing.T, active *fakeActiveRuns, repos map[string]bool) (*workflow.Service, *workflow.Store) {
56
+ t.Helper()
57
+ store := newStore(t)
58
+ svc := workflow.NewService(store, active, fakeRepoLookup{repos: repos})
59
+ return svc, store
60
+ }
61
+
62
+ func TestStorePutGetLoadAll(t *testing.T) {
63
+ s := newStore(t)
64
+ if err := s.Put("basicFlow", []byte(storeValid)); err != nil {
65
+ t.Fatalf("Put failed: %v", err)
66
+ }
67
+ // File lands at <dir>/<name>.yaml with 0644.
68
+ fi, err := os.Stat(filepath.Join(s.Dir, "basicFlow.yaml"))
69
+ if err != nil {
70
+ t.Fatalf("workflow file not stored: %v", err)
71
+ }
72
+ if fi.Mode().Perm() != 0644 {
73
+ t.Fatalf("workflow file mode = %o, want 0644", fi.Mode().Perm())
74
+ }
75
+
76
+ wf, err := s.Get("basicFlow")
77
+ if err != nil {
78
+ t.Fatalf("Get failed: %v", err)
79
+ }
80
+ if wf.Name != "basicFlow" {
81
+ t.Fatalf("Get name = %q", wf.Name)
82
+ }
83
+
84
+ all, err := s.LoadAll()
85
+ if err != nil || len(all) != 1 {
86
+ t.Fatalf("LoadAll = %d, %v", len(all), err)
87
+ }
88
+
89
+ if err := s.Remove("basicFlow"); err != nil {
90
+ t.Fatalf("Remove failed: %v", err)
91
+ }
92
+ if _, err := s.Get("basicFlow"); err == nil {
93
+ t.Fatal("Get after Remove succeeded")
94
+ }
95
+ }
96
+
97
+ func TestServiceSubmitValidatesBeforeStoring(t *testing.T) {
98
+ active := &fakeActiveRuns{active: map[string]bool{}}
99
+ svc, store := newService(t, active, map[string]bool{"payments": true})
100
+
101
+ // Invalid YAML must fail before any file is written.
102
+ if _, err := svc.Submit(context.Background(), []byte("name: bad name!!")); err == nil {
103
+ t.Fatal("invalid workflow submitted")
104
+ }
105
+ if _, err := store.Get("bad name!!"); err == nil {
106
+ t.Fatal("invalid workflow was stored")
107
+ }
108
+
109
+ wf, err := svc.Submit(context.Background(), []byte(storeValid))
110
+ if err != nil {
111
+ t.Fatalf("valid Submit failed: %v", err)
112
+ }
113
+ if wf.Name != "basicFlow" {
114
+ t.Fatalf("submitted name = %q", wf.Name)
115
+ }
116
+ // Submission updates the in-memory registry: the workflow is retrievable.
117
+ if _, err := svc.Get("basicFlow"); err != nil {
118
+ t.Fatal("submitted workflow not in registry after submit")
119
+ }
120
+ }
121
+
122
+ func TestServiceSubmitValidatesReferencedRepoConfigBeforeStoring(t *testing.T) {
123
+ active := &fakeActiveRuns{active: map[string]bool{}}
124
+ svc, store := newService(t, active, map[string]bool{"payments": true})
125
+ svc.ValidateTaskConfig = func(_ context.Context, wf *workflow.Workflow) error {
126
+ if wf.Nodes["coding"].TaskConfig["invalid"] == true {
127
+ return errors.New("repo payments node coding: invalid task config")
128
+ }
129
+ return nil
130
+ }
131
+ invalid := []byte(strings.Replace(storeValid, "description: work", "description: work\n taskConfig:\n invalid: true", 1))
132
+
133
+ if _, err := svc.Submit(context.Background(), invalid); err == nil || !strings.Contains(err.Error(), "invalid task config") {
134
+ t.Fatalf("Submit error = %v", err)
135
+ }
136
+ if _, err := store.Get("basicFlow"); err == nil {
137
+ t.Fatal("workflow was stored despite task validation failure")
138
+ }
139
+ }
140
+
141
+ func TestServiceSubmitRejectsUnknownRepo(t *testing.T) {
142
+ active := &fakeActiveRuns{active: map[string]bool{}}
143
+ svc, store := newService(t, active, map[string]bool{}) // no repos registered
144
+ if _, err := svc.Submit(context.Background(), []byte(storeValid)); err == nil {
145
+ t.Fatal("workflow referencing unregistered repo accepted")
146
+ }
147
+ if _, err := store.Get("basicFlow"); err == nil {
148
+ t.Fatal("unknown-repo workflow stored")
149
+ }
150
+ }
151
+
152
+ func TestServiceReplaceRejectedWhileActive(t *testing.T) {
153
+ active := &fakeActiveRuns{active: map[string]bool{}}
154
+ svc, store := newService(t, active, map[string]bool{"payments": true})
155
+ if _, err := svc.Submit(context.Background(), []byte(storeValid)); err != nil {
156
+ t.Fatal(err)
157
+ }
158
+ before, err := store.Get("basicFlow")
159
+ if err != nil {
160
+ t.Fatal(err)
161
+ }
162
+
163
+ // Now a run is active; replacement must be rejected and the stored file
164
+ // and in-memory definition must remain unchanged. Change a REAL field
165
+ // (the coding node description), not just a comment.
166
+ active.set("basicFlow", true)
167
+ replacement := []byte(strings.Replace(storeValid, "description: work", "description: changed work", 1))
168
+ if _, err := svc.Submit(context.Background(), replacement); err == nil {
169
+ t.Fatal("replacement during active run accepted")
170
+ }
171
+ after, err := store.Get("basicFlow")
172
+ if err != nil {
173
+ t.Fatal(err)
174
+ }
175
+ if after.Name != before.Name {
176
+ t.Fatal("stored definition changed during rejected replacement")
177
+ }
178
+ if after.Nodes["coding"].Description != before.Nodes["coding"].Description {
179
+ t.Fatalf("in-memory definition changed during rejected replacement: %q -> %q",
180
+ before.Nodes["coding"].Description, after.Nodes["coding"].Description)
181
+ }
182
+ rawOnDisk, rerr := os.ReadFile(filepath.Join(store.Dir, "basicFlow.yaml"))
183
+ if rerr == nil && strings.Contains(string(rawOnDisk), "changed work") {
184
+ t.Fatal("stored workflow file changed during rejected replacement")
185
+ }
186
+
187
+ // After completion, replacement succeeds and becomes the definition.
188
+ active.set("basicFlow", false)
189
+ if _, err := svc.Submit(context.Background(), replacement); err != nil {
190
+ t.Fatalf("replacement after completion rejected: %v", err)
191
+ }
192
+ installed, err := store.Get("basicFlow")
193
+ if err != nil {
194
+ t.Fatal(err)
195
+ }
196
+ if installed.Nodes["coding"].Description != "changed work" {
197
+ t.Fatalf("changed definition not installed after completion: %q", installed.Nodes["coding"].Description)
198
+ }
199
+ }
200
+
201
+ func TestServiceFailedWritePreservesExisting(t *testing.T) {
202
+ active := &fakeActiveRuns{active: map[string]bool{}}
203
+ svc, store := newService(t, active, map[string]bool{"payments": true})
204
+ if _, err := svc.Submit(context.Background(), []byte(storeValid)); err != nil {
205
+ t.Fatal(err)
206
+ }
207
+
208
+ // Make the store directory read-only so the atomic replacement fails.
209
+ if err := os.Chmod(store.Dir, 0500); err != nil {
210
+ t.Fatal(err)
211
+ }
212
+ defer os.Chmod(store.Dir, 0700)
213
+
214
+ replacement := []byte(storeValid + "\n# changed\n")
215
+ if _, err := svc.Submit(context.Background(), replacement); err == nil {
216
+ t.Fatal("submission with failing write succeeded")
217
+ }
218
+ // Prior file and in-memory definition remain usable/active.
219
+ os.Chmod(store.Dir, 0700)
220
+ got, err := store.Get("basicFlow")
221
+ if err != nil {
222
+ t.Fatalf("prior workflow unusable after failed write: %v", err)
223
+ }
224
+ if got.Name != "basicFlow" {
225
+ t.Fatal("in-memory definition lost after failed write")
226
+ }
227
+ if _, err := svc.Get("basicFlow"); err != nil {
228
+ t.Fatal("in-memory definition lost after failed write")
229
+ }
230
+ }
231
+
232
+ func TestServiceRemoveProtectsActiveRuns(t *testing.T) {
233
+ active := &fakeActiveRuns{active: map[string]bool{}}
234
+ svc, _ := newService(t, active, map[string]bool{"payments": true})
235
+ if _, err := svc.Submit(context.Background(), []byte(storeValid)); err != nil {
236
+ t.Fatal(err)
237
+ }
238
+
239
+ active.set("basicFlow", true)
240
+ if err := svc.Remove(context.Background(), "basicFlow"); err == nil {
241
+ t.Fatal("removal with active run accepted")
242
+ }
243
+
244
+ active.set("basicFlow", false)
245
+ if err := svc.Remove(context.Background(), "basicFlow"); err != nil {
246
+ t.Fatalf("removal without active runs rejected: %v", err)
247
+ }
248
+ if _, err := svc.Get("basicFlow"); err == nil {
249
+ t.Fatal("removed workflow still returned by Get")
250
+ }
251
+ // Registry updated on removal: List no longer returns it.
252
+ for _, w := range svc.List() {
253
+ if w.Name == "basicFlow" {
254
+ t.Fatal("removed workflow still listed")
255
+ }
256
+ }
257
+ }
258
+
259
+ func TestRegistryReferencesRepoAndReplace(t *testing.T) {
260
+ reg := &workflow.Registry{}
261
+ wf, err := workflow.Parse("basicFlow", []byte(storeValid))
262
+ if err != nil {
263
+ t.Fatal(err)
264
+ }
265
+ reg.Replace(wf)
266
+ if !reg.ReferencesRepo("payments") {
267
+ t.Fatal("ReferencesRepo(payments) = false")
268
+ }
269
+ if reg.ReferencesRepo("other") {
270
+ t.Fatal("ReferencesRepo(other) = true")
271
+ }
272
+ if got, ok := reg.Get("basicFlow"); !ok || got.Name != "basicFlow" {
273
+ t.Fatalf("Registry.Get = %v, %v", got, ok)
274
+ }
275
+ reg.Remove("basicFlow")
276
+ if _, ok := reg.Get("basicFlow"); ok {
277
+ t.Fatal("Registry.Get after Remove found workflow")
278
+ }
279
+ if reg.ReferencesRepo("payments") {
280
+ t.Fatal("ReferencesRepo still true after removal")
281
+ }
282
+ }
@@ -0,0 +1,342 @@
1
+ // Package workflow defines workflow values, strict parsing, validation,
2
+ // structured reports, storage, and the workflow service.
3
+ package workflow
4
+
5
+ import (
6
+ "bytes"
7
+ "fmt"
8
+ "regexp"
9
+ "sort"
10
+
11
+ "gopkg.in/yaml.v3"
12
+
13
+ "github.com/rajpopat27/relay-flow/internal/config"
14
+ )
15
+
16
+ type NodeType string
17
+
18
+ const (
19
+ NodeAgent NodeType = "agent"
20
+ NodeHITL NodeType = "hitl"
21
+ )
22
+
23
+ type Outcome string
24
+
25
+ const (
26
+ OutcomeSuccess Outcome = "success"
27
+ OutcomeFailure Outcome = "failure"
28
+ )
29
+
30
+ // Reserved lifecycle node names. The word "terminal" is runner-only.
31
+ const (
32
+ StartNode = "start"
33
+ EndNode = "end"
34
+ )
35
+
36
+ type Workflow struct {
37
+ Name string `yaml:"name" json:"name"`
38
+ Repos []string `yaml:"repos" json:"repos"`
39
+ CleanupRunnerOnEnd bool `yaml:"cleanupRunnerOnEnd" json:"cleanupRunnerOnEnd"`
40
+ TaskConfig config.RawValues `yaml:"taskConfig,omitempty" json:"taskConfig,omitempty"`
41
+ Nodes map[string]Node `yaml:"nodes" json:"nodes"`
42
+ }
43
+
44
+ type Node struct {
45
+ Type NodeType `yaml:"type,omitempty" json:"type,omitempty"`
46
+ Agent string `yaml:"agent,omitempty" json:"agent,omitempty"`
47
+ Description string `yaml:"description,omitempty" json:"description,omitempty"`
48
+ NudgePrompt string `yaml:"nudgePrompt,omitempty" json:"nudgePrompt,omitempty"`
49
+ TaskConfig config.RawValues `yaml:"taskConfig,omitempty" json:"taskConfig,omitempty"`
50
+ OnSuccess []Route `yaml:"onSuccess,omitempty" json:"onSuccess,omitempty"`
51
+ OnFailure []Route `yaml:"onFailure,omitempty" json:"onFailure,omitempty"`
52
+ }
53
+
54
+ type Route struct {
55
+ Target string `yaml:"target" json:"target"`
56
+ When string `yaml:"when,omitempty" json:"when,omitempty"`
57
+ }
58
+
59
+ type NudgeTemplateData struct {
60
+ Ticket string
61
+ Workflow string
62
+ Repo string
63
+ Node string
64
+ NextSteps string
65
+ }
66
+
67
+ var namePattern = regexp.MustCompile(`^[a-z][a-zA-Z0-9]*$`)
68
+
69
+ var nudgeVarPattern = regexp.MustCompile(`\{\{([^{}]*)\}\}`)
70
+
71
+ var knownNudgeVars = map[string]bool{
72
+ "ticket": true, "workflow": true, "repo": true, "node": true, "nextSteps": true,
73
+ }
74
+
75
+ // Parse strictly decodes a workflow YAML document. Unknown fields and
76
+ // explicit null values inside any taskConfig are rejected. The returned
77
+ // workflow is not yet validated.
78
+ func Parse(name string, yamlBytes []byte) (*Workflow, error) {
79
+ var raw map[string]any
80
+ if err := yaml.Unmarshal(yamlBytes, &raw); err != nil {
81
+ return nil, fmt.Errorf("parse workflow %q: %w", name, err)
82
+ }
83
+ var wf Workflow
84
+ dec := yaml.NewDecoder(bytes.NewReader(yamlBytes))
85
+ dec.KnownFields(true)
86
+ if err := dec.Decode(&wf); err != nil {
87
+ return nil, fmt.Errorf("parse workflow %q: %w", name, err)
88
+ }
89
+ if wf.Nodes == nil {
90
+ wf.Nodes = map[string]Node{}
91
+ }
92
+ if v, present := raw["taskConfig"]; present && v == nil {
93
+ return nil, fmt.Errorf("parse workflow %q: taskConfig: explicit null is not allowed", name)
94
+ }
95
+ if err := rejectNullValues(wf.TaskConfig, "taskConfig"); err != nil {
96
+ return nil, fmt.Errorf("parse workflow %q: %w", name, err)
97
+ }
98
+ rawNodes, _ := raw["nodes"].(map[string]any)
99
+ for nodeName, n := range wf.Nodes {
100
+ if rn, ok := rawNodes[nodeName].(map[string]any); ok {
101
+ if v, present := rn["taskConfig"]; present && v == nil {
102
+ return nil, fmt.Errorf("parse workflow %q node %q: taskConfig: explicit null is not allowed", name, nodeName)
103
+ }
104
+ }
105
+ if err := rejectNullValues(n.TaskConfig, "nodes."+nodeName+".taskConfig"); err != nil {
106
+ return nil, fmt.Errorf("parse workflow %q: %w", name, err)
107
+ }
108
+ }
109
+ return &wf, nil
110
+ }
111
+
112
+ // rejectNullValues recursively rejects explicit YAML nulls in raw task
113
+ // config values.
114
+ func rejectNullValues(values map[string]any, path string) error {
115
+ for k, v := range values {
116
+ key := path + "." + k
117
+ if err := rejectNullValue(v, key); err != nil {
118
+ return err
119
+ }
120
+ }
121
+ return nil
122
+ }
123
+
124
+ func rejectNullValue(v any, key string) error {
125
+ if v == nil {
126
+ return fmt.Errorf("config key %q: explicit null is not allowed", key)
127
+ }
128
+ switch t := v.(type) {
129
+ case map[string]any:
130
+ return rejectNullValues(t, key)
131
+ case []any:
132
+ for i, elem := range t {
133
+ if err := rejectNullValue(elem, fmt.Sprintf("%s[%d]", key, i)); err != nil {
134
+ return err
135
+ }
136
+ }
137
+ }
138
+ return nil
139
+ }
140
+
141
+ // Validate checks the workflow graph, identity, repos, nodes, routes, and
142
+ // nudge templates against the workflow-definition spec.
143
+ func (w *Workflow) Validate() error {
144
+ if !namePattern.MatchString(w.Name) {
145
+ return fmt.Errorf("workflow name %q must be lower-camel alphanumeric beginning with a lowercase letter", w.Name)
146
+ }
147
+ if len(w.Repos) == 0 {
148
+ return fmt.Errorf("workflow %q must list at least one repo", w.Name)
149
+ }
150
+ seen := map[string]bool{}
151
+ for _, r := range w.Repos {
152
+ if seen[r] {
153
+ return fmt.Errorf("workflow %q lists duplicate repo %q", w.Name, r)
154
+ }
155
+ seen[r] = true
156
+ }
157
+
158
+ start, ok := w.Nodes[StartNode]
159
+ if !ok {
160
+ return fmt.Errorf("workflow %q must contain a %q node", w.Name, StartNode)
161
+ }
162
+ if err := validateStart(start); err != nil {
163
+ return fmt.Errorf("workflow %q start: %w", w.Name, err)
164
+ }
165
+ end, ok := w.Nodes[EndNode]
166
+ if !ok {
167
+ return fmt.Errorf("workflow %q must contain an %q node", w.Name, EndNode)
168
+ }
169
+ if err := validateEnd(end); err != nil {
170
+ return fmt.Errorf("workflow %q end: %w", w.Name, err)
171
+ }
172
+
173
+ for name, n := range w.Nodes {
174
+ if name == StartNode || name == EndNode {
175
+ continue
176
+ }
177
+ if err := validateWorkNode(w.Name, name, n); err != nil {
178
+ return err
179
+ }
180
+ }
181
+ for name, n := range w.Nodes {
182
+ for _, r := range allRoutes(n) {
183
+ if r.Target == StartNode {
184
+ return fmt.Errorf("workflow %q node %q: routes cannot target reserved %q", w.Name, name, StartNode)
185
+ }
186
+ if _, ok := w.Nodes[r.Target]; !ok {
187
+ return fmt.Errorf("workflow %q node %q: route target %q does not exist", w.Name, name, r.Target)
188
+ }
189
+ }
190
+ }
191
+ if err := w.validateReachability(); err != nil {
192
+ return err
193
+ }
194
+ return nil
195
+ }
196
+
197
+ func validateStart(n Node) error {
198
+ if n.Type != "" || n.Agent != "" || n.Description != "" || n.NudgePrompt != "" {
199
+ return fmt.Errorf("start must not declare type, agent, description, or nudgePrompt")
200
+ }
201
+ if len(n.OnFailure) != 0 {
202
+ return fmt.Errorf("start must not declare failure routes")
203
+ }
204
+ if len(n.OnSuccess) != 1 {
205
+ return fmt.Errorf("start must declare exactly one success target, got %d", len(n.OnSuccess))
206
+ }
207
+ return nil
208
+ }
209
+
210
+ func validateEnd(n Node) error {
211
+ if n.Type != "" || n.Agent != "" || n.Description != "" || n.NudgePrompt != "" {
212
+ return fmt.Errorf("end must not declare type, agent, description, or nudgePrompt")
213
+ }
214
+ if len(n.OnSuccess) != 0 || len(n.OnFailure) != 0 {
215
+ return fmt.Errorf("end must not declare routes")
216
+ }
217
+ return nil
218
+ }
219
+
220
+ func validateWorkNode(wfName, name string, n Node) error {
221
+ if n.Type != NodeAgent && n.Type != NodeHITL {
222
+ return fmt.Errorf("workflow %q node %q: type must be %q or %q", wfName, name, NodeAgent, NodeHITL)
223
+ }
224
+ if n.Agent == "" {
225
+ return fmt.Errorf("workflow %q node %q: agent is required", wfName, name)
226
+ }
227
+ if n.Description == "" {
228
+ return fmt.Errorf("workflow %q node %q: description is required", wfName, name)
229
+ }
230
+ if len(n.OnSuccess) == 0 {
231
+ return fmt.Errorf("workflow %q node %q: at least one success route is required", wfName, name)
232
+ }
233
+ if len(n.OnFailure) == 0 {
234
+ return fmt.Errorf("workflow %q node %q: at least one failure route is required", wfName, name)
235
+ }
236
+ for _, m := range nudgeVarPattern.FindAllStringSubmatch(n.NudgePrompt, -1) {
237
+ if !knownNudgeVars[m[1]] {
238
+ return fmt.Errorf("workflow %q node %q: unknown nudge template variable {{%s}}", wfName, name, m[1])
239
+ }
240
+ }
241
+ return nil
242
+ }
243
+
244
+ func allRoutes(n Node) []Route {
245
+ out := make([]Route, 0, len(n.OnSuccess)+len(n.OnFailure))
246
+ out = append(out, n.OnSuccess...)
247
+ out = append(out, n.OnFailure...)
248
+ return out
249
+ }
250
+
251
+ // validateReachability ensures every work node is reachable from start and
252
+ // that end is reachable. Backward edges and self-loops are allowed.
253
+ func (w *Workflow) validateReachability() error {
254
+ adj := map[string][]string{}
255
+ for name, n := range w.Nodes {
256
+ for _, r := range allRoutes(n) {
257
+ adj[name] = append(adj[name], r.Target)
258
+ }
259
+ }
260
+ visited := map[string]bool{}
261
+ queue := []string{StartNode}
262
+ for len(queue) > 0 {
263
+ cur := queue[0]
264
+ queue = queue[1:]
265
+ if visited[cur] {
266
+ continue
267
+ }
268
+ visited[cur] = true
269
+ queue = append(queue, adj[cur]...)
270
+ }
271
+ for name := range w.Nodes {
272
+ if !visited[name] {
273
+ return fmt.Errorf("workflow %q node %q is not reachable from %q", w.Name, name, StartNode)
274
+ }
275
+ }
276
+ return nil
277
+ }
278
+
279
+ // StartTarget returns the single success target of the start node.
280
+ func (w *Workflow) StartTarget() (string, error) {
281
+ start, ok := w.Nodes[StartNode]
282
+ if !ok {
283
+ return "", fmt.Errorf("workflow %q has no %q node", w.Name, StartNode)
284
+ }
285
+ if len(start.OnSuccess) != 1 {
286
+ return "", fmt.Errorf("workflow %q start must have exactly one success target, got %d", w.Name, len(start.OnSuccess))
287
+ }
288
+ return start.OnSuccess[0].Target, nil
289
+ }
290
+
291
+ // Routes returns the configured routes for a node and outcome.
292
+ func (w *Workflow) Routes(node string, outcome Outcome) ([]Route, error) {
293
+ n, ok := w.Nodes[node]
294
+ if !ok {
295
+ return nil, fmt.Errorf("workflow %q has no node %q", w.Name, node)
296
+ }
297
+ switch outcome {
298
+ case OutcomeSuccess:
299
+ return n.OnSuccess, nil
300
+ case OutcomeFailure:
301
+ return n.OnFailure, nil
302
+ default:
303
+ return nil, fmt.Errorf("workflow %q node %q: unknown outcome %q", w.Name, node, outcome)
304
+ }
305
+ }
306
+
307
+ // RenderNudge renders the node's optional custom instructions with the
308
+ // supported variables.
309
+ func (w *Workflow) RenderNudge(node string, data NudgeTemplateData) (string, error) {
310
+ n, ok := w.Nodes[node]
311
+ if !ok {
312
+ return "", fmt.Errorf("workflow %q has no node %q", w.Name, node)
313
+ }
314
+ tmpl := n.NudgePrompt
315
+ out := nudgeVarPattern.ReplaceAllStringFunc(tmpl, func(m string) string {
316
+ varName := nudgeVarPattern.FindStringSubmatch(m)[1]
317
+ switch varName {
318
+ case "ticket":
319
+ return data.Ticket
320
+ case "workflow":
321
+ return data.Workflow
322
+ case "repo":
323
+ return data.Repo
324
+ case "node":
325
+ return data.Node
326
+ case "nextSteps":
327
+ return data.NextSteps
328
+ }
329
+ return m
330
+ })
331
+ return out, nil
332
+ }
333
+
334
+ // sortedTargets is a helper for deterministic error messages.
335
+ func sortedTargets(routes []Route) []string {
336
+ out := make([]string, 0, len(routes))
337
+ for _, r := range routes {
338
+ out = append(out, r.Target)
339
+ }
340
+ sort.Strings(out)
341
+ return out
342
+ }