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,201 @@
1
+ package orca
2
+
3
+ import (
4
+ "testing"
5
+ "time"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/orcacli"
8
+ "github.com/rajpopat27/relay-flow/internal/runner"
9
+ "github.com/rajpopat27/relay-flow/internal/tasks"
10
+ )
11
+
12
+ type fakeOrca struct {
13
+ listErr error
14
+ worktrees []orcacli.Worktree
15
+ terminals map[string][]orcacli.Terminal // worktree name → terminals
16
+ created []string
17
+ sent []string
18
+ closed []string
19
+ waitErr error
20
+ createErr error
21
+ findBranchFn func(repoPath, key string) (string, bool, error)
22
+ agentExists bool
23
+ }
24
+
25
+ func (f *fakeOrca) WorktreeList() ([]orcacli.Worktree, error) { return f.worktrees, nil }
26
+ func (f *fakeOrca) WorktreeCreate(ticketKey, repoID, parentWorktreeID, baseBranch string) error {
27
+ f.created = append(f.created, ticketKey)
28
+ f.worktrees = append(f.worktrees, orcacli.Worktree{ID: "wt-" + ticketKey, DisplayName: ticketKey, RepoID: repoID, Branch: baseBranch})
29
+ return nil
30
+ }
31
+ func (f *fakeOrca) FindWorktree(repoID, displayName string) (orcacli.Worktree, bool, error) {
32
+ for _, w := range f.worktrees {
33
+ if w.RepoID == repoID && w.DisplayName == displayName {
34
+ return w, true, nil
35
+ }
36
+ }
37
+ return orcacli.Worktree{}, false, nil
38
+ }
39
+ func (f *fakeOrca) MainWorktree(repoID string) (orcacli.Worktree, bool, error) {
40
+ for _, w := range f.worktrees {
41
+ if w.RepoID == repoID && w.IsMainWorktree {
42
+ return w, true, nil
43
+ }
44
+ }
45
+ return orcacli.Worktree{}, false, nil
46
+ }
47
+ func (f *fakeOrca) TerminalList(worktree string) ([]orcacli.Terminal, error) {
48
+ if f.listErr != nil {
49
+ return nil, f.listErr
50
+ }
51
+ return f.terminals[worktree], nil
52
+ }
53
+ func (f *fakeOrca) TerminalCreate(ticketKey, title, command string) (string, error) {
54
+ if f.createErr != nil {
55
+ return "", f.createErr
56
+ }
57
+ h := "h-" + title
58
+ f.terminals["name:"+ticketKey] = append(f.terminals["name:"+ticketKey], orcacli.Terminal{Handle: h, Title: title, Connected: true})
59
+ return h, nil
60
+ }
61
+ func (f *fakeOrca) TerminalWait(handle, forState string, timeoutMs int) error { return f.waitErr }
62
+ func (f *fakeOrca) TerminalClose(handle string) error {
63
+ f.closed = append(f.closed, handle)
64
+ return nil
65
+ }
66
+ func (f *fakeOrca) TerminalSend(handle, text string) error {
67
+ f.sent = append(f.sent, handle+":"+text)
68
+ return nil
69
+ }
70
+
71
+ func newTestRunner(f *fakeOrca) *orcaRunner {
72
+ return &orcaRunner{
73
+ repoID: "repo-1",
74
+ orca: f,
75
+ exists: func(string) (bool, error) { return f.agentExists, nil },
76
+ sleep: func(time.Duration) {},
77
+ findBranch: func(repoPath, key string) (string, bool, error) {
78
+ if f.findBranchFn != nil {
79
+ return f.findBranchFn(repoPath, key)
80
+ }
81
+ return "", false, nil
82
+ },
83
+ }
84
+ }
85
+
86
+ func TestUnmarshalConfig(t *testing.T) {
87
+ if _, err := unmarshalConfig(map[string]any{}); err != nil {
88
+ t.Fatalf("empty config must be valid: %v", err)
89
+ }
90
+ if _, err := unmarshalConfig(nil); err != nil {
91
+ t.Fatalf("nil config must be valid: %v", err)
92
+ }
93
+ if _, err := unmarshalConfig(map[string]any{"bogus": 1}); err == nil {
94
+ t.Fatal("unknown field must be rejected")
95
+ }
96
+ }
97
+
98
+ func TestSpawnCreatesWorktreeAndTerminal(t *testing.T) {
99
+ f := &fakeOrca{
100
+ worktrees: []orcacli.Worktree{{ID: "main", DisplayName: "main", RepoID: "repo-1", IsMainWorktree: true, Branch: "main", Path: "/tmp"}},
101
+ terminals: map[string][]orcacli.Terminal{},
102
+ agentExists: true,
103
+ }
104
+ r := newTestRunner(f)
105
+ tk := tasks.Ticket{Key: "XYZ-1"}
106
+ err := r.Spawn(tk, "coding", "build", "do the work", map[string]string{
107
+ "RELAY_FLOW_WORKFLOW": "wf", "RELAY_FLOW_TICKET": "XYZ-1", "RELAY_FLOW_NODE": "coding", "RELAY_FLOW_AGENT": "build",
108
+ })
109
+ if err != nil {
110
+ t.Fatalf("%v", err)
111
+ }
112
+ if len(f.created) != 1 || f.created[0] != "XYZ-1" {
113
+ t.Errorf("worktree created = %v", f.created)
114
+ }
115
+ terms := f.terminals["name:XYZ-1"]
116
+ if len(terms) != 1 || terms[0].Title != "XYZ-1:build:coding" {
117
+ t.Errorf("terminals = %+v", terms)
118
+ }
119
+ }
120
+
121
+ func TestSpawnUnknownAgent(t *testing.T) {
122
+ f := &fakeOrca{terminals: map[string][]orcacli.Terminal{}, agentExists: false}
123
+ r := newTestRunner(f)
124
+ if err := r.Spawn(tasks.Ticket{Key: "XYZ-1"}, "coding", "ghost", "p", nil); err == nil {
125
+ t.Fatal("unknown agent must error")
126
+ }
127
+ if len(f.created) != 0 {
128
+ t.Errorf("no worktree should be created for unknown agent: %v", f.created)
129
+ }
130
+ }
131
+
132
+ func TestFindExactTitle(t *testing.T) {
133
+ f := &fakeOrca{terminals: map[string][]orcacli.Terminal{
134
+ "name:XYZ-1": {
135
+ {Handle: "h1", Title: "XYZ-1:build:coding"},
136
+ {Handle: "h2", Title: "XYZ-1:build:reviewing"},
137
+ {Handle: "h3", Title: "Terminal 1"},
138
+ },
139
+ }}
140
+ r := newTestRunner(f)
141
+ s, ok, err := r.Find(tasks.Ticket{Key: "XYZ-1"}, "coding")
142
+ if err != nil || !ok || s.ID != "h1" {
143
+ t.Errorf("Find coding = %+v ok=%v err=%v", s, ok, err)
144
+ }
145
+ if _, ok, _ := r.Find(tasks.Ticket{Key: "XYZ-1"}, "nowhere"); ok {
146
+ t.Error("Find nowhere must miss")
147
+ }
148
+ if _, ok, _ := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); ok {
149
+ t.Error("Find unknown ticket must miss")
150
+ }
151
+ }
152
+
153
+ func TestFindMissingWorktreeIsNotFound(t *testing.T) {
154
+ // selector_not_found (worktree gone after crash) must be "no session",
155
+ // not an error — otherwise bounce never reaches the respawn branch.
156
+ f := &fakeOrca{listErr: errSelectorNotFound}
157
+ r := newTestRunner(f)
158
+ if _, ok, err := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); err != nil || ok {
159
+ t.Errorf("Find = ok=%v err=%v, want no-session", ok, err)
160
+ }
161
+ // Real errors still propagate.
162
+ f.listErr = errBoom
163
+ if _, _, err := r.Find(tasks.Ticket{Key: "XYZ-9"}, "coding"); err == nil {
164
+ t.Error("real list error must propagate")
165
+ }
166
+ }
167
+
168
+ var errSelectorNotFound = errorString("exit status 1: selector_not_found")
169
+ var errBoom = errorString("boom")
170
+
171
+ type errorString string
172
+
173
+ func (e errorString) Error() string { return string(e) }
174
+
175
+ func TestNudgeSendsPrompt(t *testing.T) {
176
+ f := &fakeOrca{}
177
+ r := newTestRunner(f)
178
+ if err := r.Nudge(runner.Session{ID: "h1", Title: "XYZ-1:build:coding"}, "continue please"); err != nil {
179
+ t.Fatalf("%v", err)
180
+ }
181
+ if len(f.sent) != 1 || f.sent[0] != "h1:continue please" {
182
+ t.Errorf("sent = %v", f.sent)
183
+ }
184
+ }
185
+
186
+ func TestCloseClosesTicketTerminals(t *testing.T) {
187
+ f := &fakeOrca{terminals: map[string][]orcacli.Terminal{
188
+ "name:XYZ-1": {
189
+ {Handle: "h1", Title: "XYZ-1:build:coding"},
190
+ {Handle: "h2", Title: "XYZ-1:build:reviewing"},
191
+ {Handle: "h3", Title: "Setup"}, // not ours — survives
192
+ },
193
+ }}
194
+ r := newTestRunner(f)
195
+ if err := r.Close(tasks.Ticket{Key: "XYZ-1"}); err != nil {
196
+ t.Fatalf("%v", err)
197
+ }
198
+ if len(f.closed) != 2 {
199
+ t.Errorf("closed = %v, want h1+h2 only", f.closed)
200
+ }
201
+ }
@@ -0,0 +1,81 @@
1
+ // Package runner defines the execution-backend plug point. Orca ships
2
+ // built in (internal/runner/orca); tmux or others register the same way.
3
+ // A Runner knows how to spawn agent sessions, re-find them (bounce),
4
+ // nudge them, and tear them down — nothing about trackers.
5
+ package runner
6
+
7
+ import (
8
+ "fmt"
9
+ "sort"
10
+ "sync"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/tasks"
13
+ )
14
+
15
+ // Session is a live agent terminal/process handle.
16
+ type Session struct {
17
+ ID string
18
+ Title string
19
+ }
20
+
21
+ // Runner is the execution-backend interface.
22
+ type Runner interface {
23
+ // Spawn creates (or ensures) the ticket's worktree/session titled
24
+ // "<key>:<agent>:<node>", injects env, and sends the initial prompt.
25
+ Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error
26
+ // Find locates the existing session for this ticket+node (bounce).
27
+ Find(t tasks.Ticket, node string) (Session, bool, error)
28
+ // Nudge sends a prompt into an existing session.
29
+ Nudge(s Session, prompt string) error
30
+ // Close tears down all of the ticket's sessions (terminal node).
31
+ Close(t tasks.Ticket) error
32
+ }
33
+
34
+ // Factory builds a runner instance. runner.config is decoded by
35
+ // UnmarshalConfig (strict), then passed to New.
36
+ type Factory struct {
37
+ UnmarshalConfig func(map[string]any) (any, error)
38
+ New func(cfg any) (Runner, error)
39
+ }
40
+
41
+ var (
42
+ mu sync.RWMutex
43
+ factories = map[string]Factory{}
44
+ )
45
+
46
+ // Register makes a runner available under `type: <name>`. Panics on
47
+ // duplicate registration (programmer error).
48
+ func Register(name string, f Factory) {
49
+ mu.Lock()
50
+ defer mu.Unlock()
51
+ if _, dup := factories[name]; dup {
52
+ panic("runner: duplicate registration " + name)
53
+ }
54
+ factories[name] = f
55
+ }
56
+
57
+ // New resolves a runner by type name and builds an instance.
58
+ func New(typeName string, rawCfg map[string]any) (Runner, error) {
59
+ mu.RLock()
60
+ f, ok := factories[typeName]
61
+ mu.RUnlock()
62
+ if !ok {
63
+ return nil, fmt.Errorf("unknown runner type %q (registered: %v)", typeName, registered())
64
+ }
65
+ cfg, err := f.UnmarshalConfig(rawCfg)
66
+ if err != nil {
67
+ return nil, fmt.Errorf("runner type %q config: %w", typeName, err)
68
+ }
69
+ return f.New(cfg)
70
+ }
71
+
72
+ func registered() []string {
73
+ mu.RLock()
74
+ defer mu.RUnlock()
75
+ names := make([]string, 0, len(factories))
76
+ for n := range factories {
77
+ names = append(names, n)
78
+ }
79
+ sort.Strings(names)
80
+ return names
81
+ }
@@ -0,0 +1,64 @@
1
+ package runner
2
+
3
+ import (
4
+ "strings"
5
+ "testing"
6
+
7
+ "github.com/rajpopat27/relay-flow/internal/tasks"
8
+ )
9
+
10
+ type fakeRunner struct {
11
+ spawned []string
12
+ nudged []string
13
+ closed []string
14
+ found map[string]Session
15
+ }
16
+
17
+ func (f *fakeRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
18
+ f.spawned = append(f.spawned, t.Key+":"+node+":"+agent+":"+env["RELAY_WORKFLOW"])
19
+ return nil
20
+ }
21
+ func (f *fakeRunner) Find(t tasks.Ticket, node string) (Session, bool, error) {
22
+ s, ok := f.found[t.Key+":"+node]
23
+ return s, ok, nil
24
+ }
25
+ func (f *fakeRunner) Nudge(s Session, prompt string) error {
26
+ f.nudged = append(f.nudged, s.Title+":"+prompt)
27
+ return nil
28
+ }
29
+ func (f *fakeRunner) Close(t tasks.Ticket) error {
30
+ f.closed = append(f.closed, t.Key)
31
+ return nil
32
+ }
33
+
34
+ func TestRegistryNew(t *testing.T) {
35
+ fake := &fakeRunner{}
36
+ Register("fakerun", Factory{
37
+ UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
38
+ New: func(cfg any) (Runner, error) { return fake, nil },
39
+ })
40
+
41
+ r, err := New("fakerun", nil)
42
+ if err != nil {
43
+ t.Fatalf("%v", err)
44
+ }
45
+ tk := tasks.Ticket{Key: "XYZ-2"}
46
+ r.Spawn(tk, "coding", "build", "go", map[string]string{"RELAY_WORKFLOW": "w"})
47
+ if len(fake.spawned) != 1 || fake.spawned[0] != "XYZ-2:coding:build:w" {
48
+ t.Errorf("spawned = %v", fake.spawned)
49
+ }
50
+
51
+ if _, err := New("nope", nil); err == nil || !strings.Contains(err.Error(), "unknown runner type") {
52
+ t.Errorf("unknown runner error = %v", err)
53
+ }
54
+ }
55
+
56
+ func TestDuplicateRegisterPanics(t *testing.T) {
57
+ defer func() {
58
+ if recover() == nil {
59
+ t.Fatal("expected panic on duplicate registration")
60
+ }
61
+ }()
62
+ Register("duprun", Factory{})
63
+ Register("duprun", Factory{})
64
+ }
@@ -0,0 +1,126 @@
1
+ package server
2
+
3
+ import (
4
+ "bytes"
5
+ "context"
6
+ "encoding/json"
7
+ "fmt"
8
+ "net"
9
+ "net/http"
10
+ "os"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/discovery"
13
+ )
14
+
15
+ // Client talks to a running `serve` process over its unix socket. Zero
16
+ // value is unusable; construct with NewClient (prod socket) or set Socket
17
+ // directly (tests).
18
+ type Client struct {
19
+ Socket string
20
+ }
21
+
22
+ // NewClient returns a Client for the default socket path.
23
+ func NewClient() (*Client, error) {
24
+ p, err := discovery.SocketPath()
25
+ if err != nil {
26
+ return nil, err
27
+ }
28
+ return &Client{Socket: p}, nil
29
+ }
30
+
31
+ func (c *Client) httpClient() *http.Client {
32
+ return &http.Client{Transport: &http.Transport{
33
+ DialContext: func(_ context.Context, _, _ string) (net.Conn, error) {
34
+ return net.Dial("unix", c.Socket)
35
+ },
36
+ }}
37
+ }
38
+
39
+ func (c *Client) do(method, path string, body any) error {
40
+ var rdr *bytes.Reader
41
+ if body != nil {
42
+ b, _ := json.Marshal(body)
43
+ rdr = bytes.NewReader(b)
44
+ } else {
45
+ rdr = bytes.NewReader(nil)
46
+ }
47
+ req, err := http.NewRequest(method, "http://unix"+path, rdr)
48
+ if err != nil {
49
+ return err
50
+ }
51
+ req.Header.Set("Content-Type", "application/json")
52
+ resp, err := c.httpClient().Do(req)
53
+ if err != nil {
54
+ if _, statErr := os.Stat(c.Socket); os.IsNotExist(statErr) {
55
+ return fmt.Errorf("no server at %s — is `relay-flow serve` running?", c.Socket)
56
+ }
57
+ return fmt.Errorf("server call %s %s: %w (is `relay-flow serve` running?)", method, path, err)
58
+ }
59
+ defer resp.Body.Close()
60
+ var env struct {
61
+ OK bool `json:"ok"`
62
+ Err string `json:"error"`
63
+ }
64
+ if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
65
+ return fmt.Errorf("decode server reply: %w", err)
66
+ }
67
+ if resp.StatusCode >= 400 || (!env.OK && env.Err != "") {
68
+ if env.Err != "" {
69
+ return fmt.Errorf("server: %s", env.Err)
70
+ }
71
+ return fmt.Errorf("server: status %d", resp.StatusCode)
72
+ }
73
+ return nil
74
+ }
75
+
76
+ // Submit sends a workflow YAML to the server (wired end-to-end in P6).
77
+ func (c *Client) Submit(repoPath string, yamlBytes []byte) error {
78
+ return c.do("POST", "/submit", map[string]string{
79
+ "repoPath": repoPath, "yaml": string(yamlBytes),
80
+ })
81
+ }
82
+
83
+ // Shutdown asks the server to stop.
84
+ func (c *Client) Shutdown() error {
85
+ return c.do("POST", "/shutdown", nil)
86
+ }
87
+
88
+ // ReportResult is the server's reply to a report call.
89
+ type ReportResult struct {
90
+ Action string `json:"action"` // transitioned | commented | error
91
+ Detail string `json:"detail"`
92
+ }
93
+
94
+ // Report posts an agent outcome to the server, which routes it to the
95
+ // workflow's tasks adapter.
96
+ func (c *Client) Report(workflow, ticket, node, outcome, summary string) (*ReportResult, error) {
97
+ b, _ := json.Marshal(map[string]string{
98
+ "workflow": workflow, "ticket": ticket, "node": node, "outcome": outcome, "summary": summary,
99
+ })
100
+ req, err := http.NewRequest("POST", "http://unix/report", bytes.NewReader(b))
101
+ if err != nil {
102
+ return nil, err
103
+ }
104
+ req.Header.Set("Content-Type", "application/json")
105
+ resp, err := c.httpClient().Do(req)
106
+ if err != nil {
107
+ if _, statErr := os.Stat(c.Socket); os.IsNotExist(statErr) {
108
+ return nil, fmt.Errorf("no server at %s — is `relay-flow serve` running?", c.Socket)
109
+ }
110
+ return nil, fmt.Errorf("server call POST /report: %w", err)
111
+ }
112
+ defer resp.Body.Close()
113
+ var out struct {
114
+ OK bool `json:"ok"`
115
+ Err string `json:"error"`
116
+ Action string `json:"action"`
117
+ Detail string `json:"detail"`
118
+ }
119
+ if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
120
+ return nil, fmt.Errorf("decode server reply: %w", err)
121
+ }
122
+ if resp.StatusCode >= 400 || (!out.OK && out.Err != "") {
123
+ return nil, fmt.Errorf("server: %s", out.Err)
124
+ }
125
+ return &ReportResult{Action: out.Action, Detail: out.Detail}, nil
126
+ }