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.
- package/README.md +62 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +167 -60
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +3 -0
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +34 -3
- package/internal/repo/service_test.go +22 -0
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +19 -0
- package/internal/server/client.go +10 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/server.go +41 -0
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
package/cmd/relay-flow/main.go
CHANGED
|
@@ -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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
258
|
+
case interactiveInitTTY(stdin):
|
|
244
259
|
var err error
|
|
245
|
-
names, err =
|
|
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 &&
|
|
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
|
|
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
|
-
|
|
598
|
+
deadline := time.Now().Add(backgroundServeStartupTimeout)
|
|
599
|
+
lockHeld, err := serverLockHeld(p.Lock)
|
|
549
600
|
if err != nil {
|
|
550
|
-
return fmt.Errorf("serve --background:
|
|
601
|
+
return fmt.Errorf("serve --background: inspect server lock: %w", err)
|
|
551
602
|
}
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
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
|
-
|
|
585
|
-
|
|
586
|
-
|
|
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
|
-
|
|
847
|
-
|
|
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
|
-
|
|
852
|
-
|
|
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(®ister),
|
|
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
|
+
}
|