relay-flow 0.0.1 → 0.2.0-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 +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- package/internal/tasks/tasks_test.go +0 -91
|
@@ -1,243 +1,436 @@
|
|
|
1
|
-
// Package orca is the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
1
|
+
// Package orca is the Orca runner adapter. It owns ticket-scoped worktrees
|
|
2
|
+
// (environments), terminals, liveness, and cleanup. It does not know
|
|
3
|
+
// task-system fields, workflow routes, report contents, or agent command
|
|
4
|
+
// syntax.
|
|
5
|
+
//
|
|
6
|
+
// 9.5 external-call logging: every adapter boundary emits one debug line
|
|
7
|
+
// BEFORE the call (operation, ticket/runID, title when applicable) and one
|
|
8
|
+
// info line AFTER with only the outcome (ok/error), never payloads.
|
|
4
9
|
package orca
|
|
5
10
|
|
|
6
11
|
import (
|
|
12
|
+
"context"
|
|
13
|
+
"errors"
|
|
7
14
|
"fmt"
|
|
15
|
+
"log/slog"
|
|
8
16
|
"strings"
|
|
9
|
-
"time"
|
|
10
17
|
|
|
11
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
12
|
-
"github.com/rajpopat27/relay-flow/internal/orcacli"
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
13
19
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
14
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/runner/orca/orcacli"
|
|
15
21
|
)
|
|
16
22
|
|
|
23
|
+
// Config is the adapter-owned root runnerConfig.
|
|
24
|
+
type Config struct {
|
|
25
|
+
// BaseRef is the base branch for ticket worktrees. When empty, the base
|
|
26
|
+
// branch is derived from the repo's primary worktree reported by Orca.
|
|
27
|
+
BaseRef string `yaml:"baseRef,omitempty"`
|
|
28
|
+
}
|
|
29
|
+
|
|
17
30
|
func init() {
|
|
18
|
-
runner.Register("orca", runner.
|
|
19
|
-
|
|
20
|
-
New: func(cfg any) (runner.Runner, error) {
|
|
21
|
-
c, ok := cfg.(Config)
|
|
22
|
-
if !ok {
|
|
23
|
-
return nil, fmt.Errorf("internal: orca factory received %T", cfg)
|
|
24
|
-
}
|
|
25
|
-
return NewRunner(c, nil), nil
|
|
26
|
-
},
|
|
31
|
+
runner.Register("orca", func(raw config.RawValues) (runner.Runner, error) {
|
|
32
|
+
return New(orcacli.New(), raw)
|
|
27
33
|
})
|
|
28
34
|
}
|
|
29
35
|
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
36
|
+
// adapter is the Orca runner.Runner. It is safe for concurrent use; the CLI
|
|
37
|
+
// client owns subprocess serialization.
|
|
38
|
+
type adapter struct {
|
|
39
|
+
cli orcacli.Client
|
|
40
|
+
cfg Config
|
|
41
|
+
}
|
|
34
42
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
43
|
+
// New constructs the adapter from root runnerConfig around an explicit CLI
|
|
44
|
+
// seam (tests inject a fake Client).
|
|
45
|
+
func New(cli orcacli.Client, raw config.RawValues) (runner.Runner, error) {
|
|
46
|
+
var cfg Config
|
|
47
|
+
if err := config.DecodeStrict(raw, &cfg); err != nil {
|
|
48
|
+
return nil, fmt.Errorf("orca runnerConfig: %w", err)
|
|
40
49
|
}
|
|
41
|
-
return
|
|
50
|
+
return &adapter{cli: cli, cfg: cfg}, nil
|
|
42
51
|
}
|
|
43
52
|
|
|
44
|
-
//
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
53
|
+
// --- Repos ---
|
|
54
|
+
|
|
55
|
+
// DiscoverRepos returns Orca-registered repos as registration candidates.
|
|
56
|
+
func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
|
|
57
|
+
repos, err := a.cli.ListRepos(ctx)
|
|
58
|
+
if err != nil {
|
|
59
|
+
return nil, err
|
|
60
|
+
}
|
|
61
|
+
out := make([]runner.RepoCandidate, 0, len(repos))
|
|
62
|
+
for _, r := range repos {
|
|
63
|
+
out = append(out, runner.RepoCandidate{Name: r.DisplayName, Path: r.Path})
|
|
64
|
+
}
|
|
65
|
+
return out, nil
|
|
56
66
|
}
|
|
57
67
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
findBranch func(repoPath, key string) (string, bool, error)
|
|
65
|
-
sleep func(time.Duration) // test seam for ensureWorktree retries
|
|
68
|
+
// ValidateRepo verifies the named repo exists in Orca at the given path.
|
|
69
|
+
func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
|
|
70
|
+
if _, err := a.repoID(ctx, name, path); err != nil {
|
|
71
|
+
return err
|
|
72
|
+
}
|
|
73
|
+
return nil
|
|
66
74
|
}
|
|
67
75
|
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
76
|
+
// repoID resolves a registered repo to its Orca repo ID. The repo path is
|
|
77
|
+
// the stable identity (the Orca repo ID is an internal detail that can
|
|
78
|
+
// change across machines); name is matched as a secondary check.
|
|
79
|
+
func (a *adapter) repoID(ctx context.Context, name, path string) (string, error) {
|
|
80
|
+
repos, err := a.cli.ListRepos(ctx)
|
|
81
|
+
if err != nil {
|
|
82
|
+
return "", err
|
|
75
83
|
}
|
|
76
|
-
|
|
77
|
-
r.
|
|
84
|
+
for _, r := range repos {
|
|
85
|
+
if r.Path == path && r.DisplayName == name {
|
|
86
|
+
return r.ID, nil
|
|
87
|
+
}
|
|
78
88
|
}
|
|
79
|
-
return
|
|
89
|
+
return "", fmt.Errorf("orca: repo %q at %q not registered", name, path)
|
|
80
90
|
}
|
|
81
91
|
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
92
|
+
// --- Environment ---
|
|
93
|
+
|
|
94
|
+
// EnsureEnvironment returns the ticket-scoped worktree, creating it from the
|
|
95
|
+
// repo's main worktree and the configured base ref (or the primary
|
|
96
|
+
// worktree's branch) when absent.
|
|
97
|
+
func (a *adapter) EnsureEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, error) {
|
|
98
|
+
slog.Debug("orca call",
|
|
99
|
+
"op", "ensure-environment", "ticket", spec.TicketKey,
|
|
100
|
+
"runID", string(spec.RunID), "repo", spec.RepoName)
|
|
101
|
+
env, reused, err := a.ensureEnvironment(ctx, spec)
|
|
102
|
+
attrs := []any{
|
|
103
|
+
"op", "ensure-environment", "ticket", spec.TicketKey,
|
|
104
|
+
"runID", string(spec.RunID), "repo", spec.RepoName,
|
|
105
|
+
}
|
|
106
|
+
if err != nil {
|
|
107
|
+
attrs = append(attrs, "result", "error", "error", sanitizeErr(err))
|
|
108
|
+
} else if reused {
|
|
109
|
+
attrs = append(attrs, "result", "exists")
|
|
110
|
+
} else {
|
|
111
|
+
attrs = append(attrs, "result", "created")
|
|
88
112
|
}
|
|
113
|
+
slog.Info("orca outcome", attrs...)
|
|
114
|
+
return env, err
|
|
89
115
|
}
|
|
90
116
|
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
94
|
-
|
|
117
|
+
// ensureEnvironment is the unlogged body factored out so the public method
|
|
118
|
+
// can emit one outcome line (created/exists/error) without duplicate
|
|
119
|
+
// logging on the inner re-reads.
|
|
120
|
+
func (a *adapter) ensureEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, bool, error) {
|
|
121
|
+
repoID, err := a.repoID(ctx, spec.RepoName, spec.RepoPath)
|
|
122
|
+
if err != nil {
|
|
123
|
+
return runner.Environment{}, false, err
|
|
124
|
+
}
|
|
125
|
+
wts, err := a.cli.ListWorktrees(ctx)
|
|
126
|
+
if err != nil {
|
|
127
|
+
return runner.Environment{}, false, err
|
|
128
|
+
}
|
|
129
|
+
var main *orcacli.Worktree
|
|
130
|
+
for i := range wts {
|
|
131
|
+
w := &wts[i]
|
|
132
|
+
if w.RepoID != repoID {
|
|
133
|
+
continue
|
|
134
|
+
}
|
|
135
|
+
if w.DisplayName == spec.TicketKey {
|
|
136
|
+
return runner.Environment{ID: w.ID, Path: w.Path}, true, nil
|
|
137
|
+
}
|
|
138
|
+
if w.IsMainWorktree {
|
|
139
|
+
main = w
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if main == nil {
|
|
143
|
+
return runner.Environment{}, false, fmt.Errorf("orca: repo %q has no main worktree", spec.RepoName)
|
|
144
|
+
}
|
|
145
|
+
baseRef := a.cfg.BaseRef
|
|
146
|
+
if existing, ok, findErr := orcacli.FindExistingBranch(main.Path, spec.TicketKey); findErr == nil && ok {
|
|
147
|
+
baseRef = existing
|
|
148
|
+
} else if baseRef == "" {
|
|
149
|
+
baseRef = primaryBranch(main)
|
|
150
|
+
}
|
|
151
|
+
if err := a.cli.CreateWorktree(ctx, spec.TicketKey, repoID, main.ID, baseRef); err != nil {
|
|
152
|
+
return runner.Environment{}, false, err
|
|
153
|
+
}
|
|
154
|
+
// Re-read to return the created worktree's identity.
|
|
155
|
+
wts, err = a.cli.ListWorktrees(ctx)
|
|
156
|
+
if err != nil {
|
|
157
|
+
return runner.Environment{}, false, err
|
|
158
|
+
}
|
|
159
|
+
for _, w := range wts {
|
|
160
|
+
if w.RepoID == repoID && w.DisplayName == spec.TicketKey {
|
|
161
|
+
return runner.Environment{ID: w.ID, Path: w.Path}, false, nil
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return runner.Environment{}, false, fmt.Errorf("orca: worktree %q not found after create", spec.TicketKey)
|
|
95
165
|
}
|
|
96
166
|
|
|
97
|
-
//
|
|
98
|
-
//
|
|
99
|
-
//
|
|
100
|
-
//
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
167
|
+
// primaryBranch derives the base branch name from the repo's primary
|
|
168
|
+
// worktree reported by Orca. Orca returns a fully-qualified ref like
|
|
169
|
+
// "refs/heads/master"; normalize to the bare branch name. When the
|
|
170
|
+
// worktree's branch is empty (no primary branch recorded), fall back to
|
|
171
|
+
// "main".
|
|
172
|
+
func primaryBranch(w *orcacli.Worktree) string {
|
|
173
|
+
const prefix = "refs/heads/"
|
|
174
|
+
b := w.Branch
|
|
175
|
+
if strings.HasPrefix(b, prefix) {
|
|
176
|
+
b = strings.TrimPrefix(b, prefix)
|
|
106
177
|
}
|
|
107
|
-
if
|
|
108
|
-
return
|
|
178
|
+
if b == "" {
|
|
179
|
+
return "main"
|
|
109
180
|
}
|
|
110
|
-
|
|
111
|
-
|
|
181
|
+
return b
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// --- Terminals ---
|
|
185
|
+
|
|
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)
|
|
112
192
|
if err != nil {
|
|
113
|
-
|
|
193
|
+
slog.Info("orca outcome", "op", "find-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
|
|
194
|
+
return runner.Terminal{}, false, err
|
|
114
195
|
}
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
119
204
|
}
|
|
120
205
|
|
|
121
|
-
//
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
if v, ok := env[k]; ok {
|
|
130
|
-
parts = append(parts, k+"="+shellQuote(v))
|
|
131
|
-
}
|
|
206
|
+
// CloseTerminal closes one terminal by handle.
|
|
207
|
+
func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
|
|
208
|
+
slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
|
|
209
|
+
err := a.cli.CloseTerminal(ctx, terminal.ID)
|
|
210
|
+
if err != nil {
|
|
211
|
+
slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
|
|
212
|
+
} else {
|
|
213
|
+
slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "ok")
|
|
132
214
|
}
|
|
133
|
-
return
|
|
134
|
-
strings.Join(parts, " "), shellQuote(agent), shellQuote(prompt))
|
|
215
|
+
return err
|
|
135
216
|
}
|
|
136
217
|
|
|
137
|
-
|
|
138
|
-
|
|
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) {
|
|
221
|
+
t, err := a.cli.ShowTerminal(ctx, terminal.ID)
|
|
222
|
+
if errors.Is(err, orcacli.ErrTerminalUnavailable) {
|
|
223
|
+
return runner.Terminal{}, false, nil
|
|
224
|
+
}
|
|
225
|
+
if err != nil {
|
|
226
|
+
return runner.Terminal{}, false, err
|
|
227
|
+
}
|
|
228
|
+
if !t.Connected {
|
|
229
|
+
return runner.Terminal{}, false, nil
|
|
230
|
+
}
|
|
231
|
+
return runner.Terminal{ID: t.Handle, Title: t.Title}, true, nil
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
func (a *adapter) SendTerminal(ctx context.Context, terminal runner.Terminal, text string) error {
|
|
235
|
+
return a.cli.SendTerminal(ctx, terminal.ID, text)
|
|
139
236
|
}
|
|
140
237
|
|
|
141
|
-
//
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
terms, err := r.orca.TerminalList("name:" + t.Key)
|
|
238
|
+
// CreateTerminal always creates a terminal; it performs no title discovery.
|
|
239
|
+
func (a *adapter) CreateTerminal(ctx context.Context, env runner.Environment, title string, command runner.Command) (runner.Terminal, error) {
|
|
240
|
+
name := strings.SplitN(title, ":", 2)[0]
|
|
241
|
+
handle, err := a.cli.CreateTerminal(ctx, name, title, shellCommand(command))
|
|
146
242
|
if err != nil {
|
|
147
|
-
|
|
148
|
-
return runner.Session{}, false, nil
|
|
149
|
-
}
|
|
150
|
-
return runner.Session{}, false, fmt.Errorf("terminal list: %w", err)
|
|
243
|
+
return runner.Terminal{}, err
|
|
151
244
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
if
|
|
155
|
-
|
|
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
|
|
156
253
|
}
|
|
157
254
|
}
|
|
158
|
-
return runner.
|
|
255
|
+
return runner.Terminal{ID: handle, Title: title}, nil
|
|
159
256
|
}
|
|
160
257
|
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
return fmt.Errorf("session %q busy, nudge not delivered", s.Title)
|
|
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
|
+
}
|
|
167
263
|
}
|
|
168
|
-
return
|
|
264
|
+
return false
|
|
169
265
|
}
|
|
170
266
|
|
|
171
|
-
//
|
|
172
|
-
//
|
|
173
|
-
//
|
|
174
|
-
|
|
175
|
-
|
|
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) {
|
|
272
|
+
slog.Debug("orca call", "op", "ensure-terminal", "title", title, "envID", env.ID)
|
|
273
|
+
if t, ok, err := a.FindTerminal(ctx, env, title); err != nil {
|
|
274
|
+
slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
|
|
275
|
+
return runner.Terminal{}, err
|
|
276
|
+
} else if ok {
|
|
277
|
+
slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "exists")
|
|
278
|
+
return t, nil
|
|
279
|
+
}
|
|
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))
|
|
176
284
|
if err != nil {
|
|
177
|
-
|
|
285
|
+
slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "error", "error", sanitizeErr(err))
|
|
286
|
+
return runner.Terminal{}, err
|
|
178
287
|
}
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
288
|
+
slog.Info("orca outcome", "op", "ensure-terminal", "title", title, "result", "created")
|
|
289
|
+
return runner.Terminal{ID: handle, Title: title}, nil
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// sanitizeErr strips the leading "orca [args...]:" prefix from orcacli
|
|
293
|
+
// errors so info-level outcome lines never leak argv payloads (notably the
|
|
294
|
+
// --command string built by shellCommand, which carries the agent prompt
|
|
295
|
+
// and RELAY_FLOW_* env). Keeps the trailing stderr/exit fragment.
|
|
296
|
+
func sanitizeErr(err error) string {
|
|
297
|
+
if err == nil {
|
|
298
|
+
return ""
|
|
299
|
+
}
|
|
300
|
+
s := err.Error()
|
|
301
|
+
// "orca terminal create: orca [args...]: <err>: <out>" — drop the
|
|
302
|
+
// "[args...]" middle.
|
|
303
|
+
if i := strings.Index(s, "]: "); i >= 0 {
|
|
304
|
+
// Keep any "orca <words>:" prefix before the "[" (e.g. the
|
|
305
|
+
// "orca terminal create:" wrap), then append the post-] tail.
|
|
306
|
+
prefix := ""
|
|
307
|
+
if j := strings.Index(s, "["); j > 0 {
|
|
308
|
+
prefix = strings.TrimSuffix(s[:j], " ")
|
|
309
|
+
}
|
|
310
|
+
if prefix != "" {
|
|
311
|
+
return prefix + "]: " + s[i+3:]
|
|
185
312
|
}
|
|
313
|
+
return s[i+3:]
|
|
186
314
|
}
|
|
187
|
-
return
|
|
315
|
+
return s
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// shellCommand renders the structured command as one shell line with env
|
|
319
|
+
// assignments; the runner executes it but never constructs it.
|
|
320
|
+
func shellCommand(c runner.Command) string {
|
|
321
|
+
var b strings.Builder
|
|
322
|
+
for _, k := range sortedKeys(c.Env) {
|
|
323
|
+
b.WriteString(k)
|
|
324
|
+
b.WriteString("=")
|
|
325
|
+
b.WriteString(shellQuote(c.Env[k]))
|
|
326
|
+
b.WriteString(" ")
|
|
327
|
+
}
|
|
328
|
+
b.WriteString(shellQuote(c.Executable))
|
|
329
|
+
for _, arg := range c.Args {
|
|
330
|
+
b.WriteString(" ")
|
|
331
|
+
b.WriteString(shellQuote(arg))
|
|
332
|
+
}
|
|
333
|
+
return b.String()
|
|
188
334
|
}
|
|
189
335
|
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
336
|
+
func shellQuote(s string) string {
|
|
337
|
+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
func sortedKeys(m map[string]string) []string {
|
|
341
|
+
keys := make([]string, 0, len(m))
|
|
342
|
+
for k := range m {
|
|
343
|
+
keys = append(keys, k)
|
|
344
|
+
}
|
|
345
|
+
for i := 1; i < len(keys); i++ {
|
|
346
|
+
for j := i; j > 0 && keys[j] < keys[j-1]; j-- {
|
|
347
|
+
keys[j], keys[j-1] = keys[j-1], keys[j]
|
|
198
348
|
}
|
|
199
|
-
|
|
200
|
-
|
|
349
|
+
}
|
|
350
|
+
return keys
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// --- Cleanup ---
|
|
354
|
+
|
|
355
|
+
// CloseTerminals closes the run's agent terminals while preserving the
|
|
356
|
+
// worktree and any non-run tabs (setup, user shells). Run-owned terminals
|
|
357
|
+
// are identified by the stable <ticket>:<node> title prefix.
|
|
358
|
+
func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error {
|
|
359
|
+
slog.Debug("orca call", "op", "close-terminals", "ticket", spec.TicketKey, "runID", string(spec.RunID))
|
|
360
|
+
env, ok, err := a.findEnvironment(ctx, spec)
|
|
361
|
+
if err != nil || !ok {
|
|
362
|
+
if err != nil {
|
|
363
|
+
slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
364
|
+
} else {
|
|
365
|
+
slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "no-environment")
|
|
201
366
|
}
|
|
367
|
+
return err
|
|
202
368
|
}
|
|
203
|
-
|
|
369
|
+
terms, err := a.cli.ListTerminals(ctx, "id:"+env.ID)
|
|
204
370
|
if err != nil {
|
|
371
|
+
slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
205
372
|
return err
|
|
206
373
|
}
|
|
207
|
-
|
|
374
|
+
prefix := spec.TicketKey + ":"
|
|
375
|
+
closed := 0
|
|
376
|
+
for _, t := range terms {
|
|
377
|
+
if !strings.HasPrefix(t.Title, prefix) {
|
|
378
|
+
continue
|
|
379
|
+
}
|
|
380
|
+
// 9.5: one outcome line per actual terminal close, with its title.
|
|
381
|
+
slog.Debug("orca call", "op", "close-terminal", "title", t.Title, "handle", t.Handle)
|
|
382
|
+
if err := a.cli.CloseTerminal(ctx, t.Handle); err != nil {
|
|
383
|
+
slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "error", "error", sanitizeErr(err))
|
|
384
|
+
slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
385
|
+
return fmt.Errorf("close terminal %q: %w", t.Title, err)
|
|
386
|
+
}
|
|
387
|
+
slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "ok")
|
|
388
|
+
closed++
|
|
389
|
+
}
|
|
390
|
+
slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "ok", "closed", closed)
|
|
391
|
+
return nil
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// CleanupRun removes all runner-owned run resources: terminals, then the
|
|
395
|
+
// ticket worktree itself.
|
|
396
|
+
func (a *adapter) CleanupRun(ctx context.Context, spec runner.RunSpec) error {
|
|
397
|
+
slog.Debug("orca call", "op", "cleanup-run", "ticket", spec.TicketKey, "runID", string(spec.RunID))
|
|
398
|
+
if err := a.CloseTerminals(ctx, spec); err != nil {
|
|
399
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
208
400
|
return err
|
|
209
401
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
if attempt < 2 {
|
|
217
|
-
r.sleep(2 * time.Second)
|
|
402
|
+
env, ok, err := a.findEnvironment(ctx, spec)
|
|
403
|
+
if err != nil || !ok {
|
|
404
|
+
if err != nil {
|
|
405
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
406
|
+
} else {
|
|
407
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "no-environment")
|
|
218
408
|
}
|
|
409
|
+
return err
|
|
410
|
+
}
|
|
411
|
+
err = a.cli.DeleteWorktree(ctx, env.ID)
|
|
412
|
+
if err != nil {
|
|
413
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
|
|
414
|
+
} else {
|
|
415
|
+
slog.Info("orca outcome", "op", "cleanup-run", "ticket", spec.TicketKey, "result", "ok")
|
|
219
416
|
}
|
|
220
|
-
return
|
|
417
|
+
return err
|
|
221
418
|
}
|
|
222
419
|
|
|
223
|
-
//
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
// parent info that tasks.Ticket doesn't carry — deferred; YAGNI until a
|
|
227
|
-
// tracker adapter exposes them.)
|
|
228
|
-
func (r *orcaRunner) resolveWorktreeParent(t tasks.Ticket) (parentWorktreeID, baseBranch string, err error) {
|
|
229
|
-
w, ok, err := r.orca.MainWorktree(r.repoID)
|
|
420
|
+
// findEnvironment locates the ticket worktree without creating it.
|
|
421
|
+
func (a *adapter) findEnvironment(ctx context.Context, spec runner.RunSpec) (runner.Environment, bool, error) {
|
|
422
|
+
repoID, err := a.repoID(ctx, spec.RepoName, spec.RepoPath)
|
|
230
423
|
if err != nil {
|
|
231
|
-
return
|
|
424
|
+
return runner.Environment{}, false, err
|
|
232
425
|
}
|
|
233
|
-
|
|
234
|
-
|
|
426
|
+
wts, err := a.cli.ListWorktrees(ctx)
|
|
427
|
+
if err != nil {
|
|
428
|
+
return runner.Environment{}, false, err
|
|
235
429
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
return w.ID, existing, nil
|
|
430
|
+
for _, w := range wts {
|
|
431
|
+
if w.RepoID == repoID && w.DisplayName == spec.TicketKey {
|
|
432
|
+
return runner.Environment{ID: w.ID, Path: w.Path}, true, nil
|
|
433
|
+
}
|
|
241
434
|
}
|
|
242
|
-
return
|
|
435
|
+
return runner.Environment{}, false, nil
|
|
243
436
|
}
|