relay-flow 0.3.13-alpha → 0.3.14-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 (50) hide show
  1. package/README.md +39 -5
  2. package/cmd/relay-flow/commands_test.go +9 -0
  3. package/cmd/relay-flow/main.go +16 -7
  4. package/cmd/relay-flow/repo_registration.go +68 -0
  5. package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
  6. package/cmd/relay-flow/scenario_test.go +25 -0
  7. package/cmd/relay-flow/serve.go +33 -2
  8. package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
  9. package/cmd/relay-flow/startup_errors.go +85 -0
  10. package/cmd/relay-flow/startup_errors_test.go +88 -0
  11. package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
  12. package/cmd/relay-flow/startup_ordering_test.go +75 -0
  13. package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
  14. package/cmd/relay-flow/startup_readiness_test.go +116 -0
  15. package/cmd/relay-flow/startup_submission_test.go +123 -0
  16. package/internal/harness/opencode/opencode.go +14 -0
  17. package/internal/harness/opencode/opencode_test.go +1 -1
  18. package/internal/harness/opencode/repo_setup.go +1 -1
  19. package/internal/harness/pi/pi.go +14 -0
  20. package/internal/harness/startup.go +25 -0
  21. package/internal/harness/startup_test.go +85 -0
  22. package/internal/runner/herdr/herdr.go +9 -0
  23. package/internal/runner/herdr/herdrcli/exec.go +14 -0
  24. package/internal/runner/herdr/herdrcli/operations.go +8 -2
  25. package/internal/runner/herdr/herdrcli/response.go +6 -0
  26. package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
  27. package/internal/runner/herdr/startup_test.go +58 -0
  28. package/internal/runner/orca/orca.go +9 -0
  29. package/internal/runner/orca/orcacli/orcacli.go +99 -34
  30. package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
  31. package/internal/runner/orca/orcacli/startup_test.go +158 -0
  32. package/internal/runner/orca/startup_test.go +57 -0
  33. package/internal/runner/startup.go +27 -0
  34. package/internal/runner/startup_test.go +59 -0
  35. package/internal/task/beads/bdcli/bdcli.go +12 -0
  36. package/internal/task/beads/bdcli/startup.go +20 -0
  37. package/internal/task/beads/bdcli/startup_test.go +27 -0
  38. package/internal/task/beads/beads.go +1 -0
  39. package/internal/task/beads/startup.go +75 -0
  40. package/internal/task/beads/startup_test.go +155 -0
  41. package/internal/task/factory.go +6 -2
  42. package/internal/task/jira/jira.go +4 -3
  43. package/internal/task/jira/rest/client.go +40 -6
  44. package/internal/task/jira/rest/startup_redirect_test.go +23 -0
  45. package/internal/task/jira/rest/startup_test.go +133 -0
  46. package/internal/task/jira/startup.go +22 -0
  47. package/internal/task/jira/startup_test.go +78 -0
  48. package/internal/task/startup.go +25 -0
  49. package/internal/task/startup_test.go +51 -0
  50. package/package.json +1 -1
@@ -0,0 +1,85 @@
1
+ package harness_test
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+
12
+ "github.com/rajpopat27/relay-flow/internal/harness"
13
+ _ "github.com/rajpopat27/relay-flow/internal/harness/opencode"
14
+ _ "github.com/rajpopat27/relay-flow/internal/harness/pi"
15
+ )
16
+
17
+ func TestSelectedHarnessStartupChecksOnlyLocalExecutable(t *testing.T) {
18
+ for _, name := range []string{"pi", "opencode"} {
19
+ t.Run(name, func(t *testing.T) {
20
+ bin := t.TempDir()
21
+ marker := filepath.Join(t.TempDir(), "invoked")
22
+ t.Setenv("HARNESS_INVOKED", marker)
23
+ if err := os.WriteFile(filepath.Join(bin, name), []byte("#!/bin/sh\nprintf '%s' invoked > \"$HARNESS_INVOKED\"\nexit 99\n"), 0o755); err != nil {
24
+ t.Fatal(err)
25
+ }
26
+ t.Setenv("PATH", bin)
27
+ h, err := harness.New(name, nil)
28
+ if err != nil {
29
+ t.Fatal(err)
30
+ }
31
+ if err := harness.ProbeStartup(context.Background(), h); err != nil {
32
+ t.Fatal(err)
33
+ }
34
+ if _, err := os.Stat(marker); !os.IsNotExist(err) {
35
+ t.Fatalf("startup invoked %s instead of resolving it: %v", name, err)
36
+ }
37
+ // No fallback to the other installed harness executable.
38
+ other := "pi"
39
+ if name == "pi" {
40
+ other = "opencode"
41
+ }
42
+ missing, err := harness.New(other, nil)
43
+ if err != nil {
44
+ t.Fatal(err)
45
+ }
46
+ if err := harness.ProbeStartup(context.Background(), missing); err == nil || !strings.Contains(err.Error(), other) {
47
+ t.Fatalf("missing selected harness = %v", err)
48
+ }
49
+ ctx, cancel := context.WithCancel(context.Background())
50
+ cancel()
51
+ if err := harness.ProbeStartup(ctx, h); !errors.Is(err, context.Canceled) {
52
+ t.Fatalf("cancellation = %v", err)
53
+ }
54
+ })
55
+ }
56
+ }
57
+
58
+ type startupHarness struct {
59
+ *fakeHarness
60
+ probe func(context.Context) error
61
+ }
62
+
63
+ func (h *startupHarness) ProbeStartup(ctx context.Context) error { return h.probe(ctx) }
64
+
65
+ func TestHarnessStartupDispatchRequiresSupportAndPreservesBudget(t *testing.T) {
66
+ if err := harness.ProbeStartup(context.Background(), newFakeHarness()); err == nil {
67
+ t.Fatal("silently skipped selected harness without startup support")
68
+ }
69
+ calls := 0
70
+ failure := errors.New("executable unavailable")
71
+ ctx, cancel := context.WithTimeout(context.Background(), time.Second)
72
+ defer cancel()
73
+ deadline, _ := ctx.Deadline()
74
+ h := &startupHarness{fakeHarness: newFakeHarness(), probe: func(ctx context.Context) error {
75
+ calls++
76
+ got, _ := ctx.Deadline()
77
+ if !got.Equal(deadline) {
78
+ t.Fatalf("startup extended caller deadline: %v", got)
79
+ }
80
+ return failure
81
+ }}
82
+ if err := harness.ProbeStartup(ctx, h); !errors.Is(err, failure) || calls != 1 {
83
+ t.Fatalf("startup = %v, calls=%d", err, calls)
84
+ }
85
+ }
@@ -76,6 +76,15 @@ func logOutcome(operation, result string, attrs ...any) {
76
76
  slog.Info("herdr outcome", args...)
77
77
  }
78
78
 
79
+ var _ runner.StartupProber = (*adapter)(nil)
80
+
81
+ // ProbeStartup checks the selected runtime with one snapshot, not repository
82
+ // discovery (which can issue additional per-workspace Git requests).
83
+ func (a *adapter) ProbeStartup(ctx context.Context) error {
84
+ _, err := a.cli.Snapshot(ctx)
85
+ return err
86
+ }
87
+
79
88
  // --- Repos ---
80
89
 
81
90
  // DiscoverRepos returns the repositories Herdr currently has open, derived
@@ -3,11 +3,13 @@ package herdrcli
3
3
  import (
4
4
  "bytes"
5
5
  "context"
6
+ "errors"
6
7
  "fmt"
7
8
  "os"
8
9
  "os/exec"
9
10
  "path/filepath"
10
11
  "strings"
12
+ "time"
11
13
  )
12
14
 
13
15
  // execute invokes the installed Herdr executable and keeps its two output
@@ -19,6 +21,8 @@ func (c *CLI) execute(ctx context.Context, args ...string) (stdout, stderr []byt
19
21
  }
20
22
 
21
23
  cmd := exec.CommandContext(ctx, "herdr", args...)
24
+ // Do not let a descendant retaining stdout/stderr defeat cancellation.
25
+ cmd.WaitDelay = 100 * time.Millisecond
22
26
  cmd.Env = c.environment(os.Environ())
23
27
 
24
28
  var out bytes.Buffer
@@ -26,6 +30,16 @@ func (c *CLI) execute(ctx context.Context, args ...string) (stdout, stderr []byt
26
30
  cmd.Stdout = &out
27
31
  cmd.Stderr = &errOut
28
32
  err = cmd.Run()
33
+ if ctx.Err() != nil {
34
+ err = fmt.Errorf("herdr executable %q: timeout/cancellation: %w", cmd.Path, ctx.Err())
35
+ } else if errors.Is(err, exec.ErrNotFound) {
36
+ err = fmt.Errorf("missing executable %q: %w", cmd.Path, err)
37
+ } else if err != nil {
38
+ var exitErr *exec.ExitError
39
+ if !errors.As(err, &exitErr) {
40
+ err = fmt.Errorf("herdr executable %q: launch failure: %w", cmd.Path, err)
41
+ }
42
+ }
29
43
  return out.Bytes(), errOut.Bytes(), err
30
44
  }
31
45
 
@@ -1,6 +1,9 @@
1
1
  package herdrcli
2
2
 
3
- import "context"
3
+ import (
4
+ "context"
5
+ "fmt"
6
+ )
4
7
 
5
8
  type workspaceResponse struct {
6
9
  ID string `json:"workspace_id"`
@@ -57,7 +60,7 @@ type foregroundProcessResponse struct {
57
60
 
58
61
  func (c *CLI) Snapshot(ctx context.Context) (Snapshot, error) {
59
62
  var response struct {
60
- Snapshot struct {
63
+ Snapshot *struct {
61
64
  Workspaces []workspaceResponse `json:"workspaces"`
62
65
  Tabs []tabResponse `json:"tabs"`
63
66
  Panes []paneResponse `json:"panes"`
@@ -66,6 +69,9 @@ func (c *CLI) Snapshot(ctx context.Context) (Snapshot, error) {
66
69
  if err := c.runJSON(ctx, "api snapshot", &response, "api", "snapshot"); err != nil {
67
70
  return Snapshot{}, err
68
71
  }
72
+ if response.Snapshot == nil || response.Snapshot.Workspaces == nil || response.Snapshot.Tabs == nil || response.Snapshot.Panes == nil {
73
+ return Snapshot{}, fmt.Errorf("herdr api snapshot: malformed response: missing snapshot arrays")
74
+ }
69
75
  return Snapshot{
70
76
  Workspaces: convertWorkspaces(response.Snapshot.Workspaces),
71
77
  Tabs: convertTabs(response.Snapshot.Tabs),
@@ -46,6 +46,9 @@ func sentinelFor(code string) error {
46
46
  // the operation name and Herdr's own message.
47
47
  func (c *CLI) runJSON(ctx context.Context, operation string, dest any, args ...string) error {
48
48
  stdout, stderr, err := c.execute(ctx, args...)
49
+ if ctx.Err() != nil {
50
+ return fmt.Errorf("herdr %s: timeout/cancellation: %w", operation, ctx.Err())
51
+ }
49
52
  if apiErr := decodeError(operation, stderr); apiErr != nil {
50
53
  return apiErr
51
54
  }
@@ -59,6 +62,9 @@ func (c *CLI) runJSON(ctx context.Context, operation string, dest any, args ...s
59
62
  // result body or no body at all; only the error envelope matters.
60
63
  func (c *CLI) runCommand(ctx context.Context, operation string, args ...string) error {
61
64
  _, stderr, err := c.execute(ctx, args...)
65
+ if ctx.Err() != nil {
66
+ return fmt.Errorf("herdr %s: timeout/cancellation: %w", operation, ctx.Err())
67
+ }
62
68
  if apiErr := decodeError(operation, stderr); apiErr != nil {
63
69
  return apiErr
64
70
  }
@@ -0,0 +1,101 @@
1
+ package herdrcli
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "os"
7
+ "path/filepath"
8
+ "strings"
9
+ "testing"
10
+ "time"
11
+ )
12
+
13
+ func installSnapshotLauncher(t *testing.T, script string) {
14
+ t.Helper()
15
+ bin := t.TempDir()
16
+ if err := os.WriteFile(filepath.Join(bin, "herdr"), []byte("#!/bin/sh\n"+script), 0o755); err != nil {
17
+ t.Fatal(err)
18
+ }
19
+ t.Setenv("PATH", bin)
20
+ }
21
+
22
+ func TestSnapshotAcceptsEmptyRuntimeAndRejectsMalformedResponses(t *testing.T) {
23
+ for _, tc := range []struct {
24
+ response string
25
+ valid bool
26
+ }{
27
+ {`{"result":{"snapshot":{"workspaces":[],"tabs":[],"panes":[]}}}`, true},
28
+ {`not-json`, false},
29
+ {`{}`, false},
30
+ {`{"result":null}`, false},
31
+ {`{"result":{}}`, false},
32
+ {`{"result":{"snapshot":null}}`, false},
33
+ {`{"result":{"snapshot":{}}}`, false},
34
+ {`{"result":{"snapshot":{"workspaces":null,"tabs":[],"panes":[]}}}`, false},
35
+ } {
36
+ t.Run(tc.response, func(t *testing.T) {
37
+ installSnapshotLauncher(t, "printf '%s' '"+tc.response+"'\n")
38
+ snapshot, err := New(Options{}).Snapshot(context.Background())
39
+ if tc.valid {
40
+ if err != nil || len(snapshot.Workspaces) != 0 || len(snapshot.Tabs) != 0 || len(snapshot.Panes) != 0 {
41
+ t.Fatalf("empty runtime = %+v, %v", snapshot, err)
42
+ }
43
+ } else if err == nil {
44
+ t.Fatal("accepted malformed snapshot")
45
+ }
46
+ })
47
+ }
48
+ }
49
+
50
+ func TestSnapshotPassesSelectorsWithExactlyOneRead(t *testing.T) {
51
+ log := filepath.Join(t.TempDir(), "calls")
52
+ t.Setenv("SNAPSHOT_CALL_LOG", log)
53
+ t.Setenv("HERDR_SESSION", "ambient-session")
54
+ t.Setenv("HERDR_SOCKET_PATH", "/ambient/socket")
55
+ installSnapshotLauncher(t, `
56
+ [ "$*" = 'api snapshot' ] || exit 2
57
+ [ "$HERDR_SESSION" = 'configured-session' ] || exit 3
58
+ [ "$HERDR_SOCKET_PATH" = '/configured/socket' ] || exit 4
59
+ printf '%s\n' "$*" >> "$SNAPSHOT_CALL_LOG"
60
+ printf '%s' '{"result":{"snapshot":{"workspaces":[],"tabs":[],"panes":[]}}}'
61
+ `)
62
+ _, err := New(Options{Session: "configured-session", SocketPath: "/configured/socket"}).Snapshot(context.Background())
63
+ if err != nil {
64
+ t.Fatal(err)
65
+ }
66
+ calls, err := os.ReadFile(log)
67
+ if err != nil || string(calls) != "api snapshot\n" {
68
+ t.Fatalf("snapshot calls = %q, %v", calls, err)
69
+ }
70
+ }
71
+
72
+ func TestSnapshotReportsMissingExecutableAndSocketFailure(t *testing.T) {
73
+ t.Run("executable", func(t *testing.T) {
74
+ t.Setenv("PATH", t.TempDir())
75
+ _, err := New(Options{}).Snapshot(context.Background())
76
+ if err == nil || !strings.Contains(err.Error(), "missing executable") || !strings.Contains(err.Error(), "herdr") {
77
+ t.Fatalf("missing executable = %v", err)
78
+ }
79
+ })
80
+ t.Run("socket", func(t *testing.T) {
81
+ installSnapshotLauncher(t, "printf '%s' '{\"error\":{\"code\":\"connection_refused\",\"message\":\"socket unavailable\"}}' >&2\nexit 1\n")
82
+ _, err := New(Options{SocketPath: "/configured/socket"}).Snapshot(context.Background())
83
+ if err == nil || !strings.Contains(err.Error(), "connection_refused") || !strings.Contains(err.Error(), "api snapshot") {
84
+ t.Fatalf("socket failure = %v", err)
85
+ }
86
+ })
87
+ }
88
+
89
+ func TestSnapshotCancellationBoundsInheritedOutputPipes(t *testing.T) {
90
+ installSnapshotLauncher(t, "/bin/sleep 2 &\nwait\n")
91
+ ctx, cancel := context.WithTimeout(context.Background(), 25*time.Millisecond)
92
+ defer cancel()
93
+ start := time.Now()
94
+ _, err := New(Options{}).Snapshot(ctx)
95
+ if !errors.Is(err, context.DeadlineExceeded) || !strings.Contains(err.Error(), "timeout") {
96
+ t.Fatalf("timeout = %v", err)
97
+ }
98
+ if elapsed := time.Since(start); elapsed > time.Second {
99
+ t.Fatalf("inherited pipes defeated deadline: %v", elapsed)
100
+ }
101
+ }
@@ -0,0 +1,58 @@
1
+ package herdr
2
+
3
+ import (
4
+ "context"
5
+ "errors"
6
+ "testing"
7
+
8
+ "github.com/rajpopat27/relay-flow/internal/runner"
9
+ "github.com/rajpopat27/relay-flow/internal/runner/herdr/herdrcli"
10
+ )
11
+
12
+ type startupClient struct {
13
+ *fakeClient
14
+ snapshotCalls int
15
+ listingCalls int
16
+ }
17
+
18
+ func (c *startupClient) Snapshot(ctx context.Context) (herdrcli.Snapshot, error) {
19
+ c.snapshotCalls++
20
+ if ctx.Err() != nil {
21
+ return herdrcli.Snapshot{}, ctx.Err()
22
+ }
23
+ return c.snapshot, c.snapshotErr
24
+ }
25
+
26
+ func (c *startupClient) WorktreeList(context.Context, string) (herdrcli.WorktreeListing, error) {
27
+ c.listingCalls++
28
+ return herdrcli.WorktreeListing{}, errors.New("startup must not discover per-workspace repositories")
29
+ }
30
+
31
+ func TestStartupProbeUsesOnlyOneSnapshot(t *testing.T) {
32
+ for _, snapshot := range []herdrcli.Snapshot{{}, {
33
+ Workspaces: []herdrcli.Workspace{{ID: "w1", Label: "app"}},
34
+ Panes: []herdrcli.Pane{{ID: "p1", WorkspaceID: "w1", CWD: "/work/app"}},
35
+ }} {
36
+ c := &startupClient{fakeClient: &fakeClient{snapshot: snapshot}}
37
+ if err := runner.ProbeStartup(context.Background(), newAdapter(c)); err != nil {
38
+ t.Fatal(err)
39
+ }
40
+ if c.snapshotCalls != 1 || c.listingCalls != 0 || len(c.workspaceCreateCalls) != 0 || len(c.createCalls) != 0 || len(c.runCalls) != 0 || len(c.closedPanes) != 0 || len(c.closedWorkspaces) != 0 {
41
+ t.Fatalf("unexpected startup calls: %+v", c)
42
+ }
43
+ }
44
+ }
45
+
46
+ func TestStartupProbePropagatesSnapshotFailureAndCancellation(t *testing.T) {
47
+ failure := errors.New("herdr socket unavailable")
48
+ c := &startupClient{fakeClient: &fakeClient{snapshotErr: failure}}
49
+ a := newAdapter(c)
50
+ if err := runner.ProbeStartup(context.Background(), a); !errors.Is(err, failure) || c.snapshotCalls != 1 {
51
+ t.Fatalf("failure = %v, calls=%d", err, c.snapshotCalls)
52
+ }
53
+ ctx, cancel := context.WithCancel(context.Background())
54
+ cancel()
55
+ if err := runner.ProbeStartup(ctx, a); !errors.Is(err, context.Canceled) || c.snapshotCalls != 2 {
56
+ t.Fatalf("cancellation = %v, calls=%d", err, c.snapshotCalls)
57
+ }
58
+ }
@@ -51,6 +51,15 @@ func New(cli orcacli.Client, raw config.RawValues) (runner.Runner, error) {
51
51
  return &adapter{cli: cli, cfg: cfg}, nil
52
52
  }
53
53
 
54
+ var _ runner.StartupProber = (*adapter)(nil)
55
+
56
+ // ProbeStartup checks the Orca runtime without validating any repository or
57
+ // creating worktrees/terminals. An empty repository list is healthy.
58
+ func (a *adapter) ProbeStartup(ctx context.Context) error {
59
+ _, err := a.cli.ListRepos(ctx)
60
+ return err
61
+ }
62
+
54
63
  // --- Repos ---
55
64
 
56
65
  // DiscoverRepos returns Orca-registered repos as registration candidates.
@@ -7,9 +7,13 @@ import (
7
7
  "encoding/json"
8
8
  "errors"
9
9
  "fmt"
10
+ "os"
10
11
  "os/exec"
12
+ "path/filepath"
11
13
  "regexp"
14
+ "runtime"
12
15
  "strings"
16
+ "time"
13
17
  )
14
18
 
15
19
  var ErrTerminalUnavailable = errors.New("terminal unavailable")
@@ -54,8 +58,12 @@ type Client interface {
54
58
  CloseTerminal(ctx context.Context, handle string) error
55
59
  }
56
60
 
57
- // CLI is the production Client backed by the orca binary.
58
- type CLI struct{}
61
+ // CLI retains the selected launcher and its resolved path for all operations.
62
+ // Construction is local; runtime connectivity is checked by ListRepos.
63
+ type CLI struct {
64
+ executable string
65
+ lookupErr error
66
+ }
59
67
 
60
68
  // RepoRegistrar is the optional repository-provisioning seam used by the
61
69
  // runner adapter. Keeping it separate from Client lets existing read-only
@@ -64,34 +72,63 @@ type RepoRegistrar interface {
64
72
  AddRepo(ctx context.Context, path string) error
65
73
  }
66
74
 
67
- func New() *CLI { return &CLI{} }
75
+ func New() *CLI {
76
+ command := selectCommand(runtime.GOOS, os.Getenv("ORCA_CLI_COMMAND"), os.Getenv("ORCA_DEV_REPO_ROOT"))
77
+ path, err := exec.LookPath(command)
78
+ if err == nil {
79
+ path, err = filepath.Abs(path)
80
+ }
81
+ if err != nil {
82
+ path = command
83
+ }
84
+ return &CLI{executable: path, lookupErr: err}
85
+ }
86
+
87
+ // Orca's explicit session selector takes precedence over the dev launcher.
88
+ // Linux defaults to orca-ide, never the unrelated GNOME screen reader or a
89
+ // legacy alias/shim. Select once; there is no executable fallback.
90
+ func selectCommand(goos, command, devRoot string) string {
91
+ if command != "" {
92
+ return command
93
+ }
94
+ if devRoot != "" {
95
+ return "orca-dev"
96
+ }
97
+ if goos == "linux" {
98
+ return "orca-ide"
99
+ }
100
+ return "orca"
101
+ }
68
102
 
69
103
  // AddRepo registers an existing local repository with Orca. Orca derives its
70
104
  // display name from the path; relay-flow keeps the user-facing registration
71
105
  // name separately.
72
- func (CLI) AddRepo(ctx context.Context, path string) error {
73
- return run(ctx, "repo", "add", "--path", path, "--json")
106
+ func (c CLI) AddRepo(ctx context.Context, path string) error {
107
+ return c.run(ctx, "repo", "add", "--path", path, "--json")
74
108
  }
75
109
 
76
- func (CLI) ListRepos(ctx context.Context) ([]Repo, error) {
110
+ func (c CLI) ListRepos(ctx context.Context) ([]Repo, error) {
77
111
  var res struct {
78
- Result struct {
112
+ Result *struct {
79
113
  Repos []Repo `json:"repos"`
80
114
  } `json:"result"`
81
115
  }
82
- if err := runJSON(ctx, &res, "repo", "list", "--json"); err != nil {
116
+ if err := c.runJSON(ctx, &res, "repo", "list", "--json"); err != nil {
83
117
  return nil, fmt.Errorf("orca repo list: %w", err)
84
118
  }
119
+ if res.Result == nil || res.Result.Repos == nil {
120
+ return nil, fmt.Errorf("orca repo list (%s): malformed response: missing result.repos array", c.executable)
121
+ }
85
122
  return res.Result.Repos, nil
86
123
  }
87
124
 
88
- func (CLI) ListWorktrees(ctx context.Context) ([]Worktree, error) {
125
+ func (c CLI) ListWorktrees(ctx context.Context) ([]Worktree, error) {
89
126
  var res struct {
90
127
  Result struct {
91
128
  Worktrees []Worktree `json:"worktrees"`
92
129
  } `json:"result"`
93
130
  }
94
- if err := runJSON(ctx, &res, "worktree", "list", "--json"); err != nil {
131
+ if err := c.runJSON(ctx, &res, "worktree", "list", "--json"); err != nil {
95
132
  return nil, fmt.Errorf("orca worktree list: %w", err)
96
133
  }
97
134
  return res.Result.Worktrees, nil
@@ -100,13 +137,13 @@ func (CLI) ListWorktrees(ctx context.Context) ([]Worktree, error) {
100
137
  // CreateWorktree always sets --parent-worktree and --base-branch explicitly
101
138
  // (never Orca's inferred defaults) so every ticket worktree has a
102
139
  // deliberate, known ancestry.
103
- func (CLI) CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error {
104
- return run(ctx, "worktree", "create", "--name", ticketKey, "--repo", "id:"+repoID,
140
+ func (c CLI) CreateWorktree(ctx context.Context, ticketKey, repoID, parentWorktreeID, baseBranch string) error {
141
+ return c.run(ctx, "worktree", "create", "--name", ticketKey, "--repo", "id:"+repoID,
105
142
  "--parent-worktree", "worktree:"+parentWorktreeID, "--base-branch", baseBranch, "--json")
106
143
  }
107
144
 
108
- func (CLI) SetWorktreeStatus(ctx context.Context, worktreeID, status string) error {
109
- return run(ctx, "worktree", "set", "--worktree", "id:"+worktreeID, "--workspace-status", status, "--json")
145
+ func (c CLI) SetWorktreeStatus(ctx context.Context, worktreeID, status string) error {
146
+ return c.run(ctx, "worktree", "set", "--worktree", "id:"+worktreeID, "--workspace-status", status, "--json")
110
147
  }
111
148
 
112
149
  // FindExistingBranch returns the first local or remote-tracking branch whose
@@ -127,11 +164,11 @@ func FindExistingBranch(repoPath, ticketKey string) (string, bool, error) {
127
164
  return "", false, nil
128
165
  }
129
166
 
130
- func (CLI) DeleteWorktree(ctx context.Context, worktreeID string) error {
131
- return run(ctx, "worktree", "rm", "--worktree", "id:"+worktreeID, "--json")
167
+ func (c CLI) DeleteWorktree(ctx context.Context, worktreeID string) error {
168
+ return c.run(ctx, "worktree", "rm", "--worktree", "id:"+worktreeID, "--json")
132
169
  }
133
170
 
134
- func (CLI) ShowTerminal(ctx context.Context, handle string) (Terminal, error) {
171
+ func (c CLI) ShowTerminal(ctx context.Context, handle string) (Terminal, error) {
135
172
  var res struct {
136
173
  Result struct {
137
174
  Terminal struct {
@@ -142,7 +179,7 @@ func (CLI) ShowTerminal(ctx context.Context, handle string) (Terminal, error) {
142
179
  } `json:"terminal"`
143
180
  } `json:"result"`
144
181
  }
145
- if err := runJSON(ctx, &res, "terminal", "show", "--terminal", handle, "--json"); err != nil {
182
+ if err := c.runJSON(ctx, &res, "terminal", "show", "--terminal", handle, "--json"); err != nil {
146
183
  if strings.Contains(err.Error(), "terminal_handle_stale") {
147
184
  return Terminal{}, ErrTerminalUnavailable
148
185
  }
@@ -152,14 +189,14 @@ func (CLI) ShowTerminal(ctx context.Context, handle string) (Terminal, error) {
152
189
  return Terminal{Handle: t.Handle, Title: t.Title, Connected: t.Connected && t.Writable}, nil
153
190
  }
154
191
 
155
- func (CLI) SendTerminal(ctx context.Context, handle, text string) error {
156
- return run(ctx, "terminal", "send", "--terminal", handle, "--text", text, "--enter", "--json")
192
+ func (c CLI) SendTerminal(ctx context.Context, handle, text string) error {
193
+ return c.run(ctx, "terminal", "send", "--terminal", handle, "--text", text, "--enter", "--json")
157
194
  }
158
195
 
159
196
  // ListTerminals returns tabs (with their persistent tab-level title) for a
160
197
  // worktree, e.g. "name:PAY-101". --include-visual-layouts is mandatory:
161
198
  // orca omits visualLayouts from JSON without it.
162
- func (CLI) ListTerminals(ctx context.Context, worktree string) ([]Terminal, error) {
199
+ func (c CLI) ListTerminals(ctx context.Context, worktree string) ([]Terminal, error) {
163
200
  var res struct {
164
201
  Result struct {
165
202
  VisualLayouts []struct {
@@ -175,7 +212,7 @@ func (CLI) ListTerminals(ctx context.Context, worktree string) ([]Terminal, erro
175
212
  } `json:"visualLayouts"`
176
213
  } `json:"result"`
177
214
  }
178
- if err := runJSON(ctx, &res, "terminal", "list", "--worktree", worktree, "--include-visual-layouts", "--json"); err != nil {
215
+ if err := c.runJSON(ctx, &res, "terminal", "list", "--worktree", worktree, "--include-visual-layouts", "--json"); err != nil {
179
216
  return nil, fmt.Errorf("orca terminal list: %w", err)
180
217
  }
181
218
  var terms []Terminal
@@ -189,7 +226,7 @@ func (CLI) ListTerminals(ctx context.Context, worktree string) ([]Terminal, erro
189
226
 
190
227
  // CreateTerminal launches the given shell command in a fresh terminal on
191
228
  // the ticket's worktree.
192
- func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string) (string, error) {
229
+ func (c CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string) (string, error) {
193
230
  var res struct {
194
231
  Result struct {
195
232
  Terminal struct {
@@ -197,7 +234,7 @@ func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string)
197
234
  } `json:"terminal"`
198
235
  } `json:"result"`
199
236
  }
200
- if err := runJSON(ctx, &res, "terminal", "create",
237
+ if err := c.runJSON(ctx, &res, "terminal", "create",
201
238
  "--worktree", "name:"+ticketKey,
202
239
  "--title", title,
203
240
  "--command", command,
@@ -207,8 +244,8 @@ func (CLI) CreateTerminal(ctx context.Context, ticketKey, title, command string)
207
244
  return res.Result.Terminal.Handle, nil
208
245
  }
209
246
 
210
- func (CLI) CloseTerminal(ctx context.Context, handle string) error {
211
- if err := run(ctx, "terminal", "close", "--terminal", handle, "--json"); err != nil {
247
+ func (c CLI) CloseTerminal(ctx context.Context, handle string) error {
248
+ if err := c.run(ctx, "terminal", "close", "--terminal", handle, "--json"); err != nil {
212
249
  if strings.Contains(err.Error(), "terminal_handle_stale") {
213
250
  return ErrTerminalUnavailable
214
251
  }
@@ -217,24 +254,52 @@ func (CLI) CloseTerminal(ctx context.Context, handle string) error {
217
254
  return nil
218
255
  }
219
256
 
220
- func run(ctx context.Context, args ...string) error {
221
- cmd := exec.CommandContext(ctx, "orca", args...)
257
+ func (c CLI) run(ctx context.Context, args ...string) error {
258
+ if c.lookupErr != nil {
259
+ return c.commandError(ctx, args, nil, c.lookupErr)
260
+ }
261
+ cmd := exec.CommandContext(ctx, c.executable, args...)
262
+ cmd.WaitDelay = 100 * time.Millisecond
222
263
  out, err := cmd.CombinedOutput()
223
264
  if err != nil {
224
- return fmt.Errorf("orca %v: %w: %s", args, err, strings.TrimSpace(string(out)))
265
+ return c.commandError(ctx, args, out, err)
225
266
  }
226
267
  return nil
227
268
  }
228
269
 
229
- func runJSON(ctx context.Context, dest any, args ...string) error {
230
- cmd := exec.CommandContext(ctx, "orca", args...)
270
+ func (c CLI) runJSON(ctx context.Context, dest any, args ...string) error {
271
+ if c.lookupErr != nil {
272
+ return c.commandError(ctx, args, nil, c.lookupErr)
273
+ }
274
+ cmd := exec.CommandContext(ctx, c.executable, args...)
275
+ cmd.WaitDelay = 100 * time.Millisecond
231
276
  out, err := cmd.Output()
232
277
  if err != nil {
233
- // orca prints its JSON error envelope to stdout even on failure.
234
- return fmt.Errorf("orca %v: %w: %s", args, err, strings.TrimSpace(string(out)))
278
+ // Orca prints JSON errors to stdout; launcher failures may use stderr.
279
+ var exitErr *exec.ExitError
280
+ if errors.As(err, &exitErr) {
281
+ out = append(out, exitErr.Stderr...)
282
+ }
283
+ return c.commandError(ctx, args, out, err)
235
284
  }
236
285
  if err := json.Unmarshal(out, dest); err != nil {
237
- return fmt.Errorf("orca %v: parse json: %w", args, err)
286
+ return fmt.Errorf("orca %s (%s): malformed JSON response: %w", strings.Join(args[:2], " "), c.executable, err)
238
287
  }
239
288
  return nil
240
289
  }
290
+
291
+ // Report safe operation identity, not argv containing commands or prompts.
292
+ func (c CLI) commandError(ctx context.Context, args []string, out []byte, err error) error {
293
+ operation := strings.Join(args[:2], " ")
294
+ if ctx.Err() != nil {
295
+ return fmt.Errorf("orca %s (%s): timeout/cancellation: %w", operation, c.executable, ctx.Err())
296
+ }
297
+ if c.lookupErr != nil && (errors.Is(err, exec.ErrNotFound) || errors.Is(err, os.ErrNotExist)) {
298
+ return fmt.Errorf("orca %s: missing executable %q: %w", operation, c.executable, err)
299
+ }
300
+ var exitErr *exec.ExitError
301
+ if !errors.As(err, &exitErr) {
302
+ return fmt.Errorf("orca %s (%s): launch failure: %w", operation, c.executable, err)
303
+ }
304
+ return fmt.Errorf("orca %s (%s): runtime failure: %w: %s", operation, c.executable, err, strings.TrimSpace(string(out)))
305
+ }
@@ -6,6 +6,7 @@ import (
6
6
  "os"
7
7
  "os/exec"
8
8
  "path/filepath"
9
+ "runtime"
9
10
  "strings"
10
11
  "testing"
11
12
  )
@@ -161,7 +162,9 @@ func runGit(t *testing.T, repo string, args ...string) {
161
162
  func installStrictFakeOrca(t *testing.T) string {
162
163
  t.Helper()
163
164
  binDir := t.TempDir()
164
- fake := filepath.Join(binDir, "orca")
165
+ t.Setenv("ORCA_CLI_COMMAND", "")
166
+ t.Setenv("ORCA_DEV_REPO_ROOT", "")
167
+ fake := filepath.Join(binDir, selectCommand(runtime.GOOS, "", ""))
165
168
  script, err := os.ReadFile(filepath.Join("testdata", "strict-orca.sh"))
166
169
  if err != nil {
167
170
  t.Fatal(err)