relay-flow 0.2.8-alpha → 0.2.10-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 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +391 -83
- package/cmd/relay-flow/observability_test.go +204 -0
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/render.go +629 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +35 -1
- package/go.mod +12 -8
- package/go.sum +24 -17
- package/internal/execution/goworkflows/activities.go +9 -0
- package/internal/execution/goworkflows/engine.go +15 -0
- package/internal/execution/goworkflows/engine_test.go +3 -3
- package/internal/execution/goworkflows/interpreter.go +86 -3
- package/internal/execution/goworkflows/projection.go +16 -0
- package/internal/execution/projection/detail_test.go +259 -0
- package/internal/execution/projection/projection.go +290 -6
- package/internal/execution/projection/projection_test.go +3 -2
- package/internal/execution/temporal/activities.go +9 -0
- package/internal/execution/temporal/engine.go +13 -0
- package/internal/execution/temporal/interpreter.go +131 -3
- package/internal/execution/temporal/operations.go +174 -0
- package/internal/execution/temporal/operations_test.go +36 -0
- package/internal/harness/opencode/opencode_test.go +4 -4
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/prompt_test.go +3 -3
- package/internal/repo/service.go +34 -3
- package/internal/repo/service_test.go +22 -0
- package/internal/run/detail.go +277 -0
- package/internal/run/detail_test.go +71 -0
- package/internal/run/run.go +4 -0
- package/internal/runner/herdr/herdr.go +103 -4
- package/internal/runner/herdr/herdr_test.go +72 -2
- package/internal/runner/herdr/herdrcli/contract.go +12 -3
- package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
- package/internal/runner/herdr/herdrcli/operations.go +16 -0
- package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
- package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
- package/internal/runner/orca/orca.go +68 -9
- package/internal/runner/orca/orca_test.go +134 -2
- package/internal/runner/orca/orcacli/orcacli.go +21 -1
- package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
- package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
- package/internal/runner/runner.go +9 -0
- package/internal/server/api_test.go +19 -0
- package/internal/server/client.go +49 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/observability.go +109 -0
- package/internal/server/observability_test.go +60 -0
- package/internal/server/server.go +77 -0
- package/internal/task/jira/testdata/jira_search_issues.json +4 -4
- package/internal/workflow/workflow.go +4 -1
- package/internal/workflow/workflow_test.go +4 -4
- package/package.json +1 -1
|
@@ -26,6 +26,7 @@ type worktreeSourceResponse struct {
|
|
|
26
26
|
RepoName string `json:"repo_name"`
|
|
27
27
|
RepoRoot string `json:"repo_root"`
|
|
28
28
|
SourceCheckoutPath string `json:"source_checkout_path"`
|
|
29
|
+
SourceWorkspaceID string `json:"source_workspace_id"`
|
|
29
30
|
}
|
|
30
31
|
|
|
31
32
|
type tabResponse struct {
|
|
@@ -72,6 +73,20 @@ func (c *CLI) Snapshot(ctx context.Context) (Snapshot, error) {
|
|
|
72
73
|
}, nil
|
|
73
74
|
}
|
|
74
75
|
|
|
76
|
+
// CreateWorkspace opens a repository path as a Herdr workspace. A source
|
|
77
|
+
// workspace is enough for repository discovery; ticket worktrees are still
|
|
78
|
+
// created lazily by EnsureEnvironment.
|
|
79
|
+
func (c *CLI) CreateWorkspace(ctx context.Context, cwd, label string) (Workspace, error) {
|
|
80
|
+
var response struct {
|
|
81
|
+
Workspace workspaceResponse `json:"workspace"`
|
|
82
|
+
}
|
|
83
|
+
if err := c.runJSON(ctx, "workspace create", &response,
|
|
84
|
+
"workspace", "create", "--cwd", cwd, "--label", label, "--no-focus"); err != nil {
|
|
85
|
+
return Workspace{}, err
|
|
86
|
+
}
|
|
87
|
+
return convertWorkspace(response.Workspace), nil
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
func (c *CLI) WorktreeList(ctx context.Context, repoPath string) (WorktreeListing, error) {
|
|
76
91
|
var response struct {
|
|
77
92
|
Source worktreeSourceResponse `json:"source"`
|
|
@@ -85,6 +100,7 @@ func (c *CLI) WorktreeList(ctx context.Context, repoPath string) (WorktreeListin
|
|
|
85
100
|
RepoName: response.Source.RepoName,
|
|
86
101
|
RepoRoot: response.Source.RepoRoot,
|
|
87
102
|
SourceCheckoutPath: response.Source.SourceCheckoutPath,
|
|
103
|
+
SourceWorkspaceID: response.Source.SourceWorkspaceID,
|
|
88
104
|
},
|
|
89
105
|
Worktrees: convertWorktrees(response.Worktrees),
|
|
90
106
|
}, nil
|
|
@@ -162,6 +162,14 @@ case "$#:${1-}:${2-}" in
|
|
|
162
162
|
*) emit_result pane-close.json ;;
|
|
163
163
|
esac
|
|
164
164
|
;;
|
|
165
|
+
7:workspace:create)
|
|
166
|
+
[ "$3" = --cwd ] || fail "$@"
|
|
167
|
+
check_absolute_cwd "$4"
|
|
168
|
+
[ "$5" = --label ] || fail "$@"
|
|
169
|
+
check_value "$6"
|
|
170
|
+
[ "$7" = --no-focus ] || fail "$@"
|
|
171
|
+
emit_result workspace-create.json
|
|
172
|
+
;;
|
|
165
173
|
3:workspace:close)
|
|
166
174
|
check_value "$3"
|
|
167
175
|
case "$3" in
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"id":"cli:workspace:create","result":{"root_pane":{"agent_status":"unknown","cwd":"/tmp/relay-flow-herdr-fixture-9wIblp/repo","focused":true,"foreground_cwd":"/tmp/relay-flow-herdr-fixture-9wIblp/repo","pane_id":"w1:p1","revision":0,"scroll":{"max_offset_from_bottom":0,"offset_from_bottom":0,"viewport_rows":40},"tab_id":"w1:t1","terminal_id":"term_65b0ccebd6e821","workspace_id":"w1"},"tab":{"agent_status":"unknown","focused":true,"label":"1","number":1,"pane_count":1,"tab_id":"w1:t1","workspace_id":"w1"},"type":"workspace_created","workspace":{"active_tab_id":"w1:t1","agent_status":"unknown","focused":true,"label":"payments","number":1,"pane_count":1,"tab_count":1,"workspace_id":"w1"}}}
|
|
@@ -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"`
|
|
@@ -14,6 +14,7 @@ import (
|
|
|
14
14
|
|
|
15
15
|
"github.com/rajpopat27/relay-flow/internal/config"
|
|
16
16
|
"github.com/rajpopat27/relay-flow/internal/run"
|
|
17
|
+
"github.com/rajpopat27/relay-flow/internal/runner"
|
|
17
18
|
"github.com/rajpopat27/relay-flow/internal/server"
|
|
18
19
|
"github.com/rajpopat27/relay-flow/internal/task"
|
|
19
20
|
"github.com/rajpopat27/relay-flow/internal/workflow"
|
|
@@ -333,6 +334,24 @@ func TestClientRepoRegistrationFieldsUsesGetThenPost(t *testing.T) {
|
|
|
333
334
|
}
|
|
334
335
|
}
|
|
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
|
+
|
|
336
355
|
func TestRepoOperations(t *testing.T) {
|
|
337
356
|
c, cleanup := startHandler(t, &fakeServices{})
|
|
338
357
|
defer cleanup()
|
|
@@ -118,6 +118,18 @@ func (c *Client) ListWorkflows(ctx context.Context) ([]*workflow.Workflow, error
|
|
|
118
118
|
return out, nil
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
// ListWorkflowSummaries returns one additive summary row per workflow. The
|
|
122
|
+
// server computes all rows in one query boundary; a server without the
|
|
123
|
+
// optional capability still returns definitions, which are decoded as rows
|
|
124
|
+
// with zero execution counts.
|
|
125
|
+
func (c *Client) ListWorkflowSummaries(ctx context.Context) ([]WorkflowSummary, error) {
|
|
126
|
+
var out []WorkflowSummary
|
|
127
|
+
if err := c.call(ctx, http.MethodGet, "/workflows?summary=1", nil, &out); err != nil {
|
|
128
|
+
return nil, err
|
|
129
|
+
}
|
|
130
|
+
return out, nil
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
// GetWorkflow returns one workflow by name.
|
|
122
134
|
func (c *Client) GetWorkflow(ctx context.Context, name string) (*workflow.Workflow, error) {
|
|
123
135
|
var wf workflow.Workflow
|
|
@@ -127,6 +139,15 @@ func (c *Client) GetWorkflow(ctx context.Context, name string) (*workflow.Workfl
|
|
|
127
139
|
return &wf, nil
|
|
128
140
|
}
|
|
129
141
|
|
|
142
|
+
// GetWorkflowDetail returns a definition and a small recent-run summary.
|
|
143
|
+
func (c *Client) GetWorkflowDetail(ctx context.Context, name string) (WorkflowDetail, error) {
|
|
144
|
+
var out WorkflowDetail
|
|
145
|
+
if err := c.call(ctx, http.MethodGet, "/workflows/"+url.PathEscape(name)+"?detail=1", nil, &out); err != nil {
|
|
146
|
+
return WorkflowDetail{}, err
|
|
147
|
+
}
|
|
148
|
+
return out, nil
|
|
149
|
+
}
|
|
150
|
+
|
|
130
151
|
// DiscoverRepos returns runner-visible registration candidates.
|
|
131
152
|
func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, error) {
|
|
132
153
|
var out []runner.RepoCandidate
|
|
@@ -136,6 +157,16 @@ func (c *Client) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, err
|
|
|
136
157
|
return out, nil
|
|
137
158
|
}
|
|
138
159
|
|
|
160
|
+
// EnsureRepo asks the configured runner to make a repository resource
|
|
161
|
+
// available without persisting a relay-flow repo entry.
|
|
162
|
+
func (c *Client) EnsureRepo(ctx context.Context, candidate runner.RepoCandidate) error {
|
|
163
|
+
payload, err := json.Marshal(candidate)
|
|
164
|
+
if err != nil {
|
|
165
|
+
return err
|
|
166
|
+
}
|
|
167
|
+
return c.call(ctx, http.MethodPost, "/repos/ensure", payload, nil)
|
|
168
|
+
}
|
|
169
|
+
|
|
139
170
|
// RepoTaskFields returns the documented initial required-key metadata.
|
|
140
171
|
func (c *Client) RepoTaskFields(ctx context.Context) ([]string, error) {
|
|
141
172
|
var out struct {
|
|
@@ -262,6 +293,13 @@ func (c *Client) ListRuns(ctx context.Context, filter run.Filter) ([]run.Run, er
|
|
|
262
293
|
if filter.Ticket != "" {
|
|
263
294
|
q.Set("ticket", filter.Ticket)
|
|
264
295
|
}
|
|
296
|
+
if filter.Active != nil {
|
|
297
|
+
if *filter.Active {
|
|
298
|
+
q.Set("active", "1")
|
|
299
|
+
} else {
|
|
300
|
+
q.Set("active", "0")
|
|
301
|
+
}
|
|
302
|
+
}
|
|
265
303
|
path := "/runs"
|
|
266
304
|
if s := q.Encode(); s != "" {
|
|
267
305
|
path += "?" + s
|
|
@@ -281,3 +319,14 @@ func (c *Client) GetRunByTicket(ctx context.Context, ticket string) (run.Run, er
|
|
|
281
319
|
}
|
|
282
320
|
return out, nil
|
|
283
321
|
}
|
|
322
|
+
|
|
323
|
+
// GetRunDetailByTicket returns the additive run inspection response. Existing
|
|
324
|
+
// callers should continue using GetRunByTicket when they only need lifecycle
|
|
325
|
+
// fields.
|
|
326
|
+
func (c *Client) GetRunDetailByTicket(ctx context.Context, ticket string) (run.RunDetail, error) {
|
|
327
|
+
var out run.RunDetail
|
|
328
|
+
if err := c.call(ctx, http.MethodGet, "/runs/by-ticket/"+url.PathEscape(ticket), nil, &out); err != nil {
|
|
329
|
+
return run.RunDetail{}, err
|
|
330
|
+
}
|
|
331
|
+
return out, nil
|
|
332
|
+
}
|
|
@@ -40,6 +40,8 @@ type fakeServices struct {
|
|
|
40
40
|
restartErr error
|
|
41
41
|
restarts []string
|
|
42
42
|
registrationValues []config.RawValues
|
|
43
|
+
ensureRepoCalls []runner.RepoCandidate
|
|
44
|
+
ensureRepoErr error
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
func (f *fakeServices) SubmitWorkflow(_ context.Context, yaml []byte) (*workflow.Workflow, error) {
|
|
@@ -155,6 +157,11 @@ func (f *fakeServices) DiscoverRepos(context.Context) ([]runner.RepoCandidate, e
|
|
|
155
157
|
return []runner.RepoCandidate{{Name: "payments", Path: "/srv/payments"}}, nil
|
|
156
158
|
}
|
|
157
159
|
|
|
160
|
+
func (f *fakeServices) EnsureRepo(_ context.Context, candidate runner.RepoCandidate) error {
|
|
161
|
+
f.ensureRepoCalls = append(f.ensureRepoCalls, candidate)
|
|
162
|
+
return f.ensureRepoErr
|
|
163
|
+
}
|
|
164
|
+
|
|
158
165
|
func (f *fakeServices) TaskRegistrationFields(_ context.Context, values config.RawValues) ([]task.RegistrationField, error) {
|
|
159
166
|
f.registrationValues = append(f.registrationValues, values)
|
|
160
167
|
return []task.RegistrationField{{Key: "project", Title: "Project"}, {Key: "component", Title: "Component", Derived: true}}, nil
|