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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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
|
+
}
|