relay-flow 0.2.0-alpha → 0.2.2-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 (102) hide show
  1. package/README.md +171 -26
  2. package/cmd/relay-flow/beads_composition_test.go +451 -0
  3. package/cmd/relay-flow/commands_test.go +593 -15
  4. package/cmd/relay-flow/main.go +356 -119
  5. package/cmd/relay-flow/scenario_test.go +246 -35
  6. package/cmd/relay-flow/serve.go +5 -2
  7. package/examples/beads-workflow.yaml +74 -0
  8. package/examples/default-story-workflow.yaml +88 -0
  9. package/go.mod +2 -1
  10. package/go.sum +2 -0
  11. package/internal/config/config.go +13 -2
  12. package/internal/config/merge_test.go +18 -0
  13. package/internal/execution/goworkflows/activities.go +136 -87
  14. package/internal/execution/goworkflows/end_feedback_test.go +124 -0
  15. package/internal/execution/goworkflows/engine.go +41 -8
  16. package/internal/execution/goworkflows/engine_test.go +100 -22
  17. package/internal/execution/goworkflows/fakes_test.go +63 -25
  18. package/internal/execution/goworkflows/interpreter.go +45 -30
  19. package/internal/execution/goworkflows/mailbox_test.go +85 -0
  20. package/internal/execution/goworkflows/node_runtime_integration_test.go +12 -6
  21. package/internal/execution/goworkflows/node_runtime_test.go +72 -23
  22. package/internal/execution/goworkflows/recovery_test.go +7 -7
  23. package/internal/execution/goworkflows/report_contract_fixture_test.go +31 -0
  24. package/internal/execution/goworkflows/retry_log_test.go +11 -11
  25. package/internal/harness/contract_test.go +15 -0
  26. package/internal/harness/factory.go +25 -3
  27. package/internal/harness/harness.go +30 -4
  28. package/internal/harness/opencode/opencode.go +125 -10
  29. package/internal/harness/opencode/opencode_test.go +183 -0
  30. package/internal/harness/opencode/repo_setup.go +361 -0
  31. package/internal/harness/plugin_selection_test.go +5 -5
  32. package/internal/paths/paths.go +18 -16
  33. package/internal/recover/recover.go +11 -6
  34. package/internal/repo/repo.go +13 -0
  35. package/internal/repo/service.go +10 -0
  36. package/internal/repo/service_test.go +55 -6
  37. package/internal/router/router.go +3 -2
  38. package/internal/router/router_test.go +87 -0
  39. package/internal/run/manager.go +14 -1
  40. package/internal/run/run.go +6 -4
  41. package/internal/run/run_manager_test.go +21 -1
  42. package/internal/runner/contract_test.go +64 -26
  43. package/internal/runner/orca/orca.go +30 -54
  44. package/internal/runner/orca/orca_test.go +143 -4
  45. package/internal/runner/orca/orcacli/orcacli.go +5 -0
  46. package/internal/runner/orca/orcacli/orcacli_test.go +3 -0
  47. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +2 -0
  48. package/internal/runner/runner.go +15 -8
  49. package/internal/task/auth_test.go +48 -0
  50. package/internal/task/beads/bdcli/bdcli.go +323 -0
  51. package/internal/task/beads/bdcli/bdcli_test.go +297 -0
  52. package/internal/task/beads/bdcli/testdata/array.json +1 -0
  53. package/internal/task/beads/bdcli/testdata/children.json +1 -0
  54. package/internal/task/beads/bdcli/testdata/claimed.json +1 -0
  55. package/internal/task/beads/bdcli/testdata/commented.json +1 -0
  56. package/internal/task/beads/bdcli/testdata/comments.json +1 -0
  57. package/internal/task/beads/bdcli/testdata/created.json +1 -0
  58. package/internal/task/beads/bdcli/testdata/object.json +1 -0
  59. package/internal/task/beads/bdcli/testdata/ready.json +1 -0
  60. package/internal/task/beads/bdcli/testdata/show.json +1 -0
  61. package/internal/task/beads/bdcli/testdata/strict-bd.sh +149 -0
  62. package/internal/task/beads/bdcli/testdata/updated.json +1 -0
  63. package/internal/task/beads/beads.go +840 -0
  64. package/internal/task/beads/beads_test.go +609 -0
  65. package/internal/task/beads/comments_test.go +242 -0
  66. package/internal/task/beads/config_compatibility_test.go +163 -0
  67. package/internal/task/beads/lifecycle_inheritance_test.go +168 -0
  68. package/internal/task/beads/repo_composition_test.go +232 -0
  69. package/internal/task/beads/runtime_config_test.go +81 -0
  70. package/internal/task/beads/status_compatibility_test.go +233 -0
  71. package/internal/task/beads/status_test.go +257 -0
  72. package/internal/task/beads/testdata/strict-bd-repo.sh +27 -0
  73. package/internal/task/beads/validation_test.go +110 -0
  74. package/internal/task/contract_test.go +12 -0
  75. package/internal/task/factory.go +52 -3
  76. package/internal/task/jira/auth.go +209 -0
  77. package/internal/task/jira/auth_test.go +160 -0
  78. package/internal/task/jira/effects_test.go +39 -0
  79. package/internal/task/jira/filters_test.go +96 -16
  80. package/internal/task/jira/helpers_test.go +29 -19
  81. package/internal/task/jira/jira.go +263 -90
  82. package/internal/task/jira/lifecycle_inheritance_test.go +172 -0
  83. package/internal/task/jira/normalize.go +32 -14
  84. package/internal/task/jira/rest/adf.go +165 -0
  85. package/internal/task/jira/rest/adf_test.go +60 -0
  86. package/internal/task/jira/rest/client.go +573 -0
  87. package/internal/task/jira/rest/client_test.go +381 -0
  88. package/internal/task/jira/templates_test.go +118 -0
  89. package/internal/task/jira/transition_defaults_test.go +22 -18
  90. package/internal/task/jira/validation_test.go +1 -1
  91. package/internal/task/task.go +32 -0
  92. package/internal/workflow/report_test.go +45 -0
  93. package/internal/workflow/workflow.go +9 -6
  94. package/internal/workflow/workflow_test.go +14 -12
  95. package/package.json +2 -1
  96. package/internal/task/jira/acli/acli.go +0 -306
  97. package/internal/task/jira/acli/acli_test.go +0 -208
  98. package/internal/task/jira/acli/testdata/acli_comments.json +0 -55
  99. package/internal/task/jira/acli/testdata/search_invalid_assignee.txt +0 -1
  100. package/internal/task/jira/acli/testdata/search_invalid_status.txt +0 -1
  101. /package/internal/task/{jira/acli/testdata/search_success.json → beads/bdcli/testdata/empty.json} +0 -0
  102. /package/internal/task/jira/testdata/{acli_search.json → jira_search_issues.json} +0 -0
@@ -12,7 +12,9 @@ import (
12
12
  "flag"
13
13
  "fmt"
14
14
  "io"
15
+ "log/slog"
15
16
  "os"
17
+ "os/exec"
16
18
  "os/signal"
17
19
  "path/filepath"
18
20
  "strings"
@@ -36,6 +38,7 @@ import (
36
38
  // name validation at init-time).
37
39
  _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
38
40
  _ "github.com/rajpopat27/relay-flow/internal/runner/orca"
41
+ _ "github.com/rajpopat27/relay-flow/internal/task/beads"
39
42
  _ "github.com/rajpopat27/relay-flow/internal/task/jira"
40
43
  )
41
44
 
@@ -55,14 +58,15 @@ func home() (paths.Paths, error) {
55
58
  if env := os.Getenv("RELAY_FLOW_HOME"); env != "" {
56
59
  root := env
57
60
  return paths.Paths{
58
- Root: root,
59
- Config: filepath.Join(root, "config.yaml"),
60
- Workflows: filepath.Join(root, "workflows"),
61
- Database: filepath.Join(root, "state.db"),
62
- Socket: filepath.Join(root, "server.sock"),
63
- Lock: filepath.Join(root, "server.lock"),
64
- ServerLog: filepath.Join(root, "server.log"),
65
- PluginLog: filepath.Join(root, "plugin.log"),
61
+ Root: root,
62
+ Config: filepath.Join(root, "config.yaml"),
63
+ Credentials: filepath.Join(root, "credentials.yaml"),
64
+ Workflows: filepath.Join(root, "workflows"),
65
+ Database: filepath.Join(root, "state.db"),
66
+ Socket: filepath.Join(root, "server.sock"),
67
+ Lock: filepath.Join(root, "server.lock"),
68
+ ServerLog: filepath.Join(root, "server.log"),
69
+ PluginLog: filepath.Join(root, "plugin.log"),
66
70
  }, nil
67
71
  }
68
72
  return paths.ForUserHome()
@@ -109,6 +113,8 @@ func run(args []string, stdin io.Reader) int {
109
113
  return exitUsage
110
114
  }
111
115
  return cmdRuntimeRegister(client, stdin)
116
+ case "task":
117
+ return cmdTask(p, args[1:], stdin)
112
118
  case "workflow":
113
119
  return cmdWorkflow(client, args[1:])
114
120
  case "repo":
@@ -125,8 +131,9 @@ func usage(w io.Writer) {
125
131
  fmt.Fprintln(w, `relay-flow — durable ticket runner
126
132
 
127
133
  Usage:
128
- relay-flow init [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
129
- relay-flow serve [--recover] [--debug]
134
+ relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
135
+ relay-flow task auth [task-plugin options]
136
+ relay-flow serve [--recover] [--debug] [--background]
130
137
  relay-flow stop
131
138
  relay-flow report
132
139
 
@@ -149,7 +156,8 @@ Usage:
149
156
 
150
157
  // cmdInit is the init composition root (docs lines 950/1028): select the
151
158
  // three plugin names, atomically write the machine config, and initialize
152
- // the SQLite database. Refuses to overwrite existing config or history.
159
+ // the SQLite database. Refuses to overwrite existing config or history unless
160
+ // --force safely updates plugin selections while preserving durable state.
153
161
  // Plugin selection precedence: --task-plugin/--runner-plugin/--harness-plugin
154
162
  // flags (all three required for a fully non-interactive run) → huh form on
155
163
  // a TTY → three stdin lines (task, runner, harness), the documented test
@@ -159,6 +167,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
159
167
  taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
160
168
  runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
161
169
  harnessName := fs.String("harness-plugin", "", "harness plugin name (non-interactive)")
170
+ force := fs.Bool("force", false, "update plugin selections while preserving existing state")
162
171
  if err := fs.Parse(args); err != nil {
163
172
  return exitUsage
164
173
  }
@@ -167,20 +176,53 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
167
176
  fmt.Fprintln(os.Stderr, "init: --task-plugin, --runner-plugin, and --harness-plugin must be given together")
168
177
  return exitUsage
169
178
  }
170
- // Refuse to overwrite existing state.
171
- if _, err := os.Stat(p.Config); err == nil {
179
+ configExists, err := pathExists(p.Config)
180
+ if err != nil {
181
+ fmt.Fprintln(os.Stderr, err)
182
+ return exitFail
183
+ }
184
+ databaseExists, err := pathExists(p.Database)
185
+ if err != nil {
186
+ fmt.Fprintln(os.Stderr, err)
187
+ return exitFail
188
+ }
189
+ if !*force && configExists {
172
190
  fmt.Fprintln(os.Stderr, "init: already initialized (config exists): "+p.Config)
173
191
  return exitFail
174
192
  }
175
- if _, err := os.Stat(p.Database); err == nil {
193
+ if !*force && databaseExists {
176
194
  fmt.Fprintln(os.Stderr, "init: already initialized (database exists): "+p.Database)
177
195
  return exitFail
178
196
  }
197
+ if *force && configExists && !databaseExists {
198
+ fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
199
+ return exitFail
200
+ }
179
201
  if err := os.MkdirAll(p.Root, 0o700); err != nil {
180
202
  fmt.Fprintln(os.Stderr, err)
181
203
  return exitFail
182
204
  }
183
205
  _ = os.Chmod(p.Root, 0o700)
206
+ var unlock func()
207
+ if *force {
208
+ unlock, err = lockForForcedInit(p.Lock)
209
+ if err != nil {
210
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
211
+ return exitFail
212
+ }
213
+ defer unlock()
214
+ if databaseExists {
215
+ active, err := goworkflows.HasNonterminalRuns(p.Database)
216
+ if err != nil {
217
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
218
+ return exitFail
219
+ }
220
+ if active {
221
+ fmt.Fprintln(os.Stderr, "init: cannot use --force while a run is nonterminal")
222
+ return exitFail
223
+ }
224
+ }
225
+ }
184
226
 
185
227
  // Selection precedence: flags → TTY form → stdin lines. The stdin path
186
228
  // is the documented test seam and script path.
@@ -196,9 +238,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
196
238
  return exitFail
197
239
  }
198
240
  default:
199
- names = readPluginLines(stdin)
241
+ names = readInitLines(stdin)
200
242
  if names == nil {
201
- fmt.Fprintln(os.Stderr, "init: expected three plugin selections (task, runner, harness) on stdin")
243
+ fmt.Fprintln(os.Stderr, "init: expected task, runner, and harness plugin selections on stdin")
202
244
  return exitFail
203
245
  }
204
246
  }
@@ -217,83 +259,184 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
217
259
  return exitFail
218
260
  }
219
261
 
220
- cfg := &config.Machine{
221
- TaskPlugin: names[0],
222
- RunnerPlugin: names[1],
223
- HarnessPlugin: names[2],
224
- KeepTerminalsAlive: true,
225
- KeepSessionsAlive: true,
262
+ cfg := &config.Machine{KeepTerminalsAlive: true, KeepSessionsAlive: true}
263
+ if *force && configExists {
264
+ cfg, err = config.LoadMachine(p.Config)
265
+ if err != nil {
266
+ fmt.Fprintln(os.Stderr, err)
267
+ return exitFail
268
+ }
226
269
  }
227
- if err := config.SaveMachine(p.Config, cfg); err != nil {
228
- fmt.Fprintln(os.Stderr, err)
270
+ cfg.TaskPlugin = names[0]
271
+ cfg.RunnerPlugin = names[1]
272
+ cfg.HarnessPlugin = names[2]
273
+ taskDefaults, err := task.Defaults(names[0])
274
+ if err != nil {
275
+ fmt.Fprintln(os.Stderr, "init: task plugin: "+err.Error())
229
276
  return exitFail
230
277
  }
231
- if err := goworkflows.InitDatabase(p.Database); err != nil {
278
+ if !*force || !configExists {
279
+ cfg.TaskConfig = taskDefaults
280
+ } else {
281
+ cfg.TaskConfig = config.Merge(taskDefaults, cfg.TaskConfig)
282
+ }
283
+ if err := task.ValidateTextConfig(names[0], cfg.TaskConfig); err != nil {
284
+ fmt.Fprintln(os.Stderr, "init: task plugin config: "+err.Error())
285
+ return exitFail
286
+ }
287
+ harnessDefaults, err := harness.Defaults(names[2])
288
+ if err != nil {
289
+ fmt.Fprintln(os.Stderr, "init: harness plugin: "+err.Error())
290
+ return exitFail
291
+ }
292
+ if !*force || !configExists {
293
+ cfg.HarnessConfig = harnessDefaults
294
+ } else {
295
+ cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
296
+ }
297
+ if err := config.SaveMachine(p.Config, cfg); err != nil {
232
298
  fmt.Fprintln(os.Stderr, err)
233
299
  return exitFail
234
300
  }
301
+ if !databaseExists {
302
+ if err := goworkflows.InitDatabase(p.Database); err != nil {
303
+ fmt.Fprintln(os.Stderr, err)
304
+ return exitFail
305
+ }
306
+ }
307
+ fmt.Printf("Task system: %s\nRunner: %s\nHarness: %s\nRelay-flow initialized\n", names[0], names[1], names[2])
235
308
  return exitOK
236
309
  }
237
310
 
311
+ func pathExists(path string) (bool, error) {
312
+ _, err := os.Stat(path)
313
+ if err == nil {
314
+ return true, nil
315
+ }
316
+ if os.IsNotExist(err) {
317
+ return false, nil
318
+ }
319
+ return false, fmt.Errorf("stat %s: %w", path, err)
320
+ }
321
+
322
+ func lockForForcedInit(path string) (func(), error) {
323
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
324
+ if err != nil {
325
+ return nil, fmt.Errorf("open server lock: %w", err)
326
+ }
327
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
328
+ f.Close()
329
+ return nil, fmt.Errorf("cannot use --force while the server is running")
330
+ }
331
+ return func() {
332
+ _ = syscall.Flock(int(f.Fd()), syscall.LOCK_UN)
333
+ _ = f.Close()
334
+ }, nil
335
+ }
336
+
238
337
  // isTTY reports whether stdin is an interactive terminal.
239
338
  func isTTY(stdin io.Reader) bool {
240
339
  f, ok := stdin.(*os.File)
241
340
  return ok && isatty.IsTerminal(f.Fd())
242
341
  }
243
342
 
244
- // pickPluginsInteractive runs the huh form: one searchable select per
245
- // plugin (task, runner, harness); enter advances/submits.
343
+ // pickPluginsInteractive runs one searchable select per plugin type.
246
344
  func pickPluginsInteractive() ([]string, error) {
247
345
  names := make([]string, 3)
248
- form := huh.NewForm(
249
- huh.NewGroup(huh.NewSelect[string]().
250
- Title("Task plugin").
251
- Options(huh.NewOptions(task.Names()...)...).
252
- Filtering(true).
253
- Value(&names[0])),
254
- huh.NewGroup(huh.NewSelect[string]().
255
- Title("Runner plugin").
256
- Options(huh.NewOptions(runner.Names()...)...).
257
- Filtering(true).
258
- Value(&names[1])),
259
- huh.NewGroup(huh.NewSelect[string]().
260
- Title("Harness plugin").
261
- Options(huh.NewOptions(harness.Names()...)...).
262
- Filtering(true).
263
- Value(&names[2])),
264
- )
346
+ groups := make([]*huh.Group, 0, 3)
347
+ for _, selection := range []struct {
348
+ title string
349
+ options []string
350
+ value *string
351
+ }{
352
+ {"Select task system", task.Names(), &names[0]},
353
+ {"Select runner", runner.Names(), &names[1]},
354
+ {"Select harness", harness.Names(), &names[2]},
355
+ } {
356
+ field, err := pluginSelectField(selection.title, selection.options, selection.value)
357
+ if err != nil {
358
+ return nil, err
359
+ }
360
+ if field != nil {
361
+ groups = append(groups, huh.NewGroup(field))
362
+ }
363
+ }
364
+ if len(groups) == 0 {
365
+ return names, nil
366
+ }
367
+ form := huh.NewForm(groups...)
265
368
  if err := form.Run(); err != nil {
266
369
  return nil, err
267
370
  }
268
371
  return names, nil
269
372
  }
270
373
 
271
- // readPluginLines reads the three plugin names from stdin, one per line
272
- // (task, runner, harness); trailing whitespace tolerated. Returns nil when
273
- // fewer than three non-empty lines are present.
274
- func readPluginLines(stdin io.Reader) []string {
275
- names := make([]string, 0, 3)
374
+ func pluginSelectField(title string, options []string, value *string) (huh.Field, error) {
375
+ if len(options) == 0 {
376
+ return nil, fmt.Errorf("%s: no plugins registered", title)
377
+ }
378
+ return huh.NewSelect[string]().
379
+ Title(title).
380
+ Description("Press / to filter available plugins.").
381
+ Options(huh.NewOptions(options...)...).
382
+ Value(value), nil
383
+ }
384
+
385
+ // readInitLines reads the three plugin selections.
386
+ func readInitLines(stdin io.Reader) []string {
387
+ values := make([]string, 0, 3)
276
388
  scanner := bufio.NewScanner(stdin)
277
- for len(names) < 3 && scanner.Scan() {
389
+ for len(values) < 3 && scanner.Scan() {
278
390
  line := strings.TrimSpace(scanner.Text())
279
391
  if line == "" {
280
392
  continue
281
393
  }
282
- names = append(names, line)
394
+ values = append(values, line)
283
395
  }
284
- if len(names) != 3 {
396
+ if len(values) < 3 {
285
397
  return nil
286
398
  }
287
- return names
399
+ return values
400
+ }
401
+
402
+ // cmdTask dispatches task-system commands to the selected task plugin.
403
+ func cmdTask(p paths.Paths, args []string, stdin io.Reader) int {
404
+ if len(args) == 0 || args[0] != "auth" {
405
+ usage(os.Stderr)
406
+ return exitUsage
407
+ }
408
+ return cmdTaskAuth(p, args[1:], stdin)
409
+ }
410
+
411
+ func cmdTaskAuth(p paths.Paths, args []string, stdin io.Reader) int {
412
+ cfg, err := config.LoadMachine(p.Config)
413
+ if err != nil {
414
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
415
+ return exitFail
416
+ }
417
+ if err := task.Auth(context.Background(), cfg.TaskPlugin, args, stdin); err != nil {
418
+ fmt.Fprintln(os.Stderr, "task auth: "+err.Error())
419
+ return exitFail
420
+ }
421
+ return exitOK
288
422
  }
289
423
 
290
424
  func cmdServe(p paths.Paths, args []string) int {
291
425
  fs := flag.NewFlagSet("serve", flag.ContinueOnError)
292
426
  recover := fs.Bool("recover", false, "treat execution state as lost and rebuild from the task system")
293
427
  debug := fs.Bool("debug", false, "enable debug logging (overrides RELAY_FLOW_LOG_LEVEL)")
428
+ background := fs.Bool("background", false, "start the server detached and return after readiness")
294
429
  if err := fs.Parse(args); err != nil {
295
430
  return exitUsage
296
431
  }
432
+ if *background {
433
+ if err := startBackgroundServe(p, *recover, *debug); err != nil {
434
+ fmt.Fprintln(os.Stderr, err)
435
+ return exitFail
436
+ }
437
+ fmt.Println("Relay-flow server started")
438
+ return exitOK
439
+ }
297
440
  logCloser, err := logging.Setup(p.ServerLog, logging.Options{
298
441
  Debug: *debug,
299
442
  Env: os.Getenv("RELAY_FLOW_LOG_LEVEL"),
@@ -306,12 +449,69 @@ func cmdServe(p paths.Paths, args []string) int {
306
449
  ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
307
450
  defer stop()
308
451
  if err := serveRoot(ctx, p, *recover); err != nil {
452
+ slog.Error("server startup failed", "error", err)
309
453
  fmt.Fprintln(os.Stderr, err)
310
454
  return exitFail
311
455
  }
312
456
  return exitOK
313
457
  }
314
458
 
459
+ func startBackgroundServe(p paths.Paths, recover, debug bool) error {
460
+ client := server.NewClient(p.Socket)
461
+ if serverResponding(client, 200*time.Millisecond) {
462
+ return fmt.Errorf("serve --background: server is already running")
463
+ }
464
+ executable, err := os.Executable()
465
+ if err != nil {
466
+ return fmt.Errorf("serve --background: resolve executable: %w", err)
467
+ }
468
+ childArgs := []string{"serve"}
469
+ if recover {
470
+ childArgs = append(childArgs, "--recover")
471
+ }
472
+ if debug {
473
+ childArgs = append(childArgs, "--debug")
474
+ }
475
+ devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
476
+ if err != nil {
477
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
478
+ }
479
+ defer devNull.Close()
480
+ cmd := exec.Command(executable, childArgs...)
481
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
482
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
483
+ if err := cmd.Start(); err != nil {
484
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
485
+ }
486
+ wait := make(chan error, 1)
487
+ go func() { wait <- cmd.Wait() }()
488
+ deadline := time.NewTimer(10 * time.Second)
489
+ defer deadline.Stop()
490
+ ticker := time.NewTicker(50 * time.Millisecond)
491
+ defer ticker.Stop()
492
+ for {
493
+ select {
494
+ case err := <-wait:
495
+ return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", err, p.ServerLog)
496
+ case <-deadline.C:
497
+ _ = cmd.Process.Kill()
498
+ <-wait
499
+ return fmt.Errorf("serve --background: startup timed out; see %s", p.ServerLog)
500
+ case <-ticker.C:
501
+ if serverResponding(client, 200*time.Millisecond) {
502
+ return nil
503
+ }
504
+ }
505
+ }
506
+ }
507
+
508
+ func serverResponding(client *server.Client, timeout time.Duration) bool {
509
+ ctx, cancel := context.WithTimeout(context.Background(), timeout)
510
+ defer cancel()
511
+ _, err := client.ListRepos(ctx)
512
+ return err == nil
513
+ }
514
+
315
515
  // --- report ---
316
516
 
317
517
  // cmdReport reads one JSON object from stdin and posts it to /reports.
@@ -513,12 +713,8 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
513
713
  return exitUsage
514
714
  }
515
715
 
516
- // cmdRepoRegister registers one repo. Fully-flagged invocation
517
- // (--name/--path plus --set for every required task repo key) never
518
- // prompts and produces the same repo entry as the interactive run.
519
- // With no flags, a TTY drives the interactive flow: discover runner
520
- // repos, huh searchable select, then prompts for name, path, and each
521
- // required key with the selected candidate's name/path as defaults.
716
+ // cmdRepoRegister registers one flagged repo or multiple interactively selected
717
+ // runner repos. Jira project is shared and component is always the repo name.
522
718
  func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
523
719
  ctx := context.Background()
524
720
  flagged := flagName != "" || flagPath != "" || len(sets) > 0
@@ -529,39 +725,18 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
529
725
  fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
530
726
  return exitUsage
531
727
  }
532
- taskCfg := config.RawValues{}
533
- for k, v := range sets {
534
- if v == "" {
535
- fmt.Fprintf(os.Stderr, "repo register: --set %s requires a non-empty value\n", k)
536
- return exitUsage
537
- }
538
- taskCfg[k] = v
728
+ if _, ok := sets["component"]; ok {
729
+ fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
730
+ return exitUsage
539
731
  }
540
732
  fields, err := c.RepoTaskFields(ctx)
541
733
  if err != nil {
542
734
  fmt.Fprintln(os.Stderr, err)
543
735
  return exitFail
544
736
  }
545
- required := map[string]bool{}
546
- for _, f := range fields {
547
- required[f] = true
548
- }
549
- for k := range taskCfg {
550
- if !required[k] {
551
- fmt.Fprintf(os.Stderr, "repo register: unknown task key %q (required keys: %s)\n",
552
- k, strings.Join(fields, ", "))
553
- return exitUsage
554
- }
555
- }
556
- var missing []string
557
- for _, f := range fields {
558
- if _, ok := taskCfg[f]; !ok {
559
- missing = append(missing, f)
560
- }
561
- }
562
- if len(missing) > 0 {
563
- fmt.Fprintf(os.Stderr, "repo register: missing required task keys: %s (pass --set %s=<value>)\n",
564
- strings.Join(missing, ", "), missing[0])
737
+ taskCfg, err := repoTaskConfig(fields, sets, flagName)
738
+ if err != nil {
739
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
565
740
  return exitUsage
566
741
  }
567
742
  info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
@@ -593,55 +768,117 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
593
768
  return exitFail
594
769
  }
595
770
 
596
- // Searchable select over discovered candidates.
597
- selected := 0
771
+ selected := []int{}
598
772
  options := make([]huh.Option[int], len(candidates))
599
773
  for i, cand := range candidates {
600
774
  options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
601
775
  }
602
- pick := huh.NewForm(huh.NewGroup(huh.NewSelect[int]().
603
- Title("Repository").
604
- Options(options...).
605
- Filtering(true).
606
- Value(&selected)))
776
+ pickField := repoMultiSelect(options, &selected)
777
+ pick := huh.NewForm(huh.NewGroup(pickField))
607
778
  if err := pick.Run(); err != nil {
608
779
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
609
780
  return exitFail
610
781
  }
611
782
 
612
- // Name/path inputs default to the SELECTED candidate, then one input
613
- // per required task repo key. Values pass through as entered; the
614
- // service validates required keys.
615
- cand := candidates[selected]
616
- name := cand.Name
617
- path := cand.Path
618
- inputs := []huh.Field{
619
- huh.NewInput().Title("Name").Value(&name),
620
- huh.NewInput().Title("Path").Value(&path),
783
+ sharedFields := registrationSharedFields(fields)
784
+ values := make([]string, len(sharedFields))
785
+ inputs := make([]huh.Field, len(sharedFields))
786
+ for i, field := range sharedFields {
787
+ title := field
788
+ if field == "project" {
789
+ title = "Jira project"
790
+ }
791
+ inputs[i] = huh.NewInput().Title(title).Value(&values[i])
792
+ }
793
+ if len(inputs) > 0 {
794
+ if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
795
+ fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
796
+ return exitFail
797
+ }
621
798
  }
622
- values := make([]string, len(fields))
623
- for i, f := range fields {
624
- inputs = append(inputs, huh.NewInput().Title(f).Value(&values[i]))
799
+ shared := kvFlags{}
800
+ for i, field := range sharedFields {
801
+ shared[field] = values[i]
625
802
  }
626
- if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
803
+ if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
627
804
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
628
805
  return exitFail
629
806
  }
630
- taskCfg := config.RawValues{}
631
- for i, f := range fields {
632
- taskCfg[f] = values[i]
807
+ return exitOK
808
+ }
809
+
810
+ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
811
+ return huh.NewMultiSelect[int]().
812
+ Title("Select repositories").
813
+ Options(options...).
814
+ Value(selected).
815
+ Validate(func(values []int) error {
816
+ if len(values) == 0 {
817
+ return fmt.Errorf("select at least one repository")
818
+ }
819
+ return nil
820
+ })
821
+ }
822
+
823
+ func registrationSharedFields(fields []string) []string {
824
+ shared := make([]string, 0, len(fields))
825
+ for _, field := range fields {
826
+ if field != "component" {
827
+ shared = append(shared, field)
828
+ }
633
829
  }
634
- info, err := c.RegisterRepo(ctx, repo.RegisterInput{
635
- Name: name,
636
- Path: path,
637
- TaskConfig: taskCfg,
638
- })
639
- if err != nil {
640
- fmt.Fprintln(os.Stderr, err)
641
- return exitFail
830
+ return shared
831
+ }
832
+
833
+ func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
834
+ required := map[string]bool{}
835
+ for _, field := range fields {
836
+ required[field] = true
642
837
  }
643
- fmt.Println(info.Name)
644
- return exitOK
838
+ values := config.RawValues{}
839
+ for key, value := range supplied {
840
+ if !required[key] {
841
+ return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
842
+ }
843
+ if value == "" {
844
+ return nil, fmt.Errorf("task key %q requires a non-empty value", key)
845
+ }
846
+ values[key] = value
847
+ }
848
+ if required["component"] {
849
+ values["component"] = repoName
850
+ }
851
+ var missing []string
852
+ for _, field := range fields {
853
+ if _, ok := values[field]; !ok {
854
+ missing = append(missing, field)
855
+ }
856
+ }
857
+ if len(missing) > 0 {
858
+ return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
859
+ strings.Join(missing, ", "), missing[0])
860
+ }
861
+ return values, nil
862
+ }
863
+
864
+ func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
865
+ for _, index := range selected {
866
+ candidate := candidates[index]
867
+ taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
868
+ if err != nil {
869
+ return fmt.Errorf("%s: %w", candidate.Name, err)
870
+ }
871
+ info, err := c.RegisterRepo(ctx, repo.RegisterInput{
872
+ Name: candidate.Name,
873
+ Path: candidate.Path,
874
+ TaskConfig: taskCfg,
875
+ })
876
+ if err != nil {
877
+ return fmt.Errorf("%s: %w", candidate.Name, err)
878
+ }
879
+ fmt.Println(info.Name)
880
+ }
881
+ return nil
645
882
  }
646
883
 
647
884
  // kvFlags collects repeated key=value flags (registration task config).