relay-flow 0.2.1-alpha → 0.2.3-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 (83) hide show
  1. package/README.md +193 -25
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +79 -1
  4. package/cmd/relay-flow/main.go +50 -1
  5. package/cmd/relay-flow/scenario_test.go +48 -12
  6. package/cmd/relay-flow/serve.go +16 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +6 -6
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +124 -56
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +20 -0
  16. package/internal/execution/goworkflows/engine_test.go +33 -15
  17. package/internal/execution/goworkflows/fakes_test.go +84 -10
  18. package/internal/execution/goworkflows/interpreter.go +84 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_test.go +30 -5
  21. package/internal/execution/goworkflows/projection.go +52 -11
  22. package/internal/execution/goworkflows/recovery_test.go +131 -2
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/harness/contract_test.go +10 -0
  25. package/internal/harness/factory.go +25 -3
  26. package/internal/harness/harness.go +30 -4
  27. package/internal/harness/opencode/opencode.go +125 -10
  28. package/internal/harness/opencode/opencode_test.go +238 -0
  29. package/internal/harness/opencode/repo_setup.go +401 -0
  30. package/internal/harness/plugin_selection_test.go +5 -5
  31. package/internal/identity/identity.go +28 -1
  32. package/internal/identity/identity_test.go +40 -0
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/service.go +10 -0
  35. package/internal/repo/service_test.go +51 -2
  36. package/internal/run/manager.go +193 -25
  37. package/internal/run/run.go +30 -10
  38. package/internal/run/run_manager_test.go +100 -0
  39. package/internal/server/api_test.go +54 -0
  40. package/internal/server/client.go +11 -0
  41. package/internal/server/fixture_test.go +18 -0
  42. package/internal/server/server.go +16 -0
  43. package/internal/task/beads/bdcli/bdcli.go +323 -0
  44. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  45. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  46. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  47. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  48. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  49. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  50. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  51. package/internal/task/beads/bdcli/testdata/empty.json +1 -0
  52. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  56. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  57. package/internal/task/beads/beads.go +856 -0
  58. package/internal/task/beads/beads_test.go +609 -0
  59. package/internal/task/beads/comments_test.go +242 -0
  60. package/internal/task/beads/config_compatibility_test.go +163 -0
  61. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  62. package/internal/task/beads/repo_composition_test.go +232 -0
  63. package/internal/task/beads/runtime_config_test.go +81 -0
  64. package/internal/task/beads/status_compatibility_test.go +276 -0
  65. package/internal/task/beads/status_test.go +257 -0
  66. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  67. package/internal/task/beads/validation_test.go +110 -0
  68. package/internal/task/contract_test.go +10 -0
  69. package/internal/task/factory.go +37 -4
  70. package/internal/task/jira/auth.go +27 -1
  71. package/internal/task/jira/auth_test.go +54 -1
  72. package/internal/task/jira/filters_test.go +60 -0
  73. package/internal/task/jira/helpers_test.go +3 -0
  74. package/internal/task/jira/jira.go +187 -29
  75. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  76. package/internal/task/jira/rest/adf.go +119 -82
  77. package/internal/task/jira/rest/adf_test.go +60 -0
  78. package/internal/task/jira/rest/client_test.go +1 -1
  79. package/internal/task/jira/templates_test.go +118 -0
  80. package/internal/task/jira/transition_defaults_test.go +47 -2
  81. package/internal/task/task.go +40 -0
  82. package/internal/workflow/report_test.go +45 -0
  83. package/package.json +1 -1
@@ -0,0 +1,451 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "os"
6
+ "path/filepath"
7
+ "strings"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/config"
12
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
13
+ "github.com/rajpopat27/relay-flow/internal/repo"
14
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
15
+ "github.com/rajpopat27/relay-flow/internal/server"
16
+ "github.com/rajpopat27/relay-flow/internal/workflow"
17
+ )
18
+
19
+ // TestBeadsCompositionUsesRealRepoServiceAndDurableRun exercises the real
20
+ // composition path with the production Beads factory and bd subprocess seam.
21
+ // The fake bd intentionally blocks mailbox discovery until the test observes
22
+ // the durable run, making claim -> run creation -> mailbox creation ordering
23
+ // explicit rather than inferred from timing.
24
+ func TestBeadsCompositionUsesRealRepoServiceAndDurableRun(t *testing.T) {
25
+ base := t.TempDir()
26
+ relayHome := filepath.Join(base, "relay-flow")
27
+ codePath := filepath.Join(base, "code", "payments")
28
+ beadsDir := filepath.Join(base, "beads", "payments", ".beads")
29
+ for _, dir := range []string{relayHome, codePath, beadsDir} {
30
+ if err := os.MkdirAll(dir, 0o755); err != nil {
31
+ t.Fatal(err)
32
+ }
33
+ }
34
+ t.Setenv("RELAY_FLOW_HOME", relayHome)
35
+
36
+ logPath, releasePath, stateDir := installCompositionFakeBD(t, codePath, beadsDir)
37
+
38
+ p, err := home()
39
+ if err != nil {
40
+ t.Fatal(err)
41
+ }
42
+ if err := config.SaveMachine(p.Config, &config.Machine{
43
+ PollIntervalSeconds: 1,
44
+ CompletedRunRetentionDays: 1,
45
+ TaskPlugin: "beads",
46
+ RunnerPlugin: scenarioRunnerPlugin,
47
+ HarnessPlugin: scenarioHarnessPlugin,
48
+ Repos: map[string]config.Repo{},
49
+ }); err != nil {
50
+ t.Fatal(err)
51
+ }
52
+
53
+ log := newScenarioLog()
54
+ rnr := newScenarioRunner(log)
55
+ hrn := newScenarioHarness(log)
56
+ // The scenario runner and harness are the existing replaceable seams used
57
+ // by the composition tests; only the task system is the real Beads adapter.
58
+ setScenarioFactoryAdapters(nil, rnr, hrn)
59
+ repoService := repo.NewService(repo.ServiceConfig{
60
+ ConfigPath: p.Config,
61
+ TaskPlugin: "beads",
62
+ Runner: rnr,
63
+ Harness: hrn,
64
+ Active: compositionNoActiveRuns{},
65
+ Workflows: compositionNoWorkflowRefs{},
66
+ })
67
+ if _, err := repoService.Register(context.Background(), repo.RegisterInput{
68
+ Name: "payments",
69
+ Path: codePath,
70
+ TaskConfig: config.RawValues{"beadsDir": beadsDir},
71
+ }); err != nil {
72
+ t.Fatalf("register Beads repo: %v", err)
73
+ }
74
+ // Registration probes the real adapter once. Start the composition
75
+ // assertions with a clean command trace and clean fake state.
76
+ if err := os.WriteFile(logPath, nil, 0o600); err != nil {
77
+ t.Fatal(err)
78
+ }
79
+ if err := os.RemoveAll(stateDir); err != nil {
80
+ t.Fatal(err)
81
+ }
82
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
83
+ t.Fatal(err)
84
+ }
85
+
86
+ if err := os.MkdirAll(p.Workflows, 0o700); err != nil {
87
+ t.Fatal(err)
88
+ }
89
+ store := &workflow.Store{Dir: p.Workflows}
90
+ if err := store.Put("beadsComposition", []byte(beadsCompositionWorkflowYAML)); err != nil {
91
+ t.Fatal(err)
92
+ }
93
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
94
+ t.Fatal(err)
95
+ }
96
+
97
+ ctx, cancel := context.WithCancel(context.Background())
98
+ done := make(chan error, 1)
99
+ go func() { done <- serveRoot(ctx, p, false) }()
100
+
101
+ released := false
102
+ cleanup := func() {
103
+ if !released {
104
+ _ = os.WriteFile(releasePath, []byte("release\n"), 0o600)
105
+ released = true
106
+ }
107
+ cancel()
108
+ select {
109
+ case <-done:
110
+ case <-time.After(15 * time.Second):
111
+ }
112
+ }
113
+ t.Cleanup(cleanup)
114
+
115
+ client := server.NewClient(p.Socket)
116
+ waitForServer(t, client)
117
+
118
+ var active runsvc.Run
119
+ waitScenario(t, 15*time.Second, func() bool {
120
+ var getErr error
121
+ active, getErr = client.GetRunByTicket(context.Background(), "demo-parent")
122
+ return getErr == nil
123
+ })
124
+ if active.ID == "" {
125
+ t.Fatal("durable run was not created after the Beads claim")
126
+ }
127
+ if err := appendCompositionLog(logPath, "test|run-observed\n"); err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ lines := readCompositionLog(t, logPath)
131
+ readyIndex := compositionLogIndex(lines, "list --ready --no-parent --limit 0 --json")
132
+ claimIndex := compositionLogIndex(lines, "update demo-parent --add-label wf:beadsComposition --json")
133
+ runObservedIndex := compositionLogIndex(lines, "test|run-observed")
134
+ if readyIndex < 0 || claimIndex < 0 || runObservedIndex < 0 {
135
+ t.Fatalf("composition trace missing poll/claim/run events: %v", lines)
136
+ }
137
+ if !(readyIndex < claimIndex && claimIndex < runObservedIndex) {
138
+ t.Fatalf("poll -> claim -> run ordering = ready:%d claim:%d run:%d; trace=%v", readyIndex, claimIndex, runObservedIndex, lines)
139
+ }
140
+
141
+ if err := os.WriteFile(releasePath, []byte("release\n"), 0o600); err != nil {
142
+ t.Fatal(err)
143
+ }
144
+ released = true
145
+ waitScenario(t, 15*time.Second, func() bool {
146
+ current, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
147
+ return getErr == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "implement"
148
+ })
149
+ lines = readCompositionLog(t, logPath)
150
+ createImplementIndex := compositionLogIndex(lines, "create demo-parent:implement")
151
+ if createImplementIndex <= runObservedIndex {
152
+ t.Fatalf("mailbox creation did not follow run creation: run:%d create:%d; trace=%v", runObservedIndex, createImplementIndex, lines)
153
+ }
154
+
155
+ current, err := client.GetRunByTicket(context.Background(), "demo-parent")
156
+ if err != nil {
157
+ t.Fatal(err)
158
+ }
159
+ if ack, err := client.SubmitReport(context.Background(), runsvc.ReportRequest{
160
+ RunID: current.ID,
161
+ Node: current.CurrentNode,
162
+ ReportID: string(current.CurrentNodeVisitID) + ":composition-implement",
163
+ Report: scenarioReport(workflow.OutcomeSuccess, "verify"),
164
+ }); err != nil || !ack.Accepted {
165
+ t.Fatalf("submit implement report: ack=%+v err=%v", ack, err)
166
+ }
167
+ waitScenario(t, 15*time.Second, func() bool {
168
+ current, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
169
+ return getErr == nil && current.State == runsvc.StateWaiting && current.CurrentNode == "verify"
170
+ })
171
+
172
+ current, err = client.GetRunByTicket(context.Background(), "demo-parent")
173
+ if err != nil {
174
+ t.Fatal(err)
175
+ }
176
+ if ack, err := client.SubmitReport(context.Background(), runsvc.ReportRequest{
177
+ RunID: current.ID,
178
+ Node: current.CurrentNode,
179
+ ReportID: string(current.CurrentNodeVisitID) + ":composition-verify",
180
+ Report: scenarioReport(workflow.OutcomeSuccess, "end"),
181
+ }); err != nil || !ack.Accepted {
182
+ t.Fatalf("submit verify report: ack=%+v err=%v", ack, err)
183
+ }
184
+ waitScenario(t, 15*time.Second, func() bool {
185
+ finished, getErr := client.GetRunByTicket(context.Background(), "demo-parent")
186
+ return getErr == nil && finished.State == runsvc.StateCompleted
187
+ })
188
+
189
+ lines = readCompositionLog(t, logPath)
190
+ if compositionLogIndex(lines, "create demo-parent:verify") < 0 {
191
+ t.Fatalf("verify mailbox was not created: %v", lines)
192
+ }
193
+ comments := readCompositionState(t, stateDir, "comments.log")
194
+ for _, want := range []string{
195
+ "comment demo-parent.1",
196
+ "comment demo-parent.2",
197
+ "Summary for implement",
198
+ "Feedback from implement to verify mailbox demo-parent.2",
199
+ "Summary for verify",
200
+ } {
201
+ if !strings.Contains(comments, want) {
202
+ t.Fatalf("comments missing %q: %q", want, comments)
203
+ }
204
+ }
205
+ if strings.Contains(comments, "comment demo-parent ") {
206
+ t.Fatalf("summary/feedback was written to the parent: %q", comments)
207
+ }
208
+ if log.count("runner-repo-validated") == 0 || log.count("harness-agent-validated:implementer") == 0 {
209
+ t.Fatalf("existing runner/harness seams were not used: %v", log.all())
210
+ }
211
+
212
+ if err := client.Stop(context.Background()); err != nil {
213
+ t.Fatal(err)
214
+ }
215
+ select {
216
+ case serveErr := <-done:
217
+ if serveErr != nil {
218
+ t.Fatalf("serveRoot: %v", serveErr)
219
+ }
220
+ case <-time.After(15 * time.Second):
221
+ t.Fatal("serveRoot did not stop")
222
+ }
223
+ released = true
224
+ }
225
+
226
+ func installCompositionFakeBD(t *testing.T, repoPath, beadsDir string) (logPath, releasePath, stateDir string) {
227
+ t.Helper()
228
+ binDir := t.TempDir()
229
+ logPath = filepath.Join(t.TempDir(), "bd.log")
230
+ releasePath = filepath.Join(t.TempDir(), "release")
231
+ stateDir = filepath.Join(t.TempDir(), "state")
232
+ if err := os.WriteFile(logPath, nil, 0o600); err != nil {
233
+ t.Fatal(err)
234
+ }
235
+ if err := os.MkdirAll(stateDir, 0o700); err != nil {
236
+ t.Fatal(err)
237
+ }
238
+ path := filepath.Join(binDir, "bd")
239
+ if err := os.WriteFile(path, []byte(compositionFakeBDScript), 0o755); err != nil {
240
+ t.Fatal(err)
241
+ }
242
+ t.Setenv("PATH", binDir+string(os.PathListSeparator)+os.Getenv("PATH"))
243
+ t.Setenv("BD_LOG", logPath)
244
+ t.Setenv("BD_STATE", stateDir)
245
+ t.Setenv("BD_GATE_RELEASE", releasePath)
246
+ t.Setenv("BD_EXPECT_CWD", repoPath)
247
+ t.Setenv("BD_EXPECT_BEADS_DIR", beadsDir)
248
+ // These ambient selectors must be removed by bdcli.commandEnvironment.
249
+ t.Setenv("BEADS_DIR", "/ambient/workspace")
250
+ t.Setenv("BEADS_DB", "/ambient/beads.db")
251
+ t.Setenv("BD_DB", "/ambient/legacy.db")
252
+ return logPath, releasePath, stateDir
253
+ }
254
+
255
+ func appendCompositionLog(path, text string) error {
256
+ f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600)
257
+ if err != nil {
258
+ return err
259
+ }
260
+ defer f.Close()
261
+ _, err = f.WriteString(text)
262
+ return err
263
+ }
264
+
265
+ func readCompositionLog(t *testing.T, path string) []string {
266
+ t.Helper()
267
+ raw, err := os.ReadFile(path)
268
+ if err != nil {
269
+ t.Fatal(err)
270
+ }
271
+ return strings.Split(strings.TrimSpace(string(raw)), "\n")
272
+ }
273
+
274
+ func compositionLogIndex(lines []string, want string) int {
275
+ for i, line := range lines {
276
+ if strings.Contains(line, want) {
277
+ return i
278
+ }
279
+ }
280
+ return -1
281
+ }
282
+
283
+ func readCompositionState(t *testing.T, dir, name string) string {
284
+ t.Helper()
285
+ raw, err := os.ReadFile(filepath.Join(dir, name))
286
+ if err != nil {
287
+ t.Fatal(err)
288
+ }
289
+ return string(raw)
290
+ }
291
+
292
+ type compositionNoActiveRuns struct{}
293
+
294
+ func (compositionNoActiveRuns) HasActiveRepo(context.Context, string) (bool, error) {
295
+ return false, nil
296
+ }
297
+
298
+ type compositionNoWorkflowRefs struct{}
299
+
300
+ func (compositionNoWorkflowRefs) ReferencesRepo(string) bool { return false }
301
+
302
+ const beadsCompositionWorkflowYAML = `name: beadsComposition
303
+ repos: [payments]
304
+ cleanupRunnerOnEnd: true
305
+ nodes:
306
+ start:
307
+ onSuccess: [{target: implement}]
308
+ implement:
309
+ type: agent
310
+ agent: implementer
311
+ description: Implement the requested change.
312
+ onSuccess: [{target: verify}]
313
+ onFailure: [{target: implement, when: implementation cannot proceed}]
314
+ verify:
315
+ type: agent
316
+ agent: verifier
317
+ description: Verify the implementation.
318
+ onSuccess: [{target: end}]
319
+ onFailure: [{target: implement, when: verification fails}]
320
+ end: {}
321
+ `
322
+
323
+ const compositionFakeBDScript = `#!/bin/sh
324
+ set -eu
325
+
326
+ fail() {
327
+ printf 'unsupported or malformed composition bd invocation: %s\n' "$*" >&2
328
+ exit 64
329
+ }
330
+
331
+ [ -n "${BD_LOG-}" ] || fail "BD_LOG is missing"
332
+ [ -n "${BD_STATE-}" ] || fail "BD_STATE is missing"
333
+ [ -n "${BD_GATE_RELEASE-}" ] || fail "BD_GATE_RELEASE is missing"
334
+ [ "$PWD" = "${BD_EXPECT_CWD-}" ] || fail "wrong cwd: $PWD"
335
+ [ "${BEADS_DIR-}" = "${BD_EXPECT_BEADS_DIR-}" ] || fail "wrong BEADS_DIR: ${BEADS_DIR-}"
336
+ [ "${BEADS_DB+x}" != x ] || fail "ambient BEADS_DB leaked"
337
+ [ "${BD_DB+x}" != x ] || fail "ambient BD_DB leaked"
338
+ printf 'bd|%s\n' "$*" >> "$BD_LOG"
339
+
340
+ status_file() {
341
+ case "$1" in
342
+ demo-parent) printf '%s/parent.status' "$BD_STATE" ;;
343
+ demo-parent.1) printf '%s/implement.status' "$BD_STATE" ;;
344
+ demo-parent.2) printf '%s/verify.status' "$BD_STATE" ;;
345
+ *) fail "unknown issue $1" ;;
346
+ esac
347
+ }
348
+
349
+ issue_status() {
350
+ file=$(status_file "$1")
351
+ if [ -f "$file" ]; then
352
+ tr -d '\n' < "$file"
353
+ else
354
+ printf 'open'
355
+ fi
356
+ }
357
+
358
+ issue_json() {
359
+ issue=$1
360
+ status=$(issue_status "$issue")
361
+ case "$issue" in
362
+ demo-parent)
363
+ if [ -f "$BD_STATE/claimed" ]; then
364
+ labels='["wf:beadsComposition"]'
365
+ else
366
+ labels='[]'
367
+ fi
368
+ printf '[{"id":"demo-parent","title":"Composition parent","status":"%s","issue_type":"epic","priority":1,"labels":%s}]\n' "$status" "$labels"
369
+ ;;
370
+ demo-parent.1)
371
+ printf '[{"id":"demo-parent.1","title":"demo-parent:implement","status":"%s","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}]\n' "$status"
372
+ ;;
373
+ demo-parent.2)
374
+ printf '[{"id":"demo-parent.2","title":"demo-parent:verify","status":"%s","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}]\n' "$status"
375
+ ;;
376
+ *) fail "unknown issue $issue" ;;
377
+ esac
378
+ }
379
+
380
+ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --limit ] && [ "$4" = 1 ] && [ "$5" = --no-parent ] && [ "$6" = --json ]; then
381
+ printf '[]\n'
382
+ exit 0
383
+ fi
384
+ if [ "$#" -eq 6 ] && [ "$1" = list ] && [ "$2" = --ready ] && [ "$3" = --no-parent ] && [ "$4" = --limit ] && [ "$5" = 0 ] && [ "$6" = --json ]; then
385
+ if [ -f "$BD_STATE/claimed" ]; then
386
+ printf '[]\n'
387
+ else
388
+ printf '[{"id":"demo-parent","title":"Composition parent","status":"open","issue_type":"epic","priority":1,"labels":[]}]\n'
389
+ fi
390
+ exit 0
391
+ fi
392
+ if [ "$#" -eq 9 ] && [ "$1" = list ] && [ "$2" = --no-parent ] && [ "$3" = --status ] && [ "$4" = open,in_progress,blocked,deferred ] && [ "$5" = --label-pattern ] && [ "$6" = 'wf:*' ] && [ "$7" = --limit ] && [ "$8" = 0 ] && [ "$9" = --json ]; then
393
+ printf '[]\n'
394
+ exit 0
395
+ fi
396
+ if [ "$#" -eq 7 ] && [ "$1" = list ] && [ "$2" = --parent ] && [ "$3" = demo-parent ] && [ "$4" = --all ] && [ "$5" = --limit ] && [ "$6" = 0 ] && [ "$7" = --json ]; then
397
+ while [ ! -f "$BD_GATE_RELEASE" ]; do
398
+ sleep 0.01
399
+ done
400
+ out='['
401
+ sep=
402
+ if [ -f "$BD_STATE/implement" ]; then
403
+ out="${out}${sep}{\"id\":\"demo-parent.1\",\"title\":\"demo-parent:implement\",\"status\":\"$(issue_status demo-parent.1)\",\"issue_type\":\"task\",\"priority\":2,\"labels\":[\"wf:beadsComposition\"],\"parent\":\"demo-parent\"}"
404
+ sep=,
405
+ fi
406
+ if [ -f "$BD_STATE/verify" ]; then
407
+ out="${out}${sep}{\"id\":\"demo-parent.2\",\"title\":\"demo-parent:verify\",\"status\":\"$(issue_status demo-parent.2)\",\"issue_type\":\"task\",\"priority\":2,\"labels\":[\"wf:beadsComposition\"],\"parent\":\"demo-parent\"}"
408
+ fi
409
+ printf '%s]\n' "$out"
410
+ exit 0
411
+ fi
412
+ if [ "$#" -eq 3 ] && [ "$1" = show ] && [ "$3" = --json ]; then
413
+ issue_json "$2"
414
+ exit 0
415
+ fi
416
+ if [ "$#" -eq 11 ] && [ "$1" = create ] && [ "$3" = --type ] && [ "$4" = task ] && [ "$5" = --parent ] && [ "$7" = --no-inherit-labels ] && [ "$8" = --labels ] && [ "$9" = wf:beadsComposition ] && [ "${10}" = --stdin ] && [ "${11}" = --json ]; then
417
+ case "$2" in
418
+ demo-parent:implement) id=demo-parent.1; node=implement ;;
419
+ demo-parent:verify) id=demo-parent.2; node=verify ;;
420
+ *) fail "unknown child title $2" ;;
421
+ esac
422
+ cat > "$BD_STATE/$node.description"
423
+ : > "$BD_STATE/$node"
424
+ printf '%s\n' open > "$BD_STATE/$node.status"
425
+ printf '{"id":"%s","title":"%s","status":"open","issue_type":"task","priority":2,"labels":["wf:beadsComposition"],"parent":"demo-parent"}\n' "$id" "$2"
426
+ exit 0
427
+ fi
428
+ if [ "$#" -eq 5 ] && [ "$1" = update ] && [ "$2" = demo-parent ] && [ "$3" = --add-label ] && [ "$4" = wf:beadsComposition ] && [ "$5" = --json ]; then
429
+ : > "$BD_STATE/claimed"
430
+ printf '{}\n'
431
+ exit 0
432
+ fi
433
+ if [ "$#" -eq 5 ] && [ "$1" = update ] && [ "$3" = --status ] && [ "$5" = --json ]; then
434
+ file=$(status_file "$2")
435
+ printf '%s\n' "$4" > "$file"
436
+ printf '{}\n'
437
+ exit 0
438
+ fi
439
+ if [ "$#" -eq 3 ] && [ "$1" = comments ] && [ "$3" = --json ]; then
440
+ printf '[]\n'
441
+ exit 0
442
+ fi
443
+ if [ "$#" -eq 4 ] && [ "$1" = comment ] && [ "$3" = --stdin ] && [ "$4" = --json ]; then
444
+ printf 'comment %s\n' "$2" >> "$BD_STATE/comments.log"
445
+ cat >> "$BD_STATE/comments.log"
446
+ printf '\n---\n' >> "$BD_STATE/comments.log"
447
+ printf '{}\n'
448
+ exit 0
449
+ fi
450
+ fail "$@"
451
+ `
@@ -47,7 +47,7 @@ func TestCommandSurfaceExists(t *testing.T) {
47
47
  {"workflow", "submit", "--file", "x.yaml"}, {"workflow", "remove", "--name", "x"},
48
48
  {"workflow", "list"}, {"workflow", "get", "--name", "x"},
49
49
  {"repo", "register"}, {"repo", "remove", "--name", "x"}, {"repo", "list"}, {"repo", "get", "--name", "x"},
50
- {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
50
+ {"run", "list"}, {"run", "get", "--ticket", "PAY-101"}, {"run", "restart", "--ticket", "PAY-101"}, {"run", "cancel", "--ticket", "PAY-101"},
51
51
  }
52
52
  for _, argv := range commands {
53
53
  // Recognized commands do not exit 2 ("usage/unknown"); they may exit
@@ -75,6 +75,7 @@ func TestRequiredFlagMissingExits2(t *testing.T) {
75
75
  {"repo", "remove"}, // missing --name
76
76
  {"repo", "get"}, // missing --name
77
77
  {"run", "get"}, // missing --ticket
78
+ {"run", "restart"}, // missing --ticket
78
79
  {"run", "cancel"}, // missing --ticket
79
80
  } {
80
81
  if code := cli(t, home, "", argv...); code != 2 {
@@ -346,6 +347,43 @@ func TestInitPrintsSelectedValuesAndCompletion(t *testing.T) {
346
347
  }
347
348
  }
348
349
 
350
+ func TestInitWritesHarnessPromptDefaults(t *testing.T) {
351
+ home := t.TempDir()
352
+ if code := cli(t, home, "", initArgs()...); code != 0 {
353
+ t.Fatalf("init exit = %d", code)
354
+ }
355
+ cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
356
+ if err != nil {
357
+ t.Fatal(err)
358
+ }
359
+ for _, key := range []string{"initial", "feedback", "hitl"} {
360
+ value, ok := cfg.HarnessConfig[key].(string)
361
+ if !ok || value == "" {
362
+ t.Fatalf("harnessConfig.%s = %#v", key, cfg.HarnessConfig[key])
363
+ }
364
+ }
365
+ }
366
+
367
+ func TestInitWritesTaskTextDefaults(t *testing.T) {
368
+ home := t.TempDir()
369
+ if code := cli(t, home, "", initArgs()...); code != 0 {
370
+ t.Fatalf("init exit = %d", code)
371
+ }
372
+ cfg, err := config.LoadMachine(filepath.Join(home, ".relay-flow", "config.yaml"))
373
+ if err != nil {
374
+ t.Fatal(err)
375
+ }
376
+ templates, ok := rawValuesMap(cfg.TaskConfig["templates"])
377
+ if !ok {
378
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
379
+ }
380
+ for _, key := range []string{"mailboxDescription", "summaryComment", "feedbackComment"} {
381
+ if value, ok := templates[key].(string); !ok || value == "" {
382
+ t.Fatalf("taskConfig.templates.%s = %#v", key, templates[key])
383
+ }
384
+ }
385
+ }
386
+
349
387
  func TestInitDoesNotCollectOrWriteTaskCredentials(t *testing.T) {
350
388
  home := t.TempDir()
351
389
  if code := cli(t, home, "", initArgs()...); code != 0 {
@@ -413,6 +451,12 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
413
451
  t.Fatal(err)
414
452
  }
415
453
  cfg.Repos = map[string]config.Repo{"existing": {Path: "/srv/existing"}}
454
+ cfg.HarnessConfig["initial"] = "custom {{ticket}}"
455
+ taskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
456
+ if !ok {
457
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
458
+ }
459
+ taskTemplates["mailboxDescription"] = "custom {{nodeDescription}}"
416
460
  if err := config.SaveMachine(filepath.Join(root, "config.yaml"), cfg); err != nil {
417
461
  t.Fatal(err)
418
462
  }
@@ -453,6 +497,26 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
453
497
  if _, ok := cfg.Repos["existing"]; !ok {
454
498
  t.Fatal("forced init removed existing repo config")
455
499
  }
500
+ if got := cfg.HarnessConfig["initial"]; got != "custom {{ticket}}" {
501
+ t.Fatalf("forced init changed prompt override: %#v", got)
502
+ }
503
+ gotTaskTemplates, ok := rawValuesMap(cfg.TaskConfig["templates"])
504
+ if !ok {
505
+ t.Fatalf("taskConfig.templates = %#v", cfg.TaskConfig["templates"])
506
+ }
507
+ if got := gotTaskTemplates["mailboxDescription"]; got != "custom {{nodeDescription}}" {
508
+ t.Fatalf("forced init changed task text override: %#v", got)
509
+ }
510
+ for _, key := range []string{"summaryComment", "feedbackComment"} {
511
+ if got, ok := gotTaskTemplates[key].(string); !ok || got == "" {
512
+ t.Fatalf("forced init omitted task default %s: %#v", key, gotTaskTemplates[key])
513
+ }
514
+ }
515
+ for _, key := range []string{"feedback", "hitl"} {
516
+ if got, ok := cfg.HarnessConfig[key].(string); !ok || got == "" {
517
+ t.Fatalf("forced init omitted harness default %s: %#v", key, cfg.HarnessConfig[key])
518
+ }
519
+ }
456
520
  db, err := sql.Open("sqlite", dbPath)
457
521
  if err != nil {
458
522
  t.Fatal(err)
@@ -467,6 +531,17 @@ func TestInitForcePreservesDurableAndUserState(t *testing.T) {
467
531
  }
468
532
  }
469
533
 
534
+ func rawValuesMap(value any) (map[string]any, bool) {
535
+ switch values := value.(type) {
536
+ case map[string]any:
537
+ return values, true
538
+ case config.RawValues:
539
+ return map[string]any(values), true
540
+ default:
541
+ return nil, false
542
+ }
543
+ }
544
+
470
545
  // 8.2: --task-plugin/--runner-plugin/--harness-plugin run init without any
471
546
  // prompt/stdin and write the same machine config as the stdin path.
472
547
  func TestInitFlagsNonInteractive(t *testing.T) {
@@ -930,6 +1005,9 @@ func (s *ackServer) ListRuns(context.Context, runsvc.Filter) ([]runsvc.Run, erro
930
1005
  func (s *ackServer) GetRunByTicket(context.Context, string) (runsvc.Run, error) {
931
1006
  panic("unreachable")
932
1007
  }
1008
+ func (s *ackServer) RestartRun(context.Context, string) (runsvc.Run, error) {
1009
+ panic("unreachable")
1010
+ }
933
1011
  func (s *ackServer) CancelRun(context.Context, string, string) error { panic("unreachable") }
934
1012
  func (s *ackServer) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
935
1013
  panic("unreachable")
@@ -38,6 +38,7 @@ import (
38
38
  // name validation at init-time).
39
39
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
40
40
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
41
+ _ "github.com/rajpopat27/relay-flow/internal/task/beads"
41
42
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
42
43
  )
43
44
 
@@ -148,6 +149,7 @@ Usage:
148
149
 
149
150
  relay-flow run list
150
151
  relay-flow run get --ticket <key>
152
+ relay-flow run restart --ticket <key>
151
153
  relay-flow run cancel --ticket <key>`)
152
154
  }
153
155
 
@@ -269,6 +271,30 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
269
271
  cfg.TaskPlugin = names[0]
270
272
  cfg.RunnerPlugin = names[1]
271
273
  cfg.HarnessPlugin = names[2]
274
+ taskDefaults, err := task.Defaults(names[0])
275
+ if err != nil {
276
+ fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
277
+ return exitFail
278
+ }
279
+ if !*force || !configExists {
280
+ cfg.TaskConfig = taskDefaults
281
+ } else {
282
+ cfg.TaskConfig = config.Merge(taskDefaults, cfg.TaskConfig)
283
+ }
284
+ if err := task.ValidateTextConfig(names[0], cfg.TaskConfig); err != nil {
285
+ fmt.Fprintln(os.Stderr, "init: task plugin config: "+err.Error())
286
+ return exitFail
287
+ }
288
+ harnessDefaults, err := harness.Defaults(names[2])
289
+ if err != nil {
290
+ fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
291
+ return exitFail
292
+ }
293
+ if !*force || !configExists {
294
+ cfg.HarnessConfig = harnessDefaults
295
+ } else {
296
+ cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
297
+ }
272
298
  if err := config.SaveMachine(p.Config, cfg); err != nil {
273
299
  fmt.Fprintln(os.Stderr, err)
274
300
  return exitFail
@@ -921,6 +947,25 @@ func cmdRun(c *server.Client, args []string) int {
921
947
  enc.SetIndent("", " ")
922
948
  _ = enc.Encode(rn)
923
949
  return exitOK
950
+ case "restart":
951
+ fs := flag.NewFlagSet("run restart", flag.ContinueOnError)
952
+ ticket := fs.String("ticket", "", "ticket key")
953
+ if err := fs.Parse(args[1:]); err != nil {
954
+ return exitUsage
955
+ }
956
+ if *ticket == "" {
957
+ fmt.Fprintln(os.Stderr, "run restart: --ticket is required")
958
+ return exitUsage
959
+ }
960
+ rn, err := c.RestartRun(context.Background(), *ticket)
961
+ if err != nil {
962
+ fmt.Fprintln(os.Stderr, err)
963
+ return exitFail
964
+ }
965
+ enc := json.NewEncoder(os.Stdout)
966
+ enc.SetIndent("", " ")
967
+ _ = enc.Encode(rn)
968
+ return exitOK
924
969
  case "cancel":
925
970
  fs := flag.NewFlagSet("run cancel", flag.ContinueOnError)
926
971
  ticket := fs.String("ticket", "", "ticket key")
@@ -943,7 +988,11 @@ func cmdRun(c *server.Client, args []string) int {
943
988
  }
944
989
 
945
990
  func formatRunListRow(r runsvc.Run) string {
946
- row := fmt.Sprintf("%s\t%s\t%s\t%s", r.ID, r.Ticket.Key, r.Workflow, r.State)
991
+ attempt := r.AttemptID
992
+ if attempt == 0 {
993
+ attempt = 1
994
+ }
995
+ row := fmt.Sprintf("%s\t%s\t%s\t%s\tattempt=%d", r.ID, r.Ticket.Key, r.Workflow, r.State, attempt)
947
996
  if r.Retry != nil {
948
997
  row += fmt.Sprintf("\tretrying attempt=%d next=%s error=%q",
949
998
  r.Retry.Attempt, r.Retry.NextRetryAt.Format(time.RFC3339), r.Retry.LastError)