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
@@ -32,12 +32,23 @@ type Repo struct {
32
32
  TaskConfig config.RawValues
33
33
  TaskSystem task.System
34
34
  Workflows []WorkflowBinding
35
+ bindingsMu sync.RWMutex
35
36
  }
36
37
 
37
38
  func (r *Repo) Info() Info {
38
39
  return Info{Name: r.Name, Path: r.Path, TaskConfig: r.TaskConfig}
39
40
  }
40
41
 
42
+ // Bindings returns a snapshot of the repo's currently published workflow
43
+ // bindings. WorkflowBinding values are immutable after publication.
44
+ func (r *Repo) Bindings() []WorkflowBinding {
45
+ r.bindingsMu.RLock()
46
+ bindings := make([]WorkflowBinding, len(r.Workflows))
47
+ copy(bindings, r.Workflows)
48
+ r.bindingsMu.RUnlock()
49
+ return bindings
50
+ }
51
+
41
52
  // Registry is the in-memory repo set.
42
53
  type Registry struct {
43
54
  mu sync.RWMutex
@@ -113,7 +124,9 @@ func (r *Registry) BindWorkflows(workflows []*workflow.Workflow) error {
113
124
  next = append(next, WorkflowBinding{Workflow: b.wf, Match: b.match})
114
125
  }
115
126
  sort.Slice(next, func(i, j int) bool { return next[i].Workflow.Name < next[j].Workflow.Name })
127
+ rp.bindingsMu.Lock()
116
128
  rp.Workflows = next
129
+ rp.bindingsMu.Unlock()
117
130
  }
118
131
  return nil
119
132
  }
@@ -6,6 +6,7 @@ import (
6
6
  "path/filepath"
7
7
 
8
8
  "github.com/rajpopat27/relay-flow/internal/config"
9
+ "github.com/rajpopat27/relay-flow/internal/harness"
9
10
  "github.com/rajpopat27/relay-flow/internal/runner"
10
11
  "github.com/rajpopat27/relay-flow/internal/task"
11
12
  )
@@ -26,6 +27,7 @@ type ServiceConfig struct {
26
27
  ConfigPath string
27
28
  TaskPlugin string
28
29
  Runner runner.Runner
30
+ Harness harness.Harness
29
31
  Active ActiveRuns
30
32
  Workflows WorkflowRefs
31
33
  }
@@ -34,6 +36,7 @@ type Service struct {
34
36
  cfgPath string
35
37
  taskPlugin string
36
38
  runner runner.Runner
39
+ harness harness.Harness
37
40
  active ActiveRuns
38
41
  workflows WorkflowRefs
39
42
  reg *Registry
@@ -51,6 +54,7 @@ func NewServiceWithRegistry(cfg ServiceConfig, reg *Registry) *Service {
51
54
  cfgPath: cfg.ConfigPath,
52
55
  taskPlugin: cfg.TaskPlugin,
53
56
  runner: cfg.Runner,
57
+ harness: cfg.Harness,
54
58
  active: cfg.Active,
55
59
  workflows: cfg.Workflows,
56
60
  reg: reg,
@@ -141,6 +145,12 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
141
145
  if err != nil {
142
146
  return Info{}, fmt.Errorf("repo %q: task system connectivity: %w", input.Name, err)
143
147
  }
148
+ if s.harness == nil {
149
+ return Info{}, fmt.Errorf("repo %q: harness is not configured", input.Name)
150
+ }
151
+ if err := s.harness.SetupRepo(ctx, input.Path); err != nil {
152
+ return Info{}, fmt.Errorf("repo %q: harness setup: %w", input.Name, err)
153
+ }
144
154
  // Persist atomically, then update the in-memory registry.
145
155
  r := config.Repo{Path: input.Path, TaskConfig: input.TaskConfig}
146
156
  if cfg.Repos == nil {
@@ -8,6 +8,7 @@ import (
8
8
  "testing"
9
9
 
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
+ "github.com/rajpopat27/relay-flow/internal/harness"
11
12
  "github.com/rajpopat27/relay-flow/internal/repo"
12
13
  "github.com/rajpopat27/relay-flow/internal/runner"
13
14
  "github.com/rajpopat27/relay-flow/internal/task"
@@ -29,10 +30,10 @@ func (f *fakeRunnerDiscovery) ValidateRepo(_ context.Context, _, _ string) error
29
30
  func (f *fakeRunnerDiscovery) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
30
31
  return runner.Environment{}, nil
31
32
  }
32
- func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
33
- return runner.Terminal{}, false, nil
33
+ func (f *fakeRunnerDiscovery) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
34
+ return nil
34
35
  }
35
- func (f *fakeRunnerDiscovery) InspectTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
36
+ func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
36
37
  return runner.Terminal{}, false, nil
37
38
  }
38
39
  func (f *fakeRunnerDiscovery) SendTerminal(context.Context, runner.Terminal, string) error {
@@ -42,12 +43,32 @@ func (f *fakeRunnerDiscovery) CreateTerminal(context.Context, runner.Environment
42
43
  return runner.Terminal{}, nil
43
44
  }
44
45
  func (f *fakeRunnerDiscovery) CloseTerminal(context.Context, runner.Terminal) error { return nil }
45
- func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment, string, runner.Command) (runner.Terminal, error) {
46
+ func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment, runner.Terminal, string, runner.Command) (runner.Terminal, error) {
46
47
  return runner.Terminal{}, nil
47
48
  }
48
49
  func (f *fakeRunnerDiscovery) CloseTerminals(context.Context, runner.RunSpec) error { return nil }
49
50
  func (f *fakeRunnerDiscovery) CleanupRun(context.Context, runner.RunSpec) error { return nil }
50
51
 
52
+ type fakeHarness struct {
53
+ setupPaths []string
54
+ setupErr error
55
+ }
56
+
57
+ func (f *fakeHarness) SetupRepo(_ context.Context, path string) error {
58
+ f.setupPaths = append(f.setupPaths, path)
59
+ return f.setupErr
60
+ }
61
+ func (*fakeHarness) ValidateAgent(context.Context, string, string) error { return nil }
62
+ func (*fakeHarness) FindSession(context.Context, string, string) (harness.Session, bool, error) {
63
+ return harness.Session{}, false, nil
64
+ }
65
+ func (*fakeHarness) RenderPrompt(harness.PromptKind, harness.PromptData, string) (string, error) {
66
+ return "", nil
67
+ }
68
+ func (*fakeHarness) BuildCommand(harness.LaunchSpec) (runner.Command, error) {
69
+ return runner.Command{}, nil
70
+ }
71
+
51
72
  // fakeActiveRuns / fakeWorkflowRefs implement the consumer interfaces.
52
73
  type fakeActiveRuns struct{ repos map[string]bool }
53
74
 
@@ -102,6 +123,7 @@ type serviceFixture struct {
102
123
  cfgPath string
103
124
  active *fakeActiveRuns
104
125
  wfRefs *fakeWorkflowRefs
126
+ harness *fakeHarness
105
127
  }
106
128
 
107
129
  func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
@@ -118,14 +140,16 @@ func newServiceFixture(t *testing.T, rn runner.Runner) serviceFixture {
118
140
  }
119
141
  active := &fakeActiveRuns{repos: map[string]bool{}}
120
142
  wfRefs := &fakeWorkflowRefs{refs: map[string]bool{}}
143
+ hrn := &fakeHarness{}
121
144
  svc := repo.NewService(repo.ServiceConfig{
122
145
  ConfigPath: cfgPath,
123
146
  TaskPlugin: "testjira",
124
147
  Runner: rn,
148
+ Harness: hrn,
125
149
  Active: active,
126
150
  Workflows: wfRefs,
127
151
  })
128
- return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs}
152
+ return serviceFixture{svc: svc, cfgPath: cfgPath, active: active, wfRefs: wfRefs, harness: hrn}
129
153
  }
130
154
 
131
155
  func TestDiscoverDelegatesToRunner(t *testing.T) {
@@ -222,7 +246,7 @@ func TestRegisterValidatesTaskConnectivity(t *testing.T) {
222
246
  }
223
247
  svc := repo.NewService(repo.ServiceConfig{
224
248
  ConfigPath: cfgPath, TaskPlugin: "failingtask",
225
- Runner: &fakeRunnerDiscovery{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
249
+ Runner: &fakeRunnerDiscovery{}, Harness: &fakeHarness{}, Active: &fakeActiveRuns{repos: map[string]bool{}}, Workflows: &fakeWorkflowRefs{refs: map[string]bool{}},
226
250
  })
227
251
  _, err := svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY"}})
228
252
  if err == nil {
@@ -256,6 +280,31 @@ func TestRegisterAtomicallyPersists(t *testing.T) {
256
280
  }
257
281
  }
258
282
 
283
+ func TestRegisterSetsUpHarnessRepo(t *testing.T) {
284
+ fx := newServiceFixture(t, &fakeRunnerDiscovery{})
285
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
286
+ t.Fatal(err)
287
+ }
288
+ if len(fx.harness.setupPaths) != 1 || fx.harness.setupPaths[0] != "/srv/payments" {
289
+ t.Fatalf("SetupRepo paths = %v", fx.harness.setupPaths)
290
+ }
291
+ }
292
+
293
+ func TestRegisterHarnessSetupFailureDoesNotPersist(t *testing.T) {
294
+ fx := newServiceFixture(t, &fakeRunnerDiscovery{})
295
+ fx.harness.setupErr = errors.New("setup failed")
296
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err == nil {
297
+ t.Fatal("registration succeeded despite harness setup failure")
298
+ }
299
+ cfg, err := config.LoadMachine(fx.cfgPath)
300
+ if err != nil {
301
+ t.Fatal(err)
302
+ }
303
+ if len(cfg.Repos) != 0 {
304
+ t.Fatalf("repos persisted after setup failure: %v", cfg.Repos)
305
+ }
306
+ }
307
+
259
308
  func TestRemoveStopsPollerAndRemoves(t *testing.T) {
260
309
  fx := newServiceFixture(t, &fakeRunnerDiscovery{})
261
310
  if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "PAY", "component": "api"}}); err != nil {
@@ -52,9 +52,10 @@ func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workf
52
52
  if len(claims) > 1 {
53
53
  return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: strings.Join(claims, ","), Repo: registered.Name}
54
54
  }
55
+ bindings := registered.Bindings()
55
56
  if len(claims) == 1 {
56
57
  name := strings.TrimPrefix(claims[0], claimPrefix)
57
- for _, b := range registered.Workflows {
58
+ for _, b := range bindings {
58
59
  if b.Workflow.Name == name {
59
60
  return b.Workflow, nil
60
61
  }
@@ -62,7 +63,7 @@ func ResolveWorkflow(registered *repo.Repo, ticket task.Ticket) (*workflow.Workf
62
63
  return nil, &InvalidClaimError{Ticket: ticket.Key, Workflow: name, Repo: registered.Name}
63
64
  }
64
65
  var matched []*workflow.Workflow
65
- for _, b := range registered.Workflows {
66
+ for _, b := range bindings {
66
67
  if b.Match != nil && b.Match(ticket) {
67
68
  matched = append(matched, b.Workflow)
68
69
  }
@@ -1,9 +1,14 @@
1
1
  package router_test
2
2
 
3
3
  import (
4
+ "context"
4
5
  "errors"
6
+ "fmt"
7
+ "sync/atomic"
5
8
  "testing"
9
+ "time"
6
10
 
11
+ "github.com/rajpopat27/relay-flow/internal/config"
7
12
  "github.com/rajpopat27/relay-flow/internal/repo"
8
13
  "github.com/rajpopat27/relay-flow/internal/router"
9
14
  "github.com/rajpopat27/relay-flow/internal/task"
@@ -142,3 +147,85 @@ func TestMultipleMatchesAmbiguous(t *testing.T) {
142
147
  t.Fatalf("ticket mutated on ambiguity: %+v", ticket)
143
148
  }
144
149
  }
150
+
151
+ type routingTaskSystem struct{ task.System }
152
+
153
+ func (routingTaskSystem) Poll(context.Context) ([]task.Ticket, error) {
154
+ return []task.Ticket{{Key: "PAY-101"}}, nil
155
+ }
156
+
157
+ func (routingTaskSystem) CompileFilter(config.RawValues) (func(task.Ticket) bool, error) {
158
+ return func(task.Ticket) bool { return true }, nil
159
+ }
160
+
161
+ func TestPollerRoutesWhileBindingsAreReplaced(t *testing.T) {
162
+ registry := repo.NewRegistry()
163
+ registered := &repo.Repo{Name: "payments", TaskSystem: routingTaskSystem{}}
164
+ registry.Replace(registered)
165
+
166
+ first, second := wf("firstFlow"), wf("secondFlow")
167
+ if err := registry.BindWorkflows([]*workflow.Workflow{first}); err != nil {
168
+ t.Fatal(err)
169
+ }
170
+
171
+ var routed atomic.Int64
172
+ failures := make(chan error, 1)
173
+ poller := &repo.RepoPoller{
174
+ Repo: registered,
175
+ Interval: time.Microsecond,
176
+ Handle: func(_ context.Context, registered *repo.Repo, tickets []task.Ticket) {
177
+ for _, ticket := range tickets {
178
+ resolved, err := router.ResolveWorkflow(registered, ticket)
179
+ if err != nil {
180
+ select {
181
+ case failures <- err:
182
+ default:
183
+ }
184
+ continue
185
+ }
186
+ if resolved != first && resolved != second {
187
+ select {
188
+ case failures <- fmt.Errorf("resolved unexpected workflow %q", resolved.Name):
189
+ default:
190
+ }
191
+ }
192
+ routed.Add(1)
193
+ }
194
+ },
195
+ }
196
+
197
+ ctx, cancel := context.WithCancel(context.Background())
198
+ done := make(chan struct{})
199
+ go func() {
200
+ poller.Run(ctx)
201
+ close(done)
202
+ }()
203
+
204
+ for i := 0; i < 1_000; i++ {
205
+ bindings := []*workflow.Workflow{first}
206
+ if i%2 == 1 {
207
+ bindings = []*workflow.Workflow{second}
208
+ }
209
+ if err := registry.BindWorkflows(bindings); err != nil {
210
+ cancel()
211
+ <-done
212
+ t.Fatal(err)
213
+ }
214
+ }
215
+
216
+ deadline := time.Now().Add(time.Second)
217
+ for routed.Load() == 0 && time.Now().Before(deadline) {
218
+ time.Sleep(time.Millisecond)
219
+ }
220
+ cancel()
221
+ <-done
222
+
223
+ select {
224
+ case err := <-failures:
225
+ t.Fatalf("routing during binding replacement failed: %v", err)
226
+ default:
227
+ }
228
+ if routed.Load() == 0 {
229
+ t.Fatal("poller did not route while bindings were replaced")
230
+ }
231
+ }
@@ -47,6 +47,19 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
47
47
  break
48
48
  }
49
49
  }
50
+ existingRun := false
51
+ if claimed && m.Runs != nil {
52
+ existing, err := m.Runs.ListRuns(ctx, Filter{Repo: rp.Name, Workflow: wf.Name, Ticket: ticket.Key})
53
+ if err != nil {
54
+ return fmt.Errorf("check existing run %s: %w", id, err)
55
+ }
56
+ for _, candidate := range existing {
57
+ if candidate.ID == id {
58
+ existingRun = true
59
+ break
60
+ }
61
+ }
62
+ }
50
63
  if !claimed {
51
64
  if err := rp.TaskSystem.Claim(ctx, ticket.Ref(), wf.Name); err != nil {
52
65
  slog.Info("ensure-run outcome",
@@ -54,7 +67,7 @@ func (m *RunManager) EnsureRun(ctx context.Context, rp *repo.Repo, wf *workflow.
54
67
  "outcome", "error", "stage", "claim", "error", err)
55
68
  return fmt.Errorf("claim %s for workflow %s: %w", ticket.Key, wf.Name, err)
56
69
  }
57
- } else {
70
+ } else if !existingRun {
58
71
  // Claimed but possibly missing its run (claim-before-run crash gap or
59
72
  // retention cleanup): never recreate a canceled run.
60
73
  marked, err := rp.TaskSystem.HasComment(ctx, task.Target{Parent: ticket.Ref()}, CancellationMarker(id))
@@ -63,10 +63,12 @@ type NodeWork struct {
63
63
  type CommentWork struct {
64
64
  // RunID is the enclosing durable run; the comment activity uses it to
65
65
  // attribute its 9.3 transition log lines (ticket/runID/node attrs).
66
- RunID ID
67
- Item task.Target
68
- Body string
69
- Marker string
66
+ RunID ID
67
+ Item task.Target
68
+ Body string
69
+ TextKind task.TextKind
70
+ TextData task.TextData
71
+ Marker string
70
72
  }
71
73
 
72
74
  // RetryStatus describes the currently pending durable activity retry without
@@ -81,6 +81,8 @@ func (e *fakeExecutor) CancelRun(context.Context, run.ID, string) error { return
81
81
  // fakeQueries answers run lookups.
82
82
  type fakeQueries struct {
83
83
  byTicket map[string]run.Run
84
+ list []run.Run
85
+ listErr error
84
86
  }
85
87
 
86
88
  func (q *fakeQueries) GetRun(_ context.Context, id run.ID) (run.Run, error) {
@@ -99,7 +101,9 @@ func (q *fakeQueries) FindRunByTicket(_ context.Context, ticket string) (run.Run
99
101
  return run.Run{}, errors.New("not found")
100
102
  }
101
103
 
102
- func (q *fakeQueries) ListRuns(context.Context, run.Filter) ([]run.Run, error) { return nil, nil }
104
+ func (q *fakeQueries) ListRuns(context.Context, run.Filter) ([]run.Run, error) {
105
+ return append([]run.Run(nil), q.list...), q.listErr
106
+ }
103
107
  func (q *fakeQueries) HasActiveWorkflow(context.Context, string) (bool, error) { return false, nil }
104
108
  func (q *fakeQueries) HasActiveRepo(context.Context, string) (bool, error) { return false, nil }
105
109
 
@@ -171,6 +175,22 @@ func TestClaimedTicketSkipsClaiming(t *testing.T) {
171
175
  }
172
176
  }
173
177
 
178
+ func TestExistingRunSkipsJiraCalls(t *testing.T) {
179
+ log := newEventLog()
180
+ sys := &recordingSystem{log: log, hasCancel: true}
181
+ id := identity.NewRunID("payments", "basicFlow", "PAY-101")
182
+ exec := &fakeExecutor{log: log}
183
+ m := &run.RunManager{Executor: exec, Runs: &fakeQueries{list: []run.Run{{ID: id}}}}
184
+ ticket := task.Ticket{ID: "1", Key: "PAY-101", WorkflowClaims: []string{"wf:basicFlow"}}
185
+
186
+ if err := m.EnsureRun(context.Background(), testRepo(sys), testWorkflow("basicFlow"), ticket); err != nil {
187
+ t.Fatal(err)
188
+ }
189
+ if sys.hasCommentN != 0 || len(log.all()) != 1 || len(exec.ensures) != 1 {
190
+ t.Fatalf("existing run caused external work: comments=%d events=%v ensures=%d", sys.hasCommentN, log.all(), len(exec.ensures))
191
+ }
192
+ }
193
+
174
194
  func TestClaimFailurePreventsRunCreation(t *testing.T) {
175
195
  log := newEventLog()
176
196
  sys := &recordingSystem{log: log, claimErr: errors.New("jira down")}
@@ -2,6 +2,7 @@ package runner_test
2
2
 
3
3
  import (
4
4
  "context"
5
+ "fmt"
5
6
  "strings"
6
7
  "testing"
7
8
 
@@ -20,9 +21,13 @@ type fakeRunner struct {
20
21
  closed []string
21
22
  cleaned []string
22
23
  ensureN int
24
+ findN int
25
+ createN int
23
26
  ensureEnvN int
24
27
  }
25
28
 
29
+ var _ runner.Runner = (*fakeRunner)(nil)
30
+
26
31
  func newFakeRunner() *fakeRunner {
27
32
  return &fakeRunner{
28
33
  envs: map[string]runner.Environment{},
@@ -57,16 +62,12 @@ func (f *fakeRunner) EnsureEnvironment(_ context.Context, s runner.RunSpec) (run
57
62
  return env, nil
58
63
  }
59
64
 
60
- func (f *fakeRunner) FindTerminal(_ context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
61
- key := env.ID + "/" + title
62
- term, ok := f.terminals[key]
63
- if !ok || f.dead[key] {
64
- // Only a live usable terminal is returned.
65
- return runner.Terminal{}, false, nil
66
- }
67
- return term, true, nil
65
+ func (*fakeRunner) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
66
+ return nil
68
67
  }
69
- func (f *fakeRunner) InspectTerminal(_ context.Context, term runner.Terminal) (runner.Terminal, bool, error) {
68
+
69
+ func (f *fakeRunner) FindTerminal(_ context.Context, term runner.Terminal) (runner.Terminal, bool, error) {
70
+ f.findN++
70
71
  for key, current := range f.terminals {
71
72
  if current.ID == term.ID && !f.dead[key] {
72
73
  return current, true, nil
@@ -75,8 +76,13 @@ func (f *fakeRunner) InspectTerminal(_ context.Context, term runner.Terminal) (r
75
76
  return runner.Terminal{}, false, nil
76
77
  }
77
78
  func (f *fakeRunner) SendTerminal(context.Context, runner.Terminal, string) error { return nil }
78
- func (f *fakeRunner) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
79
- return f.EnsureTerminal(ctx, env, title, command)
79
+ func (f *fakeRunner) CreateTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
80
+ f.createN++
81
+ key := env.ID + "/" + title
82
+ term := runner.Terminal{ID: fmt.Sprintf("term-%d-%s", f.createN, key), Title: title}
83
+ f.terminals[key] = term
84
+ delete(f.dead, key)
85
+ return term, nil
80
86
  }
81
87
 
82
88
  func (f *fakeRunner) CloseTerminal(_ context.Context, term runner.Terminal) error {
@@ -89,16 +95,14 @@ func (f *fakeRunner) CloseTerminal(_ context.Context, term runner.Terminal) erro
89
95
  return nil
90
96
  }
91
97
 
92
- func (f *fakeRunner) EnsureTerminal(_ context.Context, env runner.Environment, title string, _ runner.Command) (runner.Terminal, error) {
98
+ func (f *fakeRunner) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
93
99
  f.ensureN++
94
- key := env.ID + "/" + title
95
- if term, ok := f.terminals[key]; ok && !f.dead[key] {
100
+ if term, ok, err := f.FindTerminal(ctx, stored); err != nil {
101
+ return runner.Terminal{}, err
102
+ } else if ok {
96
103
  return term, nil
97
104
  }
98
- term := runner.Terminal{ID: "term-" + key, Title: title}
99
- f.terminals[key] = term
100
- delete(f.dead, key)
101
- return term, nil
105
+ return f.CreateTerminal(ctx, env, title, command)
102
106
  }
103
107
 
104
108
  func (f *fakeRunner) CloseTerminals(_ context.Context, s runner.RunSpec) error {
@@ -128,7 +132,7 @@ func TestTerminalTitleIsTicketColonNode(t *testing.T) {
128
132
  if err != nil {
129
133
  t.Fatal(err)
130
134
  }
131
- term, err := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{Executable: "opencode"})
135
+ term, err := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{Executable: "opencode"})
132
136
  if err != nil {
133
137
  t.Fatal(err)
134
138
  }
@@ -148,16 +152,16 @@ func TestFindTerminalReturnsOnlyLiveUsable(t *testing.T) {
148
152
  f := newFakeRunner()
149
153
  ctx := context.Background()
150
154
  env, _ := f.EnsureEnvironment(ctx, spec())
151
- term, _ := f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
155
+ term, _ := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
152
156
 
153
- got, ok, err := f.FindTerminal(ctx, env, "PAY-101:coding")
157
+ got, ok, err := f.FindTerminal(ctx, term)
154
158
  if err != nil || !ok || got.ID != term.ID {
155
159
  t.Fatalf("FindTerminal = %v,%v,%v; want live terminal", got, ok, err)
156
160
  }
157
161
 
158
162
  // A stale/dead record is treated as absent.
159
163
  f.dead[env.ID+"/PAY-101:coding"] = true
160
- _, ok, err = f.FindTerminal(ctx, env, "PAY-101:coding")
164
+ _, ok, err = f.FindTerminal(ctx, term)
161
165
  if err != nil {
162
166
  t.Fatal(err)
163
167
  }
@@ -171,7 +175,7 @@ func TestCloseTerminalsPreservesEnvironment(t *testing.T) {
171
175
  ctx := context.Background()
172
176
  sp := spec()
173
177
  env, _ := f.EnsureEnvironment(ctx, sp)
174
- _, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
178
+ _, _ = f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
175
179
 
176
180
  if err := f.CloseTerminals(ctx, sp); err != nil {
177
181
  t.Fatal(err)
@@ -189,7 +193,7 @@ func TestCleanupRunRemovesAllRunResources(t *testing.T) {
189
193
  ctx := context.Background()
190
194
  sp := spec()
191
195
  env, _ := f.EnsureEnvironment(ctx, sp)
192
- _, _ = f.EnsureTerminal(ctx, env, "PAY-101:coding", runner.Command{})
196
+ _, _ = f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
193
197
 
194
198
  if err := f.CleanupRun(ctx, sp); err != nil {
195
199
  t.Fatal(err)
@@ -213,9 +217,43 @@ func TestEnsureIdempotent(t *testing.T) {
213
217
  t.Fatalf("EnsureEnvironment not idempotent: %v vs %v", env1, env2)
214
218
  }
215
219
 
216
- term1, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
217
- term2, _ := f.EnsureTerminal(ctx, env1, "PAY-101:coding", runner.Command{})
220
+ term1, _ := f.EnsureTerminal(ctx, env1, runner.Terminal{}, "PAY-101:coding", runner.Command{})
221
+ term2, _ := f.EnsureTerminal(ctx, env1, term1, "PAY-101:coding", runner.Command{})
218
222
  if term1 != term2 {
219
223
  t.Fatalf("EnsureTerminal not idempotent: %v vs %v", term1, term2)
220
224
  }
225
+ if f.findN != 2 || f.createN != 1 {
226
+ t.Fatalf("EnsureTerminal calls: find=%d create=%d, want find=2 create=1", f.findN, f.createN)
227
+ }
228
+ }
229
+
230
+ func TestEnsureTerminalReplacesUnavailableStoredTerminal(t *testing.T) {
231
+ f := newFakeRunner()
232
+ ctx := context.Background()
233
+ env, _ := f.EnsureEnvironment(ctx, spec())
234
+ stored, _ := f.EnsureTerminal(ctx, env, runner.Terminal{}, "PAY-101:coding", runner.Command{})
235
+ f.dead[env.ID+"/PAY-101:coding"] = true
236
+
237
+ replacement, err := f.EnsureTerminal(ctx, env, stored, "PAY-101:coding", runner.Command{})
238
+ if err != nil {
239
+ t.Fatal(err)
240
+ }
241
+ if replacement.ID == stored.ID {
242
+ t.Fatalf("replacement ID = stored ID %q", stored.ID)
243
+ }
244
+ if f.createN != 2 {
245
+ t.Fatalf("CreateTerminal calls = %d, want 2", f.createN)
246
+ }
247
+ }
248
+
249
+ func TestCreateTerminalAlwaysCreates(t *testing.T) {
250
+ f := newFakeRunner()
251
+ ctx := context.Background()
252
+ env, _ := f.EnsureEnvironment(ctx, spec())
253
+
254
+ first, _ := f.CreateTerminal(ctx, env, "PAY-101:coding", runner.Command{})
255
+ second, _ := f.CreateTerminal(ctx, env, "PAY-101:coding", runner.Command{})
256
+ if first.ID == second.ID {
257
+ t.Fatalf("CreateTerminal reused terminal %q", first.ID)
258
+ }
221
259
  }