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.
- package/README.md +76 -37
- package/cmd/relay-flow/commands_test.go +199 -19
- package/cmd/relay-flow/main.go +183 -144
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +454 -0
- package/cmd/relay-flow/serve.go +5 -2
- package/examples/config-reference.yaml +7 -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 +47 -3
- package/internal/repo/service_test.go +36 -1
- 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 +65 -0
- package/internal/server/client.go +40 -2
- package/internal/server/fixture_test.go +27 -16
- package/internal/server/server.go +80 -4
- package/internal/task/beads/beads.go +7 -2
- package/internal/task/beads/beads_test.go +13 -0
- package/internal/task/factory.go +9 -7
- package/internal/task/jira/auth_test.go +9 -1
- package/internal/task/jira/filters_test.go +2 -2
- package/internal/task/jira/helpers_test.go +13 -0
- package/internal/task/jira/jira.go +233 -44
- package/internal/task/jira/lifecycle_inheritance_test.go +5 -5
- package/internal/task/jira/rest/client.go +73 -31
- package/internal/task/jira/rest/client_test.go +25 -0
- package/internal/task/jira/status_defaults_test.go +134 -0
- package/internal/task/jira/templates_test.go +2 -2
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/task/jira/transition_defaults_test.go +8 -12
- package/internal/task/jira/validation_test.go +3 -1
- package/internal/task/registration.go +59 -0
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- 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
|
|
77
|
-
// the stable identity (the Orca repo ID is an internal detail that
|
|
78
|
-
// change across machines); name is
|
|
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 ==
|
|
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
|
-
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
-
//
|
|
138
|
-
//
|
|
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
|
|
27
|
-
failRepos
|
|
28
|
-
slowReport
|
|
29
|
-
workflows
|
|
30
|
-
repos
|
|
31
|
-
runs
|
|
32
|
-
shutdownCh
|
|
33
|
-
registrations
|
|
34
|
-
runtimeAck
|
|
35
|
-
processedReports
|
|
36
|
-
submittedReports
|
|
37
|
-
restartRun
|
|
38
|
-
restartErr
|
|
39
|
-
restarts
|
|
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)
|
|
156
|
-
|
|
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) {
|