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
@@ -3,6 +3,7 @@ package main
3
3
  import (
4
4
  "context"
5
5
  "fmt"
6
+ "path/filepath"
6
7
  "strings"
7
8
 
8
9
  "github.com/charmbracelet/huh"
@@ -13,6 +14,224 @@ import (
13
14
  "github.com/rajpopat27/relay-flow/internal/task"
14
15
  )
15
16
 
17
+ const addRepositorySelection = -1
18
+
19
+ func repoSelectionOptions(candidates []runner.RepoCandidate) []huh.Option[int] {
20
+ options := make([]huh.Option[int], 0, len(candidates)+1)
21
+ for i, candidate := range candidates {
22
+ label := strings.TrimSpace(candidate.Name)
23
+ if label == "" {
24
+ label = candidate.Path
25
+ }
26
+ options = append(options, huh.NewOption(label+" ("+candidate.Path+")", i))
27
+ }
28
+ options = append(options, huh.NewOption("[+] Add repository", addRepositorySelection))
29
+ return options
30
+ }
31
+
32
+ func canonicalRepoPath(path string) string {
33
+ path = strings.TrimSpace(path)
34
+ if path == "" {
35
+ return ""
36
+ }
37
+ absolute, err := filepath.Abs(path)
38
+ if err != nil {
39
+ return filepath.Clean(path)
40
+ }
41
+ absolute = filepath.Clean(absolute)
42
+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
43
+ return filepath.Clean(resolved)
44
+ }
45
+ return absolute
46
+ }
47
+
48
+ func selectedRepoPaths(candidates []runner.RepoCandidate, selected []int) []string {
49
+ paths := make([]string, 0, len(selected))
50
+ seen := map[string]bool{}
51
+ for _, index := range selected {
52
+ if index < 0 || index >= len(candidates) {
53
+ continue
54
+ }
55
+ path := canonicalRepoPath(candidates[index].Path)
56
+ if path == "" || seen[path] {
57
+ continue
58
+ }
59
+ seen[path] = true
60
+ paths = append(paths, path)
61
+ }
62
+ return paths
63
+ }
64
+
65
+ func selectedRepoIndices(candidates []runner.RepoCandidate, paths []string) []int {
66
+ wanted := map[string]bool{}
67
+ for _, path := range paths {
68
+ if path != "" {
69
+ wanted[path] = true
70
+ }
71
+ }
72
+ selected := make([]int, 0, len(wanted))
73
+ for i, candidate := range candidates {
74
+ if wanted[canonicalRepoPath(candidate.Path)] {
75
+ selected = append(selected, i)
76
+ }
77
+ }
78
+ return selected
79
+ }
80
+
81
+ func candidateAtPath(candidates []runner.RepoCandidate, path string) bool {
82
+ path = canonicalRepoPath(path)
83
+ for _, candidate := range candidates {
84
+ if canonicalRepoPath(candidate.Path) == path {
85
+ return true
86
+ }
87
+ }
88
+ return false
89
+ }
90
+
91
+ func mergeRepoCandidateNames(candidates []runner.RepoCandidate, names map[string]string) []runner.RepoCandidate {
92
+ out := make([]runner.RepoCandidate, len(candidates))
93
+ copy(out, candidates)
94
+ for i := range out {
95
+ if name := names[canonicalRepoPath(out[i].Path)]; name != "" {
96
+ out[i].Name = name
97
+ }
98
+ }
99
+ return out
100
+ }
101
+
102
+ func validateAddedRepo(candidate runner.RepoCandidate, candidates []runner.RepoCandidate, registered []repo.Info) error {
103
+ candidate.Name = strings.TrimSpace(candidate.Name)
104
+ candidate.Path = canonicalRepoPath(candidate.Path)
105
+ if candidate.Path == "" {
106
+ return fmt.Errorf("repository path is required")
107
+ }
108
+ if candidate.Name == "" {
109
+ return fmt.Errorf("repository name is required")
110
+ }
111
+ for _, existing := range candidates {
112
+ if canonicalRepoPath(existing.Path) == candidate.Path {
113
+ return fmt.Errorf("repository path %q is already discovered as %q", candidate.Path, existing.Name)
114
+ }
115
+ if strings.TrimSpace(existing.Name) == candidate.Name {
116
+ return fmt.Errorf("repository name %q is already discovered", candidate.Name)
117
+ }
118
+ }
119
+ for _, existing := range registered {
120
+ if canonicalRepoPath(existing.Path) == candidate.Path {
121
+ return fmt.Errorf("repository path %q is already registered as %q", candidate.Path, existing.Name)
122
+ }
123
+ if strings.TrimSpace(existing.Name) == candidate.Name {
124
+ return fmt.Errorf("repository name %q is already registered", candidate.Name)
125
+ }
126
+ }
127
+ return nil
128
+ }
129
+
130
+ func promptAddedRepo() (runner.RepoCandidate, error) {
131
+ var path, name string
132
+ pathInput := huh.NewInput().Title("Repository path").Value(&path).Validate(func(value string) error {
133
+ if strings.TrimSpace(value) == "" {
134
+ return fmt.Errorf("repository path is required")
135
+ }
136
+ return nil
137
+ })
138
+ nameInput := huh.NewInput().Title("Registered name").Value(&name).Validate(func(value string) error {
139
+ if strings.TrimSpace(value) == "" {
140
+ return fmt.Errorf("repository name is required")
141
+ }
142
+ return nil
143
+ })
144
+ if err := huh.NewForm(huh.NewGroup(pathInput, nameInput)).Run(); err != nil {
145
+ return runner.RepoCandidate{}, err
146
+ }
147
+ return runner.RepoCandidate{Name: strings.TrimSpace(name), Path: canonicalRepoPath(path)}, nil
148
+ }
149
+
150
+ type repoSelectionPrompt func([]runner.RepoCandidate, []int) ([]int, error)
151
+ type repoAddPrompt func() (runner.RepoCandidate, error)
152
+
153
+ func selectReposInteractive(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, registered []repo.Info) ([]runner.RepoCandidate, []int, error) {
154
+ return selectReposInteractiveWithPrompts(ctx, c, candidates, registered, promptRepoSelection, promptAddedRepo)
155
+ }
156
+
157
+ func promptRepoSelection(candidates []runner.RepoCandidate, selected []int) ([]int, error) {
158
+ pick := huh.NewForm(huh.NewGroup(repoMultiSelect(repoSelectionOptions(candidates), &selected)))
159
+ if err := pick.Run(); err != nil {
160
+ return nil, err
161
+ }
162
+ return selected, nil
163
+ }
164
+
165
+ func selectReposInteractiveWithPrompts(ctx context.Context, c *server.Client, candidates []runner.RepoCandidate, registered []repo.Info, selectPrompt repoSelectionPrompt, addPrompt repoAddPrompt) ([]runner.RepoCandidate, []int, error) {
166
+ selectedPaths := []string{}
167
+ nameOverrides := map[string]string{}
168
+ addedCandidates := []runner.RepoCandidate{}
169
+ for {
170
+ candidates = mergeRepoCandidateNames(candidates, nameOverrides)
171
+ selected, err := selectPrompt(candidates, selectedRepoIndices(candidates, selectedPaths))
172
+ if err != nil {
173
+ return nil, nil, err
174
+ }
175
+ selectedPaths = selectedRepoPaths(candidates, selected)
176
+ add := false
177
+ for _, value := range selected {
178
+ if value == addRepositorySelection {
179
+ add = true
180
+ break
181
+ }
182
+ }
183
+ if !add {
184
+ if len(selectedPaths) == 0 {
185
+ return nil, nil, fmt.Errorf("select at least one repository")
186
+ }
187
+ return candidates, selectedRepoIndices(candidates, selectedPaths), nil
188
+ }
189
+
190
+ added, err := addPrompt()
191
+ if err != nil {
192
+ return nil, nil, err
193
+ }
194
+ knownCandidates := append(append([]runner.RepoCandidate(nil), candidates...), addedCandidates...)
195
+ if err := validateAddedRepo(added, knownCandidates, registered); err != nil {
196
+ return nil, nil, err
197
+ }
198
+ if err := c.EnsureRepo(ctx, added); err != nil {
199
+ return nil, nil, fmt.Errorf("ensure repository %q: %w", added.Name, err)
200
+ }
201
+ path := canonicalRepoPath(added.Path)
202
+ nameOverrides[path] = added.Name
203
+ addedCandidates = append(addedCandidates, added)
204
+ selectedPaths = append(selectedPaths, path)
205
+
206
+ refreshed, err := c.DiscoverRepos(ctx)
207
+ if err != nil {
208
+ return nil, nil, err
209
+ }
210
+ refreshed = mergeRepoCandidateNames(refreshed, nameOverrides)
211
+ // Preserve selected entries across a runner refresh even if the
212
+ // external runner's listing is briefly eventually consistent.
213
+ for _, old := range candidates {
214
+ oldPath := canonicalRepoPath(old.Path)
215
+ if containsPath(selectedPaths, oldPath) && !candidateAtPath(refreshed, oldPath) {
216
+ refreshed = append(refreshed, old)
217
+ }
218
+ }
219
+ if !candidateAtPath(refreshed, path) {
220
+ refreshed = append(refreshed, added)
221
+ }
222
+ candidates = refreshed
223
+ }
224
+ }
225
+
226
+ func containsPath(paths []string, want string) bool {
227
+ for _, path := range paths {
228
+ if path == want {
229
+ return true
230
+ }
231
+ }
232
+ return false
233
+ }
234
+
16
235
  // loadRepoRegistration asks the selected task plugin for the fields needed by
17
236
  // registration. A second pass lets a plugin expose dependent choices after a
18
237
  // value such as a project has been supplied.
@@ -565,6 +565,9 @@ func (d *serveDeps) RegisterNodeSession(ctx context.Context, registration runsvc
565
565
  func (d *serveDeps) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
566
566
  return d.repos.Discover(ctx)
567
567
  }
568
+ func (d *serveDeps) EnsureRepo(ctx context.Context, candidate runner.RepoCandidate) error {
569
+ return d.repos.EnsureRepo(ctx, candidate.Name, candidate.Path)
570
+ }
568
571
  func (d *serveDeps) TaskRegistrationFields(ctx context.Context, values config.RawValues) ([]task.RegistrationField, error) {
569
572
  return d.repos.RegistrationFields(ctx, values)
570
573
  }
package/go.mod CHANGED
@@ -3,6 +3,7 @@ module github.com/rajpopat27/relay-flow
3
3
  go 1.26.0
4
4
 
5
5
  require (
6
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b
6
7
  github.com/cschleiden/go-workflows v1.4.2
7
8
  github.com/google/renameio/v2 v2.0.1
8
9
  github.com/google/uuid v1.6.0
@@ -16,6 +17,9 @@ require (
16
17
 
17
18
  require (
18
19
  github.com/cespare/xxhash/v2 v2.3.0 // indirect
20
+ github.com/clipperhouse/displaywidth v0.9.0 // indirect
21
+ github.com/clipperhouse/stringish v0.1.1 // indirect
22
+ github.com/clipperhouse/uax29/v2 v2.5.0 // indirect
19
23
  go.opentelemetry.io/auto/sdk v1.2.1 // indirect
20
24
  google.golang.org/genproto/googleapis/api v0.0.0-20260825221802-da73d73af1c5 // indirect
21
25
  google.golang.org/genproto/googleapis/rpc v0.0.0-20260825221802-da73d73af1c5 // indirect
@@ -27,15 +31,15 @@ require (
27
31
  github.com/benbjohnson/clock v1.3.0 // indirect
28
32
  github.com/catppuccin/go v0.3.0 // indirect
29
33
  github.com/cenkalti/backoff/v4 v4.3.0 // indirect
30
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect
31
- github.com/charmbracelet/bubbletea v1.3.6 // indirect
32
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
34
+ github.com/charmbracelet/bubbles v1.0.0 // indirect
35
+ github.com/charmbracelet/bubbletea v1.3.10 // indirect
36
+ github.com/charmbracelet/colorprofile v0.4.1 // indirect
33
37
  github.com/charmbracelet/huh v1.0.0
34
38
  github.com/charmbracelet/lipgloss v1.1.0 // indirect
35
- github.com/charmbracelet/x/ansi v0.9.3 // indirect
36
- github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
39
+ github.com/charmbracelet/x/ansi v0.11.6 // indirect
40
+ github.com/charmbracelet/x/cellbuf v0.0.15 // indirect
37
41
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect
38
- github.com/charmbracelet/x/term v0.2.1 // indirect
42
+ github.com/charmbracelet/x/term v0.2.2 // indirect
39
43
  github.com/davecgh/go-spew v1.1.1 // indirect
40
44
  github.com/dustin/go-humanize v1.0.1 // indirect
41
45
  github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
@@ -52,10 +56,10 @@ require (
52
56
  github.com/hashicorp/go-multierror v1.1.1 // indirect
53
57
  github.com/jellydator/ttlcache/v3 v3.0.0 // indirect
54
58
  github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
55
- github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
59
+ github.com/lucasb-eyer/go-colorful v1.3.0 // indirect
56
60
  github.com/mattn/go-isatty v0.0.20
57
61
  github.com/mattn/go-localereader v0.0.1 // indirect
58
- github.com/mattn/go-runewidth v0.0.16 // indirect
62
+ github.com/mattn/go-runewidth v0.0.19 // indirect
59
63
  github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect
60
64
  github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
61
65
  github.com/muesli/cancelreader v0.2.2 // indirect
package/go.sum CHANGED
@@ -14,20 +14,22 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3
14
14
  github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
15
15
  github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
16
16
  github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
17
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 h1:JFgG/xnwFfbezlUnFMJy0nusZvytYysV4SCS2cYbvws=
18
- github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7/go.mod h1:ISC1gtLcVilLOf23wvTfoQuYbW2q0JevFxPfUzZ9Ybw=
19
- github.com/charmbracelet/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
20
- github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
21
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
22
- github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
17
+ github.com/charmbracelet/bubbles v1.0.0 h1:12J8/ak/uCZEMQ6KU7pcfwceyjLlWsDLAxB5fXonfvc=
18
+ github.com/charmbracelet/bubbles v1.0.0/go.mod h1:9d/Zd5GdnauMI5ivUIVisuEm3ave1XwXtD1ckyV6r3E=
19
+ github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
20
+ github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
21
+ github.com/charmbracelet/colorprofile v0.4.1 h1:a1lO03qTrSIRaK8c3JRxJDZOvhvIeSco3ej+ngLk1kk=
22
+ github.com/charmbracelet/colorprofile v0.4.1/go.mod h1:U1d9Dljmdf9DLegaJ0nGZNJvoXAhayhmidOdcBwAvKk=
23
23
  github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyIxw=
24
24
  github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4=
25
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b h1:deQbW7eR/gYwkXonGX6a1now6H6f8v4kfv0OIKECu0I=
26
+ github.com/charmbracelet/huh/spinner v0.0.0-20260223110133-9dc45e34a40b/go.mod h1:Y68nuKJuC/Q2lmiq18EkHWkVWi2VGLrwaOfOyPKLkkE=
25
27
  github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
26
28
  github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
27
- github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
28
- github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
29
- github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
30
- github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
29
+ github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8=
30
+ github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ=
31
+ github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI=
32
+ github.com/charmbracelet/x/cellbuf v0.0.15/go.mod h1:J1YVbR7MUuEGIFPCaaZ96KDl5NoS0DAWkskup+mOY+Q=
31
33
  github.com/charmbracelet/x/conpty v0.1.0 h1:4zc8KaIcbiL4mghEON8D72agYtSeIgq8FSThSPQIb+U=
32
34
  github.com/charmbracelet/x/conpty v0.1.0/go.mod h1:rMFsDJoDwVmiYM10aD4bH2XiRgwI7NYJtQgl5yskjEQ=
33
35
  github.com/charmbracelet/x/errors v0.0.0-20240508181413-e8d8b6e2de86 h1:JSt3B+U9iqk37QUU2Rvb6DSBYRLtWqFqfxf8l5hOZUA=
@@ -36,12 +38,18 @@ github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91 h1:payR
36
38
  github.com/charmbracelet/x/exp/golden v0.0.0-20241011142426-46044092ad91/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
37
39
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 h1:qko3AQ4gK1MTS/de7F5hPGx6/k1u0w4TeYmBFwzYVP4=
38
40
  github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0/go.mod h1:pBhA0ybfXv6hDjQUZ7hk1lVxBiUbupdw5R31yPUViVQ=
39
- github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
40
- github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
41
+ github.com/charmbracelet/x/term v0.2.2 h1:xVRT/S2ZcKdhhOuSP4t5cLi5o+JxklsoEObBSgfgZRk=
42
+ github.com/charmbracelet/x/term v0.2.2/go.mod h1:kF8CY5RddLWrsgVwpw4kAa6TESp6EB5y3uxGLeCqzAI=
41
43
  github.com/charmbracelet/x/termios v0.1.1 h1:o3Q2bT8eqzGnGPOYheoYS8eEleT5ZVNYNy8JawjaNZY=
42
44
  github.com/charmbracelet/x/termios v0.1.1/go.mod h1:rB7fnv1TgOPOyyKRJ9o+AsTU/vK5WHJ2ivHeut/Pcwo=
43
45
  github.com/charmbracelet/x/xpty v0.1.2 h1:Pqmu4TEJ8KeA9uSkISKMU3f+C1F6OGBn8ABuGlqCbtI=
44
46
  github.com/charmbracelet/x/xpty v0.1.2/go.mod h1:XK2Z0id5rtLWcpeNiMYBccNNBrP2IJnzHI0Lq13Xzq4=
47
+ github.com/clipperhouse/displaywidth v0.9.0 h1:Qb4KOhYwRiN3viMv1v/3cTBlz3AcAZX3+y9OLhMtAtA=
48
+ github.com/clipperhouse/displaywidth v0.9.0/go.mod h1:aCAAqTlh4GIVkhQnJpbL0T/WfcrJXHcj8C0yjYcjOZA=
49
+ github.com/clipperhouse/stringish v0.1.1 h1:+NSqMOr3GR6k1FdRhhnXrLfztGzuG+VuFDfatpWHKCs=
50
+ github.com/clipperhouse/stringish v0.1.1/go.mod h1:v/WhFtE1q0ovMta2+m+UbpZ+2/HEXNWYXQgCt4hdOzA=
51
+ github.com/clipperhouse/uax29/v2 v2.5.0 h1:x7T0T4eTHDONxFJsL94uKNKPHrclyFI0lm7+w94cO8U=
52
+ github.com/clipperhouse/uax29/v2 v2.5.0/go.mod h1:Wn1g7MK6OoeDT0vL+Q0SQLDz/KpfsVRgg6W7ihQeh4g=
45
53
  github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
46
54
  github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
47
55
  github.com/cschleiden/go-workflows v1.4.2 h1:s7wgx3iKvFmwJVzB7wSuUFqbplcpCUGrn8u1J4/T+2c=
@@ -98,14 +106,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
98
106
  github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
99
107
  github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
100
108
  github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o=
101
- github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
102
- github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
109
+ github.com/lucasb-eyer/go-colorful v1.3.0 h1:2/yBRLdWBZKrf7gB40FoiKfAWYQ0lqNcbuQwVHXptag=
110
+ github.com/lucasb-eyer/go-colorful v1.3.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
103
111
  github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
104
112
  github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
105
113
  github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
106
114
  github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
107
- github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
108
- github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
115
+ github.com/mattn/go-runewidth v0.0.19 h1:v++JhqYnZuu5jSKrk9RbgF5v4CGUjqRfBm05byFGLdw=
116
+ github.com/mattn/go-runewidth v0.0.19/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
109
117
  github.com/mattn/go-sqlite3 v1.14.16 h1:yOQRA0RpS5PFz/oikGwBEqvAWhWg5ufRz4ETLjwpU1Y=
110
118
  github.com/mattn/go-sqlite3 v1.14.16/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
111
119
  github.com/mitchellh/hashstructure/v2 v2.0.2 h1:vGKWl0YJqUNxE8d+h8f6NJLcCJrgbhC4NcD46KavDd4=
@@ -124,7 +132,6 @@ github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZb
124
132
  github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
125
133
  github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
126
134
  github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
127
- github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
128
135
  github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
129
136
  github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
130
137
  github.com/robfig/cron v1.2.0 h1:ZjScXvvxeQ63Dbyxy76Fj3AT3Ut0aKsyd2/tl3DTMuQ=
@@ -187,7 +187,7 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
187
187
 
188
188
  wf := linearWorkflow(false)
189
189
  node := wf.Nodes["coding"]
190
- node.NudgePrompt = "Continue {{ticket}} at {{node}}. Valid next steps: {{nextSteps}}."
190
+ node.NudgePrompt = "Continue {{ticket}} at {{node}} in {{mailbox}}. Valid next steps: {{nextSteps}}."
191
191
  wf.Nodes["coding"] = node
192
192
  rid, err := startRun(engine, wf)
193
193
  if err != nil {
@@ -221,8 +221,8 @@ func TestRunBeginsAtStartAndFollowsEntryEdge(t *testing.T) {
221
221
  if call.NudgeTemplate != node.NudgePrompt {
222
222
  t.Fatalf("nudge passed to harness = %q, want raw template %q", call.NudgeTemplate, node.NudgePrompt)
223
223
  }
224
- if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.NextSteps == "" {
225
- t.Fatalf("nudge prompt data = %+v, want current workflow values", call.Data)
224
+ if call.Data.Ticket != "PAY-101" || call.Data.Workflow != wf.Name || call.Data.Repo != "payments" || call.Data.Node != "coding" || call.Data.Mailbox != "PAY-101-coding" || call.Data.NextSteps == "" {
225
+ t.Fatalf("nudge prompt data = %+v, want current workflow and mailbox values", call.Data)
226
226
  }
227
227
 
228
228
  // Pre-edge gate: before following the start edge the run ensures the
@@ -14,7 +14,7 @@ import (
14
14
  "github.com/rajpopat27/relay-flow/internal/workflow"
15
15
  )
16
16
 
17
- const configuredPlugin = "relay-flow-plugin@0.2.8-alpha"
17
+ const configuredPlugin = "relay-flow-plugin@0.2.9-alpha"
18
18
 
19
19
  func TestBuildCommandArgv(t *testing.T) {
20
20
  t.Setenv("RELAY_FLOW_HOME", "/var/lib/relay-flow-test")
@@ -71,12 +71,12 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
71
71
  Node: "review", NodeType: workflow.NodeHITL, Agent: "build", NodeDescription: "Review it.",
72
72
  NextSteps: "end (when: approved)", Mailbox: "PAY-234",
73
73
  }
74
- nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
74
+ nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{mailbox}}|{{nextSteps}}"
75
75
  initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
76
76
  if err != nil {
77
77
  t.Fatal(err)
78
78
  }
79
- wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"
79
+ wantInitial := "initial linear|PAY-101|basicFlow|payments|review|hitl|build|Review it.|end (when: approved)|PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|PAY-234|end (when: approved)"
80
80
  if initial != wantInitial {
81
81
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
82
82
  }
@@ -84,7 +84,7 @@ func TestRenderPromptTemplatesExposeAllValues(t *testing.T) {
84
84
  if err != nil {
85
85
  t.Fatal(err)
86
86
  }
87
- if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|end (when: approved)"; feedback != want {
87
+ if want := "feedback PAY-234\n\nReturn the complete report directly. Relay-flow will show a native TUI approval dialog after the report is valid. Do not use OpenCode's Question tool for relay-flow approval.\n\nnudge linear|PAY-101|basicFlow|payments|review|PAY-234|end (when: approved)"; feedback != want {
88
88
  t.Fatalf("feedback prompt = %q, want %q", feedback, want)
89
89
  }
90
90
  }
@@ -10,7 +10,7 @@ import (
10
10
  "github.com/rajpopat27/relay-flow/internal/config"
11
11
  )
12
12
 
13
- const relayFlowPlugin = "relay-flow-plugin@0.2.8-alpha"
13
+ const relayFlowPlugin = "relay-flow-plugin@0.2.9-alpha"
14
14
 
15
15
  type jsoncToken struct {
16
16
  kind byte
@@ -23,13 +23,13 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
23
23
  NextSteps: "review (when: ready)",
24
24
  Mailbox: "PAY-234",
25
25
  }
26
- nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{nextSteps}}"
26
+ nudge := "nudge {{taskSystem}}|{{ticket}}|{{workflow}}|{{repo}}|{{node}}|{{mailbox}}|{{nextSteps}}"
27
27
 
28
28
  initial, err := h.RenderPrompt(harness.PromptInitial, data, nudge)
29
29
  if err != nil {
30
30
  t.Fatalf("RenderPrompt(initial): %v", err)
31
31
  }
32
- wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
32
+ wantInitial := "Task system: jira\nUse the jira tools to read the parent ticket PAY-101.\n\nYour mailbox is PAY-234. Read its description and comments for node instructions and feedback.\n\nKeep the summary brief, and make the feedback as detailed and actionable as possible for the next agent.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
33
33
  if initial != wantInitial {
34
34
  t.Fatalf("initial prompt = %q, want %q", initial, wantInitial)
35
35
  }
@@ -38,7 +38,7 @@ func TestPiRenderPromptSubstitutesInitialAndFeedbackData(t *testing.T) {
38
38
  if err != nil {
39
39
  t.Fatalf("RenderPrompt(feedback): %v", err)
40
40
  }
41
- wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|review (when: ready)"
41
+ wantFeedback := "New feedback was added to the comments section of your mailbox subtask PAY-234. Read it.\n\nnudge jira|PAY-101|basicFlow|payments|implement|PAY-234|review (when: ready)"
42
42
  if feedback != wantFeedback {
43
43
  t.Fatalf("feedback prompt = %q, want %q", feedback, wantFeedback)
44
44
  }
@@ -4,6 +4,7 @@ import (
4
4
  "context"
5
5
  "fmt"
6
6
  "path/filepath"
7
+ "strings"
7
8
 
8
9
  "github.com/rajpopat27/relay-flow/internal/config"
9
10
  "github.com/rajpopat27/relay-flow/internal/harness"
@@ -70,6 +71,25 @@ func (s *Service) Discover(ctx context.Context) ([]runner.RepoCandidate, error)
70
71
  return s.runner.DiscoverRepos(ctx)
71
72
  }
72
73
 
74
+ // EnsureRepo makes the runner resource for a repository available without
75
+ // changing relay-flow's machine configuration. Runner adapters that expose
76
+ // RepoRegistrar perform their idempotent external provisioning; other
77
+ // runners validate the repository through the existing read-only contract.
78
+ func (s *Service) EnsureRepo(ctx context.Context, name, path string) error {
79
+ name = strings.TrimSpace(name)
80
+ path = strings.TrimSpace(path)
81
+ if name == "" {
82
+ return fmt.Errorf("repo: name is required")
83
+ }
84
+ if path == "" {
85
+ return fmt.Errorf("repo %q: path is required", name)
86
+ }
87
+ if registrar, ok := s.runner.(runner.RepoRegistrar); ok {
88
+ return registrar.EnsureRepo(ctx, name, path)
89
+ }
90
+ return s.runner.ValidateRepo(ctx, name, path)
91
+ }
92
+
73
93
  // RequiredRepoKeys delegates to the task factory's method of the same name.
74
94
  func (s *Service) RequiredRepoKeys() []string {
75
95
  keys, err := task.RequiredRepoKeys(s.taskPlugin)
@@ -95,9 +115,14 @@ type RegisterInput struct {
95
115
  // connectivity, duplicate names, canonical paths, and the task plugin's
96
116
  // registration identity before atomically writing machine config.
97
117
  func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, error) {
118
+ input.Name = strings.TrimSpace(input.Name)
119
+ input.Path = strings.TrimSpace(input.Path)
98
120
  if input.Name == "" {
99
121
  return Info{}, fmt.Errorf("repo: name is required")
100
122
  }
123
+ if input.Path == "" {
124
+ return Info{}, fmt.Errorf("repo %q: path is required", input.Name)
125
+ }
101
126
  cfg, err := config.LoadMachine(s.cfgPath)
102
127
  if err != nil {
103
128
  return Info{}, err
@@ -154,8 +179,10 @@ func (s *Service) Register(ctx context.Context, input RegisterInput) (Info, erro
154
179
  return Info{}, fmt.Errorf("repo %q: task registration identity already used by repo %q", input.Name, name)
155
180
  }
156
181
  }
157
- // Runner validates the repo.
158
- if err := s.runner.ValidateRepo(ctx, input.Name, input.Path); err != nil {
182
+ // Runner validates or provisions the repo, depending on the adapter
183
+ // capability. Provisioning is idempotent so explicit registration and the
184
+ // interactive Add action share one runner-owned path.
185
+ if err := s.EnsureRepo(ctx, input.Name, input.Path); err != nil {
159
186
  return Info{}, fmt.Errorf("repo %q: runner validation: %w", input.Name, err)
160
187
  }
161
188
  // Task-system connectivity: construct the repo-bound System.
@@ -245,5 +272,9 @@ func canonicalPath(p string) string {
245
272
  if err != nil {
246
273
  return filepath.Clean(p)
247
274
  }
248
- return filepath.Clean(abs)
275
+ abs = filepath.Clean(abs)
276
+ if resolved, err := filepath.EvalSymlinks(abs); err == nil {
277
+ return filepath.Clean(resolved)
278
+ }
279
+ return abs
249
280
  }
@@ -23,6 +23,17 @@ type fakeRunnerDiscovery struct {
23
23
  validErr error
24
24
  }
25
25
 
26
+ type fakeRunnerRegistrar struct {
27
+ *fakeRunnerDiscovery
28
+ ensureErr error
29
+ ensureCalls []runner.RepoCandidate
30
+ }
31
+
32
+ func (f *fakeRunnerRegistrar) EnsureRepo(_ context.Context, name, path string) error {
33
+ f.ensureCalls = append(f.ensureCalls, runner.RepoCandidate{Name: name, Path: path})
34
+ return f.ensureErr
35
+ }
36
+
26
37
  func (f *fakeRunnerDiscovery) DiscoverRepos(context.Context) ([]runner.RepoCandidate, error) {
27
38
  return f.candidates, nil
28
39
  }
@@ -228,6 +239,17 @@ func TestRegisterValidatesRunnerRepo(t *testing.T) {
228
239
  }
229
240
  }
230
241
 
242
+ func TestRegisterUsesRunnerRepoProvisioningCapability(t *testing.T) {
243
+ rn := &fakeRunnerRegistrar{fakeRunnerDiscovery: &fakeRunnerDiscovery{validErr: errInvalidRepo{}}}
244
+ fx := newServiceFixture(t, rn)
245
+ if _, err := fx.svc.Register(context.Background(), repo.RegisterInput{Name: "payments", Path: "/srv/payments", TaskConfig: config.RawValues{"project": "P", "component": "c"}}); err != nil {
246
+ t.Fatal(err)
247
+ }
248
+ if len(rn.ensureCalls) != 1 || rn.ensureCalls[0] != (runner.RepoCandidate{Name: "payments", Path: "/srv/payments"}) {
249
+ t.Fatalf("EnsureRepo calls = %+v", rn.ensureCalls)
250
+ }
251
+ }
252
+
231
253
  func TestRegisterValidatesTaskConnectivity(t *testing.T) {
232
254
  // The task factory's New is invoked to validate connectivity; its error
233
255
  // must reject registration before persisting.