relay-flow 0.0.1 → 0.2.0-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (116) hide show
  1. package/README.md +148 -143
  2. package/cmd/relay-flow/commands_test.go +464 -0
  3. package/cmd/relay-flow/main.go +670 -180
  4. package/cmd/relay-flow/scenario_test.go +1135 -0
  5. package/cmd/relay-flow/serve.go +609 -0
  6. package/go.mod +69 -2
  7. package/go.sum +185 -0
  8. package/internal/config/config.go +88 -0
  9. package/internal/config/machine.go +99 -48
  10. package/internal/config/machine_test.go +248 -0
  11. package/internal/config/merge_test.go +118 -0
  12. package/internal/config/writeatomic.go +36 -0
  13. package/internal/config/writeatomic_test.go +98 -0
  14. package/internal/execution/goworkflows/activities.go +490 -0
  15. package/internal/execution/goworkflows/engine.go +487 -0
  16. package/internal/execution/goworkflows/engine_test.go +600 -0
  17. package/internal/execution/goworkflows/fakes_test.go +517 -0
  18. package/internal/execution/goworkflows/interpreter.go +605 -0
  19. package/internal/execution/goworkflows/logging_test.go +154 -0
  20. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  21. package/internal/execution/goworkflows/node_runtime_integration_test.go +127 -0
  22. package/internal/execution/goworkflows/node_runtime_test.go +486 -0
  23. package/internal/execution/goworkflows/projection.go +504 -0
  24. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  25. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  26. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  27. package/internal/harness/contract_test.go +169 -0
  28. package/internal/harness/factory.go +63 -0
  29. package/internal/harness/harness.go +41 -0
  30. package/internal/harness/opencode/opencode.go +166 -0
  31. package/internal/harness/opencode/opencode_test.go +50 -0
  32. package/internal/harness/plugin_selection_test.go +126 -0
  33. package/internal/identity/identity.go +37 -0
  34. package/internal/logging/logging.go +56 -0
  35. package/internal/logging/logging_test.go +116 -0
  36. package/internal/paths/paths.go +67 -0
  37. package/internal/recover/recover.go +115 -0
  38. package/internal/repo/poller.go +186 -0
  39. package/internal/repo/poller_test.go +327 -0
  40. package/internal/repo/repo.go +119 -0
  41. package/internal/repo/service.go +216 -0
  42. package/internal/repo/service_test.go +298 -0
  43. package/internal/retry/retry.go +118 -0
  44. package/internal/router/router.go +83 -0
  45. package/internal/router/router_test.go +144 -0
  46. package/internal/run/manager.go +108 -0
  47. package/internal/run/run.go +140 -0
  48. package/internal/run/run_identity_test.go +52 -0
  49. package/internal/run/run_manager_test.go +266 -0
  50. package/internal/runner/contract_test.go +221 -0
  51. package/internal/runner/factory.go +65 -0
  52. package/internal/runner/orca/orca.go +363 -170
  53. package/internal/runner/orca/orca_test.go +134 -160
  54. package/internal/runner/orca/orcacli/orcacli.go +215 -0
  55. package/internal/runner/orca/orcacli/orcacli_test.go +154 -0
  56. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  57. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +30 -0
  58. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  66. package/internal/runner/runner.go +47 -64
  67. package/internal/server/api_test.go +300 -0
  68. package/internal/server/client.go +192 -74
  69. package/internal/server/fixture_test.go +248 -0
  70. package/internal/server/server.go +425 -248
  71. package/internal/server/shutdown_test.go +116 -0
  72. package/internal/task/contract_test.go +223 -0
  73. package/internal/task/factory.go +103 -0
  74. package/internal/task/jira/acli/acli.go +306 -0
  75. package/internal/task/jira/acli/acli_test.go +208 -0
  76. package/internal/task/jira/acli/testdata/acli_comments.json +55 -0
  77. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +1 -0
  78. package/internal/task/jira/acli/testdata/search_invalid_status.txt +1 -0
  79. package/internal/task/jira/acli/testdata/search_success.json +1 -0
  80. package/internal/task/jira/filters_test.go +234 -0
  81. package/internal/task/jira/helpers_test.go +60 -0
  82. package/internal/task/jira/jira.go +507 -0
  83. package/internal/task/jira/normalize.go +101 -0
  84. package/internal/task/jira/testdata/acli_search.json +120 -0
  85. package/internal/task/jira/transition_defaults_test.go +156 -0
  86. package/internal/task/jira/validation_test.go +94 -0
  87. package/internal/task/task.go +84 -0
  88. package/internal/workflow/report.go +85 -0
  89. package/internal/workflow/report_test.go +259 -0
  90. package/internal/workflow/service.go +142 -0
  91. package/internal/workflow/store.go +136 -0
  92. package/internal/workflow/store_test.go +282 -0
  93. package/internal/workflow/workflow.go +342 -0
  94. package/internal/workflow/workflow_test.go +410 -0
  95. package/package.json +1 -1
  96. package/internal/acli/acli.go +0 -229
  97. package/internal/config/demo_test.go +0 -17
  98. package/internal/config/schema.go +0 -193
  99. package/internal/config/schema_test.go +0 -162
  100. package/internal/daemon/daemon.go +0 -218
  101. package/internal/daemon/daemon_test.go +0 -204
  102. package/internal/discovery/discovery.go +0 -122
  103. package/internal/discovery/discovery_test.go +0 -62
  104. package/internal/opencode/opencode.go +0 -26
  105. package/internal/orcacli/orcacli.go +0 -264
  106. package/internal/runner/orca/README.md +0 -64
  107. package/internal/runner/runner_test.go +0 -64
  108. package/internal/server/server_test.go +0 -195
  109. package/internal/tasks/jira/README.md +0 -69
  110. package/internal/tasks/jira/component_test.go +0 -16
  111. package/internal/tasks/jira/decode.go +0 -24
  112. package/internal/tasks/jira/jira.go +0 -231
  113. package/internal/tasks/jira/jira_test.go +0 -259
  114. package/internal/tasks/jira/jql_test.go +0 -16
  115. package/internal/tasks/tasks.go +0 -90
  116. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,609 @@
1
+ package main
2
+
3
+ // Composition root for `relay-flow serve` (task 5.1). Explicit ordered Go
4
+ // wiring per docs/structs-methods-interfaces.md "Startup Wiring"
5
+ // (lines 1081-1097) and design.md decision 24. The flock on server.lock is
6
+ // acquired FIRST; startup refuses to run if another relay-flow process
7
+ // holds it. No container, no DI framework.
8
+
9
+ import (
10
+ "context"
11
+ "errors"
12
+ "fmt"
13
+ "log/slog"
14
+ "net"
15
+ "net/http"
16
+ "os"
17
+ "strings"
18
+ "sync"
19
+ "syscall"
20
+ "time"
21
+
22
+ "github.com/rajpopat27/relay-flow/internal/config"
23
+ "github.com/rajpopat27/relay-flow/internal/execution/goworkflows"
24
+ "github.com/rajpopat27/relay-flow/internal/harness"
25
+ "github.com/rajpopat27/relay-flow/internal/paths"
26
+ recoverpkg "github.com/rajpopat27/relay-flow/internal/recover"
27
+ "github.com/rajpopat27/relay-flow/internal/repo"
28
+ "github.com/rajpopat27/relay-flow/internal/router"
29
+ runsvc "github.com/rajpopat27/relay-flow/internal/run"
30
+ "github.com/rajpopat27/relay-flow/internal/runner"
31
+ "github.com/rajpopat27/relay-flow/internal/server"
32
+ "github.com/rajpopat27/relay-flow/internal/task"
33
+ "github.com/rajpopat27/relay-flow/internal/workflow"
34
+
35
+ // Adapter registrations (factories are registered by name via init).
36
+ _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
37
+ _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
38
+ _ "github.com/rajpopat27/relay-flow/internal/task/jira"
39
+ )
40
+
41
+ // serveRoot is the composition root. It runs the documented startup order
42
+ // and blocks until ctx is canceled, then shuts down in reverse.
43
+ //
44
+ // Order (docs/structs-methods-interfaces.md lines 1081-1097, verbatim):
45
+ //
46
+ // flock → load machine config → select task/runner/harness factories →
47
+ // construct shared runner/harness → load repos + one task.System per repo →
48
+ // load workflow files → validate each workflow against every referenced
49
+ // repo task system → bind workflows+matchers to repos → open go-workflows
50
+ // SQLite engine and start its workers → construct the Run Manager →
51
+ // start the Repo Poller group → start the Unix-socket server.
52
+ func serveRoot(ctx context.Context, p paths.Paths, recover bool) error {
53
+ // Flock FIRST (docs lines 1028/1142): refuse to start if another
54
+ // process holds it. The only pre-flock side effect is creating the
55
+ // Root directory so the lock file itself can exist; nothing else in
56
+ // the layout is touched until the lock is held.
57
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
58
+ return fmt.Errorf("create root %s: %w", p.Root, err)
59
+ }
60
+ if err := os.Chmod(p.Root, 0o700); err != nil {
61
+ return fmt.Errorf("chmod root %s: %w", p.Root, err)
62
+ }
63
+ lockFile, err := os.OpenFile(p.Lock, os.O_CREATE|os.O_RDWR, 0o600)
64
+ if err != nil {
65
+ return fmt.Errorf("open lock %s: %w", p.Lock, err)
66
+ }
67
+ defer lockFile.Close()
68
+ if err := lockFile.Chmod(0o600); err != nil {
69
+ return fmt.Errorf("chmod lock %s: %w", p.Lock, err)
70
+ }
71
+ if err := syscall.Flock(int(lockFile.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
72
+ return fmt.Errorf("another relay-flow server holds %s", p.Lock)
73
+ }
74
+ defer func() { _ = syscall.Flock(int(lockFile.Fd()), syscall.LOCK_UN) }()
75
+
76
+ // Single-instance held; materialize the rest of the layout.
77
+ if err := paths.Ensure(p); err != nil {
78
+ return err
79
+ }
80
+
81
+ // Load machine config.
82
+ cfg, err := config.LoadMachine(p.Config)
83
+ if err != nil {
84
+ return err
85
+ }
86
+
87
+ // Select factories; construct shared runner and harness.
88
+ rnr, err := runner.New(cfg.RunnerPlugin, cfg.RunnerConfig)
89
+ if err != nil {
90
+ return fmt.Errorf("runner plugin %q: %w", cfg.RunnerPlugin, err)
91
+ }
92
+ hrn, err := harness.New(cfg.HarnessPlugin, cfg.HarnessConfig)
93
+ if err != nil {
94
+ return fmt.Errorf("harness plugin %q: %w", cfg.HarnessPlugin, err)
95
+ }
96
+
97
+ // Load registered repos and one task.System per repo. The registry is
98
+ // shared with repo.Service (constructed below) so the engine, pollers,
99
+ // and server handlers observe the same in-memory set.
100
+ repoReg := repo.NewRegistry()
101
+ for name, rc := range cfg.Repos {
102
+ ts, err := task.New(ctx, cfg.TaskPlugin, task.RepoSpec{
103
+ Name: name,
104
+ Path: rc.Path,
105
+ RootConfig: cfg.TaskConfig,
106
+ RepoConfig: rc.TaskConfig,
107
+ })
108
+ if err != nil {
109
+ return fmt.Errorf("repo %q task system: %w", name, err)
110
+ }
111
+ repoReg.Replace(&repo.Repo{
112
+ Name: name,
113
+ Path: rc.Path,
114
+ TaskConfig: rc.TaskConfig,
115
+ TaskSystem: ts,
116
+ })
117
+ }
118
+
119
+ // Load workflow files.
120
+ store := &workflow.Store{Dir: p.Workflows}
121
+ workflowList, err := store.LoadAll()
122
+ if err != nil {
123
+ return fmt.Errorf("load workflows: %w", err)
124
+ }
125
+
126
+ // Validate each workflow structurally (lifecycle nodes, routes,
127
+ // reachability, nudge templates) before any per-repo validation or
128
+ // binding; an invalid stored workflow must fail startup, not bind.
129
+ for _, wf := range workflowList {
130
+ if err := wf.Validate(); err != nil {
131
+ return fmt.Errorf("workflow %q: %w", wf.Name, err)
132
+ }
133
+ }
134
+
135
+ // Validate each workflow against every referenced repo task system.
136
+ for _, wf := range workflowList {
137
+ nodeCfgs := map[string]config.RawValues{}
138
+ for nodeName, n := range wf.Nodes {
139
+ nodeCfgs[nodeName] = n.TaskConfig
140
+ }
141
+ for _, repoName := range wf.Repos {
142
+ rp, ok := repoReg.Get(repoName)
143
+ if !ok {
144
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
145
+ }
146
+ if err := rp.TaskSystem.ValidateConfig(ctx, wf.TaskConfig, nodeCfgs); err != nil {
147
+ return fmt.Errorf("workflow %q repo %q: %w", wf.Name, repoName, err)
148
+ }
149
+ }
150
+ }
151
+
152
+ // Bind workflows and compiled matchers to repos.
153
+ if err := repoReg.BindWorkflows(workflowList); err != nil {
154
+ return err
155
+ }
156
+
157
+ // 5.5 normal-start refusal: serve REQUIRES an initialized database
158
+ // (init creates it); --recover is the explicit opt-out.
159
+ if !recover {
160
+ if _, err := os.Stat(p.Database); err != nil {
161
+ if os.IsNotExist(err) {
162
+ return fmt.Errorf("database %s is missing; run `relay-flow init` first, or `relay-flow serve --recover` to rebuild from the task system", p.Database)
163
+ }
164
+ return fmt.Errorf("stat database %s: %w", p.Database, err)
165
+ }
166
+ }
167
+
168
+ // 5.6 (decision 22): --recover replaces the execution state BEFORE any
169
+ // worker starts, with two explicit guarantees:
170
+ // (a) The flock acquired at the top of serveRoot proves no other
171
+ // relay-flow process has this database open — relay-flow is the
172
+ // only process that ever opens state.db, and we hold server.lock.
173
+ // (b) The old database is moved aside as a timestamped sibling (never
174
+ // destroyed in place) and the destination path is verified absent
175
+ // before goworkflows.New runs. Engine.New therefore opens a
176
+ // provably empty database, and Engine.Start has no prior history
177
+ // to resume.
178
+ if recover {
179
+ stamp := time.Now().UTC().Format("20060102T150405Z")
180
+ for _, suffix := range []string{"", "-wal", "-shm"} {
181
+ src := p.Database + suffix
182
+ if _, err := os.Stat(src); os.IsNotExist(err) {
183
+ continue
184
+ }
185
+ dst := fmt.Sprintf("%s.recover-%s.bak%s", p.Database, stamp, suffix)
186
+ if err := os.Rename(src, dst); err != nil {
187
+ return fmt.Errorf("preserve stale database %s as %s: %w", src, dst, err)
188
+ }
189
+ }
190
+ // Provably-empty engine: the primary DB path MUST NOT exist before
191
+ // engine construction. If it does, the rename-aside above failed
192
+ // silently for an unexpected reason; refuse to start rather than
193
+ // resume stale state.
194
+ if _, err := os.Stat(p.Database); err == nil {
195
+ return fmt.Errorf("recover: %s still exists after rename-aside; refusing to open possibly-stale database", p.Database)
196
+ } else if !os.IsNotExist(err) {
197
+ return fmt.Errorf("recover: stat %s: %w", p.Database, err)
198
+ }
199
+ }
200
+
201
+ // Open the go-workflows SQLite engine (migrates relay_runs; does not
202
+ // yet start workers).
203
+ engine, err := goworkflows.New(p.Database, goworkflows.Dependencies{
204
+ Repos: repoReg,
205
+ Runner: rnr,
206
+ Harness: hrn,
207
+ RetentionDays: cfg.CompletedRunRetentionDays,
208
+ Runtime: &runsvc.RuntimePolicy{
209
+ KeepTerminalsAlive: cfg.KeepTerminalsAlive,
210
+ KeepSessionsAlive: cfg.KeepSessionsAlive,
211
+ },
212
+ })
213
+ if err != nil {
214
+ return fmt.Errorf("open engine: %w", err)
215
+ }
216
+
217
+ // 5.2 fail-fast preflight: before workers/pollers start, validate
218
+ // task-system, runner, and harness credentials/permissions/connectivity,
219
+ // every registered repo, and every configured agent. Known permanent
220
+ // errors abort startup; runtime failures of existing runs retry forever
221
+ // (design.md decision 16).
222
+ if err := preflight(ctx, repoReg, rnr, hrn, workflowList); err != nil {
223
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
224
+ _ = engine.Shutdown(shutdownCtx)
225
+ cancel()
226
+ return fmt.Errorf("startup validation: %w", err)
227
+ }
228
+
229
+ // Start engine workers and the one-shot startup retention sweep
230
+ // (3.25; runs once here, no background ticker).
231
+ if err := engine.Start(ctx); err != nil {
232
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
233
+ _ = engine.Shutdown(shutdownCtx)
234
+ cancel()
235
+ return fmt.Errorf("start engine: %w", err)
236
+ }
237
+
238
+ // 5.4 lifecycle gate: ONE sync.Mutex shared by workflow.Service
239
+ // Submit/Remove and RunManager.EnsureRun (design.md decision 23). Plain
240
+ // mutex in the wiring — no lock service, no new abstraction.
241
+ lifecycleGate := &sync.Mutex{}
242
+
243
+ // Construct the Run Manager.
244
+ runManager := &runsvc.RunManager{Executor: engine, Runs: engine, Gate: lifecycleGate}
245
+
246
+ // Services consumed by the Unix-socket server. The repo.Service takes
247
+ // the SAME registry by pointer (via replaceInternal), so the engine,
248
+ // pollers, and handlers observe one in-memory repo set.
249
+ wfSvc := workflow.NewService(store, engine, repoExists{repoReg})
250
+ wfSvc.Gate = lifecycleGate
251
+ wfSvc.ValidateTaskConfig = workflowConfigValidator(repoReg)
252
+ // Submit/Remove must also rebuild repo bindings under the gate (spec
253
+ // 3.34: bindings rebuilt on submit/remove/startup). Wired as a plain
254
+ // callback — no dispatcher.
255
+ wfSvc.Rebind = func() error {
256
+ return repoReg.BindWorkflows(wfSvc.Registry().List())
257
+ }
258
+ for _, wf := range workflowList {
259
+ wfSvc.Registry().Replace(wf)
260
+ }
261
+ repoSvc := repo.NewServiceWithRegistry(repo.ServiceConfig{
262
+ ConfigPath: p.Config,
263
+ TaskPlugin: cfg.TaskPlugin,
264
+ Runner: rnr,
265
+ Active: engine,
266
+ Workflows: wfSvc.Registry(),
267
+ }, repoReg)
268
+
269
+ // 5.6 database-loss recovery (explicit --recover only; never inferred).
270
+ // Runs after the engine is open and BEFORE normal pollers start so the
271
+ // recovered runs exist before any poll cycle observes them. Mailbox
272
+ // specs come from the engine so the recover path builds the same
273
+ // description content as normal run execution.
274
+ if recover {
275
+ specsFor := func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
276
+ return goworkflows.MailboxSpecs(wf, ticketKey)
277
+ }
278
+ if err := recoverpkg.FromTaskSystem(ctx, repoReg, rnr, runManager, specsFor); err != nil {
279
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
280
+ _ = engine.Shutdown(shutdownCtx)
281
+ cancel()
282
+ return fmt.Errorf("recover: %w", err)
283
+ }
284
+ }
285
+
286
+ // Start the Repo Poller group with the handleBatch callback (5.3,
287
+ // docs/structs-methods-interfaces.md lines 1099-1117 verbatim). No
288
+ // dispatcher type, no extra abstraction.
289
+ pollers := repo.NewPollerGroup(10, handleBatch(runManager))
290
+ pollers.Interval = time.Duration(cfg.PollIntervalSeconds) * time.Second
291
+ pollers.ReplaceRepos(repoSvc.Registry().List())
292
+ pollerCtx, cancelPollers := context.WithCancel(context.Background())
293
+ pollersDone := make(chan struct{})
294
+ go func() {
295
+ pollers.Run(pollerCtx)
296
+ close(pollersDone)
297
+ }()
298
+
299
+ // cleanup is the single bounded shutdown path (5.5). Order:
300
+ // 1. Stop accepting new polls AND new HTTP requests immediately.
301
+ // 2. Wait (within one 30s budget) for in-flight polls and in-flight
302
+ // HTTP calls to return.
303
+ // 3. Cancel worker polling and close SQLite via engine.Shutdown.
304
+ // Every termination path — signal, /stop, listener error, startup
305
+ // failure after pollers launched — funnels through here so no path
306
+ // closes the database under running handlers or skips poller join.
307
+ cleanup := func(httpServer *http.Server, serveErr chan error, serveConsumed bool) {
308
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
309
+ defer cancel()
310
+ // Stop new work immediately: no new polls, no new HTTP requests.
311
+ cancelPollers()
312
+ if httpServer != nil {
313
+ _ = httpServer.Shutdown(shutdownCtx)
314
+ }
315
+ // Wait for in-flight HTTP within the budget.
316
+ if httpServer != nil && !serveConsumed {
317
+ select {
318
+ case <-serveErr:
319
+ case <-shutdownCtx.Done():
320
+ }
321
+ }
322
+ // Wait for in-flight pollers within the same 30s budget (spec:
323
+ // "waits up to 30 seconds ... then close the socket and database").
324
+ // PollerGroup.Run returns once every in-flight handleBatch handler
325
+ // completes; on deadline we proceed to engine.Shutdown anyway so
326
+ // shutdown is bounded — the spec's rule is that already-running
327
+ // external work is not interruptible and durable state must remain
328
+ // recoverable (engine history is authoritative; a handler that
329
+ // survives past deadline hits a closed-DB error and logs it).
330
+ select {
331
+ case <-pollersDone:
332
+ case <-shutdownCtx.Done():
333
+ }
334
+ // Cancel worker polling and close the database. Engine.Shutdown
335
+ // uses the shared budget for its own bounded wait on active
336
+ // workflow/activity tasks.
337
+ _ = engine.Shutdown(shutdownCtx)
338
+ }
339
+
340
+ // Start the Unix-socket server.
341
+ _ = os.Remove(p.Socket)
342
+ listener, err := net.Listen("unix", p.Socket)
343
+ if err != nil {
344
+ cleanup(nil, nil, true)
345
+ return fmt.Errorf("listen %s: %w", p.Socket, err)
346
+ }
347
+ if err := os.Chmod(p.Socket, 0o600); err != nil {
348
+ listener.Close()
349
+ _ = os.Remove(p.Socket) // leave no stale socket file on failure
350
+ cleanup(nil, nil, true)
351
+ return fmt.Errorf("chmod %s: %w", p.Socket, err)
352
+ }
353
+
354
+ // /stop and ctx cancellation funnel into stopCtx; both end at the
355
+ // single cleanup path.
356
+ stopCtx, stopServe := context.WithCancel(context.Background())
357
+ defer stopServe()
358
+
359
+ deps := &serveDeps{
360
+ wf: wfSvc,
361
+ repos: repoSvc,
362
+ engine: engine,
363
+ runManager: runManager,
364
+ // Repo register/remove must reach the running poller group — the
365
+ // startup ReplaceRepos only covers repos present at boot. Re-sync
366
+ // after every successful mutation so a newly registered repo is
367
+ // polled on the next tick without a serve restart.
368
+ onReposChanged: func() {
369
+ pollers.ReplaceRepos(repoSvc.Registry().List())
370
+ },
371
+ shutdown: func(context.Context) error {
372
+ stopServe()
373
+ return nil
374
+ },
375
+ }
376
+ httpServer := &http.Server{Handler: server.New(deps)}
377
+
378
+ serveErr := make(chan error, 1)
379
+ go func() { serveErr <- httpServer.Serve(listener) }()
380
+
381
+ var serveResult error
382
+ serveConsumed := false
383
+ select {
384
+ case <-ctx.Done():
385
+ case <-stopCtx.Done():
386
+ case err := <-serveErr:
387
+ serveConsumed = true
388
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
389
+ serveResult = fmt.Errorf("serve %s: %w", p.Socket, err)
390
+ }
391
+ // Listener closed on its own — treat as normal shutdown request.
392
+ }
393
+
394
+ cleanup(httpServer, serveErr, serveConsumed)
395
+ return serveResult
396
+ }
397
+
398
+ func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *workflow.Workflow) error {
399
+ return func(ctx context.Context, wf *workflow.Workflow) error {
400
+ nodeCfgs := map[string]config.RawValues{}
401
+ for nodeName, n := range wf.Nodes {
402
+ nodeCfgs[nodeName] = n.TaskConfig
403
+ }
404
+ for _, repoName := range wf.Repos {
405
+ rp, ok := repoReg.Get(repoName)
406
+ if !ok {
407
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
408
+ }
409
+ if err := rp.TaskSystem.ValidateConfig(ctx, wf.TaskConfig, nodeCfgs); err != nil {
410
+ return fmt.Errorf("workflow %q repo %q: %w", wf.Name, repoName, err)
411
+ }
412
+ }
413
+ return nil
414
+ }
415
+ }
416
+
417
+ // repoExists adapts *repo.Registry to workflow.RepoLookup (Exists).
418
+ type repoExists struct{ reg *repo.Registry }
419
+
420
+ func (r repoExists) Exists(name string) bool {
421
+ _, ok := r.reg.Get(name)
422
+ return ok
423
+ }
424
+
425
+ // serveDeps adapts composition-root services to server.Deps. Thin forwarder;
426
+ // no logic. Signatures match docs/structs-methods-interfaces.md Client.
427
+ type serveDeps struct {
428
+ wf *workflow.Service
429
+ repos *repo.Service
430
+ engine *goworkflows.Engine
431
+ runManager *runsvc.RunManager
432
+ onReposChanged func()
433
+ shutdown func(context.Context) error
434
+ }
435
+
436
+ func (d *serveDeps) SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.Workflow, error) {
437
+ return d.wf.Submit(ctx, yaml)
438
+ }
439
+ func (d *serveDeps) GetWorkflow(_ context.Context, name string) (*workflow.Workflow, error) {
440
+ return d.wf.Get(name)
441
+ }
442
+ func (d *serveDeps) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
443
+ return d.wf.List(), nil
444
+ }
445
+ func (d *serveDeps) RemoveWorkflow(ctx context.Context, name string) error {
446
+ return d.wf.Remove(ctx, name)
447
+ }
448
+
449
+ func (d *serveDeps) ListRuns(ctx context.Context, filter runsvc.Filter) ([]runsvc.Run, error) {
450
+ return d.engine.ListRuns(ctx, filter)
451
+ }
452
+ func (d *serveDeps) GetRunByTicket(ctx context.Context, ticket string) (runsvc.Run, error) {
453
+ return d.engine.FindRunByTicket(ctx, ticket)
454
+ }
455
+ func (d *serveDeps) CancelRun(ctx context.Context, ticket, reason string) error {
456
+ return d.runManager.CancelByTicket(ctx, ticket, reason)
457
+ }
458
+
459
+ func (d *serveDeps) SubmitReport(ctx context.Context, rep runsvc.ReportRequest) (runsvc.ReportAck, error) {
460
+ return d.engine.SubmitReport(ctx, rep)
461
+ }
462
+ func (d *serveDeps) HasProcessedReport(ctx context.Context, id runsvc.ID, reportID string) (bool, error) {
463
+ return d.engine.HasProcessedReport(ctx, id, reportID)
464
+ }
465
+ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc.NodeRuntimeRegistration) (runsvc.NodeRuntimeRegistrationAck, error) {
466
+ return d.engine.RegisterNodeSession(ctx, registration)
467
+ }
468
+
469
+ func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
470
+ return d.repos.Discover(ctx)
471
+ }
472
+ func (d *serveDeps) TaskFields(context.Context) ([]string, error) {
473
+ return d.repos.RequiredRepoKeys(), nil
474
+ }
475
+ func (d *serveDeps) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
476
+ info, err := d.repos.Register(ctx, input)
477
+ if err == nil && d.onReposChanged != nil {
478
+ d.onReposChanged()
479
+ }
480
+ return info, err
481
+ }
482
+ func (d *serveDeps) ListRepos(context.Context) ([]repo.Info, error) {
483
+ return d.repos.List(), nil
484
+ }
485
+ func (d *serveDeps) GetRepo(_ context.Context, name string) (repo.Info, error) {
486
+ return d.repos.Get(name)
487
+ }
488
+ func (d *serveDeps) RemoveRepo(ctx context.Context, name string) error {
489
+ err := d.repos.Remove(ctx, name)
490
+ if err == nil && d.onReposChanged != nil {
491
+ d.onReposChanged()
492
+ }
493
+ return err
494
+ }
495
+
496
+ func (d *serveDeps) Shutdown(ctx context.Context) error { return d.shutdown(ctx) }
497
+
498
+ // handleBatch is the Repo Poller group callback (5.3). Implemented verbatim
499
+ // from docs/structs-methods-interfaces.md lines 1101-1117: per ticket,
500
+ // ResolveWorkflow; ErrNoMatch → continue; other routing errors → log and
501
+ // continue (mutate nothing); success → RunManager.EnsureRun, log its errors.
502
+ // No dispatcher type, no extra abstraction. A small closure captures the
503
+ // RunManager so the repo.BatchHandler signature is satisfied.
504
+ //
505
+ // 9.2 poller logging: one info line per repo poll cycle (batch size), one
506
+ // info line per ticket with the routing outcome, one debug line per ticket
507
+ // with normalized fields. EnsureRun emits its own outcome lines from
508
+ // internal/run/manager.go.
509
+ func handleBatch(runManager *runsvc.RunManager) repo.BatchHandler {
510
+ return func(ctx context.Context, rp *repo.Repo, tickets []task.Ticket) {
511
+ slog.Info("poll cycle", "repo", rp.Name, "batch", len(tickets))
512
+ for _, ticket := range tickets {
513
+ slog.Debug("poll ticket",
514
+ "repo", rp.Name, "ticket", ticket.Key, "id", ticket.ID,
515
+ "title", ticket.Title, "claims", strings.Join(ticket.WorkflowClaims, ","))
516
+ wf, err := router.ResolveWorkflow(rp, ticket)
517
+ switch {
518
+ case errors.Is(err, router.ErrNoMatch):
519
+ slog.Info("route outcome",
520
+ "repo", rp.Name, "ticket", ticket.Key, "outcome", "no-match")
521
+ continue
522
+ case err != nil:
523
+ var amb *router.AmbiguousError
524
+ var inv *router.InvalidClaimError
525
+ outcome := "error"
526
+ switch {
527
+ case errors.As(err, &amb):
528
+ outcome = "ambiguous"
529
+ case errors.As(err, &inv):
530
+ outcome = "invalid-claim"
531
+ }
532
+ slog.Info("route outcome",
533
+ "repo", rp.Name, "ticket", ticket.Key, "outcome", outcome, "error", err)
534
+ continue
535
+ }
536
+ routeOutcome := "claimed"
537
+ for _, c := range ticket.WorkflowClaims {
538
+ if c == "wf:"+wf.Name {
539
+ routeOutcome = "already-claimed"
540
+ break
541
+ }
542
+ }
543
+ slog.Info("route outcome",
544
+ "repo", rp.Name, "ticket", ticket.Key, "workflow", wf.Name, "outcome", routeOutcome)
545
+ if err := runManager.EnsureRun(ctx, rp, wf, ticket); err != nil {
546
+ // EnsureRun already logged the outcome; nothing to add here.
547
+ _ = err
548
+ }
549
+ }
550
+ }
551
+ }
552
+
553
+ // preflight is the 5.2 fail-fast startup validation. It runs after the
554
+ // engine is open and BEFORE workers/pollers start. Permanent errors abort
555
+ // startup; runtime errors of existing runs retry forever (different rule).
556
+ //
557
+ // Probes are no-side-effect reads on each adapter boundary:
558
+ // - runner: ValidateRepo per registered repo (Orca connectivity + repo
559
+ // presence at the configured path).
560
+ // - task system: Poll per repo (credentials/permissions/connectivity;
561
+ // returns active parents only, mutates nothing).
562
+ // - harness: ValidateAgent per (repo, workflow) agent node (configured
563
+ // agent must exist for that repo).
564
+ //
565
+ // Repos with no workflows still validate runner + task connectivity; agent
566
+ // validation runs once per distinct (repoPath, agent) pair to avoid
567
+ // redundant CLI calls when several workflows share an agent on one repo.
568
+ func preflight(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, hrn harness.Harness, workflows []*workflow.Workflow) error {
569
+ for _, rp := range repoReg.List() {
570
+ if err := rnr.ValidateRepo(ctx, rp.Name, rp.Path); err != nil {
571
+ return fmt.Errorf("repo %q runner: %w", rp.Name, err)
572
+ }
573
+ if _, err := rp.TaskSystem.Poll(ctx); err != nil {
574
+ return fmt.Errorf("repo %q task system: %w", rp.Name, err)
575
+ }
576
+ }
577
+ type agentProbe struct {
578
+ repoPath string
579
+ agent string
580
+ }
581
+ seen := map[agentProbe]bool{}
582
+ for _, wf := range workflows {
583
+ for nodeName, n := range wf.Nodes {
584
+ // Validate EVERY configured agent (agent and HITL work nodes
585
+ // both declare one); skip start/end which never carry Agent.
586
+ if (n.Type != workflow.NodeAgent && n.Type != workflow.NodeHITL) || n.Agent == "" {
587
+ continue
588
+ }
589
+ for _, repoName := range wf.Repos {
590
+ rp, ok := repoReg.Get(repoName)
591
+ if !ok {
592
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
593
+ }
594
+ key := agentProbe{repoPath: rp.Path, agent: n.Agent}
595
+ if seen[key] {
596
+ continue
597
+ }
598
+ seen[key] = true
599
+ if err := hrn.ValidateAgent(ctx, rp.Path, n.Agent); err != nil {
600
+ return fmt.Errorf("workflow %q node %q repo %q: %w", wf.Name, nodeName, repoName, err)
601
+ }
602
+ }
603
+ }
604
+ }
605
+ return nil
606
+ }
607
+
608
+ // The recover composition lives in internal/recover (recoverpkg.FromTaskSystem)
609
+ // so it is importable by both serve and the recover test — see task 6.3.
package/go.mod CHANGED
@@ -1,5 +1,72 @@
1
1
  module github.com/rajpopat27/relay-flow
2
2
 
3
- go 1.21.11
3
+ go 1.24.6
4
4
 
5
- require gopkg.in/yaml.v3 v3.0.1 // indirect
5
+ require (
6
+ github.com/cschleiden/go-workflows v1.4.2
7
+ github.com/google/renameio/v2 v2.0.1
8
+ github.com/google/uuid v1.6.0
9
+ gopkg.in/yaml.v3 v3.0.1
10
+ )
11
+
12
+ require (
13
+ github.com/atotto/clipboard v0.1.4 // indirect
14
+ github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
15
+ github.com/benbjohnson/clock v1.3.0 // indirect
16
+ github.com/catppuccin/go v0.3.0 // indirect
17
+ github.com/cenkalti/backoff/v4 v4.3.0 // indirect
18
+ github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
19
+ github.com/charmbracelet/bubbletea v1.3.6 // indirect
20
+ github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
21
+ github.com/charmbracelet/huh v1.0.0 // indirect
22
+ github.com/charmbracelet/lipgloss v1.1.0 // indirect
23
+ github.com/charmbracelet/x/ansi v0.9.3 // indirect
24
+ github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
25
+ github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
26
+ github.com/charmbracelet/x/term v0.2.1 // indirect
27
+ github.com/davecgh/go-spew v1.1.1 // indirect
28
+ github.com/dustin/go-humanize v1.0.1 // indirect
29
+ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
30
+ github.com/go-errors/errors v1.4.2 // indirect
31
+ github.com/go-logr/logr v1.4.2 // indirect
32
+ github.com/go-logr/stdr v1.2.2 // indirect
33
+ github.com/golang-migrate/migrate/v4 v4.16.2 // indirect
34
+ github.com/hashicorp/errwrap v1.1.0 // indirect
35
+ github.com/hashicorp/go-multierror v1.1.1 // indirect
36
+ github.com/jellydator/ttlcache/v3 v3.0.0 // indirect
37
+ github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
38
+ github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
39
+ github.com/mattn/go-isatty v0.0.20 // indirect
40
+ github.com/mattn/go-localereader v0.0.1 // indirect
41
+ github.com/mattn/go-runewidth v0.0.16 // indirect
42
+ github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
43
+ github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
44
+ github.com/muesli/cancelreader v0.2.2 // indirect
45
+ github.com/muesli/termenv v0.16.0 // indirect
46
+ github.com/pmezard/go-difflib v1.0.0 // indirect
47
+ github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
48
+ github.com/rivo/uniseg v0.4.7 // indirect
49
+ github.com/stretchr/objx v0.5.2 // indirect
50
+ github.com/stretchr/testify v1.10.0 // indirect
51
+ github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
52
+ go.opentelemetry.io/otel v1.31.0 // indirect
53
+ go.opentelemetry.io/otel/metric v1.31.0 // indirect
54
+ go.opentelemetry.io/otel/sdk v1.31.0 // indirect
55
+ go.opentelemetry.io/otel/trace v1.31.0 // indirect
56
+ go.uber.org/atomic v1.11.0 // indirect
57
+ golang.org/x/mod v0.28.0 // indirect
58
+ golang.org/x/sync v0.17.0 // indirect
59
+ golang.org/x/sys v0.36.0 // indirect
60
+ golang.org/x/text v0.29.0 // indirect
61
+ golang.org/x/tools v0.37.0 // indirect
62
+ lukechampine.com/uint128 v1.2.0 // indirect
63
+ modernc.org/cc/v3 v3.40.0 // indirect
64
+ modernc.org/ccgo/v3 v3.16.13 // indirect
65
+ modernc.org/libc v1.29.0 // indirect
66
+ modernc.org/mathutil v1.6.0 // indirect
67
+ modernc.org/memory v1.7.2 // indirect
68
+ modernc.org/opt v0.1.3 // indirect
69
+ modernc.org/sqlite v1.27.0 // indirect
70
+ modernc.org/strutil v1.1.3 // indirect
71
+ modernc.org/token v1.0.1 // indirect
72
+ )