relay-flow 0.2.8-alpha → 0.2.9-alpha
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +62 -35
- package/cmd/relay-flow/commands_test.go +117 -8
- package/cmd/relay-flow/main.go +167 -60
- package/cmd/relay-flow/onboarding.go +344 -0
- package/cmd/relay-flow/onboarding_test.go +515 -0
- package/cmd/relay-flow/repo_registration.go +219 -0
- package/cmd/relay-flow/serve.go +3 -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 +34 -3
- package/internal/repo/service_test.go +22 -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 +10 -0
- package/internal/server/fixture_test.go +7 -0
- package/internal/server/server.go +41 -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
|
@@ -91,11 +91,15 @@ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, er
|
|
|
91
91
|
}
|
|
92
92
|
candidates := make(map[string]runner.RepoCandidate)
|
|
93
93
|
for _, workspace := range snapshot.Workspaces {
|
|
94
|
-
|
|
95
|
-
if
|
|
94
|
+
candidate, ok, err := a.workspaceRepository(ctx, workspace, snapshot.Panes)
|
|
95
|
+
if err != nil {
|
|
96
|
+
logOutcome("discover-repos", "error")
|
|
97
|
+
return nil, err
|
|
98
|
+
}
|
|
99
|
+
if !ok {
|
|
96
100
|
continue
|
|
97
101
|
}
|
|
98
|
-
candidates[
|
|
102
|
+
candidates[canonicalCandidatePath(candidate.Path)] = candidate
|
|
99
103
|
}
|
|
100
104
|
out := make([]runner.RepoCandidate, 0, len(candidates))
|
|
101
105
|
for _, candidate := range candidates {
|
|
@@ -111,8 +115,102 @@ func (a *adapter) DiscoverRepos(ctx context.Context) ([]runner.RepoCandidate, er
|
|
|
111
115
|
return out, nil
|
|
112
116
|
}
|
|
113
117
|
|
|
118
|
+
// workspaceRepository resolves the Git identity of a workspace. Herdr's
|
|
119
|
+
// snapshot includes worktree metadata for linked worktrees, but plain source
|
|
120
|
+
// workspaces may omit the worktree object; their pane CWD is the supported
|
|
121
|
+
// lookup handle for worktree list.
|
|
122
|
+
func (a *adapter) workspaceRepository(ctx context.Context, workspace herdrcli.Workspace, panes []herdrcli.Pane) (runner.RepoCandidate, bool, error) {
|
|
123
|
+
if root := normalizePath(workspace.Worktree.RepoRoot); root != "" {
|
|
124
|
+
name := workspace.Worktree.RepoName
|
|
125
|
+
if name == "" {
|
|
126
|
+
name = workspace.Label
|
|
127
|
+
}
|
|
128
|
+
return runner.RepoCandidate{Name: name, Path: root}, true, nil
|
|
129
|
+
}
|
|
130
|
+
for _, pane := range panes {
|
|
131
|
+
if pane.WorkspaceID != workspace.ID || !filepath.IsAbs(strings.TrimSpace(pane.CWD)) {
|
|
132
|
+
continue
|
|
133
|
+
}
|
|
134
|
+
listing, err := a.cli.WorktreeList(ctx, pane.CWD)
|
|
135
|
+
if errors.Is(err, herdrcli.ErrNotGitWorktree) {
|
|
136
|
+
continue
|
|
137
|
+
}
|
|
138
|
+
if err != nil {
|
|
139
|
+
return runner.RepoCandidate{}, false, err
|
|
140
|
+
}
|
|
141
|
+
root := normalizePath(listing.Source.RepoRoot)
|
|
142
|
+
if root == "" {
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
name := listing.Source.RepoName
|
|
146
|
+
if name == "" {
|
|
147
|
+
name = workspace.Label
|
|
148
|
+
}
|
|
149
|
+
return runner.RepoCandidate{Name: name, Path: root}, true, nil
|
|
150
|
+
}
|
|
151
|
+
return runner.RepoCandidate{}, false, nil
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
func canonicalCandidatePath(path string) string {
|
|
155
|
+
if normalized := normalizePath(path); normalized != "" {
|
|
156
|
+
return normalized
|
|
157
|
+
}
|
|
158
|
+
return path
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// EnsureRepo opens a source repository as a Herdr workspace when it is not
|
|
162
|
+
// already open. WorktreeList is the source of truth for both the repository
|
|
163
|
+
// root and the source workspace handle; it is checked before workspace create
|
|
164
|
+
// so repeated registration remains idempotent even when snapshots omit
|
|
165
|
+
// worktree metadata for plain source workspaces.
|
|
166
|
+
func (a *adapter) EnsureRepo(ctx context.Context, name, path string) error {
|
|
167
|
+
attrs := []any{"repo", name}
|
|
168
|
+
logCall("ensure-repo", attrs...)
|
|
169
|
+
root := normalizePath(path)
|
|
170
|
+
if root == "" {
|
|
171
|
+
logOutcome("ensure-repo", "error", attrs...)
|
|
172
|
+
return fmt.Errorf("herdr: repository %q has an empty path", name)
|
|
173
|
+
}
|
|
174
|
+
listing, err := a.cli.WorktreeList(ctx, root)
|
|
175
|
+
if err != nil {
|
|
176
|
+
logOutcome("ensure-repo", "error", attrs...)
|
|
177
|
+
return err
|
|
178
|
+
}
|
|
179
|
+
if reported := normalizePath(listing.Source.RepoRoot); reported != root {
|
|
180
|
+
logOutcome("ensure-repo", "error", attrs...)
|
|
181
|
+
return fmt.Errorf("herdr: repository %q path %q is not its repository root %q", name, path, reported)
|
|
182
|
+
}
|
|
183
|
+
if hasOpenSourceWorkspace(listing) {
|
|
184
|
+
logOutcome("ensure-repo", "exists", attrs...)
|
|
185
|
+
return nil
|
|
186
|
+
}
|
|
187
|
+
registrar, ok := a.cli.(herdrcli.RepositoryRegistrar)
|
|
188
|
+
if !ok {
|
|
189
|
+
logOutcome("ensure-repo", "error", attrs...)
|
|
190
|
+
return fmt.Errorf("herdr: repository workspace provisioning is unavailable")
|
|
191
|
+
}
|
|
192
|
+
if _, err := registrar.CreateWorkspace(ctx, root, name); err != nil {
|
|
193
|
+
logOutcome("ensure-repo", "error", attrs...)
|
|
194
|
+
return err
|
|
195
|
+
}
|
|
196
|
+
logOutcome("ensure-repo", "created", attrs...)
|
|
197
|
+
return nil
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
func hasOpenSourceWorkspace(listing herdrcli.WorktreeListing) bool {
|
|
201
|
+
if listing.Source.SourceWorkspaceID != "" {
|
|
202
|
+
return true
|
|
203
|
+
}
|
|
204
|
+
for _, worktree := range listing.Worktrees {
|
|
205
|
+
if !worktree.IsLinked && worktree.OpenWorkspaceID != "" {
|
|
206
|
+
return true
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
return false
|
|
210
|
+
}
|
|
211
|
+
|
|
114
212
|
// ValidateRepo verifies the registered path is the root of a Git repository
|
|
115
|
-
// Herdr can manage
|
|
213
|
+
// Herdr can manage without creating a workspace.
|
|
116
214
|
func (a *adapter) ValidateRepo(ctx context.Context, name, path string) error {
|
|
117
215
|
logCall("validate-repo", "repo", name)
|
|
118
216
|
registered := normalizePath(path)
|
|
@@ -590,3 +688,4 @@ func normalizePath(path string) string {
|
|
|
590
688
|
}
|
|
591
689
|
|
|
592
690
|
var _ runner.Runner = (*adapter)(nil)
|
|
691
|
+
var _ runner.RepoRegistrar = (*adapter)(nil)
|
|
@@ -29,8 +29,11 @@ type fakeClient struct {
|
|
|
29
29
|
openWorkspace herdrcli.Workspace
|
|
30
30
|
openErr error
|
|
31
31
|
|
|
32
|
-
createWorkspace
|
|
33
|
-
createErr
|
|
32
|
+
createWorkspace herdrcli.Workspace
|
|
33
|
+
createErr error
|
|
34
|
+
createdSourceWorkspace herdrcli.Workspace
|
|
35
|
+
createdSourceErr error
|
|
36
|
+
workspaceCreateCalls []worktreeCall
|
|
34
37
|
|
|
35
38
|
tabs []herdrcli.Tab
|
|
36
39
|
tabsErr error
|
|
@@ -95,6 +98,13 @@ func (f *fakeClient) WorktreeOpen(_ context.Context, repoPath, branch, label str
|
|
|
95
98
|
return f.openWorkspace, f.openErr
|
|
96
99
|
}
|
|
97
100
|
|
|
101
|
+
func (f *fakeClient) CreateWorkspace(_ context.Context, repoPath, label string) (herdrcli.Workspace, error) {
|
|
102
|
+
f.mu.Lock()
|
|
103
|
+
defer f.mu.Unlock()
|
|
104
|
+
f.workspaceCreateCalls = append(f.workspaceCreateCalls, worktreeCall{RepoPath: repoPath, Label: label})
|
|
105
|
+
return f.createdSourceWorkspace, f.createdSourceErr
|
|
106
|
+
}
|
|
107
|
+
|
|
98
108
|
func (f *fakeClient) CreateTab(_ context.Context, workspaceID, cwd, label string) (herdrcli.Tab, herdrcli.Pane, error) {
|
|
99
109
|
f.mu.Lock()
|
|
100
110
|
defer f.mu.Unlock()
|
|
@@ -240,6 +250,26 @@ func TestDiscoverReposDeduplicatesRepositoryRoots(t *testing.T) {
|
|
|
240
250
|
}
|
|
241
251
|
}
|
|
242
252
|
|
|
253
|
+
func TestDiscoverReposResolvesPlainSourceWorkspaceFromPaneCWD(t *testing.T) {
|
|
254
|
+
cli := &fakeClient{
|
|
255
|
+
snapshot: herdrcli.Snapshot{
|
|
256
|
+
Workspaces: []herdrcli.Workspace{{ID: "w-source", Label: "payments"}},
|
|
257
|
+
Panes: []herdrcli.Pane{{ID: "w-source:p1", WorkspaceID: "w-source", CWD: repoPath}},
|
|
258
|
+
},
|
|
259
|
+
listing: herdrcli.WorktreeListing{
|
|
260
|
+
Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
|
|
261
|
+
},
|
|
262
|
+
}
|
|
263
|
+
got, err := newAdapter(cli).DiscoverRepos(context.Background())
|
|
264
|
+
if err != nil {
|
|
265
|
+
t.Fatal(err)
|
|
266
|
+
}
|
|
267
|
+
want := []runner.RepoCandidate{{Name: "payments", Path: repoPath}}
|
|
268
|
+
if len(got) != 1 || got[0] != want[0] {
|
|
269
|
+
t.Fatalf("DiscoverRepos = %+v, want %+v", got, want)
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
243
273
|
func TestValidateRepoAcceptsRepositoryRootAndRejectsInnerPaths(t *testing.T) {
|
|
244
274
|
cli := &fakeClient{listing: herdrcli.WorktreeListing{
|
|
245
275
|
Source: herdrcli.WorktreeSource{RepoName: "payments", RepoRoot: repoPath, SourceCheckoutPath: repoPath},
|
|
@@ -278,6 +308,46 @@ func TestValidateRepoCreatesNothing(t *testing.T) {
|
|
|
278
308
|
}
|
|
279
309
|
}
|
|
280
310
|
|
|
311
|
+
func TestEnsureRepoReusesAnOpenSourceWorkspace(t *testing.T) {
|
|
312
|
+
cli := &fakeClient{
|
|
313
|
+
listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath, SourceWorkspaceID: "w-source"}},
|
|
314
|
+
snapshot: herdrcli.Snapshot{Workspaces: []herdrcli.Workspace{{ID: "w-source"}}},
|
|
315
|
+
}
|
|
316
|
+
a := newAdapter(cli)
|
|
317
|
+
if err := a.EnsureRepo(context.Background(), "payments", repoPath); err != nil {
|
|
318
|
+
t.Fatal(err)
|
|
319
|
+
}
|
|
320
|
+
if err := a.EnsureRepo(context.Background(), "payments", repoPath); err != nil {
|
|
321
|
+
t.Fatal(err)
|
|
322
|
+
}
|
|
323
|
+
if len(cli.workspaceCreateCalls) != 0 {
|
|
324
|
+
t.Fatalf("CreateWorkspace calls = %+v, want none", cli.workspaceCreateCalls)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
func TestEnsureRepoOpensMissingSourceWorkspace(t *testing.T) {
|
|
329
|
+
cli := &fakeClient{
|
|
330
|
+
listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath}},
|
|
331
|
+
createdSourceWorkspace: herdrcli.Workspace{ID: "source-workspace"},
|
|
332
|
+
}
|
|
333
|
+
if err := newAdapter(cli).EnsureRepo(context.Background(), "payments", repoPath); err != nil {
|
|
334
|
+
t.Fatal(err)
|
|
335
|
+
}
|
|
336
|
+
if len(cli.workspaceCreateCalls) != 1 || cli.workspaceCreateCalls[0] != (worktreeCall{RepoPath: repoPath, Label: "payments"}) {
|
|
337
|
+
t.Fatalf("CreateWorkspace calls = %+v", cli.workspaceCreateCalls)
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
func TestEnsureRepoPropagatesSourceWorkspaceFailure(t *testing.T) {
|
|
342
|
+
cli := &fakeClient{
|
|
343
|
+
listing: herdrcli.WorktreeListing{Source: herdrcli.WorktreeSource{RepoRoot: repoPath}},
|
|
344
|
+
createdSourceErr: errors.New("herdr unavailable"),
|
|
345
|
+
}
|
|
346
|
+
if err := newAdapter(cli).EnsureRepo(context.Background(), "payments", repoPath); err == nil || !strings.Contains(err.Error(), "herdr unavailable") {
|
|
347
|
+
t.Fatalf("EnsureRepo error = %v, want workspace creation failure", err)
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
281
351
|
// --- Environment ---
|
|
282
352
|
|
|
283
353
|
func TestEnsureEnvironmentReusesExistingTicketWorktree(t *testing.T) {
|
|
@@ -35,6 +35,13 @@ type Client interface {
|
|
|
35
35
|
CloseWorkspace(ctx context.Context, workspaceID string) error
|
|
36
36
|
}
|
|
37
37
|
|
|
38
|
+
// RepositoryRegistrar is the optional repository-workspace provisioning seam
|
|
39
|
+
// used by the Herdr runner adapter. It remains separate from Client so
|
|
40
|
+
// existing runtime-only test clients do not need to implement registration.
|
|
41
|
+
type RepositoryRegistrar interface {
|
|
42
|
+
CreateWorkspace(ctx context.Context, cwd, label string) (Workspace, error)
|
|
43
|
+
}
|
|
44
|
+
|
|
38
45
|
// Options selects the Herdr session or explicit socket used by the CLI.
|
|
39
46
|
type Options struct {
|
|
40
47
|
Session string
|
|
@@ -51,9 +58,10 @@ func New(options Options) *CLI {
|
|
|
51
58
|
return &CLI{options: options}
|
|
52
59
|
}
|
|
53
60
|
|
|
54
|
-
// WorkspaceWorktree is the Git identity Herdr
|
|
55
|
-
//
|
|
56
|
-
//
|
|
61
|
+
// WorkspaceWorktree is the Git identity Herdr may report for a workspace.
|
|
62
|
+
// Linked ticket workspaces have IsLinked true and the source RepoRoot. Plain
|
|
63
|
+
// source workspaces can omit this object; callers resolve those through their
|
|
64
|
+
// pane CWD and worktree list.
|
|
57
65
|
type WorkspaceWorktree struct {
|
|
58
66
|
CheckoutPath string
|
|
59
67
|
RepoName string
|
|
@@ -82,6 +90,7 @@ type WorktreeSource struct {
|
|
|
82
90
|
RepoName string
|
|
83
91
|
RepoRoot string
|
|
84
92
|
SourceCheckoutPath string
|
|
93
|
+
SourceWorkspaceID string
|
|
85
94
|
}
|
|
86
95
|
|
|
87
96
|
// WorktreeListing is the response of worktree list for one repository.
|
|
@@ -24,6 +24,9 @@ func TestCLIUsesExactProductionCommandShapes(t *testing.T) {
|
|
|
24
24
|
if _, err := cli.WorktreeOpen(ctx, "/work/payments", "PAY-101", "PAY-101"); err != nil {
|
|
25
25
|
t.Fatalf("WorktreeOpen: %v", err)
|
|
26
26
|
}
|
|
27
|
+
if _, err := cli.CreateWorkspace(ctx, "/work/payments", "payments"); err != nil {
|
|
28
|
+
t.Fatalf("CreateWorkspace: %v", err)
|
|
29
|
+
}
|
|
27
30
|
if _, err := cli.WorktreeCreate(ctx, "/work/payments", "PAY-101", "origin/main", "PAY-101"); err != nil {
|
|
28
31
|
t.Fatalf("WorktreeCreate: %v", err)
|
|
29
32
|
}
|
|
@@ -85,11 +88,19 @@ func TestCLIDecodesCapturedResponseLocations(t *testing.T) {
|
|
|
85
88
|
t.Fatalf("Snapshot panes = %+v", snapshot.Panes)
|
|
86
89
|
}
|
|
87
90
|
|
|
91
|
+
sourceWorkspace, err := cli.CreateWorkspace(ctx, "/work/payments", "payments")
|
|
92
|
+
if err != nil {
|
|
93
|
+
t.Fatalf("CreateWorkspace: %v", err)
|
|
94
|
+
}
|
|
95
|
+
if sourceWorkspace.ID != "w1" || sourceWorkspace.Label != "payments" || sourceWorkspace.Worktree.RepoRoot != "" {
|
|
96
|
+
t.Fatalf("CreateWorkspace = %+v, want the captured plain source workspace shape", sourceWorkspace)
|
|
97
|
+
}
|
|
98
|
+
|
|
88
99
|
listing, err := cli.WorktreeList(ctx, "/work/payments")
|
|
89
100
|
if err != nil {
|
|
90
101
|
t.Fatalf("WorktreeList: %v", err)
|
|
91
102
|
}
|
|
92
|
-
if listing.Source.RepoRoot != "/work/payments" || listing.Source.RepoName != "repo" {
|
|
103
|
+
if listing.Source.RepoRoot != "/work/payments" || listing.Source.RepoName != "repo" || listing.Source.SourceWorkspaceID != "w1" {
|
|
93
104
|
t.Fatalf("WorktreeList source = %+v", listing.Source)
|
|
94
105
|
}
|
|
95
106
|
if len(listing.Worktrees) != 2 {
|
|
@@ -231,8 +242,12 @@ func TestCLIRejectsRelativeCWD(t *testing.T) {
|
|
|
231
242
|
|
|
232
243
|
func TestStrictFakeHerdrRejectsUnsupportedProductionShapes(t *testing.T) {
|
|
233
244
|
fake := installStrictFakeHerdr(t)
|
|
245
|
+
// This test invokes the fake executable directly rather than through CLI,
|
|
246
|
+
// so provide the same selector environment the production wrapper sets.
|
|
247
|
+
t.Setenv("HERDR_SESSION", "relay-flow")
|
|
248
|
+
t.Setenv("HERDR_SOCKET_PATH", "/tmp/relay-flow-herdr.sock")
|
|
234
249
|
unsupported := [][]string{
|
|
235
|
-
{"workspace", "
|
|
250
|
+
{"workspace", "get", "w2"},
|
|
236
251
|
{"worktree", "remove", "--workspace", "w2"},
|
|
237
252
|
{"terminal", "create", "--pane", "w2:p2"},
|
|
238
253
|
{"pane", "get", "--pane", "w2:p2"},
|
|
@@ -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) {
|