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.
- package/README.md +18 -6
- package/cmd/relay-flow/commands_test.go +519 -15
- package/cmd/relay-flow/main.go +331 -119
- package/cmd/relay-flow/scenario_test.go +209 -34
- package/cmd/relay-flow/serve.go +1 -0
- package/examples/default-story-workflow.yaml +88 -0
- package/internal/execution/goworkflows/activities.go +65 -65
- package/internal/execution/goworkflows/engine.go +41 -8
- package/internal/execution/goworkflows/engine_test.go +73 -13
- package/internal/execution/goworkflows/fakes_test.go +13 -21
- package/internal/execution/goworkflows/interpreter.go +16 -8
- package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
- package/internal/execution/goworkflows/node_runtime_test.go +45 -21
- package/internal/execution/goworkflows/recovery_test.go +5 -5
- package/internal/execution/goworkflows/retry_log_test.go +11 -11
- package/internal/harness/contract_test.go +5 -0
- package/internal/paths/paths.go +18 -16
- package/internal/repo/repo.go +13 -0
- package/internal/repo/service_test.go +4 -4
- package/internal/router/router.go +3 -2
- package/internal/router/router_test.go +87 -0
- package/internal/run/manager.go +14 -1
- package/internal/run/run_manager_test.go +21 -1
- package/internal/runner/contract_test.go +64 -26
- package/internal/runner/orca/orca.go +30 -54
- package/internal/runner/orca/orca_test.go +143 -4
- package/internal/runner/orca/orcacli/orcacli.go +5 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
- package/internal/runner/runner.go +15 -8
- package/internal/task/auth_test.go +48 -0
- package/internal/task/contract_test.go +2 -0
- package/internal/task/factory.go +16 -0
- package/internal/task/jira/auth.go +183 -0
- package/internal/task/jira/auth_test.go +107 -0
- package/internal/task/jira/effects_test.go +39 -0
- package/internal/task/jira/filters_test.go +36 -16
- package/internal/task/jira/helpers_test.go +29 -19
- package/internal/task/jira/jira.go +92 -61
- package/internal/task/jira/normalize.go +32 -14
- package/internal/task/jira/rest/adf.go +128 -0
- package/internal/task/jira/rest/client.go +573 -0
- package/internal/task/jira/rest/client_test.go +381 -0
- package/internal/task/jira/transition_defaults_test.go +18 -16
- package/internal/task/jira/validation_test.go +1 -1
- package/internal/workflow/workflow.go +9 -6
- package/internal/workflow/workflow_test.go +14 -12
- package/package.json +2 -1
- package/internal/task/jira/acli/acli.go +0 -306
- package/internal/task/jira/acli/acli_test.go +0 -208
- package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
- package/internal/task/jira/acli/testdata/search_success.json +0 -1
- /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
package/internal/paths/paths.go
CHANGED
|
@@ -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
|
|
14
|
-
Config
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
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:
|
|
32
|
-
Config:
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
package/internal/repo/repo.go
CHANGED
|
@@ -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)
|
|
33
|
-
return
|
|
32
|
+
func (f *fakeRunnerDiscovery) SetEnvironmentStatus(context.Context, runner.Environment, string) error {
|
|
33
|
+
return nil
|
|
34
34
|
}
|
|
35
|
-
func (f *fakeRunnerDiscovery)
|
|
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
|
|
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
|
|
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
|
+
}
|
package/internal/run/manager.go
CHANGED
|
@@ -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) {
|
|
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 (
|
|
61
|
-
|
|
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
|
-
|
|
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(
|
|
79
|
-
|
|
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(
|
|
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
|
-
|
|
95
|
-
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
//
|
|
219
|
-
//
|
|
220
|
-
func (a *adapter)
|
|
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
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
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,
|
|
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
|
-
|
|
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
|
|
265
|
+
return terminal, nil
|
|
290
266
|
}
|
|
291
267
|
|
|
292
268
|
// sanitizeErr strips the leading "orca [args...]:" prefix from orcacli
|