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
@@ -13,6 +13,7 @@ import (
13
13
  "errors"
14
14
  "fmt"
15
15
  "log/slog"
16
+ "path/filepath"
16
17
  "strings"
17
18
 
18
19
  "github.com/rajpopat27/relay-flow/internal/config"
@@ -65,6 +66,42 @@ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, er
65
66
  return out, nil
66
67
  }
67
68
 
69
+ // EnsureRepo makes an Orca repository available by canonical path. Orca
70
+ // derives its display name from the path, so the relay-flow registration name
71
+ // is intentionally not used as the Orca identity.
72
+ func (a *adapter) EnsureRepo(ctx context.Context, name, path string) error {
73
+ slog.Debug("orca call", "op", "ensure-repo", "repo", name)
74
+ canonical := normalizePath(path)
75
+ if canonical == "" {
76
+ err := fmt.Errorf("orca: repository %q has an empty path", name)
77
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "error", "error", sanitizeErr(err))
78
+ return err
79
+ }
80
+ repos, err := a.cli.ListRepos(ctx)
81
+ if err != nil {
82
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "error", "error", sanitizeErr(err))
83
+ return err
84
+ }
85
+ for _, r := range repos {
86
+ if normalizePath(r.Path) == canonical {
87
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "exists")
88
+ return nil
89
+ }
90
+ }
91
+ adder, ok := a.cli.(orcacli.RepoRegistrar)
92
+ if !ok {
93
+ err := fmt.Errorf("orca: repository provisioning is unavailable")
94
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "error", "error", sanitizeErr(err))
95
+ return err
96
+ }
97
+ if err := adder.AddRepo(ctx, canonical); err != nil {
98
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "error", "error", sanitizeErr(err))
99
+ return err
100
+ }
101
+ slog.Info("orca outcome", "op", "ensure-repo", "repo", name, "result", "created")
102
+ return nil
103
+ }
104
+
68
105
  // ValidateRepo verifies the named repo exists in Orca at the given path.
69
106
  func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
70
107
  if _, err := a.repoID(ctx, name, path); err != nil {
@@ -73,22 +110,41 @@ func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
73
110
  return nil
74
111
  }
75
112
 
76
- // repoID resolves a registered repo to its Orca repo ID. The repo path is
77
- // the stable identity (the Orca repo ID is an internal detail that can
78
- // change across machines); name is matched as a secondary check.
113
+ // repoID resolves a registered repo to its Orca repo ID. The canonical repo
114
+ // path is the stable identity (the Orca repo ID is an internal detail that
115
+ // can change across machines); relay-flow's name is independent metadata.
79
116
  func (a *adapter) repoID(ctx context.Context, name, path string) (string, error) {
117
+ canonical := normalizePath(path)
118
+ if canonical == "" {
119
+ return "", fmt.Errorf("orca: repo %q has an empty path", name)
120
+ }
80
121
  repos, err := a.cli.ListRepos(ctx)
81
122
  if err != nil {
82
123
  return "", err
83
124
  }
84
125
  for _, r := range repos {
85
- if r.Path == path && r.DisplayName == name {
126
+ if normalizePath(r.Path) == canonical {
86
127
  return r.ID, nil
87
128
  }
88
129
  }
89
130
  return "", fmt.Errorf("orca: repo %q at %q not registered", name, path)
90
131
  }
91
132
 
133
+ func normalizePath(path string) string {
134
+ if strings.TrimSpace(path) == "" {
135
+ return ""
136
+ }
137
+ absolute, err := filepath.Abs(path)
138
+ if err != nil {
139
+ return filepath.Clean(path)
140
+ }
141
+ absolute = filepath.Clean(absolute)
142
+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
143
+ return filepath.Clean(resolved)
144
+ }
145
+ return absolute
146
+ }
147
+
92
148
  // --- Environment ---
93
149
 
94
150
  // EnsureEnvironment returns the ticket-scoped worktree, creating it from the
@@ -198,6 +254,10 @@ func primaryBranch(w *orcacli.Worktree) string {
198
254
  func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
199
255
  slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
200
256
  err := a.cli.CloseTerminal(ctx, terminal.ID)
257
+ if errors.Is(err, orcacli.ErrTerminalUnavailable) {
258
+ slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "absent")
259
+ return nil
260
+ }
201
261
  if err != nil {
202
262
  slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
203
263
  } else {
@@ -386,14 +446,10 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
386
446
  if !strings.HasPrefix(t.Title, prefix) {
387
447
  continue
388
448
  }
389
- // 9.5: one outcome line per actual terminal close, with its title.
390
- slog.Debug("orca call", "op", "close-terminal", "title", t.Title, "handle", t.Handle)
391
- if err := a.cli.CloseTerminal(ctx, t.Handle); err != nil {
392
- slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "error", "error", sanitizeErr(err))
449
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: t.Handle, Title: t.Title}); err != nil {
393
450
  slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
394
451
  return fmt.Errorf("close terminal %q: %w", t.Title, err)
395
452
  }
396
- slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "ok")
397
453
  closed++
398
454
  }
399
455
  slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "ok", "closed", closed)
@@ -443,3 +499,6 @@ func (a *adapter) findEnvironment(ctx context.Context, spec runner.RunSpec) (run
443
499
  }
444
500
  return runner.Environment{}, false, nil
445
501
  }
502
+
503
+ var _ runner.Runner = (*adapter)(nil)
504
+ var _ runner.RepoRegistrar = (*adapter)(nil)
@@ -50,6 +50,9 @@ type fakeCLI struct {
50
50
  showHandles []string
51
51
  createCommands []string
52
52
  createN int
53
+ closedHandles []string
54
+ closeErrors map[string]error
55
+ deletedWorktrees []string
53
56
  }
54
57
 
55
58
  func (f *fakeCLI) ListRepos(context.Context) ([]orcacli.Repo, error) { return f.repos, nil }
@@ -72,7 +75,10 @@ func (f *fakeCLI) SetWorktreeStatus(_ context.Context, _, status string) error {
72
75
  f.status = status
73
76
  return nil
74
77
  }
75
- func (f *fakeCLI) DeleteWorktree(context.Context, string) error { return nil }
78
+ func (f *fakeCLI) DeleteWorktree(_ context.Context, worktreeID string) error {
79
+ f.deletedWorktrees = append(f.deletedWorktrees, worktreeID)
80
+ return nil
81
+ }
76
82
  func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Terminal, error) {
77
83
  f.showHandles = append(f.showHandles, handle)
78
84
  t, ok := f.terminals[handle]
@@ -95,7 +101,59 @@ func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command str
95
101
  f.terminals[handle] = orcacli.Terminal{Handle: handle, Title: title, Connected: true}
96
102
  return handle, nil
97
103
  }
98
- func (f *fakeCLI) CloseTerminal(context.Context, string) error { return nil }
104
+ func (f *fakeCLI) CloseTerminal(_ context.Context, handle string) error {
105
+ f.closedHandles = append(f.closedHandles, handle)
106
+ if err, ok := f.closeErrors[handle]; ok {
107
+ return err
108
+ }
109
+ return nil
110
+ }
111
+
112
+ type repoAddingCLI struct {
113
+ *fakeCLI
114
+ addPaths []string
115
+ addErr error
116
+ }
117
+
118
+ func (f *repoAddingCLI) AddRepo(_ context.Context, path string) error {
119
+ f.addPaths = append(f.addPaths, path)
120
+ return f.addErr
121
+ }
122
+
123
+ func TestEnsureRepoSkipsAnExistingCanonicalPath(t *testing.T) {
124
+ path := t.TempDir()
125
+ fx := &repoAddingCLI{fakeCLI: &fakeCLI{repos: []orcacli.Repo{{ID: "r1", DisplayName: "runner-name", Path: path}}}}
126
+ a, err := New(fx, config.RawValues{})
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "relay-name", path+"/."); err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ if len(fx.addPaths) != 0 {
134
+ t.Fatalf("AddRepo calls = %v, want none for an existing path", fx.addPaths)
135
+ }
136
+ }
137
+
138
+ func TestEnsureRepoAddsMissingPathAndPropagatesFailures(t *testing.T) {
139
+ path := t.TempDir()
140
+ fx := &repoAddingCLI{fakeCLI: &fakeCLI{}}
141
+ a, err := New(fx, config.RawValues{})
142
+ if err != nil {
143
+ t.Fatal(err)
144
+ }
145
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "payments", path); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if len(fx.addPaths) != 1 || fx.addPaths[0] != path {
149
+ t.Fatalf("AddRepo calls = %v, want [%q]", fx.addPaths, path)
150
+ }
151
+
152
+ fx.addErr = errors.New("orca unavailable")
153
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "other", t.TempDir()); err == nil || !strings.Contains(err.Error(), "orca unavailable") {
154
+ t.Fatalf("EnsureRepo error = %v, want AddRepo failure", err)
155
+ }
156
+ }
99
157
 
100
158
  // 9.16: when the repo's primary worktree is on master (refs/heads/master),
101
159
  // EnsureEnvironment must pass --base-branch master (via CreateWorktree), not
@@ -290,6 +348,80 @@ func TestCreateTerminalAlwaysCreatesAndTreatsCommandAsOpaque(t *testing.T) {
290
348
  }
291
349
  }
292
350
 
351
+ func TestCloseTerminalTreatsUnavailableAsAbsent(t *testing.T) {
352
+ fx := &fakeCLI{closeErrors: map[string]error{
353
+ "term-stale": orcacli.ErrTerminalUnavailable,
354
+ }}
355
+ a, err := New(fx, config.RawValues{})
356
+ if err != nil {
357
+ t.Fatal(err)
358
+ }
359
+ if err := a.CloseTerminal(context.Background(), runner.Terminal{ID: "term-stale", Title: "PAY-1:implement"}); err != nil {
360
+ t.Fatalf("CloseTerminal(stale) = %v, want nil", err)
361
+ }
362
+ if len(fx.closedHandles) != 1 || fx.closedHandles[0] != "term-stale" {
363
+ t.Fatalf("closed handles = %v, want [term-stale]", fx.closedHandles)
364
+ }
365
+ }
366
+
367
+ func TestCloseTerminalPropagatesGenuineFailure(t *testing.T) {
368
+ wantErr := errors.New("orca permission denied")
369
+ fx := &fakeCLI{closeErrors: map[string]error{"term-failed": wantErr}}
370
+ a, err := New(fx, config.RawValues{})
371
+ if err != nil {
372
+ t.Fatal(err)
373
+ }
374
+ if err := a.CloseTerminal(context.Background(), runner.Terminal{ID: "term-failed", Title: "PAY-1:implement"}); !errors.Is(err, wantErr) {
375
+ t.Fatalf("CloseTerminal(genuine failure) = %v, want %v", err, wantErr)
376
+ }
377
+ }
378
+
379
+ func TestCloseTerminalsContinuesAfterStaleHandle(t *testing.T) {
380
+ fx := &fakeCLI{
381
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
382
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
383
+ listedTerminals: []orcacli.Terminal{
384
+ {Handle: "term-stale", Title: "PAY-1:stale", Connected: true},
385
+ {Handle: "term-live", Title: "PAY-1:implement", Connected: true},
386
+ {Handle: "term-user", Title: "shell", Connected: true},
387
+ },
388
+ closeErrors: map[string]error{"term-stale": orcacli.ErrTerminalUnavailable},
389
+ }
390
+ a, err := New(fx, config.RawValues{})
391
+ if err != nil {
392
+ t.Fatal(err)
393
+ }
394
+ if err := a.CloseTerminals(context.Background(), runner.RunSpec{
395
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
396
+ }); err != nil {
397
+ t.Fatalf("CloseTerminals = %v, want nil after stale handle", err)
398
+ }
399
+ if len(fx.closedHandles) != 2 || fx.closedHandles[0] != "term-stale" || fx.closedHandles[1] != "term-live" {
400
+ t.Fatalf("closed handles = %v, want stale and live run terminals", fx.closedHandles)
401
+ }
402
+ }
403
+
404
+ func TestCleanupRunDeletesWorktreeAfterStaleHandle(t *testing.T) {
405
+ fx := &fakeCLI{
406
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
407
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
408
+ listedTerminals: []orcacli.Terminal{{Handle: "term-stale", Title: "PAY-1:implement", Connected: true}},
409
+ closeErrors: map[string]error{"term-stale": orcacli.ErrTerminalUnavailable},
410
+ }
411
+ a, err := New(fx, config.RawValues{})
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ if err := a.CleanupRun(context.Background(), runner.RunSpec{
416
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
417
+ }); err != nil {
418
+ t.Fatalf("CleanupRun = %v, want nil after stale handle", err)
419
+ }
420
+ if len(fx.deletedWorktrees) != 1 || fx.deletedWorktrees[0] != "wt-PAY-1" {
421
+ t.Fatalf("deleted worktrees = %v, want [wt-PAY-1]", fx.deletedWorktrees)
422
+ }
423
+ }
424
+
293
425
  // An existing ticket branch must win even over a configured baseRef. Passing
294
426
  // any other base would make Orca hit its branch-name collision behavior.
295
427
  func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {
@@ -57,8 +57,22 @@ type Client interface {
57
57
  // CLI is the production Client backed by the orca binary.
58
58
  type CLI struct{}
59
59
 
60
+ // RepoRegistrar is the optional repository-provisioning seam used by the
61
+ // runner adapter. Keeping it separate from Client lets existing read-only
62
+ // test clients remain focused on the runtime operations they exercise.
63
+ type RepoRegistrar interface {
64
+ AddRepo(ctx context.Context, path string) error
65
+ }
66
+
60
67
  func New() *CLI { return &CLI{} }
61
68
 
69
+ // AddRepo registers an existing local repository with Orca. Orca derives its
70
+ // display name from the path; relay-flow keeps the user-facing registration
71
+ // name separately.
72
+ func (CLI) AddRepo(ctx context.Context, path string) error {
73
+ return run(ctx, "repo", "add", "--path", path, "--json")
74
+ }
75
+
62
76
  func (CLI) ListRepos(ctx context.Context) ([]Repo, error) {
63
77
  var res struct {
64
78
  Result struct {
@@ -194,7 +208,13 @@ func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string)
194
208
  }
195
209
 
196
210
  func (CLI) CloseTerminal(ctx context.Context, handle string) error {
197
- return run(ctx, "terminal", "close", "--terminal", handle, "--json")
211
+ if err := run(ctx, "terminal", "close", "--terminal", handle, "--json"); err != nil {
212
+ if strings.Contains(err.Error(), "terminal_handle_stale") {
213
+ return ErrTerminalUnavailable
214
+ }
215
+ return err
216
+ }
217
+ return nil
198
218
  }
199
219
 
200
220
  func run(ctx context.Context, args ...string) error {
@@ -2,6 +2,7 @@ package orcacli
2
2
 
3
3
  import (
4
4
  "context"
5
+ "errors"
5
6
  "os"
6
7
  "os/exec"
7
8
  "path/filepath"
@@ -30,6 +31,9 @@ func TestCLIContractsAgainstCapturedRealOutput(t *testing.T) {
30
31
  t.Fatalf("ListWorktrees = %+v", worktrees)
31
32
  }
32
33
 
34
+ if err := cli.AddRepo(ctx, "/work/new"); err != nil {
35
+ t.Fatal(err)
36
+ }
33
37
  if err := cli.CreateWorktree(ctx, "PAY-101", "repo-1", "wt-main", "origin/alice/PAY-101"); err != nil {
34
38
  t.Fatal(err)
35
39
  }
@@ -80,6 +84,24 @@ func TestStrictFakeOrcaRejectsMissingFlags(t *testing.T) {
80
84
  }
81
85
  }
82
86
 
87
+ func TestCloseTerminalMapsStaleHandleToUnavailable(t *testing.T) {
88
+ installStrictFakeOrca(t)
89
+ cli := New()
90
+
91
+ err := cli.CloseTerminal(context.Background(), "term-stale")
92
+ if !errors.Is(err, ErrTerminalUnavailable) {
93
+ t.Fatalf("CloseTerminal(stale) = %v, want ErrTerminalUnavailable", err)
94
+ }
95
+
96
+ err = cli.CloseTerminal(context.Background(), "term-unknown")
97
+ if err == nil {
98
+ t.Fatal("CloseTerminal(unknown) = nil, want the CLI failure")
99
+ }
100
+ if errors.Is(err, ErrTerminalUnavailable) {
101
+ t.Fatalf("CloseTerminal(unknown) = %v, incorrectly classified as stale", err)
102
+ }
103
+ }
104
+
83
105
  func TestFindExistingBranchLocalOnly(t *testing.T) {
84
106
  repo := newGitRepo(t)
85
107
  runGit(t, repo, "branch", "alice/PAY-101")
@@ -4,6 +4,8 @@ set -eu
4
4
  fixture=
5
5
  if [ "$#" -eq 3 ] && [ "$1" = repo ] && [ "$2" = list ] && [ "$3" = --json ]; then
6
6
  fixture=repo-list.json
7
+ elif [ "$#" -eq 5 ] && [ "$1" = repo ] && [ "$2" = add ] && [ "$3" = --path ] && [ "$4" = /work/new ] && [ "$5" = --json ]; then
8
+ fixture=repo-list.json
7
9
  elif [ "$#" -eq 3 ] && [ "$1" = worktree ] && [ "$2" = list ] && [ "$3" = --json ]; then
8
10
  fixture=worktree-list.json
9
11
  elif [ "$#" -eq 11 ] && [ "$1" = worktree ] && [ "$2" = create ] && [ "$3" = --name ] && [ "$4" = PAY-101 ] && [ "$5" = --repo ] && [ "$6" = id:repo-1 ] && [ "$7" = --parent-worktree ] && [ "$8" = worktree:wt-main ] && [ "$9" = --base-branch ] && [ "${10}" = origin/alice/PAY-101 ] && [ "${11}" = --json ]; then
@@ -20,6 +22,9 @@ elif [ "$#" -eq 8 ] && [ "$1" = terminal ] && [ "$2" = send ] && [ "$3" = --term
20
22
  fixture=terminal-send.json
21
23
  elif [ "$#" -eq 9 ] && [ "$1" = terminal ] && [ "$2" = create ] && [ "$3" = --worktree ] && [ "$4" = name:PAY-101 ] && [ "$5" = --title ] && [ "$6" = PAY-101:implement ] && [ "$7" = --command ] && [ "$8" = 'echo hello' ] && [ "$9" = --json ]; then
22
24
  fixture=terminal-create.json
25
+ elif [ "$#" -eq 5 ] && [ "$1" = terminal ] && [ "$2" = close ] && [ "$3" = --terminal ] && [ "$4" = term-stale ] && [ "$5" = --json ]; then
26
+ printf '%s\n' '{"ok":false,"error":{"code":"terminal_handle_stale","message":"terminal_handle_stale"}}'
27
+ exit 1
23
28
  elif [ "$#" -eq 5 ] && [ "$1" = terminal ] && [ "$2" = close ] && [ "$3" = --terminal ] && [ "$4" = term-1 ] && [ "$5" = --json ]; then
24
29
  fixture=terminal-close.json
25
30
  else
@@ -24,6 +24,15 @@ type RepoCandidate struct {
24
24
  Path string `json:"path"`
25
25
  }
26
26
 
27
+ // RepoRegistrar is the optional runner capability used by repository
28
+ // registration. Runners that manage an external repository/workspace can
29
+ // make the resource available before relay-flow persists the repo entry.
30
+ // Runners without a provisioning primitive still use ValidateRepo through
31
+ // the repo service.
32
+ type RepoRegistrar interface {
33
+ EnsureRepo(context.Context, string, string) error
34
+ }
35
+
27
36
  type Environment struct {
28
37
  ID string `json:"id"`
29
38
  Path string `json:"path"`
@@ -2,15 +2,20 @@ package server_test
2
2
 
3
3
  import (
4
4
  "bytes"
5
+ "context"
5
6
  "encoding/json"
6
7
  "fmt"
7
8
  "io"
8
9
  "net/http"
10
+ "path/filepath"
9
11
  "strings"
10
12
  "testing"
11
13
  "time"
12
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
13
16
  "github.com/rajpopat27/relay-flow/internal/run"
17
+ "github.com/rajpopat27/relay-flow/internal/runner"
18
+ "github.com/rajpopat27/relay-flow/internal/server"
14
19
  "github.com/rajpopat27/relay-flow/internal/task"
15
20
  "github.com/rajpopat27/relay-flow/internal/workflow"
16
21
  )
@@ -287,6 +292,66 @@ func TestRunEndpointsExposeRetryDetails(t *testing.T) {
287
292
  }
288
293
  }
289
294
 
295
+ func TestRepoTaskFieldsSupportsInitialGetAndDependentPost(t *testing.T) {
296
+ fake := &fakeServices{}
297
+ c, cleanup := startHandler(t, fake)
298
+ defer cleanup()
299
+
300
+ code, env := do(t, c, http.MethodGet, "http://relay/repos/task-fields", nil)
301
+ if code != http.StatusOK || !env.OK || !bytes.Contains(env.Data, []byte(`"fields"`)) {
302
+ t.Fatalf("GET /repos/task-fields: code=%d env=%+v", code, env)
303
+ }
304
+ code, env = do(t, c, http.MethodPost, "http://relay/repos/task-fields", []byte(`{"values":{"project":"PAY"}}`))
305
+ if code != http.StatusOK || !env.OK || len(fake.registrationValues) != 2 {
306
+ t.Fatalf("POST /repos/task-fields: code=%d env=%+v values=%v", code, env, fake.registrationValues)
307
+ }
308
+ if fake.registrationValues[1]["project"] != "PAY" {
309
+ t.Fatalf("dependent registration values = %#v", fake.registrationValues[1])
310
+ }
311
+ }
312
+
313
+ func TestClientRepoRegistrationFieldsUsesGetThenPost(t *testing.T) {
314
+ fake := &fakeServices{}
315
+ dir := t.TempDir()
316
+ _, cleanup := startHandlerOnSocket(t, dir, fake)
317
+ defer cleanup()
318
+ client := server.NewClient(filepath.Join(dir, "server.sock"))
319
+ keys, err := client.RepoTaskFields(context.Background())
320
+ if err != nil || len(keys) != 2 || keys[0] != "project" || keys[1] != "component" {
321
+ t.Fatalf("documented task fields = %v, err=%v", keys, err)
322
+ }
323
+
324
+ initial, err := client.RepoRegistrationFields(context.Background(), nil)
325
+ if err != nil || len(initial.Fields) != 2 {
326
+ t.Fatalf("initial registration fields = %#v, err=%v", initial, err)
327
+ }
328
+ dependent, err := client.RepoRegistrationFields(context.Background(), config.RawValues{"project": "PAY"})
329
+ if err != nil || len(dependent.Fields) != 2 {
330
+ t.Fatalf("dependent registration fields = %#v, err=%v", dependent, err)
331
+ }
332
+ if len(fake.registrationValues) != 3 || fake.registrationValues[0] != nil || fake.registrationValues[1] != nil || fake.registrationValues[2]["project"] != "PAY" {
333
+ t.Fatalf("registration request values = %#v", fake.registrationValues)
334
+ }
335
+ }
336
+
337
+ func TestRepoEnsureEndpoint(t *testing.T) {
338
+ fake := &fakeServices{}
339
+ c, cleanup := startHandler(t, fake)
340
+ defer cleanup()
341
+ code, env := do(t, c, http.MethodPost, "http://relay/repos/ensure", []byte(`{"name":"payments","path":"/srv/payments"}`))
342
+ if code != http.StatusOK || !env.OK {
343
+ t.Fatalf("POST /repos/ensure: code=%d env=%+v, want 200 ok", code, env)
344
+ }
345
+ if len(fake.ensureRepoCalls) != 1 || fake.ensureRepoCalls[0] != (runner.RepoCandidate{Name: "payments", Path: "/srv/payments"}) {
346
+ t.Fatalf("EnsureRepo calls = %+v", fake.ensureRepoCalls)
347
+ }
348
+
349
+ code, env = do(t, c, http.MethodPost, "http://relay/repos/ensure", []byte(`{"name":"payments"}`))
350
+ if code != http.StatusBadRequest || env.OK {
351
+ t.Fatalf("invalid POST /repos/ensure: code=%d env=%+v, want 400", code, env)
352
+ }
353
+ }
354
+
290
355
  func TestRepoOperations(t *testing.T) {
291
356
  c, cleanup := startHandler(t, &fakeServices{})
292
357
  defer cleanup()
@@ -10,9 +10,11 @@ import (
10
10
  "net/http"
11
11
  "net/url"
12
12
 
13
+ "github.com/rajpopat27/relay-flow/internal/config"
13
14
  "github.com/rajpopat27/relay-flow/internal/repo"
14
15
  "github.com/rajpopat27/relay-flow/internal/run"
15
16
  "github.com/rajpopat27/relay-flow/internal/runner"
17
+ "github.com/rajpopat27/relay-flow/internal/task"
16
18
  "github.com/rajpopat27/relay-flow/internal/workflow"
17
19
  )
18
20
 
@@ -134,8 +136,17 @@ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, err
134
136
  return out, nil
135
137
  }
136
138
 
137
- // RepoTaskFields returns the task factory's required repo keys, matching
138
- // GET /repos/task-fields (server wraps the list as {"fields": [...]}).
139
+ // EnsureRepo asks the configured runner to make a repository resource
140
+ // available without persisting a relay-flow repo entry.
141
+ func (c *Client) EnsureRepo(ctx context.Context, candidate runner.RepoCandidate) error {
142
+ payload, err := json.Marshal(candidate)
143
+ if err != nil {
144
+ return err
145
+ }
146
+ return c.call(ctx, http.MethodPost, "/repos/ensure", payload, nil)
147
+ }
148
+
149
+ // RepoTaskFields returns the documented initial required-key metadata.
139
150
  func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
140
151
  var out struct {
141
152
  Fields []string `json:"fields"`
@@ -146,6 +157,33 @@ func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
146
157
  return out.Fields, nil
147
158
  }
148
159
 
160
+ // RepoRegistrationFields returns task-plugin-owned repository prompts. Values
161
+ // are flat registration inputs so plugins can discover dependent choices (for
162
+ // example project statuses after project is selected). Initial discovery uses
163
+ // the documented GET endpoint; dependent discovery uses POST.
164
+ func (c *Client) RepoRegistrationFields(ctx context.Context, values config.RawValues) (task.Registration, error) {
165
+ if len(values) == 0 {
166
+ var out struct {
167
+ Registration task.Registration `json:"registration"`
168
+ }
169
+ if err := c.call(ctx, http.MethodGet, "/repos/task-fields", nil, &out); err != nil {
170
+ return task.Registration{}, err
171
+ }
172
+ return out.Registration, nil
173
+ }
174
+ payload, err := json.Marshal(struct {
175
+ Values config.RawValues `json:"values,omitempty"`
176
+ }{Values: values})
177
+ if err != nil {
178
+ return task.Registration{}, err
179
+ }
180
+ var out task.Registration
181
+ if err := c.call(ctx, http.MethodPost, "/repos/task-fields", payload, &out); err != nil {
182
+ return task.Registration{}, err
183
+ }
184
+ return out, nil
185
+ }
186
+
149
187
  // RegisterRepo registers a repo by name/path with optional task config.
150
188
  func (c *Client) RegisterRepo(ctx context.Context, input repo.RegisterInput) (repo.Info, error) {
151
189
  payload, _ := json.Marshal(input)
@@ -12,10 +12,12 @@ import (
12
12
  "path/filepath"
13
13
  "testing"
14
14
 
15
+ "github.com/rajpopat27/relay-flow/internal/config"
15
16
  "github.com/rajpopat27/relay-flow/internal/repo"
16
17
  "github.com/rajpopat27/relay-flow/internal/run"
17
18
  "github.com/rajpopat27/relay-flow/internal/runner"
18
19
  "github.com/rajpopat27/relay-flow/internal/server"
20
+ "github.com/rajpopat27/relay-flow/internal/task"
19
21
  "github.com/rajpopat27/relay-flow/internal/workflow"
20
22
  )
21
23
 
@@ -23,20 +25,23 @@ import (
23
25
  // seams. activeWorkflows seeds workflows with an active run (drives 409);
24
26
  // failRepos forces a 500; slowReport simulates a long-running report call.
25
27
  type fakeServices struct {
26
- activeWorkflows map[string]bool
27
- failRepos bool
28
- slowReport chan struct{}
29
- workflows map[string]*workflow.Workflow
30
- repos map[string]repo.Info
31
- runs []run.Run
32
- shutdownCh chan struct{}
33
- registrations []run.NodeRuntimeRegistration
34
- runtimeAck run.NodeRuntimeRegistrationAck
35
- processedReports map[string]bool
36
- submittedReports int
37
- restartRun run.Run
38
- restartErr error
39
- restarts []string
28
+ activeWorkflows map[string]bool
29
+ failRepos bool
30
+ slowReport chan struct{}
31
+ workflows map[string]*workflow.Workflow
32
+ repos map[string]repo.Info
33
+ runs []run.Run
34
+ shutdownCh chan struct{}
35
+ registrations []run.NodeRuntimeRegistration
36
+ runtimeAck run.NodeRuntimeRegistrationAck
37
+ processedReports map[string]bool
38
+ submittedReports int
39
+ restartRun run.Run
40
+ restartErr error
41
+ restarts []string
42
+ registrationValues []config.RawValues
43
+ ensureRepoCalls []runner.RepoCandidate
44
+ ensureRepoErr error
40
45
  }
41
46
 
42
47
  func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
@@ -152,8 +157,14 @@ func (f *fakeServices) DiscoverRepos(context.Context) ([]runner.RepoCandidate, e
152
157
  return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
153
158
  }
154
159
 
155
- func (f *fakeServices) TaskFields(context.Context) ([]string, error) {
156
- return []string{"status", "labels"}, nil
160
+ func (f *fakeServices) EnsureRepo(_ context.Context, candidate runner.RepoCandidate) error {
161
+ f.ensureRepoCalls = append(f.ensureRepoCalls, candidate)
162
+ return f.ensureRepoErr
163
+ }
164
+
165
+ func (f *fakeServices) TaskRegistrationFields(_ context.Context, values config.RawValues) ([]task.RegistrationField, error) {
166
+ f.registrationValues = append(f.registrationValues, values)
167
+ return []task.RegistrationField{{Key: "project", Title: "Project"}, {Key: "component", Title: "Component", Derived: true}}, nil
157
168
  }
158
169
 
159
170
  func (f *fakeServices) RegisterRepo(_ context.Context, input repo.RegisterInput) (repo.Info, error) {