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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -1,204 +0,0 @@
1
- package daemon
2
-
3
- import (
4
- "strings"
5
- "testing"
6
-
7
- "github.com/rajpopat27/relay-flow/internal/config"
8
- "github.com/rajpopat27/relay-flow/internal/runner"
9
- "github.com/rajpopat27/relay-flow/internal/tasks"
10
- )
11
-
12
- func testConfig() *config.Config {
13
- return &config.Config{
14
- Name: "wf",
15
- PollIntervalSeconds: 15,
16
- CloseOn: config.StringList{"done"},
17
- Nodes: map[string]config.Node{
18
- "coding": {Agent: "build", When: "In Progress", OnSuccess: "reviewing", OnFailure: "coding", NudgePrompt: "back to work on {{ticket}} at {{node}}"},
19
- "reviewing": {Agent: "build", When: "In Review", OnSuccess: "done", OnFailure: "coding", NudgePrompt: "review {{ticket}}"},
20
- "done": {When: "Done"},
21
- },
22
- }
23
- }
24
-
25
- type fakeTasks struct {
26
- listed []Ticket
27
- claims []string
28
- reports []string
29
- }
30
-
31
- type Ticket = tasks.Ticket
32
-
33
- func (f *fakeTasks) List() ([]tasks.Ticket, error) { return f.listed, nil }
34
- func (f *fakeTasks) Claim(t tasks.Ticket) error {
35
- f.claims = append(f.claims, t.Key)
36
- return nil
37
- }
38
- func (f *fakeTasks) Report(t tasks.Ticket, outcome, targetNode, summary string) error {
39
- f.reports = append(f.reports, t.Key+":"+outcome+":"+targetNode)
40
- return nil
41
- }
42
-
43
- type fakeRunner struct {
44
- spawned []string
45
- nudged []string
46
- closed []string
47
- found map[string]runner.Session
48
- }
49
-
50
- func (f *fakeRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
51
- f.spawned = append(f.spawned, t.Key+":"+node+":"+agent+":"+env["RELAY_FLOW_WORKFLOW"]+":"+env["RELAY_FLOW_TICKET"])
52
- return nil
53
- }
54
- func (f *fakeRunner) Find(t tasks.Ticket, node string) (runner.Session, bool, error) {
55
- s, ok := f.found[t.Key+":"+node]
56
- return s, ok, nil
57
- }
58
- func (f *fakeRunner) Nudge(s runner.Session, prompt string) error {
59
- f.nudged = append(f.nudged, s.Title+":"+prompt)
60
- return nil
61
- }
62
- func (f *fakeRunner) Close(t tasks.Ticket) error {
63
- f.closed = append(f.closed, t.Key)
64
- return nil
65
- }
66
-
67
- func newDaemon(ft *fakeTasks, fr *fakeRunner) *Daemon {
68
- return New(testConfig(), ft, fr, "repo-1", "repo:xyz", false)
69
- }
70
-
71
- func TestPollDispatchesUnclaimed(t *testing.T) {
72
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "coding"}}}
73
- fr := &fakeRunner{}
74
- d := newDaemon(ft, fr)
75
- d.PollOnce()
76
- d.Wait()
77
- if len(ft.claims) != 1 || ft.claims[0] != "XYZ-1" {
78
- t.Errorf("claims = %v", ft.claims)
79
- }
80
- if len(fr.spawned) != 1 || fr.spawned[0] != "XYZ-1:coding:build:wf:XYZ-1" {
81
- t.Errorf("spawned = %v", fr.spawned)
82
- }
83
- }
84
-
85
- func TestPollSkipsForeignClaim(t *testing.T) {
86
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "coding", ClaimedBy: "otherFlow"}}}
87
- fr := &fakeRunner{}
88
- d := newDaemon(ft, fr)
89
- d.PollOnce()
90
- d.Wait()
91
- if len(ft.claims) != 0 || len(fr.spawned) != 0 {
92
- t.Errorf("foreign ticket touched: claims=%v spawned=%v", ft.claims, fr.spawned)
93
- }
94
- }
95
-
96
- func TestPollSkipsUnmappedState(t *testing.T) {
97
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: ""}}}
98
- fr := &fakeRunner{}
99
- d := newDaemon(ft, fr)
100
- d.PollOnce()
101
- d.Wait()
102
- if len(fr.spawned) != 0 || len(fr.closed) != 0 {
103
- t.Errorf("unmapped ticket touched: spawned=%v closed=%v", fr.spawned, fr.closed)
104
- }
105
- }
106
-
107
- func TestPollHumanGateNode(t *testing.T) {
108
- cfg := testConfig()
109
- cfg.Nodes["gate"] = config.Node{When: "In Review"} // agentless, not in closeOn
110
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-5", Node: "gate"}}}
111
- fr := &fakeRunner{}
112
- d := New(cfg, ft, fr, "repo-1", "repo:xyz", false)
113
- d.PollOnce()
114
- d.Wait()
115
- if len(fr.spawned) != 0 || len(fr.nudged) != 0 || len(fr.closed) != 0 {
116
- t.Errorf("gate node must not spawn/nudge/close: %+v", fr)
117
- }
118
- if len(ft.claims) != 1 {
119
- t.Errorf("gate node must claim: %v", ft.claims)
120
- }
121
- }
122
-
123
- func TestPollClosesTerminalNode(t *testing.T) {
124
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "done", ClaimedBy: "wf"}}}
125
- fr := &fakeRunner{}
126
- d := newDaemon(ft, fr)
127
- d.PollOnce()
128
- d.Wait()
129
- if len(fr.closed) != 1 || fr.closed[0] != "XYZ-1" {
130
- t.Errorf("closed = %v", fr.closed)
131
- }
132
- if len(fr.spawned) != 0 {
133
- t.Errorf("terminal node must not spawn: %v", fr.spawned)
134
- }
135
- }
136
-
137
- func TestBounceNudgesExistingSession(t *testing.T) {
138
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "coding", ClaimedBy: "wf"}}}
139
- fr := &fakeRunner{found: map[string]runner.Session{
140
- "XYZ-1:coding": {ID: "h1", Title: "XYZ-1:build:coding"},
141
- }}
142
- d := newDaemon(ft, fr)
143
- d.PollOnce()
144
- d.Wait()
145
- if len(fr.spawned) != 0 {
146
- t.Errorf("bounce must not spawn: %v", fr.spawned)
147
- }
148
- if len(fr.nudged) != 1 || fr.nudged[0] != "XYZ-1:build:coding:back to work on XYZ-1 at coding" {
149
- t.Errorf("nudged = %v", fr.nudged)
150
- }
151
- }
152
-
153
- func TestBounceWithoutSessionSpawnsFresh(t *testing.T) {
154
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "coding", ClaimedBy: "wf"}}}
155
- fr := &fakeRunner{found: map[string]runner.Session{}}
156
- d := newDaemon(ft, fr)
157
- d.PollOnce()
158
- d.Wait()
159
- if len(fr.spawned) != 1 {
160
- t.Errorf("crash-without-terminal must respawn: spawned=%v", fr.spawned)
161
- }
162
- if len(ft.claims) != 0 {
163
- t.Errorf("already-claimed ticket must not re-claim: %v", ft.claims)
164
- }
165
- }
166
-
167
- func TestBounceNudgesOncePerNodeVisit(t *testing.T) {
168
- ft := &fakeTasks{listed: []Ticket{{Key: "XYZ-1", Node: "coding", ClaimedBy: "wf"}}}
169
- fr := &fakeRunner{found: map[string]runner.Session{
170
- "XYZ-1:coding": {ID: "h1", Title: "XYZ-1:build:coding"},
171
- }}
172
- d := newDaemon(ft, fr)
173
- d.PollOnce()
174
- d.Wait()
175
- d.PollOnce()
176
- d.Wait()
177
- if len(fr.nudged) != 1 {
178
- t.Errorf("same node visit must nudge once: %v", fr.nudged)
179
- }
180
- // Status change (report moved it) re-arms the marker.
181
- d.ClearNudged("XYZ-1")
182
- d.PollOnce()
183
- d.Wait()
184
- if len(fr.nudged) != 2 {
185
- t.Errorf("re-armed marker must allow another nudge: %v", fr.nudged)
186
- }
187
- }
188
-
189
- func TestSpawnPromptMentionsOutcomes(t *testing.T) {
190
- p := initialPrompt(testConfig(), "coding", tasks.Ticket{Key: "XYZ-1"})
191
- if !strings.Contains(p, "XYZ-1") || !strings.Contains(p, "success") || !strings.Contains(p, "failure") {
192
- t.Errorf("prompt = %q", p)
193
- }
194
- if strings.Contains(p, "\n") {
195
- t.Errorf("prompt must be flattened to one line")
196
- }
197
- }
198
-
199
- func TestNudgeTemplating(t *testing.T) {
200
- got := renderNudge(testConfig().Nodes["coding"].NudgePrompt, "XYZ-7", "coding")
201
- if got != "back to work on XYZ-7 at coding" {
202
- t.Errorf("%q", got)
203
- }
204
- }
@@ -1,122 +0,0 @@
1
- // Package discovery resolves the current Orca repo and manages the
2
- // central relay-flow server's fixed-location artifacts under ~/.relay-flow/
3
- // (socket + flock). Single-instance enforcement is flock-based: the
4
- // kernel releases the lock on any process exit, so there is no stale
5
- // state and no pid files anywhere.
6
- package discovery
7
-
8
- import (
9
- "encoding/json"
10
- "fmt"
11
- "os"
12
- "os/exec"
13
- "path/filepath"
14
- "syscall"
15
- )
16
-
17
- func dir(workflowName string) (string, error) {
18
- home, err := os.UserHomeDir()
19
- if err != nil {
20
- return "", err
21
- }
22
- d := filepath.Join(home, ".relay-flow", workflowName)
23
- if err := os.MkdirAll(d, 0o755); err != nil {
24
- return "", err
25
- }
26
- return d, nil
27
- }
28
-
29
- // CurrentRepo resolves the repoId and the *repo's* displayName (not the
30
- // worktree's own displayName, which is a different, worktree-scoped
31
- // value) for the repo the CLI is running in, via `orca worktree current`
32
- // followed by `orca repo show`.
33
- func CurrentRepo() (repoID, repoDisplayName string, err error) {
34
- return RepoFromPath(".")
35
- }
36
-
37
- // RepoFromPath is CurrentRepo rooted at an arbitrary directory instead of
38
- // the process cwd — used by the server, which receives the repo path from
39
- // the submitting client.
40
- func RepoFromPath(path string) (repoID, repoDisplayName string, err error) {
41
- cmd := exec.Command("orca", "worktree", "current", "--json")
42
- cmd.Dir = path
43
- out, err := cmd.Output()
44
- if err != nil {
45
- return "", "", fmt.Errorf("orca worktree current: %w", err)
46
- }
47
- var wres struct {
48
- Result struct {
49
- Worktree struct {
50
- RepoID string `json:"repoId"`
51
- } `json:"worktree"`
52
- } `json:"result"`
53
- }
54
- if err := json.Unmarshal(out, &wres); err != nil || wres.Result.Worktree.RepoID == "" {
55
- return "", "", fmt.Errorf("orca worktree current: no repoId in output")
56
- }
57
- repoID = wres.Result.Worktree.RepoID
58
-
59
- repoCmd := exec.Command("orca", "repo", "show", "--repo", "id:"+repoID, "--json")
60
- repoCmd.Dir = path
61
- out, err = repoCmd.Output()
62
- if err != nil {
63
- return "", "", fmt.Errorf("orca repo show: %w", err)
64
- }
65
- var rres struct {
66
- Result struct {
67
- Repo struct {
68
- DisplayName string `json:"displayName"`
69
- } `json:"repo"`
70
- } `json:"result"`
71
- }
72
- if err := json.Unmarshal(out, &rres); err != nil || rres.Result.Repo.DisplayName == "" {
73
- return "", "", fmt.Errorf("orca repo show: no displayName in output")
74
- }
75
- return repoID, rres.Result.Repo.DisplayName, nil
76
- }
77
-
78
- // SocketPath returns the unix socket the central `serve` process listens
79
- // on: ~/.relay-flow/server.sock.
80
- func SocketPath() (string, error) {
81
- d, err := dir("")
82
- if err != nil {
83
- return "", err
84
- }
85
- return filepath.Join(d, "server.sock"), nil
86
- }
87
-
88
- // ServerLockPath returns the flock file enforcing a single `serve`
89
- // process: ~/.relay-flow/server.lock.
90
- func ServerLockPath() (string, error) {
91
- d, err := dir("")
92
- if err != nil {
93
- return "", err
94
- }
95
- return filepath.Join(d, "server.lock"), nil
96
- }
97
-
98
- // AcquireServerLock takes an exclusive non-blocking flock on the server
99
- // lock file. The lock is held by the kernel for the life of the returned
100
- // file's descriptor: process exit (clean, crash, or kill -9) releases it
101
- // automatically, so there is no stale-state cleanup. Returns a release
102
- // func (also runs at process exit implicitly).
103
- func AcquireServerLock() (release func(), err error) {
104
- path, err := ServerLockPath()
105
- if err != nil {
106
- return nil, err
107
- }
108
- f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o644)
109
- if err != nil {
110
- return nil, err
111
- }
112
- if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
113
- f.Close()
114
- return nil, fmt.Errorf("server already running (lock held: %s)", path)
115
- }
116
- return func() {
117
- syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
118
- f.Close()
119
- }, nil
120
- }
121
-
122
-
@@ -1,62 +0,0 @@
1
- package discovery
2
-
3
- import (
4
- "path/filepath"
5
- "testing"
6
- )
7
-
8
- func TestSocketPath(t *testing.T) {
9
- tmp := t.TempDir()
10
- t.Setenv("HOME", tmp)
11
- p, err := SocketPath()
12
- if err != nil {
13
- t.Fatal(err)
14
- }
15
- want := filepath.Join(tmp, ".relay-flow", "server.sock")
16
- if p != want {
17
- t.Fatalf("SocketPath=%q, want %q", p, want)
18
- }
19
- }
20
-
21
- func TestServerLockPath(t *testing.T) {
22
- tmp := t.TempDir()
23
- t.Setenv("HOME", tmp)
24
- p, err := ServerLockPath()
25
- if err != nil {
26
- t.Fatal(err)
27
- }
28
- want := filepath.Join(tmp, ".relay-flow", "server.lock")
29
- if p != want {
30
- t.Fatalf("ServerLockPath=%q, want %q", p, want)
31
- }
32
- }
33
-
34
- func TestAcquireServerLock_SingleInstance(t *testing.T) {
35
- tmp := t.TempDir()
36
- t.Setenv("HOME", tmp)
37
- release, err := AcquireServerLock()
38
- if err != nil {
39
- t.Fatalf("first AcquireServerLock: %v", err)
40
- }
41
- defer release()
42
- // Second acquire while first is held must fail immediately.
43
- if _, err := AcquireServerLock(); err == nil {
44
- t.Fatal("second AcquireServerLock should fail while first is held")
45
- }
46
- }
47
-
48
- func TestAcquireServerLock_ReacquireAfterRelease(t *testing.T) {
49
- tmp := t.TempDir()
50
- t.Setenv("HOME", tmp)
51
- release, err := AcquireServerLock()
52
- if err != nil {
53
- t.Fatal(err)
54
- }
55
- release()
56
- // After release (process exit), lock is free again — no stale state.
57
- release2, err := AcquireServerLock()
58
- if err != nil {
59
- t.Fatalf("reacquire after release: %v", err)
60
- }
61
- release2()
62
- }
@@ -1,26 +0,0 @@
1
- // Package opencode wraps the `opencode` CLI to validate agent names.
2
- package opencode
3
-
4
- import (
5
- "fmt"
6
- "os/exec"
7
- "strings"
8
- )
9
-
10
- // Exists reports whether name is a known opencode agent, via
11
- // `opencode agent list` (agent names are the unindented lines).
12
- func Exists(name string) (bool, error) {
13
- out, err := exec.Command("opencode", "agent", "list").Output()
14
- if err != nil {
15
- return false, fmt.Errorf("opencode agent list: %w", err)
16
- }
17
- for _, line := range strings.Split(string(out), "\n") {
18
- if line == "" || line[0] == ' ' || line[0] == '[' || line[0] == '{' {
19
- continue
20
- }
21
- if strings.Fields(line)[0] == name {
22
- return true, nil
23
- }
24
- }
25
- return false, nil
26
- }
@@ -1,264 +0,0 @@
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
- }