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,342 @@
|
|
|
1
|
+
// Package server runs the central relay-flow process: one long-lived `serve`
|
|
2
|
+
// command hosting any number of submitted workflows, each polling in its
|
|
3
|
+
// own goroutine. Workflows arrive via `submit`, agent outcomes arrive via
|
|
4
|
+
// `report` — both over a unix socket. The tracker remains the only
|
|
5
|
+
// cross-process state.
|
|
6
|
+
package server
|
|
7
|
+
|
|
8
|
+
import (
|
|
9
|
+
"context"
|
|
10
|
+
"encoding/json"
|
|
11
|
+
"fmt"
|
|
12
|
+
"log"
|
|
13
|
+
"net"
|
|
14
|
+
"net/http"
|
|
15
|
+
"strings"
|
|
16
|
+
"sync"
|
|
17
|
+
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
19
|
+
"github.com/rajpopat27/relay-flow/internal/daemon"
|
|
20
|
+
"github.com/rajpopat27/relay-flow/internal/discovery"
|
|
21
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
22
|
+
"github.com/rajpopat27/relay-flow/internal/acli"
|
|
23
|
+
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
24
|
+
"github.com/rajpopat27/relay-flow/internal/tasks/jira"
|
|
25
|
+
|
|
26
|
+
_ "github.com/rajpopat27/relay-flow/internal/runner/orca" // built-in adapters self-register
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
// Deps injects side-effecting operations so tests never call orca/acli or
|
|
30
|
+
// spawn real poll loops.
|
|
31
|
+
type Deps struct {
|
|
32
|
+
// ResolveRepo maps a repo path to (repoID, displayName).
|
|
33
|
+
ResolveRepo func(path string) (string, string, error)
|
|
34
|
+
// ValidateConfig probe-validates adapter-visible names (tracker
|
|
35
|
+
// states, assignee) in the YAML; returns invalid names.
|
|
36
|
+
ValidateConfig func(yamlBytes []byte) ([]string, error)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ProdDeps wires Deps to the real implementations.
|
|
40
|
+
func ProdDeps(dryRun bool) Deps {
|
|
41
|
+
return Deps{
|
|
42
|
+
ResolveRepo: discovery.RepoFromPath,
|
|
43
|
+
ValidateConfig: validateConfigProd,
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
type entry struct {
|
|
48
|
+
cfg *config.Config
|
|
49
|
+
tk tasks.Tasks
|
|
50
|
+
d *daemon.Daemon
|
|
51
|
+
cancel context.CancelFunc
|
|
52
|
+
repoID string
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
type Server struct {
|
|
56
|
+
mu sync.Mutex
|
|
57
|
+
entries map[string]*entry
|
|
58
|
+
deps Deps
|
|
59
|
+
dryRun bool
|
|
60
|
+
|
|
61
|
+
ln net.Listener
|
|
62
|
+
closed chan struct{}
|
|
63
|
+
shutdownOnce sync.Once
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
func New(dryRun bool, deps Deps) *Server {
|
|
67
|
+
return &Server{
|
|
68
|
+
entries: map[string]*entry{},
|
|
69
|
+
deps: deps,
|
|
70
|
+
dryRun: dryRun,
|
|
71
|
+
closed: make(chan struct{}),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
func (s *Server) handler() http.Handler {
|
|
76
|
+
mux := http.NewServeMux()
|
|
77
|
+
mux.HandleFunc("/submit", methodGuard("POST", s.handleSubmit))
|
|
78
|
+
mux.HandleFunc("/report", methodGuard("POST", s.handleReport))
|
|
79
|
+
mux.HandleFunc("/shutdown", methodGuard("POST", s.handleShutdown))
|
|
80
|
+
return mux
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Serve accepts HTTP on ln (a unix socket) until Shutdown. Blocks.
|
|
84
|
+
func (s *Server) Serve(ln net.Listener) error {
|
|
85
|
+
s.ln = ln
|
|
86
|
+
err := (&http.Server{Handler: s.handler()}).Serve(ln)
|
|
87
|
+
select {
|
|
88
|
+
case <-s.closed:
|
|
89
|
+
return nil
|
|
90
|
+
default:
|
|
91
|
+
}
|
|
92
|
+
return err
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Shutdown stops the HTTP listener and every workflow's poll loop.
|
|
96
|
+
// Idempotent: the /shutdown handler and process signal handlers may both
|
|
97
|
+
// invoke it.
|
|
98
|
+
func (s *Server) Shutdown() {
|
|
99
|
+
s.shutdownOnce.Do(func() {
|
|
100
|
+
close(s.closed)
|
|
101
|
+
if s.ln != nil {
|
|
102
|
+
s.ln.Close()
|
|
103
|
+
}
|
|
104
|
+
s.mu.Lock()
|
|
105
|
+
defer s.mu.Unlock()
|
|
106
|
+
for name, e := range s.entries {
|
|
107
|
+
e.cancel()
|
|
108
|
+
delete(s.entries, name)
|
|
109
|
+
}
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
type submitRequest struct {
|
|
114
|
+
RepoPath string `json:"repoPath"`
|
|
115
|
+
YAML string `json:"yaml"`
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
|
|
119
|
+
var req submitRequest
|
|
120
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.YAML == "" || req.RepoPath == "" {
|
|
121
|
+
writeError(w, 400, "submit requires {repoPath, yaml}")
|
|
122
|
+
return
|
|
123
|
+
}
|
|
124
|
+
// 1. YAML must parse and validate structurally. The workflow's
|
|
125
|
+
// identity is the `name` field inside the YAML.
|
|
126
|
+
cfg, err := config.Parse("submit", []byte(req.YAML))
|
|
127
|
+
if err != nil {
|
|
128
|
+
writeError(w, 400, "invalid config: %v", err)
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
// 2. Name must be free: two workflows with the same name would share
|
|
132
|
+
// claim labels and double-dispatch tickets.
|
|
133
|
+
s.mu.Lock()
|
|
134
|
+
_, dup := s.entries[cfg.Name]
|
|
135
|
+
s.mu.Unlock()
|
|
136
|
+
if dup {
|
|
137
|
+
writeError(w, 409, "workflow %q already running; stop serve and resubmit to update", cfg.Name)
|
|
138
|
+
return
|
|
139
|
+
}
|
|
140
|
+
// 3. Repo must resolve (submitted from a directory inside the repo).
|
|
141
|
+
repoID, repoName, err := s.deps.ResolveRepo(req.RepoPath)
|
|
142
|
+
if err != nil {
|
|
143
|
+
writeError(w, 400, "resolve repo %s: %v", req.RepoPath, err)
|
|
144
|
+
return
|
|
145
|
+
}
|
|
146
|
+
// 4. Tracker-visible names (states, assignee) must probe-validate.
|
|
147
|
+
if bad, err := s.deps.ValidateConfig([]byte(req.YAML)); err != nil {
|
|
148
|
+
writeError(w, 400, "config validation: %v", err)
|
|
149
|
+
return
|
|
150
|
+
} else if len(bad) > 0 {
|
|
151
|
+
writeError(w, 400, "invalid tracker names: %v", bad)
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
// 5. Build adapters + daemon and start the poll loop. Stateless:
|
|
155
|
+
// restart means resubmit.
|
|
156
|
+
e, err := s.buildEntry(cfg, repoID, repoName)
|
|
157
|
+
if err != nil {
|
|
158
|
+
writeError(w, 400, "start workflow: %v", err)
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
s.mu.Lock()
|
|
162
|
+
s.entries[cfg.Name] = e
|
|
163
|
+
s.mu.Unlock()
|
|
164
|
+
log.Printf("submit %s: started (repo=%s)", cfg.Name, repoID)
|
|
165
|
+
writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// buildEntry wires one workflow: tasks adapter → runner adapter → daemon
|
|
169
|
+
// + poll goroutine.
|
|
170
|
+
func (s *Server) buildEntry(cfg *config.Config, repoID, repoName string) (*entry, error) {
|
|
171
|
+
tk, err := buildTasks(cfg, repoName)
|
|
172
|
+
if err != nil {
|
|
173
|
+
return nil, err
|
|
174
|
+
}
|
|
175
|
+
rn, err := runner.New(cfg.Runner.Type, cfg.Runner.Config)
|
|
176
|
+
if err != nil {
|
|
177
|
+
return nil, err
|
|
178
|
+
}
|
|
179
|
+
if wr, ok := rn.(interface{ WithRepo(string, string, bool) }); ok {
|
|
180
|
+
wr.WithRepo(repoID, repoName, s.dryRun)
|
|
181
|
+
}
|
|
182
|
+
d := daemon.New(cfg, tk, rn, repoID, repoName, s.dryRun)
|
|
183
|
+
ctx, cancel := context.WithCancel(context.Background())
|
|
184
|
+
go d.PollLoop(ctx)
|
|
185
|
+
return &entry{cfg: cfg, tk: tk, d: d, cancel: cancel, repoID: repoID}, nil
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// buildTasks constructs the tasks adapter, injecting the machine-config
|
|
189
|
+
// assignee for jira in distributed mode (centralized assigneeIsAgent
|
|
190
|
+
// skips it). Adapter-specific because only jira consumes an assignee.
|
|
191
|
+
func buildTasks(cfg *config.Config, repoName string) (tasks.Tasks, error) {
|
|
192
|
+
assignee := ""
|
|
193
|
+
if cfg.Tasks.Type == "jira" {
|
|
194
|
+
jc, err := jira.UnmarshalConfigForValidation(cfg.Tasks.Config)
|
|
195
|
+
if err != nil {
|
|
196
|
+
return nil, err
|
|
197
|
+
}
|
|
198
|
+
if !jc.AssigneeIsAgent {
|
|
199
|
+
mc, err := config.LoadMachineConfig()
|
|
200
|
+
if err != nil {
|
|
201
|
+
return nil, err
|
|
202
|
+
}
|
|
203
|
+
assignee = mc.Assignee
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return tasks.New(cfg.Tasks.Type, cfg.Tasks.Config, cfg.Name, cfg.Nodes, assignee, repoName)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// handleShutdown replies first, then stops the server (listener + every
|
|
210
|
+
// workflow's poll loop). Process exit releases the flock.
|
|
211
|
+
func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
|
|
212
|
+
writeJSON(w, 200, map[string]any{"ok": true})
|
|
213
|
+
log.Printf("shutdown requested via socket")
|
|
214
|
+
go s.Shutdown()
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
type reportRequest struct {
|
|
218
|
+
Workflow string `json:"workflow"`
|
|
219
|
+
Ticket string `json:"ticket"`
|
|
220
|
+
Node string `json:"node"`
|
|
221
|
+
Outcome string `json:"outcome"`
|
|
222
|
+
Summary string `json:"summary"`
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
func (s *Server) handleReport(w http.ResponseWriter, r *http.Request) {
|
|
226
|
+
var req reportRequest
|
|
227
|
+
if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
|
|
228
|
+
req.Workflow == "" || req.Ticket == "" || req.Node == "" || req.Outcome == "" || req.Summary == "" {
|
|
229
|
+
writeError(w, 400, "report requires {workflow, ticket, node, outcome, summary}")
|
|
230
|
+
return
|
|
231
|
+
}
|
|
232
|
+
if req.Outcome != "success" && req.Outcome != "failure" {
|
|
233
|
+
writeError(w, 400, "outcome must be success or failure, got %q", req.Outcome)
|
|
234
|
+
return
|
|
235
|
+
}
|
|
236
|
+
s.mu.Lock()
|
|
237
|
+
e, ok := s.entries[req.Workflow]
|
|
238
|
+
s.mu.Unlock()
|
|
239
|
+
if !ok {
|
|
240
|
+
writeError(w, 404, "no running workflow %q", req.Workflow)
|
|
241
|
+
return
|
|
242
|
+
}
|
|
243
|
+
node, ok := e.cfg.Nodes[req.Node]
|
|
244
|
+
if !ok {
|
|
245
|
+
writeError(w, 400, "workflow %q has no node %q", req.Workflow, req.Node)
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
target := node.OnSuccess
|
|
249
|
+
if req.Outcome == "failure" {
|
|
250
|
+
target = node.OnFailure
|
|
251
|
+
}
|
|
252
|
+
tk := tasks.Ticket{Key: req.Ticket, Node: req.Node, ClaimedBy: req.Workflow}
|
|
253
|
+
if err := e.tk.Report(tk, req.Outcome, target, req.Summary); err != nil {
|
|
254
|
+
log.Printf("report %s/%s: %v", req.Workflow, req.Ticket, err)
|
|
255
|
+
writeJSON(w, 200, map[string]any{"ok": true, "action": "error", "detail": err.Error()})
|
|
256
|
+
return
|
|
257
|
+
}
|
|
258
|
+
// Report moved the ticket: re-arm the bounce nudge marker for the
|
|
259
|
+
// next node visit.
|
|
260
|
+
e.d.ClearNudged(req.Ticket)
|
|
261
|
+
action := "transitioned"
|
|
262
|
+
if e.cfg.Nodes[target].When != "" && stringsEqualFoldNode(e.cfg, req.Node, target) {
|
|
263
|
+
action = "commented"
|
|
264
|
+
}
|
|
265
|
+
log.Printf("report %s/%s: node=%s outcome=%s → %s (%s)", req.Workflow, req.Ticket, req.Node, req.Outcome, target, action)
|
|
266
|
+
writeJSON(w, 200, map[string]any{"ok": true, "action": action, "detail": target})
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// stringsEqualFoldNode reports whether two nodes share the same tracker
|
|
270
|
+
// state (self-loop: comment only, no transition).
|
|
271
|
+
func stringsEqualFoldNode(cfg *config.Config, a, b string) bool {
|
|
272
|
+
wa, wb := cfg.Nodes[a].When, cfg.Nodes[b].When
|
|
273
|
+
if wa == "" || wb == "" {
|
|
274
|
+
return false
|
|
275
|
+
}
|
|
276
|
+
return strings.EqualFold(wa, wb)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
func methodGuard(method string, h http.HandlerFunc) http.HandlerFunc {
|
|
280
|
+
return func(w http.ResponseWriter, r *http.Request) {
|
|
281
|
+
if r.Method != method {
|
|
282
|
+
writeError(w, 405, "method %s not allowed, use %s", r.Method, method)
|
|
283
|
+
return
|
|
284
|
+
}
|
|
285
|
+
h(w, r)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
290
|
+
w.Header().Set("Content-Type", "application/json")
|
|
291
|
+
w.WriteHeader(code)
|
|
292
|
+
json.NewEncoder(w).Encode(v)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
func writeError(w http.ResponseWriter, code int, format string, args ...any) {
|
|
296
|
+
writeJSON(w, code, map[string]any{"ok": false, "error": fmt.Sprintf(format, args...)})
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
// validateConfigProd probe-validates tracker-visible names at submit:
|
|
301
|
+
// every node's `when` status against the project (jira), plus the machine
|
|
302
|
+
// assignee when in distributed mode.
|
|
303
|
+
func validateConfigProd(yamlBytes []byte) ([]string, error) {
|
|
304
|
+
cfg, err := config.Parse("submit", yamlBytes)
|
|
305
|
+
if err != nil {
|
|
306
|
+
return nil, err
|
|
307
|
+
}
|
|
308
|
+
if cfg.Tasks.Type != "jira" {
|
|
309
|
+
return nil, nil // only the jira adapter has probeable states today
|
|
310
|
+
}
|
|
311
|
+
jc, err := jiraConfigOf(cfg)
|
|
312
|
+
if err != nil {
|
|
313
|
+
return nil, err
|
|
314
|
+
}
|
|
315
|
+
projectKey, err := jira.ProjectKeyFromQuery(jc.Query)
|
|
316
|
+
if err != nil {
|
|
317
|
+
return nil, err
|
|
318
|
+
}
|
|
319
|
+
ac := acli.New()
|
|
320
|
+
bad, err := jira.ValidateStates(ac, cfg.Nodes, projectKey)
|
|
321
|
+
if err != nil {
|
|
322
|
+
return nil, err
|
|
323
|
+
}
|
|
324
|
+
if !jc.AssigneeIsAgent {
|
|
325
|
+
mc, err := config.LoadMachineConfig()
|
|
326
|
+
if err != nil {
|
|
327
|
+
return nil, err
|
|
328
|
+
}
|
|
329
|
+
if err := ac.ValidateAssignee(mc.Assignee); err != nil {
|
|
330
|
+
bad = append(bad, "assignee: "+mc.Assignee)
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
return bad, nil
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func jiraConfigOf(cfg *config.Config) (jira.JiraConfig, error) {
|
|
337
|
+
jcAny, err := jira.UnmarshalConfigForValidation(cfg.Tasks.Config)
|
|
338
|
+
if err != nil {
|
|
339
|
+
return jira.JiraConfig{}, err
|
|
340
|
+
}
|
|
341
|
+
return jcAny, nil
|
|
342
|
+
}
|
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
package server
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"encoding/json"
|
|
5
|
+
"fmt"
|
|
6
|
+
"net"
|
|
7
|
+
"net/http/httptest"
|
|
8
|
+
"strings"
|
|
9
|
+
"testing"
|
|
10
|
+
|
|
11
|
+
"github.com/rajpopat27/relay-flow/internal/config"
|
|
12
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
13
|
+
"github.com/rajpopat27/relay-flow/internal/tasks"
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
const goodYAML = `
|
|
17
|
+
name: testFlow
|
|
18
|
+
tasks:
|
|
19
|
+
type: faketasks
|
|
20
|
+
config: {}
|
|
21
|
+
runner:
|
|
22
|
+
type: fakerunner
|
|
23
|
+
closeOn: [done]
|
|
24
|
+
nodes:
|
|
25
|
+
coding:
|
|
26
|
+
agent: build
|
|
27
|
+
when: "In Progress"
|
|
28
|
+
onSuccess: done
|
|
29
|
+
onFailure: coding
|
|
30
|
+
done:
|
|
31
|
+
when: "Done"
|
|
32
|
+
`
|
|
33
|
+
|
|
34
|
+
// registerFakes installs test adapters (unique names per process via init
|
|
35
|
+
// in server package tests would collide across files — register once here).
|
|
36
|
+
var fakesOnce = registerFakes()
|
|
37
|
+
|
|
38
|
+
var (
|
|
39
|
+
lastFakeTasks *fakeTasks
|
|
40
|
+
lastFakeRunner *fakeRunner
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
func registerFakes() bool {
|
|
44
|
+
tasks.Register("faketasks", tasks.Factory{
|
|
45
|
+
UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
|
|
46
|
+
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
47
|
+
lastFakeTasks = &fakeTasks{}
|
|
48
|
+
return lastFakeTasks, nil
|
|
49
|
+
},
|
|
50
|
+
})
|
|
51
|
+
runner.Register("fakerunner", runner.Factory{
|
|
52
|
+
UnmarshalConfig: func(m map[string]any) (any, error) { return m, nil },
|
|
53
|
+
New: func(cfg any) (runner.Runner, error) {
|
|
54
|
+
lastFakeRunner = &fakeRunner{}
|
|
55
|
+
return lastFakeRunner, nil
|
|
56
|
+
},
|
|
57
|
+
})
|
|
58
|
+
return true
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type fakeTasks struct{ reports []string }
|
|
62
|
+
|
|
63
|
+
func (f *fakeTasks) List() ([]tasks.Ticket, error) { return nil, nil }
|
|
64
|
+
func (f *fakeTasks) Claim(t tasks.Ticket) error { return nil }
|
|
65
|
+
func (f *fakeTasks) Report(t tasks.Ticket, outcome, targetNode, summary string) error {
|
|
66
|
+
f.reports = append(f.reports, fmt.Sprintf("%s:%s:%s:%s", t.Key, outcome, targetNode, summary))
|
|
67
|
+
return nil
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
type fakeRunner struct{}
|
|
71
|
+
|
|
72
|
+
func (f *fakeRunner) Spawn(t tasks.Ticket, node, agent, prompt string, env map[string]string) error {
|
|
73
|
+
return nil
|
|
74
|
+
}
|
|
75
|
+
func (f *fakeRunner) Find(t tasks.Ticket, node string) (runner.Session, bool, error) {
|
|
76
|
+
return runner.Session{}, false, nil
|
|
77
|
+
}
|
|
78
|
+
func (f *fakeRunner) Nudge(s runner.Session, prompt string) error { return nil }
|
|
79
|
+
func (f *fakeRunner) Close(t tasks.Ticket) error { return nil }
|
|
80
|
+
|
|
81
|
+
func testServer(t *testing.T) *Server {
|
|
82
|
+
t.Helper()
|
|
83
|
+
_ = fakesOnce
|
|
84
|
+
s := New(true, Deps{
|
|
85
|
+
ResolveRepo: func(path string) (string, string, error) { return "repo-1", "repo:xyz", nil },
|
|
86
|
+
ValidateConfig: func(y []byte) ([]string, error) { return nil, nil },
|
|
87
|
+
})
|
|
88
|
+
return s
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
func post(t *testing.T, s *Server, path string, body string) (int, map[string]any) {
|
|
92
|
+
t.Helper()
|
|
93
|
+
req := httptest.NewRequest("POST", path, strings.NewReader(body))
|
|
94
|
+
rec := httptest.NewRecorder()
|
|
95
|
+
s.handler().ServeHTTP(rec, req)
|
|
96
|
+
var out map[string]any
|
|
97
|
+
json.NewDecoder(rec.Body).Decode(&out)
|
|
98
|
+
return rec.Code, out
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
func TestSubmitStartsWorkflow(t *testing.T) {
|
|
102
|
+
s := testServer(t)
|
|
103
|
+
code, out := post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
104
|
+
if code != 200 || out["ok"] != true {
|
|
105
|
+
t.Fatalf("code=%d out=%v", code, out)
|
|
106
|
+
}
|
|
107
|
+
if s.entries["testFlow"] == nil {
|
|
108
|
+
t.Fatal("entry not registered")
|
|
109
|
+
}
|
|
110
|
+
if lastFakeTasks == nil || lastFakeRunner == nil {
|
|
111
|
+
t.Fatal("adapters not constructed")
|
|
112
|
+
}
|
|
113
|
+
// Duplicate name → 409.
|
|
114
|
+
code, _ = post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
115
|
+
if code != 409 {
|
|
116
|
+
t.Errorf("dup submit code=%d, want 409", code)
|
|
117
|
+
}
|
|
118
|
+
s.Shutdown()
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
func TestSubmitRejectsBadYAML(t *testing.T) {
|
|
122
|
+
s := testServer(t)
|
|
123
|
+
defer s.Shutdown()
|
|
124
|
+
code, _ := post(t, s, "/submit", `{"repoPath":"/x","yaml":"name: \"\""}`)
|
|
125
|
+
if code != 400 {
|
|
126
|
+
t.Errorf("code=%d", code)
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
func TestSubmitRejectsInvalidStatuses(t *testing.T) {
|
|
131
|
+
s := New(true, Deps{
|
|
132
|
+
ResolveRepo: func(path string) (string, string, error) { return "r", "n", nil },
|
|
133
|
+
ValidateConfig: func(y []byte) ([]string, error) { return []string{"DO Done"}, nil },
|
|
134
|
+
})
|
|
135
|
+
defer s.Shutdown()
|
|
136
|
+
code, out := post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
137
|
+
if code != 400 || !strings.Contains(fmt.Sprint(out["error"]), "DO Done") {
|
|
138
|
+
t.Errorf("code=%d out=%v", code, out)
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
func TestReportSuccessTransitions(t *testing.T) {
|
|
143
|
+
s := testServer(t)
|
|
144
|
+
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
145
|
+
code, out := post(t, s, "/report",
|
|
146
|
+
`{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"success","summary":"did it"}`)
|
|
147
|
+
s.Shutdown()
|
|
148
|
+
if code != 200 || out["action"] != "transitioned" {
|
|
149
|
+
t.Fatalf("code=%d out=%v", code, out)
|
|
150
|
+
}
|
|
151
|
+
if len(lastFakeTasks.reports) != 1 || lastFakeTasks.reports[0] != "XYZ-1:success:done:did it" {
|
|
152
|
+
t.Errorf("reports = %v", lastFakeTasks.reports)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
func TestReportSelfLoopActionCommented(t *testing.T) {
|
|
157
|
+
s := testServer(t)
|
|
158
|
+
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
159
|
+
code, out := post(t, s, "/report",
|
|
160
|
+
`{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"failure","summary":"broke"}`)
|
|
161
|
+
s.Shutdown()
|
|
162
|
+
if code != 200 || out["action"] != "commented" {
|
|
163
|
+
t.Fatalf("self-loop must be commented: code=%d out=%v", code, out)
|
|
164
|
+
}
|
|
165
|
+
if lastFakeTasks.reports[0] != "XYZ-1:failure:coding:broke" {
|
|
166
|
+
t.Errorf("reports = %v", lastFakeTasks.reports)
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
func TestReportValidation(t *testing.T) {
|
|
171
|
+
s := testServer(t)
|
|
172
|
+
post(t, s, "/submit", `{"repoPath":"/x","yaml":`+jsonStr(goodYAML)+`}`)
|
|
173
|
+
defer s.Shutdown()
|
|
174
|
+
cases := []struct{ name, body string }{
|
|
175
|
+
{"bad outcome", `{"workflow":"testFlow","ticket":"XYZ-1","node":"coding","outcome":"done","summary":"x"}`},
|
|
176
|
+
{"unknown node", `{"workflow":"testFlow","ticket":"XYZ-1","node":"nope","outcome":"success","summary":"x"}`},
|
|
177
|
+
{"unknown workflow", `{"workflow":"nope","ticket":"XYZ-1","node":"coding","outcome":"success","summary":"x"}`},
|
|
178
|
+
{"missing fields", `{"workflow":"testFlow"}`},
|
|
179
|
+
}
|
|
180
|
+
for _, tc := range cases {
|
|
181
|
+
t.Run(tc.name, func(t *testing.T) {
|
|
182
|
+
code, _ := post(t, s, "/report", tc.body)
|
|
183
|
+
if code != 400 && code != 404 {
|
|
184
|
+
t.Errorf("code=%d", code)
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func jsonStr(s string) string {
|
|
191
|
+
b, _ := json.Marshal(s)
|
|
192
|
+
return string(b)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
var _ = net.Dial // keep net import if unused later
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# tasks/jira — Jira adapter
|
|
2
|
+
|
|
3
|
+
Implements `tasks.Tasks` over the [acli](https://github.com/acli) CLI. Jira
|
|
4
|
+
statuses are node states (`when`); claim labels `wf:<workflow>` are Jira
|
|
5
|
+
labels.
|
|
6
|
+
|
|
7
|
+
## Config (tasks.config)
|
|
8
|
+
|
|
9
|
+
| Field | Required | Meaning |
|
|
10
|
+
|---|---|---|
|
|
11
|
+
| `query` | yes | JQL fragment, e.g. `project = ABCD`. Must not contain `issuetype`, `assignee`, or `ORDER BY` — the adapter appends those. |
|
|
12
|
+
| `issueTypes` | yes | Scalar or list, e.g. `[Task, Story]`. Rendered as `AND issuetype IN (...)`. |
|
|
13
|
+
| `assigneeIsAgent` | no | Centralized mode: no assignee clause (org server owns the queue). Default false → `AND assignee = "<machine config>"` is appended. |
|
|
14
|
+
|
|
15
|
+
Built JQL: `(query) AND issuetype IN (...) AND component = "<repo displayName>" [AND assignee = "..."] ORDER BY updated`.
|
|
16
|
+
The repo displayName (resolved by the server from the submitter's cwd) scopes
|
|
17
|
+
the poll to one repo's tickets.
|
|
18
|
+
|
|
19
|
+
## Behavior
|
|
20
|
+
|
|
21
|
+
- **List** — one search per poll; maps each ticket's status → node via `when`
|
|
22
|
+
(unmapped → `Node: ""`, daemon skips); first `wf:*` label → `ClaimedBy`.
|
|
23
|
+
- **Claim** — adds `wf:<name>` label (labels are never removed; they're the
|
|
24
|
+
cross-restart mutex).
|
|
25
|
+
- **Report** — transitions to the target node's `when` status + posts the
|
|
26
|
+
summary comment (`[wf] KEY (agent: x, node: y) reported outcome → target`).
|
|
27
|
+
Self-loop (target status == current) → comment only; acli FAILURE envelopes
|
|
28
|
+
(exit-0 failures) are detected and returned as errors.
|
|
29
|
+
|
|
30
|
+
## Submit-time validation
|
|
31
|
+
|
|
32
|
+
`ProjectKeyFromQuery` + `ValidateStates` probe every `when` status against the
|
|
33
|
+
project — Jira's JQL parser rejects unknown statuses, so typos fail at submit
|
|
34
|
+
instead of silently matching nothing.
|
|
35
|
+
|
|
36
|
+
## Writing a new tasks adapter (beads, Linear, GitHub, ...)
|
|
37
|
+
|
|
38
|
+
1. Create `internal/tasks/<name>/` with:
|
|
39
|
+
```go
|
|
40
|
+
func init() {
|
|
41
|
+
tasks.Register("<name>", tasks.Factory{
|
|
42
|
+
UnmarshalConfig: unmarshalConfig, // strict-decode tasks.config into your struct
|
|
43
|
+
New: func(cfg any, wfName string, nodes map[string]config.Node, assignee, repoName string) (tasks.Tasks, error) {
|
|
44
|
+
// build your adapter; use nodes[..].When as the tracker-state map,
|
|
45
|
+
// wfName for claim labels, assignee/repoName if your tracker scopes by them
|
|
46
|
+
},
|
|
47
|
+
})
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
2. Implement the interface:
|
|
51
|
+
```go
|
|
52
|
+
type Tasks interface {
|
|
53
|
+
List() ([]tasks.Ticket, error) // Node + ClaimedBy filled
|
|
54
|
+
Claim(t tasks.Ticket) error // attach wf:<name> marker
|
|
55
|
+
Report(t tasks.Ticket, outcome, targetNode, summary string) error
|
|
56
|
+
}
|
|
57
|
+
```
|
|
58
|
+
`Ticket{Key, Summary, Node, ClaimedBy}` — you fill `Node` by reverse-mapping
|
|
59
|
+
the ticket's tracker state through `nodes[*].When`, and `ClaimedBy` from
|
|
60
|
+
your tracker's equivalent of claim markers (labels, assignments, ...).
|
|
61
|
+
3. Strictly validate your `tasks.config` in `UnmarshalConfig` (unknown fields
|
|
62
|
+
must error — core can't see inside).
|
|
63
|
+
4. Import your package for side effects (`_ "relay-flow/internal/tasks/<name>"`)
|
|
64
|
+
in `internal/server/server.go`, next to the jira import.
|
|
65
|
+
5. Table-driven tests with a fake for your tracker's client seam (see
|
|
66
|
+
`jira_test.go` and the `aclier` interface for the pattern).
|
|
67
|
+
|
|
68
|
+
External adapters (out of tree): fork the repo, add your package + import —
|
|
69
|
+
the registry is deliberately in-process (no .so loading, YAGNI).
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"strings"
|
|
5
|
+
"testing"
|
|
6
|
+
)
|
|
7
|
+
|
|
8
|
+
func TestJQLIncludesComponent(t *testing.T) {
|
|
9
|
+
j, err := newJira(JiraConfig{Query: "project = XYZ", IssueTypes: []string{"Task"}}, "wf", testNodes, "Jane Doe", "xyz-repo", nil)
|
|
10
|
+
if err != nil {
|
|
11
|
+
t.Fatalf("%v", err)
|
|
12
|
+
}
|
|
13
|
+
if !strings.Contains(j.jql, `component = "xyz-repo"`) {
|
|
14
|
+
t.Errorf("JQL missing component filter: %q", j.jql)
|
|
15
|
+
}
|
|
16
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
package jira
|
|
2
|
+
|
|
3
|
+
import (
|
|
4
|
+
"bytes"
|
|
5
|
+
"fmt"
|
|
6
|
+
|
|
7
|
+
"gopkg.in/yaml.v3"
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
// strictDecode re-marshals the opaque config map and decodes it into v
|
|
11
|
+
// with KnownFields(true), so unknown YAML keys in tasks.config are
|
|
12
|
+
// rejected just like top-level ones.
|
|
13
|
+
func strictDecode(m map[string]any, v any) error {
|
|
14
|
+
if m == nil {
|
|
15
|
+
m = map[string]any{}
|
|
16
|
+
}
|
|
17
|
+
b, err := yaml.Marshal(m)
|
|
18
|
+
if err != nil {
|
|
19
|
+
return fmt.Errorf("re-marshal config: %w", err)
|
|
20
|
+
}
|
|
21
|
+
dec := yaml.NewDecoder(bytes.NewReader(b))
|
|
22
|
+
dec.KnownFields(true)
|
|
23
|
+
return dec.Decode(v)
|
|
24
|
+
}
|