relay-flow 0.0.1

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.
@@ -0,0 +1,218 @@
1
+ // Package daemon runs one workflow's poll loop: list tickets from the
2
+ // tasks adapter, route each through the 3-way claim switch, and dispatch
3
+ // agent sessions through the runner adapter. It contains no tracker- or
4
+ // runner-specific logic — both arrive as interfaces.
5
+ package daemon
6
+
7
+ import (
8
+ "context"
9
+ "fmt"
10
+ "log"
11
+ "strings"
12
+ "sync"
13
+ "time"
14
+
15
+ "github.com/rajpopat27/relay-flow/internal/config"
16
+ "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/tasks"
18
+ )
19
+
20
+ // Daemon polls one workflow and dispatches tickets. Long-lived: one poll
21
+ // goroutine per submitted workflow; dispatch/bounce run as short-lived
22
+ // goroutines per ticket.
23
+ type Daemon struct {
24
+ cfg *config.Config
25
+ tasks tasks.Tasks
26
+ runner runner.Runner
27
+ repoID string
28
+ repoName string
29
+ dryRun bool
30
+
31
+ // nudged marks key → node for which a prompt/nudge was already
32
+ // delivered, so each node visit prompts exactly once. Cleared when a
33
+ // report moves the ticket (re-arming the next visit).
34
+ nudgedMu sync.Mutex
35
+ nudged map[string]string
36
+
37
+ wg sync.WaitGroup // tracks dispatch/bounce goroutines; Wait blocks tests
38
+ }
39
+
40
+ // New builds a daemon for one validated workflow config.
41
+ func New(cfg *config.Config, tk tasks.Tasks, rn runner.Runner, repoID, repoName string, dryRun bool) *Daemon {
42
+ return &Daemon{
43
+ cfg: cfg, tasks: tk, runner: rn,
44
+ repoID: repoID, repoName: repoName, dryRun: dryRun,
45
+ nudged: map[string]string{},
46
+ }
47
+ }
48
+
49
+ // PollLoop ticks until ctx is cancelled (server shutdown/remove).
50
+ func (d *Daemon) PollLoop(ctx context.Context) {
51
+ interval := time.Duration(d.cfg.PollIntervalSeconds) * time.Second
52
+ ticker := time.NewTicker(interval)
53
+ defer ticker.Stop()
54
+ d.PollOnce()
55
+ for {
56
+ select {
57
+ case <-ctx.Done():
58
+ return
59
+ case <-ticker.C:
60
+ d.PollOnce()
61
+ }
62
+ }
63
+ }
64
+
65
+ // Wait blocks until in-flight dispatch/bounce goroutines finish. Tests
66
+ // call it after PollOnce; production never needs it.
67
+ func (d *Daemon) Wait() { d.wg.Wait() }
68
+
69
+ // PollOnce lists tickets once and routes each:
70
+ //
71
+ // claimed by another workflow → skip (cross-workflow mutex)
72
+ // unmapped tracker state → log + skip
73
+ // node in closeOn → runner.Close (terminal teardown)
74
+ // claimed by me, not prompted → bounce: Find → Nudge (or respawn)
75
+ // unclaimed → dispatch: Claim → Spawn
76
+ func (d *Daemon) PollOnce() {
77
+ found, err := d.tasks.List()
78
+ if err != nil {
79
+ log.Printf("poll %s: %v", d.cfg.Name, err)
80
+ return
81
+ }
82
+ for _, t := range found {
83
+ switch {
84
+ case t.ClaimedBy != "" && t.ClaimedBy != d.cfg.Name:
85
+ // Foreign workflow owns it — never touch.
86
+ case t.Node == "":
87
+ log.Printf("poll %s: %s at unmapped state, skipping", d.cfg.Name, t.Key)
88
+ case d.cfg.CloseOn.Has(t.Node):
89
+ log.Printf("poll %s: %s at terminal node %q, closing terminals", d.cfg.Name, t.Key, t.Node)
90
+ if err := d.runner.Close(t); err != nil {
91
+ log.Printf("poll %s: close %s: %v", d.cfg.Name, t.Key, err)
92
+ }
93
+ d.ClearNudged(t.Key)
94
+ case d.cfg.Nodes[t.Node].Agent == "":
95
+ // Human gate: no automation. Claim it so foreign workflows
96
+ // leave it alone, then leave the ticket for the human.
97
+ if t.ClaimedBy == "" {
98
+ if err := d.tasks.Claim(t); err != nil {
99
+ log.Printf("poll %s: claim %s (gate node): %v", d.cfg.Name, t.Key, err)
100
+ }
101
+ }
102
+ case t.ClaimedBy == d.cfg.Name:
103
+ d.wg.Add(1)
104
+ go d.bounce(t)
105
+ default:
106
+ d.wg.Add(1)
107
+ go d.dispatch(t)
108
+ }
109
+ }
110
+ }
111
+
112
+ // dispatch claims an unclaimed ticket and spawns its agent session.
113
+ func (d *Daemon) dispatch(t tasks.Ticket) {
114
+ defer d.wg.Done()
115
+ node := d.cfg.Nodes[t.Node]
116
+ if err := d.tasks.Claim(t); err != nil {
117
+ log.Printf("dispatch %s: claim: %v", t.Key, err)
118
+ return
119
+ }
120
+ prompt := initialPrompt(d.cfg, t.Node, t)
121
+ env := map[string]string{
122
+ "RELAY_FLOW_WORKFLOW": d.cfg.Name,
123
+ "RELAY_FLOW_TICKET": t.Key,
124
+ "RELAY_FLOW_NODE": t.Node,
125
+ "RELAY_FLOW_AGENT": node.Agent,
126
+ }
127
+ if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
128
+ log.Printf("dispatch %s: spawn: %v", t.Key, err)
129
+ return
130
+ }
131
+ d.markNudged(t.Key, t.Node)
132
+ log.Printf("dispatch %s: spawned %q for node %q", t.Key, node.Agent, t.Node)
133
+ }
134
+
135
+ // bounce handles a ticket this workflow claimed but has no in-memory
136
+ // record of (server restart): find the live terminal and nudge it, once
137
+ // per node visit. No terminal → spawn fresh (claim already held).
138
+ func (d *Daemon) bounce(t tasks.Ticket) {
139
+ defer d.wg.Done()
140
+ node := d.cfg.Nodes[t.Node]
141
+ sess, ok, err := d.runner.Find(t, t.Node)
142
+ if err != nil {
143
+ log.Printf("bounce %s: find: %v", t.Key, err)
144
+ return
145
+ }
146
+ if !ok {
147
+ // No live session: marker is irrelevant — always respawn (a
148
+ // terminal may have died after we marked it prompted).
149
+ d.ClearNudged(t.Key)
150
+ // Crash took the terminal with it: spawn a fresh session.
151
+ prompt := initialPrompt(d.cfg, t.Node, t)
152
+ env := map[string]string{
153
+ "RELAY_FLOW_WORKFLOW": d.cfg.Name,
154
+ "RELAY_FLOW_TICKET": t.Key,
155
+ "RELAY_FLOW_NODE": t.Node,
156
+ "RELAY_FLOW_AGENT": node.Agent,
157
+ }
158
+ if err := d.runner.Spawn(t, t.Node, node.Agent, prompt, env); err != nil {
159
+ log.Printf("bounce %s: spawn: %v", t.Key, err)
160
+ return
161
+ }
162
+ d.markNudged(t.Key, t.Node)
163
+ log.Printf("bounce %s: no live session, spawned fresh for node %q", t.Key, t.Node)
164
+ return
165
+ }
166
+ if d.nudgedNode(t.Key) == t.Node {
167
+ return // session alive and already prompted for this visit
168
+ }
169
+ prompt := renderNudge(node.NudgePrompt, t.Key, t.Node)
170
+ if err := d.runner.Nudge(sess, prompt); err != nil {
171
+ log.Printf("bounce %s: nudge: %v (retry next poll)", t.Key, err)
172
+ return
173
+ }
174
+ d.markNudged(t.Key, t.Node)
175
+ log.Printf("bounce %s: nudged %q for node %q", t.Key, sess.Title, t.Node)
176
+ }
177
+
178
+ // ClearNudged drops the prompted marker for a ticket — called when a
179
+ // report moves it to a new node, re-arming the next visit's nudge.
180
+ func (d *Daemon) ClearNudged(key string) {
181
+ d.nudgedMu.Lock()
182
+ delete(d.nudged, key)
183
+ d.nudgedMu.Unlock()
184
+ }
185
+
186
+ func (d *Daemon) markNudged(key, node string) {
187
+ d.nudgedMu.Lock()
188
+ d.nudged[key] = node
189
+ d.nudgedMu.Unlock()
190
+ }
191
+
192
+ func (d *Daemon) nudgedNode(key string) string {
193
+ d.nudgedMu.Lock()
194
+ defer d.nudgedMu.Unlock()
195
+ return d.nudged[key]
196
+ }
197
+
198
+ // initialPrompt tells the agent which ticket it owns and the STATUS/
199
+ // SUMMARY handoff contract (statuses success|failure). The agent fetches
200
+ // ticket details itself via acli — nothing is injected here so feedback
201
+ // is always picked up fresh. Flattened to one line: the command is typed
202
+ // into a pty via keystroke simulation.
203
+ func initialPrompt(cfg *config.Config, nodeName string, t tasks.Ticket) string {
204
+ prompt := fmt.Sprintf(
205
+ "You have been assigned ticket %s. Run `acli jira workitem view %s --fields summary,description,comment --json` "+
206
+ "to fetch its details and full comment history. When you are done, end your final message with exactly: "+
207
+ "STATUS: <success or failure> SUMMARY: <one-line summary of what you did>",
208
+ t.Key, t.Key)
209
+ return strings.Join(strings.Fields(prompt), " ")
210
+ }
211
+
212
+ // renderNudge applies {{ticket}}/{{node}} templating and flattens to one
213
+ // line (keystroke simulation submits on newline).
214
+ func renderNudge(tmpl, ticket, node string) string {
215
+ s := strings.ReplaceAll(tmpl, "{{ticket}}", ticket)
216
+ s = strings.ReplaceAll(s, "{{node}}", node)
217
+ return strings.Join(strings.Fields(s), " ")
218
+ }
@@ -0,0 +1,204 @@
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
+ }
@@ -0,0 +1,122 @@
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
+
@@ -0,0 +1,62 @@
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
+ }
@@ -0,0 +1,26 @@
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
+ }