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.
- package/README.md +148 -143
- package/cmd/relay-flow/commands_test.go +464 -0
- package/cmd/relay-flow/main.go +670 -180
- package/cmd/relay-flow/scenario_test.go +1135 -0
- package/cmd/relay-flow/serve.go +609 -0
- package/go.mod +69 -2
- package/go.sum +185 -0
- package/internal/config/config.go +88 -0
- package/internal/config/machine.go +99 -48
- package/internal/config/machine_test.go +248 -0
- package/internal/config/merge_test.go +118 -0
- package/internal/config/writeatomic.go +36 -0
- package/internal/config/writeatomic_test.go +98 -0
- package/internal/execution/goworkflows/activities.go +490 -0
- package/internal/execution/goworkflows/engine.go +487 -0
- package/internal/execution/goworkflows/engine_test.go +600 -0
- package/internal/execution/goworkflows/fakes_test.go +517 -0
- package/internal/execution/goworkflows/interpreter.go +605 -0
- package/internal/execution/goworkflows/logging_test.go +154 -0
- package/internal/execution/goworkflows/mailbox_test.go +423 -0
- package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
- package/internal/execution/goworkflows/node_runtime_test.go +486 -0
- package/internal/execution/goworkflows/projection.go +504 -0
- package/internal/execution/goworkflows/recovery_test.go +1092 -0
- package/internal/execution/goworkflows/retry_log_test.go +59 -0
- package/internal/execution/goworkflows/retry_projection_test.go +98 -0
- package/internal/harness/contract_test.go +169 -0
- package/internal/harness/factory.go +63 -0
- package/internal/harness/harness.go +41 -0
- package/internal/harness/opencode/opencode.go +166 -0
- package/internal/harness/opencode/opencode_test.go +50 -0
- package/internal/harness/plugin_selection_test.go +126 -0
- package/internal/identity/identity.go +37 -0
- package/internal/logging/logging.go +56 -0
- package/internal/logging/logging_test.go +116 -0
- package/internal/paths/paths.go +67 -0
- package/internal/recover/recover.go +115 -0
- package/internal/repo/poller.go +186 -0
- package/internal/repo/poller_test.go +327 -0
- package/internal/repo/repo.go +119 -0
- package/internal/repo/service.go +216 -0
- package/internal/repo/service_test.go +298 -0
- package/internal/retry/retry.go +118 -0
- package/internal/router/router.go +83 -0
- package/internal/router/router_test.go +144 -0
- package/internal/run/manager.go +108 -0
- package/internal/run/run.go +140 -0
- package/internal/run/run_identity_test.go +52 -0
- package/internal/run/run_manager_test.go +266 -0
- package/internal/runner/contract_test.go +221 -0
- package/internal/runner/factory.go +65 -0
- package/internal/runner/orca/orca.go +363 -170
- package/internal/runner/orca/orca_test.go +134 -160
- package/internal/runner/orca/orcacli/orcacli.go +215 -0
- package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
- package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
- package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
- package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
- package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
- package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
- package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
- package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
- package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
- package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
- package/internal/runner/runner.go +47 -64
- package/internal/server/api_test.go +300 -0
- package/internal/server/client.go +192 -74
- package/internal/server/fixture_test.go +248 -0
- package/internal/server/server.go +425 -248
- package/internal/server/shutdown_test.go +116 -0
- package/internal/task/contract_test.go +223 -0
- package/internal/task/factory.go +103 -0
- package/internal/task/jira/acli/acli.go +306 -0
- package/internal/task/jira/acli/acli_test.go +208 -0
- package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
- package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
- package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
- package/internal/task/jira/acli/testdata/search_success.json +1 -0
- package/internal/task/jira/filters_test.go +234 -0
- package/internal/task/jira/helpers_test.go +60 -0
- package/internal/task/jira/jira.go +507 -0
- package/internal/task/jira/normalize.go +101 -0
- package/internal/task/jira/testdata/acli_search.json +120 -0
- package/internal/task/jira/transition_defaults_test.go +156 -0
- package/internal/task/jira/validation_test.go +94 -0
- package/internal/task/task.go +84 -0
- package/internal/workflow/report.go +85 -0
- package/internal/workflow/report_test.go +259 -0
- package/internal/workflow/service.go +142 -0
- package/internal/workflow/store.go +136 -0
- package/internal/workflow/store_test.go +282 -0
- package/internal/workflow/workflow.go +342 -0
- package/internal/workflow/workflow_test.go +410 -0
- package/package.json +1 -1
- package/internal/acli/acli.go +0 -229
- package/internal/config/demo_test.go +0 -17
- package/internal/config/schema.go +0 -193
- package/internal/config/schema_test.go +0 -162
- package/internal/daemon/daemon.go +0 -218
- package/internal/daemon/daemon_test.go +0 -204
- package/internal/discovery/discovery.go +0 -122
- package/internal/discovery/discovery_test.go +0 -62
- package/internal/opencode/opencode.go +0 -26
- package/internal/orcacli/orcacli.go +0 -264
- package/internal/runner/orca/README.md +0 -64
- package/internal/runner/runner_test.go +0 -64
- package/internal/server/server_test.go +0 -195
- package/internal/tasks/jira/README.md +0 -69
- package/internal/tasks/jira/component_test.go +0 -16
- package/internal/tasks/jira/decode.go +0 -24
- package/internal/tasks/jira/jira.go +0 -231
- package/internal/tasks/jira/jira_test.go +0 -259
- package/internal/tasks/jira/jql_test.go +0 -16
- package/internal/tasks/tasks.go +0 -90
- 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
|
-
"
|
|
11
|
+
"net/url"
|
|
11
12
|
|
|
12
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
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
|
|
16
|
-
//
|
|
17
|
-
//
|
|
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
|
|
23
|
-
func NewClient()
|
|
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{
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
40
|
-
|
|
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
|
-
|
|
43
|
-
rdr = bytes.NewReader(b)
|
|
44
|
-
} else {
|
|
45
|
-
rdr = bytes.NewReader(nil)
|
|
45
|
+
rdr = bytes.NewReader(body)
|
|
46
46
|
}
|
|
47
|
-
req, err := http.
|
|
47
|
+
req, err := http.NewRequestWithContext(ctx, method, "http://unix"+path, rdr)
|
|
48
48
|
if err != nil {
|
|
49
49
|
return err
|
|
50
50
|
}
|
|
51
|
-
|
|
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
|
-
|
|
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
|
|
62
|
-
|
|
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
|
|
65
|
-
|
|
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
|
|
68
|
-
if env.
|
|
69
|
-
return fmt.Errorf("
|
|
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
|
-
//
|
|
77
|
-
func (c *Client)
|
|
78
|
-
return c.
|
|
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
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
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
|
-
//
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
//
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
120
|
-
|
|
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
|
-
|
|
123
|
-
|
|
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
|
|
243
|
+
return out, nil
|
|
126
244
|
}
|