relay-flow 0.3.12-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.
- package/README.md +39 -5
- package/cmd/relay-flow/commands_test.go +48 -0
- package/cmd/relay-flow/main.go +17 -8
- package/cmd/relay-flow/repo_registration.go +68 -0
- package/cmd/relay-flow/repo_selection_keyboard_test.go +178 -0
- package/cmd/relay-flow/scenario_test.go +25 -0
- package/cmd/relay-flow/serve.go +33 -2
- package/cmd/relay-flow/startup_alternate_plugins_test.go +138 -0
- package/cmd/relay-flow/startup_errors.go +85 -0
- package/cmd/relay-flow/startup_errors_test.go +88 -0
- package/cmd/relay-flow/startup_failure_categories_test.go +115 -0
- package/cmd/relay-flow/startup_ordering_test.go +75 -0
- package/cmd/relay-flow/startup_prerequisites_test.go +324 -0
- package/cmd/relay-flow/startup_readiness_test.go +116 -0
- package/cmd/relay-flow/startup_submission_test.go +123 -0
- package/internal/execution/goworkflows/engine_test.go +1 -1
- package/internal/execution/temporal/operations_test.go +1 -1
- package/internal/harness/opencode/opencode.go +14 -0
- package/internal/harness/opencode/opencode_test.go +1 -1
- package/internal/harness/opencode/repo_setup.go +1 -1
- package/internal/harness/pi/pi.go +14 -0
- package/internal/harness/startup.go +25 -0
- package/internal/harness/startup_test.go +85 -0
- package/internal/runner/herdr/herdr.go +9 -0
- package/internal/runner/herdr/herdrcli/exec.go +14 -0
- package/internal/runner/herdr/herdrcli/operations.go +8 -2
- package/internal/runner/herdr/herdrcli/response.go +6 -0
- package/internal/runner/herdr/herdrcli/startup_test.go +101 -0
- package/internal/runner/herdr/startup_test.go +58 -0
- package/internal/runner/orca/orca.go +9 -0
- package/internal/runner/orca/orcacli/orcacli.go +99 -34
- package/internal/runner/orca/orcacli/orcacli_test.go +4 -1
- package/internal/runner/orca/orcacli/startup_test.go +158 -0
- package/internal/runner/orca/startup_test.go +57 -0
- package/internal/runner/startup.go +27 -0
- package/internal/runner/startup_test.go +59 -0
- package/internal/task/beads/bdcli/bdcli.go +12 -0
- package/internal/task/beads/bdcli/startup.go +20 -0
- package/internal/task/beads/bdcli/startup_test.go +27 -0
- package/internal/task/beads/beads.go +1 -0
- package/internal/task/beads/startup.go +75 -0
- package/internal/task/beads/startup_test.go +155 -0
- package/internal/task/factory.go +6 -2
- package/internal/task/jira/jira.go +4 -3
- package/internal/task/jira/rest/client.go +40 -6
- package/internal/task/jira/rest/startup_redirect_test.go +23 -0
- package/internal/task/jira/rest/startup_test.go +133 -0
- package/internal/task/jira/startup.go +22 -0
- package/internal/task/jira/startup_test.go +78 -0
- package/internal/task/startup.go +25 -0
- package/internal/task/startup_test.go +51 -0
- package/internal/workflow/report.go +4 -4
- package/internal/workflow/report_test.go +4 -4
- 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
|
|
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
|
|
58
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
//
|
|
234
|
-
|
|
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 %
|
|
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
|
-
|
|
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)
|