relay-flow 0.1.0-alpha.0 → 0.2.1-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 (118) hide show
  1. package/README.md +160 -143
  2. package/cmd/relay-flow/commands_test.go +968 -0
  3. package/cmd/relay-flow/main.go +877 -175
  4. package/cmd/relay-flow/scenario_test.go +1310 -0
  5. package/cmd/relay-flow/serve.go +610 -0
  6. package/examples/default-story-workflow.yaml +88 -0
  7. package/go.mod +69 -2
  8. package/go.sum +185 -0
  9. package/internal/config/config.go +88 -0
  10. package/internal/config/machine.go +99 -48
  11. package/internal/config/machine_test.go +248 -0
  12. package/internal/config/merge_test.go +118 -0
  13. package/internal/config/writeatomic.go +36 -0
  14. package/internal/config/writeatomic_test.go +98 -0
  15. package/internal/execution/goworkflows/activities.go +490 -0
  16. package/internal/execution/goworkflows/engine.go +520 -0
  17. package/internal/execution/goworkflows/engine_test.go +660 -0
  18. package/internal/execution/goworkflows/fakes_test.go +509 -0
  19. package/internal/execution/goworkflows/interpreter.go +613 -0
  20. package/internal/execution/goworkflows/logging_test.go +154 -0
  21. package/internal/execution/goworkflows/mailbox_test.go +423 -0
  22. package/internal/execution/goworkflows/node_runtime_integration_test.go +133 -0
  23. package/internal/execution/goworkflows/node_runtime_test.go +510 -0
  24. package/internal/execution/goworkflows/projection.go +504 -0
  25. package/internal/execution/goworkflows/recovery_test.go +1092 -0
  26. package/internal/execution/goworkflows/retry_log_test.go +59 -0
  27. package/internal/execution/goworkflows/retry_projection_test.go +98 -0
  28. package/internal/harness/contract_test.go +174 -0
  29. package/internal/harness/factory.go +63 -0
  30. package/internal/harness/harness.go +41 -0
  31. package/internal/harness/opencode/opencode.go +166 -0
  32. package/internal/harness/opencode/opencode_test.go +50 -0
  33. package/internal/harness/plugin_selection_test.go +126 -0
  34. package/internal/identity/identity.go +37 -0
  35. package/internal/logging/logging.go +56 -0
  36. package/internal/logging/logging_test.go +116 -0
  37. package/internal/paths/paths.go +69 -0
  38. package/internal/recover/recover.go +115 -0
  39. package/internal/repo/poller.go +186 -0
  40. package/internal/repo/poller_test.go +327 -0
  41. package/internal/repo/repo.go +132 -0
  42. package/internal/repo/service.go +216 -0
  43. package/internal/repo/service_test.go +298 -0
  44. package/internal/retry/retry.go +118 -0
  45. package/internal/router/router.go +84 -0
  46. package/internal/router/router_test.go +231 -0
  47. package/internal/run/manager.go +121 -0
  48. package/internal/run/run.go +140 -0
  49. package/internal/run/run_identity_test.go +52 -0
  50. package/internal/run/run_manager_test.go +286 -0
  51. package/internal/runner/contract_test.go +259 -0
  52. package/internal/runner/factory.go +65 -0
  53. package/internal/runner/orca/orca.go +341 -172
  54. package/internal/runner/orca/orca_test.go +256 -143
  55. package/internal/runner/orca/orcacli/orcacli.go +220 -0
  56. package/internal/runner/orca/orcacli/orcacli_test.go +157 -0
  57. package/internal/runner/orca/orcacli/testdata/repo-list.json +18 -0
  58. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +32 -0
  59. package/internal/runner/orca/orcacli/testdata/terminal-close.json +12 -0
  60. package/internal/runner/orca/orcacli/testdata/terminal-create.json +18 -0
  61. package/internal/runner/orca/orcacli/testdata/terminal-list.json +51 -0
  62. package/internal/runner/orca/orcacli/testdata/terminal-send.json +1 -0
  63. package/internal/runner/orca/orcacli/testdata/terminal-show.json +1 -0
  64. package/internal/runner/orca/orcacli/testdata/worktree-create.json +22 -0
  65. package/internal/runner/orca/orcacli/testdata/worktree-list.json +31 -0
  66. package/internal/runner/orca/orcacli/testdata/worktree-remove.json +6 -0
  67. package/internal/runner/runner.go +54 -64
  68. package/internal/server/api_test.go +300 -0
  69. package/internal/server/client.go +192 -74
  70. package/internal/server/fixture_test.go +248 -0
  71. package/internal/server/server.go +425 -248
  72. package/internal/server/shutdown_test.go +116 -0
  73. package/internal/task/auth_test.go +48 -0
  74. package/internal/task/contract_test.go +225 -0
  75. package/internal/task/factory.go +119 -0
  76. package/internal/task/jira/auth.go +183 -0
  77. package/internal/task/jira/auth_test.go +107 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +254 -0
  80. package/internal/task/jira/helpers_test.go +70 -0
  81. package/internal/task/jira/jira.go +538 -0
  82. package/internal/task/jira/normalize.go +119 -0
  83. package/internal/task/jira/rest/adf.go +128 -0
  84. package/internal/task/jira/rest/client.go +573 -0
  85. package/internal/task/jira/rest/client_test.go +381 -0
  86. package/internal/task/jira/testdata/jira_search_issues.json +120 -0
  87. package/internal/task/jira/transition_defaults_test.go +158 -0
  88. package/internal/task/jira/validation_test.go +94 -0
  89. package/internal/task/task.go +84 -0
  90. package/internal/workflow/report.go +85 -0
  91. package/internal/workflow/report_test.go +259 -0
  92. package/internal/workflow/service.go +142 -0
  93. package/internal/workflow/store.go +136 -0
  94. package/internal/workflow/store_test.go +282 -0
  95. package/internal/workflow/workflow.go +345 -0
  96. package/internal/workflow/workflow_test.go +412 -0
  97. package/package.json +7 -2
  98. package/internal/acli/acli.go +0 -229
  99. package/internal/config/demo_test.go +0 -17
  100. package/internal/config/schema.go +0 -193
  101. package/internal/config/schema_test.go +0 -162
  102. package/internal/daemon/daemon.go +0 -218
  103. package/internal/daemon/daemon_test.go +0 -204
  104. package/internal/discovery/discovery.go +0 -122
  105. package/internal/discovery/discovery_test.go +0 -62
  106. package/internal/opencode/opencode.go +0 -26
  107. package/internal/orcacli/orcacli.go +0 -264
  108. package/internal/runner/orca/README.md +0 -64
  109. package/internal/runner/runner_test.go +0 -64
  110. package/internal/server/server_test.go +0 -195
  111. package/internal/tasks/jira/README.md +0 -69
  112. package/internal/tasks/jira/component_test.go +0 -16
  113. package/internal/tasks/jira/decode.go +0 -24
  114. package/internal/tasks/jira/jira.go +0 -231
  115. package/internal/tasks/jira/jira_test.go +0 -259
  116. package/internal/tasks/jira/jql_test.go +0 -16
  117. package/internal/tasks/tasks.go +0 -90
  118. package/internal/tasks/tasks_test.go +0 -91
@@ -0,0 +1,610 @@
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
+ TaskSystem: cfg.TaskPlugin,
208
+ RetentionDays: cfg.CompletedRunRetentionDays,
209
+ Runtime: &runsvc.RuntimePolicy{
210
+ KeepTerminalsAlive: cfg.KeepTerminalsAlive,
211
+ KeepSessionsAlive: cfg.KeepSessionsAlive,
212
+ },
213
+ })
214
+ if err != nil {
215
+ return fmt.Errorf("open engine: %w", err)
216
+ }
217
+
218
+ // 5.2 fail-fast preflight: before workers/pollers start, validate
219
+ // task-system, runner, and harness credentials/permissions/connectivity,
220
+ // every registered repo, and every configured agent. Known permanent
221
+ // errors abort startup; runtime failures of existing runs retry forever
222
+ // (design.md decision 16).
223
+ if err := preflight(ctx, repoReg, rnr, hrn, workflowList); err != nil {
224
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
225
+ _ = engine.Shutdown(shutdownCtx)
226
+ cancel()
227
+ return fmt.Errorf("startup validation: %w", err)
228
+ }
229
+
230
+ // Start engine workers and the one-shot startup retention sweep
231
+ // (3.25; runs once here, no background ticker).
232
+ if err := engine.Start(ctx); err != nil {
233
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
234
+ _ = engine.Shutdown(shutdownCtx)
235
+ cancel()
236
+ return fmt.Errorf("start engine: %w", err)
237
+ }
238
+
239
+ // 5.4 lifecycle gate: ONE sync.Mutex shared by workflow.Service
240
+ // Submit/Remove and RunManager.EnsureRun (design.md decision 23). Plain
241
+ // mutex in the wiring — no lock service, no new abstraction.
242
+ lifecycleGate := &sync.Mutex{}
243
+
244
+ // Construct the Run Manager.
245
+ runManager := &runsvc.RunManager{Executor: engine, Runs: engine, Gate: lifecycleGate}
246
+
247
+ // Services consumed by the Unix-socket server. The repo.Service takes
248
+ // the SAME registry by pointer (via replaceInternal), so the engine,
249
+ // pollers, and handlers observe one in-memory repo set.
250
+ wfSvc := workflow.NewService(store, engine, repoExists{repoReg})
251
+ wfSvc.Gate = lifecycleGate
252
+ wfSvc.ValidateTaskConfig = workflowConfigValidator(repoReg)
253
+ // Submit/Remove must also rebuild repo bindings under the gate (spec
254
+ // 3.34: bindings rebuilt on submit/remove/startup). Wired as a plain
255
+ // callback — no dispatcher.
256
+ wfSvc.Rebind = func() error {
257
+ return repoReg.BindWorkflows(wfSvc.Registry().List())
258
+ }
259
+ for _, wf := range workflowList {
260
+ wfSvc.Registry().Replace(wf)
261
+ }
262
+ repoSvc := repo.NewServiceWithRegistry(repo.ServiceConfig{
263
+ ConfigPath: p.Config,
264
+ TaskPlugin: cfg.TaskPlugin,
265
+ Runner: rnr,
266
+ Active: engine,
267
+ Workflows: wfSvc.Registry(),
268
+ }, repoReg)
269
+
270
+ // 5.6 database-loss recovery (explicit --recover only; never inferred).
271
+ // Runs after the engine is open and BEFORE normal pollers start so the
272
+ // recovered runs exist before any poll cycle observes them. Mailbox
273
+ // specs come from the engine so the recover path builds the same
274
+ // description content as normal run execution.
275
+ if recover {
276
+ specsFor := func(wf *workflow.Workflow, ticketKey string) []task.MailboxSpec {
277
+ return goworkflows.MailboxSpecs(wf, ticketKey)
278
+ }
279
+ if err := recoverpkg.FromTaskSystem(ctx, repoReg, rnr, runManager, specsFor); err != nil {
280
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
281
+ _ = engine.Shutdown(shutdownCtx)
282
+ cancel()
283
+ return fmt.Errorf("recover: %w", err)
284
+ }
285
+ }
286
+
287
+ // Start the Repo Poller group with the handleBatch callback (5.3,
288
+ // docs/structs-methods-interfaces.md lines 1099-1117 verbatim). No
289
+ // dispatcher type, no extra abstraction.
290
+ pollers := repo.NewPollerGroup(10, handleBatch(runManager))
291
+ pollers.Interval = time.Duration(cfg.PollIntervalSeconds) * time.Second
292
+ pollers.ReplaceRepos(repoSvc.Registry().List())
293
+ pollerCtx, cancelPollers := context.WithCancel(context.Background())
294
+ pollersDone := make(chan struct{})
295
+ go func() {
296
+ pollers.Run(pollerCtx)
297
+ close(pollersDone)
298
+ }()
299
+
300
+ // cleanup is the single bounded shutdown path (5.5). Order:
301
+ // 1. Stop accepting new polls AND new HTTP requests immediately.
302
+ // 2. Wait (within one 30s budget) for in-flight polls and in-flight
303
+ // HTTP calls to return.
304
+ // 3. Cancel worker polling and close SQLite via engine.Shutdown.
305
+ // Every termination path — signal, /stop, listener error, startup
306
+ // failure after pollers launched — funnels through here so no path
307
+ // closes the database under running handlers or skips poller join.
308
+ cleanup := func(httpServer *http.Server, serveErr chan error, serveConsumed bool) {
309
+ shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
310
+ defer cancel()
311
+ // Stop new work immediately: no new polls, no new HTTP requests.
312
+ cancelPollers()
313
+ if httpServer != nil {
314
+ _ = httpServer.Shutdown(shutdownCtx)
315
+ }
316
+ // Wait for in-flight HTTP within the budget.
317
+ if httpServer != nil && !serveConsumed {
318
+ select {
319
+ case <-serveErr:
320
+ case <-shutdownCtx.Done():
321
+ }
322
+ }
323
+ // Wait for in-flight pollers within the same 30s budget (spec:
324
+ // "waits up to 30 seconds ... then close the socket and database").
325
+ // PollerGroup.Run returns once every in-flight handleBatch handler
326
+ // completes; on deadline we proceed to engine.Shutdown anyway so
327
+ // shutdown is bounded — the spec's rule is that already-running
328
+ // external work is not interruptible and durable state must remain
329
+ // recoverable (engine history is authoritative; a handler that
330
+ // survives past deadline hits a closed-DB error and logs it).
331
+ select {
332
+ case <-pollersDone:
333
+ case <-shutdownCtx.Done():
334
+ }
335
+ // Cancel worker polling and close the database. Engine.Shutdown
336
+ // uses the shared budget for its own bounded wait on active
337
+ // workflow/activity tasks.
338
+ _ = engine.Shutdown(shutdownCtx)
339
+ }
340
+
341
+ // Start the Unix-socket server.
342
+ _ = os.Remove(p.Socket)
343
+ listener, err := net.Listen("unix", p.Socket)
344
+ if err != nil {
345
+ cleanup(nil, nil, true)
346
+ return fmt.Errorf("listen %s: %w", p.Socket, err)
347
+ }
348
+ if err := os.Chmod(p.Socket, 0o600); err != nil {
349
+ listener.Close()
350
+ _ = os.Remove(p.Socket) // leave no stale socket file on failure
351
+ cleanup(nil, nil, true)
352
+ return fmt.Errorf("chmod %s: %w", p.Socket, err)
353
+ }
354
+
355
+ // /stop and ctx cancellation funnel into stopCtx; both end at the
356
+ // single cleanup path.
357
+ stopCtx, stopServe := context.WithCancel(context.Background())
358
+ defer stopServe()
359
+
360
+ deps := &serveDeps{
361
+ wf: wfSvc,
362
+ repos: repoSvc,
363
+ engine: engine,
364
+ runManager: runManager,
365
+ // Repo register/remove must reach the running poller group — the
366
+ // startup ReplaceRepos only covers repos present at boot. Re-sync
367
+ // after every successful mutation so a newly registered repo is
368
+ // polled on the next tick without a serve restart.
369
+ onReposChanged: func() {
370
+ pollers.ReplaceRepos(repoSvc.Registry().List())
371
+ },
372
+ shutdown: func(context.Context) error {
373
+ stopServe()
374
+ return nil
375
+ },
376
+ }
377
+ httpServer := &http.Server{Handler: server.New(deps)}
378
+
379
+ serveErr := make(chan error, 1)
380
+ go func() { serveErr <- httpServer.Serve(listener) }()
381
+
382
+ var serveResult error
383
+ serveConsumed := false
384
+ select {
385
+ case <-ctx.Done():
386
+ case <-stopCtx.Done():
387
+ case err := <-serveErr:
388
+ serveConsumed = true
389
+ if err != nil && !errors.Is(err, http.ErrServerClosed) {
390
+ serveResult = fmt.Errorf("serve %s: %w", p.Socket, err)
391
+ }
392
+ // Listener closed on its own — treat as normal shutdown request.
393
+ }
394
+
395
+ cleanup(httpServer, serveErr, serveConsumed)
396
+ return serveResult
397
+ }
398
+
399
+ func workflowConfigValidator(repoReg *repo.Registry) func(context.Context, *workflow.Workflow) error {
400
+ return func(ctx context.Context, wf *workflow.Workflow) error {
401
+ nodeCfgs := map[string]config.RawValues{}
402
+ for nodeName, n := range wf.Nodes {
403
+ nodeCfgs[nodeName] = n.TaskConfig
404
+ }
405
+ for _, repoName := range wf.Repos {
406
+ rp, ok := repoReg.Get(repoName)
407
+ if !ok {
408
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
409
+ }
410
+ if err := rp.TaskSystem.ValidateConfig(ctx, wf.TaskConfig, nodeCfgs); err != nil {
411
+ return fmt.Errorf("workflow %q repo %q: %w", wf.Name, repoName, err)
412
+ }
413
+ }
414
+ return nil
415
+ }
416
+ }
417
+
418
+ // repoExists adapts *repo.Registry to workflow.RepoLookup (Exists).
419
+ type repoExists struct{ reg *repo.Registry }
420
+
421
+ func (r repoExists) Exists(name string) bool {
422
+ _, ok := r.reg.Get(name)
423
+ return ok
424
+ }
425
+
426
+ // serveDeps adapts composition-root services to server.Deps. Thin forwarder;
427
+ // no logic. Signatures match docs/structs-methods-interfaces.md Client.
428
+ type serveDeps struct {
429
+ wf *workflow.Service
430
+ repos *repo.Service
431
+ engine *goworkflows.Engine
432
+ runManager *runsvc.RunManager
433
+ onReposChanged func()
434
+ shutdown func(context.Context) error
435
+ }
436
+
437
+ func (d *serveDeps) SubmitWorkflow(ctx context.Context, yaml []byte) (*workflow.Workflow, error) {
438
+ return d.wf.Submit(ctx, yaml)
439
+ }
440
+ func (d *serveDeps) GetWorkflow(_ context.Context, name string) (*workflow.Workflow, error) {
441
+ return d.wf.Get(name)
442
+ }
443
+ func (d *serveDeps) ListWorkflows(context.Context) ([]*workflow.Workflow, error) {
444
+ return d.wf.List(), nil
445
+ }
446
+ func (d *serveDeps) RemoveWorkflow(ctx context.Context, name string) error {
447
+ return d.wf.Remove(ctx, name)
448
+ }
449
+
450
+ func (d *serveDeps) ListRuns(ctx context.Context, filter runsvc.Filter) ([]runsvc.Run, error) {
451
+ return d.engine.ListRuns(ctx, filter)
452
+ }
453
+ func (d *serveDeps) GetRunByTicket(ctx context.Context, ticket string) (runsvc.Run, error) {
454
+ return d.engine.FindRunByTicket(ctx, ticket)
455
+ }
456
+ func (d *serveDeps) CancelRun(ctx context.Context, ticket, reason string) error {
457
+ return d.runManager.CancelByTicket(ctx, ticket, reason)
458
+ }
459
+
460
+ func (d *serveDeps) SubmitReport(ctx context.Context, rep runsvc.ReportRequest) (runsvc.ReportAck, error) {
461
+ return d.engine.SubmitReport(ctx, rep)
462
+ }
463
+ func (d *serveDeps) HasProcessedReport(ctx context.Context, id runsvc.ID, reportID string) (bool, error) {
464
+ return d.engine.HasProcessedReport(ctx, id, reportID)
465
+ }
466
+ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc.NodeRuntimeRegistration) (runsvc.NodeRuntimeRegistrationAck, error) {
467
+ return d.engine.RegisterNodeSession(ctx, registration)
468
+ }
469
+
470
+ func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
471
+ return d.repos.Discover(ctx)
472
+ }
473
+ func (d *serveDeps) TaskFields(context.Context) ([]string, error) {
474
+ return d.repos.RequiredRepoKeys(), nil
475
+ }
476
+ func (d *serveDeps) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
477
+ info, err := d.repos.Register(ctx, input)
478
+ if err == nil && d.onReposChanged != nil {
479
+ d.onReposChanged()
480
+ }
481
+ return info, err
482
+ }
483
+ func (d *serveDeps) ListRepos(context.Context) ([]repo.Info, error) {
484
+ return d.repos.List(), nil
485
+ }
486
+ func (d *serveDeps) GetRepo(_ context.Context, name string) (repo.Info, error) {
487
+ return d.repos.Get(name)
488
+ }
489
+ func (d *serveDeps) RemoveRepo(ctx context.Context, name string) error {
490
+ err := d.repos.Remove(ctx, name)
491
+ if err == nil && d.onReposChanged != nil {
492
+ d.onReposChanged()
493
+ }
494
+ return err
495
+ }
496
+
497
+ func (d *serveDeps) Shutdown(ctx context.Context) error { return d.shutdown(ctx) }
498
+
499
+ // handleBatch is the Repo Poller group callback (5.3). Implemented verbatim
500
+ // from docs/structs-methods-interfaces.md lines 1101-1117: per ticket,
501
+ // ResolveWorkflow; ErrNoMatch → continue; other routing errors → log and
502
+ // continue (mutate nothing); success → RunManager.EnsureRun, log its errors.
503
+ // No dispatcher type, no extra abstraction. A small closure captures the
504
+ // RunManager so the repo.BatchHandler signature is satisfied.
505
+ //
506
+ // 9.2 poller logging: one info line per repo poll cycle (batch size), one
507
+ // info line per ticket with the routing outcome, one debug line per ticket
508
+ // with normalized fields. EnsureRun emits its own outcome lines from
509
+ // internal/run/manager.go.
510
+ func handleBatch(runManager *runsvc.RunManager) repo.BatchHandler {
511
+ return func(ctx context.Context, rp *repo.Repo, tickets []task.Ticket) {
512
+ slog.Info("poll cycle", "repo", rp.Name, "batch", len(tickets))
513
+ for _, ticket := range tickets {
514
+ slog.Debug("poll ticket",
515
+ "repo", rp.Name, "ticket", ticket.Key, "id", ticket.ID,
516
+ "title", ticket.Title, "claims", strings.Join(ticket.WorkflowClaims, ","))
517
+ wf, err := router.ResolveWorkflow(rp, ticket)
518
+ switch {
519
+ case errors.Is(err, router.ErrNoMatch):
520
+ slog.Info("route outcome",
521
+ "repo", rp.Name, "ticket", ticket.Key, "outcome", "no-match")
522
+ continue
523
+ case err != nil:
524
+ var amb *router.AmbiguousError
525
+ var inv *router.InvalidClaimError
526
+ outcome := "error"
527
+ switch {
528
+ case errors.As(err, &amb):
529
+ outcome = "ambiguous"
530
+ case errors.As(err, &inv):
531
+ outcome = "invalid-claim"
532
+ }
533
+ slog.Info("route outcome",
534
+ "repo", rp.Name, "ticket", ticket.Key, "outcome", outcome, "error", err)
535
+ continue
536
+ }
537
+ routeOutcome := "claimed"
538
+ for _, c := range ticket.WorkflowClaims {
539
+ if c == "wf:"+wf.Name {
540
+ routeOutcome = "already-claimed"
541
+ break
542
+ }
543
+ }
544
+ slog.Info("route outcome",
545
+ "repo", rp.Name, "ticket", ticket.Key, "workflow", wf.Name, "outcome", routeOutcome)
546
+ if err := runManager.EnsureRun(ctx, rp, wf, ticket); err != nil {
547
+ // EnsureRun already logged the outcome; nothing to add here.
548
+ _ = err
549
+ }
550
+ }
551
+ }
552
+ }
553
+
554
+ // preflight is the 5.2 fail-fast startup validation. It runs after the
555
+ // engine is open and BEFORE workers/pollers start. Permanent errors abort
556
+ // startup; runtime errors of existing runs retry forever (different rule).
557
+ //
558
+ // Probes are no-side-effect reads on each adapter boundary:
559
+ // - runner: ValidateRepo per registered repo (Orca connectivity + repo
560
+ // presence at the configured path).
561
+ // - task system: Poll per repo (credentials/permissions/connectivity;
562
+ // returns active parents only, mutates nothing).
563
+ // - harness: ValidateAgent per (repo, workflow) agent node (configured
564
+ // agent must exist for that repo).
565
+ //
566
+ // Repos with no workflows still validate runner + task connectivity; agent
567
+ // validation runs once per distinct (repoPath, agent) pair to avoid
568
+ // redundant CLI calls when several workflows share an agent on one repo.
569
+ func preflight(ctx context.Context, repoReg *repo.Registry, rnr runner.Runner, hrn harness.Harness, workflows []*workflow.Workflow) error {
570
+ for _, rp := range repoReg.List() {
571
+ if err := rnr.ValidateRepo(ctx, rp.Name, rp.Path); err != nil {
572
+ return fmt.Errorf("repo %q runner: %w", rp.Name, err)
573
+ }
574
+ if _, err := rp.TaskSystem.Poll(ctx); err != nil {
575
+ return fmt.Errorf("repo %q task system: %w", rp.Name, err)
576
+ }
577
+ }
578
+ type agentProbe struct {
579
+ repoPath string
580
+ agent string
581
+ }
582
+ seen := map[agentProbe]bool{}
583
+ for _, wf := range workflows {
584
+ for nodeName, n := range wf.Nodes {
585
+ // Validate EVERY configured agent (agent and HITL work nodes
586
+ // both declare one); skip start/end which never carry Agent.
587
+ if (n.Type != workflow.NodeAgent && n.Type != workflow.NodeHITL) || n.Agent == "" {
588
+ continue
589
+ }
590
+ for _, repoName := range wf.Repos {
591
+ rp, ok := repoReg.Get(repoName)
592
+ if !ok {
593
+ return fmt.Errorf("workflow %q references unregistered repo %q", wf.Name, repoName)
594
+ }
595
+ key := agentProbe{repoPath: rp.Path, agent: n.Agent}
596
+ if seen[key] {
597
+ continue
598
+ }
599
+ seen[key] = true
600
+ if err := hrn.ValidateAgent(ctx, rp.Path, n.Agent); err != nil {
601
+ return fmt.Errorf("workflow %q node %q repo %q: %w", wf.Name, nodeName, repoName, err)
602
+ }
603
+ }
604
+ }
605
+ }
606
+ return nil
607
+ }
608
+
609
+ // The recover composition lives in internal/recover (recoverpkg.FromTaskSystem)
610
+ // so it is importable by both serve and the recover test — see task 6.3.
@@ -0,0 +1,88 @@
1
+ # Copy this file and replace "payments" with one or more registered repo names.
2
+ # Optional fields are shown commented; uncomment only the behavior you need.
3
+ name: defaultStoryFlow
4
+ repos:
5
+ - payments
6
+ cleanupRunnerOnEnd: true
7
+
8
+ taskConfig:
9
+ filters:
10
+ parentStatuses:
11
+ - To Do
12
+ issueTypes:
13
+ - Story
14
+ # labels:
15
+ # - coding
16
+ # assignees:
17
+ # - owner@example.com
18
+ # assignee: default-node-owner@example.com
19
+ # project: PAY
20
+ # component: api
21
+ # transitionTo:
22
+ # parentStatus: In Progress
23
+ # taskStatus: In Progress
24
+
25
+ nodes:
26
+ start:
27
+ taskConfig:
28
+ transitionTo:
29
+ parentStatus: In Progress
30
+ onSuccess:
31
+ - target: implement
32
+ when: The parent story is ready for implementation
33
+
34
+ implement:
35
+ type: agent
36
+ agent: coding
37
+ description: Implement the parent story and verify the changes.
38
+ # nudgePrompt: "Finish {{node}} for {{ticket}} and choose one of: {{nextSteps}}"
39
+ taskConfig:
40
+ # assignee: developer@example.com
41
+ transitionTo:
42
+ taskStatus: In Progress
43
+ # parentStatus: In Progress
44
+ onSuccess:
45
+ - target: review
46
+ when: Implementation and verification are complete
47
+ onFailure:
48
+ - target: implement
49
+ when: Implementation needs another pass
50
+
51
+ review:
52
+ type: agent
53
+ agent: review
54
+ description: Review the implementation for correctness, regressions, and missing tests.
55
+ # nudgePrompt: "Complete the review for {{ticket}}. Valid routes: {{nextSteps}}"
56
+ taskConfig:
57
+ # assignee: reviewer@example.com
58
+ transitionTo:
59
+ taskStatus: In Review
60
+ # parentStatus: In Review
61
+ onSuccess:
62
+ - target: prReview
63
+ when: The changes are ready for human approval
64
+ onFailure:
65
+ - target: implement
66
+ when: Code changes are required
67
+
68
+ prReview:
69
+ type: hitl
70
+ agent: review
71
+ description: Discuss the pull request with the human and obtain approval.
72
+ # nudgePrompt: "Review {{ticket}} with the human. Valid routes: {{nextSteps}}"
73
+ taskConfig:
74
+ # assignee: human-reviewer@example.com
75
+ transitionTo:
76
+ taskStatus: In Review
77
+ # parentStatus: In Review
78
+ onSuccess:
79
+ - target: end
80
+ when: The human approves the review
81
+ onFailure:
82
+ - target: implement
83
+ when: The human requests code changes
84
+
85
+ end:
86
+ taskConfig:
87
+ transitionTo:
88
+ parentStatus: Done