relay-flow 0.2.7-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 (52) hide show
  1. package/README.md +76 -37
  2. package/cmd/relay-flow/commands_test.go +199 -19
  3. package/cmd/relay-flow/main.go +183 -144
  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 +454 -0
  7. package/cmd/relay-flow/serve.go +5 -2
  8. package/examples/config-reference.yaml +7 -0
  9. package/go.mod +12 -8
  10. package/go.sum +24 -17
  11. package/internal/execution/goworkflows/engine_test.go +3 -3
  12. package/internal/harness/opencode/opencode_test.go +4 -4
  13. package/internal/harness/opencode/repo_setup.go +1 -1
  14. package/internal/harness/pi/prompt_test.go +3 -3
  15. package/internal/repo/service.go +47 -3
  16. package/internal/repo/service_test.go +36 -1
  17. package/internal/runner/herdr/herdr.go +103 -4
  18. package/internal/runner/herdr/herdr_test.go +72 -2
  19. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  20. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  21. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  22. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  23. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  24. package/internal/runner/orca/orca.go +68 -9
  25. package/internal/runner/orca/orca_test.go +134 -2
  26. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  27. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  28. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  29. package/internal/runner/runner.go +9 -0
  30. package/internal/server/api_test.go +65 -0
  31. package/internal/server/client.go +40 -2
  32. package/internal/server/fixture_test.go +27 -16
  33. package/internal/server/server.go +80 -4
  34. package/internal/task/beads/beads.go +7 -2
  35. package/internal/task/beads/beads_test.go +13 -0
  36. package/internal/task/factory.go +9 -7
  37. package/internal/task/jira/auth_test.go +9 -1
  38. package/internal/task/jira/filters_test.go +2 -2
  39. package/internal/task/jira/helpers_test.go +13 -0
  40. package/internal/task/jira/jira.go +233 -44
  41. package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
  42. package/internal/task/jira/rest/client.go +73 -31
  43. package/internal/task/jira/rest/client_test.go +25 -0
  44. package/internal/task/jira/status_defaults_test.go +134 -0
  45. package/internal/task/jira/templates_test.go +2 -2
  46. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  47. package/internal/task/jira/transition_defaults_test.go +8 -12
  48. package/internal/task/jira/validation_test.go +3 -1
  49. package/internal/task/registration.go +59 -0
  50. package/internal/workflow/workflow.go +4 -1
  51. package/internal/workflow/workflow_test.go +4 -4
  52. 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,53 +585,119 @@ 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)
551
- }
552
- childArgs := []string{"serve"}
553
- if recover {
554
- childArgs = append(childArgs, "--recover")
601
+ return fmt.Errorf("serve --background: inspect server lock: %w", err)
555
602
  }
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)
657
+ }
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)
587
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
+ }
676
+ }
677
+ }
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
588
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
589
699
  }
700
+ return false, nil
590
701
  }
591
702
 
592
703
  func serverResponding(client *server.Client, timeout time.Duration) bool {
@@ -798,7 +909,7 @@ func cmdRepo(c *server.Client, args []string, stdin io.Reader) int {
798
909
  }
799
910
 
800
911
  // cmdRepoRegister registers one flagged repo or multiple interactively selected
801
- // runner repos. Jira project is shared and component is always the repo name.
912
+ // runner repos using registration metadata supplied by the selected task plugin.
802
913
  func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags, stdin io.Reader) int {
803
914
  ctx := context.Background()
804
915
  flagged := flagName != "" || flagPath != "" || len(sets) > 0
@@ -809,32 +920,28 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
809
920
  fmt.Fprintln(os.Stderr, "repo register: --name and --path are required for non-interactive registration")
810
921
  return exitUsage
811
922
  }
812
- if _, ok := sets["component"]; ok {
813
- fmt.Fprintln(os.Stderr, "repo register: component is derived from --name and cannot be overridden")
814
- return exitUsage
815
- }
816
- fields, err := c.RepoTaskFields(ctx)
923
+ registration, err := loadRepoRegistration(ctx, c, sets)
817
924
  if err != nil {
818
- fmt.Fprintln(os.Stderr, err)
925
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
819
926
  return exitFail
820
927
  }
821
- taskCfg, err := repoTaskConfig(fields, sets, flagName)
928
+ taskCfg, err := registrationTaskConfig(registration, sets, flagName)
822
929
  if err != nil {
823
930
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
824
931
  return exitUsage
825
932
  }
826
933
  info, err := c.RegisterRepo(ctx, repo.RegisterInput{Name: flagName, Path: flagPath, TaskConfig: taskCfg})
827
934
  if err != nil {
828
- fmt.Fprintln(os.Stderr, err)
935
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
829
936
  return exitFail
830
937
  }
831
938
  fmt.Println(info.Name)
832
939
  return exitOK
833
940
  }
834
941
 
835
- fields, err := c.RepoTaskFields(ctx)
942
+ registration, err := c.RepoRegistrationFields(ctx, nil)
836
943
  if err != nil {
837
- fmt.Fprintln(os.Stderr, err)
944
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
838
945
  return exitFail
839
946
  }
840
947
 
@@ -844,48 +951,39 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
844
951
  }
845
952
  candidates, err := c.DiscoverRepos(ctx)
846
953
  if err != nil {
847
- fmt.Fprintln(os.Stderr, err)
954
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
848
955
  return exitFail
849
956
  }
850
- if len(candidates) == 0 {
851
- 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))
960
+ return exitFail
961
+ }
962
+ candidates, selected, err := selectReposInteractive(ctx, c, candidates, registered)
963
+ if err != nil {
964
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
852
965
  return exitFail
853
966
  }
854
967
 
855
- selected := []int{}
856
- options := make([]huh.Option[int], len(candidates))
857
- for i, cand := range candidates {
858
- options[i] = huh.NewOption(cand.Name+" ("+cand.Path+")", i)
859
- }
860
- pickField := repoMultiSelect(options, &selected)
861
- pick := huh.NewForm(huh.NewGroup(pickField))
862
- if err := pick.Run(); err != nil {
968
+ shared := kvFlags{}
969
+ if err := promptRepoRegistration(registration, shared); err != nil {
863
970
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
864
971
  return exitFail
865
972
  }
866
-
867
- sharedFields := registrationSharedFields(fields)
868
- values := make([]string, len(sharedFields))
869
- inputs := make([]huh.Field, len(sharedFields))
870
- for i, field := range sharedFields {
871
- title := field
872
- if field == "project" {
873
- title = "Jira project"
874
- }
875
- inputs[i] = huh.NewInput().Title(title).Value(&values[i])
876
- }
877
- if len(inputs) > 0 {
878
- if err := huh.NewForm(huh.NewGroup(inputs...)).Run(); err != nil {
973
+ if len(shared) > 0 {
974
+ dependent, err := c.RepoRegistrationFields(ctx, flatRegistrationValues(shared))
975
+ if err != nil {
976
+ fmt.Fprintln(os.Stderr, standaloneRepoServerHint(err))
977
+ return exitFail
978
+ }
979
+ registration = mergeRegistration(registration, dependent)
980
+ if err := promptRepoRegistration(registration, shared); err != nil {
879
981
  fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
880
982
  return exitFail
881
983
  }
882
984
  }
883
- shared := kvFlags{}
884
- for i, field := range sharedFields {
885
- shared[field] = values[i]
886
- }
887
- if err := registerSelectedRepos(ctx, c, candidates, selected, fields, shared); err != nil {
888
- fmt.Fprintln(os.Stderr, "repo register: "+err.Error())
985
+ if err := registerSelectedReposDynamic(ctx, c, candidates, selected, registration, shared); err != nil {
986
+ fmt.Fprintln(os.Stderr, "repo register: "+standaloneRepoServerHint(err).Error())
889
987
  return exitFail
890
988
  }
891
989
  return exitOK
@@ -894,6 +992,8 @@ func cmdRepoRegister(c *server.Client, flagName, flagPath string, sets kvFlags,
894
992
  func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelect[int] {
895
993
  return huh.NewMultiSelect[int]().
896
994
  Title("Select repositories").
995
+ Description("Press / to filter repositories; select [+] Add repository to add a runner resource.").
996
+ Filterable(true).
897
997
  Options(options...).
898
998
  Value(selected).
899
999
  Validate(func(values []int) error {
@@ -904,67 +1004,6 @@ func repoMultiSelect(options []huh.Option[int], selected *[]int) *huh.MultiSelec
904
1004
  })
905
1005
  }
906
1006
 
907
- func registrationSharedFields(fields []string) []string {
908
- shared := make([]string, 0, len(fields))
909
- for _, field := range fields {
910
- if field != "component" {
911
- shared = append(shared, field)
912
- }
913
- }
914
- return shared
915
- }
916
-
917
- func repoTaskConfig(fields []string, supplied kvFlags, repoName string) (config.RawValues, error) {
918
- required := map[string]bool{}
919
- for _, field := range fields {
920
- required[field] = true
921
- }
922
- values := config.RawValues{}
923
- for key, value := range supplied {
924
- if !required[key] {
925
- return nil, fmt.Errorf("unknown task key %q (required keys: %s)", key, strings.Join(fields, ", "))
926
- }
927
- if value == "" {
928
- return nil, fmt.Errorf("task key %q requires a non-empty value", key)
929
- }
930
- values[key] = value
931
- }
932
- if required["component"] {
933
- values["component"] = repoName
934
- }
935
- var missing []string
936
- for _, field := range fields {
937
- if _, ok := values[field]; !ok {
938
- missing = append(missing, field)
939
- }
940
- }
941
- if len(missing) > 0 {
942
- return nil, fmt.Errorf("missing required task keys: %s (pass --set %s=<value>)",
943
- strings.Join(missing, ", "), missing[0])
944
- }
945
- return values, nil
946
- }
947
-
948
- func registerSelectedRepos(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, selected []int, fields []string, shared kvFlags) error {
949
- for _, index := range selected {
950
- candidate := candidates[index]
951
- taskCfg, err := repoTaskConfig(fields, shared, candidate.Name)
952
- if err != nil {
953
- return fmt.Errorf("%s: %w", candidate.Name, err)
954
- }
955
- info, err := c.RegisterRepo(ctx, repo.RegisterInput{
956
- Name: candidate.Name,
957
- Path: candidate.Path,
958
- TaskConfig: taskCfg,
959
- })
960
- if err != nil {
961
- return fmt.Errorf("%s: %w", candidate.Name, err)
962
- }
963
- fmt.Println(info.Name)
964
- }
965
- return nil
966
- }
967
-
968
1007
  // kvFlags collects repeated key=value flags (registration task config).
969
1008
  // Parse rejects malformed pairs, empty keys/values, and duplicates.
970
1009
  type kvFlags map[string]string