relay-flow 0.2.8-alpha → 0.2.9-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 (36) hide show
  1. package/README.md +62 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +167 -60
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +219 -0
  7. package/cmd/relay-flow/serve.go +3 -0
  8. package/go.mod +12 -8
  9. package/go.sum +24 -17
  10. package/internal/execution/goworkflows/engine_test.go +3 -3
  11. package/internal/harness/opencode/opencode_test.go +4 -4
  12. package/internal/harness/opencode/repo_setup.go +1 -1
  13. package/internal/harness/pi/prompt_test.go +3 -3
  14. package/internal/repo/service.go +34 -3
  15. package/internal/repo/service_test.go +22 -0
  16. package/internal/runner/herdr/herdr.go +103 -4
  17. package/internal/runner/herdr/herdr_test.go +72 -2
  18. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  19. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  20. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  21. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  22. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  23. package/internal/runner/orca/orca.go +68 -9
  24. package/internal/runner/orca/orca_test.go +134 -2
  25. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  26. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  27. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  28. package/internal/runner/runner.go +9 -0
  29. package/internal/server/api_test.go +19 -0
  30. package/internal/server/client.go +10 -0
  31. package/internal/server/fixture_test.go +7 -0
  32. package/internal/server/server.go +41 -0
  33. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  34. package/internal/workflow/workflow.go +4 -1
  35. package/internal/workflow/workflow_test.go +4 -4
  36. package/package.json +1 -1
@@ -136,6 +136,7 @@ Usage:
136
136
  relay-flow init [--force] [--task-plugin <name> --runner-plugin <name> --harness-plugin <name>]
137
137
  [--executor-plugin <goworkflows|temporal>]
138
138
  [--temporal-address <host:port>] [--temporal-namespace <name>]
139
+ (interactive first run also authenticates and optionally registers repos)
139
140
  relay-flow task auth [task-plugin options]
140
141
  relay-flow serve [--recover] [--debug] [--background]
141
142
  relay-flow stop
@@ -168,6 +169,14 @@ Usage:
168
169
  // a TTY → three stdin lines (task, runner, harness), the documented test
169
170
  // seam and script path.
170
171
  func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
172
+ if err := recoverFirstRunPublication(p); err != nil {
173
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
174
+ return exitFail
175
+ }
176
+ if err := cleanupFirstRunStaging(p); err != nil {
177
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
178
+ return exitFail
179
+ }
171
180
  fs := flag.NewFlagSet("init", flag.ContinueOnError)
172
181
  taskName := fs.String("task-plugin", "", "task plugin name (non-interactive)")
173
182
  runnerName := fs.String("runner-plugin", "", "runner plugin name (non-interactive)")
@@ -206,11 +215,17 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
206
215
  fmt.Fprintln(os.Stderr, "init: database is missing; refusing to recreate existing durable state")
207
216
  return exitFail
208
217
  }
209
- if err := os.MkdirAll(p.Root, 0o700); err != nil {
210
- fmt.Fprintln(os.Stderr, err)
211
- return exitFail
218
+ // A first interactive setup keeps the final home untouched until the
219
+ // selected task plugin has authenticated successfully. Scripted setup and
220
+ // --force retain the existing eager directory creation behavior.
221
+ firstRunInteractive := !*force && !configExists && !databaseExists && !flagged && interactiveInitTTY(stdin)
222
+ if !firstRunInteractive {
223
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
224
+ fmt.Fprintln(os.Stderr, err)
225
+ return exitFail
226
+ }
227
+ _ = os.Chmod(p.Root, 0o700)
212
228
  }
213
- _ = os.Chmod(p.Root, 0o700)
214
229
  var unlock func()
215
230
  if *force {
216
231
  unlock, err = lockForForcedInit(p.Lock)
@@ -240,9 +255,9 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
240
255
  switch {
241
256
  case flagged:
242
257
  names = []string{*taskName, *runnerName, *harnessName}
243
- case isTTY(stdin):
258
+ case interactiveInitTTY(stdin):
244
259
  var err error
245
- names, err = pickPluginsInteractive()
260
+ names, err = interactiveInitPluginPick()
246
261
  if err != nil {
247
262
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
248
263
  return exitFail
@@ -258,7 +273,7 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
258
273
  return exitFail
259
274
  }
260
275
  }
261
- if executor == executorTemporal && isTTY(stdin) {
276
+ if executor == executorTemporal && interactiveInitTTY(stdin) {
262
277
  address, namespace, err := promptTemporalSettings(*temporalAddress, *temporalNamespace)
263
278
  if err != nil {
264
279
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
@@ -353,6 +368,19 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
353
368
  } else {
354
369
  cfg.HarnessConfig = config.Merge(harnessDefaults, cfg.HarnessConfig)
355
370
  }
371
+ var stagedAuthDir string
372
+ if firstRunInteractive {
373
+ if names[0] == "beads" {
374
+ fmt.Println("Beads authentication is managed by the Beads workspace and Dolt setup; relay-flow does not store task credentials.")
375
+ }
376
+ var authErr error
377
+ cfg, stagedAuthDir, authErr = stageFirstRunAuthentication(p, cfg, names[0], stdin)
378
+ if authErr != nil {
379
+ fmt.Fprintln(os.Stderr, "init: task auth: "+authErr.Error())
380
+ return exitFail
381
+ }
382
+ defer os.RemoveAll(stagedAuthDir)
383
+ }
356
384
  identity := projection.ExecutorIdentity{
357
385
  ExecutorPlugin: executor,
358
386
  TemporalAddress: cfg.TemporalAddress,
@@ -373,13 +401,30 @@ func cmdInit(p paths.Paths, args []string, stdin io.Reader) int {
373
401
  return exitFail
374
402
  }
375
403
  }
376
- if !databaseExists {
404
+ if !firstRunInteractive && !databaseExists {
377
405
  if err := projection.InitDatabaseWithIdentity(p.Database, identity); err != nil {
378
406
  fmt.Fprintln(os.Stderr, "init: "+err.Error())
379
407
  return exitFail
380
408
  }
381
409
  }
382
- if err := config.SaveMachine(p.Config, cfg); err != nil {
410
+ if firstRunInteractive {
411
+ if err := commitFirstRun(p, stagedAuthDir, identity); err != nil {
412
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
413
+ return exitFail
414
+ }
415
+ register, err := interactiveInitRepoPrompt()
416
+ if err != nil {
417
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
418
+ return exitFail
419
+ }
420
+ if register {
421
+ if err := interactiveInitRepoSetup(p, stdin); err != nil {
422
+ fmt.Fprintln(os.Stderr, "init: "+err.Error())
423
+ return exitFail
424
+ }
425
+ fmt.Println("Repository setup complete. The server is running; stop it with `relay-flow stop`.")
426
+ }
427
+ } else if err := config.SaveMachine(p.Config, cfg); err != nil {
383
428
  fmt.Fprintln(os.Stderr, err)
384
429
  return exitFail
385
430
  }
@@ -540,55 +585,121 @@ func cmdServe(p paths.Paths, args []string) int {
540
585
  return exitOK
541
586
  }
542
587
 
588
+ var (
589
+ backgroundServeStartupTimeout = 10 * time.Second
590
+ backgroundServePollInterval = 50 * time.Millisecond
591
+ )
592
+
543
593
  func startBackgroundServe(p paths.Paths, recover, debug bool) error {
544
594
  client := server.NewClient(p.Socket)
545
595
  if serverResponding(client, 200*time.Millisecond) {
546
596
  return fmt.Errorf("serve --background: server is already running")
547
597
  }
548
- executable, err := os.Executable()
598
+ deadline := time.Now().Add(backgroundServeStartupTimeout)
599
+ lockHeld, err := serverLockHeld(p.Lock)
549
600
  if err != nil {
550
- return fmt.Errorf("serve --background: resolve executable: %w", err)
601
+ return fmt.Errorf("serve --background: inspect server lock: %w", err)
551
602
  }
552
- childArgs := []string{"serve"}
553
- if recover {
554
- childArgs = append(childArgs, "--recover")
555
- }
556
- if debug {
557
- childArgs = append(childArgs, "--debug")
603
+
604
+ var (
605
+ cmd *exec.Cmd
606
+ wait chan error
607
+ observedOwner = lockHeld
608
+ devNull *os.File
609
+ )
610
+ if !lockHeld {
611
+ executable, resolveErr := os.Executable()
612
+ if resolveErr != nil {
613
+ return fmt.Errorf("serve --background: resolve executable: %w", resolveErr)
614
+ }
615
+ childArgs := []string{"serve"}
616
+ if recover {
617
+ childArgs = append(childArgs, "--recover")
618
+ }
619
+ if debug {
620
+ childArgs = append(childArgs, "--debug")
621
+ }
622
+ devNull, err = os.OpenFile(os.DevNull, os.O_RDWR, 0)
623
+ if err != nil {
624
+ return fmt.Errorf("serve --background: open %s: %w", os.DevNull, err)
625
+ }
626
+ defer devNull.Close()
627
+ cmd = exec.Command(executable, childArgs...)
628
+ cmd.Stdin, cmd.Stdout, cmd.Stderr = devNull, devNull, devNull
629
+ cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true}
630
+ if err := cmd.Start(); err != nil {
631
+ return fmt.Errorf("serve --background: start: %w; see %s", err, p.ServerLog)
632
+ }
633
+ wait = make(chan error, 1)
634
+ go func() { wait <- cmd.Wait() }()
558
635
  }
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)
636
+
637
+ ticker := time.NewTicker(backgroundServePollInterval)
575
638
  defer ticker.Stop()
576
639
  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
640
+ if serverResponding(client, 200*time.Millisecond) {
641
+ return nil
642
+ }
643
+ if !time.Now().Before(deadline) {
644
+ if cmd != nil && wait != nil {
645
+ _ = cmd.Process.Kill()
646
+ <-wait
647
+ }
583
648
  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
649
+ }
650
+ if observedOwner && wait == nil {
651
+ held, lockErr := serverLockHeld(p.Lock)
652
+ if lockErr != nil {
653
+ return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
654
+ }
655
+ if !held {
656
+ return fmt.Errorf("serve --background: server exited before readiness; see %s", p.ServerLog)
587
657
  }
588
658
  }
659
+ select {
660
+ case childErr := <-wait:
661
+ held, lockErr := serverLockHeld(p.Lock)
662
+ if lockErr != nil {
663
+ return fmt.Errorf("serve --background: inspect server lock: %w", lockErr)
664
+ }
665
+ if held {
666
+ // Another process acquired the lock while this child was
667
+ // starting. Wait for that owner instead of spawning again.
668
+ observedOwner = true
669
+ wait = nil
670
+ cmd = nil
671
+ continue
672
+ }
673
+ return fmt.Errorf("serve --background: server exited before readiness: %v; see %s", childErr, p.ServerLog)
674
+ case <-ticker.C:
675
+ }
589
676
  }
590
677
  }
591
678
 
679
+ func serverLockHeld(path string) (bool, error) {
680
+ f, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0o600)
681
+ if err != nil {
682
+ if os.IsNotExist(err) {
683
+ return false, nil
684
+ }
685
+ return false, err
686
+ }
687
+ defer f.Close()
688
+ if err := f.Chmod(0o600); err != nil {
689
+ return false, err
690
+ }
691
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_EX|syscall.LOCK_NB); err != nil {
692
+ if err == syscall.EWOULDBLOCK || err == syscall.EAGAIN {
693
+ return true, nil
694
+ }
695
+ return false, err
696
+ }
697
+ if err := syscall.Flock(int(f.Fd()), syscall.LOCK_UN); err != nil {
698
+ return false, err
699
+ }
700
+ return false, nil
701
+ }
702
+
592
703
  func serverResponding(client *server.Client, timeout time.Duration) bool {
593
704
  ctx, cancel := context.WithTimeout(context.Background(), timeout)
594
705
  defer cancel()
@@ -811,7 +922,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
811
922
  }
812
923
  registration, err := loadRepoRegistration(ctx, c, sets)
813
924
  if err != nil {
814
- fmt.Fprintln(os.Stderr, err)
925
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
815
926
  return exitFail
816
927
  }
817
928
  taskCfg, err := registrationTaskConfig(registration, sets, flagName)
@@ -821,7 +932,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
821
932
  }
822
933
  info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
823
934
  if err != nil {
824
- fmt.Fprintln(os.Stderr, err)
935
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
825
936
  return exitFail
826
937
  }
827
938
  fmt.Println(info.Name)
@@ -830,7 +941,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
830
941
 
831
942
  registration, err := c.RepoRegistrationFields(ctx, nil)
832
943
  if err != nil {
833
- fmt.Fprintln(os.Stderr, err)
944
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
834
945
  return exitFail
835
946
  }
836
947
 
@@ -840,23 +951,17 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
840
951
  }
841
952
  candidates, err := c.DiscoverRepos(ctx)
842
953
  if err != nil {
843
- fmt.Fprintln(os.Stderr, err)
954
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
844
955
  return exitFail
845
956
  }
846
- if len(candidates) == 0 {
847
- fmt.Fprintln(os.Stderr, "repo register: runner discovered no repos")
957
+ registered, err := c.ListRepos(ctx)
958
+ if err != nil {
959
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
848
960
  return exitFail
849
961
  }
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())
962
+ candidates, selected, err := selectReposInteractive(ctx, c, candidates, registered)
963
+ if err != nil {
964
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
860
965
  return exitFail
861
966
  }
862
967
 
@@ -868,7 +973,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
868
973
  if len(shared) > 0 {
869
974
  dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(shared))
870
975
  if err != nil {
871
- fmt.Fprintln(os.Stderr, err)
976
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
872
977
  return exitFail
873
978
  }
874
979
  registration = mergeRegistration(registration, dependent)
@@ -878,7 +983,7 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
878
983
  }
879
984
  }
880
985
  if err := registerSelectedReposDynamic(ctx, c, candidates, selected, registration, shared); err != nil {
881
- fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
986
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
882
987
  return exitFail
883
988
  }
884
989
  return exitOK
@@ -887,6 +992,8 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
887
992
  func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
888
993
  return huh.NewMultiSelect[int]().
889
994
  Title("Select repositories").
995
+ Description("Press / to filter repositories; select [+] Add repository to add a runner resource.").
996
+ Filterable(true).
890
997
  Options(options...).
891
998
  Value(selected).
892
999
  Validate(func(values []int) error {
@@ -0,0 +1,344 @@
1
+ package main
2
+
3
+ import (
4
+ "context"
5
+ "encoding/json"
6
+ "fmt"
7
+ "io"
8
+ "os"
9
+ "path/filepath"
10
+ "strings"
11
+ "time"
12
+
13
+ "github.com/charmbracelet/huh"
14
+ huhspinner "github.com/charmbracelet/huh/spinner"
15
+ "github.com/rajpopat27/relay-flow/internal/config"
16
+ "github.com/rajpopat27/relay-flow/internal/execution/projection"
17
+ "github.com/rajpopat27/relay-flow/internal/paths"
18
+ "github.com/rajpopat27/relay-flow/internal/server"
19
+ "github.com/rajpopat27/relay-flow/internal/task"
20
+ )
21
+
22
+ // Small command-level seams keep the interactive orchestration testable
23
+ // without changing task, runner, or server package boundaries.
24
+ var (
25
+ interactiveInitTTY = isTTY
26
+ interactiveInitPluginPick = pickPluginsInteractive
27
+ interactiveInitRepoPrompt = promptFirstRunRepositorySetup
28
+ interactiveInitRepoSetup = runFirstRunRepositorySetup
29
+ firstRunRegistration = cmdRepoRegister
30
+ )
31
+
32
+ // stageFirstRunAuthentication gives the selected task plugin its normal auth
33
+ // entry point without exposing a partially initialized relay-flow home. Jira's
34
+ // auth flow also writes the first mailbox-assignee default into config.yaml;
35
+ // staging that file keeps the plugin's existing behavior while allowing the
36
+ // final config, credentials, and database to be committed only after auth.
37
+ func stageFirstRunAuthentication(p paths.Paths, cfg *config.Machine, plugin string, stdin io.Reader) (*config.Machine, string, error) {
38
+ if err := os.MkdirAll(filepath.Dir(p.Root), 0o700); err != nil {
39
+ return nil, "", fmt.Errorf("create initialization parent directory: %w", err)
40
+ }
41
+ stage, err := os.MkdirTemp(filepath.Dir(p.Root), ".relay-flow-init-")
42
+ if err != nil {
43
+ return nil, "", fmt.Errorf("create initialization staging directory: %w", err)
44
+ }
45
+ cleanup := func() {
46
+ _ = os.RemoveAll(stage)
47
+ }
48
+ if err := config.SaveMachine(filepath.Join(stage, "config.yaml"), cfg); err != nil {
49
+ cleanup()
50
+ return nil, "", fmt.Errorf("stage machine config: %w", err)
51
+ }
52
+
53
+ previousHome, hadHome := os.LookupEnv("RELAY_FLOW_HOME")
54
+ if err := os.Setenv("RELAY_FLOW_HOME", stage); err != nil {
55
+ cleanup()
56
+ return nil, "", fmt.Errorf("stage relay-flow home: %w", err)
57
+ }
58
+ authErr := task.Auth(context.Background(), plugin, nil, stdin)
59
+ if hadHome {
60
+ _ = os.Setenv("RELAY_FLOW_HOME", previousHome)
61
+ } else {
62
+ _ = os.Unsetenv("RELAY_FLOW_HOME")
63
+ }
64
+ if authErr != nil {
65
+ cleanup()
66
+ return nil, "", authErr
67
+ }
68
+
69
+ staged, err := config.LoadMachine(filepath.Join(stage, "config.yaml"))
70
+ if err != nil {
71
+ cleanup()
72
+ return nil, "", fmt.Errorf("load authenticated machine config: %w", err)
73
+ }
74
+ return staged, stage, nil
75
+ }
76
+
77
+ const firstRunPendingName = ".init-pending"
78
+
79
+ type firstRunPending struct {
80
+ Credentials bool `json:"credentials"`
81
+ }
82
+
83
+ func firstRunPendingPath(p paths.Paths) string {
84
+ return filepath.Join(p.Root, firstRunPendingName)
85
+ }
86
+
87
+ // commitFirstRun publishes the staged config and task credentials only after
88
+ // the durable database has been initialized. The pending marker makes the
89
+ // multi-file publication recoverable if the process is interrupted; each
90
+ // regular file is written with renameio through config.WriteAtomic.
91
+ func commitFirstRun(p paths.Paths, stage string, identity projection.ExecutorIdentity) (err error) {
92
+ stagedConfig := filepath.Join(stage, "config.yaml")
93
+ stagedCredentials := filepath.Join(stage, "credentials.yaml")
94
+ configData, err := os.ReadFile(stagedConfig)
95
+ if err != nil {
96
+ return fmt.Errorf("read staged machine config: %w", err)
97
+ }
98
+ credentialsData, credentials, err := stagedCredentialsData(stagedCredentials)
99
+ if err != nil {
100
+ return err
101
+ }
102
+
103
+ for _, path := range []string{p.Config, p.Database, p.Credentials} {
104
+ if _, statErr := os.Stat(path); statErr == nil {
105
+ return fmt.Errorf("initialization artifact %s already exists; refusing to overwrite it", path)
106
+ } else if !os.IsNotExist(statErr) {
107
+ return fmt.Errorf("stat initialization artifact %s: %w", path, statErr)
108
+ }
109
+ }
110
+ if err := os.MkdirAll(p.Root, 0o700); err != nil {
111
+ return fmt.Errorf("create relay-flow home: %w", err)
112
+ }
113
+ if err := os.Chmod(p.Root, 0o700); err != nil {
114
+ return fmt.Errorf("chmod relay-flow home: %w", err)
115
+ }
116
+
117
+ pending := firstRunPending{Credentials: credentials}
118
+ pendingData, err := json.Marshal(pending)
119
+ if err != nil {
120
+ return fmt.Errorf("marshal initialization marker: %w", err)
121
+ }
122
+ if err := config.WriteAtomic(firstRunPendingPath(p), pendingData, 0o600); err != nil {
123
+ return fmt.Errorf("write initialization marker: %w", err)
124
+ }
125
+ published := false
126
+ defer func() {
127
+ if published {
128
+ return
129
+ }
130
+ _ = removeFirstRunArtifacts(p)
131
+ }()
132
+
133
+ if err := projection.InitDatabaseWithIdentity(p.Database, identity); err != nil {
134
+ return fmt.Errorf("initialize durable state: %w", err)
135
+ }
136
+ if err := config.WriteAtomic(p.Config, configData, 0o600); err != nil {
137
+ return fmt.Errorf("publish machine config: %w", err)
138
+ }
139
+ if credentials {
140
+ if err := config.WriteAtomic(p.Credentials, credentialsData, 0o600); err != nil {
141
+ return fmt.Errorf("publish task credentials: %w", err)
142
+ }
143
+ }
144
+
145
+ // Once all durable artifacts exist, leave them in place even if marker
146
+ // cleanup is interrupted. The next init removes the marker idempotently.
147
+ published = true
148
+ if err := os.Remove(firstRunPendingPath(p)); err != nil && !os.IsNotExist(err) {
149
+ return fmt.Errorf("finish initialization publication: %w", err)
150
+ }
151
+ if err := syncDirectory(p.Root); err != nil {
152
+ return fmt.Errorf("sync relay-flow home: %w", err)
153
+ }
154
+ return nil
155
+ }
156
+
157
+ func stagedCredentialsData(path string) ([]byte, bool, error) {
158
+ data, err := os.ReadFile(path)
159
+ if err == nil {
160
+ return data, true, nil
161
+ }
162
+ if os.IsNotExist(err) {
163
+ return nil, false, nil
164
+ }
165
+ return nil, false, fmt.Errorf("read staged task credentials: %w", err)
166
+ }
167
+
168
+ func removeFirstRunArtifacts(p paths.Paths) error {
169
+ // Keep the marker until every owned artifact is gone. If cleanup fails or
170
+ // the process is interrupted, the next init can retry from the marker.
171
+ for _, path := range []string{
172
+ p.Config, p.Credentials, p.Database,
173
+ p.Database + "-wal", p.Database + "-shm",
174
+ } {
175
+ if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
176
+ return fmt.Errorf("remove initialization artifact %s: %w", path, err)
177
+ }
178
+ }
179
+ if _, err := os.Stat(p.Root); os.IsNotExist(err) {
180
+ return nil
181
+ } else if err != nil {
182
+ return fmt.Errorf("stat relay-flow home: %w", err)
183
+ }
184
+ // Persist the artifact removals while the marker still protects recovery.
185
+ if err := syncDirectory(p.Root); err != nil {
186
+ return fmt.Errorf("sync cleaned initialization artifacts: %w", err)
187
+ }
188
+ if err := os.Remove(firstRunPendingPath(p)); err != nil && !os.IsNotExist(err) {
189
+ return fmt.Errorf("remove initialization marker: %w", err)
190
+ }
191
+ if err := syncDirectory(p.Root); err != nil {
192
+ return fmt.Errorf("sync initialization marker removal: %w", err)
193
+ }
194
+ return nil
195
+ }
196
+
197
+ func syncDirectory(path string) error {
198
+ dir, err := os.Open(path)
199
+ if err != nil {
200
+ return err
201
+ }
202
+ defer dir.Close()
203
+ return dir.Sync()
204
+ }
205
+
206
+ // recoverFirstRunPublication completes marker cleanup for a complete
207
+ // publication or removes only the artifacts owned by an incomplete first-run.
208
+ // It runs before init's normal existing-state refusal so an interrupted setup
209
+ // can be retried instead of wedging the home.
210
+ func recoverFirstRunPublication(p paths.Paths) error {
211
+ marker := firstRunPendingPath(p)
212
+ raw, err := os.ReadFile(marker)
213
+ if err != nil {
214
+ if os.IsNotExist(err) {
215
+ return nil
216
+ }
217
+ return fmt.Errorf("read initialization marker: %w", err)
218
+ }
219
+ var pending firstRunPending
220
+ if err := json.Unmarshal(raw, &pending); err != nil {
221
+ if cleanupErr := removeFirstRunArtifacts(p); cleanupErr != nil {
222
+ return fmt.Errorf("recover invalid initialization marker: %v; cleanup: %w", err, cleanupErr)
223
+ }
224
+ return nil
225
+ }
226
+ complete := true
227
+ for _, path := range []string{p.Config, p.Database} {
228
+ if _, statErr := os.Stat(path); statErr != nil {
229
+ if !os.IsNotExist(statErr) {
230
+ return fmt.Errorf("inspect initialization artifact %s: %w", path, statErr)
231
+ }
232
+ complete = false
233
+ }
234
+ }
235
+ if pending.Credentials {
236
+ if _, statErr := os.Stat(p.Credentials); statErr != nil {
237
+ if !os.IsNotExist(statErr) {
238
+ return fmt.Errorf("inspect initialization credentials: %w", statErr)
239
+ }
240
+ complete = false
241
+ }
242
+ }
243
+ if complete {
244
+ if err := os.Remove(marker); err != nil && !os.IsNotExist(err) {
245
+ return fmt.Errorf("remove completed initialization marker: %w", err)
246
+ }
247
+ return syncDirectory(p.Root)
248
+ }
249
+ return removeFirstRunArtifacts(p)
250
+ }
251
+
252
+ func cleanupFirstRunStaging(p paths.Paths) error {
253
+ entries, err := os.ReadDir(filepath.Dir(p.Root))
254
+ if err != nil {
255
+ if os.IsNotExist(err) {
256
+ return nil
257
+ }
258
+ return fmt.Errorf("read initialization staging parent: %w", err)
259
+ }
260
+ for _, entry := range entries {
261
+ if !entry.IsDir() || !strings.HasPrefix(entry.Name(), ".relay-flow-init-") {
262
+ continue
263
+ }
264
+ if err := os.RemoveAll(filepath.Join(filepath.Dir(p.Root), entry.Name())); err != nil {
265
+ return fmt.Errorf("remove stale initialization staging %s: %w", entry.Name(), err)
266
+ }
267
+ }
268
+ return nil
269
+ }
270
+
271
+ func promptFirstRunRepositorySetup() (bool, error) {
272
+ register := false
273
+ form := huh.NewForm(huh.NewGroup(
274
+ huh.NewConfirm().
275
+ Title("Register repositories now?").
276
+ Description("You can register repositories later with relay-flow repo register.").
277
+ Affirmative("Yes").
278
+ Negative("No").
279
+ Value(&register),
280
+ ))
281
+ if err := form.Run(); err != nil {
282
+ return false, fmt.Errorf("repository setup prompt: %w", err)
283
+ }
284
+ return register, nil
285
+ }
286
+
287
+ // runFirstRunRepositorySetup starts or reuses the server, showing a small
288
+ // terminal spinner while the Unix socket becomes ready, then delegates to the
289
+ // existing interactive repository-registration flow.
290
+ func runFirstRunRepositorySetup(p paths.Paths, stdin io.Reader) error {
291
+ if err := runOnboardingSpinner("Starting relay-flow server", func() error {
292
+ return ensureFirstRunServer(p)
293
+ }); err != nil {
294
+ return err
295
+ }
296
+
297
+ if code := firstRunRegistration(server.NewClient(p.Socket), "", "", nil, stdin); code != exitOK {
298
+ return fmt.Errorf("repository registration failed")
299
+ }
300
+ return nil
301
+ }
302
+
303
+ func ensureFirstRunServer(p paths.Paths) error {
304
+ client := server.NewClient(p.Socket)
305
+ if serverResponding(client, 200*time.Millisecond) {
306
+ return nil
307
+ }
308
+ if err := startBackgroundServe(p, false, false); err != nil {
309
+ // A server may have become ready between the initial probe and the
310
+ // child launch. Reuse it when that race is harmless.
311
+ if serverResponding(client, 200*time.Millisecond) {
312
+ return nil
313
+ }
314
+ return err
315
+ }
316
+ return nil
317
+ }
318
+
319
+ // runOnboardingSpinner keeps the server-readiness wait visible without
320
+ // changing the existing Huh forms or the non-interactive command paths.
321
+ func runOnboardingSpinner(title string, action func() error) error {
322
+ return huhspinner.New().
323
+ Type(huhspinner.Line).
324
+ Title(title + "...").
325
+ Output(os.Stderr).
326
+ ActionWithErr(func(context.Context) error { return action() }).
327
+ Run()
328
+ }
329
+
330
+ // standaloneRepoServerHint adds the actionable startup hint required for the
331
+ // independent repo-register command while preserving server/API messages for
332
+ // failures that occur after a successful connection.
333
+ func standaloneRepoServerHint(err error) error {
334
+ if err == nil {
335
+ return nil
336
+ }
337
+ message := err.Error()
338
+ for _, marker := range []string{"dial unix", "connect: no such file or directory", "connection refused"} {
339
+ if strings.Contains(message, marker) {
340
+ return fmt.Errorf("%w; start the server with `relay-flow serve --background` (or use the guided init path)", err)
341
+ }
342
+ }
343
+ return err
344
+ }