relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  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 +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -2,9 +2,11 @@ package main
2
2
 
3
3
  import (
4
4
  "context"
5
+ "encoding/json"
5
6
  "errors"
7
+ "fmt"
6
8
  "path/filepath"
7
- "sort"
9
+ "reflect"
8
10
  "strings"
9
11
  "sync"
10
12
  "testing"
@@ -14,23 +16,34 @@ import (
14
16
  "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
15
17
  "github.com/rajpopat27/relay-flow/internal/harness"
16
18
  "github.com/rajpopat27/relay-flow/internal/identity"
19
+ "github.com/rajpopat27/relay-flow/internal/paths"
17
20
  "github.com/rajpopat27/relay-flow/internal/repo"
18
21
  runsvc "github.com/rajpopat27/relay-flow/internal/run"
19
22
  "github.com/rajpopat27/relay-flow/internal/runner"
23
+ "github.com/rajpopat27/relay-flow/internal/server"
20
24
  "github.com/rajpopat27/relay-flow/internal/task"
21
25
  "github.com/rajpopat27/relay-flow/internal/workflow"
22
26
  )
23
27
 
24
- const scenarioTaskPlugin = "scenario-e2e-task"
28
+ const (
29
+ scenarioTaskPlugin = "scenario-e2e-task"
30
+ scenarioRunnerPlugin = "scenario-e2e-runner"
31
+ scenarioHarnessPlugin = "scenario-e2e-harness"
32
+ )
25
33
 
26
34
  var (
27
- scenarioFactoryMu sync.Mutex
28
- scenarioFactorySystem task.System
35
+ scenarioFactoryMu sync.Mutex
36
+ scenarioFactorySystem task.System
37
+ scenarioFactoryRunner runner.Runner
38
+ scenarioFactoryHarness harness.Harness
29
39
  )
30
40
 
31
41
  func init() {
32
42
  task.Register(scenarioTaskPlugin, task.Factory{
33
43
  RequiredRepoKeys: func() []string { return nil },
44
+ DefaultConfig: func() config.RawValues {
45
+ return config.RawValues{"templates": map[string]any{"format": "alternate"}}
46
+ },
34
47
  TaskScopeKey: func(config.RawValues, config.RawValues) (string, error) {
35
48
  return "scenario-scope", nil
36
49
  },
@@ -40,9 +53,37 @@ func init() {
40
53
  if scenarioFactorySystem == nil {
41
54
  return nil, errors.New("scenario task system not configured")
42
55
  }
56
+ if fake, ok := scenarioFactorySystem.(*scenarioTaskSystem); ok {
57
+ fake.log.add("factory:task:" + scenarioTaskPlugin)
58
+ }
43
59
  return scenarioFactorySystem, nil
44
60
  },
45
61
  })
62
+ runner.Register(scenarioRunnerPlugin, func(config.RawValues) (runner.Runner, error) {
63
+ scenarioFactoryMu.Lock()
64
+ defer scenarioFactoryMu.Unlock()
65
+ if scenarioFactoryRunner == nil {
66
+ return nil, errors.New("scenario runner not configured")
67
+ }
68
+ if fake, ok := scenarioFactoryRunner.(*scenarioRunner); ok {
69
+ fake.log.add("factory:runner:" + scenarioRunnerPlugin)
70
+ }
71
+ return scenarioFactoryRunner, nil
72
+ })
73
+ harness.Register(scenarioHarnessPlugin, harness.Factory{
74
+ DefaultConfig: func() config.RawValues { return config.RawValues{"runtime": "alternate"} },
75
+ New: func(config.RawValues) (harness.Harness, error) {
76
+ scenarioFactoryMu.Lock()
77
+ defer scenarioFactoryMu.Unlock()
78
+ if scenarioFactoryHarness == nil {
79
+ return nil, errors.New("scenario harness not configured")
80
+ }
81
+ if fake, ok := scenarioFactoryHarness.(*scenarioHarness); ok {
82
+ fake.log.add("factory:harness:" + scenarioHarnessPlugin)
83
+ }
84
+ return scenarioFactoryHarness, nil
85
+ },
86
+ })
46
87
  }
47
88
 
48
89
  func setScenarioFactorySystem(system task.System) {
@@ -51,6 +92,14 @@ func setScenarioFactorySystem(system task.System) {
51
92
  scenarioFactoryMu.Unlock()
52
93
  }
53
94
 
95
+ func setScenarioFactoryAdapters(system task.System, rnr runner.Runner, hrn harness.Harness) {
96
+ scenarioFactoryMu.Lock()
97
+ scenarioFactorySystem = system
98
+ scenarioFactoryRunner = rnr
99
+ scenarioFactoryHarness = hrn
100
+ scenarioFactoryMu.Unlock()
101
+ }
102
+
54
103
  // These scenarios exercise the same composition chain as serve:
55
104
  // RepoPoller/handleBatch -> RunManager -> real go-workflows SQLite engine.
56
105
  // The only replacements are the documented task, runner, and harness seams.
@@ -89,6 +138,130 @@ func TestScenarioHappyPath(t *testing.T) {
89
138
  assertExactHappyEffects(t, f)
90
139
  }
91
140
 
141
+ func TestCompositionRootSelectsAlternatePluginsForDurableRun(t *testing.T) {
142
+ log := newScenarioLog()
143
+ tasks := newScenarioTaskSystem(log)
144
+ rnr := newScenarioRunner(log)
145
+ hrn := newScenarioHarness(log)
146
+ setScenarioFactoryAdapters(tasks, rnr, hrn)
147
+
148
+ root := filepath.Join(t.TempDir(), ".relay-flow")
149
+ t.Setenv("RELAY_FLOW_HOME", root)
150
+ p, err := home()
151
+ if err != nil {
152
+ t.Fatal(err)
153
+ }
154
+ if err := paths.Ensure(p); err != nil {
155
+ t.Fatal(err)
156
+ }
157
+ if err := config.SaveMachine(p.Config, &config.Machine{
158
+ PollIntervalSeconds: 1,
159
+ TaskPlugin: scenarioTaskPlugin,
160
+ TaskConfig: config.RawValues{"provider": "alternate"},
161
+ RunnerPlugin: scenarioRunnerPlugin,
162
+ RunnerConfig: config.RawValues{"transport": "opaque"},
163
+ HarnessPlugin: scenarioHarnessPlugin,
164
+ HarnessConfig: config.RawValues{"runtime": "alternate"},
165
+ Repos: map[string]config.Repo{
166
+ scenarioRepo: {Path: scenarioRepoPath, TaskConfig: config.RawValues{"scope": "test"}},
167
+ },
168
+ }); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+ if err := (&workflow.Store{Dir: p.Workflows}).Put(scenarioWorkflowName, scenarioWorkflowYAML); err != nil {
172
+ t.Fatal(err)
173
+ }
174
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
175
+ t.Fatal(err)
176
+ }
177
+
178
+ ctx, cancel := context.WithCancel(context.Background())
179
+ done := make(chan error, 1)
180
+ stopped := false
181
+ go func() { done <- serveRoot(ctx, p, false) }()
182
+ t.Cleanup(func() {
183
+ if stopped {
184
+ return
185
+ }
186
+ cancel()
187
+ select {
188
+ case <-done:
189
+ case <-time.After(10 * time.Second):
190
+ }
191
+ })
192
+
193
+ client := server.NewClient(p.Socket)
194
+ waitForServer(t, client)
195
+ var active runsvc.Run
196
+ waitScenario(t, 10*time.Second, func() bool {
197
+ var lookupErr error
198
+ active, lookupErr = client.GetRunByTicket(context.Background(), scenarioTicket)
199
+ return lookupErr == nil && active.State == runsvc.StateWaiting && active.CurrentNode == "implement"
200
+ })
201
+
202
+ launch := hrn.launch("implement")
203
+ wantPrompt := "initial taskSystem=" + scenarioTaskPlugin
204
+ if !strings.Contains(launch.Prompt, wantPrompt) {
205
+ t.Fatalf("selected task system did not reach launch prompt: %q", launch.Prompt)
206
+ }
207
+ command := rnr.command(scenarioTicket + ":implement")
208
+ wantArgs := []string{"opaque", launch.Prompt}
209
+ if command.Executable != "fake-harness" || !reflect.DeepEqual(command.Args, wantArgs) {
210
+ t.Fatalf("runner command = %#v, want opaque harness command args %#v", command, wantArgs)
211
+ }
212
+ for _, event := range []string{
213
+ "factory:task:" + scenarioTaskPlugin,
214
+ "factory:runner:" + scenarioRunnerPlugin,
215
+ "factory:harness:" + scenarioHarnessPlugin,
216
+ "task-config-validated",
217
+ "runner-repo-validated",
218
+ "harness-agent-validated:implementer",
219
+ "harness-launched:" + scenarioTicket + ":implement",
220
+ "terminal-created:" + scenarioTicket + ":implement",
221
+ } {
222
+ if log.countPrefix(event) == 0 {
223
+ t.Fatalf("composition did not call selected interface boundary %q; events=%v", event, log.all())
224
+ }
225
+ }
226
+
227
+ for i, next := range []string{"verify", "pr-review", "end"} {
228
+ current, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
229
+ if getErr != nil {
230
+ t.Fatal(getErr)
231
+ }
232
+ ack, submitErr := client.SubmitReport(context.Background(), runsvc.ReportRequest{
233
+ RunID: current.ID, Node: current.CurrentNode,
234
+ ReportID: "alternate-session:message-" + fmt.Sprint(i+1),
235
+ Report: scenarioReport(workflow.OutcomeSuccess, next),
236
+ })
237
+ if submitErr != nil || !ack.Accepted {
238
+ t.Fatalf("submit durable report %s -> %s: ack=%+v err=%v", current.CurrentNode, next, ack, submitErr)
239
+ }
240
+ if next != "end" {
241
+ waitScenario(t, 10*time.Second, func() bool {
242
+ advanced, advanceErr := client.GetRunByTicket(context.Background(), scenarioTicket)
243
+ return advanceErr == nil && advanced.State == runsvc.StateWaiting && advanced.CurrentNode == next
244
+ })
245
+ }
246
+ }
247
+ waitScenario(t, 10*time.Second, func() bool {
248
+ finished, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
249
+ return getErr == nil && finished.State == runsvc.StateCompleted
250
+ })
251
+ if err := client.Stop(context.Background()); err != nil {
252
+ t.Fatal(err)
253
+ }
254
+ select {
255
+ case err := <-done:
256
+ if err != nil {
257
+ t.Fatalf("serveRoot: %v", err)
258
+ }
259
+ case <-time.After(10 * time.Second):
260
+ t.Fatal("serveRoot did not stop")
261
+ }
262
+ stopped = true
263
+ }
264
+
92
265
  func TestScenarioHITLRejectLoop(t *testing.T) {
93
266
  f := newScenarioFixture(t)
94
267
  f.pollOnce()
@@ -249,7 +422,7 @@ func TestScenarioLateRegisterAndLateSubmit(t *testing.T) {
249
422
  }
250
423
  repoService := repo.NewServiceWithRegistry(repo.ServiceConfig{
251
424
  ConfigPath: configPath, TaskPlugin: scenarioTaskPlugin, Runner: fr,
252
- Active: engine, Workflows: wfService.Registry(),
425
+ Harness: fh, Active: engine, Workflows: wfService.Registry(),
253
426
  }, reg)
254
427
  deps := &serveDeps{
255
428
  repos: repoService,
@@ -618,6 +791,7 @@ type scenarioTaskSystem struct {
618
791
  mu sync.Mutex
619
792
 
620
793
  claimed bool
794
+ renderText func(task.TextKind, task.TextData) (string, error)
621
795
  mailboxes map[string]task.Mailbox
622
796
  specs map[string]task.MailboxSpec
623
797
  mailboxStatus map[string]string
@@ -629,6 +803,8 @@ type scenarioTaskSystem struct {
629
803
  completeFailures int
630
804
  }
631
805
 
806
+ var _ task.System = (*scenarioTaskSystem)(nil)
807
+
632
808
  func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
633
809
  return &scenarioTaskSystem{
634
810
  log: log, mailboxes: map[string]task.Mailbox{}, specs: map[string]task.MailboxSpec{},
@@ -660,9 +836,26 @@ func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workfl
660
836
  }
661
837
 
662
838
  func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
839
+ s.log.add("task-config-validated")
663
840
  return nil
664
841
  }
665
842
 
843
+ func (s *scenarioTaskSystem) RenderText(kind task.TextKind, data task.TextData) (string, error) {
844
+ if s.renderText != nil {
845
+ return s.renderText(kind, data)
846
+ }
847
+ switch kind {
848
+ case task.TextMailboxDescription:
849
+ return "mailbox " + data.Node + ": " + data.NodeDescription, nil
850
+ case task.TextSummaryComment:
851
+ return "SUMMARY\n" + data.SummaryReport, nil
852
+ case task.TextFeedbackComment:
853
+ return "Feedback from " + data.SourceNode + " to " + data.TargetNode + " mailbox " + data.Mailbox + "\n" + data.FeedbackReport, nil
854
+ default:
855
+ return "", fmt.Errorf("unknown task text kind %q", kind)
856
+ }
857
+ }
858
+
666
859
  func (s *scenarioTaskSystem) EnsureMailboxes(_ context.Context, parent task.TicketRef, _ string, specs []task.MailboxSpec) (map[string]task.Mailbox, error) {
667
860
  s.log.add("mailboxes:ensure")
668
861
  s.mu.Lock()
@@ -827,6 +1020,8 @@ type scenarioRunner struct {
827
1020
  cleanups int
828
1021
  }
829
1022
 
1023
+ var _ runner.Runner = (*scenarioRunner)(nil)
1024
+
830
1025
  func newScenarioRunner(log *scenarioLog) *scenarioRunner {
831
1026
  return &scenarioRunner{
832
1027
  log: log, environments: map[string]runner.Environment{}, terminals: map[string]*scenarioTerminal{},
@@ -838,7 +1033,10 @@ func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate,
838
1033
  return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
839
1034
  }
840
1035
 
841
- func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error { return nil }
1036
+ func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error {
1037
+ r.log.add("runner-repo-validated")
1038
+ return nil
1039
+ }
842
1040
 
843
1041
  func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
844
1042
  r.mu.Lock()
@@ -852,16 +1050,12 @@ func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpe
852
1050
  return env, nil
853
1051
  }
854
1052
 
855
- func (r *scenarioRunner) FindTerminal(_ context.Context, _ runner.Environment, title string) (runner.Terminal, bool, error) {
856
- r.mu.Lock()
857
- defer r.mu.Unlock()
858
- t, ok := r.terminals[title]
859
- if !ok || !t.live {
860
- return runner.Terminal{}, false, nil
861
- }
862
- return t.terminal, true, nil
1053
+ func (r *scenarioRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
1054
+ r.log.add("workspace-status:" + status)
1055
+ return nil
863
1056
  }
864
- func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
1057
+
1058
+ func (r *scenarioRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
865
1059
  r.mu.Lock()
866
1060
  defer r.mu.Unlock()
867
1061
  for _, current := range r.terminals {
@@ -872,8 +1066,15 @@ func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Term
872
1066
  return runner.Terminal{}, false, nil
873
1067
  }
874
1068
  func (r *scenarioRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
875
- func (r *scenarioRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
876
- return r.EnsureTerminal(ctx, env, title, command)
1069
+ func (r *scenarioRunner) CreateTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
1070
+ r.mu.Lock()
1071
+ defer r.mu.Unlock()
1072
+ terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
1073
+ r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
1074
+ r.commands[title] = append(r.commands[title], command)
1075
+ r.launches[title]++
1076
+ r.log.add("terminal-created:" + title)
1077
+ return terminal, nil
877
1078
  }
878
1079
 
879
1080
  func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
@@ -886,18 +1087,13 @@ func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Termin
886
1087
  return nil
887
1088
  }
888
1089
 
889
- func (r *scenarioRunner) EnsureTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
890
- r.mu.Lock()
891
- defer r.mu.Unlock()
892
- if existing, ok := r.terminals[title]; ok && existing.live {
893
- return existing.terminal, nil
1090
+ func (r *scenarioRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
1091
+ if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
1092
+ return runner.Terminal{}, err
1093
+ } else if ok {
1094
+ return terminal, nil
894
1095
  }
895
- terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
896
- r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
897
- r.commands[title] = append(r.commands[title], command)
898
- r.launches[title]++
899
- r.log.add("terminal-created:" + title)
900
- return terminal, nil
1096
+ return r.CreateTerminal(ctx, env, title, command)
901
1097
  }
902
1098
 
903
1099
  func (r *scenarioRunner) CloseTerminals(context.Context, runner.RunSpec) error {
@@ -948,11 +1144,18 @@ type scenarioHarness struct {
948
1144
  launches map[string][]harness.LaunchSpec
949
1145
  }
950
1146
 
1147
+ func (*scenarioHarness) SetupRepo(context.Context, string) error { return nil }
1148
+
1149
+ var _ harness.Harness = (*scenarioHarness)(nil)
1150
+
951
1151
  func newScenarioHarness(log *scenarioLog) *scenarioHarness {
952
1152
  return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
953
1153
  }
954
1154
 
955
- func (h *scenarioHarness) ValidateAgent(context.Context, string, string) error { return nil }
1155
+ func (h *scenarioHarness) ValidateAgent(_ context.Context, _, agent string) error {
1156
+ h.log.add("harness-agent-validated:" + agent)
1157
+ return nil
1158
+ }
956
1159
 
957
1160
  func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string) (harness.Session, bool, error) {
958
1161
  h.mu.Lock()
@@ -961,6 +1164,17 @@ func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string)
961
1164
  return session, ok, nil
962
1165
  }
963
1166
 
1167
+ func (h *scenarioHarness) RenderPrompt(kind harness.PromptKind, data harness.PromptData, nudge string) (string, error) {
1168
+ prompt := string(kind) + " taskSystem=" + data.TaskSystem + " mailbox=" + data.Mailbox
1169
+ if data.NodeType == workflow.NodeHITL {
1170
+ prompt += " hitl"
1171
+ }
1172
+ if nudge != "" {
1173
+ prompt += " " + nudge
1174
+ }
1175
+ return prompt, nil
1176
+ }
1177
+
964
1178
  func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command, error) {
965
1179
  h.mu.Lock()
966
1180
  h.launches[spec.Node] = append(h.launches[spec.Node], spec)
@@ -969,6 +1183,7 @@ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command,
969
1183
  h.log.add("harness-launched:" + spec.Title)
970
1184
  return runner.Command{
971
1185
  Executable: "fake-harness",
1186
+ Args: []string{"opaque", spec.Prompt},
972
1187
  Env: map[string]string{
973
1188
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
974
1189
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -993,12 +1208,8 @@ func (h *scenarioHarness) launch(node string) harness.LaunchSpec {
993
1208
  }
994
1209
 
995
1210
  func routesJSON(routes []workflow.Route) string {
996
- parts := make([]string, 0, len(routes))
997
- for _, route := range routes {
998
- parts = append(parts, route.Target)
999
- }
1000
- sort.Strings(parts)
1001
- return "[\"" + strings.Join(parts, "\",\"") + "\"]"
1211
+ b, _ := json.Marshal(routes)
1212
+ return string(b)
1002
1213
  }
1003
1214
 
1004
1215
  type scenarioRepoLookup struct{ reg *repo.Registry }
@@ -35,6 +35,7 @@ import (
35
35
  // Adapter registrations (factories are registered by name via init).
36
36
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
37
37
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
38
+ _ "github.com/rajpopat27/relay-flow/internal/task/beads"
38
39
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
39
40
  )
40
41
 
@@ -204,6 +205,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
204
205
  Repos: repoReg,
205
206
  Runner: rnr,
206
207
  Harness: hrn,
208
+ TaskSystem: cfg.TaskPlugin,
207
209
  RetentionDays: cfg.CompletedRunRetentionDays,
208
210
  Runtime: &runsvc.RuntimePolicy{
209
211
  KeepTerminalsAlive: cfg.KeepTerminalsAlive,
@@ -262,6 +264,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
262
264
  ConfigPath: p.Config,
263
265
  TaskPlugin: cfg.TaskPlugin,
264
266
  Runner: rnr,
267
+ Harness: hrn,
265
268
  Active: engine,
266
269
  Workflows: wfSvc.Registry(),
267
270
  }, repoReg)
@@ -272,8 +275,8 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
272
275
  // specs come from the engine so the recover path builds the same
273
276
  // description content as normal run execution.
274
277
  if recover {
275
- specsFor := func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
276
- return goworkflows.MailboxSpecs(wf, ticketKey)
278
+ specsFor := func(sys task.System, work runsvc.Work, wf *workflow.Workflow) ([]task.MailboxSpec, error) {
279
+ return goworkflows.RenderMailboxSpecs(sys, work, wf)
277
280
  }
278
281
  if err := recoverpkg.FromTaskSystem(ctx, repoReg, rnr, runManager, specsFor); err != nil {
279
282
  shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
@@ -0,0 +1,74 @@
1
+ # Beads workflow example. Register the code repository with a required
2
+ # repo-scoped taskConfig.beadsDir before submitting this workflow, for example:
3
+ #
4
+ # relay-flow repo register --name payments --path /work/payments \
5
+ # --set beadsDir=/work/payments/.beads
6
+ #
7
+ # The Beads prefix is optional and is not a workspace/component selector.
8
+ name: beadsStoryFlow
9
+ repos:
10
+ - payments
11
+ cleanupRunnerOnEnd: true
12
+
13
+ taskConfig:
14
+ filters:
15
+ # Beads statuses are open, in_progress, blocked, deferred, hooked, closed.
16
+ parentStatuses:
17
+ - open
18
+ issueTypes:
19
+ - epic
20
+ labels:
21
+ - relay-ready
22
+ # assignees:
23
+ # - owner@example.com
24
+ # Optional shared default; filters.assignees overrides it when provided.
25
+ # An assignee in effect for a node also assigns that node's mailbox.
26
+ # assignee: owner@example.com
27
+
28
+ # transitionTo describes one lifecycle point, so configure it on a node.
29
+ # Omitting it entirely already gives the Jira-equivalent lifecycle:
30
+ # start parent -> in_progress, work mailbox -> in_progress, end parent -> closed.
31
+ # Values are Beads-native, not Jira values such as "In Progress" or "Done".
32
+ nodes:
33
+ start:
34
+ taskConfig:
35
+ transitionTo:
36
+ parentStatus: in_progress
37
+ onSuccess:
38
+ - target: implement
39
+ when: The Beads parent is ready for implementation
40
+
41
+ implement:
42
+ type: agent
43
+ agent: build
44
+ description: Implement the Beads parent and verify the changes.
45
+ taskConfig:
46
+ # assignee: developer@example.com
47
+ transitionTo:
48
+ taskStatus: in_progress
49
+ onSuccess:
50
+ - target: review
51
+ when: Implementation and verification are complete
52
+ onFailure:
53
+ - target: implement
54
+ when: Implementation needs another pass
55
+
56
+ review:
57
+ type: hitl
58
+ agent: plan
59
+ description: Review the implementation and obtain approval.
60
+ taskConfig:
61
+ # assignee: reviewer@example.com
62
+ transitionTo:
63
+ taskStatus: in_progress
64
+ onSuccess:
65
+ - target: end
66
+ when: The review is approved
67
+ onFailure:
68
+ - target: implement
69
+ when: Changes are requested
70
+
71
+ end:
72
+ taskConfig:
73
+ transitionTo:
74
+ parentStatus: closed
@@ -0,0 +1,88 @@
1
+ # Copy this file and replace "payments" with one or more registered repo names.
2
+ # Optional fields are shown commented; uncomment only the behavior you need.
3
+ name: defaultStoryFlow
4
+ repos:
5
+ - test-relay-flow
6
+ cleanupRunnerOnEnd: true
7
+
8
+ taskConfig:
9
+ filters:
10
+ parentStatuses:
11
+ - To Do
12
+ issueTypes:
13
+ - Story
14
+ # labels:
15
+ # - coding
16
+ # assignees:
17
+ # - owner@example.com
18
+ # assignee: default-node-owner@example.com
19
+ # project: PAY
20
+ # component: api
21
+ # transitionTo:
22
+ # parentStatus: In Progress
23
+ # taskStatus: In Progress
24
+
25
+ nodes:
26
+ start:
27
+ taskConfig:
28
+ transitionTo:
29
+ parentStatus: In Progress
30
+ onSuccess:
31
+ - target: implement
32
+ when: The parent story is ready for implementation
33
+
34
+ implement:
35
+ type: agent
36
+ agent: build
37
+ description: Implement the parent story and verify the changes.
38
+ # nudgePrompt: "Finish {{node}} for {{ticket}} and choose one of: {{nextSteps}}"
39
+ taskConfig:
40
+ # assignee: developer@example.com
41
+ transitionTo:
42
+ taskStatus: In Progress
43
+ # parentStatus: In Progress
44
+ onSuccess:
45
+ - target: review
46
+ when: Implementation and verification are complete
47
+ onFailure:
48
+ - target: implement
49
+ when: Implementation needs another pass
50
+
51
+ review:
52
+ type: agent
53
+ agent: plan
54
+ description: Review the implementation for correctness, regressions, and missing tests.
55
+ # nudgePrompt: "Complete the review for {{ticket}}. Valid routes: {{nextSteps}}"
56
+ taskConfig:
57
+ # assignee: reviewer@example.com
58
+ transitionTo:
59
+ taskStatus: In Progress
60
+ # parentStatus: In Review
61
+ onSuccess:
62
+ - target: prReview
63
+ when: The changes are ready for human approval
64
+ onFailure:
65
+ - target: implement
66
+ when: Code changes are required
67
+
68
+ prReview:
69
+ type: hitl
70
+ agent: plan
71
+ description: Discuss the pull request with the human and obtain approval.
72
+ # nudgePrompt: "Review {{ticket}} with the human. Valid routes: {{nextSteps}}"
73
+ taskConfig:
74
+ # assignee: human-reviewer@example.com
75
+ transitionTo:
76
+ taskStatus: In Progress
77
+ # parentStatus: In Review
78
+ onSuccess:
79
+ - target: end
80
+ when: The human approves the review
81
+ onFailure:
82
+ - target: implement
83
+ when: The human requests code changes
84
+
85
+ end:
86
+ taskConfig:
87
+ transitionTo:
88
+ parentStatus: Done
package/go.mod CHANGED
@@ -2,10 +2,11 @@ module github.com/rajpopat27/relay-flow
2
2
 
3
3
  go 1.24.6
4
4
 
5
- require (
5
+ require (
6
6
  github.com/cschleiden/go-workflows v1.4.2
7
7
  github.com/google/renameio/v2 v2.0.1
8
8
  github.com/google/uuid v1.6.0
9
+ github.com/yuin/goldmark v1.7.13
9
10
  gopkg.in/yaml.v3 v3.0.1
10
11
  )
11
12
 
package/go.sum CHANGED
@@ -122,6 +122,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf
122
122
  github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
123
123
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
124
124
  github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
125
+ github.com/yuin/goldmark v1.7.13 h1:GPddIs617DnBLFFVJFgpo1aBfe/4xcvMc3SB5t/D0pA=
126
+ github.com/yuin/goldmark v1.7.13/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
125
127
  go.opentelemetry.io/otel v1.31.0 h1:NsJcKPIW0D0H3NgzPDHmo0WW6SptzPdqg/L1zsIm2hY=
126
128
  go.opentelemetry.io/otel v1.31.0/go.mod h1:O0C14Yl9FgkjqcCZAsE053C13OaddMYr/hz6clDkEJE=
127
129
  go.opentelemetry.io/otel/metric v1.31.0 h1:FSErL0ATQAmYHUIzSezZibnyVlft1ybhy4ozRPcF2fE=
@@ -26,9 +26,9 @@ func Merge(values ...RawValues) RawValues {
26
26
 
27
27
  func mergeInto(dst, src map[string]any) map[string]any {
28
28
  for k, sv := range src {
29
- if sm, ok := sv.(map[string]any); ok {
29
+ if sm, ok := rawMap(sv); ok {
30
30
  fresh := map[string]any{}
31
- if dm, ok := dst[k].(map[string]any); ok {
31
+ if dm, ok := rawMap(dst[k]); ok {
32
32
  mergeInto(fresh, dm)
33
33
  }
34
34
  dst[k] = mergeInto(fresh, sm)
@@ -39,6 +39,17 @@ func mergeInto(dst, src map[string]any) map[string]any {
39
39
  return dst
40
40
  }
41
41
 
42
+ func rawMap(value any) (map[string]any, bool) {
43
+ switch values := value.(type) {
44
+ case map[string]any:
45
+ return values, true
46
+ case RawValues:
47
+ return map[string]any(values), true
48
+ default:
49
+ return nil, false
50
+ }
51
+ }
52
+
42
53
  // DecodeStrict decodes raw values into dst, rejecting unknown fields and
43
54
  // explicit null values.
44
55
  func DecodeStrict(values RawValues, dst any) error {