relay-flow 0.2.8-alpha → 0.2.10-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 (55) hide show
  1. package/README.md +76 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +391 -83
  4. package/cmd/relay-flow/observability_test.go +204 -0
  5. package/cmd/relay-flow/onboarding.go +344 -0
  6. package/cmd/relay-flow/onboarding_test.go +515 -0
  7. package/cmd/relay-flow/render.go +629 -0
  8. package/cmd/relay-flow/repo_registration.go +219 -0
  9. package/cmd/relay-flow/serve.go +35 -1
  10. package/go.mod +12 -8
  11. package/go.sum +24 -17
  12. package/internal/execution/goworkflows/activities.go +9 -0
  13. package/internal/execution/goworkflows/engine.go +15 -0
  14. package/internal/execution/goworkflows/engine_test.go +3 -3
  15. package/internal/execution/goworkflows/interpreter.go +86 -3
  16. package/internal/execution/goworkflows/projection.go +16 -0
  17. package/internal/execution/projection/detail_test.go +259 -0
  18. package/internal/execution/projection/projection.go +290 -6
  19. package/internal/execution/projection/projection_test.go +3 -2
  20. package/internal/execution/temporal/activities.go +9 -0
  21. package/internal/execution/temporal/engine.go +13 -0
  22. package/internal/execution/temporal/interpreter.go +131 -3
  23. package/internal/execution/temporal/operations.go +174 -0
  24. package/internal/execution/temporal/operations_test.go +36 -0
  25. package/internal/harness/opencode/opencode_test.go +4 -4
  26. package/internal/harness/opencode/repo_setup.go +1 -1
  27. package/internal/harness/pi/prompt_test.go +3 -3
  28. package/internal/repo/service.go +34 -3
  29. package/internal/repo/service_test.go +22 -0
  30. package/internal/run/detail.go +277 -0
  31. package/internal/run/detail_test.go +71 -0
  32. package/internal/run/run.go +4 -0
  33. package/internal/runner/herdr/herdr.go +103 -4
  34. package/internal/runner/herdr/herdr_test.go +72 -2
  35. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  36. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  37. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  38. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  39. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  40. package/internal/runner/orca/orca.go +68 -9
  41. package/internal/runner/orca/orca_test.go +134 -2
  42. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  43. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  44. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  45. package/internal/runner/runner.go +9 -0
  46. package/internal/server/api_test.go +19 -0
  47. package/internal/server/client.go +49 -0
  48. package/internal/server/fixture_test.go +7 -0
  49. package/internal/server/observability.go +109 -0
  50. package/internal/server/observability_test.go +60 -0
  51. package/internal/server/server.go +77 -0
  52. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  53. package/internal/workflow/workflow.go +4 -1
  54. package/internal/workflow/workflow_test.go +4 -4
  55. package/package.json +1 -1
@@ -17,6 +17,7 @@ import (
17
17
  "os/exec"
18
18
  "os/signal"
19
19
  "path/filepath"
20
+ "runtime"
20
21
  "strings"
21
22
  "syscall"
22
23
  "time"
@@ -52,6 +53,14 @@ const (
52
53
  exitUsage = 2
53
54
  )
54
55
 
56
+ // Build metadata has safe local-build defaults and can be replaced by release
57
+ // builds with -ldflags -X main.version=... and friends.
58
+ var (
59
+ version = "dev"
60
+ commit = "unknown"
61
+ buildDate = "unknown"
62
+ )
63
+
55
64
  func main() { os.Exit(run(os.Args[1:], os.Stdin)) }
56
65
 
57
66
  // home returns the relay-flow root, honoring RELAY_FLOW_HOME (tests and
@@ -81,6 +90,13 @@ func run(args []string, stdin io.Reader) int {
81
90
  usage(os.Stderr)
82
91
  return exitUsage
83
92
  }
93
+ if isVersionArgs(args) {
94
+ printVersion(os.Stdout, len(args) == 1 && args[0] == "version")
95
+ return exitOK
96
+ }
97
+ if hasHelpArg(args) {
98
+ return printScopedHelp(args, os.Stdout)
99
+ }
84
100
  p, err := home()
85
101
  if err != nil {
86
102
  fmt.Fprintln(os.Stderr, err)
@@ -133,28 +149,185 @@ func usage(w io.Writer) {
133
149
  fmt.Fprintln(w, `relay-flow — durable ticket runner
134
150
 
135
151
  Usage:
152
+ relay-flow [command] [flags]
153
+
154
+ Setup and server:
136
155
  relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
137
156
  [--executor-plugin <goworkflows|temporal>]
138
157
  [--temporal-address <host:port>] [--temporal-namespace <name>]
158
+ (interactive first run also authenticates and optionally registers repos)
139
159
  relay-flow task auth [task-plugin options]
140
160
  relay-flow serve [--recover] [--debug] [--background]
141
161
  relay-flow stop
142
162
  relay-flow report
163
+ relay-flow version | --version | -v
143
164
 
165
+ Workflow:
144
166
  relay-flow workflow submit --file <path>
145
167
  relay-flow workflow remove --name <name>
146
- relay-flow workflow list
147
- relay-flow workflow get --name <name>
168
+ relay-flow workflow list [--json]
169
+ relay-flow workflow get --name <name> [--json]
148
170
 
171
+ Repository:
149
172
  relay-flow repo register [--name <name> --path <path> --set key=value ...]
150
173
  relay-flow repo remove --name <name>
151
174
  relay-flow repo list
152
175
  relay-flow repo get --name <name>
153
176
 
154
- relay-flow run list
155
- relay-flow run get --ticket <key>
177
+ Run:
178
+ relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json]
179
+ relay-flow run get --ticket <key> [--json]
156
180
  relay-flow run restart --ticket <key>
157
- relay-flow run cancel --ticket <key>`)
181
+ relay-flow run cancel --ticket <key>
182
+
183
+ Examples:
184
+ relay-flow workflow list --json
185
+ relay-flow run get --ticket PAY-101
186
+ Example: relay-flow run get --ticket PAY-101`)
187
+ }
188
+
189
+ func isVersionArgs(args []string) bool {
190
+ return len(args) == 1 && (args[0] == "version" || args[0] == "--version" || args[0] == "-v")
191
+ }
192
+
193
+ func hasHelpArg(args []string) bool {
194
+ for _, arg := range args {
195
+ if arg == "--help" || arg == "-h" {
196
+ return true
197
+ }
198
+ }
199
+ return false
200
+ }
201
+
202
+ func printVersion(w io.Writer, long bool) {
203
+ if !long {
204
+ fmt.Fprintf(w, "relay-flow %s\n", version)
205
+ return
206
+ }
207
+ fmt.Fprintf(w, "relay-flow %s\n", version)
208
+ fmt.Fprintf(w, "Commit: %s\n", commit)
209
+ fmt.Fprintf(w, "Build date: %s\n", buildDate)
210
+ fmt.Fprintf(w, "Go: %s\n", runtime.Version())
211
+ fmt.Fprintf(w, "Platform: %s/%s\n", runtime.GOOS, runtime.GOARCH)
212
+ }
213
+
214
+ func printScopedHelp(args []string, w io.Writer) int {
215
+ // Help is handled before home/config/client creation. Keep each block
216
+ // scoped to the requested command instead of dumping the entire surface.
217
+ path := make([]string, 0, len(args))
218
+ for _, arg := range args {
219
+ if arg == "--help" || arg == "-h" {
220
+ continue
221
+ }
222
+ // Command names are positional; once a flag appears, the remaining
223
+ // tokens are command arguments and must not change the help scope.
224
+ if strings.HasPrefix(arg, "-") {
225
+ break
226
+ }
227
+ path = append(path, arg)
228
+ }
229
+ if len(path) == 0 {
230
+ usage(w)
231
+ return exitOK
232
+ }
233
+ name := strings.Join(path, " ")
234
+ switch name {
235
+ case "init":
236
+ fmt.Fprintln(w, "Usage: relay-flow init [flags]")
237
+ fmt.Fprintln(w, "\nInitialize relay-flow and its durable state.")
238
+ fmt.Fprintln(w, "Flags: --force, --task-plugin, --runner-plugin, --harness-plugin, --executor-plugin")
239
+ fmt.Fprintln(w, "Example: relay-flow init --task-plugin jira --runner-plugin orca --harness-plugin opencode")
240
+ case "serve":
241
+ fmt.Fprintln(w, "Usage: relay-flow serve [--recover] [--debug] [--background]")
242
+ fmt.Fprintln(w, "\nStart the relay-flow server.")
243
+ fmt.Fprintln(w, "Example: relay-flow serve --background")
244
+ case "stop":
245
+ fmt.Fprintln(w, "Usage: relay-flow stop")
246
+ fmt.Fprintln(w, "\nStop the running relay-flow server.")
247
+ fmt.Fprintln(w, "Example: relay-flow stop")
248
+ case "report":
249
+ fmt.Fprintln(w, "Usage: relay-flow report < report.json")
250
+ fmt.Fprintln(w, "\nSubmit one complete structured node report.")
251
+ fmt.Fprintln(w, "Example: relay-flow report < report.json")
252
+ case "task":
253
+ fmt.Fprintln(w, "Usage: relay-flow task <command>")
254
+ fmt.Fprintln(w, "\nCommands: auth")
255
+ fmt.Fprintln(w, "Example: relay-flow task auth")
256
+ case "task auth":
257
+ fmt.Fprintln(w, "Usage: relay-flow task auth [task-plugin options]")
258
+ fmt.Fprintln(w, "\nDelegate authentication to the configured task-system plugin.")
259
+ fmt.Fprintln(w, "Example: relay-flow task auth")
260
+ case "runtime-register":
261
+ fmt.Fprintln(w, "Usage: relay-flow runtime-register < session.json")
262
+ fmt.Fprintln(w, "\nRegister a harness runtime session with the server.")
263
+ fmt.Fprintln(w, "Example: relay-flow runtime-register < session.json")
264
+ case "version":
265
+ fmt.Fprintln(w, "Usage: relay-flow version")
266
+ fmt.Fprintln(w, "\nPrint release and build metadata without contacting the server.")
267
+ fmt.Fprintln(w, "Example: relay-flow version")
268
+ case "workflow":
269
+ fmt.Fprintln(w, "Usage: relay-flow workflow <command>")
270
+ fmt.Fprintln(w, "\nCommands: submit, remove, list, get")
271
+ fmt.Fprintln(w, "Use relay-flow workflow <command> --help for command-specific flags.")
272
+ fmt.Fprintln(w, "Example: relay-flow workflow list --json")
273
+ case "workflow submit":
274
+ fmt.Fprintln(w, "Usage: relay-flow workflow submit --file <path>")
275
+ fmt.Fprintln(w, "\nCreate or replace a validated workflow definition.")
276
+ fmt.Fprintln(w, "Example: relay-flow workflow submit --file workflow.yaml")
277
+ case "workflow remove":
278
+ fmt.Fprintln(w, "Usage: relay-flow workflow remove --name <name>")
279
+ fmt.Fprintln(w, "\nRemove a workflow with no active runs.")
280
+ fmt.Fprintln(w, "Example: relay-flow workflow remove --name basicFlow")
281
+ case "workflow list":
282
+ fmt.Fprintln(w, "Usage: relay-flow workflow list [--json] [--no-color]")
283
+ fmt.Fprintln(w, "\nList configured workflows and their latest execution summaries.")
284
+ fmt.Fprintln(w, "Example: relay-flow workflow list --json")
285
+ case "workflow get":
286
+ fmt.Fprintln(w, "Usage: relay-flow workflow get --name <name> [--json] [--no-color]")
287
+ fmt.Fprintln(w, "\nShow validated static graph and recent runs. Use --json for automation.")
288
+ fmt.Fprintln(w, "Example: relay-flow workflow get --name basicFlow")
289
+ case "repo":
290
+ fmt.Fprintln(w, "Usage: relay-flow repo <command>")
291
+ fmt.Fprintln(w, "\nCommands: register, remove, list, get")
292
+ fmt.Fprintln(w, "Use relay-flow repo <command> --help for command-specific flags.")
293
+ fmt.Fprintln(w, "Example: relay-flow repo list")
294
+ case "repo register":
295
+ fmt.Fprintln(w, "Usage: relay-flow repo register [--name <name> --path <path> --set key=value]")
296
+ fmt.Fprintln(w, "\nRegister a runner repository and task-system configuration.")
297
+ fmt.Fprintln(w, "Example: relay-flow repo register --name payments --path /work/payments")
298
+ case "repo remove", "repo get":
299
+ fmt.Fprintf(w, "Usage: relay-flow %s --name <name>\n", name)
300
+ fmt.Fprintf(w, "Example: relay-flow %s --name payments\n", name)
301
+ case "repo list":
302
+ fmt.Fprintln(w, "Usage: relay-flow repo list")
303
+ fmt.Fprintln(w, "\nList registered repositories.")
304
+ fmt.Fprintln(w, "Example: relay-flow repo list")
305
+ case "run":
306
+ fmt.Fprintln(w, "Usage: relay-flow run <command>")
307
+ fmt.Fprintln(w, "\nCommands: list, get, restart, cancel")
308
+ fmt.Fprintln(w, "Use relay-flow run <command> --help for command-specific flags.")
309
+ fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
310
+ case "run list":
311
+ fmt.Fprintln(w, "Usage: relay-flow run list [--repo <name>] [--workflow <name>] [--ticket <key>] [--active] [--json] [--no-color]")
312
+ fmt.Fprintln(w, "\nList durable runs. Use --json for automation.")
313
+ fmt.Fprintln(w, "Example: relay-flow run list --active --json")
314
+ case "run get":
315
+ fmt.Fprintln(w, "Usage: relay-flow run get --ticket <key> [--json] [--no-color]")
316
+ fmt.Fprintln(w, "\nShow an Argo-shaped execution detail view and actual step visits.")
317
+ fmt.Fprintln(w, "Example: relay-flow run get --ticket PAY-101")
318
+ case "run restart":
319
+ fmt.Fprintln(w, "Usage: relay-flow run restart --ticket <key>")
320
+ fmt.Fprintln(w, "\nStart a fresh attempt for a canceled run.")
321
+ fmt.Fprintln(w, "Example: relay-flow run restart --ticket PAY-101")
322
+ case "run cancel":
323
+ fmt.Fprintln(w, "Usage: relay-flow run cancel --ticket <key> [--reason <text>]")
324
+ fmt.Fprintln(w, "\nPermanently cancel the current execution attempt.")
325
+ fmt.Fprintln(w, "Example: relay-flow run cancel --ticket PAY-101 --reason "+`"operator request"`)
326
+ default:
327
+ fmt.Fprintf(os.Stderr, "unknown help target: %s\n", name)
328
+ return exitUsage
329
+ }
330
+ return exitOK
158
331
  }
159
332
 
160
333
  // --- init / serve delegate to the section-5 composition root ---
@@ -168,6 +341,14 @@ Usage:
168
341
  // a TTY → three stdin lines (task, runner, harness), the documented test
169
342
  // seam and script path.
170
343
  func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
344
+ if err := recoverFirstRunPublication(p); err != nil {
345
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
346
+ return exitFail
347
+ }
348
+ if err := cleanupFirstRunStaging(p); err != nil {
349
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
350
+ return exitFail
351
+ }
171
352
  fs := flag.NewFlagSet("init", flag.ContinueOnError)
172
353
  taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
173
354
  runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
@@ -206,11 +387,17 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
206
387
  fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
207
388
  return exitFail
208
389
  }
209
- if err := os.MkdirAll(p.Root, 0o700); err != nil {
210
- fmt.Fprintln(os.Stderr, err)
211
- return exitFail
390
+ // A first interactive setup keeps the final home untouched until the
391
+ // selected task plugin has authenticated successfully. Scripted setup and
392
+ // --force retain the existing eager directory creation behavior.
393
+ firstRunInteractive := !*force && !configExists && !databaseExists && !flagged && interactiveInitTTY(stdin)
394
+ if !firstRunInteractive {
395
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
396
+ fmt.Fprintln(os.Stderr, err)
397
+ return exitFail
398
+ }
399
+ _ = os.Chmod(p.Root, 0o700)
212
400
  }
213
- _ = os.Chmod(p.Root, 0o700)
214
401
  var unlock func()
215
402
  if *force {
216
403
  unlock, err = lockForForcedInit(p.Lock)
@@ -240,9 +427,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
240
427
  switch {
241
428
  case flagged:
242
429
  names = []string{*taskName, *runnerName, *harnessName}
243
- case isTTY(stdin):
430
+ case interactiveInitTTY(stdin):
244
431
  var err error
245
- names, err = pickPluginsInteractive()
432
+ names, err = interactiveInitPluginPick()
246
433
  if err != nil {
247
434
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
248
435
  return exitFail
@@ -258,7 +445,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
258
445
  return exitFail
259
446
  }
260
447
  }
261
- if executor == executorTemporal && isTTY(stdin) {
448
+ if executor == executorTemporal && interactiveInitTTY(stdin) {
262
449
  address, namespace, err := promptTemporalSettings(*temporalAddress, *temporalNamespace)
263
450
  if err != nil {
264
451
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
@@ -353,6 +540,19 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
353
540
  } else {
354
541
  cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
355
542
  }
543
+ var stagedAuthDir string
544
+ if firstRunInteractive {
545
+ if names[0] == "beads" {
546
+ fmt.Println("Beads authentication is managed by the Beads workspace and Dolt setup; relay-flow does not store task credentials.")
547
+ }
548
+ var authErr error
549
+ cfg, stagedAuthDir, authErr = stageFirstRunAuthentication(p, cfg, names[0], stdin)
550
+ if authErr != nil {
551
+ fmt.Fprintln(os.Stderr, "init: task auth: "+authErr.Error())
552
+ return exitFail
553
+ }
554
+ defer os.RemoveAll(stagedAuthDir)
555
+ }
356
556
  identity := projection.ExecutorIdentity{
357
557
  ExecutorPlugin: executor,
358
558
  TemporalAddress: cfg.TemporalAddress,
@@ -373,13 +573,30 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
373
573
  return exitFail
374
574
  }
375
575
  }
376
- if !databaseExists {
576
+ if !firstRunInteractive && !databaseExists {
377
577
  if err := projection.InitDatabaseWithIdentity(p.Database, identity); err != nil {
378
578
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
379
579
  return exitFail
380
580
  }
381
581
  }
382
- if err := config.SaveMachine(p.Config, cfg); err != nil {
582
+ if firstRunInteractive {
583
+ if err := commitFirstRun(p, stagedAuthDir, identity); err != nil {
584
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
585
+ return exitFail
586
+ }
587
+ register, err := interactiveInitRepoPrompt()
588
+ if err != nil {
589
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
590
+ return exitFail
591
+ }
592
+ if register {
593
+ if err := interactiveInitRepoSetup(p, stdin); err != nil {
594
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
595
+ return exitFail
596
+ }
597
+ fmt.Println("Repository setup complete. The server is running; stop it with `relay-flow stop`.")
598
+ }
599
+ } else if err := config.SaveMachine(p.Config, cfg); err != nil {
383
600
  fmt.Fprintln(os.Stderr, err)
384
601
  return exitFail
385
602
  }
@@ -540,55 +757,121 @@ func cmdServe(p paths.Paths, args []string) int {
540
757
  return exitOK
541
758
  }
542
759
 
760
+ var (
761
+ backgroundServeStartupTimeout = 10 * time.Second
762
+ backgroundServePollInterval = 50 * time.Millisecond
763
+ )
764
+
543
765
  func startBackgroundServe(p paths.Paths, recover, debug bool) error {
544
766
  client := server.NewClient(p.Socket)
545
767
  if serverResponding(client, 200*time.Millisecond) {
546
768
  return fmt.Errorf("serve --background: server is already running")
547
769
  }
548
- executable, err := os.Executable()
770
+ deadline := time.Now().Add(backgroundServeStartupTimeout)
771
+ lockHeld, err := serverLockHeld(p.Lock)
549
772
  if err != nil {
550
- return fmt.Errorf("serve --background: resolve executable: %w", err)
773
+ return fmt.Errorf("serve --background: inspect server lock: %w", err)
551
774
  }
552
- childArgs := []string{"serve"}
553
- if recover {
554
- childArgs = append(childArgs, "--recover")
555
- }
556
- if debug {
557
- childArgs = append(childArgs, "--debug")
775
+
776
+ var (
777
+ cmd *exec.Cmd
778
+ wait chan error
779
+ observedOwner = lockHeld
780
+ devNull *os.File
781
+ )
782
+ if !lockHeld {
783
+ executable, resolveErr := os.Executable()
784
+ if resolveErr != nil {
785
+ return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
786
+ }
787
+ childArgs := []string{"serve"}
788
+ if recover {
789
+ childArgs = append(childArgs, "--recover")
790
+ }
791
+ if debug {
792
+ childArgs = append(childArgs, "--debug")
793
+ }
794
+ devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
795
+ if err != nil {
796
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
797
+ }
798
+ defer devNull.Close()
799
+ cmd = exec.Command(executable, childArgs...)
800
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
801
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
802
+ if err := cmd.Start(); err != nil {
803
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
804
+ }
805
+ wait = make(chan error, 1)
806
+ go func() { wait <- cmd.Wait() }()
558
807
  }
559
- devNull, err := os.OpenFile(os.DevNull, os.O_RDWR, 0)
560
- if err != nil {
561
- return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
562
- }
563
- defer devNull.Close()
564
- cmd := exec.Command(executable, childArgs...)
565
- cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
566
- cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
567
- if err := cmd.Start(); err != nil {
568
- return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
569
- }
570
- wait := make(chan error, 1)
571
- go func() { wait <- cmd.Wait() }()
572
- deadline := time.NewTimer(10 * time.Second)
573
- defer deadline.Stop()
574
- ticker := time.NewTicker(50 * time.Millisecond)
808
+
809
+ ticker := time.NewTicker(backgroundServePollInterval)
575
810
  defer ticker.Stop()
576
811
  for {
577
- select {
578
- case err := <-wait:
579
- return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", err, p.ServerLog)
580
- case <-deadline.C:
581
- _ = cmd.Process.Kill()
582
- <-wait
812
+ if serverResponding(client, 200*time.Millisecond) {
813
+ return nil
814
+ }
815
+ if !time.Now().Before(deadline) {
816
+ if cmd != nil && wait != nil {
817
+ _ = cmd.Process.Kill()
818
+ <-wait
819
+ }
583
820
  return fmt.Errorf("serve --background: startup timed out; see %s", p.ServerLog)
584
- case <-ticker.C:
585
- if serverResponding(client, 200*time.Millisecond) {
586
- return nil
821
+ }
822
+ if observedOwner && wait == nil {
823
+ held, lockErr := serverLockHeld(p.Lock)
824
+ if lockErr != nil {
825
+ return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
826
+ }
827
+ if !held {
828
+ return fmt.Errorf("serve --background: server exited before readiness; see %s", p.ServerLog)
829
+ }
830
+ }
831
+ select {
832
+ case childErr := <-wait:
833
+ held, lockErr := serverLockHeld(p.Lock)
834
+ if lockErr != nil {
835
+ return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
836
+ }
837
+ if held {
838
+ // Another process acquired the lock while this child was
839
+ // starting. Wait for that owner instead of spawning again.
840
+ observedOwner = true
841
+ wait = nil
842
+ cmd = nil
843
+ continue
587
844
  }
845
+ return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", childErr, p.ServerLog)
846
+ case <-ticker.C:
588
847
  }
589
848
  }
590
849
  }
591
850
 
851
+ func serverLockHeld(path string) (bool, error) {
852
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
853
+ if err != nil {
854
+ if os.IsNotExist(err) {
855
+ return false, nil
856
+ }
857
+ return false, err
858
+ }
859
+ defer f.Close()
860
+ if err := f.Chmod(0o600); err != nil {
861
+ return false, err
862
+ }
863
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
864
+ if err == syscall.EWOULDBLOCK || err == syscall.EAGAIN {
865
+ return true, nil
866
+ }
867
+ return false, err
868
+ }
869
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil {
870
+ return false, err
871
+ }
872
+ return false, nil
873
+ }
874
+
592
875
  func serverResponding(client *server.Client, timeout time.Duration) bool {
593
876
  ctx, cancel := context.WithTimeout(context.Background(), timeout)
594
877
  defer cancel()
@@ -690,21 +973,28 @@ func cmdWorkflow(c *server.Client, args []string) int {
690
973
  return exitOK
691
974
  case "list":
692
975
  fs := flag.NewFlagSet("workflow list", flag.ContinueOnError)
976
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
977
+ fs.Bool("no-color", false, "disable terminal colors")
693
978
  if err := fs.Parse(args[1:]); err != nil {
694
979
  return exitUsage
695
980
  }
696
- wfs, err := c.ListWorkflows(context.Background())
981
+ finishLoading := beginLoading("Loading workflows", *jsonOutput)
982
+ summaries, err := c.ListWorkflowSummaries(context.Background())
983
+ finishLoading(err)
697
984
  if err != nil {
698
985
  fmt.Fprintln(os.Stderr, err)
699
986
  return exitFail
700
987
  }
701
- for _, wf := range wfs {
702
- fmt.Println(wf.Name)
988
+ if *jsonOutput {
989
+ return encodeJSON(summaries)
703
990
  }
991
+ renderWorkflowSummaries(os.Stdout, summaries, cliRenderOptions())
704
992
  return exitOK
705
993
  case "get":
706
994
  fs := flag.NewFlagSet("workflow get", flag.ContinueOnError)
707
995
  name := fs.String("name", "", "workflow name")
996
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
997
+ fs.Bool("no-color", false, "disable terminal colors")
708
998
  if err := fs.Parse(args[1:]); err != nil {
709
999
  return exitUsage
710
1000
  }
@@ -712,14 +1002,23 @@ func cmdWorkflow(c *server.Client, args []string) int {
712
1002
  fmt.Fprintln(os.Stderr, "workflow get: --name is required")
713
1003
  return exitUsage
714
1004
  }
715
- wf, err := c.GetWorkflow(context.Background(), *name)
1005
+ finishLoading := beginLoading("Loading workflow", *jsonOutput)
1006
+ if *jsonOutput {
1007
+ wf, err := c.GetWorkflow(context.Background(), *name)
1008
+ finishLoading(err)
1009
+ if err != nil {
1010
+ fmt.Fprintln(os.Stderr, err)
1011
+ return exitFail
1012
+ }
1013
+ return encodeJSON(wf)
1014
+ }
1015
+ detail, err := c.GetWorkflowDetail(context.Background(), *name)
1016
+ finishLoading(err)
716
1017
  if err != nil {
717
1018
  fmt.Fprintln(os.Stderr, err)
718
1019
  return exitFail
719
1020
  }
720
- enc := json.NewEncoder(os.Stdout)
721
- enc.SetIndent("", " ")
722
- _ = enc.Encode(wf)
1021
+ renderWorkflowDetail(os.Stdout, detail, cliRenderOptions())
723
1022
  return exitOK
724
1023
  }
725
1024
  usage(os.Stderr)
@@ -811,7 +1110,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
811
1110
  }
812
1111
  registration, err := loadRepoRegistration(ctx, c, sets)
813
1112
  if err != nil {
814
- fmt.Fprintln(os.Stderr, err)
1113
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
815
1114
  return exitFail
816
1115
  }
817
1116
  taskCfg, err := registrationTaskConfig(registration, sets, flagName)
@@ -821,7 +1120,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
821
1120
  }
822
1121
  info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
823
1122
  if err != nil {
824
- fmt.Fprintln(os.Stderr, err)
1123
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
825
1124
  return exitFail
826
1125
  }
827
1126
  fmt.Println(info.Name)
@@ -830,7 +1129,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
830
1129
 
831
1130
  registration, err := c.RepoRegistrationFields(ctx, nil)
832
1131
  if err != nil {
833
- fmt.Fprintln(os.Stderr, err)
1132
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
834
1133
  return exitFail
835
1134
  }
836
1135
 
@@ -840,23 +1139,17 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
840
1139
  }
841
1140
  candidates, err := c.DiscoverRepos(ctx)
842
1141
  if err != nil {
843
- fmt.Fprintln(os.Stderr, err)
1142
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
844
1143
  return exitFail
845
1144
  }
846
- if len(candidates) == 0 {
847
- fmt.Fprintln(os.Stderr, "repo register: runner discovered no repos")
1145
+ registered, err := c.ListRepos(ctx)
1146
+ if err != nil {
1147
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
848
1148
  return exitFail
849
1149
  }
850
-
851
- selected := []int{}
852
- options := make([]huh.Option[int], len(candidates))
853
- for i, cand := range candidates {
854
- options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
855
- }
856
- pickField := repoMultiSelect(options, &selected)
857
- pick := huh.NewForm(huh.NewGroup(pickField))
858
- if err := pick.Run(); err != nil {
859
- fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
1150
+ candidates, selected, err := selectReposInteractive(ctx, c, candidates, registered)
1151
+ if err != nil {
1152
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
860
1153
  return exitFail
861
1154
  }
862
1155
 
@@ -868,7 +1161,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
868
1161
  if len(shared) > 0 {
869
1162
  dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(shared))
870
1163
  if err != nil {
871
- fmt.Fprintln(os.Stderr, err)
1164
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
872
1165
  return exitFail
873
1166
  }
874
1167
  registration = mergeRegistration(registration, dependent)
@@ -878,7 +1171,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
878
1171
  }
879
1172
  }
880
1173
  if err := registerSelectedReposDynamic(ctx, c, candidates, selected, registration, shared); err != nil {
881
- fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
1174
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
882
1175
  return exitFail
883
1176
  }
884
1177
  return exitOK
@@ -887,6 +1180,8 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
887
1180
  func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
888
1181
  return huh.NewMultiSelect[int]().
889
1182
  Title("Select repositories").
1183
+ Description("Press / to filter repositories; select [+] Add repository to add a runner resource.").
1184
+ Filterable(true).
890
1185
  Options(options...).
891
1186
  Value(selected).
892
1187
  Validate(func(values []int) error {
@@ -927,25 +1222,35 @@ func cmdRun(c *server.Client, args []string) int {
927
1222
  repoName := fs.String("repo", "", "filter by repo")
928
1223
  workflowName := fs.String("workflow", "", "filter by workflow")
929
1224
  ticket := fs.String("ticket", "", "filter by ticket")
1225
+ activeOnly := fs.Bool("active", false, "show only active runs")
1226
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
1227
+ fs.Bool("no-color", false, "disable terminal colors")
930
1228
  if err := fs.Parse(args[1:]); err != nil {
931
1229
  return exitUsage
932
1230
  }
933
- runs, err := c.ListRuns(context.Background(), runsvc.Filter{
934
- Repo: *repoName,
935
- Workflow: *workflowName,
936
- Ticket: *ticket,
937
- })
1231
+ var active *bool
1232
+ if *activeOnly {
1233
+ value := true
1234
+ active = &value
1235
+ }
1236
+ filter := runsvc.Filter{Repo: *repoName, Workflow: *workflowName, Ticket: *ticket, Active: active}
1237
+ finishLoading := beginLoading("Loading runs", *jsonOutput)
1238
+ runs, err := c.ListRuns(context.Background(), filter)
1239
+ finishLoading(err)
938
1240
  if err != nil {
939
1241
  fmt.Fprintln(os.Stderr, err)
940
1242
  return exitFail
941
1243
  }
942
- for _, r := range runs {
943
- fmt.Println(formatRunListRow(r))
1244
+ if *jsonOutput {
1245
+ return encodeJSON(runs)
944
1246
  }
1247
+ renderRunsFiltered(os.Stdout, runs, cliRenderOptions(), filter)
945
1248
  return exitOK
946
1249
  case "get":
947
1250
  fs := flag.NewFlagSet("run get", flag.ContinueOnError)
948
1251
  ticket := fs.String("ticket", "", "ticket key")
1252
+ jsonOutput := fs.Bool("json", false, "print stable JSON")
1253
+ fs.Bool("no-color", false, "disable terminal colors")
949
1254
  if err := fs.Parse(args[1:]); err != nil {
950
1255
  return exitUsage
951
1256
  }
@@ -953,14 +1258,17 @@ func cmdRun(c *server.Client, args []string) int {
953
1258
  fmt.Fprintln(os.Stderr, "run get: --ticket is required")
954
1259
  return exitUsage
955
1260
  }
956
- rn, err := c.GetRunByTicket(context.Background(), *ticket)
1261
+ finishLoading := beginLoading("Loading run", *jsonOutput)
1262
+ detail, err := c.GetRunDetailByTicket(context.Background(), *ticket)
1263
+ finishLoading(err)
957
1264
  if err != nil {
958
1265
  fmt.Fprintln(os.Stderr, err)
959
1266
  return exitFail
960
1267
  }
961
- enc := json.NewEncoder(os.Stdout)
962
- enc.SetIndent("", " ")
963
- _ = enc.Encode(rn)
1268
+ if *jsonOutput {
1269
+ return encodeJSON(detail)
1270
+ }
1271
+ renderRunDetail(os.Stdout, detail, cliRenderOptions())
964
1272
  return exitOK
965
1273
  case "restart":
966
1274
  fs := flag.NewFlagSet("run restart", flag.ContinueOnError)