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
@@ -10,14 +10,15 @@ import (
10
10
 
11
11
  // Paths is the fixed ~/.relay-flow layout. All process artifacts stay under Root.
12
12
  type Paths struct {
13
- Root string
14
- Config string
15
- Workflows string
16
- Database string
17
- Socket string
18
- Lock string
19
- ServerLog string
20
- PluginLog string
13
+ Root string
14
+ Config string
15
+ Credentials string
16
+ Workflows string
17
+ Database string
18
+ Socket string
19
+ Lock string
20
+ ServerLog string
21
+ PluginLog string
21
22
  }
22
23
 
23
24
  // ForUserHome returns the Paths rooted at the current user's home directory.
@@ -28,14 +29,15 @@ func ForUserHome() (Paths, error) {
28
29
  }
29
30
  root := filepath.Join(home, ".relay-flow")
30
31
  return Paths{
31
- Root: root,
32
- Config: filepath.Join(root, "config.yaml"),
33
- Workflows: filepath.Join(root, "workflows"),
34
- Database: filepath.Join(root, "state.db"),
35
- Socket: filepath.Join(root, "server.sock"),
36
- Lock: filepath.Join(root, "server.lock"),
37
- ServerLog: filepath.Join(root, "server.log"),
38
- PluginLog: filepath.Join(root, "plugin.log"),
32
+ Root: root,
33
+ Config: filepath.Join(root, "config.yaml"),
34
+ Credentials: filepath.Join(root, "credentials.yaml"),
35
+ Workflows: filepath.Join(root, "workflows"),
36
+ Database: filepath.Join(root, "state.db"),
37
+ Socket: filepath.Join(root, "server.sock"),
38
+ Lock: filepath.Join(root, "server.lock"),
39
+ ServerLog: filepath.Join(root, "server.log"),
40
+ PluginLog: filepath.Join(root, "plugin.log"),
39
41
  }, nil
40
42
  }
41
43
 
@@ -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
  }
@@ -29,10 +29,10 @@ func (f *fakeRunnerDiscovery) ValidateRepo(_ context.Context, _, _ string) error
29
29
  func (f *fakeRunnerDiscovery) EnsureEnvironment(context.Context, runner.RunSpec) (runner.Environment, error) {
30
30
  return runner.Environment{}, nil
31
31
  }
32
- func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Environment, string) (runner.Terminal, bool, error) {
33
- return runner.Terminal{}, false, nil
32
+ func (f *fakeRunnerDiscovery) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
33
+ return nil
34
34
  }
35
- func (f *fakeRunnerDiscovery) InspectTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
35
+ func (f *fakeRunnerDiscovery) FindTerminal(context.Context, runner.Terminal) (runner.Terminal, bool, error) {
36
36
  return runner.Terminal{}, false, nil
37
37
  }
38
38
  func (f *fakeRunnerDiscovery) SendTerminal(context.Context, runner.Terminal, string) error {
@@ -42,7 +42,7 @@ func (f *fakeRunnerDiscovery) CreateTerminal(context.Context, runner.Environment
42
42
  return runner.Terminal{}, nil
43
43
  }
44
44
  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) {
45
+ func (f *fakeRunnerDiscovery) EnsureTerminal(context.Context, runner.Environment, runner.Terminal, string, runner.Command) (runner.Terminal, error) {
46
46
  return runner.Terminal{}, nil
47
47
  }
48
48
  func (f *fakeRunnerDiscovery) CloseTerminals(context.Context, runner.RunSpec) error { return 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))
@@ -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
  }
@@ -114,6 +114,17 @@ func (a *adapter) EnsureEnvironment(ctx context.Context, spec runner.RunSpec) (r
114
114
  return env, err
115
115
  }
116
116
 
117
+ func (a *adapter) SetEnvironmentStatus(ctx context.Context, env runner.Environment, status string) error {
118
+ slog.Debug("orca call", "op", "set-environment-status", "envID", env.ID, "status", status)
119
+ err := a.cli.SetWorktreeStatus(ctx, env.ID, status)
120
+ if err != nil {
121
+ slog.Info("orca outcome", "op", "set-environment-status", "envID", env.ID, "status", status, "result", "error", "error", sanitizeErr(err))
122
+ } else {
123
+ slog.Info("orca outcome", "op", "set-environment-status", "envID", env.ID, "status", status, "result", "ok")
124
+ }
125
+ return err
126
+ }
127
+
117
128
  // ensureEnvironment is the unlogged body factored out so the public method
118
129
  // can emit one outcome line (created/exists/error) without duplicate
119
130
  // logging on the inner re-reads.
@@ -183,26 +194,6 @@ func primaryBranch(w *orcacli.Worktree) string {
183
194
 
184
195
  // --- Terminals ---
185
196
 
186
- // FindTerminal returns the terminal titled exactly title in the environment
187
- // when it is live and usable; stale/disconnected records are treated as
188
- // absent.
189
- func (a *adapter) FindTerminal(ctx context.Context, env runner.Environment, title string) (runner.Terminal, bool, error) {
190
- slog.Debug("orca call", "op", "find-terminal", "title", title, "envID", env.ID)
191
- terms, err := a.cli.ListTerminals(ctx, "id:"+env.ID)
192
- if err != nil {
193
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
194
- return runner.Terminal{}, false, err
195
- }
196
- for _, t := range terms {
197
- if t.Title == title && t.Connected {
198
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "found")
199
- return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
200
- }
201
- }
202
- slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "absent")
203
- return runner.Terminal{}, false, nil
204
- }
205
-
206
197
  // CloseTerminal closes one terminal by handle.
207
198
  func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
208
199
  slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
@@ -215,20 +206,29 @@ func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) e
215
206
  return err
216
207
  }
217
208
 
218
- // InspectTerminal addresses a persisted handle directly. Normal execution
219
- // never lists terminals or rediscovers by title.
220
- func (a *adapter) InspectTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
209
+ // FindTerminal addresses a persisted handle directly. Normal execution never
210
+ // lists terminals or rediscovers by title.
211
+ func (a *adapter) FindTerminal(ctx context.Context, terminal runner.Terminal) (runner.Terminal, bool, error) {
212
+ slog.Debug("orca call", "op", "find-terminal", "title", terminal.Title, "handle", terminal.ID)
213
+ if terminal.ID == "" {
214
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
215
+ return runner.Terminal{}, false, nil
216
+ }
221
217
  t, err := a.cli.ShowTerminal(ctx, terminal.ID)
222
218
  if errors.Is(err, orcacli.ErrTerminalUnavailable) {
219
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
223
220
  return runner.Terminal{}, false, nil
224
221
  }
225
222
  if err != nil {
223
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
226
224
  return runner.Terminal{}, false, err
227
225
  }
228
226
  if !t.Connected {
227
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "absent")
229
228
  return runner.Terminal{}, false, nil
230
229
  }
231
- return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
230
+ slog.Info("orca outcome", "op", "find-terminal", "title", terminal.Title, "result", "found")
231
+ return runner.Terminal{ID: t.Handle, Title: terminal.Title}, true, nil
232
232
  }
233
233
 
234
234
  func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
@@ -242,51 +242,27 @@ func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, ti
242
242
  if err != nil {
243
243
  return runner.Terminal{}, err
244
244
  }
245
- if commandResumesSession(command) {
246
- t, showErr := a.cli.ShowTerminal(ctx, handle)
247
- if errors.Is(showErr, orcacli.ErrTerminalUnavailable) || (showErr == nil && !t.Connected) {
248
- _ = a.cli.CloseTerminal(ctx, handle)
249
- return runner.Terminal{}, runner.ErrSessionUnavailable
250
- }
251
- if showErr != nil {
252
- return runner.Terminal{}, showErr
253
- }
254
- }
255
245
  return runner.Terminal{ID: handle, Title: title}, nil
256
246
  }
257
247
 
258
- func commandResumesSession(command runner.Command) bool {
259
- for i, arg := range command.Args {
260
- if arg == "--session" && i+1 < len(command.Args) && command.Args[i+1] != "" {
261
- return true
262
- }
263
- }
264
- return false
265
- }
266
-
267
- // EnsureTerminal is idempotent: it returns the live terminal with the stable
268
- // title when present, otherwise creates one running command. The title
269
- // contains only <ticket>:<node>; visit metadata lives in the command's
270
- // environment, never the title.
271
- func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
248
+ // EnsureTerminal checks the persisted terminal, then creates a replacement
249
+ // only when that terminal is unavailable. The title is used only for creation.
250
+ func (a *adapter) EnsureTerminal(ctx context.Context, env runner.Environment, stored runner.Terminal, title string, command runner.Command) (runner.Terminal, error) {
272
251
  slog.Debug("orca call", "op", "ensure-terminal", "title", title, "envID", env.ID)
273
- if t, ok, err := a.FindTerminal(ctx, env, title); err != nil {
252
+ if t, ok, err := a.FindTerminal(ctx, stored); err != nil {
274
253
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
275
254
  return runner.Terminal{}, err
276
255
  } else if ok {
277
256
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "exists")
278
257
  return t, nil
279
258
  }
280
- // The worktree display name is the ticket key (EnsureEnvironment names
281
- // it after the ticket).
282
- name := strings.SplitN(title, ":", 2)[0]
283
- handle, err := a.cli.CreateTerminal(ctx, name, title, shellCommand(command))
259
+ terminal, err := a.CreateTerminal(ctx, env, title, command)
284
260
  if err != nil {
285
261
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
286
262
  return runner.Terminal{}, err
287
263
  }
288
264
  slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "created")
289
- return runner.Terminal{ID: handle, Title: title}, nil
265
+ return terminal, nil
290
266
  }
291
267
 
292
268
  // sanitizeErr strips the leading "orca [args...]:" prefix from orcacli