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
@@ -1,342 +1,519 @@
1
- // Package server runs the central relay-flow process: one long-lived `serve`
2
- // command hosting any number of submitted workflows, each polling in its
3
- // own goroutine. Workflows arrive via `submit`, agent outcomes arrive via
4
- // `report` — both over a unix socket. The tracker remains the only
5
- // cross-process state.
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
- "log"
13
- "net"
11
+ "io"
14
12
  "net/http"
15
13
  "strings"
16
- "sync"
17
14
 
18
- "github.com/rajpopat27/relay-flow/internal/config"
19
- "github.com/rajpopat27/relay-flow/internal/daemon"
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/acli"
23
- "github.com/rajpopat27/relay-flow/internal/tasks"
24
- "github.com/rajpopat27/relay-flow/internal/tasks/jira"
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
- _ "github.com/rajpopat27/relay-flow/internal/runner/orca" // built-in adapters self-register
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
- // Deps injects side-effecting operations so tests never call orca/acli or
30
- // spawn real poll loops.
31
- type Deps struct {
32
- // ResolveRepo maps a repo path to (repoID, displayName).
33
- ResolveRepo func(path string) (string, string, error)
34
- // ValidateConfig probe-validates adapter-visible names (tracker
35
- // states, assignee) in the YAML; returns invalid names.
36
- ValidateConfig func(yamlBytes []byte) ([]string, error)
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
- // ProdDeps wires Deps to the real implementations.
40
- func ProdDeps(dryRun bool) Deps {
41
- return Deps{
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
- type entry struct {
48
- cfg *config.Config
49
- tk tasks.Tasks
50
- d *daemon.Daemon
51
- cancel context.CancelFunc
52
- repoID string
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 Server struct {
56
- mu sync.Mutex
57
- entries map[string]*entry
58
- deps Deps
59
- dryRun bool
95
+ type server struct {
96
+ deps Deps
97
+ }
98
+
99
+ // --- envelope helpers ---
60
100
 
61
- ln net.Listener
62
- closed chan struct{}
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 New(dryRun bool, deps Deps) *Server {
67
- return &Server{
68
- entries: map[string]*entry{},
69
- deps: deps,
70
- dryRun: dryRun,
71
- closed: make(chan struct{}),
72
- }
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 (s *Server) handler() http.Handler {
76
- mux := http.NewServeMux()
77
- mux.HandleFunc("/submit", methodGuard("POST", s.handleSubmit))
78
- mux.HandleFunc("/report", methodGuard("POST", s.handleReport))
79
- mux.HandleFunc("/shutdown", methodGuard("POST", s.handleShutdown))
80
- return mux
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
- // Serve accepts HTTP on ln (a unix socket) until Shutdown. Blocks.
84
- func (s *Server) Serve(ln net.Listener) error {
85
- s.ln = ln
86
- err := (&http.Server{Handler: s.handler()}).Serve(ln)
87
- select {
88
- case <-s.closed:
89
- return nil
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
- // Shutdown stops the HTTP listener and every workflow's poll loop.
96
- // Idempotent: the /shutdown handler and process signal handlers may both
97
- // invoke it.
98
- func (s *Server) Shutdown() {
99
- s.shutdownOnce.Do(func() {
100
- close(s.closed)
101
- if s.ln != nil {
102
- s.ln.Close()
103
- }
104
- s.mu.Lock()
105
- defer s.mu.Unlock()
106
- for name, e := range s.entries {
107
- e.cancel()
108
- delete(s.entries, name)
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
- type submitRequest struct {
114
- RepoPath string `json:"repoPath"`
115
- YAML string `json:"yaml"`
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 (s *Server) handleSubmit(w http.ResponseWriter, r *http.Request) {
119
- var req submitRequest
120
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.YAML == "" || req.RepoPath == "" {
121
- writeError(w, 400, "submit requires {repoPath, yaml}")
122
- return
154
+ func readBody(r *http.Request) ([]byte, error) {
155
+ if r.Body == nil {
156
+ return nil, nil
123
157
  }
124
- // 1. YAML must parse and validate structurally. The workflow's
125
- // identity is the `name` field inside the YAML.
126
- cfg, err := config.Parse("submit", []byte(req.YAML))
127
- if err != nil {
128
- writeError(w, 400, "invalid config: %v", err)
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
- // 2. Name must be free: two workflows with the same name would share
132
- // claim labels and double-dispatch tickets.
133
- s.mu.Lock()
134
- _, dup := s.entries[cfg.Name]
135
- s.mu.Unlock()
136
- if dup {
137
- writeError(w, 409, "workflow %q already running; stop serve and resubmit to update", cfg.Name)
168
+ if err := s.deps.Shutdown(r.Context()); err != nil {
169
+ mapErr(w, err)
138
170
  return
139
171
  }
140
- // 3. Repo must resolve (submitted from a directory inside the repo).
141
- repoID, repoName, err := s.deps.ResolveRepo(req.RepoPath)
142
- if err != nil {
143
- writeError(w, 400, "resolve repo %s: %v", req.RepoPath, err)
144
- return
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
- // 4. Tracker-visible names (states, assignee) must probe-validate.
147
- if bad, err := s.deps.ValidateConfig([]byte(req.YAML)); err != nil {
148
- writeError(w, 400, "config validation: %v", err)
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
- } else if len(bad) > 0 {
151
- writeError(w, 400, "invalid tracker names: %v", bad)
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
- // 5. Build adapters + daemon and start the poll loop. Stateless:
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
- writeError(w, 400, "start workflow: %v", err)
239
+ mapErr(w, err)
159
240
  return
160
241
  }
161
- s.mu.Lock()
162
- s.entries[cfg.Name] = e
163
- s.mu.Unlock()
164
- log.Printf("submit %s: started (repo=%s)", cfg.Name, repoID)
165
- writeJSON(w, 200, map[string]any{"ok": true, "name": cfg.Name})
242
+ writeOK(w, http.StatusOK, candidates)
166
243
  }
167
244
 
168
- // buildEntry wires one workflow: tasks adapter → runner adapter → daemon
169
- // + poll goroutine.
170
- func (s *Server) buildEntry(cfg *config.Config, repoID, repoName string) (*entry, error) {
171
- tk, err := buildTasks(cfg, repoName)
172
- if err != nil {
173
- return nil, err
245
+ func (s *server) handleRepoTaskFields(w http.ResponseWriter, r *http.Request) {
246
+ if !methodOnly(w, r, http.MethodGet) {
247
+ return
174
248
  }
175
- rn, err := runner.New(cfg.Runner.Type, cfg.Runner.Config)
249
+ fields, err := s.deps.TaskFields(r.Context())
176
250
  if err != nil {
177
- return nil, err
178
- }
179
- if wr, ok := rn.(interface{ WithRepo(string, string, bool) }); ok {
180
- wr.WithRepo(repoID, repoName, s.dryRun)
251
+ mapErr(w, err)
252
+ return
181
253
  }
182
- d := daemon.New(cfg, tk, rn, repoID, repoName, s.dryRun)
183
- ctx, cancel := context.WithCancel(context.Background())
184
- go d.PollLoop(ctx)
185
- return &entry{cfg: cfg, tk: tk, d: d, cancel: cancel, repoID: repoID}, nil
254
+ writeOK(w, http.StatusOK, map[string]any{"fields": fields})
186
255
  }
187
256
 
188
- // buildTasks constructs the tasks adapter, injecting the machine-config
189
- // assignee for jira in distributed mode (centralized assigneeIsAgent
190
- // skips it). Adapter-specific because only jira consumes an assignee.
191
- func buildTasks(cfg *config.Config, repoName string) (tasks.Tasks, error) {
192
- assignee := ""
193
- if cfg.Tasks.Type == "jira" {
194
- jc, err := jira.UnmarshalConfigForValidation(cfg.Tasks.Config)
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
- return nil, err
262
+ mapErr(w, err)
263
+ return
197
264
  }
198
- if !jc.AssigneeIsAgent {
199
- mc, err := config.LoadMachineConfig()
200
- if err != nil {
201
- return nil, err
202
- }
203
- assignee = mc.Assignee
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
- // handleShutdown replies first, then stops the server (listener + every
210
- // workflow's poll loop). Process exit releases the flock.
211
- func (s *Server) handleShutdown(w http.ResponseWriter, r *http.Request) {
212
- writeJSON(w, 200, map[string]any{"ok": true})
213
- log.Printf("shutdown requested via socket")
214
- go s.Shutdown()
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
- type reportRequest struct {
218
- Workflow string `json:"workflow"`
219
- Ticket string `json:"ticket"`
220
- Node string `json:"node"`
221
- Outcome string `json:"outcome"`
222
- Summary string `json:"summary"`
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 *Server) handleReport(w http.ResponseWriter, r *http.Request) {
226
- var req reportRequest
227
- if err := json.NewDecoder(r.Body).Decode(&req); err != nil ||
228
- req.Workflow == "" || req.Ticket == "" || req.Node == "" || req.Outcome == "" || req.Summary == "" {
229
- writeError(w, 400, "report requires {workflow, ticket, node, outcome, summary}")
342
+ func (s *server) handleReports(w http.ResponseWriter, r *http.Request) {
343
+ if !methodOnly(w, r, http.MethodPost) {
230
344
  return
231
345
  }
232
- if req.Outcome != "success" && req.Outcome != "failure" {
233
- writeError(w, 400, "outcome must be success or failure, got %q", req.Outcome)
346
+ body, err := readBody(r)
347
+ if err != nil {
348
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
234
349
  return
235
350
  }
236
- s.mu.Lock()
237
- e, ok := s.entries[req.Workflow]
238
- s.mu.Unlock()
239
- if !ok {
240
- writeError(w, 404, "no running workflow %q", req.Workflow)
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
- node, ok := e.cfg.Nodes[req.Node]
244
- if !ok {
245
- writeError(w, 400, "workflow %q has no node %q", req.Workflow, req.Node)
246
- return
358
+ var runID run.ID
359
+ var reportID string
360
+ if raw, ok := top["runId"]; ok {
361
+ _ = json.Unmarshal(raw, &runID)
247
362
  }
248
- target := node.OnSuccess
249
- if req.Outcome == "failure" {
250
- target = node.OnFailure
363
+ if raw, ok := top["reportId"]; ok {
364
+ _ = json.Unmarshal(raw, &reportID)
251
365
  }
252
- tk := tasks.Ticket{Key: req.Ticket, Node: req.Node, ClaimedBy: req.Workflow}
253
- if err := e.tk.Report(tk, req.Outcome, target, req.Summary); err != nil {
254
- log.Printf("report %s/%s: %v", req.Workflow, req.Ticket, err)
255
- writeJSON(w, 200, map[string]any{"ok": true, "action": "error", "detail": err.Error()})
256
- return
257
- }
258
- // Report moved the ticket: re-arm the bounce nudge marker for the
259
- // next node visit.
260
- e.d.ClearNudged(req.Ticket)
261
- action := "transitioned"
262
- if e.cfg.Nodes[target].When != "" && stringsEqualFoldNode(e.cfg, req.Node, target) {
263
- action = "commented"
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
- log.Printf("report %s/%s: node=%s outcome=%s %s (%s)", req.Workflow, req.Ticket, req.Node, req.Outcome, target, action)
266
- writeJSON(w, 200, map[string]any{"ok": true, "action": action, "detail": target})
267
- }
268
-
269
- // stringsEqualFoldNode reports whether two nodes share the same tracker
270
- // state (self-loop: comment only, no transition).
271
- func stringsEqualFoldNode(cfg *config.Config, a, b string) bool {
272
- wa, wb := cfg.Nodes[a].When, cfg.Nodes[b].When
273
- if wa == "" || wb == "" {
274
- return false
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
- return strings.EqualFold(wa, wb)
277
- }
278
-
279
- func methodGuard(method string, h http.HandlerFunc) http.HandlerFunc {
280
- return func(w http.ResponseWriter, r *http.Request) {
281
- if r.Method != method {
282
- writeError(w, 405, "method %s not allowed, use %s", r.Method, method)
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
- h(w, r)
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
- func writeJSON(w http.ResponseWriter, code int, v any) {
290
- w.Header().Set("Content-Type", "application/json")
291
- w.WriteHeader(code)
292
- json.NewEncoder(w).Encode(v)
293
- }
294
-
295
- func writeError(w http.ResponseWriter, code int, format string, args ...any) {
296
- writeJSON(w, code, map[string]any{"ok": false, "error": fmt.Sprintf(format, args...)})
297
- }
419
+ // --- /runtime/session ---
298
420
 
421
+ var runtimeSessionKeys = []string{"runId", "node", "sessionId"}
299
422
 
300
- // validateConfigProd probe-validates tracker-visible names at submit:
301
- // every node's `when` status against the project (jira), plus the machine
302
- // assignee when in distributed mode.
303
- func validateConfigProd(yamlBytes []byte) ([]string, error) {
304
- cfg, err := config.Parse("submit", yamlBytes)
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
- return nil, err
429
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
430
+ return
307
431
  }
308
- if cfg.Tasks.Type != "jira" {
309
- return nil, nil // only the jira adapter has probeable states today
432
+ if err := rejectUnknownKeys(body, runtimeSessionKeys, "runtime session"); err != nil {
433
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
434
+ return
310
435
  }
311
- jc, err := jiraConfigOf(cfg)
312
- if err != nil {
313
- return nil, err
436
+ var registration run.NodeRuntimeRegistration
437
+ if err := decodeStrict(body, &registration); err != nil {
438
+ writeErr(w, http.StatusBadRequest, "invalid", err.Error())
439
+ return
314
440
  }
315
- projectKey, err := jira.ProjectKeyFromQuery(jc.Query)
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
- return nil, err
447
+ mapErr(w, err)
448
+ return
318
449
  }
319
- ac := acli.New()
320
- bad, err := jira.ValidateStates(ac, cfg.Nodes, projectKey)
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
- return nil, err
470
+ mapErr(w, err)
471
+ return
323
472
  }
324
- if !jc.AssigneeIsAgent {
325
- mc, err := config.LoadMachineConfig()
326
- if err != nil {
327
- return nil, err
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
- if err := ac.ValidateAssignee(mc.Assignee); err != nil {
330
- bad = append(bad, "assignee: "+mc.Assignee)
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
- return bad, nil
334
- }
335
-
336
- func jiraConfigOf(cfg *config.Config) (jira.JiraConfig, error) {
337
- jcAny, err := jira.UnmarshalConfigForValidation(cfg.Tasks.Config)
338
- if err != nil {
339
- return jira.JiraConfig{}, err
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
- return jcAny, nil
518
+ writeErr(w, http.StatusNotFound, "notFound", "run not found")
342
519
  }