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,410 @@
1
+ package workflow_test
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/workflow"
8
+ )
9
+
10
+ // 3.1: parse/validate behavior per specs/workflow-definition.
11
+
12
+ const minimalValid = `
13
+ name: basicFlow
14
+ repos: [payments]
15
+ nodes:
16
+ start:
17
+ onSuccess:
18
+ - target: coding
19
+ coding:
20
+ type: agent
21
+ agent: build
22
+ description: Do the coding work.
23
+ onSuccess:
24
+ - target: end
25
+ onFailure:
26
+ - target: coding
27
+ end: {}
28
+ `
29
+
30
+ func parse(t *testing.T, name, yaml string) *workflow.Workflow {
31
+ t.Helper()
32
+ wf, err := workflow.Parse(name, []byte(yaml))
33
+ if err != nil {
34
+ t.Fatalf("Parse(%q) failed: %v", name, err)
35
+ }
36
+ return wf
37
+ }
38
+
39
+ func TestParseMinimalValid(t *testing.T) {
40
+ wf := parse(t, "basicFlow", minimalValid)
41
+ if err := wf.Validate(); err != nil {
42
+ t.Fatalf("Validate failed: %v", err)
43
+ }
44
+ if wf.Name != "basicFlow" {
45
+ t.Fatalf("Name = %q, want basicFlow", wf.Name)
46
+ }
47
+ }
48
+
49
+ func TestParseRejectsUnknownRootFields(t *testing.T) {
50
+ // Legacy fields and plugin selectors are rejected by strict parsing.
51
+ for _, tc := range []struct{ name, yaml string }{
52
+ {"legacy closeOn", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ncloseOn: [end]", 1)},
53
+ {"legacy tasks", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ntasks: {}", 1)},
54
+ {"runner plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\nrunnerPlugin: orca", 1)},
55
+ {"harness plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\nharnessPlugin: opencode", 1)},
56
+ {"task plugin selector", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ntaskPlugin: jira", 1)},
57
+ {"poll interval override", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\npollIntervalSeconds: 5", 1)},
58
+ {"arbitrary query field", strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\njql: project = PAY", 1)},
59
+ {"legacy node-level when", strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n when: In Progress", 1)},
60
+ {"unknown node-level field", strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n timeout: 30", 1)},
61
+ } {
62
+ t.Run(tc.name, func(t *testing.T) {
63
+ if _, err := workflow.Parse("basicFlow", []byte(tc.yaml)); err == nil {
64
+ t.Fatal("Parse succeeded, want strict-schema rejection")
65
+ }
66
+ })
67
+ }
68
+ }
69
+
70
+ func TestValidateWorkflowName(t *testing.T) {
71
+ for _, bad := range []string{"BasicFlow", "basic flow", "basic-flow", "basic_flow", "1flow", ""} {
72
+ yaml := strings.Replace(minimalValid, "name: basicFlow", "name: "+bad, 1)
73
+ wf, err := workflow.Parse(bad, []byte(yaml))
74
+ if err == nil {
75
+ err = wf.Validate()
76
+ }
77
+ if err == nil {
78
+ t.Fatalf("name %q accepted, want rejection", bad)
79
+ }
80
+ }
81
+ if err := parse(t, "basicFlow", minimalValid).Validate(); err != nil {
82
+ t.Fatalf("basicFlow rejected: %v", err)
83
+ }
84
+ }
85
+
86
+ func TestValidateReposRequiredAndUnique(t *testing.T) {
87
+ t.Run("no repos", func(t *testing.T) {
88
+ yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: []", 1)
89
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
90
+ if err := wf.Validate(); err == nil {
91
+ t.Fatal("empty repos accepted")
92
+ }
93
+ })
94
+ t.Run("duplicate repo", func(t *testing.T) {
95
+ yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: [payments, payments]", 1)
96
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
97
+ if err := wf.Validate(); err == nil {
98
+ t.Fatal("duplicate repo accepted")
99
+ }
100
+ })
101
+ }
102
+
103
+ func TestValidateStartNode(t *testing.T) {
104
+ t.Run("missing start", func(t *testing.T) {
105
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:\n - target: coding\n", "", 1)
106
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
107
+ if err := wf.Validate(); err == nil {
108
+ t.Fatal("workflow without start accepted")
109
+ }
110
+ })
111
+ t.Run("start with several targets", func(t *testing.T) {
112
+ yaml := strings.Replace(minimalValid, " - target: coding\n coding:", " - target: coding\n - target: end\n coding:", 1)
113
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
114
+ if err := wf.Validate(); err == nil {
115
+ t.Fatal("start with two success routes accepted")
116
+ }
117
+ })
118
+ t.Run("start with agent", func(t *testing.T) {
119
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n agent: build\n onSuccess:", 1)
120
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
121
+ if err := wf.Validate(); err == nil {
122
+ t.Fatal("start with agent accepted")
123
+ }
124
+ })
125
+ t.Run("start with type", func(t *testing.T) {
126
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n type: agent\n onSuccess:", 1)
127
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
128
+ if err := wf.Validate(); err == nil {
129
+ t.Fatal("start with type accepted")
130
+ }
131
+ })
132
+ t.Run("start with description", func(t *testing.T) {
133
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n description: nope\n onSuccess:", 1)
134
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
135
+ if err := wf.Validate(); err == nil {
136
+ t.Fatal("start with description accepted")
137
+ }
138
+ })
139
+ t.Run("start with nudge prompt", func(t *testing.T) {
140
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n nudgePrompt: nope\n onSuccess:", 1)
141
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
142
+ if err := wf.Validate(); err == nil {
143
+ t.Fatal("start with nudgePrompt accepted")
144
+ }
145
+ })
146
+ t.Run("start with failure route", func(t *testing.T) {
147
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:\n - target: coding", " start:\n onSuccess:\n - target: coding\n onFailure:\n - target: coding", 1)
148
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
149
+ if err := wf.Validate(); err == nil {
150
+ t.Fatal("start with onFailure accepted")
151
+ }
152
+ })
153
+ t.Run("start with taskConfig ok", func(t *testing.T) {
154
+ yaml := strings.Replace(minimalValid, " start:\n onSuccess:", " start:\n taskConfig:\n transitionTo:\n parentStatus: In Progress\n onSuccess:", 1)
155
+ wf := parse(t, "basicFlow", yaml)
156
+ if err := wf.Validate(); err != nil {
157
+ t.Fatalf("start with taskConfig rejected: %v", err)
158
+ }
159
+ })
160
+ }
161
+
162
+ func TestValidateEndNode(t *testing.T) {
163
+ t.Run("missing end", func(t *testing.T) {
164
+ yaml := strings.Replace(minimalValid, " end: {}\n", "", 1)
165
+ // coding needs a success target that exists; point it at itself.
166
+ yaml = strings.Replace(yaml, " - target: end", " - target: coding", 1)
167
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
168
+ if err := wf.Validate(); err == nil {
169
+ t.Fatal("workflow without end accepted")
170
+ }
171
+ })
172
+ t.Run("end with route", func(t *testing.T) {
173
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n onSuccess:\n - target: coding", 1)
174
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
175
+ if err := wf.Validate(); err == nil {
176
+ t.Fatal("end with outgoing route accepted")
177
+ }
178
+ })
179
+ t.Run("end with type", func(t *testing.T) {
180
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n type: agent", 1)
181
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
182
+ if err := wf.Validate(); err == nil {
183
+ t.Fatal("end with type accepted")
184
+ }
185
+ })
186
+ t.Run("end with agent", func(t *testing.T) {
187
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n agent: build", 1)
188
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
189
+ if err := wf.Validate(); err == nil {
190
+ t.Fatal("end with agent accepted")
191
+ }
192
+ })
193
+ t.Run("end with description", func(t *testing.T) {
194
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n description: nope", 1)
195
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
196
+ if err := wf.Validate(); err == nil {
197
+ t.Fatal("end with description accepted")
198
+ }
199
+ })
200
+ t.Run("end with nudge prompt", func(t *testing.T) {
201
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n nudgePrompt: nope", 1)
202
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
203
+ if err := wf.Validate(); err == nil {
204
+ t.Fatal("end with nudgePrompt accepted")
205
+ }
206
+ })
207
+ t.Run("end with failure route", func(t *testing.T) {
208
+ yaml := strings.Replace(minimalValid, " end: {}", " end:\n onFailure:\n - target: coding", 1)
209
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
210
+ if err := wf.Validate(); err == nil {
211
+ t.Fatal("end with failure route accepted")
212
+ }
213
+ })
214
+ t.Run("terminal is not a lifecycle node", func(t *testing.T) {
215
+ yaml := strings.Replace(minimalValid, " end: {}", " terminal: {}", 1)
216
+ yaml = strings.Replace(yaml, " - target: end", " - target: terminal", 1)
217
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
218
+ if err := wf.Validate(); err == nil {
219
+ t.Fatal("workflow using terminal as lifecycle exit accepted; reserved exit is end")
220
+ }
221
+ })
222
+ }
223
+
224
+ func TestValidateWorkNodes(t *testing.T) {
225
+ replaceCoding := func(node string) string {
226
+ return strings.Replace(minimalValid, " coding:\n type: agent\n agent: build\n description: Do the coding work.\n onSuccess:\n - target: end\n onFailure:\n - target: coding", node, 1)
227
+ }
228
+ t.Run("missing type", func(t *testing.T) {
229
+ wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n agent: build\n description: work\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
230
+ if err := wf.Validate(); err == nil {
231
+ t.Fatal("work node without type accepted")
232
+ }
233
+ })
234
+ t.Run("missing agent", func(t *testing.T) {
235
+ wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n description: work\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
236
+ if err := wf.Validate(); err == nil {
237
+ t.Fatal("work node without agent accepted")
238
+ }
239
+ })
240
+ t.Run("missing description", func(t *testing.T) {
241
+ wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n onSuccess:\n - target: end\n onFailure:\n - target: coding")))
242
+ if err := wf.Validate(); err == nil {
243
+ t.Fatal("work node without description accepted")
244
+ }
245
+ })
246
+ t.Run("missing failure route", func(t *testing.T) {
247
+ wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n description: work\n onSuccess:\n - target: end")))
248
+ if err := wf.Validate(); err == nil {
249
+ t.Fatal("work node without failure route accepted")
250
+ }
251
+ })
252
+ t.Run("missing success route", func(t *testing.T) {
253
+ wf, _ := workflow.Parse("basicFlow", []byte(replaceCoding(" coding:\n type: agent\n agent: build\n description: work\n onFailure:\n - target: coding")))
254
+ if err := wf.Validate(); err == nil {
255
+ t.Fatal("work node without success route accepted")
256
+ }
257
+ })
258
+ t.Run("valid hitl node", func(t *testing.T) {
259
+ wf := parse(t, "basicFlow", replaceCoding(" coding:\n type: hitl\n agent: build\n description: human-guided work\n onSuccess:\n - target: end\n onFailure:\n - target: coding"))
260
+ if err := wf.Validate(); err != nil {
261
+ t.Fatalf("valid hitl node rejected: %v", err)
262
+ }
263
+ if wf.Nodes["coding"].Type != workflow.NodeHITL {
264
+ t.Fatalf("Type = %q, want hitl", wf.Nodes["coding"].Type)
265
+ }
266
+ })
267
+ }
268
+
269
+ func TestValidateRoutes(t *testing.T) {
270
+ t.Run("dangling target", func(t *testing.T) {
271
+ yaml := strings.Replace(minimalValid, " - target: end", " - target: nowhere", 1)
272
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
273
+ if err := wf.Validate(); err == nil {
274
+ t.Fatal("route to unknown node accepted")
275
+ }
276
+ })
277
+ t.Run("route targets start", func(t *testing.T) {
278
+ yaml := strings.Replace(minimalValid, " - target: coding\n end:", " - target: start\n end:", 1)
279
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
280
+ if err := wf.Validate(); err == nil {
281
+ t.Fatal("route targeting start accepted")
282
+ }
283
+ })
284
+ t.Run("failure route targets start", func(t *testing.T) {
285
+ yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: start", 1)
286
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
287
+ if err := wf.Validate(); err == nil {
288
+ t.Fatal("failure route targeting start accepted")
289
+ }
290
+ })
291
+ t.Run("when is allowed and not a condition", func(t *testing.T) {
292
+ yaml := strings.Replace(minimalValid, " - target: end", " - target: end\n when: work is complete", 1)
293
+ wf := parse(t, "basicFlow", yaml)
294
+ if err := wf.Validate(); err != nil {
295
+ t.Fatalf("route with when rejected: %v", err)
296
+ }
297
+ routes, err := wf.Routes("coding", workflow.OutcomeSuccess)
298
+ if err != nil {
299
+ t.Fatalf("Routes failed: %v", err)
300
+ }
301
+ if len(routes) != 1 || routes[0].When != "work is complete" {
302
+ t.Fatalf("Routes = %+v", routes)
303
+ }
304
+ })
305
+ t.Run("several routes for one outcome", func(t *testing.T) {
306
+ yaml := strings.Replace(minimalValid, " onFailure:\n - target: coding", " onFailure:\n - target: coding\n when: retry\n - target: end\n when: give up", 1)
307
+ wf := parse(t, "basicFlow", yaml)
308
+ if err := wf.Validate(); err != nil {
309
+ t.Fatalf("two failure routes rejected: %v", err)
310
+ }
311
+ routes, err := wf.Routes("coding", workflow.OutcomeFailure)
312
+ if err != nil || len(routes) != 2 {
313
+ t.Fatalf("Routes = %+v, err=%v", routes, err)
314
+ }
315
+ })
316
+ }
317
+
318
+ func TestValidateGraphReachability(t *testing.T) {
319
+ t.Run("unreachable node", func(t *testing.T) {
320
+ yaml := minimalValid + `
321
+ orphan:
322
+ type: agent
323
+ agent: build
324
+ description: never reached
325
+ onSuccess:
326
+ - target: end
327
+ onFailure:
328
+ - target: orphan
329
+ `
330
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
331
+ if err := wf.Validate(); err == nil {
332
+ t.Fatal("unreachable node accepted")
333
+ }
334
+ })
335
+ t.Run("end unreachable", func(t *testing.T) {
336
+ yaml := strings.Replace(minimalValid, " - target: end", " - target: coding", 1)
337
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
338
+ if err := wf.Validate(); err == nil {
339
+ t.Fatal("workflow with unreachable end accepted")
340
+ }
341
+ })
342
+ t.Run("self loop allowed", func(t *testing.T) {
343
+ // minimalValid already has coding onFailure -> coding.
344
+ wf := parse(t, "basicFlow", minimalValid)
345
+ if err := wf.Validate(); err != nil {
346
+ t.Fatalf("self-loop rejected: %v", err)
347
+ }
348
+ })
349
+ }
350
+
351
+ func TestValidateNudgeTemplate(t *testing.T) {
352
+ t.Run("supported variables", func(t *testing.T) {
353
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"{{ticket}} {{workflow}} {{repo}} {{node}} {{nextSteps}}\"", 1)
354
+ wf := parse(t, "basicFlow", yaml)
355
+ if err := wf.Validate(); err != nil {
356
+ t.Fatalf("supported nudge variables rejected: %v", err)
357
+ }
358
+ })
359
+ t.Run("unknown variable rejected", func(t *testing.T) {
360
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"hello {{assignee}}\"", 1)
361
+ wf, _ := workflow.Parse("basicFlow", []byte(yaml))
362
+ if err := wf.Validate(); err == nil {
363
+ t.Fatal("nudge with unknown variable {{assignee}} accepted")
364
+ }
365
+ })
366
+ }
367
+
368
+ func TestStartTarget(t *testing.T) {
369
+ wf := parse(t, "basicFlow", minimalValid)
370
+ target, err := wf.StartTarget()
371
+ if err != nil {
372
+ t.Fatalf("StartTarget failed: %v", err)
373
+ }
374
+ if target != "coding" {
375
+ t.Fatalf("StartTarget = %q, want coding", target)
376
+ }
377
+ }
378
+
379
+ func TestCleanupRunnerOnEndDefaultsFalse(t *testing.T) {
380
+ wf := parse(t, "basicFlow", minimalValid)
381
+ if wf.CleanupRunnerOnEnd {
382
+ t.Fatal("CleanupRunnerOnEnd defaulted to true, want false")
383
+ }
384
+ yaml := strings.Replace(minimalValid, "repos: [payments]", "repos: [payments]\ncleanupRunnerOnEnd: true", 1)
385
+ wf = parse(t, "basicFlow", yaml)
386
+ if !wf.CleanupRunnerOnEnd {
387
+ t.Fatal("CleanupRunnerOnEnd true not parsed")
388
+ }
389
+ }
390
+
391
+ func TestRenderNudge(t *testing.T) {
392
+ yaml := strings.Replace(minimalValid, " description: Do the coding work.", " description: Do the coding work.\n nudgePrompt: \"ticket={{ticket}} wf={{workflow}} repo={{repo}} node={{node}} steps={{nextSteps}}\"", 1)
393
+ wf := parse(t, "basicFlow", yaml)
394
+ out, err := wf.RenderNudge("coding", workflow.NudgeTemplateData{
395
+ Ticket: "PAY-101", Workflow: "basicFlow", Repo: "payments", Node: "coding", NextSteps: "end",
396
+ })
397
+ if err != nil {
398
+ t.Fatalf("RenderNudge failed: %v", err)
399
+ }
400
+ want := "ticket=PAY-101 wf=basicFlow repo=payments node=coding steps=end"
401
+ if out != want {
402
+ t.Fatalf("RenderNudge = %q, want %q", out, want)
403
+ }
404
+
405
+ wf.Nodes["coding"] = workflow.Node{Type: workflow.NodeAgent}
406
+ out, err = wf.RenderNudge("coding", workflow.NudgeTemplateData{})
407
+ if err != nil || out != "" {
408
+ t.Fatalf("empty RenderNudge = %q, %v; want empty", out, err)
409
+ }
410
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "relay-flow",
3
- "version": "0.0.1",
3
+ "version": "0.2.0-alpha",
4
4
  "description": "Graph-based agent workflow engine — tracker-agnostic, pluggable runners",
5
5
  "bin": {
6
6
  "relay-flow": "./bin/relay-flow.js",
@@ -1,229 +0,0 @@
1
- // Package acli wraps the Atlassian `acli` CLI. Every call is real — no
2
- // dry-run mode: acli/Jira calls always execute (only orca CLI calls
3
- // support dry-run).
4
- package acli
5
-
6
- import (
7
- "encoding/json"
8
- "fmt"
9
- "log"
10
- "os/exec"
11
- "strings"
12
- )
13
-
14
- type Client struct{}
15
-
16
- func New() *Client {
17
- return &Client{}
18
- }
19
-
20
- // rawSearchResult matches acli search's minimal JSON shape: only `key`.
21
- type rawSearchResult struct {
22
- Key string `json:"key"`
23
- }
24
-
25
- // rawTicket matches acli view's JSON shape: `key` is top-level, other
26
- // fields live under `fields`.
27
- type rawTicket struct {
28
- Key string `json:"key"`
29
- Fields struct {
30
- Summary string `json:"summary"`
31
- Status struct {
32
- Name string `json:"name"`
33
- } `json:"status"`
34
- Components []struct {
35
- Name string `json:"name"`
36
- } `json:"components"`
37
- IssueType struct {
38
- Name string `json:"name"`
39
- } `json:"issuetype"`
40
- Description json.RawMessage `json:"description"`
41
- Parent struct {
42
- Key string `json:"key"`
43
- } `json:"parent"`
44
- Labels []string `json:"labels"`
45
- } `json:"fields"`
46
- }
47
-
48
- type Ticket struct {
49
- Key string
50
- Summary string
51
- Description string
52
- Status string
53
- IssueType string
54
- // Component resolves to an Orca repo by matching repo displayName.
55
- Component string
56
- // ParentKey is the Jira parent ticket's key (native subtask parent
57
- // link), if any. Subtasks reuse their parent's worktree/base branch
58
- // instead of branching from main.
59
- ParentKey string
60
- // Labels are the ticket's Jira labels, e.g. "baseBranch:foo" or the
61
- // "orca-workflow:<name>" claim label.
62
- Labels []string
63
- }
64
-
65
- // LabelValue returns the value portion of a "prefix:value" label on the
66
- // ticket, if present, e.g. LabelValue("baseBranch") -> "foo" for label
67
- // "baseBranch:foo".
68
- func (t Ticket) LabelValue(prefix string) (string, bool) {
69
- for _, l := range t.Labels {
70
- if strings.HasPrefix(l, prefix+":") {
71
- return strings.TrimPrefix(l, prefix+":"), true
72
- }
73
- }
74
- return "", false
75
- }
76
-
77
- // adfText extracts plain text from a Jira ADF (Atlassian Document Format)
78
- // description by concatenating every "text" node.
79
- func adfText(raw json.RawMessage) string {
80
- var node struct {
81
- Text string `json:"text"`
82
- Content []json.RawMessage `json:"content"`
83
- }
84
- if err := json.Unmarshal(raw, &node); err != nil {
85
- return ""
86
- }
87
- var parts []string
88
- if node.Text != "" {
89
- parts = append(parts, node.Text)
90
- }
91
- for _, c := range node.Content {
92
- if t := adfText(c); t != "" {
93
- parts = append(parts, t)
94
- }
95
- }
96
- return strings.Join(parts, " ")
97
- }
98
-
99
- // Search runs `acli jira workitem search --jql <jql> --fields key --json`
100
- // to get matching ticket keys only, then fetches full details for each via
101
- // `view`. In dry-run mode, no subprocess is executed; caller should treat
102
- // result as empty.
103
- func (c *Client) Search(jql string) ([]Ticket, error) {
104
- // No --fields flag: default output always includes top-level "key",
105
- // which is all we need here. Full details are fetched per-ticket via view.
106
- cmd := exec.Command("acli", "jira", "workitem", "search",
107
- "--jql", jql,
108
- "--json")
109
- var stderr strings.Builder
110
- cmd.Stderr = &stderr
111
- out, err := cmd.Output()
112
- if err != nil {
113
- return nil, fmt.Errorf("acli search: %w: %s", err, strings.TrimSpace(stderr.String()))
114
- }
115
- var raw []rawSearchResult
116
- if err := json.Unmarshal(out, &raw); err != nil {
117
- return nil, fmt.Errorf("acli search: parse json: %w", err)
118
- }
119
- tickets := make([]Ticket, 0, len(raw))
120
- for _, r := range raw {
121
- t, err := c.View(r.Key)
122
- if err != nil {
123
- log.Printf("acli search: could not fetch details for %s: %v", r.Key, err)
124
- continue
125
- }
126
- tickets = append(tickets, t)
127
- }
128
- return tickets, nil
129
- }
130
-
131
- // ValidateAssignee checks that the given user (display name or accountId)
132
- // exists by running `assignee = "<user>"` as a JQL fragment — Jira's JQL
133
- // parser rejects unknown users with a hard error, like unknown statuses.
134
- func (c *Client) ValidateAssignee(assignee string) error {
135
- jql := fmt.Sprintf(`assignee = %q`, assignee)
136
- out, err := exec.Command("acli", "jira", "workitem", "search",
137
- "--jql", jql, "--json").CombinedOutput()
138
- if err != nil {
139
- return fmt.Errorf("assignee %q is not a valid Jira user: %s", assignee, strings.TrimSpace(string(out)))
140
- }
141
- return nil
142
- }
143
-
144
- // ValidateStatus checks that status is a real Jira status in the given
145
- // project by running `status = "<status>"` as a JQL fragment — Jira's JQL
146
- // parser rejects unknown status values with a hard error, so a typo in
147
- // the workflow YAML ("DO Done") fails here instead of silently never
148
- // matching at runtime. Returns nil if valid, error otherwise.
149
- func (c *Client) ValidateStatus(projectKey, status string) error {
150
- jql := fmt.Sprintf(`project = %s AND status = %q`, projectKey, status)
151
- out, err := exec.Command("acli", "jira", "workitem", "search",
152
- "--jql", jql, "--json").CombinedOutput()
153
- if err != nil {
154
- return fmt.Errorf("status %q is not valid in project %s: %s", status, projectKey, strings.TrimSpace(string(out)))
155
- }
156
- return nil
157
- }
158
-
159
- // view fetches full ticket details via
160
- // `acli jira workitem view <key> --fields status,assignee,reporter,components --json`.
161
- func (c *Client) View(key string) (Ticket, error) {
162
- out, err := exec.Command("acli", "jira", "workitem", "view", key,
163
- "--fields", "summary,description,status,components,issuetype,parent,labels",
164
- "--json").Output()
165
- if err != nil {
166
- return Ticket{}, fmt.Errorf("acli view %s: %w", key, err)
167
- }
168
- var r rawTicket
169
- if err := json.Unmarshal(out, &r); err != nil {
170
- return Ticket{}, fmt.Errorf("acli view %s: parse json: %w", key, err)
171
- }
172
- var t Ticket
173
- t.Key = r.Key
174
- t.Summary = r.Fields.Summary
175
- t.Description = adfText(r.Fields.Description)
176
- t.Status = r.Fields.Status.Name
177
- t.IssueType = r.Fields.IssueType.Name
178
- if len(r.Fields.Components) > 0 {
179
- t.Component = r.Fields.Components[0].Name
180
- }
181
- t.ParentKey = r.Fields.Parent.Key
182
- t.Labels = r.Fields.Labels
183
- return t, nil
184
- }
185
-
186
- // AddLabel adds label to the ticket's existing labels (acli's --labels
187
- // flag replaces the set, so the caller/View result must be merged in).
188
- func (c *Client) AddLabel(key string, existing []string, label string) error {
189
- for _, l := range existing {
190
- if l == label {
191
- return nil // already present
192
- }
193
- }
194
- all := append(append([]string{}, existing...), label)
195
- return runAcli("jira", "workitem", "edit", "--key", key, "--labels", strings.Join(all, ","), "--yes", "--json")
196
- }
197
-
198
- func (c *Client) Transition(key, status string) error {
199
- return runAcli("jira", "workitem", "transition", "--key", key, "--status", status, "--yes", "--json")
200
- }
201
-
202
- func (c *Client) Comment(key, body string) error {
203
- return runAcli("jira", "workitem", "comment", "create", "--key", key, "--body", body, "--json")
204
- }
205
-
206
- func runAcli(args ...string) error {
207
- out, err := exec.Command("acli", args...).CombinedOutput()
208
- if err != nil {
209
- return fmt.Errorf("acli %v: %w: %s", args, err, string(out))
210
- }
211
- // acli exits 0 even when the operation fails server-side — the JSON
212
- // envelope carries per-item results with status FAILURE.
213
- var env struct {
214
- Results []struct {
215
- Status string `json:"status"`
216
- Message string `json:"message"`
217
- ID string `json:"id"`
218
- } `json:"results"`
219
- SuccessCount int `json:"successCount"`
220
- }
221
- if jsonErr := json.Unmarshal(out, &env); jsonErr == nil && env.Results != nil {
222
- for _, r := range env.Results {
223
- if !strings.EqualFold(r.Status, "SUCCESS") {
224
- return fmt.Errorf("acli %v: %s: %s", args, r.ID, r.Message)
225
- }
226
- }
227
- }
228
- return nil
229
- }
@@ -1,17 +0,0 @@
1
- package config
2
-
3
- import (
4
- "os"
5
- "path/filepath"
6
- "testing"
7
- )
8
-
9
- func TestDemoWorkflowYAML(t *testing.T) {
10
- b, err := os.ReadFile(filepath.Join("..", "..", "..", ".workflow", "workflow.yaml"))
11
- if err != nil {
12
- t.Skip("demo yaml not present")
13
- }
14
- if _, err := Parse("demo", b); err != nil {
15
- t.Fatalf("demo .workflow/workflow.yaml must validate: %v", err)
16
- }
17
- }