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,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
|
-
}
|