relay-flow 0.1.0-alpha.0
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 +23 -0
|
@@ -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
|
+
}
|