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
|
@@ -1,342 +1,519 @@
|
|
|
1
|
-
// Package server
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// `report` — both over a unix socket. The tracker remains the only
|
|
5
|
-
// cross-process state.
|
|
1
|
+
// Package server translates Unix-socket JSON to services. It contains no
|
|
2
|
+
// Jira, Orca, workflow graph, or SQLite logic; handlers call consumer
|
|
3
|
+
// services behind Deps and return the standard JSON envelope.
|
|
6
4
|
package server
|
|
7
5
|
|
|
8
6
|
import (
|
|
9
7
|
"context"
|
|
10
8
|
"encoding/json"
|
|
9
|
+
"errors"
|
|
11
10
|
"fmt"
|
|
12
|
-
"
|
|
13
|
-
"net"
|
|
11
|
+
"io"
|
|
14
12
|
"net/http"
|
|
15
13
|
"strings"
|
|
16
|
-
"sync"
|
|
17
14
|
|
|
18
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
19
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
20
|
-
"github.com/rajpopat27/relay-flow/internal/discovery"
|
|
15
|
+
"github.com/rajpopat27/relay-flow/internal/repo"
|
|
16
|
+
"github.com/rajpopat27/relay-flow/internal/run"
|
|
21
17
|
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
22
|
-
"github.com/rajpopat27/relay-flow/internal/
|
|
23
|
-
|
|
24
|
-
|
|
18
|
+
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
// Deps are the consumer services the handlers call. The composition root
|
|
22
|
+
// (section 5) supplies the real implementations; tests supply fakes.
|
|
23
|
+
// Signatures match docs/structs-methods-interfaces.md (Client) exactly.
|
|
24
|
+
type Deps interface {
|
|
25
|
+
// Workflows
|
|
26
|
+
SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.Workflow, error)
|
|
27
|
+
GetWorkflow(ctx context.Context, name string) (*workflow.Workflow, error)
|
|
28
|
+
ListWorkflows(ctx context.Context) ([]*workflow.Workflow, error)
|
|
29
|
+
RemoveWorkflow(ctx context.Context, name string) error
|
|
30
|
+
|
|
31
|
+
// Runs
|
|
32
|
+
ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, error)
|
|
33
|
+
GetRunByTicket(ctx context.Context, ticket string) (run.Run, error)
|
|
34
|
+
CancelRun(ctx context.Context, ticket, reason string) error
|
|
35
|
+
|
|
36
|
+
// Reports
|
|
37
|
+
HasProcessedReport(ctx context.Context, id run.ID, reportID string) (bool, error)
|
|
38
|
+
SubmitReport(ctx context.Context, report run.ReportRequest) (run.ReportAck, error)
|
|
39
|
+
RegisterNodeSession(ctx context.Context, registration run.NodeRuntimeRegistration) (run.NodeRuntimeRegistrationAck, error)
|
|
40
|
+
|
|
41
|
+
// Repos
|
|
42
|
+
DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error)
|
|
43
|
+
TaskFields(ctx context.Context) ([]string, error)
|
|
44
|
+
RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error)
|
|
45
|
+
ListRepos(ctx context.Context) ([]repo.Info, error)
|
|
46
|
+
GetRepo(ctx context.Context, name string) (repo.Info, error)
|
|
47
|
+
RemoveRepo(ctx context.Context, name string) error
|
|
25
48
|
|
|
26
|
-
|
|
49
|
+
// Shutdown requests graceful server shutdown; the serve command
|
|
50
|
+
// supplies the concrete hook (signal the main loop to exit).
|
|
51
|
+
Shutdown(ctx context.Context) error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Error classification: services return typed errors (or errors wrapping
|
|
55
|
+
// them) so handlers map to stable HTTP status codes without any
|
|
56
|
+
// Jira/Orca/graph/SQLite knowledge.
|
|
57
|
+
var (
|
|
58
|
+
// ErrNotFound maps to 404.
|
|
59
|
+
ErrNotFound = errors.New("not found")
|
|
60
|
+
// ErrConflict maps to 409.
|
|
61
|
+
ErrConflict = errors.New("conflict")
|
|
62
|
+
// ErrInvalid maps to 400.
|
|
63
|
+
ErrInvalid = errors.New("invalid")
|
|
27
64
|
)
|
|
28
65
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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)
|
|
66
|
+
type envelope struct {
|
|
67
|
+
OK bool `json:"ok"`
|
|
68
|
+
Data any `json:"data,omitempty"`
|
|
69
|
+
Error *errBody `json:"error,omitempty"`
|
|
37
70
|
}
|
|
38
71
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
ResolveRepo: discovery.RepoFromPath,
|
|
43
|
-
ValidateConfig: validateConfigProd,
|
|
44
|
-
}
|
|
72
|
+
type errBody struct {
|
|
73
|
+
Code string `json:"code"`
|
|
74
|
+
Message string `json:"message"`
|
|
45
75
|
}
|
|
46
76
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
77
|
+
// New builds the HTTP handler over the given services.
|
|
78
|
+
func New(deps Deps) http.Handler {
|
|
79
|
+
s := &server{deps: deps}
|
|
80
|
+
mux := http.NewServeMux()
|
|
81
|
+
mux.HandleFunc("/stop", s.handleStop)
|
|
82
|
+
mux.HandleFunc("/workflows", s.handleWorkflows)
|
|
83
|
+
mux.HandleFunc("/workflows/", s.handleWorkflowByName)
|
|
84
|
+
mux.HandleFunc("/repos/discover", s.handleReposDiscover)
|
|
85
|
+
mux.HandleFunc("/repos/task-fields", s.handleRepoTaskFields)
|
|
86
|
+
mux.HandleFunc("/repos", s.handleRepos)
|
|
87
|
+
mux.HandleFunc("/repos/", s.handleRepoByName)
|
|
88
|
+
mux.HandleFunc("/reports", s.handleReports)
|
|
89
|
+
mux.HandleFunc("/runtime/session", s.handleRuntimeSession)
|
|
90
|
+
mux.HandleFunc("/runs", s.handleRuns)
|
|
91
|
+
mux.HandleFunc("/runs/by-ticket/", s.handleRunByTicket)
|
|
92
|
+
return mux
|
|
53
93
|
}
|
|
54
94
|
|
|
55
|
-
type
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
95
|
+
type server struct {
|
|
96
|
+
deps Deps
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// --- envelope helpers ---
|
|
60
100
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
shutdownOnce sync.Once
|
|
101
|
+
func writeOK(w http.ResponseWriter, status int, data any) {
|
|
102
|
+
writeEnv(w, status, envelope{OK: true, Data: data})
|
|
64
103
|
}
|
|
65
104
|
|
|
66
|
-
func
|
|
67
|
-
|
|
68
|
-
entries: map[string]*entry{},
|
|
69
|
-
deps: deps,
|
|
70
|
-
dryRun: dryRun,
|
|
71
|
-
closed: make(chan struct{}),
|
|
72
|
-
}
|
|
105
|
+
func writeErr(w http.ResponseWriter, status int, code, msg string) {
|
|
106
|
+
writeEnv(w, status, envelope{OK: false, Error: &errBody{Code: code, Message: msg}})
|
|
73
107
|
}
|
|
74
108
|
|
|
75
|
-
func (
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
mux.HandleFunc("/shutdown", methodGuard("POST", s.handleShutdown))
|
|
80
|
-
return mux
|
|
109
|
+
func writeEnv(w http.ResponseWriter, status int, env envelope) {
|
|
110
|
+
w.Header().Set("Content-Type", "application/json")
|
|
111
|
+
w.WriteHeader(status)
|
|
112
|
+
_ = json.NewEncoder(w).Encode(env)
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
//
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
case
|
|
89
|
-
|
|
115
|
+
// mapErr translates a service error into HTTP status + lowerCamel code.
|
|
116
|
+
// Services return errors wrapping ErrNotFound/ErrConflict/ErrInvalid;
|
|
117
|
+
// anything else is an unexpected 500.
|
|
118
|
+
func mapErr(w http.ResponseWriter, err error) {
|
|
119
|
+
switch {
|
|
120
|
+
case errors.Is(err, ErrNotFound):
|
|
121
|
+
writeErr(w, http.StatusNotFound, "notFound", err.Error())
|
|
122
|
+
case errors.Is(err, ErrConflict):
|
|
123
|
+
writeErr(w, http.StatusConflict, "conflict", err.Error())
|
|
124
|
+
case errors.Is(err, ErrInvalid):
|
|
125
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
90
126
|
default:
|
|
127
|
+
writeErr(w, http.StatusInternalServerError, "internalError", err.Error())
|
|
91
128
|
}
|
|
92
|
-
return err
|
|
93
129
|
}
|
|
94
130
|
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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)
|
|
131
|
+
func methodOnly(w http.ResponseWriter, r *http.Request, allowed ...string) bool {
|
|
132
|
+
for _, m := range allowed {
|
|
133
|
+
if r.Method == m {
|
|
134
|
+
return true
|
|
109
135
|
}
|
|
110
|
-
}
|
|
136
|
+
}
|
|
137
|
+
writeErr(w, http.StatusMethodNotAllowed, "methodNotAllowed", "method not allowed")
|
|
138
|
+
return false
|
|
111
139
|
}
|
|
112
140
|
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
141
|
+
// decodeStrict unmarshals body as JSON, rejecting unknown fields. Note:
|
|
142
|
+
// encoding/json matches keys case-insensitively, so DisallowUnknownFields
|
|
143
|
+
// alone does not reject wrong-cased keys; handlers that require strict
|
|
144
|
+
// lowerCamel keys must pre-check raw keys separately.
|
|
145
|
+
func decodeStrict(body []byte, dest any) error {
|
|
146
|
+
dec := json.NewDecoder(strings.NewReader(string(body)))
|
|
147
|
+
dec.DisallowUnknownFields()
|
|
148
|
+
if err := dec.Decode(dest); err != nil {
|
|
149
|
+
return fmt.Errorf("malformed body: %w", err)
|
|
150
|
+
}
|
|
151
|
+
return nil
|
|
116
152
|
}
|
|
117
153
|
|
|
118
|
-
func (
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
writeError(w, 400, "submit requires {repoPath, yaml}")
|
|
122
|
-
return
|
|
154
|
+
func readBody(r *http.Request) ([]byte, error) {
|
|
155
|
+
if r.Body == nil {
|
|
156
|
+
return nil, nil
|
|
123
157
|
}
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
158
|
+
defer r.Body.Close()
|
|
159
|
+
return io.ReadAll(r.Body)
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// --- /stop ---
|
|
163
|
+
|
|
164
|
+
func (s *server) handleStop(w http.ResponseWriter, r *http.Request) {
|
|
165
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
129
166
|
return
|
|
130
167
|
}
|
|
131
|
-
|
|
132
|
-
|
|
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)
|
|
168
|
+
if err := s.deps.Shutdown(r.Context()); err != nil {
|
|
169
|
+
mapErr(w, err)
|
|
138
170
|
return
|
|
139
171
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
172
|
+
writeOK(w, http.StatusOK, map[string]string{"status": "stopping"})
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- /workflows ---
|
|
176
|
+
|
|
177
|
+
func (s *server) handleWorkflows(w http.ResponseWriter, r *http.Request) {
|
|
178
|
+
switch r.Method {
|
|
179
|
+
case http.MethodGet:
|
|
180
|
+
wfs, err := s.deps.ListWorkflows(r.Context())
|
|
181
|
+
if err != nil {
|
|
182
|
+
mapErr(w, err)
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
writeOK(w, http.StatusOK, wfs)
|
|
186
|
+
case http.MethodPost:
|
|
187
|
+
// The body IS the workflow YAML; no JSON wrapper. Content-Type is
|
|
188
|
+
// not enforced because the route is the only writer and the body is
|
|
189
|
+
// passed verbatim to the workflow parser.
|
|
190
|
+
body, err := readBody(r)
|
|
191
|
+
if err != nil {
|
|
192
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
193
|
+
return
|
|
194
|
+
}
|
|
195
|
+
wf, err := s.deps.SubmitWorkflow(r.Context(), body)
|
|
196
|
+
if err != nil {
|
|
197
|
+
mapErr(w, err)
|
|
198
|
+
return
|
|
199
|
+
}
|
|
200
|
+
writeOK(w, http.StatusOK, wf)
|
|
201
|
+
default:
|
|
202
|
+
writeErr(w, http.StatusMethodNotAllowed, "methodNotAllowed", "method not allowed")
|
|
145
203
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
func (s *server) handleWorkflowByName(w http.ResponseWriter, r *http.Request) {
|
|
207
|
+
name := strings.TrimPrefix(r.URL.Path, "/workflows/")
|
|
208
|
+
if name == "" || strings.Contains(name, "/") {
|
|
209
|
+
writeErr(w, http.StatusNotFound, "notFound", "workflow not found")
|
|
149
210
|
return
|
|
150
|
-
}
|
|
151
|
-
|
|
211
|
+
}
|
|
212
|
+
switch r.Method {
|
|
213
|
+
case http.MethodGet:
|
|
214
|
+
wf, err := s.deps.GetWorkflow(r.Context(), name)
|
|
215
|
+
if err != nil {
|
|
216
|
+
mapErr(w, err)
|
|
217
|
+
return
|
|
218
|
+
}
|
|
219
|
+
writeOK(w, http.StatusOK, wf)
|
|
220
|
+
case http.MethodDelete:
|
|
221
|
+
if err := s.deps.RemoveWorkflow(r.Context(), name); err != nil {
|
|
222
|
+
mapErr(w, err)
|
|
223
|
+
return
|
|
224
|
+
}
|
|
225
|
+
writeOK(w, http.StatusOK, map[string]string{"removed": name})
|
|
226
|
+
default:
|
|
227
|
+
writeErr(w, http.StatusMethodNotAllowed, "methodNotAllowed", "method not allowed")
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// --- /repos ---
|
|
232
|
+
|
|
233
|
+
func (s *server) handleReposDiscover(w http.ResponseWriter, r *http.Request) {
|
|
234
|
+
if !methodOnly(w, r, http.MethodGet) {
|
|
152
235
|
return
|
|
153
236
|
}
|
|
154
|
-
|
|
155
|
-
// restart means resubmit.
|
|
156
|
-
e, err := s.buildEntry(cfg, repoID, repoName)
|
|
237
|
+
candidates, err := s.deps.DiscoverRepos(r.Context())
|
|
157
238
|
if err != nil {
|
|
158
|
-
|
|
239
|
+
mapErr(w, err)
|
|
159
240
|
return
|
|
160
241
|
}
|
|
161
|
-
|
|
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})
|
|
242
|
+
writeOK(w, http.StatusOK, candidates)
|
|
166
243
|
}
|
|
167
244
|
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
tk, err := buildTasks(cfg, repoName)
|
|
172
|
-
if err != nil {
|
|
173
|
-
return nil, err
|
|
245
|
+
func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
|
|
246
|
+
if !methodOnly(w, r, http.MethodGet) {
|
|
247
|
+
return
|
|
174
248
|
}
|
|
175
|
-
|
|
249
|
+
fields, err := s.deps.TaskFields(r.Context())
|
|
176
250
|
if err != nil {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
if wr, ok := rn.(interface{ WithRepo(string, string, bool) }); ok {
|
|
180
|
-
wr.WithRepo(repoID, repoName, s.dryRun)
|
|
251
|
+
mapErr(w, err)
|
|
252
|
+
return
|
|
181
253
|
}
|
|
182
|
-
|
|
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
|
|
254
|
+
writeOK(w, http.StatusOK, map[string]any{"fields": fields})
|
|
186
255
|
}
|
|
187
256
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
assignee := ""
|
|
193
|
-
if cfg.Tasks.Type == "jira" {
|
|
194
|
-
jc, err := jira.UnmarshalConfigForValidation(cfg.Tasks.Config)
|
|
257
|
+
func (s *server) handleRepos(w http.ResponseWriter, r *http.Request) {
|
|
258
|
+
switch r.Method {
|
|
259
|
+
case http.MethodGet:
|
|
260
|
+
infos, err := s.deps.ListRepos(r.Context())
|
|
195
261
|
if err != nil {
|
|
196
|
-
|
|
262
|
+
mapErr(w, err)
|
|
263
|
+
return
|
|
197
264
|
}
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
265
|
+
writeOK(w, http.StatusOK, infos)
|
|
266
|
+
case http.MethodPost:
|
|
267
|
+
body, err := readBody(r)
|
|
268
|
+
if err != nil {
|
|
269
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
var payload repo.RegisterInput
|
|
273
|
+
if err := decodeStrict(body, &payload); err != nil {
|
|
274
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
275
|
+
return
|
|
204
276
|
}
|
|
277
|
+
info, err := s.deps.RegisterRepo(r.Context(), payload)
|
|
278
|
+
if err != nil {
|
|
279
|
+
mapErr(w, err)
|
|
280
|
+
return
|
|
281
|
+
}
|
|
282
|
+
writeOK(w, http.StatusOK, info)
|
|
283
|
+
default:
|
|
284
|
+
writeErr(w, http.StatusMethodNotAllowed, "methodNotAllowed", "method not allowed")
|
|
205
285
|
}
|
|
206
|
-
return tasks.New(cfg.Tasks.Type, cfg.Tasks.Config, cfg.Name, cfg.Nodes, assignee, repoName)
|
|
207
286
|
}
|
|
208
287
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
288
|
+
func (s *server) handleRepoByName(w http.ResponseWriter, r *http.Request) {
|
|
289
|
+
name := strings.TrimPrefix(r.URL.Path, "/repos/")
|
|
290
|
+
if name == "" || strings.Contains(name, "/") {
|
|
291
|
+
writeErr(w, http.StatusNotFound, "notFound", "repo not found")
|
|
292
|
+
return
|
|
293
|
+
}
|
|
294
|
+
switch r.Method {
|
|
295
|
+
case http.MethodGet:
|
|
296
|
+
info, err := s.deps.GetRepo(r.Context(), name)
|
|
297
|
+
if err != nil {
|
|
298
|
+
mapErr(w, err)
|
|
299
|
+
return
|
|
300
|
+
}
|
|
301
|
+
writeOK(w, http.StatusOK, info)
|
|
302
|
+
case http.MethodDelete:
|
|
303
|
+
if err := s.deps.RemoveRepo(r.Context(), name); err != nil {
|
|
304
|
+
mapErr(w, err)
|
|
305
|
+
return
|
|
306
|
+
}
|
|
307
|
+
writeOK(w, http.StatusOK, map[string]string{"removed": name})
|
|
308
|
+
default:
|
|
309
|
+
writeErr(w, http.StatusMethodNotAllowed, "methodNotAllowed", "method not allowed")
|
|
310
|
+
}
|
|
215
311
|
}
|
|
216
312
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
313
|
+
// --- /reports ---
|
|
314
|
+
|
|
315
|
+
// ReportRequest wire keys are lowerCamel per docs. encoding/json matches
|
|
316
|
+
// keys case-insensitively, so the handler rejects any key that does not
|
|
317
|
+
// exactly match the contract at every nesting level.
|
|
318
|
+
var (
|
|
319
|
+
reportTopKeys = []string{"runId", "node", "reportId", "report"}
|
|
320
|
+
reportBodyKeys = []string{"status", "nextStep", "summary", "feedback"}
|
|
321
|
+
reportSummaryKeys = []string{"completed", "commits", "notCompleted", "issuesDiscovered", "verification", "notes"}
|
|
322
|
+
reportFeedbackKeys = []string{"reasonForNextStep", "requiredActions", "relevantContext", "expectedResult"}
|
|
323
|
+
)
|
|
324
|
+
|
|
325
|
+
func rejectUnknownKeys(raw json.RawMessage, allowed []string, path string) error {
|
|
326
|
+
var m map[string]json.RawMessage
|
|
327
|
+
if err := json.Unmarshal(raw, &m); err != nil {
|
|
328
|
+
return fmt.Errorf("%s: malformed object: %w", path, err)
|
|
329
|
+
}
|
|
330
|
+
allowedSet := make(map[string]bool, len(allowed))
|
|
331
|
+
for _, k := range allowed {
|
|
332
|
+
allowedSet[k] = true
|
|
333
|
+
}
|
|
334
|
+
for k := range m {
|
|
335
|
+
if !allowedSet[k] {
|
|
336
|
+
return fmt.Errorf("%s: unknown field %q", path, k)
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
return nil
|
|
223
340
|
}
|
|
224
341
|
|
|
225
|
-
func (s *
|
|
226
|
-
|
|
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}")
|
|
342
|
+
func (s *server) handleReports(w http.ResponseWriter, r *http.Request) {
|
|
343
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
230
344
|
return
|
|
231
345
|
}
|
|
232
|
-
|
|
233
|
-
|
|
346
|
+
body, err := readBody(r)
|
|
347
|
+
if err != nil {
|
|
348
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
234
349
|
return
|
|
235
350
|
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
if
|
|
240
|
-
|
|
351
|
+
// Processed IDs are payload-independent: extract only exact identity keys
|
|
352
|
+
// and return the duplicate ack before validating the report body.
|
|
353
|
+
var top map[string]json.RawMessage
|
|
354
|
+
if err := json.Unmarshal(body, &top); err != nil {
|
|
355
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
241
356
|
return
|
|
242
357
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
358
|
+
var runID run.ID
|
|
359
|
+
var reportID string
|
|
360
|
+
if raw, ok := top["runId"]; ok {
|
|
361
|
+
_ = json.Unmarshal(raw, &runID)
|
|
247
362
|
}
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
target = node.OnFailure
|
|
363
|
+
if raw, ok := top["reportId"]; ok {
|
|
364
|
+
_ = json.Unmarshal(raw, &reportID)
|
|
251
365
|
}
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
if e.cfg.Nodes[target].When != "" && stringsEqualFoldNode(e.cfg, req.Node, target) {
|
|
263
|
-
action = "commented"
|
|
366
|
+
if runID != "" && reportID != "" {
|
|
367
|
+
processed, err := s.deps.HasProcessedReport(r.Context(), runID, reportID)
|
|
368
|
+
if err != nil {
|
|
369
|
+
mapErr(w, err)
|
|
370
|
+
return
|
|
371
|
+
}
|
|
372
|
+
if processed {
|
|
373
|
+
writeOK(w, http.StatusOK, run.ReportAck{Accepted: true, Duplicate: true})
|
|
374
|
+
return
|
|
375
|
+
}
|
|
264
376
|
}
|
|
265
|
-
|
|
266
|
-
|
|
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
|
|
377
|
+
// Strict-case validation across every nested level.
|
|
378
|
+
if err := rejectUnknownKeys(body, reportTopKeys, "report"); err != nil {
|
|
379
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
380
|
+
return
|
|
275
381
|
}
|
|
276
|
-
|
|
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)
|
|
382
|
+
if rep, ok := top["report"]; ok {
|
|
383
|
+
if err := rejectUnknownKeys(rep, reportBodyKeys, "report.report"); err != nil {
|
|
384
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
283
385
|
return
|
|
284
386
|
}
|
|
285
|
-
|
|
387
|
+
var repBody map[string]json.RawMessage
|
|
388
|
+
_ = json.Unmarshal(rep, &repBody)
|
|
389
|
+
if sum, ok := repBody["summary"]; ok {
|
|
390
|
+
if err := rejectUnknownKeys(sum, reportSummaryKeys, "report.report.summary"); err != nil {
|
|
391
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
if fb, ok := repBody["feedback"]; ok {
|
|
396
|
+
if err := rejectUnknownKeys(fb, reportFeedbackKeys, "report.report.feedback"); err != nil {
|
|
397
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
}
|
|
286
401
|
}
|
|
402
|
+
var req run.ReportRequest
|
|
403
|
+
if err := decodeStrict(body, &req); err != nil {
|
|
404
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
if req.RunID == "" || req.Node == "" || req.ReportID == "" {
|
|
408
|
+
writeErr(w, http.StatusBadRequest, "invalid", "runId, node, and reportId are required")
|
|
409
|
+
return
|
|
410
|
+
}
|
|
411
|
+
ack, err := s.deps.SubmitReport(r.Context(), req)
|
|
412
|
+
if err != nil {
|
|
413
|
+
mapErr(w, err)
|
|
414
|
+
return
|
|
415
|
+
}
|
|
416
|
+
writeOK(w, http.StatusOK, ack)
|
|
287
417
|
}
|
|
288
418
|
|
|
289
|
-
|
|
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
|
-
}
|
|
419
|
+
// --- /runtime/session ---
|
|
298
420
|
|
|
421
|
+
var runtimeSessionKeys = []string{"runId", "node", "sessionId"}
|
|
299
422
|
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
423
|
+
func (s *server) handleRuntimeSession(w http.ResponseWriter, r *http.Request) {
|
|
424
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
425
|
+
return
|
|
426
|
+
}
|
|
427
|
+
body, err := readBody(r)
|
|
305
428
|
if err != nil {
|
|
306
|
-
|
|
429
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
430
|
+
return
|
|
307
431
|
}
|
|
308
|
-
if
|
|
309
|
-
|
|
432
|
+
if err := rejectUnknownKeys(body, runtimeSessionKeys, "runtime session"); err != nil {
|
|
433
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
434
|
+
return
|
|
310
435
|
}
|
|
311
|
-
|
|
312
|
-
if err != nil {
|
|
313
|
-
|
|
436
|
+
var registration run.NodeRuntimeRegistration
|
|
437
|
+
if err := decodeStrict(body, ®istration); err != nil {
|
|
438
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
439
|
+
return
|
|
314
440
|
}
|
|
315
|
-
|
|
441
|
+
if registration.RunID == "" || registration.Node == "" || registration.SessionID == "" {
|
|
442
|
+
writeErr(w, http.StatusBadRequest, "invalid", "runId, node, and sessionId are required")
|
|
443
|
+
return
|
|
444
|
+
}
|
|
445
|
+
ack, err := s.deps.RegisterNodeSession(r.Context(), registration)
|
|
316
446
|
if err != nil {
|
|
317
|
-
|
|
447
|
+
mapErr(w, err)
|
|
448
|
+
return
|
|
318
449
|
}
|
|
319
|
-
|
|
320
|
-
|
|
450
|
+
writeOK(w, http.StatusOK, ack)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// --- /runs ---
|
|
454
|
+
|
|
455
|
+
func (s *server) handleRuns(w http.ResponseWriter, r *http.Request) {
|
|
456
|
+
if r.URL.Path != "/runs" {
|
|
457
|
+
writeErr(w, http.StatusNotFound, "notFound", "run not found")
|
|
458
|
+
return
|
|
459
|
+
}
|
|
460
|
+
if !methodOnly(w, r, http.MethodGet) {
|
|
461
|
+
return
|
|
462
|
+
}
|
|
463
|
+
var filter run.Filter
|
|
464
|
+
q := r.URL.Query()
|
|
465
|
+
filter.Repo = q.Get("repo")
|
|
466
|
+
filter.Workflow = q.Get("workflow")
|
|
467
|
+
filter.Ticket = q.Get("ticket")
|
|
468
|
+
runs, err := s.deps.ListRuns(r.Context(), filter)
|
|
321
469
|
if err != nil {
|
|
322
|
-
|
|
470
|
+
mapErr(w, err)
|
|
471
|
+
return
|
|
323
472
|
}
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
473
|
+
writeOK(w, http.StatusOK, runs)
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
func (s *server) handleRunByTicket(w http.ResponseWriter, r *http.Request) {
|
|
477
|
+
// /runs/by-ticket/{key} GET
|
|
478
|
+
// /runs/by-ticket/{key}/cancel POST
|
|
479
|
+
rest := strings.TrimPrefix(r.URL.Path, "/runs/by-ticket/")
|
|
480
|
+
parts := strings.Split(rest, "/")
|
|
481
|
+
if len(parts) == 1 && parts[0] != "" {
|
|
482
|
+
if !methodOnly(w, r, http.MethodGet) {
|
|
483
|
+
return
|
|
328
484
|
}
|
|
329
|
-
|
|
330
|
-
|
|
485
|
+
rn, err := s.deps.GetRunByTicket(r.Context(), parts[0])
|
|
486
|
+
if err != nil {
|
|
487
|
+
mapErr(w, err)
|
|
488
|
+
return
|
|
331
489
|
}
|
|
490
|
+
writeOK(w, http.StatusOK, rn)
|
|
491
|
+
return
|
|
332
492
|
}
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
493
|
+
if len(parts) == 2 && parts[0] != "" && parts[1] == "cancel" {
|
|
494
|
+
if !methodOnly(w, r, http.MethodPost) {
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
var payload struct {
|
|
498
|
+
Reason string `json:"reason,omitempty"`
|
|
499
|
+
}
|
|
500
|
+
body, err := readBody(r)
|
|
501
|
+
if err != nil {
|
|
502
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
503
|
+
return
|
|
504
|
+
}
|
|
505
|
+
if len(strings.TrimSpace(string(body))) > 0 {
|
|
506
|
+
if err := decodeStrict(body, &payload); err != nil {
|
|
507
|
+
writeErr(w, http.StatusBadRequest, "invalid", err.Error())
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
if err := s.deps.CancelRun(r.Context(), parts[0], payload.Reason); err != nil {
|
|
512
|
+
mapErr(w, err)
|
|
513
|
+
return
|
|
514
|
+
}
|
|
515
|
+
writeOK(w, http.StatusOK, map[string]string{"canceled": parts[0]})
|
|
516
|
+
return
|
|
340
517
|
}
|
|
341
|
-
|
|
518
|
+
writeErr(w, http.StatusNotFound, "notFound", "run not found")
|
|
342
519
|
}
|