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.
- 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 +27 -0
|
@@ -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
|
+
}
|