relay-flow 0.0.1 → 0.2.0-alpha

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.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,300 @@
1
+ package server_test
2
+
3
+ import (
4
+ "bytes"
5
+ "encoding/json"
6
+ "io"
7
+ "net/http"
8
+ "testing"
9
+ "time"
10
+
11
+ "github.com/rajpopat27/relay-flow/internal/run"
12
+ "github.com/rajpopat27/relay-flow/internal/task"
13
+ "github.com/rajpopat27/relay-flow/internal/workflow"
14
+ )
15
+
16
+ // 3.35: server API per specs/workflow-repo-management "API responses and
17
+ // status codes are stable". Drives the real http.Handler (server.New) over
18
+ // in-process HTTP with fake services.
19
+
20
+ type envelope struct {
21
+ OK bool `json:"ok"`
22
+ Data json.RawMessage `json:"data"`
23
+ Error *struct {
24
+ Code string `json:"code"`
25
+ Message string `json:"message"`
26
+ } `json:"error"`
27
+ }
28
+
29
+ func do(t *testing.T, c *http.Client, method, url string, body []byte) (int, envelope) {
30
+ t.Helper()
31
+ var rdr io.Reader
32
+ if body != nil {
33
+ rdr = bytes.NewReader(body)
34
+ }
35
+ req, err := http.NewRequest(method, url, rdr)
36
+ if err != nil {
37
+ t.Fatal(err)
38
+ }
39
+ if body != nil {
40
+ req.Header.Set("Content-Type", "application/json")
41
+ }
42
+ resp, err := c.Do(req)
43
+ if err != nil {
44
+ t.Fatalf("%s %s failed: %v", method, url, err)
45
+ }
46
+ defer resp.Body.Close()
47
+ raw, _ := io.ReadAll(resp.Body)
48
+ var env envelope
49
+ if err := json.Unmarshal(raw, &env); err != nil {
50
+ t.Fatalf("response not a JSON envelope: %q", raw)
51
+ }
52
+ return resp.StatusCode, env
53
+ }
54
+
55
+ func TestSuccessEnvelope(t *testing.T) {
56
+ c, cleanup := startHandler(t, &fakeServices{})
57
+ defer cleanup()
58
+ code, env := do(t, c, http.MethodGet, "http://relay/workflows", nil)
59
+ if code != http.StatusOK || !env.OK {
60
+ t.Fatalf("GET /workflows: code=%d env=%+v, want 200 ok", code, env)
61
+ }
62
+ }
63
+
64
+ func TestErrorEnvelopeAndStatusMapping(t *testing.T) {
65
+ c, cleanup := startHandler(t, &fakeServices{})
66
+ defer cleanup()
67
+
68
+ // 400 malformed JSON: drive a JSON endpoint (POST /repos) with malformed
69
+ // input. POST /workflows takes raw YAML by design (docs routes).
70
+ code, env := do(t, c, http.MethodPost, "http://relay/repos", []byte("{not json"))
71
+ if code != http.StatusBadRequest || env.OK || env.Error == nil || env.Error.Code == "" || env.Error.Message == "" {
72
+ t.Fatalf("malformed input: code=%d env=%+v, want 400 + lowerCamel error envelope", code, env)
73
+ }
74
+ if !isLowerCamel(env.Error.Code) {
75
+ t.Fatalf("error code %q not lowerCamel", env.Error.Code)
76
+ }
77
+
78
+ // 404 missing resource.
79
+ code, env = do(t, c, http.MethodGet, "http://relay/workflows/ghost", nil)
80
+ if code != http.StatusNotFound || env.OK || env.Error == nil {
81
+ t.Fatalf("missing resource: code=%d env=%+v, want 404", code, env)
82
+ }
83
+
84
+ // 405 wrong method.
85
+ code, env = do(t, c, http.MethodPatch, "http://relay/workflows", []byte("{}"))
86
+ if code != http.StatusMethodNotAllowed || env.OK {
87
+ t.Fatalf("wrong method: code=%d env=%+v, want 405", code, env)
88
+ }
89
+ }
90
+
91
+ func TestWorkflowConflictMapsTo409(t *testing.T) {
92
+ // basicFlow is seeded with an active run, so replacement conflicts -> 409.
93
+ c, cleanup := startHandler(t, &fakeServices{activeWorkflows: map[string]bool{"basicFlow": true}})
94
+ defer cleanup()
95
+
96
+ yamlBody := "name: basicFlow\nrepos: [payments]\nnodes:\n start: {onSuccess: [{target: end}]}\n end: {}\n"
97
+ // POST /workflows body IS the raw YAML (docs routes; client sends raw).
98
+ code, env := do(t, c, http.MethodPost, "http://relay/workflows", []byte(yamlBody))
99
+ if code != http.StatusConflict {
100
+ t.Fatalf("replacement during active run: code=%d, want 409", code)
101
+ }
102
+ if env.OK || env.Error == nil || !isLowerCamel(env.Error.Code) || env.Error.Message == "" {
103
+ t.Fatalf("409 without standard error envelope: %+v", env)
104
+ }
105
+ }
106
+
107
+ func TestUnexpectedFailureMapsTo500(t *testing.T) {
108
+ c, cleanup := startHandler(t, &fakeServices{failRepos: true})
109
+ defer cleanup()
110
+ code, env := do(t, c, http.MethodGet, "http://relay/repos/any", nil)
111
+ if code != http.StatusInternalServerError || env.OK || env.Error == nil {
112
+ t.Fatalf("unexpected failure: code=%d env=%+v, want 500 + error envelope", code, env)
113
+ }
114
+ }
115
+
116
+ func TestReportEndpointAcceptsJSON(t *testing.T) {
117
+ c, cleanup := startHandler(t, &fakeServices{})
118
+ defer cleanup()
119
+ report := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"session-1:message-1","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
120
+ code, env := do(t, c, http.MethodPost, "http://relay/reports", []byte(report))
121
+ if code != http.StatusOK || !env.OK {
122
+ t.Fatalf("valid report: code=%d env=%+v, want 200 ok", code, env)
123
+ }
124
+ var ack struct {
125
+ Accepted bool `json:"accepted"`
126
+ Duplicate bool `json:"duplicate"`
127
+ }
128
+ if err := json.Unmarshal(env.Data, &ack); err != nil {
129
+ t.Fatalf("report ack data not the documented shape: %v", err)
130
+ }
131
+ }
132
+
133
+ func TestProcessedReportIDBypassesReportBodyValidation(t *testing.T) {
134
+ runID := "payments/basicFlow/PAY-101"
135
+ reportID := "session-1:message-1"
136
+ fake := &fakeServices{processedReports: map[string]bool{runID + ":" + reportID: true}}
137
+ c, cleanup := startHandler(t, fake)
138
+ defer cleanup()
139
+ body := []byte(`{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"session-1:message-1","report":{"unknown":"ignored"}}`)
140
+ code, env := do(t, c, http.MethodPost, "http://relay/reports", body)
141
+ if code != http.StatusOK || !env.OK || !bytes.Contains(env.Data, []byte(`"duplicate":true`)) {
142
+ t.Fatalf("processed report: code=%d env=%+v, want duplicate ack", code, env)
143
+ }
144
+ if fake.submittedReports != 0 {
145
+ t.Fatalf("processed report reached SubmitReport %d times", fake.submittedReports)
146
+ }
147
+ }
148
+
149
+ func TestReportRejectsWrongWireKeyCasing(t *testing.T) {
150
+ c, cleanup := startHandler(t, &fakeServices{})
151
+ defer cleanup()
152
+ // runID/reportID (wrong casing) must be rejected by strict decoding.
153
+ bad := `{"runID":"payments/basicFlow/PAY-101","node":"coding","reportID":"session-1:message-1","report":{"status":"success","nextStep":"end","summary":{"completed":"x","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"x","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
154
+ code, env := do(t, c, http.MethodPost, "http://relay/reports", []byte(bad))
155
+ if code != http.StatusBadRequest || env.OK {
156
+ t.Fatalf("wrong-cased wire keys: code=%d env=%+v, want 400 not ok", code, env)
157
+ }
158
+ }
159
+
160
+ func TestReportMultilineFieldsPreserved(t *testing.T) {
161
+ c, cleanup := startHandler(t, &fakeServices{})
162
+ defer cleanup()
163
+ // Multiline summary/feedback arrive as one JSON object, never as flags.
164
+ report := `{"runId":"payments/basicFlow/PAY-101","node":"coding","reportId":"session-1:message-1","report":{"status":"success","nextStep":"end","summary":{"completed":"line1\nline2\n- bullet","commits":"abc123","notCompleted":"None","issuesDiscovered":"None","verification":"ran ` + "`go test ./...`" + `","notes":"None"},"feedback":{"reasonForNextStep":"None","requiredActions":"None","relevantContext":"None","expectedResult":"None"}}}`
165
+ code, env := do(t, c, http.MethodPost, "http://relay/reports", []byte(report))
166
+ if code != http.StatusOK || !env.OK {
167
+ t.Fatalf("multiline report: code=%d env=%+v, want 200 ok", code, env)
168
+ }
169
+ }
170
+
171
+ func TestRuntimeSessionRegistrationEndpoint(t *testing.T) {
172
+ fake := &fakeServices{runtimeAck: run.NodeRuntimeRegistrationAck{Accepted: true}}
173
+ c, cleanup := startHandler(t, fake)
174
+ defer cleanup()
175
+ body := []byte(`{"runId":"payments/basic/PAY-101","node":"implement","sessionId":"session-1"}`)
176
+ code, env := do(t, c, http.MethodPost, "http://relay/runtime/session", body)
177
+ if code != http.StatusOK || !env.OK {
178
+ t.Fatalf("runtime registration: code=%d env=%+v", code, env)
179
+ }
180
+ if len(fake.registrations) != 1 || fake.registrations[0].SessionID != "session-1" {
181
+ t.Fatalf("registrations = %+v", fake.registrations)
182
+ }
183
+ if !bytes.Contains(env.Data, []byte(`"accepted":true`)) {
184
+ t.Fatalf("accepted ack missing: %s", env.Data)
185
+ }
186
+ }
187
+
188
+ func TestRepoAndRunEndpoints(t *testing.T) {
189
+ c, cleanup := startHandler(t, &fakeServices{})
190
+ defer cleanup()
191
+
192
+ // 200 repo list.
193
+ code, env := do(t, c, http.MethodGet, "http://relay/repos", nil)
194
+ if code != http.StatusOK || !env.OK {
195
+ t.Fatalf("GET /repos: code=%d env=%+v, want 200 ok", code, env)
196
+ }
197
+ // 404 missing repo.
198
+ code, env = do(t, c, http.MethodGet, "http://relay/repos/ghost", nil)
199
+ if code != http.StatusNotFound || env.OK {
200
+ t.Fatalf("GET /repos/ghost: code=%d, want 404", code)
201
+ }
202
+ // 200 run list.
203
+ code, env = do(t, c, http.MethodGet, "http://relay/runs", nil)
204
+ if code != http.StatusOK || !env.OK {
205
+ t.Fatalf("GET /runs: code=%d env=%+v, want 200 ok", code, env)
206
+ }
207
+ // 404 run by ticket.
208
+ code, env = do(t, c, http.MethodGet, "http://relay/runs/by-ticket/NOPE-1", nil)
209
+ if code != http.StatusNotFound || env.OK {
210
+ t.Fatalf("GET /runs/by-ticket/NOPE-1: code=%d, want 404", code)
211
+ }
212
+ }
213
+
214
+ func TestRunEndpointsExposeRetryDetails(t *testing.T) {
215
+ next := time.Now().UTC().Add(time.Minute)
216
+ retrying := run.Run{
217
+ ID: "payments/basicFlow/PAY-101", State: run.StateStarting,
218
+ Ticket: task.TicketRef{Key: "PAY-101"},
219
+ Retry: &run.RetryStatus{Attempt: 2, LastError: "jira unavailable", NextRetryAt: next},
220
+ }
221
+ c, cleanup := startHandler(t, &fakeServices{runs: []run.Run{retrying}})
222
+ defer cleanup()
223
+
224
+ for _, url := range []string{"http://relay/runs", "http://relay/runs/by-ticket/PAY-101"} {
225
+ code, env := do(t, c, http.MethodGet, url, nil)
226
+ if code != http.StatusOK || !env.OK {
227
+ t.Fatalf("GET %s: code=%d env=%+v", url, code, env)
228
+ }
229
+ if !bytes.Contains(env.Data, []byte(`"state":"starting"`)) ||
230
+ !bytes.Contains(env.Data, []byte(`"retry":{"attempt":2,"lastError":"jira unavailable","nextRetryAt":`)) {
231
+ t.Fatalf("GET %s omitted lifecycle/retry details: %s", url, env.Data)
232
+ }
233
+ }
234
+ }
235
+
236
+ func TestRepoOperations(t *testing.T) {
237
+ c, cleanup := startHandler(t, &fakeServices{})
238
+ defer cleanup()
239
+
240
+ // Discover returns candidates (200). Route is GET per docs.
241
+ code, env := do(t, c, http.MethodGet, "http://relay/repos/discover", nil)
242
+ if code != http.StatusOK || !env.OK {
243
+ t.Fatalf("POST /repos/discover: code=%d env=%+v, want 200 ok", code, env)
244
+ }
245
+ // Register a repo (200), then get it (200).
246
+ code, env = do(t, c, http.MethodPost, "http://relay/repos", []byte(`{"name":"payments","path":"/srv/payments"}`))
247
+ if code != http.StatusOK && code != http.StatusCreated {
248
+ t.Fatalf("POST /repos: code=%d env=%+v, want 200/201", code, env)
249
+ }
250
+ code, env = do(t, c, http.MethodGet, "http://relay/repos/payments", nil)
251
+ if code != http.StatusOK || !env.OK {
252
+ t.Fatalf("GET /repos/payments: code=%d env=%+v, want 200 ok", code, env)
253
+ }
254
+ // Remove it (200).
255
+ code, env = do(t, c, http.MethodDelete, "http://relay/repos/payments", nil)
256
+ if code != http.StatusOK || !env.OK {
257
+ t.Fatalf("DELETE /repos/payments: code=%d env=%+v, want 200 ok", code, env)
258
+ }
259
+ }
260
+
261
+ func TestWorkflowGetRemoveAndRunCancel(t *testing.T) {
262
+ fake := &fakeServices{workflows: map[string]*workflow.Workflow{}, activeWorkflows: map[string]bool{}, runs: []run.Run{
263
+ {ID: "payments/basicFlow/PAY-101", State: run.StateRunning, Ticket: task.TicketRef{Key: "PAY-101"}},
264
+ }}
265
+ c, cleanup := startHandler(t, fake)
266
+ defer cleanup()
267
+
268
+ // Submit then GET a workflow (200).
269
+ wfYAML := "name: basicFlow\nrepos: [payments]\nnodes:\n start: {onSuccess: [{target: coding}]}\n coding: {type: agent, agent: build, description: work, onSuccess: [{target: end}], onFailure: [{target: coding}]}\n end: {}\n"
270
+ code, _ := do(t, c, http.MethodPost, "http://relay/workflows", []byte(wfYAML))
271
+ if code != http.StatusOK && code != http.StatusCreated {
272
+ t.Fatalf("POST /workflows: code=%d, want 200/201", code)
273
+ }
274
+ code, env := do(t, c, http.MethodGet, "http://relay/workflows/basicFlow", nil)
275
+ if code != http.StatusOK || !env.OK {
276
+ t.Fatalf("GET /workflows/basicFlow: code=%d env=%+v, want 200 ok", code, env)
277
+ }
278
+ // Cancel the run by ticket (200), then remove the workflow (200).
279
+ // Route is /runs/by-ticket/{key}/cancel per docs.
280
+ code, env = do(t, c, http.MethodPost, "http://relay/runs/by-ticket/PAY-101/cancel", []byte(`{"reason":"done"}`))
281
+ if code != http.StatusOK || !env.OK {
282
+ t.Fatalf("POST /runs/PAY-101/cancel: code=%d env=%+v, want 200 ok", code, env)
283
+ }
284
+ code, env = do(t, c, http.MethodDelete, "http://relay/workflows/basicFlow", nil)
285
+ if code != http.StatusOK || !env.OK {
286
+ t.Fatalf("DELETE /workflows/basicFlow: code=%d env=%+v, want 200 ok", code, env)
287
+ }
288
+ }
289
+
290
+ func isLowerCamel(s string) bool {
291
+ if s == "" || s[0] < 'a' || s[0] > 'z' {
292
+ return false
293
+ }
294
+ for _, r := range s {
295
+ if r == '_' || r == '-' || r == ' ' {
296
+ return false
297
+ }
298
+ }
299
+ return true
300
+ }
@@ -5,122 +5,240 @@ import (
5
5
  "context"
6
6
  "encoding/json"
7
7
  "fmt"
8
+ "io"
8
9
  "net"
9
10
  "net/http"
10
- "os"
11
+ "net/url"
11
12
 
12
- "github.com/rajpopat27/relay-flow/internal/discovery"
13
+ "github.com/rajpopat27/relay-flow/internal/repo"
14
+ "github.com/rajpopat27/relay-flow/internal/run"
15
+ "github.com/rajpopat27/relay-flow/internal/runner"
16
+ "github.com/rajpopat27/relay-flow/internal/workflow"
13
17
  )
14
18
 
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).
19
+ // Client is the typed HTTP-over-Unix-socket client matching the routes in
20
+ // docs/structs-methods-interfaces.md. Commands use it; the CLI never
21
+ // speaks JSON itself.
18
22
  type Client struct {
19
23
  Socket string
20
24
  }
21
25
 
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
- }
26
+ // NewClient returns a Client bound to the given socket path.
27
+ func NewClient(socket string) *Client { return &Client{Socket: socket} }
30
28
 
31
29
  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)
30
+ return &http.Client{
31
+ Transport: &http.Transport{
32
+ DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
33
+ return (&net.Dialer{}).DialContext(ctx, "unix", c.Socket)
34
+ },
35
35
  },
36
- }}
36
+ }
37
37
  }
38
38
 
39
- func (c *Client) do(method, path string, body any) error {
40
- var rdr *bytes.Reader
39
+ // call performs one HTTP request against the socket server and decodes
40
+ // the standard envelope. Non-2xx responses return an error carrying the
41
+ // server-provided code and message.
42
+ func (c *Client) call(ctx context.Context, method, path string, body []byte, out any) error {
43
+ var rdr io.Reader
41
44
  if body != nil {
42
- b, _ := json.Marshal(body)
43
- rdr = bytes.NewReader(b)
44
- } else {
45
- rdr = bytes.NewReader(nil)
45
+ rdr = bytes.NewReader(body)
46
46
  }
47
- req, err := http.NewRequest(method, "http://unix"+path, rdr)
47
+ req, err := http.NewRequestWithContext(ctx, method, "http://unix"+path, rdr)
48
48
  if err != nil {
49
49
  return err
50
50
  }
51
- req.Header.Set("Content-Type", "application/json")
51
+ if body != nil {
52
+ // Default to JSON; raw YAML workflow submits override below.
53
+ ct := "application/json"
54
+ if len(body) > 0 && body[0] != '{' && body[0] != '[' {
55
+ ct = "application/x-yaml"
56
+ }
57
+ req.Header.Set("Content-Type", ct)
58
+ }
52
59
  resp, err := c.httpClient().Do(req)
53
60
  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)
61
+ return fmt.Errorf("server call %s %s: %w", method, path, err)
58
62
  }
59
63
  defer resp.Body.Close()
64
+ raw, _ := io.ReadAll(resp.Body)
60
65
  var env struct {
61
- OK bool `json:"ok"`
62
- Err string `json:"error"`
66
+ OK bool `json:"ok"`
67
+ Data json.RawMessage `json:"data"`
68
+ Error *struct {
69
+ Code string `json:"code"`
70
+ Message string `json:"message"`
71
+ } `json:"error"`
72
+ }
73
+ if err := json.Unmarshal(raw, &env); err != nil {
74
+ return fmt.Errorf("server response not JSON envelope: %w", err)
63
75
  }
64
- if err := json.NewDecoder(resp.Body).Decode(&env); err != nil {
65
- return fmt.Errorf("decode server reply: %w", err)
76
+ if !env.OK {
77
+ if env.Error != nil {
78
+ return fmt.Errorf("server %s: %s", env.Error.Code, env.Error.Message)
79
+ }
80
+ return fmt.Errorf("server returned not-ok (HTTP %d)", resp.StatusCode)
66
81
  }
67
- if resp.StatusCode >= 400 || (!env.OK && env.Err != "") {
68
- if env.Err != "" {
69
- return fmt.Errorf("server: %s", env.Err)
82
+ if out != nil && len(env.Data) > 0 {
83
+ if err := json.Unmarshal(env.Data, out); err != nil {
84
+ return fmt.Errorf("decode response data: %w", err)
70
85
  }
71
- return fmt.Errorf("server: status %d", resp.StatusCode)
72
86
  }
73
87
  return nil
74
88
  }
75
89
 
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
- })
90
+ // Stop asks the server to shut down gracefully.
91
+ func (c *Client) Stop(ctx context.Context) error {
92
+ return c.call(ctx, http.MethodPost, "/stop", nil, nil)
81
93
  }
82
94
 
83
- // Shutdown asks the server to stop.
84
- func (c *Client) Shutdown() error {
85
- return c.do("POST", "/shutdown", nil)
95
+ // SubmitWorkflow creates or replaces a workflow definition from YAML.
96
+ // The request body is the raw YAML document, not a JSON wrapper.
97
+ func (c *Client) SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.Workflow, error) {
98
+ var wf workflow.Workflow
99
+ if err := c.call(ctx, http.MethodPost, "/workflows", yaml, &wf); err != nil {
100
+ return nil, err
101
+ }
102
+ return &wf, nil
86
103
  }
87
104
 
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"`
105
+ // RemoveWorkflow deletes a workflow by name.
106
+ func (c *Client) RemoveWorkflow(ctx context.Context, name string) error {
107
+ return c.call(ctx, http.MethodDelete, "/workflows/"+url.PathEscape(name), nil, nil)
92
108
  }
93
109
 
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 {
110
+ // ListWorkflows returns every registered workflow.
111
+ func (c *Client) ListWorkflows(ctx context.Context) ([]*workflow.Workflow, error) {
112
+ var out []*workflow.Workflow
113
+ if err := c.call(ctx, http.MethodGet, "/workflows", nil, &out); err != nil {
102
114
  return nil, err
103
115
  }
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)
116
+ return out, nil
117
+ }
118
+
119
+ // GetWorkflow returns one workflow by name.
120
+ func (c *Client) GetWorkflow(ctx context.Context, name string) (*workflow.Workflow, error) {
121
+ var wf workflow.Workflow
122
+ if err := c.call(ctx, http.MethodGet, "/workflows/"+url.PathEscape(name), nil, &wf); err != nil {
123
+ return nil, err
111
124
  }
112
- defer resp.Body.Close()
125
+ return &wf, nil
126
+ }
127
+
128
+ // DiscoverRepos returns runner-visible registration candidates.
129
+ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
130
+ var out []runner.RepoCandidate
131
+ if err := c.call(ctx, http.MethodGet, "/repos/discover", nil, &out); err != nil {
132
+ return nil, err
133
+ }
134
+ return out, nil
135
+ }
136
+
137
+ // RepoTaskFields returns the task factory's required repo keys, matching
138
+ // GET /repos/task-fields (server wraps the list as {"fields": [...]}).
139
+ func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
113
140
  var out struct {
114
- OK bool `json:"ok"`
115
- Err string `json:"error"`
116
- Action string `json:"action"`
117
- Detail string `json:"detail"`
141
+ Fields []string `json:"fields"`
142
+ }
143
+ if err := c.call(ctx, http.MethodGet, "/repos/task-fields", nil, &out); err != nil {
144
+ return nil, err
145
+ }
146
+ return out.Fields, nil
147
+ }
148
+
149
+ // RegisterRepo registers a repo by name/path with optional task config.
150
+ func (c *Client) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
151
+ payload, _ := json.Marshal(input)
152
+ var out repo.Info
153
+ if err := c.call(ctx, http.MethodPost, "/repos", payload, &out); err != nil {
154
+ return repo.Info{}, err
155
+ }
156
+ return out, nil
157
+ }
158
+
159
+ // RemoveRepo unregisters a repo by name.
160
+ func (c *Client) RemoveRepo(ctx context.Context, name string) error {
161
+ return c.call(ctx, http.MethodDelete, "/repos/"+url.PathEscape(name), nil, nil)
162
+ }
163
+
164
+ // ListRepos returns registered repo infos.
165
+ func (c *Client) ListRepos(ctx context.Context) ([]repo.Info, error) {
166
+ var out []repo.Info
167
+ if err := c.call(ctx, http.MethodGet, "/repos", nil, &out); err != nil {
168
+ return nil, err
169
+ }
170
+ return out, nil
171
+ }
172
+
173
+ // GetRepo returns one repo by name.
174
+ func (c *Client) GetRepo(ctx context.Context, name string) (repo.Info, error) {
175
+ var out repo.Info
176
+ if err := c.call(ctx, http.MethodGet, "/repos/"+url.PathEscape(name), nil, &out); err != nil {
177
+ return repo.Info{}, err
178
+ }
179
+ return out, nil
180
+ }
181
+
182
+ // SubmitReport delivers one structured node report.
183
+ func (c *Client) SubmitReport(ctx context.Context, report run.ReportRequest) (run.ReportAck, error) {
184
+ payload, err := json.Marshal(report)
185
+ if err != nil {
186
+ return run.ReportAck{}, err
118
187
  }
119
- if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
120
- return nil, fmt.Errorf("decode server reply: %w", err)
188
+ var ack run.ReportAck
189
+ if err := c.call(ctx, http.MethodPost, "/reports", payload, &ack); err != nil {
190
+ return run.ReportAck{}, err
121
191
  }
122
- if resp.StatusCode >= 400 || (!out.OK && out.Err != "") {
123
- return nil, fmt.Errorf("server: %s", out.Err)
192
+ return ack, nil
193
+ }
194
+
195
+ // RegisterNodeSession binds the OpenCode session emitted for a run/node.
196
+ func (c *Client) RegisterNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (run.NodeRuntimeRegistrationAck, error) {
197
+ payload, err := json.Marshal(registration)
198
+ if err != nil {
199
+ return run.NodeRuntimeRegistrationAck{}, err
200
+ }
201
+ var ack run.NodeRuntimeRegistrationAck
202
+ if err := c.call(ctx, http.MethodPost, "/runtime/session", payload, &ack); err != nil {
203
+ return run.NodeRuntimeRegistrationAck{}, err
204
+ }
205
+ return ack, nil
206
+ }
207
+
208
+ // CancelRun cancels the active run for the given ticket with a reason.
209
+ func (c *Client) CancelRun(ctx context.Context, ticket, reason string) error {
210
+ payload, _ := json.Marshal(map[string]string{"reason": reason})
211
+ return c.call(ctx, http.MethodPost, "/runs/by-ticket/"+url.PathEscape(ticket)+"/cancel", payload, nil)
212
+ }
213
+
214
+ // ListRuns returns runs matching the filter.
215
+ func (c *Client) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error) {
216
+ q := url.Values{}
217
+ if filter.Repo != "" {
218
+ q.Set("repo", filter.Repo)
219
+ }
220
+ if filter.Workflow != "" {
221
+ q.Set("workflow", filter.Workflow)
222
+ }
223
+ if filter.Ticket != "" {
224
+ q.Set("ticket", filter.Ticket)
225
+ }
226
+ path := "/runs"
227
+ if s := q.Encode(); s != "" {
228
+ path += "?" + s
229
+ }
230
+ var out []run.Run
231
+ if err := c.call(ctx, http.MethodGet, path, nil, &out); err != nil {
232
+ return nil, err
233
+ }
234
+ return out, nil
235
+ }
236
+
237
+ // GetRunByTicket returns the run for a ticket.
238
+ func (c *Client) GetRunByTicket(ctx context.Context, ticket string) (run.Run, error) {
239
+ var out run.Run
240
+ if err := c.call(ctx, http.MethodGet, "/runs/by-ticket/"+url.PathEscape(ticket), nil, &out); err != nil {
241
+ return run.Run{}, err
124
242
  }
125
- return &ReportResult{Action: out.Action, Detail: out.Detail}, nil
243
+ return out, nil
126
244
  }