relay-flow 0.2.8-alpha → 0.2.9-alpha

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/README.md +62 -35
  2. package/cmd/relay-flow/commands_test.go +117 -8
  3. package/cmd/relay-flow/main.go +167 -60
  4. package/cmd/relay-flow/onboarding.go +344 -0
  5. package/cmd/relay-flow/onboarding_test.go +515 -0
  6. package/cmd/relay-flow/repo_registration.go +219 -0
  7. package/cmd/relay-flow/serve.go +3 -0
  8. package/go.mod +12 -8
  9. package/go.sum +24 -17
  10. package/internal/execution/goworkflows/engine_test.go +3 -3
  11. package/internal/harness/opencode/opencode_test.go +4 -4
  12. package/internal/harness/opencode/repo_setup.go +1 -1
  13. package/internal/harness/pi/prompt_test.go +3 -3
  14. package/internal/repo/service.go +34 -3
  15. package/internal/repo/service_test.go +22 -0
  16. package/internal/runner/herdr/herdr.go +103 -4
  17. package/internal/runner/herdr/herdr_test.go +72 -2
  18. package/internal/runner/herdr/herdrcli/contract.go +12 -3
  19. package/internal/runner/herdr/herdrcli/herdrcli_test.go +17 -2
  20. package/internal/runner/herdr/herdrcli/operations.go +16 -0
  21. package/internal/runner/herdr/herdrcli/testdata/strict-herdr.sh +8 -0
  22. package/internal/runner/herdr/herdrcli/testdata/workspace-create.json +1 -0
  23. package/internal/runner/orca/orca.go +68 -9
  24. package/internal/runner/orca/orca_test.go +134 -2
  25. package/internal/runner/orca/orcacli/orcacli.go +21 -1
  26. package/internal/runner/orca/orcacli/orcacli_test.go +22 -0
  27. package/internal/runner/orca/orcacli/testdata/strict-orca.sh +5 -0
  28. package/internal/runner/runner.go +9 -0
  29. package/internal/server/api_test.go +19 -0
  30. package/internal/server/client.go +10 -0
  31. package/internal/server/fixture_test.go +7 -0
  32. package/internal/server/server.go +41 -0
  33. package/internal/task/jira/testdata/jira_search_issues.json +4 -4
  34. package/internal/workflow/workflow.go +4 -1
  35. package/internal/workflow/workflow_test.go +4 -4
  36. package/package.json +1 -1
@@ -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
- root := normalizePath(workspace.Worktree.RepoRoot)
95
- if root == "" {
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[root] = runner.RepoCandidate{Name: workspace.Worktree.RepoName, Path: root}
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. Registration needs no Herdr workspace and creates none.
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 herdrcli.Workspace
33
- createErr error
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 reports for a workspace. The
55
- // source checkout of a repository has IsLinked false; every ticket worktree
56
- // workspace has IsLinked true and the same RepoRoot.
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", "create", "--cwd", "/work/payments", "--label", "payments", "--no-focus"},
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 path is
77
- // the stable identity (the Orca repo ID is an internal detail that can
78
- // change across machines); name is matched as a secondary check.
113
+ // repoID resolves a registered repo to its Orca repo ID. The canonical repo
114
+ // path is the stable identity (the Orca repo ID is an internal detail that
115
+ // can change across machines); relay-flow's name is independent metadata.
79
116
  func (a *adapter) repoID(ctx context.Context, name, path string) (string, error) {
117
+ canonical := normalizePath(path)
118
+ if canonical == "" {
119
+ return "", fmt.Errorf("orca: repo %q has an empty path", name)
120
+ }
80
121
  repos, err := a.cli.ListRepos(ctx)
81
122
  if err != nil {
82
123
  return "", err
83
124
  }
84
125
  for _, r := range repos {
85
- if r.Path == path && r.DisplayName == name {
126
+ if normalizePath(r.Path) == canonical {
86
127
  return r.ID, nil
87
128
  }
88
129
  }
89
130
  return "", fmt.Errorf("orca: repo %q at %q not registered", name, path)
90
131
  }
91
132
 
133
+ func normalizePath(path string) string {
134
+ if strings.TrimSpace(path) == "" {
135
+ return ""
136
+ }
137
+ absolute, err := filepath.Abs(path)
138
+ if err != nil {
139
+ return filepath.Clean(path)
140
+ }
141
+ absolute = filepath.Clean(absolute)
142
+ if resolved, err := filepath.EvalSymlinks(absolute); err == nil {
143
+ return filepath.Clean(resolved)
144
+ }
145
+ return absolute
146
+ }
147
+
92
148
  // --- Environment ---
93
149
 
94
150
  // EnsureEnvironment returns the ticket-scoped worktree, creating it from the
@@ -198,6 +254,10 @@ func primaryBranch(w *orcacli.Worktree) string {
198
254
  func (a *adapter) CloseTerminal(ctx context.Context, terminal runner.Terminal) error {
199
255
  slog.Debug("orca call", "op", "close-terminal", "title", terminal.Title, "handle", terminal.ID)
200
256
  err := a.cli.CloseTerminal(ctx, terminal.ID)
257
+ if errors.Is(err, orcacli.ErrTerminalUnavailable) {
258
+ slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "absent")
259
+ return nil
260
+ }
201
261
  if err != nil {
202
262
  slog.Info("orca outcome", "op", "close-terminal", "title", terminal.Title, "result", "error", "error", sanitizeErr(err))
203
263
  } else {
@@ -386,14 +446,10 @@ func (a *adapter) CloseTerminals(ctx context.Context, spec runner.RunSpec) error
386
446
  if !strings.HasPrefix(t.Title, prefix) {
387
447
  continue
388
448
  }
389
- // 9.5: one outcome line per actual terminal close, with its title.
390
- slog.Debug("orca call", "op", "close-terminal", "title", t.Title, "handle", t.Handle)
391
- if err := a.cli.CloseTerminal(ctx, t.Handle); err != nil {
392
- slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "error", "error", sanitizeErr(err))
449
+ if err := a.CloseTerminal(ctx, runner.Terminal{ID: t.Handle, Title: t.Title}); err != nil {
393
450
  slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "error", "error", sanitizeErr(err))
394
451
  return fmt.Errorf("close terminal %q: %w", t.Title, err)
395
452
  }
396
- slog.Info("orca outcome", "op", "close-terminal", "title", t.Title, "result", "ok")
397
453
  closed++
398
454
  }
399
455
  slog.Info("orca outcome", "op", "close-terminals", "ticket", spec.TicketKey, "result", "ok", "closed", closed)
@@ -443,3 +499,6 @@ func (a *adapter) findEnvironment(ctx context.Context, spec runner.RunSpec) (run
443
499
  }
444
500
  return runner.Environment{}, false, nil
445
501
  }
502
+
503
+ var _ runner.Runner = (*adapter)(nil)
504
+ var _ runner.RepoRegistrar = (*adapter)(nil)
@@ -50,6 +50,9 @@ type fakeCLI struct {
50
50
  showHandles []string
51
51
  createCommands []string
52
52
  createN int
53
+ closedHandles []string
54
+ closeErrors map[string]error
55
+ deletedWorktrees []string
53
56
  }
54
57
 
55
58
  func (f *fakeCLI) ListRepos(context.Context) ([]orcacli.Repo, error) { return f.repos, nil }
@@ -72,7 +75,10 @@ func (f *fakeCLI) SetWorktreeStatus(_ context.Context, _, status string) error {
72
75
  f.status = status
73
76
  return nil
74
77
  }
75
- func (f *fakeCLI) DeleteWorktree(context.Context, string) error { return nil }
78
+ func (f *fakeCLI) DeleteWorktree(_ context.Context, worktreeID string) error {
79
+ f.deletedWorktrees = append(f.deletedWorktrees, worktreeID)
80
+ return nil
81
+ }
76
82
  func (f *fakeCLI) ShowTerminal(_ context.Context, handle string) (orcacli.Terminal, error) {
77
83
  f.showHandles = append(f.showHandles, handle)
78
84
  t, ok := f.terminals[handle]
@@ -95,7 +101,59 @@ func (f *fakeCLI) CreateTerminal(_ context.Context, _ string, title, command str
95
101
  f.terminals[handle] = orcacli.Terminal{Handle: handle, Title: title, Connected: true}
96
102
  return handle, nil
97
103
  }
98
- func (f *fakeCLI) CloseTerminal(context.Context, string) error { return nil }
104
+ func (f *fakeCLI) CloseTerminal(_ context.Context, handle string) error {
105
+ f.closedHandles = append(f.closedHandles, handle)
106
+ if err, ok := f.closeErrors[handle]; ok {
107
+ return err
108
+ }
109
+ return nil
110
+ }
111
+
112
+ type repoAddingCLI struct {
113
+ *fakeCLI
114
+ addPaths []string
115
+ addErr error
116
+ }
117
+
118
+ func (f *repoAddingCLI) AddRepo(_ context.Context, path string) error {
119
+ f.addPaths = append(f.addPaths, path)
120
+ return f.addErr
121
+ }
122
+
123
+ func TestEnsureRepoSkipsAnExistingCanonicalPath(t *testing.T) {
124
+ path := t.TempDir()
125
+ fx := &repoAddingCLI{fakeCLI: &fakeCLI{repos: []orcacli.Repo{{ID: "r1", DisplayName: "runner-name", Path: path}}}}
126
+ a, err := New(fx, config.RawValues{})
127
+ if err != nil {
128
+ t.Fatal(err)
129
+ }
130
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "relay-name", path+"/."); err != nil {
131
+ t.Fatal(err)
132
+ }
133
+ if len(fx.addPaths) != 0 {
134
+ t.Fatalf("AddRepo calls = %v, want none for an existing path", fx.addPaths)
135
+ }
136
+ }
137
+
138
+ func TestEnsureRepoAddsMissingPathAndPropagatesFailures(t *testing.T) {
139
+ path := t.TempDir()
140
+ fx := &repoAddingCLI{fakeCLI: &fakeCLI{}}
141
+ a, err := New(fx, config.RawValues{})
142
+ if err != nil {
143
+ t.Fatal(err)
144
+ }
145
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "payments", path); err != nil {
146
+ t.Fatal(err)
147
+ }
148
+ if len(fx.addPaths) != 1 || fx.addPaths[0] != path {
149
+ t.Fatalf("AddRepo calls = %v, want [%q]", fx.addPaths, path)
150
+ }
151
+
152
+ fx.addErr = errors.New("orca unavailable")
153
+ if err := a.(runner.RepoRegistrar).EnsureRepo(context.Background(), "other", t.TempDir()); err == nil || !strings.Contains(err.Error(), "orca unavailable") {
154
+ t.Fatalf("EnsureRepo error = %v, want AddRepo failure", err)
155
+ }
156
+ }
99
157
 
100
158
  // 9.16: when the repo's primary worktree is on master (refs/heads/master),
101
159
  // EnsureEnvironment must pass --base-branch master (via CreateWorktree), not
@@ -290,6 +348,80 @@ func TestCreateTerminalAlwaysCreatesAndTreatsCommandAsOpaque(t *testing.T) {
290
348
  }
291
349
  }
292
350
 
351
+ func TestCloseTerminalTreatsUnavailableAsAbsent(t *testing.T) {
352
+ fx := &fakeCLI{closeErrors: map[string]error{
353
+ "term-stale": orcacli.ErrTerminalUnavailable,
354
+ }}
355
+ a, err := New(fx, config.RawValues{})
356
+ if err != nil {
357
+ t.Fatal(err)
358
+ }
359
+ if err := a.CloseTerminal(context.Background(), runner.Terminal{ID: "term-stale", Title: "PAY-1:implement"}); err != nil {
360
+ t.Fatalf("CloseTerminal(stale) = %v, want nil", err)
361
+ }
362
+ if len(fx.closedHandles) != 1 || fx.closedHandles[0] != "term-stale" {
363
+ t.Fatalf("closed handles = %v, want [term-stale]", fx.closedHandles)
364
+ }
365
+ }
366
+
367
+ func TestCloseTerminalPropagatesGenuineFailure(t *testing.T) {
368
+ wantErr := errors.New("orca permission denied")
369
+ fx := &fakeCLI{closeErrors: map[string]error{"term-failed": wantErr}}
370
+ a, err := New(fx, config.RawValues{})
371
+ if err != nil {
372
+ t.Fatal(err)
373
+ }
374
+ if err := a.CloseTerminal(context.Background(), runner.Terminal{ID: "term-failed", Title: "PAY-1:implement"}); !errors.Is(err, wantErr) {
375
+ t.Fatalf("CloseTerminal(genuine failure) = %v, want %v", err, wantErr)
376
+ }
377
+ }
378
+
379
+ func TestCloseTerminalsContinuesAfterStaleHandle(t *testing.T) {
380
+ fx := &fakeCLI{
381
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
382
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
383
+ listedTerminals: []orcacli.Terminal{
384
+ {Handle: "term-stale", Title: "PAY-1:stale", Connected: true},
385
+ {Handle: "term-live", Title: "PAY-1:implement", Connected: true},
386
+ {Handle: "term-user", Title: "shell", Connected: true},
387
+ },
388
+ closeErrors: map[string]error{"term-stale": orcacli.ErrTerminalUnavailable},
389
+ }
390
+ a, err := New(fx, config.RawValues{})
391
+ if err != nil {
392
+ t.Fatal(err)
393
+ }
394
+ if err := a.CloseTerminals(context.Background(), runner.RunSpec{
395
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
396
+ }); err != nil {
397
+ t.Fatalf("CloseTerminals = %v, want nil after stale handle", err)
398
+ }
399
+ if len(fx.closedHandles) != 2 || fx.closedHandles[0] != "term-stale" || fx.closedHandles[1] != "term-live" {
400
+ t.Fatalf("closed handles = %v, want stale and live run terminals", fx.closedHandles)
401
+ }
402
+ }
403
+
404
+ func TestCleanupRunDeletesWorktreeAfterStaleHandle(t *testing.T) {
405
+ fx := &fakeCLI{
406
+ repos: []orcacli.Repo{{ID: "r1", DisplayName: "app", Path: "/srv/app"}},
407
+ worktrees: []orcacli.Worktree{{ID: "wt-PAY-1", RepoID: "r1", DisplayName: "PAY-1"}},
408
+ listedTerminals: []orcacli.Terminal{{Handle: "term-stale", Title: "PAY-1:implement", Connected: true}},
409
+ closeErrors: map[string]error{"term-stale": orcacli.ErrTerminalUnavailable},
410
+ }
411
+ a, err := New(fx, config.RawValues{})
412
+ if err != nil {
413
+ t.Fatal(err)
414
+ }
415
+ if err := a.CleanupRun(context.Background(), runner.RunSpec{
416
+ RepoName: "app", RepoPath: "/srv/app", TicketKey: "PAY-1",
417
+ }); err != nil {
418
+ t.Fatalf("CleanupRun = %v, want nil after stale handle", err)
419
+ }
420
+ if len(fx.deletedWorktrees) != 1 || fx.deletedWorktrees[0] != "wt-PAY-1" {
421
+ t.Fatalf("deleted worktrees = %v, want [wt-PAY-1]", fx.deletedWorktrees)
422
+ }
423
+ }
424
+
293
425
  // An existing ticket branch must win even over a configured baseRef. Passing
294
426
  // any other base would make Orca hit its branch-name collision behavior.
295
427
  func TestEnsureEnvironment_ExistingTicketBranchAvoidsCollision(t *testing.T) {