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