relay-flow 0.1.0-alpha.0
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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/relay-flow.js +50 -0
- package/cmd/relay-flow/main.go +250 -0
- package/go.mod +5 -0
- package/go.sum +3 -0
- package/internal/acli/acli.go +229 -0
- package/internal/config/demo_test.go +17 -0
- package/internal/config/machine.go +71 -0
- package/internal/config/schema.go +193 -0
- package/internal/config/schema_test.go +162 -0
- package/internal/daemon/daemon.go +218 -0
- package/internal/daemon/daemon_test.go +204 -0
- package/internal/discovery/discovery.go +122 -0
- package/internal/discovery/discovery_test.go +62 -0
- package/internal/opencode/opencode.go +26 -0
- package/internal/orcacli/orcacli.go +264 -0
- package/internal/runner/orca/README.md +64 -0
- package/internal/runner/orca/orca.go +243 -0
- package/internal/runner/orca/orca_test.go +201 -0
- package/internal/runner/runner.go +81 -0
- package/internal/runner/runner_test.go +64 -0
- package/internal/server/client.go +126 -0
- package/internal/server/server.go +342 -0
- package/internal/server/server_test.go +195 -0
- package/internal/tasks/jira/README.md +69 -0
- package/internal/tasks/jira/component_test.go +16 -0
- package/internal/tasks/jira/decode.go +24 -0
- package/internal/tasks/jira/jira.go +231 -0
- package/internal/tasks/jira/jira_test.go +259 -0
- package/internal/tasks/jira/jql_test.go +16 -0
- package/internal/tasks/tasks.go +90 -0
- package/internal/tasks/tasks_test.go +91 -0
- package/package.json +23 -0
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
// Package orcacli wraps the `orca` CLI for worktree/terminal management.
|
|
2
|
+
// Every call is skippable via DryRun for safe local testing. All real
|
|
3
|
+
// `orca ... --json` output is wrapped as {id, ok, result: {...}}.
|
|
4
|
+
package orcacli
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"encoding/json"
|
|
8
|
+
"fmt"
|
|
9
|
+
"log"
|
|
10
|
+
"os/exec"
|
|
11
|
+
"regexp"
|
|
12
|
+
"strings"
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
type Client struct {
|
|
16
|
+
DryRun bool
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
func New(dryRun bool) *Client {
|
|
20
|
+
return &Client{DryRun: dryRun}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
type Repo struct {
|
|
24
|
+
ID string `json:"id"`
|
|
25
|
+
DisplayName string `json:"displayName"`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Worktree struct {
|
|
29
|
+
ID string `json:"id"`
|
|
30
|
+
RepoID string `json:"repoId"`
|
|
31
|
+
DisplayName string `json:"displayName"`
|
|
32
|
+
Branch string `json:"branch"`
|
|
33
|
+
Path string `json:"path"`
|
|
34
|
+
IsMainWorktree bool `json:"isMainWorktree"`
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// Terminal is a tab's persistent identity: Title is the tab-level title we
|
|
38
|
+
// set via --title (visualLayouts[].root.tabs[].title), which persists —
|
|
39
|
+
// unlike the pane-level title, which the running program (opencode) resets.
|
|
40
|
+
type Terminal struct {
|
|
41
|
+
Handle string
|
|
42
|
+
Title string
|
|
43
|
+
Connected bool
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ListRepos returns all Orca-registered repos, used to resolve a Jira
|
|
47
|
+
// ticket's component name to a repoId (component name == repo displayName).
|
|
48
|
+
func (c *Client) ListRepos() ([]Repo, error) {
|
|
49
|
+
if c.DryRun {
|
|
50
|
+
log.Printf("[dry-run] orca repo list --json (skipped, returning empty)")
|
|
51
|
+
return nil, nil
|
|
52
|
+
}
|
|
53
|
+
var res struct {
|
|
54
|
+
Result struct {
|
|
55
|
+
Repos []Repo `json:"repos"`
|
|
56
|
+
} `json:"result"`
|
|
57
|
+
}
|
|
58
|
+
if err := runOrcaJSON(&res, "repo", "list", "--json"); err != nil {
|
|
59
|
+
return nil, fmt.Errorf("orca repo list: %w", err)
|
|
60
|
+
}
|
|
61
|
+
return res.Result.Repos, nil
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
func (c *Client) WorktreeList() ([]Worktree, error) {
|
|
65
|
+
if c.DryRun {
|
|
66
|
+
log.Printf("[dry-run] orca worktree list --json (skipped, returning empty)")
|
|
67
|
+
return nil, nil
|
|
68
|
+
}
|
|
69
|
+
var res struct {
|
|
70
|
+
Result struct {
|
|
71
|
+
Worktrees []Worktree `json:"worktrees"`
|
|
72
|
+
} `json:"result"`
|
|
73
|
+
}
|
|
74
|
+
if err := runOrcaJSON(&res, "worktree", "list", "--json"); err != nil {
|
|
75
|
+
return nil, fmt.Errorf("orca worktree list: %w", err)
|
|
76
|
+
}
|
|
77
|
+
return res.Result.Worktrees, nil
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// WorktreeCreate always explicitly sets --parent-worktree and --base-branch
|
|
81
|
+
// (never relies on Orca's inferred defaults) so every ticket's worktree has
|
|
82
|
+
// a deliberate, known ancestry: main by default, or an explicit parent
|
|
83
|
+
// ticket's worktree/branch for subtasks.
|
|
84
|
+
func (c *Client) WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error {
|
|
85
|
+
if c.DryRun {
|
|
86
|
+
log.Printf("[dry-run] orca worktree create --name %s --repo id:%s --parent-worktree worktree:%s --base-branch %s --json (skipped)", ticketKey, repoID, parentWorktreeID, baseBranch)
|
|
87
|
+
return nil
|
|
88
|
+
}
|
|
89
|
+
return runOrca("worktree", "create", "--name", ticketKey, "--repo", "id:"+repoID,
|
|
90
|
+
"--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// FindWorktree returns the worktree in repoID with the given displayName.
|
|
94
|
+
func (c *Client) FindWorktree(repoID, displayName string) (Worktree, bool, error) {
|
|
95
|
+
wts, err := c.WorktreeList()
|
|
96
|
+
if err != nil {
|
|
97
|
+
return Worktree{}, false, err
|
|
98
|
+
}
|
|
99
|
+
for _, w := range wts {
|
|
100
|
+
if w.RepoID == repoID && w.DisplayName == displayName {
|
|
101
|
+
return w, true, nil
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return Worktree{}, false, nil
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// FindExistingBranch looks for a branch containing ticketKey
|
|
108
|
+
// (prefix-agnostic — e.g. "Raj-Popat/KCC-1374" or "someone-else/KCC-1374"
|
|
109
|
+
// both match) in the repo checked out at repoPath, checking BOTH local and
|
|
110
|
+
// remote-tracking branches: Orca's worktree-create name-collision logic
|
|
111
|
+
// consults remote branches too, so a leftover origin/Raj-Popat/KCC-1377
|
|
112
|
+
// (pushed by an agent, local copy long deleted) would otherwise make Orca
|
|
113
|
+
// silently suffix the new worktree (-2, -3, ...) and desync every
|
|
114
|
+
// ticket-key-based lookup we do afterward.
|
|
115
|
+
//
|
|
116
|
+
// Return value is the ref to pass as --base-branch:
|
|
117
|
+
// - local-only branch -> its name as-is ("Raj-Popat/KCC-1377")
|
|
118
|
+
// - remote-only branch -> the remote-qualified ref ("origin/Raj-Popat/KCC-1377");
|
|
119
|
+
// Orca recognizes this as "create the worktree on this existing branch"
|
|
120
|
+
// and does NOT suffix the worktree name (verified empirically).
|
|
121
|
+
func FindExistingBranch(repoPath, ticketKey string) (string, bool, error) {
|
|
122
|
+
out, err := exec.Command("git", "-C", repoPath, "branch", "-a", "--list", "--format=%(refname:short)").CombinedOutput()
|
|
123
|
+
if err != nil {
|
|
124
|
+
return "", false, fmt.Errorf("git branch --list: %w: %s", err, out)
|
|
125
|
+
}
|
|
126
|
+
re := regexp.MustCompile(regexp.QuoteMeta(ticketKey))
|
|
127
|
+
for _, line := range strings.Split(string(out), "\n") {
|
|
128
|
+
branch := strings.TrimSpace(line)
|
|
129
|
+
if branch != "" && re.MatchString(branch) {
|
|
130
|
+
return branch, true, nil
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return "", false, nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// MainWorktree returns repoID's main worktree (the one checked out on the
|
|
137
|
+
// repo's primary branch, e.g. main).
|
|
138
|
+
func (c *Client) MainWorktree(repoID string) (Worktree, bool, error) {
|
|
139
|
+
wts, err := c.WorktreeList()
|
|
140
|
+
if err != nil {
|
|
141
|
+
return Worktree{}, false, err
|
|
142
|
+
}
|
|
143
|
+
for _, w := range wts {
|
|
144
|
+
if w.RepoID == repoID && w.IsMainWorktree {
|
|
145
|
+
return w, true, nil
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return Worktree{}, false, nil
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// TerminalList returns tabs (with their persistent tab-level title) for a
|
|
152
|
+
// given worktree, e.g. "name:KCC-1373". --include-visual-layouts is
|
|
153
|
+
// mandatory: orca omits visualLayouts from JSON without it, which would
|
|
154
|
+
// make every lookup return zero tabs and the daemon would spawn duplicate
|
|
155
|
+
// terminals on every poll.
|
|
156
|
+
func (c *Client) TerminalList(worktree string) ([]Terminal, error) {
|
|
157
|
+
if c.DryRun {
|
|
158
|
+
log.Printf("[dry-run] orca terminal list --worktree %s --json (skipped, returning empty)", worktree)
|
|
159
|
+
return nil, nil
|
|
160
|
+
}
|
|
161
|
+
var res struct {
|
|
162
|
+
Result struct {
|
|
163
|
+
VisualLayouts []struct {
|
|
164
|
+
Root struct {
|
|
165
|
+
Tabs []struct {
|
|
166
|
+
Title string `json:"title"`
|
|
167
|
+
Panes struct {
|
|
168
|
+
Handle string `json:"handle"`
|
|
169
|
+
Connected bool `json:"connected"`
|
|
170
|
+
} `json:"panes"`
|
|
171
|
+
} `json:"tabs"`
|
|
172
|
+
} `json:"root"`
|
|
173
|
+
} `json:"visualLayouts"`
|
|
174
|
+
} `json:"result"`
|
|
175
|
+
}
|
|
176
|
+
if err := runOrcaJSON(&res, "terminal", "list", "--worktree", worktree, "--include-visual-layouts", "--json"); err != nil {
|
|
177
|
+
return nil, fmt.Errorf("orca terminal list: %w", err)
|
|
178
|
+
}
|
|
179
|
+
var terms []Terminal
|
|
180
|
+
for _, vl := range res.Result.VisualLayouts {
|
|
181
|
+
for _, tab := range vl.Root.Tabs {
|
|
182
|
+
terms = append(terms, Terminal{Handle: tab.Panes.Handle, Title: tab.Title, Connected: tab.Panes.Connected})
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
return terms, nil
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// TerminalCreate launches the given shell command in a fresh terminal on
|
|
189
|
+
// the ticket's worktree. (orca terminal create has no --agent/--prompt
|
|
190
|
+
// flags — those exist only on `worktree create` — so the opencode
|
|
191
|
+
// invocation, with its RELAY_* env markers, is a --command line.)
|
|
192
|
+
func (c *Client) TerminalCreate(ticketKey, title, command string) (string, error) {
|
|
193
|
+
if c.DryRun {
|
|
194
|
+
log.Printf("[dry-run] orca terminal create --worktree name:%s --title %q --command %q --json (skipped)", ticketKey, title, command)
|
|
195
|
+
return "dry-run-handle", nil
|
|
196
|
+
}
|
|
197
|
+
var res struct {
|
|
198
|
+
Result struct {
|
|
199
|
+
Terminal struct {
|
|
200
|
+
Handle string `json:"handle"`
|
|
201
|
+
} `json:"terminal"`
|
|
202
|
+
} `json:"result"`
|
|
203
|
+
}
|
|
204
|
+
if err := runOrcaJSON(&res, "terminal", "create",
|
|
205
|
+
"--worktree", "name:"+ticketKey,
|
|
206
|
+
"--title", title,
|
|
207
|
+
"--command", command,
|
|
208
|
+
"--json"); err != nil {
|
|
209
|
+
return "", fmt.Errorf("orca terminal create: %w", err)
|
|
210
|
+
}
|
|
211
|
+
return res.Result.Terminal.Handle, nil
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
func (c *Client) TerminalWait(handle, forState string, timeoutMs int) error {
|
|
215
|
+
if c.DryRun {
|
|
216
|
+
log.Printf("[dry-run] orca terminal wait --terminal %s --for %s --timeout-ms %d --json (skipped)", handle, forState, timeoutMs)
|
|
217
|
+
return nil
|
|
218
|
+
}
|
|
219
|
+
return runOrca("terminal", "wait",
|
|
220
|
+
"--terminal", handle,
|
|
221
|
+
"--for", forState,
|
|
222
|
+
"--timeout-ms", fmt.Sprintf("%d", timeoutMs),
|
|
223
|
+
"--json")
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
func (c *Client) TerminalClose(handle string) error {
|
|
227
|
+
if c.DryRun {
|
|
228
|
+
log.Printf("[dry-run] orca terminal close --terminal %s (skipped)", handle)
|
|
229
|
+
return nil
|
|
230
|
+
}
|
|
231
|
+
return runOrca("terminal", "close", "--terminal", handle)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// TerminalSend types text into an existing terminal and presses Enter.
|
|
235
|
+
// text must be pre-flattened to a single line by the caller — send works
|
|
236
|
+
// via keystroke simulation, so an embedded newline would submit early.
|
|
237
|
+
func (c *Client) TerminalSend(handle, text string) error {
|
|
238
|
+
if c.DryRun {
|
|
239
|
+
log.Printf("[dry-run] orca terminal send --terminal %s --text %q --enter --json (skipped)", handle, text)
|
|
240
|
+
return nil
|
|
241
|
+
}
|
|
242
|
+
return runOrca("terminal", "send", "--terminal", handle, "--text", text, "--enter", "--json")
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
func runOrca(args ...string) error {
|
|
246
|
+
out, err := exec.Command("orca", args...).CombinedOutput()
|
|
247
|
+
if err != nil {
|
|
248
|
+
return fmt.Errorf("orca %v: %w: %s", args, err, string(out))
|
|
249
|
+
}
|
|
250
|
+
return nil
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
func runOrcaJSON(dest any, args ...string) error {
|
|
254
|
+
out, err := exec.Command("orca", args...).Output()
|
|
255
|
+
if err != nil {
|
|
256
|
+
// orca prints its JSON error envelope to stdout even on failure —
|
|
257
|
+
// surface it so callers can match on codes like selector_not_found.
|
|
258
|
+
return fmt.Errorf("%w: %s", err, strings.TrimSpace(string(out)))
|
|
259
|
+
}
|
|
260
|
+
if err := json.Unmarshal(out, dest); err != nil {
|
|
261
|
+
return fmt.Errorf("parse json: %w", err)
|
|
262
|
+
}
|
|
263
|
+
return nil
|
|
264
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# runner/orca — Orca adapter
|
|
2
|
+
|
|
3
|
+
Implements `runner.Runner` over the Orca CLI: each ticket gets an Orca
|
|
4
|
+
worktree; each node visit gets a fresh terminal titled
|
|
5
|
+
`<key>:<agent>:<node>` running opencode.
|
|
6
|
+
|
|
7
|
+
## Config (runner.config)
|
|
8
|
+
|
|
9
|
+
Empty — the adapter takes no YAML fields. Repo binding arrives at runtime:
|
|
10
|
+
the server resolves the submitter's repo and calls `WithRepo(repoID,
|
|
11
|
+
displayName, dryRun)` after construction.
|
|
12
|
+
|
|
13
|
+
## Behavior
|
|
14
|
+
|
|
15
|
+
- **Spawn** — verifies the opencode agent exists (`opencode agent list`),
|
|
16
|
+
ensures the ticket's worktree (creates off the repo's main worktree branch;
|
|
17
|
+
reuses an existing ticket branch if one exists; verifies the exact name
|
|
18
|
+
landed because Orca silently auto-suffixes on collisions), then creates the
|
|
19
|
+
terminal running `opencode --agent <agent> --prompt <p>` with `RELAY_FLOW_*` env
|
|
20
|
+
markers (`RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`) so the plugin can report back.
|
|
21
|
+
- **Find** — exact-title match on the ticket's terminal list. A missing
|
|
22
|
+
worktree (`selector_not_found`) means "no session", not an error — that's
|
|
23
|
+
what lets bounce respawn after a crash.
|
|
24
|
+
- **Nudge** — waits for `tui-idle` (typed text mid-turn corrupts the input
|
|
25
|
+
box), then sends the prompt flattened to one line (keystroke simulation
|
|
26
|
+
submits on newline).
|
|
27
|
+
- **Close** — closes every terminal titled `<key>:*`; scaffolding tabs
|
|
28
|
+
("Terminal 1", "Setup") survive.
|
|
29
|
+
|
|
30
|
+
## Writing a new runner (tmux, ...)
|
|
31
|
+
|
|
32
|
+
1. Create `internal/runner/<name>/` with:
|
|
33
|
+
```go
|
|
34
|
+
func init() {
|
|
35
|
+
runner.Register("<name>", runner.Factory{
|
|
36
|
+
UnmarshalConfig: unmarshalConfig, // strict-decode runner.config (empty is fine)
|
|
37
|
+
New: func(cfg any) (runner.Runner, error) { ... },
|
|
38
|
+
})
|
|
39
|
+
}
|
|
40
|
+
```
|
|
41
|
+
2. Implement the interface:
|
|
42
|
+
```go
|
|
43
|
+
type Runner interface {
|
|
44
|
+
Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error
|
|
45
|
+
Find(t tasks.Ticket, node string) (runner.Session, bool, error)
|
|
46
|
+
Nudge(s runner.Session, prompt string) error
|
|
47
|
+
Close(t tasks.Ticket) error
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
Contract highlights:
|
|
51
|
+
- **Titles are identity**: sessions must be findable later by
|
|
52
|
+
`<key>:<agent>:<node>` — bounce depends on it.
|
|
53
|
+
- **env must reach the agent process** — the report plugin keys off
|
|
54
|
+
`RELAY_FLOW_WORKFLOW/TICKET/NODE/AGENT`.
|
|
55
|
+
- **Find must distinguish "gone" from "error"** — a missing session is a
|
|
56
|
+
normal bounce case (respawn), not a failure.
|
|
57
|
+
- The runner must still launch **opencode** inside whatever session it
|
|
58
|
+
creates — the report plugin is opencode-specific. (tmux example:
|
|
59
|
+
`tmux new-session -s key-agent-node` + `send-keys "RELAY_FLOW_*=... opencode --agent ..." Enter`.)
|
|
60
|
+
3. If your runner needs the repo binding, implement
|
|
61
|
+
`WithRepo(repoID, repoName string, dryRun bool)` — the server calls it via
|
|
62
|
+
interface assertion when present.
|
|
63
|
+
4. Import for side effects in `internal/server/server.go`.
|
|
64
|
+
5. Tests with a fake CLI seam (see `orca_test.go` / the `orcaCLI` interface).
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// Package orca is the built-in Runner adapter: it executes agent sessions
|
|
2
|
+
// as Orca terminals on per-ticket worktrees. It knows nothing about
|
|
3
|
+
// trackers — tickets arrive as tasks.Ticket values.
|
|
4
|
+
package orca
|
|
5
|
+
|
|
6
|
+
import (
|
|
7
|
+
"fmt"
|
|
8
|
+
"strings"
|
|
9
|
+
"time"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/opencode"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/orcacli"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
14
|
+
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
func init() {
|
|
18
|
+
runner.Register("orca", runner.Factory{
|
|
19
|
+
UnmarshalConfig: unmarshalConfig,
|
|
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
|
+
},
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Config is the strictly-unmarshalled runner.config for type orca.
|
|
31
|
+
// Empty today — worktree ancestry details (repo, parent) are passed by
|
|
32
|
+
// the server at construction, not committed in YAML.
|
|
33
|
+
type Config struct{}
|
|
34
|
+
|
|
35
|
+
func unmarshalConfig(m map[string]any) (any, error) {
|
|
36
|
+
if len(m) > 0 {
|
|
37
|
+
for k := range m {
|
|
38
|
+
return nil, fmt.Errorf("unknown field %q (orca runner takes no config)", k)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return Config{}, nil
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// orcaCLI is the seam to the orca CLI. *orcacli.Client satisfies it;
|
|
45
|
+
// tests fake it.
|
|
46
|
+
type orcaCLI interface {
|
|
47
|
+
WorktreeList() ([]orcacli.Worktree, error)
|
|
48
|
+
WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error
|
|
49
|
+
FindWorktree(repoID, displayName string) (orcacli.Worktree, bool, error)
|
|
50
|
+
MainWorktree(repoID string) (orcacli.Worktree, bool, error)
|
|
51
|
+
TerminalList(worktree string) ([]orcacli.Terminal, error)
|
|
52
|
+
TerminalCreate(ticketKey, title, command string) (string, error)
|
|
53
|
+
TerminalWait(handle, forState string, timeoutMs int) error
|
|
54
|
+
TerminalClose(handle string) error
|
|
55
|
+
TerminalSend(handle, text string) error
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
type orcaRunner struct {
|
|
59
|
+
repoID string
|
|
60
|
+
repoName string // Jira component name; unused by the runner itself
|
|
61
|
+
dryRun bool
|
|
62
|
+
orca orcaCLI
|
|
63
|
+
exists func(string) (bool, error)
|
|
64
|
+
findBranch func(repoPath, key string) (string, bool, error)
|
|
65
|
+
sleep func(time.Duration) // test seam for ensureWorktree retries
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// NewRunner builds the orca runner. oc nil → real orcacli client (dryRun
|
|
69
|
+
// plumbed through). repoID is set by WithRepo at submit time.
|
|
70
|
+
func NewRunner(_ Config, oc orcaCLI) runner.Runner {
|
|
71
|
+
r := &orcaRunner{
|
|
72
|
+
exists: opencode.Exists,
|
|
73
|
+
findBranch: orcacli.FindExistingBranch,
|
|
74
|
+
sleep: time.Sleep,
|
|
75
|
+
}
|
|
76
|
+
if oc != nil {
|
|
77
|
+
r.orca = oc
|
|
78
|
+
}
|
|
79
|
+
return r
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// WithRepo binds the repo this runner serves (resolved server-side from
|
|
83
|
+
// the submitting client's cwd) and the dry-run flag.
|
|
84
|
+
func (r *orcaRunner) WithRepo(repoID, repoName string, dryRun bool) {
|
|
85
|
+
r.repoID, r.repoName, r.dryRun = repoID, repoName, dryRun
|
|
86
|
+
if r.orca == nil {
|
|
87
|
+
r.orca = orcacli.New(dryRun)
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// title is the session identity: <key>:<agent>:<node>. Bounce and Close
|
|
92
|
+
// both match on it.
|
|
93
|
+
func title(t tasks.Ticket, node, agent string) string {
|
|
94
|
+
return fmt.Sprintf("%s:%s:%s", t.Key, agent, node)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Spawn ensures the ticket's worktree exists, then creates a fresh
|
|
98
|
+
// terminal titled key:agent:node running opencode with the RELAY_FLOW_* env
|
|
99
|
+
// markers and the initial prompt. A fresh terminal per node visit:
|
|
100
|
+
// reusing an old session would leak the previous node's context.
|
|
101
|
+
func (r *orcaRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
|
|
102
|
+
if ok, err := r.exists(agent); err != nil {
|
|
103
|
+
return fmt.Errorf("verify opencode agent %q: %w", agent, err)
|
|
104
|
+
} else if !ok {
|
|
105
|
+
return fmt.Errorf("opencode agent %q does not exist", agent)
|
|
106
|
+
}
|
|
107
|
+
if err := r.ensureWorktree(t); err != nil {
|
|
108
|
+
return fmt.Errorf("ensure worktree: %w", err)
|
|
109
|
+
}
|
|
110
|
+
command := buildCommand(env, agent, prompt)
|
|
111
|
+
handle, err := r.orca.TerminalCreate(t.Key, title(t, node, agent), command)
|
|
112
|
+
if err != nil {
|
|
113
|
+
return fmt.Errorf("terminal create: %w", err)
|
|
114
|
+
}
|
|
115
|
+
// Best-effort: the wait only bounds how long Spawn blocks; the plugin
|
|
116
|
+
// report is the real synchronization.
|
|
117
|
+
_ = r.orca.TerminalWait(handle, "tui-idle", 10*60*1000)
|
|
118
|
+
return nil
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// buildCommand renders the opencode invocation typed into the new
|
|
122
|
+
// terminal, with RELAY_FLOW_* env markers so the plugin can report back. A
|
|
123
|
+
// developer's own opencode session never has these set, so it never
|
|
124
|
+
// reports.
|
|
125
|
+
func buildCommand(env map[string]string, agent, prompt string) string {
|
|
126
|
+
parts := make([]string, 0, len(env))
|
|
127
|
+
// Deterministic order for logs/tests.
|
|
128
|
+
for _, k := range []string{"RELAY_FLOW_WORKFLOW", "RELAY_FLOW_TICKET", "RELAY_FLOW_NODE", "RELAY_FLOW_AGENT"} {
|
|
129
|
+
if v, ok := env[k]; ok {
|
|
130
|
+
parts = append(parts, k+"="+shellQuote(v))
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return fmt.Sprintf("%s opencode --agent %s --prompt %s",
|
|
134
|
+
strings.Join(parts, " "), shellQuote(agent), shellQuote(prompt))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func shellQuote(s string) string {
|
|
138
|
+
return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'"
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Find locates the live session for ticket+node by exact title. A
|
|
142
|
+
// missing worktree (selector_not_found — e.g. claim label survived a
|
|
143
|
+
// crash but the worktree didn't) means "no session", not an error.
|
|
144
|
+
func (r *orcaRunner) Find(t tasks.Ticket, node string) (runner.Session, bool, error) {
|
|
145
|
+
terms, err := r.orca.TerminalList("name:" + t.Key)
|
|
146
|
+
if err != nil {
|
|
147
|
+
if strings.Contains(err.Error(), "selector_not_found") {
|
|
148
|
+
return runner.Session{}, false, nil
|
|
149
|
+
}
|
|
150
|
+
return runner.Session{}, false, fmt.Errorf("terminal list: %w", err)
|
|
151
|
+
}
|
|
152
|
+
want := t.Key + ":"
|
|
153
|
+
for _, term := range terms {
|
|
154
|
+
if strings.HasPrefix(term.Title, want) && strings.HasSuffix(term.Title, ":"+node) {
|
|
155
|
+
return runner.Session{ID: term.Handle, Title: term.Title}, true, nil
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return runner.Session{}, false, nil
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Nudge types a prompt into an existing session. The caller must have
|
|
162
|
+
// waited for idle first — typed text mid-turn corrupts the input box.
|
|
163
|
+
func (r *orcaRunner) Nudge(s runner.Session, prompt string) error {
|
|
164
|
+
flat := strings.Join(strings.Fields(prompt), " ")
|
|
165
|
+
if err := r.orca.TerminalWait(s.ID, "tui-idle", 3000); err != nil {
|
|
166
|
+
return fmt.Errorf("session %q busy, nudge not delivered", s.Title)
|
|
167
|
+
}
|
|
168
|
+
return r.orca.TerminalSend(s.ID, flat)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Close tears down every terminal titled <key>:* on the ticket's
|
|
172
|
+
// worktree. Scaffolding tabs ("Terminal 1", "Setup") are not ours and
|
|
173
|
+
// survive.
|
|
174
|
+
func (r *orcaRunner) Close(t tasks.Ticket) error {
|
|
175
|
+
terms, err := r.orca.TerminalList("name:" + t.Key)
|
|
176
|
+
if err != nil {
|
|
177
|
+
return fmt.Errorf("terminal list: %w", err)
|
|
178
|
+
}
|
|
179
|
+
prefix := t.Key + ":"
|
|
180
|
+
for _, term := range terms {
|
|
181
|
+
if strings.HasPrefix(term.Title, prefix) {
|
|
182
|
+
if err := r.orca.TerminalClose(term.Handle); err != nil {
|
|
183
|
+
return fmt.Errorf("close %q: %w", term.Title, err)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
return nil
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// ensureWorktree creates the ticket's worktree if missing, verifying the
|
|
191
|
+
// exact name landed (Orca silently auto-suffixes on collisions).
|
|
192
|
+
func (r *orcaRunner) ensureWorktree(t tasks.Ticket) error {
|
|
193
|
+
for attempt := 0; attempt < 3; attempt++ {
|
|
194
|
+
if _, ok, err := r.orca.FindWorktree(r.repoID, t.Key); err != nil {
|
|
195
|
+
return err
|
|
196
|
+
} else if ok {
|
|
197
|
+
return nil
|
|
198
|
+
}
|
|
199
|
+
if attempt < 2 {
|
|
200
|
+
r.sleep(2 * time.Second)
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
parentID, baseBranch, err := r.resolveWorktreeParent(t)
|
|
204
|
+
if err != nil {
|
|
205
|
+
return err
|
|
206
|
+
}
|
|
207
|
+
if err := r.orca.WorktreeCreate(t.Key, r.repoID, parentID, baseBranch); err != nil {
|
|
208
|
+
return err
|
|
209
|
+
}
|
|
210
|
+
for attempt := 0; attempt < 3; attempt++ {
|
|
211
|
+
if _, ok, err := r.orca.FindWorktree(r.repoID, t.Key); err != nil {
|
|
212
|
+
return err
|
|
213
|
+
} else if ok {
|
|
214
|
+
return nil
|
|
215
|
+
}
|
|
216
|
+
if attempt < 2 {
|
|
217
|
+
r.sleep(2 * time.Second)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return fmt.Errorf("worktree %q not found after create (Orca likely auto-suffixed it on a name/branch collision) — clean up the suffixed worktree/branch manually", t.Key)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// resolveWorktreeParent picks the ancestry for a new ticket worktree:
|
|
224
|
+
// main worktree's branch, reused ticket branch if one exists. (The
|
|
225
|
+
// baseBranch-label and subtask-parent rules from v3 need ticket labels/
|
|
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)
|
|
230
|
+
if err != nil {
|
|
231
|
+
return "", "", err
|
|
232
|
+
}
|
|
233
|
+
if !ok {
|
|
234
|
+
return "", "", fmt.Errorf("could not find main worktree for repo %s", r.repoID)
|
|
235
|
+
}
|
|
236
|
+
// A branch for this ticket may already exist (left over from a
|
|
237
|
+
// removed worktree). Reuse it — Orca silently renames the worktree on
|
|
238
|
+
// branch collision, desyncing every ticket-key lookup.
|
|
239
|
+
if existing, ok, err := r.findBranch(w.Path, t.Key); err == nil && ok {
|
|
240
|
+
return w.ID, existing, nil
|
|
241
|
+
}
|
|
242
|
+
return w.ID, w.Branch, nil
|
|
243
|
+
}
|