relay-flow 0.2.0-alpha → 0.2.1-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 (55) hide show
  1. package/README.md +18 -6
  2. package/cmd/relay-flow/commands_test.go +519 -15
  3. package/cmd/relay-flow/main.go +331 -119
  4. package/cmd/relay-flow/scenario_test.go +209 -34
  5. package/cmd/relay-flow/serve.go +1 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/internal/execution/goworkflows/activities.go +65 -65
  8. package/internal/execution/goworkflows/engine.go +41 -8
  9. package/internal/execution/goworkflows/engine_test.go +73 -13
  10. package/internal/execution/goworkflows/fakes_test.go +13 -21
  11. package/internal/execution/goworkflows/interpreter.go +16 -8
  12. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  13. package/internal/execution/goworkflows/node_runtime_test.go +45 -21
  14. package/internal/execution/goworkflows/recovery_test.go +5 -5
  15. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  16. package/internal/harness/contract_test.go +5 -0
  17. package/internal/paths/paths.go +18 -16
  18. package/internal/repo/repo.go +13 -0
  19. package/internal/repo/service_test.go +4 -4
  20. package/internal/router/router.go +3 -2
  21. package/internal/router/router_test.go +87 -0
  22. package/internal/run/manager.go +14 -1
  23. package/internal/run/run_manager_test.go +21 -1
  24. package/internal/runner/contract_test.go +64 -26
  25. package/internal/runner/orca/orca.go +30 -54
  26. package/internal/runner/orca/orca_test.go +143 -4
  27. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  28. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  29. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  30. package/internal/runner/runner.go +15 -8
  31. package/internal/task/auth_test.go +48 -0
  32. package/internal/task/contract_test.go +2 -0
  33. package/internal/task/factory.go +16 -0
  34. package/internal/task/jira/auth.go +183 -0
  35. package/internal/task/jira/auth_test.go +107 -0
  36. package/internal/task/jira/effects_test.go +39 -0
  37. package/internal/task/jira/filters_test.go +36 -16
  38. package/internal/task/jira/helpers_test.go +29 -19
  39. package/internal/task/jira/jira.go +92 -61
  40. package/internal/task/jira/normalize.go +32 -14
  41. package/internal/task/jira/rest/adf.go +128 -0
  42. package/internal/task/jira/rest/client.go +573 -0
  43. package/internal/task/jira/rest/client_test.go +381 -0
  44. package/internal/task/jira/transition_defaults_test.go +18 -16
  45. package/internal/task/jira/validation_test.go +1 -1
  46. package/internal/workflow/workflow.go +9 -6
  47. package/internal/workflow/workflow_test.go +14 -12
  48. package/package.json +2 -1
  49. package/internal/task/jira/acli/acli.go +0 -306
  50. package/internal/task/jira/acli/acli_test.go +0 -208
  51. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  52. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  53. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  54. package/internal/task/jira/acli/testdata/search_success.json +0 -1
  55. /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,18 +16,26 @@ 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() {
@@ -40,9 +50,34 @@ func init() {
40
50
  if scenarioFactorySystem == nil {
41
51
  return nil, errors.New("scenario task system not configured")
42
52
  }
53
+ if fake, ok := scenarioFactorySystem.(*scenarioTaskSystem); ok {
54
+ fake.log.add("factory:task:" + scenarioTaskPlugin)
55
+ }
43
56
  return scenarioFactorySystem, nil
44
57
  },
45
58
  })
59
+ runner.Register(scenarioRunnerPlugin, func(config.RawValues) (runner.Runner, error) {
60
+ scenarioFactoryMu.Lock()
61
+ defer scenarioFactoryMu.Unlock()
62
+ if scenarioFactoryRunner == nil {
63
+ return nil, errors.New("scenario runner not configured")
64
+ }
65
+ if fake, ok := scenarioFactoryRunner.(*scenarioRunner); ok {
66
+ fake.log.add("factory:runner:" + scenarioRunnerPlugin)
67
+ }
68
+ return scenarioFactoryRunner, nil
69
+ })
70
+ harness.Register(scenarioHarnessPlugin, func(config.RawValues) (harness.Harness, error) {
71
+ scenarioFactoryMu.Lock()
72
+ defer scenarioFactoryMu.Unlock()
73
+ if scenarioFactoryHarness == nil {
74
+ return nil, errors.New("scenario harness not configured")
75
+ }
76
+ if fake, ok := scenarioFactoryHarness.(*scenarioHarness); ok {
77
+ fake.log.add("factory:harness:" + scenarioHarnessPlugin)
78
+ }
79
+ return scenarioFactoryHarness, nil
80
+ })
46
81
  }
47
82
 
48
83
  func setScenarioFactorySystem(system task.System) {
@@ -51,6 +86,14 @@ func setScenarioFactorySystem(system task.System) {
51
86
  scenarioFactoryMu.Unlock()
52
87
  }
53
88
 
89
+ func setScenarioFactoryAdapters(system task.System, rnr runner.Runner, hrn harness.Harness) {
90
+ scenarioFactoryMu.Lock()
91
+ scenarioFactorySystem = system
92
+ scenarioFactoryRunner = rnr
93
+ scenarioFactoryHarness = hrn
94
+ scenarioFactoryMu.Unlock()
95
+ }
96
+
54
97
  // These scenarios exercise the same composition chain as serve:
55
98
  // RepoPoller/handleBatch -> RunManager -> real go-workflows SQLite engine.
56
99
  // The only replacements are the documented task, runner, and harness seams.
@@ -89,6 +132,130 @@ func TestScenarioHappyPath(t *testing.T) {
89
132
  assertExactHappyEffects(t, f)
90
133
  }
91
134
 
135
+ func TestCompositionRootSelectsAlternatePluginsForDurableRun(t *testing.T) {
136
+ log := newScenarioLog()
137
+ tasks := newScenarioTaskSystem(log)
138
+ rnr := newScenarioRunner(log)
139
+ hrn := newScenarioHarness(log)
140
+ setScenarioFactoryAdapters(tasks, rnr, hrn)
141
+
142
+ root := filepath.Join(t.TempDir(), ".relay-flow")
143
+ t.Setenv("RELAY_FLOW_HOME", root)
144
+ p, err := home()
145
+ if err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if err := paths.Ensure(p); err != nil {
149
+ t.Fatal(err)
150
+ }
151
+ if err := config.SaveMachine(p.Config, &config.Machine{
152
+ PollIntervalSeconds: 1,
153
+ TaskPlugin: scenarioTaskPlugin,
154
+ TaskConfig: config.RawValues{"provider": "alternate"},
155
+ RunnerPlugin: scenarioRunnerPlugin,
156
+ RunnerConfig: config.RawValues{"transport": "opaque"},
157
+ HarnessPlugin: scenarioHarnessPlugin,
158
+ HarnessConfig: config.RawValues{"runtime": "alternate"},
159
+ Repos: map[string]config.Repo{
160
+ scenarioRepo: {Path: scenarioRepoPath, TaskConfig: config.RawValues{"scope": "test"}},
161
+ },
162
+ }); err != nil {
163
+ t.Fatal(err)
164
+ }
165
+ if err := (&workflow.Store{Dir: p.Workflows}).Put(scenarioWorkflowName, scenarioWorkflowYAML); err != nil {
166
+ t.Fatal(err)
167
+ }
168
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
169
+ t.Fatal(err)
170
+ }
171
+
172
+ ctx, cancel := context.WithCancel(context.Background())
173
+ done := make(chan error, 1)
174
+ stopped := false
175
+ go func() { done <- serveRoot(ctx, p, false) }()
176
+ t.Cleanup(func() {
177
+ if stopped {
178
+ return
179
+ }
180
+ cancel()
181
+ select {
182
+ case <-done:
183
+ case <-time.After(10 * time.Second):
184
+ }
185
+ })
186
+
187
+ client := server.NewClient(p.Socket)
188
+ waitForServer(t, client)
189
+ var active runsvc.Run
190
+ waitScenario(t, 10*time.Second, func() bool {
191
+ var lookupErr error
192
+ active, lookupErr = client.GetRunByTicket(context.Background(), scenarioTicket)
193
+ return lookupErr == nil && active.State == runsvc.StateWaiting && active.CurrentNode == "implement"
194
+ })
195
+
196
+ launch := hrn.launch("implement")
197
+ wantPrompt := "Task system: " + scenarioTaskPlugin + "\nUse the " + scenarioTaskPlugin + " tools to read the parent ticket " + scenarioTicket + "."
198
+ if !strings.Contains(launch.Prompt, wantPrompt) {
199
+ t.Fatalf("selected task system did not reach launch prompt: %q", launch.Prompt)
200
+ }
201
+ command := rnr.command(scenarioTicket + ":implement")
202
+ wantArgs := []string{"opaque", launch.Prompt}
203
+ if command.Executable != "fake-harness" || !reflect.DeepEqual(command.Args, wantArgs) {
204
+ t.Fatalf("runner command = %#v, want opaque harness command args %#v", command, wantArgs)
205
+ }
206
+ for _, event := range []string{
207
+ "factory:task:" + scenarioTaskPlugin,
208
+ "factory:runner:" + scenarioRunnerPlugin,
209
+ "factory:harness:" + scenarioHarnessPlugin,
210
+ "task-config-validated",
211
+ "runner-repo-validated",
212
+ "harness-agent-validated:implementer",
213
+ "harness-launched:" + scenarioTicket + ":implement",
214
+ "terminal-created:" + scenarioTicket + ":implement",
215
+ } {
216
+ if log.countPrefix(event) == 0 {
217
+ t.Fatalf("composition did not call selected interface boundary %q; events=%v", event, log.all())
218
+ }
219
+ }
220
+
221
+ for i, next := range []string{"verify", "pr-review", "end"} {
222
+ current, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
223
+ if getErr != nil {
224
+ t.Fatal(getErr)
225
+ }
226
+ ack, submitErr := client.SubmitReport(context.Background(), runsvc.ReportRequest{
227
+ RunID: current.ID, Node: current.CurrentNode,
228
+ ReportID: "alternate-session:message-" + fmt.Sprint(i+1),
229
+ Report: scenarioReport(workflow.OutcomeSuccess, next),
230
+ })
231
+ if submitErr != nil || !ack.Accepted {
232
+ t.Fatalf("submit durable report %s -> %s: ack=%+v err=%v", current.CurrentNode, next, ack, submitErr)
233
+ }
234
+ if next != "end" {
235
+ waitScenario(t, 10*time.Second, func() bool {
236
+ advanced, advanceErr := client.GetRunByTicket(context.Background(), scenarioTicket)
237
+ return advanceErr == nil && advanced.State == runsvc.StateWaiting && advanced.CurrentNode == next
238
+ })
239
+ }
240
+ }
241
+ waitScenario(t, 10*time.Second, func() bool {
242
+ finished, getErr := client.GetRunByTicket(context.Background(), scenarioTicket)
243
+ return getErr == nil && finished.State == runsvc.StateCompleted
244
+ })
245
+ if err := client.Stop(context.Background()); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ select {
249
+ case err := <-done:
250
+ if err != nil {
251
+ t.Fatalf("serveRoot: %v", err)
252
+ }
253
+ case <-time.After(10 * time.Second):
254
+ t.Fatal("serveRoot did not stop")
255
+ }
256
+ stopped = true
257
+ }
258
+
92
259
  func TestScenarioHITLRejectLoop(t *testing.T) {
93
260
  f := newScenarioFixture(t)
94
261
  f.pollOnce()
@@ -629,6 +796,8 @@ type scenarioTaskSystem struct {
629
796
  completeFailures int
630
797
  }
631
798
 
799
+ var _ task.System = (*scenarioTaskSystem)(nil)
800
+
632
801
  func newScenarioTaskSystem(log *scenarioLog) *scenarioTaskSystem {
633
802
  return &scenarioTaskSystem{
634
803
  log: log, mailboxes: map[string]task.Mailbox{}, specs: map[string]task.MailboxSpec{},
@@ -660,6 +829,7 @@ func (s *scenarioTaskSystem) Claim(_ context.Context, ref task.TicketRef, workfl
660
829
  }
661
830
 
662
831
  func (s *scenarioTaskSystem) ValidateConfig(context.Context, config.RawValues, map[string]config.RawValues) error {
832
+ s.log.add("task-config-validated")
663
833
  return nil
664
834
  }
665
835
 
@@ -827,6 +997,8 @@ type scenarioRunner struct {
827
997
  cleanups int
828
998
  }
829
999
 
1000
+ var _ runner.Runner = (*scenarioRunner)(nil)
1001
+
830
1002
  func newScenarioRunner(log *scenarioLog) *scenarioRunner {
831
1003
  return &scenarioRunner{
832
1004
  log: log, environments: map[string]runner.Environment{}, terminals: map[string]*scenarioTerminal{},
@@ -838,7 +1010,10 @@ func (r *scenarioRunner) DiscoverRepos(context.Context) ([]runner.RepoCandidate,
838
1010
  return []runner.RepoCandidate{{Name: scenarioRepo, Path: scenarioRepoPath}}, nil
839
1011
  }
840
1012
 
841
- func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error { return nil }
1013
+ func (r *scenarioRunner) ValidateRepo(context.Context, string, string) error {
1014
+ r.log.add("runner-repo-validated")
1015
+ return nil
1016
+ }
842
1017
 
843
1018
  func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpec) (runner.Environment, error) {
844
1019
  r.mu.Lock()
@@ -852,16 +1027,12 @@ func (r *scenarioRunner) EnsureEnvironment(_ context.Context, spec runner.RunSpe
852
1027
  return env, nil
853
1028
  }
854
1029
 
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
1030
+ func (r *scenarioRunner) SetEnvironmentStatus(_ context.Context, _ runner.Environment, status string) error {
1031
+ r.log.add("workspace-status:" + status)
1032
+ return nil
863
1033
  }
864
- func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
1034
+
1035
+ func (r *scenarioRunner) FindTerminal(_ context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
865
1036
  r.mu.Lock()
866
1037
  defer r.mu.Unlock()
867
1038
  for _, current := range r.terminals {
@@ -872,8 +1043,15 @@ func (r *scenarioRunner) InspectTerminal(_ context.Context, terminal runner.Term
872
1043
  return runner.Terminal{}, false, nil
873
1044
  }
874
1045
  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)
1046
+ func (r *scenarioRunner) CreateTerminal(_ context.Context, _ runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
1047
+ r.mu.Lock()
1048
+ defer r.mu.Unlock()
1049
+ terminal := runner.Terminal{ID: "terminal-" + title, Title: title}
1050
+ r.terminals[title] = &scenarioTerminal{terminal: terminal, live: true}
1051
+ r.commands[title] = append(r.commands[title], command)
1052
+ r.launches[title]++
1053
+ r.log.add("terminal-created:" + title)
1054
+ return terminal, nil
877
1055
  }
878
1056
 
879
1057
  func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Terminal) error {
@@ -886,18 +1064,13 @@ func (r *scenarioRunner) CloseTerminal(_ context.Context, terminal runner.Termin
886
1064
  return nil
887
1065
  }
888
1066
 
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
1067
+ func (r *scenarioRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
1068
+ if terminal, ok, err := r.FindTerminal(ctx, stored); err != nil {
1069
+ return runner.Terminal{}, err
1070
+ } else if ok {
1071
+ return terminal, nil
894
1072
  }
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
1073
+ return r.CreateTerminal(ctx, env, title, command)
901
1074
  }
902
1075
 
903
1076
  func (r *scenarioRunner) CloseTerminals(context.Context, runner.RunSpec) error {
@@ -948,11 +1121,16 @@ type scenarioHarness struct {
948
1121
  launches map[string][]harness.LaunchSpec
949
1122
  }
950
1123
 
1124
+ var _ harness.Harness = (*scenarioHarness)(nil)
1125
+
951
1126
  func newScenarioHarness(log *scenarioLog) *scenarioHarness {
952
1127
  return &scenarioHarness{log: log, sessions: map[string]harness.Session{}, launches: map[string][]harness.LaunchSpec{}}
953
1128
  }
954
1129
 
955
- func (h *scenarioHarness) ValidateAgent(context.Context, string, string) error { return nil }
1130
+ func (h *scenarioHarness) ValidateAgent(_ context.Context, _, agent string) error {
1131
+ h.log.add("harness-agent-validated:" + agent)
1132
+ return nil
1133
+ }
956
1134
 
957
1135
  func (h *scenarioHarness) FindSession(_ context.Context, _ string, title string) (harness.Session, bool, error) {
958
1136
  h.mu.Lock()
@@ -969,6 +1147,7 @@ func (h *scenarioHarness) BuildCommand(spec harness.LaunchSpec) (runner.Command,
969
1147
  h.log.add("harness-launched:" + spec.Title)
970
1148
  return runner.Command{
971
1149
  Executable: "fake-harness",
1150
+ Args: []string{"opaque", spec.Prompt},
972
1151
  Env: map[string]string{
973
1152
  "RELAY_FLOW_RUN_ID": string(spec.RunID),
974
1153
  "RELAY_FLOW_WORKFLOW": spec.Workflow,
@@ -993,12 +1172,8 @@ func (h *scenarioHarness) launch(node string) harness.LaunchSpec {
993
1172
  }
994
1173
 
995
1174
  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, "\",\"") + "\"]"
1175
+ b, _ := json.Marshal(routes)
1176
+ return string(b)
1002
1177
  }
1003
1178
 
1004
1179
  type scenarioRepoLookup struct{ reg *repo.Registry }
@@ -204,6 +204,7 @@ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
204
204
  Repos: repoReg,
205
205
  Runner: rnr,
206
206
  Harness: hrn,
207
+ TaskSystem: cfg.TaskPlugin,
207
208
  RetentionDays: cfg.CompletedRunRetentionDays,
208
209
  Runtime: &runsvc.RuntimePolicy{
209
210
  KeepTerminalsAlive: cfg.KeepTerminalsAlive,
@@ -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
+ - payments
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: coding
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: review
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 Review
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: review
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 Review
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
@@ -2,7 +2,6 @@ package goworkflows
2
2
 
3
3
  import (
4
4
  "context"
5
- "errors"
6
5
  "fmt"
7
6
  "log/slog"
8
7
  "sort"
@@ -21,10 +20,11 @@ import (
21
20
  // Activities holds the replaceable dependencies shared by every durable
22
21
  // activity. One Activities value is registered with the activity worker.
23
22
  type Activities struct {
24
- Repos *repo.Registry
25
- Runner runner.Runner
26
- Harness harness.Harness
27
- Runs *RunProjection
23
+ Repos *repo.Registry
24
+ Runner runner.Runner
25
+ Harness harness.Harness
26
+ TaskSystem string
27
+ Runs *RunProjection
28
28
  }
29
29
 
30
30
  func (a *Activities) taskSystem(repoName string) (task.System, error) {
@@ -97,13 +97,23 @@ func (a *Activities) EnsureEnvironment(ctx context.Context, w run.Work, repoPath
97
97
  return a.Runner.EnsureEnvironment(ctx, spec)
98
98
  }
99
99
 
100
+ func (a *Activities) SetEnvironmentStatus(ctx context.Context, w run.Work, repoPath, status string) error {
101
+ spec := a.runSpec(w)
102
+ spec.RepoPath = repoPath
103
+ env, err := a.Runner.EnsureEnvironment(ctx, spec)
104
+ if err != nil {
105
+ return err
106
+ }
107
+ return a.Runner.SetEnvironmentStatus(ctx, env, status)
108
+ }
109
+
100
110
  func (a *Activities) LoadNodeRuntime(ctx context.Context, id run.ID, node string) (NodeRuntime, error) {
101
111
  return a.Runs.loadNodeRuntime(ctx, id, node)
102
112
  }
103
113
 
104
114
  // EnsureNodeRuntime uses only persisted terminal/session IDs on the normal
105
- // path. A live terminal is rebound to the new visit; otherwise a fresh
106
- // terminal is created and its direct IDs atomically replace the old binding.
115
+ // path. A live terminal is rebound to the new visit; otherwise EnsureTerminal
116
+ // creates a replacement and its direct ID is persisted immediately.
107
117
  func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, repoPath string, spec harness.LaunchSpec, rt NodeRuntime) error {
108
118
  a.Runs.runtimeMu.Lock()
109
119
  defer a.Runs.runtimeMu.Unlock()
@@ -124,45 +134,25 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
124
134
  if err != nil {
125
135
  return err
126
136
  }
137
+ rs := a.runSpec(nw.Work)
138
+ rs.RepoPath = repoPath
139
+ env, err := a.Runner.EnsureEnvironment(ctx, rs)
140
+ if err != nil {
141
+ return err
142
+ }
143
+ status := runner.WorkspaceStatusInProgress
144
+ if spec.NodeType == workflow.NodeHITL {
145
+ status = runner.WorkspaceStatusInReview
146
+ }
147
+ if err := a.Runner.SetEnvironmentStatus(ctx, env, status); err != nil {
148
+ return err
149
+ }
127
150
  hadRuntime := currentRuntime.TerminalID != "" || currentRuntime.SessionID != ""
128
151
  // IDs come from the guarded current row; the activity input's prior visit
129
152
  // is used only to decide whether a live process needs rebinding.
130
153
  rt.TerminalID = currentRuntime.TerminalID
131
154
  rt.SessionID = currentRuntime.SessionID
132
- freshSession := false
133
- if rt.TerminalID != "" {
134
- terminal := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
135
- _, ok, inspectErr := a.Runner.InspectTerminal(ctx, terminal)
136
- if inspectErr != nil {
137
- return inspectErr
138
- }
139
- if ok {
140
- // Same-visit retry/restart leaves the running turn untouched. A
141
- // revisit sends only the new work prompt to the retained session.
142
- if !revisit {
143
- return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
144
- rt.TerminalID, rt.SessionID, rt.SessionID)
145
- }
146
- prompt := followUpPrompt(nw.Mailbox.Key)
147
- if spec.NudgePrompt != "" {
148
- prompt += "\n\n" + spec.NudgePrompt
149
- }
150
- if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
151
- return a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
152
- rt.TerminalID, rt.SessionID, rt.SessionID)
153
- }
154
- // Direct use failed. Close the known live terminal before replacing
155
- // it so a second agent process cannot be left running.
156
- if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
157
- return err
158
- }
159
- freshSession = true
160
- }
161
- }
162
-
163
- if rt.SessionID != "" && !freshSession {
164
- spec.ResumeID = rt.SessionID
165
- }
155
+ spec.ResumeID = rt.SessionID
166
156
  // Custom instructions belong to a node entry, not same-visit recovery.
167
157
  if !hadRuntime || revisit {
168
158
  spec.Prompt = appendPrompt(spec.Prompt, spec.NudgePrompt)
@@ -171,34 +161,46 @@ func (a *Activities) EnsureNodeRuntime(ctx context.Context, nw run.NodeWork, rep
171
161
  if err != nil {
172
162
  return err
173
163
  }
174
- rs := a.runSpec(nw.Work)
175
- rs.RepoPath = repoPath
176
- env, err := a.Runner.EnsureEnvironment(ctx, rs)
164
+ stored := runner.Terminal{ID: rt.TerminalID, Title: spec.Title}
165
+ terminal, err := a.Runner.EnsureTerminal(ctx, env, stored, spec.Title, cmd)
177
166
  if err != nil {
178
167
  return err
179
168
  }
180
- terminal, err := a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
181
- sessionID := rt.SessionID
182
- if errors.Is(err, runner.ErrSessionUnavailable) && rt.SessionID != "" {
183
- spec.ResumeID = ""
184
- cmd, buildErr := a.Harness.BuildCommand(spec)
185
- if buildErr != nil {
186
- return buildErr
169
+ // Persist a newly created/replacement handle before any later external
170
+ // effect. Runtime session registration may update SessionID independently.
171
+ if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
172
+ terminal.ID, rt.SessionID, rt.SessionID); err != nil {
173
+ // The visit changed after terminal creation. Close the terminal whose
174
+ // binding was rejected so stale work cannot leak or report.
175
+ if terminal.ID != rt.TerminalID {
176
+ _ = a.Runner.CloseTerminal(ctx, terminal)
187
177
  }
188
- terminal, err = a.Runner.CreateTerminal(ctx, env, spec.Title, cmd)
189
- sessionID = ""
178
+ return err
179
+ }
180
+ if terminal.ID != rt.TerminalID || !revisit {
181
+ // A newly created terminal receives its prompt in the launch command;
182
+ // same-visit reuse leaves the running turn untouched.
183
+ return nil
184
+ }
185
+ prompt := followUpPrompt(nw.Mailbox.Key)
186
+ if spec.NudgePrompt != "" {
187
+ prompt += "\n\n" + spec.NudgePrompt
190
188
  }
191
- if freshSession {
192
- sessionID = ""
189
+ if err := a.Runner.SendTerminal(ctx, terminal, prompt); err == nil {
190
+ return nil
191
+ }
192
+ // Direct use failed. Close the known live terminal before replacing it so
193
+ // a second agent process cannot be left running.
194
+ if err := a.Runner.CloseTerminal(ctx, terminal); err != nil {
195
+ return err
193
196
  }
197
+ replacement, err := a.Runner.EnsureTerminal(ctx, env, terminal, spec.Title, cmd)
194
198
  if err != nil {
195
199
  return err
196
200
  }
197
201
  if err := a.Runs.replaceNodeRuntime(ctx, nw.RunID, nw.Node, nw.NodeVisitID,
198
- terminal.ID, rt.SessionID, sessionID); err != nil {
199
- // The visit changed after terminal creation. Close the terminal whose
200
- // binding was rejected so stale work cannot leak or report.
201
- _ = a.Runner.CloseTerminal(ctx, terminal)
202
+ replacement.ID, rt.SessionID, rt.SessionID); err != nil {
203
+ _ = a.Runner.CloseTerminal(ctx, replacement)
202
204
  return err
203
205
  }
204
206
  return nil
@@ -393,15 +395,13 @@ func (a *Activities) ProjectionUpdateRetry(ctx context.Context, id run.ID, statu
393
395
  // description, and every legal route with its when explanation.
394
396
  func MailboxSpecForNode(wf *workflow.Workflow, ticketKey, name string, n workflow.Node) task.MailboxSpec {
395
397
  var b strings.Builder
396
- fmt.Fprintf(&b, "Parent Jira ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
398
+ fmt.Fprintf(&b, "Parent ticket: %s\nNode: %s\nType: %s\nAgent: %s\n\nWork:\n%s\n\nRead this subtask's comments for feedback from previous nodes.",
397
399
  ticketKey, name, n.Type, n.Agent, n.Description)
398
400
  if n.Type == workflow.NodeHITL {
399
401
  b.WriteString(`
400
402
 
401
403
  1. Discuss the task with the human, request the PR link or any missing context, and review the changes. Do not make code changes.
402
- 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.
403
- 3. Present the complete report through OpenCode's Question tool with exactly two options: Approve and Reject.
404
- 4. If approved, output the report verbatim. If rejected, return to step 1.`)
404
+ 2. Resolve questions and requested review updates through normal conversation until the human is satisfied with the review.`)
405
405
  }
406
406
  b.WriteString(`
407
407
 
@@ -465,8 +465,8 @@ func MailboxSpecs(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
465
465
  }
466
466
 
467
467
  // BuildLaunchSpecPrompt points the agent to its parent and isolated mailbox.
468
- func BuildLaunchSpecPrompt(ticketKey, mailboxKey string) string {
469
- return fmt.Sprintf("Read parent Jira ticket %s for the original task context.\n\nYour Jira mailbox subtask is %s. Read only its description and comments for your node instructions and feedback.", ticketKey, mailboxKey)
468
+ func BuildLaunchSpecPrompt(taskSystem, ticketKey, mailboxKey string) string {
469
+ return fmt.Sprintf("Task system: %s\nUse the %s tools to read the parent ticket %s.\n\nYour mailbox is %s. Read its description and comments for node instructions and feedback.", taskSystem, taskSystem, ticketKey, mailboxKey)
470
470
  }
471
471
 
472
472
  func followUpPrompt(mailboxKey string) string {